diff --git a/packages/ui/.env b/packages/ui/.env index b968c62d..40ffd983 100644 --- a/packages/ui/.env +++ b/packages/ui/.env @@ -1,3 +1,4 @@ PUBLIC_PROVIDER=https://goerli.infura.io/v3/6d4ffa9d4df447ebb73468f4efcb8e8e PUBLIC_GROUP_ID=38219357914 PUBLIC_GLOBAL_ANONYMOUS_FEED_ADDRESS=0x7A5fe8899b9e189483Db0545c71D6cad92df93F0 +PUBLIC_TARGET_CHAIN_ID=0x5 \ No newline at end of file diff --git a/packages/ui/src/lib/components/modal.svelte b/packages/ui/src/lib/components/modal.svelte new file mode 100644 index 00000000..99d72451 --- /dev/null +++ b/packages/ui/src/lib/components/modal.svelte @@ -0,0 +1,39 @@ + + +
+
+

{message}

+ {#if action !== undefined} +
+
+ + diff --git a/packages/ui/src/lib/constants.ts b/packages/ui/src/lib/constants.ts index d08536a1..fa888f5b 100644 --- a/packages/ui/src/lib/constants.ts +++ b/packages/ui/src/lib/constants.ts @@ -2,8 +2,10 @@ import { PUBLIC_PROVIDER, PUBLIC_GROUP_ID, PUBLIC_GLOBAL_ANONYMOUS_FEED_ADDRESS, + PUBLIC_TARGET_CHAIN_ID, } from '$env/static/public' export const GLOBAL_ANONYMOUS_FEED_ADDRESS = PUBLIC_GLOBAL_ANONYMOUS_FEED_ADDRESS export const GROUP_ID = PUBLIC_GROUP_ID export const PROVIDER = PUBLIC_PROVIDER +export const TARGET_CHAIN_ID = PUBLIC_TARGET_CHAIN_ID diff --git a/packages/ui/src/lib/services/blockchain.ts b/packages/ui/src/lib/services/blockchain.ts new file mode 100644 index 00000000..6415389a --- /dev/null +++ b/packages/ui/src/lib/services/blockchain.ts @@ -0,0 +1,59 @@ +import { providers, Signer } from 'ethers' +import { TARGET_CHAIN_ID } from '$lib/constants' +import { browser } from '$app/environment' + +type WindowWithEthereum = Window & typeof globalThis & { ethereum: providers.ExternalProvider } + +export async function connectWallet(network?: providers.Networkish): Promise { + const provider = new providers.Web3Provider(getEthereum(), network) + await provider.send('eth_requestAccounts', []) + return provider.getSigner() +} + +export function canConnectWallet() { + return browser && Boolean((window as WindowWithEthereum)?.ethereum) +} + +export function hasWallet(w?: Window & typeof globalThis): w is WindowWithEthereum { + return browser && Boolean(((w ?? window) as WindowWithEthereum)?.ethereum) +} + +export function getEthereum(): providers.ExternalProvider { + if (hasWallet()) { + return (window as WindowWithEthereum)?.ethereum + } + + throw new Error('No web3 wallet found') +} + +export async function checkNetwork(targetChainId = TARGET_CHAIN_ID) { + if (!browser) return + + const ethereum = (window as WindowWithEthereum)?.ethereum + if (ethereum && ethereum.request) { + const currentChainId = await ethereum.request({ + method: 'eth_chainId', + }) + + if (Number.parseInt(currentChainId, 16) === Number.parseInt(targetChainId, 16)) { + return true + } + } + + return false +} + +export async function switchNetwork(targetChainId = TARGET_CHAIN_ID) { + if (!browser) return + + const ethereum = (window as WindowWithEthereum)?.ethereum + if (ethereum && ethereum.request) { + await ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: targetChainId }], + }) + + // refresh + window.location.reload() + } +} diff --git a/packages/ui/src/lib/services/index.ts b/packages/ui/src/lib/services/zk.ts similarity index 82% rename from packages/ui/src/lib/services/index.ts rename to packages/ui/src/lib/services/zk.ts index 130dced4..19dc83f7 100644 --- a/packages/ui/src/lib/services/index.ts +++ b/packages/ui/src/lib/services/zk.ts @@ -1,6 +1,6 @@ -import { ethers, providers, Signer, type BigNumberish, type ContractTransaction } from 'ethers' -import { Identity } from '@semaphore-protocol/identity' +import { ethers, Signer, type BigNumberish, type ContractTransaction } from 'ethers' import { Group, type Member } from '@semaphore-protocol/group' +import { Identity } from '@semaphore-protocol/identity' import { generateProof, packToSolidityProof, @@ -16,19 +16,6 @@ import { GLOBAL_ANONYMOUS_FEED_ADDRESS, GROUP_ID } from '$lib/constants' import { solidityKeccak256, type BytesLike, type Hexable } from 'ethers/lib/utils' import type { PromiseOrValue } from '$lib/assets/typechain/common' -type WindowWithEthereum = Window & - typeof globalThis & { ethereum: providers.ExternalProvider | providers.JsonRpcFetchFunc } - -export async function connectWallet(network?: providers.Networkish): Promise { - const provider = new providers.Web3Provider((window as WindowWithEthereum).ethereum, network) - await provider.send('eth_requestAccounts', []) - return provider.getSigner() -} - -export function canConnectWallet() { - return Boolean((window as WindowWithEthereum)?.ethereum) -} - export async function createIdentity(signer: Signer, secret: string) { const identitySeed = await signer.signMessage(secret) return new Identity(identitySeed) diff --git a/packages/ui/src/lib/stores/errors.ts b/packages/ui/src/lib/stores/errors.ts new file mode 100644 index 00000000..0fbfad77 --- /dev/null +++ b/packages/ui/src/lib/stores/errors.ts @@ -0,0 +1,18 @@ +import { writable, type Writable } from 'svelte/store' + +export interface UserStore extends Writable { + add: (error: Error) => void +} + +function createErrorStore(): UserStore { + const store = writable([]) + + return { + ...store, + add: (error: Error) => { + store.update((errors) => [...errors, error]) + }, + } +} + +export const errors = createErrorStore() diff --git a/packages/ui/src/lib/stores/post.ts b/packages/ui/src/lib/stores/post.ts index cc009db7..ab746f22 100644 --- a/packages/ui/src/lib/stores/post.ts +++ b/packages/ui/src/lib/stores/post.ts @@ -1,8 +1,10 @@ import { writable, type Writable } from 'svelte/store' import { browser } from '$app/environment' -import { getGlobalAnonymousFeed } from '$lib/services' +import { getGlobalAnonymousFeed } from '$lib/services/zk' import { providers } from 'ethers' import { PROVIDER } from '$lib/constants' +import { errors } from '$lib/stores/errors' +import type { ErrorWithCode } from '$lib/types' export interface Post { timestamp: number @@ -36,8 +38,12 @@ async function pullFeed() { localStorage.setItem('messages', JSON.stringify(messages)) } } - } catch (e) { - console.error(e) + } catch (error) { + if ((error as ErrorWithCode).code === 'NETWORK_ERROR') { + errors.add(new Error('Could not connect to the ethereum blockchain.')) + } else { + errors.add(error as Error) + } } } diff --git a/packages/ui/src/lib/types.ts b/packages/ui/src/lib/types.ts index 158b3443..ef151cd7 100644 --- a/packages/ui/src/lib/types.ts +++ b/packages/ui/src/lib/types.ts @@ -10,3 +10,7 @@ export interface IconProps { size?: number class?: string } + +export interface ErrorWithCode extends Error { + code?: string +} diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 5475a434..b0236cd3 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -3,6 +3,77 @@ import '@fontsource/source-sans-pro' import '@fontsource/source-serif-pro' import './styles.css' + + import Modal from '$lib/components/modal.svelte' + + import { errors } from '$lib/stores/errors' + import { onDestroy, onMount } from 'svelte' + import { checkNetwork, getEthereum, switchNetwork } from '$lib/services/blockchain' + import type { providers } from 'ethers' + import { TARGET_CHAIN_ID } from '$lib/constants' + import type { ErrorWithCode } from '$lib/types' + import { browser } from '$app/environment' + + function switchToTargetNetwork() { + $errors = [new Error('You are connected to a wrong ethereum network. Please switch to Goerli')] + switchNetwork(TARGET_CHAIN_ID).catch( + () => + ($errors = [ + new Error('Network change rejected, please change the network manually.'), + ...$errors, + ]), + ) + } + + onMount(() => { + if (!browser) return + const ethereum = getEthereum() as providers.Provider + + checkNetwork(TARGET_CHAIN_ID) + .then((networkMatches) => { + if (!networkMatches) switchToTargetNetwork() + }) + .catch(errors.add) + + ethereum.on('chainChanged', (chainId: string) => { + if (chainId !== TARGET_CHAIN_ID) { + switchToTargetNetwork() + } else { + window.location.reload() + } + }) + + ethereum.on('disconnect', (error: ErrorWithCode) => { + console.log(error) + errors.add( + new Error( + 'Disconnected from the ethereum network. Please check your internet connection and refresh the page', + ), + ) + }) + }) + + onDestroy(() => { + try { + const ethereum = getEthereum() as providers.Provider + + ethereum.off('chainChanged') + ethereum.off('disconnect') + } catch (error) { + console.error(error) + } + }) +{#if $errors.length > 0} + { + const [err, ...rest] = $errors + $errors = rest + console.error(`Resolved error ${err.message}`) + }} + /> +{/if} + diff --git a/packages/ui/src/routes/+page.svelte b/packages/ui/src/routes/+page.svelte index e9e04fe8..a50a9328 100644 --- a/packages/ui/src/routes/+page.svelte +++ b/packages/ui/src/routes/+page.svelte @@ -1,5 +1,4 @@
- - diff --git a/packages/ui/src/routes/post/new/+page.svelte b/packages/ui/src/routes/post/new/+page.svelte index f34543a5..3a75880c 100644 --- a/packages/ui/src/routes/post/new/+page.svelte +++ b/packages/ui/src/routes/post/new/+page.svelte @@ -3,7 +3,6 @@ import Close from '$lib/components/icons/close.svelte' import SendAltFilled from '$lib/components/icons/send-alt-filled.svelte' import InputString from '$lib/components/input-string.svelte' - import { profile } from '$lib/stores/profile' import { goto } from '$app/navigation' import { ROUTES } from '$lib/routes' import { @@ -12,8 +11,13 @@ getGlobalAnonymousFeed, getRandomExternalNullifier, validateProofOnChain, - } from '$lib/services/index' + } from '$lib/services/zk' + + import { profile } from '$lib/stores/profile' import { posts } from '$lib/stores/post' + import { errors } from '$lib/stores/errors' + + import type { ErrorWithCode } from '$lib/types' let cls: string | undefined = undefined export { cls as class } @@ -24,16 +28,21 @@ async function submit() { try { const signer = $profile.signer - if (!signer) throw new Error('no signer') + if (!signer) + throw new Error( + 'Failed to post - could not connect to web3 wallet. Try and refresh the page.', + ) const identity = $profile.identities.anonymous - if (!identity) throw new Error('no identity') + if (!identity) + throw new Error('Failed to post - no identity found. Try and refresh the page.') const globalAnonymousFeed = getGlobalAnonymousFeed(signer) const group = await getContractGroup(globalAnonymousFeed) const externalNullifier = getRandomExternalNullifier() const proof = await generateGroupProof(group, identity, postText, externalNullifier) + const tx = await validateProofOnChain(globalAnonymousFeed, proof, postText, externalNullifier) const res = await tx.wait() @@ -45,7 +54,15 @@ }) goto(ROUTES.HOME) } catch (error) { - console.error(error) + if ((error as ErrorWithCode)?.code === 'ACTION_REJECTED') { + errors.add( + new Error( + "You've rejected the post transaction. If you still want to publish your post, press publish button again.", + ), + ) + } else { + errors.add(error as Error) + } } } diff --git a/packages/ui/src/routes/profile/+page.svelte b/packages/ui/src/routes/profile/+page.svelte index 7fb72259..f999d90c 100644 --- a/packages/ui/src/routes/profile/+page.svelte +++ b/packages/ui/src/routes/profile/+page.svelte @@ -6,15 +6,14 @@ import Wallet from '$lib/components/icons/wallet.svelte' import WalletInfo from '$lib/components/wallet-info.svelte' import { formatAddress } from '$lib/utils' + import { connectWallet, canConnectWallet } from '$lib/services/blockchain' import { - connectWallet, - canConnectWallet, createIdentity, getGlobalAnonymousFeed, getContractGroup, joinGroupOffChain, joinGroupOnChain, - } from '$lib/services' + } from '$lib/services/zk' import { profile } from '$lib/stores/profile' let y: number