feat: ZKLoginClient

This commit is contained in:
Kyle Fang
2023-12-12 08:21:52 +08:00
parent 6b03eb6b58
commit 9d29f8d75c
6 changed files with 584 additions and 9 deletions

4
.gitignore vendored
View File

@@ -66,4 +66,6 @@ node_modules/
.env
.envrc.override
build
build
.gitconfig

View File

@@ -10,22 +10,26 @@
"preview": "vite preview"
},
"dependencies": {
"@mysten/dapp-kit": "0.10.2",
"@mysten/sui.js": "0.48.0",
"@mysten/zklogin": "^0.3.9",
"@radix-ui/colors": "^3.0.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^2.0.0",
"@tanstack/react-query": "^5.0.0",
"axios": "^1.6.2",
"dnum-cjs": "^2.9.0",
"fabric": "^5.3.0",
"firebase": "^10.7.1",
"jwt-decode": "^4.0.0",
"model": "../model",
"query-string": "^8.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^6.20.1",
"react-router-dom": "^6.20.1",
"styled-components": "^6.1.1",
"styled-normalize": "^8.1.0",
"@mysten/dapp-kit": "0.10.2",
"@mysten/sui.js": "0.48.0",
"@radix-ui/colors": "^3.0.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^2.0.0",
"@tanstack/react-query": "^5.0.0"
"styled-normalize": "^8.1.0"
},
"devDependencies": {
"@types/fabric": "^5.3.6",
@@ -41,4 +45,4 @@
"vite": "^5.0.0",
"vite-plugin-pages": "^0.32.0"
}
}
}

69
app/pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ dependencies:
'@mysten/sui.js':
specifier: 0.48.0
version: 0.48.0
'@mysten/zklogin':
specifier: ^0.3.9
version: 0.3.9
'@radix-ui/colors':
specifier: ^3.0.0
version: 3.0.0
@@ -26,15 +29,24 @@ dependencies:
axios:
specifier: ^1.6.2
version: 1.6.2
dnum-cjs:
specifier: ^2.9.0
version: 2.9.0
fabric:
specifier: ^5.3.0
version: 5.3.0
firebase:
specifier: ^10.7.1
version: 10.7.1
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
model:
specifier: ../model
version: link:../model
query-string:
specifier: ^8.1.0
version: 8.1.0
react:
specifier: ^18.2.0
version: 18.2.0
@@ -946,6 +958,16 @@ packages:
'@wallet-standard/core': 1.0.3
dev: false
/@mysten/zklogin@0.3.9:
resolution: {integrity: sha512-mOdw0Gjbst0epu7TJJRkh5xb5nXKTTLAvpn3x9jKopvq2np/sIlLiZYjNtUR2aKHM+pIg8EKZLubh/vfKZuP6g==}
dependencies:
'@mysten/bcs': 0.9.0
'@mysten/sui.js': 0.48.0
'@noble/hashes': 1.3.2
jose: 4.15.4
poseidon-lite: 0.2.0
dev: false
/@noble/curves@1.2.0:
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
dependencies:
@@ -2998,6 +3020,11 @@ packages:
dev: false
optional: true
/decode-uri-component@0.4.1:
resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==}
engines: {node: '>=14.16'}
dev: false
/decompress-response@4.2.1:
resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==}
engines: {node: '>=8'}
@@ -3091,6 +3118,12 @@ packages:
path-type: 4.0.0
dev: true
/dnum-cjs@2.9.0:
resolution: {integrity: sha512-truheGbCvjrsPGTKvig62+eECxgPWupjvAMyVKeiBVCDNY0klj+icBTOaZhYQX1tjuNjiZ9FI12ZMJAy8IsWnw==}
dependencies:
from-exponential: 1.1.1
dev: false
/doctrine@3.0.0:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
@@ -3371,6 +3404,11 @@ packages:
to-regex-range: 5.0.1
dev: true
/filter-obj@5.1.0:
resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==}
engines: {node: '>=14.16'}
dev: false
/find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -3450,6 +3488,10 @@ packages:
mime-types: 2.1.35
dev: false
/from-exponential@1.1.1:
resolution: {integrity: sha512-VBE7f5OVnYwdgB3LHa+Qo29h8qVpxhVO9Trlc+AWm+/XNAgks1tAwMFHb33mjeiof77GglsJzeYF7OqXrROP/A==}
dev: false
/fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
@@ -3852,6 +3894,10 @@ packages:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
/jose@4.15.4:
resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==}
dev: false
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
dev: false
@@ -3930,6 +3976,11 @@ packages:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
/jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
dev: false
/keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
dependencies:
@@ -4268,6 +4319,10 @@ packages:
pathe: 1.1.1
dev: true
/poseidon-lite@0.2.0:
resolution: {integrity: sha512-vivDZnGmz8W4G/GzVA72PXkfYStjilu83rjjUfpL4PueKcC8nfX6hCPh2XhoC5FBgC6y0TA3YuUeUo5YCcNoig==}
dev: false
/postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
dev: false
@@ -4318,6 +4373,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
/query-string@8.1.0:
resolution: {integrity: sha512-BFQeWxJOZxZGix7y+SByG3F36dA0AbTy9o6pSmKFcFz7DAj0re9Frkty3saBn3nHo3D0oZJ/+rx3r8H8r8Jbpw==}
engines: {node: '>=14.16'}
dependencies:
decode-uri-component: 0.4.1
filter-obj: 5.1.0
split-on-first: 3.0.0
dev: false
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
requiresBuild: true
@@ -4624,6 +4688,11 @@ packages:
dev: false
optional: true
/split-on-first@3.0.0:
resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==}
engines: {node: '>=12'}
dev: false
/stop-iteration-iterator@1.0.0:
resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==}
engines: {node: '>= 0.4'}

View File

@@ -3,6 +3,7 @@ import { Container, Flex, Heading, Text } from "@radix-ui/themes";
import { OwnedObjects } from "./OwnedObjects";
import { Mint } from "./Mint";
import { Query } from "./Query";
import { ZKLogin } from "./ZKLogin";
export function WalletStatus() {
const account = useCurrentAccount();
@@ -23,6 +24,8 @@ export function WalletStatus() {
<OwnedObjects />
<h1>Mint</h1>
<Mint />
<h1>zklogin</h1>
<ZKLogin />
<h1>Query</h1>
<Query />
</Container>

View File

@@ -0,0 +1,225 @@
import { useEffect, useState } from "react";
import { ZKLoginStore, client, upsertSalt } from "./zklogin.store";
import queryString from "query-string";
import { useLocation, useNavigate } from "react-router";
import { useSuiClientQuery } from "@mysten/dapp-kit";
import { MIST_PER_SUI } from "@mysten/sui.js/utils";
import * as dn from "dnum-cjs";
import { TransactionBlock } from "@mysten/sui.js/transactions";
function getTransactionBlock(sender: string): TransactionBlock {
const txb = new TransactionBlock();
txb.setSender(sender);
// txb.setGasPrice(5);
// txb.setGasBudget(5_000_000);
txb.moveCall({
target: `0xebc67aa17051eaea7c373e5b72c267dcd7267ce060e79479559eee3eaee3f49b::story_nft_display::mint`,
arguments: [
txb.pure("image 2"),
txb.pure(
"premium_photo-1669324357471-e33e71e3f3d8?q=80&w=4140&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
),
],
});
return txb;
}
export const ZKLogin = () => {
const location = useLocation();
const navigate = useNavigate();
const [address, setAddress] = useState<string>(
"0xedecedb35529bcbfb7edd620634b748d15e3af0c9734dd520b3d275017213b96"
);
const [digest, setDigest] = useState<string>("");
const [suiToken, setSuiToken] = useState<bigint>(1n);
const [store, setStore] = useState<ZKLoginStore>(new ZKLoginStore());
useEffect(() => {
(async () => {
const oauthParams = queryString.parse(location.hash);
if (
oauthParams &&
oauthParams.id_token &&
store.info?.id_token !== oauthParams.id_token
) {
const salt = await upsertSalt(oauthParams.id_token as string);
const store = new ZKLoginStore({
salt,
id_token: oauthParams.id_token as string,
});
setStore(store);
}
})();
}, [location.hash, store.info?.id_token]);
const { data: addressBalance } = useSuiClientQuery(
"getBalance",
{
owner: store.client ? store.client.userAddress : "",
},
{
enabled: store.client ? Boolean(store.client.userAddress) : false,
refetchInterval: 1500,
}
);
if (store == null) {
return <div>loading...</div>;
}
return (
<div style={{ padding: 20 }}>
<div>
<button
onClick={() => {
store.resetStorage();
navigate("/debug");
}}
>
reset config
</button>
</div>
<div>userAddress: {store.client ? store.client.userAddress : ""}</div>
<div>
Balance:{" "}
{addressBalance?.totalBalance
? dn.format(
[
BigInt(addressBalance.totalBalance),
MIST_PER_SUI.toString().length - 1,
],
6
)
: "0.000000"}{" "}
SUI
</div>
<div>
<div>
Google Auth Status: {store.client ? "logged in" : "not logged in"}
</div>
{store.client == null && (
<button
onClick={() => {
store.signInWithGoogle();
}}
>
Login with ZKLogin
</button>
)}
</div>
{store.client && (
<div>
<button
onClick={() => {
store.client?.requestTestSUIToken();
}}
>
request test SUI token
</button>
</div>
)}
{store.client && (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 20,
padding: 20,
}}
>
<hr />
<div
style={{
padding: 20,
border: "1px solid black",
}}
>
<div>
transaction address:
<input
style={{
width: "100%",
}}
value={address}
onChange={(e) => {
setAddress(e.target.value);
}}
/>
</div>
<div>transaction digest: {digest}</div>
</div>
<div>
<div>
sui token
<input
value={suiToken.toString()}
onChange={(e) => {
const parseInt = Number.parseInt(e.target.value);
if (Number.isNaN(parseInt)) {
setSuiToken(0n);
return;
}
setSuiToken(BigInt(parseInt));
}}
/>
</div>
<button
onClick={async () => {
const txb = new TransactionBlock();
const [coin] = txb.splitCoins(txb.gas, [
suiToken * 1000000000n,
]);
txb.transferObjects([coin], address);
txb.setSender(store.client!.userAddress);
const { bytes, signature } = await txb.sign({
client,
signer: store.ephemeralKeyPair,
});
const res = await client.executeTransactionBlock({
transactionBlock: bytes,
signature: store.client!.genZkLoginSignature(signature),
});
setDigest(res.digest);
}}
>
execute transaction token
</button>
</div>
<div>
<button
onClick={async () => {
const tx = getTransactionBlock(store.client!.userAddress);
const { bytes, signature } = await tx.sign({
client,
signer: store.ephemeralKeyPair,
});
const res = await client.executeTransactionBlock({
transactionBlock: bytes,
signature: store.client!.genZkLoginSignature(signature),
});
setDigest(res.digest);
}}
>
execute mint
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,272 @@
import { SuiClient, getFullnodeUrl } from "@mysten/sui.js/client";
import { SerializedSignature } from "@mysten/sui.js/cryptography";
import { Ed25519Keypair } from "@mysten/sui.js/keypairs/ed25519";
import { fromB64 } from "@mysten/sui.js/utils";
import {
genAddressSeed,
generateNonce,
generateRandomness,
getExtendedEphemeralPublicKey,
getZkLoginSignature,
jwtToAddress,
} from "@mysten/zklogin";
import { jwtDecode, JwtPayload } from "jwt-decode";
import { auth, db } from "../firebase";
import { doc, getDoc, setDoc } from "firebase/firestore";
import { GoogleAuthProvider, signInWithCredential } from "firebase/auth";
const CLIENT_ID =
"78105453039-rc9sdmkol2dej5u365dfgq6bjeopmu0f.apps.googleusercontent.com";
const REDIRECT_URI = "http://localhost:5173/debug";
export const SUI_DEVNET_FAUCET = "https://faucet.devnet.sui.io/gas";
export const PROVER_URL = "https://prover-dev.mystenlabs.com/v1";
export const client = new SuiClient({ url: getFullnodeUrl("devnet") });
export type PartialZkLoginSignature = Omit<
Parameters<typeof getZkLoginSignature>["0"]["inputs"],
"addressSeed"
>;
export class ZKLoginStore {
public initialized = false;
ephemeralKeyPair!: Ed25519Keypair;
epoch?: {
currentEpoch: number;
maxEpoch: number;
};
nonce?: {
randomness: string;
currentNonce: string;
};
client?: ZKLoginClient;
constructor(readonly info?: { salt: string; id_token: string }) {
this.initailize();
}
resetStorage() {
window.sessionStorage.removeItem("ephemeralKeyPair");
window.sessionStorage.removeItem("randomness");
}
private async initailize() {
this.genEphemeralKeyPair();
await this.getEpoch();
this.genNone();
if (this.info != null) {
this.client = new ZKLoginClient(this, this.info.salt, this.info.id_token);
}
this.initialized = true;
}
private get publicKey() {
return this.ephemeralKeyPair.getPublicKey();
}
get extendedEphemeralPublicKey() {
return getExtendedEphemeralPublicKey(this.ephemeralKeyPair.getPublicKey());
}
private genEphemeralKeyPair() {
const ephemeralKeyPair = window.sessionStorage.getItem("ephemeralKeyPair");
if (ephemeralKeyPair) {
this.ephemeralKeyPair = Ed25519Keypair.fromSecretKey(
fromB64(ephemeralKeyPair)
);
} else {
this.ephemeralKeyPair = Ed25519Keypair.generate();
window.sessionStorage.setItem(
"ephemeralKeyPair",
this.ephemeralKeyPair.export().privateKey
);
}
}
private async getEpoch() {
const { epoch } = await client.getLatestSuiSystemState();
this.epoch = {
currentEpoch: Number(epoch),
maxEpoch: Number(epoch) + 10,
};
}
private genNone() {
if (this.epoch == null) {
throw new Error("epoch is not defined");
}
let randomness = window.sessionStorage.getItem("randomness");
if (randomness === null) {
randomness = generateRandomness();
window.sessionStorage.setItem("randomness", randomness);
}
const nonce = generateNonce(
this.publicKey,
this.epoch.maxEpoch,
randomness
);
this.nonce = {
randomness,
currentNonce: nonce,
};
}
async signInWithGoogle() {
if (this.nonce == null) {
throw new Error("nonce is not defined");
}
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "id_token",
scope: "openid email",
nonce: this.nonce.currentNonce,
});
const loginURL = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
// window.location.replace(loginURL);
window.location.href = loginURL;
}
}
class ZKLoginClient {
private readonly jwtPayload: JwtPayload;
private partialZkLoginSignature: PartialZkLoginSignature | undefined;
constructor(
readonly store: ZKLoginStore,
readonly salt: string,
readonly id_token: string
) {
this.jwtPayload = jwtDecode(id_token);
console.log(this.jwtPayload);
this.initailize();
}
private async initailize() {
await this.genPartialZkLoginSignature();
}
get userAddress() {
const zkLoginUserAddress = jwtToAddress(this.id_token, this.salt);
return zkLoginUserAddress;
}
async requestTestSUIToken() {
await fetch(SUI_DEVNET_FAUCET, {
method: "POST",
headers: {
"Content-Type": "application/json",
mode: "no-cors",
},
body: JSON.stringify({
FixedAmountRequest: {
recipient: this.userAddress,
},
}),
});
}
private async genPartialZkLoginSignature() {
if (!this.id_token) {
throw new Error("jwtString is not defined");
}
if (!this.store.nonce || !this.store.epoch) {
throw new Error("nonce or epoch is not defined");
}
const zkProofResult = await fetch(PROVER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
jwt: this.id_token,
extendedEphemeralPublicKey: this.store.extendedEphemeralPublicKey,
maxEpoch: this.store.epoch.maxEpoch,
jwtRandomness: this.store.nonce.randomness,
salt: this.salt,
keyClaimName: "sub",
}),
}).then((res) => res.json());
this.partialZkLoginSignature = zkProofResult as PartialZkLoginSignature;
}
private get addressSeed() {
if (this.jwtPayload == null) {
throw new Error("jwtPayload is not defined");
}
return genAddressSeed(
BigInt(this.salt),
"sub",
this.jwtPayload.sub!,
this.jwtPayload.aud as string
).toString();
}
genZkLoginSignature(signature: string) {
if (!this.store.epoch) {
throw new Error("epoch is not defined");
}
if (!this.partialZkLoginSignature) {
throw new Error("partialZkLoginSignature is not defined");
}
return getZkLoginSignature({
inputs: {
...this.partialZkLoginSignature,
addressSeed: this.addressSeed,
},
maxEpoch: this.store.epoch.maxEpoch,
userSignature: signature,
}) as SerializedSignature;
}
}
export const upsertSalt = async (id_token: string) => {
const credential = GoogleAuthProvider.credential(id_token);
await signInWithCredential(auth, credential);
if (auth.currentUser?.uid == null) {
throw new Error("auth.currentUser?.uid is not defined");
}
const docRef = doc(db, "users", auth.currentUser.uid);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
const data = docSnap.data();
if (data?.salt) {
return data.salt;
}
}
const salt = generateRandomness();
setDoc(
docRef,
{
salt,
},
{ merge: true }
);
return salt;
};