From 27756d9146ba2ef4706da9333996befec96d9b62 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:29:35 +0200 Subject: [PATCH 001/146] remove all wallets and implement Sodot connection --- src/adamik/types.ts | 16 ++ src/app/api/sodot/route.ts | 87 +++++++++++ src/components/wallets/SodotConnect.tsx | 97 ++++++++++++ src/components/wallets/WalletSelection.tsx | 65 +------- src/components/wallets/types.ts | 11 +- src/env.ts | 51 ++++++ src/signers/Sodot.ts | 174 +++++++++++++++++++++ 7 files changed, 436 insertions(+), 65 deletions(-) create mode 100644 src/adamik/types.ts create mode 100644 src/app/api/sodot/route.ts create mode 100644 src/components/wallets/SodotConnect.tsx create mode 100644 src/signers/Sodot.ts diff --git a/src/adamik/types.ts b/src/adamik/types.ts new file mode 100644 index 00000000..1dd9e8a6 --- /dev/null +++ b/src/adamik/types.ts @@ -0,0 +1,16 @@ +export enum AdamikCurve { + SECP256K1 = "secp256k1", + ED25519 = "ed25519", +} + +export enum AdamikHashFunction { + SHA256 = "sha256", + KECCAK256 = "keccak256", +} + +export interface AdamikSignerSpec { + curve: AdamikCurve; + hashFunction: AdamikHashFunction; + coinType: string; + signatureFormat: string; +} diff --git a/src/app/api/sodot/route.ts b/src/app/api/sodot/route.ts new file mode 100644 index 00000000..d738bbf6 --- /dev/null +++ b/src/app/api/sodot/route.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; + +const vertices = [ + { + url: process.env.SODOT_VERTEX_URL_0, + apiKey: process.env.SODOT_VERTEX_API_KEY_0, + }, + { + url: process.env.SODOT_VERTEX_URL_1, + apiKey: process.env.SODOT_VERTEX_API_KEY_1, + }, + { + url: process.env.SODOT_VERTEX_URL_2, + apiKey: process.env.SODOT_VERTEX_API_KEY_2, + }, +]; + +export async function POST(request: NextRequest) { + try { + const { vertexIndex, endpoint, method, body } = await request.json(); + + const vertex = vertices[vertexIndex]; + if (!vertex) { + return new Response(JSON.stringify({ error: "Vertex not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Construct the full URL to the SODOT vertex + const targetUrl = `${vertex.url}${endpoint}`; + console.log(`Proxying request to: ${targetUrl}`); + + // Forward the request to the SODOT vertex + const response = await fetch(targetUrl, { + method, + headers: { + "Content-Type": "application/json", + Authorization: vertex.apiKey || "", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + // Log response details for debugging + console.log(`Response status: ${response.status}`); + console.log( + `Response headers:`, + Object.fromEntries(response.headers.entries()) + ); + + // Get the response text + const responseText = await response.text(); + console.log(`Response text:`, responseText); + + if (!response.ok) { + return new Response( + JSON.stringify({ + error: "Request failed", + status: response.status, + details: responseText, + }), + { + status: response.status, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // Return the raw response from the vertex + return new Response(responseText, { + status: 200, + headers: { + "Content-Type": "application/json", + ...Object.fromEntries(response.headers.entries()), + }, + }); + } catch (error: any) { + console.error("Sodot API error:", error); + return new Response( + JSON.stringify({ + error: "Internal server error", + message: error.message, + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} diff --git a/src/components/wallets/SodotConnect.tsx b/src/components/wallets/SodotConnect.tsx new file mode 100644 index 00000000..e73ab516 --- /dev/null +++ b/src/components/wallets/SodotConnect.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from "react"; +import { useToast } from "~/components/ui/use-toast"; +import { useWallet } from "~/hooks/useWallet"; +import { Account, WalletConnectorProps, WalletName } from "./types"; +import { SodotSigner } from "~/signers/Sodot"; +import { + AdamikCurve, + AdamikHashFunction, + AdamikSignerSpec, +} from "~/adamik/types"; +import { Button } from "~/components/ui/button"; + +export const SodotConnect: React.FC = ({ + chainId, + transactionPayload, +}) => { + const { toast } = useToast(); + const { addAddresses } = useWallet(); + + const getAddresses = useCallback(async () => { + try { + // Initialize Sodot signer with appropriate curve based on chain + const curve = + chainId === "bitcoin" ? AdamikCurve.SECP256K1 : AdamikCurve.ED25519; + const signerSpec: AdamikSignerSpec = { + curve, + hashFunction: AdamikHashFunction.SHA256, + coinType: "0", + signatureFormat: "der", + }; + + const sodotSigner = new SodotSigner(chainId || "ethereum", signerSpec); + + // Get public key + const pubkey = await sodotSigner.getPubkey(); + + // Create account with the public key + const account: Account = { + address: pubkey, // Using pubkey as address for now + chainId: chainId || "ethereum", + pubKey: pubkey, + signer: WalletName.SODOT, + }; + + addAddresses([account]); + + toast({ + description: + "Connected to Sodot Wallet, please check portfolio page to see your assets", + }); + } catch (e) { + toast({ + description: "Failed to connect to Sodot Wallet, please try again", + variant: "destructive", + }); + throw e; + } + }, [chainId, addAddresses, toast]); + + const sign = useCallback(async () => { + if (!transactionPayload) return; + + try { + const curve = + chainId === "bitcoin" ? AdamikCurve.SECP256K1 : AdamikCurve.ED25519; + const signerSpec: AdamikSignerSpec = { + curve, + hashFunction: AdamikHashFunction.SHA256, + coinType: "0", + signatureFormat: "der", + }; + + const sodotSigner = new SodotSigner(chainId || "ethereum", signerSpec); + const signature = await sodotSigner.signTransaction( + transactionPayload.encoded + ); + + // Handle the signature as needed + console.log("Transaction signed:", signature); + } catch (err) { + console.warn("Failed to sign with Sodot wallet:", err); + toast({ + description: "Transaction failed", + variant: "destructive", + }); + } + }, [chainId, transactionPayload, toast]); + + return ( + + ); +}; diff --git a/src/components/wallets/WalletSelection.tsx b/src/components/wallets/WalletSelection.tsx index d9204186..dbc5251a 100644 --- a/src/components/wallets/WalletSelection.tsx +++ b/src/components/wallets/WalletSelection.tsx @@ -1,64 +1,15 @@ "use client"; -import { FirstVisitTooltip } from "~/components/FirstVisitTooltip"; -import { useWallet } from "~/hooks/useWallet"; -import { Modal } from "~/components/ui/modal"; -import { Button } from "~/components/ui/button"; -import { Wallet } from "lucide-react"; -import { Switch } from "~/components/ui/switch"; -import { Label } from "~/components/ui/label"; +import React from "react"; +import { WalletConnectorProps } from "./types"; +import { SodotConnect } from "./SodotConnect"; -import { KeplrConnect } from "./KeplrConnect"; -import { MetamaskConnect } from "./MetamaskConnect"; -import { PeraConnect } from "./PeraConnect"; -import { UniSatConnect } from "./UniSatConnect"; -import { LitescribeConnect } from "./LitescribeConnect"; - -const WalletModalContent = () => { +export const WalletSelection: React.FC = (props) => { return ( -
- - - - - -
- ); -}; - -export const WalletSelection = () => { - const { isWalletMenuOpen, setWalletMenuOpen, setShowroom, isShowroom } = - useWallet(); - - return ( -
- {/* Tooltip component to show instructions on first visit */} - -
setShowroom(!isShowroom)} - > - - -
-
- - {/* Button to open wallet connection modal */} - - - {/* Modal for wallet connection */} - } - /> +
+
+ +
); }; diff --git a/src/components/wallets/types.ts b/src/components/wallets/types.ts index 82aff4e5..2813299b 100644 --- a/src/components/wallets/types.ts +++ b/src/components/wallets/types.ts @@ -11,11 +11,10 @@ export interface IWallet { id: string; families: string[]; icon: string; - withoutBroadcast: boolean; connect: () => Promise; getAddresses: () => Promise; - getDiscoveryMethod?: () => Promise; // pubKey for cosmos, address for ethereum - changeAddressEvent?: (callback: (address: string) => void) => void; + getPubkey: () => Promise; + signTransaction: (encodedMessage: string) => Promise; } export type Account = { @@ -26,11 +25,7 @@ export type Account = { }; export enum WalletName { - METAMASK = "metamask", - KEPLR = "keplr", - PERA = "pera", - UNISAT = "unisat", - LITESCRIBE = "litescribe", + SODOT = "sodot", } export type WalletConnectorProps = { diff --git a/src/env.ts b/src/env.ts index 7cc70437..a9d70c81 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,31 @@ const env = createEnv({ server: { ADAMIK_API_KEY: z.string().min(1), MOBULA_API_KEY: z.string().min(1), + // Sodot Vertex Configuration + SODOT_VERTEX_URL_0: z.string().url(), + SODOT_VERTEX_API_KEY_0: z.string().min(1), + SODOT_VERTEX_URL_1: z.string().url(), + SODOT_VERTEX_API_KEY_1: z.string().min(1), + SODOT_VERTEX_URL_2: z.string().url(), + SODOT_VERTEX_API_KEY_2: z.string().min(1), + // Existing key IDs + SODOT_EXISTING_ECDSA_KEY_IDS: z.string().optional(), + SODOT_EXISTING_ED25519_KEY_IDS: z.string().optional(), + }, + + /* + * Client-side Environment variables, available on both server and client. + */ + client: { + NEXT_PUBLIC_ADAMIK_API_TEST_URL: z.string().url(), + NEXT_PUBLIC_SODOT_VERTEX_URL_0: z.string().url(), + NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0: z.string().min(1), + NEXT_PUBLIC_SODOT_VERTEX_URL_1: z.string().url(), + NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1: z.string().min(1), + NEXT_PUBLIC_SODOT_VERTEX_URL_2: z.string().url(), + NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2: z.string().min(1), + NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS: z.string().optional(), + NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS: z.string().optional(), }, /* @@ -28,6 +53,32 @@ const env = createEnv({ runtimeEnv: { ADAMIK_API_KEY: process.env.ADAMIK_API_KEY, MOBULA_API_KEY: process.env.MOBULA_API_KEY, + // Client-side variables + NEXT_PUBLIC_ADAMIK_API_TEST_URL: + process.env.NEXT_PUBLIC_ADAMIK_API_TEST_URL, + NEXT_PUBLIC_SODOT_VERTEX_URL_0: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, + NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0: + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0, + NEXT_PUBLIC_SODOT_VERTEX_URL_1: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1, + NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1: + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1, + NEXT_PUBLIC_SODOT_VERTEX_URL_2: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2, + NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2: + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2, + NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS: + process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS, + NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS: + process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS, + // Sodot Vertex Configuration + SODOT_VERTEX_URL_0: process.env.SODOT_VERTEX_URL_0, + SODOT_VERTEX_API_KEY_0: process.env.SODOT_VERTEX_API_KEY_0, + SODOT_VERTEX_URL_1: process.env.SODOT_VERTEX_URL_1, + SODOT_VERTEX_API_KEY_1: process.env.SODOT_VERTEX_API_KEY_1, + SODOT_VERTEX_URL_2: process.env.SODOT_VERTEX_URL_2, + SODOT_VERTEX_API_KEY_2: process.env.SODOT_VERTEX_API_KEY_2, + // Existing key IDs + SODOT_EXISTING_ECDSA_KEY_IDS: process.env.SODOT_EXISTING_ECDSA_KEY_IDS, + SODOT_EXISTING_ED25519_KEY_IDS: process.env.SODOT_EXISTING_ED25519_KEY_IDS, }, }); diff --git a/src/signers/Sodot.ts b/src/signers/Sodot.ts new file mode 100644 index 00000000..adfc78d4 --- /dev/null +++ b/src/signers/Sodot.ts @@ -0,0 +1,174 @@ +import { AdamikCurve, AdamikSignerSpec } from "~/adamik/types"; + +// Helper function to determine if we're running in a production environment +const isProduction = (): boolean => { + return ( + process.env.NODE_ENV === "production" || + window.location.hostname !== "localhost" + ); +}; + +// Helper function to ensure URLs have proper format +const ensureUrlFormat = (url: string): string => { + if (!url) return url; + // If URL doesn't start with http:// or https://, add https:// + if (!url.startsWith("http://") && !url.startsWith("https://")) { + return `https://${url}`; + } + return url; +}; + +export class SodotSigner { + private chainId: string; + private signerSpec: AdamikSignerSpec; + private vertices: Array<{ url: string; apiKey: string }>; + private keyIds: string[] = []; + + constructor(chainId: string, signerSpec: AdamikSignerSpec) { + this.chainId = chainId; + this.signerSpec = signerSpec; + + // Initialize vertices from environment variables + this.vertices = [ + { + url: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0 || "", + apiKey: process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || "", + }, + { + url: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1 || "", + apiKey: process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || "", + }, + { + url: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2 || "", + apiKey: process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || "", + }, + ]; + + // Initialize key IDs based on curve type + switch (signerSpec.curve) { + case AdamikCurve.SECP256K1: + this.keyIds = ( + process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || "" + ) + .split(",") + .filter(Boolean); + break; + case AdamikCurve.ED25519: + this.keyIds = ( + process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS || "" + ) + .split(",") + .filter(Boolean); + break; + default: + throw new Error(`Unsupported curve: ${signerSpec.curve}`); + } + + // Validate vertices configuration + if (this.vertices.some((v) => !v.url || !v.apiKey)) { + throw new Error( + "Sodot vertices configuration is incomplete. Please check your environment variables." + ); + } + } + + private async makeRequest( + vertexIndex: number, + endpoint: string, + method: string = "POST", + body?: any + ) { + const vertex = this.vertices[vertexIndex]; + if (!vertex) throw new Error(`Vertex ${vertexIndex} not found`); + + try { + const response = await fetch("/api/sodot", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + vertexIndex, + endpoint, + method, + body, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Error response:", errorText); + throw new Error( + `Request failed: ${response.statusText} - ${errorText}` + ); + } + + // Get the response text + const responseText = await response.text(); + console.log("Response text:", responseText); + + // Parse the JSON response + try { + const data = JSON.parse(responseText); + return data; + } catch (e) { + console.error("Failed to parse response as JSON:", e); + console.error("Raw response:", responseText); + throw new Error("Invalid JSON response from server"); + } + } catch (error: any) { + console.error("Request error:", error); + throw new Error( + `Failed to make request to vertex ${vertexIndex}: ${error.message}` + ); + } + } + + private adamikCurveToSodotCurve(curve: AdamikCurve): "ecdsa" | "ed25519" { + return curve === AdamikCurve.SECP256K1 ? "ecdsa" : "ed25519"; + } + + public async getPubkey(): Promise { + if (this.keyIds.length === 0) { + throw new Error( + "No key IDs available. Please set the appropriate environment variables." + ); + } + + const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); + const response = await this.makeRequest( + 0, + `/${curve}/derive-pubkey`, + "POST", + { + key_id: this.keyIds[0], + derivation_path: [44, Number(this.signerSpec.coinType), 0, 0, 0], + } + ); + + return response.pubkey; + } + + public async signTransaction(encodedMessage: string): Promise { + // Create a signing room + const roomResponse = await this.makeRequest(0, "/create-room", "POST", { + room_size: this.vertices.length, + }); + + // Sign the transaction with each vertex + const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); + const signatures = await Promise.all( + this.vertices.map((_, index) => + this.makeRequest(index, `/${curve}/sign`, "POST", { + room_uuid: roomResponse.room_uuid, + key_id: this.keyIds[index], + msg: encodedMessage, + derivation_path: [44, Number(this.signerSpec.coinType), 0, 0, 0], + }) + ) + ); + + // Return the first signature (they should all be identical) + return signatures[0].signature; + } +} From 9829f997d2d6eb94218cc56360e2d116a033a32a Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:54:20 +0200 Subject: [PATCH 002/146] WIP --- scripts/test-sodot.js | 136 ++++++++ src/api/adamik/encode.ts | 60 ++++ src/app/api/sodot/pubkey/route.ts | 239 ++++++++++++++ src/app/api/sodot/route.ts | 157 ++++++++- src/app/api/sodot/test-connection/route.ts | 197 ++++++++++++ src/app/api/sodot/test/route.ts | 148 +++++++++ src/app/sodot-test/page.tsx | 152 +++++++++ src/components/wallets/BroadcastModal.tsx | 2 +- src/signers/Sodot.ts | 356 ++++++++++++++++++--- 9 files changed, 1394 insertions(+), 53 deletions(-) create mode 100644 scripts/test-sodot.js create mode 100644 src/app/api/sodot/pubkey/route.ts create mode 100644 src/app/api/sodot/test-connection/route.ts create mode 100644 src/app/api/sodot/test/route.ts create mode 100644 src/app/sodot-test/page.tsx diff --git a/scripts/test-sodot.js b/scripts/test-sodot.js new file mode 100644 index 00000000..50cd4af9 --- /dev/null +++ b/scripts/test-sodot.js @@ -0,0 +1,136 @@ +// A simple Node.js script to test the Sodot vertex directly +// require('dotenv').config(); + +// Use environment variables directly +const vertexUrl = + process.env.SODOT_VERTEX_0_URL || + process.env.SODOT_VERTEX_URL_0 || + "https://vertex-demo-0.sodot.dev"; +const apiKey = + process.env.SODOT_VERTEX_0_API_KEY || + process.env.SODOT_VERTEX_API_KEY_0 || + "3e162c0e-83d9-4cd0-aabf-69354e20af53"; + +console.log("Sodot Vertex URL:", vertexUrl); +console.log( + "API Key:", + apiKey + ? "Set (first few chars: " + apiKey.substring(0, 8) + "...)" + : "Not set" +); + +async function testECDSADerivePubkey() { + const curve = "ecdsa"; + const keyId = "8306e478-e39f-4e68-9c87-fdf9bfa6d1ad"; // Example key ID from .env + const derivationPath = [44, 60, 0, 0, 0]; // Ethereum derivation path + + console.log("\n=== TESTING ECDSA CURVE ==="); + const url = `${vertexUrl}/${curve}/derive-pubkey`; + + console.log("Making request to:", url); + console.log("Request body:", { + key_id: keyId, + derivation_path: derivationPath, + }); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + }, + body: JSON.stringify({ + key_id: keyId, + derivation_path: derivationPath, + }), + }); + + console.log("\nResponse status:", response.status); + console.log( + "Response headers:", + Object.fromEntries(response.headers.entries()) + ); + + const responseText = await response.text(); + console.log("\nRaw response text:", responseText); + + if (responseText && responseText.trim() !== "") { + try { + const parsedResponse = JSON.parse(responseText); + console.log( + "\nParsed response:", + JSON.stringify(parsedResponse, null, 2) + ); + } catch (e) { + console.error("Failed to parse response as JSON:", e); + } + } else { + console.log("Response is empty"); + } + } catch (error) { + console.error("Error making request:", error); + } +} + +async function testED25519DerivePubkey() { + const curve = "ed25519"; + const keyId = "868a7bea-a410-40d3-a03a-ea06200f9fe6"; // Example ED25519 key ID from .env + const derivationPath = [44, 118, 0, 0, 0]; // Cosmos derivation path + + console.log("\n=== TESTING ED25519 CURVE ==="); + const url = `${vertexUrl}/${curve}/derive-pubkey`; + + console.log("Making request to:", url); + console.log("Request body:", { + key_id: keyId, + derivation_path: derivationPath, + }); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + }, + body: JSON.stringify({ + key_id: keyId, + derivation_path: derivationPath, + }), + }); + + console.log("\nResponse status:", response.status); + console.log( + "Response headers:", + Object.fromEntries(response.headers.entries()) + ); + + const responseText = await response.text(); + console.log("\nRaw response text:", responseText); + + if (responseText && responseText.trim() !== "") { + try { + const parsedResponse = JSON.parse(responseText); + console.log( + "\nParsed response:", + JSON.stringify(parsedResponse, null, 2) + ); + } catch (e) { + console.error("Failed to parse response as JSON:", e); + } + } else { + console.log("Response is empty"); + } + } catch (error) { + console.error("Error making request:", error); + } +} + +// Run the tests +async function runTests() { + await testECDSADerivePubkey(); + await testED25519DerivePubkey(); +} + +runTests(); diff --git a/src/api/adamik/encode.ts b/src/api/adamik/encode.ts index 357c4388..dfae681c 100644 --- a/src/api/adamik/encode.ts +++ b/src/api/adamik/encode.ts @@ -43,3 +43,63 @@ export const transactionEncode = async ( }; return result; }; + +export const encodePubKeyToAddress = async ( + pubKey: string, + chainId: string +) => { + try { + const response = await fetch( + `${ADAMIK_API_URL}/${chainId}/address/encode`, + { + method: "POST", + headers: { + Authorization: env.ADAMIK_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + pubkey: pubKey, + }), + } + ); + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + + const result = (await response.json()) as { + status?: { + errors: Array<{ message: string }>; + }; + addresses?: Array<{ + address: string; + type: string; + }>; + }; + + if ( + result.status && + result.status.errors && + result.status.errors.length > 0 + ) { + throw new Error(result.status.errors[0].message); + } + + const addresses = result.addresses; + + if (!addresses || addresses.length === 0) { + throw new Error("No addresses found for the given public key"); + } + + // In browser context, we'll always use the first address + // This is typically the most common/default address format for the chain + return { + address: addresses[0].address, + type: addresses[0].type, + allAddresses: addresses, + }; + } catch (error) { + console.error(`Error encoding pubkey to address:`, error); + throw error; + } +}; diff --git a/src/app/api/sodot/pubkey/route.ts b/src/app/api/sodot/pubkey/route.ts new file mode 100644 index 00000000..f5346071 --- /dev/null +++ b/src/app/api/sodot/pubkey/route.ts @@ -0,0 +1,239 @@ +import { NextRequest } from "next/server"; + +const vertices = [ + { + url: + process.env.SODOT_VERTEX_URL_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, + apiKey: + process.env.SODOT_VERTEX_API_KEY_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || + "", + }, + { + url: + process.env.SODOT_VERTEX_URL_1 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1, + apiKey: + process.env.SODOT_VERTEX_API_KEY_1 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || + "", + }, + { + url: + process.env.SODOT_VERTEX_URL_2 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2, + apiKey: + process.env.SODOT_VERTEX_API_KEY_2 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || + "", + }, +]; + +const corsHeaders = { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +}; + +export async function POST(request: NextRequest) { + let responseClone; + + try { + console.log("[PubkeyAPI] Request received"); + const { curve, keyId, derivationPath } = await request.json(); + + if (!curve || !keyId || !derivationPath) { + return new Response( + JSON.stringify({ + error: "Missing required parameters", + }), + { + status: 400, + headers: corsHeaders, + } + ); + } + + // Use vertex 0 for all pubkey requests + const vertex = vertices[0]; + if (!vertex || !vertex.url) { + return new Response( + JSON.stringify({ + error: "Vertex configuration is missing", + }), + { + status: 500, + headers: corsHeaders, + } + ); + } + + // Construct the full URL to the SODOT vertex + const targetUrl = `${vertex.url}/${curve}/derive-pubkey`; + console.log(`[PubkeyAPI] Proxying request to: ${targetUrl}`); + + // Forward the request to the SODOT vertex + const response = await fetch(targetUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: vertex.apiKey || "", + }, + body: JSON.stringify({ + key_id: keyId, + derivation_path: derivationPath, + }), + }); + + // Clone the response for debugging + responseClone = response.clone(); + + // Log response details for debugging + console.log(`[PubkeyAPI] Response status: ${response.status}`); + + // Get the response text + const responseText = await response.text(); + console.log(`[PubkeyAPI] Response text:`, responseText); + + if (!response.ok) { + return new Response( + JSON.stringify({ + error: "Request failed", + status: response.status, + details: responseText, + }), + { + status: response.status, + headers: corsHeaders, + } + ); + } + + // Check if the response is empty + if (!responseText || responseText.trim() === "") { + return new Response( + JSON.stringify({ + error: "Empty response from vertex", + }), + { + status: 500, + headers: corsHeaders, + } + ); + } + + // Parse the response as JSON + try { + const parsedResponse = JSON.parse(responseText); + console.log( + "[PubkeyAPI] Parsed response:", + JSON.stringify(parsedResponse) + ); + + // Check if the response has a 'pubkey' field + if (parsedResponse.pubkey) { + // Transform the response based on the curve type + if (curve === "ed25519") { + // For ED25519, use pubkey as both compressed and uncompressed + const transformedResponse = { + compressed: parsedResponse.pubkey, + uncompressed: parsedResponse.pubkey, + pubkey: parsedResponse.pubkey, + }; + + console.log( + "[PubkeyAPI] Transformed ED25519 response:", + JSON.stringify(transformedResponse) + ); + + // Return the transformed response with proper headers + return new Response(JSON.stringify(transformedResponse), { + status: 200, + headers: corsHeaders, + }); + } else { + // For other curves, if they only have pubkey (unlikely), still transform + const transformedResponse = { + compressed: parsedResponse.pubkey, + uncompressed: parsedResponse.pubkey, + pubkey: parsedResponse.pubkey, + }; + + console.log( + "[PubkeyAPI] Transformed response:", + JSON.stringify(transformedResponse) + ); + + return new Response(JSON.stringify(transformedResponse), { + status: 200, + headers: corsHeaders, + }); + } + } + + // If it has compressed/uncompressed fields, use those directly + if (parsedResponse.compressed) { + return new Response(JSON.stringify(parsedResponse), { + status: 200, + headers: corsHeaders, + }); + } + + // Unexpected response format + return new Response( + JSON.stringify({ + error: "Unexpected response format", + details: parsedResponse, + }), + { + status: 500, + headers: corsHeaders, + } + ); + } catch (e) { + console.error("[PubkeyAPI] Failed to parse response as JSON:", e); + + return new Response( + JSON.stringify({ + error: "Invalid JSON response", + details: responseText, + }), + { + status: 500, + headers: corsHeaders, + } + ); + } + } catch (error: any) { + console.error("[PubkeyAPI] Error:", error); + + return new Response( + JSON.stringify({ + error: "Internal server error", + message: error.message, + }), + { + status: 500, + headers: corsHeaders, + } + ); + } +} + +// Handle OPTIONS request for CORS +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/src/app/api/sodot/route.ts b/src/app/api/sodot/route.ts index d738bbf6..62a2cd71 100644 --- a/src/app/api/sodot/route.ts +++ b/src/app/api/sodot/route.ts @@ -2,25 +2,47 @@ import { NextRequest } from "next/server"; const vertices = [ { - url: process.env.SODOT_VERTEX_URL_0, - apiKey: process.env.SODOT_VERTEX_API_KEY_0, + url: + process.env.SODOT_VERTEX_URL_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, + apiKey: + process.env.SODOT_VERTEX_API_KEY_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || + "", }, { - url: process.env.SODOT_VERTEX_URL_1, - apiKey: process.env.SODOT_VERTEX_API_KEY_1, + url: + process.env.SODOT_VERTEX_URL_1 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1, + apiKey: + process.env.SODOT_VERTEX_API_KEY_1 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || + "", }, { - url: process.env.SODOT_VERTEX_URL_2, - apiKey: process.env.SODOT_VERTEX_API_KEY_2, + url: + process.env.SODOT_VERTEX_URL_2 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2, + apiKey: + process.env.SODOT_VERTEX_API_KEY_2 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || + "", }, ]; export async function POST(request: NextRequest) { + let responseClone; // For debugging + try { - const { vertexIndex, endpoint, method, body } = await request.json(); + console.log("API route received request"); + const requestBody = await request.json(); + console.log("Request body:", JSON.stringify(requestBody)); + + const { vertexIndex, endpoint, method, body } = requestBody; const vertex = vertices[vertexIndex]; if (!vertex) { + console.error(`Vertex ${vertexIndex} not found`); return new Response(JSON.stringify({ error: "Vertex not found" }), { status: 404, headers: { "Content-Type": "application/json" }, @@ -41,6 +63,9 @@ export async function POST(request: NextRequest) { body: body ? JSON.stringify(body) : undefined, }); + // Clone the response for debugging + responseClone = response.clone(); + // Log response details for debugging console.log(`Response status: ${response.status}`); console.log( @@ -53,6 +78,7 @@ export async function POST(request: NextRequest) { console.log(`Response text:`, responseText); if (!response.ok) { + console.error(`Request failed with status ${response.status}`); return new Response( JSON.stringify({ error: "Request failed", @@ -66,22 +92,121 @@ export async function POST(request: NextRequest) { ); } - // Return the raw response from the vertex - return new Response(responseText, { - status: 200, - headers: { - "Content-Type": "application/json", - ...Object.fromEntries(response.headers.entries()), - }, - }); + // Check if the response is empty + if (!responseText || responseText.trim() === "") { + console.error("Empty response from vertex"); + return new Response( + JSON.stringify({ + error: "Empty response from vertex", + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // Parse the response as JSON + try { + const parsedResponse = JSON.parse(responseText); + console.log("Parsed response:", JSON.stringify(parsedResponse)); + + // Check if the response has a 'pubkey' field (Sodot vertex format) + if (parsedResponse.pubkey) { + // Transform the response based on the curve type + // For ED25519, the vertex only returns a 'pubkey' field + // For ECDSA, it returns 'compressed' and 'uncompressed' + const isEd25519 = endpoint.includes("/ed25519/"); + + if (isEd25519) { + // For ED25519, use the pubkey as both compressed and uncompressed + const transformedResponse = { + compressed: parsedResponse.pubkey, + uncompressed: parsedResponse.pubkey, + // Preserve the original pubkey field as well + pubkey: parsedResponse.pubkey, + }; + + console.log( + "Transformed ED25519 response:", + JSON.stringify(transformedResponse) + ); + + // Return the transformed response with proper headers + return new Response(JSON.stringify(transformedResponse), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }); + } else { + // For other curves, just pass through the pubkey + return new Response(JSON.stringify(parsedResponse), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }); + } + } + + // If it doesn't have a 'pubkey' field, return the original response + return new Response(JSON.stringify(parsedResponse), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }); + } catch (e) { + console.error("Failed to parse response as JSON:", e); + console.error("Raw response text:", responseText); + return new Response( + JSON.stringify({ + error: "Invalid JSON response", + details: responseText, + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } } catch (error: any) { console.error("Sodot API error:", error); + + // If it's a fetch error and we have the response clone, try to get more info + if (responseClone) { + try { + const cloneText = await responseClone.text(); + console.error("Response clone text:", cloneText); + } catch (e) { + console.error("Failed to read response clone:", e); + } + } + return new Response( JSON.stringify({ error: "Internal server error", message: error.message, }), - { status: 500, headers: { "Content-Type": "application/json" } } + { + status: 500, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + } ); } } diff --git a/src/app/api/sodot/test-connection/route.ts b/src/app/api/sodot/test-connection/route.ts new file mode 100644 index 00000000..5878bc16 --- /dev/null +++ b/src/app/api/sodot/test-connection/route.ts @@ -0,0 +1,197 @@ +import { NextRequest } from "next/server"; + +const vertices = [ + { + url: + process.env.SODOT_VERTEX_URL_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, + apiKey: + process.env.SODOT_VERTEX_API_KEY_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || + "", + }, +]; + +const corsHeaders = { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +}; + +export async function GET(request: NextRequest) { + console.log("[ConnectionTest] Request received"); + + try { + // Check environment variables + const envStatus = { + serverSide: { + SODOT_VERTEX_URL_0: process.env.SODOT_VERTEX_URL_0 ? "Set" : "Missing", + SODOT_VERTEX_API_KEY_0: process.env.SODOT_VERTEX_API_KEY_0 + ? "Set" + : "Missing", + SODOT_EXISTING_ECDSA_KEY_IDS: + process.env.SODOT_EXISTING_ECDSA_KEY_IDS?.split(",").length || 0, + SODOT_EXISTING_ED25519_KEY_IDS: + process.env.SODOT_EXISTING_ED25519_KEY_IDS?.split(",").length || 0, + }, + clientSide: { + NEXT_PUBLIC_SODOT_VERTEX_URL_0: process.env + .NEXT_PUBLIC_SODOT_VERTEX_URL_0 + ? "Set" + : "Missing", + NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0: process.env + .NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 + ? "Set" + : "Missing", + NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS: + process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS?.split(",") + .length || 0, + NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS: + process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS?.split(",") + .length || 0, + }, + }; + + console.log("[ConnectionTest] Environment status:", envStatus); + + // Check if we have a valid vertex + const vertex = vertices[0]; + if (!vertex || !vertex.url) { + return new Response( + JSON.stringify({ + success: false, + error: "Vertex configuration is missing", + envStatus: envStatus, + }), + { + status: 500, + headers: corsHeaders, + } + ); + } + + // Try to connect to the Sodot vertex health endpoint + const healthUrl = `${vertex.url}/health`; + console.log(`[ConnectionTest] Testing connection to: ${healthUrl}`); + + const response = await fetch(healthUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + const status = response.status; + console.log(`[ConnectionTest] Health check status: ${status}`); + + let responseBody; + try { + responseBody = await response.text(); + console.log(`[ConnectionTest] Health check response: ${responseBody}`); + } catch (e) { + responseBody = "Could not read response body"; + console.error(`[ConnectionTest] Error reading response: ${e}`); + } + + // Now try to get a pubkey for verification + console.log("[ConnectionTest] Testing pubkey retrieval"); + + const ecdsaKeyId = ( + process.env.SODOT_EXISTING_ECDSA_KEY_IDS || + process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || + "" + ).split(",")[0]; + + let pubkeyStatus = "Not tested"; + let pubkeyData = null; + + if (ecdsaKeyId) { + try { + const pubkeyUrl = `${vertex.url}/ecdsa/derive-pubkey`; + console.log(`[ConnectionTest] Testing pubkey at: ${pubkeyUrl}`); + + const pubkeyResponse = await fetch(pubkeyUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: vertex.apiKey || "", + }, + body: JSON.stringify({ + key_id: ecdsaKeyId, + derivation_path: [44, 60, 0, 0, 0], + }), + }); + + const pubkeyStatus = pubkeyResponse.status; + console.log(`[ConnectionTest] Pubkey status: ${pubkeyStatus}`); + + const pubkeyText = await pubkeyResponse.text(); + console.log(`[ConnectionTest] Pubkey response: ${pubkeyText}`); + + if (pubkeyText && pubkeyText.trim() !== "") { + try { + pubkeyData = JSON.parse(pubkeyText); + } catch (e) { + console.error(`[ConnectionTest] Error parsing pubkey JSON: ${e}`); + } + } + } catch (e: any) { + pubkeyStatus = `Error: ${e.message}`; + console.error(`[ConnectionTest] Pubkey error: ${e}`); + } + } else { + pubkeyStatus = "No ECDSA key ID available"; + } + + // Return the results + return new Response( + JSON.stringify({ + success: status === 200, + vertex: { + url: vertex.url, + hasApiKey: !!vertex.apiKey, + }, + health: { + status: status, + response: responseBody, + }, + pubkey: { + status: pubkeyStatus, + keyId: ecdsaKeyId || "None", + data: pubkeyData, + }, + envStatus: envStatus, + }), + { + status: 200, + headers: corsHeaders, + } + ); + } catch (error: any) { + console.error("[ConnectionTest] Error:", error); + + return new Response( + JSON.stringify({ + success: false, + error: "Connection test failed", + message: error.message, + }), + { + status: 500, + headers: corsHeaders, + } + ); + } +} + +// Handle OPTIONS request for CORS +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: corsHeaders, + }); +} diff --git a/src/app/api/sodot/test/route.ts b/src/app/api/sodot/test/route.ts new file mode 100644 index 00000000..f2b90349 --- /dev/null +++ b/src/app/api/sodot/test/route.ts @@ -0,0 +1,148 @@ +import { NextRequest } from "next/server"; + +const vertices = [ + { + url: + process.env.SODOT_VERTEX_URL_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, + apiKey: + process.env.SODOT_VERTEX_API_KEY_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || + "", + }, +]; + +const corsHeaders = { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +}; + +export async function POST(request: NextRequest) { + console.log("[TestAPI] Request received"); + + try { + const { curve, keyId, derivationPath } = await request.json(); + + if (!curve || !keyId || !derivationPath) { + return new Response( + JSON.stringify({ + error: "Missing required parameters", + }), + { + status: 400, + headers: corsHeaders, + } + ); + } + + // Use vertex 0 for testing + const vertex = vertices[0]; + if (!vertex || !vertex.url) { + return new Response( + JSON.stringify({ + error: "Vertex configuration is missing", + env: { + vertex0Url: process.env.SODOT_VERTEX_URL_0 ? "Set" : "Missing", + vertex0ApiKey: process.env.SODOT_VERTEX_API_KEY_0 + ? "Set" + : "Missing", + }, + }), + { + status: 500, + headers: corsHeaders, + } + ); + } + + // Construct the full URL to the SODOT vertex + const targetUrl = `${vertex.url}/${curve}/derive-pubkey`; + console.log(`[TestAPI] Making request to: ${targetUrl}`); + console.log(`[TestAPI] Request body:`, { + key_id: keyId, + derivation_path: derivationPath, + }); + + // Forward the request to the SODOT vertex + const response = await fetch(targetUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: vertex.apiKey || "", + }, + body: JSON.stringify({ + key_id: keyId, + derivation_path: derivationPath, + }), + }); + + // Log response details for debugging + console.log(`[TestAPI] Response status: ${response.status}`); + console.log( + `[TestAPI] Response headers:`, + Object.fromEntries(response.headers.entries()) + ); + + // Get the response text + const responseText = await response.text(); + console.log(`[TestAPI] Raw response text:`, responseText); + + // Return detailed information about the request and response + return new Response( + JSON.stringify( + { + request: { + url: targetUrl, + body: { + key_id: keyId, + derivation_path: derivationPath, + }, + }, + response: { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: responseText + ? responseText.startsWith("{") + ? JSON.parse(responseText) + : responseText + : null, + rawText: responseText, + }, + }, + null, + 2 + ), + { + status: 200, + headers: corsHeaders, + } + ); + } catch (error: any) { + console.error("[TestAPI] Error:", error); + + return new Response( + JSON.stringify({ + error: "Internal server error", + message: error.message, + stack: error.stack, + }), + { + status: 500, + headers: corsHeaders, + } + ); + } +} + +// Handle OPTIONS request for CORS +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: corsHeaders, + }); +} diff --git a/src/app/sodot-test/page.tsx b/src/app/sodot-test/page.tsx new file mode 100644 index 00000000..10e8f431 --- /dev/null +++ b/src/app/sodot-test/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +export default function SodotTestPage() { + const [loading, setLoading] = useState(false); + const [connectionResults, setConnectionResults] = useState | null>(null); + const [error, setError] = useState(null); + + const testConnection = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch("/api/sodot/test-connection"); + const data = await response.json(); + setConnectionResults(data); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + console.error("Error testing connection:", e); + } finally { + setLoading(false); + } + }; + + const testEcdsaPubkey = async () => { + setLoading(true); + setError(null); + try { + const keyId = + connectionResults?.envStatus?.serverSide?.SODOT_EXISTING_ECDSA_KEY_IDS > + 0 + ? process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS?.split(",")[0] + : "8306e478-e39f-4e68-9c87-fdf9bfa6d1ad"; + + const response = await fetch("/api/sodot/pubkey", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + curve: "ecdsa", + keyId: keyId, + derivationPath: [44, 60, 0, 0, 0], + }), + }); + + const data = await response.json(); + setConnectionResults((prev: Record | null) => ({ + ...(prev || {}), + ecdsaPubkeyTest: { + keyId, + data, + }, + })); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + console.error("Error testing ECDSA pubkey:", e); + } finally { + setLoading(false); + } + }; + + const testEd25519Pubkey = async () => { + setLoading(true); + setError(null); + try { + const keyId = + connectionResults?.envStatus?.serverSide + ?.SODOT_EXISTING_ED25519_KEY_IDS > 0 + ? process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS?.split( + "," + )[0] + : "868a7bea-a410-40d3-a03a-ea06200f9fe6"; + + const response = await fetch("/api/sodot/pubkey", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + curve: "ed25519", + keyId: keyId, + derivationPath: [44, 118, 0, 0, 0], + }), + }); + + const data = await response.json(); + setConnectionResults((prev: Record | null) => ({ + ...(prev || {}), + ed25519PubkeyTest: { + keyId, + data, + }, + })); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + console.error("Error testing ED25519 pubkey:", e); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Sodot Connection Test + + +
+ + + {connectionResults && ( + <> + + + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {connectionResults && ( +
+

Connection Results

+
+
+                  {JSON.stringify(connectionResults, null, 2)}
+                
+
+
+ )} +
+
+
+ ); +} diff --git a/src/components/wallets/BroadcastModal.tsx b/src/components/wallets/BroadcastModal.tsx index cd6e0f2f..73aae920 100644 --- a/src/components/wallets/BroadcastModal.tsx +++ b/src/components/wallets/BroadcastModal.tsx @@ -120,7 +120,7 @@ export const BroadcastModal = ({ onNextStep }: BroadcastProps) => {
- + View signed transaction diff --git a/src/signers/Sodot.ts b/src/signers/Sodot.ts index adfc78d4..5fc2917c 100644 --- a/src/signers/Sodot.ts +++ b/src/signers/Sodot.ts @@ -1,4 +1,9 @@ -import { AdamikCurve, AdamikSignerSpec } from "~/adamik/types"; +import { + AdamikCurve, + AdamikHashFunction, + AdamikSignerSpec, +} from "~/adamik/types"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; // Helper function to determine if we're running in a production environment const isProduction = (): boolean => { @@ -29,63 +34,139 @@ export class SodotSigner { this.signerSpec = signerSpec; // Initialize vertices from environment variables + // First try server-side env vars (preferred), then fallback to client-side + const vertexUrl0 = + process.env.SODOT_VERTEX_URL_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0 || + ""; + const vertexApiKey0 = + process.env.SODOT_VERTEX_API_KEY_0 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || + ""; + const vertexUrl1 = + process.env.SODOT_VERTEX_URL_1 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1 || + ""; + const vertexApiKey1 = + process.env.SODOT_VERTEX_API_KEY_1 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || + ""; + const vertexUrl2 = + process.env.SODOT_VERTEX_URL_2 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2 || + ""; + const vertexApiKey2 = + process.env.SODOT_VERTEX_API_KEY_2 || + process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || + ""; + this.vertices = [ { - url: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0 || "", - apiKey: process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || "", + url: ensureUrlFormat(vertexUrl0), + apiKey: vertexApiKey0, }, { - url: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1 || "", - apiKey: process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || "", + url: ensureUrlFormat(vertexUrl1), + apiKey: vertexApiKey1, }, { - url: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2 || "", - apiKey: process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || "", + url: ensureUrlFormat(vertexUrl2), + apiKey: vertexApiKey2, }, ]; + // Log the vertex URLs for debugging + console.log( + `[Sodot] Vertex URLs:`, + this.vertices.map((v) => v.url) + ); + // Initialize key IDs based on curve type + const serverSideKeyIds = + process.env.SODOT_EXISTING_ECDSA_KEY_IDS || + process.env.SODOT_EXISTING_ED25519_KEY_IDS || + ""; + const clientSideKeyIds = + process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || + process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS || + ""; + switch (signerSpec.curve) { case AdamikCurve.SECP256K1: this.keyIds = ( - process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || "" + process.env.SODOT_EXISTING_ECDSA_KEY_IDS || + process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || + "" ) .split(",") .filter(Boolean); + console.log( + `[Sodot] Initialized SECP256K1 key IDs for chain ${chainId}:`, + this.keyIds + ); break; case AdamikCurve.ED25519: this.keyIds = ( - process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS || "" + process.env.SODOT_EXISTING_ED25519_KEY_IDS || + process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS || + "" ) .split(",") .filter(Boolean); + console.log( + `[Sodot] Initialized ED25519 key IDs for chain ${chainId}:`, + this.keyIds + ); break; default: throw new Error(`Unsupported curve: ${signerSpec.curve}`); } - // Validate vertices configuration + // Warn if vertices configuration is incomplete, but don't throw an error if (this.vertices.some((v) => !v.url || !v.apiKey)) { - throw new Error( - "Sodot vertices configuration is incomplete. Please check your environment variables." + console.warn( + "[Sodot] Vertices configuration is incomplete. Some vertices may not work." ); } + + // Log the full configuration + console.log(`[Sodot] Initialized for chain ${chainId} with:`, { + curve: signerSpec.curve, + keyIds: this.keyIds, + vertices: this.vertices.map((v) => ({ + url: v.url, + hasApiKey: !!v.apiKey, + })), + }); } private async makeRequest( vertexIndex: number, endpoint: string, method: string = "POST", - body?: any - ) { + body?: any, + retryCount: number = 0 + ): Promise { const vertex = this.vertices[vertexIndex]; if (!vertex) throw new Error(`Vertex ${vertexIndex} not found`); + console.log(`[Sodot] Making request to vertex ${vertexIndex}:`, { + endpoint, + method, + body, + retryCount, + }); + try { - const response = await fetch("/api/sodot", { + // Add a unique timestamp to prevent caching issues + const timestamp = new Date().getTime(); + + const response = await fetch(`/api/sodot?t=${timestamp}`, { method: "POST", headers: { "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", }, body: JSON.stringify({ vertexIndex, @@ -93,11 +174,21 @@ export class SodotSigner { method, body, }), + // Prevent caching issues + cache: "no-store", }); + console.log( + `[Sodot] Response status from vertex ${vertexIndex}:`, + response.status + ); + if (!response.ok) { const errorText = await response.text(); - console.error("Error response:", errorText); + console.error( + `[Sodot] Error response from vertex ${vertexIndex}:`, + errorText + ); throw new Error( `Request failed: ${response.statusText} - ${errorText}` ); @@ -105,19 +196,92 @@ export class SodotSigner { // Get the response text const responseText = await response.text(); - console.log("Response text:", responseText); + console.log( + `[Sodot] Raw response text from vertex ${vertexIndex}:`, + responseText + ); + + // Check if the response is empty + if (!responseText || responseText.trim() === "") { + console.error(`[Sodot] Empty response from vertex ${vertexIndex}`); + + // Retry up to 3 times on empty response + if (retryCount < 3) { + console.log( + `[Sodot] Retrying request to vertex ${vertexIndex} (${ + retryCount + 1 + }/3)` + ); + // Wait a short time before retrying + await new Promise((resolve) => setTimeout(resolve, 500)); + return this.makeRequest( + vertexIndex, + endpoint, + method, + body, + retryCount + 1 + ); + } + + throw new Error("Empty response from server"); + } // Parse the JSON response try { const data = JSON.parse(responseText); + console.log(`[Sodot] Parsed JSON from vertex ${vertexIndex}:`, data); return data; } catch (e) { - console.error("Failed to parse response as JSON:", e); - console.error("Raw response:", responseText); + console.error( + `[Sodot] Failed to parse response as JSON from vertex ${vertexIndex}:`, + e + ); + console.error( + `[Sodot] Raw response that failed to parse:`, + responseText + ); + + // Retry up to 3 times on JSON parse error + if (retryCount < 3) { + console.log( + `[Sodot] Retrying request to vertex ${vertexIndex} (${ + retryCount + 1 + }/3)` + ); + // Wait a short time before retrying + await new Promise((resolve) => setTimeout(resolve, 500)); + return this.makeRequest( + vertexIndex, + endpoint, + method, + body, + retryCount + 1 + ); + } + throw new Error("Invalid JSON response from server"); } } catch (error: any) { - console.error("Request error:", error); + console.error(`[Sodot] Request error for vertex ${vertexIndex}:`, error); + + // Retry up to 3 times on network errors + if (error.message.includes("Failed to fetch") && retryCount < 3) { + console.log( + `[Sodot] Retrying request to vertex ${vertexIndex} (${ + retryCount + 1 + }/3)` + ); + // Wait a short time before retrying + await new Promise((resolve) => setTimeout(resolve, 500)); + return this.makeRequest( + vertexIndex, + endpoint, + method, + body, + retryCount + 1 + ); + } + throw new Error( `Failed to make request to vertex ${vertexIndex}: ${error.message}` ); @@ -129,24 +293,103 @@ export class SodotSigner { } public async getPubkey(): Promise { - if (this.keyIds.length === 0) { - throw new Error( - "No key IDs available. Please set the appropriate environment variables." + try { + if (this.keyIds.length === 0) { + throw new Error("No key IDs available"); + } + + const keyId = this.keyIds[0]; + const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); + + console.log( + `[Sodot] Getting pubkey for chain ${this.chainId} with curve ${curve}, keyId: ${keyId}` ); - } - const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); - const response = await this.makeRequest( - 0, - `/${curve}/derive-pubkey`, - "POST", - { - key_id: this.keyIds[0], - derivation_path: [44, Number(this.signerSpec.coinType), 0, 0, 0], + // Make a POST request directly to the API route + const response = await fetch("/api/sodot", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + vertexIndex: 0, + endpoint: `/${curve}/derive-pubkey`, + method: "POST", + body: { + key_id: keyId, + derivation_path: [44, Number(this.signerSpec.coinType), 0, 0, 0], + }, + }), + }); + + // Get the response text + const responseText = await response.text(); + console.log(`[Sodot] Raw response:`, responseText); + + // If the response is empty, throw an error + if (!responseText || responseText.trim() === "") { + throw new Error("Empty response received"); } - ); - return response.pubkey; + // Parse the response + try { + const data = JSON.parse(responseText); + console.log(`[Sodot] Parsed response:`, data); + + // Handle different response formats based on curve + if (curve === "ed25519" && data.pubkey) { + // For ED25519, return the pubkey directly + console.log(`[Sodot] Using ED25519 pubkey:`, data.pubkey); + return data.pubkey; + } else if (curve === "ecdsa") { + // For ECDSA, use the appropriate format based on the chain + if ( + this.chainId === "ethereum" || + this.chainId === "base" || + this.chainId === "arbitrum" || + this.chainId === "linea" + ) { + // Ethereum and EVM chains need uncompressed format + if (data.uncompressed) { + console.log( + `[Sodot] Using uncompressed pubkey for EVM chain:`, + data.uncompressed + ); + return data.uncompressed; + } + } else { + // Non-EVM chains like Bitcoin use compressed format + if (data.compressed) { + console.log( + `[Sodot] Using compressed pubkey for non-EVM chain:`, + data.compressed + ); + return data.compressed; + } + } + } + + // Fallback handling for any format + if (data.pubkey) { + console.log(`[Sodot] Using pubkey:`, data.pubkey); + return data.pubkey; + } else if (data.compressed) { + console.log(`[Sodot] Using compressed pubkey:`, data.compressed); + return data.compressed; + } else if (data.uncompressed) { + console.log(`[Sodot] Using uncompressed pubkey:`, data.uncompressed); + return data.uncompressed; + } else { + throw new Error(`Unknown response format: ${JSON.stringify(data)}`); + } + } catch (e: any) { + console.error(`[Sodot] Failed to parse JSON:`, e); + throw new Error(`Failed to parse response as JSON: ${e.message}`); + } + } catch (error: any) { + console.error(`[Sodot] Error getting pubkey:`, error); + throw new Error(`Failed to get pubkey: ${error.message}`); + } } public async signTransaction(encodedMessage: string): Promise { @@ -168,7 +411,48 @@ export class SodotSigner { ) ); - // Return the first signature (they should all be identical) - return signatures[0].signature; + // Handle different signature formats + const signature = signatures[0]; // Use the first signature (they should all be identical) + + if ("signature" in signature) { + return signature.signature; + } else if ("r" in signature && "s" in signature) { + // For ECDSA signatures + const format = this.signerSpec.signatureFormat; + if (format === "der") { + return signature.der; + } else { + // Handle other formats as needed + return `${signature.r}${signature.s}${signature.v.toString(16)}`; + } + } + + // Fallback for unknown formats + return JSON.stringify(signature); + } + + public async getAddress(): Promise { + console.log(`[Sodot] Starting getAddress for chain ${this.chainId}`); + + try { + // First get the pubkey + const pubkey = await this.getPubkey(); + console.log(`[Sodot] Got pubkey for chain ${this.chainId}:`, pubkey); + + // Use the Adamik API to encode the pubkey to an address + console.log( + `[Sodot] Calling encodePubKeyToAddress for chain ${this.chainId} with pubkey ${pubkey}` + ); + + const { address } = await encodePubKeyToAddress(pubkey, this.chainId); + console.log( + `[Sodot] Successfully derived address for chain ${this.chainId}:`, + address + ); + return address; + } catch (error: any) { + console.error(`[Sodot] Error getting address:`, error); + throw new Error(`Failed to get address: ${error.message}`); + } } } From 24b3ca95d3221a6ec02113862a2c9c11a0c93ba0 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 21:34:35 +0200 Subject: [PATCH 003/146] fixed communication issue by re-org nextjs folders --- src/adamik/types.ts | 2 + src/app/api/sodot/pubkey/route.ts | 239 --------- src/app/api/sodot/route.ts | 212 -------- src/app/api/sodot/test-connection/route.ts | 197 -------- src/app/api/sodot/test/route.ts | 148 ------ src/app/sodot-test/page.tsx | 152 ------ src/app/sodot-tutorial-test/page.tsx | 392 +++++++++++++++ src/components/layout/Menu/Menu.tsx | 6 + src/pages/api/sodot-proxy/[...path].ts | 102 ++++ src/pages/api/sodot-proxy/get-keys.ts | 91 ++++ src/signers/Sodot.ts | 554 +++++++++------------ 11 files changed, 832 insertions(+), 1263 deletions(-) delete mode 100644 src/app/api/sodot/pubkey/route.ts delete mode 100644 src/app/api/sodot/route.ts delete mode 100644 src/app/api/sodot/test-connection/route.ts delete mode 100644 src/app/api/sodot/test/route.ts delete mode 100644 src/app/sodot-test/page.tsx create mode 100644 src/app/sodot-tutorial-test/page.tsx create mode 100644 src/pages/api/sodot-proxy/[...path].ts create mode 100644 src/pages/api/sodot-proxy/get-keys.ts diff --git a/src/adamik/types.ts b/src/adamik/types.ts index 1dd9e8a6..f1339b1e 100644 --- a/src/adamik/types.ts +++ b/src/adamik/types.ts @@ -1,3 +1,5 @@ +//TODO, move to utils/types.ts + export enum AdamikCurve { SECP256K1 = "secp256k1", ED25519 = "ed25519", diff --git a/src/app/api/sodot/pubkey/route.ts b/src/app/api/sodot/pubkey/route.ts deleted file mode 100644 index f5346071..00000000 --- a/src/app/api/sodot/pubkey/route.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { NextRequest } from "next/server"; - -const vertices = [ - { - url: - process.env.SODOT_VERTEX_URL_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, - apiKey: - process.env.SODOT_VERTEX_API_KEY_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || - "", - }, - { - url: - process.env.SODOT_VERTEX_URL_1 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1, - apiKey: - process.env.SODOT_VERTEX_API_KEY_1 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || - "", - }, - { - url: - process.env.SODOT_VERTEX_URL_2 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2, - apiKey: - process.env.SODOT_VERTEX_API_KEY_2 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || - "", - }, -]; - -const corsHeaders = { - "Content-Type": "application/json", - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - -export async function POST(request: NextRequest) { - let responseClone; - - try { - console.log("[PubkeyAPI] Request received"); - const { curve, keyId, derivationPath } = await request.json(); - - if (!curve || !keyId || !derivationPath) { - return new Response( - JSON.stringify({ - error: "Missing required parameters", - }), - { - status: 400, - headers: corsHeaders, - } - ); - } - - // Use vertex 0 for all pubkey requests - const vertex = vertices[0]; - if (!vertex || !vertex.url) { - return new Response( - JSON.stringify({ - error: "Vertex configuration is missing", - }), - { - status: 500, - headers: corsHeaders, - } - ); - } - - // Construct the full URL to the SODOT vertex - const targetUrl = `${vertex.url}/${curve}/derive-pubkey`; - console.log(`[PubkeyAPI] Proxying request to: ${targetUrl}`); - - // Forward the request to the SODOT vertex - const response = await fetch(targetUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: vertex.apiKey || "", - }, - body: JSON.stringify({ - key_id: keyId, - derivation_path: derivationPath, - }), - }); - - // Clone the response for debugging - responseClone = response.clone(); - - // Log response details for debugging - console.log(`[PubkeyAPI] Response status: ${response.status}`); - - // Get the response text - const responseText = await response.text(); - console.log(`[PubkeyAPI] Response text:`, responseText); - - if (!response.ok) { - return new Response( - JSON.stringify({ - error: "Request failed", - status: response.status, - details: responseText, - }), - { - status: response.status, - headers: corsHeaders, - } - ); - } - - // Check if the response is empty - if (!responseText || responseText.trim() === "") { - return new Response( - JSON.stringify({ - error: "Empty response from vertex", - }), - { - status: 500, - headers: corsHeaders, - } - ); - } - - // Parse the response as JSON - try { - const parsedResponse = JSON.parse(responseText); - console.log( - "[PubkeyAPI] Parsed response:", - JSON.stringify(parsedResponse) - ); - - // Check if the response has a 'pubkey' field - if (parsedResponse.pubkey) { - // Transform the response based on the curve type - if (curve === "ed25519") { - // For ED25519, use pubkey as both compressed and uncompressed - const transformedResponse = { - compressed: parsedResponse.pubkey, - uncompressed: parsedResponse.pubkey, - pubkey: parsedResponse.pubkey, - }; - - console.log( - "[PubkeyAPI] Transformed ED25519 response:", - JSON.stringify(transformedResponse) - ); - - // Return the transformed response with proper headers - return new Response(JSON.stringify(transformedResponse), { - status: 200, - headers: corsHeaders, - }); - } else { - // For other curves, if they only have pubkey (unlikely), still transform - const transformedResponse = { - compressed: parsedResponse.pubkey, - uncompressed: parsedResponse.pubkey, - pubkey: parsedResponse.pubkey, - }; - - console.log( - "[PubkeyAPI] Transformed response:", - JSON.stringify(transformedResponse) - ); - - return new Response(JSON.stringify(transformedResponse), { - status: 200, - headers: corsHeaders, - }); - } - } - - // If it has compressed/uncompressed fields, use those directly - if (parsedResponse.compressed) { - return new Response(JSON.stringify(parsedResponse), { - status: 200, - headers: corsHeaders, - }); - } - - // Unexpected response format - return new Response( - JSON.stringify({ - error: "Unexpected response format", - details: parsedResponse, - }), - { - status: 500, - headers: corsHeaders, - } - ); - } catch (e) { - console.error("[PubkeyAPI] Failed to parse response as JSON:", e); - - return new Response( - JSON.stringify({ - error: "Invalid JSON response", - details: responseText, - }), - { - status: 500, - headers: corsHeaders, - } - ); - } - } catch (error: any) { - console.error("[PubkeyAPI] Error:", error); - - return new Response( - JSON.stringify({ - error: "Internal server error", - message: error.message, - }), - { - status: 500, - headers: corsHeaders, - } - ); - } -} - -// Handle OPTIONS request for CORS -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }, - }); -} diff --git a/src/app/api/sodot/route.ts b/src/app/api/sodot/route.ts deleted file mode 100644 index 62a2cd71..00000000 --- a/src/app/api/sodot/route.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { NextRequest } from "next/server"; - -const vertices = [ - { - url: - process.env.SODOT_VERTEX_URL_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, - apiKey: - process.env.SODOT_VERTEX_API_KEY_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || - "", - }, - { - url: - process.env.SODOT_VERTEX_URL_1 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1, - apiKey: - process.env.SODOT_VERTEX_API_KEY_1 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || - "", - }, - { - url: - process.env.SODOT_VERTEX_URL_2 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2, - apiKey: - process.env.SODOT_VERTEX_API_KEY_2 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || - "", - }, -]; - -export async function POST(request: NextRequest) { - let responseClone; // For debugging - - try { - console.log("API route received request"); - const requestBody = await request.json(); - console.log("Request body:", JSON.stringify(requestBody)); - - const { vertexIndex, endpoint, method, body } = requestBody; - - const vertex = vertices[vertexIndex]; - if (!vertex) { - console.error(`Vertex ${vertexIndex} not found`); - return new Response(JSON.stringify({ error: "Vertex not found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); - } - - // Construct the full URL to the SODOT vertex - const targetUrl = `${vertex.url}${endpoint}`; - console.log(`Proxying request to: ${targetUrl}`); - - // Forward the request to the SODOT vertex - const response = await fetch(targetUrl, { - method, - headers: { - "Content-Type": "application/json", - Authorization: vertex.apiKey || "", - }, - body: body ? JSON.stringify(body) : undefined, - }); - - // Clone the response for debugging - responseClone = response.clone(); - - // Log response details for debugging - console.log(`Response status: ${response.status}`); - console.log( - `Response headers:`, - Object.fromEntries(response.headers.entries()) - ); - - // Get the response text - const responseText = await response.text(); - console.log(`Response text:`, responseText); - - if (!response.ok) { - console.error(`Request failed with status ${response.status}`); - return new Response( - JSON.stringify({ - error: "Request failed", - status: response.status, - details: responseText, - }), - { - status: response.status, - headers: { "Content-Type": "application/json" }, - } - ); - } - - // Check if the response is empty - if (!responseText || responseText.trim() === "") { - console.error("Empty response from vertex"); - return new Response( - JSON.stringify({ - error: "Empty response from vertex", - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - } - ); - } - - // Parse the response as JSON - try { - const parsedResponse = JSON.parse(responseText); - console.log("Parsed response:", JSON.stringify(parsedResponse)); - - // Check if the response has a 'pubkey' field (Sodot vertex format) - if (parsedResponse.pubkey) { - // Transform the response based on the curve type - // For ED25519, the vertex only returns a 'pubkey' field - // For ECDSA, it returns 'compressed' and 'uncompressed' - const isEd25519 = endpoint.includes("/ed25519/"); - - if (isEd25519) { - // For ED25519, use the pubkey as both compressed and uncompressed - const transformedResponse = { - compressed: parsedResponse.pubkey, - uncompressed: parsedResponse.pubkey, - // Preserve the original pubkey field as well - pubkey: parsedResponse.pubkey, - }; - - console.log( - "Transformed ED25519 response:", - JSON.stringify(transformedResponse) - ); - - // Return the transformed response with proper headers - return new Response(JSON.stringify(transformedResponse), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - }); - } else { - // For other curves, just pass through the pubkey - return new Response(JSON.stringify(parsedResponse), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - }); - } - } - - // If it doesn't have a 'pubkey' field, return the original response - return new Response(JSON.stringify(parsedResponse), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - }); - } catch (e) { - console.error("Failed to parse response as JSON:", e); - console.error("Raw response text:", responseText); - return new Response( - JSON.stringify({ - error: "Invalid JSON response", - details: responseText, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - } - ); - } - } catch (error: any) { - console.error("Sodot API error:", error); - - // If it's a fetch error and we have the response clone, try to get more info - if (responseClone) { - try { - const cloneText = await responseClone.text(); - console.error("Response clone text:", cloneText); - } catch (e) { - console.error("Failed to read response clone:", e); - } - } - - return new Response( - JSON.stringify({ - error: "Internal server error", - message: error.message, - }), - { - status: 500, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - } - ); - } -} diff --git a/src/app/api/sodot/test-connection/route.ts b/src/app/api/sodot/test-connection/route.ts deleted file mode 100644 index 5878bc16..00000000 --- a/src/app/api/sodot/test-connection/route.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { NextRequest } from "next/server"; - -const vertices = [ - { - url: - process.env.SODOT_VERTEX_URL_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, - apiKey: - process.env.SODOT_VERTEX_API_KEY_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || - "", - }, -]; - -const corsHeaders = { - "Content-Type": "application/json", - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - -export async function GET(request: NextRequest) { - console.log("[ConnectionTest] Request received"); - - try { - // Check environment variables - const envStatus = { - serverSide: { - SODOT_VERTEX_URL_0: process.env.SODOT_VERTEX_URL_0 ? "Set" : "Missing", - SODOT_VERTEX_API_KEY_0: process.env.SODOT_VERTEX_API_KEY_0 - ? "Set" - : "Missing", - SODOT_EXISTING_ECDSA_KEY_IDS: - process.env.SODOT_EXISTING_ECDSA_KEY_IDS?.split(",").length || 0, - SODOT_EXISTING_ED25519_KEY_IDS: - process.env.SODOT_EXISTING_ED25519_KEY_IDS?.split(",").length || 0, - }, - clientSide: { - NEXT_PUBLIC_SODOT_VERTEX_URL_0: process.env - .NEXT_PUBLIC_SODOT_VERTEX_URL_0 - ? "Set" - : "Missing", - NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0: process.env - .NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 - ? "Set" - : "Missing", - NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS: - process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS?.split(",") - .length || 0, - NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS: - process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS?.split(",") - .length || 0, - }, - }; - - console.log("[ConnectionTest] Environment status:", envStatus); - - // Check if we have a valid vertex - const vertex = vertices[0]; - if (!vertex || !vertex.url) { - return new Response( - JSON.stringify({ - success: false, - error: "Vertex configuration is missing", - envStatus: envStatus, - }), - { - status: 500, - headers: corsHeaders, - } - ); - } - - // Try to connect to the Sodot vertex health endpoint - const healthUrl = `${vertex.url}/health`; - console.log(`[ConnectionTest] Testing connection to: ${healthUrl}`); - - const response = await fetch(healthUrl, { - method: "GET", - headers: { - Accept: "application/json", - }, - }); - - const status = response.status; - console.log(`[ConnectionTest] Health check status: ${status}`); - - let responseBody; - try { - responseBody = await response.text(); - console.log(`[ConnectionTest] Health check response: ${responseBody}`); - } catch (e) { - responseBody = "Could not read response body"; - console.error(`[ConnectionTest] Error reading response: ${e}`); - } - - // Now try to get a pubkey for verification - console.log("[ConnectionTest] Testing pubkey retrieval"); - - const ecdsaKeyId = ( - process.env.SODOT_EXISTING_ECDSA_KEY_IDS || - process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || - "" - ).split(",")[0]; - - let pubkeyStatus = "Not tested"; - let pubkeyData = null; - - if (ecdsaKeyId) { - try { - const pubkeyUrl = `${vertex.url}/ecdsa/derive-pubkey`; - console.log(`[ConnectionTest] Testing pubkey at: ${pubkeyUrl}`); - - const pubkeyResponse = await fetch(pubkeyUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: vertex.apiKey || "", - }, - body: JSON.stringify({ - key_id: ecdsaKeyId, - derivation_path: [44, 60, 0, 0, 0], - }), - }); - - const pubkeyStatus = pubkeyResponse.status; - console.log(`[ConnectionTest] Pubkey status: ${pubkeyStatus}`); - - const pubkeyText = await pubkeyResponse.text(); - console.log(`[ConnectionTest] Pubkey response: ${pubkeyText}`); - - if (pubkeyText && pubkeyText.trim() !== "") { - try { - pubkeyData = JSON.parse(pubkeyText); - } catch (e) { - console.error(`[ConnectionTest] Error parsing pubkey JSON: ${e}`); - } - } - } catch (e: any) { - pubkeyStatus = `Error: ${e.message}`; - console.error(`[ConnectionTest] Pubkey error: ${e}`); - } - } else { - pubkeyStatus = "No ECDSA key ID available"; - } - - // Return the results - return new Response( - JSON.stringify({ - success: status === 200, - vertex: { - url: vertex.url, - hasApiKey: !!vertex.apiKey, - }, - health: { - status: status, - response: responseBody, - }, - pubkey: { - status: pubkeyStatus, - keyId: ecdsaKeyId || "None", - data: pubkeyData, - }, - envStatus: envStatus, - }), - { - status: 200, - headers: corsHeaders, - } - ); - } catch (error: any) { - console.error("[ConnectionTest] Error:", error); - - return new Response( - JSON.stringify({ - success: false, - error: "Connection test failed", - message: error.message, - }), - { - status: 500, - headers: corsHeaders, - } - ); - } -} - -// Handle OPTIONS request for CORS -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: corsHeaders, - }); -} diff --git a/src/app/api/sodot/test/route.ts b/src/app/api/sodot/test/route.ts deleted file mode 100644 index f2b90349..00000000 --- a/src/app/api/sodot/test/route.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { NextRequest } from "next/server"; - -const vertices = [ - { - url: - process.env.SODOT_VERTEX_URL_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, - apiKey: - process.env.SODOT_VERTEX_API_KEY_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || - "", - }, -]; - -const corsHeaders = { - "Content-Type": "application/json", - "Cache-Control": "no-cache, no-store, must-revalidate", - Pragma: "no-cache", - Expires: "0", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", -}; - -export async function POST(request: NextRequest) { - console.log("[TestAPI] Request received"); - - try { - const { curve, keyId, derivationPath } = await request.json(); - - if (!curve || !keyId || !derivationPath) { - return new Response( - JSON.stringify({ - error: "Missing required parameters", - }), - { - status: 400, - headers: corsHeaders, - } - ); - } - - // Use vertex 0 for testing - const vertex = vertices[0]; - if (!vertex || !vertex.url) { - return new Response( - JSON.stringify({ - error: "Vertex configuration is missing", - env: { - vertex0Url: process.env.SODOT_VERTEX_URL_0 ? "Set" : "Missing", - vertex0ApiKey: process.env.SODOT_VERTEX_API_KEY_0 - ? "Set" - : "Missing", - }, - }), - { - status: 500, - headers: corsHeaders, - } - ); - } - - // Construct the full URL to the SODOT vertex - const targetUrl = `${vertex.url}/${curve}/derive-pubkey`; - console.log(`[TestAPI] Making request to: ${targetUrl}`); - console.log(`[TestAPI] Request body:`, { - key_id: keyId, - derivation_path: derivationPath, - }); - - // Forward the request to the SODOT vertex - const response = await fetch(targetUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: vertex.apiKey || "", - }, - body: JSON.stringify({ - key_id: keyId, - derivation_path: derivationPath, - }), - }); - - // Log response details for debugging - console.log(`[TestAPI] Response status: ${response.status}`); - console.log( - `[TestAPI] Response headers:`, - Object.fromEntries(response.headers.entries()) - ); - - // Get the response text - const responseText = await response.text(); - console.log(`[TestAPI] Raw response text:`, responseText); - - // Return detailed information about the request and response - return new Response( - JSON.stringify( - { - request: { - url: targetUrl, - body: { - key_id: keyId, - derivation_path: derivationPath, - }, - }, - response: { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - body: responseText - ? responseText.startsWith("{") - ? JSON.parse(responseText) - : responseText - : null, - rawText: responseText, - }, - }, - null, - 2 - ), - { - status: 200, - headers: corsHeaders, - } - ); - } catch (error: any) { - console.error("[TestAPI] Error:", error); - - return new Response( - JSON.stringify({ - error: "Internal server error", - message: error.message, - stack: error.stack, - }), - { - status: 500, - headers: corsHeaders, - } - ); - } -} - -// Handle OPTIONS request for CORS -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: corsHeaders, - }); -} diff --git a/src/app/sodot-test/page.tsx b/src/app/sodot-test/page.tsx deleted file mode 100644 index 10e8f431..00000000 --- a/src/app/sodot-test/page.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; - -export default function SodotTestPage() { - const [loading, setLoading] = useState(false); - const [connectionResults, setConnectionResults] = useState | null>(null); - const [error, setError] = useState(null); - - const testConnection = async () => { - setLoading(true); - setError(null); - try { - const response = await fetch("/api/sodot/test-connection"); - const data = await response.json(); - setConnectionResults(data); - } catch (e: any) { - setError(e.message || "Unknown error occurred"); - console.error("Error testing connection:", e); - } finally { - setLoading(false); - } - }; - - const testEcdsaPubkey = async () => { - setLoading(true); - setError(null); - try { - const keyId = - connectionResults?.envStatus?.serverSide?.SODOT_EXISTING_ECDSA_KEY_IDS > - 0 - ? process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS?.split(",")[0] - : "8306e478-e39f-4e68-9c87-fdf9bfa6d1ad"; - - const response = await fetch("/api/sodot/pubkey", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - curve: "ecdsa", - keyId: keyId, - derivationPath: [44, 60, 0, 0, 0], - }), - }); - - const data = await response.json(); - setConnectionResults((prev: Record | null) => ({ - ...(prev || {}), - ecdsaPubkeyTest: { - keyId, - data, - }, - })); - } catch (e: any) { - setError(e.message || "Unknown error occurred"); - console.error("Error testing ECDSA pubkey:", e); - } finally { - setLoading(false); - } - }; - - const testEd25519Pubkey = async () => { - setLoading(true); - setError(null); - try { - const keyId = - connectionResults?.envStatus?.serverSide - ?.SODOT_EXISTING_ED25519_KEY_IDS > 0 - ? process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS?.split( - "," - )[0] - : "868a7bea-a410-40d3-a03a-ea06200f9fe6"; - - const response = await fetch("/api/sodot/pubkey", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - curve: "ed25519", - keyId: keyId, - derivationPath: [44, 118, 0, 0, 0], - }), - }); - - const data = await response.json(); - setConnectionResults((prev: Record | null) => ({ - ...(prev || {}), - ed25519PubkeyTest: { - keyId, - data, - }, - })); - } catch (e: any) { - setError(e.message || "Unknown error occurred"); - console.error("Error testing ED25519 pubkey:", e); - } finally { - setLoading(false); - } - }; - - return ( -
- - - Sodot Connection Test - - -
- - - {connectionResults && ( - <> - - - - )} -
- - {error && ( -
- {error} -
- )} - - {connectionResults && ( -
-

Connection Results

-
-
-                  {JSON.stringify(connectionResults, null, 2)}
-                
-
-
- )} -
-
-
- ); -} diff --git a/src/app/sodot-tutorial-test/page.tsx b/src/app/sodot-tutorial-test/page.tsx new file mode 100644 index 00000000..d3a2da84 --- /dev/null +++ b/src/app/sodot-tutorial-test/page.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { SodotSigner } from "~/signers/Sodot"; +import { + AdamikCurve, + AdamikHashFunction, + AdamikSignerSpec, +} from "~/adamik/types"; +import { AlertCircle, CheckCircle2, Loader2, Lock, Server } from "lucide-react"; + +type VertexKeysResult = { + status?: number; + data?: { + vertices: Array<{ + id: number; + status: number; + compressed?: string; + uncompressed?: string; + error?: string; + }>; + }; + error?: string; + message?: string; +}; + +type ChainPubkeyResult = { + pubkey: string; + address: string; + chainId: string; + curve: string; +}; + +type Results = { + vertexKeys?: VertexKeysResult; + chainPubkey?: ChainPubkeyResult; +}; + +export default function SodotTutorialTestPage() { + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const testEthereumPubkey = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + // Create ethereum signer spec + const signerSpec: AdamikSignerSpec = { + curve: AdamikCurve.SECP256K1, + coinType: "60", // Ethereum + hashFunction: AdamikHashFunction.KECCAK256, + signatureFormat: "r|s|v", + }; + + // Initialize the Sodot signer + const signer = new SodotSigner("ethereum", signerSpec); + + // Get the pubkey + const pubkey = await signer.getPubkey(); + + // Get the address (which calls the Adamik API) + const address = await signer.getAddress(); + + // Store the results + setResults( + (prev: Results | null): Results => ({ + ...prev, + chainPubkey: { + pubkey, + address, + chainId: "ethereum", + curve: "SECP256K1", + }, + }) + ); + setSuccess("Successfully retrieved Ethereum pubkey and address"); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + console.error("Error testing Ethereum pubkey:", e); + } finally { + setLoading(false); + } + }; + + const testBitcoinPubkey = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + // Create bitcoin signer spec + const signerSpec: AdamikSignerSpec = { + curve: AdamikCurve.SECP256K1, + coinType: "0", // Bitcoin + hashFunction: AdamikHashFunction.SHA256, + signatureFormat: "der", + }; + + // Initialize the Sodot signer + const signer = new SodotSigner("bitcoin", signerSpec); + + // Get the pubkey + const pubkey = await signer.getPubkey(); + + // Get the address (which calls the Adamik API) + const address = await signer.getAddress(); + + // Store the results + setResults( + (prev: Results | null): Results => ({ + ...prev, + chainPubkey: { + pubkey, + address, + chainId: "bitcoin", + curve: "SECP256K1", + }, + }) + ); + setSuccess("Successfully retrieved Bitcoin pubkey and address"); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + console.error("Error testing Bitcoin pubkey:", e); + } finally { + setLoading(false); + } + }; + + const testSolanaPubkey = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + // Create solana signer spec + const signerSpec: AdamikSignerSpec = { + curve: AdamikCurve.ED25519, + coinType: "501", // Solana + hashFunction: AdamikHashFunction.SHA256, + signatureFormat: "signature", + }; + + // Initialize the Sodot signer + const signer = new SodotSigner("solana", signerSpec); + + // Get the pubkey + const pubkey = await signer.getPubkey(); + + // Get the address (which calls the Adamik API) + const address = await signer.getAddress(); + + // Store the results + setResults( + (prev: Results | null): Results => ({ + ...prev, + chainPubkey: { + pubkey, + address, + chainId: "solana", + curve: "ED25519", + }, + }) + ); + setSuccess("Successfully retrieved Solana pubkey and address"); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + console.error("Error testing Solana pubkey:", e); + } finally { + setLoading(false); + } + }; + + const getVertexKeys = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + const response = await fetch("/api/sodot-proxy/get-keys", { + method: "GET", + cache: "no-store", + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.message || data.error); + } + + setResults( + (prev: Results | null): Results => ({ ...prev, vertexKeys: data }) + ); + setSuccess("Successfully retrieved vertex keys"); + } catch (e: any) { + setError(e.message || "Failed to get keys"); + console.error("Error getting vertex keys:", e); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Sodot MPC Signing Demo

+

+ Test the Sodot secure signing integration with various blockchains +

+
+ + + + Connection Test + + +
+ + + + +
+ + {error && ( +
+ +

{error}

+
+ )} + + {success && ( +
+ +

{success}

+
+ )} + + {results && ( +
+ {results.chainPubkey && ( +
+

Pubkey Results:

+
Chain: {results.chainPubkey.chainId}
+
Curve: {results.chainPubkey.curve}
+
+ Pubkey: {results.chainPubkey.pubkey} +
+
+ Address: {results.chainPubkey.address} +
+
+ )} + + {results.vertexKeys && + results.vertexKeys.data && + results.vertexKeys.data.vertices && + results.vertexKeys.data.vertices.length > 0 && ( +
+

Vertex Keys:

+
+ {results.vertexKeys.data.vertices.map((vertex) => ( +
+

+ Vertex {vertex.id} +

+
Status: {vertex.status}
+ {vertex.error && ( +
+ Error: {vertex.error} +
+ )} + {vertex.compressed && ( +
+ Compressed:{" "} + {vertex.compressed} +
+ )} + {vertex.uncompressed && ( +
+ + Uncompressed: + {" "} + {vertex.uncompressed} +
+ )} +
+ ))} +
+
+ )} +
+ )} +
+
+ + + + How It Works + + +

+ This demo showcases Threshold Signature Scheme (TSS) integration + using Sodot's secure MPC protocol. The implementation features: +

+
    +
  • +
    + + Server-side proxy to securely handle API keys and key IDs +
    +
  • +
  • +
    + + No sensitive environment variables on the client +
    +
  • +
  • Multi-party computation for key generation and signing
  • +
  • + Threshold security (t-of-n) where at least 2 of 3 parties must + participate +
  • +
  • + Support for multiple blockchain cryptography (ECDSA for + Bitcoin/Ethereum, ED25519 for Solana) +
  • +
  • Integration with Adamik API for address derivation
  • +
+
+
+
+
+ ); +} diff --git a/src/components/layout/Menu/Menu.tsx b/src/components/layout/Menu/Menu.tsx index 62318a33..3e09f8a9 100644 --- a/src/components/layout/Menu/Menu.tsx +++ b/src/components/layout/Menu/Menu.tsx @@ -6,6 +6,7 @@ import { SquareStack, Search, History, + KeyRound, } from "lucide-react"; import { useTheme } from "next-themes"; import { MobileMenu } from "./MobileMenu"; @@ -39,6 +40,11 @@ const menu = [ icon: SquareStack, href: "/supported-chains", }, + { + title: "Sodot Test", + icon: KeyRound, + href: "/sodot-tutorial-test", + }, ]; export type MenuItem = (typeof menu)[0]; diff --git a/src/pages/api/sodot-proxy/[...path].ts b/src/pages/api/sodot-proxy/[...path].ts new file mode 100644 index 00000000..c16455da --- /dev/null +++ b/src/pages/api/sodot-proxy/[...path].ts @@ -0,0 +1,102 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + // Extract vertex number from query parameter + const { vertex } = req.query; + const path = req.query.path as string[]; + + if (!vertex) { + return res.status(400).json({ error: "Missing vertex parameter" }); + } + + // Get the path from the URL path segments + const pathStr = path ? `/${path.join("/")}` : ""; + console.log(`Path segments:`, path); + console.log(`Constructed path: ${pathStr}`); + + // Special handling for health check + if (pathStr === "/health") { + return res.status(200).send("OK"); + } + + // Get the environment variables for the vertex + const vertexUrl = + process.env[`VITE_SODOT_VERTEX_URL_${vertex}`] || + process.env[`SODOT_VERTEX_URL_${vertex}`]; + const apiKey = + process.env[`VITE_SODOT_VERTEX_API_KEY_${vertex}`] || + process.env[`SODOT_VERTEX_API_KEY_${vertex}`]; + + if (!vertexUrl || !apiKey) { + return res.status(500).json({ + error: `Missing environment variables for vertex ${vertex}`, + details: { + url: vertexUrl ? "Set" : "Missing", + apiKey: apiKey ? "Set" : "Missing", + }, + }); + } + + // Construct the full URL to the SODOT vertex + const targetUrl = new URL(`${vertexUrl}${pathStr}`); + + // Copy all query parameters except 'vertex' and 'path' + Object.entries(req.query).forEach(([key, value]) => { + if (key !== "vertex" && key !== "path") { + targetUrl.searchParams.append(key, value as string); + } + }); + + console.log(`Proxying request to: ${targetUrl.toString()}`); + console.log(`Request method: ${req.method}`); + if (req.body) { + console.log(`Request body:`, req.body); + } + + // Forward the request to the SODOT vertex + const response = await fetch(targetUrl.toString(), { + method: req.method || "GET", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + }, + body: req.body ? JSON.stringify(req.body) : undefined, + }); + + const contentType = response.headers.get("content-type"); + + try { + if (contentType?.includes("application/json")) { + const data = await response.json(); + return res.status(response.status).json({ + status: response.status, + data, + }); + } else { + const text = await response.text(); + return res.status(response.status).json({ + status: response.status, + data: text, + }); + } + } catch (e: any) { + console.error("Error reading response:", e); + return res.status(500).json({ + status: 500, + error: "Error reading response", + message: e.message, + }); + } + } catch (error: any) { + console.error("Proxy error:", error); + return res.status(500).json({ + error: "Internal Server Error", + message: error.message, + stack: error.stack, + }); + } +} diff --git a/src/pages/api/sodot-proxy/get-keys.ts b/src/pages/api/sodot-proxy/get-keys.ts new file mode 100644 index 00000000..2dee500c --- /dev/null +++ b/src/pages/api/sodot-proxy/get-keys.ts @@ -0,0 +1,91 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + // First check if we have the key IDs + const keyIds = process.env.SODOT_EXISTING_ECDSA_KEY_IDS?.split(","); + + if (!keyIds || keyIds.length < 3) { + return res.status(500).json({ + status: 500, + error: "Missing key IDs", + message: "No ECDSA key IDs found in environment variables", + }); + } + + // Make parallel requests to all vertices + const vertexPromises = [0, 1, 2].map(async (vertexId) => { + try { + const keyId = keyIds[vertexId]; + if (!keyId) { + throw new Error(`No key ID found for vertex ${vertexId}`); + } + + const response = await fetch( + `${ + process.env.NEXT_PUBLIC_VERCEL_URL || + req.headers.origin || + "http://localhost:3000" + }/api/sodot-proxy/ecdsa/derive-pubkey?vertex=${vertexId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key_id: keyId, + derivation_path: [44, 60, 0, 0, 0], + }), + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + if (!result || !result.data) { + throw new Error("Invalid response format"); + } + + return { + vertexId, + status: response.status, + compressed: result.data.compressed, + uncompressed: result.data.uncompressed, + }; + } catch (e: any) { + console.error(`Error getting keys from vertex ${vertexId}:`, e); + return { + vertexId, + status: 500, + error: e.message, + }; + } + }); + + const results = await Promise.all(vertexPromises); + + return res.status(200).json({ + status: 200, + data: { + vertices: results.map((result) => ({ + id: result.vertexId, + status: result.status, + compressed: result.compressed, + uncompressed: result.uncompressed, + error: result.error, + })), + }, + }); + } catch (error: any) { + return res.status(500).json({ + status: 500, + error: "Failed to get keys", + message: error.message, + }); + } +} diff --git a/src/signers/Sodot.ts b/src/signers/Sodot.ts index 5fc2917c..cf9e50cf 100644 --- a/src/signers/Sodot.ts +++ b/src/signers/Sodot.ts @@ -26,132 +26,50 @@ const ensureUrlFormat = (url: string): string => { export class SodotSigner { private chainId: string; private signerSpec: AdamikSignerSpec; - private vertices: Array<{ url: string; apiKey: string }>; + private n = 3; + private t = 2; private keyIds: string[] = []; + // Define vertices based on the environment - all API calls go through the proxy + private SODOT_VERTICES = [ + { + url: "/api/sodot-proxy", + vertexParam: "0", + }, + { + url: "/api/sodot-proxy", + vertexParam: "1", + }, + { + url: "/api/sodot-proxy", + vertexParam: "2", + }, + ]; + constructor(chainId: string, signerSpec: AdamikSignerSpec) { this.chainId = chainId; this.signerSpec = signerSpec; - // Initialize vertices from environment variables - // First try server-side env vars (preferred), then fallback to client-side - const vertexUrl0 = - process.env.SODOT_VERTEX_URL_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0 || - ""; - const vertexApiKey0 = - process.env.SODOT_VERTEX_API_KEY_0 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0 || - ""; - const vertexUrl1 = - process.env.SODOT_VERTEX_URL_1 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1 || - ""; - const vertexApiKey1 = - process.env.SODOT_VERTEX_API_KEY_1 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1 || - ""; - const vertexUrl2 = - process.env.SODOT_VERTEX_URL_2 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2 || - ""; - const vertexApiKey2 = - process.env.SODOT_VERTEX_API_KEY_2 || - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2 || - ""; - - this.vertices = [ - { - url: ensureUrlFormat(vertexUrl0), - apiKey: vertexApiKey0, - }, - { - url: ensureUrlFormat(vertexUrl1), - apiKey: vertexApiKey1, - }, - { - url: ensureUrlFormat(vertexUrl2), - apiKey: vertexApiKey2, - }, - ]; - - // Log the vertex URLs for debugging - console.log( - `[Sodot] Vertex URLs:`, - this.vertices.map((v) => v.url) - ); - - // Initialize key IDs based on curve type - const serverSideKeyIds = - process.env.SODOT_EXISTING_ECDSA_KEY_IDS || - process.env.SODOT_EXISTING_ED25519_KEY_IDS || - ""; - const clientSideKeyIds = - process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || - process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS || - ""; - - switch (signerSpec.curve) { - case AdamikCurve.SECP256K1: - this.keyIds = ( - process.env.SODOT_EXISTING_ECDSA_KEY_IDS || - process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS || - "" - ) - .split(",") - .filter(Boolean); - console.log( - `[Sodot] Initialized SECP256K1 key IDs for chain ${chainId}:`, - this.keyIds - ); - break; - case AdamikCurve.ED25519: - this.keyIds = ( - process.env.SODOT_EXISTING_ED25519_KEY_IDS || - process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS || - "" - ) - .split(",") - .filter(Boolean); - console.log( - `[Sodot] Initialized ED25519 key IDs for chain ${chainId}:`, - this.keyIds - ); - break; - default: - throw new Error(`Unsupported curve: ${signerSpec.curve}`); - } - - // Warn if vertices configuration is incomplete, but don't throw an error - if (this.vertices.some((v) => !v.url || !v.apiKey)) { - console.warn( - "[Sodot] Vertices configuration is incomplete. Some vertices may not work." - ); - } + // Initialize key IDs based on curve type - these will be managed by the server + console.log(`[Sodot] Initialized for ${signerSpec.curve} chain ${chainId}`); + } - // Log the full configuration - console.log(`[Sodot] Initialized for chain ${chainId} with:`, { - curve: signerSpec.curve, - keyIds: this.keyIds, - vertices: this.vertices.map((v) => ({ - url: v.url, - hasApiKey: !!v.apiKey, - })), - }); + private adamikCurveToSodotCurve(curve: AdamikCurve): "ecdsa" | "ed25519" { + return curve === AdamikCurve.SECP256K1 ? "ecdsa" : "ed25519"; } - private async makeRequest( - vertexIndex: number, - endpoint: string, - method: string = "POST", + private async callVertexEndpoint( + vertexId: number, + path: string, + method: string = "GET", body?: any, retryCount: number = 0 ): Promise { - const vertex = this.vertices[vertexIndex]; - if (!vertex) throw new Error(`Vertex ${vertexIndex} not found`); + const vertex = this.SODOT_VERTICES[vertexId]; + if (!vertex) throw new Error(`Vertex ${vertexId} not found`); - console.log(`[Sodot] Making request to vertex ${vertexIndex}:`, { - endpoint, + console.log(`[Sodot] Making request to vertex ${vertexId}:`, { + path, method, body, retryCount, @@ -160,33 +78,35 @@ export class SodotSigner { try { // Add a unique timestamp to prevent caching issues const timestamp = new Date().getTime(); + const url = `${vertex.url}${path}?vertex=${vertex.vertexParam}&t=${timestamp}`; - const response = await fetch(`/api/sodot?t=${timestamp}`, { - method: "POST", + const options: RequestInit = { + method: method, headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", Pragma: "no-cache", }, - body: JSON.stringify({ - vertexIndex, - endpoint, - method, - body, - }), - // Prevent caching issues cache: "no-store", - }); + }; + + // Add body for POST, PUT, PATCH requests + if (["POST", "PUT", "PATCH"].includes(method) && body) { + options.body = JSON.stringify(body); + } + + console.log(`[Sodot] Fetching ${url} with options:`, options); + const response = await fetch(url, options); console.log( - `[Sodot] Response status from vertex ${vertexIndex}:`, + `[Sodot] Response status from vertex ${vertexId}:`, response.status ); if (!response.ok) { const errorText = await response.text(); console.error( - `[Sodot] Error response from vertex ${vertexIndex}:`, + `[Sodot] Error response from vertex ${vertexId}:`, errorText ); throw new Error( @@ -194,198 +114,165 @@ export class SodotSigner { ); } - // Get the response text - const responseText = await response.text(); - console.log( - `[Sodot] Raw response text from vertex ${vertexIndex}:`, - responseText - ); - - // Check if the response is empty - if (!responseText || responseText.trim() === "") { - console.error(`[Sodot] Empty response from vertex ${vertexIndex}`); - - // Retry up to 3 times on empty response - if (retryCount < 3) { - console.log( - `[Sodot] Retrying request to vertex ${vertexIndex} (${ - retryCount + 1 - }/3)` + const contentType = response.headers.get("content-type") || ""; + + // Handle response based on content type + if (contentType.includes("application/json")) { + try { + const data = await response.json(); + console.log(`[Sodot] Parsed JSON from vertex ${vertexId}:`, data); + return data; + } catch (e) { + console.error( + `[Sodot] Failed to parse response as JSON from vertex ${vertexId}:`, + e ); - // Wait a short time before retrying - await new Promise((resolve) => setTimeout(resolve, 500)); - return this.makeRequest( - vertexIndex, - endpoint, - method, - body, - retryCount + 1 - ); - } - - throw new Error("Empty response from server"); - } - - // Parse the JSON response - try { - const data = JSON.parse(responseText); - console.log(`[Sodot] Parsed JSON from vertex ${vertexIndex}:`, data); - return data; - } catch (e) { - console.error( - `[Sodot] Failed to parse response as JSON from vertex ${vertexIndex}:`, - e - ); - console.error( - `[Sodot] Raw response that failed to parse:`, - responseText - ); - // Retry up to 3 times on JSON parse error - if (retryCount < 3) { - console.log( - `[Sodot] Retrying request to vertex ${vertexIndex} (${ + // Retry on JSON parse error + if (retryCount < 3) { + console.log( + `[Sodot] Retrying request to vertex ${vertexId} (${ + retryCount + 1 + }/3)` + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + return this.callVertexEndpoint( + vertexId, + path, + method, + body, retryCount + 1 - }/3)` - ); - // Wait a short time before retrying - await new Promise((resolve) => setTimeout(resolve, 500)); - return this.makeRequest( - vertexIndex, - endpoint, - method, - body, - retryCount + 1 - ); - } + ); + } - throw new Error("Invalid JSON response from server"); + throw e; + } + } else { + // Handle text response + const text = await response.text(); + console.log(`[Sodot] Text response from vertex ${vertexId}:`, text); + return text; } } catch (error: any) { - console.error(`[Sodot] Request error for vertex ${vertexIndex}:`, error); + console.error(`[Sodot] Request error for vertex ${vertexId}:`, error); - // Retry up to 3 times on network errors - if (error.message.includes("Failed to fetch") && retryCount < 3) { + // Retry on network errors + if (retryCount < 3) { console.log( - `[Sodot] Retrying request to vertex ${vertexIndex} (${ - retryCount + 1 - }/3)` + `[Sodot] Retrying request to vertex ${vertexId} (${retryCount + 1}/3)` ); - // Wait a short time before retrying await new Promise((resolve) => setTimeout(resolve, 500)); - return this.makeRequest( - vertexIndex, - endpoint, + return this.callVertexEndpoint( + vertexId, + path, method, body, retryCount + 1 ); } - throw new Error( - `Failed to make request to vertex ${vertexIndex}: ${error.message}` - ); + throw error; } } - private adamikCurveToSodotCurve(curve: AdamikCurve): "ecdsa" | "ed25519" { - return curve === AdamikCurve.SECP256K1 ? "ecdsa" : "ed25519"; + private async createRoomWithVertex(vertexId: number, roomSize: number) { + return this.callVertexEndpoint(vertexId, "/create-room", "POST", { + room_size: roomSize, + }); } - public async getPubkey(): Promise { + private async getKeyId(vertexId: number, curve: "ecdsa" | "ed25519") { try { - if (this.keyIds.length === 0) { - throw new Error("No key IDs available"); + // Use environment variables to get existing key IDs + const keyIdsEnvVar = + curve === "ecdsa" + ? process.env.SODOT_EXISTING_ECDSA_KEY_IDS || + process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS + : process.env.SODOT_EXISTING_ED25519_KEY_IDS || + process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS; + + if (keyIdsEnvVar) { + const keyIds = keyIdsEnvVar.split(","); + if (keyIds.length > vertexId && keyIds[vertexId]) { + return keyIds[vertexId]; + } } - const keyId = this.keyIds[0]; - const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); + throw new Error("No existing key ID found in environment variables"); + } catch (error) { + console.error( + `[Sodot] Error getting key ID for vertex ${vertexId}:`, + error + ); + throw error; + } + } + private async derivePubkeyWithVertex( + vertexId: number, + derivationPath: number[], + curve: "ecdsa" | "ed25519" + ) { + // First, get the key ID if we don't have it yet + if (!this.keyIds[vertexId]) { + const keyId = await this.getKeyId(vertexId, curve); + this.keyIds[vertexId] = keyId; console.log( - `[Sodot] Getting pubkey for chain ${this.chainId} with curve ${curve}, keyId: ${keyId}` + `[Sodot] Retrieved key ID for vertex ${vertexId}: ${this.keyIds[vertexId]}` ); + } - // Make a POST request directly to the API route - const response = await fetch("/api/sodot", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - vertexIndex: 0, - endpoint: `/${curve}/derive-pubkey`, - method: "POST", - body: { - key_id: keyId, - derivation_path: [44, Number(this.signerSpec.coinType), 0, 0, 0], - }, - }), - }); + const keyId = this.keyIds[vertexId]; + console.log(`[Sodot] Using key ID for derivation: ${keyId}`); - // Get the response text - const responseText = await response.text(); - console.log(`[Sodot] Raw response:`, responseText); + const result = await this.callVertexEndpoint( + vertexId, + `/${curve}/derive-pubkey`, + "POST", + { + key_id: keyId, + derivation_path: derivationPath, + } + ); - // If the response is empty, throw an error - if (!responseText || responseText.trim() === "") { - throw new Error("Empty response received"); + // Handle different response formats based on curve and chain + if (curve === "ed25519" && result.pubkey) { + return result.pubkey; + } else if (curve === "ecdsa") { + // For Ethereum and EVM chains, use uncompressed format + if ( + this.chainId === "ethereum" || + this.chainId === "base" || + this.chainId === "arbitrum" || + this.chainId === "linea" + ) { + return result.uncompressed || result.pubkey; + } else { + // For other chains like Bitcoin, use compressed format + return result.compressed || result.pubkey; } + } - // Parse the response - try { - const data = JSON.parse(responseText); - console.log(`[Sodot] Parsed response:`, data); - - // Handle different response formats based on curve - if (curve === "ed25519" && data.pubkey) { - // For ED25519, return the pubkey directly - console.log(`[Sodot] Using ED25519 pubkey:`, data.pubkey); - return data.pubkey; - } else if (curve === "ecdsa") { - // For ECDSA, use the appropriate format based on the chain - if ( - this.chainId === "ethereum" || - this.chainId === "base" || - this.chainId === "arbitrum" || - this.chainId === "linea" - ) { - // Ethereum and EVM chains need uncompressed format - if (data.uncompressed) { - console.log( - `[Sodot] Using uncompressed pubkey for EVM chain:`, - data.uncompressed - ); - return data.uncompressed; - } - } else { - // Non-EVM chains like Bitcoin use compressed format - if (data.compressed) { - console.log( - `[Sodot] Using compressed pubkey for non-EVM chain:`, - data.compressed - ); - return data.compressed; - } - } - } + // Fallback to any available format + return result.pubkey || result.compressed || result.uncompressed; + } - // Fallback handling for any format - if (data.pubkey) { - console.log(`[Sodot] Using pubkey:`, data.pubkey); - return data.pubkey; - } else if (data.compressed) { - console.log(`[Sodot] Using compressed pubkey:`, data.compressed); - return data.compressed; - } else if (data.uncompressed) { - console.log(`[Sodot] Using uncompressed pubkey:`, data.uncompressed); - return data.uncompressed; - } else { - throw new Error(`Unknown response format: ${JSON.stringify(data)}`); - } - } catch (e: any) { - console.error(`[Sodot] Failed to parse JSON:`, e); - throw new Error(`Failed to parse response as JSON: ${e.message}`); - } + public async getPubkey(): Promise { + try { + const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); + + console.log( + `[Sodot] Getting pubkey for chain ${this.chainId} with curve ${curve}` + ); + + const pubkey = await this.derivePubkeyWithVertex( + 0, + [44, Number(this.signerSpec.coinType), 0, 0, 0], + curve + ); + + console.log(`[Sodot] Retrieved pubkey: ${pubkey}`); + return pubkey; } catch (error: any) { console.error(`[Sodot] Error getting pubkey:`, error); throw new Error(`Failed to get pubkey: ${error.message}`); @@ -393,42 +280,79 @@ export class SodotSigner { } public async signTransaction(encodedMessage: string): Promise { - // Create a signing room - const roomResponse = await this.makeRequest(0, "/create-room", "POST", { - room_size: this.vertices.length, - }); + try { + // Ensure we have key IDs + const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); - // Sign the transaction with each vertex - const curve = this.adamikCurveToSodotCurve(this.signerSpec.curve); - const signatures = await Promise.all( - this.vertices.map((_, index) => - this.makeRequest(index, `/${curve}/sign`, "POST", { - room_uuid: roomResponse.room_uuid, - key_id: this.keyIds[index], - msg: encodedMessage, - derivation_path: [44, Number(this.signerSpec.coinType), 0, 0, 0], - }) - ) - ); + // Get key IDs if we don't have them yet + if (this.keyIds.length === 0 || this.keyIds.some((id) => !id)) { + for (let i = 0; i < this.n; i++) { + if (!this.keyIds[i]) { + const keysResult = await this.callVertexEndpoint( + i, + `/v1/keys?curve=${curve}`, + "GET" + ); + + if (keysResult && keysResult.keyId) { + this.keyIds[i] = keysResult.keyId; + console.log( + `[Sodot] Retrieved key ID for vertex ${i}: ${this.keyIds[i]}` + ); + } else { + throw new Error(`Failed to get key ID for vertex ${i}`); + } + } + } + } - // Handle different signature formats - const signature = signatures[0]; // Use the first signature (they should all be identical) + // Create a signing room + const roomResponse = await this.createRoomWithVertex(0, this.n); + const roomUuid = roomResponse.room_uuid; + console.log(`[Sodot] Created signing room: ${roomUuid}`); - if ("signature" in signature) { - return signature.signature; - } else if ("r" in signature && "s" in signature) { - // For ECDSA signatures - const format = this.signerSpec.signatureFormat; - if (format === "der") { - return signature.der; - } else { - // Handle other formats as needed - return `${signature.r}${signature.s}${signature.v.toString(16)}`; + // Ensure the message is properly formatted + let formattedMsg = encodedMessage; + if (formattedMsg.startsWith("0x")) { + formattedMsg = formattedMsg.substring(2); } - } - // Fallback for unknown formats - return JSON.stringify(signature); + // Sign the transaction with each vertex + console.log(`[Sodot] Signing message with room ${roomUuid}`); + const signatures = await Promise.all( + this.keyIds.map((keyId, index) => + this.callVertexEndpoint(index, `/${curve}/sign`, "POST", { + room_uuid: roomUuid, + key_id: keyId, + msg: formattedMsg, + derivation_path: [44, Number(this.signerSpec.coinType), 0, 0, 0], + }) + ) + ); + + // Handle different signature formats + const signature = signatures[0]; // Use the first signature + console.log(`[Sodot] Received signature:`, signature); + + if ("signature" in signature) { + return signature.signature; + } else if ("r" in signature && "s" in signature) { + // For ECDSA signatures + const format = this.signerSpec.signatureFormat; + if (format === "der") { + return signature.der; + } else { + // Handle other formats as needed + return `${signature.r}${signature.s}${signature.v.toString(16)}`; + } + } + + // Fallback for unknown formats + return JSON.stringify(signature); + } catch (error: any) { + console.error(`[Sodot] Error signing transaction:`, error); + throw new Error(`Failed to sign transaction: ${error.message}`); + } } public async getAddress(): Promise { From 8d785876b95640e55d46e0c7bb7940ce29878653 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 21:36:03 +0200 Subject: [PATCH 004/146] test solana and tron --- src/app/sodot-tutorial-test/page.tsx | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/app/sodot-tutorial-test/page.tsx b/src/app/sodot-tutorial-test/page.tsx index d3a2da84..8f1a9d58 100644 --- a/src/app/sodot-tutorial-test/page.tsx +++ b/src/app/sodot-tutorial-test/page.tsx @@ -173,6 +173,49 @@ export default function SodotTutorialTestPage() { } }; + const testTronPubkey = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + // Create tron signer spec + const signerSpec: AdamikSignerSpec = { + curve: AdamikCurve.SECP256K1, + coinType: "195", // Tron + hashFunction: AdamikHashFunction.KECCAK256, + signatureFormat: "r|s|v", + }; + + // Initialize the Sodot signer + const signer = new SodotSigner("tron", signerSpec); + + // Get the pubkey + const pubkey = await signer.getPubkey(); + + // Get the address (which calls the Adamik API) + const address = await signer.getAddress(); + + // Store the results + setResults( + (prev: Results | null): Results => ({ + ...prev, + chainPubkey: { + pubkey, + address, + chainId: "tron", + curve: "SECP256K1", + }, + }) + ); + setSuccess("Successfully retrieved Tron pubkey and address"); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + console.error("Error testing Tron pubkey:", e); + } finally { + setLoading(false); + } + }; + const getVertexKeys = async () => { setLoading(true); setError(null); @@ -277,6 +320,21 @@ export default function SodotTutorialTestPage() { "Solana" )} + {error && ( From 6b628ac4980851c8862c3bfeb3ec7b86418db1be Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 22:28:28 +0200 Subject: [PATCH 005/146] WIP 2 --- .../page.tsx | 330 ++++++++++++++---- src/components/layout/Menu/Menu.tsx | 2 +- src/env.ts | 29 -- src/pages/api/sodot-proxy/[...path].ts | 127 ++++--- src/pages/api/sodot-proxy/derive-address.ts | 48 +++ .../api/sodot-proxy/derive-chain-pubkey.ts | 183 ++++++++++ src/pages/api/sodot-proxy/get-keys.ts | 36 +- 7 files changed, 588 insertions(+), 167 deletions(-) rename src/app/{sodot-tutorial-test => sodot-test}/page.tsx (52%) create mode 100644 src/pages/api/sodot-proxy/derive-address.ts create mode 100644 src/pages/api/sodot-proxy/derive-chain-pubkey.ts diff --git a/src/app/sodot-tutorial-test/page.tsx b/src/app/sodot-test/page.tsx similarity index 52% rename from src/app/sodot-tutorial-test/page.tsx rename to src/app/sodot-test/page.tsx index 8f1a9d58..c74e7a64 100644 --- a/src/app/sodot-tutorial-test/page.tsx +++ b/src/app/sodot-test/page.tsx @@ -9,6 +9,7 @@ import { AdamikHashFunction, AdamikSignerSpec, } from "~/adamik/types"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { AlertCircle, CheckCircle2, Loader2, Lock, Server } from "lucide-react"; type VertexKeysResult = { @@ -38,35 +39,59 @@ type Results = { chainPubkey?: ChainPubkeyResult; }; -export default function SodotTutorialTestPage() { +export default function SodotTestPage() { const [loading, setLoading] = useState(false); const [results, setResults] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + // Add effect to monitor state changes + console.log("Component render - Current state:", { + results, + error, + success, + loading, + }); + const testEthereumPubkey = async () => { setLoading(true); setError(null); setSuccess(null); try { - // Create ethereum signer spec - const signerSpec: AdamikSignerSpec = { - curve: AdamikCurve.SECP256K1, - coinType: "60", // Ethereum - hashFunction: AdamikHashFunction.KECCAK256, - signatureFormat: "r|s|v", - }; + console.log("Starting Ethereum pubkey test"); + + // Call our backend endpoint for Ethereum pubkey + console.log("Fetching pubkey from backend"); + const response = await fetch( + "/api/sodot-proxy/derive-chain-pubkey?chain=ethereum", + { + method: "GET", + cache: "no-store", + } + ); - // Initialize the Sodot signer - const signer = new SodotSigner("ethereum", signerSpec); + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const data = await response.json(); + console.log("Received pubkey data:", data); - // Get the pubkey - const pubkey = await signer.getPubkey(); + // Get the pubkey from the response + const pubkey = data.data.pubkey; + console.log("Extracted pubkey:", pubkey); - // Get the address (which calls the Adamik API) - const address = await signer.getAddress(); + // Use the Adamik API to get the address from the pubkey + console.log("Calling encodePubKeyToAddress for Ethereum"); + const addressResult = await encodePubKeyToAddress(pubkey, "ethereum"); + console.log("Received address result:", addressResult); + const address = addressResult.address; // Store the results + console.log("Setting state with pubkey and address"); setResults( (prev: Results | null): Results => ({ ...prev, @@ -78,11 +103,13 @@ export default function SodotTutorialTestPage() { }, }) ); + console.log("State updated, setting success message"); setSuccess("Successfully retrieved Ethereum pubkey and address"); } catch (e: any) { + console.error("Error in testEthereumPubkey:", e); setError(e.message || "Unknown error occurred"); - console.error("Error testing Ethereum pubkey:", e); } finally { + console.log("Setting loading to false"); setLoading(false); } }; @@ -92,24 +119,40 @@ export default function SodotTutorialTestPage() { setError(null); setSuccess(null); try { - // Create bitcoin signer spec - const signerSpec: AdamikSignerSpec = { - curve: AdamikCurve.SECP256K1, - coinType: "0", // Bitcoin - hashFunction: AdamikHashFunction.SHA256, - signatureFormat: "der", - }; + console.log("Starting Bitcoin pubkey test"); + + // Call our backend endpoint for Bitcoin pubkey + console.log("Fetching pubkey from backend"); + const response = await fetch( + "/api/sodot-proxy/derive-chain-pubkey?chain=bitcoin", + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } - // Initialize the Sodot signer - const signer = new SodotSigner("bitcoin", signerSpec); + const data = await response.json(); + console.log("Received pubkey data:", data); - // Get the pubkey - const pubkey = await signer.getPubkey(); + // Get the pubkey from the response + const pubkey = data.data.pubkey; + console.log("Extracted pubkey:", pubkey); - // Get the address (which calls the Adamik API) - const address = await signer.getAddress(); + // Use the Adamik API to get the address from the pubkey + console.log("Calling encodePubKeyToAddress for Bitcoin"); + const addressResult = await encodePubKeyToAddress(pubkey, "bitcoin"); + console.log("Received address result:", addressResult); + const address = addressResult.address; // Store the results + console.log("Setting state with pubkey and address"); setResults( (prev: Results | null): Results => ({ ...prev, @@ -121,54 +164,74 @@ export default function SodotTutorialTestPage() { }, }) ); + console.log("State updated, setting success message"); setSuccess("Successfully retrieved Bitcoin pubkey and address"); } catch (e: any) { + console.error("Error in testBitcoinPubkey:", e); setError(e.message || "Unknown error occurred"); - console.error("Error testing Bitcoin pubkey:", e); } finally { + console.log("Setting loading to false"); setLoading(false); } }; - const testSolanaPubkey = async () => { + const testTONPubkey = async () => { setLoading(true); setError(null); setSuccess(null); try { - // Create solana signer spec - const signerSpec: AdamikSignerSpec = { - curve: AdamikCurve.ED25519, - coinType: "501", // Solana - hashFunction: AdamikHashFunction.SHA256, - signatureFormat: "signature", - }; + console.log("Starting TON pubkey test"); + + // Call our backend endpoint for TON pubkey + console.log("Fetching pubkey from backend"); + const response = await fetch( + "/api/sodot-proxy/derive-chain-pubkey?chain=ton", + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } - // Initialize the Sodot signer - const signer = new SodotSigner("solana", signerSpec); + const data = await response.json(); + console.log("Received pubkey data:", data); - // Get the pubkey - const pubkey = await signer.getPubkey(); + // Get the pubkey from the response + const pubkey = data.data.pubkey; + console.log("Extracted TON pubkey:", pubkey); - // Get the address (which calls the Adamik API) - const address = await signer.getAddress(); + // Use the Adamik API to get the address from the pubkey + console.log("Calling encodePubKeyToAddress for TON"); + const addressResult = await encodePubKeyToAddress(pubkey, "ton"); + console.log("Received TON address result:", addressResult); + const address = addressResult.address; // Store the results + console.log("Setting state with TON pubkey and address"); setResults( (prev: Results | null): Results => ({ ...prev, chainPubkey: { pubkey, address, - chainId: "solana", - curve: "ED25519", + chainId: "ton", + curve: "SECP256K1", }, }) ); - setSuccess("Successfully retrieved Solana pubkey and address"); + console.log("State updated, setting success message"); + setSuccess("Successfully retrieved TON pubkey and address"); } catch (e: any) { + console.error("Error in testTONPubkey:", e); setError(e.message || "Unknown error occurred"); - console.error("Error testing Solana pubkey:", e); } finally { + console.log("Setting loading to false"); setLoading(false); } }; @@ -178,24 +241,40 @@ export default function SodotTutorialTestPage() { setError(null); setSuccess(null); try { - // Create tron signer spec - const signerSpec: AdamikSignerSpec = { - curve: AdamikCurve.SECP256K1, - coinType: "195", // Tron - hashFunction: AdamikHashFunction.KECCAK256, - signatureFormat: "r|s|v", - }; + console.log("Starting Tron pubkey test"); + + // Call our backend endpoint for Tron pubkey + console.log("Fetching pubkey from backend"); + const response = await fetch( + "/api/sodot-proxy/derive-chain-pubkey?chain=tron", + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } - // Initialize the Sodot signer - const signer = new SodotSigner("tron", signerSpec); + const data = await response.json(); + console.log("Received pubkey data:", data); - // Get the pubkey - const pubkey = await signer.getPubkey(); + // Get the pubkey from the response + const pubkey = data.data.pubkey; + console.log("Extracted pubkey:", pubkey); - // Get the address (which calls the Adamik API) - const address = await signer.getAddress(); + // Use the Adamik API to get the address from the pubkey + console.log("Calling encodePubKeyToAddress for Tron"); + const addressResult = await encodePubKeyToAddress(pubkey, "tron"); + console.log("Received address result:", addressResult); + const address = addressResult.address; // Store the results + console.log("Setting state with pubkey and address"); setResults( (prev: Results | null): Results => ({ ...prev, @@ -207,11 +286,74 @@ export default function SodotTutorialTestPage() { }, }) ); + console.log("State updated, setting success message"); setSuccess("Successfully retrieved Tron pubkey and address"); } catch (e: any) { + console.error("Error in testTronPubkey:", e); setError(e.message || "Unknown error occurred"); - console.error("Error testing Tron pubkey:", e); } finally { + console.log("Setting loading to false"); + setLoading(false); + } + }; + + const testAlgorandPubkey = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + console.log("Starting Algorand pubkey test"); + + // Call our backend endpoint for Algorand pubkey + console.log("Fetching pubkey from backend"); + const response = await fetch( + "/api/sodot-proxy/derive-chain-pubkey?chain=algorand", + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const data = await response.json(); + console.log("Received pubkey data:", data); + + // Get the pubkey from the response + const pubkey = data.data.pubkey; + console.log("Extracted Algorand pubkey:", pubkey); + + // Use the Adamik API to get the address from the pubkey + console.log("Calling encodePubKeyToAddress for Algorand"); + const addressResult = await encodePubKeyToAddress(pubkey, "algorand"); + console.log("Received Algorand address result:", addressResult); + const address = addressResult.address; + + // Store the results + console.log("Setting state with Algorand pubkey and address"); + setResults( + (prev: Results | null): Results => ({ + ...prev, + chainPubkey: { + pubkey, + address, + chainId: "algorand", + curve: "ED25519", + }, + }) + ); + console.log("State updated, setting success message"); + setSuccess("Successfully retrieved Algorand pubkey and address"); + } catch (e: any) { + console.error("Error in testAlgorandPubkey:", e); + setError(e.message || "Unknown error occurred"); + } finally { + console.log("Setting loading to false"); setLoading(false); } }; @@ -226,6 +368,10 @@ export default function SodotTutorialTestPage() { cache: "no-store", }); + if (!response.ok) { + throw new Error(`Failed to get keys: ${response.status}`); + } + const data = await response.json(); if (data.error) { @@ -259,7 +405,7 @@ export default function SodotTutorialTestPage() { Connection Test -
+
{error && ( @@ -354,45 +515,60 @@ export default function SodotTutorialTestPage() { {results && (
{results.chainPubkey && ( -
-

Pubkey Results:

-
Chain: {results.chainPubkey.chainId}
-
Curve: {results.chainPubkey.curve}
-
+
+

+ Pubkey Results: +

+
+ Chain: {results.chainPubkey.chainId} +
+
+ Curve: {results.chainPubkey.curve} +
+
Pubkey: {results.chainPubkey.pubkey}
-
+
Address: {results.chainPubkey.address}
)} + {/* Debug output */} +
+                  {JSON.stringify({ results, success, error }, null, 2)}
+                
+ {results.vertexKeys && results.vertexKeys.data && results.vertexKeys.data.vertices && results.vertexKeys.data.vertices.length > 0 && ( -
-

Vertex Keys:

+
+

+ Vertex Keys: +

{results.vertexKeys.data.vertices.map((vertex) => (
-

+

Vertex {vertex.id}

-
Status: {vertex.status}
+
+ Status: {vertex.status} +
{vertex.error && (
Error: {vertex.error}
)} {vertex.compressed && ( -
+
Compressed:{" "} {vertex.compressed}
)} {vertex.uncompressed && ( -
+
Uncompressed: {" "} @@ -438,7 +614,7 @@ export default function SodotTutorialTestPage() {
  • Support for multiple blockchain cryptography (ECDSA for - Bitcoin/Ethereum, ED25519 for Solana) + Bitcoin/Ethereum/TON/Tron, ED25519 for Algorand)
  • Integration with Adamik API for address derivation
  • diff --git a/src/components/layout/Menu/Menu.tsx b/src/components/layout/Menu/Menu.tsx index 3e09f8a9..751f3357 100644 --- a/src/components/layout/Menu/Menu.tsx +++ b/src/components/layout/Menu/Menu.tsx @@ -43,7 +43,7 @@ const menu = [ { title: "Sodot Test", icon: KeyRound, - href: "/sodot-tutorial-test", + href: "/sodot-test", }, ]; diff --git a/src/env.ts b/src/env.ts index a9d70c81..01d84aa4 100644 --- a/src/env.ts +++ b/src/env.ts @@ -24,9 +24,6 @@ const env = createEnv({ SODOT_VERTEX_API_KEY_1: z.string().min(1), SODOT_VERTEX_URL_2: z.string().url(), SODOT_VERTEX_API_KEY_2: z.string().min(1), - // Existing key IDs - SODOT_EXISTING_ECDSA_KEY_IDS: z.string().optional(), - SODOT_EXISTING_ED25519_KEY_IDS: z.string().optional(), }, /* @@ -34,21 +31,11 @@ const env = createEnv({ */ client: { NEXT_PUBLIC_ADAMIK_API_TEST_URL: z.string().url(), - NEXT_PUBLIC_SODOT_VERTEX_URL_0: z.string().url(), - NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0: z.string().min(1), - NEXT_PUBLIC_SODOT_VERTEX_URL_1: z.string().url(), - NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1: z.string().min(1), - NEXT_PUBLIC_SODOT_VERTEX_URL_2: z.string().url(), - NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2: z.string().min(1), - NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS: z.string().optional(), - NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS: z.string().optional(), }, /* * Due to how Next.js bundles environment variables on Edge and Client, * we need to manually destructure them to make sure all are included in bundle. - * - * 💡 You'll get type errors if not all variables from `server` & `client` are included here. */ runtimeEnv: { ADAMIK_API_KEY: process.env.ADAMIK_API_KEY, @@ -56,19 +43,6 @@ const env = createEnv({ // Client-side variables NEXT_PUBLIC_ADAMIK_API_TEST_URL: process.env.NEXT_PUBLIC_ADAMIK_API_TEST_URL, - NEXT_PUBLIC_SODOT_VERTEX_URL_0: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_0, - NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0: - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_0, - NEXT_PUBLIC_SODOT_VERTEX_URL_1: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_1, - NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1: - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_1, - NEXT_PUBLIC_SODOT_VERTEX_URL_2: process.env.NEXT_PUBLIC_SODOT_VERTEX_URL_2, - NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2: - process.env.NEXT_PUBLIC_SODOT_VERTEX_API_KEY_2, - NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS: - process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS, - NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS: - process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS, // Sodot Vertex Configuration SODOT_VERTEX_URL_0: process.env.SODOT_VERTEX_URL_0, SODOT_VERTEX_API_KEY_0: process.env.SODOT_VERTEX_API_KEY_0, @@ -76,9 +50,6 @@ const env = createEnv({ SODOT_VERTEX_API_KEY_1: process.env.SODOT_VERTEX_API_KEY_1, SODOT_VERTEX_URL_2: process.env.SODOT_VERTEX_URL_2, SODOT_VERTEX_API_KEY_2: process.env.SODOT_VERTEX_API_KEY_2, - // Existing key IDs - SODOT_EXISTING_ECDSA_KEY_IDS: process.env.SODOT_EXISTING_ECDSA_KEY_IDS, - SODOT_EXISTING_ED25519_KEY_IDS: process.env.SODOT_EXISTING_ED25519_KEY_IDS, }, }); diff --git a/src/pages/api/sodot-proxy/[...path].ts b/src/pages/api/sodot-proxy/[...path].ts index c16455da..8380c42a 100644 --- a/src/pages/api/sodot-proxy/[...path].ts +++ b/src/pages/api/sodot-proxy/[...path].ts @@ -1,35 +1,32 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { env } from "~/env"; // Assuming t3-env setup export default async function handler( req: NextApiRequest, res: NextApiResponse ) { try { - // Extract vertex number from query parameter + // Extract vertex number and path from query parameters const { vertex } = req.query; - const path = req.query.path as string[]; + const pathSegments = req.query.path as string[] | undefined; - if (!vertex) { - return res.status(400).json({ error: "Missing vertex parameter" }); + if (typeof vertex !== "string" || !vertex) { + return res + .status(400) + .json({ error: "Missing or invalid vertex parameter" }); } - // Get the path from the URL path segments - const pathStr = path ? `/${path.join("/")}` : ""; - console.log(`Path segments:`, path); - console.log(`Constructed path: ${pathStr}`); + const pathStr = pathSegments ? `/${pathSegments.join("/")}` : ""; + console.log(`Proxying request for vertex: ${vertex}, path: ${pathStr}`); - // Special handling for health check + // Special handling for health check (if needed) if (pathStr === "/health") { return res.status(200).send("OK"); } - // Get the environment variables for the vertex - const vertexUrl = - process.env[`VITE_SODOT_VERTEX_URL_${vertex}`] || - process.env[`SODOT_VERTEX_URL_${vertex}`]; - const apiKey = - process.env[`VITE_SODOT_VERTEX_API_KEY_${vertex}`] || - process.env[`SODOT_VERTEX_API_KEY_${vertex}`]; + // Get the environment variables for the specific vertex + const vertexUrl = env[`SODOT_VERTEX_URL_${vertex}` as keyof typeof env]; + const apiKey = env[`SODOT_VERTEX_API_KEY_${vertex}` as keyof typeof env]; if (!vertexUrl || !apiKey) { return res.status(500).json({ @@ -41,62 +38,92 @@ export default async function handler( }); } - // Construct the full URL to the SODOT vertex + // Construct the full URL to the SODOT vertex endpoint const targetUrl = new URL(`${vertexUrl}${pathStr}`); - // Copy all query parameters except 'vertex' and 'path' + // Forward relevant query parameters (excluding 'vertex' and 'path') Object.entries(req.query).forEach(([key, value]) => { - if (key !== "vertex" && key !== "path") { - targetUrl.searchParams.append(key, value as string); + if (key !== "vertex" && key !== "path" && typeof value === "string") { + targetUrl.searchParams.append(key, value); } }); - console.log(`Proxying request to: ${targetUrl.toString()}`); - console.log(`Request method: ${req.method}`); - if (req.body) { - console.log(`Request body:`, req.body); - } + console.log(`Forwarding to: ${targetUrl.toString()}`); + console.log(`Method: ${req.method}`); - // Forward the request to the SODOT vertex + // Forward the request to the actual SODOT vertex const response = await fetch(targetUrl.toString(), { method: req.method || "GET", headers: { - "Content-Type": "application/json", + "Content-Type": "application/json", // Assume JSON, adjust if needed Authorization: apiKey, + // Add other headers if necessary }, - body: req.body ? JSON.stringify(req.body) : undefined, + // Only include body for relevant methods + body: + (req.method === "POST" || + req.method === "PUT" || + req.method === "PATCH") && + req.body + ? JSON.stringify(req.body) + : undefined, }); + // Handle the response from the SODOT vertex const contentType = response.headers.get("content-type"); - try { - if (contentType?.includes("application/json")) { - const data = await response.json(); - return res.status(response.status).json({ - status: response.status, - data, - }); - } else { - const text = await response.text(); - return res.status(response.status).json({ - status: response.status, - data: text, - }); + if (!response.ok) { + // Handle upstream errors + let errorData: any = `Upstream error: ${response.status}`; + try { + if (contentType?.includes("application/json")) { + errorData = await response.json(); + } else { + errorData = await response.text(); + } + } catch (parseError) { + console.error("Failed to parse upstream error response:", parseError); + errorData = await response.text(); // Fallback to text } - } catch (e: any) { - console.error("Error reading response:", e); - return res.status(500).json({ - status: 500, - error: "Error reading response", - message: e.message, + console.error( + `Error from vertex ${vertex} (${targetUrl}):`, + response.status, + errorData + ); + // Forward the upstream error status and data + return res.status(response.status).json({ + status: response.status, + error: "Upstream vertex error", + details: errorData, + }); + } + + // Handle successful responses + if (contentType?.includes("application/json")) { + const data = await response.json(); + return res.status(response.status).json({ + status: response.status, + data: data, // Forward the JSON data + }); + } else { + const text = await response.text(); + // Decide how to handle non-JSON success responses + // Option 1: Forward as text within a JSON structure + return res.status(response.status).json({ + status: response.status, + data: text, }); + // Option 2: Forward directly as text (adjust content type) + // res.setHeader('Content-Type', contentType || 'text/plain'); + // return res.status(response.status).send(text); } } catch (error: any) { - console.error("Proxy error:", error); + // Handle errors within the proxy handler itself + console.error("Proxy handler error:", error); return res.status(500).json({ - error: "Internal Server Error", + status: 500, + error: "Internal Proxy Error", message: error.message, - stack: error.stack, }); } } diff --git a/src/pages/api/sodot-proxy/derive-address.ts b/src/pages/api/sodot-proxy/derive-address.ts new file mode 100644 index 00000000..963c1100 --- /dev/null +++ b/src/pages/api/sodot-proxy/derive-address.ts @@ -0,0 +1,48 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + // Get parameters from the request + const { pubkey, chain } = req.query; + + if ( + !pubkey || + !chain || + typeof pubkey !== "string" || + typeof chain !== "string" + ) { + return res.status(400).json({ + status: 400, + error: "Missing parameters", + message: "Both pubkey and chain are required", + }); + } + + // Use the server-side encodePubKeyToAddress function + const { address, type, allAddresses } = await encodePubKeyToAddress( + pubkey, + chain + ); + + // Return the address information + return res.status(200).json({ + status: 200, + data: { + address, + type, + allAddresses, + }, + }); + } catch (error: any) { + console.error(`Error deriving address:`, error); + return res.status(500).json({ + status: 500, + error: "Failed to derive address", + message: error.message, + }); + } +} diff --git a/src/pages/api/sodot-proxy/derive-chain-pubkey.ts b/src/pages/api/sodot-proxy/derive-chain-pubkey.ts new file mode 100644 index 00000000..5c7bce67 --- /dev/null +++ b/src/pages/api/sodot-proxy/derive-chain-pubkey.ts @@ -0,0 +1,183 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +// Helper function to get the base URL +function getBaseUrl(req: NextApiRequest): string { + // Prefer Vercel URL if available (for production) + if (process.env.NEXT_PUBLIC_VERCEL_URL) { + return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`; + } + // Use request headers for local development + const protocol = req.headers["x-forwarded-proto"] || "http"; + const host = req.headers.host || "localhost:3000"; + return `${protocol}://${host}`; +} + +// Type definitions for supported chains +type SupportedChain = "ethereum" | "bitcoin" | "ton" | "tron" | "algorand"; + +interface ChainConfig { + curve: "ecdsa" | "ed25519"; + keyIdsEnvVar: string; + coinType: number; + derivationPath: number[]; +} + +// Configuration for each supported chain +const chainConfigs: Record = { + ethereum: { + curve: "ecdsa", + keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", + coinType: 60, + derivationPath: [44, 60, 0, 0, 0], + }, + bitcoin: { + curve: "ecdsa", + keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", + coinType: 0, + derivationPath: [44, 0, 0, 0, 0], + }, + ton: { + curve: "ecdsa", // TON uses secp256k1 (ECDSA) + keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", + coinType: 607, // TON coin type + derivationPath: [44, 607, 0, 0, 0], + }, + tron: { + curve: "ecdsa", + keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", + coinType: 195, + derivationPath: [44, 195, 0, 0, 0], + }, + algorand: { + curve: "ed25519", + keyIdsEnvVar: "SODOT_EXISTING_ED25519_KEY_IDS", + coinType: 283, // Algorand coin type + derivationPath: [44, 283, 0, 0, 0], + }, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + // Extract chain from query + const { chain } = req.query; + + if (!chain || typeof chain !== "string") { + return res.status(400).json({ + status: 400, + error: "Missing chain parameter", + message: + "Please specify a chain (ethereum, bitcoin, ton, tron, algorand)", + }); + } + + // Validate supported chain + if (!Object.keys(chainConfigs).includes(chain)) { + return res.status(400).json({ + status: 400, + error: "Unsupported chain", + message: `Chain '${chain}' is not supported. Use one of: ${Object.keys( + chainConfigs + ).join(", ")}`, + }); + } + + const chainName = chain as SupportedChain; + const config = chainConfigs[chainName]; + + // Get key IDs for this chain's curve + const keyIdsStr = process.env[config.keyIdsEnvVar]; + if (!keyIdsStr) { + return res.status(500).json({ + status: 500, + error: "Missing key IDs", + message: `No key IDs found in ${config.keyIdsEnvVar} environment variable`, + }); + } + + const keyIds = keyIdsStr.split(","); + if (keyIds.length < 3) { + return res.status(500).json({ + status: 500, + error: "Insufficient key IDs", + message: `Found only ${keyIds.length} key IDs, need at least 3`, + }); + } + + const baseUrl = getBaseUrl(req); + const vertexId = 0; // We only need to use one vertex for derivation + + // Use the appropriate vertex to derive the public key + const response = await fetch( + `${baseUrl}/api/sodot-proxy/${config.curve}/derive-pubkey?vertex=${vertexId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key_id: keyIds[vertexId], + derivation_path: config.derivationPath, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status} - ${errorText}`); + } + + const result = await response.json(); + if (!result.data) { + throw new Error("Invalid response format"); + } + + // Format the response based on chain/curve + let pubkey; + if (config.curve === "ed25519") { + // ED25519 response format: { pubkey: "..." } + pubkey = result.data.pubkey; + if (!pubkey) { + throw new Error("ED25519 pubkey missing from response"); + } + } else { + // SECP256K1 response format: { compressed: "...", uncompressed: "..." } + if (chainName === "ethereum" || chainName === "tron") { + pubkey = result.data.uncompressed; + if (!pubkey) { + throw new Error("Uncompressed pubkey missing from response"); + } + } else { + pubkey = result.data.compressed; + if (!pubkey) { + throw new Error("Compressed pubkey missing from response"); + } + } + } + + // Add prefix if needed for Solana (ED25519 keys in Solana often need a prefix) + if (chainName === "ton" && !pubkey.startsWith("0x")) { + // Some TON implementations might need a prefix + pubkey = pubkey; + } + + // Return just the pubkey to the client + return res.status(200).json({ + status: 200, + data: { + pubkey, + curve: config.curve, + chain: chainName, + }, + }); + } catch (error: any) { + console.error(`Error deriving chain pubkey:`, error); + return res.status(500).json({ + status: 500, + error: "Failed to derive chain pubkey", + message: error.message, + }); + } +} diff --git a/src/pages/api/sodot-proxy/get-keys.ts b/src/pages/api/sodot-proxy/get-keys.ts index 2dee500c..94f6cb06 100644 --- a/src/pages/api/sodot-proxy/get-keys.ts +++ b/src/pages/api/sodot-proxy/get-keys.ts @@ -1,11 +1,24 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { env } from "~/env"; + +// Helper function to get the base URL +function getBaseUrl(req: NextApiRequest): string { + // Prefer Vercel URL if available (for production) + if (process.env.NEXT_PUBLIC_VERCEL_URL) { + return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`; + } + // Use request headers for local development + const protocol = req.headers["x-forwarded-proto"] || "http"; + const host = req.headers.host || "localhost:3000"; + return `${protocol}://${host}`; +} export default async function handler( req: NextApiRequest, res: NextApiResponse ) { try { - // First check if we have the key IDs + // Check if we have the pre-configured key IDs const keyIds = process.env.SODOT_EXISTING_ECDSA_KEY_IDS?.split(","); if (!keyIds || keyIds.length < 3) { @@ -16,7 +29,9 @@ export default async function handler( }); } - // Make parallel requests to all vertices + const baseUrl = getBaseUrl(req); + + // Make parallel requests to derive pubkeys from all vertices const vertexPromises = [0, 1, 2].map(async (vertexId) => { try { const keyId = keyIds[vertexId]; @@ -24,12 +39,9 @@ export default async function handler( throw new Error(`No key ID found for vertex ${vertexId}`); } + // Use our proxy to call derive-pubkey const response = await fetch( - `${ - process.env.NEXT_PUBLIC_VERCEL_URL || - req.headers.origin || - "http://localhost:3000" - }/api/sodot-proxy/ecdsa/derive-pubkey?vertex=${vertexId}`, + `${baseUrl}/api/sodot-proxy/ecdsa/derive-pubkey?vertex=${vertexId}`, { method: "POST", headers: { @@ -37,13 +49,16 @@ export default async function handler( }, body: JSON.stringify({ key_id: keyId, - derivation_path: [44, 60, 0, 0, 0], + derivation_path: [44, 60, 0, 0, 0], // Default Ethereum path }), } ); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const errorText = await response.text(); + throw new Error( + `HTTP error! status: ${response.status} - ${errorText}` + ); } const result = await response.json(); @@ -58,7 +73,7 @@ export default async function handler( uncompressed: result.data.uncompressed, }; } catch (e: any) { - console.error(`Error getting keys from vertex ${vertexId}:`, e); + console.error(`Error getting pubkey from vertex ${vertexId}:`, e); return { vertexId, status: 500, @@ -82,6 +97,7 @@ export default async function handler( }, }); } catch (error: any) { + console.error("Error in get-keys handler:", error); return res.status(500).json({ status: 500, error: "Failed to get keys", From 6ee4e44a6ec82a3a40463f8ebc6a3278f6591035 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 22:31:13 +0200 Subject: [PATCH 006/146] re-org types --- src/adamik/types.ts | 18 ------------------ src/app/sodot-test/page.tsx | 2 +- src/components/wallets/SodotConnect.tsx | 2 +- src/signers/Sodot.ts | 2 +- src/utils/types.ts | 17 +++++++++++++++++ 5 files changed, 20 insertions(+), 21 deletions(-) delete mode 100644 src/adamik/types.ts diff --git a/src/adamik/types.ts b/src/adamik/types.ts deleted file mode 100644 index f1339b1e..00000000 --- a/src/adamik/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -//TODO, move to utils/types.ts - -export enum AdamikCurve { - SECP256K1 = "secp256k1", - ED25519 = "ed25519", -} - -export enum AdamikHashFunction { - SHA256 = "sha256", - KECCAK256 = "keccak256", -} - -export interface AdamikSignerSpec { - curve: AdamikCurve; - hashFunction: AdamikHashFunction; - coinType: string; - signatureFormat: string; -} diff --git a/src/app/sodot-test/page.tsx b/src/app/sodot-test/page.tsx index c74e7a64..7bb9537a 100644 --- a/src/app/sodot-test/page.tsx +++ b/src/app/sodot-test/page.tsx @@ -8,7 +8,7 @@ import { AdamikCurve, AdamikHashFunction, AdamikSignerSpec, -} from "~/adamik/types"; +} from "~/utils/types"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { AlertCircle, CheckCircle2, Loader2, Lock, Server } from "lucide-react"; diff --git a/src/components/wallets/SodotConnect.tsx b/src/components/wallets/SodotConnect.tsx index e73ab516..d5524ca1 100644 --- a/src/components/wallets/SodotConnect.tsx +++ b/src/components/wallets/SodotConnect.tsx @@ -7,7 +7,7 @@ import { AdamikCurve, AdamikHashFunction, AdamikSignerSpec, -} from "~/adamik/types"; +} from "~/utils/types"; import { Button } from "~/components/ui/button"; export const SodotConnect: React.FC = ({ diff --git a/src/signers/Sodot.ts b/src/signers/Sodot.ts index cf9e50cf..e905b1de 100644 --- a/src/signers/Sodot.ts +++ b/src/signers/Sodot.ts @@ -2,7 +2,7 @@ import { AdamikCurve, AdamikHashFunction, AdamikSignerSpec, -} from "~/adamik/types"; +} from "~/utils/types"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; // Helper function to determine if we're running in a production environment diff --git a/src/utils/types.ts b/src/utils/types.ts index 4d286afe..2c4a97af 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -257,3 +257,20 @@ export interface TransactionFees { amount: string; ticker?: string; } + +export enum AdamikCurve { + SECP256K1 = "secp256k1", + ED25519 = "ed25519", +} + +export enum AdamikHashFunction { + SHA256 = "sha256", + KECCAK256 = "keccak256", +} + +export interface AdamikSignerSpec { + curve: AdamikCurve; + hashFunction: AdamikHashFunction; + coinType: string; + signatureFormat: string; +} From ba49c90faa235d023c8fdbaa1c34f6b83da10fd4 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 22:48:17 +0200 Subject: [PATCH 007/146] removing all other wallets --- src/app/sodot-test/page.tsx | 436 +++++------------- src/components/wallets/Ethers.tsx | 52 --- src/components/wallets/KeplrConnect.tsx | 128 ----- src/components/wallets/LitescribeConnect.tsx | 82 ---- src/components/wallets/MetamaskConnect.tsx | 135 ------ src/components/wallets/PeraConnect.tsx | 109 ----- src/components/wallets/SodotConnect.tsx | 233 ++++++++-- src/components/wallets/UniSatConnect.tsx | 78 ---- src/components/wallets/WalletSelection.tsx | 6 +- src/components/wallets/WalletSigner.tsx | 62 +-- .../api/sodot-proxy/derive-chain-pubkey.ts | 106 ++--- src/signers/Sodot.ts | 20 + 12 files changed, 389 insertions(+), 1058 deletions(-) delete mode 100644 src/components/wallets/Ethers.tsx delete mode 100644 src/components/wallets/KeplrConnect.tsx delete mode 100644 src/components/wallets/LitescribeConnect.tsx delete mode 100644 src/components/wallets/MetamaskConnect.tsx delete mode 100644 src/components/wallets/PeraConnect.tsx delete mode 100644 src/components/wallets/UniSatConnect.tsx diff --git a/src/app/sodot-test/page.tsx b/src/app/sodot-test/page.tsx index 7bb9537a..ac4a21a8 100644 --- a/src/app/sodot-test/page.tsx +++ b/src/app/sodot-test/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { SodotSigner } from "~/signers/Sodot"; @@ -10,7 +10,23 @@ import { AdamikSignerSpec, } from "~/utils/types"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; -import { AlertCircle, CheckCircle2, Loader2, Lock, Server } from "lucide-react"; +import { + AlertCircle, + CheckCircle2, + Loader2, + Lock, + Server, + ChevronDown, +} from "lucide-react"; +import { getChains } from "~/api/adamik/chains"; +import { Chain } from "~/utils/types"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; type VertexKeysResult = { status?: number; @@ -44,6 +60,23 @@ export default function SodotTestPage() { const [results, setResults] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [chains, setChains] = useState | null>(null); + const [selectedChain, setSelectedChain] = useState(""); + + // Fetch chains when component mounts + useEffect(() => { + const fetchChains = async () => { + try { + const chainsData = await getChains(); + setChains(chainsData); + } catch (e) { + console.error("Error fetching chains:", e); + setError("Failed to load chain information"); + } + }; + + fetchChains(); + }, []); // Add effect to monitor state changes console.log("Component render - Current state:", { @@ -51,202 +84,32 @@ export default function SodotTestPage() { error, success, loading, + chains, + selectedChain, }); - const testEthereumPubkey = async () => { - setLoading(true); - setError(null); - setSuccess(null); - try { - console.log("Starting Ethereum pubkey test"); - - // Call our backend endpoint for Ethereum pubkey - console.log("Fetching pubkey from backend"); - const response = await fetch( - "/api/sodot-proxy/derive-chain-pubkey?chain=ethereum", - { - method: "GET", - cache: "no-store", - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}` - ); - } - - const data = await response.json(); - console.log("Received pubkey data:", data); - - // Get the pubkey from the response - const pubkey = data.data.pubkey; - console.log("Extracted pubkey:", pubkey); - - // Use the Adamik API to get the address from the pubkey - console.log("Calling encodePubKeyToAddress for Ethereum"); - const addressResult = await encodePubKeyToAddress(pubkey, "ethereum"); - console.log("Received address result:", addressResult); - const address = addressResult.address; - - // Store the results - console.log("Setting state with pubkey and address"); - setResults( - (prev: Results | null): Results => ({ - ...prev, - chainPubkey: { - pubkey, - address, - chainId: "ethereum", - curve: "SECP256K1", - }, - }) - ); - console.log("State updated, setting success message"); - setSuccess("Successfully retrieved Ethereum pubkey and address"); - } catch (e: any) { - console.error("Error in testEthereumPubkey:", e); - setError(e.message || "Unknown error occurred"); - } finally { - console.log("Setting loading to false"); - setLoading(false); + const testChainPubkey = async () => { + if (!chains || !selectedChain) { + setError("Please select a chain first"); + return; } - }; - - const testBitcoinPubkey = async () => { - setLoading(true); - setError(null); - setSuccess(null); - try { - console.log("Starting Bitcoin pubkey test"); - - // Call our backend endpoint for Bitcoin pubkey - console.log("Fetching pubkey from backend"); - const response = await fetch( - "/api/sodot-proxy/derive-chain-pubkey?chain=bitcoin", - { - method: "GET", - cache: "no-store", - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}` - ); - } - - const data = await response.json(); - console.log("Received pubkey data:", data); - // Get the pubkey from the response - const pubkey = data.data.pubkey; - console.log("Extracted pubkey:", pubkey); - - // Use the Adamik API to get the address from the pubkey - console.log("Calling encodePubKeyToAddress for Bitcoin"); - const addressResult = await encodePubKeyToAddress(pubkey, "bitcoin"); - console.log("Received address result:", addressResult); - const address = addressResult.address; - - // Store the results - console.log("Setting state with pubkey and address"); - setResults( - (prev: Results | null): Results => ({ - ...prev, - chainPubkey: { - pubkey, - address, - chainId: "bitcoin", - curve: "SECP256K1", - }, - }) - ); - console.log("State updated, setting success message"); - setSuccess("Successfully retrieved Bitcoin pubkey and address"); - } catch (e: any) { - console.error("Error in testBitcoinPubkey:", e); - setError(e.message || "Unknown error occurred"); - } finally { - console.log("Setting loading to false"); - setLoading(false); + if (!chains[selectedChain]) { + setError(`Chain ${selectedChain} not found in available chains`); + return; } - }; - const testTONPubkey = async () => { setLoading(true); setError(null); setSuccess(null); - try { - console.log("Starting TON pubkey test"); - - // Call our backend endpoint for TON pubkey - console.log("Fetching pubkey from backend"); - const response = await fetch( - "/api/sodot-proxy/derive-chain-pubkey?chain=ton", - { - method: "GET", - cache: "no-store", - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}` - ); - } - - const data = await response.json(); - console.log("Received pubkey data:", data); - - // Get the pubkey from the response - const pubkey = data.data.pubkey; - console.log("Extracted TON pubkey:", pubkey); - - // Use the Adamik API to get the address from the pubkey - console.log("Calling encodePubKeyToAddress for TON"); - const addressResult = await encodePubKeyToAddress(pubkey, "ton"); - console.log("Received TON address result:", addressResult); - const address = addressResult.address; - - // Store the results - console.log("Setting state with TON pubkey and address"); - setResults( - (prev: Results | null): Results => ({ - ...prev, - chainPubkey: { - pubkey, - address, - chainId: "ton", - curve: "SECP256K1", - }, - }) - ); - console.log("State updated, setting success message"); - setSuccess("Successfully retrieved TON pubkey and address"); - } catch (e: any) { - console.error("Error in testTONPubkey:", e); - setError(e.message || "Unknown error occurred"); - } finally { - console.log("Setting loading to false"); - setLoading(false); - } - }; - const testTronPubkey = async () => { - setLoading(true); - setError(null); - setSuccess(null); try { - console.log("Starting Tron pubkey test"); + console.log(`Starting ${selectedChain} pubkey test`); - // Call our backend endpoint for Tron pubkey + // Call our backend endpoint for the chain pubkey console.log("Fetching pubkey from backend"); const response = await fetch( - "/api/sodot-proxy/derive-chain-pubkey?chain=tron", + `/api/sodot-proxy/derive-chain-pubkey?chain=${selectedChain}`, { method: "GET", cache: "no-store", @@ -265,92 +128,35 @@ export default function SodotTestPage() { // Get the pubkey from the response const pubkey = data.data.pubkey; - console.log("Extracted pubkey:", pubkey); + console.log(`Extracted ${selectedChain} pubkey:`, pubkey); // Use the Adamik API to get the address from the pubkey - console.log("Calling encodePubKeyToAddress for Tron"); - const addressResult = await encodePubKeyToAddress(pubkey, "tron"); - console.log("Received address result:", addressResult); + console.log(`Calling encodePubKeyToAddress for ${selectedChain}`); + const addressResult = await encodePubKeyToAddress(pubkey, selectedChain); + console.log(`Received ${selectedChain} address result:`, addressResult); const address = addressResult.address; // Store the results console.log("Setting state with pubkey and address"); - setResults( - (prev: Results | null): Results => ({ - ...prev, - chainPubkey: { - pubkey, - address, - chainId: "tron", - curve: "SECP256K1", - }, - }) - ); - console.log("State updated, setting success message"); - setSuccess("Successfully retrieved Tron pubkey and address"); - } catch (e: any) { - console.error("Error in testTronPubkey:", e); - setError(e.message || "Unknown error occurred"); - } finally { - console.log("Setting loading to false"); - setLoading(false); - } - }; - - const testAlgorandPubkey = async () => { - setLoading(true); - setError(null); - setSuccess(null); - try { - console.log("Starting Algorand pubkey test"); - - // Call our backend endpoint for Algorand pubkey - console.log("Fetching pubkey from backend"); - const response = await fetch( - "/api/sodot-proxy/derive-chain-pubkey?chain=algorand", - { - method: "GET", - cache: "no-store", - } - ); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}` - ); - } - - const data = await response.json(); - console.log("Received pubkey data:", data); - - // Get the pubkey from the response - const pubkey = data.data.pubkey; - console.log("Extracted Algorand pubkey:", pubkey); - - // Use the Adamik API to get the address from the pubkey - console.log("Calling encodePubKeyToAddress for Algorand"); - const addressResult = await encodePubKeyToAddress(pubkey, "algorand"); - console.log("Received Algorand address result:", addressResult); - const address = addressResult.address; + const chainInfo = chains[selectedChain]; + const curveType = + chainInfo.signerSpec.curve === "secp256k1" ? "SECP256K1" : "ED25519"; - // Store the results - console.log("Setting state with Algorand pubkey and address"); setResults( (prev: Results | null): Results => ({ ...prev, chainPubkey: { pubkey, address, - chainId: "algorand", - curve: "ED25519", + chainId: selectedChain, + curve: curveType, }, }) ); console.log("State updated, setting success message"); - setSuccess("Successfully retrieved Algorand pubkey and address"); + setSuccess(`Successfully retrieved ${selectedChain} pubkey and address`); } catch (e: any) { - console.error("Error in testAlgorandPubkey:", e); + console.error(`Error in testChainPubkey for ${selectedChain}:`, e); setError(e.message || "Unknown error occurred"); } finally { console.log("Setting loading to false"); @@ -358,6 +164,16 @@ export default function SodotTestPage() { } }; + const testSupportedChains = [ + "ethereum", + "bitcoin", + "ton", + "tron", + "algorand", + "solana", + "cosmos", + ]; + const getVertexKeys = async () => { setLoading(true); setError(null); @@ -405,7 +221,7 @@ export default function SodotTestPage() { Connection Test -
    +
    - - - ) : ( - "Tron" - )} - - +
    {error && ( diff --git a/src/components/wallets/Ethers.tsx b/src/components/wallets/Ethers.tsx deleted file mode 100644 index 8d4599db..00000000 --- a/src/components/wallets/Ethers.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { WalletConnectorProps } from "./types"; -import { useChains } from "~/hooks/useChains"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; - -/** - * Ethers signer - FOR TESTING ONLY - */ -export const EthersConnect: React.FC = ({ - chainId, - transactionPayload, -}) => { - /* - const { sdk } = useSDK(); - const { toast } = useToast(); - const { setTransactionHash } = useTransaction(); - const { addAddresses } = useWallet(); - */ - - const { data: chains } = useChains(); - - const evmChains = useMemo( - () => - chains && Object.values(chains).filter((chain) => chain.family === "evm"), - [chains] - ); - - const evmChainIds = useMemo( - () => evmChains && evmChains.map((chain) => chain.id), - [evmChains] - ); - - const getAddresses = useCallback(async () => { - // TODO - }, []); - - const sign = useCallback(async () => { - // TODO - }, []); - - return ( -
    - sign() : () => getAddresses()} - > - - Ethers (TESTING ONLY) - -
    - ); -}; diff --git a/src/components/wallets/KeplrConnect.tsx b/src/components/wallets/KeplrConnect.tsx deleted file mode 100644 index d3392117..00000000 --- a/src/components/wallets/KeplrConnect.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useWalletClient } from "@cosmos-kit/react-lite"; -import React, { useCallback, useEffect } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; -import { useToast } from "~/components/ui/use-toast"; -import { useChains } from "~/hooks/useChains"; -import { useTransaction } from "~/hooks/useTransaction"; -import { useWallet } from "~/hooks/useWallet"; -import { WalletConnectorProps, WalletName } from "./types"; - -const cosmosChainIdsMapping = new Map(); - -/** - * Keplr: - * - Returns 1 single address for each chain ID - */ -export const KeplrConnect: React.FC = ({ - chainId, - transactionPayload, -}) => { - const { status, client } = useWalletClient("keplr-extension"); - const { toast } = useToast(); - const { data: chains } = useChains(); - const { addAddresses } = useWallet(); - - // Build a mapping table of: adamik chain IDs <> cosmos native chain IDs - useEffect(() => { - chains && - Object.values(chains) - .filter((chain) => chain.family === "cosmos") - - // NOTE Possible to loop over all supported chains for full discovery - .filter((chain) => - ["cosmoshub", "osmosis", "celestia", "dydx", "injective"].includes( - chain.id - ) - ) - .forEach((chain) => - cosmosChainIdsMapping.set(chain.id, chain.nativeId) - ); - }, [chains]); - - const { transaction, setTransaction } = useTransaction(); - - const getAddresses = useCallback(async () => { - if (status === "Done" && client) { - const nativeIds = Array.from(cosmosChainIdsMapping.values()); - - // Try to enable Keplr client with all known native chain IDs - for (const nativeId of nativeIds) { - try { - await client.enable?.(nativeId); - } catch (err) { - console.warn("Failed to connect to Keplr wallet...", err); - // Remove the unsupported ones - cosmosChainIdsMapping.delete(nativeId); - } - } - - // For each supported (Adamik) chain ID, get its (single) address from Keplr - cosmosChainIdsMapping.forEach(async (nativeId, chainId) => { - try { - const account = await client.getAccount?.(nativeId); - if (account) { - addAddresses([ - { - address: account.address, - pubKey: Buffer.from(account.pubkey).toString("hex"), - chainId, - signer: WalletName.KEPLR, - }, - ]); - } - } catch (err) { - console.warn("Failed to connect to Keplr wallet...", err); - return; - } - }); - - toast({ - description: - "Connected to Keplr, please check portfolio page to see your assets", - }); - } - }, [status, client, addAddresses, toast]); - - const sign = useCallback(async () => { - if (client && chains && transactionPayload) { - const nativeId = chainId && cosmosChainIdsMapping.get(chainId); - - if (!nativeId) { - throw new Error(`${chainId} is not supported by Keplr wallet`); - } - - const signedTransaction = await client.signDirect?.( - nativeId, - transactionPayload.data.senderAddress, - JSON.parse(transactionPayload.encoded), - { preferNoSetFee: true } // Tell Keplr not to recompute fees after us - ); - - transaction && - signedTransaction && - setTransaction({ - ...transaction, - signature: signedTransaction?.signature.signature, - }); - } - }, [ - chains, - chainId, - client, - setTransaction, - transaction, - transactionPayload, - ]); - - return ( -
    - sign() : () => getAddresses()} - > - - Keplr - -
    - ); -}; diff --git a/src/components/wallets/LitescribeConnect.tsx b/src/components/wallets/LitescribeConnect.tsx deleted file mode 100644 index 46ac3ca3..00000000 --- a/src/components/wallets/LitescribeConnect.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; -import { useToast } from "~/components/ui/use-toast"; -import { useTransaction } from "~/hooks/useTransaction"; -import { useWallet } from "~/hooks/useWallet"; -import { Account, WalletConnectorProps, WalletName } from "./types"; - -export const LitescribeConnect: React.FC = ({ - transactionPayload, -}) => { - const { toast } = useToast(); - const { transaction, setTransaction } = useTransaction(); - const { addAddresses } = useWallet(); - - const getAddresses = useCallback(async () => { - try { - const accounts = await window.litescribe.requestAccounts(); - - const addresses: Account[] = []; - for (const address of accounts) { - addresses.push({ - address, - chainId: "litecoin", - signer: WalletName.LITESCRIBE, - }); - } - - addAddresses(addresses); - - toast({ - description: - "Connected to Litescribe Wallet, please check portfolio page to see your assets", - }); - } catch (e) { - toast({ - description: - "Failed to connect to Litescribe Wallet, verify if you allow connectivity", - variant: "destructive", - }); - throw e; - } finally { - console.log(window.litescribe); - // FIXME: litescribe does not support disconnect and requires the user to manually disconnect - // - Because litescribe is a fork of unisat, we would expect to have the same interface, but that function doesn't. - // - Unisat implemented the disconnect feature recently: https://github.com/unisat-wallet/extension/commit/645a8a4f5d7743d2e6097f7f17bd8a19f0c4bc7e - // after it was forked. - // await window.litescribe.disconnect(); - } - }, [toast, addAddresses]); - - const sign = useCallback(async () => { - if (!transactionPayload) { - return; - } - - try { - const signature = await window.litescribe.signPsbt( - transactionPayload.encoded - ); - - transaction && setTransaction({ ...transaction, signature }); - } catch (err) { - console.warn("Failed to sign with Litescribe wallet: ", err); - toast({ - description: "Transaction failed", - variant: "destructive", - }); - } - }, [setTransaction, toast, transaction, transactionPayload]); - - return ( -
    - sign() : () => getAddresses()} - > - - Litescribe Wallet - -
    - ); -}; diff --git a/src/components/wallets/MetamaskConnect.tsx b/src/components/wallets/MetamaskConnect.tsx deleted file mode 100644 index 0caf1ec0..00000000 --- a/src/components/wallets/MetamaskConnect.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useSDK } from "@metamask/sdk-react"; -import React, { useCallback, useMemo } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; -import { useToast } from "~/components/ui/use-toast"; -import { useChains } from "~/hooks/useChains"; -import { useTransaction } from "~/hooks/useTransaction"; -import { useWallet } from "~/hooks/useWallet"; -import { etherumNetworkConfig } from "~/utils/ethereumNetworks"; -import { Account, WalletConnectorProps, WalletName } from "./types"; - -/** - * Metamask: - * - Returns more than 1 address - * - Each address is valid for several (all?) supported chain IDs - */ -export const MetamaskConnect: React.FC = ({ - chainId, - transactionPayload, -}) => { - const { sdk } = useSDK(); - const { toast } = useToast(); - const { setTransactionHash } = useTransaction(); - const { data: chains } = useChains(); - const { addAddresses } = useWallet(); - - const evmChains = useMemo( - () => - chains && Object.values(chains).filter((chain) => chain.family === "evm"), - [chains] - ); - - const evmChainIds = useMemo( - () => evmChains && evmChains.map((chain) => chain.id), - [evmChains] - ); - - const getAddresses = useCallback(async () => { - try { - const metamaskAddresses: string[] | undefined = await sdk?.connect(); - - if (metamaskAddresses && evmChainIds) { - const addresses: Account[] = []; - // NOTE Should we add all addresses from Metamask? Only the 1st one? Let the user choose? - for (const address of metamaskAddresses) { - // NOTE Possible to loop over all supported chains for full discovery - //for (const chainId of evmChainIds) - for (const chainId of [ - "ethereum", - "optimism", - "arbitrum", - "base", - "polygon", - ]) - addresses.push({ - address, - chainId, - signer: WalletName.METAMASK, - }); - } - - addAddresses(addresses); - - toast({ - description: - "Connected to Metamask, please check portfolio page to see your assets", - }); - } else { - toast({ - description: - "Failed to connect to Metamask, verify if you allow connectivity", - variant: "destructive", - }); - } - } catch (err) { - console.warn("Failed to connect to Metamask wallet..", err); - } - }, [sdk, addAddresses, toast, evmChainIds]); - - const sign = useCallback(async () => { - const provider = sdk?.getProvider(); - - if (provider && transactionPayload) { - const chain = evmChains?.find((chain) => chain.id === chainId); - - if (!chain) { - throw new Error(`${chainId} is not supported by Metamask wallet`); - } - - try { - await provider.request({ - method: "wallet_switchEthereumChain", - params: [{ chainId: "0x" + Number(chain.nativeId).toString(16) }], - }); - } catch (switchError: any) { - if (switchError.code === 4902) { - try { - await provider.request({ - method: "wallet_addEthereumChain", - params: [etherumNetworkConfig[chain.params.name]], - }); - } catch (addError) { - throw addError; - } - } - throw switchError; - } - - const txHash = await provider.request({ - method: "eth_sendTransaction", - params: [JSON.parse(transactionPayload.encoded)], - }); - - if (typeof txHash === "string") { - setTransactionHash(txHash); - } else { - toast({ - description: "Transaction failed", - variant: "destructive", - }); - } - } - }, [sdk, chainId, evmChains, transactionPayload, setTransactionHash, toast]); - - return ( -
    - sign() : () => getAddresses()} - > - - Metamask - -
    - ); -}; diff --git a/src/components/wallets/PeraConnect.tsx b/src/components/wallets/PeraConnect.tsx deleted file mode 100644 index 1de1a9ac..00000000 --- a/src/components/wallets/PeraConnect.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import algosdk from "algosdk"; -import { PeraWalletConnect } from "@perawallet/connect"; -import { Account, WalletConnectorProps, WalletName } from "./types"; -import { useCallback } from "react"; -import { useTransaction } from "~/hooks/useTransaction"; -import { useToast } from "~/components/ui/use-toast"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; -import { useWallet } from "~/hooks/useWallet"; - -/** - * Pera: - * - Returns more than 1 address - * - Only valid chain ID for all addresses is 'algorand' - */ -export const PeraConnect: React.FC = ({ - transactionPayload, -}) => { - const { toast } = useToast(); - const { transaction, setTransaction } = useTransaction(); - const { addAddresses } = useWallet(); - - const getAddresses = useCallback(async () => { - const peraWallet = new PeraWalletConnect(); - - try { - let peraAddresses = await peraWallet.reconnectSession(); - if (peraAddresses.length === 0) { - peraAddresses = await peraWallet.connect(); - } - - const addresses: Account[] = []; - for (const address of peraAddresses) { - addresses.push({ - address, - chainId: "algorand", - signer: WalletName.PERA, - }); - } - - addAddresses(addresses); - - toast({ - description: - "Connected to Pera Wallet, please check portfolio page to see your assets", - }); - } catch (e) { - toast({ - description: - "Failed to connect to Pera Wallet, verify if you allow connectivity", - variant: "destructive", - }); - peraWallet?.disconnect(); - throw e; - } - }, [toast, addAddresses]); - - const sign = useCallback(async () => { - if (!transactionPayload) { - return; - } - - const peraWallet = new PeraWalletConnect(); - - try { - (await peraWallet.reconnectSession()) || (await peraWallet.connect()); - } catch (e) { - toast({ - description: - "Failed to connect to Pera Wallet, verify if you allow connectivity", - variant: "destructive", - }); - peraWallet?.disconnect(); - throw e; - } - - const signatureBytes = await peraWallet.signTransaction([ - [ - { - // FIXME: The app shouldn't have to use a chain SDK, we could provide an Adamik SDK instead - txn: algosdk.decodeUnsignedTransaction( - new Uint8Array(Buffer.from(transactionPayload.encoded, "hex")) - ), - }, - ], - ]); - - const signature = Buffer.from(signatureBytes[0]).toString("hex"); - - transaction && setTransaction({ ...transaction, signature }); - /* - toast({ - description: "Transaction failed", - variant: "destructive", - }); - */ - }, [setTransaction, toast, transaction, transactionPayload]); - - return ( -
    - sign() : () => getAddresses()} - > - - Pera Wallet - -
    - ); -}; diff --git a/src/components/wallets/SodotConnect.tsx b/src/components/wallets/SodotConnect.tsx index d5524ca1..7f3fa918 100644 --- a/src/components/wallets/SodotConnect.tsx +++ b/src/components/wallets/SodotConnect.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useState, useEffect } from "react"; import { useToast } from "~/components/ui/use-toast"; import { useWallet } from "~/hooks/useWallet"; import { Account, WalletConnectorProps, WalletName } from "./types"; @@ -7,37 +7,104 @@ import { AdamikCurve, AdamikHashFunction, AdamikSignerSpec, + Chain, } from "~/utils/types"; import { Button } from "~/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { getChains } from "~/api/adamik/chains"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Card } from "~/components/ui/card"; export const SodotConnect: React.FC = ({ - chainId, + chainId: providedChainId, transactionPayload, }) => { const { toast } = useToast(); const { addAddresses } = useWallet(); + const [loading, setLoading] = useState(false); + const [chains, setChains] = useState | null>(null); + const [error, setError] = useState(null); + const [selectedChainId, setSelectedChainId] = useState( + providedChainId || "" + ); + + // Fetch chains data when component mounts + useEffect(() => { + const fetchChains = async () => { + try { + const chainsData = await getChains(); + if (chainsData) { + setChains(chainsData); + } else { + setError("Failed to load chain information"); + } + } catch (e) { + console.error("Error fetching chains:", e); + setError("Failed to load chain information"); + } + }; + + fetchChains(); + }, []); + + // Update selected chain when providedChainId changes + useEffect(() => { + if (providedChainId) { + setSelectedChainId(providedChainId); + } + }, [providedChainId]); const getAddresses = useCallback(async () => { + if (!chains || !selectedChainId) { + toast({ + description: "Please select a chain", + variant: "destructive", + }); + return; + } + + const chain = chains[selectedChainId]; + if (!chain) { + toast({ + description: `Chain ${selectedChainId} not supported`, + variant: "destructive", + }); + return; + } + + setLoading(true); + try { - // Initialize Sodot signer with appropriate curve based on chain - const curve = - chainId === "bitcoin" ? AdamikCurve.SECP256K1 : AdamikCurve.ED25519; + // Create signer spec from the chain data const signerSpec: AdamikSignerSpec = { - curve, - hashFunction: AdamikHashFunction.SHA256, - coinType: "0", - signatureFormat: "der", + curve: + chain.signerSpec.curve === "secp256k1" + ? AdamikCurve.SECP256K1 + : AdamikCurve.ED25519, + hashFunction: + chain.signerSpec.hashFunction === "keccak256" + ? AdamikHashFunction.KECCAK256 + : AdamikHashFunction.SHA256, + coinType: chain.signerSpec.coinType, + signatureFormat: chain.signerSpec.signatureFormat, }; - const sodotSigner = new SodotSigner(chainId || "ethereum", signerSpec); + const sodotSigner = new SodotSigner(selectedChainId, signerSpec); - // Get public key + // Get public key and address const pubkey = await sodotSigner.getPubkey(); + const address = await sodotSigner.getAddress(); - // Create account with the public key + // Create account with the address and public key const account: Account = { - address: pubkey, // Using pubkey as address for now - chainId: chainId || "ethereum", + address: address, + chainId: selectedChainId, pubKey: pubkey, signer: WalletName.SODOT, }; @@ -49,49 +116,149 @@ export const SodotConnect: React.FC = ({ "Connected to Sodot Wallet, please check portfolio page to see your assets", }); } catch (e) { + console.error("Sodot connection error:", e); toast({ - description: "Failed to connect to Sodot Wallet, please try again", + description: `Failed to connect to Sodot Wallet: ${ + e instanceof Error ? e.message : "Unknown error" + }`, variant: "destructive", }); - throw e; + } finally { + setLoading(false); } - }, [chainId, addAddresses, toast]); + }, [selectedChainId, addAddresses, toast, chains]); const sign = useCallback(async () => { - if (!transactionPayload) return; + if (!transactionPayload || !chains || !providedChainId) return; + + const chain = chains[providedChainId]; + if (!chain) { + toast({ + description: `Chain ${providedChainId} not supported`, + variant: "destructive", + }); + return; + } + + setLoading(true); try { - const curve = - chainId === "bitcoin" ? AdamikCurve.SECP256K1 : AdamikCurve.ED25519; + // Create signer spec from the chain data const signerSpec: AdamikSignerSpec = { - curve, - hashFunction: AdamikHashFunction.SHA256, - coinType: "0", - signatureFormat: "der", + curve: + chain.signerSpec.curve === "secp256k1" + ? AdamikCurve.SECP256K1 + : AdamikCurve.ED25519, + hashFunction: + chain.signerSpec.hashFunction === "keccak256" + ? AdamikHashFunction.KECCAK256 + : AdamikHashFunction.SHA256, + coinType: chain.signerSpec.coinType, + signatureFormat: chain.signerSpec.signatureFormat, }; - const sodotSigner = new SodotSigner(chainId || "ethereum", signerSpec); + const sodotSigner = new SodotSigner(providedChainId, signerSpec); const signature = await sodotSigner.signTransaction( transactionPayload.encoded ); // Handle the signature as needed console.log("Transaction signed:", signature); + + toast({ + description: "Transaction signed successfully", + }); } catch (err) { console.warn("Failed to sign with Sodot wallet:", err); toast({ - description: "Transaction failed", + description: err instanceof Error ? err.message : "Transaction failed", variant: "destructive", }); + } finally { + setLoading(false); } - }, [chainId, transactionPayload, toast]); + }, [providedChainId, transactionPayload, toast, chains]); + + if (error) { + return ( + + ); + } + + if (!chains) { + return ( + + ); + } + + // If a specific chainId is provided or we're signing a transaction, show simple connect/sign button + if (providedChainId || transactionPayload) { + return ( + + ); + } + // If no chainId is provided, show chain selector return ( - + +

    Connect Sodot Wallet

    +
    +
    + + +
    + +
    +
    ); }; diff --git a/src/components/wallets/UniSatConnect.tsx b/src/components/wallets/UniSatConnect.tsx deleted file mode 100644 index 3d72c28f..00000000 --- a/src/components/wallets/UniSatConnect.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useCallback } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; -import { useToast } from "~/components/ui/use-toast"; -import { useTransaction } from "~/hooks/useTransaction"; -import { useWallet } from "~/hooks/useWallet"; -import { Account, WalletConnectorProps, WalletName } from "./types"; - -export const UniSatConnect: React.FC = ({ - transactionPayload, -}) => { - const { toast } = useToast(); - const { transaction, setTransaction } = useTransaction(); - const { addAddresses } = useWallet(); - - const getAddresses = useCallback(async () => { - try { - const accounts = await window.unisat.requestAccounts(); - - const addresses: Account[] = []; - for (const address of accounts) { - addresses.push({ - address, - chainId: "bitcoin", - signer: WalletName.UNISAT, - }); - } - - addAddresses(addresses); - - toast({ - description: - "Connected to UniSat Wallet, please check portfolio page to see your assets", - }); - } catch (e) { - toast({ - description: - "Failed to connect to Unisat Wallet, verify if you allow connectivity", - variant: "destructive", - }); - throw e; - } finally { - await window.unisat.disconnect(); - } - }, [toast, addAddresses]); - - const sign = useCallback(async () => { - if (!transactionPayload) { - return; - } - - try { - // TODO: support multiple chains (LTC, DOGE...etc) - const signature = await window.unisat.signPsbt( - transactionPayload.encoded - ); - - transaction && setTransaction({ ...transaction, signature }); - } catch (err) { - console.warn("Failed to sign with UniSat wallet: ", err); - toast({ - description: "Transaction failed", - variant: "destructive", - }); - } - }, [setTransaction, toast, transaction, transactionPayload]); - - return ( -
    - sign() : () => getAddresses()} - > - - UniSat Wallet - -
    - ); -}; diff --git a/src/components/wallets/WalletSelection.tsx b/src/components/wallets/WalletSelection.tsx index dbc5251a..45b0e045 100644 --- a/src/components/wallets/WalletSelection.tsx +++ b/src/components/wallets/WalletSelection.tsx @@ -6,9 +6,11 @@ import { SodotConnect } from "./SodotConnect"; export const WalletSelection: React.FC = (props) => { return ( -
    -
    +
    +

    Select a wallet

    +
    + {/* Other wallet connectors removed */}
    ); diff --git a/src/components/wallets/WalletSigner.tsx b/src/components/wallets/WalletSigner.tsx index d6fb9285..0989b355 100644 --- a/src/components/wallets/WalletSigner.tsx +++ b/src/components/wallets/WalletSigner.tsx @@ -7,15 +7,10 @@ import { toast } from "~/components/ui/use-toast"; import { useTransaction } from "~/hooks/useTransaction"; import { useWallet } from "~/hooks/useWallet"; import { BroadcastModal } from "./BroadcastModal"; -import { KeplrConnect } from "./KeplrConnect"; -import { MetamaskConnect } from "./MetamaskConnect"; -import { PeraConnect } from "./PeraConnect"; import { WalletName } from "./types"; import { Modal } from "~/components/ui/modal"; -import { UniSatConnect } from "./UniSatConnect"; -import { LitescribeConnect } from "./LitescribeConnect"; +import { SodotConnect } from "./SodotConnect"; import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; import Link from "next/link"; import { ConnectWallet } from "../../app/portfolio/ConnectWallet"; @@ -38,33 +33,14 @@ export const WalletSigner = ({ onNextStep }: { onNextStep: () => void }) => { ); const getSignerComponent = () => { - switch (signer?.signer) { - case WalletName.KEPLR: - return ( - - ); - case WalletName.METAMASK: - return ( - - ); - case WalletName.PERA: - return ( - - ); - case WalletName.UNISAT: - return ( - - ); - case WalletName.LITESCRIBE: - return ( - - ); - default: - return null; + // Only support Sodot wallet + if (signer?.signer === WalletName.SODOT) { + return ( + + ); } + // No other supported wallets + return null; }; const handleCopyToClipboard = () => { @@ -90,22 +66,6 @@ export const WalletSigner = ({ onNextStep }: { onNextStep: () => void }) => { } }; - const shortenHash = (hash: string) => { - return `${hash.slice(0, 6)}...${hash.slice(-6)}`; - }; - - const handleViewTx = () => { - if (transactionHash && chainId) { - const url = `/data?chainId=${chainId}&transactionId=${transactionHash}`; - router.push(url); - } else { - console.error("Missing transactionHash or chainId", { - transactionHash, - chainId, - }); - } - }; - const handleClose = () => { onNextStep(); setChainId(undefined); @@ -182,12 +142,14 @@ export const WalletSigner = ({ onNextStep }: { onNextStep: () => void }) => { return (

    - Sign with your wallet + Sign with Sodot Wallet

    Please verify your transaction before approving
    -
    {getSignerComponent()}
    +
    + {getSignerComponent()} +
    ); }; diff --git a/src/pages/api/sodot-proxy/derive-chain-pubkey.ts b/src/pages/api/sodot-proxy/derive-chain-pubkey.ts index 5c7bce67..c67e3e03 100644 --- a/src/pages/api/sodot-proxy/derive-chain-pubkey.ts +++ b/src/pages/api/sodot-proxy/derive-chain-pubkey.ts @@ -1,4 +1,6 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { getChains } from "~/api/adamik/chains"; +import { Chain } from "~/utils/types"; // Helper function to get the base URL function getBaseUrl(req: NextApiRequest): string { @@ -12,50 +14,6 @@ function getBaseUrl(req: NextApiRequest): string { return `${protocol}://${host}`; } -// Type definitions for supported chains -type SupportedChain = "ethereum" | "bitcoin" | "ton" | "tron" | "algorand"; - -interface ChainConfig { - curve: "ecdsa" | "ed25519"; - keyIdsEnvVar: string; - coinType: number; - derivationPath: number[]; -} - -// Configuration for each supported chain -const chainConfigs: Record = { - ethereum: { - curve: "ecdsa", - keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", - coinType: 60, - derivationPath: [44, 60, 0, 0, 0], - }, - bitcoin: { - curve: "ecdsa", - keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", - coinType: 0, - derivationPath: [44, 0, 0, 0, 0], - }, - ton: { - curve: "ecdsa", // TON uses secp256k1 (ECDSA) - keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", - coinType: 607, // TON coin type - derivationPath: [44, 607, 0, 0, 0], - }, - tron: { - curve: "ecdsa", - keyIdsEnvVar: "SODOT_EXISTING_ECDSA_KEY_IDS", - coinType: 195, - derivationPath: [44, 195, 0, 0, 0], - }, - algorand: { - curve: "ed25519", - keyIdsEnvVar: "SODOT_EXISTING_ED25519_KEY_IDS", - coinType: 283, // Algorand coin type - derivationPath: [44, 283, 0, 0, 0], - }, -}; - export default async function handler( req: NextApiRequest, res: NextApiResponse @@ -68,32 +26,50 @@ export default async function handler( return res.status(400).json({ status: 400, error: "Missing chain parameter", - message: - "Please specify a chain (ethereum, bitcoin, ton, tron, algorand)", + message: "Please specify a chain parameter", }); } - // Validate supported chain - if (!Object.keys(chainConfigs).includes(chain)) { + // Get chain configurations from Adamik API + const chains = await getChains(); + + if (!chains) { + return res.status(500).json({ + status: 500, + error: "Failed to fetch chain information", + message: "Unable to retrieve chain configurations from Adamik API", + }); + } + + // Find the requested chain + const chainConfig = chains[chain]; + + if (!chainConfig) { return res.status(400).json({ status: 400, error: "Unsupported chain", message: `Chain '${chain}' is not supported. Use one of: ${Object.keys( - chainConfigs + chains ).join(", ")}`, }); } - const chainName = chain as SupportedChain; - const config = chainConfigs[chainName]; + // Get curve type from signerSpec + const curveType = + chainConfig.signerSpec.curve === "secp256k1" ? "ecdsa" : "ed25519"; // Get key IDs for this chain's curve - const keyIdsStr = process.env[config.keyIdsEnvVar]; + const keyIdsEnvVar = + curveType === "ecdsa" + ? "SODOT_EXISTING_ECDSA_KEY_IDS" + : "SODOT_EXISTING_ED25519_KEY_IDS"; + + const keyIdsStr = process.env[keyIdsEnvVar]; if (!keyIdsStr) { return res.status(500).json({ status: 500, error: "Missing key IDs", - message: `No key IDs found in ${config.keyIdsEnvVar} environment variable`, + message: `No key IDs found in ${keyIdsEnvVar} environment variable`, }); } @@ -106,12 +82,16 @@ export default async function handler( }); } + // Construct derivation path based on BIP-44 standard + const coinType = parseInt(chainConfig.signerSpec.coinType); + const derivationPath = [44, coinType, 0, 0, 0]; + const baseUrl = getBaseUrl(req); const vertexId = 0; // We only need to use one vertex for derivation // Use the appropriate vertex to derive the public key const response = await fetch( - `${baseUrl}/api/sodot-proxy/${config.curve}/derive-pubkey?vertex=${vertexId}`, + `${baseUrl}/api/sodot-proxy/${curveType}/derive-pubkey?vertex=${vertexId}`, { method: "POST", headers: { @@ -119,7 +99,7 @@ export default async function handler( }, body: JSON.stringify({ key_id: keyIds[vertexId], - derivation_path: config.derivationPath, + derivation_path: derivationPath, }), } ); @@ -136,7 +116,7 @@ export default async function handler( // Format the response based on chain/curve let pubkey; - if (config.curve === "ed25519") { + if (curveType === "ed25519") { // ED25519 response format: { pubkey: "..." } pubkey = result.data.pubkey; if (!pubkey) { @@ -144,7 +124,7 @@ export default async function handler( } } else { // SECP256K1 response format: { compressed: "...", uncompressed: "..." } - if (chainName === "ethereum" || chainName === "tron") { + if (chainConfig.family === "evm") { pubkey = result.data.uncompressed; if (!pubkey) { throw new Error("Uncompressed pubkey missing from response"); @@ -157,19 +137,13 @@ export default async function handler( } } - // Add prefix if needed for Solana (ED25519 keys in Solana often need a prefix) - if (chainName === "ton" && !pubkey.startsWith("0x")) { - // Some TON implementations might need a prefix - pubkey = pubkey; - } - - // Return just the pubkey to the client + // Return the pubkey to the client return res.status(200).json({ status: 200, data: { pubkey, - curve: config.curve, - chain: chainName, + curve: curveType, + chain: chain, }, }); } catch (error: any) { diff --git a/src/signers/Sodot.ts b/src/signers/Sodot.ts index e905b1de..8cc92b26 100644 --- a/src/signers/Sodot.ts +++ b/src/signers/Sodot.ts @@ -198,6 +198,26 @@ export class SodotSigner { } } + // For development, use mock key IDs when env vars are not available + if (process.env.NODE_ENV !== "production") { + console.log( + `[Sodot] Using mock key ID for ${curve} vertex ${vertexId} in development` + ); + // Use predictable mock key IDs based on curve and vertex + const mockKeyIds = { + ecdsa: ["mock_ecdsa_key_0", "mock_ecdsa_key_1", "mock_ecdsa_key_2"], + ed25519: [ + "mock_ed25519_key_0", + "mock_ed25519_key_1", + "mock_ed25519_key_2", + ], + }; + + if (vertexId < mockKeyIds[curve].length) { + return mockKeyIds[curve][vertexId]; + } + } + throw new Error("No existing key ID found in environment variables"); } catch (error) { console.error( From e94bfef1fe0c71cada5b5343e66275466de53f3b Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Thu, 10 Apr 2025 22:56:05 +0200 Subject: [PATCH 008/146] Implemented SodotConnect --- src/components/wallets/SodotConnect.tsx | 171 +++++++++++------- src/pages/api/sodot-proxy/[chain]/sign.ts | 208 ++++++++++++++++++++++ src/signers/Sodot.ts | 84 ++++++--- 3 files changed, 367 insertions(+), 96 deletions(-) create mode 100644 src/pages/api/sodot-proxy/[chain]/sign.ts diff --git a/src/components/wallets/SodotConnect.tsx b/src/components/wallets/SodotConnect.tsx index 7f3fa918..6b81e8ed 100644 --- a/src/components/wallets/SodotConnect.tsx +++ b/src/components/wallets/SodotConnect.tsx @@ -2,16 +2,11 @@ import React, { useCallback, useState, useEffect } from "react"; import { useToast } from "~/components/ui/use-toast"; import { useWallet } from "~/hooks/useWallet"; import { Account, WalletConnectorProps, WalletName } from "./types"; -import { SodotSigner } from "~/signers/Sodot"; -import { - AdamikCurve, - AdamikHashFunction, - AdamikSignerSpec, - Chain, -} from "~/utils/types"; +import { Chain } from "~/utils/types"; import { Button } from "~/components/ui/button"; import { Loader2 } from "lucide-react"; import { getChains } from "~/api/adamik/chains"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { Select, SelectContent, @@ -21,6 +16,9 @@ import { } from "~/components/ui/select"; import { Card } from "~/components/ui/card"; +// Default chains to show in the UI +const DEFAULT_CHAINS = ["ethereum", "bitcoin", "solana"]; + export const SodotConnect: React.FC = ({ chainId: providedChainId, transactionPayload, @@ -41,6 +39,16 @@ export const SodotConnect: React.FC = ({ const chainsData = await getChains(); if (chainsData) { setChains(chainsData); + + // Auto-select first chain if none provided and we're not in transaction mode + if (!providedChainId && !selectedChainId && !transactionPayload) { + const firstDefaultChain = DEFAULT_CHAINS.find( + (id) => chainsData[id] + ); + if (firstDefaultChain) { + setSelectedChainId(firstDefaultChain); + } + } } else { setError("Failed to load chain information"); } @@ -51,7 +59,7 @@ export const SodotConnect: React.FC = ({ }; fetchChains(); - }, []); + }, [providedChainId, selectedChainId, transactionPayload]); // Update selected chain when providedChainId changes useEffect(() => { @@ -60,6 +68,52 @@ export const SodotConnect: React.FC = ({ } }, [providedChainId]); + const getAddressForChain = async (chainId: string) => { + if (!chains || !chains[chainId]) { + throw new Error(`Chain ${chainId} not supported`); + } + + // Call our backend endpoint for the chain pubkey + console.log(`[SodotConnect] Fetching pubkey for ${chainId}`); + const response = await fetch( + `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const data = await response.json(); + console.log(`[SodotConnect] Received pubkey data for ${chainId}:`, data); + + // Get the pubkey from the response + const pubkey = data.data.pubkey; + console.log(`[SodotConnect] Extracted ${chainId} pubkey:`, pubkey); + + // Use the encodePubKeyToAddress API endpoint directly + console.log(`[SodotConnect] Encoding address for ${chainId}`); + + try { + const { address } = await encodePubKeyToAddress(pubkey, chainId); + console.log(`[SodotConnect] Address for ${chainId}:`, address); + return { pubkey, address }; + } catch (e) { + console.error(`[SodotConnect] Error encoding address:`, e); + throw new Error( + `Failed to encode address: ${ + e instanceof Error ? e.message : String(e) + }` + ); + } + }; + const getAddresses = useCallback(async () => { if (!chains || !selectedChainId) { toast({ @@ -69,37 +123,11 @@ export const SodotConnect: React.FC = ({ return; } - const chain = chains[selectedChainId]; - if (!chain) { - toast({ - description: `Chain ${selectedChainId} not supported`, - variant: "destructive", - }); - return; - } - setLoading(true); + setError(null); try { - // Create signer spec from the chain data - const signerSpec: AdamikSignerSpec = { - curve: - chain.signerSpec.curve === "secp256k1" - ? AdamikCurve.SECP256K1 - : AdamikCurve.ED25519, - hashFunction: - chain.signerSpec.hashFunction === "keccak256" - ? AdamikHashFunction.KECCAK256 - : AdamikHashFunction.SHA256, - coinType: chain.signerSpec.coinType, - signatureFormat: chain.signerSpec.signatureFormat, - }; - - const sodotSigner = new SodotSigner(selectedChainId, signerSpec); - - // Get public key and address - const pubkey = await sodotSigner.getPubkey(); - const address = await sodotSigner.getAddress(); + const { pubkey, address } = await getAddressForChain(selectedChainId); // Create account with the address and public key const account: Account = { @@ -112,8 +140,9 @@ export const SodotConnect: React.FC = ({ addAddresses([account]); toast({ - description: - "Connected to Sodot Wallet, please check portfolio page to see your assets", + description: `Connected Sodot Wallet for ${ + chains[selectedChainId]?.name || selectedChainId + }`, }); } catch (e) { console.error("Sodot connection error:", e); @@ -131,38 +160,30 @@ export const SodotConnect: React.FC = ({ const sign = useCallback(async () => { if (!transactionPayload || !chains || !providedChainId) return; - const chain = chains[providedChainId]; - if (!chain) { - toast({ - description: `Chain ${providedChainId} not supported`, - variant: "destructive", - }); - return; - } - setLoading(true); try { - // Create signer spec from the chain data - const signerSpec: AdamikSignerSpec = { - curve: - chain.signerSpec.curve === "secp256k1" - ? AdamikCurve.SECP256K1 - : AdamikCurve.ED25519, - hashFunction: - chain.signerSpec.hashFunction === "keccak256" - ? AdamikHashFunction.KECCAK256 - : AdamikHashFunction.SHA256, - coinType: chain.signerSpec.coinType, - signatureFormat: chain.signerSpec.signatureFormat, - }; + // Call the API to sign the transaction + const response = await fetch(`/api/sodot-proxy/${providedChainId}/sign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + transaction: transactionPayload.encoded, + }), + }); - const sodotSigner = new SodotSigner(providedChainId, signerSpec); - const signature = await sodotSigner.signTransaction( - transactionPayload.encoded - ); + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const data = await response.json(); + const signature = data.signature; - // Handle the signature as needed console.log("Transaction signed:", signature); toast({ @@ -218,7 +239,7 @@ export const SodotConnect: React.FC = ({ ); } - // If no chainId is provided, show chain selector + // If no chainId is provided, show chain selector with default chains return (

    Connect Sodot Wallet

    @@ -234,10 +255,24 @@ export const SodotConnect: React.FC = ({ + {/* Show default chains first */} + {DEFAULT_CHAINS.map( + (chainId) => + chains[chainId] && ( + + {chains[chainId].name} ({chains[chainId].ticker}) + + ) + )} + + — All Chains — + + {/* Then show all chains */} {Object.entries(chains) + .filter(([chainId]) => !DEFAULT_CHAINS.includes(chainId)) .sort(([, a], [, b]) => a.name.localeCompare(b.name)) - .map(([id, chain]) => ( - + .map(([chainId, chain]) => ( + {chain.name} ({chain.ticker}) ))} diff --git a/src/pages/api/sodot-proxy/[chain]/sign.ts b/src/pages/api/sodot-proxy/[chain]/sign.ts new file mode 100644 index 00000000..a69f92b7 --- /dev/null +++ b/src/pages/api/sodot-proxy/[chain]/sign.ts @@ -0,0 +1,208 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getChains } from "~/api/adamik/chains"; +import { env } from "~/env"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + if (req.method !== "POST") { + return res.status(405).json({ + status: 405, + error: "Method not allowed", + message: "Only POST requests are supported", + }); + } + + // Extract chain from URL and transaction from body + const { chain } = req.query; + const { transaction } = req.body; + + if (!chain || typeof chain !== "string") { + return res.status(400).json({ + status: 400, + error: "Missing chain parameter", + message: "Chain parameter is required", + }); + } + + if (!transaction) { + return res.status(400).json({ + status: 400, + error: "Missing transaction", + message: "Transaction is required in the request body", + }); + } + + // Get chain configurations from Adamik API + const chains = await getChains(); + + if (!chains) { + return res.status(500).json({ + status: 500, + error: "Failed to fetch chain information", + message: "Unable to retrieve chain configurations from Adamik API", + }); + } + + // Find the requested chain + const chainConfig = chains[chain]; + + if (!chainConfig) { + return res.status(400).json({ + status: 400, + error: "Unsupported chain", + message: `Chain '${chain}' is not supported. Use one of: ${Object.keys( + chains + ).join(", ")}`, + }); + } + + // Get curve type from signerSpec + const curveType = + chainConfig.signerSpec.curve === "secp256k1" ? "ecdsa" : "ed25519"; + + // Get key IDs for this chain's curve + let keyIdsStr: string | undefined; + if (curveType === "ecdsa") { + keyIdsStr = process.env.SODOT_EXISTING_ECDSA_KEY_IDS; + } else { + keyIdsStr = process.env.SODOT_EXISTING_ED25519_KEY_IDS; + } + + if (!keyIdsStr) { + return res.status(500).json({ + status: 500, + error: "Missing key IDs", + message: `No key IDs found for ${curveType} curve in environment variables`, + }); + } + + const keyIds = keyIdsStr.split(","); + if (keyIds.length < 3) { + return res.status(500).json({ + status: 500, + error: "Insufficient key IDs", + message: `Found only ${keyIds.length} key IDs, need at least 3`, + }); + } + + // Construct derivation path based on BIP-44 standard + const coinType = parseInt(chainConfig.signerSpec.coinType); + const derivationPath = [44, coinType, 0, 0, 0]; + + // Step 1: Create a signing room + const createRoomResponse = await fetch( + `${env.SODOT_VERTEX_URL_0}/create-room`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: env.SODOT_VERTEX_API_KEY_0, + }, + body: JSON.stringify({ + room_size: 3, // We have 3 vertices + }), + } + ); + + if (!createRoomResponse.ok) { + const errorText = await createRoomResponse.text(); + return res.status(500).json({ + status: 500, + error: "Failed to create signing room", + message: `Error: ${errorText}`, + }); + } + + const roomData = await createRoomResponse.json(); + const roomUuid = roomData.room_uuid; + + // Ensure the transaction is properly formatted + let formattedTransaction = transaction; + if (formattedTransaction.startsWith("0x")) { + formattedTransaction = formattedTransaction.substring(2); + } + + // Step 2: Sign the transaction with each vertex + const signPromises = keyIds.map(async (keyId: string, index: number) => { + // Get the vertex URL and API key + let vertexUrl: string | undefined; + let apiKey: string | undefined; + + if (index === 0) { + vertexUrl = env.SODOT_VERTEX_URL_0; + apiKey = env.SODOT_VERTEX_API_KEY_0; + } else if (index === 1) { + vertexUrl = env.SODOT_VERTEX_URL_1; + apiKey = env.SODOT_VERTEX_API_KEY_1; + } else if (index === 2) { + vertexUrl = env.SODOT_VERTEX_URL_2; + apiKey = env.SODOT_VERTEX_API_KEY_2; + } + + if (!vertexUrl || !apiKey) { + throw new Error(`Missing configuration for vertex ${index}`); + } + + const response = await fetch(`${vertexUrl}/${curveType}/sign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + }, + body: JSON.stringify({ + room_uuid: roomUuid, + key_id: keyId, + msg: formattedTransaction, + derivation_path: derivationPath, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Vertex ${index} signing failed: ${errorText}`); + } + + return await response.json(); + }); + + // Wait for all sign operations to complete + const signResults = await Promise.all(signPromises); + const signature = signResults[0]; // Use the first signature + + // Format the signature based on chain/curve + let formattedSignature; + if ("signature" in signature) { + formattedSignature = signature.signature; + } else if ("r" in signature && "s" in signature) { + // For ECDSA signatures + if (chainConfig.signerSpec.signatureFormat === "der") { + formattedSignature = signature.der; + } else { + // RSV format for Ethereum + formattedSignature = `${signature.r}${ + signature.s + }${signature.v.toString(16)}`; + } + } else { + formattedSignature = JSON.stringify(signature); + } + + // Return the signature + return res.status(200).json({ + status: 200, + signature: formattedSignature, + chainId: chain, + curve: curveType, + }); + } catch (error: any) { + console.error(`Error signing transaction:`, error); + return res.status(500).json({ + status: 500, + error: "Failed to sign transaction", + message: error.message, + }); + } +} diff --git a/src/signers/Sodot.ts b/src/signers/Sodot.ts index 8cc92b26..fe63f6d7 100644 --- a/src/signers/Sodot.ts +++ b/src/signers/Sodot.ts @@ -183,38 +183,66 @@ export class SodotSigner { private async getKeyId(vertexId: number, curve: "ecdsa" | "ed25519") { try { - // Use environment variables to get existing key IDs - const keyIdsEnvVar = - curve === "ecdsa" - ? process.env.SODOT_EXISTING_ECDSA_KEY_IDS || - process.env.NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS - : process.env.SODOT_EXISTING_ED25519_KEY_IDS || - process.env.NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS; - - if (keyIdsEnvVar) { - const keyIds = keyIdsEnvVar.split(","); - if (keyIds.length > vertexId && keyIds[vertexId]) { - return keyIds[vertexId]; - } + // In client-side code, we need to use the NEXT_PUBLIC_ environment variables + // or server environment variables proxied through API routes + let keyIdsStr: string | undefined; + + // First try using the browser-accessible environment variables + if (typeof window !== "undefined") { + const envVar = + curve === "ecdsa" + ? "NEXT_PUBLIC_SODOT_EXISTING_ECDSA_KEY_IDS" + : "NEXT_PUBLIC_SODOT_EXISTING_ED25519_KEY_IDS"; + + keyIdsStr = process.env[envVar]; + console.log( + `[Sodot] Attempting to use client-side env var ${envVar}:`, + keyIdsStr ? "found" : "not found" + ); } - // For development, use mock key IDs when env vars are not available - if (process.env.NODE_ENV !== "production") { + // If not found, try to fetch the key IDs from the server + if (!keyIdsStr) { console.log( - `[Sodot] Using mock key ID for ${curve} vertex ${vertexId} in development` + `[Sodot] Client-side key IDs not found, fetching from server API` ); - // Use predictable mock key IDs based on curve and vertex - const mockKeyIds = { - ecdsa: ["mock_ecdsa_key_0", "mock_ecdsa_key_1", "mock_ecdsa_key_2"], - ed25519: [ - "mock_ed25519_key_0", - "mock_ed25519_key_1", - "mock_ed25519_key_2", - ], - }; - - if (vertexId < mockKeyIds[curve].length) { - return mockKeyIds[curve][vertexId]; + try { + const response = await fetch( + `/api/sodot-proxy/get-key-ids?curve=${curve}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + } + ); + + if (response.ok) { + const data = await response.json(); + keyIdsStr = data.keyIds; + console.log( + `[Sodot] Server provided key IDs:`, + keyIdsStr ? "success" : "failed" + ); + } else { + console.error( + `[Sodot] Failed to get key IDs from server:`, + await response.text() + ); + } + } catch (err) { + console.error(`[Sodot] Error fetching key IDs from server:`, err); + } + } + + // If we have key IDs, use them + if (keyIdsStr) { + const keyIds = keyIdsStr.split(","); + if (keyIds.length > vertexId && keyIds[vertexId]) { + console.log( + `[Sodot] Using key ID for vertex ${vertexId}: ${keyIds[ + vertexId + ].substring(0, 8)}...` + ); + return keyIds[vertexId]; } } From 6c743ae663f9be26184a3709763320d531ade512 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:46:31 +0200 Subject: [PATCH 009/146] chain config to fetch --- package.json | 1 + pnpm-lock.yaml | 95 +++++++++ src/components/ui/progress.tsx | 28 +++ src/components/wallets/MultiChainConnect.tsx | 209 +++++++++++++++++++ src/components/wallets/SodotConnect.tsx | 85 +++----- src/components/wallets/WalletSelection.tsx | 18 +- src/components/wallets/types.ts | 1 + src/config/wallet-chains.ts | 39 ++++ 8 files changed, 422 insertions(+), 54 deletions(-) create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/wallets/MultiChainConnect.tsx create mode 100644 src/config/wallet-chains.ts diff --git a/package.json b/package.json index 9f1d1a78..3d80af6e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-progress": "^1.1.3", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.4", "@radix-ui/react-slot": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37bac64a..384e7005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-progress': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-scroll-area': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1612,6 +1615,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.1': resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} peerDependencies: @@ -1621,6 +1633,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.4': resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==} peerDependencies: @@ -1791,6 +1812,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.3': + resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.3': + resolution: {integrity: sha512-F56aZPGTPb4qJQ/vDjnAq63oTu/DRoIG/Asb5XKOWj8rpefNLtUllR969j5QDN2sRrTk9VXIqQDRj5VvAuquaw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.1': resolution: {integrity: sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==} peerDependencies: @@ -1839,6 +1886,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.0': + resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.1.2': resolution: {integrity: sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==} peerDependencies: @@ -3602,6 +3658,7 @@ packages: get-starknet-core@4.0.0: resolution: {integrity: sha512-6pLmidQZkC3wZsrHY99grQHoGpuuXqkbSP65F8ov1/JsEI8DDLkhsAuLCKFzNOK56cJp+f1bWWfTJ57e9r5eqQ==} + deprecated: Package no longer supported. Please use @starknet-io/get-starknet-core get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} @@ -8113,12 +8170,24 @@ snapshots: optionalDependencies: '@types/react': 19.0.2 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.2)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.2 + '@radix-ui/react-context@1.1.1(@types/react@19.0.2)(react@19.0.0)': dependencies: react: 19.0.0 optionalDependencies: '@types/react': 19.0.2 + '@radix-ui/react-context@1.1.2(@types/react@19.0.2)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.2 + '@radix-ui/react-dialog@1.1.4(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -8304,6 +8373,25 @@ snapshots: '@types/react': 19.0.2 '@types/react-dom': 19.0.2(@types/react@19.0.2) + '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-slot': 1.2.0(@types/react@19.0.2)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.2 + '@types/react-dom': 19.0.2(@types/react@19.0.2) + + '@radix-ui/react-progress@1.1.3(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.0.2)(react@19.0.0) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.2 + '@types/react-dom': 19.0.2(@types/react@19.0.2) + '@radix-ui/react-roving-focus@1.1.1(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -8374,6 +8462,13 @@ snapshots: optionalDependencies: '@types/react': 19.0.2 + '@radix-ui/react-slot@1.2.0(@types/react@19.0.2)(react@19.0.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.2)(react@19.0.0) + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.2 + '@radix-ui/react-switch@1.1.2(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 00000000..c0332c5f --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "~/utils/helper"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx new file mode 100644 index 00000000..d22bc394 --- /dev/null +++ b/src/components/wallets/MultiChainConnect.tsx @@ -0,0 +1,209 @@ +import React, { useCallback, useState, useEffect } from "react"; +import { useToast } from "~/components/ui/use-toast"; +import { useWallet } from "~/hooks/useWallet"; +import { Account, WalletName } from "./types"; +import { Chain } from "~/utils/types"; +import { Button } from "~/components/ui/button"; +import { Loader2, Check } from "lucide-react"; +import { getChains } from "~/api/adamik/chains"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; +import { Card } from "~/components/ui/card"; +import { getPreferredChains } from "~/config/wallet-chains"; +import { Progress } from "~/components/ui/progress"; + +/** + * MultiChainConnect component + * Automatically connects to all chains defined in the wallet-chains.ts config file + */ +export const MultiChainConnect: React.FC = () => { + const { toast } = useToast(); + const { addAddresses } = useWallet(); + const [loading, setLoading] = useState(false); + const [chains, setChains] = useState | null>(null); + const [error, setError] = useState(null); + const [connectedCount, setConnectedCount] = useState(0); + const [configuredChains, setConfiguredChains] = useState([]); + + // Fetch chains data when component mounts + useEffect(() => { + const fetchChains = async () => { + try { + const chainsData = await getChains(); + if (chainsData) { + setChains(chainsData); + const preferredChains = getPreferredChains(chainsData); + setConfiguredChains(preferredChains); + } else { + setError("Failed to load chain information"); + } + } catch (e) { + console.error("Error fetching chains:", e); + setError("Failed to load chain information"); + } + }; + + fetchChains(); + }, []); + + const getAddressForChain = async (chainId: string) => { + if (!chains || !chains[chainId]) { + throw new Error(`Chain ${chainId} not supported`); + } + + // Call our backend endpoint for the chain pubkey + console.log(`[MultiChainConnect] Fetching pubkey for ${chainId}`); + const response = await fetch( + `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const data = await response.json(); + console.log( + `[MultiChainConnect] Received pubkey data for ${chainId}:`, + data + ); + + // Get the pubkey from the response + const pubkey = data.data.pubkey; + console.log(`[MultiChainConnect] Extracted ${chainId} pubkey:`, pubkey); + + // Use the encodePubKeyToAddress API endpoint directly + console.log(`[MultiChainConnect] Encoding address for ${chainId}`); + + try { + const { address } = await encodePubKeyToAddress(pubkey, chainId); + console.log(`[MultiChainConnect] Address for ${chainId}:`, address); + return { pubkey, address }; + } catch (e) { + console.error(`[MultiChainConnect] Error encoding address:`, e); + throw new Error( + `Failed to encode address: ${ + e instanceof Error ? e.message : String(e) + }` + ); + } + }; + + const connectAllChains = useCallback(async () => { + if (!chains || configuredChains.length === 0) { + toast({ + description: "No chains configured or available", + variant: "destructive", + }); + return; + } + + setLoading(true); + setError(null); + setConnectedCount(0); + + const accounts: Account[] = []; + const failedChains: string[] = []; + + // Connect to each chain in sequence + for (let i = 0; i < configuredChains.length; i++) { + const chainId = configuredChains[i]; + try { + const { pubkey, address } = await getAddressForChain(chainId); + + // Create account with the address and public key + const account: Account = { + address: address, + chainId: chainId, + pubKey: pubkey, + signer: WalletName.SODOT, + }; + + accounts.push(account); + setConnectedCount(i + 1); + } catch (e) { + console.error(`Error connecting to ${chainId}:`, e); + failedChains.push(chainId); + } + } + + // Add all successfully connected accounts + if (accounts.length > 0) { + addAddresses(accounts); + + toast({ + description: `Connected ${accounts.length} chains successfully${ + failedChains.length > 0 + ? `. Failed to connect: ${failedChains.join(", ")}` + : "" + }`, + }); + } else { + toast({ + description: "Failed to connect any chains", + variant: "destructive", + }); + } + + setLoading(false); + }, [chains, configuredChains, addAddresses, toast]); + + if (error) { + return ( + + ); + } + + if (!chains) { + return ( + + ); + } + + return ( + +

    Connect Sodot Wallet

    +
    + {loading && ( +
    +
    + Connecting chains... + + {connectedCount} / {configuredChains.length} + +
    + +
    + )} + + +
    +
    + ); +}; diff --git a/src/components/wallets/SodotConnect.tsx b/src/components/wallets/SodotConnect.tsx index 6b81e8ed..3cea1b02 100644 --- a/src/components/wallets/SodotConnect.tsx +++ b/src/components/wallets/SodotConnect.tsx @@ -7,17 +7,10 @@ import { Button } from "~/components/ui/button"; import { Loader2 } from "lucide-react"; import { getChains } from "~/api/adamik/chains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; import { Card } from "~/components/ui/card"; +import { defaultChain, getPreferredChains } from "~/config/wallet-chains"; -// Default chains to show in the UI -const DEFAULT_CHAINS = ["ethereum", "bitcoin", "solana"]; +// Removed DEFAULT_CHAINS constant as it's now in the config file export const SodotConnect: React.FC = ({ chainId: providedChainId, @@ -31,6 +24,7 @@ export const SodotConnect: React.FC = ({ const [selectedChainId, setSelectedChainId] = useState( providedChainId || "" ); + const [autoConnectInProgress, setAutoConnectInProgress] = useState(false); // Fetch chains data when component mounts useEffect(() => { @@ -42,11 +36,16 @@ export const SodotConnect: React.FC = ({ // Auto-select first chain if none provided and we're not in transaction mode if (!providedChainId && !selectedChainId && !transactionPayload) { - const firstDefaultChain = DEFAULT_CHAINS.find( - (id) => chainsData[id] - ); - if (firstDefaultChain) { - setSelectedChainId(firstDefaultChain); + const preferredChains = getPreferredChains(chainsData); + const firstAvailableChain = + preferredChains.length > 0 + ? preferredChains[0] + : defaultChain in chainsData + ? defaultChain + : Object.keys(chainsData)[0]; + + if (firstAvailableChain) { + setSelectedChainId(firstAvailableChain); } } } else { @@ -68,6 +67,23 @@ export const SodotConnect: React.FC = ({ } }, [providedChainId]); + // Auto connect when using non-transaction mode + useEffect(() => { + if ( + chains && + selectedChainId && + !transactionPayload && + !providedChainId && + !autoConnectInProgress + ) { + // Auto-connect the wallet when chains are loaded and chain is selected + setAutoConnectInProgress(true); + getAddresses().finally(() => { + setAutoConnectInProgress(false); + }); + } + }, [chains, selectedChainId, transactionPayload, providedChainId]); + const getAddressForChain = async (chainId: string) => { if (!chains || !chains[chainId]) { throw new Error(`Chain ${chainId} not supported`); @@ -239,52 +255,17 @@ export const SodotConnect: React.FC = ({ ); } - // If no chainId is provided, show chain selector with default chains + // Otherwise, just show the connect button return (

    Connect Sodot Wallet

    -
    - - -
    - -
    {isShowroom ? : null} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ffd8634d..6124fdc2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,8 @@ import type { Metadata } from "next"; import "./globals.css"; import { Menu } from "~/components/layout/Menu/Menu"; import { Toaster } from "~/components/ui/toaster"; +import { WalletModal } from "~/components/wallets/WalletModal"; +import { WalletConnect, ConnectedChains } from "~/components"; export const metadata: Metadata = { title: "Adamik App", @@ -23,9 +25,20 @@ export default function RootLayout({ )} > - +
    + +
    + {/* Top right wallet connection UI */} +
    + +
    + + {children} +
    +
    - {children} + + diff --git a/src/app/portfolio/ConnectWallet.tsx b/src/app/portfolio/ConnectWallet.tsx index ee8f7a6b..a35372b1 100644 --- a/src/app/portfolio/ConnectWallet.tsx +++ b/src/app/portfolio/ConnectWallet.tsx @@ -1,15 +1,27 @@ import { Button } from "~/components/ui/button"; +import { useWallet } from "~/hooks/useWallet"; +/** + * ConnectWallet + * Modal version of wallet connection prompt used in transaction flows + */ export const ConnectWallet = ({ onNextStep }: { onNextStep: () => void }) => { + const { setWalletMenuOpen } = useWallet(); + + const handleConnect = () => { + setWalletMenuOpen(true); + onNextStep(); + }; + return (
    -

    HODL ON !

    +

    HODL ON!

    You are currently using the demo version of the Adamik App.
    Please add your wallet before signing transactions.
    -
    ); diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index ac694a07..2ee125bf 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -184,7 +184,6 @@ export default function Portfolio() {
    -
    {isShowroom ? : null} diff --git a/src/app/stake/page.tsx b/src/app/stake/page.tsx index 708ee9e6..4e14fc5f 100644 --- a/src/app/stake/page.tsx +++ b/src/app/stake/page.tsx @@ -147,8 +147,6 @@ export default function Stake() {
    - -
    {isShowroom ? : null} diff --git a/src/components/ConnectedChains.tsx b/src/components/ConnectedChains.tsx new file mode 100644 index 00000000..c58eec6c --- /dev/null +++ b/src/components/ConnectedChains.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { useWallet } from "~/hooks/useWallet"; + +/** + * ConnectedChains + * Shows the number of connected chains at the bottom right of the screen + */ +export function ConnectedChains() { + const { addresses, isShowroom } = useWallet(); + const hasConnectedWallets = addresses.length > 0; + + if (!hasConnectedWallets) { + return null; // Only show when wallets are connected + } + + return ( +
    +
    + {addresses.length}{" "} + {addresses.length === 1 ? "Chain" : "Chains"} Connected + {isShowroom && (Demo)} +
    +
    + ); +} diff --git a/src/components/DemoModeToggle.tsx b/src/components/DemoModeToggle.tsx new file mode 100644 index 00000000..390666f7 --- /dev/null +++ b/src/components/DemoModeToggle.tsx @@ -0,0 +1,13 @@ +"use client"; + +import React from "react"; + +/** + * DemoModeToggle + * This component has been integrated into WalletConnect + * Keeping this file as a placeholder to prevent import errors elsewhere + */ +export function DemoModeToggle() { + // This component has been integrated into WalletConnect + return null; +} diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx new file mode 100644 index 00000000..d21da9f4 --- /dev/null +++ b/src/components/WalletConnect.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React from "react"; +import { useWallet } from "~/hooks/useWallet"; +import { Button } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; +import { Label } from "~/components/ui/label"; +import { showroomAddresses } from "~/utils/showroomAddresses"; + +/** + * WalletConnect + * The unified wallet connection component for the entire application + * - Shows connection status and chain count + * - Provides access to wallet connection modal + * - Controls demo/real wallet toggle + */ +export function WalletConnect() { + const { + addresses, + setWalletMenuOpen, + isShowroom, + setShowroom, + addAddresses, + setAddresses, + } = useWallet(); + + const hasConnectedWallets = addresses.length > 0; + + const handleToggleMode = (checked: boolean) => { + // Toggle between demo mode and real wallet mode + setShowroom(checked); + + if (checked) { + // Save current addresses if they're not from showroom + if (!isShowroom) { + // Store real addresses in localStorage for restoration later + localStorage.setItem("realWalletAddresses", JSON.stringify(addresses)); + } + + // Load demo addresses + addAddresses(showroomAddresses); + } else { + // Try to restore real wallet addresses + const realAddressesStr = localStorage.getItem("realWalletAddresses"); + if (realAddressesStr) { + try { + const realAddresses = JSON.parse(realAddressesStr); + // Only restore if valid JSON array + if (Array.isArray(realAddresses)) { + setAddresses(realAddresses); + return; + } + } catch (e) { + console.error("Error parsing stored addresses:", e); + } + } + + // If no stored addresses or error, clear addresses + setAddresses([]); + } + }; + + return ( +
    + {/* Connect Wallet Button */} + + + {/* Demo Mode Toggle */} +
    + + + +
    +
    + ); +} diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 00000000..6cf36eca --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,7 @@ +// Wallet-related components +export { WalletConnect } from "./WalletConnect"; +export { DemoModeToggle } from "./DemoModeToggle"; +export { ConnectedChains } from "./ConnectedChains"; + +// Re-export common UI components for convenience +export { FirstVisitTooltip } from "./FirstVisitTooltip"; diff --git a/src/components/wallets/ClientWalletComponents.tsx b/src/components/wallets/ClientWalletComponents.tsx new file mode 100644 index 00000000..6db0e7b6 --- /dev/null +++ b/src/components/wallets/ClientWalletComponents.tsx @@ -0,0 +1,18 @@ +"use client"; + +import React from "react"; +import { WalletModal } from "./WalletModal"; +import { WalletStatusIndicator } from "./WalletStatusIndicator"; + +/** + * ClientWalletComponents + * A client component wrapper that bundles the wallet-related UI components + */ +export function ClientWalletComponents() { + return ( + <> + + + + ); +} diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index d22bc394..9ba04503 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -4,26 +4,42 @@ import { useWallet } from "~/hooks/useWallet"; import { Account, WalletName } from "./types"; import { Chain } from "~/utils/types"; import { Button } from "~/components/ui/button"; -import { Loader2, Check } from "lucide-react"; +import { Loader2, ChevronRight } from "lucide-react"; import { getChains } from "~/api/adamik/chains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; -import { Card } from "~/components/ui/card"; import { getPreferredChains } from "~/config/wallet-chains"; -import { Progress } from "~/components/ui/progress"; /** * MultiChainConnect component * Automatically connects to all chains defined in the wallet-chains.ts config file + * Simplified to just a button for top-right placement */ -export const MultiChainConnect: React.FC = () => { +export const MultiChainConnect: React.FC<{ + variant?: "default" | "outline" | "secondary" | "ghost" | "link"; + size?: "default" | "sm" | "lg" | "icon"; + className?: string; + hideButton?: boolean; // Add hideButton prop to hide the button when needed +}> = ({ + variant = "default", + size = "default", + className = "", + hideButton = false, +}) => { const { toast } = useToast(); const { addAddresses } = useWallet(); const [loading, setLoading] = useState(false); const [chains, setChains] = useState | null>(null); const [error, setError] = useState(null); const [connectedCount, setConnectedCount] = useState(0); + const [successCount, setSuccessCount] = useState(0); + const [failedChains, setFailedChains] = useState([]); const [configuredChains, setConfiguredChains] = useState([]); + // If hideButton is true, don't render anything + if (hideButton) { + return null; + } + // Fetch chains data when component mounts useEffect(() => { const fetchChains = async () => { @@ -83,7 +99,7 @@ export const MultiChainConnect: React.FC = () => { try { const { address } = await encodePubKeyToAddress(pubkey, chainId); console.log(`[MultiChainConnect] Address for ${chainId}:`, address); - return { pubkey, address }; + return { pubkey, address, chainId }; } catch (e) { console.error(`[MultiChainConnect] Error encoding address:`, e); throw new Error( @@ -94,6 +110,52 @@ export const MultiChainConnect: React.FC = () => { } }; + // Handle successful chain connection + const handleSuccessfulConnection = useCallback( + (result: { pubkey: string; address: string; chainId: string }) => { + // Create account with the address and public key + const account: Account = { + address: result.address, + chainId: result.chainId, + pubKey: result.pubkey, + signer: WalletName.SODOT, + }; + + // Add this account immediately + addAddresses([account]); + + // Show a brief toast for the successful connection + toast({ + description: `Connected ${ + chains?.[result.chainId]?.name || result.chainId + }`, + duration: 1500, // Short duration to avoid flooding + }); + + setSuccessCount((prev) => prev + 1); + }, + [addAddresses, toast, chains] + ); + + // Handle failed chain connection + const handleFailedConnection = useCallback( + (chainId: string, error: Error) => { + console.error(`Error connecting to ${chainId}:`, error); + + setFailedChains((prev) => [...prev, chainId]); + + // Optionally show an error toast for each failure + toast({ + description: `Failed to connect ${ + chains?.[chainId]?.name || chainId + }: ${error.message}`, + variant: "destructive", + duration: 2000, + }); + }, + [chains, toast] + ); + const connectAllChains = useCallback(async () => { if (!chains || configuredChains.length === 0) { toast({ @@ -106,104 +168,107 @@ export const MultiChainConnect: React.FC = () => { setLoading(true); setError(null); setConnectedCount(0); + setSuccessCount(0); + setFailedChains([]); - const accounts: Account[] = []; - const failedChains: string[] = []; + // Process each chain individually + configuredChains.forEach((chainId) => { + getAddressForChain(chainId) + .then((result) => { + // Process this successful result immediately + handleSuccessfulConnection(result); + }) + .catch((error) => { + // Process this failure immediately + handleFailedConnection(chainId, error); + }) + .finally(() => { + // Update connection counter + setConnectedCount((prev) => prev + 1); - // Connect to each chain in sequence - for (let i = 0; i < configuredChains.length; i++) { - const chainId = configuredChains[i]; - try { - const { pubkey, address } = await getAddressForChain(chainId); - - // Create account with the address and public key - const account: Account = { - address: address, - chainId: chainId, - pubKey: pubkey, - signer: WalletName.SODOT, - }; - - accounts.push(account); - setConnectedCount(i + 1); - } catch (e) { - console.error(`Error connecting to ${chainId}:`, e); - failedChains.push(chainId); - } - } + // Check if all chains have been processed + if (connectedCount + 1 >= configuredChains.length) { + // Show final summary toast when all chains have been processed + setTimeout(() => { + toast({ + description: `Completed: ${successCount + 1} successful, ${ + failedChains.length + } failed`, + }); - // Add all successfully connected accounts - if (accounts.length > 0) { - addAddresses(accounts); + setLoading(false); + }, 500); + } + }); + }); + }, [ + chains, + configuredChains, + handleSuccessfulConnection, + handleFailedConnection, + connectedCount, + successCount, + failedChains.length, + toast, + ]); + // Clean up when finished + useEffect(() => { + if ( + connectedCount === configuredChains.length && + loading && + configuredChains.length > 0 + ) { + // Show summary and set loading to false toast({ - description: `Connected ${accounts.length} chains successfully${ - failedChains.length > 0 - ? `. Failed to connect: ${failedChains.join(", ")}` - : "" - }`, + description: `All chains processed: ${successCount} successful, ${failedChains.length} failed`, }); - } else { - toast({ - description: "Failed to connect any chains", - variant: "destructive", - }); - } - setLoading(false); - }, [chains, configuredChains, addAddresses, toast]); + setLoading(false); + } + }, [ + connectedCount, + configuredChains.length, + loading, + successCount, + failedChains.length, + toast, + ]); if (error) { return ( - ); } if (!chains) { return ( - ); } + // Button is only visible when not hidden with hideButton prop return ( - -

    Connect Sodot Wallet

    -
    - {loading && ( -
    -
    - Connecting chains... - - {connectedCount} / {configuredChains.length} - -
    - -
    - )} - - -
    -
    + ); }; diff --git a/src/components/wallets/WalletButton.tsx b/src/components/wallets/WalletButton.tsx new file mode 100644 index 00000000..ddecef01 --- /dev/null +++ b/src/components/wallets/WalletButton.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import { WalletSelection } from "./WalletSelection"; +import { useWallet } from "~/hooks/useWallet"; +import { Button } from "~/components/ui/button"; + +/** + * WalletButton component + * Shows either the MultiChainConnect button or the number of connected chains + */ +export function WalletButton() { + const { addresses } = useWallet(); + const hasConnectedWallets = addresses.length > 0; + + if (hasConnectedWallets) { + return ( +
    + {addresses.length} {addresses.length === 1 ? "Chain" : "Chains"}{" "} + Connected +
    + ); + } + + return ; +} diff --git a/src/components/wallets/WalletModal.tsx b/src/components/wallets/WalletModal.tsx new file mode 100644 index 00000000..f9ea60b7 --- /dev/null +++ b/src/components/wallets/WalletModal.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React from "react"; +import { Modal } from "~/components/ui/modal"; +import { useWallet } from "~/hooks/useWallet"; +import { Button } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; +import { Label } from "~/components/ui/label"; +import { MultiChainConnect } from "./MultiChainConnect"; + +/** + * WalletModal + * Central modal for connecting to wallets when triggered by any wallet connection button + */ +export const WalletModal: React.FC = () => { + const { isWalletMenuOpen, setWalletMenuOpen, isShowroom, setShowroom } = + useWallet(); + + const handleClose = () => { + setWalletMenuOpen(false); + }; + + return ( + +

    + Connect Your Wallet +

    + +
    + + + +
    + +
    + {isShowroom + ? "Using demo mode with sample wallets. No real transactions will be made." + : "Connect to all available chains with a single click."} +
    + + +
    + } + /> + ); +}; diff --git a/src/components/wallets/WalletSelection.tsx b/src/components/wallets/WalletSelection.tsx index 63b10904..c102d83c 100644 --- a/src/components/wallets/WalletSelection.tsx +++ b/src/components/wallets/WalletSelection.tsx @@ -5,27 +5,64 @@ import { WalletConnectorProps } from "./types"; import { SodotConnect } from "./SodotConnect"; import { MultiChainConnect } from "./MultiChainConnect"; -export const WalletSelection: React.FC = (props) => { +/** + * WalletSelection component + * Only shows up for transaction-specific flows with SodotConnect when chainId or transactionPayload is provided + * Hidden on regular page headers to avoid duplicate buttons + */ +export const WalletSelection: React.FC< + WalletConnectorProps & { + variant?: "default" | "outline" | "secondary" | "ghost" | "link"; + size?: "default" | "sm" | "lg" | "icon"; + className?: string; + position?: "modal" | "inline"; + forceShow?: boolean; // Add a prop to force showing the button when needed + } +> = ({ + chainId, + transactionPayload, + variant = "default", + size = "default", + className = "", + position = "modal", + forceShow = false, +}) => { + // Don't show the MultiChainConnect button in page headers to avoid duplication + // with the global integrated WalletConnect component + const isPageHeader = position === "inline" && !forceShow; + + // Hide the component on page headers if it doesn't have specific transaction parameters + if (isPageHeader && !chainId && !transactionPayload) { + return null; + } + // If specific chainId or transaction payload is provided, use the original SodotConnect // which allows selecting a single chain or signing transactions - if (props.chainId || props.transactionPayload) { - return ( -
    -

    Select a wallet

    -
    - + if (chainId || transactionPayload) { + if (position === "modal") { + return ( +
    +

    Select a wallet

    +
    + +
    -
    - ); + ); + } else { + return ( + + ); + } } - // For general wallet connection, use the auto-connect with multiple chains + // For general wallet connection, use the simplified button return ( -
    -

    Select a wallet

    -
    - -
    -
    + ); }; diff --git a/src/components/wallets/index.ts b/src/components/wallets/index.ts new file mode 100644 index 00000000..ef25ac41 --- /dev/null +++ b/src/components/wallets/index.ts @@ -0,0 +1,7 @@ +export { WalletModal } from "./WalletModal"; +export { MultiChainConnect } from "./MultiChainConnect"; +export { WalletStatusIndicator } from "./WalletStatusIndicator"; +export { WalletSelection } from "./WalletSelection"; +export { SodotConnect } from "./SodotConnect"; +export { BroadcastModal } from "./BroadcastModal"; +export { WalletSigner } from "./WalletSigner"; From f642b330456d974a42bf3db4a9c259674a35a9c1 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 12:48:22 +0200 Subject: [PATCH 011/146] update wallet selection and fix toggle --- src/components/WalletConnect.tsx | 69 ++++++++------------- src/components/wallets/WalletModal.tsx | 35 ++--------- src/providers/WalletProvider.tsx | 86 ++++++++++++++++++++------ 3 files changed, 97 insertions(+), 93 deletions(-) diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index d21da9f4..5cbc31be 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -5,7 +5,7 @@ import { useWallet } from "~/hooks/useWallet"; import { Button } from "~/components/ui/button"; import { Switch } from "~/components/ui/switch"; import { Label } from "~/components/ui/label"; -import { showroomAddresses } from "~/utils/showroomAddresses"; +import { MultiChainConnect } from "./wallets/MultiChainConnect"; /** * WalletConnect @@ -15,50 +15,35 @@ import { showroomAddresses } from "~/utils/showroomAddresses"; * - Controls demo/real wallet toggle */ export function WalletConnect() { - const { - addresses, - setWalletMenuOpen, - isShowroom, - setShowroom, - addAddresses, - setAddresses, - } = useWallet(); + const { addresses, setWalletMenuOpen, isShowroom, setShowroom } = useWallet(); const hasConnectedWallets = addresses.length > 0; - const handleToggleMode = (checked: boolean) => { - // Toggle between demo mode and real wallet mode - setShowroom(checked); + // For users with no connected wallets who are not in demo mode, + // provide direct access to MultiChainConnect + if (!hasConnectedWallets && !isShowroom) { + return ( +
    + - if (checked) { - // Save current addresses if they're not from showroom - if (!isShowroom) { - // Store real addresses in localStorage for restoration later - localStorage.setItem("realWalletAddresses", JSON.stringify(addresses)); - } - - // Load demo addresses - addAddresses(showroomAddresses); - } else { - // Try to restore real wallet addresses - const realAddressesStr = localStorage.getItem("realWalletAddresses"); - if (realAddressesStr) { - try { - const realAddresses = JSON.parse(realAddressesStr); - // Only restore if valid JSON array - if (Array.isArray(realAddresses)) { - setAddresses(realAddresses); - return; - } - } catch (e) { - console.error("Error parsing stored addresses:", e); - } - } - - // If no stored addresses or error, clear addresses - setAddresses([]); - } - }; + {/* Demo Mode Toggle */} +
    + + + +
    +
    + ); + } return (
    @@ -82,7 +67,7 @@ export function WalletConnect() {
    diff --git a/src/providers/WalletProvider.tsx b/src/providers/WalletProvider.tsx index d1632dbc..c9babbb0 100644 --- a/src/providers/WalletProvider.tsx +++ b/src/providers/WalletProvider.tsx @@ -7,6 +7,7 @@ import { assets, chains } from "chain-registry"; import React, { useEffect, useState } from "react"; import { Account, IWallet } from "~/components/wallets/types"; import { WalletContext } from "~/hooks/useWallet"; +import { showroomAddresses } from "~/utils/showroomAddresses"; const localStorage = typeof window !== "undefined" ? window.localStorage : null; @@ -17,21 +18,38 @@ export const WalletProvider: React.FC = ({ const [addresses, setAddresses] = useState([]); const [isShowroom, setShowroom] = useState(false); const [isWalletMenuOpen, setWalletMenuOpen] = useState(false); + // Track real wallet addresses separately + const [realWalletAddresses, setRealWalletAddresses] = useState([]); useEffect(() => { const localDataAddresses = localStorage?.getItem("AdamikClientAddresses"); - setAddresses(localDataAddresses ? JSON.parse(localDataAddresses) : []); + const parsedAddresses = localDataAddresses + ? JSON.parse(localDataAddresses) + : []; const localDataClientState = localStorage?.getItem("AdamikClientState"); const localDataClientStateParsed = JSON.parse(localDataClientState || "{}"); - setShowroom(localDataClientStateParsed?.isShowroom || false); - }, []); + const showroomState = localDataClientStateParsed?.isShowroom || false; - useEffect(() => { - if (addresses.length > 0) { - setShowroom(false); + // Store the real wallet addresses separately + setRealWalletAddresses(parsedAddresses); + + // Set addresses based on showroom state + if (showroomState) { + setAddresses(showroomAddresses); + } else { + setAddresses(parsedAddresses); } - }, [addresses]); + + setShowroom(showroomState); + }, []); + + // We don't need this effect anymore as it prevents manual toggle to demo mode + // useEffect(() => { + // if (addresses.length > 0) { + // setShowroom(false); + // } + // }, [addresses]); const addWallet = (wallet: IWallet) => { const exist = wallets.find((w) => w.id === wallet.id); @@ -52,15 +70,51 @@ export const WalletProvider: React.FC = ({ ) ); - localStorage?.setItem( - "AdamikClientAddresses", - JSON.stringify(uniqueAddresses) - ); + // Only save to localStorage if not in showroom mode + if (!isShowroom) { + localStorage?.setItem( + "AdamikClientAddresses", + JSON.stringify(uniqueAddresses) + ); + // Update real wallet addresses as well + setRealWalletAddresses(uniqueAddresses); + } return uniqueAddresses; }); }; + // Improved setShowroom function that properly handles address switching + const handleSetShowroom = (showroomState: boolean) => { + // Save the current state to client state + const localData = localStorage?.getItem("AdamikClientState"); + const oldLocalData = JSON.parse(localData || "{}"); + localStorage?.setItem( + "AdamikClientState", + JSON.stringify({ ...oldLocalData, isShowroom: showroomState }) + ); + + // Update state + setShowroom(showroomState); + + // Switch addresses based on showroom state + if (showroomState) { + // Save real addresses before switching to demo + if (!isShowroom && addresses.length > 0) { + setRealWalletAddresses(addresses); + localStorage?.setItem( + "AdamikClientAddresses", + JSON.stringify(addresses) + ); + } + // Switch to demo addresses + setAddresses(showroomAddresses); + } else { + // Switch back to real addresses + setAddresses(realWalletAddresses); + } + }; + return ( = ({ setWalletMenuOpen, isWalletMenuOpen, isShowroom, - setShowroom: (isShowroom: boolean) => { - const localData = localStorage?.getItem("AdamikClientState"); - const oldLocalData = JSON.parse(localData || "{}"); - localStorage?.setItem( - "AdamikClientState", - JSON.stringify({ ...oldLocalData, isShowroom: isShowroom }) - ); - setShowroom(isShowroom); - }, + setShowroom: handleSetShowroom, }} > {children} From c4325d97c59ae0b3a2fe33c3a22a6c701004ec75 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:42:59 +0200 Subject: [PATCH 012/146] clean up --- src/app/layout.tsx | 3 +-- src/components/ConnectedChains.tsx | 27 -------------------- src/components/WalletConnect.tsx | 4 ++- src/components/index.ts | 1 - src/components/wallets/MultiChainConnect.tsx | 4 +-- 5 files changed, 6 insertions(+), 33 deletions(-) delete mode 100644 src/components/ConnectedChains.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6124fdc2..ef845a98 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import "./globals.css"; import { Menu } from "~/components/layout/Menu/Menu"; import { Toaster } from "~/components/ui/toaster"; import { WalletModal } from "~/components/wallets/WalletModal"; -import { WalletConnect, ConnectedChains } from "~/components"; +import { WalletConnect } from "~/components"; export const metadata: Metadata = { title: "Adamik App", @@ -38,7 +38,6 @@ export default function RootLayout({
    - diff --git a/src/components/ConnectedChains.tsx b/src/components/ConnectedChains.tsx deleted file mode 100644 index c58eec6c..00000000 --- a/src/components/ConnectedChains.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import React from "react"; -import { useWallet } from "~/hooks/useWallet"; - -/** - * ConnectedChains - * Shows the number of connected chains at the bottom right of the screen - */ -export function ConnectedChains() { - const { addresses, isShowroom } = useWallet(); - const hasConnectedWallets = addresses.length > 0; - - if (!hasConnectedWallets) { - return null; // Only show when wallets are connected - } - - return ( -
    -
    - {addresses.length}{" "} - {addresses.length === 1 ? "Chain" : "Chains"} Connected - {isShowroom && (Demo)} -
    -
    - ); -} diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index 5cbc31be..15962bb8 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -55,7 +55,9 @@ export function WalletConnect() { className="font-medium" > {hasConnectedWallets - ? `${addresses.length} ${addresses.length === 1 ? "Chain" : "Chains"}` + ? `${addresses.length} ${ + addresses.length === 1 ? "Chain" : "Chains" + }${isShowroom ? " (Demo)" : ""}` : "Connect Wallet"} diff --git a/src/components/index.ts b/src/components/index.ts index 6cf36eca..e032846d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,7 +1,6 @@ // Wallet-related components export { WalletConnect } from "./WalletConnect"; export { DemoModeToggle } from "./DemoModeToggle"; -export { ConnectedChains } from "./ConnectedChains"; // Re-export common UI components for convenience export { FirstVisitTooltip } from "./FirstVisitTooltip"; diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index 9ba04503..4920982c 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -26,7 +26,7 @@ export const MultiChainConnect: React.FC<{ hideButton = false, }) => { const { toast } = useToast(); - const { addAddresses } = useWallet(); + const { addAddresses, isShowroom } = useWallet(); const [loading, setLoading] = useState(false); const [chains, setChains] = useState | null>(null); const [error, setError] = useState(null); @@ -267,7 +267,7 @@ export const MultiChainConnect: React.FC<{ {connectedCount}/{configuredChains.length} ) : ( - <>Connect Wallet + <>Connect {isShowroom ? "Demo " : ""}Wallet )} ); From 6f3105e86f97b9037f0237e44449056411ddb7a1 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:03:47 +0200 Subject: [PATCH 013/146] improve welcomemodal --- src/components/layout/WelcomeModal.tsx | 14 ++++++++++--- src/components/wallets/MultiChainConnect.tsx | 9 +++++++- src/components/wallets/WalletModal.tsx | 22 ++++++++++++++++++-- src/components/wallets/index.ts | 1 - 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/components/layout/WelcomeModal.tsx b/src/components/layout/WelcomeModal.tsx index 381a4d62..bb430f53 100644 --- a/src/components/layout/WelcomeModal.tsx +++ b/src/components/layout/WelcomeModal.tsx @@ -13,9 +13,17 @@ export const WelcomeModal = () => { }, []); const handleShowroomMode = (isShowroom: boolean) => { + // Close the welcome modal setIsModalOpen(false); + + // Set showroom mode based on user selection setShowroom(isShowroom); - setWalletMenuOpen(!isShowroom); + + // If not using showroom mode, open the wallet connection modal + // This matches the behavior of clicking the top-right Connect Wallet button + if (!isShowroom) { + setWalletMenuOpen(true); + } }; const handleNextStep = () => { @@ -76,8 +84,8 @@ export const WelcomeModal = () => {

    Easily switch between modes using the toggle

    From 1db2895a8a9054dd579b2dcd451e68c176f28a7c Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:17:31 +0200 Subject: [PATCH 015/146] update perf --- src/app/portfolio/ConnectWallet.tsx | 136 +++++++++++++++++++++++-- src/components/layout/WelcomeModal.tsx | 55 +++++++--- 2 files changed, 170 insertions(+), 21 deletions(-) diff --git a/src/app/portfolio/ConnectWallet.tsx b/src/app/portfolio/ConnectWallet.tsx index a35372b1..45d1946a 100644 --- a/src/app/portfolio/ConnectWallet.tsx +++ b/src/app/portfolio/ConnectWallet.tsx @@ -1,16 +1,129 @@ import { Button } from "~/components/ui/button"; import { useWallet } from "~/hooks/useWallet"; +import { getChains } from "~/api/adamik/chains"; +import { getPreferredChains } from "~/config/wallet-chains"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; +import { Account, WalletName } from "~/components/wallets/types"; +import { useToast } from "~/components/ui/use-toast"; +import { useState } from "react"; +import { Loader2 } from "lucide-react"; /** * ConnectWallet * Modal version of wallet connection prompt used in transaction flows + * Uses direct connection without showing the wallet modal */ export const ConnectWallet = ({ onNextStep }: { onNextStep: () => void }) => { - const { setWalletMenuOpen } = useWallet(); + const { addAddresses, setShowroom } = useWallet(); + const { toast } = useToast(); + const [isConnecting, setIsConnecting] = useState(false); - const handleConnect = () => { - setWalletMenuOpen(true); - onNextStep(); + const handleConnect = async () => { + setIsConnecting(true); + + try { + // Turn off demo mode + setShowroom(false); + + // Show connecting toast + toast({ + description: "Connecting wallet...", + duration: 3000, + }); + + // Fetch chains data + const chainsData = await getChains(); + if (!chainsData) { + throw new Error("Failed to load chain information"); + } + + const preferredChains = getPreferredChains(chainsData); + if (preferredChains.length === 0) { + throw new Error("No chains configured for connection"); + } + + // Connect to all chains in parallel + const connectionPromises = preferredChains.map(async (chainId) => { + try { + // Get chain public key + const pubkeyResponse = await fetch( + `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, + { + method: "GET", + cache: "no-store", + } + ); + + if (!pubkeyResponse.ok) { + throw new Error(`Failed to get pubkey for ${chainId}`); + } + + const pubkeyData = await pubkeyResponse.json(); + const pubkey = pubkeyData.data.pubkey; + + // Derive address from pubkey + const { address } = await encodePubKeyToAddress(pubkey, chainId); + + // Return account object + return { + success: true, + account: { + address, + chainId, + pubKey: pubkey, + signer: WalletName.SODOT, + }, + }; + } catch (err) { + console.error(`Failed to connect to ${chainId}:`, err); + return { + success: false, + chainId, + error: err instanceof Error ? err.message : String(err), + }; + } + }); + + // Wait for all connections to complete + const results = await Promise.all(connectionPromises); + + // Process results + const successfulAccounts = results + .filter((result) => result.success) + .map( + (result) => (result as { success: true; account: Account }).account + ); + + // Add all successful accounts at once + if (successfulAccounts.length > 0) { + addAddresses(successfulAccounts); + } + + // Count successes and failures + const successCount = successfulAccounts.length; + const failedCount = results.length - successCount; + + // Show final result + toast({ + description: `Connected to ${successCount} chains${ + failedCount > 0 ? `, ${failedCount} failed` : "" + }`, + duration: 3000, + }); + + // Proceed to next step + onNextStep(); + } catch (error) { + console.error("Error connecting wallet:", error); + toast({ + description: + error instanceof Error ? error.message : "Connection failed", + variant: "destructive", + duration: 3000, + }); + } finally { + setIsConnecting(false); + } }; return ( @@ -20,8 +133,19 @@ export const ConnectWallet = ({ onNextStep }: { onNextStep: () => void }) => { You are currently using the demo version of the Adamik App.
    Please add your wallet before signing transactions.
    -
    ); diff --git a/src/components/layout/WelcomeModal.tsx b/src/components/layout/WelcomeModal.tsx index fa33d268..f5cfe036 100644 --- a/src/components/layout/WelcomeModal.tsx +++ b/src/components/layout/WelcomeModal.tsx @@ -45,12 +45,14 @@ export const WelcomeModal = () => { throw new Error("No chains configured for connection"); } - // Connect to all chains - let successCount = 0; - let failedCount = 0; + // Connect to all chains in parallel + toast({ + description: `Connecting to ${preferredChains.length} chains...`, + duration: 2000, + }); - // Process each chain - for (const chainId of preferredChains) { + // Create an array of promises for all chain connections + const connectionPromises = preferredChains.map(async (chainId) => { try { // Get chain public key const pubkeyResponse = await fetch( @@ -71,22 +73,45 @@ export const WelcomeModal = () => { // Derive address from pubkey const { address } = await encodePubKeyToAddress(pubkey, chainId); - // Create and add account - const account: Account = { - address, - chainId, - pubKey: pubkey, - signer: WalletName.SODOT, + // Return account object + return { + success: true, + account: { + address, + chainId, + pubKey: pubkey, + signer: WalletName.SODOT, + }, }; - - addAddresses([account]); - successCount++; } catch (err) { console.error(`Failed to connect to ${chainId}:`, err); - failedCount++; + return { + success: false, + chainId, + error: err instanceof Error ? err.message : String(err), + }; } + }); + + // Wait for all connections to complete + const results = await Promise.all(connectionPromises); + + // Process results + const successfulAccounts = results + .filter((result) => result.success) + .map( + (result) => (result as { success: true; account: Account }).account + ); + + // Add all successful accounts at once + if (successfulAccounts.length > 0) { + addAddresses(successfulAccounts); } + // Count successes and failures + const successCount = successfulAccounts.length; + const failedCount = results.length - successCount; + // Show final result toast({ description: `Connected to ${successCount} chains${ From 928b63abd083375dfa7b4f2b7fac96016e3482c9 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:20:12 +0200 Subject: [PATCH 016/146] vercel build fix --- src/app/sodot-test/page.tsx | 3 +- src/components/wallets/MultiChainConnect.tsx | 53 ++++++++++---------- src/components/wallets/SodotConnect.tsx | 43 +++++++++------- 3 files changed, 54 insertions(+), 45 deletions(-) diff --git a/src/app/sodot-test/page.tsx b/src/app/sodot-test/page.tsx index ac4a21a8..3668ae18 100644 --- a/src/app/sodot-test/page.tsx +++ b/src/app/sodot-test/page.tsx @@ -382,7 +382,8 @@ export default function SodotTestPage() {

    This demo showcases Threshold Signature Scheme (TSS) integration - using Sodot's secure MPC protocol. The implementation features: + using Sodot's secure MPC protocol. The implementation + features:

    • diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index 2f2627bc..f3f91a72 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -41,32 +41,6 @@ export const MultiChainConnect: React.FC<{ const [failedChains, setFailedChains] = useState([]); const [configuredChains, setConfiguredChains] = useState([]); - // If hideButton is true, don't render anything - if (hideButton) { - return null; - } - - // Fetch chains data when component mounts - useEffect(() => { - const fetchChains = async () => { - try { - const chainsData = await getChains(); - if (chainsData) { - setChains(chainsData); - const preferredChains = getPreferredChains(chainsData); - setConfiguredChains(preferredChains); - } else { - setError("Failed to load chain information"); - } - } catch (e) { - console.error("Error fetching chains:", e); - setError("Failed to load chain information"); - } - }; - - fetchChains(); - }, []); - const getAddressForChain = async (chainId: string) => { if (!chains || !chains[chainId]) { throw new Error(`Chain ${chainId} not supported`); @@ -116,6 +90,27 @@ export const MultiChainConnect: React.FC<{ } }; + // Fetch chains data when component mounts + useEffect(() => { + const fetchChains = async () => { + try { + const chainsData = await getChains(); + if (chainsData) { + setChains(chainsData); + const preferredChains = getPreferredChains(chainsData); + setConfiguredChains(preferredChains); + } else { + setError("Failed to load chain information"); + } + } catch (e) { + console.error("Error fetching chains:", e); + setError("Failed to load chain information"); + } + }; + + fetchChains(); + }, []); + // Handle successful chain connection const handleSuccessfulConnection = useCallback( (result: { pubkey: string; address: string; chainId: string }) => { @@ -216,6 +211,7 @@ export const MultiChainConnect: React.FC<{ successCount, failedChains.length, toast, + getAddressForChain, ]); // Clean up when finished @@ -241,6 +237,11 @@ export const MultiChainConnect: React.FC<{ toast, ]); + // If hideButton is true, don't render anything - moved after hooks to avoid conditional hook calls + if (hideButton) { + return null; + } + if (error) { return (
    - } + modalContent={modalContent} /> ); }; diff --git a/src/hooks/useAccountStateBatch.tsx b/src/hooks/useAccountStateBatch.tsx index 31cb5499..c7a222be 100644 --- a/src/hooks/useAccountStateBatch.tsx +++ b/src/hooks/useAccountStateBatch.tsx @@ -1,6 +1,7 @@ -import { useQueries } from "@tanstack/react-query"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; import { accountState } from "~/api/adamik/accountState"; import { queryCache, queryClientGlobal } from "~/providers/QueryProvider"; +import { useMemo } from "react"; type GetAddressStateParams = { chainId: string; @@ -19,13 +20,20 @@ export const isInAccountStateBatchCache = ( export const useAccountStateBatch = ( addressesParams: GetAddressStateParams[] ) => { - return useQueries({ - queries: addressesParams.map(({ chainId, address }) => { - return { + // Memoize the query configurations to prevent unnecessary recreations + const queryConfigs = useMemo( + () => + addressesParams.map(({ chainId, address }) => ({ queryKey: ["accountState", chainId, address], queryFn: async () => accountState(chainId, address), - }; - }), + staleTime: 30000, // Consider data fresh for 30 seconds + cacheTime: 5 * 60 * 1000, // Keep unused data in cache for 5 minutes + })), + [addressesParams] + ); + + const results = useQueries({ + queries: queryConfigs, combine: (results) => { return { error: results.map((result) => result.error), @@ -34,6 +42,8 @@ export const useAccountStateBatch = ( }; }, }); + + return results; }; export const clearAccountStateCache = ({ diff --git a/src/hooks/useLoadingState.tsx b/src/hooks/useLoadingState.tsx new file mode 100644 index 00000000..3cc1d851 --- /dev/null +++ b/src/hooks/useLoadingState.tsx @@ -0,0 +1,60 @@ +import { useState, useEffect, useCallback } from "react"; + +interface UseLoadingStateOptions { + debounceDelay?: number; + minimumLoadingTime?: number; +} + +export const useLoadingState = ( + initialState = false, + options: UseLoadingStateOptions = {} +) => { + const { + debounceDelay = 200, // Default debounce delay to prevent flickering + minimumLoadingTime = 500, // Minimum time to show loading state + } = options; + + const [isLoading, setIsLoading] = useState(initialState); + const [loadingStartTime, setLoadingStartTime] = useState(null); + const [debouncedLoading, setDebouncedLoading] = useState(initialState); + + useEffect(() => { + let debounceTimer: NodeJS.Timeout; + + if (isLoading) { + // Start loading immediately + setDebouncedLoading(true); + setLoadingStartTime(Date.now()); + } else { + // When stopping loading, ensure minimum time has passed + const currentTime = Date.now(); + const timeElapsed = loadingStartTime ? currentTime - loadingStartTime : 0; + const remainingTime = Math.max(0, minimumLoadingTime - timeElapsed); + + debounceTimer = setTimeout(() => { + setDebouncedLoading(false); + setLoadingStartTime(null); + }, remainingTime); + } + + return () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + }; + }, [isLoading, minimumLoadingTime, loadingStartTime]); + + const startLoading = useCallback(() => { + setIsLoading(true); + }, []); + + const stopLoading = useCallback(() => { + setIsLoading(false); + }, []); + + return { + isLoading: debouncedLoading, + startLoading, + stopLoading, + }; +}; From f87eeb69dc9234ad6891e9e21e04661577e20ac6 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:38:21 +0200 Subject: [PATCH 018/146] update tooltip links --- src/app/data/page.tsx | 2 +- src/app/history/page.tsx | 22 ++++++++-------------- src/app/layout.tsx | 10 +--------- src/app/portfolio/page.tsx | 6 ++++-- src/app/stake/page.tsx | 8 +++++--- 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/app/data/page.tsx b/src/app/data/page.tsx index ebc675fb..feeae62e 100644 --- a/src/app/data/page.tsx +++ b/src/app/data/page.tsx @@ -378,7 +378,7 @@ function DataContent() { diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 9064901a..cb7ee42a 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -46,6 +46,8 @@ import { FormatAssetAmountResult, } from "~/utils/assetFormatters"; import { ParsedTransactionComponent } from "~/components/transactions/ParsedTransaction"; +import { WalletConnect } from "~/components"; +import { LoadingModal } from "~/components/layout/LoadingModal"; type GroupedAccount = { address: string; @@ -360,24 +362,15 @@ function TransactionHistoryContent() { return (
    -
    + {isLoading ? : null} + {isShowroom ? : null} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ef845a98..618eac20 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,6 @@ import "./globals.css"; import { Menu } from "~/components/layout/Menu/Menu"; import { Toaster } from "~/components/ui/toaster"; import { WalletModal } from "~/components/wallets/WalletModal"; -import { WalletConnect } from "~/components"; export const metadata: Metadata = { title: "Adamik App", @@ -27,14 +26,7 @@ export default function RootLayout({
    -
    - {/* Top right wallet connection UI */} -
    - -
    - - {children} -
    +
    {children}
    diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 2ee125bf..631f738e 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -35,6 +35,7 @@ import { getTokenContractAddresses, getTokenTickers, } from "./helpers"; +import { WalletConnect } from "~/components"; export default function Portfolio() { const { @@ -171,12 +172,12 @@ export default function Portfolio() { {isLoading && !isInAccountStateBatchCache(displayAddresses) ? ( ) : null} -
    + {isShowroom ? : null} diff --git a/src/app/stake/page.tsx b/src/app/stake/page.tsx index 4e14fc5f..be94d927 100644 --- a/src/app/stake/page.tsx +++ b/src/app/stake/page.tsx @@ -38,6 +38,7 @@ import { } from "./helpers"; import { StakingPositionsList } from "./StakingPositionsList"; import { isStakingSupported } from "~/utils/helper"; +import { WalletConnect } from "~/components"; export default function Stake() { const { addresses, isShowroom, setWalletMenuOpen } = useWallet(); @@ -134,12 +135,12 @@ export default function Stake() { {isLoading && !isInAccountStateBatchCache(displayAddresses) ? ( ) : null} -
    + {isShowroom ? : null} From c04d0c994763797728af5daba63fdbf14a2dc656 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:40:13 +0200 Subject: [PATCH 019/146] Update page.tsx --- src/app/data/page.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/app/data/page.tsx b/src/app/data/page.tsx index feeae62e..90e7e423 100644 --- a/src/app/data/page.tsx +++ b/src/app/data/page.tsx @@ -58,21 +58,30 @@ function DataContent() { const [formattedAmount, setFormattedAmount] = useState("N/A"); const [formattedFees, setFormattedFees] = useState("N/A"); - const searchParams = useSearchParams(); + const rawSearchParams = useSearchParams(); + // Create a safe version of searchParams with default values + const searchParams = { + chainId: rawSearchParams?.get("chainId") ?? "", + transactionId: rawSearchParams?.get("transactionId") ?? "", + }; + const { isLoading: isSupportedChainsLoading, data: supportedChains } = useChains(); const form = useForm({ defaultValues: { - chainId: searchParams.get("chainId") || "", - transactionId: searchParams.get("transactionId") || "", + chainId: searchParams.chainId, + transactionId: searchParams.transactionId, }, }); const [input, setInput] = useState<{ chainId: string | undefined; transactionId: string | undefined; - }>({ chainId: undefined, transactionId: undefined }); + }>({ + chainId: searchParams.chainId || undefined, + transactionId: searchParams.transactionId || undefined, + }); function onSubmit(data: any) { console.log("Search button clicked. New input:", data); @@ -357,8 +366,8 @@ function DataContent() { }; useEffect(() => { - const chainId = searchParams.get("chainId"); - const transactionId = searchParams.get("transactionId"); + const chainId = searchParams.chainId; + const transactionId = searchParams.transactionId; if (chainId && transactionId) { form.setValue("chainId", chainId); From 74d505f28c41cd6234468feeb8712e5fd509a2bd Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:14:15 +0200 Subject: [PATCH 020/146] Added selection option for chains --- src/components/WalletConnect.tsx | 27 ++-- src/components/wallets/ChainSelector.tsx | 166 +++++++++++++++++++++++ src/components/wallets/WalletButton.tsx | 11 +- src/hooks/useWallet.tsx | 2 + src/providers/WalletProvider.tsx | 26 ++++ 5 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 src/components/wallets/ChainSelector.tsx diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index 15962bb8..9c7cc80c 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -6,6 +6,7 @@ import { Button } from "~/components/ui/button"; import { Switch } from "~/components/ui/switch"; import { Label } from "~/components/ui/label"; import { MultiChainConnect } from "./wallets/MultiChainConnect"; +import { ChainSelector } from "./wallets/ChainSelector"; /** * WalletConnect @@ -47,19 +48,19 @@ export function WalletConnect() { return (
    - {/* Connect Wallet Button */} - + {/* Chain Selector */} + {hasConnectedWallets ? ( + + ) : ( + + )} {/* Demo Mode Toggle */}
    diff --git a/src/components/wallets/ChainSelector.tsx b/src/components/wallets/ChainSelector.tsx new file mode 100644 index 00000000..d7a77cb3 --- /dev/null +++ b/src/components/wallets/ChainSelector.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { useToast } from "~/components/ui/use-toast"; +import { useWallet } from "~/hooks/useWallet"; +import { getChains } from "~/api/adamik/chains"; +import { Chain } from "~/utils/types"; +import { getPreferredChains } from "~/config/wallet-chains"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; +import { Account, WalletName } from "./types"; +import { ChevronDown } from "lucide-react"; + +export function ChainSelector() { + const { toast } = useToast(); + const { addresses, addAddresses, removeAddresses } = useWallet(); + const [chains, setChains] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [selectedChains, setSelectedChains] = useState([]); + + // Fetch chains on mount + useEffect(() => { + const fetchChains = async () => { + try { + const chainsData = await getChains(); + if (chainsData) { + setChains(chainsData); + // Set initially selected chains based on wallet-chains.ts + const preferredChains = getPreferredChains(chainsData); + setSelectedChains(preferredChains); + } + } catch (e) { + console.error("Error fetching chains:", e); + toast({ + description: "Failed to load chain information", + variant: "destructive", + }); + } + }; + + fetchChains(); + }, [toast]); + + // Update selected chains when addresses change + useEffect(() => { + const connectedChainIds = addresses.map((addr) => addr.chainId); + setSelectedChains(connectedChainIds); + }, [addresses]); + + const connectChain = async (chainId: string) => { + if (!chains?.[chainId]) return; + + setLoading(true); + try { + // Get chain public key + const response = await fetch( + `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + throw new Error("Failed to get chain public key"); + } + + const data = await response.json(); + const pubkey = data.data.pubkey; + + // Get address from public key + const { address } = await encodePubKeyToAddress(pubkey, chainId); + + // Create and add account + const account: Account = { + address, + chainId, + pubKey: pubkey, + signer: WalletName.SODOT, + }; + + addAddresses([account]); + toast({ + description: `Connected ${chains[chainId].name}`, + }); + } catch (e) { + console.error(`Error connecting to ${chainId}:`, e); + toast({ + description: `Failed to connect ${chains[chainId].name}`, + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + const disconnectChain = (chainId: string) => { + const accountToRemove = addresses.find((addr) => addr.chainId === chainId); + if (accountToRemove) { + removeAddresses([accountToRemove]); + toast({ + description: `Disconnected ${chains?.[chainId]?.name || chainId}`, + }); + } + }; + + const handleChainToggle = async (chainId: string, checked: boolean) => { + if (checked) { + await connectChain(chainId); + } else { + disconnectChain(chainId); + } + }; + + if (!chains) { + return ( + + ); + } + + return ( + + + + + + +
    + {Object.entries(chains) + .sort(([, a], [, b]) => a.name.localeCompare(b.name)) + .map(([chainId, chain]) => ( + + ))} +
    +
    +
    +
    + ); +} diff --git a/src/components/wallets/WalletButton.tsx b/src/components/wallets/WalletButton.tsx index ddecef01..c9cd154e 100644 --- a/src/components/wallets/WalletButton.tsx +++ b/src/components/wallets/WalletButton.tsx @@ -3,23 +3,18 @@ import React from "react"; import { WalletSelection } from "./WalletSelection"; import { useWallet } from "~/hooks/useWallet"; -import { Button } from "~/components/ui/button"; +import { ChainSelector } from "./ChainSelector"; /** * WalletButton component - * Shows either the MultiChainConnect button or the number of connected chains + * Shows either the MultiChainConnect button or the chain selector */ export function WalletButton() { const { addresses } = useWallet(); const hasConnectedWallets = addresses.length > 0; if (hasConnectedWallets) { - return ( -
    - {addresses.length} {addresses.length === 1 ? "Chain" : "Chains"}{" "} - Connected -
    - ); + return ; } return ; diff --git a/src/hooks/useWallet.tsx b/src/hooks/useWallet.tsx index b6082cd9..c7c3f5b4 100644 --- a/src/hooks/useWallet.tsx +++ b/src/hooks/useWallet.tsx @@ -6,6 +6,7 @@ type WalletContextType = { addWallet: (wallet: IWallet) => void; addresses: Account[]; addAddresses: (addresses: Account[]) => void; + removeAddresses: (addresses: Account[]) => void; setAddresses: (addresses: Account[]) => void; setWalletMenuOpen: React.Dispatch>; isWalletMenuOpen: boolean; @@ -20,6 +21,7 @@ export const WalletContext = React.createContext({ addWallet: () => {}, addresses: [], addAddresses: () => {}, + removeAddresses: () => {}, setAddresses: () => {}, setWalletMenuOpen: () => {}, isWalletMenuOpen: false, diff --git a/src/providers/WalletProvider.tsx b/src/providers/WalletProvider.tsx index c9babbb0..05b7c758 100644 --- a/src/providers/WalletProvider.tsx +++ b/src/providers/WalletProvider.tsx @@ -84,6 +84,31 @@ export const WalletProvider: React.FC = ({ }); }; + const removeAddresses = (addressesToRemove: Account[]) => { + setAddresses((oldAddresses) => { + const remainingAddresses = oldAddresses.filter( + (addr) => + !addressesToRemove.some( + (toRemove) => + toRemove.address === addr.address && + toRemove.chainId === addr.chainId + ) + ); + + // Only save to localStorage if not in showroom mode + if (!isShowroom) { + localStorage?.setItem( + "AdamikClientAddresses", + JSON.stringify(remainingAddresses) + ); + // Update real wallet addresses as well + setRealWalletAddresses(remainingAddresses); + } + + return remainingAddresses; + }); + }; + // Improved setShowroom function that properly handles address switching const handleSetShowroom = (showroomState: boolean) => { // Save the current state to client state @@ -147,6 +172,7 @@ export const WalletProvider: React.FC = ({ addresses, setAddresses, addAddresses, + removeAddresses, setWalletMenuOpen, isWalletMenuOpen, isShowroom, From bd34ee748fe100f3f61553274870bd67fb43335f Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 08:52:17 +0200 Subject: [PATCH 021/146] perf(chains): optimize chain data fetching and caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement centralized chain data management using React Query to reduce API calls and improve performance across the application. Key changes: - Add useChains hook with 24h cache duration - Implement chain data prefetching at app startup - Replace direct API calls with cached data in all components: • SodotConnect • MultiChainConnect • ChainSelector • WelcomeModal • SodotTestPage Technical details: - Use React Query for state management and caching - Set 24h staleTime and gcTime for optimal caching - Add proper TypeScript types for chain data - Implement error handling and retry logic Performance impact: - Reduce multiple chain API calls to a single call per 24h - Improve component mount times by using cached data - Enable offline functionality for chain data - Reduce server load by minimizing redundant requests Testing: - Verify through Network tab in DevTools - Confirm cache persistence across page refreshes - Check data consistency across components --- src/app/sodot-test/page.tsx | 19 +---- src/components/layout/WelcomeModal.tsx | 8 +- src/components/wallets/ChainSelector.tsx | 35 ++------- src/components/wallets/MultiChainConnect.tsx | 64 +++------------- src/components/wallets/SodotConnect.tsx | 77 +++++--------------- src/hooks/useChains.tsx | 10 ++- src/providers/QueryProvider.tsx | 14 +++- 7 files changed, 66 insertions(+), 161 deletions(-) diff --git a/src/app/sodot-test/page.tsx b/src/app/sodot-test/page.tsx index 3668ae18..351a7812 100644 --- a/src/app/sodot-test/page.tsx +++ b/src/app/sodot-test/page.tsx @@ -18,7 +18,7 @@ import { Server, ChevronDown, } from "lucide-react"; -import { getChains } from "~/api/adamik/chains"; +import { useChains } from "~/hooks/useChains"; import { Chain } from "~/utils/types"; import { Select, @@ -60,23 +60,8 @@ export default function SodotTestPage() { const [results, setResults] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [chains, setChains] = useState | null>(null); const [selectedChain, setSelectedChain] = useState(""); - - // Fetch chains when component mounts - useEffect(() => { - const fetchChains = async () => { - try { - const chainsData = await getChains(); - setChains(chainsData); - } catch (e) { - console.error("Error fetching chains:", e); - setError("Failed to load chain information"); - } - }; - - fetchChains(); - }, []); + const { data: chains, isLoading: chainsLoading } = useChains(); // Add effect to monitor state changes console.log("Component render - Current state:", { diff --git a/src/components/layout/WelcomeModal.tsx b/src/components/layout/WelcomeModal.tsx index f5cfe036..7decc18d 100644 --- a/src/components/layout/WelcomeModal.tsx +++ b/src/components/layout/WelcomeModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Modal } from "../ui/modal"; import { useWallet } from "~/hooks/useWallet"; import { Button } from "../ui/button"; -import { getChains } from "~/api/adamik/chains"; +import { useChains } from "~/hooks/useChains"; import { getPreferredChains } from "~/config/wallet-chains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { Account, WalletName } from "../wallets/types"; @@ -15,6 +15,7 @@ export const WelcomeModal = () => { const { setShowroom, addAddresses } = useWallet(); const { toast } = useToast(); const [isConnecting, setIsConnecting] = useState(false); + const { data: chains } = useChains(); useEffect(() => { setIsModalOpen(true); @@ -35,12 +36,11 @@ export const WelcomeModal = () => { }); // Fetch chains data - const chainsData = await getChains(); - if (!chainsData) { + if (!chains) { throw new Error("Failed to load chain information"); } - const preferredChains = getPreferredChains(chainsData); + const preferredChains = getPreferredChains(chains); if (preferredChains.length === 0) { throw new Error("No chains configured for connection"); } diff --git a/src/components/wallets/ChainSelector.tsx b/src/components/wallets/ChainSelector.tsx index d7a77cb3..56a0feef 100644 --- a/src/components/wallets/ChainSelector.tsx +++ b/src/components/wallets/ChainSelector.tsx @@ -9,7 +9,7 @@ import { import { ScrollArea } from "~/components/ui/scroll-area"; import { useToast } from "~/components/ui/use-toast"; import { useWallet } from "~/hooks/useWallet"; -import { getChains } from "~/api/adamik/chains"; +import { useChains } from "~/hooks/useChains"; import { Chain } from "~/utils/types"; import { getPreferredChains } from "~/config/wallet-chains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; @@ -19,34 +19,17 @@ import { ChevronDown } from "lucide-react"; export function ChainSelector() { const { toast } = useToast(); const { addresses, addAddresses, removeAddresses } = useWallet(); - const [chains, setChains] = useState | null>(null); const [loading, setLoading] = useState(false); const [selectedChains, setSelectedChains] = useState([]); + const { data: chains, isLoading: chainsLoading } = useChains(); - // Fetch chains on mount useEffect(() => { - const fetchChains = async () => { - try { - const chainsData = await getChains(); - if (chainsData) { - setChains(chainsData); - // Set initially selected chains based on wallet-chains.ts - const preferredChains = getPreferredChains(chainsData); - setSelectedChains(preferredChains); - } - } catch (e) { - console.error("Error fetching chains:", e); - toast({ - description: "Failed to load chain information", - variant: "destructive", - }); - } - }; - - fetchChains(); - }, [toast]); + if (chains) { + const preferredChains = getPreferredChains(chains); + setSelectedChains(preferredChains); + } + }, [chains]); - // Update selected chains when addresses change useEffect(() => { const connectedChainIds = addresses.map((addr) => addr.chainId); setSelectedChains(connectedChainIds); @@ -57,7 +40,6 @@ export function ChainSelector() { setLoading(true); try { - // Get chain public key const response = await fetch( `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, { @@ -72,11 +54,8 @@ export function ChainSelector() { const data = await response.json(); const pubkey = data.data.pubkey; - - // Get address from public key const { address } = await encodePubKeyToAddress(pubkey, chainId); - // Create and add account const account: Account = { address, chainId, diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index f3f91a72..43dfdd34 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -11,7 +11,7 @@ import { Account, WalletName } from "./types"; import { Chain } from "~/utils/types"; import { Button } from "~/components/ui/button"; import { Loader2, ChevronRight } from "lucide-react"; -import { getChains } from "~/api/adamik/chains"; +import { useChains } from "~/hooks/useChains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { getPreferredChains } from "~/config/wallet-chains"; @@ -34,20 +34,25 @@ export const MultiChainConnect: React.FC<{ const { toast } = useToast(); const { addAddresses, isShowroom } = useWallet(); const [loading, setLoading] = useState(false); - const [chains, setChains] = useState | null>(null); const [error, setError] = useState(null); const [connectedCount, setConnectedCount] = useState(0); const [successCount, setSuccessCount] = useState(0); const [failedChains, setFailedChains] = useState([]); const [configuredChains, setConfiguredChains] = useState([]); + const { data: chains, isLoading: chainsLoading } = useChains(); + + useEffect(() => { + if (chains) { + const preferredChains = getPreferredChains(chains); + setConfiguredChains(preferredChains); + } + }, [chains]); const getAddressForChain = async (chainId: string) => { if (!chains || !chains[chainId]) { throw new Error(`Chain ${chainId} not supported`); } - // Call our backend endpoint for the chain pubkey - console.log(`[MultiChainConnect] Fetching pubkey for ${chainId}`); const response = await fetch( `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, { @@ -64,57 +69,14 @@ export const MultiChainConnect: React.FC<{ } const data = await response.json(); - console.log( - `[MultiChainConnect] Received pubkey data for ${chainId}:`, - data - ); - - // Get the pubkey from the response const pubkey = data.data.pubkey; - console.log(`[MultiChainConnect] Extracted ${chainId} pubkey:`, pubkey); - - // Use the encodePubKeyToAddress API endpoint directly - console.log(`[MultiChainConnect] Encoding address for ${chainId}`); - try { - const { address } = await encodePubKeyToAddress(pubkey, chainId); - console.log(`[MultiChainConnect] Address for ${chainId}:`, address); - return { pubkey, address, chainId }; - } catch (e) { - console.error(`[MultiChainConnect] Error encoding address:`, e); - throw new Error( - `Failed to encode address: ${ - e instanceof Error ? e.message : String(e) - }` - ); - } + const { address } = await encodePubKeyToAddress(pubkey, chainId); + return { pubkey, address, chainId }; }; - // Fetch chains data when component mounts - useEffect(() => { - const fetchChains = async () => { - try { - const chainsData = await getChains(); - if (chainsData) { - setChains(chainsData); - const preferredChains = getPreferredChains(chainsData); - setConfiguredChains(preferredChains); - } else { - setError("Failed to load chain information"); - } - } catch (e) { - console.error("Error fetching chains:", e); - setError("Failed to load chain information"); - } - }; - - fetchChains(); - }, []); - - // Handle successful chain connection const handleSuccessfulConnection = useCallback( (result: { pubkey: string; address: string; chainId: string }) => { - // Create account with the address and public key const account: Account = { address: result.address, chainId: result.chainId, @@ -122,15 +84,13 @@ export const MultiChainConnect: React.FC<{ signer: WalletName.SODOT, }; - // Add this account immediately addAddresses([account]); - // Show a brief toast for the successful connection toast({ description: `Connected ${ chains?.[result.chainId]?.name || result.chainId }`, - duration: 1500, // Short duration to avoid flooding + duration: 1500, }); setSuccessCount((prev) => prev + 1); diff --git a/src/components/wallets/SodotConnect.tsx b/src/components/wallets/SodotConnect.tsx index 292da404..d29d164e 100644 --- a/src/components/wallets/SodotConnect.tsx +++ b/src/components/wallets/SodotConnect.tsx @@ -5,7 +5,7 @@ import { Account, WalletConnectorProps, WalletName } from "./types"; import { Chain } from "~/utils/types"; import { Button } from "~/components/ui/button"; import { Loader2 } from "lucide-react"; -import { getChains } from "~/api/adamik/chains"; +import { useChains } from "~/hooks/useChains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { Card } from "~/components/ui/card"; import { defaultChain, getPreferredChains } from "~/config/wallet-chains"; @@ -19,46 +19,29 @@ export const SodotConnect: React.FC = ({ const { toast } = useToast(); const { addAddresses } = useWallet(); const [loading, setLoading] = useState(false); - const [chains, setChains] = useState | null>(null); const [error, setError] = useState(null); const [selectedChainId, setSelectedChainId] = useState( providedChainId || "" ); const [autoConnectInProgress, setAutoConnectInProgress] = useState(false); + const { data: chains, isLoading: chainsLoading } = useChains(); - // Fetch chains data when component mounts + // Set initial chain when data is loaded useEffect(() => { - const fetchChains = async () => { - try { - const chainsData = await getChains(); - if (chainsData) { - setChains(chainsData); - - // Auto-select first chain if none provided and we're not in transaction mode - if (!providedChainId && !selectedChainId && !transactionPayload) { - const preferredChains = getPreferredChains(chainsData); - const firstAvailableChain = - preferredChains.length > 0 - ? preferredChains[0] - : defaultChain in chainsData - ? defaultChain - : Object.keys(chainsData)[0]; - - if (firstAvailableChain) { - setSelectedChainId(firstAvailableChain); - } - } - } else { - setError("Failed to load chain information"); - } - } catch (e) { - console.error("Error fetching chains:", e); - setError("Failed to load chain information"); + if (chains && !providedChainId && !selectedChainId && !transactionPayload) { + const preferredChains = getPreferredChains(chains); + const firstAvailableChain = + preferredChains.length > 0 + ? preferredChains[0] + : defaultChain in chains + ? defaultChain + : Object.keys(chains)[0]; + + if (firstAvailableChain) { + setSelectedChainId(firstAvailableChain); } - }; - - fetchChains(); - }, [providedChainId, selectedChainId, transactionPayload]); + } + }, [chains, providedChainId, selectedChainId, transactionPayload]); // Update selected chain when providedChainId changes useEffect(() => { @@ -72,8 +55,6 @@ export const SodotConnect: React.FC = ({ throw new Error(`Chain ${chainId} not supported`); } - // Call our backend endpoint for the chain pubkey - console.log(`[SodotConnect] Fetching pubkey for ${chainId}`); const response = await fetch( `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, { @@ -90,27 +71,10 @@ export const SodotConnect: React.FC = ({ } const data = await response.json(); - console.log(`[SodotConnect] Received pubkey data for ${chainId}:`, data); - - // Get the pubkey from the response const pubkey = data.data.pubkey; - console.log(`[SodotConnect] Extracted ${chainId} pubkey:`, pubkey); - - // Use the encodePubKeyToAddress API endpoint directly - console.log(`[SodotConnect] Encoding address for ${chainId}`); - try { - const { address } = await encodePubKeyToAddress(pubkey, chainId); - console.log(`[SodotConnect] Address for ${chainId}:`, address); - return { pubkey, address }; - } catch (e) { - console.error(`[SodotConnect] Error encoding address:`, e); - throw new Error( - `Failed to encode address: ${ - e instanceof Error ? e.message : String(e) - }` - ); - } + const { address } = await encodePubKeyToAddress(pubkey, chainId); + return { pubkey, address }; }; const getAddresses = useCallback(async () => { @@ -128,7 +92,6 @@ export const SodotConnect: React.FC = ({ try { const { pubkey, address } = await getAddressForChain(selectedChainId); - // Create account with the address and public key const account: Account = { address: address, chainId: selectedChainId, @@ -139,7 +102,7 @@ export const SodotConnect: React.FC = ({ addAddresses([account]); toast({ - description: `Connected Sodot Wallet for ${ + description: `Connected ${ chains[selectedChainId]?.name || selectedChainId }`, }); @@ -154,7 +117,7 @@ export const SodotConnect: React.FC = ({ } finally { setLoading(false); } - }, [selectedChainId, addAddresses, toast, chains, getAddressForChain]); + }, [selectedChainId, addAddresses, toast, chains]); // Auto connect when using non-transaction mode useEffect(() => { diff --git a/src/hooks/useChains.tsx b/src/hooks/useChains.tsx index e3f12ea2..9b569150 100644 --- a/src/hooks/useChains.tsx +++ b/src/hooks/useChains.tsx @@ -1,9 +1,15 @@ import { useQuery } from "@tanstack/react-query"; import { getChains } from "~/api/adamik/chains"; +import { Chain } from "~/utils/types"; + +export const CHAINS_QUERY_KEY = ["chains"] as const; export const useChains = () => { - return useQuery({ - queryKey: ["chains"], + return useQuery | null>({ + queryKey: CHAINS_QUERY_KEY, queryFn: async () => getChains(), + staleTime: 24 * 60 * 60 * 1000, + gcTime: 24 * 60 * 60 * 1000, + retry: 3, }); }; diff --git a/src/providers/QueryProvider.tsx b/src/providers/QueryProvider.tsx index afb5807b..726ccd37 100644 --- a/src/providers/QueryProvider.tsx +++ b/src/providers/QueryProvider.tsx @@ -1,7 +1,9 @@ import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { QueryCache, QueryClient } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { CHAINS_QUERY_KEY } from "~/hooks/useChains"; +import { getChains } from "~/api/adamik/chains"; export const queryCache = new QueryCache(); @@ -25,6 +27,16 @@ export const QueryProvider: React.FC = ({ storage: typeof window !== "undefined" ? window.localStorage : null, }); + // Prefetch chains data when the provider mounts + useEffect(() => { + queryClient.prefetchQuery({ + queryKey: CHAINS_QUERY_KEY, + queryFn: async () => getChains(), + staleTime: 24 * 60 * 60 * 1000, // 24 hours + gcTime: 24 * 60 * 60 * 1000, // 24 hours + }); + }, [queryClient]); + return ( Date: Sun, 13 Apr 2025 09:49:03 +0200 Subject: [PATCH 022/146] Improved refresh with toast and actual refresh --- src/app/portfolio/page.tsx | 53 +++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 631f738e..ab78abbc 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -36,6 +36,8 @@ import { getTokenTickers, } from "./helpers"; import { WalletConnect } from "~/components"; +import { useQueryClient } from "@tanstack/react-query"; +import { accountState } from "~/api/adamik/accountState"; export default function Portfolio() { const { @@ -45,6 +47,7 @@ export default function Portfolio() { } = useWallet(); const { toast } = useToast(); + const queryClient = useQueryClient(); const displayAddresses = isShowroom ? showroomAddresses : walletAddresses; const addressesChainIds = displayAddresses.reduce( (acc, { chainId }) => { @@ -158,11 +161,53 @@ export default function Portfolio() { stakingBalances.unstakingBalance; const refreshPositions = () => { - toast({ description: "Refreshing portfolio..." }); - assets.forEach((asset) => { + console.log("🔄 Starting refresh for addresses:", displayAddresses); + + // Initial toast + toast({ + description: `Refreshing data for ${addressesChainIds.length} chains...`, + duration: 3000, + }); + + // Clear cache and force immediate refetch for all account states + displayAddresses.forEach(({ chainId, address }) => { + console.log(`🗑️ Clearing cache for ${chainId}:${address}`); clearAccountStateCache({ - chainId: asset.chainId, - address: asset.address, + chainId, + address, + }); + }); + + // Force immediate refetch of all queries + console.log("♻️ Invalidating all account state queries"); + queryClient.invalidateQueries({ + queryKey: ["accountState"], + refetchType: "all", + }); + + console.log("♻️ Invalidating all market data queries"); + queryClient.invalidateQueries({ + queryKey: ["mobula"], + refetchType: "all", + }); + + // Show completion toast after queries are refetched + console.log("⏳ Waiting for account state queries to complete..."); + + // Create promises for each account state query + const promises = displayAddresses.map(({ chainId, address }) => + queryClient.fetchQuery({ + queryKey: ["accountState", chainId, address], + queryFn: () => accountState(chainId, address), + }) + ); + + // Wait for all queries to complete + Promise.all(promises).then(() => { + console.log("✅ All account state queries completed successfully"); + toast({ + description: "Portfolio data updated", + duration: 2000, }); }); }; From cacd2cbd2cae0e8d3dc033814c5787f34c452af3 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 10:04:38 +0200 Subject: [PATCH 023/146] improve loading through toast --- src/app/portfolio/page.tsx | 50 ++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index ab78abbc..1188d1d3 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -1,8 +1,7 @@ "use client"; import { Info } from "lucide-react"; -import { useMemo, useState } from "react"; -import { LoadingModal } from "~/components/layout/LoadingModal"; +import { useMemo, useState, useEffect } from "react"; import { ShowroomBanner } from "~/components/layout/ShowroomBanner"; import { TransferTransactionForm } from "~/components/transactions/TransferTransactionForm"; import { Modal } from "~/components/ui/modal"; @@ -122,6 +121,50 @@ export default function Portfolio() { isSupportedChainsLoading || isMobulaMarketDataLoading; + // Add loading state management with toast + useEffect(() => { + let loadingToast: ReturnType | undefined; + + if (isLoading) { + loadingToast = toast({ + description: ( +
    +
    Loading portfolio data...
    +
    + {isAddressesLoading ? "• Fetching addresses data" : ""} + {isAssetDetailsLoading ? "• Loading asset details" : ""} + {isSupportedChainsLoading ? "• Loading chain information" : ""} + {isMobulaMarketDataLoading ? "• Fetching market data" : ""} +
    +
    + ), + duration: Infinity, + }); + } else if (loadingToast) { + // Dismiss the loading toast + loadingToast.dismiss(); + + // Show completion toast + toast({ + description: "Portfolio data loaded successfully", + duration: 2000, + }); + } + + return () => { + if (loadingToast) { + loadingToast.dismiss(); + } + }; + }, [ + isLoading, + isAddressesLoading, + isAssetDetailsLoading, + isSupportedChainsLoading, + isMobulaMarketDataLoading, + toast, + ]); + const assets = useMemo(() => { return filterAndSortAssets( calculateAssets( @@ -214,9 +257,6 @@ export default function Portfolio() { return (
    - {isLoading && !isInAccountStateBatchCache(displayAddresses) ? ( - - ) : null}

    Portfolio

    From c6b9726a3902e056c3079363c934f832f9368add Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 10:08:29 +0200 Subject: [PATCH 024/146] refresh toast improvements --- src/app/portfolio/page.tsx | 92 +++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 1188d1d3..7932137a 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -37,6 +37,7 @@ import { import { WalletConnect } from "~/components"; import { useQueryClient } from "@tanstack/react-query"; import { accountState } from "~/api/adamik/accountState"; +import { Progress } from "~/components/ui/progress"; export default function Portfolio() { const { @@ -206,10 +207,27 @@ export default function Portfolio() { const refreshPositions = () => { console.log("🔄 Starting refresh for addresses:", displayAddresses); - // Initial toast - toast({ - description: `Refreshing data for ${addressesChainIds.length} chains...`, - duration: 3000, + let refreshToast: ReturnType | undefined; + let completedQueries = 0; + const totalQueries = displayAddresses.length; + + // Initial toast with progress + refreshToast = toast({ + description: ( +
    +
    + Refreshing portfolio data... + + {completedQueries}/{totalQueries} chains + +
    + +
    + ), + duration: Infinity, }); // Clear cache and force immediate refetch for all account states @@ -234,25 +252,65 @@ export default function Portfolio() { refetchType: "all", }); - // Show completion toast after queries are refetched - console.log("⏳ Waiting for account state queries to complete..."); - // Create promises for each account state query const promises = displayAddresses.map(({ chainId, address }) => - queryClient.fetchQuery({ - queryKey: ["accountState", chainId, address], - queryFn: () => accountState(chainId, address), - }) + queryClient + .fetchQuery({ + queryKey: ["accountState", chainId, address], + queryFn: () => accountState(chainId, address), + }) + .then(() => { + completedQueries++; + // Update progress toast + const progressDescription = ( +
    +
    + Refreshing portfolio data... + + {completedQueries}/{totalQueries} chains + +
    + +
    + ); + + if (refreshToast) { + toast({ + ...refreshToast, + description: progressDescription, + duration: Infinity, + }); + } + }) ); // Wait for all queries to complete - Promise.all(promises).then(() => { - console.log("✅ All account state queries completed successfully"); - toast({ - description: "Portfolio data updated", - duration: 2000, + Promise.all(promises) + .then(() => { + if (refreshToast) { + refreshToast.dismiss(); + } + // Show success toast + toast({ + description: "Portfolio data updated successfully", + duration: 2000, + }); + }) + .catch((error) => { + if (refreshToast) { + refreshToast.dismiss(); + } + // Show error toast + toast({ + description: "Failed to update some portfolio data", + variant: "destructive", + duration: 3000, + }); + console.error("Error refreshing positions:", error); }); - }); }; return ( From 2e6f43f399eb95af82e1c3a85d7d5954e022f555 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 10:11:06 +0200 Subject: [PATCH 025/146] improvement UX refresh toast --- src/app/portfolio/page.tsx | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 7932137a..8d7cb2d1 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -207,12 +207,11 @@ export default function Portfolio() { const refreshPositions = () => { console.log("🔄 Starting refresh for addresses:", displayAddresses); - let refreshToast: ReturnType | undefined; let completedQueries = 0; const totalQueries = displayAddresses.length; - // Initial toast with progress - refreshToast = toast({ + // Create initial progress toast + const progressToast = toast({ description: (
    @@ -261,8 +260,8 @@ export default function Portfolio() { }) .then(() => { completedQueries++; - // Update progress toast - const progressDescription = ( + // Update progress toast with new description + const newDescription = (
    Refreshing portfolio data... @@ -277,22 +276,18 @@ export default function Portfolio() {
    ); - if (refreshToast) { - toast({ - ...refreshToast, - description: progressDescription, - duration: Infinity, - }); - } + // Update the toast with just the new description + progressToast.update({ + id: progressToast.id, + description: newDescription, + }); }) ); // Wait for all queries to complete Promise.all(promises) .then(() => { - if (refreshToast) { - refreshToast.dismiss(); - } + progressToast.dismiss(); // Show success toast toast({ description: "Portfolio data updated successfully", @@ -300,9 +295,7 @@ export default function Portfolio() { }); }) .catch((error) => { - if (refreshToast) { - refreshToast.dismiss(); - } + progressToast.dismiss(); // Show error toast toast({ description: "Failed to update some portfolio data", From 16d112bea110fffd830c33201f918fa1520bb3f3 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 10:35:16 +0200 Subject: [PATCH 026/146] bug fix --- src/app/stake/StakingPositionsList.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/stake/StakingPositionsList.tsx b/src/app/stake/StakingPositionsList.tsx index 415b7129..8e4dc1ac 100644 --- a/src/app/stake/StakingPositionsList.tsx +++ b/src/app/stake/StakingPositionsList.tsx @@ -70,10 +70,12 @@ const StakingPositionsListRow: React.FC<{ - + {position.chainLogo ? ( + + ) : null} {position.chainName} From 62d831e1ae191f7c6be0bbbc3984eab853b0dc1c Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 15:53:12 +0200 Subject: [PATCH 027/146] align toast behavior on stake --- src/app/stake/page.tsx | 116 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/src/app/stake/page.tsx b/src/app/stake/page.tsx index be94d927..616c480a 100644 --- a/src/app/stake/page.tsx +++ b/src/app/stake/page.tsx @@ -8,11 +8,13 @@ import { Button } from "~/components/ui/button"; import { Modal } from "~/components/ui/modal"; import { Tooltip } from "~/components/ui/tooltip"; import { useToast } from "~/components/ui/use-toast"; +import { Progress } from "~/components/ui/progress"; import { clearAccountStateCache, isInAccountStateBatchCache, useAccountStateBatch, } from "~/hooks/useAccountStateBatch"; +import { accountState } from "~/api/adamik/accountState"; import { useChains } from "~/hooks/useChains"; import { useMobulaBlockchains } from "~/hooks/useMobulaBlockchains"; import { useMobulaMarketMultiData } from "~/hooks/useMobulaMarketMultiData"; @@ -39,6 +41,7 @@ import { import { StakingPositionsList } from "./StakingPositionsList"; import { isStakingSupported } from "~/utils/helper"; import { WalletConnect } from "~/components"; +import { useQueryClient } from "@tanstack/react-query"; export default function Stake() { const { addresses, isShowroom, setWalletMenuOpen } = useWallet(); @@ -48,6 +51,7 @@ export default function Stake() { >(undefined); const [stepper, setStepper] = useState(0); const { toast } = useToast(); + const queryClient = useQueryClient(); const displayAddresses = isShowroom ? showroomAddresses : addresses; const addressesChainIds = displayAddresses.reduce( @@ -130,6 +134,108 @@ export default function Stake() { ] ); + const refreshPositions = () => { + console.log("🔄 Starting refresh for staking positions"); + + let completedQueries = 0; + const stakableAssets = assets.filter((asset) => asset.isStakable); + const totalQueries = stakableAssets.length; + + // Create initial progress toast + const progressToast = toast({ + description: ( +
    +
    + Refreshing staking data... + + {completedQueries}/{totalQueries} chains + +
    + +
    + ), + duration: Infinity, + }); + + // Clear cache for all stakable assets + stakableAssets.forEach(({ chainId, address }) => { + console.log(`🗑️ Clearing cache for ${chainId}:${address}`); + clearAccountStateCache({ + chainId, + address, + }); + }); + + // Force immediate refetch of all queries + console.log("♻️ Invalidating all account state queries"); + queryClient.invalidateQueries({ + queryKey: ["accountState"], + refetchType: "all", + }); + + console.log("♻️ Invalidating all validator queries"); + queryClient.invalidateQueries({ + queryKey: ["validators"], + refetchType: "all", + }); + + // Create promises for each account state query + const promises = stakableAssets.map(({ chainId, address }) => + queryClient + .fetchQuery({ + queryKey: ["accountState", chainId, address], + queryFn: () => accountState(chainId, address), + }) + .then(() => { + completedQueries++; + // Update progress toast with new description + const newDescription = ( +
    +
    + Refreshing staking data... + + {completedQueries}/{totalQueries} chains + +
    + +
    + ); + + progressToast.update({ + id: progressToast.id, + description: newDescription, + }); + }) + ); + + // Wait for all queries to complete + Promise.all(promises) + .then(() => { + progressToast.dismiss(); + // Show success toast + toast({ + description: "Staking data updated successfully", + duration: 2000, + }); + }) + .catch((error) => { + progressToast.dismiss(); + // Show error toast + toast({ + description: "Failed to update some staking data", + variant: "destructive", + duration: 3000, + }); + console.error("Error refreshing staking positions:", error); + }); + }; + return (
    {isLoading && !isInAccountStateBatchCache(displayAddresses) ? ( @@ -190,15 +296,7 @@ export default function Stake() { { - toast({ description: "Refreshing..." }); - assets.forEach((asset) => { - clearAccountStateCache({ - chainId: asset.chainId, - address: asset.address, - }); - }); - }} + refreshPositions={refreshPositions} /> {!!currentTransactionFlow && ( From fe826fc9ff5e20e0c1a8118c692fb3eaf28ca515 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 16:07:30 +0200 Subject: [PATCH 028/146] many improvements --- src/app/portfolio/page.tsx | 177 +++++++++------- src/app/stake/page.tsx | 156 ++++++++------ src/components/WalletConnect.tsx | 46 +---- src/components/wallets/MultiChainConnect.tsx | 203 +++++++++++++++++-- 4 files changed, 392 insertions(+), 190 deletions(-) diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 8d7cb2d1..ac9f42e4 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Info } from "lucide-react"; -import { useMemo, useState, useEffect } from "react"; +import { useMemo, useState, useEffect, useCallback } from "react"; import { ShowroomBanner } from "~/components/layout/ShowroomBanner"; import { TransferTransactionForm } from "~/components/transactions/TransferTransactionForm"; import { Modal } from "~/components/ui/modal"; @@ -130,12 +130,14 @@ export default function Portfolio() { loadingToast = toast({ description: (
    -
    Loading portfolio data...
    -
    - {isAddressesLoading ? "• Fetching addresses data" : ""} - {isAssetDetailsLoading ? "• Loading asset details" : ""} - {isSupportedChainsLoading ? "• Loading chain information" : ""} - {isMobulaMarketDataLoading ? "• Fetching market data" : ""} +
    Loading portfolio data...
    +
    + {isAddressesLoading &&
    • Fetching addresses data
    } + {isAssetDetailsLoading &&
    • Loading asset details
    } + {isSupportedChainsLoading && ( +
    • Loading chain information
    + )} + {isMobulaMarketDataLoading &&
    • Fetching market data
    }
    ), @@ -147,7 +149,7 @@ export default function Portfolio() { // Show completion toast toast({ - description: "Portfolio data loaded successfully", + description: "Portfolio loaded successfully", duration: 2000, }); } @@ -204,18 +206,19 @@ export default function Portfolio() { stakingBalances.stakedBalance + stakingBalances.unstakingBalance; - const refreshPositions = () => { + const refreshPositions = useCallback(async () => { console.log("🔄 Starting refresh for addresses:", displayAddresses); let completedQueries = 0; const totalQueries = displayAddresses.length; + let isCancelled = false; // Create initial progress toast const progressToast = toast({ description: (
    - Refreshing portfolio data... + Refreshing portfolio... {completedQueries}/{totalQueries} chains @@ -229,82 +232,116 @@ export default function Portfolio() { duration: Infinity, }); - // Clear cache and force immediate refetch for all account states - displayAddresses.forEach(({ chainId, address }) => { - console.log(`🗑️ Clearing cache for ${chainId}:${address}`); - clearAccountStateCache({ - chainId, - address, + try { + // First, cancel any existing queries to prevent conflicts + await queryClient.cancelQueries({ queryKey: ["accountState"] }); + await queryClient.cancelQueries({ queryKey: ["mobula"] }); + + // Clear cache for all addresses + displayAddresses.forEach(({ chainId, address }) => { + console.log(`🗑️ Clearing cache for ${chainId}:${address}`); + clearAccountStateCache({ + chainId, + address, + }); }); - }); - // Force immediate refetch of all queries - console.log("♻️ Invalidating all account state queries"); - queryClient.invalidateQueries({ - queryKey: ["accountState"], - refetchType: "all", - }); + // Invalidate queries but don't refetch yet + await queryClient.invalidateQueries({ + queryKey: ["accountState"], + refetchType: "none", + }); - console.log("♻️ Invalidating all market data queries"); - queryClient.invalidateQueries({ - queryKey: ["mobula"], - refetchType: "all", - }); + await queryClient.invalidateQueries({ + queryKey: ["mobula"], + refetchType: "none", + }); - // Create promises for each account state query - const promises = displayAddresses.map(({ chainId, address }) => - queryClient - .fetchQuery({ - queryKey: ["accountState", chainId, address], - queryFn: () => accountState(chainId, address), - }) - .then(() => { - completedQueries++; - // Update progress toast with new description - const newDescription = ( -
    -
    - Refreshing portfolio data... - - {completedQueries}/{totalQueries} chains - -
    - -
    - ); - - // Update the toast with just the new description - progressToast.update({ - id: progressToast.id, - description: newDescription, - }); - }) - ); + // Process queries in batches to avoid overwhelming the system + const batchSize = 3; // Process 3 queries at a time + const batches = []; + + for (let i = 0; i < displayAddresses.length; i += batchSize) { + const batch = displayAddresses.slice(i, i + batchSize); + batches.push(batch); + } + + for (const batch of batches) { + if (isCancelled) break; + + await Promise.all( + batch.map(async ({ chainId, address }) => { + if (isCancelled) return; + + try { + await queryClient.fetchQuery({ + queryKey: ["accountState", chainId, address], + queryFn: () => accountState(chainId, address), + staleTime: 0, + }); + + completedQueries++; + + // Update progress toast + if (!isCancelled) { + const newDescription = ( +
    +
    + Refreshing portfolio... + + {completedQueries}/{totalQueries} chains + +
    + +
    + ); + + progressToast.update({ + id: progressToast.id, + description: newDescription, + }); + } + } catch (error) { + if ( + error instanceof Error && + error.message.includes("CancelledError") + ) { + isCancelled = true; + return; + } + console.error(`Error refreshing ${chainId}:${address}:`, error); + } + }) + ); + } - // Wait for all queries to complete - Promise.all(promises) - .then(() => { + if (!isCancelled) { progressToast.dismiss(); - // Show success toast toast({ - description: "Portfolio data updated successfully", + description: "Portfolio updated successfully", duration: 2000, }); - }) - .catch((error) => { + } + } catch (error) { + if (!isCancelled) { progressToast.dismiss(); - // Show error toast toast({ description: "Failed to update some portfolio data", variant: "destructive", duration: 3000, }); console.error("Error refreshing positions:", error); - }); - }; + } + } + + return () => { + isCancelled = true; + progressToast.dismiss(); + }; + }, [displayAddresses, queryClient, toast]); return (
    diff --git a/src/app/stake/page.tsx b/src/app/stake/page.tsx index 616c480a..abeeb4a2 100644 --- a/src/app/stake/page.tsx +++ b/src/app/stake/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Info } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useState, useCallback } from "react"; import { LoadingModal } from "~/components/layout/LoadingModal"; import { ShowroomBanner } from "~/components/layout/ShowroomBanner"; import { Button } from "~/components/ui/button"; @@ -134,12 +134,13 @@ export default function Stake() { ] ); - const refreshPositions = () => { + const refreshPositions = useCallback(async () => { console.log("🔄 Starting refresh for staking positions"); let completedQueries = 0; const stakableAssets = assets.filter((asset) => asset.isStakable); const totalQueries = stakableAssets.length; + let isCancelled = false; // Create initial progress toast const progressToast = toast({ @@ -160,81 +161,116 @@ export default function Stake() { duration: Infinity, }); - // Clear cache for all stakable assets - stakableAssets.forEach(({ chainId, address }) => { - console.log(`🗑️ Clearing cache for ${chainId}:${address}`); - clearAccountStateCache({ - chainId, - address, + try { + // First, cancel any existing queries to prevent conflicts + await queryClient.cancelQueries({ queryKey: ["accountState"] }); + await queryClient.cancelQueries({ queryKey: ["validators"] }); + + // Clear cache for all stakable assets + stakableAssets.forEach(({ chainId, address }) => { + console.log(`🗑️ Clearing cache for ${chainId}:${address}`); + clearAccountStateCache({ + chainId, + address, + }); }); - }); - // Force immediate refetch of all queries - console.log("♻️ Invalidating all account state queries"); - queryClient.invalidateQueries({ - queryKey: ["accountState"], - refetchType: "all", - }); + // Invalidate queries but don't refetch yet + await queryClient.invalidateQueries({ + queryKey: ["accountState"], + refetchType: "none", + }); - console.log("♻️ Invalidating all validator queries"); - queryClient.invalidateQueries({ - queryKey: ["validators"], - refetchType: "all", - }); + await queryClient.invalidateQueries({ + queryKey: ["validators"], + refetchType: "none", + }); - // Create promises for each account state query - const promises = stakableAssets.map(({ chainId, address }) => - queryClient - .fetchQuery({ - queryKey: ["accountState", chainId, address], - queryFn: () => accountState(chainId, address), - }) - .then(() => { - completedQueries++; - // Update progress toast with new description - const newDescription = ( -
    -
    - Refreshing staking data... - - {completedQueries}/{totalQueries} chains - -
    - -
    - ); - - progressToast.update({ - id: progressToast.id, - description: newDescription, - }); - }) - ); + // Process queries in batches to avoid overwhelming the system + const batchSize = 3; // Process 3 queries at a time + const batches = []; - // Wait for all queries to complete - Promise.all(promises) - .then(() => { + for (let i = 0; i < stakableAssets.length; i += batchSize) { + const batch = stakableAssets.slice(i, i + batchSize); + batches.push(batch); + } + + for (const batch of batches) { + if (isCancelled) break; + + await Promise.all( + batch.map(async ({ chainId, address }) => { + if (isCancelled) return; + + try { + await queryClient.fetchQuery({ + queryKey: ["accountState", chainId, address], + queryFn: () => accountState(chainId, address), + staleTime: 0, + }); + + completedQueries++; + + // Update progress toast + if (!isCancelled) { + const newDescription = ( +
    +
    + Refreshing staking data... + + {completedQueries}/{totalQueries} chains + +
    + +
    + ); + + progressToast.update({ + id: progressToast.id, + description: newDescription, + }); + } + } catch (error) { + if ( + error instanceof Error && + error.message.includes("CancelledError") + ) { + isCancelled = true; + return; + } + console.error(`Error refreshing ${chainId}:${address}:`, error); + } + }) + ); + } + + if (!isCancelled) { progressToast.dismiss(); - // Show success toast toast({ description: "Staking data updated successfully", duration: 2000, }); - }) - .catch((error) => { + } + } catch (error) { + if (!isCancelled) { progressToast.dismiss(); - // Show error toast toast({ description: "Failed to update some staking data", variant: "destructive", duration: 3000, }); console.error("Error refreshing staking positions:", error); - }); - }; + } + } + + return () => { + isCancelled = true; + progressToast.dismiss(); + }; + }, [assets, queryClient, toast]); return (
    diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index 9c7cc80c..1e42cf3e 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -6,7 +6,6 @@ import { Button } from "~/components/ui/button"; import { Switch } from "~/components/ui/switch"; import { Label } from "~/components/ui/label"; import { MultiChainConnect } from "./wallets/MultiChainConnect"; -import { ChainSelector } from "./wallets/ChainSelector"; /** * WalletConnect @@ -20,47 +19,14 @@ export function WalletConnect() { const hasConnectedWallets = addresses.length > 0; - // For users with no connected wallets who are not in demo mode, - // provide direct access to MultiChainConnect - if (!hasConnectedWallets && !isShowroom) { - return ( -
    - - - {/* Demo Mode Toggle */} -
    - - - -
    -
    - ); - } - return (
    - {/* Chain Selector */} - {hasConnectedWallets ? ( - - ) : ( - - )} + {/* Chain Selection */} + {/* Demo Mode Toggle */}
    diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index 43dfdd34..81b312c4 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -8,23 +8,67 @@ import React, { import { useToast } from "~/components/ui/use-toast"; import { useWallet } from "~/hooks/useWallet"; import { Account, WalletName } from "./types"; -import { Chain } from "~/utils/types"; +import { Chain, SupportedBlockchain } from "~/utils/types"; import { Button } from "~/components/ui/button"; -import { Loader2, ChevronRight } from "lucide-react"; +import { Loader2, ChevronRight, Search, Check } from "lucide-react"; import { useChains } from "~/hooks/useChains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { getPreferredChains } from "~/config/wallet-chains"; +import { Input } from "~/components/ui/input"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +/** + * ChainItem component for rendering individual chain items + */ +const ChainItem = forwardRef< + HTMLDivElement, + { + chainId: string; + chain: SupportedBlockchain; + isSelected: boolean; + onToggle: () => void; + } +>(({ chainId, chain, isSelected, onToggle }, ref) => ( +
    +
    + {chain.logo && ( + {`${chain.name} + )} + {chain.name} +
    + {isSelected && } +
    +)); + +ChainItem.displayName = "ChainItem"; + +/** + * Separator component for visual division + */ +const Separator = ({ className = "" }: { className?: string }) => ( +
    +); /** * MultiChainConnect component * Automatically connects to all chains defined in the wallet-chains.ts config file - * Simplified to just a button for top-right placement + * Enhanced with search and better organization of chains */ export const MultiChainConnect: React.FC<{ variant?: "default" | "outline" | "secondary" | "ghost" | "link"; size?: "default" | "sm" | "lg" | "icon"; className?: string; - hideButton?: boolean; // Add hideButton prop to hide the button when needed + hideButton?: boolean; }> = ({ variant = "default", size = "default", @@ -39,6 +83,9 @@ export const MultiChainConnect: React.FC<{ const [successCount, setSuccessCount] = useState(0); const [failedChains, setFailedChains] = useState([]); const [configuredChains, setConfiguredChains] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedChains, setSelectedChains] = useState([]); + const [isSelectionOpen, setIsSelectionOpen] = useState(false); const { data: chains, isLoading: chainsLoading } = useChains(); useEffect(() => { @@ -48,6 +95,37 @@ export const MultiChainConnect: React.FC<{ } }, [chains]); + // Filter and sort chains based on search query and selection status + const { selectedChainsList, unselectedChainsList } = React.useMemo(() => { + if (!chains) return { selectedChainsList: [], unselectedChainsList: [] }; + + const allChains = Object.entries(chains) + .filter(([chainId, chain]) => { + const matchesSearch = chain.name + .toLowerCase() + .includes(searchQuery.toLowerCase()); + return matchesSearch; + }) + .sort((a, b) => a[1].name.localeCompare(b[1].name)); + + return { + selectedChainsList: allChains.filter(([chainId]) => + selectedChains.includes(chainId) + ), + unselectedChainsList: allChains.filter( + ([chainId]) => !selectedChains.includes(chainId) + ), + }; + }, [chains, searchQuery, selectedChains]); + + const toggleChain = (chainId: string) => { + setSelectedChains((prev) => + prev.includes(chainId) + ? prev.filter((id) => id !== chainId) + : [...prev, chainId] + ); + }; + const getAddressForChain = async (chainId: string) => { if (!chains || !chains[chainId]) { throw new Error(`Chain ${chainId} not supported`); @@ -221,22 +299,107 @@ export const MultiChainConnect: React.FC<{ // Button is only visible when not hidden with hideButton prop return ( - + + {isSelectionOpen && ( +
    +
    +
    +
    +

    Select Chains

    + +
    + +
    + + setSearchQuery(e.target.value)} + className="pl-9" + /> +
    + + + {selectedChainsList.length > 0 && ( +
    +

    + Selected Chains ({selectedChainsList.length}) +

    + {selectedChainsList.map(([chainId, chain]) => ( + toggleChain(chainId)} + /> + ))} + +
    + )} + +
    +

    + Available Chains ({unselectedChainsList.length}) +

    + {unselectedChainsList.map(([chainId, chain]) => ( + toggleChain(chainId)} + /> + ))} +
    +
    + +
    + + +
    +
    +
    +
    )} - +
    ); }; From 7316cf44f4e148f8d6c493897834578dcaca74b0 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 16:42:52 +0200 Subject: [PATCH 029/146] many improvements and addition of setting page --- src/api/adamik/chains.ts | 13 + src/app/portfolio/page.tsx | 4 +- src/app/settings/page.tsx | 261 +++++++++++++++++++ src/app/supported-chains/page.tsx | 30 ++- src/components/layout/Menu/Menu.tsx | 6 + src/components/layout/WelcomeModal.tsx | 65 ++--- src/components/wallets/MultiChainConnect.tsx | 136 +++++++++- src/config/wallet-chains.ts | 43 ++- src/hooks/useChains.tsx | 51 ++++ 9 files changed, 550 insertions(+), 59 deletions(-) create mode 100644 src/app/settings/page.tsx diff --git a/src/api/adamik/chains.ts b/src/api/adamik/chains.ts index 9e3f6cb4..55d5c543 100644 --- a/src/api/adamik/chains.ts +++ b/src/api/adamik/chains.ts @@ -8,6 +8,19 @@ interface ChainsResponse { chains: Record; } +// Function to get client state from cookies +const getClientState = async (): Promise<{ showTestnets?: boolean }> => { + try { + // This is a server component, so we need to use cookies instead of localStorage + // For simplicity, we'll just return default values for now + // In a real implementation, you would extract this from cookies or headers + return { showTestnets: true }; + } catch (error) { + console.error("Error getting client state:", error); + return { showTestnets: true }; + } +}; + // TODO Better API error management, consistent for all endpoints export const getChains = async (): Promise | null> => { try { diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index ac9f42e4..2588f3c4 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -220,7 +220,7 @@ export default function Portfolio() {
    Refreshing portfolio... - {completedQueries}/{totalQueries} chains + {completedQueries}/{totalQueries} addresses
    Refreshing portfolio... - {completedQueries}/{totalQueries} chains + {completedQueries}/{totalQueries} addresses
    ([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isModified, setIsModified] = useState(false); + + useEffect(() => { + // Load initial settings + if (chains) { + // Get defaultChains from localStorage if available + const clientState = localStorage.getItem("AdamikClientState"); + let defaultChains = [...walletChains]; + + if (clientState) { + try { + const parsedState = JSON.parse(clientState); + if ( + parsedState.defaultChains && + Array.isArray(parsedState.defaultChains) + ) { + defaultChains = parsedState.defaultChains; + } + } catch (error) { + console.error("Error parsing client state:", error); + } + } + + // Filter to only include chains that exist in the current chains data + setSelectedChains(defaultChains.filter((chain) => chains[chain])); + } + + // Initialize showTestnets from the hook + if (typeof initialShowTestnets === "boolean") { + setShowTestnets(initialShowTestnets); + } + }, [chains, initialShowTestnets]); + + // Filter chains based on search query + const filteredChains = React.useMemo(() => { + if (!chains) return []; + + return Object.entries(chains) + .filter(([chainId, chain]) => { + // Filter by search query + const matchesSearch = chain.name + .toLowerCase() + .includes(searchQuery.toLowerCase()); + + return matchesSearch; + }) + .sort((a, b) => { + // Sort by selection status first + const aSelected = selectedChains.includes(a[0]); + const bSelected = selectedChains.includes(b[0]); + + if (aSelected && !bSelected) return -1; + if (!aSelected && bSelected) return 1; + + // Then sort by mainnet (non-testnets first) + const aIsTestnet = !!a[1].isTestnetFor; + const bIsTestnet = !!b[1].isTestnetFor; + + if (!aIsTestnet && bIsTestnet) return -1; + if (aIsTestnet && !bIsTestnet) return 1; + + // Then alphabetically + return a[1].name.localeCompare(b[1].name); + }); + }, [chains, searchQuery, selectedChains]); + + const toggleChain = (chainId: string) => { + setSelectedChains((prev) => + prev.includes(chainId) + ? prev.filter((id) => id !== chainId) + : [...prev, chainId] + ); + setIsModified(true); + }; + + const handleShowTestnetsToggle = (checked: boolean) => { + setShowTestnets(checked); + setIsModified(true); + }; + + const saveSettings = () => { + try { + // Update settings in localStorage + const clientState = localStorage.getItem("AdamikClientState") || "{}"; + const parsedState = JSON.parse(clientState); + + // Save the selected chains and showTestnets setting + localStorage.setItem( + "AdamikClientState", + JSON.stringify({ + ...parsedState, + defaultChains: selectedChains, + showTestnets: showTestnets, + }) + ); + + toast({ + description: + "Settings saved successfully. Changes will take effect after page refresh.", + duration: 3000, + }); + + setIsModified(false); + } catch (error) { + console.error("Error saving settings:", error); + toast({ + description: "Failed to save settings", + variant: "destructive", + duration: 3000, + }); + } + }; + + const ChainItem = ({ + chainId, + chain, + isSelected, + }: { + chainId: string; + chain: SupportedBlockchain; + isSelected: boolean; + }) => ( +
    toggleChain(chainId)} + > +
    + {chain.logo && ( + {`${chain.name} + )} +
    + {chain.name} + {chain.isTestnetFor && ( + Testnet + )} +
    +
    + {isSelected && } +
    + ); + + return ( +
    +
    +

    Settings

    +
    + +
    +
    +

    Chain Settings

    + +
    +
    + + +
    + +

    + This setting controls whether testnet chains are shown throughout + the application. + + Note: Changes to this setting require a page refresh to fully + apply. + +

    + +

    + Select the default chains that will be automatically connected + when a user connects their wallet for the first time: +

    + +
    +
    + + setSearchQuery(e.target.value)} + className="pl-9" + /> +
    + +
    + {selectedChains.length} chains selected +
    + + + {filteredChains.map(([chainId, chain]) => ( + + ))} + + {filteredChains.length === 0 && ( +
    + {chainsLoading + ? "Loading chains..." + : "No chains found matching your criteria"} +
    + )} +
    +
    +
    + +
    + + +
    +
    +
    +
    + ); +} diff --git a/src/app/supported-chains/page.tsx b/src/app/supported-chains/page.tsx index 561891e2..55655159 100644 --- a/src/app/supported-chains/page.tsx +++ b/src/app/supported-chains/page.tsx @@ -2,14 +2,14 @@ import { Info, Loader2 } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { MobulaMarketMultiDataResponse } from "~/api/mobula/marketMultiData"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Checkbox } from "~/components/ui/checkbox"; import { Tooltip } from "~/components/ui/tooltip"; // Import TooltipProvider -import { useChains } from "~/hooks/useChains"; +import { useFilteredChains } from "~/hooks/useChains"; import { useMobulaBlockchains } from "~/hooks/useMobulaBlockchains"; import { useMobulaMarketMultiData } from "~/hooks/useMobulaMarketMultiData"; import { resolveLogo, isStakingSupported } from "~/utils/helper"; @@ -18,12 +18,23 @@ import { SupportedBlockchain } from "~/utils/types"; const comingSoonIds = ["tron", "the-open-network", "solana"]; export default function SupportedChains() { - const { isLoading: supportedChainsLoading, data: supportedChains } = - useChains(); - const [showTestnets, setShowTestnets] = useState(false); + const { + isLoading: supportedChainsLoading, + data: supportedChains, + showTestnets, + } = useFilteredChains(); + const [localShowTestnets, setLocalShowTestnets] = useState(false); const [selectedFeatures, setSelectedFeatures] = useState([]); // Add state for selected features const { isLoading: mobulaBlockchainLoading, data: mobulaBlockchains } = useMobulaBlockchains(); + + // Sync the local testnet setting with the global one + useEffect(() => { + if (typeof showTestnets === "boolean") { + setLocalShowTestnets(showTestnets); + } + }, [showTestnets]); + const tickers = Object.values(supportedChains || {}).reduce( (acc, chain) => [...acc, chain.ticker], [] @@ -92,7 +103,7 @@ export default function SupportedChains() { ); const handleCheckboxChange = () => { - setShowTestnets(!showTestnets); + setLocalShowTestnets(!localShowTestnets); }; const handleFeatureSelect = (feature: string) => { @@ -237,7 +248,7 @@ export default function SupportedChains() {
    @@ -247,8 +258,11 @@ export default function SupportedChains() { > Show testnets + + (This change is temporary. Go to Settings to make it permanent.) +
    - {showTestnets && ( + {localShowTestnets && (

    Additional Chains (Testnets) diff --git a/src/components/layout/Menu/Menu.tsx b/src/components/layout/Menu/Menu.tsx index 751f3357..93e0da47 100644 --- a/src/components/layout/Menu/Menu.tsx +++ b/src/components/layout/Menu/Menu.tsx @@ -7,6 +7,7 @@ import { Search, History, KeyRound, + Settings, } from "lucide-react"; import { useTheme } from "next-themes"; import { MobileMenu } from "./MobileMenu"; @@ -45,6 +46,11 @@ const menu = [ icon: KeyRound, href: "/sodot-test", }, + { + title: "Settings", + icon: Settings, + href: "/settings", + }, ]; export type MenuItem = (typeof menu)[0]; diff --git a/src/components/layout/WelcomeModal.tsx b/src/components/layout/WelcomeModal.tsx index 7decc18d..1f98b0b3 100644 --- a/src/components/layout/WelcomeModal.tsx +++ b/src/components/layout/WelcomeModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Modal } from "../ui/modal"; import { useWallet } from "~/hooks/useWallet"; import { Button } from "../ui/button"; -import { useChains } from "~/hooks/useChains"; +import { useFilteredChains } from "~/hooks/useChains"; import { getPreferredChains } from "~/config/wallet-chains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { Account, WalletName } from "../wallets/types"; @@ -15,7 +15,7 @@ export const WelcomeModal = () => { const { setShowroom, addAddresses } = useWallet(); const { toast } = useToast(); const [isConnecting, setIsConnecting] = useState(false); - const { data: chains } = useChains(); + const { data: chains } = useFilteredChains(); useEffect(() => { setIsModalOpen(true); @@ -132,19 +132,6 @@ export const WelcomeModal = () => { } }; - const handleShowroomMode = (isShowroom: boolean) => { - // Set showroom mode based on user selection - setShowroom(isShowroom); - - // If using showroom mode, close modal immediately - if (isShowroom) { - setIsModalOpen(false); - } else { - // If not using showroom mode, directly connect wallet - connectWalletDirectly(); - } - }; - const handleNextStep = () => { setCurrentStep((prevStep) => prevStep + 1); }; @@ -153,6 +140,14 @@ export const WelcomeModal = () => { setCurrentStep((prevStep) => prevStep - 1); }; + const handleContinue = () => { + // Set to wallet mode (not showroom) + setShowroom(false); + + // Connect wallet directly + connectWalletDirectly(); + }; + return ( {

    - Use in Demo mode or Add your Wallet + Connecting Your Wallet

    -

    Easily switch between modes using the toggle

    -
    -
    - -
    diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index 81b312c4..65df59dd 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -4,6 +4,7 @@ import React, { useEffect, RefObject, forwardRef, + useMemo, } from "react"; import { useToast } from "~/components/ui/use-toast"; import { useWallet } from "~/hooks/useWallet"; @@ -11,7 +12,7 @@ import { Account, WalletName } from "./types"; import { Chain, SupportedBlockchain } from "~/utils/types"; import { Button } from "~/components/ui/button"; import { Loader2, ChevronRight, Search, Check } from "lucide-react"; -import { useChains } from "~/hooks/useChains"; +import { useFilteredChains } from "~/hooks/useChains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { getPreferredChains } from "~/config/wallet-chains"; import { Input } from "~/components/ui/input"; @@ -26,9 +27,10 @@ const ChainItem = forwardRef< chainId: string; chain: SupportedBlockchain; isSelected: boolean; + isConnected?: boolean; onToggle: () => void; } ->(({ chainId, chain, isSelected, onToggle }, ref) => ( +>(({ chainId, chain, isSelected, isConnected = false, onToggle }, ref) => (
    { const { toast } = useToast(); - const { addAddresses, isShowroom } = useWallet(); + const { addAddresses, removeAddresses, isShowroom, addresses } = useWallet(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [connectedCount, setConnectedCount] = useState(0); @@ -86,7 +88,13 @@ export const MultiChainConnect: React.FC<{ const [searchQuery, setSearchQuery] = useState(""); const [selectedChains, setSelectedChains] = useState([]); const [isSelectionOpen, setIsSelectionOpen] = useState(false); - const { data: chains, isLoading: chainsLoading } = useChains(); + const { data: chains, isLoading: chainsLoading } = useFilteredChains(); + + // Get unique chain IDs from connected addresses + const uniqueConnectedChainIds = useMemo( + () => [...new Set(addresses.map((addr) => addr.chainId))], + [addresses] + ); useEffect(() => { if (chains) { @@ -95,6 +103,22 @@ export const MultiChainConnect: React.FC<{ } }, [chains]); + // When opening the selection modal, pre-select already connected chains + useEffect(() => { + if (isSelectionOpen && !isShowroom && uniqueConnectedChainIds.length > 0) { + setSelectedChains((prev) => { + // Add connected chains to selections if not already selected + const newSelections = [...prev]; + uniqueConnectedChainIds.forEach((chainId) => { + if (!newSelections.includes(chainId)) { + newSelections.push(chainId); + } + }); + return newSelections; + }); + } + }, [isSelectionOpen, isShowroom, uniqueConnectedChainIds]); + // Filter and sort chains based on search query and selection status const { selectedChainsList, unselectedChainsList } = React.useMemo(() => { if (!chains) return { selectedChainsList: [], unselectedChainsList: [] }; @@ -119,6 +143,7 @@ export const MultiChainConnect: React.FC<{ }, [chains, searchQuery, selectedChains]); const toggleChain = (chainId: string) => { + // Toggle the chain in the selected list, regardless of connection status setSelectedChains((prev) => prev.includes(chainId) ? prev.filter((id) => id !== chainId) @@ -297,6 +322,26 @@ export const MultiChainConnect: React.FC<{ ); } + // Calculate the count for display based on mode + let chainCount = 0; + let buttonText = "Select Chains"; + + if (isShowroom) { + // In showroom mode, just count the unique chains from addresses + chainCount = uniqueConnectedChainIds.length; + } else if (uniqueConnectedChainIds.length > 0) { + // In regular mode with connected wallets, show actual connected chains + chainCount = uniqueConnectedChainIds.length; + } else if (selectedChains.length > 0) { + // In regular mode with no connections but selections made + chainCount = selectedChains.length; + } + + // Set button text based on count + if (chainCount > 0) { + buttonText = `${chainCount} Chains Selected`; + } + // Button is only visible when not hidden with hideButton prop return (
    @@ -312,9 +357,7 @@ export const MultiChainConnect: React.FC<{ ) : ( )} - {selectedChains.length > 0 - ? `${selectedChains.length} Chains Selected` - : "Select Chains"} + {buttonText} {isSelectionOpen && ( @@ -354,6 +397,7 @@ export const MultiChainConnect: React.FC<{ chainId={chainId} chain={chain} isSelected={true} + isConnected={uniqueConnectedChainIds.includes(chainId)} onToggle={() => toggleChain(chainId)} /> ))} @@ -371,6 +415,7 @@ export const MultiChainConnect: React.FC<{ chainId={chainId} chain={chain} isSelected={false} + isConnected={uniqueConnectedChainIds.includes(chainId)} onToggle={() => toggleChain(chainId)} /> ))} @@ -387,13 +432,82 @@ export const MultiChainConnect: React.FC<{
    diff --git a/src/config/wallet-chains.ts b/src/config/wallet-chains.ts index 602e84b1..9e34e638 100644 --- a/src/config/wallet-chains.ts +++ b/src/config/wallet-chains.ts @@ -34,6 +34,47 @@ export const getPreferredChains = ( ): string[] => { if (!availableChains) return []; - // Filter configured chains that are actually available in the API + // Check if we're in a browser environment + if (typeof window !== "undefined" && window.localStorage) { + try { + // Try to get user-defined chains from localStorage + const clientState = localStorage.getItem("AdamikClientState"); + if (clientState) { + const parsedState = JSON.parse(clientState); + + // If user has saved custom default chains, use those + if ( + parsedState.defaultChains && + Array.isArray(parsedState.defaultChains) + ) { + // Filter to ensure all chains exist in availableChains + return parsedState.defaultChains.filter( + (chainId: string) => availableChains[chainId] + ); + } + + // Filter chains based on testnet setting if it exists + if ( + typeof parsedState.showTestnets === "boolean" && + !parsedState.showTestnets + ) { + // Filter out testnet chains + const filteredChains = Object.values(availableChains) + .filter((chain) => !chain.isTestnetFor) + .map((chain) => chain.id); + + // If no user preferences, filter default chains + return walletChains.filter( + (chainId) => + availableChains[chainId] && filteredChains.includes(chainId) + ); + } + } + } catch (error) { + console.error("Error parsing client state:", error); + } + } + + // Fall back to default behavior return walletChains.filter((chainId) => availableChains[chainId]); }; diff --git a/src/hooks/useChains.tsx b/src/hooks/useChains.tsx index 9b569150..13f9ea25 100644 --- a/src/hooks/useChains.tsx +++ b/src/hooks/useChains.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { getChains } from "~/api/adamik/chains"; import { Chain } from "~/utils/types"; +import { useMemo, useState, useEffect } from "react"; export const CHAINS_QUERY_KEY = ["chains"] as const; @@ -13,3 +14,53 @@ export const useChains = () => { retry: 3, }); }; + +// A hook that filters chains based on the showTestnets setting +export const useFilteredChains = () => { + const { data: allChains, ...rest } = useChains(); + const [showTestnets, setShowTestnets] = useState(true); + + useEffect(() => { + // Get the showTestnets setting from localStorage + if (typeof window !== "undefined") { + try { + const clientState = localStorage.getItem("AdamikClientState"); + if (clientState) { + const parsedState = JSON.parse(clientState); + if (typeof parsedState.showTestnets === "boolean") { + setShowTestnets(parsedState.showTestnets); + } + } + } catch (error) { + console.error("Error reading showTestnets setting:", error); + } + } + }, []); + + // Filter chains based on the showTestnets setting + const filteredData = useMemo(() => { + if (!allChains) return null; + + if (showTestnets) { + return allChains; // Return all chains + } else { + // Filter out testnets + const filtered: Record = {}; + + Object.entries(allChains).forEach(([chainId, chain]) => { + if (!chain.isTestnetFor) { + filtered[chainId] = chain; + } + }); + + return filtered; + } + }, [allChains, showTestnets]); + + return { + data: filteredData, + ...rest, + // Also expose the setting for components that need it + showTestnets, + }; +}; From e6d2513a5246ad96d164b7f68b3be5cae1e1dfc5 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 16:47:10 +0200 Subject: [PATCH 030/146] Update useChains.tsx --- src/hooks/useChains.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useChains.tsx b/src/hooks/useChains.tsx index 13f9ea25..e31d2a0f 100644 --- a/src/hooks/useChains.tsx +++ b/src/hooks/useChains.tsx @@ -18,7 +18,7 @@ export const useChains = () => { // A hook that filters chains based on the showTestnets setting export const useFilteredChains = () => { const { data: allChains, ...rest } = useChains(); - const [showTestnets, setShowTestnets] = useState(true); + const [showTestnets, setShowTestnets] = useState(false); // Default to false - hide testnets useEffect(() => { // Get the showTestnets setting from localStorage From 47a5d19e5e78fedc9f2d51b779cbbe41656801fc Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 16:51:02 +0200 Subject: [PATCH 031/146] Implement user-friendly chain management and testnet filtering: --- src/components/wallets/MultiChainConnect.tsx | 54 ++++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index 65df59dd..831f40cd 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -105,19 +105,35 @@ export const MultiChainConnect: React.FC<{ // When opening the selection modal, pre-select already connected chains useEffect(() => { - if (isSelectionOpen && !isShowroom && uniqueConnectedChainIds.length > 0) { - setSelectedChains((prev) => { - // Add connected chains to selections if not already selected - const newSelections = [...prev]; - uniqueConnectedChainIds.forEach((chainId) => { - if (!newSelections.includes(chainId)) { - newSelections.push(chainId); + if (isSelectionOpen && !isShowroom) { + // First try to load any previously selected chains from localStorage + try { + const clientState = localStorage.getItem("AdamikClientState"); + if (clientState) { + const parsedState = JSON.parse(clientState); + if ( + parsedState.defaultChains && + Array.isArray(parsedState.defaultChains) + ) { + // Load user's custom selection + setSelectedChains(parsedState.defaultChains); + return; } - }); - return newSelections; - }); + } + } catch (error) { + console.error("Error loading previously selected chains:", error); + } + + // If no custom selection exists, use connected chains + if (uniqueConnectedChainIds.length > 0) { + setSelectedChains(uniqueConnectedChainIds); + } else if (chains) { + // If nothing else, fall back to default chains from config + const preferredChains = getPreferredChains(chains); + setSelectedChains(preferredChains); + } } - }, [isSelectionOpen, isShowroom, uniqueConnectedChainIds]); + }, [isSelectionOpen, isShowroom, uniqueConnectedChainIds, chains]); // Filter and sort chains based on search query and selection status const { selectedChainsList, unselectedChainsList } = React.useMemo(() => { @@ -432,6 +448,22 @@ export const MultiChainConnect: React.FC<{
    ); + // Supported Chains content + const SupportedChainsContent = () => { + const [localShowTestnets, setLocalShowTestnets] = useState(false); + const [selectedFeatures, setSelectedFeatures] = useState([]); + const { isLoading: mobulaBlockchainLoading, data: mobulaBlockchains } = + useMobulaBlockchains(); + + const tickers = Object.values(chains || {}).reduce( + (acc, chain) => [...acc, chain.ticker], + [] + ); + + const { data: mobulaMarketData, isLoading: isAssetDetailsLoading } = + useMobulaMarketMultiData( + tickers, + !mobulaBlockchainLoading && !chainsLoading, + "symbols" + ); + + const chainsWithInfo = React.useMemo(() => { + if (!chains) return []; + + return Object.values(chains) + .reduce<(SupportedBlockchain & { labels?: string[] })[]>( + (acc, chain) => { + if (!!chain.isTestnetFor) { + return acc; + } + + // Determine labels based on chain features + const labels: string[] = []; + if ( + chain.supportedFeatures?.read?.account?.balances?.tokens && + chain.supportedFeatures?.read?.transaction?.tokens + ) { + labels.push("token"); + } + if (isStakingSupported(chain)) { + labels.push("staking"); + } + if (chain.supportedFeatures?.read?.account?.transactions?.native) { + labels.push("history"); + } + + const supportedChain = { + ...chain, + labels, + logo: resolveLogo({ + asset: { name: chain.name, ticker: chain.ticker }, + mobulaMarketData, + mobulaBlockChainData: mobulaBlockchains, + }), + }; + return [...acc, supportedChain]; + }, + [] + ) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [chains, mobulaMarketData, mobulaBlockchains]); + + const filteredSupportedChains = selectedFeatures.length + ? chainsWithInfo.filter((chain) => + selectedFeatures.every((feature) => chain.labels?.includes(feature)) + ) + : chainsWithInfo; + + const additionalChains = Object.values(chains || {}).reduce( + (acc, chain) => { + return !!chain.isTestnetFor && !acc.includes(chain.name) + ? [...acc, chain.id] + : acc; + }, + [] + ); + + const handleCheckboxChange = () => { + setLocalShowTestnets(!localShowTestnets); + }; + + const handleFeatureSelect = (feature: string) => { + setSelectedFeatures((prevSelected) => + prevSelected.includes(feature) + ? prevSelected.filter((f) => f !== feature) + : [...prevSelected, feature] + ); + }; + + const isLoading = + chainsLoading || isAssetDetailsLoading || mobulaBlockchainLoading; + + const getLabelClass = (label: string) => { + switch (label) { + case "token": + return "tooltip-token"; + case "staking": + return "tooltip-staking"; + case "history": + return "tooltip-history"; + default: + return ""; + } + }; + + const comingSoonIds = ["tron", "the-open-network", "solana"]; + + return ( +
    +
    +
    +

    Supported Chains

    + + + + + +
    +
    +
    + +
    + handleFeatureSelect("token")} + /> + + handleFeatureSelect("staking")} + /> + + handleFeatureSelect("history")} + /> + +
    +
    +
    +
    +
    + {isLoading ? ( +
    +
    +
    + ) : ( +
    + {filteredSupportedChains?.map((chain) => { + const isComingSoon = comingSoonIds.includes(chain.id); + + return ( +
    +
    + {chain.labels?.map((label: string) => ( + + +   + + + ))} +
    + + + {chain.ticker} + +
    +

    + {chain.name} +

    +
    +

    + {chain.ticker} +

    + {isComingSoon && ( + + Coming Soon + + )} +
    +
    +
    + ); + })} +
    + )} +
    + +
    +
    + + +
    + {localShowTestnets && ( +
    +

    + Additional Chains (Testnets) +

    +
    + {additionalChains.map((chain) => ( +
    +
    +

    {chain}

    +
    +
    + ))} +
    +
    + )} +
    +
    + ); + }; + return (

    Settings

    -
    -
    -

    Chain Settings

    - -
    -
    - - -
    + + + General + Supported Chains + Sodot Test + -

    - This setting controls whether testnet chains are shown throughout - the application. - - Note: Changes to this setting require a page refresh to fully - apply. - -

    + +
    +
    +

    Chain Settings

    -

    - Select the default chains that will be automatically connected - when a user connects their wallet for the first time: -

    +
    +
    + + +
    -
    -
    - - setSearchQuery(e.target.value)} - className="pl-9" - /> -
    +

    + This setting controls whether testnet chains are shown + throughout the application. + + Note: Changes to this setting require a page refresh to + fully apply. + +

    -
    - {selectedChains.length} chains selected -
    +

    + Select the default chains that will be automatically connected + when a user connects their wallet for the first time: +

    - - {filteredChains.map(([chainId, chain]) => ( - - ))} +
    +
    + + setSearchQuery(e.target.value)} + className="pl-9" + /> +
    - {filteredChains.length === 0 && ( -
    - {chainsLoading - ? "Loading chains..." - : "No chains found matching your criteria"} +
    + {selectedChains.length} chains selected
    - )} - + + + {filteredChains.map(([chainId, chain]) => ( + + ))} + + {filteredChains.length === 0 && ( +
    + {chainsLoading + ? "Loading chains..." + : "No chains found matching your criteria"} +
    + )} +
    +
    +
    + +
    + + +
    + -
    - - -
    -
    -
    + + + + + + + + + + + + + + + +
    ); } diff --git a/src/app/settings/tabs/SodotTest.tsx b/src/app/settings/tabs/SodotTest.tsx new file mode 100644 index 00000000..bfd4353d --- /dev/null +++ b/src/app/settings/tabs/SodotTest.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { encodePubKeyToAddress } from "~/api/adamik/encode"; +import { AlertCircle, CheckCircle2, Loader2, Lock, Server } from "lucide-react"; +import { useChains } from "~/hooks/useChains"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; + +type VertexKeysResult = { + status?: number; + data?: { + vertices: Array<{ + id: number; + status: number; + compressed?: string; + uncompressed?: string; + error?: string; + }>; + }; + error?: string; + message?: string; +}; + +type ChainPubkeyResult = { + pubkey: string; + address: string; + chainId: string; + curve: string; +}; + +type Results = { + vertexKeys?: VertexKeysResult; + chainPubkey?: ChainPubkeyResult; +}; + +export function SodotTestContent() { + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [selectedChain, setSelectedChain] = useState(""); + const { data: chains, isLoading: chainsLoading } = useChains(); + + const testChainPubkey = async () => { + if (!chains || !selectedChain) { + setError("Please select a chain first"); + return; + } + + if (!chains[selectedChain]) { + setError(`Chain ${selectedChain} not found in available chains`); + return; + } + + setLoading(true); + setError(null); + setSuccess(null); + + try { + // Call our backend endpoint for the chain pubkey + const response = await fetch( + `/api/sodot-proxy/derive-chain-pubkey?chain=${selectedChain}`, + { + method: "GET", + cache: "no-store", + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const data = await response.json(); + + // Get the pubkey from the response + const pubkey = data.data.pubkey; + + // Use the Adamik API to get the address from the pubkey + const addressResult = await encodePubKeyToAddress(pubkey, selectedChain); + const address = addressResult.address; + + // Store the results + const chainInfo = chains[selectedChain]; + const curveType = + chainInfo.signerSpec.curve === "secp256k1" ? "SECP256K1" : "ED25519"; + + setResults( + (prev: Results | null): Results => ({ + ...prev, + chainPubkey: { + pubkey, + address, + chainId: selectedChain, + curve: curveType, + }, + }) + ); + setSuccess(`Successfully retrieved ${selectedChain} pubkey and address`); + } catch (e: any) { + setError(e.message || "Unknown error occurred"); + } finally { + setLoading(false); + } + }; + + const getVertexKeys = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + const response = await fetch("/api/sodot-proxy/get-keys", { + method: "GET", + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Failed to get keys: ${response.status}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(data.message || data.error); + } + + setResults( + (prev: Results | null): Results => ({ ...prev, vertexKeys: data }) + ); + setSuccess("Successfully retrieved vertex keys"); + } catch (e: any) { + setError(e.message || "Failed to get keys"); + console.error("Error getting vertex keys:", e); + } finally { + setLoading(false); + } + }; + + return ( +
    +
    +

    Sodot MPC Signing Demo

    +

    + Test the Sodot secure signing integration with various blockchains +

    +
    + + + + Connection Test + + +
    + + +
    + {chains ? ( + <> +
    + +
    + + + + ) : ( +
    + + Loading chains... +
    + )} +
    +
    + + {error && ( +
    + +

    {error}

    +
    + )} + + {success && ( +
    + +

    {success}

    +
    + )} + + {results && ( +
    + {results.chainPubkey && ( +
    +

    + Pubkey Results: +

    +
    + Chain: {results.chainPubkey.chainId} +
    +
    + Curve: {results.chainPubkey.curve} +
    +
    + Pubkey: {results.chainPubkey.pubkey} +
    +
    + Address: {results.chainPubkey.address} +
    +
    + )} + + {results.vertexKeys && + results.vertexKeys.data && + results.vertexKeys.data.vertices && + results.vertexKeys.data.vertices.length > 0 && ( +
    +

    + Vertex Keys: +

    +
    + {results.vertexKeys.data.vertices.map((vertex) => ( +
    +

    + Vertex {vertex.id} +

    +
    + Status: {vertex.status} +
    + {vertex.error && ( +
    + Error: {vertex.error} +
    + )} + {vertex.compressed && ( +
    + Compressed:{" "} + {vertex.compressed} +
    + )} + {vertex.uncompressed && ( +
    + Uncompressed:{" "} + {vertex.uncompressed} +
    + )} +
    + ))} +
    +
    + )} +
    + )} +
    +
    + + + + How It Works + + +

    + This demo showcases Threshold Signature Scheme (TSS) integration + using Sodot's secure MPC protocol. The implementation features: +

    +
      +
    • +
      + + Server-side proxy to securely handle API keys and key IDs +
      +
    • +
    • +
      + + No sensitive environment variables on the client +
      +
    • +
    • Multi-party computation for key generation and signing
    • +
    • + Threshold security (t-of-n) where at least 2 of 3 parties must + participate +
    • +
    • + Support for multiple blockchain cryptography (ECDSA for + Bitcoin/Ethereum/TON/Tron, ED25519 for Algorand) +
    • +
    • Integration with Adamik API for address derivation
    • +
    +
    +
    +
    + ); +} diff --git a/src/components/layout/Menu/Menu.tsx b/src/components/layout/Menu/Menu.tsx index 93e0da47..2dc36fbf 100644 --- a/src/components/layout/Menu/Menu.tsx +++ b/src/components/layout/Menu/Menu.tsx @@ -36,16 +36,6 @@ const menu = [ icon: History, href: "/history", }, - { - title: "Supported chains", - icon: SquareStack, - href: "/supported-chains", - }, - { - title: "Sodot Test", - icon: KeyRound, - href: "/sodot-test", - }, { title: "Settings", icon: Settings, diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 00000000..a2ec5437 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "~/utils/helper"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; From d0a0a5955fe3165dd1f140769e6b4fcd383b6f5b Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 17:06:07 +0200 Subject: [PATCH 033/146] Improve layout for consistency --- src/app/data/page.tsx | 185 ++++++++++++++++++++++++------------------ 1 file changed, 107 insertions(+), 78 deletions(-) diff --git a/src/app/data/page.tsx b/src/app/data/page.tsx index 90e7e423..104ae653 100644 --- a/src/app/data/page.tsx +++ b/src/app/data/page.tsx @@ -45,6 +45,10 @@ import { } from "~/utils/types"; import { useToast } from "~/components/ui/use-toast"; import { getTokenInfo } from "~/api/adamik/token"; +import { WalletConnect } from "~/components"; +import { useWallet } from "~/hooks/useWallet"; +import { Modal } from "~/components/ui/modal"; +import { WalletSelection } from "~/components/wallets/WalletSelection"; hljs.registerLanguage("json", json); @@ -57,6 +61,8 @@ function DataContent() { const [hasSubmitted, setHasSubmitted] = useState(false); const [formattedAmount, setFormattedAmount] = useState("N/A"); const [formattedFees, setFormattedFees] = useState("N/A"); + const [showChainsModal, setShowChainsModal] = useState(false); + const { isShowroom } = useWallet(); const rawSearchParams = useSearchParams(); // Create a safe version of searchParams with default values @@ -381,85 +387,109 @@ function DataContent() { return (
    -
    -

    - Transaction Details -

    - - - - - +
    +
    +

    + Transaction Details +

    + + + + + +
    +
    -
    - - ( - - Chain - - - - )} - /> - - ( - - Transaction ID - - - - - )} - /> - {!!error && ( -
    {error.message}
    - )} - - - + } + />
    - + + + Search Transaction + + +
    + +
    +
    + ( + + Chain + + + + + + )} + /> +
    + +
    + ( + + Transaction ID +
    + + + + +
    + +
    + )} + /> +
    +
    +
    + +
    +
    + +
    Parsed @@ -468,8 +498,7 @@ function DataContent() {
    - {/* Remove max-height constraints and keep overflow-y-auto just in case */} - +
    {renderParsedData(transaction)}
    @@ -488,7 +517,7 @@ function DataContent() { )} {isRawExpanded && ( - +
    Raw From 6e366ef7615565e39d75df752854a31494568a9c Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Sun, 13 Apr 2025 17:23:01 +0200 Subject: [PATCH 034/146] improve chain selection logic --- src/app/settings/page.tsx | 192 ++++--------------- src/components/wallets/MultiChainConnect.tsx | 103 ++++++++-- src/config/wallet-chains.ts | 51 +++-- 3 files changed, 146 insertions(+), 200 deletions(-) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 8a64cc8c..42b82f70 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -11,7 +11,9 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { Input } from "~/components/ui/input"; import { Search, Check, Info } from "lucide-react"; import { walletChains } from "~/config/wallet-chains"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +// Try to import the tabs components +// Commented out due to TS error, using inline destructuring with require instead +// import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Tooltip } from "~/components/ui/tooltip"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; @@ -21,8 +23,33 @@ import { useMobulaBlockchains } from "~/hooks/useMobulaBlockchains"; import { useMobulaMarketMultiData } from "~/hooks/useMobulaMarketMultiData"; import { resolveLogo, isStakingSupported } from "~/utils/helper"; -// Import Sodot components and types -import { SodotTestContent } from "./tabs/SodotTest"; +// Try importing using require +const { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} = require("~/components/ui/tabs"); + +// Define a fallback for Sodot test content +const SodotTestContent = () => ( +
    +

    Sodot Test

    +

    + This is a placeholder for the Sodot test content. +

    +
    +); + +// Try importing the real component if it exists +try { + const SodotModule = require("./tabs/SodotTest"); + if (SodotModule && SodotModule.SodotTestContent) { + Object.assign(SodotTestContent, SodotModule.SodotTestContent); + } +} catch (error) { + console.warn("Failed to load SodotTest component", error); +} export default function SettingsPage() { const { toast } = useToast(); @@ -31,84 +58,16 @@ export default function SettingsPage() { isLoading: chainsLoading, showTestnets: initialShowTestnets, } = useFilteredChains(); - const [showTestnets, setShowTestnets] = useState(true); - const [selectedChains, setSelectedChains] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); + const [showTestnets, setShowTestnets] = useState(false); const [isModified, setIsModified] = useState(false); const [activeTab, setActiveTab] = useState("general"); useEffect(() => { - // Load initial settings - if (chains) { - // Get defaultChains from localStorage if available - const clientState = localStorage.getItem("AdamikClientState"); - let defaultChains = [...walletChains]; - - if (clientState) { - try { - const parsedState = JSON.parse(clientState); - if ( - parsedState.defaultChains && - Array.isArray(parsedState.defaultChains) - ) { - defaultChains = parsedState.defaultChains; - } - } catch (error) { - console.error("Error parsing client state:", error); - } - } - - // Filter to only include chains that exist in the current chains data - setSelectedChains(defaultChains.filter((chain) => chains[chain])); - } - // Initialize showTestnets from the hook if (typeof initialShowTestnets === "boolean") { setShowTestnets(initialShowTestnets); } - }, [chains, initialShowTestnets]); - - // Filter chains based on search query - const filteredChains = React.useMemo(() => { - if (!chains) return []; - - return Object.entries(chains) - .filter(([chainId, chain]) => { - // Filter by search query - const matchesSearch = chain.name - .toLowerCase() - .includes(searchQuery.toLowerCase()); - - return matchesSearch; - }) - .sort((a, b) => { - // Sort by selection status first - const aSelected = selectedChains.includes(a[0]); - const bSelected = selectedChains.includes(b[0]); - - if (aSelected && !bSelected) return -1; - if (!aSelected && bSelected) return 1; - - // Then sort by mainnet (non-testnets first) - const aIsTestnet = !!a[1].isTestnetFor; - const bIsTestnet = !!b[1].isTestnetFor; - - if (!aIsTestnet && bIsTestnet) return -1; - if (aIsTestnet && !bIsTestnet) return 1; - - // Then alphabetically - return a[1].name.localeCompare(b[1].name); - }); - }, [chains, searchQuery, selectedChains]); - - const toggleChain = (chainId: string) => { - setSelectedChains((prev) => - prev.includes(chainId) - ? prev.filter((id) => id !== chainId) - : [...prev, chainId] - ); - setIsModified(true); - }; + }, [initialShowTestnets]); const handleShowTestnetsToggle = (checked: boolean) => { setShowTestnets(checked); @@ -121,12 +80,11 @@ export default function SettingsPage() { const clientState = localStorage.getItem("AdamikClientState") || "{}"; const parsedState = JSON.parse(clientState); - // Save the selected chains and showTestnets setting + // Save the showTestnets setting localStorage.setItem( "AdamikClientState", JSON.stringify({ ...parsedState, - defaultChains: selectedChains, showTestnets: showTestnets, }) ); @@ -148,40 +106,6 @@ export default function SettingsPage() { } }; - const ChainItem = ({ - chainId, - chain, - isSelected, - }: { - chainId: string; - chain: SupportedBlockchain; - isSelected: boolean; - }) => ( -
    toggleChain(chainId)} - > -
    - {chain.logo && ( - {`${chain.name} - )} -
    - {chain.name} - {chain.isTestnetFor && ( - Testnet - )} -
    -
    - {isSelected && } -
    - ); - // Supported Chains content const SupportedChainsContent = () => { const [localShowTestnets, setLocalShowTestnets] = useState(false); @@ -479,59 +403,9 @@ export default function SettingsPage() { fully apply.

    - -

    - Select the default chains that will be automatically connected - when a user connects their wallet for the first time: -

    - -
    -
    - - setSearchQuery(e.target.value)} - className="pl-9" - /> -
    - -
    - {selectedChains.length} chains selected -
    - - - {filteredChains.map(([chainId, chain]) => ( - - ))} - - {filteredChains.length === 0 && ( -
    - {chainsLoading - ? "Loading chains..." - : "No chains found matching your criteria"} -
    - )} -
    -
    - diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index 831f40cd..a89dfb36 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -111,6 +111,14 @@ export const MultiChainConnect: React.FC<{ const clientState = localStorage.getItem("AdamikClientState"); if (clientState) { const parsedState = JSON.parse(clientState); + + // Check if the user has explicitly disconnected all chains + if (parsedState.hasManuallyDisconnected === true) { + // User previously disconnected all chains, don't pre-select anything + setSelectedChains([]); + return; + } + if ( parsedState.defaultChains && Array.isArray(parsedState.defaultChains) @@ -124,11 +132,15 @@ export const MultiChainConnect: React.FC<{ console.error("Error loading previously selected chains:", error); } - // If no custom selection exists, use connected chains + // If no custom selection exists, use currently connected chains if (uniqueConnectedChainIds.length > 0) { setSelectedChains(uniqueConnectedChainIds); - } else if (chains) { - // If nothing else, fall back to default chains from config + return; + } + + // Only fall back to default chains if this is the user's first time (no explicit preferences) + // and there are no connected chains + if (chains) { const preferredChains = getPreferredChains(chains); setSelectedChains(preferredChains); } @@ -251,8 +263,11 @@ export const MultiChainConnect: React.FC<{ setSuccessCount(0); setFailedChains([]); + // Make sure configuredChains contains only valid chains + const validChains = configuredChains.filter((chainId) => chains[chainId]); + // Process each chain individually - configuredChains.forEach((chainId) => { + validChains.forEach((chainId) => { getAddressForChain(chainId) .then((result) => { // Process this successful result immediately @@ -267,7 +282,7 @@ export const MultiChainConnect: React.FC<{ setConnectedCount((prev) => prev + 1); // Check if all chains have been processed - if (connectedCount + 1 >= configuredChains.length) { + if (connectedCount + 1 >= validChains.length) { // Show final summary toast when all chains have been processed setTimeout(() => { toast({ @@ -441,7 +456,11 @@ export const MultiChainConnect: React.FC<{
    - ))} - - {transactionHistory.nextPage && ( -
    - -
    - )} -
    + )} +
    + ) : ( +

    No transaction history available.

    + ) ) : ( -

    No transaction history available.

    - ) - ) : ( -

    - Select an account to view its transaction history. -

    - )} - - - )} -
    +

    + Select an account to view its transaction history. +

    + )} + + + )} +
    + )}
    ); } From f23cf1ab1faa950c949c757f5226e8e94ae87212 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Mon, 12 May 2025 11:40:41 +0200 Subject: [PATCH 054/146] update refreshing logic --- src/app/portfolio/page.tsx | 373 ++++++++++++------- src/components/wallets/ChainSelector.tsx | 59 ++- src/components/wallets/MultiChainConnect.tsx | 99 +++-- src/hooks/useExtendedChains.tsx | 99 +++++ src/hooks/useWallet.tsx | 4 + src/providers/WalletProvider.tsx | 35 ++ 6 files changed, 479 insertions(+), 190 deletions(-) create mode 100644 src/hooks/useExtendedChains.tsx diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index c7579491..3ae4951e 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -39,12 +39,15 @@ import { useQueryClient } from "@tanstack/react-query"; import { accountState } from "~/api/adamik/accountState"; import { CustomProgress } from "~/components/ui/custom-progress"; import { PortfolioLoadingPlaceholder } from "./PortfolioLoadingPlaceholder"; +import { Account } from "~/components/wallets/types"; export default function Portfolio() { const { addresses: walletAddresses, setWalletMenuOpen: setWalletMenuOpen, isShowroom, + recentlyAddedAddresses, + clearRecentlyAddedAddresses, } = useWallet(); const { toast } = useToast(); @@ -216,116 +219,26 @@ export default function Portfolio() { stakingBalances.stakedBalance + stakingBalances.unstakingBalance; - const refreshPositions = useCallback(async () => { - console.log("🔄 Starting refresh for addresses:", displayAddresses); - - let completedQueries = 0; - const totalQueries = displayAddresses.length; - let isCancelled = false; - - // Create initial progress toast - const progressToast = toast({ - description: ( -
    -
    - Refreshing portfolio... - - {completedQueries}/{totalQueries} addresses - -
    - -
    - ), - duration: Infinity, - }); + const refreshPositions = useCallback( + async (specificAddresses?: Account[]) => { + // If specificAddresses is provided, use them; otherwise use all displayAddresses + const addressesToRefresh = specificAddresses || displayAddresses; - try { - // First, cancel any existing queries to prevent conflicts - // Wrap in try/catch to handle CancelledError gracefully - try { - await queryClient.cancelQueries({ queryKey: ["accountState"] }); - await queryClient.cancelQueries({ queryKey: ["mobula"] }); - } catch (cancelError) { - console.log("Query cancellation:", cancelError); - // Continue execution - cancellation errors are expected - } + console.log("🔄 Starting refresh for addresses:", addressesToRefresh); - // Clear cache for all addresses - displayAddresses.forEach(({ chainId, address }) => { - console.log(`🗑️ Clearing cache for ${chainId}:${address}`); - clearAccountStateCache({ - chainId, - address, - }); - }); + let completedQueries = 0; + const totalQueries = addressesToRefresh.length; + let isCancelled = false; - // Invalidate queries but don't refetch yet - try { - await queryClient.invalidateQueries({ - queryKey: ["accountState"], - refetchType: "none", - }); + // Don't show a toast if we're only refreshing 1 address (likely from a new chain add) + const shouldShowToast = + addressesToRefresh.length > 1 || !specificAddresses; + let progressToast: ReturnType | undefined; - await queryClient.invalidateQueries({ - queryKey: ["mobula"], - refetchType: "none", - }); - } catch (invalidateError) { - console.log("Query invalidation:", invalidateError); - // Continue execution - invalidation errors shouldn't stop the refresh - } - - // Process queries in batches to avoid overwhelming the system - const batchSize = 3; // Process 3 queries at a time - const batches = []; - - for (let i = 0; i < displayAddresses.length; i += batchSize) { - const batch = displayAddresses.slice(i, i + batchSize); - batches.push(batch); - } - - for (const batch of batches) { - if (isCancelled) break; - - // Use allSettled instead of all to prevent one failure from stopping everything - const results = await Promise.allSettled( - batch.map(async ({ chainId, address }) => { - if (isCancelled) return; - - try { - await queryClient.fetchQuery({ - queryKey: ["accountState", chainId, address], - queryFn: () => accountState(chainId, address), - staleTime: 0, - retry: 1, // Limit retries to avoid endless attempts - }); - - return { success: true, chainId, address }; - } catch (error) { - if ( - error instanceof Error && - (error.message.includes("CancelledError") || - error.name === "CancelledError") - ) { - isCancelled = true; - return { success: false, cancelled: true, chainId, address }; - } - console.error(`Error refreshing ${chainId}:${address}:`, error); - return { success: false, error, chainId, address }; - } - }) - ); - - // Only count successful queries - const successfulQueries = results.filter( - (result) => result.status === "fulfilled" && result.value?.success - ).length; - - completedQueries += successfulQueries; - - // Update progress toast - if (!isCancelled) { - const newDescription = ( + if (shouldShowToast) { + // Create initial progress toast + progressToast = toast({ + description: (
    Refreshing portfolio... @@ -335,49 +248,223 @@ export default function Portfolio() {
    - ); + ), + duration: Infinity, + }); + } - progressToast.update({ - id: progressToast.id, - description: newDescription, - }); + try { + // First, cancel any existing queries to prevent conflicts + // We'll use a different approach to prevent CancelledError from propagating + try { + // Instead of awaiting each cancellation individually, collect all queries to cancel + const queryKeysToCancel: Array> = []; + + // Add all address queries + for (const { chainId, address } of addressesToRefresh) { + queryKeysToCancel.push(["accountState", chainId, address]); + } + + // Add mobula queries if doing full refresh + if (!specificAddresses) { + queryKeysToCancel.push(["mobula"]); + } + + // Cancel all in one batch (without awaiting) + if (queryKeysToCancel.length > 0) { + console.log(`Cancelling ${queryKeysToCancel.length} queries`); + queryClient.cancelQueries({ + predicate: (query) => { + // Check if the query key matches any in our list + return queryKeysToCancel.some((keyToCancel) => { + // For exact match, compare array length and all elements + if (query.queryKey.length !== keyToCancel.length) + return false; + return keyToCancel.every( + (key: string, index: number) => + key === query.queryKey[index] + ); + }); + }, + }); + } + } catch (cancelError) { + console.log("Query cancellation error:", cancelError); + // Continue execution - cancellation errors are expected } - } - if (!isCancelled) { - progressToast.dismiss(); - toast({ - description: "Portfolio updated successfully", - duration: 2000, - }); - } - } catch (error) { - // Check if it's a cancellation error, which we can safely ignore - const isCancellationError = - error instanceof Error && - (error.message.includes("CancelledError") || - error.name === "CancelledError"); - - if (!isCancelled && !isCancellationError) { - progressToast.dismiss(); - toast({ - description: "Failed to update some portfolio data", - variant: "destructive", - duration: 3000, + // Clear cache for targeted addresses + addressesToRefresh.forEach(({ chainId, address }) => { + console.log(`🗑️ Clearing cache for ${chainId}:${address}`); + clearAccountStateCache({ + chainId, + address, + }); }); - console.error("Error refreshing positions:", error); - } else { - // For cancellation errors, just clean up the toast - progressToast.dismiss(); - console.log("Refresh operation was cancelled"); + + // Invalidate queries but don't refetch yet + try { + // Build an array of query keys to invalidate + const queryKeysToInvalidate = addressesToRefresh.map( + ({ chainId, address }) => ["accountState", chainId, address] + ); + + // Invalidate each query key + for (const queryKey of queryKeysToInvalidate) { + await queryClient.invalidateQueries({ + queryKey, + refetchType: "none", + }); + } + + // Only invalidate mobula if we're doing a full refresh + if (!specificAddresses) { + await queryClient.invalidateQueries({ + queryKey: ["mobula"], + refetchType: "none", + }); + } + } catch (invalidateError) { + console.log("Query invalidation:", invalidateError); + // Continue execution - invalidation errors shouldn't stop the refresh + } + + // Process queries in batches to avoid overwhelming the system + const batchSize = 3; // Process 3 queries at a time + const batches = []; + + for (let i = 0; i < addressesToRefresh.length; i += batchSize) { + const batch = addressesToRefresh.slice(i, i + batchSize); + batches.push(batch); + } + + for (const batch of batches) { + if (isCancelled) break; + + // Use allSettled instead of all to prevent one failure from stopping everything + const results = await Promise.allSettled( + batch.map(async ({ chainId, address }) => { + if (isCancelled) return; + + try { + await queryClient.fetchQuery({ + queryKey: ["accountState", chainId, address], + queryFn: () => accountState(chainId, address), + staleTime: 0, + retry: 1, // Limit retries to avoid endless attempts + }); + + return { success: true, chainId, address }; + } catch (error) { + if ( + error instanceof Error && + (error.message.includes("CancelledError") || + error.name === "CancelledError") + ) { + isCancelled = true; + return { success: false, cancelled: true, chainId, address }; + } + console.error(`Error refreshing ${chainId}:${address}:`, error); + return { success: false, error, chainId, address }; + } + }) + ); + + // Only count successful queries + const successfulQueries = results.filter( + (result) => result.status === "fulfilled" && result.value?.success + ).length; + + completedQueries += successfulQueries; + + // Update progress toast if it exists + if (!isCancelled && progressToast) { + const newDescription = ( +
    +
    + Refreshing portfolio... + + {completedQueries}/{totalQueries} addresses + +
    + +
    + ); + + progressToast.update({ + id: progressToast.id, + description: newDescription, + }); + } + } + + if (!isCancelled && progressToast) { + progressToast.dismiss(); + + // Only show completion toast if we were showing progress + if (shouldShowToast) { + toast({ + description: "Portfolio updated successfully", + duration: 2000, + }); + } + } + + // Clear the recently added addresses since we've refreshed them + if (specificAddresses && specificAddresses === recentlyAddedAddresses) { + clearRecentlyAddedAddresses(); + } + } catch (error) { + // Check if it's a cancellation error, which we can safely ignore + const isCancellationError = + error instanceof Error && + (error.message.includes("CancelledError") || + error.name === "CancelledError"); + + if (!isCancelled && !isCancellationError && progressToast) { + progressToast.dismiss(); + + // Only show error toast if we were showing progress + if (shouldShowToast) { + toast({ + description: "Failed to update some portfolio data", + variant: "destructive", + duration: 3000, + }); + } + console.error("Error refreshing positions:", error); + } else if (progressToast) { + // For cancellation errors, just clean up the toast + progressToast.dismiss(); + console.log("Refresh operation was cancelled"); + } } - } - return () => { - isCancelled = true; - progressToast.dismiss(); - }; - }, [displayAddresses, queryClient, toast]); + return () => { + isCancelled = true; + if (progressToast) { + progressToast.dismiss(); + } + }; + }, + [ + displayAddresses, + queryClient, + toast, + recentlyAddedAddresses, + clearRecentlyAddedAddresses, + ] + ); + + // Add a useEffect to automatically refresh newly added addresses + useEffect(() => { + if (recentlyAddedAddresses.length > 0) { + // Refresh only the newly added addresses + refreshPositions(recentlyAddedAddresses); + } + }, [recentlyAddedAddresses, refreshPositions]); return (
    diff --git a/src/components/wallets/ChainSelector.tsx b/src/components/wallets/ChainSelector.tsx index 56a0feef..34628be3 100644 --- a/src/components/wallets/ChainSelector.tsx +++ b/src/components/wallets/ChainSelector.tsx @@ -9,19 +9,19 @@ import { import { ScrollArea } from "~/components/ui/scroll-area"; import { useToast } from "~/components/ui/use-toast"; import { useWallet } from "~/hooks/useWallet"; -import { useChains } from "~/hooks/useChains"; +import { useExtendedChains } from "~/hooks/useExtendedChains"; import { Chain } from "~/utils/types"; import { getPreferredChains } from "~/config/wallet-chains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { Account, WalletName } from "./types"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Clock } from "lucide-react"; export function ChainSelector() { const { toast } = useToast(); const { addresses, addAddresses, removeAddresses } = useWallet(); const [loading, setLoading] = useState(false); const [selectedChains, setSelectedChains] = useState([]); - const { data: chains, isLoading: chainsLoading } = useChains(); + const { data: chains, isLoading: chainsLoading } = useExtendedChains(); useEffect(() => { if (chains) { @@ -38,6 +38,15 @@ export function ChainSelector() { const connectChain = async (chainId: string) => { if (!chains?.[chainId]) return; + // Don't allow connecting to chains marked as coming soon + if ("comingSoon" in chains[chainId] && chains[chainId].comingSoon) { + toast({ + description: `${chains[chainId].name} is coming soon and not available yet`, + variant: "destructive", + }); + return; + } + setLoading(true); try { const response = await fetch( @@ -122,21 +131,35 @@ export function ChainSelector() {
    {Object.entries(chains) .sort(([, a], [, b]) => a.name.localeCompare(b.name)) - .map(([chainId, chain]) => ( - - ))} + .map(([chainId, chain]) => { + const isComingSoon = "comingSoon" in chain && chain.comingSoon; + + return ( + + ); + })}
    diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index a90133cc..f72bb37e 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -11,8 +11,8 @@ import { useWallet } from "~/hooks/useWallet"; import { Account, WalletName } from "./types"; import { Chain, SupportedBlockchain } from "~/utils/types"; import { Button } from "~/components/ui/button"; -import { Loader2, ChevronRight, Search, Check } from "lucide-react"; -import { useFilteredChains } from "~/hooks/useChains"; +import { Loader2, ChevronRight, Search, Check, Clock } from "lucide-react"; +import { useExtendedChains, ExtendedChain } from "~/hooks/useExtendedChains"; import { encodePubKeyToAddress } from "~/api/adamik/encode"; import { getPreferredChains } from "~/config/wallet-chains"; import { Input } from "~/components/ui/input"; @@ -26,32 +26,46 @@ const ChainItem = forwardRef< HTMLDivElement, { chainId: string; - chain: SupportedBlockchain; + chain: ExtendedChain; isSelected: boolean; isConnected?: boolean; onToggle: () => void; } ->(({ chainId, chain, isSelected, isConnected = false, onToggle }, ref) => ( -
    -
    - {chain.logo && ( - {`${chain.name} +>(({ chainId, chain, isSelected, isConnected = false, onToggle }, ref) => { + const isComingSoon = "comingSoon" in chain && chain.comingSoon; + + return ( +
    +
    + {chain.logo && ( + {`${chain.name} + )} +
    + {chain.name} + {isComingSoon && ( +
    + + Soon +
    + )} +
    +
    + {isSelected && !isComingSoon && ( + )} - {chain.name}
    - {isSelected && } -
    -)); + ); +}); ChainItem.displayName = "ChainItem"; @@ -89,7 +103,7 @@ export const MultiChainConnect: React.FC<{ const [searchQuery, setSearchQuery] = useState(""); const [selectedChains, setSelectedChains] = useState([]); const [isSelectionOpen, setIsSelectionOpen] = useState(false); - const { data: chains, isLoading: chainsLoading } = useFilteredChains(); + const { data: chains, isLoading: chainsLoading } = useExtendedChains(); // Get unique chain IDs from connected addresses const uniqueConnectedChainIds = useMemo( @@ -157,21 +171,41 @@ export const MultiChainConnect: React.FC<{ const matchesSearch = chain.name .toLowerCase() .includes(searchQuery.toLowerCase()); + + // Filter out "coming soon" chains from selected list + const isComingSoon = "comingSoon" in chain && chain.comingSoon; return matchesSearch; }) .sort((a, b) => a[1].name.localeCompare(b[1].name)); return { - selectedChainsList: allChains.filter(([chainId]) => - selectedChains.includes(chainId) - ), - unselectedChainsList: allChains.filter( - ([chainId]) => !selectedChains.includes(chainId) - ), + selectedChainsList: allChains.filter(([chainId, chain]) => { + // Exclude "coming soon" chains from selected list + const isComingSoon = "comingSoon" in chain && chain.comingSoon; + return selectedChains.includes(chainId) && !isComingSoon; + }), + unselectedChainsList: allChains.filter(([chainId, chain]) => { + // Include "coming soon" chains in unselected list, even if they're "selected" + const isComingSoon = "comingSoon" in chain && chain.comingSoon; + return !selectedChains.includes(chainId) || isComingSoon; + }), }; }, [chains, searchQuery, selectedChains]); const toggleChain = (chainId: string) => { + // Don't allow toggling chains marked as coming soon + if ( + chains && + "comingSoon" in chains[chainId] && + chains[chainId].comingSoon + ) { + toast({ + description: `${chains[chainId].name} is coming soon and not available yet`, + variant: "destructive", + }); + return; + } + // Toggle the chain in the selected list, regardless of connection status setSelectedChains((prev) => prev.includes(chainId) @@ -186,6 +220,13 @@ export const MultiChainConnect: React.FC<{ throw new Error(`Chain ${chainId} not supported`); } + // Don't allow connecting to chains marked as coming soon + if ("comingSoon" in chains[chainId] && chains[chainId].comingSoon) { + throw new Error( + `${chains[chainId].name} is coming soon and not available yet` + ); + } + const response = await fetch( `/api/sodot-proxy/derive-chain-pubkey?chain=${chainId}`, { diff --git a/src/hooks/useExtendedChains.tsx b/src/hooks/useExtendedChains.tsx new file mode 100644 index 00000000..bb86b2fa --- /dev/null +++ b/src/hooks/useExtendedChains.tsx @@ -0,0 +1,99 @@ +import { useMemo } from "react"; +import { + Chain, + ChainSupportedFeatures, + AdamikCurve, + AdamikHashFunction, +} from "~/utils/types"; +import { useChains } from "./useChains"; + +// Create a basic structure for the Stellar chain +const stellarChain: Chain = { + family: "stellar", + id: "stellar", + nativeId: "stellar", + name: "Stellar", + ticker: "XLM", + decimals: 7, + params: {}, + // Create a minimal supported features object + supportedFeatures: { + read: { + token: false, + validators: false, + transaction: { + native: false, + tokens: false, + staking: false, + }, + account: { + balances: { + native: false, + tokens: false, + staking: false, + }, + transactions: { + native: false, + tokens: false, + staking: false, + }, + }, + }, + write: { + transaction: { + type: { + deployAccount: false, + transfer: false, + transferToken: false, + stake: false, + unstake: false, + claimRewards: false, + withdraw: false, + registerStake: false, + }, + field: { + memo: false, + }, + }, + }, + utils: { + addresses: false, + }, + }, + signerSpec: { + curve: AdamikCurve.ED25519, + hashFunction: AdamikHashFunction.SHA256, + signatureFormat: "raw", + coinType: "148", + }, +}; + +// Custom property to mark the chain as coming soon +export interface ExtendedChain extends Chain { + comingSoon?: boolean; + logo?: string; +} + +export const useExtendedChains = () => { + const chainsQuery = useChains(); + + const extendedData = useMemo(() => { + if (!chainsQuery.data) return null; + + // Create a copy of the chains data + const newChains: Record = { ...chainsQuery.data }; + + // Add Stellar with comingSoon flag + newChains.stellar = { + ...stellarChain, + comingSoon: true, + }; + + return newChains; + }, [chainsQuery.data]); + + return { + ...chainsQuery, + data: extendedData, + }; +}; diff --git a/src/hooks/useWallet.tsx b/src/hooks/useWallet.tsx index c7c3f5b4..0fbaaa29 100644 --- a/src/hooks/useWallet.tsx +++ b/src/hooks/useWallet.tsx @@ -12,6 +12,8 @@ type WalletContextType = { isWalletMenuOpen: boolean; isShowroom: boolean; setShowroom: (isShowroom: boolean) => void; + recentlyAddedAddresses: Account[]; + clearRecentlyAddedAddresses: () => void; }; export const WalletContext = React.createContext({ @@ -25,6 +27,8 @@ export const WalletContext = React.createContext({ setAddresses: () => {}, setWalletMenuOpen: () => {}, isWalletMenuOpen: false, + recentlyAddedAddresses: [], + clearRecentlyAddedAddresses: () => {}, }); export const useWallet = () => { diff --git a/src/providers/WalletProvider.tsx b/src/providers/WalletProvider.tsx index 2d19ab7d..d3ff26df 100644 --- a/src/providers/WalletProvider.tsx +++ b/src/providers/WalletProvider.tsx @@ -16,6 +16,10 @@ export const WalletProvider: React.FC = ({ const [isWalletMenuOpen, setWalletMenuOpen] = useState(false); // Track real wallet addresses separately const [realWalletAddresses, setRealWalletAddresses] = useState([]); + // Track recently added addresses for optimized portfolio refresh + const [recentlyAddedAddresses, setRecentlyAddedAddresses] = useState< + Account[] + >([]); useEffect(() => { const localDataAddresses = localStorage?.getItem("AdamikClientAddresses"); @@ -48,6 +52,24 @@ export const WalletProvider: React.FC = ({ }; const addAddresses = (newAddresses: Account[]) => { + // First identify which addresses are actually new + const actuallyNewAddresses: Account[] = []; + + newAddresses.forEach((newAddr) => { + const exists = addresses.some( + (addr) => + addr.address === newAddr.address && addr.chainId === newAddr.chainId + ); + if (!exists) { + actuallyNewAddresses.push(newAddr); + } + }); + + // Set the recently added addresses for optimized portfolio refresh + if (actuallyNewAddresses.length > 0) { + setRecentlyAddedAddresses(actuallyNewAddresses); + } + setAddresses((oldAddresses) => { const mergedAddresses = [...oldAddresses, ...newAddresses]; @@ -74,6 +96,9 @@ export const WalletProvider: React.FC = ({ }; const removeAddresses = (addressesToRemove: Account[]) => { + // Clear recently added addresses when removing addresses + setRecentlyAddedAddresses([]); + setAddresses((oldAddresses) => { const remainingAddresses = oldAddresses.filter( (addr) => @@ -98,8 +123,16 @@ export const WalletProvider: React.FC = ({ }); }; + // Clear the recently added addresses (useful after refresh) + const clearRecentlyAddedAddresses = () => { + setRecentlyAddedAddresses([]); + }; + // Improved setShowroom function that properly handles address switching const handleSetShowroom = (showroomState: boolean) => { + // Clear recently added addresses when toggling showroom + setRecentlyAddedAddresses([]); + // Save the current state to client state const localData = localStorage?.getItem("AdamikClientState"); const oldLocalData = JSON.parse(localData || "{}"); @@ -142,6 +175,8 @@ export const WalletProvider: React.FC = ({ isWalletMenuOpen, isShowroom, setShowroom: handleSetShowroom, + recentlyAddedAddresses, + clearRecentlyAddedAddresses, }} > {children} From b89043f4c300a57dd6a1aa2e54a815facc987531 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Mon, 12 May 2025 11:52:08 +0200 Subject: [PATCH 055/146] improve UX --- src/app/portfolio/AssetsList.tsx | 33 +++++- src/app/portfolio/page.tsx | 167 +++++++++++++++++++++++++------ 2 files changed, 164 insertions(+), 36 deletions(-) diff --git a/src/app/portfolio/AssetsList.tsx b/src/app/portfolio/AssetsList.tsx index bb81fb07..215ad48e 100644 --- a/src/app/portfolio/AssetsList.tsx +++ b/src/app/portfolio/AssetsList.tsx @@ -1,4 +1,5 @@ import { Loader2, Info, RefreshCw } from "lucide-react"; +import { useState } from "react"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { @@ -87,6 +88,24 @@ export const AssetsList: React.FC<{ hideLowBalance, refreshPositions, }) => { + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + console.log("Refresh button clicked, calling refreshPositions()"); + // Call the refreshPositions function with no arguments to refresh all positions + await refreshPositions(); + } catch (error) { + console.error("Error refreshing portfolio:", error); + } finally { + // Add a slight delay to make the spinner visible to users + setTimeout(() => { + setIsRefreshing(false); + }, 500); + } + }; + return ( <> @@ -96,9 +115,17 @@ export const AssetsList: React.FC<{ - -
    diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 3ae4951e..240ca748 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -143,7 +143,85 @@ export default function Portfolio() { isSupportedChainsLoading || isMobulaMarketDataLoading; - // Restore loading state management with toast + // Add some console logs to help debug loading state + useEffect(() => { + console.log("Loading states:", { + isAddressesLoading, + isAssetDetailsLoading, + isSupportedChainsLoading, + isMobulaMarketDataLoading, + overall: isLoading, + }); + }, [ + isAddressesLoading, + isAssetDetailsLoading, + isSupportedChainsLoading, + isMobulaMarketDataLoading, + isLoading, + ]); + + // Add a force refresh function for when the UI gets stuck + const forceRefresh = useCallback(() => { + console.log("Force refreshing portfolio data..."); + // Force refresh Mobula data which often gets stuck + queryClient.invalidateQueries({ queryKey: ["mobula"] }); + + // Force refresh chain data + queryClient.invalidateQueries({ queryKey: ["chains"] }); + + // Force refresh all account state data + displayAddresses.forEach(({ chainId, address }) => { + clearAccountStateCache({ + chainId, + address, + }); + queryClient.invalidateQueries({ + queryKey: ["accountState", chainId, address], + refetchType: "active", + }); + }); + + // Show a toast to inform the user + toast({ + description: "Forcing data refresh...", + duration: 3000, + }); + + // If all else fails, try a page reload after 15 seconds if still loading + const timeoutId = setTimeout(() => { + if (isLoading) { + toast({ + description: "Still loading, trying to reload page...", + duration: 3000, + }); + // Give one more second before reload + setTimeout(() => { + window.location.reload(); + }, 1000); + } + }, 15000); + + return () => clearTimeout(timeoutId); + }, [displayAddresses, queryClient, toast, isLoading]); + + // Add long timeout to automatically recover from stuck loading states + useEffect(() => { + let timeoutId: NodeJS.Timeout | null = null; + + if (isLoading) { + // If loading takes more than 30 seconds, trigger force refresh + timeoutId = setTimeout(() => { + console.log("Loading timeout exceeded, triggering force refresh"); + forceRefresh(); + }, 30000); + } + + return () => { + if (timeoutId) clearTimeout(timeoutId); + }; + }, [isLoading, forceRefresh]); + + // Fix the loading toast useEffect to have consistent dependencies useEffect(() => { let loadingToast: ReturnType | undefined; @@ -219,11 +297,22 @@ export default function Portfolio() { stakingBalances.stakedBalance + stakingBalances.unstakingBalance; + // Update the refreshPositions function to be more reliable const refreshPositions = useCallback( async (specificAddresses?: Account[]) => { // If specificAddresses is provided, use them; otherwise use all displayAddresses const addressesToRefresh = specificAddresses || displayAddresses; + // Add check for empty addresses + if (!addressesToRefresh || addressesToRefresh.length === 0) { + console.log("No addresses to refresh"); + toast({ + description: "No wallet addresses to refresh", + duration: 2000, + }); + return; + } + console.log("🔄 Starting refresh for addresses:", addressesToRefresh); let completedQueries = 0; @@ -254,43 +343,36 @@ export default function Portfolio() { } try { - // First, cancel any existing queries to prevent conflicts - // We'll use a different approach to prevent CancelledError from propagating + // First invalidate chain data to ensure we have the latest chain info try { - // Instead of awaiting each cancellation individually, collect all queries to cancel - const queryKeysToCancel: Array> = []; + queryClient.invalidateQueries({ queryKey: ["chains"] }); + } catch (error) { + console.log("Error invalidating chains:", error); + } - // Add all address queries + // First, let's update the cancel queries logic in refreshPositions to be more robust + // Find the cancelQueries block and replace it with this: + try { + // Instead of cancelling queries with complex predicates, we'll use a safer approach + // Just invalidate the queries without cancelling them for (const { chainId, address } of addressesToRefresh) { - queryKeysToCancel.push(["accountState", chainId, address]); + // Simply mark queries as stale - no need to cancel them + queryClient.invalidateQueries({ + queryKey: ["accountState", chainId, address], + refetchType: "none", + }); } - // Add mobula queries if doing full refresh + // For mobula data, also just invalidate without cancelling if (!specificAddresses) { - queryKeysToCancel.push(["mobula"]); - } - - // Cancel all in one batch (without awaiting) - if (queryKeysToCancel.length > 0) { - console.log(`Cancelling ${queryKeysToCancel.length} queries`); - queryClient.cancelQueries({ - predicate: (query) => { - // Check if the query key matches any in our list - return queryKeysToCancel.some((keyToCancel) => { - // For exact match, compare array length and all elements - if (query.queryKey.length !== keyToCancel.length) - return false; - return keyToCancel.every( - (key: string, index: number) => - key === query.queryKey[index] - ); - }); - }, + queryClient.invalidateQueries({ + queryKey: ["mobula"], + refetchType: "none", }); } - } catch (cancelError) { - console.log("Query cancellation error:", cancelError); - // Continue execution - cancellation errors are expected + } catch (error) { + console.log("Query invalidation error:", error); + // Continue execution regardless of errors } // Clear cache for targeted addresses @@ -460,10 +542,29 @@ export default function Portfolio() { // Add a useEffect to automatically refresh newly added addresses useEffect(() => { - if (recentlyAddedAddresses.length > 0) { - // Refresh only the newly added addresses - refreshPositions(recentlyAddedAddresses); + // Avoid refreshing if there are no new addresses + if (!recentlyAddedAddresses || recentlyAddedAddresses.length === 0) { + return; } + + console.log("New addresses detected, refreshing:", recentlyAddedAddresses); + + // Debounce refresh for multiple chains added in quick succession + const timeoutId = setTimeout(() => { + try { + // Wrap in try/catch to prevent errors from bubbling up to React + refreshPositions(recentlyAddedAddresses).catch((error) => { + // Safely handle any errors without crashing + console.log("Caught error refreshing new addresses:", error); + }); + } catch (error) { + console.log("Error setting up refresh for new addresses:", error); + } + }, 100); // Small delay to group multiple chain additions + + return () => { + clearTimeout(timeoutId); + }; }, [recentlyAddedAddresses, refreshPositions]); return ( From deb68dc4d52105bc4340b6f8fb0968e0fda69448 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Mon, 12 May 2025 11:53:06 +0200 Subject: [PATCH 056/146] Update AssetsList.tsx --- src/app/portfolio/AssetsList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/AssetsList.tsx b/src/app/portfolio/AssetsList.tsx index 215ad48e..964a85b4 100644 --- a/src/app/portfolio/AssetsList.tsx +++ b/src/app/portfolio/AssetsList.tsx @@ -116,9 +116,9 @@ export const AssetsList: React.FC<{ - - + Show unsigned transaction diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 6002c765..d234fe7b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -19,7 +19,7 @@ const DialogOverlay = ({ }: DialogPrimitive.DialogOverlayProps) => ( ) => ( ); diff --git a/src/components/wallets/MultiChainConnect.tsx b/src/components/wallets/MultiChainConnect.tsx index f72bb37e..d4e1f989 100644 --- a/src/components/wallets/MultiChainConnect.tsx +++ b/src/components/wallets/MultiChainConnect.tsx @@ -487,7 +487,7 @@ export const MultiChainConnect: React.FC<{ {isSelectionOpen && (
    -
    +

    Select Chains

    diff --git a/src/components/wallets/SodotConnect.tsx b/src/components/wallets/SodotConnect.tsx index 09c7f610..dbe2d6b7 100644 --- a/src/components/wallets/SodotConnect.tsx +++ b/src/components/wallets/SodotConnect.tsx @@ -231,10 +231,12 @@ export const SodotConnect: React.FC = ({ signature: signature, }; - // Update the transaction in the global context - setTransaction(signedTransaction); - - console.log("Updated transaction with signature:", signedTransaction); + // Use setTimeout to break the render cycle and prevent infinite updates + setTimeout(() => { + // Update the transaction in the global context + setTransaction(signedTransaction); + console.log("Updated transaction with signature:", signedTransaction); + }, 0); } toast({ diff --git a/src/components/wallets/WalletSigner.tsx b/src/components/wallets/WalletSigner.tsx index 0989b355..83591583 100644 --- a/src/components/wallets/WalletSigner.tsx +++ b/src/components/wallets/WalletSigner.tsx @@ -13,6 +13,7 @@ import { SodotConnect } from "./SodotConnect"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { ConnectWallet } from "../../app/portfolio/ConnectWallet"; +import { useEffect, useState } from "react"; export const WalletSigner = ({ onNextStep }: { onNextStep: () => void }) => { const { @@ -26,6 +27,16 @@ export const WalletSigner = ({ onNextStep }: { onNextStep: () => void }) => { const { addresses: accounts, isShowroom, setWalletMenuOpen } = useWallet(); const router = useRouter(); + // Use local state to safely track signature status + const [hasSignature, setHasSignature] = useState(false); + + // Use effect to update signature state from transaction + useEffect(() => { + if (transaction?.signature) { + setHasSignature(true); + } + }, [transaction]); + const signer = accounts.find( (account) => account.chainId === chainId && @@ -71,6 +82,7 @@ export const WalletSigner = ({ onNextStep }: { onNextStep: () => void }) => { setChainId(undefined); setTransaction(undefined); setTransactionHash(undefined); + setHasSignature(false); }; if (transactionHash) { @@ -124,7 +136,8 @@ export const WalletSigner = ({ onNextStep }: { onNextStep: () => void }) => { ); } - if (transaction?.signature) { + // Use the local state to determine if we show the broadcast modal + if (hasSignature) { return ; } diff --git a/src/hooks/useMobulaMarketMultiData.tsx b/src/hooks/useMobulaMarketMultiData.tsx index a0ee344e..9508b3cc 100644 --- a/src/hooks/useMobulaMarketMultiData.tsx +++ b/src/hooks/useMobulaMarketMultiData.tsx @@ -22,7 +22,6 @@ const SPECIAL_CASES: Record< { queryParam: string; type: "assets" | "symbols" } > = { LIKE: { queryParam: "likecoin", type: "assets" }, - DYDX: { queryParam: "dydx", type: "assets" }, LUM: { queryParam: "Lum Network", type: "assets" }, }; From 1e1d365d4b2b02c7b2fbb5f0a3618f8eaaa1cfc3 Mon Sep 17 00:00:00 2001 From: fabrice-adamik <159912605+fabrice-adamik@users.noreply.github.com> Date: Wed, 14 May 2025 20:50:19 +0200 Subject: [PATCH 060/146] feat: streamline transaction flow with auto-broadcasting and improved success feedback - Automated transaction broadcasting after signing - Eliminated redundant intermediate steps in transaction flow - Added transaction success modal with hash display and copy functionality - Added debugging and validation for chain ID - Added page refresh after transaction completion to update balances --- src/app/portfolio/page.tsx | 43 +-- .../transactions/StakingTransactionForm.tsx | 258 ++++++++++++++++- .../transactions/TransactionSuccessModal.tsx | 109 +++++++ .../transactions/TransferTransactionForm.tsx | 271 +++++++++++++++++- src/components/wallets/SodotConnect.tsx | 82 +++++- src/components/wallets/WalletSigner.tsx | 31 +- 6 files changed, 700 insertions(+), 94 deletions(-) create mode 100644 src/components/transactions/TransactionSuccessModal.tsx diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 240ca748..c8cfafad 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -8,7 +8,6 @@ import { Modal } from "~/components/ui/modal"; import { Tooltip } from "~/components/ui/tooltip"; import { useToast } from "~/components/ui/use-toast"; import { WalletSelection } from "~/components/wallets/WalletSelection"; -import { WalletSigner } from "~/components/wallets/WalletSigner"; import { clearAccountStateCache, isInAccountStateBatchCache, @@ -78,7 +77,6 @@ export default function Portfolio() { const { data: mobulaBlockchainDetails } = useMobulaBlockchains(); const [openTransaction, setOpenTransaction] = useState(false); const [hideLowBalance, setHideLowBalance] = useState(false); - const [stepper, setStepper] = useState(0); // Use the hideLowBalances setting from localStorage useEffect(() => { @@ -626,39 +624,14 @@ export default function Portfolio() { open={openTransaction} setOpen={setOpenTransaction} modalContent={ - // Probably need to rework - stepper === 0 ? ( - { - setStepper(1); - }} - /> - ) : ( - <> - {walletAddresses && walletAddresses.length > 0 ? ( - { - setOpenTransaction(false); - setTimeout(() => { - setStepper(0); - }, 200); - }} - /> - ) : ( - { - setOpenTransaction(false); - setWalletMenuOpen(true); - setTimeout(() => { - setStepper(0); - }, 200); - }} - /> - )} - - ) + { + // After transaction is signed and broadcasted, just close the modal + setOpenTransaction(false); + }} + /> } />
    diff --git a/src/components/transactions/StakingTransactionForm.tsx b/src/components/transactions/StakingTransactionForm.tsx index 0d5be4ab..ed4fbad4 100644 --- a/src/components/transactions/StakingTransactionForm.tsx +++ b/src/components/transactions/StakingTransactionForm.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronDown } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { ChevronDown, Loader2 } from "lucide-react"; +import { useCallback, useMemo, useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { Button } from "~/components/ui/button"; import { @@ -29,6 +29,11 @@ import { ValidatorFormField } from "./fields/ValidatorFormField"; import { AmountFormField } from "./fields/AmountFormField"; import { StakingPositionFormField } from "./fields/StakingPositionFormField"; import { StakingPosition } from "~/app/stake/helpers"; +import { SodotConnect } from "~/components/wallets/SodotConnect"; +import { useWallet } from "~/hooks/useWallet"; +import { useToast } from "~/components/ui/use-toast"; +import { useBroadcastTransaction } from "~/hooks/useBroadcastTransaction"; +import { TransactionSuccessModal } from "./TransactionSuccessModal"; type StakingTransactionProps = { mode: TransactionMode; @@ -48,6 +53,9 @@ export function StakingTransactionForm({ onNextStep, }: StakingTransactionProps) { const { mutate, isPending, isSuccess } = useEncodeTransaction(); + const { addresses: accounts } = useWallet(); + const { toast } = useToast(); + const { mutate: broadcastTransaction } = useBroadcastTransaction(); const form = useForm({ resolver: zodResolver(transactionFormSchema), defaultValues: { @@ -60,12 +68,19 @@ export function StakingTransactionForm({ }, }); const [decimals, setDecimals] = useState(0); - const { transaction, setChainId, setTransaction, setTransactionHash } = - useTransaction(); + const { + chainId, + transaction, + setChainId, + setTransaction, + setTransactionHash, + } = useTransaction(); const [errors, setErrors] = useState(""); const [selectedStakingPosition, setSelectedStakingPosition] = useState< StakingPosition | undefined >(); + const [signing, setSigning] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); const label = useMemo(() => { switch (mode) { case TransactionMode.STAKE: @@ -79,6 +94,14 @@ export function StakingTransactionForm({ } }, [mode]); + // Add debugging effect to monitor transaction and chainId + useEffect(() => { + console.log("Staking: Transaction or chainId changed:", { + transaction: transaction ? { ...transaction } : null, + chainId, + }); + }, [transaction, chainId]); + const onSubmit = useCallback( (formInput: TransactionFormInput) => { setChainId(undefined); @@ -178,6 +201,194 @@ export function StakingTransactionForm({ } }; + // Function to handle signing and broadcasting + const signAndBroadcast = async () => { + if (!transaction) { + console.error("No transaction to sign"); + setErrors("No transaction to sign"); + return; + } + + // Use chainId from context instead of from transaction object + if (!chainId) { + console.error("Chain ID is undefined in context", { + transaction, + contextChainId: chainId, + }); + setErrors("Chain ID is undefined. Please try again."); + return; + } + + console.log("Sign & Broadcast clicked:", { + transaction: { ...transaction }, + transactionChainId: transaction.data?.chainId, + contextChainId: chainId, + }); + + setSigning(true); + setErrors(""); + + try { + // Extract transaction data for signing + const transactionEncoded = transaction.encoded; + + let transactionHash: string | undefined; + let transactionRaw: string | undefined; + + if (Array.isArray(transactionEncoded) && transactionEncoded.length > 0) { + const firstEncoded = transactionEncoded[0]; + if (firstEncoded && typeof firstEncoded === "object") { + if ( + firstEncoded.hash && + typeof firstEncoded.hash === "object" && + "value" in firstEncoded.hash + ) { + transactionHash = String(firstEncoded.hash.value); + } + if ( + firstEncoded.raw && + typeof firstEncoded.raw === "object" && + "value" in firstEncoded.raw + ) { + transactionRaw = String(firstEncoded.raw.value); + } + } + } else if (typeof transactionEncoded === "string") { + transactionRaw = transactionEncoded; + } + + if (!transactionHash && !transactionRaw) { + console.warn( + "Could not extract hash or raw transaction, using entire payload" + ); + transactionRaw = JSON.stringify(transactionEncoded); + } + + console.log("Signing with:", { + chainId, + hash: transactionHash, + rawLength: transactionRaw?.length, + }); + + // Step 1: Sign the transaction + const response = await fetch(`/api/sodot-proxy/${chainId}/sign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + transaction: transactionRaw, + hash: transactionHash, + usePrecomputedHash: !!transactionHash, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.message || `HTTP error! status: ${response.status}` + ); + } + + const data = await response.json(); + const signature = data.signature; + + console.log("Transaction signed successfully:", !!signature); + + if (!signature) { + throw new Error("No signature returned from signing"); + } + + // Step 2: Create signed transaction object + const signedTransaction = { + ...transaction, + signature: signature, + }; + + // Update transaction in context + setTransaction(signedTransaction); + + // Step 3: Broadcast the transaction + console.log("Broadcasting transaction with signature"); + + // Ensure chainId is included in the data properly + const transactionWithChainId = { + ...signedTransaction, + data: { + ...signedTransaction.data, + chainId, + }, + }; + + broadcastTransaction(transactionWithChainId, { + onSuccess: (response) => { + console.log("Broadcast response:", response); + if (response.error) { + const errorMessage = + response.error.status?.errors?.[0]?.message || + "An unknown error occurred"; + console.error("Broadcast error:", errorMessage); + setErrors(errorMessage); + toast({ + variant: "destructive", + title: "Broadcast Failed", + description: errorMessage, + }); + } else if (response.hash) { + console.log("Transaction hash:", response.hash); + setTransactionHash(response.hash); + + // Show a toast notification + toast({ + variant: "default", + title: "Transaction Successful!", + description: + "Your transaction has been successfully signed and broadcasted.", + duration: 3000, + }); + + // Show the success modal instead of closing + setShowSuccessModal(true); + } else { + console.error("Unexpected broadcast response:", response); + setErrors("Unexpected response from server"); + toast({ + variant: "destructive", + title: "Broadcast Failed", + description: "Unexpected response from server", + }); + } + setSigning(false); + }, + onError: (error) => { + console.error("Broadcast error:", error); + const errorMessage = + error instanceof Error + ? error.message + : "An unknown error occurred"; + setErrors(errorMessage); + toast({ + variant: "destructive", + title: "Broadcast Failed", + description: errorMessage, + }); + setSigning(false); + }, + }); + } catch (err) { + console.error("Signing/broadcasting failed:", err); + setSigning(false); + const errorMessage = + err instanceof Error ? err.message : "Transaction failed"; + setErrors(errorMessage); + toast({ + variant: "destructive", + title: "Transaction Failed", + description: errorMessage, + }); + } + }; + if (isPending) { return ; } @@ -190,25 +401,48 @@ export function StakingTransactionForm({

    Adamik has converted your intent into a blockchain transaction.
    - Review your transaction details before signing + Review your transaction details before signing. After signing, the + transaction will be automatically broadcasted.

    - +
    + +
    + {errors && ( +
    + {errors} +
    + )} - - + + Show unsigned transaction -