Adding ZKsync SSO

Complete your mobile payment app with ZKsync SSO integration

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.

Do not publicly commit this file with a real private key, or use this file in production. In a production app, the private key should be securely located in a private backend server. This will explained in more detail in the passkey creation section.
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:

  1. prepareSendTransaction: prepares the full transaction details of a transaction in case you want to display those.
  2. 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:

  1. Generates a random challenge (i.e. message) for the passkey to sign.
  2. Prepares the RpId for the passkey to be associated with.
  3. 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.

DO NOT use this exact implementation in production! For a production app, account creation with 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!

Note that the 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.

Share on X

Made with ❤️ by the ZKsync Community