Skip to content

Commit

Permalink
feat: add cron to gate the group chat via safe membership
Browse files Browse the repository at this point in the history
  • Loading branch information
peterferguson committed Apr 11, 2024
1 parent 9d3924a commit fcbb45f
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 24 deletions.
6 changes: 4 additions & 2 deletions src/actions/get-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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];
}),
),
Expand All @@ -56,7 +57,8 @@ export async function getDeployments({
break;
}
case "party":
// tODO: support party
// tODO: support party
break;
default:
break;
}
Expand Down
45 changes: 27 additions & 18 deletions src/actions/remove-members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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`);
Expand All @@ -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(
Expand Down
157 changes: 157 additions & 0 deletions src/actions/sync-group-chats-with-safe-members.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions src/actions/sync-stored-members-with-xmtp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
12 changes: 8 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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));
},
}),
Expand Down

0 comments on commit fcbb45f

Please sign in to comment.