Persisting CBSW Session Keys in IndexedDB

0 views

Coinbase team is soon going to add Session Keys support to the Smart Wallet!

Currently, you can play with/test the Coinbase Smart Wallet with Session Keys on Testnet by following the guide here.

If you have already been testing Session Keys you may have noticed that whenever you make a change to your frontend you need to redo the process of granting permissions, this is because permissionsContext and credential which are important variables required to perform transaction using Session Keys are state variables that get destroyed on hot refresh triggered every time you make a change.

credential-and-permissionsContext

Here is an example.

non-persisted-session-keys-demo

This can be very frustrating during the development process.

The official guide does mention this and suggest that we can persist permissionsContext and credential variables in IndexedDB

official-guide-suggestion

This guide helps you with exactly that!

Let's get started!

Prerequisites

You should have already integrated smart wallet into your dApp (here's a guide if you haven't) along with Session Keys support. This guide does not go over the integration part.

Installing dependencies

You can interact with IndexedDB via IndexedDB API directly however idb is a convenient npm package that streamlines the interaction with IndexedDB.

npm install idb

Writing a React hook

We will write a React hook that load and store value to IndexedDB very similar to how useState does.

useIndexedDBState will take a key which will be the objectStore name and an initialValue to be stored (if any).

We will have 2 objectStores one for credential and other for permissionsContext.

ReactReact's logo.
useIndexedDBState.tsx
src>hooks>useIndexedDBState.tsx
123456
import { useState } from "react";
const useIndexedDBState = (key: string, initialValue: unknown) => {
const [state, setState] = useState(initialValue);
return { state, setState };
};

Create the Database and defining the schema

Let's now write a function to create the db and define the schema.

ReactReact's logo.
useIndexedDBState.tsx
src>hooks>useIndexedDBState.tsx
12345678910111213141516171819202122232425262728
import { useEffect, useState } from "react";
import { openDB } from "idb";
async function getDatabase(key: string) {
return openDB("cbsw", 1, {
upgrade(db: any) {
if (!db.objectStoreNames.contains(key)) {
const contextStore = db.createObjectStore("context", {
keyPath: "address",
});
const credentialStore = db.createObjectStore("credential", {
keyPath: "address",
});
contextStore.createIndex("address", "address", {
unique: true,
});
credentialStore.createIndex("address", "address", {
unique: true,
});
}
},
});
}
const useIndexedDBState = (key: string, initialValue: unknown) => {
const [state, setState] = useState(initialValue);
return { state, setState };
};

getDatabase explained

The getDatabase takes in a key and tries to get the objectStore if it does not exist if creates it along with the schema.

The function openDB tries to open the version 1 of db called cbsw.

The openDB function also takes in a upgrade function, the upgrade function is called when we try to open version 1 of cbsw db and it does not exist.

So the upgrade function is the right place to define the schema, since it will called when we try to open the db the first time because it does not exist.

Inside the upgrade function we check if the objectStores permissionsContext and credential already exists.

if (!db.objectStoreNames.contains(key)) {
// objectStores do not exist.
}

Since they don't we can create the objectStores using createObjectStore which takes in the name and some optional properties, in our case keyPath which is used to specify the column/property we will use to search values in the objectStore.

In our example, the property we will use to search on both objectStores is address, more on that later.

if (!db.objectStoreNames.contains(key)) {
const contextStore = db.createObjectStore("context", {
keyPath: "address",
});
const credentialStore = db.createObjectStore("credential", {
keyPath: "address",
});
}

We can now create indexes using createIndex for the both objectStores, in our example below we are creating an index named address where the keyPath is address and we want to enforce address property to be unique.

We want to enforce uniqueness on the address property because if the user has multiple smart wallets connected to your dApp and each of them have session keys associated with them then you want your dApp to be able to load the session keys respective to the smart wallet that is currently connected which you can do by searching the objectStore using the connected smart wallet's address.

if (!db.objectStoreNames.contains(key)) {
const contextStore = db.createObjectStore("context", {
keyPath: "address",
});
const credentialStore = db.createObjectStore("credential", {
keyPath: "address",
});
contextStore.createIndex("address", "address", {
unique: true,
});
credentialStore.createIndex("address", "address", {
unique: true,
});
}

Now we have a function getDatabase that can create a db when it does not exist and get the db when it does.

Loading the database and values from IndexedDB (if it exists).

Let's now use a useEffect to create/load the db when the dApp mounts.

In the code below initDB is the called first when the dApp mounts and then everytime the key (the objectStore to access) or the connected wallet address changes.

ReactReact's logo.
useIndexedDBState.tsx
src>hooks>useIndexedDBState.tsx
1234567891011121314151617181920212223242526272829303132333435363738394041
import { useEffect, useState } from "react";
import { openDB } from "idb";
import { useAccount } from "wagmi";
async function getDatabase(key: string) {
return openDB("cbsw", 1, {
upgrade(db: any) {
if (!db.objectStoreNames.contains(key)) {
const contextStore = db.createObjectStore("context", {
keyPath: "address",
});
const credentialStore = db.createObjectStore("credential", {
keyPath: "address",
});
contextStore.createIndex("address", "address", {
unique: true,
});
credentialStore.createIndex("address", "address", {
unique: true,
});
}
},
});
}
const useIndexedDBState = (key: string, initialValue: unknown) => {
const [state, setState] = useState(initialValue);
const { address } = useAccount();
useEffect(() => {
const initDB = async () => {
if (address) {
const db = await getDatabase(key);
}
};
initDB();
}, [key, address]);
return { state, setState };
};

Once we get the DB, we can search for the value we are looking for using the objectStore name and connected wallet address.

The value retrieved can then be loaded onto state.

Depending on the key (credential or context) we load the value and set the respective as the state, this has to be done because the type of value is different for credential and context.

ReactReact's logo.
useIndexedDBState.tsx
src>hooks>useIndexedDBState.tsx
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
import { useEffect, useState } from "react";
import { openDB } from "idb";
import { useAccount } from "wagmi";
import { checksumAddress } from "viem";
async function getDatabase(key: string) {
return openDB("cbsw", 1, {
upgrade(db: any) {
if (!db.objectStoreNames.contains(key)) {
const contextStore = db.createObjectStore("context", {
keyPath: "address",
});
const credentialStore = db.createObjectStore("credential", {
keyPath: "address",
});
contextStore.createIndex("address", "address", {
unique: true,
});
credentialStore.createIndex("address", "address", {
unique: true,
});
}
},
});
}
const useIndexedDBState = (key: string, initialValue: unknown) => {
const [state, setState] = useState(initialValue);
const { address } = useAccount();
useEffect(() => {
const initDB = async () => {
if (address) {
const db = await getDatabase(key);
}
const storedValue = await db.get(key, checksumAddress(address));
if (storedValue !== undefined) {
if (key === "credential") {
const { credential } = storedValue as {
address: Hex;
credential: unknown;
};
setState(credential);
} else {
const { context } = storedValue as {
address: Hex;
context: unknown;
};
setState(context);
}
}
};
initDB();
}, [key, address]);
return { state, setState };
};

Now the hook can create/load DB, load values from DB (if they exist) and initialize the state.

Saving the React state values to IndexedDB

Let's now write another useEffect which will update the values in IndexedDB when the React state values change.

The saveToDB function is called when the frontend dApp changes the credential and permissionsContext value, so that the updated state values can then be stored in IndexedDB.

Depending on the key to useIndexedDBState the value is stored in the respective format.

We store the value using db.put which takes in the objectStore name and the value we want to store, in our example we store both the connected wallet address address and the value (state value of credential or context).

ReactReact's logo.
useIndexedDBState.tsx
src>hooks>useIndexedDBState.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
import { useEffect, useState } from "react";
import { openDB } from "idb";
import { checksumAddress } from "viem";
import { useAccount } from "wagmi";
async function getDatabase(key: string) {
return openDB("cbsw", 1, {
upgrade(db: any) {
if (!db.objectStoreNames.contains(key)) {
const contextStore = db.createObjectStore("context", {
keyPath: "address",
});
const credentialStore = db.createObjectStore("credential", {
keyPath: "address",
});
contextStore.createIndex("address", "address", {
unique: true,
});
credentialStore.createIndex("address", "address", {
unique: true,
});
}
},
});
}
const useIndexedDBState = (key: string, initialValue: unknown) => {
const [state, setState] = useState(initialValue);
const { address } = useAccount();
useEffect(() => {
const initDB = async () => {
if (address) {
const db = await getDatabase(key);
}
const storedValue = await db.get(key, checksumAddress(address));
if (storedValue !== undefined) {
if (key === "credential") {
const { credential } = storedValue as {
address: Hex;
credential: unknown;
};
setState(credential);
} else {
const { context } = storedValue as {
address: Hex;
context: unknown;
};
setState(context);
}
}
};
initDB();
}, [key, address]);
useEffect(() => {
const saveToDB = async () => {
if (address) {
const db = await getDatabase(key);
if (key === "credential") {
await db.put(key, {
address: checksumAddress(address),
credential: state,
});
} else {
await db.put(key, {
address: checksumAddress(address),
context: state,
});
}
}
};
if (state !== undefined) {
saveToDB();
}
}, [state, key]);
return { state, setState };
};
export default useIndexedDBState;

Fixing Types

Finally, let's add appropriate types to the hook.

ReactReact's logo.
useIndexedDBState.tsx
src>hooks>useIndexedDBState.tsx
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
import { useEffect, useState } from "react";
import { openDB } from "idb";
import { checksumAddress } from "viem";
import { useAccount } from "wagmi";
type Types = {
credential: Omit<P256Credential<"cryptokey">, "sign">;
context: Hex;
};
async function getDatabase(key: string) {
return openDB("cbsw", 1, {
upgrade(db: any) {
if (!db.objectStoreNames.contains(key)) {
const contextStore = db.createObjectStore("context", {
keyPath: "address",
});
const credentialStore = db.createObjectStore("credential", {
keyPath: "address",
});
contextStore.createIndex("address", "address", {
unique: true,
});
credentialStore.createIndex("address", "address", {
unique: true,
});
}
},
});
}
const useIndexedDBState = <T extends keyof Types>(
key: T,
initialValue: Types[T] | undefined
) => {
const [state, setState] = useState<Types[T] | undefined>(initialValue);
const { address } = useAccount();
useEffect(() => {
const initDB = async () => {
if (address) {
const db = await getDatabase(key);
}
const storedValue = await db.get(key, checksumAddress(address));
if (storedValue !== undefined) {
if (key === "credential") {
const { credential } = storedValue as {
address: Hex;
credential: Types[T];
};
setState(credential);
} else {
const { context } = storedValue as {
address: Hex;
context: Types[T];
};
setState(context);
}
}
};
initDB();
}, [key, address]);
useEffect(() => {
const saveToDB = async () => {
if (address) {
const db = await getDatabase(key);
if (key === "credential") {
await db.put(key, {
address: checksumAddress(address),
credential: state,
});
} else {
await db.put(key, {
address: checksumAddress(address),
context: state,
});
}
}
};
if (state !== undefined) {
saveToDB();
}
}, [state, key]);
return { state, setState };
};
export default useIndexedDBState;

That's it now the hook is ready to be used!

Using the useIndexedDBState hook

This is how you can use the hook in the your dApp.

The values credential and permissionsContext are now persisted in IndexedDB and can be used in your dApp.

export function App() {
const { state: permissionsContext, setState: setPermissionsContext } =
useIndexedDBState<"context">("context", undefined);
const { state: credential, setState: setCredential } =
useIndexedDBState<"credential">("credential", undefined);
// rest of the code
const callsId = await sendCallsAsync({
// other properties and arguments
capabilities: {
permissions: {
context: permissionsContext,
},
paymasterService: {
url: import.meta.env.VITE_PAYMASTER_URL,
},
},
signatureOverride: signWithCredential(credential),
});
}

Now you won't have to grant permissions after every reload, yay!

Preview

Here is the previous example with the new changes.

persisted-session-keys-demo

Here is the codebase, it has a lot of code which is subjective to the example.

Thanks to @lsr for providing some of the code above and answering my questions!