diff --git a/apps/demo-identity-app/src/App.tsx b/apps/demo-identity-app/src/App.tsx index 8d30e72..bf8b419 100644 --- a/apps/demo-identity-app/src/App.tsx +++ b/apps/demo-identity-app/src/App.tsx @@ -19,6 +19,7 @@ import { VerifyButton } from "./components/VerifyButton" import { IdentityCard } from "./components/IdentityCard" import { SigningModal } from "./components/SigningModal" import { ClaimButton } from "./components/ClaimButton" +import { WalletLinkWidget } from "./components/WalletLinkWidget" const tamaguiConfig = createTamagui(config) @@ -263,6 +264,14 @@ const App: React.FC = () => { )} + {/* --- NEW WALLET LINK WIDGET SECTION --- */} + {isConnected && ( + + + + )} + {/* -------------------------------------- */} + setIsSigningModalOpen(false)} diff --git a/apps/demo-identity-app/src/components/WalletLinkWidget.tsx b/apps/demo-identity-app/src/components/WalletLinkWidget.tsx new file mode 100644 index 0000000..c35a210 --- /dev/null +++ b/apps/demo-identity-app/src/components/WalletLinkWidget.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { useWalletLink } from "@goodsdks/react-hooks"; +import { Address, isAddress } from "viem"; + +export const WalletLinkWidget = () => { + const [targetAddress, setTargetAddress] = useState(""); + const { connectAccount, disconnectAccount, connectedStatus } = useWalletLink("development", targetAddress as Address); + + const handleConnect = async () => { + if (!targetAddress) return; + if (!isAddress(targetAddress)) { + alert("Invalid Ethereum Address format!"); + return; + } + try { + await connectAccount.connect(targetAddress as Address); + connectedStatus.refetch(); + } catch (err) { + console.error("Connect failed", err); + } + }; + + const handleDisconnect = async () => { + if (!targetAddress) return; + if (!isAddress(targetAddress)) { + alert("Invalid Ethereum Address format!"); + return; + } + try { + await disconnectAccount.disconnect(targetAddress as Address); + connectedStatus.refetch(); + } catch (err) { + console.error("Disconnect failed", err); + } + }; + + const handleCheckStatus = () => { + if (!targetAddress) return; + if (!isAddress(targetAddress)) { + alert("Invalid Ethereum Address format!"); + return; + } + connectedStatus.refetch(); + }; + + if (connectAccount.pendingSecurityConfirm || disconnectAccount.pendingSecurityConfirm) { + const pending = connectAccount.pendingSecurityConfirm || disconnectAccount.pendingSecurityConfirm; + const confirmFn = connectAccount.pendingSecurityConfirm + ? connectAccount.confirmSecurity + : disconnectAccount.confirmSecurity; + + return ( +
+

⚠️ Security Notice

+
{pending?.message}
+
+ + +
+
+ ); + } + + return ( +
+

🔗 Wallet Link (Citizen SDK)

+ +
+ setTargetAddress(e.target.value)} + style={{ width: "100%", padding: "8px", marginBottom: "10px" }} + /> +
+ + + +
+
+ + {(connectAccount.error || disconnectAccount.error) && ( +

Error: {connectAccount.error || disconnectAccount.error}

+ )} + + {(connectAccount.txHash || disconnectAccount.txHash) && ( +

Tx Hash: {connectAccount.txHash || disconnectAccount.txHash}

+ )} + +
+

Status for {targetAddress || "..."}

+ {connectedStatus.loading ? ( +

Loading status...

+ ) : ( + <> +

Connected (Current Chain): {connectedStatus.status?.isConnected ? "✅ Yes" : "❌ No"}

+

Root Identity: {connectedStatus.status?.root || "None"}

+ +
+ Multi-Chain Statuses +
    + {connectedStatus.allChainStatuses.map(chain => ( +
  • + {chain.chainName}: {chain.isConnected ? `✅ (Root: ${chain.root})` : "❌"} +
  • + ))} +
+
+ + )} +
+
+ ); +}; \ No newline at end of file diff --git a/apps/demo-identity-app/src/main.tsx b/apps/demo-identity-app/src/main.tsx index 1e9594c..8bcb1ec 100644 --- a/apps/demo-identity-app/src/main.tsx +++ b/apps/demo-identity-app/src/main.tsx @@ -1,6 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { AppKitProvider } from "@/config"; +import { AppKitProvider } from "./config"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; import "./index.css"; diff --git a/packages/citizen-sdk/src/constants.ts b/packages/citizen-sdk/src/constants.ts index cfc8897..371a4fd 100644 --- a/packages/citizen-sdk/src/constants.ts +++ b/packages/citizen-sdk/src/constants.ts @@ -146,12 +146,18 @@ export const chainConfigs: Record = { label: "XDC Network", shortName: "XDC", explorer: makeExplorer("https://xdcscan.com"), - // rpcUrls: ["https://rpc.xdc.network", "https://rpc.ankr.com/xdc"], rpcUrls: ["https://rpc.ankr.com/xdc"], defaultGasPrice: BigInt(12.5e9), claimGasBuffer: 150000n, fvDefaultChain: SupportedChains.XDC, contracts: { + // Production identity contract sourced from reference-assets connect-a-wallet example + production: { + identityContract: "0x27a4a02C9ed591E1a86e2e5D05870292c34622C9", + ubiContract: "0x0000000000000000000000000000000000000000", // TODO: add when deployed + faucetContract: "0x0000000000000000000000000000000000000000", // TODO: add when deployed + g$Contract: "0x0000000000000000000000000000000000000000", // TODO: add when deployed + }, development: { identityContract: "0xa6632e9551A340E8582cc797017fbA645695E29f", ubiContract: "0xA2619D468EfE2f6D8b3D915B999327C8fE13aE2c", @@ -189,12 +195,29 @@ export const createRpcUrlIterator = (chainId: SupportedChains) => { } } +/** + * Core identity ABI — includes both read/write identity methods + * and IdentityV4 wallet-link methods (connectAccount, disconnectAccount, connectedAccounts). + */ export const identityV2ABI = parseAbi([ + // Whitelist management (IdentityV2) "function addWhitelisted(address account)", "function removeWhitelisted(address account)", "function getWhitelistedRoot(address account) view returns (address)", "function lastAuthenticated(address account) view returns (uint256)", "function authenticationPeriod() view returns (uint256)", + // Wallet-link methods (IdentityV4) + "function connectAccount(address account) external", + "function disconnectAccount(address account) external", + "function connectedAccounts(address account) view returns (address)", +]) + +/** Alias exported for clarity when consumers only need wallet-link methods. */ +export const walletLinkABI = parseAbi([ + "function connectAccount(address account) external", + "function disconnectAccount(address account) external", + "function connectedAccounts(address account) view returns (address)", + "function getWhitelistedRoot(address account) view returns (address)", ]) // ABI for the UBISchemeV2 contract for essential functions and events @@ -217,3 +240,26 @@ export const faucetABI = parseAbi([ export const g$ABI = parseAbi([ "function balanceOf(address account) view returns (uint256)", ]) + +/** + * Security messages shown to end-users before wallet-link actions. + * Wallet integrators can opt out by passing `skipSecurityMessage: true` in WalletLinkOptions. + */ +export const WALLET_LINK_SECURITY_MESSAGES = { + connect: [ + "SECURITY NOTICE — Connect Wallet", + "You are about to link a secondary wallet to your GoodDollar identity.", + "• Only connect wallets you own and control.", + "• All connected wallets share a single daily UBI claim.", + "• The whitelisted root identity is the only account that can connect a wallet.", + "Do not proceed if you did not initiate this action.", + ].join("\n"), + + disconnect: [ + "SECURITY NOTICE — Disconnect Wallet", + "You are about to remove a wallet from your GoodDollar identity.", + "• Once disconnected the wallet no longer shares your UBI claim.", + "• Either the root identity or the connected account itself can disconnect.", + "Do not proceed if you did not initiate this action.", + ].join("\n"), +} as const \ No newline at end of file diff --git a/packages/citizen-sdk/src/index.ts b/packages/citizen-sdk/src/index.ts index c096ba9..d6797fe 100644 --- a/packages/citizen-sdk/src/index.ts +++ b/packages/citizen-sdk/src/index.ts @@ -1,3 +1,13 @@ export * from "./sdks" export * from "./constants" export * from "./utils/triggerFaucet" + + +export type { + IdentityExpiry, + IdentityExpiryData, + IdentityContract, + WalletLinkOptions, + ConnectedAccountStatus, + ChainConnectedStatus +} from "./types" \ No newline at end of file diff --git a/packages/citizen-sdk/src/sdks/viem-identity-sdk.ts b/packages/citizen-sdk/src/sdks/viem-identity-sdk.ts index 7877c5f..49e2129 100644 --- a/packages/citizen-sdk/src/sdks/viem-identity-sdk.ts +++ b/packages/citizen-sdk/src/sdks/viem-identity-sdk.ts @@ -7,6 +7,8 @@ import { SimulateContractParameters, WalletActions, zeroAddress, + createPublicClient, + http, } from "viem" import { waitForTransactionReceipt } from "viem/actions" @@ -20,6 +22,7 @@ import { identityV2ABI, SupportedChains, isSupportedChain, + WALLET_LINK_SECURITY_MESSAGES, } from "../constants" import { resolveChainAndContract } from "../utils/chains" @@ -28,14 +31,11 @@ import type { IdentityContract, IdentityExpiryData, IdentityExpiry, + WalletLinkOptions, + ConnectedAccountStatus, + ChainConnectedStatus, } from "../types" -/** - * Initializes the Identity Contract. - * @param publicClient - The PublicClient instance. - * @param contractAddress - The contract address. - * @returns An IdentityContract instance. - */ export const initializeIdentityContract = ( publicClient: PublicClient, contractAddress: Address, @@ -51,9 +51,6 @@ export interface IdentitySDKOptions { env: contractEnv } -/** - * Handles interactions with the Identity Contract. - */ export class IdentitySDK { public account: Address publicClient: PublicClient @@ -62,13 +59,10 @@ export class IdentitySDK { public env: contractEnv = "production" private readonly chainId: SupportedChains private readonly fvDefaultChain: SupportedChains + + // Cache for cross-chain public clients + private publicClientCache: Map = new Map() - /** - * Initializes the IdentitySDK. - * @param publicClient - The PublicClient instance. - * @param walletClient - The WalletClient with WalletActions. - * @param env - The environment to use ("production" | "staging" | "development"). - */ constructor({ account, publicClient, @@ -106,13 +100,40 @@ export class IdentitySDK { return new IdentitySDK({ account, ...props }) } - /** - * Submits a transaction and waits for its receipt. - * @param params - Parameters for simulating the contract call. - * @param onHash - Optional callback to receive the transaction hash. - * @returns The transaction receipt. - * @throws If submission fails or no active wallet address is found. - */ + private getOrCreatePublicClient(config: any): PublicClient { + const key = `${config.id}:${this.env}` + const cached = this.publicClientCache.get(key) + if (cached) return cached + + const publicClient = createPublicClient({ + transport: http(config.rpcUrls[0]), + }) + + this.publicClientCache.set(key, publicClient) + return publicClient + } + + private async runSecurityCheck( + action: keyof typeof WALLET_LINK_SECURITY_MESSAGES, + options?: WalletLinkOptions, + ): Promise { + if (options?.skipSecurityMessage) return + + const message = WALLET_LINK_SECURITY_MESSAGES[action] + + if (options?.onSecurityMessage) { + const confirmed = await options.onSecurityMessage(message) + if (!confirmed) { + throw new Error( + `Wallet ${action} cancelled: user did not confirm security notice.`, + ) + } + return + } + + console.info(`[IdentitySDK] ${message}`) + } + async submitAndWait( params: SimulateContractParameters, onHash?: (hash: `0x${string}`) => void, @@ -130,17 +151,12 @@ export class IdentitySDK { return waitForTransactionReceipt(this.publicClient, { hash }) } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) console.error("submitAndWait Error:", error) - throw new Error(`Failed to submit transaction: ${error.message}`) + throw new Error(`Failed to submit transaction: ${message}`) } } - /** - * Returns whitelist status of main account or any connected account. - * @param account - The account address to check. - * @returns An object containing whitelist status and root address. - * @reference: https://docs.gooddollar.org/user-guides/connect-another-wallet-address-to-identity - */ async getWhitelistedRoot( account: Address, ): Promise<{ isWhitelisted: boolean; root: Address }> { @@ -157,16 +173,87 @@ export class IdentitySDK { root, } } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) console.error("getWhitelistedRoot Error:", error) - throw new Error(`Failed to get whitelisted root: ${error.message}`) + throw new Error(`Failed to get whitelisted root: ${message}`) } } - /** - * Retrieves identity expiry data for a given account. - * @param account - The account address. - * @returns The identity expiry data. - */ + async getConnectedAccounts(account: Address): Promise { + try { + const root = await this.publicClient.readContract({ + address: this.contract.contractAddress, + abi: identityV2ABI, + functionName: "connectedAccounts", + args: [account], + }) as Address + + return { + isConnected: root !== zeroAddress, + root, + } + } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) + console.error("getConnectedAccounts Error:", error) + throw new Error(`Failed to get connected accounts: ${message}`) + } + } + + async isAccountConnected(account: Address): Promise { + const { isConnected } = await this.getConnectedAccounts(account) + return isConnected + } + + async checkConnectedStatusAllChains( + account: Address, + ): Promise { + const entries = Object.values(chainConfigs) + + const settled = await Promise.allSettled( + entries.map(async (config) => { + const contracts = config.contracts[this.env] + + if (!contracts) { + return { + chainId: config.id, + chainName: config.label, + isConnected: false, + root: zeroAddress as Address, + error: `No contract configured for env "${this.env}" on ${config.label}`, + } satisfies ChainConnectedStatus + } + + const client = this.getOrCreatePublicClient(config) + + const root = (await client.readContract({ + address: contracts.identityContract, + abi: identityV2ABI, + functionName: "connectedAccounts", + args: [account], + })) as Address + + return { + chainId: config.id, + chainName: config.label, + isConnected: root !== zeroAddress, + root, + } satisfies ChainConnectedStatus + }), + ) + + return settled.map((result, i) => { + const config = entries[i] + if (result.status === "fulfilled") return result.value + return { + chainId: config.id, + chainName: config.label, + isConnected: false, + root: zeroAddress as Address, + error: (result.reason as Error)?.message ?? "Unknown RPC error", + } + }) + } + async getIdentityExpiryData(account: Address): Promise { try { const [lastAuthenticated, authPeriod] = await Promise.all([ @@ -186,20 +273,60 @@ export class IdentitySDK { return { lastAuthenticated, authPeriod } } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) console.error("getIdentityExpiryData Error:", error) throw new Error( - `Failed to retrieve identity expiry data: ${error.message}`, + `Failed to retrieve identity expiry data: ${message}`, + ) + } + } + + async connectAccount( + account: Address, + options?: WalletLinkOptions, + ): Promise { + try { + await this.runSecurityCheck("connect", options) + + return this.submitAndWait( + { + address: this.contract.contractAddress, + abi: identityV2ABI, + functionName: "connectAccount", + args: [account], + }, + options?.onHash, ) + } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) + console.error("connectAccount Error:", error) + throw new Error(`Failed to connect account: ${message}`) + } + } + + async disconnectAccount( + account: Address, + options?: WalletLinkOptions, + ): Promise { + try { + await this.runSecurityCheck("disconnect", options) + + return this.submitAndWait( + { + address: this.contract.contractAddress, + abi: identityV2ABI, + functionName: "disconnectAccount", + args: [account], + }, + options?.onHash, + ) + } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) + console.error("disconnectAccount Error:", error) + throw new Error(`Failed to disconnect account: ${message}`) } } - /** - * Generates a Face Verification Link. - * @param popupMode - Whether to generate a popup link. - * @param callbackUrl - The URL to callback after verification. - * @param chainId - The blockchain network ID. - * @returns The generated Face Verification link. - */ async generateFVLink( popupMode: boolean = false, callbackUrl?: string, @@ -255,19 +382,14 @@ export class IdentitySDK { ) return url.toString() } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) console.error("generateFVLink Error:", error) throw new Error( - `Failed to generate Face Verification link: ${error.message}`, + `Failed to generate Face Verification link: ${message}`, ) } } - /** - * Calculates the identity expiry timestamp. - * @param lastAuthenticated - The timestamp of last authentication. - * @param authPeriod - The authentication period. - * @returns The identity expiry data. - */ calculateIdentityExpiry( lastAuthenticated: bigint, authPeriod: bigint, @@ -279,8 +401,6 @@ export class IdentitySDK { const expiryTimestamp = lastAuthenticated * BigInt(MS_IN_A_SECOND) + periodInMs - return { - expiryTimestamp, - } + return { expiryTimestamp } } -} +} \ No newline at end of file diff --git a/packages/citizen-sdk/src/types.ts b/packages/citizen-sdk/src/types.ts index e6d866a..a41d4b2 100644 --- a/packages/citizen-sdk/src/types.ts +++ b/packages/citizen-sdk/src/types.ts @@ -1,18 +1,18 @@ -import { Address, PublicClient } from "viem"; +import { Address, PublicClient } from "viem" export interface IdentityExpiry { - expiryTimestamp: bigint; - formattedExpiryTimestamp?: string; + expiryTimestamp: bigint + formattedExpiryTimestamp?: string } export interface IdentityExpiryData { - lastAuthenticated: bigint; - authPeriod: bigint; + lastAuthenticated: bigint + authPeriod: bigint } export interface IdentityContract { - publicClient: PublicClient; - contractAddress: Address; + publicClient: PublicClient + contractAddress: Address } export interface WalletClaimStatus { @@ -22,3 +22,22 @@ export interface WalletClaimStatus { } export type SupportedChains = 42220 | 122 | 50 + +export interface WalletLinkOptions { + skipSecurityMessage?: boolean + onSecurityMessage?: (message: string) => Promise + onHash?: (hash: `0x${string}`) => void +} + +export interface ConnectedAccountStatus { + isConnected: boolean + root: Address +} + +export interface ChainConnectedStatus { + chainId: number + chainName: string + isConnected: boolean + root: Address + error?: string +} \ No newline at end of file diff --git a/packages/citizen-sdk/test/wallet-link.test.ts b/packages/citizen-sdk/test/wallet-link.test.ts new file mode 100644 index 0000000..2ae02bc --- /dev/null +++ b/packages/citizen-sdk/test/wallet-link.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { zeroAddress } from "viem" +import { IdentitySDK } from "../src/sdks/viem-identity-sdk" + +const MOCK_ROOT_ACCOUNT = "0x1111111111111111111111111111111111111111" +const MOCK_CHILD_ACCOUNT = "0x2222222222222222222222222222222222222222" + +describe("IdentitySDK - Wallet Link Flows (Mocked)", () => { + let publicClient: any + let walletClient: any + let sdk: IdentitySDK + + beforeEach(() => { + publicClient = { + readContract: vi.fn(), + simulateContract: vi.fn().mockResolvedValue({ request: {} }), + } + + walletClient = { + account: { address: MOCK_ROOT_ACCOUNT }, + getAddresses: vi.fn().mockResolvedValue([MOCK_ROOT_ACCOUNT]), + writeContract: vi.fn().mockResolvedValue("0xMockTxHash"), + } + + sdk = new IdentitySDK({ + publicClient, + walletClient, + env: "development", + }) + + sdk.submitAndWait = vi.fn().mockResolvedValue({ transactionHash: "0xMockTxHash" }) + }) + + describe("Read Paths: connectedAccounts & checkConnectedStatusAllChains", () => { + it("getConnectedAccounts should return isConnected=true and root for a child account", async () => { + publicClient.readContract.mockResolvedValueOnce(MOCK_ROOT_ACCOUNT) + const status = await sdk.getConnectedAccounts(MOCK_CHILD_ACCOUNT) + expect(publicClient.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "connectedAccounts", + args: [MOCK_CHILD_ACCOUNT], + }) + ) + expect(status.isConnected).toBe(true) + expect(status.root).toBe(MOCK_ROOT_ACCOUNT) + }) + + it("getConnectedAccounts should return isConnected=false for unknown account", async () => { + publicClient.readContract.mockResolvedValueOnce(zeroAddress) + const status = await sdk.getConnectedAccounts(MOCK_CHILD_ACCOUNT) + expect(status.isConnected).toBe(false) + expect(status.root).toBe(zeroAddress) + }) + + it("isAccountConnected convenience boolean works", async () => { + publicClient.readContract.mockResolvedValueOnce(MOCK_ROOT_ACCOUNT) + const isConnected = await sdk.isAccountConnected(MOCK_CHILD_ACCOUNT) + expect(isConnected).toBe(true) + }) + + it("checkConnectedStatusAllChains should return status entries per chain and set isConnected/root correctly", async () => { + publicClient.readContract + .mockResolvedValueOnce(MOCK_ROOT_ACCOUNT) + .mockResolvedValueOnce(zeroAddress) + + const result = await sdk.checkConnectedStatusAllChains(MOCK_CHILD_ACCOUNT) + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBeGreaterThanOrEqual(2) + + const connectedEntry = result.find((entry) => entry.isConnected) + const disconnectedEntry = result.find((entry) => !entry.isConnected) + + expect(connectedEntry).toEqual( + expect.objectContaining({ + isConnected: true, + root: MOCK_ROOT_ACCOUNT, + }), + ) + + expect(disconnectedEntry).toEqual( + expect.objectContaining({ + isConnected: false, + root: zeroAddress, + }), + ) + }) + + it("checkConnectedStatusAllChains should surface readContract rejections in the error field", async () => { + const readError = new Error("read failed") + publicClient.readContract + .mockResolvedValueOnce(MOCK_ROOT_ACCOUNT) + .mockRejectedValueOnce(readError) + + const result = await sdk.checkConnectedStatusAllChains(MOCK_CHILD_ACCOUNT) + const erroredEntry = result.find((entry) => entry.error) + + expect(erroredEntry).toEqual( + expect.objectContaining({ + isConnected: false, + error: expect.stringContaining("read failed"), + }), + ) + }) + + it("getConnectedAccounts should wrap readContract errors with a helpful message", async () => { + const underlyingError = new Error("boom") + publicClient.readContract.mockRejectedValueOnce(underlyingError) + + await expect(sdk.getConnectedAccounts(MOCK_CHILD_ACCOUNT)).rejects.toThrow( + "Failed to get connected accounts: boom", + ) + }) + }) + + describe("Write Paths: connectAccount & disconnectAccount", () => { + it("connectAccount executes custom onSecurityMessage and submits when true", async () => { + const onSecurityMessage = vi.fn().mockResolvedValue(true) + await sdk.connectAccount(MOCK_CHILD_ACCOUNT, { onSecurityMessage }) + + expect(onSecurityMessage).toHaveBeenCalledTimes(1) + expect(onSecurityMessage).toHaveBeenCalledWith(expect.stringMatching(/connect/i)) + + expect(sdk.submitAndWait).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "connectAccount", + args: [MOCK_CHILD_ACCOUNT], + }), + undefined + ) + }) + + it("disconnectAccount suppresses security message when skipSecurityMessage=true", async () => { + const onSecurityMessage = vi.fn() + await sdk.disconnectAccount(MOCK_CHILD_ACCOUNT, { + skipSecurityMessage: true, + onSecurityMessage, + }) + + expect(onSecurityMessage).not.toHaveBeenCalled() + expect(sdk.submitAndWait).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "disconnectAccount", + args: [MOCK_CHILD_ACCOUNT], + }), + undefined + ) + }) + + it("disconnectAccount throws and does not submit when onSecurityMessage resolves to false", async () => { + const onSecurityMessage = vi.fn().mockResolvedValue(false) + + await expect( + sdk.disconnectAccount(MOCK_CHILD_ACCOUNT, { onSecurityMessage }) + ).rejects.toMatchObject({ + name: expect.stringMatching(/Error/i), + }) + + expect(onSecurityMessage).toHaveBeenCalledTimes(1) + expect(sdk.submitAndWait).not.toHaveBeenCalled() + }) + }) +}) \ No newline at end of file diff --git a/packages/react-hooks/src/citizen-sdk/wagmi-identity-sdk.ts b/packages/react-hooks/src/citizen-sdk/wagmi-identity-sdk.ts index c4bcccd..ed4fec4 100644 --- a/packages/react-hooks/src/citizen-sdk/wagmi-identity-sdk.ts +++ b/packages/react-hooks/src/citizen-sdk/wagmi-identity-sdk.ts @@ -1,8 +1,13 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useCallback } from "react" import { usePublicClient, useWalletClient } from "wagmi" -import { PublicClient } from "viem" +import { PublicClient, Address } from "viem" import { contractEnv, IdentitySDK } from "@goodsdks/citizen-sdk" +import type { + WalletLinkOptions, + ConnectedAccountStatus, + ChainConnectedStatus, +} from "@goodsdks/citizen-sdk" export const useIdentitySDK = ( env: contractEnv = "production", @@ -40,3 +45,202 @@ export const useIdentitySDK = ( return { sdk, loading, error } } + + + +type WalletLinkAction = (sdk: IdentitySDK, account: Address, options?: WalletLinkOptions) => Promise + +interface UseWalletLinkActionReturn { + run: (account: Address, options?: WalletLinkOptions) => Promise + loading: boolean + error: string | null + txHash: `0x${string}` | null + pendingSecurityConfirm: { message: string; resolve: (confirmed: boolean) => void } | null + confirmSecurity: (confirmed: boolean) => void + reset: () => void +} + +const useWalletLinkAction = ( + sdk: IdentitySDK | null, + action: WalletLinkAction, +): UseWalletLinkActionReturn => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [txHash, setTxHash] = useState<`0x${string}` | null>(null) + const [pendingSecurityConfirm, setPendingSecurityConfirm] = useState<{ + message: string + resolve: (confirmed: boolean) => void + } | null>(null) + + const reset = useCallback(() => { + setLoading(false) + setError(null) + setTxHash(null) + setPendingSecurityConfirm(null) + }, []) + + const confirmSecurity = useCallback( + (confirmed: boolean) => { + pendingSecurityConfirm?.resolve(confirmed) + setPendingSecurityConfirm(null) + }, + [pendingSecurityConfirm], + ) + + const run = useCallback( + async (account: Address, options?: WalletLinkOptions) => { + if (!sdk) { + setError("IdentitySDK not initialized") + return + } + + setLoading(true) + setError(null) + setTxHash(null) + + try { + await action(sdk, account, { + ...options, + onHash: (hash) => { + setTxHash(hash) + options?.onHash?.(hash) + }, + onSecurityMessage: + options?.onSecurityMessage ?? + (options?.skipSecurityMessage + ? undefined + : (message) => + new Promise((resolve) => { + setPendingSecurityConfirm({ message, resolve }) + })), + }) + } catch (err: any) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, + [sdk, action], + ) + + return { + run, + loading, + error, + txHash, + pendingSecurityConfirm, + confirmSecurity, + reset, + } +} + + +export interface UseConnectAccountReturn extends Omit { + connect: UseWalletLinkActionReturn["run"] + pendingSecurityConfirm: { message: string } | null +} + +export const useConnectAccount = (sdk: IdentitySDK | null): UseConnectAccountReturn => { + const base = useWalletLinkAction(sdk, (s, account, options) => + s.connectAccount(account, options), + ) + return { + ...base, + connect: base.run, + pendingSecurityConfirm: base.pendingSecurityConfirm ? { message: base.pendingSecurityConfirm.message } : null + } +} + +export interface UseDisconnectAccountReturn extends Omit { + disconnect: UseWalletLinkActionReturn["run"] + pendingSecurityConfirm: { message: string } | null +} + +export const useDisconnectAccount = (sdk: IdentitySDK | null): UseDisconnectAccountReturn => { + const base = useWalletLinkAction(sdk, (s, account, options) => + s.disconnectAccount(account, options), + ) + return { + ...base, + disconnect: base.run, + pendingSecurityConfirm: base.pendingSecurityConfirm ? { message: base.pendingSecurityConfirm.message } : null + } +} + +export interface UseConnectedStatusReturn { + status: ConnectedAccountStatus | null + allChainStatuses: ChainConnectedStatus[] + loading: boolean + error: string | null + refetch: () => void +} + +export const useConnectedStatus = ( + sdk: IdentitySDK | null, + account: Address | undefined, +): UseConnectedStatusReturn => { + const [status, setStatus] = useState(null) + const [allChainStatuses, setAllChainStatuses] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [trigger, setTrigger] = useState(0) + + const refetch = useCallback(() => setTrigger((n) => n + 1), []) + + useEffect(() => { + if (!sdk || !account) { + setStatus(null) + setAllChainStatuses([]) + return + } + + let cancelled = false + setLoading(true) + setError(null) + + Promise.all([ + sdk.getConnectedAccounts(account), + sdk.checkConnectedStatusAllChains(account), + ]) + .then(([singleChain, allChains]) => { + if (cancelled) return + setStatus(singleChain) + setAllChainStatuses(allChains) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [sdk, account, trigger]) + + return { status, allChainStatuses, loading, error, refetch } +} + +export interface UseWalletLinkReturn { + sdk: IdentitySDK | null + sdkLoading: boolean + sdkError: string | null + connectAccount: UseConnectAccountReturn + disconnectAccount: UseDisconnectAccountReturn + connectedStatus: UseConnectedStatusReturn +} + +export const useWalletLink = ( + env: contractEnv = "production", + watchAccount?: Address, +): UseWalletLinkReturn => { + const { sdk, loading, error } = useIdentitySDK(env) + + const connectAccount = useConnectAccount(sdk) + const disconnectAccount = useDisconnectAccount(sdk) + const connectedStatus = useConnectedStatus(sdk, watchAccount) + + return { sdk, sdkLoading: loading, sdkError: error, connectAccount, disconnectAccount, connectedStatus } +} \ No newline at end of file