diff --git a/.DS_Store b/.DS_Store index cef815e..6b982a8 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index cc8e32d..ca69e6c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,16 @@ dist ignition/deployments/chain-31337 ignition/deployments/chain-84532 +.aider* +.venv/ +.envrc +.python-version + +*scripts/ +*.vscode/ + +*apps/notes/ +.claudeignore +*.claude/ +CLAUDE.md +*myplans \ No newline at end of file diff --git a/README.md b/README.md index 0451446..af32588 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- E2EE messaging over Ethereum logs, using the blockchain as the only transport layer. + E2EE messaging over the blockchain, using EVM logs as the only transport layer.

@@ -16,11 +16,11 @@ - - + + -

@@ -28,7 +28,7 @@ #### Built with - +- [**Noble**](https://paulmillr.com/noble/) – audited JS implementations for curves, hashes, secp256k1, and ML-KEM-768 (post-quantum) - [**TweetNaCl**](https://tweetnacl.js.org/) – for encryption/decryption with NaCl box - [**Ethers v6**](https://docs.ethers.org/v6/) – for all Ethereum interactions - [**Viem**](https://viem.sh/) – specific for EIP-1271/6492 verification @@ -36,140 +36,19 @@ --- -## How can Alice & Bob use Verbeth? - -Alice wants to initiate a secure chat with Bob: - -1. Alice generates a new **ephemeral keypair**. -2. She emits a `Handshake` event that includes: - - her ephemeral public key (for this handshake only) - - her long-term unified keys (X25519 + Ed25519) - - a plaintext payload carrying her identityProof and an optional note - - the recipientHash mapping to Bob -3. Bob watches logs for handshake events addressed to him (matching his recipientHash), and: - - verifies Alice’s identity with the included identityProof, - - prepares his `HandshakeResponse` -4. Bob computes a response tag and emits the response event: - - - Bob generates an ephemeral keypair (R, r) dedicated to the tag (i.e. Bob can't forge a response for someone else other than Alice) - - He computes the tag `H( HKDF( ECDH( r, Alice.viewPub ), "verbeth:hsr"))` - - He encrypts the response to Alice’s handshake ephemeral public key and includes: - - his ephemeral public key for post-handshake - - his identity keys (unified) + identityProof - - topicInfo (see below) inside the encrypted payload - - the public R and response tag in the log - -5. Using her view (ephemeral) secret key and Bob’s public R, Alice recomputes the tag. She can then filter handshake response logs by this tag and decrypt the matching one. - -6. Once handshake is complete, both derive duplex topics and start emitting `MessageSent` events: - -- Using a long-term diffie–hellman shared secret and the response tag as salt, they derive: - ``` - shared = ECDH( Alice , Bob ) - topic = keccak256( HKDF(sha256, shared, salt, info) ) - ``` - N.B: with the tag salt, each handshake creates fresh topics -- Alice encrypts messages using Bob’s identity key with a fresh ephemeral key per message (and vice versa). -- They can sign messages with their long term signing key - -``` -ALICE (Initiator) BLOCKCHAIN BOB (Responder) - | | | - |----------------------------|----------------------------| - | PHASE 0: | - | Identity and Key Derivation | - |--------------------------->| | - | Generate identity keys | | - | Sign identity-binding msg | | - | Create IdentityProof | | - | |<---------------------------| - | | Generate identity keys | - | | Sign identity-binding msg | - | | Create IdentityProof | - | | | - | PHASE 1: Alice Initiates Handshake | - |--------------------------->| | - | Generate ephemeral keypair| | - | Prepare HandshakeContent | | - | Encode unified pubKeys | | - | initiateHandshake() |--------------------------->| - | | Emit Handshake event | - | |--------------------------->| - | | PHASE 2: Bob Receives | - | | Listen for event | - | | Parse unified pubKeys | - | | Extract IdentityProof | - | | Verify Alice's identity | - | | | - | | PHASE 3: Bob Responds | - | |--------------------------->| - | | If valid: | - | | - Generate ephemeral key | - | | - Prepare response | - | | - Encrypt w/ Alice's | - | | EPHEMERAL key | - | | respondToHandshake() | - | | Emit HandshakeResponse | - | |--------------------------->| - | | Else: reject handshake | - | | | - | PHASE 4: Alice Receives Response | - |<--------------------------| | - | Listen for HandshakeResponse event | - | Decrypt response w/ own ephemeral secret | - | Extract Bob's keys & proof | - | Verify Bob's identity | - | | - | PHASE 5: Secure Communication Established | - |--------------------------->| | - | Store Bob's keys | | - | Ongoing: | | - | - Generate fresh | | - | ephemeral keys | | - | - Encrypt w/ Bob's | | - | IDENTITY key + | | - | fresh ephemeral | | - | - Sign w/ Alice's key | | - | - sendMessage() |--------------------------->| - | | Message event received | - | | Decrypt w/ Bob's | - | | IDENTITY key + | - | | ephemeral from msg | - | | Verify signature | - | | Secure message delivered | - |----------------------------|----------------------------| - | | - -``` - -## Contract - -We include `sender` (= `msg.sender`) as an **indexed event field** to bind each log to the actual caller account (EOA or smart account) and make it "bloom-filterable". - -A transaction receipt does not expose the immediate caller of this contract — it only contains the emitter address (this contract) and the topics/data — so recovering `msg.sender` would require execution traces. - -Under ERC-4337 this becomes even trickier: the outer transaction targets the EntryPoint and tx.from is the bundler, not the smart account. Without including sender in the event, reliably linking a log to the originating account would require correlating EntryPoint internals or traces. +## How it works -### Deployed Addresses (base mainnet) +To start a conversation, Alice emits a `Handshake` event with her ephemeral keys and an identity proof. Bob sees it, verifies her, and replies with a `HandshakeResponse`. They combine X25519 and ML-KEM-768 secrets to derive a shared root key that's secure even against future quantum computers. -LogChainV1 (singleton) `0x41a3eaC0d858028E9228d1E2092e6178fc81c4f0` +From there it's just encrypted `MessageSent` events back and forth. A Double Ratchet keeps churning keys forward so old messages stay safe even if something leaks later. Topics rotate too, making it hard for observers to link conversations across time. More info [here](). -ERC1967Proxy `0x62720f39d5Ec6501508bDe4D152c1E13Fd2F6707` -## Features +### Deployed Addresses (base mainnet) -- Stateless encrypted messaging via logs -- Ephemeral keys & forward secrecy -- Handshake-based key exchange (no prior trust) -- Minimal metadata via `recipientHash` -- Fully on-chain: no servers, no relays -- Compatible with EOAs and smart contract accounts +VerbethV1 (singleton) `0x51670aB6eDE1d1B11C654CCA53b7D42080802326` -The SDK verifies handshakes logs using [viem.verifyMessage](https://viem.sh/docs/actions/public/verifyMessage). -It supports both EOAs and Smart Contract Accounts — whether they’re already deployed or still counterfactual/pre-deployed — by leveraging: +ERC1967Proxy `0x82C9c5475D63e4C9e959280e9066aBb24973a663` -- ERC-1271: for verifying signatures from smart contract wallets that are deployed. -- ERC-6492: a wrapper standard that lets smart contract accounts sign and be verified before deployment. ### Notes on the current model @@ -177,54 +56,4 @@ It supports both EOAs and Smart Contract Accounts — whether they’re already **Identity key binding**: The message (es. “VerbEth Key Binding v1\nAddress: …\nPkEd25519: …\nPkX25519: …\nContext: …\nVersion: …”) is signed by the evm account directly binding its address to the long-term keys (i.e. preventing impersonation). -**Non-repudiation**: By default, confidentiality and integrity are guaranteed by AEAD with NaCl box. Additionally, the sender can attach a detached Ed25519 signature over using the Ed25519 key bound in the handshake. This effectively provides per-message origin authentication that is verifiable: a recipient (or any third party) can prove the message was produced by the holder of that specific Ed25519 key. Otherwise, attribution relies on context, making sender spoofing at the application layer harder to detect. - -**Forward secrecy**: Each message uses a fresh sender ephemeral key. This provides sender-side forward secrecy for sent messages: once the sender deletes the ephemeral secret, a future compromise of their long-term keys does not expose past ciphertexts. Handshake responses also use ephemeral↔ephemeral, enjoying the same property. However, if a recipient’s long-term X25519 key is compromised, all past messages addressed to them remain decryptable. A double-ratchet (or ephemeral↔ephemeral messaging) can extend forward secrecy to the recipient side (see [here](#improvement-ideas)). - -**Handshake linkability:** -Each handshake relies on a diffie–hellman exchange between the initiator’s handshake ephemeral key and the responder’s tag ephemeral R. The resulting tag is an opaque pointer that hides who the initiator is. Reusing only the responder’s R lets observers group his responses that reused the same tag key, but it does not reveal which initiator each response targets. Reusing only initiator's ephemeral pubkey lets observers group her handshakes (which already show sender in this design, but breaking unlinkability if hidden behind a relay). The tag repeats only if both ephemerals are reused together. The real issue is a lack of forward secrecy during handshaking: if either handshake-ephemeral secret is later compromised and had been reused, an attacker could retroactively derive all matching tags and link multiple past handshakes between the same parties. In practice, both sides should generate fresh ephemerals per handshake and securely erase them after use. - -**Communication channels linkability**: Current version has duplex topics by default: one topic per direction, obtained with HKDF. So, each side writes on its own secret topic and we don’t get the “two accounts posting on the same topic, hence they’re chatting” giveaway. Also, the topic is optionally bound to each message by covering it in the detached Ed25519 signature (topic || epk || nonce || ciphertext), which kills cross-topic replays. At the application level, each client queries only its inbound topics so the RPC endpoint never learns both sides of a duplex pair. Note: timing during the handshake phase (and general traffic analysis) can still reveal communication patterns. - -## Example Usage (WIP) - -```ts -import { - decryptLog, - initiateHandshake, - sendEncryptedMessage, - deriveIdentityKeyPairWithProof, -} from "@verbeth/sdk"; - -// 1. Generate or load your long-term identity keypair -const { publicKey, secretKey } = await deriveIdentityKeyPairWithProof( - walletClient -); - -// 2. Receive and decrypt a message from an on-chain log event -const decrypted = decryptLog(eventLog, secretKey); - -// 3. Start a handshake with another user -await initiateHandshake({ - contract, // LogChainV1 - recipientAddress: "0xBob...", - ephemeralPubKey: ephemeralKey.publicKey, - plaintextPayload: "Hi Bob, ping from Alice", // (optional) plaintext handshake message -}); - -// 4. Send an encrypted message (after handshake is established) -await sendEncryptedMessage({ - contract, - recipientAddress: "0xBob...", - message: "Hello again, Bob!", - senderEphemeralKeyPair: ephemeralKey, // ephemeral keypair used for forward secrecy - recipientPublicKey, -}); -``` - -## Improvement ideas - -| Title | Description | Refs | -| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Bidirectional Forward Secrecy (session ratchet) | Achieve **end-to-end, bilateral FS** even if the **recipient’s long-term X25519** is later compromised. Two options: (1) switch messaging to **ephemeral↔ephemeral** (derive per-message DH and discard secrets), or (2) derive a **symmetric session ratchet** from the handshake (e.g., **Double Ratchet** for 1:1; **MLS** for 1\:many) so every message advances sending/receiving chains and old keys are irrecoverable. | Signal **Double Ratchet** spec (post-X3DH): [https://signal.org/docs/specifications/doubleratchet/](https://signal.org/docs/specifications/doubleratchet/) ; **MLS** (RFC 9420): [https://www.rfc-editor.org/rfc/rfc9420](https://www.rfc-editor.org/rfc/rfc9420) ; Matrix **Olm/Megolm** (Double Ratchet for 1:1 / group): [https://gitlab.matrix.org/matrix-org/olm](https://gitlab.matrix.org/matrix-org/olm) ; **Status/Waku** Double Ratchet transport: [https://specs.status.im/spec/5](https://specs.status.im/spec/5) and Waku X3DH/DR notes: [https://rfc.vac.dev/waku/standards/application/53/x3dh/](https://rfc.vac.dev/waku/standards/application/53/x3dh/) ; **XMTP** (MLS-based): [https://docs.xmtp.org/protocol/overview](https://docs.xmtp.org/protocol/overview) | -| Passkeys & WebAuthn PRF for encryption of messages | Let smart accounts encrypt messages with the same passkey used for UserOps. Use the WebAuthn **PRF** extension to derive an AEAD key at auth time (plus per-message salt/nonce) so users only manage the passkey—gaining stronger security (hardware/biometric protection) and portability/recovery (OS-synced passkeys or hardware keys). | [Corbado: Passkeys & PRF](https://www.corbado.com/blog/passkeys-prf-webauthn), [W3C WebAuthn L3: PRF extension](https://www.w3.org/TR/webauthn-3/), [Chrome: Intent to Ship (PRF)](https://groups.google.com/a/chromium.org/g/blink-dev/c/iTNOgLwD2bI), [SimpleWebAuthn: PRF docs](https://simplewebauthn.dev/docs/advanced/prf) | +**Non-repudiation**: By default, confidentiality and integrity are guaranteed by AEAD with NaCl box. Additionally, the sender can attach a detached Ed25519 signature over using the Ed25519 key bound in the handshake. This effectively provides per-message origin authentication that is verifiable: a recipient (or any third party) can prove the message was produced by the holder of that specific Ed25519 key. Otherwise, attribution relies on context, making sender spoofing at the application layer harder to detect. | diff --git a/apps/.DS_Store b/apps/.DS_Store index dba4aeb..c1f7e0e 100644 Binary files a/apps/.DS_Store and b/apps/.DS_Store differ diff --git a/apps/demo/.env.example b/apps/demo/.env.example new file mode 100644 index 0000000..a394bbb --- /dev/null +++ b/apps/demo/.env.example @@ -0,0 +1,3 @@ +# Alchemy RPC endpoints (demo works without these via public fallback) +# VITE_RPC_HTTP_URL=https://base-sepolia.g.alchemy.com/v2/YOUR_KEY +# VITE_RPC_WS_URL=wss://base-sepolia.g.alchemy.com/v2/YOUR_KEY diff --git a/apps/demo/config.ts b/apps/demo/config.ts index e3a78fc..c7b2942 100644 --- a/apps/demo/config.ts +++ b/apps/demo/config.ts @@ -1,11 +1,12 @@ import { http, createConfig } from 'wagmi'; -import { base, mainnet } from 'wagmi/chains'; +import { base, mainnet, baseSepolia} from 'wagmi/chains'; import { connectorsForWallets } from '@rainbow-me/rainbowkit'; import { coinbaseWallet, metaMaskWallet, walletConnectWallet, } from '@rainbow-me/rainbowkit/wallets'; +import { BASESEPOLIA_HTTP_URL } from './src/rpc.js'; const projectId = 'abcd4fa063dd349643afb0bdc85bb248'; @@ -31,9 +32,10 @@ const connectors = connectorsForWallets( export const config = createConfig({ connectors, - chains: [base, mainnet], + chains: [baseSepolia, base, mainnet], transports: { - [mainnet.id]: http(), + [baseSepolia.id]: http(BASESEPOLIA_HTTP_URL), [base.id]: http('https://base-rpc.publicnode.com'), + [mainnet.id]: http(), }, }); \ No newline at end of file diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index 9318fe3..9ab98aa 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,45 +1,33 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from "react"; -import { Fingerprint } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Fingerprint, RotateCcw, X } from "lucide-react"; import { ConnectButton } from '@rainbow-me/rainbowkit'; -import Safe from '@safe-global/protocol-kit' -import SafeApiKit from '@safe-global/api-kit' import { useAccount, useWalletClient } from 'wagmi'; import { useRpcClients } from './rpc.js'; -import { BrowserProvider, Wallet } from "ethers"; import { - LogChainV1__factory, - type LogChainV1, -} from "@verbeth/contracts/typechain-types/index.js"; -import { - IExecutor, - ExecutorFactory, - deriveIdentityKeyPairWithProof, - IdentityKeyPair, - IdentityProof, VerbethClient, - SafeSessionSigner + createVerbethClient, } from '@verbeth/sdk'; import { useMessageListener } from './hooks/useMessageListener.js'; import { useMessageProcessor } from './hooks/useMessageProcessor.js'; -import { dbService } from './services/DbService.js'; import { - LOGCHAIN_SINGLETON_ADDR, - CONTRACT_CREATION_BLOCK, + VERBETH_SINGLETON_ADDR, Contact, - StoredIdentity, - SAFE_MODULE_ADDRESS, - SAFE_TX_SERVICE_URL + Message, } from './types.js'; import { InitialForm } from './components/InitialForm.js'; import { SideToastNotifications } from './components/SideToastNotification.js'; import { IdentityCreation } from './components/IdentityCreation.js'; import { CelebrationToast } from "./components/CelebrationToast.js"; +import { SessionSetupPrompt } from './components/SessionSetupPrompt.js'; import { useChatActions } from './hooks/useChatActions.js'; - - +import { useSessionSetup } from './hooks/useSessionSetup.js'; +import { useInitIdentity } from './hooks/useInitIdentity.js'; +import { usePendingSessionReset } from './hooks/usePendingSessionReset.js'; +import { PinnedResetRequest } from './components/PinnedResetRequest.js'; +import { sessionStore, pendingStore } from './services/StorageAdapters.js'; export default function App() { - const { ethers: readProvider, viem: viemClient } = useRpcClients(); + const { ethers: readProvider, viem: viemClient, transportStatus } = useRpcClients(); const { address, isConnected } = useAccount(); const { data: walletClient } = useWalletClient(); @@ -48,149 +36,156 @@ export default function App() { const [message, setMessage] = useState(""); const [selectedContact, setSelectedContact] = useState(null); const [loading, setLoading] = useState(false); - const [currentAccount, setCurrentAccount] = useState(null); const [showHandshakeForm, setShowHandshakeForm] = useState(true); const [handshakeToasts, setHandshakeToasts] = useState([]); - const [needsIdentityCreation, setNeedsIdentityCreation] = useState(false); const [showToast, setShowToast] = useState(false); - - const [identityKeyPair, setIdentityKeyPair] = useState(null); - const [identityProof, setIdentityProof] = useState(null); - const [executor, setExecutor] = useState(null); - const [contract, setContract] = useState(null); - const [signer, setSigner] = useState(null); - const [isActivityLogOpen, setIsActivityLogOpen] = useState(false); - const [activityLogs, setActivityLogs] = useState(""); - const [verbethClient, setVerbethClient] = useState(null); - const logRef = useRef(null); + const [healthBannerDismissed, setHealthBannerDismissed] = useState(false); + const [isResettingContacts, setIsResettingContacts] = useState(false); - // Identity context (domain and chain binding) const chainId = Number(import.meta.env.VITE_CHAIN_ID); - const rpId = globalThis.location?.host ?? ""; - const identityContext = useMemo(() => ({ chainId, rpId }), [chainId, rpId]); - const addLog = useCallback((message: string) => { - const timestamp = new Date().toLocaleTimeString(); - const logEntry = `[${timestamp}] ${message}\n`; - setActivityLogs(prev => { - const newLogs = prev + logEntry; + const { + identityKeyPair, + identityProof, + executor, + identitySigner, + safeAddr, + needsIdentityCreation, + identityContext, + // Session state + sessionSignerAddr, + needsSessionSetup, + isSafeDeployed, + isModuleEnabled, + setIsSafeDeployed, + setIsModuleEnabled, + setNeedsSessionSetup, + signingStep, + // Actions + needsModeSelection, + fastModeAvailable, + executionMode, + emitterAddress, + createIdentity, + } = useInitIdentity({ + walletClient, + address, + chainId, + readProvider, + ready, + onIdentityCreated: () => setShowToast(true), + onReset: () => { + setSelectedContact(null); + setVerbethClient(null); + }, + }); - setTimeout(() => { - if (logRef.current && isActivityLogOpen) { - logRef.current.scrollTop = logRef.current.scrollHeight; - } - }, 0); - - return newLogs; - }); - }, [isActivityLogOpen]); - - - const createIdentity = useCallback(async () => { - // Wagmi - if (signer && address) { - setLoading(true); - try { - addLog("Deriving new identity key (2 signatures)..."); - - const result = await deriveIdentityKeyPairWithProof(signer, address, identityContext); - - setIdentityKeyPair(result.keyPair); - setIdentityProof(result.identityProof); - - const identityToStore: StoredIdentity = { - address: address, - keyPair: result.keyPair, - derivedAt: Date.now(), - proof: result.identityProof - }; - - await dbService.saveIdentity(identityToStore); - addLog(`New identity key derived and saved for EOA`); - setNeedsIdentityCreation(false); - setShowToast(true); - - } catch (signError: any) { - if (signError.code === 4001) { - addLog("User rejected signing request."); - } else { - addLog(`✗ Failed to derive identity: ${signError.message}`); - } - } finally { - setLoading(false); - } - return; - } + // useSessionSetup receives state from useInitIdentity + const { + sessionSignerBalance, + refreshSessionBalance, + setupSession, + } = useSessionSetup({ + walletClient, + address, + safeAddr, + sessionSignerAddr, + chainId, + readProvider, + isSafeDeployed, + isModuleEnabled, + setIsSafeDeployed, + setIsModuleEnabled, + setNeedsSessionSetup, + executionMode, + }); + + // =========================================================================== + // Create VerbethClient with storage adapters using factory function + // =========================================================================== + useEffect(() => { + const currentAddress = address; - addLog("✗ Missing signer/provider or address for identity creation"); - }, [signer, address, identityContext, addLog]); + if (executor && identityKeyPair && identityProof && identitySigner && currentAddress) { + const client = createVerbethClient({ + address: currentAddress, + signer: identitySigner, + identityKeyPair, + identityProof, + executor, + sessionStore, + pendingStore, + }); + + setVerbethClient(client); + } else { + setVerbethClient(null); + } + }, [executor, identityKeyPair, identityProof, identitySigner, address]); const { messages, pendingHandshakes, contacts, addMessage, + updateMessageStatus, + removeMessage, removePendingHandshake, updateContact, - processEvents + processEvents, + markMessagesLost } = useMessageProcessor({ readProvider, identityContext, address: address ?? undefined, + emitterAddress: emitterAddress ?? undefined, identityKeyPair, - onLog: addLog + verbethClient, }); + const { hasPendingReset, pendingHandshake: pendingResetHandshake, limboAfterTimestamp } = + usePendingSessionReset(selectedContact, pendingHandshakes); const { isInitialLoading, isLoadingMore, canLoadMore, syncProgress, + syncStatus, loadMoreHistory, + health, } = useMessageListener({ readProvider, address: address ?? undefined, - onLog: addLog, - onEventsProcessed: processEvents + emitterAddress: emitterAddress ?? undefined, + onEventsProcessed: processEvents, + viemClient, + verbethClient, }); const { sendHandshake, acceptHandshake, - sendMessageToContact + sendMessageToContact, + retryFailedMessage, + cancelQueuedMessage, + getContactQueueStatus, } = useChatActions({ verbethClient, - addLog, updateContact: async (contact: Contact) => { await updateContact(contact); }, addMessage: async (message: any) => { await addMessage(message); }, + updateMessageStatus, + removeMessage, removePendingHandshake: async (id: string) => { await removePendingHandshake(id); }, setSelectedContact, setLoading, setMessage, setRecipientAddress, + markMessagesLost, }); - useEffect(() => { - const currentAddress = address; - - if (executor && identityKeyPair && identityProof && signer && currentAddress) { - const client = new VerbethClient({ - executor, - identityKeyPair, - identityProof, - signer, - address: currentAddress, - }); - setVerbethClient(client); - addLog(`VerbethClient initialized for ${currentAddress.slice(0, 8)}...`); - } else { - setVerbethClient(null); - } - }, [executor, identityKeyPair, identityProof, signer, address, addLog]); - // sync handshakeToasts useEffect(() => { const currentlyConnected = isConnected; @@ -207,6 +202,7 @@ export default function App() { sender: h.sender, message: h.message, verified: h.verified, + isExistingContact: h.isExistingContact, onAccept: (msg: string) => acceptHandshake(h, msg), onReject: () => removePendingHandshake(h.id), })) @@ -217,13 +213,44 @@ export default function App() { setHandshakeToasts((prev) => prev.filter((n) => n.id !== id)); }; + // Auto-reset health banner when health recovers to "ok" useEffect(() => { - setReady(readProvider !== null && isConnected && walletClient !== undefined); - }, [readProvider, isConnected, walletClient]); + if (health.level === "ok" && healthBannerDismissed) { + setHealthBannerDismissed(false); + } + }, [health.level, healthBannerDismissed]); + + const providerLabel = (() => { + switch (transportStatus) { + case "ws": + return "Alchemy WS + HTTP"; + case "http-alchemy": + return "Alchemy HTTP"; + case "http-public": + return "Public HTTP"; + case "disconnected": + return "Disconnected"; + } + })(); + + const syncStatusLabel = (() => { + switch (syncStatus.mode) { + case "catching_up": + return `Catching up (${syncStatus.pendingRanges} ranges queued)`; + case "retrying": + return `Retrying (${syncStatus.pendingRanges} ranges pending)`; + case "degraded": + return "Degraded"; + case "synced": + return "Synced"; + default: + return "Idle"; + } + })(); useEffect(() => { - handleInitialization(); - }, [ready, readProvider, walletClient, address]); + setReady(readProvider !== null && isConnected && walletClient !== undefined); + }, [readProvider, isConnected, walletClient]); // hide handshake form when we have contacts AND user is connected useEffect(() => { @@ -231,76 +258,100 @@ export default function App() { setShowHandshakeForm(!ready || !currentlyConnected || contacts.length === 0 || needsIdentityCreation); }, [ready, isConnected, contacts.length, needsIdentityCreation]); - const handleInitialization = useCallback(async () => { - try { - if (ready && readProvider && walletClient && address) { - await initializeWagmiAccount(); - return; - } - - if (!address) { - resetState(); - } - } catch (error) { - console.error("Failed to initialize:", error); - addLog(`✗ Failed to initialize: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - }, [ready, readProvider, walletClient, address]); - - const initializeWagmiAccount = async () => { - const ethersProvider = new BrowserProvider(walletClient!.transport); - const ethersSigner = await ethersProvider.getSigner(); - - const net = await ethersProvider.getNetwork(); - if (Number(net.chainId) !== chainId) { - addLog(`Wrong network: connected to chain ${Number(net.chainId)}, expected ${chainId}. Please switch network in your wallet.`); - return; - } - - const contractInstance = LogChainV1__factory.connect(LOGCHAIN_SINGLETON_ADDR, ethersSigner as any); - const executorInstance = ExecutorFactory.createEOA(contractInstance); - - setSigner(ethersSigner); - setExecutor(executorInstance); - setContract(contractInstance); - - if (address !== currentAccount) { - console.log(`EOA connected: ${address!.slice(0, 8)}...`); - await switchToAccount(address!); - } - }; - - const switchToAccount = async (newAddress: string) => { - setIdentityKeyPair(null); - setIdentityProof(null); - setSelectedContact(null); - - await dbService.switchAccount(newAddress); - setCurrentAccount(newAddress); + const renderMessage = (msg: Message) => { + const isOutgoing = msg.direction === 'outgoing'; + const isFailed = msg.status === 'failed'; + const isPending = msg.status === 'pending'; + const isLost = msg.isLost === true; + const isInLimbo = !isLost && hasPendingReset && isOutgoing && msg.type !== 'system' && limboAfterTimestamp && msg.timestamp > limboAfterTimestamp; + + return ( +
+
+

+ {msg.type === "system" && msg.verified !== undefined && ( + msg.verified ? ( + + + + Identity proof verified + + + ) : ( + + + + Identity proof not verified + + + ) + )} - const storedIdentity = await dbService.getIdentity(newAddress); - if (storedIdentity) { - setIdentityKeyPair(storedIdentity.keyPair); - setIdentityProof(storedIdentity.proof ?? null); - setNeedsIdentityCreation(false); - addLog(`Identity keys restored from database`); - } else { - setNeedsIdentityCreation(true); - } - }; + {msg.type === "system" && msg.decrypted ? ( + <> + {msg.decrypted.split(":")[0]}: + {msg.decrypted.split(":").slice(1).join(":")} + + ) : ( + msg.decrypted || msg.ciphertext + )} +

+ +
+ + {new Date(msg.timestamp).toLocaleTimeString()} + + {isOutgoing && ( + + {isLost ? '✗' : + isInLimbo ? '✓' : + msg.status === 'confirmed' ? '✓✓' : + msg.status === 'failed' ? '✗' : + msg.status === 'pending' ? '✓' : '?'} + + )} +
+
- const resetState = () => { - setCurrentAccount(null); - setIdentityKeyPair(null); - setIdentityProof(null); - setSelectedContact(null); - setSigner(null); - setContract(null); - setExecutor(null); - setNeedsIdentityCreation(false); - setVerbethClient(null); + {/* Failed message actions */} + {isFailed && isOutgoing && ( +
+ Failed to send + + +
+ )} +
+ ); }; + // Get queue status for selected contact + const queueStatus = selectedContact ? getContactQueueStatus(selectedContact) : null; return (
@@ -330,10 +381,10 @@ export default function App() { {/* LEFT: title */}

- Unstoppable Chat + Verbeth

- powered by Verbeth + powered by the world computer
{/* RIGHT: auth buttons - EOA only */} @@ -366,11 +417,29 @@ export default function App() { setShowToast(false)} /> - {needsIdentityCreation ? ( + {/* Session Setup Prompt - show when wallet connected but session not ready */} + {isConnected && sessionSignerAddr && !needsIdentityCreation && (needsSessionSetup || (sessionSignerBalance !== null && sessionSignerBalance < BigInt(0.0001 * 1e18))) && ( + + )} + + {(needsIdentityCreation || needsModeSelection) ? ( ) : showHandshakeForm ? ( a.timestamp - b.timestamp) - .map((msg) => ( -
-

- {msg.type === "system" && msg.verified !== undefined && ( - msg.verified ? ( - - - - Identity proof verified - - - ) : ( - - - - Identity proof not verified - - - ) - )} - - {msg.type === "system" && msg.decrypted ? ( - <> - {msg.decrypted.split(":")[0]}: - {msg.decrypted.split(":").slice(1).join(":")} - - ) : ( - msg.decrypted || msg.ciphertext - )} -

- -
- - {new Date(msg.timestamp).toLocaleTimeString()} - - {msg.direction === 'outgoing' && ( - - {msg.status === 'confirmed' ? '✓✓' : - msg.status === 'failed' ? '✗' : - msg.status === 'pending' ? '✓' : '?'} - - )} -
-
- ))} + .map(renderMessage)} + {messages.filter(m => { const currentAddress = address; if (!currentAddress || !selectedContact?.address) return false; @@ -533,13 +553,37 @@ export default function App() { (selectedContact.topicOutbound && m.topic === selectedContact.topicOutbound) || (selectedContact.topicInbound && m.topic === selectedContact.topicInbound) ); - }).length === 0 && ( + }).length === 0 && !hasPendingReset && (

No messages yet. {selectedContact.status === 'established' ? 'Start the conversation!' : 'Waiting for handshake completion.'}

)} + + {hasPendingReset && pendingResetHandshake && ( + + )}
+ {/* Queue Status Indicator */} + {queueStatus && queueStatus.queueLength > 0 && ( +
+ {queueStatus.isProcessing ? ( + <> + + Sending {queueStatus.queueLength} message{queueStatus.queueLength > 1 ? 's' : ''}... + + ) : ( + <> + 📨 + {queueStatus.queueLength} message{queueStatus.queueLength > 1 ? 's' : ''} queued + + )} +
+ )} + {/* Message Input */} {selectedContact.status === 'established' && selectedContact.identityPubKey && (
@@ -584,85 +628,39 @@ export default function App() {
- {/* Activity Log + Debug Info */} - {ready && ( -
-
-
setIsActivityLogOpen(!isActivityLogOpen)} - > -
-
-

Activity Log

- - {isActivityLogOpen ? '▼' : '▶'} - -
- {canLoadMore && ready && isActivityLogOpen && ( - - )} -
- {(isInitialLoading || isLoadingMore) && isActivityLogOpen && ( -
-
- {isInitialLoading ? 'Initial sync...' : 'Loading more...'} - {syncProgress && ( - ({syncProgress.current}/{syncProgress.total}) - )} -
- )} -
- -
-
-