Skip to content

Commit

Permalink
Register for maci vote (#13)
Browse files Browse the repository at this point in the history
* Generate account & keys on initial page load

* Sponsor vote signup & update button rendering

* Check eligibility after joining semaphore group

* Clean up

* Update voice credits after sign up

* Call updateEligibility inside useEffect
  • Loading branch information
JohnGuilding authored Aug 27, 2024
1 parent 56483a7 commit 8bec6e5
Show file tree
Hide file tree
Showing 16 changed files with 344 additions and 210 deletions.
6 changes: 5 additions & 1 deletion packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ app.post('/send-otp', async (req, res) => {
await sendOtp(email)
return res.status(200).json({ message: 'OTP sent successfully' })
} catch (error) {
return res.status(500).json({ message: 'Failed to send OTP', error })
if (error instanceof Error) {
// TODO: (merge-ok) 500 isn't appropriate in all cases e.g. "User already registered". Add better error handling
return res.status(500).json({ message: error.message })
}
return res.status(500).json({ message: 'Failed to send OTP' })
}
})

Expand Down
4 changes: 2 additions & 2 deletions packages/interface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
"graphql-request": "^6.1.0",
"lowdb": "^1.0.0",
"lucide-react": "^0.316.0",
"maci-cli": "^2.1.0",
"maci-domainobjs": "^2.0.0",
"maci-cli": "^2.2.0",
"maci-domainobjs": "^2.2.0",
"next": "^14.1.0",
"next-auth": "^4.24.5",
"next-themes": "^0.2.1",
Expand Down
5 changes: 3 additions & 2 deletions packages/interface/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { EAppState } from "~/utils/types";
import { ConnectButton } from "./ConnectButton";
import { IconButton } from "./ui/Button";
import { Logo } from "./ui/Logo";
import useSmartAccount from "~/hooks/useSmartAccount";

interface INavLinkProps extends ComponentPropsWithRef<typeof Link> {
isActive: boolean;
Expand Down Expand Up @@ -60,6 +61,7 @@ const Header = ({ navLinks }: IHeaderProps) => {
const { ballot } = useBallot();
const appState = useAppState();
const { theme, setTheme } = useTheme();
const { smartAccount } = useSmartAccount();

const handleChangeTheme = useCallback(() => {
setTheme(theme === "light" ? "dark" : "light");
Expand Down Expand Up @@ -109,8 +111,7 @@ const Header = ({ navLinks }: IHeaderProps) => {
variant="ghost"
onClick={handleChangeTheme}
/>

<ConnectButton />
<p>{`Addr: ${smartAccount?.address ?? ""}`}</p>
</div>

<MobileMenu isOpen={isOpen} navLinks={navLinks} />
Expand Down
9 changes: 8 additions & 1 deletion packages/interface/src/components/JoinButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback } from "react";
import Link from "next/link";
import { toast } from "sonner";

import { useMaci } from "~/contexts/Maci";
Expand All @@ -16,10 +17,16 @@ export const JoinButton = (): JSX.Element => {

return (
<div>
{appState === EAppState.VOTING && !isEligibleToVote && (
{appState === EAppState.VOTING && !isEligibleToVote && isRegistered && (
<Button variant="disabled">You are not allowed to vote</Button>
)}

{appState !== EAppState.TALLYING && !isEligibleToVote && !isRegistered && (
<Button variant="primary">
<Link href="/signup/registerEmail">Register</Link>
</Button>
)}

{appState === EAppState.VOTING && isEligibleToVote && !isRegistered && (
<Button variant={isRegistered === undefined || isLoading ? "disabled" : "primary"} onClick={handleSignup}>
Voter sign up
Expand Down
3 changes: 2 additions & 1 deletion packages/interface/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as wagmiChains from "wagmi/chains";
import { Config } from "./utils/types";

export const metadata = {
title: "MACI PLATFORM",
Expand Down Expand Up @@ -91,7 +92,7 @@ export const getPimlicoRPCURL = (): string | undefined => {
}
};

export const config = {
export const config: Config = {
logoUrl: "/Logo.svg",
pageSize: 3 * 4,
// TODO: temp solution until we come up with solid one
Expand Down
100 changes: 49 additions & 51 deletions packages/interface/src/contexts/Maci.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Identity } from "@semaphore-protocol/core";
import { isAfter } from "date-fns";
import { type Signer, BrowserProvider } from "ethers";
import {
signup,
isRegisteredUser,
publishBatch,
type TallyData,
Expand All @@ -15,17 +14,18 @@ import {
getHatsSingleGatekeeperData,
} from "maci-cli/sdk";
import React, { createContext, useContext, useCallback, useEffect, useMemo, useState } from "react";
import { useAccount, useSignMessage } from "wagmi";

import { config } from "~/config";
import { useEthersSigner } from "~/hooks/useEthersSigner";
import useSmartAccount from "~/hooks/useSmartAccount";
import { api } from "~/utils/api";
import { getHatsClient } from "~/utils/hatsProtocol";
import { getSemaphoreProof } from "~/utils/semaphore";

import type { IVoteArgs, MaciContextType, MaciProviderProps } from "./types";
import type { EIP1193Provider } from "viem";
import { type EIP1193Provider } from "viem";
import type { Attestation } from "~/utils/types";
import signUp from "~/utils/signUp";

export const MaciContext = createContext<MaciContextType | undefined>(undefined);

Expand All @@ -35,9 +35,10 @@ export const MaciContext = createContext<MaciContextType | undefined>(undefined)
* @returns The Context data (variables and functions)
*/
export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProviderProps) => {
const signer = useEthersSigner();
const { address, isConnected, isDisconnected } = useAccount();
const { address, smartAccount, smartAccountClient } = useSmartAccount();
const signer = useEthersSigner({ client: smartAccountClient });

const [isEligibleToVote, setIsEligibleToVote] = useState<boolean>();
const [isRegistered, setIsRegistered] = useState<boolean>();
const [stateIndex, setStateIndex] = useState<string>();
const [initialVoiceCredits, setInitialVoiceCredits] = useState<number>(0);
Expand All @@ -55,7 +56,6 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
const [gatekeeperTrait, setGatekeeperTrait] = useState<GatekeeperTrait | undefined>();
const [sgData, setSgData] = useState<string | undefined>();

const { signMessageAsync } = useSignMessage();
const user = api.maci.user.useQuery(
{ publicKey: maciPubKey ?? "" },
{ enabled: Boolean(maciPubKey && config.maciSubgraphUrl) },
Expand Down Expand Up @@ -108,10 +108,10 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
// add custom logic for other gatekeepers here
switch (gatekeeperTrait) {
case GatekeeperTrait.Semaphore:
if (!signer) {
if (!signer || !semaphoreIdentity) {
return;
}
getSemaphoreProof(signer, semaphoreIdentity!)
getSemaphoreProof(signer, semaphoreIdentity)
.then((proof) => {
setSgData(proof);
})
Expand Down Expand Up @@ -148,17 +148,24 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
default:
break;
}
}, [gatekeeperTrait, attestationId, semaphoreIdentity, signer]);
}, [gatekeeperTrait, attestationId, semaphoreIdentity, signer, isEligibleToVote]);

// a user is eligible to vote if they pass certain conditions
// with gatekeepers like EAS it is possible to determine whether you are allowed
// just by fetching the attestation. On the other hand, with other
// gatekeepers it might be more difficult to determine it
// for instance with semaphore
const isEligibleToVote = useMemo(
() => gatekeeperTrait && (gatekeeperTrait === GatekeeperTrait.FreeForAll || Boolean(sgData)) && Boolean(address),
[sgData, address],
);
useEffect(() => {
updateEligibility(sgData, address);
}, [sgData, address])

const updateEligibility = (_sgData: string | undefined, _address: `0x${string}` | undefined) => {
setIsEligibleToVote(checkEligibility(_sgData, _address));
}

function checkEligibility(sgData: string | undefined, address: `0x${string}` | undefined): boolean {
return (gatekeeperTrait && (gatekeeperTrait === GatekeeperTrait.FreeForAll || Boolean(sgData)) && Boolean(address)) ?? false;
}

// on load get the key pair from local storage and set the signature message
useEffect(() => {
Expand Down Expand Up @@ -188,21 +195,27 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv

// generate the maci keypair using a ECDSA signature
const generateKeypair = useCallback(async () => {
// if we are not connected then do not generate the key pair
if (!address) {
if (!smartAccount) {
return;
}

const signature = await signMessageAsync({ message: signatureMessage });
const newSemaphoreIdentity = new Identity(signature);
const userKeyPair = genKeyPair({ seed: BigInt(signature) });
localStorage.setItem("maciPrivKey", userKeyPair.privateKey);
localStorage.setItem("maciPubKey", userKeyPair.publicKey);
localStorage.setItem("semaphoreIdentity", newSemaphoreIdentity.privateKey.toString());
setMaciPrivKey(userKeyPair.privateKey);
setMaciPubKey(userKeyPair.publicKey);
setSemaphoreIdentity(newSemaphoreIdentity);
}, [address, signatureMessage, signMessageAsync, setMaciPrivKey, setMaciPubKey, setSemaphoreIdentity]);
const maciPrivKey = localStorage.getItem("maciPrivKey");
const maciPubKey = localStorage.getItem("maciPubKey");
const semaphoreIdentity = localStorage.getItem("semaphoreIdentity");

// Only generate key pair & identity if values do not exist in local storage
if (!maciPrivKey || !maciPubKey || !semaphoreIdentity) {
const signature = await smartAccount.signMessage({ message: signatureMessage });
const newSemaphoreIdentity = new Identity(signature);
const userKeyPair = genKeyPair({ seed: BigInt(signature) });
localStorage.setItem("maciPrivKey", userKeyPair.privateKey);
localStorage.setItem("maciPubKey", userKeyPair.publicKey);
localStorage.setItem("semaphoreIdentity", newSemaphoreIdentity.privateKey.toString());
setMaciPrivKey(userKeyPair.privateKey);
setMaciPubKey(userKeyPair.publicKey);
setSemaphoreIdentity(newSemaphoreIdentity);
}
}, [smartAccount, signatureMessage, setMaciPrivKey, setMaciPubKey, setSemaphoreIdentity]);

// memo to calculate the voting end date
const votingEndsAt = useMemo(
Expand All @@ -213,23 +226,18 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
// function to be used to signup to MACI
const onSignup = useCallback(
async (onError: () => void) => {
if (!signer || !maciPubKey || (gatekeeperTrait && gatekeeperTrait !== GatekeeperTrait.FreeForAll && !sgData)) {
if (!smartAccount || !smartAccountClient || !maciPubKey || !sgData || (gatekeeperTrait && gatekeeperTrait !== GatekeeperTrait.FreeForAll && !sgData)) {
return;
}

setIsLoading(true);

try {
const { stateIndex: index } = await signup({
maciPubKey,
maciAddress: config.maciAddress!,
sgDataArg: sgData,
signer,
});

if (index) {
const { stateIndex, voiceCreditBalance } = await signUp(smartAccount, smartAccountClient, maciPubKey, sgData);
if (stateIndex) {
setIsRegistered(true);
setStateIndex(index);
setStateIndex(stateIndex.toString());
setInitialVoiceCredits(Number(voiceCreditBalance));
} else {
throw new Error("Unexpected event log arguments")
}
} catch (e) {
onError();
Expand All @@ -238,7 +246,7 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
setIsLoading(false);
}
},
[maciPubKey, signer, setIsRegistered, setStateIndex, setIsLoading, sgData],
[maciPubKey, setIsRegistered, setStateIndex, setIsLoading, sgData],
);

// function to be used to vote on a poll
Expand Down Expand Up @@ -284,17 +292,6 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
[stateIndex, pollData, maciPubKey, maciPrivKey, signer, setIsLoading, setError],
);

useEffect(() => {
if (isDisconnected) {
setMaciPrivKey(undefined);
setMaciPubKey(undefined);
setSemaphoreIdentity(undefined);
localStorage.removeItem("maciPrivKey");
localStorage.removeItem("maciPubKey");
localStorage.removeItem("semaphoreIdentity");
}
}, [isDisconnected]);

useEffect(() => {
generateKeypair().catch(console.error);
}, [generateKeypair]);
Expand All @@ -309,7 +306,7 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv

// check if the user already registered
useEffect(() => {
if (!isConnected || !signer || !maciPubKey || !address || isLoading) {
if (!signer || !maciPubKey || !address || isLoading) {
return;
}

Expand Down Expand Up @@ -337,7 +334,6 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
}
}, [
isLoading,
isConnected,
isRegistered,
maciPubKey,
address,
Expand Down Expand Up @@ -426,6 +422,7 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
maciPubKey,
onSignup,
onVote,
updateEligibility,
}),
[
isLoading,
Expand All @@ -440,6 +437,7 @@ export const MaciProvider: React.FC<MaciProviderProps> = ({ children }: MaciProv
maciPubKey,
onSignup,
onVote,
updateEligibility,
],
);

Expand Down
6 changes: 5 additions & 1 deletion packages/interface/src/contexts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export interface MaciContextType {
onVote: (
args: IVoteArgs[],
onError: () => void | Promise<void>,
onSuccess: () => void | Promise<void>,
onSuccess: () => void | Promise<void>
) => Promise<void>;
updateEligibility: (
sgData: string | undefined,
address: `0x${string}` | undefined
) => Promise<void>;
}

Expand Down
16 changes: 0 additions & 16 deletions packages/interface/src/hooks/useAccount.ts

This file was deleted.

25 changes: 17 additions & 8 deletions packages/interface/src/hooks/useEthersSigner.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { BrowserProvider, JsonRpcSigner } from "ethers";
import { useMemo } from "react";
import { HttpTransport, Chain, createWalletClient, http } from "viem";
import { SmartAccountClient } from "permissionless";
import { EntryPoint } from "permissionless/types";
import { useConnectorClient } from "wagmi";
import { getRPCURL } from "~/config";

import type { Account, Chain, Client, Transport } from "viem";

function clientToSigner(client: Client<Transport, Chain, Account>): JsonRpcSigner | undefined {
function clientToSigner(client: SmartAccountClient<EntryPoint, HttpTransport, Chain>): JsonRpcSigner | undefined {
const { account, chain, transport } = client;

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!chain) {
if (!chain || !account) {
return undefined;
}

const provider = new BrowserProvider(transport, {
const walletClient = createWalletClient({
account,
chain: chain,
transport: http(getRPCURL()),
})

const provider = new BrowserProvider(walletClient.transport, {
chainId: chain.id,
name: chain.name,
ensAddress: chain.contracts?.ensRegistry?.address,
Expand All @@ -22,8 +30,9 @@ function clientToSigner(client: Client<Transport, Chain, Account>): JsonRpcSigne
}

/** Hook to convert a viem Wallet Client to an ethers.js Signer. */
export function useEthersSigner({ chainId }: { chainId?: number } = {}): JsonRpcSigner | undefined {
const { data: client } = useConnectorClient({ chainId });
export function useEthersSigner({ chainId, client }: { chainId?: number, client?: SmartAccountClient<EntryPoint, HttpTransport, Chain> } = {}): JsonRpcSigner | undefined {
const { data: connectorClient } = useConnectorClient({ chainId });
const resolvedClient = client ?? connectorClient;

return useMemo(() => (client ? clientToSigner(client) : undefined), [client]);
return useMemo(() => (resolvedClient ? clientToSigner(resolvedClient) : undefined), [resolvedClient]);
}
Loading

0 comments on commit 8bec6e5

Please sign in to comment.