diff --git a/package.json b/package.json index f7aa8bca..82a14001 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@orca-so/whirlpools-sdk": "^0.13.12", "@pythnetwork/hermes-client": "^1.3.0", "@raydium-io/raydium-sdk-v2": "0.1.95-alpha", + "@solana/spl-governance": "^0.3.28", "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.98.0", "@tensor-oss/tensorswap-sdk": "^4.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ef0c43a..05cb7312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@raydium-io/raydium-sdk-v2': specifier: 0.1.95-alpha version: 0.1.95-alpha(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.7.2)(utf-8-validate@5.0.10) + '@solana/spl-governance': + specifier: ^0.3.28 + version: 0.3.28(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@solana/spl-token': specifier: ^0.4.9 version: 0.4.9(@solana/web3.js@1.98.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.7.2)(utf-8-validate@5.0.10) @@ -974,6 +977,9 @@ packages: peerDependencies: '@solana/web3.js': ^1.50.1 + '@solana/spl-governance@0.3.28': + resolution: {integrity: sha512-CUi1hMvzId2rAtMFTlxMwOy0EmFeT0VcmiC+iQnDhRBuM8LLLvRrbTYBWZo3xIvtPQW9HfhVBoL7P/XNFIqYVQ==} + '@solana/spl-token-group@0.0.4': resolution: {integrity: sha512-7+80nrEMdUKlK37V6kOe024+T7J4nNss0F8LQ9OOPYdWCCfJmsGUzVx2W3oeizZR4IHM6N4yC9v1Xqwc3BTPWw==} engines: {node: '>=16'} @@ -1075,6 +1081,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/bn.js@4.11.6': + resolution: {integrity: sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg==} + '@types/bn.js@5.1.6': resolution: {integrity: sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w==} @@ -1449,6 +1458,9 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + borsh@0.3.1: + resolution: {integrity: sha512-gJoSTnhwLxN/i2+15Y7uprU8h3CKI+Co4YKZKvrGYUy0FwHWM20x5Sx7eU8Xv4HQqV+7rb4r3P7K1cBIQe3q8A==} + borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} @@ -5237,6 +5249,21 @@ snapshots: - supports-color - utf-8-validate + '@solana/spl-governance@0.3.28(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + dependencies: + '@solana/web3.js': 1.98.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + axios: 1.7.9 + bignumber.js: 9.1.2 + bn.js: 5.2.1 + borsh: 0.3.1 + bs58: 4.0.1 + superstruct: 0.15.5 + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - utf-8-validate + '@solana/spl-token-group@0.0.4(@solana/web3.js@1.98.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(fastestsmallesttextencoderdecoder@1.0.22)': dependencies: '@solana/codecs': 2.0.0-preview.2(fastestsmallesttextencoderdecoder@1.0.22) @@ -5572,6 +5599,10 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/bn.js@4.11.6': + dependencies: + '@types/node': 22.10.5 + '@types/bn.js@5.1.6': dependencies: '@types/node': 22.10.5 @@ -6054,6 +6085,13 @@ snapshots: transitivePeerDependencies: - supports-color + borsh@0.3.1: + dependencies: + '@types/bn.js': 4.11.6 + bn.js: 5.2.1 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + borsh@0.7.0: dependencies: bn.js: 5.2.1 diff --git a/src/actions/index.ts b/src/actions/index.ts index c9742098..12d32033 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -30,6 +30,10 @@ import launchPumpfunTokenAction from "./launchPumpfunToken"; import getWalletAddressAction from "./getWalletAddress"; import flashOpenTradeAction from "./flashOpenTrade"; import flashCloseTradeAction from "./flashCloseTrade"; +import castGovernanceVoteAction from "./realm/castVote"; +import delegateVotingPowerAction from "./realm/getVotingPower"; +import getVotingOutcomeAction from "./realm/getVotingOutcome"; +import getVotingPowerAction from "./realm/delegateVotingPower"; export const ACTIONS = { WALLET_ADDRESS_ACTION: getWalletAddressAction, @@ -65,6 +69,10 @@ export const ACTIONS = { LAUNCH_PUMPFUN_TOKEN_ACTION: launchPumpfunTokenAction, FLASH_OPEN_TRADE_ACTION: flashOpenTradeAction, FLASH_CLOSE_TRADE_ACTION: flashCloseTradeAction, + CAST_VOTE_ACTION: castGovernanceVoteAction, + GET_VOTING_POWER_ACTION: getVotingPowerAction, + DELEGATE_VOTING_POWER_ACTION: delegateVotingPowerAction, + GET_VOTING_OUTCOME_ACTION: getVotingOutcomeAction, }; export type { Action, ActionExample, Handler } from "../types/action"; diff --git a/src/actions/realm/castVote.ts b/src/actions/realm/castVote.ts new file mode 100644 index 00000000..1c41762e --- /dev/null +++ b/src/actions/realm/castVote.ts @@ -0,0 +1,72 @@ +import { PublicKey } from "@solana/web3.js"; +import { Action } from "../../types/action"; +import { z } from "zod"; +import { castGovernanceVote } from "../../tools"; +import { SolanaAgentKit } from "../../agent"; + +const castGovernanceVoteAction: Action = { + name: "CAST_GOVERNANCE_VOTE", + similes: [ + "vote on proposal", + "cast governance vote", + "submit proposal vote", + "vote on governance", + "vote on dao proposal", + ], + description: `Cast a vote on a governance proposal in a Solana DAO. + + Inputs ( input is a JSON string ): + realmAccount: string, eg "7nxQB..." (required) - The public key of the realm + proposalAccount: string, eg "8x2dR..." (required) - The public key of the proposal + voteType: string, either "yes" or "no" (required) - The type of vote to cast`, + + examples: [ + [ + { + input: { + realmAccount: "7nxQB1nGrqk8WKXeFDR6ZUaQtYjV7HMsAGWgwtGHwmQU", + proposalAccount: "8x2dR8Mpzuz2YqyZyZjUbYWKSWesBo5jMx2Q9Y86udVk", + voteType: "yes", + }, + output: { + status: "success", + signature: "2GjfL3N9E4cHp7WhDZRkx7oF2J9m3Sf5hT6zRHcVWUjp", + message: "Vote cast successfully", + }, + explanation: "Cast a yes vote on a governance proposal", + }, + ], + ], + + schema: z.object({ + realmAccount: z.string().min(32, "Invalid realm account address"), + proposalAccount: z.string().min(32, "Invalid proposal account address"), + voteType: z.enum(["yes", "no"], { + description: "Vote type must be either 'yes' or 'no'", + }), + }), + + handler: async (agent: SolanaAgentKit, input: Record) => { + try { + const signature = await castGovernanceVote( + agent, + new PublicKey(input.realmAccount), + new PublicKey(input.proposalAccount), + input.voteType, + ); + + return { + status: "success", + signature, + message: "Vote cast successfully", + }; + } catch (error: any) { + return { + status: "error", + message: `Failed to cast vote: ${error.message}`, + }; + } + }, +}; + +export default castGovernanceVoteAction; diff --git a/src/actions/realm/delegateVotingPower.ts b/src/actions/realm/delegateVotingPower.ts new file mode 100644 index 00000000..55c0b84e --- /dev/null +++ b/src/actions/realm/delegateVotingPower.ts @@ -0,0 +1,82 @@ +import { PublicKey } from "@solana/web3.js"; +import { Action } from "../../types/action"; +import { z } from "zod"; +import { delegateVotingPower } from "../../tools"; +import { SolanaAgentKit } from "../../agent"; + +const delegateVotingPowerAction: Action = { + name: "DELEGATE_GOVERNANCE_VOTING", + similes: [ + "delegate governance votes", + "assign voting delegate", + "transfer voting rights", + "set voting delegate", + "delegate dao voting power", + ], + description: `Delegate voting power to another wallet in a governance realm. + + Inputs ( input is a JSON string ): + realm: string, eg "7nxQB..." (required) - The public key of the realm + governingTokenMint: string, eg "8x2dR..." (required) - The mint of the governing token + delegate: string, eg "9aUn5..." (required) - The wallet address to delegate voting power to`, + + examples: [ + [ + { + input: { + realm: "7nxQB1nGrqk8WKXeFDR6ZUaQtYjV7HMsAGWgwtGHwmQU", + governingTokenMint: "8x2dR8Mpzuz2YqyZyZjUbYWKSWesBo5jMx2Q9Y86udVk", + delegate: "9aUn5swQzUTRanaaTwmszxiv89cvFwUCjEBv1vZCoT1u", + }, + output: { + status: "success", + message: "Successfully delegated voting power", + signature: "2GjfL3N9E4cHp7WhDZRkx7oF2J9m3Sf5hT6zRHcVWUjp", + }, + explanation: "Delegate governance voting power to another wallet", + }, + ], + ], + + schema: z.object({ + realm: z.string().min(32, "Invalid realm address"), + governingTokenMint: z + .string() + .min(32, "Invalid governing token mint address"), + delegate: z.string().min(32, "Invalid delegate wallet address"), + }), + + handler: async (agent: SolanaAgentKit, input: Record) => { + try { + const signature = await delegateVotingPower( + agent, + new PublicKey(input.realm), + new PublicKey(input.governingTokenMint), + new PublicKey(input.delegate), + ); + + return { + status: "success", + message: "Successfully delegated voting power", + signature, + }; + } catch (error: any) { + let errorMessage = error.message; + + // Handle specific error cases with user-friendly messages + if (error.message.includes("Account not found")) { + errorMessage = + "No token owner record found - you need to deposit tokens first"; + } else if (error.message.includes("Invalid delegate")) { + errorMessage = "The provided delegate address is invalid"; + } + + return { + status: "error", + message: errorMessage, + }; + } + }, +}; + +export default delegateVotingPowerAction; diff --git a/src/actions/realm/getVotingOutcome.ts b/src/actions/realm/getVotingOutcome.ts new file mode 100644 index 00000000..98d720b5 --- /dev/null +++ b/src/actions/realm/getVotingOutcome.ts @@ -0,0 +1,91 @@ +import { PublicKey } from "@solana/web3.js"; +import { Action } from "../../types/action"; +import { z } from "zod"; +import { getVotingOutcome } from "../../tools"; +import { SolanaAgentKit } from "../../agent"; + +const getVotingOutcomeAction: Action = { + name: "GET_GOVERNANCE_VOTING_OUTCOME", + similes: [ + "check proposal votes", + "get proposal results", + "view voting outcome", + "check governance votes", + "get dao vote status", + ], + description: `Get the voting outcome and details for a governance proposal. + + Inputs ( input is a JSON string ): + proposalAccount: string, eg "8x2dR..." (required) - The public key of the proposal account to check`, + + examples: [ + [ + { + input: { + proposalAccount: "8x2dR8Mpzuz2YqyZyZjUbYWKSWesBo5jMx2Q9Y86udVk", + }, + output: { + status: "success", + message: "Successfully retrieved proposal voting outcome", + data: { + state: "Voting", + name: "Treasury Transfer Proposal", + yesVotes: "1000000", + noVotes: "500000", + description: "https://example.com/proposal", + isVoteFinalized: false, + votingStartedAt: 1678901234, + votingCompletedAt: null, + signatoriesRequired: 3, + signatoriesSigned: 3, + }, + }, + explanation: "Get voting outcome for a governance proposal", + }, + ], + ], + + schema: z.object({ + proposalAccount: z.string().min(32, "Invalid proposal account address"), + }), + + handler: async (agent: SolanaAgentKit, input: Record) => { + try { + const proposal = await getVotingOutcome( + agent, + new PublicKey(input.proposalAccount), + ); + + // Format the response with relevant proposal data + return { + status: "success", + message: "Successfully retrieved proposal voting outcome", + data: { + state: proposal.state.toString(), + name: proposal.name, + yesVotes: proposal.yesVotesCount.toString(), + noVotes: proposal.noVotesCount.toString(), + description: proposal.descriptionLink, + isVoteFinalized: proposal.isVoteFinalized(), + votingStartedAt: proposal.votingAt?.toNumber() || null, + votingCompletedAt: proposal.votingCompletedAt?.toNumber() || null, + signatoriesRequired: proposal.signatoriesCount, + signatoriesSigned: proposal.signatoriesSignedOffCount, + }, + }; + } catch (error: any) { + let errorMessage = error.message; + + if (error.message.includes("Account not found")) { + errorMessage = "Proposal not found - please check the proposal address"; + } + + return { + status: "error", + message: errorMessage, + }; + } + }, +}; + +export default getVotingOutcomeAction; diff --git a/src/actions/realm/getVotingPower.ts b/src/actions/realm/getVotingPower.ts new file mode 100644 index 00000000..16e2835c --- /dev/null +++ b/src/actions/realm/getVotingPower.ts @@ -0,0 +1,90 @@ +import { PublicKey } from "@solana/web3.js"; +import { Action } from "../../types/action"; +import { z } from "zod"; +import { getVotingPower } from "../../tools"; +import { SolanaAgentKit } from "../../agent"; + +const getVotingPowerAction: Action = { + name: "GET_GOVERNANCE_VOTING_POWER", + similes: [ + "check voting power", + "view governance power", + "get dao voting weight", + "check vote delegation power", + "view voting rights", + ], + description: `Get current voting power and delegation details for a wallet in a governance realm. + + Inputs ( input is a JSON string ): + realm: string, eg "7nxQB..." (required) - The public key of the realm + governingTokenMint: string, eg "8x2dR..." (required) - The mint of the governing token to check power for`, + + examples: [ + [ + { + input: { + realm: "7nxQB1nGrqk8WKXeFDR6ZUaQtYjV7HMsAGWgwtGHwmQU", + governingTokenMint: "8x2dR8Mpzuz2YqyZyZjUbYWKSWesBo5jMx2Q9Y86udVk", + }, + output: { + status: "success", + message: "Successfully retrieved voting power information", + data: { + votingPower: 1000000, + delegatedPower: 500000, + totalVotesCount: 5, + unrelinquishedVotesCount: 2, + outstandingProposalCount: 1, + }, + }, + explanation: + "Get current voting power and delegation details for a wallet", + }, + ], + ], + + schema: z.object({ + realm: z.string().min(32, "Invalid realm address"), + governingTokenMint: z + .string() + .min(32, "Invalid governing token mint address"), + }), + + handler: async (agent: SolanaAgentKit, input: Record) => { + try { + const votingPowerInfo = await getVotingPower( + agent, + new PublicKey(input.realm), + new PublicKey(input.governingTokenMint), + ); + + return { + status: "success", + message: "Successfully retrieved voting power information", + data: { + votingPower: votingPowerInfo.votingPower, + delegatedPower: votingPowerInfo.delegatedPower, + totalVotesCount: votingPowerInfo.totalVotesCount, + unrelinquishedVotesCount: votingPowerInfo.unrelinquishedVotesCount, + outstandingProposalCount: votingPowerInfo.outstandingProposalCount, + }, + }; + } catch (error: any) { + let errorMessage = error.message; + + // Handle specific error cases + if (error.message.includes("Account not found")) { + errorMessage = "No voting record found for this wallet"; + } else if (error.message.includes("Invalid mint")) { + errorMessage = "Invalid governing token mint provided"; + } + + return { + status: "error", + message: errorMessage, + }; + } + }, +}; + +export default getVotingPowerAction; diff --git a/src/agent/index.ts b/src/agent/index.ts index 56517037..0cb25f18 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -3,7 +3,7 @@ import { BN } from "@coral-xyz/anchor"; import bs58 from "bs58"; import Decimal from "decimal.js"; import { DEFAULT_OPTIONS } from "../constants"; -import { Config, TokenCheck } from "../types"; +import { Config, TokenCheck, VotingPowerInfo } from "../types"; import { deploy_collection, deploy_token, @@ -63,6 +63,10 @@ import { fetchPythPriceFeedID, flashOpenTrade, flashCloseTrade, + castGovernanceVote, + getVotingPower, + delegateVotingPower, + getVotingOutcome, } from "../tools"; import { CollectionDeployment, @@ -92,6 +96,7 @@ import { create_proposal } from "../tools/squads_multisig/create_proposal"; import { approve_proposal } from "../tools/squads_multisig/approve_proposal"; import { execute_transaction } from "../tools/squads_multisig/execute_proposal"; import { reject_proposal } from "../tools/squads_multisig/reject_proposal"; +import { Proposal } from "@solana/spl-governance"; /** * Main class for interacting with Solana blockchain @@ -655,4 +660,31 @@ export class SolanaAgentKit { ): Promise { return execute_transaction(this, transactionIndex); } + + async castGovernanceVote( + realmAccount: PublicKey, + proposalAccount: PublicKey, + voteType: "yes" | "no", + ): Promise { + return castGovernanceVote(this, realmAccount, proposalAccount, voteType); + } + + async getVotingPower( + realm: PublicKey, + governingTokenMint: PublicKey, + ): Promise { + return getVotingPower(this, realm, governingTokenMint); + } + + async delegateVotingPower( + realm: PublicKey, + governingTokenMint: PublicKey, + delegate: PublicKey, + ): Promise { + return delegateVotingPower(this, realm, governingTokenMint, delegate); + } + + async getVotingOutcome(proposal: PublicKey): Promise { + return getVotingOutcome(this, proposal); + } } diff --git a/src/constants/index.ts b/src/constants/index.ts index 69965bf8..51045a8d 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -33,3 +33,10 @@ export const DEFAULT_OPTIONS = { export const JUP_API = "https://quote-api.jup.ag/v6"; export const JUP_REFERRAL_ADDRESS = "REFER4ZgmyYx9c6He5XfaTMiGfdLwRnkV4RPp9t9iF3"; + +/** + * Governance Program Address + */ + +export const GOVERNANCE_PROGRAM_ADDRESS = + "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw"; diff --git a/src/langchain/index.ts b/src/langchain/index.ts index e442206a..b98306c8 100644 --- a/src/langchain/index.ts +++ b/src/langchain/index.ts @@ -2688,6 +2688,186 @@ export class SolanaExecuteProposal2by2Multisig extends Tool { } } +export class SolanaCastGovernanceVoteTool extends Tool { + name = "solana_governance_vote"; + description = `Cast a vote on a governance proposal. + + Inputs (input is a JSON string): + - realmAccount: string, the address eg "7nxQB..." of the realm (required) + - proposalAccount: string, the address eg "8x2dR..." of the proposal (required) + - vote: string, the type of vote, either "yes" or "no" (required)`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + protected async _call(input: string): Promise { + try { + const { realmAccount, proposalAccount, vote } = JSON.parse(input); + + if (!["yes", "no"].includes(vote.toLowerCase())) { + throw new Error("Invalid voteType. Allowed values: 'yes', 'no'"); + } + // Validate public keys + if ( + !PublicKey.isOnCurve(realmAccount) || + !PublicKey.isOnCurve(proposalAccount) + ) { + throw new Error("Invalid realmAccount or proposalAccount"); + } + const signature = await this.solanaKit.castGovernanceVote( + new PublicKey(realmAccount), + new PublicKey(proposalAccount), + vote, + ); + + return JSON.stringify({ + status: "success", + message: "Vote cast successfully", + transaction: signature, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + +export class SolanaGetVotingPowerTool extends Tool { + name = "get_voting_power"; + description = `Get current voting power in a realm. + + Inputs (input is a JSON string): + - realmAccount: string, the address eg "7nxQB..." of the realm (required) + - governingTokenMint: string, the PublicKey of the Governing Token Mint (required)`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + protected async _call(input: string): Promise { + try { + const { realm, governingTokenMint } = JSON.parse(input); + + if ( + !PublicKey.isOnCurve(realm) || + !PublicKey.isOnCurve(governingTokenMint) + ) { + throw new Error( + "Invalid public key provided for realm or governingTokenMint", + ); + } + + const votingPower = await this.solanaKit.getVotingPower( + new PublicKey(realm), + new PublicKey(governingTokenMint), + ); + + return JSON.stringify({ + status: "success", + message: "Voting power fetched successfully", + data: votingPower, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + +export class SolanaDelegateVoteTool extends Tool { + name = "delegate_vote"; + description = `Delegate voting power to another wallet. + + Input should be a JSON string containing: + + - realmAccount : string, the address eg "7nxQB..." of the realm (required) + - governingTokenMint: string, the PublicKey of the governing token mint (required) + - delegate: string, the PublicKey of the new delegate (required) `; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + protected async _call(input: string): Promise { + try { + const { realm, governingTokenMint, delegate } = JSON.parse(input); + + // Validate public keys + if ( + !PublicKey.isOnCurve(realm) || + !PublicKey.isOnCurve(governingTokenMint) || + !PublicKey.isOnCurve(delegate) + ) { + throw new Error( + "Invalid public key provided for realm, governingTokenMint, or delegate", + ); + } + const signature = await this.solanaKit.delegateVotingPower( + new PublicKey(realm), + new PublicKey(governingTokenMint), + new PublicKey(delegate), + ); + + return JSON.stringify({ + status: "success", + message: "Voting power delegated successfully", + data: { signature }, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + +export class SolanaGetVotingOutcomeTool extends Tool { + name = "get_voting_outcome"; + description = `Get the current outcome of a governance proposal. + + Input should be a JSON string containing: + - proposalAccount: string, the address of the proposal (required)`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + protected async _call(input: string): Promise { + try { + const { proposal } = JSON.parse(input); + + if (!PublicKey.isOnCurve(proposal)) { + throw new Error("Invalid public key provided for proposal"); + } + + const outcome = await this.solanaKit.getVotingOutcome( + new PublicKey(proposal), + ); + + return JSON.stringify({ + status: "success", + message: "Voting outcome fetched successfully", + data: outcome, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + export function createSolanaTools(solanaKit: SolanaAgentKit) { return [ new SolanaBalanceTool(solanaKit), @@ -2755,5 +2935,9 @@ export function createSolanaTools(solanaKit: SolanaAgentKit) { new SolanaApproveProposal2by2Multisig(solanaKit), new SolanaRejectProposal2by2Multisig(solanaKit), new SolanaExecuteProposal2by2Multisig(solanaKit), + new SolanaCastGovernanceVoteTool(solanaKit), + new SolanaGetVotingPowerTool(solanaKit), + new SolanaDelegateVoteTool(solanaKit), + new SolanaGetVotingOutcomeTool(solanaKit), ]; } diff --git a/src/tools/index.ts b/src/tools/index.ts index 2363e3ab..6ef46fae 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -50,3 +50,4 @@ export * from "./flash_open_trade"; export * from "./flash_close_trade"; export * from "./create_3land_collectible"; +export * from "./realm"; diff --git a/src/tools/realm/caste_vote.ts b/src/tools/realm/caste_vote.ts new file mode 100644 index 00000000..b6b005df --- /dev/null +++ b/src/tools/realm/caste_vote.ts @@ -0,0 +1,125 @@ +import { PublicKey, Transaction } from "@solana/web3.js"; +import { SolanaAgentKit } from "../../index"; +import { + getGovernanceProgramVersion, + getTokenOwnerRecordAddress, + getVoteRecordAddress, + getProposal, + Vote, + VoteChoice, + withCastVote, + getRealm, + VoteKind, +} from "@solana/spl-governance"; +import { GOVERNANCE_PROGRAM_ADDRESS } from "../../constants"; + +/** + * Cast a vote on a governance proposal + * + * @param agent {SolanaAgentKit} The Solana Agent Kit instance + * @param realmAccount {PublicKey} The public key of the realm + * @param proposalAccount {PublicKey} The public key of the proposal being voted on + * @param voteType {"yes" | "no"} Type of vote to cast + * + * @returns {Promise} Transaction signature + * + * @throws Will throw an error if the vote transaction fails + * + * @example + * const signature = await castVote( + * agent, + * new PublicKey("realm-address"), + * new PublicKey("proposal-address"), + * "yes" + * ); + */ +export async function castGovernanceVote( + agent: SolanaAgentKit, + realmAccount: PublicKey, + proposalAccount: PublicKey, + voteType: "yes" | "no", +): Promise { + try { + const connection = agent.connection; + const governanceProgramId = new PublicKey(GOVERNANCE_PROGRAM_ADDRESS); + + // Get governance program version for the connected chain + const programVersion = await getGovernanceProgramVersion( + connection, + governanceProgramId, + ); + + // Fetch realm info and get governing token mint + const realmInfo = await getRealm(connection, realmAccount); + const governingTokenMint = realmInfo.account.communityMint; + + // Get voter's token owner record + const tokenOwnerRecord = await getTokenOwnerRecordAddress( + governanceProgramId, + realmAccount, + governingTokenMint, + agent.wallet_address, + ); + + // Get voter's vote record + const voteRecord = await getVoteRecordAddress( + governanceProgramId, + proposalAccount, + tokenOwnerRecord, + ); + + // Get proposal data + const proposal = await getProposal(connection, proposalAccount); + + // Construct vote object + const vote = new Vote({ + voteType: voteType === "no" ? VoteKind.Deny : VoteKind.Approve, + approveChoices: + voteType === "yes" + ? [new VoteChoice({ rank: 0, weightPercentage: 100 })] + : [], + deny: voteType === "no", + veto: false, + }); + + // Create and configure transaction + const transaction = new Transaction(); + + await withCastVote( + transaction.instructions, + governanceProgramId, + programVersion, + realmAccount, + proposal.account.governance, + proposalAccount, + proposal.account.tokenOwnerRecord, + tokenOwnerRecord, + proposal.account.governingTokenMint, + voteRecord, + vote, + agent.wallet_address, + ); + + // Sign and send transaction + transaction.sign(agent.wallet); + const signature = await agent.connection.sendRawTransaction( + transaction.serialize(), + { + preflightCommitment: "confirmed", + maxRetries: 3, + }, + ); + + // Confirm transaction + const latestBlockhash = await connection.getLatestBlockhash(); + await connection.confirmTransaction({ + signature, + blockhash: latestBlockhash.blockhash, + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, + }); + + return signature; + } catch (error: any) { + throw new Error(`Failed to cast governance vote: ${error.message}`); + } +} diff --git a/src/tools/realm/delegate_voting_power.ts b/src/tools/realm/delegate_voting_power.ts new file mode 100644 index 00000000..08913929 --- /dev/null +++ b/src/tools/realm/delegate_voting_power.ts @@ -0,0 +1,99 @@ +import { PublicKey, Transaction } from "@solana/web3.js"; +import { SolanaAgentKit } from "../../index"; +import { + getGovernanceProgramVersion, + getTokenOwnerRecordAddress, + withSetGovernanceDelegate, +} from "@solana/spl-governance"; +import { GOVERNANCE_PROGRAM_ADDRESS } from "../../constants"; + +/** + * Delegate voting power to another wallet in a governance realm + * + * @param agent {SolanaAgentKit} The Solana Agent Kit instance + * @param realm {PublicKey} The public key of the realm + * @param governingTokenMint {PublicKey} The mint of the governing token to delegate + * @param delegate {PublicKey} The wallet address to delegate voting power to + * + * @returns {Promise} Transaction signature + * + * @throws {Error} If public keys are invalid + * @throws {Error} If delegation transaction fails + * @throws {Error} If token owner record doesn't exist + * + * @example + * const signature = await delegateVotingPower( + * agent, + * new PublicKey("realm-address"), + * new PublicKey("token-mint-address"), + * new PublicKey("delegate-address") + * ); + */ +export async function delegateVotingPower( + agent: SolanaAgentKit, + realm: PublicKey, + governingTokenMint: PublicKey, + delegate: PublicKey, +): Promise { + try { + const connection = agent.connection; + const governanceProgramId = new PublicKey(GOVERNANCE_PROGRAM_ADDRESS); + + // Get governance program version for the connected chain + const programVersion = await getGovernanceProgramVersion( + connection, + governanceProgramId, + ); + // Get token owner record for the current wallet + const tokenOwnerRecordAddress = await getTokenOwnerRecordAddress( + governanceProgramId, + realm, + governingTokenMint, + agent.wallet_address, + ); + + // Create transaction + const transaction = new Transaction(); + + // Add set delegate instruction + await withSetGovernanceDelegate( + transaction.instructions, + governanceProgramId, + programVersion, + realm, + governingTokenMint, + tokenOwnerRecordAddress, + agent.wallet_address, // governanceAuthority + delegate, + ); + + // Send and confirm transaction + transaction.sign(agent.wallet); + const signature = await agent.connection.sendRawTransaction( + transaction.serialize(), + { + preflightCommitment: "confirmed", + maxRetries: 3, + }, + ); + + // Wait for confirmation + const latestBlockhash = await connection.getLatestBlockhash(); + await connection.confirmTransaction({ + signature, + blockhash: latestBlockhash.blockhash, + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, + }); + + return signature; + } catch (error: any) { + // Handle specific error cases + if (error.message.includes("Account not found")) { + throw new Error("No token owner record found - deposit tokens first"); + } + if (error.message.includes("Invalid delegate")) { + throw new Error("Invalid delegate address provided"); + } + throw new Error(`Failed to delegate voting power: ${error.message}`); + } +} diff --git a/src/tools/realm/get_voting_outcome.ts b/src/tools/realm/get_voting_outcome.ts new file mode 100644 index 00000000..03887907 --- /dev/null +++ b/src/tools/realm/get_voting_outcome.ts @@ -0,0 +1,42 @@ +import { PublicKey } from "@solana/web3.js"; +import { SolanaAgentKit } from "../../index"; +import { getProposal, Proposal } from "@solana/spl-governance"; + +/** + * Get detailed voting outcome for a specific governance proposal + * + * @param agent {SolanaAgentKit} The Solana Agent Kit instance + * @param proposalAccount {PublicKey} The public key of the proposal account + * + * @returns {Promise} + * + * @throws {Error} If proposal account is invalid + * @throws {Error} If proposal is not found + * + * @example + * const outcome = await getVotingOutcome( + * agent, + * new PublicKey("proposal-address") + * ); + */ +export async function getVotingOutcome( + agent: SolanaAgentKit, + proposalAccount: PublicKey, +): Promise { + try { + const connection = agent.connection; + + // Get proposal data using supported API + const proposalData = await getProposal(connection, proposalAccount); + + if (!proposalData) { + throw new Error("Proposal not found"); + } + + const proposal = proposalData.account; + + return proposal; + } catch (error: any) { + throw new Error(`Failed to get voting outcome: ${error.message}`); + } +} diff --git a/src/tools/realm/get_voting_power.ts b/src/tools/realm/get_voting_power.ts new file mode 100644 index 00000000..d37840c5 --- /dev/null +++ b/src/tools/realm/get_voting_power.ts @@ -0,0 +1,74 @@ +import { PublicKey } from "@solana/web3.js"; +import { SolanaAgentKit, VotingPowerInfo } from "../../index"; +import { getTokenOwnerRecordsByOwner } from "@solana/spl-governance"; +import { GOVERNANCE_PROGRAM_ADDRESS } from "../../constants"; + +/** + * Get current voting power for a wallet in a realm including delegated power + * + * @param agent {SolanaAgentKit} The Solana Agent Kit instance + * @param realm {PublicKey} The public key of the realm + * @param governingTokenMint {PublicKey} The mint of the governing token to check power for + * + * @returns {Promise} VotingPowerInfo containing voting power details: + * - votingPower: Direct voting power from token deposits + * - delegatedPower: Additional voting power from delegations + * - totalVotesCount: Total number of votes cast + * - unrelinquishedVotesCount: Number of active votes + * - outstandingProposalCount: Number of outstanding proposals + * + * @throws {Error} If public keys are invalid + * @throws {Error} If fetching voting power fails + * + * @example + * const votingPower = await getVotingPower( + * agent, + * new PublicKey("realm-address"), + * new PublicKey("token-mint-address") + * ); + */ +export async function getVotingPower( + agent: SolanaAgentKit, + realm: PublicKey, + governingTokenMint: PublicKey, +): Promise { + try { + const connection = agent.connection; + const governanceProgramId = new PublicKey(GOVERNANCE_PROGRAM_ADDRESS); + + // Get all token owner records for this wallet + const tokenOwnerRecords = await getTokenOwnerRecordsByOwner( + connection, + governanceProgramId, + agent.wallet_address, + ); + + // Find the record for the specific token mint we're interested in + const relevantRecord = tokenOwnerRecords.find((record) => + record.account.governingTokenMint.equals(governingTokenMint), + ); + + if (!relevantRecord) { + return { + votingPower: 0, + delegatedPower: 0, + totalVotesCount: 0, + unrelinquishedVotesCount: 0, + outstandingProposalCount: 0, + }; + } + + return { + votingPower: + relevantRecord.account.governingTokenDepositAmount.toNumber(), + delegatedPower: relevantRecord.account.governanceDelegate + ? relevantRecord.account.governingTokenDepositAmount.toNumber() + : 0, + totalVotesCount: relevantRecord.account.totalVotesCount, + unrelinquishedVotesCount: relevantRecord.account.unrelinquishedVotesCount, + outstandingProposalCount: relevantRecord.account.outstandingProposalCount, + }; + } catch (error: any) { + throw new Error(`Failed to get voting power: ${error.message}`); + } +} diff --git a/src/tools/realm/index.ts b/src/tools/realm/index.ts new file mode 100644 index 00000000..ad368a3a --- /dev/null +++ b/src/tools/realm/index.ts @@ -0,0 +1,4 @@ +export * from "./caste_vote"; +export * from "./delegate_voting_power"; +export * from "./get_voting_outcome"; +export * from "./get_voting_power"; diff --git a/src/types/index.ts b/src/types/index.ts index 01ac152a..0d0cb617 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -237,3 +237,11 @@ export interface FlashCloseTradeParams { token: string; side: "long" | "short"; } + +export interface VotingPowerInfo { + votingPower: number; + delegatedPower: number; + totalVotesCount: number; + unrelinquishedVotesCount: number; + outstandingProposalCount: number; +} diff --git a/src/utils/keypair.ts b/src/utils/keypair.ts index 25d1a0e7..1b62f1f4 100644 --- a/src/utils/keypair.ts +++ b/src/utils/keypair.ts @@ -1,4 +1,9 @@ -import { Keypair, PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; +import { + Keypair, + PublicKey, + Transaction, + VersionedTransaction, +} from "@solana/web3.js"; import bs58 from "bs58"; export const keypair = Keypair.generate(); @@ -6,7 +11,6 @@ export const keypair = Keypair.generate(); console.log(keypair.publicKey.toString()); console.log(bs58.encode(keypair.secretKey)); - export class Wallet { private _signer: Keypair; @@ -14,7 +18,9 @@ export class Wallet { this._signer = signer; } - async signTransaction(tx: T): Promise { + async signTransaction( + tx: T, + ): Promise { if (tx instanceof Transaction) { tx.sign(this._signer); } else if (tx instanceof VersionedTransaction) { @@ -25,11 +31,13 @@ export class Wallet { return tx; } - async signAllTransactions(txs: T[]): Promise { + async signAllTransactions( + txs: T[], + ): Promise { return Promise.all(txs.map((tx) => this.signTransaction(tx))); } get publicKey(): PublicKey { return this._signer.publicKey; } -} \ No newline at end of file +}