laconic-deployer-frontend/docs/architecture/wallet_migration/1-phase-1-wallet-core.md

14 KiB

Phase 1: Wallet Core Package Implementation

Overview

Phase 1 establishes the foundation by creating the services/wallet-core package that will replace the iframe-based wallet implementation. This package will contain all core wallet functionality without UI components.

Timeline

Duration: 2 weeks Dependencies: None Team: Backend/Crypto team

Directory Structure

graph TD
    A[services/wallet-core] --> B[src/]
    B --> C[accounts/]
    B --> D[networks/]
    B --> E[storage/]
    B --> F[crypto/]
    B --> G[actions/]
    B --> H[hooks/]
    B --> I[types/]
    
    C --> C1[accounts.ts]
    C --> C2[accountsContext.ts]
    
    D --> D1[networks.ts]
    D --> D2[networksContext.ts]
    D --> D3[constants.ts]
    
    E --> E1[keystore.ts]
    E --> E2[localStorage.ts]
    E --> E3[sessionStorage.ts]
    
    F --> F1[eth.ts]
    F --> F2[cosmos.ts]
    F --> F3[signing.ts]
    
    G --> G1[walletActions.ts]
    
    H --> H1[useWallet.ts]
    H --> H2[useNetwork.ts]
    H --> H3[useAccounts.ts]
    
    I --> I1[accounts.d.ts]
    I --> I2[networks.d.ts]
    I --> I3[storage.d.ts]

Step-by-Step Implementation

1. Set up services/wallet-core package

mkdir -p services/wallet-core/src
cd services/wallet-core

Create package.json:

{
  "name": "@workspace/wallet-core",
  "version": "0.1.0",
  "private": true,
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "sideEffects": false,
  "license": "MIT",
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "dev": "tsup src/index.ts --format esm,cjs --watch --dts",
    "lint": "eslint src/",
    "clean": "rm -rf .turbo dist node_modules",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@cosmjs/proto-signing": "^0.31.1",
    "@cosmjs/stargate": "^0.31.1",
    "ethers": "^6.8.1",
    "siwe": "^2.1.4",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@types/node": "^20.5.2",
    "@types/react": "^18.2.0",
    "@workspace/typescript-config": "*",
    "eslint": "^8.46.0",
    "typescript": "^5.1.6",
    "tsup": "^7.2.0"
  },
  "peerDependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0"
  }
}

Create tsconfig.json:

{
  "extends": "@workspace/typescript-config/react-library.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src", "../../services/ui/tailwind.config.ts"],
  "exclude": ["node_modules", "dist"]
}

2. Implement core types and validation schemas

Create src/types/accounts.d.ts:

export interface Account {
  index: number;
  address: string;
  hdPath: string;
  pubKey: string;
}

export interface WalletState {
  accounts: Account[];
  currentIndex: number;
  isConnected: boolean;
  isReady: boolean;
}

// Validation schemas (using Zod)
import { z } from 'zod';

// Pattern for valid Ethereum address
const ethereumAddressPattern = /^0x[a-fA-F0-9]{40}$/;
// Pattern for valid Cosmos address (using laconic prefix)
const cosmosAddressPattern = /^laconic[a-zA-Z0-9]{39,59}$/;

export const accountSchema = z.object({
  index: z.number().int().nonnegative(),
  address: z.string().refine(
    val => ethereumAddressPattern.test(val) || cosmosAddressPattern.test(val),
    { message: "Invalid wallet address format" }
  ),
  hdPath: z.string(),
  pubKey: z.string()
});

Create src/types/networks.d.ts:

export interface NetworksFormData {
  chainId: string;
  networkName: string;
  namespace: string;
  rpcUrl: string;
  blockExplorerUrl: string;
  nativeDenom?: string;
  addressPrefix?: string;
  coinType: string;
  gasPrice?: string;
  isDefault?: boolean;
  currencySymbol?: string;
}

export interface NetworksDataState extends NetworksFormData {
  networkId: string;
}

export interface NetworkState {
  networks: NetworksDataState[];
  selectedNetwork?: NetworksDataState;
  networkType: string;
}

Create src/types/index.ts:

export * from './accounts';
export * from './networks';
export * from './storage';

3. Implement storage adapters

Create src/storage/keystore.ts:

// A next.js-friendly implementation of the key store
// ⚠️ SECURITY WARNING: This implementation is for demonstration purposes only
// Storing sensitive wallet data including private keys in browser storage can be a security risk
// For production, consider using a secure hardware wallet or a dedicated wallet provider

// In place of localStorage in browser environments
export function setInternetCredentials(name: string, username: string, password: string): void {
  if (typeof window !== 'undefined') {
    sessionStorage.setItem(name, password);
  }
}

export function getInternetCredentials(name: string): string | null {
  if (typeof window !== 'undefined') {
    return sessionStorage.getItem(name);
  }
  return null;
}

export function resetInternetCredentials(name: string): void {
  if (typeof window !== 'undefined') {
    sessionStorage.removeItem(name);
  }
}

// Helper to encrypt sensitive data
// In a production environment, consider using the Web Crypto API or a dedicated encryption library
export function encryptSensitiveData(data: string): string {
  // Implement proper encryption in production
  // This is a placeholder to emphasize sensitive data should be encrypted
  return data; 
}

4. Implement networks functionality

Create src/networks/constants.ts:

import { NetworksFormData } from '../types';

export const EIP155 = 'eip155';
export const COSMOS = 'cosmos';

export const DEFAULT_NETWORKS: NetworksFormData[] = [
  {
    chainId: 'laconic-testnet-2',
    networkName: 'laconicd testnet-2',
    namespace: COSMOS,
    rpcUrl: process.env.NEXT_PUBLIC_LACONICD_RPC_URL!,
    blockExplorerUrl: '',
    nativeDenom: 'alnt',
    addressPrefix: 'laconic',
    coinType: '118',
    gasPrice: '0.001',
    isDefault: true,
  },
  {
    chainId: '1',
    networkName: 'Ethereum Mainnet',
    namespace: EIP155,
    rpcUrl: 'https://mainnet.infura.io/v3/your-key',
    blockExplorerUrl: '',
    currencySymbol: 'ETH',
    coinType: '60',
    isDefault: true,
  },
];

Create src/networks/networks.ts:

import { NetworksDataState, NetworksFormData } from '../types';
import { getInternetCredentials, setInternetCredentials } from '../storage/keystore';
import { DEFAULT_NETWORKS } from './constants';

export async function retrieveNetworksData(): Promise<NetworksDataState[]> {
  console.log("Retrieving networks data");
  const networks = await getInternetCredentials('networks');

  if(!networks){
    console.log("No networks found in credentials, using DEFAULT_NETWORKS");
    
    // Convert NetworksFormData to NetworksDataState by adding networkId
    const defaultNetworksWithId = DEFAULT_NETWORKS.map((network, index) => ({
      ...network,
      networkId: String(index)
    }));
    
    // Store default networks in credentials
    await setInternetCredentials(
      'networks',
      '_',
      JSON.stringify(defaultNetworksWithId)
    );
    
    return defaultNetworksWithId;
  }
  
  console.log("Networks found in credentials");
  const parsedNetworks: NetworksDataState[] = JSON.parse(networks);
  
  return parsedNetworks;
}

export async function storeNetworkData(
  networkData: NetworksFormData,
): Promise<NetworksDataState[]> {
  const networks = await getInternetCredentials('networks');
  let retrievedNetworks = [];
  if (networks) {
    retrievedNetworks = JSON.parse(networks!);
  }
  let networkId = 0;
  if (retrievedNetworks.length > 0) {
    networkId = retrievedNetworks[retrievedNetworks.length - 1].networkId + 1;
  }

  const updatedNetworks: NetworksDataState[] = [
    ...retrievedNetworks,
    {
      ...networkData,
      networkId: String(networkId),
    },
  ];
  await setInternetCredentials(
    'networks',
    '_',
    JSON.stringify(updatedNetworks),
  );
  return updatedNetworks;
}

5. Implement crypto functionality

Create src/crypto/signing.ts:

import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing';
import { ethers } from 'ethers';
import { SiweMessage } from 'siwe';

import { getInternetCredentials } from '../storage/keystore';
import { COSMOS, EIP155 } from '../networks/constants';

interface SignMessageParams {
  message: string;
  namespace: string;
  chainId: string;
  accountId: number;
}

export async function signMessage({
  message,
  namespace,
  chainId,
  accountId,
}: SignMessageParams): Promise<string | undefined> {
  const path = await getPathKey(`${namespace}:${chainId}`, accountId);

  switch (namespace) {
    case EIP155:
      return await signEthMessage(message, accountId, chainId);
    case COSMOS:
      return await signCosmosMessage(message, path.path, path.address);
    default:
      throw new Error('Invalid wallet type');
  }
}

async function signEthMessage(
  message: string,
  accountId: number,
  chainId: string,
): Promise<string | undefined> {
  try {
    const privKey = (await getPathKey(`${EIP155}:${chainId}`, accountId))
      .privKey;
    const wallet = new ethers.Wallet(privKey);
    const signature = await wallet.signMessage(message);

    return signature;
  } catch (error) {
    console.error('Error signing Ethereum message:', error);
    throw error;
  }
}

async function signCosmosMessage(
  message: string,
  path: string,
  cosmosAddress: string,
): Promise<string | undefined> {
  // Implementation for Cosmos signing...
  return "cosmos_signature_placeholder";
}

async function getPathKey(
  namespaceChainId: string,
  accountId: number,
): Promise<{
  path: string;
  privKey: string;
  pubKey: string;
  address: string;
}> {
  const pathKeyStore = await getInternetCredentials(
    `accounts/${namespaceChainId}/${accountId}`,
  );

  if (!pathKeyStore) {
    throw new Error('Error while fetching key data');
  }

  const pathKeyVal = pathKeyStore;
  const pathkey = pathKeyVal.split(',');
  const path = pathkey[0];
  const privKey = pathkey[1];
  const pubKey = pathkey[2];
  const address = pathkey[3];

  return { path, privKey, pubKey, address };
}

export async function createSiweMessage(address: string, statement: string = 'Sign in With Ethereum.'): Promise<string> {
  const message = new SiweMessage({
    version: '1',
    domain: typeof window !== 'undefined' ? window.location.host : '',
    uri: typeof window !== 'undefined' ? window.location.origin : '',
    chainId: 1,
    address: address,
    statement,
  }).prepareMessage();
  
  return message;
}

6. Create client hooks

Create src/hooks/useWallet.ts:

import { useCallback, useState } from 'react';
import { createSiweMessage, signMessage } from '../crypto/signing';
import { Account } from '../types';

export function useWallet() {
  const [accounts, setAccounts] = useState<Account[]>([]);
  const [currentIndex, setCurrentIndex] = useState(0);
  const [isConnected, setIsConnected] = useState(false);
  const [isReady, setIsReady] = useState(false);

  const connect = useCallback(async () => {
    // Implementation will come in Phase 2
    console.log('Connect wallet functionality');
  }, []);

  const disconnect = useCallback(() => {
    setAccounts([]);
    setIsConnected(false);
    setIsReady(false);
  }, []);

  const signIn = useCallback(async () => {
    if (!accounts.length || currentIndex >= accounts.length) {
      throw new Error('No account selected');
    }

    const account = accounts[currentIndex];
    const message = await createSiweMessage(account.address);
    
    try {
      // Placeholder for the actual implementation
      console.log('Sign in with wallet');
      return { success: true };
    } catch (error) {
      console.error('Error signing in:', error);
      return { success: false, error };
    }
  }, [accounts, currentIndex]);

  return {
    accounts,
    setAccounts,
    currentIndex,
    setCurrentIndex,
    isConnected,
    setIsConnected,
    isReady,
    setIsReady,
    connect,
    disconnect,
    signIn,
  };
}

7. Implement server actions

Create src/actions/walletActions.ts:

'use server'

import { cookies } from 'next/headers';
import { createSiweMessage } from '../crypto/signing';

export async function validateSignature(message: string, signature: string) {
  // This will be implemented in Phase 2
  // Verifies the signature on the server side
  return { success: true };
}

export async function storeWalletSession(address: string) {
  // Store wallet address in session cookie
  cookies().set('wallet_address', address, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 60 * 60 * 24 * 7, // 1 week
    path: '/',
  });
  
  return { success: true };
}

export async function getWalletSession() {
  return cookies().get('wallet_address')?.value;
}

export async function clearWalletSession() {
  cookies().delete('wallet_address');
  return { success: true };
}

export async function checkBalance(chainId: string, address: string, amount: string) {
  // This will connect to the blockchain RPC and check balance
  // Implementation will come in Phase 2
  
  // Mock implementation for now
  return {
    success: true,
    hasEnoughBalance: true,
    balance: "1000.00" 
  };
}

8. Create package exports

Create src/index.ts:

// Export core functionality
export * from './types';
export * from './networks/constants';
export * from './networks/networks';
export * from './crypto/signing';
export * from './hooks/useWallet';
export * from './storage/keystore';
export * from './actions/walletActions';

Testing

  1. Build the package:
cd services/wallet-core
pnpm build
  1. Test in the frontend app by adding the dependency:
"dependencies": {
  "@workspace/wallet-core": "workspace:*",
  // other dependencies
}

Next Steps

  • Phase 2: Implement UI components in services/ui/wallet
  • Phase 2: Integrate with the Next.js frontend app
  • Phase 3: Complete Clerk auth integration