From fcbb45f1e502d1d398ba9b119141ce04536da503 Mon Sep 17 00:00:00 2001 From: Peter Ferguson Date: Thu, 11 Apr 2024 12:04:16 +0100 Subject: [PATCH] feat: add cron to gate the group chat via safe membership --- src/actions/get-deployments.ts | 6 +- src/actions/remove-members.ts | 45 +++-- .../sync-group-chats-with-safe-members.ts | 157 ++++++++++++++++++ src/actions/sync-stored-members-with-xmtp.ts | 1 + src/index.ts | 12 +- 5 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 src/actions/sync-group-chats-with-safe-members.ts diff --git a/src/actions/get-deployments.ts b/src/actions/get-deployments.ts index 84f2cad..e25d944 100644 --- a/src/actions/get-deployments.ts +++ b/src/actions/get-deployments.ts @@ -11,7 +11,7 @@ export async function getDeployments({ address: Address | ChainAwareAddress; type: "safe" | "party"; }) { - let deployments: { address: ChainAwareAddress; members: Address[] }[] = []; + const deployments: { address: ChainAwareAddress; members: Address[] }[] = []; switch (type) { case "safe": { @@ -37,6 +37,7 @@ export async function getDeployments({ await chain.safe?.api .getSafeInfo(addressWithoutShortName) .catch((e) => console.log("failed to get safe info ->", e)), + // biome-ignore lint/suspicious/noConfusingVoidType: this is the expected type ] satisfies [ChainWithSafe, SafeInfoResponse | undefined | void]; }), ), @@ -56,7 +57,8 @@ export async function getDeployments({ break; } case "party": - // tODO: support party + // tODO: support party + break; default: break; } diff --git a/src/actions/remove-members.ts b/src/actions/remove-members.ts index 3f35e36..9648839 100644 --- a/src/actions/remove-members.ts +++ b/src/actions/remove-members.ts @@ -6,12 +6,19 @@ import { bot } from "../lib/xmtp/client"; import { db } from "../db"; import { getGroup } from "./get-group"; import { sqliteAddressFromChainAwareAddress } from "../lib/sqlite-address-from-chain-aware-address"; +import { getWalletClient } from "../lib/eth/clients"; + +const { walletClient } = getWalletClient(); export async function removeMembers( groupId: string, - membersToRemove: Address[], + passedMembersToRemove: Address[], ): Promise { const group = await getGroup(groupId); + const membersToRemove = passedMembersToRemove.filter((address) => { + // - do not allow removing the bot + return address.toLowerCase() !== walletClient.account.address.toLowerCase(); + }); if (!group) { throw new Error(`Group with ID ${groupId} not found`); @@ -25,25 +32,27 @@ export async function removeMembers( R.mapValues((value) => value.map(({ address }) => address)), ); - try { - const removedMembers = await bot.removeMembers( - groupId, - approvedMemberAddresses, - ); - console.log( - `Group ID is ${groupId} -> remove members ${JSON.stringify( - removedMembers, - )}`, - ); - } catch (e) { - console.log( - "Failed to remove members from group", - e, - approvedMemberAddresses, - ); + if (approvedMemberAddresses?.length) { + try { + const removedMembers = await bot.removeMembers( + groupId, + approvedMemberAddresses, + ); + console.log( + `Group ID is ${groupId} -> remove members ${JSON.stringify( + removedMembers, + )}`, + ); + } catch (e) { + console.log( + "Failed to remove members from group", + e, + approvedMemberAddresses, + ); + } } - if (pendingMemberAddresses.length) { + if (pendingMemberAddresses?.length) { await db .delete(schema.groupMembers) .where( diff --git a/src/actions/sync-group-chats-with-safe-members.ts b/src/actions/sync-group-chats-with-safe-members.ts new file mode 100644 index 0000000..8ffc8b5 --- /dev/null +++ b/src/actions/sync-group-chats-with-safe-members.ts @@ -0,0 +1,157 @@ +import * as R from "remeda"; +import { db } from "../db"; +import { inArray, sql } from "drizzle-orm"; +import { bot } from "../lib/xmtp/client"; +import { getDeployments } from "./get-deployments"; +import * as schema from "../db/schema"; +import { sqliteAddressFromChainAwareAddress } from "../lib/sqlite-address-from-chain-aware-address"; +import type { Address } from "viem"; +import { addMembers } from "./add-members"; +import { removeMembers } from "./remove-members"; + +export default async function syncGroupChatsWithSafeMembers() { + console.log("syncing group chats with safe members"); + // - get the current group chats from XMTP + const groupChats = await bot.listGroups().catch((e) => {}); + + console.log("groupChats", groupChats); + + if (!groupChats || groupChats.length === 0) return; + + // - get all group wallets & the stored members of those groups from the database + const groupWallets = await db.query.groupWallets.findMany({ + with: { group: { with: { members: true } } }, + where(fields, { inArray }) { + return inArray( + fields.groupId, + groupChats.map((group) => group.group_id), + ); + }, + }); + + console.log("groupWallets", groupWallets); + + // - iterate over each groups wallet deployments & ensure the members in the chat are the union of the wallet owners + for (const groupWallet of groupWallets) { + const groupChat = groupChats.find( + (group) => group.group_id === groupWallet.groupId, + ); + + if (!groupChat) continue; + + const deployments = await getDeployments({ + type: "safe", + address: groupWallet.walletAddress, + }); + + // TODO: if we find a deployment that is not linked to a group wallet, we should link it + + const owners = R.pipe( + deployments, + R.flatMap((deployment) => deployment.members), + R.unique(), + ); + + /** + * This is a list of owners (wallet signers) with their `status` in the group chat attached. + * The `status` represents our knowlegde (the database's) of the group chat on XMTP. + * If they are approved we think they are already in the group chat. + * Otherwise, they are pending or rejected i.e. not in the group chat. + * + * The list is then partitioned into two lists: + * - `ownersThatAreMembersOfTheChat` - owners that ARE actually in the group chat on XMTP + * - `ownersThatAreNotMembersOfTheChat` - owners that ARE NOT in the group chat on XMTP + */ + const [ownersThatAreMembersOfTheChat, ownersThatAreNotMembersOfTheChat] = + R.pipe( + owners, + R.map((owner) => { + const { status } = + groupWallet?.group?.members.find((member) => + member.chainAwareAddress + .toLowerCase() + .endsWith(owner.toLowerCase()), + ) ?? {}; + + return { address: owner, status }; + }), + R.partition( + (owner) => + groupChat?.members.some( + (member) => member.toLowerCase() === owner.address.toLowerCase(), + ) ?? false, + ), + ); + + /** + * There are several situations that we need to handle explicitly here: + * 1. multisig owners that are in the group chat but not in the database + * 2. multisig owners that are in neither the database nor the group chat + * 3. group chat members / database members that are not multisig owners + * + * There are other situations that are handled elsewhere, such as: + * - owners that are in the database (as pending) but not in the group chat -> handled by `sync-members` + */ + + // 1. ensure that all multisig owners that are in the group chat are approved in the database + const unapprovedOwners = ownersThatAreMembersOfTheChat.filter( + ({ status }) => !!status && status !== "approved", + // ! filter types suck here + ) as Array<{ status: "pending" | "rejected"; address: Address }>; + + if (unapprovedOwners.length > 0) + await db + .update(schema.groupMembers) + .set({ status: "approved" }) + .where( + sql.join([ + inArray( + sqliteAddressFromChainAwareAddress( + schema.groupMembers.chainAwareAddress, + ), + unapprovedOwners, + ), + sql` collate nocase`, + ]), + ); + + console.log("approved missing owners -> ", unapprovedOwners); + + // 2. add missing owners to the database as approved & to the XMTP group chat + const membersToAdd = ownersThatAreNotMembersOfTheChat + .filter(({ status }) => status === undefined) + .map(({ address }) => address); + + console.log( + "adding missing owners to the database and XMTP group chat", + membersToAdd, + ); + + if (membersToAdd.length > 0) + await addMembers(groupChat.group_id, membersToAdd); + + // 3. remove members from the group chat that are not multisig owners + const groupChatMembersThatAreNotOwners = groupChat.members.filter( + (member) => !owners.some((m) => m.toLowerCase() === member.toLowerCase()), + ); + + const databaseMembersThatAreNotOwners = + groupWallet.group?.members.filter( + (member) => + !owners.some((m) => + member.chainAwareAddress.toLowerCase().endsWith(m.toLowerCase()), + ), + ) ?? []; + + const membersToRemove = [ + ...groupChatMembersThatAreNotOwners, + ...databaseMembersThatAreNotOwners.map( + (m) => m.chainAwareAddress.split(":")[1] as Address, + ), + ]; + + console.log("membersToRemove -> ", membersToRemove); + if (membersToRemove.length > 0) + await removeMembers(groupChat.group_id, membersToRemove); + } +} diff --git a/src/actions/sync-stored-members-with-xmtp.ts b/src/actions/sync-stored-members-with-xmtp.ts index bbce17e..133f4c2 100644 --- a/src/actions/sync-stored-members-with-xmtp.ts +++ b/src/actions/sync-stored-members-with-xmtp.ts @@ -20,6 +20,7 @@ const { walletClient } = getWalletClient(); * @param groupId Optionally provide a groupId to only sync members for that group */ export async function syncStoredMembersWithXmtp(groupId?: string) { + console.log("sync members with xmtp"); const groupChats = await bot.listGroups().catch((e) => { console.error("Failed to list groups", e); }); diff --git a/src/index.ts b/src/index.ts index 880fed5..b281f03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ 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"; +import syncGroupChatsWithSafeMembers from "./actions/sync-group-chats-with-safe-members"; /** * This service is responsible for keeping xmtp group chat members in sync with the members of a safe. @@ -39,9 +40,6 @@ import { TypedDataDomain } from "abitype/zod"; * * TODO: Add a method to link a deployed counterfactual account to the group chat * TODO: Add the ability for a member to remove themselves from a group chat - * ! only run the next two methods if the group chat is a **deployed** safe - * TODO: Add a job to periodically check for new members in a safe and add them to the group chat - * TODO: Add a job to periodically check for removed members in a safe and remove them from the group chat */ export default new Elysia() @@ -60,12 +58,18 @@ export default new Elysia() }, }), ) + .use( + cron({ + name: "sync-members-with-safes", + pattern: Patterns.everyMinutes(1), + run: syncGroupChatsWithSafeMembers, + }), + ) .use( cron({ name: "sync-members-with-xmtp", pattern: Patterns.EVERY_5_MINUTES, async run() { - console.log("sync members with xmtp"); await syncStoredMembersWithXmtp().catch((e) => console.error(e)); }, }),