diff --git a/eslint.config.js b/eslint.config.js index f179b098..e7f3e73b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,23 @@ export default [ }, }, rules: { + // Guardrail: never pull `wallet` out of @meshsdk/react 2.0's useWallet(). + // That object is a low-level CIP-30 wallet whose signData(address, payload) + // / signTx(tx, partialSign) signatures differ from the @meshsdk/core 1.9 + // IWallet the app is built on — a wrong-order call compiles but signs the + // wrong bytes (caused VESPR CIP-30 InternalError -2 and ballot witness + // divergence). Use useMeshWallet()/useActiveWallet() for any wallet ops; + // useWallet() is fine for connection state only (name/connected/connect/ + // disconnect). + "no-restricted-syntax": [ + "error", + { + selector: + "VariableDeclarator[init.callee.name='useWallet'] > ObjectPattern > Property[key.name='wallet']", + message: + "Don't destructure `wallet` from @meshsdk/react useWallet() — its signData/signTx args differ from core 1.9 and silently sign wrong bytes. Use useMeshWallet()/useActiveWallet() instead.", + }, + ], "@typescript-eslint/array-type": "off", "@typescript-eslint/consistent-type-definitions": "off", "@typescript-eslint/consistent-type-imports": [ diff --git a/src/__tests__/addressCompatibility.test.ts b/src/__tests__/addressCompatibility.test.ts new file mode 100644 index 00000000..60b1ee09 --- /dev/null +++ b/src/__tests__/addressCompatibility.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from '@jest/globals'; +import { normalizeAddressToBech32 } from '../utils/addressCompatibility'; + +describe('normalizeAddressToBech32', () => { + // 57-byte mainnet base address as returned hex-encoded by some CIP-30 + // wallets (mobile in-app browsers) from getChangeAddress/getUsedAddresses. + const mainnetBaseHex = + '01188691447471593ad888086cd3cffcb93833f38225ebd56bb1986476b59d6e7bd1e5ae3ae5ffe52dada5528d868ef67b738687543193df8d'; + const mainnetBaseBech32 = + 'addr1qyvgdy2yw3c4jwkc3qyxe570ljunsvlnsgj7h4ttkxvxga44n4h8h5094cawtll99kk6255ds680v7mns6r4gvvnm7xscrhvw9'; + + it('converts hex-encoded mainnet base address bytes to bech32', () => { + expect(normalizeAddressToBech32(mainnetBaseHex)).toBe(mainnetBaseBech32); + }); + + it('converts hex-encoded testnet base address bytes to addr_test', () => { + const testnetHex = '00' + mainnetBaseHex.slice(2); + expect(normalizeAddressToBech32(testnetHex)).toMatch(/^addr_test1/); + }); + + it('converts hex-encoded reward address bytes to stake bech32', () => { + const rewardHex = 'e1ad675b9ef479ae3ae5ffe52dada5528d868ef67b738687543193df8d'; + expect(normalizeAddressToBech32(rewardHex)).toBe( + 'stake1uxkkwku773u6uwh9lljjmtd922xcdrhk0decdp65xxfalrgc9mvct', + ); + }); + + it('returns bech32 addresses unchanged', () => { + expect(normalizeAddressToBech32(mainnetBaseBech32)).toBe(mainnetBaseBech32); + const stake = 'stake1uxkkwku773u6uwh9lljjmtd922xcdrhk0decdp65xxfalrgc9mvct'; + expect(normalizeAddressToBech32(stake)).toBe(stake); + }); + + it('returns non-address input unchanged', () => { + expect(normalizeAddressToBech32('deadbeef')).toBe('deadbeef'); + expect(normalizeAddressToBech32('not-an-address')).toBe('not-an-address'); + expect(normalizeAddressToBech32('')).toBe(''); + }); +}); diff --git a/src/components/common/cardano-objects/connect-wallet.tsx b/src/components/common/cardano-objects/connect-wallet.tsx index d228fc7d..456d8752 100644 --- a/src/components/common/cardano-objects/connect-wallet.tsx +++ b/src/components/common/cardano-objects/connect-wallet.tsx @@ -16,14 +16,8 @@ import React from "react"; import useUser from "@/hooks/useUser"; import { useUserStore } from "@/lib/zustand/user"; import { getProvider } from "@/utils/get-provider"; -import { - Asset, - deserializeAddress, - pubKeyAddress, - scriptAddress, - serializeAddressObj, - serializeRewardAddress, -} from "@meshsdk/core"; +import { Asset } from "@meshsdk/core"; +import { normalizeAddressToBech32 } from "@/utils/addressCompatibility"; import useUTXOS from "@/hooks/useUTXOS"; import { api } from "@/utils/api"; import { useWalletContext, WalletState } from "@/hooks/useWalletContext"; @@ -405,23 +399,7 @@ function ConnectWalletContent({ } // Normalize possible hex-encoded CIP-30 address bytes to bech32 (addr/addr_test) - try { - if (!address.startsWith("addr1") && !address.startsWith("addr_test1")) { - const d = deserializeAddress(address); - const stakeCredential = d.stakeCredentialHash || d.stakeScriptCredentialHash || ""; - const rebuilt = - d.pubKeyHash - ? pubKeyAddress(d.pubKeyHash, stakeCredential, !!d.stakeScriptCredentialHash) - : d.scriptHash - ? scriptAddress(d.scriptHash, stakeCredential, !!d.stakeScriptCredentialHash) - : null; - if (rebuilt) { - address = serializeAddressObj(rebuilt, netId); - } - } - } catch { - // If normalization fails, keep original (better than dropping the address) - } + address = normalizeAddressToBech32(address); setUserAddress(address); @@ -430,24 +408,8 @@ function ConnectWalletContent({ let stakeAddress = stakeAddresses[0]; // Normalize possible hex-encoded reward address bytes to bech32 (stake/stake_test) - try { - if ( - stakeAddress && - !stakeAddress.startsWith("stake1") && - !stakeAddress.startsWith("stake_test1") - ) { - const d = deserializeAddress(stakeAddress); - const stakeHash = d.stakeCredentialHash || d.stakeScriptCredentialHash; - if (stakeHash) { - stakeAddress = serializeRewardAddress( - stakeHash, - !!d.stakeScriptCredentialHash, - netId, - ); - } - } - } catch { - // ignore + if (stakeAddress) { + stakeAddress = normalizeAddressToBech32(stakeAddress); } if (!stakeAddress || !address) { @@ -483,7 +445,7 @@ function ConnectWalletContent({ utxosInitializedRef.current = false; } })(); - }, [isUtxosEnabled, utxosWallet, isUserLoading, createUser, setUserAddress, netId]); + }, [isUtxosEnabled, utxosWallet, isUserLoading, createUser, setUserAddress]); // Handle UTXOS wallet assets and network useEffect(() => { diff --git a/src/components/common/modals/WalletAuthModal.tsx b/src/components/common/modals/WalletAuthModal.tsx index 0f035e32..c53e352d 100644 --- a/src/components/common/modals/WalletAuthModal.tsx +++ b/src/components/common/modals/WalletAuthModal.tsx @@ -1,11 +1,10 @@ import { useState, useEffect, useCallback } from "react"; -import { useWallet } from "@meshsdk/react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; import useUTXOS from "@/hooks/useUTXOS"; -import { useSiteStore } from "@/lib/zustand/site"; -import { deserializeAddress, pubKeyAddress, scriptAddress, serializeAddressObj } from "@meshsdk/core"; +import useMeshWallet from "@/hooks/useMeshWallet"; +import { normalizeAddressToBech32 } from "@/utils/addressCompatibility"; interface WalletAuthModalProps { address: string; // display label; actual signing address is derived from wallet.getUsedAddresses() @@ -16,9 +15,16 @@ interface WalletAuthModalProps { } export function WalletAuthModal({ address, open, onClose, onAuthorized, autoAuthorize = false }: WalletAuthModalProps) { - const { wallet, connected } = useWallet(); - const network = useSiteStore((state) => state.network); - const netId = (network === 1 ? 1 : 0) as 0 | 1; + // Use the Mesh 1.9 BrowserWallet (via useMeshWallet), NOT react-2.0's + // useWallet().wallet. The latter is a low-level CIP-30 wallet whose + // signData(address, payload) argument order is SWAPPED relative to 1.9's + // signData(payload, address). Calling it with our (nonce, address) order + // made wallets (e.g. VESPR) sign with the nonce as the address and throw + // CIP-30 InternalError (-2). The 1.9 wallet matches the (payload, address) + // order used everywhere else in the app, and the UTXOS MeshWallet below is + // also payload-first — so a single signData(nonce, address) call is correct + // for both. + const { wallet: meshWallet, connected } = useMeshWallet(); const { wallet: utxosWallet, isEnabled: isUtxosEnabled } = useUTXOS(); const { toast } = useToast(); const [submitting, setSubmitting] = useState(false); @@ -27,22 +33,7 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized, autoAuth const signingWallet = isUtxosEnabled && utxosWallet?.cardano ? utxosWallet.cardano - : (wallet && connected ? wallet : null); - - const normalizePaymentAddress = useCallback((maybeHexOrBech: string): string => { - if (maybeHexOrBech.startsWith("addr1") || maybeHexOrBech.startsWith("addr_test1")) { - return maybeHexOrBech; - } - const d = deserializeAddress(maybeHexOrBech); - const stakeCredential = d.stakeCredentialHash || d.stakeScriptCredentialHash || ""; - const rebuilt = - d.pubKeyHash - ? pubKeyAddress(d.pubKeyHash, stakeCredential, !!d.stakeScriptCredentialHash) - : d.scriptHash - ? scriptAddress(d.scriptHash, stakeCredential, !!d.stakeScriptCredentialHash) - : null; - return rebuilt ? serializeAddressObj(rebuilt, netId) : maybeHexOrBech; - }, [netId]); + : (meshWallet && connected ? meshWallet : null); const handleAuthorize = useCallback(async () => { if (!signingWallet) { @@ -95,7 +86,10 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized, autoAuth if (!signingAddress) { throw new Error("No addresses found for wallet"); } - signingAddress = normalizePaymentAddress(signingAddress); + signingAddress = normalizeAddressToBech32(signingAddress); + if (!signingAddress.startsWith("addr1") && !signingAddress.startsWith("addr_test1")) { + throw new Error("Could not read a valid payment address from this wallet."); + } // 1) Get nonce from existing endpoint const nonceRes = await fetch(`/api/v1/getNonce?address=${encodeURIComponent(signingAddress)}`); @@ -113,26 +107,31 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized, autoAuth let signed: { signature: string; key: string } | undefined; try { - // Mirror the working Swagger token flow: signData(nonce, address) + // Mesh 1.9 / UTXOS order is signData(payload, address). signed = (await (signingWallet as any).signData( nonce, signingAddress, )) as { signature: string; key: string }; } catch (error: any) { - if (error instanceof Error) { - const msg = error.message.toLowerCase(); - if ( - msg.includes("user") || - msg.includes("cancel") || - msg.includes("decline") || - msg.includes("reject") - ) { - throw new Error( - "Signing cancelled. Please try again and approve the signing request.", - ); - } - } - throw new Error("Failed to sign nonce. Please try again."); + const raw = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : (() => { + try { + return JSON.stringify(error); + } catch { + return String(error); + } + })(); + // Surface the wallet's real error verbatim — some (e.g. the UTXOS + // smart wallet) fail inside signData with a provider-specific + // message we otherwise lose. The previous cancel/reject heuristic + // false-matched non-cancellation errors that merely contained the + // word "user", hiding the true cause, so always show the raw text. + console.error("[WalletAuthModal] signData failed:", error); + throw new Error(`Failed to sign nonce: ${raw || "unknown wallet error"}`); } if (!signed?.signature || !signed?.key) { @@ -171,7 +170,7 @@ export function WalletAuthModal({ address, open, onClose, onAuthorized, autoAuth } finally { setSubmitting(false); } - }, [signingWallet, toast, onAuthorized, onClose, normalizePaymentAddress]); + }, [signingWallet, toast, onAuthorized, onClose]); // Auto-authorize when modal opens if autoAuthorize is true (only once) useEffect(() => { diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index e1b48501..83b650df 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -1,7 +1,7 @@ import React, { useEffect, Component, ReactNode, useMemo, useCallback, useState, useRef } from "react"; import { useRouter } from "next/router"; import Link from "next/link"; -import { useWallet, useAddress } from "@meshsdk/react"; +import { useAddress } from "@meshsdk/react"; import { publicRoutes } from "@/data/public-routes"; import { api } from "@/utils/api"; import useUser from "@/hooks/useUser"; @@ -99,7 +99,6 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - const { wallet } = useWallet(); // 1.9 IWallet bridge — used for getDRep(), which the react 2.0 wallet lacks. const { wallet: meshWallet } = useMeshWallet(); const { state: walletState, connectedWalletInstance } = useWalletContext(); @@ -124,9 +123,9 @@ export default function RootLayout({ const connected = String(walletState) === String(WalletState.CONNECTED); const anyWalletConnected = connected || isUtxosEnabled; // Use connectedWalletInstance if available, otherwise fall back to wallet - const activeWallet = connectedWalletInstance && Object.keys(connectedWalletInstance).length > 0 - ? connectedWalletInstance - : wallet; + const activeWallet = connectedWalletInstance && Object.keys(connectedWalletInstance).length > 0 + ? connectedWalletInstance + : meshWallet; // Global error handler for unhandled promise rejections useEffect(() => { @@ -289,8 +288,8 @@ export default function RootLayout({ } async function initializeWallet() { - if (!walletAddress) return; - + if (!walletAddress || !activeWallet) return; + try { // Get stake address const stakeAddresses = await activeWallet.getRewardAddresses(); diff --git a/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx b/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx index 0d8f1a4a..e83f43c8 100644 --- a/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx +++ b/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx @@ -8,6 +8,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { useToast } from "@/hooks/use-toast"; import { useRouter } from "next/router"; import { useUserStore } from "@/lib/zustand/user"; @@ -24,7 +25,10 @@ export default function UserDropDownWrapper({ mode, onAction }: UserDropDownWrapperProps) { - const { wallet, connected, disconnect } = useWallet(); + // useWallet only for connection state/control; wallet ops go through the + // Mesh 1.9 bridge. + const { connected, disconnect } = useWallet(); + const { wallet } = useMeshWallet(); const { wallet: utxosWallet, isEnabled: isUtxosEnabled, disable: disableUtxos } = useUTXOS(); const { toast } = useToast(); const router = useRouter(); diff --git a/src/components/common/overall-layout/user-drop-down.tsx b/src/components/common/overall-layout/user-drop-down.tsx index adf631b0..152f3608 100644 --- a/src/components/common/overall-layout/user-drop-down.tsx +++ b/src/components/common/overall-layout/user-drop-down.tsx @@ -9,13 +9,17 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { useToast } from "@/hooks/use-toast"; import { useRouter } from "next/router"; import { useUserStore } from "@/lib/zustand/user"; import { api } from "@/utils/api"; export default function UserDropDown() { - const { wallet, disconnect } = useWallet(); + // useWallet only for connection control (disconnect); wallet ops go + // through the Mesh 1.9 bridge. + const { disconnect } = useWallet(); + const { wallet } = useMeshWallet(); const { toast } = useToast(); const router = useRouter(); const setPastWallet = useUserStore((state) => state.setPastWallet); @@ -42,6 +46,7 @@ export default function UserDropDown() { async function unlinkDiscord(): Promise { try { + if (!wallet) return; const usedAddresses = await wallet.getUsedAddresses(); const address = usedAddresses[0]; unlinkDiscordMutation.mutate({ address: address ?? "" }); @@ -84,6 +89,7 @@ export default function UserDropDown() { { try { + if (!wallet) return; let userAddress: string | undefined; try { const usedAddresses = await wallet.getUsedAddresses(); diff --git a/src/components/pages/homepage/governance/drep/id/index.tsx b/src/components/pages/homepage/governance/drep/id/index.tsx index d2050b42..00e3d264 100644 --- a/src/components/pages/homepage/governance/drep/id/index.tsx +++ b/src/components/pages/homepage/governance/drep/id/index.tsx @@ -8,7 +8,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Loader } from "lucide-react"; import ActiveIndicator from "../activeIndicator"; import ScriptIndicator from "../scriptIndicator"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import RowLabelInfo from "@/components/common/row-label-info"; import { extractJsonLdValue } from "@/utils/jsonLdParser"; import { Button } from "@/components/ui/button"; @@ -17,7 +17,7 @@ import DelegateButton from "./delegateButton"; export default function DrepDetailPage() { const router = useRouter(); const { id } = router.query; - const { wallet, connected } = useWallet(); + const { wallet, connected } = useMeshWallet(); const [drepInfo, setDrepInfo] = useState(null); const [drepMetadata, setDrepMetadata] = useState(null); diff --git a/src/components/pages/homepage/governance/drep/index.tsx b/src/components/pages/homepage/governance/drep/index.tsx index 5c7360a8..d57d5ef6 100644 --- a/src/components/pages/homepage/governance/drep/index.tsx +++ b/src/components/pages/homepage/governance/drep/index.tsx @@ -4,7 +4,7 @@ import Pagination from "@/components/common/overall-layout/pagination"; import { getProvider } from "@/utils/get-provider"; import { BlockfrostDrepInfo, BlockfrostDrepMetadata } from "@/types/governance"; import Link from "next/link"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import DelegateButton from "./id/delegateButton"; import RowLabelInfo from "@/components/common/row-label-info"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -16,7 +16,7 @@ export default function DrepOverviewPage() { Array<{ details: BlockfrostDrepInfo; metadata: BlockfrostDrepMetadata | null }> >([]); const [loading, setLoading] = useState(true); - const { wallet, connected } = useWallet(); + const { wallet, connected } = useMeshWallet(); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(25); const [order, setOrder] = useState<"asc" | "desc">("asc"); diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/cbor-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/cbor-tab.tsx index 71e2b7ed..00264cd6 100644 --- a/src/components/pages/homepage/wallets/import-wallet-flow/source/cbor-tab.tsx +++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/cbor-tab.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { resolveNativeScriptHash } from "@meshsdk/core"; import { Button } from "@/components/ui/button"; @@ -33,7 +33,7 @@ interface Props { * don't accept anonymous garbage rows. */ export default function CborTab({ flow }: Props) { - const { wallet, connected } = useWallet(); + const { wallet, connected } = useMeshWallet(); const { toast } = useToast(); const [name, setName] = useState(""); diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx index ee5f73d1..52a162c1 100644 --- a/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx +++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; +import { normalizeAddressToBech32 } from "@/utils/addressCompatibility"; import type { ResolvedWalletPayload, @@ -37,7 +38,10 @@ type Mode = * and lets them pick before signing. */ export default function InstanceTab({ flow }: Props) { - const { wallet, connected } = useWallet(); + // Mesh 1.9 bridge — signData(payload, address). react-2.0's useWallet() + // wallet has the args swapped, which made VESPR sign the wrong bytes and + // throw CIP-30 InternalError (-2) during cross-instance import. + const { wallet, connected } = useMeshWallet(); const { toast } = useToast(); const [urlInput, setUrlInput] = useState(""); const [busy, setBusy] = useState(false); @@ -246,7 +250,11 @@ async function getStakeAddress(wallet: unknown): Promise { const rewardAddresses = await ( wallet as { getRewardAddresses: () => Promise } ).getRewardAddresses(); - return rewardAddresses[0] ?? null; + const raw = rewardAddresses[0]; + if (!raw) return null; + // Mobile in-app browsers return hex-encoded CBOR bytes here; Mesh's + // signData and the origin's bech32 signer list both need bech32. + return normalizeAddressToBech32(raw); } async function fetchNonce( @@ -255,7 +263,7 @@ async function fetchNonce( address: string, ): Promise { const url = `${origin}/api/v1/exportWallet/getNonce?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(address)}`; - const res = await fetch(url, { credentials: "omit" }); + const res = await fetchFromOrigin(url, { credentials: "omit" }); const body = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(body.error || `Origin returned ${res.status}`); @@ -275,7 +283,7 @@ async function fetchRedeem( key: string; }, ): Promise<{ payload: ResolvedWalletPayload; payloadHash: string }> { - const res = await fetch(`${origin}/api/v1/exportWallet/redeem`, { + const res = await fetchFromOrigin(`${origin}/api/v1/exportWallet/redeem`, { method: "POST", credentials: "omit", headers: { "Content-Type": "application/json" }, @@ -296,7 +304,7 @@ async function fetchList( address: string, ): Promise { const url = `${origin}/api/v1/exportWallet/listMine?address=${encodeURIComponent(address)}`; - const res = await fetch(url, { credentials: "omit" }); + const res = await fetchFromOrigin(url, { credentials: "omit" }); const body = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(body.error || `Origin returned ${res.status}`); @@ -304,6 +312,25 @@ async function fetchList( return Array.isArray(body.wallets) ? body.wallets : []; } +/** + * Cross-origin fetch failures surface as an opaque TypeError ("Load failed" + * on Safari, "Failed to fetch" on Chrome) whether the origin is down, runs + * an older version without the exportWallet API, or rejects CORS. Translate + * that into something the user can act on. + */ +async function fetchFromOrigin( + input: string, + init?: RequestInit, +): Promise { + try { + return await fetch(input, init); + } catch { + throw new Error( + "Couldn't reach the origin. It may be offline, blocking cross-origin requests, or running a version without export support.", + ); + } +} + function policyLabel(w: RemoteWalletSummary): string { if (w.type === "atLeast") { return `${w.numRequiredSigners ?? "?"} of ${w.numSigners} signers required`; diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/json-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/json-tab.tsx index 52833a3f..d839a017 100644 --- a/src/components/pages/homepage/wallets/import-wallet-flow/source/json-tab.tsx +++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/json-tab.tsx @@ -3,6 +3,7 @@ import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import type { @@ -21,22 +22,25 @@ interface Props { * wallet info page: an envelope with `payload` and `payloadHash`. We * verify the hash server-side too (during importWallet), but checking * client-side gives faster feedback if the file is corrupt. + * + * Accepts either a file or pasted JSON text — mobile in-app browsers + * often can't reach the downloaded file, but can paste from clipboard. */ export default function JsonTab({ flow }: Props) { const inputRef = useRef(null); const { toast } = useToast(); const [sourceInstance, setSourceInstance] = useState(""); + const [pastedJson, setPastedJson] = useState(""); - const handleFile = async (file: File) => { + const handleText = (text: string) => { try { - const text = await file.text(); const parsed = JSON.parse(text) as { payload?: ResolvedWalletPayload; payloadHash?: string; sourceInstance?: string; }; if (!parsed.payload || typeof parsed.payloadHash !== "string") { - throw new Error("File is missing payload or payloadHash"); + throw new Error("Backup is missing payload or payloadHash"); } if (parsed.payload.schemaVersion !== 1) { throw new Error("Unsupported schema version"); @@ -52,23 +56,35 @@ export default function JsonTab({ flow }: Props) { }); } catch (err) { const message = - err instanceof Error ? err.message : "Failed to read JSON file"; + err instanceof Error ? err.message : "Failed to read JSON backup"; toast({ - title: "Invalid backup file", + title: "Invalid backup", description: message, variant: "destructive", }); } }; + const handleFile = async (file: File) => { + try { + handleText(await file.text()); + } catch { + toast({ + title: "Invalid backup file", + description: "Could not read the selected file", + variant: "destructive", + }); + } + }; + return (

From a JSON backup

Drop in a file produced by the Download JSON backup{" "} - action on the wallet info page. We'll verify the payload hash - before creating the local record. + action on the wallet info page, or paste its contents below. + We'll verify the payload hash before creating the local record.

@@ -98,16 +114,42 @@ export default function JsonTab({ flow }: Props) { if (file) void handleFile(file); }} /> +
+ +
-
- +
+
+ or +
+
+ +
+ +