Build A tRPC Sign-In With Ethereum Monorepo
Build A Web App That Utilizes tRPC With SIWE For Authentication
NOTE: This is a first draft and I will be updating this shortly with all the code.
Building With tRPC
In this tutorials we'll be building a monorepo setup with Lerna that has a backend built with tRPC and a frontend built with ViteJS React Typescript.
This is a full walkthrough with the goal is to give you a guide/walkthrough on how you can set this up and all the nuances with getting SIWE (pronounced see-wee - just found this out) working with a tRPC backend.
There's A Video Walkthrough!
What Is tRPC
First off, what is this tRPC thing? tRPC is a lightweight typesafe alternative to GraphQL. It comes with all the advantages of batch requests and automatic type suggestions in your frontend without having to generate to write a schema.
The one caveat to this is that tRPC is meant to be used in a monorepo, which is why we are using Lerna to create our base for the mono repository.
If you aren't sold yet, just wait until we get to the frontend to see the magic of type definitions automatically showing up.
Requirements
Make you have the following installed on your computer.
- nvm or node v18.12.1
- pnpm v7.15.0
Monorepos & Lerna
If you aren't familiar with monorepos (mono repositories or singular repositories), they are a single repository that holds all code in one "monolithic" source. In this case, you would multiple services all stored in one place. For example you might have a frontend folder and a backend folder in the same git repository.
To help better manage this, there are existing libraries that aid with setting up and managing monorepos better. Lerna is one of those libraries, which we will be using.
To use Lerna, we'll start by creating a new folder and setting up our monorepo inside of it.
# Our project directory
mkdir trpc-siwe-monorepo;
cd trpc-siwe-monorepo;
# Lerna scaffolding out the base code for us
pnpx lerna init;
# Installing the dependencies
pnpm install;
# Making sure we initiate git to track files
git init;
ExpressJS Backend
Now that we have our monorepository setup, we can now start building out backend. You might be confused by the title of this section as "Express Backend" when this article is about tRPC. The reason we are still opting for ExpressJS is because tRPC has an adapter that works with Express.
Why would you still want to use Express when you can use tRPC directly as a server? One of the main reasons for this is because although tRPC and GraphQL offer great advantages, there are still some things that are best done in REST endpoints, like file uploading.
By having an ExpressJS and tRPC backend API, it will give us the flexibility of both worlds and allow us to take advantage of all the libraries that currently work with ExpressJS.
Backend Folder Setup & Dependencies
Let's start by creating our backend folder for our API.
# From ./trpc-siwe-monorepo
mkdir packages/trpc;
Next we'll install the necessary dependencies.
# From ./trpc-siwe-monorepo
cd packages/trpc;
pnpm init;
pnpm install typescript siwe iron-session express ethers dotenv cors @types/cores @types/express @types/node;
pnpm install -D nodemon ts-node;
With our dependencies installed, we now need to configure it to work with TypeScript. We'll run this script to generate our tsconfig.json
file.
# From ./trpc-siwe-monorepo/packages/trpc
./node_modules/.bin/tsc --init;
Let's create our existing folders and files for our backend API.
# From ./trpc-siwe-monorepo/packages/trpc
mkdir ./src;
touch ./src/server.ts;
touch ./src/app.ts;
touch ./.env;
touch ./.env.example;
touch ./.gitignore;
File: ./packages/trpc/.gitignore
node_modules
.env
File: ./packages/trpc/.env
File: ./packages/trpc/.env.example
DEV TIP: Whenever I'm creating a new repository with environment variables I always make sure to create a example copy of the origin environment variable file so that the next developer is able to easily copy it and see which values they need to replace.
PORT=5001
When it comes to my ExpressJS structure, I usually separate both my server listening and my app with all the routes to make things a bit cleaner and also help with potential testing down the line.
DEV TIP: You'll see a lot of comments with "===" and the reason I do this to make it easier to organize and visually see breaks in the code to find things faster.
File: ./packages/trpc/src/app.ts
// Imports
// ========================================================
import express from 'express';
import cors from 'cors';
// Constants
// ========================================================
const app = express();
// Config
// ========================================================
app.use(express.json());
app.use(cors({
credentials: true,
// Modify this to whitelist certain requests
origin: (origin, callback) => {
callback(null, true);
}
}));
// Routes
// ========================================================
/**
* Our first health check route
*/
app.get('/healthz', (_req, res) => {
return res.json({ ok: true });
});
// Exports
// ========================================================
export default app;
File: ./packages/trpc/src/server.ts
// Imports
// ========================================================
import app from "./app";
// Constants
// ========================================================
const PORT = process.env.PORT || 5001;
// Server
// ========================================================
app.listen(PORT, () => console.log(`Listening on port ${PORT}.`));
Let's get our server up and running to test that it's working correctly, but adding a run script to our package.json
.
File: ./packages/trpc/package.json
{
"name": "trpc",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
+ "dev": "nodemon src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"@types/node": "^18.11.9",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"ethers": "^5.7.2",
"express": "^4.18.2",
"iron-session": "^6.3.1",
"siwe": "^1.1.6",
"typescript": "^4.9.3"
},
"devDependencies": {
"nodemon": "^2.0.20",
"ts-node": "^10.9.1"
}
}
Running our server and checking out the /healthz
route we should get the following:
pnpm run dev;
# Expected Output:
# > nodemon src/server.ts
#
# [nodemon] 2.0.20
# [nodemon] to restart at any time, enter `rs`
# [nodemon] watching path(s): *.*
# [nodemon] watching extensions: ts,json
# [nodemon] starting `ts-node src/server.ts`
# Listening on port 5001.
Great out server is up and running, and now we can start adding tRPC functionality.
Creating Our First tRPC Route
Install the necessary dependencies.
pnpm install @trpc/server @trpc/server/adapters/express;
Let's modify our app.ts
to include our first tRPC route.
File: ./packages/trpc/src/app.ts
// Imports
// ========================================================
import express from 'express';
import cors from 'cors';
import { inferAsyncReturnType, initTRPC } from '@trpc/server';
// Note this must be * because otherwise the typings won't work correctly
import * as trpcExpress from '@trpc/server/adapters/express';
// Context
// ========================================================
// Context to interpret how requests will processed with Express
const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => ({ req, res });
// Types
// ========================================================
type Context = inferAsyncReturnType<typeof createContext>;
// Constants
// ========================================================
const app = express();
// Initiated tRPC instance for routes
const t = initTRPC.context<Context>().create();
// Config
// ========================================================
app.use(express.json());
app.use(cors({
credentials: true,
// Modify this to whitelist certain requests
origin: (origin, callback) => {
callback(null, true);
}
}));
// tRPC Routes
// ========================================================
const appRouter = t.router({
getUser: t.procedure.query((_req) => {
return { hello: 'there' }
})
});
// Routes
// ========================================================
/**
* Our first health check route
*/
app.get('/healthz', (_req, res) => {
return res.json({ ok: true });
});
/**
* Support for a dedicated tRPC route
*/
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext
})
);
// Exports
// ========================================================
export default app;
Let's run our server again but this time we'll request /trpc/getUser
.
Refactor For More Routes
We could keep adding more and more routes to our existing app.ts
file but it might turn out to create a huge file. To prevent this, we'll split the routes into separate folders to make it easier to manage.
To start, we'll need to separate our tRPC instance to make it its own file.
File: ./src/trpc.ts
// Imports
// ========================================================
import { inferAsyncReturnType, initTRPC } from '@trpc/server';
import { createContext } from './app';
// Types
// ========================================================
type Context = inferAsyncReturnType<typeof createContext>;
// Config
// ========================================================
// Initiated tRPC instance for routes
const t = initTRPC.context<Context>().create();
const router = t.router;
const publicProcedure = t.procedure;
const mergeRouters = t.mergeRouters;
// Exports
// ========================================================
export {
Context,
t,
router,
publicProcedure,
mergeRouters
}
Next we'll need to modify our app.ts
to accommodate for these changes.
File: ./src/trpc.ts
// Imports
// ========================================================
import express from 'express';
import cors from 'cors';
// Note this must be * because otherwise the typings won't work correctly
import * as trpcExpress from '@trpc/server/adapters/express';
// We'll be creating this next
import appRouter from './router';
// Context
// ========================================================
// Context to interpret how requests will processed with Express
const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => ({ req, res });
// Constants
// ========================================================
const app = express();
// Config
// ========================================================
app.use(express.json());
app.use(cors({
credentials: true,
// Modify this to whitelist certain requests
origin: (origin, callback) => {
callback(null, true);
}
}));
// Routes
// ========================================================
/**
* Our first health check route
*/
app.get('/healthz', (_req, res) => {
return res.json({ ok: true });
});
/**
* Support for a dedicated tRPC route
*/
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext
})
);
// Exports
// ========================================================
export default app;
export {
createContext
}
Now that we have refactored things, we can go ahead and create our routers utilizing the new mergeRouters
functionality.
# From './
mkdir packages/trpc/src/router;
mkdir packages/trpc/src/router/user;
touch packages/trpc/src/router/index.ts;
touch packages/trpc/src/router/user/index.ts;
File: ./packages/trpc/src/router/index.ts
// Imports
// ========================================================
import { mergeRouters } from '../trpc';
// We'll be creating this next
import UserRouter from './user';
// Routes
// ========================================================
const appRouter = mergeRouters(UserRouter);
// Exports
// ========================================================
export default appRouter;
Next up is creating our user router.
File: ./packages/trpc/src/router/user/index.ts
// Imports
// ========================================================
import { router, publicProcedure } from "../../trpc";
// User Router
// ========================================================
const UserRouter = router({
getUser: publicProcedure.query(() => {
return { hello: 'there from merged routing' }
})
});
// Exports
// ========================================================
export default UserRouter;
If everything works correctly, we should be getting the following when visiting /trpc/getUser
:
Sign-In With Ethereum
To take advantage of Sign-In With Ethereum (SIWE), we'll need to add support for iron-session
in our app.ts
file and add some additional configurations to our environment variables.
File: ./packages/trpc/src/app.ts
// Imports
// ========================================================
import express from 'express';
import cors from 'cors';
// Note this must be * because otherwise the typings won't work correctly
import * as trpcExpress from '@trpc/server/adapters/express';
import { ironSession } from 'iron-session/express';
import appRouter from './router';
// Context
// ========================================================
// Context to interpret how requests will processed with Express
const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) => ({ req, res });
// Constants
// ========================================================
const app = express();
// Config
// ========================================================
app.use(express.json());
app.use(cors({
credentials: true,
// Modify this to whitelist certain requests
origin: (origin, callback) => {
callback(null, true);
}
}));
app.use(ironSession({
cookieName: 'siwe',
password: process.env.IRON_SESSION_PASSWORD || 'UNKNOWN_IRON_SESSION_PASSWORD_32',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
},
}));
// Routes
// ========================================================
/**
* Our first health check route
*/
app.get('/healthz', (_req, res) => {
return res.json({ ok: true });
});
/**
* Support for a dedicated tRPC route
*/
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext
})
);
// Exports
// ========================================================
export default app;
export {
createContext
}
Now that we have our routes, let's replace our user route with a new auth route and add the functionality we'll need for SIWE.
# From ./
mv ./packages/trpc/src/router/user ./packages/trpc/src/router/auth;
Refactor our main router/index.ts
file to adjust for this.
File: ./packages/trpc/src/router/index.ts
// Imports
// ========================================================
import { mergeRouters } from '../trpc';
import AuthRouter from './auth';
// Routes
// ========================================================
const appRouter = mergeRouters(AuthRouter);
// Exports
// ========================================================
export default appRouter;
To support SIWE, we'll start by adding our nonce functionality.
File: ./packages/trpc/src/router/auth/index.ts
// Imports
// ========================================================
import { generateNonce } from 'siwe';
import { router, publicProcedure } from "../../trpc";
// User Router
// ========================================================
const AuthRouter = router({
authNonce: publicProcedure.query(async ({ ctx }) => {
// Get current date to setup session expiration
const currentDate = new Date();
// Setup Session
ctx.req.session.nonce = generateNonce();
ctx.req.session.issuedAt = currentDate.toISOString();
ctx.req.session.expirationTime = new Date(
currentDate.getTime() + 5 * 60 * 1000, // 5 minutes from the current time
).toISOString();
// Save Session
await ctx.req.session.save();
// Return
return {
nonce: ctx.req.session.nonce,
issuedAt: ctx.req.session.issuedAt,
expirationTime: ctx.req.session.expirationTime
}
})
});
// Exports
// ========================================================
export default AuthRouter;
You may get some typing errors with session
, but we'll fix this by adding the necessary types.
# From ./
mkdir ./packages/trpc/types;
mkdir ./packages/trpc/types/iron-session;
touch ./packages/trpc/types/iron-session/index.d.ts;
Now we just need to fill in our types for iron-session
.
File: ./packages/trpc/types/iron-session/index.d.ts
// Imports
// ========================================================
import 'iron-session';
import { SiweMessage } from 'siwe';
// Type
// ========================================================
declare module 'iron-session' {
interface IronSessionData {
nonce?: string;
issuedAt?: string;
expirationTime?: string;
siwe?: SiweMessage;
}
};
We'll also need to add support for this in our package.json
file.
File: ./packages/package.json
{
"name": "trpc",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
- "dev": "nodemon src/server.ts",
+ "dev": "nodemon --files src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@trpc/server": "10.0.0-rc.8",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"@types/node": "^18.11.9",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"ethers": "^5.7.2",
"express": "link:@trpc/server/adapters/express",
"iron-session": "^6.3.1",
"siwe": "^1.1.6",
"typescript": "^4.9.3"
},
"devDependencies": {
"nodemon": "^2.0.20",
"ts-node": "^10.9.1"
}
}
Now if run our sever again and request our new route /trpc/authNonce
we should get an updated response.
# From ./packages/trpc
pnpm run dev;
# Expected Ouput:
# > nodemon --files src/server.ts
#
# [nodemon] 2.0.20
# [nodemon] to restart at any time, enter `rs`
# [nodemon] watching path(s): *.*
# [nodemon] watching extensions: ts,json
# [nodemon] starting `ts-node --files src/server.ts`
# Listening on port 5001.
Next we'll add the remaining Sign-In With Ethereum routes, but before that we're going to take advantage of zod a TypeScript-first schema validation package.
# From ./packages/trpc
pnpm install zod;
NOTE: If you get this error, try removing both of your node_modules
, running a fresh install, uninstalling the dependency, and then installing it back.
# From './package/trpc
Error: Cannot find module 'express'
Require stack:
# Solution
pnpm uninstall express;
pnpm install express;
Now we can take advantage of it for our tRPC mutations.
File: ./packages/trpc/src/router/auth/index.ts
// Imports
// ========================================================
import { generateNonce } from 'siwe';
import { z } from 'zod';
import { SiweMessage } from 'siwe';
import { router, publicProcedure } from "../../trpc";
// User Router
// ========================================================
const AuthRouter = router({
/**
* Nonce
*/
authNonce: publicProcedure.query(async ({ ctx }) => {
// Get current date to setup session expiration
const currentDate = new Date();
// Setup Session
ctx.req.session.nonce = generateNonce();
ctx.req.session.issuedAt = currentDate.toISOString();
ctx.req.session.expirationTime = new Date(
currentDate.getTime() + 5 * 60 * 1000, // 5 minutes from the current time
).toISOString();
// Save Session
await ctx.req.session.save();
// Return
return {
nonce: ctx.req.session.nonce,
issuedAt: ctx.req.session.issuedAt,
expirationTime: ctx.req.session.expirationTime
}
}),
/**
* Verify
*/
authVerify: publicProcedure
.input(z.object({ message: z.string(), signature: z.string() }))
// To take advantge of input we need to access the full request
.mutation(async (req) => {
try {
const siweMessage = new SiweMessage(req.input.message);
const fields = await siweMessage.validate(req.input.signature);
// To access the express request to need to refer to ctx from the full request
// As req.ctx.req
if (fields.nonce !== req.ctx.req.session.nonce)
throw new Error('Invalid nonce.');
req.ctx.req.session.siwe = fields;
await req.ctx.req.session.save();
return { ok: true };
} catch (error as any) {
return {
ok: false,
error: error?.message
};
}
}),
/**
* Me
*/
authMe: publicProcedure.query(async ({ ctx }) => {
// Destroy session in case there was something there before and it was invalid
if (!ctx.req.session?.siwe?.address) {
ctx.req.session.destroy();
}
return { address: ctx.req.session.siwe?.address }
}),
/**
* Logout
*/
authLogout: publicProcedure.query(async ({ ctx }) => {
ctx.req.session.destroy();
return { ok: true };
})
});
// Exports
// ========================================================
export default AuthRouter;
Let's test these new routes with curl to see their results.
# From ./
# Nonce
curl --location --request GET 'http://localhost:5001/trpc/authNonce' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive'
# Expected Output:
# {
# "result": {
# "data": {
# "nonce": "czy7cvzSaY3mQBOo2",
# "issuedAt": "2022-11-21T09:11:56.730Z",
# "expirationTime": "2022-11-21T09:16:56.730Z"
# }
# }
# }
# Verify - With invalid message and signature
curl --location --request POST 'http://localhost:5001/trpc/authVerify' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive' \
--data-raw '{
"message": "hello",
"signature": "goodbye"
}'
# Expected Output:
# {
# "result": {
# "data": {
# "ok": false
# }
# }
# }
# Me
curl --location --request GET 'http://localhost:5001/trpc/authMe' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive' \
--data-raw '{
"message": "hello",
"signature": "goodbye"
}'
# Expected Output:
# {
# "result": {
# "data": {}
# }
# }
# Logout
curl --location --request GET 'http://localhost:5001/trpc/authLogout' \
--header 'Cache-Control: no-cache' \
--header 'Accept: */*' \
--header 'Accept-Encoding: gzip, deflate' \
--header 'Connection: keep-alive' \
--data-raw '{
"message": "hello",
"signature": "goodbye"
}'
# Expected Output:
# {
# "result": {
# "data": {
# "ok": true
# }
# }
# }
Now that we have our backend, we can now built out our frontend to interact with it.
React ViteJS Frontend
We'll be using ViteJS as a client-side React application to interact with our backend.
# From ./
pnpm create vite packages/react;
# Expected Prompts:
# ? Package name: › packages-react
#
# ? Select a framework: › - Use arrow-keys. Return to submit.
# Vanilla
# Vue
# ❯ React
# Preact
# Lit
# Svelte
# Others
#
# ? Select a variant: › - Use arrow-keys. Return to submit.
# JavaScript
# ❯ TypeScript
If we run our React application we should get the default ViteJS UI.
# From ./packages/react;
pnpm install;
pnpm run dev;
# Expected Output:
# VITE v3.2.4 ready in 506 ms
#
# ➜ Local: http://127.0.0.1:5173/
# ➜ Network: use --host to expose
Support Tailwind Styling
We'll definitely want to change the UI and add support for SIWE. We'll start by installing our dependencies and configuring Tailwind.
# From ./packages/react
pnpm install -D tailwindcss postcss autoprefixer;
pnpm install wagmi ethers siwe;
Some more configurations with Tailwind:
# From ./packages/react
pnpx tailwindcss init -p;
File: ./packages/react/tailwind.config.cjs
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
File: ./packages/react/src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
Next I want to do some clean up to remove some files that won't be needed for our application.
# From ./packages/react
rm -rf src/assets;
rm src/App.css
Refactor Frontend For SIWE UI
To support the UI we want to interact with our tRPC Sign-In With Ethereum backend, we're going to modify it to include the necessary elements and do some organization to fit the following:
File: ./packages/react/src/main.tsx
// Imports
// ========================================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
// Render
// ========================================================
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
File: ./packages/react/src/App.tsx
// Imports
// ========================================================
import { useState } from "react";
// Component
// ========================================================
const App = () => {
// State / Props
const [state, setState] = useState<{
isLoading?: boolean;
isSignedIn?: boolean;
nonce?: {
expirationTime?: string;
issuedAt?: string;
nonce?: string;
},
address?: string;
error?: Error | string,
}>({});
// Hooks
// Requests
// Functions
// On Mount
// Render
return (
<main>
<div className="p-6">
<h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>
<button className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>
<button className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button>
<div className="border-t border-zinc-800 pt-4">
<label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"> </code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
<label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
<label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
<label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
<code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
</div>
</div>
</main>
)
};
// Exports
// ========================================================
export default App;
To support SIWE, we're going to do need wrap the application in its provider, but we'll abstract it to allow for multiple providers later on.
# From ./
mkdir ./packages/react/src/providers;
mkdir ./packages/react/src/providers/wagmi;
touch ./packages/react/src/providers/index.tsx;
mkdir ./packages/react/src/providers/wagmi/index.tsx;
File: ./packages/react/src/providers/wagmi/index.tsx
// Imports
// ========================================================
import { createClient, WagmiConfig } from "wagmi";
import { getDefaultProvider } from 'ethers';
// Config
// ========================================================
const client = createClient({
autoConnect: true,
provider: getDefaultProvider(),
});
// Provider
// ========================================================
const WagmiProvider = ({ children }: { children: React.ReactNode }) => {
return <WagmiConfig client={client}>{children}</WagmiConfig>
};
// Exports
// ========================================================
export default WagmiProvider;
Next we'll support for this provider in the root provider.
File: ./packages/react/src/providers/index.tsx
// Imports
// ========================================================
import WagmiProvider from "./wagmi";
// Root Provider
// ========================================================
const RootProvider = ({ children }: { children: React.ReactNode }) => {
return <div>
<WagmiProvider>
{children}
</WagmiProvider>
</div>
};
// Exports
// ========================================================
export default RootProvider;
Lastly to support all these providers, we'll add it to our main.tsx
file.
File: ./packages/react/src/main.tsx
// Imports
// ========================================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import RootProvider from './providers';
import App from './App';
import './index.css';
// Render
// ========================================================
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<RootProvider>
<App />
</RootProvider>
</React.StrictMode>
);
Wallet Connection Support
Next we'll want to hide certain elements if the user hasn't connected their wallet, and show another if they have.
File: ./packages/react/src/App.tsx
// Imports
// ========================================================
import { useState } from "react";
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { InjectedConnector } from 'wagmi/connectors/injected';
// Component
// ========================================================
const App = () => {
// State / Props
const [state, setState] = useState<{
isLoading?: boolean;
isSignedIn?: boolean;
nonce?: {
expirationTime?: string;
issuedAt?: string;
nonce?: string;
},
address?: string;
error?: Error | string,
}>({});
// Hooks
const { address, isConnected } = useAccount();
const { connect, error: errorConnect } = useConnect({
connector: new InjectedConnector(),
});
const { disconnect, error: errorDisconnect } = useDisconnect();
// Requests
// Functions
// On Mount
// Render
return (
<main>
<div className="p-6">
<h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>
{!isConnected ? <button onClick={() => connect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>: null}
{isConnected ? <button onClick={() => disconnect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button> : null}
{errorConnect || errorDisconnect ? <code className="bg-zinc-800 text-white p-4 mb-10 block">{JSON.stringify({ error: errorConnect?.message || errorDisconnect?.message || 'Unknown wallet error.' }, null, ' ')}</code> : null}
{isConnected
? <div className="border-t border-zinc-800 pt-4">
<label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block">{address}</code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
<label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
<label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
<label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
<code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
</div>
: null}
</div>
</main>
)
};
// Exports
// ========================================================
export default App;
With that updated functionality we should when the user has connected their wallet, disconnects, or has an error.
tRPC Support
Next up we need to add support for tRPC on the client-side and to do that we need to install some additional dependencies.
# From ./packages/react
pnpm add @trpc/client @trpc/server @trpc/react-query @tanstack/react-query
Next we need to create a new tRPC file that we can use throughout the application that references the backend tRPC AppRouter
.
# From ./packages/react
mkdir ./src/utils;
touch ./src/utils/trpc.ts
File: ./packages/react/src/utils/trpc.ts
// Imports
// ========================================================
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../../trpc/src/router';
// Config
// ========================================================
const trpc = createTRPCReact<AppRouter>();
// Exports
// ========================================================
export default trpc;
Next we'll add the necessary support for providers for tRPC and Tanstack React Query.
# From ./packages/react
mkdir ./src/providers/trpc;
mkdir ./src/providers/query;
touch ./src/providers/trpc/index.tsx;
touch ./src/providers/query/index.tsx;
File: ./packages/react/src/providers/query/index.tsx
// Imports
// ========================================================
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Config
// ========================================================
const queryClient = new QueryClient();
// Provider
// ========================================================
const QueryProvider = ({ children }: { children: React.ReactNode }) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
};
// Exports
// ========================================================
export default QueryProvider;
export {
queryClient
}
For our tRPC provider, we'll need to add some new environment variables for our React application. Remember that ViteJS supports environment variables with a prefix of VITE_
.
# From ./packages/react
echo "VITE_TRPC_SERVER_URL=\"http://localhost:5001\"" > ./.env.local.example;
cp ./.env.local.example ./.env.local
With that done, we can now create our tRPC provider.
File: ./packages/react/src/providers/trpc/index.tsx
// Imports
// ========================================================
import { httpBatchLink } from '@trpc/client';
import trpc from '../../utils/trpc';
import { queryClient } from '../query';
// Config
// ========================================================
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: `${import.meta.env.VITE_TRPC_SERVER_URL}/trpc`,
// Needed to support session cookies
fetch(url, options) {
return fetch(url, {
...options,
credentials: 'include'
})
}
}),
],
});
// Provider
// ========================================================
const TRPCProvider = ({ children }: { children: React.ReactNode }) => {
return <trpc.Provider client={trpcClient} queryClient={queryClient}>{children}</trpc.Provider>
};
// Exports
// ========================================================
export default TRPCProvider;
Lastly, we just need to add support for them in our RootProvider
.
File: ./packages/react/src/providers/index.tsx
// Imports
// ========================================================
import WagmiProvider from "./wagmi";
import TRPCProvider from "./trpc";
import QueryProvider from "./query";
// Root Provider
// ========================================================
const RootProvider = ({ children }: { children: React.ReactNode }) => {
return <div>
<WagmiProvider>
<TRPCProvider>
<QueryProvider>
{children}
</QueryProvider>
</TRPCProvider>
</WagmiProvider>
</div>
};
// Exports
// ========================================================
export default RootProvider;
Now that we have tRPC setup with React, we can now start creating requests. We'll first start off by creating a request for a nonce.
File: ./packages/react/src/App.tsx
// Imports
// ========================================================
import { useEffect, useState } from "react";
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { InjectedConnector } from 'wagmi/connectors/injected';
import trpc from './utils/trpc';
// Component
// ========================================================
const App = () => {
// State / Props
const [state, setState] = useState<{
isLoading?: boolean;
isSignedIn?: boolean;
nonce?: {
expirationTime?: string;
issuedAt?: string;
nonce?: string;
},
address?: string;
error?: Error | string,
}>({});
// Hooks
const { address, isConnected } = useAccount();
const { connect, error: errorConnect } = useConnect({
connector: new InjectedConnector(),
});
const { disconnect, error: errorDisconnect } = useDisconnect();
// Requests
const authNonce = trpc.authNonce.useQuery();
// Functions
// On Mount
/**
* Retrieve a new nonce every time the page loads
*/
useEffect(() => {
if (!isConnected || !authNonce.data) return;
setState((x) => ({ ...x, nonce: authNonce.data }));
}, [isConnected, authNonce?.data]);
// Render
return (
<main>
<div className="p-6">
<h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>
{!isConnected ? <button onClick={() => connect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>: null}
{isConnected ? <button onClick={() => disconnect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button> : null}
{errorConnect || errorDisconnect ? <code className="bg-zinc-800 text-white p-4 mb-10 block">{JSON.stringify({ error: errorConnect?.message || errorDisconnect?.message || 'Unknown wallet error.' }, null, ' ')}</code> : null}
{isConnected
? <div className="border-t border-zinc-800 pt-4">
<label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block">{address}</code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
<label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
<label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
<label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
<code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
</div>
: null}
</div>
</main>
)
};
// Exports
// ========================================================
export default App;
If everything works correctly, you should see an output in our UI with the nonce data.
SIWE Signature Prompt
Next we'll add support for our signature request prompt.
File: ./packages/react/src/App.tsx
// Imports
// ========================================================
import { useEffect, useState } from "react";
import { useAccount, useConnect, useDisconnect, useNetwork, useSignMessage } from 'wagmi';
import { InjectedConnector } from 'wagmi/connectors/injected';
import { SiweMessage } from 'siwe';
import trpc from './utils/trpc';
// Component
// ========================================================
const App = () => {
// State / Props
const [state, setState] = useState<{
isLoading?: boolean;
isSignedIn?: boolean;
nonce?: {
expirationTime?: string;
issuedAt?: string;
nonce?: string;
},
address?: string;
error?: Error | string,
}>({});
// Hooks
const { address, isConnected } = useAccount();
const { connect, error: errorConnect } = useConnect({
connector: new InjectedConnector(),
});
const { disconnect, error: errorDisconnect } = useDisconnect();
const { chain } = useNetwork();
const { signMessageAsync } = useSignMessage();
// Requests
const authNonce = trpc.authNonce.useQuery();
const authVerify = trpc.authVerify.useMutation();
// Functions
/**
* Performs SIWE Signature Prompt Request
* @returns {void}
*/
const signIn = async () => {
try {
const chainId = chain?.id
if (!address || !chainId) return;
setState((x) => ({ ...x, isLoading: true }));
// Create SIWE message with pre-fetched nonce and sign with wallet
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in with Ethereum to the app.',
uri: window.location.origin,
version: '1',
chainId,
expirationTime: state.nonce?.expirationTime,
issuedAt: state.nonce?.expirationTime,
nonce: state.nonce?.nonce,
});
const signature = await signMessageAsync({
message: message.prepareMessage(),
});
authVerify.mutate({ message, signature });
} catch (error) {
setState((x) => ({ ...x, isLoading: false, nonce: undefined, error: error as Error }));
}
};
// On Mount
/**
* Retrieve a new nonce every time the page loads
*/
useEffect(() => {
if (!isConnected || !authNonce.data) return;
setState((x) => ({ ...x, nonce: authNonce.data }));
}, [isConnected, authNonce?.data]);
/**
* Hooks into auth verification and sets if the result is successful
*/
useEffect(() => {
if (!isConnected || !authNonce.data) return;
if (authVerify.data?.ok) {
setState((x) => ({ ...x, isSignedIn: true, isLoading: false }));
} else {
setState((x) => ({ ...x, isSignedIn: false, isLoading: false, error: authVerify.data?.error }));
}
}, [authVerify.data]);
// Render
return (
<main>
<div className="p-6">
<h1 className="text-2xl text-white font-medium mb-4 border-b border-zinc-800 pb-4">tRPC SIWE Monorepo</h1>
{!isConnected ? <button onClick={() => connect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Connect Wallet</button>: null}
{isConnected ? <button onClick={() => disconnect()} className="h-10 mb-4 block rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Disconnect</button> : null}
{errorConnect || errorDisconnect ? <code className="bg-zinc-800 text-white p-4 mb-10 block">{JSON.stringify({ error: errorConnect?.message || errorDisconnect?.message || 'Unknown wallet error.' }, null, ' ')}</code> : null}
{isConnected
? <div className="border-t border-zinc-800 pt-4">
<label className="text-sm text-zinc-400 block mb-2">Wallet Connected</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block">{address}</code>
<button onClick={signIn} className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Sign-In With Ethereum</button>
<label className="text-sm text-zinc-400 block mb-2">Message & Signature</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify(state, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-blue-600 hover:bg-blue-700 transition-colors ease-in-out duration-200">Retrive User Session Info</button>
<label className="text-sm text-zinc-400 block mb-2">Session Information (/trpc/authMe)</label>
<code className="bg-zinc-800 text-white p-4 mb-10 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
<button className="h-10 mb-4 rounded-full px-6 text-white bg-red-600 hover:bg-red-700 transition-colors ease-in-out duration-200">Log Out</button>
<label className="text-sm text-zinc-400 block mb-2">Log Out Result</label>
<code className="bg-zinc-800 text-white p-4 block"><pre>{JSON.stringify({}, null, ' ')}</pre></code>
</div>
: null}
</div>
</main>
)
};
// Exports
// ========================================================
export default App;
The result of a successful prompt and signature should show that the user is isSignedIn
in the code section.
Adding AuthMe & AuthLogout
The final step to this would be to add the functionality to return if a user is signed in to verify the session and make sure to destroy the session with the logout.
File: ./packages/react/src/App.tsx
Code
If you want to see the full code repository, check out the GitHub link below.
What's Next
Look out for the next article to this that will demonstrate a way that you would gate certain routes and sections of your client-side React application with Sign-In With Ethereum and tRPC.
If you enjoyed this article, give it some love by sharing it on Twitter, Reddit, or anywhere.
In case you want to follow me for more updates, check out my Twitter at @codingwithmanny.