Quickstart: first zkLogin transaction
This page takes a user from a Google login to a signed Sui transaction. The code runs in a browser. For an iOS or Android app, read Native iOS and Android after this page. It uses testnet. Mainnet is the same flow with a mainnet key and the mainnet hostnames.
Before you start
- Create a Google OAuth client of type Web application. Add your page URL as an authorized redirect URI.
- Open zkLogin in the dashboard and add the Google client ID.
- Create an API key with the zkLogin and Data scopes. zkLogin is for the two HTTP calls. Data is for the gRPC gateway.
- Set allowed origins and a per-IP rate limit on the key, because it ships in a browser.
npm install @mysten/sui @protobuf-ts/grpcweb-transportShared setup for all steps:
// zklogin.ts
import { SuiGrpcClient } from '@mysten/sui/grpc'
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
import { Transaction } from '@mysten/sui/transactions'
import { getExtendedEphemeralPublicKey, getZkLoginSignature } from '@mysten/sui/zklogin'
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'
const API = 'https://testnet-api.inodra.com/v1'
const KEY = 'YOUR_API_KEY'
const GOOGLE_CLIENT_ID = 'YOUR_CLIENT_ID.apps.googleusercontent.com'
const REDIRECT_URI = window.location.origin + '/'
const headers = { 'x-api-key': KEY, 'Content-Type': 'application/json' }
const client = new SuiGrpcClient({
network: 'testnet',
transport: new GrpcWebFetchTransport({
baseUrl: 'https://testnet-grpc.inodra.com',
meta: { 'x-api-key': KEY }
})
})
async function post(path: string, body: unknown) {
const res = await fetch(`${API}${path}`, { method: 'POST', headers, body: JSON.stringify(body) })
const json = await res.json()
if (!res.ok) throw new Error(`${json.code}: ${json.error}`)
return json.data
}1. Make the ephemeral key and the nonce
The ephemeral key signs transactions for this session only. The nonce binds the JWT to that key and to maxEpoch.
Inodra reads the epoch from the network of your API key's project, so maxEpoch always fits that network. A new organization's default project is on mainnet.
async function startLogin() {
const ephemeral = Ed25519Keypair.generate()
const { nonce, jwtRandomness, maxEpoch } = await post('/zklogin/nonce', {
extendedEphemeralPublicKey: getExtendedEphemeralPublicKey(ephemeral.getPublicKey())
})
sessionStorage.setItem(
'zklogin:pending',
JSON.stringify({ secretKey: ephemeral.getSecretKey(), randomness: jwtRandomness, maxEpoch })
)
return nonce
}An epoch is about 24 hours. The default gives a session of two to three days. Send epochs to change it; the limit is 30.
2. Send the user to Google
async function redirectToGoogle() {
const nonce = await startLogin()
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'id_token',
scope: 'openid',
nonce
})
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`
}Google sends the user back with the JWT in the URL fragment:
function readJwt(): string | null {
const jwt = new URLSearchParams(window.location.hash.slice(1)).get('id_token')
if (jwt) history.replaceState(null, '', window.location.pathname)
return jwt
}3. Get the address
async function getAddress(jwt: string): Promise<string> {
const data = await post('/zklogin/address', { jwt })
console.log('address', data.address)
return data.address
}The address exists before any proof. The user can receive funds at once. This call costs 2 credits.
4. Get the proof
Do not send a salt. Inodra derives it, so the proof always matches the address from step 3.
async function getProof(jwt: string, address: string) {
const pending = JSON.parse(sessionStorage.getItem('zklogin:pending')!)
const ephemeral = Ed25519Keypair.fromSecretKey(pending.secretKey)
const proof = await post('/zklogin/prove', {
jwt,
extendedEphemeralPublicKey: getExtendedEphemeralPublicKey(ephemeral.getPublicKey()),
maxEpoch: pending.maxEpoch,
jwtRandomness: pending.randomness
})
const { maxEpoch, ...inputs } = proof
sessionStorage.setItem(
'zklogin:session',
JSON.stringify({ address, secretKey: pending.secretKey, inputs, maxEpoch })
)
sessionStorage.removeItem('zklogin:pending')
}This call takes about 3 seconds and costs 50 credits. Get one proof for each session and use it again for every transaction.
The ephemeral private key is a session secret. With the proof, it can sign for the user until
maxEpochpasses. Keep it insessionStorage, notlocalStorage. Do not send it to a server. Do not write it to a log.
5. Sign and execute
async function sendTransaction() {
const session = JSON.parse(sessionStorage.getItem('zklogin:session')!)
const ephemeral = Ed25519Keypair.fromSecretKey(session.secretKey)
const tx = new Transaction()
tx.setSender(session.address)
tx.moveCall({ target: '0x2::clock::timestamp_ms', arguments: [tx.object('0x6')] })
const bytes = await tx.build({ client })
const { signature: userSignature } = await ephemeral.signTransaction(bytes)
const zkLoginSignature = getZkLoginSignature({
inputs: session.inputs,
maxEpoch: session.maxEpoch,
userSignature
})
const result = await client.core.executeTransaction({
transaction: bytes,
signatures: [zkLoginSignature]
})
const executed = result.Transaction ?? result.FailedTransaction
console.log(result.$kind, executed.digest)
}The address pays its own gas here, so it needs testnet SUI. Use the dashboard Faucet page, or go to step 6.
Wire the steps together:
const jwt = readJwt()
if (jwt) {
const address = await getAddress(jwt)
await getProof(jwt, address)
await sendTransaction()
} else if (!sessionStorage.getItem('zklogin:session')) {
document.querySelector('#login')!.addEventListener('click', redirectToGoogle)
}6. Make it gasless
zkLogin signatures work with POST /v1/gas/submit. Your tank pays the gas and the user needs no SUI.
- The API key needs the zkLogin and Sponsor scopes.
- Build the transaction as the Fuel Station needs it. See Sponsoring transactions.
- Send the zkLogin signature from step 5 as
userSignature.
Start with the Fuel Station quickstart.
When the session ends
When the current epoch passes maxEpoch, the network rejects the signature.
- Read
maxEpochfrom the session before you sign. Compare it with the current epoch. - If the epoch passed it, delete the session and do steps 1 to 4 again.
The address stays the same. Only the ephemeral key and the proof are new.
If something fails
| What you see | Fix |
|---|---|
AUDIENCE_NOT_ALLOWED | Add the client ID on the dashboard zkLogin page. |
NONCE_MISMATCH | Send the same public key, maxEpoch, and randomness that made the nonce in step 1. |
INVALID_JWT | The JWT expired or is malformed. Google JWTs live for one hour. Log in again. |
MAX_EPOCH_OUT_OF_RANGE | Set maxEpoch between the current epoch and the current epoch + 30. |
INSUFFICIENT_SCOPE | Give the key the zkLogin scope. |
ORIGIN_NOT_ALLOWED | Add your page origin to the key's allowed origins. |
PROVER_BUSY | Wait for the Retry-After time, then try again with backoff. |
Every other code is in the API reference.