From b4114f1b3352c937aa156ef0a347ca0224bf2ce6 Mon Sep 17 00:00:00 2001 From: Peter Ferguson Date: Wed, 10 Apr 2024 14:33:30 +0100 Subject: [PATCH] wip: add wallet linking --- src/index.ts | 213 ++++++++++++++------- src/lib/eth/eip3770-shortnames.ts | 4 +- src/lib/eth/link-wallet-sign-typed-data.ts | 60 ++++++ src/lib/validators.ts | 1 + test/local-api-test.http | 12 +- 5 files changed, 216 insertions(+), 74 deletions(-) create mode 100644 src/lib/eth/link-wallet-sign-typed-data.ts diff --git a/src/index.ts b/src/index.ts index cf303c7..e86cb0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,26 @@ -import { Elysia, t } from 'elysia' -import { createXmtpGroup, createXmtpGroupValidator } from './actions/create-xmtp-group' -import { getGroup } from './actions/get-group' -import { syncPendingMembers } from './actions/sync-pending-members' -import { AddressLiteral } from './lib/validators' -import { getOwnersSafes } from './actions/get-owners-safes' -import { getGroupsByWalletAddresses } from './actions/get-group-by-wallet-address' -import { addMembers } from './actions/add-members' -import { removeMembers } from './actions/remove-members' -import { cron, Patterns } from '@elysiajs/cron' -import { db } from './db' -import { sql } from 'drizzle-orm' -import { bot } from './lib/xmtp/client' +import { Elysia, t } from "elysia"; +import { + createXmtpGroup, + createXmtpGroupValidator, +} from "./actions/create-xmtp-group"; +import { getGroup } from "./actions/get-group"; +import { syncPendingMembers } from "./actions/sync-pending-members"; +import { + AddressLiteral, + ChainAwareAddressLiteral, + HexLiteral, +} from "./lib/validators"; +import { getOwnersSafes } from "./actions/get-owners-safes"; +import { getGroupsByWalletAddresses } from "./actions/get-group-by-wallet-address"; +import { addMembers } from "./actions/add-members"; +import { removeMembers } from "./actions/remove-members"; +import { cron, Patterns } from "@elysiajs/cron"; +import { db } from "./db"; +import { sql } from "drizzle-orm"; +import { bot } from "./lib/xmtp/client"; +import { getLinkWalletEIP712TypedData } from "./lib/eth/link-wallet-sign-typed-data"; +import { verifyTypedData } from "viem"; +import { TypedDataDomain } from "abitype/zod"; /** * This service is responsible for keeping xmtp group chat members in sync with the members of a safe. @@ -36,7 +46,7 @@ import { bot } from './lib/xmtp/client' export default new Elysia() .use( cron({ - name: 'heartbeat', + name: "heartbeat", pattern: Patterns.EVERY_10_SECONDS, run() { console.log( @@ -45,108 +55,167 @@ export default new Elysia() sql`SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size();`, )[0] / 1024 } KB`, - ) + ); }, }), ) .use( cron({ - name: 'sync-pending-members', + name: "sync-pending-members", pattern: Patterns.EVERY_5_MINUTES, async run() { - console.log('try sync pending members') - await syncPendingMembers().catch((e) => console.error(e)) + console.log("try sync pending members"); + await syncPendingMembers().catch((e) => console.error(e)); }, }), ) - .get('/', async () => { - if (process.env.NODE_ENV === 'development') { - console.log('groups', await bot.listGroups()) - await bot.send('5eb5b1fa27adc585a75cdedd6a1d4d5d', 'Hello').catch(console.error) + .get("/", async () => { + if (process.env.NODE_ENV === "development") { + console.log("groups", await bot.listGroups()); + await bot + .send("5eb5b1fa27adc585a75cdedd6a1d4d5d", "Hello") + .catch(console.error); } - return 'Onit XMTP bot 🤖' + return "Onit XMTP bot 🤖"; }) - .group('/wallet/:address', { params: t.Object({ address: AddressLiteral }) }, (app) => { - return app.get('/', async ({ params: { address } }) => { - console.log('getting groups by address', address) - // - get the addresses safes - const safes = await getOwnersSafes(address) + .group( + "/wallet/:address", + { params: t.Object({ address: AddressLiteral }) }, + (app) => { + return app.get("/", async ({ params: { address } }) => { + console.log("getting groups by address", address); + // - get the addresses safes + const safes = await getOwnersSafes(address); - console.log('safes ->', safes) + console.log("safes ->", safes); - // - check for groups with the safe address - return (await getGroupsByWalletAddresses(safes)) || [] - }) - }) - .group('/group/:groupId', (app) => { + // - check for groups with the safe address + return (await getGroupsByWalletAddresses(safes)) || []; + }); + }, + ) + .group("/group/:groupId", (app) => { return app - .get('/', async ({ params: { groupId } }) => { - if (!groupId) return 'Invalid group id' - return await getGroup(groupId) + .get("/", async ({ params: { groupId } }) => { + if (!groupId) return "Invalid group id"; + return await getGroup(groupId); }) - .get('/members', async ({ params: { groupId } }) => { - if (!groupId) return 'Invalid group id' - return (await getGroup(groupId))?.pendingMembers || [] + .get("/members", async ({ params: { groupId } }) => { + if (!groupId) return "Invalid group id"; + return (await getGroup(groupId))?.pendingMembers || []; }) .post( - '/members', + "/members", async ({ params: { groupId }, body: { members, type } }) => { - const group = await getGroup(groupId) - if (!groupId || !group) return 'Invalid group id' + const group = await getGroup(groupId); + if (!groupId || !group) return "Invalid group id"; // - we only enable adding and removing members if a wallet is not already attached to the group if (group.wallets.length) - return 'Members on group chat with wallets are managed by who is a signer on each of the wallet' + return "Members on group chat with wallets are managed by who is a signer on each of the wallet"; switch (type) { - case 'add': - return addMembers(groupId, members) - case 'remove': - return removeMembers(groupId, members) + case "add": + return addMembers(groupId, members); + case "remove": + return removeMembers(groupId, members); default: - return 'Invalid type' + return "Invalid type"; } }, { body: t.Object({ members: t.Array(AddressLiteral), - type: t.Union([t.Literal('add'), t.Literal('remove')]), + type: t.Union([t.Literal("add"), t.Literal("remove")]), }), }, ) - .get('/wallets', async ({ params: { groupId } }) => { - if (!groupId) return 'Invalid group id' - return (await getGroup(groupId))?.wallets || [] - }) - .post('/link-wallet', async ({ params: { groupId }, body }) => { - // TODO: check that each member is a member of the group - // TODO: if so then add the wallet to the group - // TODO: if not return an error - return 'Not implemented' + .get("/wallets", async ({ params: { groupId } }) => { + if (!groupId) return "Invalid group id"; + return (await getGroup(groupId))?.wallets || []; }) + .group( + "/link-wallet", + { + params: t.Object({ + chainAwareAddress: ChainAwareAddressLiteral, + groupId: t.String(), + }), + }, + (app) => { + return app + .get( + "/:chainAwareAddress", + async ({ params: { groupId, chainAwareAddress } }) => { + return getLinkWalletEIP712TypedData(chainAwareAddress, groupId); + }, + ) + .post( + "/:chainAwareAddress", + async ({ params: { groupId, chainAwareAddress }, body }) => { + const group = await getGroup(groupId); + + if (!group) throw new Error("Group not found"); + + const { signature } = body; + + console.log("signature", signature); + + const signTypedData = getLinkWalletEIP712TypedData( + chainAwareAddress, + groupId, + ); + + // TODO: check that each member is a member of the group + // TODO: if so then add the wallet to the group + // TODO: if not return an error + return await Promise.any( + group?.members.map(async (member) => { + return await verifyTypedData({ + address: member.address, + ...signTypedData, + // @ts-expect-error: still throws even after parsing + domain: TypedDataDomain.parse(signTypedData.domain), + signature, + }); + }), + ); + }, + { + body: t.Object({ signature: HexLiteral }), + }, + ); + }, + ); }) - .group('/bot', (app) => { + .group("/bot", (app) => { return app - .get('/sync-pending-members', async () => { - const pendingMembers = await syncPendingMembers() - return JSON.stringify(pendingMembers, null, 4) + .get("/sync-pending-members", async () => { + const pendingMembers = await syncPendingMembers(); + return JSON.stringify(pendingMembers, null, 4); }) .post( - '/create', + "/create", async ({ body }) => { - const result = await createXmtpGroup(body) + const result = await createXmtpGroup(body); - const { groupId, members, pendingMembers, deployments } = result + const { groupId, members, pendingMembers, deployments } = result; - console.log('Created group', groupId, members, pendingMembers, deployments) + console.log( + "Created group", + groupId, + members, + pendingMembers, + deployments, + ); - if (!groupId) return 'Failed to create group' + if (!groupId) return "Failed to create group"; - return result + return result; }, { body: createXmtpGroupValidator }, - ) + ); }) .listen(8080, ({ hostname, port }) => { - console.log(`🦊 Elysia is running at http://${hostname}:${port}`) - }) + console.log(`🦊 Elysia is running at http://${hostname}:${port}`); + }); diff --git a/src/lib/eth/eip3770-shortnames.ts b/src/lib/eth/eip3770-shortnames.ts index c0206d5..2c470c8 100644 --- a/src/lib/eth/eip3770-shortnames.ts +++ b/src/lib/eth/eip3770-shortnames.ts @@ -132,7 +132,8 @@ export const chainShortNames = { 71402: "gw-mainnet-v1", 73799: "vt", 80001: "maticmum", - 84531: "base-gor", + 84531: "basegor", + 84532: "basesep", 200101: "milktada", 200202: "milktalgo", 333999: "olympus", @@ -145,6 +146,7 @@ export const chainShortNames = { 622277: "rth", 7777777: "zora", 11155111: "sep", + 11155420: "opsep", 245022926: "neonevm-devnet", 245022934: "neonevm-mainnet", 1313161554: "aurora", diff --git a/src/lib/eth/link-wallet-sign-typed-data.ts b/src/lib/eth/link-wallet-sign-typed-data.ts new file mode 100644 index 0000000..67ace71 --- /dev/null +++ b/src/lib/eth/link-wallet-sign-typed-data.ts @@ -0,0 +1,60 @@ +import { getAddress, type Address } from "viem"; +import type { ChainAwareAddress } from "../../db/schema"; +import { chainShortNames } from "./eip3770-shortnames"; +import type { TypedDataDomain } from "abitype/zod"; + +type EIP712Domain = Zod.infer; + +type LinkingMessage = { + address: Address; + groupId: string; +}; + +export function getLinkWalletEIP712TypedData( + safeAddress: ChainAwareAddress, + groupId: string, +) { + const [chainShortName, addressFromSplit] = safeAddress.split(":"); + + if (!chainShortName || !addressFromSplit) { + throw new Error("Invalid safe address"); + } + + const address = getAddress(addressFromSplit); + + const [chainId] = + Object.entries(chainShortNames).find( + ([_, shortName]) => shortName === chainShortName, + ) ?? []; + + if (!chainId) { + throw new Error(`Chain ${chainShortName} not supported`); + } + + const domain: EIP712Domain = { + name: "Link Safe With XMTP Group Chat", + version: "1", + chainId: Number(chainId), + }; + + const types = { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + ], + LinkingMessage: [ + { name: "address", type: "address" }, + { name: "groupId", type: "string" }, + ], + }; + + const message: LinkingMessage = { address, groupId }; + + return { + domain, + types, + primaryType: "LinkingMessage" as const, + message, + }; +} diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 107a609..6d972ce 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -7,6 +7,7 @@ export const uuidv7Schema = z.string().length(36).brand("Uuidv7"); export type Uuidv7 = z.infer; export type Uuid25 = z.infer; +export const HexLiteral = t.TemplateLiteral("0x${string}"); export const AddressLiteral = t.TemplateLiteral("0x${string}"); export const ChainAwareAddressLiteral = t.TemplateLiteral( "${string}:0x${string}", diff --git a/test/local-api-test.http b/test/local-api-test.http index 3ceca95..6ea6c2e 100644 --- a/test/local-api-test.http +++ b/test/local-api-test.http @@ -19,7 +19,7 @@ POST http://localhost:8080/bot/create HTTP/1.1 content-type: application/json { - "members": ["0xD452ba2fB26fB7b529178b3fa4B96b2719ca8D46"] + "members": ["0xDdD77754e23f2EA8bCd05D43C25084c6C81e82D8", "0xAF3fCab790DC38bCFcD6a19422c559d0aB57b29F"] } ### @@ -66,3 +66,13 @@ GET http://localhost:8080/group/4cf971dcb124e9e57fad653cdc6242f1/wallets HTTP/1. ### GET http://localhost:8080/0x524bF2086D4b5BBdA06f4c16Ec36f06AAd4E1Cad HTTP/1.1 +### +GET http://localhost:8080/group/ed4ac987e6556dc8802bb7d0fce39d1d/link-wallet/basesep:0xaC03aD602D6786e7E87566192b48e30666e327Ad HTTP/1.1 + +### +POST http://localhost:8080/group/ed4ac987e6556dc8802bb7d0fce39d1d/link-wallet/basesep:0xaC03aD602D6786e7E87566192b48e30666e327Ad HTTP/1.1 +Content-Type: application/json + +{ + "signature": "0x123" +} \ No newline at end of file