diff --git a/apps/connect/src/Boot.tsx b/apps/connect/src/Boot.tsx index 493d5ff2..d5aeebec 100644 --- a/apps/connect/src/Boot.tsx +++ b/apps/connect/src/Boot.tsx @@ -18,7 +18,8 @@ declare module '@tanstack/react-router' { const queryClient = new QueryClient(); -const storage = localStorage; +const addressStorage = localStorage; +const keysStorage = sessionStorage; export function Boot() { // check if the user is already authenticated on initial render @@ -26,9 +27,9 @@ export function Boot() { // using a layout effect to avoid a re-render useLayoutEffect(() => { if (!initialRenderAuthCheckRef.current) { - const accountAddress = Connect.loadAccountAddress(storage); + const accountAddress = Connect.loadAccountAddress(addressStorage); if (accountAddress) { - const keys = Connect.loadKeys(storage, accountAddress); + const keys = Connect.loadKeys(keysStorage, accountAddress); if (keys) { // user is already authenticated, set state StoreConnect.store.send({ diff --git a/apps/connect/src/components/LogoutButton.tsx b/apps/connect/src/components/LogoutButton.tsx index 5e19fc65..6b525c18 100644 --- a/apps/connect/src/components/LogoutButton.tsx +++ b/apps/connect/src/components/LogoutButton.tsx @@ -1,4 +1,5 @@ import { Loading } from '@/components/ui/Loading'; +import { Connect } from '@graphprotocol/hypergraph'; import { usePrivy } from '@privy-io/react-auth'; import { useRouter } from '@tanstack/react-router'; import { useState } from 'react'; @@ -9,6 +10,7 @@ export function LogoutButton() { const [isLoading, setIsLoading] = useState(false); const disconnectWallet = async () => { setIsLoading(true); + Connect.wipeAllAuthData(localStorage, sessionStorage); await privyLogout(); router.navigate({ to: '/login', diff --git a/apps/connect/src/routes/authenticate.tsx b/apps/connect/src/routes/authenticate.tsx index 8bebf845..027d6a53 100644 --- a/apps/connect/src/routes/authenticate.tsx +++ b/apps/connect/src/routes/authenticate.tsx @@ -12,6 +12,7 @@ import { Effect, Schema } from 'effect'; import { TriangleAlert } from 'lucide-react'; import { useEffect } from 'react'; import { createWalletClient, custom } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; const CHAIN = import.meta.env.VITE_HYPERGRAPH_CHAIN === 'geogenesis' ? Connect.GEOGENESIS : Connect.GEO_TESTNET; @@ -381,9 +382,11 @@ function AuthenticateComponent() { })) ?? []; console.log('spaces', spaces); + const localAccount = privateKeyToAccount(keys.signaturePrivateKey as `0x${string}`); + console.log('local account', localAccount.address); // TODO: add additional actions (must be passed from the app) const permissionId = await Connect.createSmartSession( - walletClient, + localAccount, accountAddress, newAppIdentity.addressPrivateKey, CHAIN, @@ -396,12 +399,13 @@ function AuthenticateComponent() { ); console.log('smart session created'); const smartAccountClient = await Connect.getSmartAccountWalletClient({ - owner: walletClient, + owner: localAccount, address: accountAddress, chain: CHAIN, rpcUrl: import.meta.env.VITE_HYPERGRAPH_RPC_URL, }); + console.log('encrypting app identity'); const { ciphertext, nonce } = await Connect.encryptAppIdentity( signer, newAppIdentity.address, @@ -409,8 +413,8 @@ function AuthenticateComponent() { permissionId, keys, ); + console.log('proving ownership'); const { accountProof, keyProof } = await Identity.proveIdentityOwnership( - walletClient, smartAccountClient, accountAddress, keys, diff --git a/apps/connect/src/routes/login.lazy.tsx b/apps/connect/src/routes/login.lazy.tsx index c66fcbc0..5244e395 100644 --- a/apps/connect/src/routes/login.lazy.tsx +++ b/apps/connect/src/routes/login.lazy.tsx @@ -8,7 +8,8 @@ import { type WalletClient, createWalletClient, custom } from 'viem'; const CHAIN = import.meta.env.VITE_HYPERGRAPH_CHAIN === 'geogenesis' ? Connect.GEOGENESIS : Connect.GEO_TESTNET; const syncServerUri = import.meta.env.VITE_HYPERGRAPH_SYNC_SERVER_ORIGIN; -const storage = localStorage; +const addressStorage = localStorage; +const keysStorage = sessionStorage; export const Route = createLazyFileRoute('/login')({ component: () => , @@ -52,7 +53,16 @@ function Login() { console.log(walletClient); console.log(rpcUrl); console.log(CHAIN); - await Connect.login({ walletClient, signer, syncServerUri, storage, identityToken, rpcUrl, chain: CHAIN }); + await Connect.login({ + walletClient, + signer, + syncServerUri, + addressStorage, + keysStorage, + identityToken, + rpcUrl, + chain: CHAIN, + }); }, [identityToken, signMessage], ); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3db21057..5744bcf4 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,9 +1,9 @@ +import { parse } from 'node:url'; import { Connect, Identity, Inboxes, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph'; import { bytesToHex, randomBytes } from '@noble/hashes/utils.js'; import cors from 'cors'; import { Effect, Exit, Schema } from 'effect'; import express, { type Request, type Response } from 'express'; -import { parse } from 'node:url'; import WebSocket, { WebSocketServer } from 'ws'; import { addAppIdentityToSpaces } from './handlers/add-app-identity-to-spaces.js'; import { applySpaceEvent } from './handlers/applySpaceEvent.js'; diff --git a/packages/hypergraph/src/connect/abis.ts b/packages/hypergraph/src/connect/abis.ts index 4b4d5a76..b3b3015f 100644 --- a/packages/hypergraph/src/connect/abis.ts +++ b/packages/hypergraph/src/connect/abis.ts @@ -52,6 +52,25 @@ export const safeOwnerManagerAbi = [ stateMutability: 'nonpayable', type: 'function', }, + { + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + ], + name: 'isOwner', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + stateMutability: 'view', + type: 'function', + }, ]; // We only use this for revokeEnableSignature to use as a noop when creating a smart session diff --git a/packages/hypergraph/src/connect/auth-storage.ts b/packages/hypergraph/src/connect/auth-storage.ts index 4bb78862..706a3631 100644 --- a/packages/hypergraph/src/connect/auth-storage.ts +++ b/packages/hypergraph/src/connect/auth-storage.ts @@ -65,3 +65,12 @@ export const storeAccountAddress = (storage: Storage, accountId: string) => { export const wipeAccountAddress = (storage: Storage) => { storage.removeItem(buildAccountAddressStorageKey()); }; + +export const wipeAllAuthData = (addressStorage: Storage, keysAndTokenStorage: Storage) => { + const accountAddress = loadAccountAddress(addressStorage); + wipeAccountAddress(addressStorage); + if (accountAddress) { + wipeKeys(keysAndTokenStorage, accountAddress); + wipeSyncServerSessionToken(keysAndTokenStorage, accountAddress); + } +}; diff --git a/packages/hypergraph/src/connect/login.ts b/packages/hypergraph/src/connect/login.ts index c613007e..4dc01328 100644 --- a/packages/hypergraph/src/connect/login.ts +++ b/packages/hypergraph/src/connect/login.ts @@ -1,14 +1,16 @@ import * as Schema from 'effect/Schema'; import type { SmartAccountClient } from 'permissionless'; import type { Address, Chain, Hex, WalletClient } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; import { proveIdentityOwnership } from '../identity/prove-ownership.js'; import * as Messages from '../messages/index.js'; import { store } from '../store-connect.js'; -import { loadAccountAddress, storeAccountAddress, storeKeys, wipeAccountAddress } from './auth-storage.js'; +import { loadAccountAddress, storeAccountAddress, storeKeys } from './auth-storage.js'; import { createIdentityKeys } from './create-identity-keys.js'; import { decryptIdentity, encryptIdentity } from './identity-encryption.js'; import { type SmartAccountParams, + addSmartAccountOwner, getSmartAccountWalletClient, isSmartAccountDeployed, smartAccountNeedsUpdate, @@ -29,17 +31,26 @@ export async function signup( smartAccountClient: SmartAccountClient, accountAddress: Address, syncServerUri: string, - storage: Storage, + addressStorage: Storage, + keysStorage: Storage, identityToken: string, + chain: Chain, + rpcUrl: string, ) { const keys = createIdentityKeys(); const { ciphertext, nonce } = await encryptIdentity(signer, keys); - const { accountProof, keyProof } = await proveIdentityOwnership( - walletClient, - smartAccountClient, - accountAddress, - keys, - ); + + const localAccount = privateKeyToAccount(keys.signaturePrivateKey as `0x${string}`); + // This will deploy the smart account if it's not deployed + await addSmartAccountOwner(smartAccountClient, localAccount.address, chain, rpcUrl); + const localSmartAccountClient = await getSmartAccountWalletClient({ + owner: localAccount, + address: accountAddress, + rpcUrl, + chain, + }); + + const { accountProof, keyProof } = await proveIdentityOwnership(localSmartAccountClient, accountAddress, keys); const req: Messages.RequestConnectCreateIdentity = { keyBox: { signer: await signer.getAddress(), accountAddress, ciphertext, nonce }, @@ -64,8 +75,8 @@ export async function signup( if (!decoded.success) { throw new Error('Error creating identity'); } - storeKeys(storage, accountAddress, keys); - storeAccountAddress(storage, accountAddress); + storeKeys(keysStorage, accountAddress, keys); + storeAccountAddress(addressStorage, accountAddress); return { accountAddress, keys, @@ -76,7 +87,8 @@ export async function restoreKeys( signer: Signer, accountAddress: Address, syncServerUri: string, - storage: Storage, + addressStorage: Storage, + keysStorage: Storage, identityToken: string, ) { const res = await fetch(new URL('/connect/identity/encrypted', syncServerUri), { @@ -93,8 +105,8 @@ export async function restoreKeys( const { keyBox } = decoded; const { ciphertext, nonce } = keyBox; const keys = await decryptIdentity(signer, ciphertext, nonce); - storeKeys(storage, accountAddress, keys); - storeAccountAddress(storage, accountAddress); + storeKeys(keysStorage, accountAddress, keys); + storeAccountAddress(addressStorage, accountAddress); return { accountAddress, keys, @@ -103,8 +115,13 @@ export async function restoreKeys( throw new Error(`Error fetching identity ${res.status}`); } -const getAndDeploySmartAccount = async (walletClient: WalletClient, rpcUrl: string, chain: Chain, storage: Storage) => { - const accountAddressFromStorage = loadAccountAddress(storage) as Hex; +const getAndUpdateSmartAccount = async ( + walletClient: WalletClient, + rpcUrl: string, + chain: Chain, + addressStorage: Storage, +) => { + const accountAddressFromStorage = loadAccountAddress(addressStorage) as Hex; const smartAccountParams: SmartAccountParams = { owner: walletClient, rpcUrl, @@ -128,21 +145,6 @@ const getAndDeploySmartAccount = async (walletClient: WalletClient, rpcUrl: stri // Create the client again to ensure we have the 7579 config now return getSmartAccountWalletClient(smartAccountParams); } - if (!(await isSmartAccountDeployed(smartAccountWalletClient))) { - // TODO: remove this once we manage to get counterfactual signatures working - console.log('sending dummy userOp to deploy smart account'); - if (!walletClient.account) { - throw new Error('Wallet client account not found'); - } - const tx = await smartAccountWalletClient.sendUserOperation({ - calls: [{ to: walletClient.account.address, data: '0x' }], - account: smartAccountWalletClient.account, - }); - - console.log('tx', tx); - const receipt = await smartAccountWalletClient.waitForUserOperationReceipt({ hash: tx }); - console.log('receipt', receipt); - } return smartAccountWalletClient; }; @@ -150,7 +152,8 @@ export async function login({ walletClient, signer, syncServerUri, - storage, + addressStorage, + keysStorage, identityToken, rpcUrl, chain, @@ -158,23 +161,18 @@ export async function login({ walletClient: WalletClient; signer: Signer; syncServerUri: string; - storage: Storage; + addressStorage: Storage; + keysStorage: Storage; identityToken: string; rpcUrl: string; chain: Chain; }) { - let smartAccountWalletClient: SmartAccountClient; - try { - smartAccountWalletClient = await getAndDeploySmartAccount(walletClient, rpcUrl, chain, storage); - } catch (error) { - wipeAccountAddress(storage); - smartAccountWalletClient = await getAndDeploySmartAccount(walletClient, rpcUrl, chain, storage); - } + const smartAccountWalletClient = await getAndUpdateSmartAccount(walletClient, rpcUrl, chain, addressStorage); if (!smartAccountWalletClient.account) { throw new Error('Smart account wallet client account not found'); } const accountAddress = smartAccountWalletClient.account.address; - // const keys = loadKeys(storage, accountAddress); + let authData: { accountAddress: Address; keys: IdentityKeys; @@ -187,11 +185,14 @@ export async function login({ smartAccountWalletClient, accountAddress, syncServerUri, - storage, + addressStorage, + keysStorage, identityToken, + chain, + rpcUrl, ); } else { - authData = await restoreKeys(signer, accountAddress, syncServerUri, storage, identityToken); + authData = await restoreKeys(signer, accountAddress, syncServerUri, addressStorage, keysStorage, identityToken); } store.send({ type: 'reset' }); store.send({ diff --git a/packages/hypergraph/src/connect/smart-account.ts b/packages/hypergraph/src/connect/smart-account.ts index ea25265c..23e6b12e 100644 --- a/packages/hypergraph/src/connect/smart-account.ts +++ b/packages/hypergraph/src/connect/smart-account.ts @@ -36,6 +36,7 @@ import { ContractFunctionExecutionError, type Hex, type Narrow, + type PrivateKeyAccount, type SignableMessage, type WalletClient, createPublicClient, @@ -582,13 +583,61 @@ const getSpaceActions = (space: { address: Hex; type: 'personal' | 'public' }) = return actions; }; +export const addSmartAccountOwner = async ( + smartAccountClient: SmartAccountClient, + newOwner: Address, + chain: Chain, + rpcUrl: string, +) => { + if (!smartAccountClient.account) { + throw new Error('Invalid smart account'); + } + const publicClient = createPublicClient({ + transport: http(rpcUrl), + chain, + }); + if (await isSmartAccountDeployed(smartAccountClient)) { + const isOwner = await publicClient.readContract({ + abi: safeOwnerManagerAbi, + address: smartAccountClient.account.address, + functionName: 'isOwner', + args: [newOwner], + }); + if (isOwner) { + return; + } + } + + const tx = await smartAccountClient.sendUserOperation({ + calls: [ + { + to: smartAccountClient.account.address, + data: encodeFunctionData({ + abi: safeOwnerManagerAbi, + functionName: 'addOwnerWithThreshold', + args: [newOwner, BigInt(1)], + }), + value: BigInt(0), + }, + ], + account: smartAccountClient.account, + }); + const receipt = await smartAccountClient.waitForUserOperationReceipt({ + hash: tx, + }); + if (!receipt.success) { + throw new Error('Transaction to add smart account owner failed'); + } + return receipt; +}; + // This is the function that the Connect app uses to create a smart session and // enable it on the smart account. // It will prompt the user to sign the message to enable the session, and then // execute the transaction to enable the session. // It will return the permissionId that can be used to create a smart session client. export const createSmartSession = async ( - owner: WalletClient, + owner: WalletClient | PrivateKeyAccount, accountAddress: Hex, sessionPrivateKey: Hex, chain: Chain, @@ -624,9 +673,6 @@ export const createSmartSession = async ( if (!smartAccountClient.chain) { throw new Error('Invalid smart account chain'); } - if (!owner.account) { - throw new Error('Invalid wallet client'); - } const sessionKeyAccount = privateKeyToAccount(sessionPrivateKey); const transport = http(rpcUrl); @@ -751,9 +797,9 @@ export const createSmartSession = async ( console.log('signing session details'); // This will prompt the user to sign the message to enable the session - sessionDetails.enableSessionData.enableSession.permissionEnableSig = await owner.signMessage({ + sessionDetails.enableSessionData.enableSession.permissionEnableSig = await (owner as WalletClient).signMessage({ message: { raw: sessionDetails.permissionEnableHash }, - account: owner.account.address, + account: (owner as WalletClient).account?.address ?? (owner as PrivateKeyAccount).address, }); console.log('session details signed'); const smartSessions = getSmartSessionsValidator({}); diff --git a/packages/hypergraph/src/identity/prove-ownership.ts b/packages/hypergraph/src/identity/prove-ownership.ts index 4572e15d..5e971f00 100644 --- a/packages/hypergraph/src/identity/prove-ownership.ts +++ b/packages/hypergraph/src/identity/prove-ownership.ts @@ -1,4 +1,4 @@ -import { http, type Chain, type Hex, type WalletClient, createPublicClient, verifyMessage } from 'viem'; +import { http, type Chain, type Hex, createPublicClient, verifyMessage } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import type { SmartAccountClient } from 'permissionless'; @@ -19,7 +19,6 @@ export const accountProofDomain = { }; export const proveIdentityOwnership = async ( - walletClient: WalletClient, smartAccountClient: SmartAccountClient, accountAddress: string, keys: IdentityKeys,