diff --git a/examples/chat-siwe/.gitignore b/examples/chat-siwe/.gitignore new file mode 100644 index 00000000..40b33d31 --- /dev/null +++ b/examples/chat-siwe/.gitignore @@ -0,0 +1,9 @@ +.hathora +node_modules +dist +.env +/api +/data/* +!/data/saves +/client/prototype-ui/* +!/client/prototype-ui/plugins diff --git a/examples/chat-siwe/Dockerfile b/examples/chat-siwe/Dockerfile new file mode 100644 index 00000000..dc005ee3 --- /dev/null +++ b/examples/chat-siwe/Dockerfile @@ -0,0 +1,15 @@ +FROM node:16 + +WORKDIR /app + +RUN npm i -g hathora@0.10.2 + +ENV NODE_ENV=production + +ARG APP_SECRET +ENV APP_SECRET=${APP_SECRET} + +COPY . . +RUN hathora build --only server + +CMD ["node", "server/dist/index.mjs"] diff --git a/examples/chat-siwe/README.md b/examples/chat-siwe/README.md new file mode 100644 index 00000000..8d326ffe --- /dev/null +++ b/examples/chat-siwe/README.md @@ -0,0 +1,11 @@ +Try it at: https://hathora-chat.surge.sh/ + +![image](https://user-images.githubusercontent.com/5400947/149680221-98474638-e88c-47db-a3bd-8bca56a611aa.png) + +To run locally: + +- install hathora (`npm install -g hathora`) +- clone or download this repo +- cd into this directory +- run `hathora dev` +- visit http://localhost:3000 in your browser diff --git a/examples/chat-siwe/hathora.yml b/examples/chat-siwe/hathora.yml new file mode 100644 index 00000000..69f5ef03 --- /dev/null +++ b/examples/chat-siwe/hathora.yml @@ -0,0 +1,25 @@ +types: + Message: + text: string + sentAt: int + sentBy: UserId + sentTo: UserId? + RoomState: + users: UserId[] + messages: Message[] + +methods: + joinRoom: + leaveRoom: + sendPublicMessage: + text: string + sendPrivateMessage: + text: string + to: UserId + +auth: + siwe: + statement: "Sign in to Hathora" + +userState: RoomState +error: string diff --git a/examples/chat-siwe/server/impl.ts b/examples/chat-siwe/server/impl.ts new file mode 100644 index 00000000..687cfa78 --- /dev/null +++ b/examples/chat-siwe/server/impl.ts @@ -0,0 +1,48 @@ +import { Methods, Context } from "./.hathora/methods"; +import { Response } from "../api/base"; +import { UserId, RoomState, ISendPublicMessageRequest, ISendPrivateMessageRequest } from "../api/types"; + +export class Impl implements Methods { + initialize(): RoomState { + return { users: [], messages: [] }; + } + joinRoom(state: RoomState, userId: string): Response { + if (state.users.includes(userId)) { + return Response.error("Already joined!"); + } + state.users.push(userId); + return Response.ok(); + } + leaveRoom(state: RoomState, userId: string): Response { + if (!state.users.includes(userId)) { + return Response.error("Not joined"); + } + state.users.splice(state.users.indexOf(userId), 1); + return Response.ok(); + } + sendPublicMessage(state: RoomState, userId: UserId, ctx: Context, request: ISendPublicMessageRequest): Response { + if (!state.users.includes(userId)) { + return Response.error("Not joined"); + } + state.messages.push({ text: request.text, sentAt: ctx.time, sentBy: userId }); + return Response.ok(); + } + sendPrivateMessage(state: RoomState, userId: UserId, ctx: Context, request: ISendPrivateMessageRequest): Response { + if (!state.users.includes(userId)) { + return Response.error("Not joined"); + } + if (!state.users.includes(request.to)) { + return Response.error("Recpient not joined"); + } + state.messages.push({ text: request.text, sentAt: ctx.time, sentBy: userId, sentTo: request.to }); + return Response.ok(); + } + getUserState(state: RoomState, userId: UserId): RoomState { + return { + users: state.users, + messages: state.messages.filter( + (msg) => msg.sentBy === userId || msg.sentTo === userId || msg.sentTo === undefined + ), + }; + } +} diff --git a/examples/chat-siwe/server/package-lock.json b/examples/chat-siwe/server/package-lock.json new file mode 100644 index 00000000..d07a71b6 --- /dev/null +++ b/examples/chat-siwe/server/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "chat-server", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "chat-server", + "version": "0.0.1", + "devDependencies": { + "typescript": "^4.5.2" + } + }, + "node_modules/typescript": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", + "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + } + }, + "dependencies": { + "typescript": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", + "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "dev": true + } + } +} diff --git a/examples/chat-siwe/server/package.json b/examples/chat-siwe/server/package.json new file mode 100644 index 00000000..cf8c69aa --- /dev/null +++ b/examples/chat-siwe/server/package.json @@ -0,0 +1,8 @@ +{ + "name": "chat-server", + "version": "0.0.1", + "type": "module", + "devDependencies": { + "typescript": "^4.5.2" + } +} diff --git a/examples/chat-siwe/server/tsconfig.json b/examples/chat-siwe/server/tsconfig.json new file mode 100644 index 00000000..d0fc07e7 --- /dev/null +++ b/examples/chat-siwe/server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "module": "esnext", + "strict": true, + "target": "esnext", + "moduleResolution": "node", + "isolatedModules": true + } +} diff --git a/src/generate.ts b/src/generate.ts index e2f649a4..e10d3339 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -18,6 +18,10 @@ const HathoraConfig = z anonymous: z.optional(z.object({ separator: z.optional(z.string()).default("-") }).strict()), nickname: z.optional(z.object({}).strict()), google: z.optional(z.object({ clientId: z.string() }).strict()), + siwe: z.optional(z.object({ statement: z.optional(z.string()).default("Sign in with Ethereum"), + chainId: z.optional(z.number().int()).default(11155111), + publicAddress: z.optional(z.string()), + }).strict()), }) .strict(), userState: z.string(), diff --git a/templates/base/api/base.ts.hbs b/templates/base/api/base.ts.hbs index bcde1878..417ecf60 100644 --- a/templates/base/api/base.ts.hbs +++ b/templates/base/api/base.ts.hbs @@ -55,6 +55,8 @@ export interface {{capitalize @key}}UserData { email: string; locale: string; picture: string; +{{else if (eq @key "siwe")}} + publicAddress: string; {{/if}} } {{/each}} @@ -74,6 +76,8 @@ export function getUserDisplayName(user: UserData) { return user.name; {{else if (eq @key "google")}} return user.name; + {{else if (eq @key "siwe")}} + return user.publicAddress; {{else if (eq @key "email")}} return user.email; {{/if}} diff --git a/templates/base/client/.hathora/client.ts.hbs b/templates/base/client/.hathora/client.ts.hbs index 07043729..864c09e4 100644 --- a/templates/base/client/.hathora/client.ts.hbs +++ b/templates/base/client/.hathora/client.ts.hbs @@ -49,6 +49,10 @@ export class HathoraClient { return this._client.loginGoogle(idToken); } +{{else if (eq @key "siwe")}} + public async loginSiwe(message: string, signature: string, nonceToken: string): Promise { + return this._client.loginSiwe(message, signature, nonceToken); + } {{/if}} {{/each}} public async create(token: string, request: IInitializeRequest): Promise { diff --git a/templates/base/client/prototype-ui/Login.tsx.hbs b/templates/base/client/prototype-ui/Login.tsx.hbs index bf97e7f8..3ca8d14f 100644 --- a/templates/base/client/prototype-ui/Login.tsx.hbs +++ b/templates/base/client/prototype-ui/Login.tsx.hbs @@ -3,6 +3,9 @@ import { toast } from "react-toastify"; {{#if auth.google}} import { GoogleLogin } from "react-google-login"; {{/if}} +{{#if auth.siwe}} +import { SiweLogin } from "./SiweLogin"; +{{/if}} import { HathoraClient } from "../.hathora/client"; export function Login({ client, setToken }: { client: HathoraClient; setToken: (token: string) => void }) { @@ -72,6 +75,23 @@ export function Login({ client, setToken }: { client: HathoraClient; setToken: ( onFailure={(error) => toast.error("Authentication error: " + error.details)} /> +{{else if (eq @key "siwe")}} +
+ + { + client + .loginSiwe(message, signature, nonceToken) + .then(token => { + sessionStorage.setItem(client.appId, token) + setToken(token) + }) + .catch(e => toast.error(`Authentication error: ${JSON.stringify(e)}`)) + } + } + onFailure={(e) => {toast.error(`Authentication error: ${JSON.stringify(e)}`)}} + /> +
{{/if}} {{/each}} diff --git a/templates/base/client/prototype-ui/SiweLogin.tsx.hbs b/templates/base/client/prototype-ui/SiweLogin.tsx.hbs new file mode 100644 index 00000000..f0d4be8f --- /dev/null +++ b/templates/base/client/prototype-ui/SiweLogin.tsx.hbs @@ -0,0 +1,61 @@ +import React, { useCallback, useMemo } from "react"; +import ReactDOM from "react-dom"; +import { ethers } from 'ethers'; +import { SiweMessage } from 'siwe'; +import { JsonRpcSigner } from '@ethersproject/providers'; +import axios from "axios"; + +export type SiweLoginProps = { + statement?: string; + onSuccess: (payload: { message: string; signature: string; nonceToken: string; }) => Promise | void; + onFailure: (error: unknown) => Promise | void; +} + +const useConnectWallet = () => { + const provider = useMemo(() => new ethers.providers.Web3Provider((window as any).ethereum), []) + const signer = useMemo(() => provider.getSigner(), []) + const connectWallet = useCallback(async () => provider.send('eth_requestAccounts', []), [provider]) + return useMemo(() => ({ connectWallet, signer, provider }), [connectWallet, signer, provider]) +} + +const useAuthServer = () => { + const getNonce = useCallback(async () => { + //make request to auth server for nonce using axios + return axios.get('http://localhost:3001/nonce') + }, []); + return useMemo(() => ({ getNonce }), [getNonce]) +} + +const getSiweMessage = (address: string, nonce: string, statement?: string): Partial => ( + { + domain: window.location.host, + address: address, + statement: statement || 'Sign in to Hathora', + uri: window.location.origin, + version: '1', + chainId: 1, + nonce + } +); + +export const SiweLogin = ({ statement, onFailure, onSuccess }: SiweLoginProps) => { + const { connectWallet, signer } = useConnectWallet() + const { getNonce } = useAuthServer(); + const handleLogin = useCallback(async () => { + try { + await connectWallet() + const address = await signer.getAddress(); + const { nonce, nonceToken } = (await getNonce())?.data + const siweMessagePartial = getSiweMessage(address, nonce, statement) + const message = new SiweMessage(siweMessagePartial) + const messagePrepared = message.prepareMessage(); + const signature = await signer.signMessage(messagePrepared) + await onSuccess({ message: messagePrepared, signature, nonceToken }) + } catch (error) { + onFailure(error) + } + }, [connectWallet, onFailure, onSuccess, signer]) + return ( + + ) +} \ No newline at end of file diff --git a/templates/base/client/prototype-ui/package.json.hbs b/templates/base/client/prototype-ui/package.json.hbs index cfa50a60..890f245b 100644 --- a/templates/base/client/prototype-ui/package.json.hbs +++ b/templates/base/client/prototype-ui/package.json.hbs @@ -8,6 +8,10 @@ {{#if auth.google}} "react-google-login": "5.2.2", {{/if}} + {{#if auth.siwe}} + "ethers": "5.5.1", + "siwe": "1.1.6", + {{/if}} "react-router-dom": "6.2.1", "react-toastify": "8.1.0" }, diff --git a/templates/base/server/.hathora/store.ts.hbs b/templates/base/server/.hathora/store.ts.hbs index a265d3b6..e0405b48 100644 --- a/templates/base/server/.hathora/store.ts.hbs +++ b/templates/base/server/.hathora/store.ts.hbs @@ -159,6 +159,8 @@ const coordinator = await register({ nickname: {}, {{else if (eq @key "google")}} google: { clientId: "{{clientId}}" }, +{{else if (eq @key "siwe")}} + {{/if}} {{/each}} },