From 54acd3e9f80c983df464223717d494d29d18ad68 Mon Sep 17 00:00:00 2001 From: Joey Meere <100378695+joeymeere@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:58:07 -0500 Subject: [PATCH] feat(chat): handle ephemeral signers for squadsx (#24) * feat(chat): handle ephemeral signers for squadsx * fix(ui): add walletContext param to DiscussionForm --- actions/castVote.ts | 56 +++++-- actions/chat/postMessage.ts | 62 +++++--- components/chat/DiscussionForm.tsx | 17 ++- hooks/useSubmitVote.ts | 3 + utils/ephemeral-signers/index.ts | 75 +++++++++ .../postMessageWithEphSigner.ts | 142 ++++++++++++++++++ 6 files changed, 316 insertions(+), 39 deletions(-) create mode 100644 utils/ephemeral-signers/index.ts create mode 100644 utils/ephemeral-signers/postMessageWithEphSigner.ts diff --git a/actions/castVote.ts b/actions/castVote.ts index 71aa202a1..af14867e7 100644 --- a/actions/castVote.ts +++ b/actions/castVote.ts @@ -43,6 +43,9 @@ import { fetchVoteRecordByPubkey } from '@hooks/queries/voteRecord' import { findPluginName } from '@constants/plugins' import { BN } from '@coral-xyz/anchor' import { postComment } from './chat/postMessage' +import { withPostChatMessageEphSigner } from '@utils/ephemeral-signers/postMessageWithEphSigner' +import { getEphemeralSigners } from '@utils/ephemeral-signers' +import { Wallet } from '@solana/wallet-adapter-react' const getVetoTokenMint = ( proposal: ProgramAccount, @@ -158,6 +161,7 @@ const createTokenOwnerRecordIfNeeded = async ({ export async function castVote( { connection, wallet, programId, walletPubkey }: RpcContext, + walletContext: Wallet, realm: ProgramAccount, proposal: ProgramAccount, tokenOwnerRecord: PublicKey, @@ -309,21 +313,43 @@ export async function castVote( createPostMessageTicketIxs ) - await withPostChatMessage( - postMessageIxs, - chatMessageSigners, - GOVERNANCE_CHAT_PROGRAM_ID, - programId, - realm.pubkey, - proposal.account.governance, - proposal.pubkey, - tokenOwnerRecord, - governanceAuthority, - payer, - undefined, - message, - plugin?.voterWeightPk - ) + // Check if the connected wallet is not SquadsX + if (walletContext.adapter.name !== 'SquadsX') { + await withPostChatMessage( + postMessageIxs, + chatMessageSigners, + GOVERNANCE_CHAT_PROGRAM_ID, + programId, + realm.pubkey, + proposal.account.governance, + proposal.pubkey, + tokenOwnerRecord, + governanceAuthority, + payer, + undefined, + message, + plugin?.voterWeightPk + ) + } else { + const chatMessage = await getEphemeralSigners(walletContext, 1) + + await withPostChatMessageEphSigner( + postMessageIxs, + chatMessageSigners, + GOVERNANCE_CHAT_PROGRAM_ID, + programId, + realm.pubkey, + proposal.account.governance, + proposal.pubkey, + tokenOwnerRecord, + governanceAuthority, + payer, + undefined, + message, + chatMessage[0], // Executing a chat message ix in Squads requires subbing in a custom ephemeral signer + plugin?.voterWeightPk + ) + } } const isNftVoter = votingPlugin?.client instanceof NftVoterClient diff --git a/actions/chat/postMessage.ts b/actions/chat/postMessage.ts index cbea1c6d4..422027438 100644 --- a/actions/chat/postMessage.ts +++ b/actions/chat/postMessage.ts @@ -22,9 +22,13 @@ import { txBatchesToInstructionSetWithSigners, } from '@utils/sendTransactions' import { sendSignAndConfirmTransactionsProps } from '@blockworks-foundation/mangolana/lib/transactions' +import { withPostChatMessageEphSigner } from '@utils/ephemeral-signers/postMessageWithEphSigner' +import { getEphemeralSigners } from '@utils/ephemeral-signers' +import { Wallet } from '@solana/wallet-adapter-react' export async function postChatMessage( { connection, wallet, programId, walletPubkey }: RpcContext, + walletContext: Wallet, realm: ProgramAccount, proposal: ProgramAccount, tokeOwnerRecord: ProgramAccount, @@ -45,21 +49,42 @@ export async function postChatMessage( createNftTicketsIxs ) - await withPostChatMessage( - instructions, - signers, - GOVERNANCE_CHAT_PROGRAM_ID, - programId, - realm.pubkey, - proposal.account.governance, - proposal.pubkey, - tokeOwnerRecord.pubkey, - governanceAuthority, - payer, - replyTo, - body, - plugin?.voterWeightPk - ) + // Check if the connected wallet is not SquadsX + if (walletContext.adapter.name !== 'SquadsX') { + await withPostChatMessage( + instructions, + signers, + GOVERNANCE_CHAT_PROGRAM_ID, + programId, + realm.pubkey, + proposal.account.governance, + proposal.pubkey, + tokeOwnerRecord.pubkey, + governanceAuthority, + payer, + replyTo, + body, + plugin?.voterWeightPk + ) + } else { + const chatMessage = await getEphemeralSigners(walletContext, 1) + await withPostChatMessageEphSigner( + instructions, + signers, + GOVERNANCE_CHAT_PROGRAM_ID, + programId, + realm.pubkey, + proposal.account.governance, + proposal.pubkey, + tokeOwnerRecord.pubkey, + governanceAuthority, + payer, + replyTo, + body, + chatMessage[0], // Executing a chat message ix in Squads requires subbing in a custom ephemeral signer + plugin?.voterWeightPk + ) + } // createTicketIxs is a list of instructions that create nftActionTicket only for nft-voter-v2 plugin // so it will be empty for other plugins or just spl-governance @@ -102,7 +127,8 @@ export async function postComment( transactionProps: sendSignAndConfirmTransactionsProps & { lookupTableAccounts?: any autoFee?: boolean -}) { + } +) { try { await sendTransactionsV3(transactionProps) } catch (e) { @@ -110,7 +136,9 @@ export async function postComment( const numbers = e.message.match(/\d+/g) const [size, maxSize] = numbers ? numbers.map(Number) : [0, 0] if (size > maxSize) { - throw new Error(`You must reduce your comment by ${size - maxSize} character(s).`) + throw new Error( + `You must reduce your comment by ${size - maxSize} character(s).` + ) } } throw e diff --git a/components/chat/DiscussionForm.tsx b/components/chat/DiscussionForm.tsx index 966f38528..cb5e1ed4c 100644 --- a/components/chat/DiscussionForm.tsx +++ b/components/chat/DiscussionForm.tsx @@ -18,7 +18,8 @@ import { useRouteProposalQuery } from '@hooks/queries/proposal' import { useVotingPop } from '@components/VotePanel/hooks' import useLegacyConnectionContext from '@hooks/useLegacyConnectionContext' import { useLegacyVoterWeight } from '@hooks/queries/governancePower' -import {useVotingClients} from "@hooks/useVotingClients"; +import { useVotingClients } from '@hooks/useVotingClients' +import { Wallet, useWallet } from '@solana/wallet-adapter-react' const DiscussionForm = () => { const [comment, setComment] = useState('') @@ -27,21 +28,22 @@ const DiscussionForm = () => { const realm = useRealmQuery().data?.result const { result: ownVoterWeight } = useLegacyVoterWeight() const { realmInfo } = useRealm() - const votingClients = useVotingClients(); + const votingClients = useVotingClients() const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') const wallet = useWalletOnePointOh() + const walletContext = useWallet() const connected = !!wallet?.connected const connection = useLegacyConnectionContext() const proposal = useRouteProposalQuery().data?.result const tokenRole = useVotingPop() const commenterVoterTokenRecord = - tokenRole === 'community' ? - ownTokenRecord ?? ownCouncilTokenRecord : - ownCouncilTokenRecord + tokenRole === 'community' + ? ownTokenRecord ?? ownCouncilTokenRecord + : ownCouncilTokenRecord - const votingClient = votingClients(tokenRole ?? 'community');// default to community if no role is provided + const votingClient = votingClients(tokenRole ?? 'community') // default to community if no role is provided const submitComment = async () => { setSubmitting(true) setError('') @@ -70,6 +72,7 @@ const DiscussionForm = () => { try { await postChatMessage( rpcContext, + walletContext.wallet as Wallet, realm, proposal, commenterVoterTokenRecord, @@ -81,7 +84,7 @@ const DiscussionForm = () => { setComment('') } catch (ex) { console.error("Can't post chat message", ex) - setError(ex.message); + setError(ex.message) //TODO: How do we present transaction errors to users? Just the notification? } finally { setSubmitting(false) diff --git a/hooks/useSubmitVote.ts b/hooks/useSubmitVote.ts index 2571fe2a5..68c580d44 100644 --- a/hooks/useSubmitVote.ts +++ b/hooks/useSubmitVote.ts @@ -32,9 +32,11 @@ import { useBatchedVoteDelegators } from '@components/VotePanel/useDelegators' import { useVotingClients } from '@hooks/useVotingClients' import { useNftClient } from '../VoterWeightPlugins/useNftClient' import { useRealmVoterWeightPlugins } from './useRealmVoterWeightPlugins' +import { Wallet, useWallet } from '@solana/wallet-adapter-react' export const useSubmitVote = () => { const wallet = useWalletOnePointOh() + const walletContext = useWallet() const connection = useLegacyConnectionContext() const realm = useRealmQuery().data?.result const proposal = useRouteProposalQuery().data?.result @@ -146,6 +148,7 @@ export const useSubmitVote = () => { try { await castVote( rpcContext, + walletContext.wallet as Wallet, realm, proposal, tokenOwnerRecordPk, diff --git a/utils/ephemeral-signers/index.ts b/utils/ephemeral-signers/index.ts new file mode 100644 index 000000000..30704e146 --- /dev/null +++ b/utils/ephemeral-signers/index.ts @@ -0,0 +1,75 @@ +import { StandardWalletAdapter } from '@solana/wallet-adapter-base' +import { Wallet } from '@solana/wallet-adapter-react' +import { Keypair, PublicKey } from '@solana/web3.js' + +/** + * Outputs `num` number of ephemeral signers for a transaction, designed to be used only in cases + * where SquadsX is the connected wallet, and a throwaway keypair is signing a transaction. + * @arg Wallet - A standard wallet context from @solana/wallet-adapter-react + * @arg num - The number of ephemeral signers to generate + * @returns - An array of ephemeral signer PublicKeys + */ +export async function getEphemeralSigners( + wallet: Wallet, + num: number +): Promise { + let adapter = wallet.adapter as StandardWalletAdapter + + const features = adapter.wallet.features + + if ( + adapter && + 'standard' in adapter && + SquadsGetEphemeralSignersFeatureIdentifier in features + ) { + const ephemeralSignerFeature = (await features[ + SquadsGetEphemeralSignersFeatureIdentifier + ]) as EphemeralSignerFeature + + const ephemeralSigners = (await ephemeralSignerFeature.getEphemeralSigners( + num + )) as GetEphemeralSignersOutput + + // WIP: Types for Solana wallet adapter features can be difficult + // @ts-ignore + return ephemeralSigners.map((signer) => new PublicKey(signer)) + } else { + return [Keypair.generate().publicKey] + } +} + +export type GetEphemeralSignersOutput = { + method: 'getEphemeralSigners' + result: { + ok: boolean + value: { + addresses: string[] + } + } +} + +export const SquadsGetEphemeralSignersFeatureIdentifier = 'fuse:getEphemeralSigners' as const + +export type WalletAdapterFeature< + FeatureName extends string, + FeatureProperties extends Record = {}, + FeatureMethods extends Record any> = {} +> = { + [K in FeatureName]: FeatureProperties & + { + [M in keyof FeatureMethods]: ( + ...args: Parameters + ) => ReturnType + } +} + +export type WalletWithEphemeralSigners = WalletAdapterFeature< + 'standard', + { + 'fuse:getEphemeralSigners': EphemeralSignerFeature + } +> + +export type EphemeralSignerFeature = { + getEphemeralSigners: (num: number) => GetEphemeralSignersOutput +} diff --git a/utils/ephemeral-signers/postMessageWithEphSigner.ts b/utils/ephemeral-signers/postMessageWithEphSigner.ts new file mode 100644 index 000000000..95151d57b --- /dev/null +++ b/utils/ephemeral-signers/postMessageWithEphSigner.ts @@ -0,0 +1,142 @@ +import { + ChatMessageBody, + GOVERNANCE_CHAT_SCHEMA, + PostChatMessageArgs, + SYSTEM_PROGRAM_ID, + getRealmConfigAddress, +} from '@solana/spl-governance' +import { + AccountMeta, + Keypair, + PublicKey, + TransactionInstruction, +} from '@solana/web3.js' +import { serialize } from 'borsh' + +export async function withPostChatMessageEphSigner( + instructions: TransactionInstruction[], + signers: Keypair[], + chatProgramId: PublicKey, + governanceProgramId: PublicKey, + realm: PublicKey, + governance: PublicKey, + proposal: PublicKey, + tokenOwnerRecord: PublicKey, + governanceAuthority: PublicKey, + payer: PublicKey, + replyTo: PublicKey | undefined, + body: ChatMessageBody, + chatMessage: PublicKey, + voterWeightRecord?: PublicKey +) { + const args = new PostChatMessageArgs({ + body, + }) + + const data = Buffer.from(serialize(GOVERNANCE_CHAT_SCHEMA, args)) + + let keys = [ + { + pubkey: governanceProgramId, + isWritable: false, + isSigner: false, + }, + { + pubkey: realm, + isWritable: false, + isSigner: false, + }, + { + pubkey: governance, + isWritable: false, + isSigner: false, + }, + { + pubkey: proposal, + isWritable: false, + isSigner: false, + }, + { + pubkey: tokenOwnerRecord, + isWritable: false, + isSigner: false, + }, + { + pubkey: governanceAuthority, + isWritable: false, + isSigner: true, + }, + { + pubkey: chatMessage, + isWritable: true, + isSigner: true, + }, + { + pubkey: payer, + isWritable: false, + isSigner: true, + }, + { + pubkey: SYSTEM_PROGRAM_ID, + isWritable: false, + isSigner: false, + }, + ] + + if (replyTo) { + keys.push({ + pubkey: replyTo, + isWritable: false, + isSigner: false, + }) + } + + await withRealmConfigPluginAccounts( + keys, + governanceProgramId, + realm, + voterWeightRecord + ) + + instructions.push( + new TransactionInstruction({ + keys, + programId: chatProgramId, + data, + }) + ) + + return chatMessage +} + +export async function withRealmConfigPluginAccounts( + keys: AccountMeta[], + programId: PublicKey, + realm: PublicKey, + voterWeightRecord?: PublicKey | undefined, + maxVoterWeightRecord?: PublicKey | undefined +) { + const realmConfigAddress = await getRealmConfigAddress(programId, realm) + + keys.push({ + pubkey: realmConfigAddress, + isWritable: false, + isSigner: false, + }) + + if (voterWeightRecord) { + keys.push({ + pubkey: voterWeightRecord, + isWritable: false, + isSigner: false, + }) + } + + if (maxVoterWeightRecord) { + keys.push({ + pubkey: maxVoterWeightRecord, + isWritable: false, + isSigner: false, + }) + } +}