Adding ZKsync SSO
Right now the app is just missing ZKsync SSO integration. Let's add that.
Installation
Use expo
to install the react-native-zksync-sso
package:
npx expo install react-native-zksync-sso
Configuration File
The configuration file is used to define what ZKsync SSO contracts will be called during account creation and authentication. For this example, we will use the ZKsync SSO contracts deployed on ZKsync Era Sepolia testnet. If you want to use a different deployment of the SSO contracts, this is where you can configure those values.
In the config, there is also a private key required that represents the wallet that will deploy accounts.
Make sure the account has some testnet ETH on ZKsync Era Sepolia testnet.
Do not include the 0x
prefix in the private key.
import type { Config } from 'react-native-zksync-sso';
export const loadConfig = (): Config => {
const config: Config = {
// ZKsync Era Sepolia testnet contracts
contracts: {
session: '0x64Fa4b6fCF655024e6d540E0dFcA4142107D4fBC',
passkey: '0x006ecc2D79242F1986b7cb5F636d6E3f499f1026',
accountFactory: '0xd122999B15081d90b175C81B8a4a9bE3327C0c2a',
accountPaymaster: '0x4Cb1C15710366b73f3D31EC2b3092d5f3BFD8504',
recovery: '0x6AA83E35439D71F28273Df396BC7768dbaA9849D',
},
nodeUrl: 'https://sepolia.era.zksync.dev',
deployWallet: {
// replace with a valid private key for deploying accounts
// do not include the `0x` prefix
// ensure the account has enough funds to deploy accounts
// do not publicly commit this file with a real private key
privateKeyHex: '<YOUR_PRIVATE_KEY>',
},
};
return config;
};
Account Types
There are a few types missing from the types/types.ts
that our app needs.
Edit the types/types.ts
file to add types for the SSO account:
import type { Config, RpId } from 'react-native-zksync-sso';
export interface AccountInfo {
name: string;
userID: string;
rpId: RpId;
}
export interface DeployedAccount {
info: AccountInfo;
address: string;
uniqueAccountId: string;
}
export interface AccountDetails {
info: AccountInfo;
address: string;
shortAddress: string;
uniqueAccountId: string;
explorerURL: string;
balance?: string;
}
export type { Config };
RpId
The RpId is short for the relaying party ID, a key component of passkeys. It represents entity whose application is registering and authenticating users. This value ties the passkey created to your domain. You can use the same domain and RpId for different applications, so the same passkey works on all of the applications.
Copy/paste the code below into your app/index.tsx
file,
and modify the input string for the createRpId
function so it matches your domain.
For iOS, you only need to modify the first input value.
The second value is only used for Android.
import sdk from 'react-native-zksync-sso';
const rpId = sdk.utils.createRpId(
// The RpId maps to the domain only, not the full App ID
// ex: for the App ID dev.zksync.auth-test.SSOTutorialDemoRN
"auth-test.zksync.dev", // RP ID (same for both platforms)
"android:apk-key-hash:-sYXRdwJA3hvue3mKpYrOZ9zSPC7b4mbgzJmdZEDO5w" // Android origin
);
Then add the prop to the MainView
component:
<MainView rpId={rpId} />
Account Info
In the MainView
component,
add variables for the accountName
and accountUserID
.
Both of these values should be strings.
Note that the accountName
is what will show as the display name for the passkey when the user is prompted to sign a transaction.
The purpose of the accountUserID
is explained in the passkey creation section.
const accountName = 'Zeek';
const accountUserID = 'zeekUserId';
Account Client
The account client is what we will use to send transactions.
Add a new folder in the utils
folder called authenticate
.
accountClient.ts
Create a new file called accountClient.ts
in the utils/authenticate
folder.
Paste the code below:
import {
type Config,
type Account,
type PreparedTransaction,
type Transaction,
type SendTransactionResult,
type RpId,
sendTransactionAsyncSigner,
prepareSendTransaction,
} from 'react-native-zksync-sso';
import { Authenticator } from './authenticator';
export { type PreparedTransaction };
/**
* Helper class for account operations like transaction preparation and sending
*/
export class AccountClient {
private account: Account;
private rpId: RpId;
private config: Config;
constructor(account: Account, rpId: RpId, config: Config) {
this.account = account;
this.rpId = rpId;
this.config = config;
}
/**
* Prepares a transaction for sending
* @param transaction The transaction to prepare
* @returns Prepared transaction with fee information
*/
async prepareTransaction(transaction: Transaction): Promise<PreparedTransaction> {
const tx: Transaction = {
to: transaction.to,
value: transaction.value,
from: this.account.address,
input: transaction.input ?? undefined,
};
const preparedTransaction = await prepareSendTransaction(tx, this.config);
return preparedTransaction;
}
/**
* Sends a transaction
* @param transaction The transaction to send
* @returns Transaction hash
*/
async sendTransaction(transaction: Transaction): Promise<SendTransactionResult> {
const authenticator = new Authenticator(this.rpId);
const result = await sendTransactionAsyncSigner(transaction, authenticator, this.config);
return result;
}
}
There are two functions in the AccountClient
class:
prepareSendTransaction
: prepares the full transaction details of a transaction in case you want to display those.sendTransaction
: sends transactions and awaits for them to be confirmed.
authenticator.ts
Create another file in the utils/authenticate
folder called authenticator.ts
.
Paste the code below:
import sdk, { type PasskeyAuthenticatorAsync, type RpId } from 'react-native-zksync-sso';
const { authenticateWithPasskey } = sdk.authenticate;
/**
* Authenticator class that implements passkey-based authentication.
* This class handles the signing of messages using platform-specific passkey authentication.
*/
export class Authenticator implements PasskeyAuthenticatorAsync {
private rpId: RpId;
/**
* Creates a new Authenticator instance.
* @param rpId - The relying party ID used for passkey authentication
*/
constructor(rpId: RpId) {
this.rpId = rpId;
}
/**
* Signs a message using the platform's passkey authentication.
* @param message - The message to sign as an ArrayBuffer
* @returns A Promise that resolves to the signed message as an ArrayBuffer
*/
async signMessage(message: ArrayBuffer): Promise<ArrayBuffer> {
return await authenticateWithPasskey(message, this.rpId);
}
}
The Authenticator
class just has one method called signMessage
.
This method is what calls the device platform's passkey authentication
when a user is prompted to sign a transaction.
Account Utils
Add a new file called account.ts
in the utils
folder.
This file will have several functions related to the user's account.
Let's start by adding some imports we'll need:
import sdk, { type AccountBalance, type Account, getBalance, getAccountByUserId } from 'react-native-zksync-sso';
import { loadConfig } from './loadConfig';
import type { AccountDetails, AccountInfo, DeployedAccount } from '../types/types';
Passkey Creation
The first function we will add to the account utils will be createPasskey
.
This function creates a new passkey and generates a new wallet in three steps:
- Generates a random challenge (i.e. message) for the passkey to sign.
- Prepares the RpId for the passkey to be associated with.
- Registers the passkey and account with
registerAccountWithUniqueId
.
export const createPasskey = async (accountInfo: AccountInfo): Promise<DeployedAccount> => {
const config = loadConfig();
const challenge = sdk.utils.generateRandomChallenge();
const rpIdString = sdk.utils.getRpIdString(accountInfo.rpId);
const account = await sdk.register.registerAccountWithUniqueId(
{
name: accountInfo.name,
userID: accountInfo.userID,
rp: {
name: rpIdString,
id: accountInfo.rpId,
},
},
challenge,
config
);
return {
info: accountInfo,
address: account.address,
uniqueAccountId: account.uniqueAccountId,
};
};
Registration Options
There are two methods available to create a new wallet and passkey: registerAccount
and registerAccountWithUniqueId
.
registerAccount
creates a random wallet address.registerAccountWithUniqueId
creates a deterministic wallet address based on the deploy wallet private key and the user ID.
For web3 native applications, generally registerAccount
is recommended.
In this case, only the account address must be stored somewhere.
The account address cannot be derived in any way.
For web2 applications that already have a username or email used to login,
registerAccountWithUniqueId
can be used to create an address based on that account info.
Because the method also relies on a private key from the ZKsync SSO config,
this method should be called from a private backend server.
In this case, the address can be derived from the userID.
For this example, however, we will be using registerAccountWithUniqueId
without a backend server to demonstrate how that works.
This will allow us to derive the account details for testing purposes without storing them directly.
Note that because both values that determine the address are hardcoded here (the private key in the config and the account user ID), this will generate the same address for all users.
registerAccountWithUniqueId
should be done on a protected backend server,
and should ensure that each user ID is unique.Deriving the Account
Next, let's add a function called getAccountByUserIdWrapper
.
This function will use the configuration file and user ID to derive the user's account details.
export const getAccountByUserIdWrapper = async (uniqueAccountId: string): Promise<Account> => {
const config = loadConfig();
const account: Account = await getAccountByUserId(uniqueAccountId, config);
return account;
};
Account Balance
Add another function called loadBalance
to streamline fetching the user's ETH balance.
export const loadBalance = async (address: string): Promise<string> => {
const config = loadConfig();
const balance: AccountBalance = await getBalance(address, config);
return balance.balance;
};
Account Details
Finally, add a function called createAccountDetails
which we can use to create an AccountDetails
object.
This will help to easily display information for the account.
export function createAccountDetails(
accountInfo: AccountInfo,
deployedAccount: Account,
balance?: string
): AccountDetails {
const address = deployedAccount.address;
return {
info: accountInfo,
address,
shortAddress: shortenAddress(address),
uniqueAccountId: deployedAccount.uniqueAccountId,
explorerURL: `https://sepolia.explorer.zksync.io/address/${address}`,
balance,
};
}
function shortenAddress(address: string): string {
if (!address || address.length < 10) return address;
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
}
Mock Data
In the utils/mockData.ts
file is used to populate the friends list.
Edit at least one of these addresses with an address that you control.
Send Transaction
The last file we need to add is the sendETH.ts
file in the utils
folder.
This is where the sendETHWithSSO
function is implemented.
Paste the code below.
import { loadConfig } from './loadConfig';
import type { Transaction, RpId } from 'react-native-zksync-sso';
import { AccountClient } from './authenticate/accountClient';
interface FromAccount {
info: {
rpId: RpId;
name?: string;
userID?: string;
};
address: string;
uniqueAccountId: string;
}
export async function sendETHWithSSO(fromAccount: FromAccount, toAddress: `0x${string}`, amountInWei: string) {
try {
const config = loadConfig();
const accountClient = new AccountClient(
{
address: fromAccount.address,
uniqueAccountId: fromAccount.uniqueAccountId,
},
fromAccount.info.rpId,
config
);
const transaction: Transaction = {
to: toAddress as string,
value: amountInWei,
from: fromAccount.address,
input: undefined,
};
// sends tx and waits for the receipt automatically
const response = await accountClient.sendTransaction(transaction as Transaction);
return response;
} catch (err) {
console.error('Error sending transaction:', err);
}
}
In this function, a new account client is created,
which is used to send an ETH transfer transaction.
If you wanted to send ERC20 tokens instead,
you could modify the transaction
here to do that.
Running the app
Now the app is ready to build and test! Plug in your device, unlock it and select to trust your computer when prompted. Use the command below to build a development build of the app:
npm run ios:device
Select your device. This will take a minute or two. If you can see your app running and the sign in screen, great! If you just see the Expo splash screen, stop the Expo server. Then run:
npm run start
Use the QR code in the console to open the development build. Now you should be able to test the development build on your device!
react-native-zksync-sso
SDK will not work in a web preview or in the Expo Go app.
A development build is required.Testing the app
Start by creating a new account and passkey. Once signed in, go to your wallet tab, and hold down your account address to copy it. Now you can use this to send the account some testnet ETH from your regular wallet. Press the "Refresh" button to refresh your wallet balance.
Now, you can try sending some ETH to one of your friends. Once sent, you should see the transaction appear on the index tab. You can also try sending a transaction from one of the friend accounts.
Remember that if you want to try creating another new account and passkey,
you must either change the user ID or the deployer private key in the utils/loadConfig.ts
file.