diff --git a/README.md b/README.md index efee7585..bf2eb9da 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ A comprehensive, enterprise-grade multi-signature wallet solution built on Carda - Secure multi-sig staking operations ### Collaboration -- Real-time Nostr-based chat - Discord integration for notifications - Signer verification via message signing - Automated transaction alerts @@ -188,11 +187,11 @@ graph TD ### Database Schema ```prisma model User { - id String @id @default(cuid()) - address String @unique - stakeAddress String @unique - nostrKey String @unique - discordId String @default("") + id String @id @default(cuid()) + address String @unique + stakeAddress String @unique + nostrKey String? @unique + discordId String @default("") } model Wallet { diff --git a/next.config.js b/next.config.js index fb9abebd..67efb015 100644 --- a/next.config.js +++ b/next.config.js @@ -55,13 +55,16 @@ const config = { layers: true, }; - // Optimize tree-shaking by ensuring proper module resolution + // Optimize tree-shaking by ensuring proper module resolution. + // Note: do NOT set `sideEffects: false` globally — it tells webpack that + // every file is side-effect-free, which silently strips CSS imports, + // polyfills, and other modules that exist purely for their side effects. + // Per-package sideEffects flags in package.json are the correct surface. config.optimization = { ...config.optimization, usedExports: true, - sideEffects: false, }; - + // Handle CommonJS modules that don't support named exports config.resolve = { ...config.resolve, @@ -75,6 +78,24 @@ const config = { // External packages for server components to avoid bundling issues serverExternalPackages: ["@fabianbormann/cardano-peer-connect"], + + // Basic security headers applied to all routes. + // NOTE: Content-Security-Policy and Strict-Transport-Security are intentionally + // omitted — CSP would break inline scripts/styles and HSTS locks browsers to + // HTTPS for max-age and should only be enabled after team review. + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + ], + }, + ]; + }, }; // Bundle analyzer - only enable when ANALYZE env var is set diff --git a/src/__tests__/pendingTransactions.test.ts b/src/__tests__/pendingTransactions.test.ts index bfa54ab8..bef748cc 100644 --- a/src/__tests__/pendingTransactions.test.ts +++ b/src/__tests__/pendingTransactions.test.ts @@ -4,47 +4,73 @@ import type { NextApiRequest, NextApiResponse } from 'next'; const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); -jest.mock( +jest.unstable_mockModule( '@/lib/cors', () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); +type JwtPayloadLike = { address: string; botId?: string; type?: string }; +const verifyJwtMock = jest.fn<(token: string | undefined) => JwtPayloadLike | null>(); +const isBotJwtMock = jest.fn<(payload: JwtPayloadLike) => boolean>( + (payload) => Boolean(payload && (payload as JwtPayloadLike).type === "bot"), +); -jest.mock( +jest.unstable_mockModule( '@/lib/verifyJwt', () => ({ __esModule: true, verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, + }), +); + +const applyRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(() => true); +const applyBotRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, botId: string, maxRequests?: number) => boolean +>(() => true); +const applyStrictRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(() => true); +const enforceBodySizeMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean +>(() => true); + +jest.unstable_mockModule( + '@/lib/security/requestGuards', + () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + applyStrictRateLimit: applyStrictRateLimitMock, + enforceBodySize: enforceBodySizeMock, + isBodyTooLarge: jest.fn(() => false), }), - { virtual: true }, ); const createCallerMock = jest.fn(); -jest.mock( +jest.unstable_mockModule( '@/server/api/root', () => ({ __esModule: true, createCaller: createCallerMock, }), - { virtual: true }, ); const dbMock = { __type: 'dbMock' }; -jest.mock( +jest.unstable_mockModule( '@/server/db', () => ({ __esModule: true, db: dbMock, }), - { virtual: true }, ); type ResponseMock = NextApiResponse & { statusCode?: number }; @@ -164,13 +190,16 @@ describe('pendingTransactions API route', () => { expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res); expect(corsMock).toHaveBeenCalledWith(req, res); expect(verifyJwtMock).toHaveBeenCalledWith(token); - expect(createCallerMock).toHaveBeenCalledWith({ - db: dbMock, - session: expect.objectContaining({ - user: { id: address }, - expires: expect.any(String), + expect(createCallerMock).toHaveBeenCalledWith( + expect.objectContaining({ + db: dbMock, + session: expect.objectContaining({ + user: { id: address }, + expires: expect.any(String), + }), + sessionAddress: address, }), - }); + ); expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); expect(transactionGetPendingTransactionsMock).toHaveBeenCalledWith({ walletId }); expect(res.status).toHaveBeenCalledWith(200); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 8c3f3826..d9260246 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,5 +1,5 @@ // Test setup file for Jest -import '@jest/globals'; +import { jest, beforeEach, afterEach } from '@jest/globals'; // Mock console methods to reduce noise in tests global.console = { @@ -14,3 +14,34 @@ global.console = { // Global test timeout jest.setTimeout(10000); + +// Determinism: freeze the wall clock (`Date.now` / `new Date()`) so tests are +// byte-identical across runs. Timer APIs (setTimeout/setInterval/etc) stay +// real — many tests in this suite hit tRPC's timing middleware and other +// real-async paths that hang under faked timers. Tests that specifically +// exercise timer behavior can opt in via `jest.useFakeTimers()` in `beforeAll`. +beforeEach(() => { + jest.useFakeTimers({ + now: new Date('2026-01-01T00:00:00Z'), + doNotFake: [ + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'queueMicrotask', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + 'hrtime', + 'performance', + ], + }); +}); + +afterEach(() => { + jest.useRealTimers(); +}); diff --git a/src/components/common/ImgDragAndDrop.tsx b/src/components/common/ImgDragAndDrop.tsx index d5cfdbd3..227a69e3 100644 --- a/src/components/common/ImgDragAndDrop.tsx +++ b/src/components/common/ImgDragAndDrop.tsx @@ -225,6 +225,7 @@ export default function ImgDragAndDrop({ onImageUpload, initialUrl }: ImgDragAnd state.setUserAssetMetadata, ); const { user, isLoading: isUserLoading } = useUser(); - const { generateNsec } = useNostrChat(); const userAddress = useUserStore((state) => state.userAddress); const setUserAddress = useUserStore((state) => state.setUserAddress); const { toast } = useToast(); @@ -472,12 +470,10 @@ function ConnectWalletContent({ // 4) Create or update user (same as normal wallet) if (!isUserLoading) { - const nostrKey = generateNsec(); createUser({ address, stakeAddress, drepKeyHash, - nostrKey: JSON.stringify(nostrKey), }); } @@ -487,7 +483,7 @@ function ConnectWalletContent({ utxosInitializedRef.current = false; } })(); - }, [isUtxosEnabled, utxosWallet, isUserLoading, createUser, generateNsec, setUserAddress, netId]); + }, [isUtxosEnabled, utxosWallet, isUserLoading, createUser, setUserAddress, netId]); // Handle UTXOS wallet assets and network useEffect(() => { diff --git a/src/components/common/cardano-objects/resolve-adahandle.tsx b/src/components/common/cardano-objects/resolve-adahandle.tsx index d1ba72d8..51ee040b 100644 --- a/src/components/common/cardano-objects/resolve-adahandle.tsx +++ b/src/components/common/cardano-objects/resolve-adahandle.tsx @@ -1,8 +1,14 @@ import { toast } from "@/hooks/use-toast"; import { getProvider } from "@/utils/get-provider"; -//AdaHandle look up provider only supports mainnnet -const provider = getProvider(1) +// AdaHandle lookup is mainnet-only. Lazy-init the provider so the module +// can be imported during SSR / page-data collection without requiring +// NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET to be defined at module-load time. +let cachedProvider: ReturnType | null = null; +const getMainnetProvider = () => { + if (!cachedProvider) cachedProvider = getProvider(1); + return cachedProvider; +}; export const resolveAdaHandle = async ( setAdaHandle: (value: string) => void, @@ -18,7 +24,7 @@ export const resolveAdaHandle = async ( return; } - const address = await provider.fetchHandleAddress(handleName); + const address = await getMainnetProvider().fetchHandleAddress(handleName); if (address) { const newAddresses = [...recipientAddresses]; diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index 78dc6e36..1e4b00ee 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -93,56 +93,6 @@ class WalletErrorBoundary extends Component< } } -// Component to track layout content changes -function LayoutContentTracker({ - children, - router, - pageIsPublic, - userAddress -}: { - children: ReactNode; - router: ReturnType; - pageIsPublic: boolean; - userAddress: string | undefined; -}) { - const prevPathRef = useRef(router.pathname); - const prevQueryRef = useRef(JSON.stringify(router.query)); - - useEffect(() => { - const handleRouteChangeStart = (url: string) => { - // Route change started - }; - - const handleRouteChangeComplete = (url: string) => { - prevPathRef.current = router.pathname; - prevQueryRef.current = JSON.stringify(router.query); - }; - - const handleRouteChangeError = (err: Error, url: string) => { - // Route change error - }; - - router.events.on('routeChangeStart', handleRouteChangeStart); - router.events.on('routeChangeComplete', handleRouteChangeComplete); - router.events.on('routeChangeError', handleRouteChangeError); - - return () => { - router.events.off('routeChangeStart', handleRouteChangeStart); - router.events.off('routeChangeComplete', handleRouteChangeComplete); - router.events.off('routeChangeError', handleRouteChangeError); - }; - }, [router]); - - useEffect(() => { - if (router.pathname !== prevPathRef.current || JSON.stringify(router.query) !== prevQueryRef.current) { - prevPathRef.current = router.pathname; - prevQueryRef.current = JSON.stringify(router.query); - } - }, [router.pathname, router.query]); - - return <>{children}; -} - export default function RootLayout({ children, }: { @@ -547,6 +497,13 @@ export default function RootLayout({ return (
+ {/* Skip link for keyboard users */} + + Skip to main content + {(shouldShowBackgroundLoading || showPostAuthLoading) && (
@@ -672,7 +629,11 @@ export default function RootLayout({ )} {/* Main content */} -
+
@@ -707,9 +668,7 @@ export default function RootLayout({
} > - - {pageIsPublic || userAddress ? children : } - + {pageIsPublic || userAddress ? children : }
diff --git a/src/components/multisig/proxy/ProxyControl.tsx b/src/components/multisig/proxy/ProxyControl.tsx index e575086f..220d831b 100644 --- a/src/components/multisig/proxy/ProxyControl.tsx +++ b/src/components/multisig/proxy/ProxyControl.tsx @@ -122,7 +122,6 @@ export default function ProxyControl() { // State management const [proxyContract, setProxyContract] = useState(null); const [isProxySetup, setIsProxySetup] = useState(false); - const [, setLocalLoading] = useState(false); const [tvlLoading, setTvlLoading] = useState(false); // Setup flow state @@ -149,10 +148,6 @@ export default function ProxyControl() { { address: "", unit: "lovelace", amount: "" } ]); - // UTxO selection state (UI only). We will still pass all UTxOs from provider to contract. - const [, setSelectedUtxos] = useState([]); - const [, setManualSelected] = useState(false); - // Helper to resolve inputs for multisig controlled txs const getMsInputs = useCallback(async (): Promise<{ utxos: UTxO[]; walletAddress: string }> => { if (!appWallet?.address) { @@ -264,8 +259,7 @@ export default function ProxyControl() { try { setSetupLoading(true); - setLocalLoading(true); - + // Reset setup data to prevent conflicts with previous attempts setSetupData({}); setSetupStep(0); @@ -301,7 +295,6 @@ export default function ProxyControl() { }); } finally { setSetupLoading(false); - setLocalLoading(false); } }, [proxyContract, isWalletReady, getMsInputs, newTransaction, toast]); @@ -318,7 +311,6 @@ export default function ProxyControl() { try { setSetupLoading(true); - setLocalLoading(true); // If msCbor is set, route through useTransaction hook to create a signable if (appWallet?.scriptCbor && setupData.txHex) { @@ -395,7 +387,6 @@ export default function ProxyControl() { }); } finally { setSetupLoading(false); - setLocalLoading(false); } }, [setupData, activeWallet, appWallet, createProxy, refetchProxies, getMsInputs, newTransaction, userAddress, toast]); @@ -600,7 +591,6 @@ export default function ProxyControl() { try { setSpendLoading(true); - setLocalLoading(true); // Get the selected proxy const proxy = proxies?.find((p: { id: string }) => p.id === selectedProxyId); @@ -672,7 +662,6 @@ export default function ProxyControl() { }); } finally { setSpendLoading(false); - setLocalLoading(false); } }, [proxyContract, isWalletReady, spendOutputs, selectedProxyId, proxies, network, activeWallet, handleProxySelection, getMsInputs, newTransaction, appWallet?.scriptCbor, toast]); @@ -840,9 +829,9 @@ export default function ProxyControl() { { - setSelectedUtxos(utxos); - setManualSelected(manual); + onSelectionChange={() => { + // Selection is for user visibility only; + // contract uses all UTxOs from the multisig wallet. }} /> diff --git a/src/components/multisig/proxy/ProxyControlExample.tsx b/src/components/multisig/proxy/ProxyControlExample.tsx deleted file mode 100644 index 7c110a04..00000000 --- a/src/components/multisig/proxy/ProxyControlExample.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Info } from "lucide-react"; -import ProxyControl from "./ProxyControl"; - -/** - * Example page demonstrating how to use the ProxyControl component - * - * This component shows how to integrate the ProxyControl into your application - * and provides context about what the proxy system does. - */ -export default function ProxyControlExample() { - return ( -
-
-

Proxy Control System

-

- Manage your Cardano proxy contract for automated and controlled transactions. -

-
- - - - - What is a Proxy Contract?
- A proxy contract allows you to create a controlled address that can be managed through auth tokens. - This enables automated transactions while maintaining security through your multisig wallet. - The proxy can hold assets and execute transactions when you have the required auth tokens. -
-
- - - - How it Works - - Understanding the proxy system workflow - - - -
-
-

1. Setup

-

- Initialize the proxy by minting 10 auth tokens. These tokens are sent to your multisig wallet. -

-
-
-

2. Control

-

- Use auth tokens to authorize spending from the proxy address. Each spend consumes one auth token. -

-
-
-

3. Automate

-

- The proxy can hold assets and execute transactions automatically when properly authorized. -

-
-
-
-
- - - - - - Integration Example - - How to use the ProxyControl component in your application - - - -
-

Basic Usage

-
-{`import ProxyControl from "@/components/multisig/proxy/ProxyControl";
-
-export default function MyPage() {
-  return (
-    
-

My Proxy Management

- -
- ); -}`} -
- -

Key Features

-
    -
  • Automatic wallet connection detection
  • -
  • Proxy setup with auth token minting
  • -
  • Real-time balance monitoring
  • -
  • Multi-output spending capabilities
  • -
  • Integration with multisig transaction system
  • -
  • Error handling and loading states
  • -
  • Responsive design for mobile and desktop
  • -
-
-
-
-
- ); -} - - - diff --git a/src/components/multisig/proxy/index.ts b/src/components/multisig/proxy/index.ts index e7c46daa..c328f688 100644 --- a/src/components/multisig/proxy/index.ts +++ b/src/components/multisig/proxy/index.ts @@ -1,3 +1,2 @@ export { default as ProxyControl } from "./ProxyControl"; -export { default as ProxyControlExample } from "./ProxyControlExample"; export { MeshProxyContract } from "./offchain"; \ No newline at end of file diff --git a/src/components/pages/homepage/index.tsx b/src/components/pages/homepage/index.tsx index da2fdd4e..3597dc4d 100644 --- a/src/components/pages/homepage/index.tsx +++ b/src/components/pages/homepage/index.tsx @@ -331,22 +331,6 @@ export function PageHomepage() { - {/* Chat and Collaborate */} - -
- Team chat -
-
diff --git a/src/components/pages/homepage/wallets/new-wallet-flow/create/ReviewSignersCard.tsx b/src/components/pages/homepage/wallets/new-wallet-flow/create/ReviewSignersCard.tsx index d72baba4..48e54669 100644 --- a/src/components/pages/homepage/wallets/new-wallet-flow/create/ReviewSignersCard.tsx +++ b/src/components/pages/homepage/wallets/new-wallet-flow/create/ReviewSignersCard.tsx @@ -52,10 +52,20 @@ interface SignerConfig { setSignerStakeKeys: React.Dispatch>; signersDRepKeys: string[]; setSignerDRepKeys: React.Dispatch>; + signerIds?: string[]; addSigner: () => void; removeSigner?: (index: number) => void; } +// Synthetic React-key helper. The on-chain address is unique per signer in the +// happy path, but during edits or paste flows it can briefly collide; using +// the address as a key triggers React reconciliation bugs (lost focus, +// duplicated DOM nodes). `signerIds` is kept index-aligned by the host hook +// (`useWalletFlowState` / `useMigrationWalletFlowState`); this fallback runs +// only when a caller hasn't wired it through yet. +const rowKey = (signerIds: string[] | undefined, index: number): string => + signerIds?.[index] ?? `signer-row-${index}`; + interface ReviewSignersCardProps { signerConfig: SignerConfig; currentUserAddress?: string; @@ -91,6 +101,7 @@ const ReviewSignersCard: React.FC = ({ setSignerStakeKeys, signersDRepKeys = [], setSignerDRepKeys, + signerIds, addSigner, removeSigner, } = signerConfig; @@ -269,7 +280,7 @@ const ReviewSignersCard: React.FC = ({ {signersAddresses.map((signer, index) => ( - + {/* Signer name */} @@ -384,7 +395,7 @@ const ReviewSignersCard: React.FC = ({
{signersAddresses.map((signer, index) => (
{/* Top row: Name and Actions */} diff --git a/src/components/pages/homepage/wallets/new-wallet-flow/create/index.tsx b/src/components/pages/homepage/wallets/new-wallet-flow/create/index.tsx index 68b0e44d..29a73531 100644 --- a/src/components/pages/homepage/wallets/new-wallet-flow/create/index.tsx +++ b/src/components/pages/homepage/wallets/new-wallet-flow/create/index.tsx @@ -37,6 +37,7 @@ export default function PageReviewWallet() { setSignerStakeKeys: walletFlow.setSignerStakeKeys, signersDRepKeys: walletFlow.signersDRepKeys, setSignerDRepKeys: walletFlow.setSignerDRepKeys, + signerIds: walletFlow.signerIds, addSigner: walletFlow.addSigner, removeSigner: walletFlow.removeSigner, }} diff --git a/src/components/pages/homepage/wallets/new-wallet-flow/shared/useWalletFlowState.tsx b/src/components/pages/homepage/wallets/new-wallet-flow/shared/useWalletFlowState.tsx index 3759303d..b52dc5f3 100644 --- a/src/components/pages/homepage/wallets/new-wallet-flow/shared/useWalletFlowState.tsx +++ b/src/components/pages/homepage/wallets/new-wallet-flow/shared/useWalletFlowState.tsx @@ -16,6 +16,7 @@ import { useUserStore } from "@/lib/zustand/user"; import { useSiteStore } from "@/lib/zustand/site"; import { useToast } from "@/hooks/use-toast"; import useUser from "@/hooks/useUser"; +import { makeSignerId } from "./signerRows"; export interface WalletFlowState { // Core wallet data @@ -35,9 +36,23 @@ export interface WalletFlowState { setSignerStakeKeys: React.Dispatch>; signersDRepKeys: string[]; setSignerDRepKeys: React.Dispatch>; - addSigner: () => void; + // Stable synthetic identifiers for each signer row, parallel to + // signersAddresses. Used as React `key` so duplicate / empty addresses + // do not collide and stomp form state on re-render. Never sent to the + // backend — purely local UI identity. + signerIds: string[]; + // Optional positional params let callers (e.g. ReviewSignersCard's + // "Add signer" dialog) push a fully-populated row through the hook so + // signerIds stays index-aligned with signersAddresses. Called with no + // args, it appends an empty row (legacy behavior). + addSigner: ( + address?: string, + stakeKey?: string, + drepKey?: string, + description?: string, + ) => void; removeSigner: (index: number) => void; - + // Signature rules numRequiredSigners: number; setNumRequiredSigners: React.Dispatch>; @@ -99,12 +114,14 @@ export interface WalletFlowState { handleSaveAdvanced: (newStakeKey: string, scriptType: "all" | "any" | "atLeast") => void; } + export function useWalletFlowState(): WalletFlowState { const router = useRouter(); const [signersAddresses, setSignerAddresses] = useState([]); const [signersDescriptions, setSignerDescriptions] = useState([]); const [signersStakeKeys, setSignerStakeKeys] = useState([]); const [signersDRepKeys, setSignerDRepKeys] = useState([]); + const [signerIds, setSignerIds] = useState([]); const [numRequiredSigners, setNumRequiredSigners] = useState(1); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -303,6 +320,7 @@ export function useWalletFlowState(): WalletFlowState { setSignerStakeKeys([stakeKey ? "" : user.stakeAddress]); // Import DRep key from user data setSignerDRepKeys([(user as any).drepKeyHash || ""]); + setSignerIds([makeSignerId()]); } }, [user, stakeKey, pathIsWalletInvite]); @@ -363,6 +381,20 @@ export function useWalletFlowState(): WalletFlowState { : []; setSignerStakeKeys(incomingStakeKeys); setSignerDRepKeys((walletInvite as any).signersDRepKeys ?? []); + // Keep synthetic ids stable across refetches: only mint new ids + // for rows that grow the array, trim if it shrinks. Re-minting on + // every refetch would unmount in-flight description inputs and + // drop focus / IME state. Mirrors handleSaveSigners' pattern. + setSignerIds((prev) => { + const incoming = walletInvite.signersAddresses ?? []; + if (prev.length === incoming.length) return prev; + if (prev.length < incoming.length) { + const next = prev.slice(); + while (next.length < incoming.length) next.push(makeSignerId()); + return next; + } + return prev.slice(0, incoming.length); + }); setNumRequiredSigners(walletInvite.numRequiredSigners!); setStakeKey((walletInvite as any).stakeCredentialHash ?? ""); setNativeScriptType((walletInvite as any).scriptType ?? "atLeast"); @@ -371,30 +403,58 @@ export function useWalletFlowState(): WalletFlowState { // Utility functions - function addSigner() { - setSignerAddresses([...signersAddresses, ""]); - setSignerDescriptions([...signersDescriptions, ""]); - // Always add empty stake key when external stake credential is set, otherwise add empty string - setSignerStakeKeys([...signersStakeKeys, stakeKey ? "" : ""]); - setSignerDRepKeys([...signersDRepKeys, ""]); + function addSigner( + address: string = "", + newStakeKey: string = "", + drepKey: string = "", + description: string = "", + ) { + // Functional updaters so two synchronous addSigner() calls in the + // same render append two rows. With closure-reading non-functional + // setters, both calls would read the same `signersAddresses` and + // collapse into a single appended row, leaving signerIds.length + // out of sync with the other arrays. + const stakeKeyForRow = stakeKey ? "" : newStakeKey; + setSignerAddresses((arr) => [...arr, address]); + setSignerDescriptions((arr) => [...arr, description]); + // Always blank the per-signer stake key when an external stake + // credential is set, regardless of what was passed in. + setSignerStakeKeys((arr) => [...arr, stakeKeyForRow]); + setSignerDRepKeys((arr) => [...arr, drepKey]); + setSignerIds((arr) => [...arr, makeSignerId()]); } function removeSigner(index: number) { - const updatedAddresses = [...signersAddresses]; - updatedAddresses.splice(index, 1); - setSignerAddresses(updatedAddresses); - - const updatedDescriptions = [...signersDescriptions]; - updatedDescriptions.splice(index, 1); - setSignerDescriptions(updatedDescriptions); - - const updatedStakeKeys = [...signersStakeKeys]; - updatedStakeKeys.splice(index, 1); - setSignerStakeKeys(updatedStakeKeys); - - const updatedDRepKeys = [...signersDRepKeys]; - updatedDRepKeys.splice(index, 1); - setSignerDRepKeys(updatedDRepKeys); + // Functional updaters mirror the Pass 3 fix in addSigner: two + // synchronous removeSigner() calls in the same render must each + // see the previous batch's update, otherwise the second call would + // splice from a stale closure-read array and the five parallel + // arrays could drift out of alignment. + setSignerAddresses((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerDescriptions((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerStakeKeys((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerDRepKeys((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerIds((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); } function createNativeScript() { @@ -666,9 +726,10 @@ export function useWalletFlowState(): WalletFlowState { setSignerStakeKeys, signersDRepKeys, setSignerDRepKeys, + signerIds, addSigner, removeSigner, - + // Signature rules numRequiredSigners, setNumRequiredSigners, diff --git a/src/components/pages/user/BotManagementCard.tsx b/src/components/pages/user/BotManagementCard.tsx index fd1eca27..d8db60c1 100644 --- a/src/components/pages/user/BotManagementCard.tsx +++ b/src/components/pages/user/BotManagementCard.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState } from "react"; import { Bot, Trash2, Loader2, Pencil, Link } from "lucide-react"; import CardUI from "@/components/ui/card-content"; diff --git a/src/components/pages/wallet/assets/wallet-assets.tsx b/src/components/pages/wallet/assets/wallet-assets.tsx index 8f10dfb6..a6a458d0 100644 --- a/src/components/pages/wallet/assets/wallet-assets.tsx +++ b/src/components/pages/wallet/assets/wallet-assets.tsx @@ -84,12 +84,10 @@ export default function WalletAssets({ appWallet }: { appWallet: Wallet }) { > {isImageIpfs ? ( ) : ( >; signersDRepKeys: string[]; setSignerDRepKeys: React.Dispatch>; - addSigner: () => void; + // Stable synthetic identifiers for each signer row, parallel to + // signersAddresses. Used as React `key` so duplicate / empty addresses + // do not collide and stomp form state on re-render. Never sent to the + // backend — purely local UI identity. + signerIds: string[]; + // Optional positional params let callers (e.g. ReviewSignersCard's + // "Add signer" dialog) push a fully-populated row through the hook so + // signerIds stays index-aligned with signersAddresses. Called with no + // args, it appends an empty row (legacy behavior). + addSigner: ( + address?: string, + stakeKey?: string, + drepKey?: string, + description?: string, + ) => void; removeSigner: (index: number) => void; - + // Signature rules numRequiredSigners: number; setNumRequiredSigners: React.Dispatch>; @@ -81,6 +96,7 @@ export function useMigrationWalletFlowState(appWallet: Wallet, migrationId?: str const [signersDescriptions, setSignerDescriptions] = useState([]); const [signersStakeKeys, setSignerStakeKeys] = useState([]); const [signersDRepKeys, setSignerDRepKeys] = useState([]); + const [signerIds, setSignerIds] = useState([]); const [numRequiredSigners, setNumRequiredSigners] = useState(1); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -147,6 +163,18 @@ export function useMigrationWalletFlowState(appWallet: Wallet, migrationId?: str setName(`${walletData.name} - Migration in Progress`); setDescription(walletData.description ?? ""); setSignerAddresses(walletData.signersAddresses ?? []); + // Stable across refetches — only grow / shrink, never re-mint, so + // an in-flight description input does not lose focus / IME state. + setSignerIds((prev) => { + const incoming = walletData.signersAddresses ?? []; + if (prev.length === incoming.length) return prev; + if (prev.length < incoming.length) { + const next = prev.slice(); + while (next.length < incoming.length) next.push(makeSignerId()); + return next; + } + return prev.slice(0, incoming.length); + }); setSignerDescriptions(walletData.signersDescriptions ?? []); setNumRequiredSigners(walletData.numRequiredSigners ?? 1); setNativeScriptType((walletData.type as "atLeast" | "all" | "any") ?? "atLeast"); @@ -183,6 +211,18 @@ export function useMigrationWalletFlowState(appWallet: Wallet, migrationId?: str setName(existingNewWallet.name); setDescription(existingNewWallet.description ?? ""); setSignerAddresses(existingNewWallet.signersAddresses ?? []); + // Stable across refetches — only grow / shrink, never re-mint, so + // an in-flight description input does not lose focus / IME state. + setSignerIds((prev) => { + const incoming = existingNewWallet.signersAddresses ?? []; + if (prev.length === incoming.length) return prev; + if (prev.length < incoming.length) { + const next = prev.slice(); + while (next.length < incoming.length) next.push(makeSignerId()); + return next; + } + return prev.slice(0, incoming.length); + }); setSignerDescriptions(existingNewWallet.signersDescriptions ?? []); setSignerStakeKeys(existingNewWallet.signersStakeKeys ?? []); setSignerDRepKeys( @@ -354,29 +394,55 @@ export function useMigrationWalletFlowState(appWallet: Wallet, migrationId?: str }); // Utility functions - function addSigner() { - setSignerAddresses([...signersAddresses, ""]); - setSignerDescriptions([...signersDescriptions, ""]); - setSignerStakeKeys([...signersStakeKeys, ""]); - setSignerDRepKeys([...signersDRepKeys, ""]); + function addSigner( + address: string = "", + newStakeKey: string = "", + drepKey: string = "", + description: string = "", + ) { + // Functional updaters so two synchronous addSigner() calls in the + // same render append two rows. With closure-reading non-functional + // setters, both calls would read the same `signersAddresses` and + // collapse into a single appended row, leaving signerIds.length + // out of sync with the other arrays. + setSignerAddresses((arr) => [...arr, address]); + setSignerDescriptions((arr) => [...arr, description]); + setSignerStakeKeys((arr) => [...arr, newStakeKey]); + setSignerDRepKeys((arr) => [...arr, drepKey]); + setSignerIds((arr) => [...arr, makeSignerId()]); } function removeSigner(index: number) { - const updatedAddresses = [...signersAddresses]; - updatedAddresses.splice(index, 1); - setSignerAddresses(updatedAddresses); - - const updatedDescriptions = [...signersDescriptions]; - updatedDescriptions.splice(index, 1); - setSignerDescriptions(updatedDescriptions); - - const updatedStakeKeys = [...signersStakeKeys]; - updatedStakeKeys.splice(index, 1); - setSignerStakeKeys(updatedStakeKeys); - - const updatedDRepKeys = [...signersDRepKeys]; - updatedDRepKeys.splice(index, 1); - setSignerDRepKeys(updatedDRepKeys); + // Functional updaters mirror the Pass 3 fix in addSigner: two + // synchronous removeSigner() calls in the same render must each + // see the previous batch's update, otherwise the second call would + // splice from a stale closure-read array and the five parallel + // arrays could drift out of alignment. + setSignerAddresses((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerDescriptions((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerStakeKeys((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerDRepKeys((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); + setSignerIds((arr) => { + const next = arr.slice(); + next.splice(index, 1); + return next; + }); } // Adjust numRequiredSigners if it exceeds the number of signers @@ -629,6 +695,13 @@ export function useMigrationWalletFlowState(appWallet: Wallet, migrationId?: str setSignerDescriptions(paddedDescriptions); setSignerStakeKeys(paddedStakeKeys); setSignerDRepKeys(paddedDRepKeys); + // Keep synthetic ids parallel to the address array. Reuse existing ids + // for the rows that survive, mint fresh ones for any newly-added rows. + setSignerIds((prevIds) => { + const next = prevIds.slice(0, paddedAddresses.length); + while (next.length < paddedAddresses.length) next.push(makeSignerId()); + return next; + }); if (newWalletId) { const updateData = { @@ -778,9 +851,10 @@ export function useMigrationWalletFlowState(appWallet: Wallet, migrationId?: str setSignerStakeKeys, signersDRepKeys, setSignerDRepKeys, + signerIds, addSigner, removeSigner, - + // Signature rules numRequiredSigners, setNumRequiredSigners, diff --git a/src/components/pages/wallet/info/signers/card-show-signers.tsx b/src/components/pages/wallet/info/signers/card-show-signers.tsx index a5097a89..e950e12e 100644 --- a/src/components/pages/wallet/info/signers/card-show-signers.tsx +++ b/src/components/pages/wallet/info/signers/card-show-signers.tsx @@ -155,24 +155,18 @@ export default function ShowSigners({ appWallet }: ShowSignersProps) { } } - function handleConnectDiscord() { - // Discord OAuth2 URL with required scopes - const DISCORD_CLIENT_ID = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; - const redirectUri = encodeURIComponent( - `${process.env.NODE_ENV === "production" ? "https://multisig.meshjs.dev" : "http://localhost:3000"}/api/auth/discord/callback`, - ); - const scope = encodeURIComponent("identify"); - const state = encodeURIComponent(userAddress || ""); - - const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=${state}`; - console.log({ - endpoint: "https://discord.com/api/oauth2/token", - client_id: process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID, - client_secret_set: !!process.env.DISCORD_CLIENT_SECRET, - redirect_uri: redirectUri, - grant_type: "authorization_code", - }); - window.location.href = url; + async function handleConnectDiscord() { + try { + const r = await fetch("/api/auth/discord/start", { credentials: "include" }); + if (!r.ok) { + throw new Error("Failed to start Discord auth"); + } + const data = (await r.json()) as { url?: string }; + if (!data.url) throw new Error("No Discord auth URL"); + window.location.href = data.url; + } catch (err) { + console.error("Discord auth start failed:", err instanceof Error ? err.message : err); + } } return appWallet.signersAddresses.map((address, index) => { diff --git a/src/components/pages/wallet/signing/index.tsx b/src/components/pages/wallet/signing/index.tsx index 590894ad..fe878b4c 100644 --- a/src/components/pages/wallet/signing/index.tsx +++ b/src/components/pages/wallet/signing/index.tsx @@ -86,7 +86,7 @@ export default function WalletSigning() { const signedAddresses = []; signedAddresses.push(userAddress); const signatures = []; - signatures.push(`signature: ${signature.signature}, key: ${signature.key}`); + signatures.push(JSON.stringify({ signature: signature.signature, key: signature.key })); let submitTx = false; diff --git a/src/components/pages/wallet/signing/signable-card.tsx b/src/components/pages/wallet/signing/signable-card.tsx index e7fed787..8f50c14c 100644 --- a/src/components/pages/wallet/signing/signable-card.tsx +++ b/src/components/pages/wallet/signing/signable-card.tsx @@ -185,7 +185,7 @@ function SignableCard({ const signatures = signable.signatures; signatures.push( - `signature: ${signature.signature}, key: ${signature.key}`, + JSON.stringify({ signature: signature.signature, key: signature.key }), ); let submitTx = false; @@ -342,9 +342,22 @@ function SignableCard({ {signable.signatures.map((sigStr, idx) => { - const [sigPart = "", keyPart = ""] = - sigStr.split(", key: "); - const signature = sigPart.replace("signature: ", ""); + // New format: JSON {signature, key}. + // Legacy format: "signature: , key: ". + let signature = ""; + let keyPart = ""; + try { + const parsed = JSON.parse(sigStr) as { + signature?: unknown; + key?: unknown; + }; + if (typeof parsed.signature === "string") signature = parsed.signature; + if (typeof parsed.key === "string") keyPart = parsed.key; + } catch { + const [sigPart = "", k = ""] = sigStr.split(", key: "); + signature = sigPart.replace("signature: ", ""); + keyPart = k; + } return ( diff --git a/src/components/ui/background.tsx b/src/components/ui/background.tsx index c7f3a2b5..0263d98d 100644 --- a/src/components/ui/background.tsx +++ b/src/components/ui/background.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx deleted file mode 100644 index a9a39ad9..00000000 --- a/src/components/ui/chart.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import * as React from "react" -import * as RechartsPrimitive from "recharts" -import { - NameType, - Payload, - ValueType, -} from "recharts/types/component/DefaultTooltipContent" - -import { cn } from "@/lib/utils" - -// Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const - -export type ChartConfig = { - [k in string]: { - label?: React.ReactNode - icon?: React.ComponentType - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record } - ) -} - -type ChartContextProps = { - config: ChartConfig -} - -const ChartContext = React.createContext(null) - -function useChart() { - const context = React.useContext(ChartContext) - - if (!context) { - throw new Error("useChart must be used within a ") - } - - return context -} - -const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - config: ChartConfig - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"] - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId() - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` - - return ( - -
- - - {children} - -
-
- ) -}) -ChartContainer.displayName = "Chart" - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color - ) - - if (!colorConfig.length) { - return null - } - - return ( -