Skip to content

zk login poc #3585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/entrykit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,34 +53,43 @@
"@latticexyz/world-module-callwithsignature": "workspace:*",
"@radix-ui/react-dialog": "^1.0.5",
"@rainbow-me/rainbowkit": "2.1.7",
"@zk-email/jwt-tx-builder-helpers": "^0.1.0",
"debug": "^4.3.4",
"dotenv": "^16.0.3",
"permissionless": "0.2.25",
"permissionless": "0.2.28",
"react-error-boundary": "5.0.0",
"react-merge-refs": "^2.1.1",
"tailwind-merge": "^1.12.0",
"usehooks-ts": "^3.1.0",
"uuid": "^11.0.5",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tanstack/react-query": "^5.56.2",
"@types/cors": "^2.8.17",
"@types/debug": "^4.1.7",
"@types/express": "^5.0.0",
"@types/react": "18.2.22",
"@types/react-dom": "18.2.7",
"@types/ws": "^8.5.4",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"cors": "^2.8.5",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"express": "^4.21.2",
"ffjavascript": "^0.3.1",
"mprocs": "^0.7.1",
"postcss": "^8.4.47",
"react": "18.2.0",
"react-dom": "18.2.0",
"snarkjs": "^0.7.5",
"tailwindcss": "^3.4.13",
"viem": "2.21.19",
"vite": "^5.4.1",
"vite-plugin-dts": "^4.2.4",
"vite-plugin-externalize-deps": "^0.8.0",
"vite-plugin-node-polyfills": "^0.23.0",
"vitest": "0.34.6",
"wagmi": "2.12.11"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/entrykit/playground/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { UserWrite } from "./UserWrite";
import { ConnectButton } from "@rainbow-me/rainbowkit";
Expand Down Expand Up @@ -28,6 +28,7 @@ export function App() {
Open modal on mount
</label>
</div>

<div>
<ConnectButton />
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/entrykit/playground/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
<body>
<div id="react-root"></div>
<script type="module" src="./index.tsx"></script>
<script src="https://accounts.google.com/gsi/client" async defer></script>
</body>
</html>
8 changes: 7 additions & 1 deletion packages/entrykit/playground/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ root.render(
<StrictMode>
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<EntryKitConfigProvider config={defineConfig({ chainId, worldAddress })}>
<EntryKitConfigProvider
config={defineConfig({
chainId,
worldAddress,
googleClientId: "188183665112-uafieilii1f4rklscv0b7gj6e42lao42.apps.googleusercontent.com",
})}
>
<App />
<AccountModal />
</EntryKitConfigProvider>
Expand Down
2 changes: 2 additions & 0 deletions packages/entrykit/playground/public/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.zkey
*.wasm
3 changes: 2 additions & 1 deletion packages/entrykit/playground/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { defineConfig } from "vite";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [react()],
plugins: [react(), nodePolyfills()],
optimizeDeps: {
esbuildOptions: {
target: "es2020",
Expand Down
99 changes: 78 additions & 21 deletions packages/entrykit/src/ConnectWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,62 @@ import { Button } from "./ui/Button";
import { useConnectModal } from "@rainbow-me/rainbowkit";
import { AppInfo } from "./AppInfo";
import { twMerge } from "tailwind-merge";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState, useRef } from "react";
import { useJwtConnector } from "../src/useJwtConnector";
import { PendingIcon } from "./icons/PendingIcon";

declare const window: {
google: any;
} & Window;

const clientId = "188183665112-uafieilii1f4rklscv0b7gj6e42lao42.apps.googleusercontent.com";

export function ConnectWallet() {
const userAccount = useAccount();
const { openConnectModal, connectModalOpen } = useConnectModal();
const [hasAutoOpened, setHasAutoOpened] = useState(false);
const { openConnectModal } = useConnectModal();
const [generatingProof, setGeneratingProof] = useState(false);
const jwtConnector = useJwtConnector();
const buttonRef = useRef<HTMLDivElement>(null);
const googleRendered = useRef(false);

const jwtSigner = jwtConnector.getSigner();

const handleCredentialResponse = useCallback(
(response: any) => {
const { credential: jwt } = response;
setGeneratingProof(true);
jwtConnector.generateJwtProof(jwt).finally(() => setGeneratingProof(false));
},
[jwtConnector],
);

// automatically open connect modal once
// TODO: remove this once we have more than "connect wallet" as an option
useEffect(() => {
if (!connectModalOpen && !hasAutoOpened) {
openConnectModal?.();
setHasAutoOpened(true);
}
}, [connectModalOpen, hasAutoOpened, openConnectModal]);
if (googleRendered.current || !window.google || !jwtSigner || !buttonRef.current) return;
googleRendered.current = true;

window.google.accounts.id.cancel();
window.google.accounts.id.initialize({
client_id: clientId,
callback: handleCredentialResponse,
auto_select: false,
nonce: jwtSigner.address,
});
window.google.accounts.id.renderButton(buttonRef.current, {
theme: "icon",
width: "200",
});
}, [jwtSigner, handleCredentialResponse]);

// TODO: show error states?

const onClick = () => {
if (!buttonRef.current) {
return;
}

(buttonRef.current.querySelector("div[role=button]") as HTMLDivElement).click();
};

return (
<div
className={twMerge("flex flex-col gap-6 p-6", "animate-in animate-duration-300 fade-in slide-in-from-bottom-8")}
Expand All @@ -29,17 +67,36 @@ export function ConnectWallet() {
{/* TODO: render appImage if available? */}
<AppInfo />
</div>
<div className="self-center flex flex-col gap-2 w-60">
<Button
key="create"
variant="secondary"
className="self-auto flex justify-center"
disabled={userAccount.status === "connecting"}
onClick={openConnectModal}
autoFocus
>
Connect wallet
</Button>
<div className="flex justify-center w-full">
{generatingProof ? (
<div className="flex items-center justify-center gap-2">
<PendingIcon />
Generating proof...
</div>
) : (
<div className="flex flex-col gap-4 w-[220px] ">
<div role="button" ref={buttonRef} className="hidden" />
<Button
variant="secondary"
className="w-full flex justify-center"
disabled={userAccount.status === "connecting"}
onClick={onClick}
autoFocus={true}
>
Sign in with Google
</Button>

<Button
key="create"
variant="secondary"
className="w-full flex justify-center"
disabled={userAccount.status === "connecting"}
onClick={openConnectModal}
>
Connect wallet
</Button>
</div>
)}
</div>
</div>
);
Expand Down
13 changes: 8 additions & 5 deletions packages/entrykit/src/getSessionSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ export function getSessionSigner(userAddress: Address) {
const sessionSignerPrivateKey =
store.getState().signers[userAddress] ??
(() => {
const privateKey =
// attempt to reuse previous AccountKit session
localStorage.get(`mud:appSigner:privateKey:${userAddress.toLowerCase()}`) ??
// otherwise create a fresh one
generatePrivateKey();
// TODO: localStorage.get gets stuck??
// const privateKey =
// // attempt to reuse previous AccountKit session
// localStorage.get(`mud:appSigner:privateKey:${userAddress.toLowerCase()}`) ??
// // otherwise create a fresh one
// generatePrivateKey();

const privateKey = generatePrivateKey();
store.setState((state) => ({
signers: {
...state.signers,
Expand Down
13 changes: 11 additions & 2 deletions packages/entrykit/src/getWallets.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { WalletList, getDefaultWallets } from "@rainbow-me/rainbowkit";
import { jwtWallet } from "./jwt/jwtWallet";

export function getWallets(_config: { readonly chainId: number }): WalletList {
export function getWallets(config: { readonly chainId: number }): WalletList {
const { wallets: defaultWallets } = getDefaultWallets();
return [
// TODO: passkey wallet
{
groupName: "Recommended",
wallets: [
jwtWallet({
// TODO: allow any chain ID
chainId: config.chainId,
}),
],
},
...defaultWallets,
];
}
26 changes: 26 additions & 0 deletions packages/entrykit/src/jwt/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createStore } from "zustand/vanilla";
import { persist } from "zustand/middleware";
import { Hex } from "viem";

export type State = {
readonly jwtProof: any | null;
readonly signer: Hex | null;
};

export const cache = createStore(
persist<State>(
() => ({
jwtProof: null,
signer: null,
}),
{ name: "mud:jwt:cache" },
),
);

// keep cache in sync across tabs/windows via storage event
function listener(event: StorageEvent) {
if (event.key === cache.persist.getOptions().name) {
cache.persist.rehydrate();
}
}
window.addEventListener("storage", listener);
70 changes: 70 additions & 0 deletions packages/entrykit/src/jwt/generateProofLocal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// TODO: for some reason the package doesn't include a dist directory
import { generateJWTAuthenticatorInputs } from "@zk-email/jwt-tx-builder-helpers/src/input-generators";
import { Hex, encodeAbiParameters, parseAbiParameters } from "viem";
import * as snarkjs from "./snarkjs.js";

export async function generateProof(jwt: string) {
const startTime = performance.now();
let lastTime = startTime;

const header = JSON.parse(Buffer.from(jwt.split(".")[0], "base64").toString("utf-8"));
const payload = JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString("utf-8"));

const pubkeys = await fetch("https://www.googleapis.com/oauth2/v3/certs").then((res) => res.json());
const { n } = pubkeys.keys.find((key: any) => key.kid === header.kid);
const pubkey = { n, e: 65537 };

const accountCode = BigInt(payload.sub);

const circuitInputs = await generateJWTAuthenticatorInputs(jwt, pubkey, accountCode, {
maxMessageLength: 1024,
});

const inputGenTime = performance.now();
console.log(`Circuit input generation took ${(inputGenTime - lastTime).toFixed(2)}ms`);
lastTime = inputGenTime;

const serializedInputs = JSON.parse(
JSON.stringify(circuitInputs, (_, value) => (typeof value === "bigint" ? value.toString() : value)),
);

console.log("circuitInputs", serializedInputs);

const { proof, publicSignals } = await snarkjs.groth16.fullProve(serializedInputs, "jwt.wasm", "jwt.zkey");

console.log({ publicSignals });
const proofGenTime = performance.now();
console.log(`Proof generation took ${(proofGenTime - lastTime).toFixed(2)}ms`);
console.log(`Total time: ${(proofGenTime - startTime).toFixed(2)}ms`);

// TODO: use helper
const match = payload.email.match(/@(.+)$/);
if (!match) {
throw new Error(`Invalid email format: ${payload.email}`);
}

const domainName = match[1];

const jwtProof = {
kid: `0x${header.kid}`,
iss: payload.iss,
azp: payload.azp,
publicKeyHash: `0x${BigInt(publicSignals[3]).toString(16).padStart(64, "0")}`,
timestamp: BigInt(publicSignals[5]).toString(),
maskedCommand: payload.nonce,
emailNullifier: `0x${BigInt(publicSignals[4]).toString(16).padStart(64, "0")}`,
accountSalt: `0x${BigInt(publicSignals[26]).toString(16).padStart(64, "0")}` as Hex,
isCodeExist: publicSignals[30] == 1,
domainName,
proof: encodeAbiParameters(parseAbiParameters("uint256[2], uint256[2][2], uint256[2]"), [
proof.pi_a.slice(0, 2).map(BigInt),
[
[BigInt(proof.pi_b[0][1]), BigInt(proof.pi_b[0][0])],
[BigInt(proof.pi_b[1][1]), BigInt(proof.pi_b[1][0])],
],
proof.pi_c.slice(0, 2).map(BigInt),
]),
};
console.log("JWT proof:", jwtProof);
return jwtProof;
}
11 changes: 11 additions & 0 deletions packages/entrykit/src/jwt/getAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Chain, Client, Hex, Transport } from "viem";
import { ToJwtSmartAccountReturnType, toJwtSmartAccount } from "./toJwtSmartAccount";
import { getSigner } from "./getSigner";

export async function getAccount(
client: Client<Transport, Chain>,
jwtProof: any,
): Promise<ToJwtSmartAccountReturnType> {
const signer = getSigner();
return await toJwtSmartAccount({ client, jwtProof, signer });
}
16 changes: 16 additions & 0 deletions packages/entrykit/src/jwt/getSigner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { cache } from "./cache";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";

export function getSigner() {
const sessionSignerPrivateKey =
cache.getState().signer ??
(() => {
const privateKey = generatePrivateKey();
cache.setState(() => ({
signer: privateKey,
}));
return privateKey;
})();

return privateKeyToAccount(sessionSignerPrivateKey);
}
Loading
Loading