Governance: Chat program (#2319)

* chore: create test-sdk crate

* chore: create test-sdk crate

* chore: use shred test bench to run tests

* chore: scaffold governance chat program

* chore: move process_transaction to bench

* chore: add with_proposal factory function

* chore: move token utility functions to bench

* chore: create test realm

* chore: move create_token_account_with_transfer_authority to bench

* chore: create test governance

* chore: create test proposal

* feat: implement process_post_message

* chore: move get_clock to bench

* chore: add clock and message author

* chore: move get_borsh_account and get_account to bench

* feat: add reaply_to

* chore: update comments

* chore: update comments and make clippy happy

* chore: add reply to test

* chore: assert is valid replyTo chat message

* chore: add error test cases

* chore: add not enough tokens error test

* chore: update Reaction comment

* feat: assert new account is not initialised

* chore: upgrade chat version

* chore: add license

* chore: update cargo lock

* chore: fix solana version

* chore: update cargo.lock

* chore: move chat program inside governance folder
This commit is contained in:
Sebastian Bor 2021-09-09 15:45:01 +01:00 committed by GitHub
parent cd696da5a4
commit b3684bbfe9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1613 additions and 477 deletions

42
Cargo.lock generated
View File

@ -3659,6 +3659,48 @@ dependencies = [
"solana-program",
"solana-program-test",
"solana-sdk",
"spl-governance-test-sdk",
"spl-token 3.2.0",
"thiserror",
]
[[package]]
name = "spl-governance-chat"
version = "0.1.0"
dependencies = [
"arrayref",
"assert_matches",
"base64 0.13.0",
"bincode",
"borsh",
"num-derive",
"num-traits",
"proptest",
"serde",
"serde_derive",
"solana-program",
"solana-program-test",
"solana-sdk",
"spl-governance",
"spl-governance-test-sdk",
"spl-token 3.2.0",
"thiserror",
]
[[package]]
name = "spl-governance-test-sdk"
version = "0.1.0"
dependencies = [
"arrayref",
"bincode",
"borsh",
"num-derive",
"num-traits",
"serde",
"serde_derive",
"solana-program",
"solana-program-test",
"solana-sdk",
"spl-token 3.2.0",
"thiserror",
]

View File

@ -11,6 +11,8 @@ members = [
"feature-proposal/program",
"feature-proposal/cli",
"governance/program",
"governance/test-sdk",
"governance/chat/program",
"libraries/math",
"memo/program",
"name-service/program",

View File

@ -0,0 +1,4 @@
# Governance chat
Governance chat is a program which allows voters to comment on proposals.
All comments are public and stored on chain.

View File

@ -0,0 +1,38 @@
[package]
name = "spl-governance-chat"
version = "0.1.0"
description = "Solana Program Library Governance Chat Program"
authors = ["Solana Maintainers <maintainers@solana.foundation>"]
repository = "https://github.com/solana-labs/solana-program-library"
license = "Apache-2.0"
edition = "2018"
[features]
no-entrypoint = []
test-bpf = []
[dependencies]
arrayref = "0.3.6"
bincode = "1.3.2"
borsh = "0.9.1"
num-derive = "0.3"
num-traits = "0.2"
serde = "1.0.127"
serde_derive = "1.0.103"
solana-program = "1.7.11"
spl-token = { version = "3.2", path = "../../../token/program", features = [ "no-entrypoint" ] }
spl-governance= { version = "1.1.0", path ="../../program", features = [ "no-entrypoint" ]}
thiserror = "1.0"
[dev-dependencies]
assert_matches = "1.5.0"
base64 = "0.13"
proptest = "1.0"
solana-program-test = "1.7.11"
solana-sdk = "1.7.11"
spl-governance-test-sdk = { version = "0.1.0", path ="../../test-sdk"}
[lib]
crate-type = ["cdylib", "lib"]

View File

@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []

View File

@ -0,0 +1,22 @@
//! Program entrypoint
#![cfg(all(target_arch = "bpf", not(feature = "no-entrypoint")))]
use crate::{error::GovernanceChatError, processor};
use solana_program::{
account_info::AccountInfo, entrypoint, entrypoint::ProgramResult,
program_error::PrintProgramError, pubkey::Pubkey,
};
entrypoint!(process_instruction);
fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
if let Err(error) = processor::process_instruction(program_id, accounts, instruction_data) {
// catch the error so we can print it
error.print::<GovernanceChatError>();
return Err(error);
}
Ok(())
}

View File

@ -0,0 +1,39 @@
//! Error types
use num_derive::FromPrimitive;
use solana_program::{
decode_error::DecodeError,
msg,
program_error::{PrintProgramError, ProgramError},
};
use thiserror::Error;
/// Errors that may be returned by the GovernanceChat program
#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)]
pub enum GovernanceChatError {
/// Owner doesn't have enough governing tokens to comment on Proposal
#[error("Owner doesn't have enough governing tokens to comment on Proposal")]
NotEnoughTokensToCommentProposal = 900,
/// Account already initialized
#[error("Account already initialized")]
AccountAlreadyInitialized,
}
impl PrintProgramError for GovernanceChatError {
fn print<E>(&self) {
msg!("GOVERNANCE-CHAT-ERROR: {}", &self.to_string());
}
}
impl From<GovernanceChatError> for ProgramError {
fn from(e: GovernanceChatError) -> Self {
ProgramError::Custom(e as u32)
}
}
impl<T> DecodeError<T> for GovernanceChatError {
fn type_of() -> &'static str {
"Governance Chat Error"
}
}

View File

@ -0,0 +1,72 @@
//! Program instructions
use borsh::{BorshDeserialize, BorshSchema, BorshSerialize};
use solana_program::{
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
system_program,
};
use crate::state::MessageBody;
/// Instructions supported by the GovernanceChat program
#[derive(Clone, Debug, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)]
#[allow(clippy::large_enum_variant)]
pub enum GovernanceChatInstruction {
/// Posts a message with a comment for a Proposal
///
/// 0. `[]` Governance program id
/// 1. `[]` Governance account the Proposal is for
/// 2. `[]` Proposal account
/// 3. `[]` TokenOwnerRecord account for the message author
/// 4. `[signer]` Governance Authority (TokenOwner or Governance Delegate)
/// 5. `[writable, signer]` ChatMessage account
/// 6. `[signer]` Payer
/// 7. `[]` System program
/// 8. `[]` ReplyTo Message account (optional)
PostMessage {
#[allow(dead_code)]
/// Message body (text or reaction)
body: MessageBody,
},
}
/// Creates PostMessage instruction
#[allow(clippy::too_many_arguments)]
pub fn post_message(
program_id: &Pubkey,
// Accounts
governance_program_id: &Pubkey,
governance: &Pubkey,
proposal: &Pubkey,
token_owner_record: &Pubkey,
governance_authority: &Pubkey,
reply_to: Option<Pubkey>,
chat_message: &Pubkey,
payer: &Pubkey,
// Args
body: MessageBody,
) -> Instruction {
let mut accounts = vec![
AccountMeta::new_readonly(*governance_program_id, false),
AccountMeta::new_readonly(*governance, false),
AccountMeta::new_readonly(*proposal, false),
AccountMeta::new_readonly(*token_owner_record, false),
AccountMeta::new_readonly(*governance_authority, true),
AccountMeta::new(*chat_message, true),
AccountMeta::new_readonly(*payer, true),
AccountMeta::new_readonly(system_program::id(), false),
];
if let Some(reply_to) = reply_to {
accounts.push(AccountMeta::new_readonly(reply_to, false));
}
let instruction = GovernanceChatInstruction::PostMessage { body };
Instruction {
program_id: *program_id,
accounts,
data: instruction.try_to_vec().unwrap(),
}
}

View File

@ -0,0 +1,12 @@
#![deny(missing_docs)]
//! Governance Chat program
pub mod entrypoint;
pub mod error;
pub mod instruction;
pub mod processor;
pub mod state;
pub mod tools;
// Export current sdk types for downstream users building with a different sdk version
pub use solana_program;

View File

@ -0,0 +1,114 @@
//! Program processor
use crate::{
error::GovernanceChatError,
instruction::GovernanceChatInstruction,
state::{assert_is_valid_chat_message, ChatMessage, GovernanceChatAccountType, MessageBody},
tools::account::create_and_serialize_account,
};
use borsh::BorshDeserialize;
use solana_program::{
account_info::{next_account_info, AccountInfo},
clock::Clock,
entrypoint::ProgramResult,
msg,
program_error::ProgramError,
pubkey::Pubkey,
sysvar::Sysvar,
};
use spl_governance::state::{
governance::get_governance_data, proposal::get_proposal_data_for_governance,
token_owner_record::get_token_owner_record_data_for_realm,
};
/// Processes an instruction
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
input: &[u8],
) -> ProgramResult {
let instruction = GovernanceChatInstruction::try_from_slice(input)
.map_err(|_| ProgramError::InvalidInstructionData)?;
match instruction {
GovernanceChatInstruction::PostMessage { body } => {
msg!("GOVERNANCE-CHAT-INSTRUCTION: PostMessage");
process_post_message(program_id, accounts, body)
}
}
}
/// Processes PostMessage instruction
pub fn process_post_message(
program_id: &Pubkey,
accounts: &[AccountInfo],
body: MessageBody,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let governance_program_info = next_account_info(account_info_iter)?; // 0
let governance_info = next_account_info(account_info_iter)?; // 1
let proposal_info = next_account_info(account_info_iter)?; // 2
let token_owner_record_info = next_account_info(account_info_iter)?; // 3
let governance_authority_info = next_account_info(account_info_iter)?; // 4
let chat_message_info = next_account_info(account_info_iter)?; // 5
let payer_info = next_account_info(account_info_iter)?; // 6
let system_info = next_account_info(account_info_iter)?; // 7
let reply_to_info = next_account_info(account_info_iter); // 8
let reply_to_address = if let Ok(reply_to_info) = reply_to_info {
assert_is_valid_chat_message(program_id, reply_to_info)?;
Some(*reply_to_info.key)
} else {
None
};
let governance_data = get_governance_data(governance_program_info.key, governance_info)?;
let token_owner_record_data = get_token_owner_record_data_for_realm(
governance_program_info.key,
token_owner_record_info,
&governance_data.realm,
)?;
token_owner_record_data.assert_token_owner_or_delegate_is_signer(governance_authority_info)?;
// deserialize proposal to assert it belongs to the given governance and hence belongs to the same realm as the token owner
let _proposal_data = get_proposal_data_for_governance(
governance_program_info.key,
proposal_info,
governance_info.key,
)?;
// The owner needs to have at least 1 governing token to comment on proposals
// Note: It can be either community or council token and is irrelevant to the proposal's governing token
// Note: 1 is currently hardcoded but if different level is required then it should be added to realm config
if token_owner_record_data.governing_token_deposit_amount < 1 {
return Err(GovernanceChatError::NotEnoughTokensToCommentProposal.into());
}
let clock = Clock::get()?;
let chat_message_data = ChatMessage {
account_type: GovernanceChatAccountType::ChatMessage,
proposal: *proposal_info.key,
author: token_owner_record_data.governing_token_owner,
posted_at: clock.unix_timestamp,
reply_to: reply_to_address,
body,
};
create_and_serialize_account(
payer_info,
chat_message_info,
&chat_message_data,
program_id,
system_info,
)?;
Ok(())
}

View File

@ -0,0 +1,95 @@
//! Program state
use borsh::{BorshDeserialize, BorshSchema, BorshSerialize};
use solana_program::{
account_info::AccountInfo, clock::UnixTimestamp, program_error::ProgramError, pubkey::Pubkey,
};
use spl_governance::tools::account::{assert_is_valid_account, AccountMaxSize};
/// Defines all GovernanceChat accounts types
#[repr(C)]
#[derive(Clone, Debug, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)]
pub enum GovernanceChatAccountType {
/// Default uninitialized account state
Uninitialized,
/// Chat message
ChatMessage,
}
/// Chat message body
#[derive(Clone, Debug, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)]
pub enum MessageBody {
/// Text message encoded as utf-8 string
Text(String),
/// Emoticon encoded using utf-8 characters
/// In the UI reactions are displayed together under the parent message (as opposed to hierarchical replies)
Reaction(String),
}
/// Chat message
#[derive(Clone, Debug, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)]
pub struct ChatMessage {
/// Account type
pub account_type: GovernanceChatAccountType,
/// The proposal the message is for
pub proposal: Pubkey,
/// Author of the message
pub author: Pubkey,
/// Message timestamp
pub posted_at: UnixTimestamp,
/// Parent message
pub reply_to: Option<Pubkey>,
/// Body of the message
pub body: MessageBody,
}
impl AccountMaxSize for ChatMessage {
fn get_max_size(&self) -> Option<usize> {
let body_size = match self.body.clone() {
MessageBody::Text(body) => body.len(),
MessageBody::Reaction(body) => body.len(),
};
Some(body_size + 111)
}
}
/// Checks whether realm account exists, is initialized and owned by Governance program
pub fn assert_is_valid_chat_message(
program_id: &Pubkey,
chat_message_info: &AccountInfo,
) -> Result<(), ProgramError> {
assert_is_valid_account(
chat_message_info,
GovernanceChatAccountType::ChatMessage,
program_id,
)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_max_size() {
let message = ChatMessage {
account_type: GovernanceChatAccountType::ChatMessage,
proposal: Pubkey::new_unique(),
author: Pubkey::new_unique(),
posted_at: 10,
reply_to: Some(Pubkey::new_unique()),
body: MessageBody::Text("message".to_string()),
};
let size = message.try_to_vec().unwrap().len();
assert_eq!(message.get_max_size(), Some(size));
}
}

View File

@ -0,0 +1,62 @@
//! General purpose account utility functions
use borsh::BorshSerialize;
use solana_program::{
account_info::AccountInfo, program::invoke, program_error::ProgramError, pubkey::Pubkey,
rent::Rent, system_instruction::create_account, system_program, sysvar::Sysvar,
};
use spl_governance::tools::account::AccountMaxSize;
use crate::error::GovernanceChatError;
/// Creates a new account and serializes data into it using AccountMaxSize to determine the account's size
pub fn create_and_serialize_account<'a, T: BorshSerialize + AccountMaxSize>(
payer_info: &AccountInfo<'a>,
account_info: &AccountInfo<'a>,
account_data: &T,
program_id: &Pubkey,
system_info: &AccountInfo<'a>,
) -> Result<(), ProgramError> {
// Assert the account is not initialized yet
if !(account_info.data_is_empty() && *account_info.owner == system_program::id()) {
return Err(GovernanceChatError::AccountAlreadyInitialized.into());
}
let (serialized_data, account_size) = if let Some(max_size) = account_data.get_max_size() {
(None, max_size)
} else {
let serialized_data = account_data.try_to_vec()?;
let account_size = serialized_data.len();
(Some(serialized_data), account_size)
};
let rent = Rent::get()?;
let create_account_instruction = create_account(
payer_info.key,
account_info.key,
rent.minimum_balance(account_size),
account_size as u64,
program_id,
);
invoke(
&create_account_instruction,
&[
payer_info.clone(),
account_info.clone(),
system_info.clone(),
],
)?;
if let Some(serialized_data) = serialized_data {
account_info
.data
.borrow_mut()
.copy_from_slice(&serialized_data);
} else {
account_data.serialize(&mut *account_info.data.borrow_mut())?;
}
Ok(())
}

View File

@ -0,0 +1,3 @@
//! Utility functions
pub mod account;

View File

@ -0,0 +1,130 @@
#![cfg(feature = "test-bpf")]
use program_test::GovernanceChatProgramTest;
use solana_program_test::tokio;
use solana_sdk::signature::Keypair;
use spl_governance::error::GovernanceError;
use spl_governance_chat::error::GovernanceChatError;
mod program_test;
#[tokio::test]
async fn test_post_message() {
// Arrange
let mut governance_chat_test = GovernanceChatProgramTest::start_new().await;
let proposal_cookie = governance_chat_test.with_proposal().await;
// Act
let chat_message_cookie = governance_chat_test
.with_chat_message(&proposal_cookie, None)
.await
.unwrap();
// Assert
let chat_message_data = governance_chat_test
.get_message_account(&chat_message_cookie.address)
.await;
assert_eq!(chat_message_data, chat_message_cookie.account);
}
#[tokio::test]
async fn test_post_reply_message() {
// Arrange
let mut governance_chat_test = GovernanceChatProgramTest::start_new().await;
let proposal_cookie = governance_chat_test.with_proposal().await;
let chat_message_cookie1 = governance_chat_test
.with_chat_message(&proposal_cookie, None)
.await
.unwrap();
// Act
let chat_message_cookie2 = governance_chat_test
.with_chat_message(&proposal_cookie, Some(chat_message_cookie1.address))
.await
.unwrap();
// Assert
let chat_message_data = governance_chat_test
.get_message_account(&chat_message_cookie2.address)
.await;
assert_eq!(chat_message_data, chat_message_cookie2.account);
}
#[tokio::test]
async fn test_post_message_with_owner_or_delegate_must_sign_error() {
// Arrange
let mut governance_chat_test = GovernanceChatProgramTest::start_new().await;
let mut proposal_cookie = governance_chat_test.with_proposal().await;
proposal_cookie.token_owner = Keypair::new();
// Act
let err = governance_chat_test
.with_chat_message(&proposal_cookie, None)
.await
.err()
.unwrap();
// Assert
assert_eq!(
err,
GovernanceError::GoverningTokenOwnerOrDelegateMustSign.into()
);
}
#[tokio::test]
async fn test_post_message_with_invalid_governance_for_proposal_error() {
// Arrange
let mut governance_chat_test = GovernanceChatProgramTest::start_new().await;
let proposal_cookie1 = governance_chat_test.with_proposal().await;
let mut proposal_cookie2 = governance_chat_test.with_proposal().await;
// Try to use proposal from a different realm
proposal_cookie2.address = proposal_cookie1.address;
// Act
let err = governance_chat_test
.with_chat_message(&proposal_cookie2, None)
.await
.err()
.unwrap();
// Assert
assert_eq!(err, GovernanceError::InvalidGovernanceForProposal.into());
}
#[tokio::test]
async fn test_post_message_with_not_enough_tokens_error() {
// Arrange
let mut governance_chat_test = GovernanceChatProgramTest::start_new().await;
let mut proposal_cookie = governance_chat_test.with_proposal().await;
let token_owner_record_cookie = governance_chat_test
.with_token_owner_deposit(&proposal_cookie, 0)
.await;
proposal_cookie.token_owner_record_address = token_owner_record_cookie.address;
proposal_cookie.token_owner = token_owner_record_cookie.token_owner;
// Act
let err = governance_chat_test
.with_chat_message(&proposal_cookie, None)
.await
.err()
.unwrap();
// Assert
assert_eq!(
err,
GovernanceChatError::NotEnoughTokensToCommentProposal.into()
);
}

View File

@ -0,0 +1,27 @@
use solana_program::pubkey::Pubkey;
use solana_sdk::signature::Keypair;
use spl_governance_chat::state::ChatMessage;
#[derive(Debug)]
pub struct ChatMessageCookie {
pub address: Pubkey,
pub account: ChatMessage,
}
#[derive(Debug)]
pub struct ProposalCookie {
pub address: Pubkey,
pub realm_address: Pubkey,
pub governance_address: Pubkey,
pub token_owner_record_address: Pubkey,
pub token_owner: Keypair,
pub governing_token_mint: Pubkey,
pub governing_token_mint_authority: Keypair,
}
#[derive(Debug)]
pub struct TokenOwnerRecordCookie {
pub address: Pubkey,
pub token_owner: Keypair,
}

View File

@ -0,0 +1,315 @@
use std::str::FromStr;
use solana_program::{program_error::ProgramError, pubkey::Pubkey};
use solana_program_test::processor;
use solana_sdk::{signature::Keypair, signer::Signer};
use spl_governance::{
instruction::{
create_account_governance, create_proposal, create_realm, deposit_governing_tokens,
},
state::{
enums::{MintMaxVoteWeightSource, VoteThresholdPercentage},
governance::{get_account_governance_address, GovernanceConfig},
proposal::get_proposal_address,
realm::get_realm_address,
token_owner_record::get_token_owner_record_address,
},
};
use spl_governance_chat::{
instruction::post_message,
processor::process_instruction,
state::{ChatMessage, GovernanceChatAccountType, MessageBody},
};
use spl_governance_test_sdk::{ProgramTestBench, TestBenchProgram};
use crate::program_test::cookies::{ChatMessageCookie, ProposalCookie};
use self::cookies::TokenOwnerRecordCookie;
pub mod cookies;
pub struct GovernanceChatProgramTest {
pub bench: ProgramTestBench,
pub program_id: Pubkey,
pub governance_program_id: Pubkey,
}
impl GovernanceChatProgramTest {
pub async fn start_new() -> Self {
let program_id = Pubkey::from_str("GovernanceChat11111111111111111111111111111").unwrap();
let chat_program = TestBenchProgram {
program_name: "spl_governance_chat",
program_id: program_id,
process_instruction: processor!(process_instruction),
};
let governance_program_id =
Pubkey::from_str("Governance111111111111111111111111111111111").unwrap();
let governance_program = TestBenchProgram {
program_name: "spl_governance",
program_id: governance_program_id,
process_instruction: processor!(spl_governance::processor::process_instruction),
};
let bench = ProgramTestBench::start_new(&[chat_program, governance_program]).await;
Self {
bench,
program_id,
governance_program_id,
}
}
#[allow(dead_code)]
pub async fn with_proposal(&mut self) -> ProposalCookie {
// Create Realm
let name = self.bench.get_unique_name("realm");
let realm_address = get_realm_address(&self.governance_program_id, &name);
let governing_token_mint_keypair = Keypair::new();
let governing_token_mint_authority = Keypair::new();
self.bench
.create_mint(
&governing_token_mint_keypair,
&governing_token_mint_authority.pubkey(),
)
.await;
let realm_authority = Keypair::new();
let create_realm_ix = create_realm(
&self.governance_program_id,
&realm_authority.pubkey(),
&governing_token_mint_keypair.pubkey(),
&self.bench.payer.pubkey(),
None,
name.clone(),
1,
MintMaxVoteWeightSource::FULL_SUPPLY_FRACTION,
);
self.bench
.process_transaction(&[create_realm_ix], None)
.await
.unwrap();
// Create TokenOwnerRecord
let token_owner = Keypair::new();
let token_source = Keypair::new();
let transfer_authority = Keypair::new();
self.bench
.create_token_account_with_transfer_authority(
&token_source,
&governing_token_mint_keypair.pubkey(),
&governing_token_mint_authority,
100,
&token_owner,
&transfer_authority.pubkey(),
)
.await;
let deposit_governing_tokens_ix = deposit_governing_tokens(
&self.governance_program_id,
&realm_address,
&token_source.pubkey(),
&token_owner.pubkey(),
&token_owner.pubkey(),
&self.bench.payer.pubkey(),
&governing_token_mint_keypair.pubkey(),
);
self.bench
.process_transaction(&[deposit_governing_tokens_ix], Some(&[&token_owner]))
.await
.unwrap();
// Create Governance
let governed_account_address = Pubkey::new_unique();
let governance_config = GovernanceConfig {
min_community_tokens_to_create_proposal: 5,
min_council_tokens_to_create_proposal: 2,
min_instruction_hold_up_time: 10,
max_voting_time: 10,
vote_threshold_percentage: VoteThresholdPercentage::YesVote(60),
vote_weight_source: spl_governance::state::enums::VoteWeightSource::Deposit,
proposal_cool_off_time: 0,
};
let token_owner_record_address = get_token_owner_record_address(
&self.governance_program_id,
&realm_address,
&governing_token_mint_keypair.pubkey(),
&token_owner.pubkey(),
);
let create_account_governance_ix = create_account_governance(
&self.governance_program_id,
&realm_address,
&governed_account_address,
&token_owner_record_address,
&self.bench.payer.pubkey(),
governance_config,
);
self.bench
.process_transaction(&[create_account_governance_ix], None)
.await
.unwrap();
// Create Proposal
let governance_address = get_account_governance_address(
&self.governance_program_id,
&realm_address,
&governed_account_address,
);
let proposal_name = "Proposal #1".to_string();
let description_link = "Proposal Description".to_string();
let proposal_index = 0;
let create_proposal_ix = create_proposal(
&self.governance_program_id,
&governance_address,
&token_owner_record_address,
&token_owner.pubkey(),
&self.bench.payer.pubkey(),
&realm_address,
proposal_name,
description_link.clone(),
&governing_token_mint_keypair.pubkey(),
proposal_index,
);
self.bench
.process_transaction(&[create_proposal_ix], Some(&[&token_owner]))
.await
.unwrap();
let proposal_address = get_proposal_address(
&self.governance_program_id,
&governance_address,
&governing_token_mint_keypair.pubkey(),
&proposal_index.to_le_bytes(),
);
ProposalCookie {
address: proposal_address,
realm_address,
governance_address,
token_owner_record_address,
token_owner,
governing_token_mint: governing_token_mint_keypair.pubkey(),
governing_token_mint_authority: governing_token_mint_authority,
}
}
#[allow(dead_code)]
pub async fn with_token_owner_deposit(
&mut self,
proposal_cookie: &ProposalCookie,
deposit_amount: u64,
) -> TokenOwnerRecordCookie {
let token_owner = Keypair::new();
let token_source = Keypair::new();
let transfer_authority = Keypair::new();
self.bench
.create_token_account_with_transfer_authority(
&token_source,
&proposal_cookie.governing_token_mint,
&proposal_cookie.governing_token_mint_authority,
deposit_amount,
&token_owner,
&transfer_authority.pubkey(),
)
.await;
let deposit_governing_tokens_ix = deposit_governing_tokens(
&self.governance_program_id,
&proposal_cookie.realm_address,
&token_source.pubkey(),
&token_owner.pubkey(),
&token_owner.pubkey(),
&self.bench.payer.pubkey(),
&proposal_cookie.governing_token_mint,
);
self.bench
.process_transaction(&[deposit_governing_tokens_ix], Some(&[&token_owner]))
.await
.unwrap();
let token_owner_record_address = get_token_owner_record_address(
&self.governance_program_id,
&proposal_cookie.realm_address,
&proposal_cookie.governing_token_mint,
&token_owner.pubkey(),
);
TokenOwnerRecordCookie {
address: token_owner_record_address,
token_owner,
}
}
#[allow(dead_code)]
pub async fn with_chat_message(
&mut self,
proposal_cookie: &ProposalCookie,
reply_to: Option<Pubkey>,
) -> Result<ChatMessageCookie, ProgramError> {
let message_account = Keypair::new();
let message_body = MessageBody::Text("My comment".to_string());
let post_message_ix = post_message(
&self.program_id,
&self.governance_program_id,
&proposal_cookie.governance_address,
&proposal_cookie.address,
&proposal_cookie.token_owner_record_address,
&proposal_cookie.token_owner.pubkey(),
reply_to,
&message_account.pubkey(),
&self.bench.payer.pubkey(),
message_body.clone(),
);
let clock = self.bench.get_clock().await;
let message = ChatMessage {
account_type: GovernanceChatAccountType::ChatMessage,
proposal: proposal_cookie.address,
author: proposal_cookie.token_owner.pubkey(),
posted_at: clock.unix_timestamp,
reply_to,
body: message_body,
};
self.bench
.process_transaction(
&[post_message_ix],
Some(&[&proposal_cookie.token_owner, &message_account]),
)
.await?;
Ok(ChatMessageCookie {
address: message_account.pubkey(),
account: message,
})
}
#[allow(dead_code)]
pub async fn get_message_account(&mut self, message_address: &Pubkey) -> ChatMessage {
self.bench
.get_borsh_account::<ChatMessage>(message_address)
.await
}
}

View File

@ -29,6 +29,7 @@ base64 = "0.13"
proptest = "1.0"
solana-program-test = "1.7.11"
solana-sdk = "1.7.11"
spl-governance-test-sdk = { version = "0.1.0", path ="../test-sdk"}
[lib]
crate-type = ["cdylib", "lib"]

View File

@ -33,7 +33,7 @@ async fn test_cancel_proposal() {
.await
.unwrap();
let clock = governance_test.get_clock().await;
let clock = governance_test.bench.get_clock().await;
// Act
governance_test

View File

@ -37,7 +37,7 @@ async fn test_cast_vote() {
.await
.unwrap();
let clock = governance_test.get_clock().await;
let clock = governance_test.bench.get_clock().await;
// Act
let vote_record_cookie = governance_test

View File

@ -3,11 +3,12 @@ mod program_test;
use solana_program_test::*;
use program_test::{tools::ProgramInstructionError, *};
use program_test::*;
use solana_sdk::signature::{Keypair, Signer};
use spl_governance::{
error::GovernanceError, tools::bpf_loader_upgradeable::get_program_upgrade_authority,
};
use spl_governance_test_sdk::tools::ProgramInstructionError;
#[tokio::test]
async fn test_create_program_governance() {

View File

@ -193,6 +193,7 @@ async fn test_deposit_initial_community_tokens_with_owner_must_sign_error() {
let token_source = Keypair::new();
governance_test
.bench
.create_token_account_with_transfer_authority(
&token_source,
&realm_cookie.account.community_mint,
@ -209,15 +210,16 @@ async fn test_deposit_initial_community_tokens_with_owner_must_sign_error() {
&token_source.pubkey(),
&token_owner.pubkey(),
&transfer_authority.pubkey(),
&governance_test.context.payer.pubkey(),
&governance_test.bench.context.payer.pubkey(),
&realm_cookie.account.community_mint,
);
instruction.accounts[3] = AccountMeta::new_readonly(token_owner.pubkey(), false);
// // Act
// Act
let error = governance_test
.bench
.process_transaction(&[instruction], Some(&[&transfer_authority]))
.await
.err()
@ -239,6 +241,7 @@ async fn test_deposit_initial_community_tokens_with_invalid_owner_error() {
let invalid_owner = Keypair::new();
governance_test
.bench
.create_token_account_with_transfer_authority(
&token_source,
&realm_cookie.account.community_mint,
@ -255,13 +258,14 @@ async fn test_deposit_initial_community_tokens_with_invalid_owner_error() {
&token_source.pubkey(),
&invalid_owner.pubkey(),
&transfer_authority.pubkey(),
&governance_test.context.payer.pubkey(),
&governance_test.bench.context.payer.pubkey(),
&realm_cookie.account.community_mint,
);
// // Act
let error = governance_test
.bench
.process_transaction(&[instruction], Some(&[&transfer_authority, &invalid_owner]))
.await
.err()
@ -282,6 +286,7 @@ async fn test_deposit_community_tokens_with_malicious_holding_account_error() {
.await;
governance_test
.bench
.mint_tokens(
&realm_cookie.account.community_mint,
&realm_cookie.community_mint_authority,
@ -296,7 +301,7 @@ async fn test_deposit_community_tokens_with_malicious_holding_account_error() {
&token_owner_record_cookie.token_source,
&token_owner_record_cookie.token_owner.pubkey(),
&token_owner_record_cookie.token_owner.pubkey(),
&governance_test.context.payer.pubkey(),
&governance_test.bench.context.payer.pubkey(),
&realm_cookie.account.community_mint,
);
@ -306,6 +311,7 @@ async fn test_deposit_community_tokens_with_malicious_holding_account_error() {
// Act
let err = governance_test
.bench
.process_transaction(
&[deposit_ix],
Some(&[&token_owner_record_cookie.token_owner]),

View File

@ -67,7 +67,7 @@ async fn test_finalize_vote_to_succeeded() {
)
.await;
let clock = governance_test.get_clock().await;
let clock = governance_test.bench.get_clock().await;
// Act

View File

@ -62,7 +62,7 @@ async fn test_execute_flag_instruction_error() {
.advance_clock_by_min_timespan(proposal_instruction_cookie.account.hold_up_time as u64)
.await;
let clock = governance_test.get_clock().await;
let clock = governance_test.bench.get_clock().await;
// Act
governance_test

View File

@ -129,6 +129,7 @@ async fn test_relinquish_active_yes_vote() {
assert_eq!(0, token_owner_record.total_votes_count);
let vote_record_account = governance_test
.bench
.get_account(&vote_record_cookie.address)
.await;
@ -195,6 +196,7 @@ async fn test_relinquish_active_no_vote() {
assert_eq!(0, token_owner_record.total_votes_count);
let vote_record_account = governance_test
.bench
.get_account(&vote_record_cookie.address)
.await;

View File

@ -60,6 +60,7 @@ async fn test_remove_instruction() {
assert_eq!(proposal_account.instructions_executed_count, 0);
let proposal_instruction_account = governance_test
.bench
.get_account(&proposal_instruction_cookie.address)
.await;
@ -192,6 +193,7 @@ async fn test_remove_front_instruction() {
assert_eq!(proposal_account.instructions_next_index, 2);
let proposal_instruction_account = governance_test
.bench
.get_account(&proposal_instruction_cookie.address)
.await;

View File

@ -58,6 +58,7 @@ async fn test_remove_signatory() {
assert_eq!(ProposalState::Draft, proposal_account.state);
let signatory_account = governance_test
.bench
.get_account(&signatory_record_cookie.address)
.await;

View File

@ -2,7 +2,7 @@
mod program_test;
use program_test::{tools::ProgramInstructionError, *};
use program_test::*;
use solana_program_test::tokio;
use solana_sdk::{signature::Keypair, signer::Signer};
use spl_governance::{
@ -10,6 +10,7 @@ use spl_governance::{
instruction::{set_governance_config, Vote},
state::enums::VoteThresholdPercentage,
};
use spl_governance_test_sdk::tools::ProgramInstructionError;
#[tokio::test]
async fn test_set_governance_config() {
@ -105,6 +106,7 @@ async fn test_set_governance_config_with_governance_must_sign_error() {
// Act
let err = governance_test
.bench
.process_transaction(&[set_governance_config_ix], None)
.await
.err()
@ -135,6 +137,7 @@ async fn test_set_governance_config_with_fake_governance_signer_error() {
// Act
let err = governance_test
.bench
.process_transaction(&[set_governance_config_ix], Some(&[&governance_signer]))
.await
.err()

View File

@ -116,6 +116,7 @@ async fn test_set_community_governance_delegate_with_owner_must_sign_error() {
// Act
let err = governance_test
.bench
.process_transaction(&[instruction], None)
.await
.err()

View File

@ -38,7 +38,7 @@ async fn test_sign_off_proposal() {
.await
.unwrap();
let clock = governance_test.get_clock().await;
let clock = governance_test.bench.get_clock().await;
// Act
governance_test

View File

@ -117,6 +117,7 @@ async fn test_withdraw_community_tokens_with_owner_must_sign_error() {
// Act
let err = governance_test
.bench
.process_transaction(&[instruction], None)
.await
.err()
@ -160,6 +161,7 @@ async fn test_withdraw_community_tokens_with_token_owner_record_address_mismatch
// Act
let err = governance_test
.bench
.process_transaction(&[instruction], Some(&[&hacker_record_cookie.token_owner]))
.await
.err()
@ -283,6 +285,7 @@ async fn test_withdraw_tokens_with_malicious_holding_account_error() {
// Try to maliciously withdraw from other token account owned by realm
let realm_token_account_cookie = governance_test
.bench
.with_token_account(
&realm_cookie.account.community_mint,
&realm_cookie.address,
@ -303,6 +306,7 @@ async fn test_withdraw_tokens_with_malicious_holding_account_error() {
// Act
let err = governance_test
.bench
.process_transaction(
&[instruction],
Some(&[&token_owner_record_cookie.token_owner]),

View File

@ -6,7 +6,7 @@ use spl_governance::state::{
vote_record::VoteRecord,
};
use crate::tools::clone_keypair;
use spl_governance_test_sdk::tools::clone_keypair;
pub trait AccountCookie {
fn get_address(&self) -> Pubkey;
@ -145,8 +145,3 @@ pub struct ProposalInstructionCookie {
pub account: ProposalInstruction,
pub instruction: Instruction,
}
#[derive(Debug)]
pub struct TokenAccountCookie {
pub address: Pubkey,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
[package]
name = "spl-governance-test-sdk"
version = "0.1.0"
description = "Solana Program Library Governance Program Test SDK"
authors = ["Solana Maintainers <maintainers@solana.foundation>"]
repository = "https://github.com/solana-labs/solana-program-library"
license = "Apache-2.0"
edition = "2018"
[dependencies]
arrayref = "0.3.6"
bincode = "1.3.2"
borsh = "0.9.1"
num-derive = "0.3"
num-traits = "0.2"
serde = "1.0.127"
serde_derive = "1.0.103"
solana-program = "1.7.11"
solana-program-test = "1.7.11"
solana-sdk = "1.7.11"
spl-token = { version = "3.2", path = "../../token/program", features = [ "no-entrypoint" ] }
thiserror = "1.0"
[lib]
crate-type = ["cdylib", "lib"]

View File

@ -0,0 +1,6 @@
use solana_program::pubkey::Pubkey;
#[derive(Debug)]
pub struct TokenAccountCookie {
pub address: Pubkey,
}

View File

@ -0,0 +1,305 @@
use std::borrow::Borrow;
use borsh::BorshDeserialize;
use cookies::TokenAccountCookie;
use solana_program::{
borsh::try_from_slice_unchecked, clock::Clock, instruction::Instruction,
program_error::ProgramError, program_pack::Pack, pubkey::Pubkey, rent::Rent,
system_instruction, sysvar,
};
use solana_program_test::{ProgramTest, ProgramTestContext};
use solana_sdk::{
account::Account, process_instruction::ProcessInstructionWithContext, signature::Keypair,
signer::Signer, transaction::Transaction,
};
use bincode::deserialize;
use tools::clone_keypair;
use crate::tools::map_transaction_error;
pub mod cookies;
pub mod tools;
/// Specification of a program which is loaded into the test bench
#[derive(Clone)]
pub struct TestBenchProgram<'a> {
pub program_name: &'a str,
pub program_id: Pubkey,
pub process_instruction: Option<ProcessInstructionWithContext>,
}
/// Program's test bench which captures test context, rent and payer and common utility functions
pub struct ProgramTestBench {
pub context: ProgramTestContext,
pub rent: Rent,
pub payer: Keypair,
pub next_id: u8,
}
impl ProgramTestBench {
pub async fn start_new(programs: &[TestBenchProgram<'_>]) -> Self {
let mut program_test = ProgramTest::default();
for program in programs {
program_test.add_program(
program.program_name,
program.program_id,
program.process_instruction,
)
}
let mut context = program_test.start_with_context().await;
let rent = context.banks_client.get_rent().await.unwrap();
let payer = clone_keypair(&context.payer);
Self {
context,
rent,
payer,
next_id: 0,
}
}
pub fn get_unique_name(&mut self, prefix: &str) -> String {
self.next_id += 1;
format!("{}.{}", prefix, self.next_id)
}
pub async fn process_transaction(
&mut self,
instructions: &[Instruction],
signers: Option<&[&Keypair]>,
) -> Result<(), ProgramError> {
let mut transaction = Transaction::new_with_payer(instructions, Some(&self.payer.pubkey()));
let mut all_signers = vec![&self.payer];
if let Some(signers) = signers {
all_signers.extend_from_slice(signers);
}
let recent_blockhash = self
.context
.banks_client
.get_recent_blockhash()
.await
.unwrap();
transaction.sign(&all_signers, recent_blockhash);
self.context
.banks_client
.process_transaction(transaction)
.await
.map_err(map_transaction_error)?;
Ok(())
}
pub async fn create_mint(&mut self, mint_keypair: &Keypair, mint_authority: &Pubkey) {
let mint_rent = self.rent.minimum_balance(spl_token::state::Mint::LEN);
let instructions = [
system_instruction::create_account(
&self.context.payer.pubkey(),
&mint_keypair.pubkey(),
mint_rent,
spl_token::state::Mint::LEN as u64,
&spl_token::id(),
),
spl_token::instruction::initialize_mint(
&spl_token::id(),
&mint_keypair.pubkey(),
mint_authority,
None,
0,
)
.unwrap(),
];
self.process_transaction(&instructions, Some(&[mint_keypair]))
.await
.unwrap();
}
#[allow(dead_code)]
pub async fn create_empty_token_account(
&mut self,
token_account_keypair: &Keypair,
token_mint: &Pubkey,
owner: &Pubkey,
) {
let create_account_instruction = system_instruction::create_account(
&self.context.payer.pubkey(),
&token_account_keypair.pubkey(),
self.rent
.minimum_balance(spl_token::state::Account::get_packed_len()),
spl_token::state::Account::get_packed_len() as u64,
&spl_token::id(),
);
let initialize_account_instruction = spl_token::instruction::initialize_account(
&spl_token::id(),
&token_account_keypair.pubkey(),
token_mint,
owner,
)
.unwrap();
self.process_transaction(
&[create_account_instruction, initialize_account_instruction],
Some(&[token_account_keypair]),
)
.await
.unwrap();
}
#[allow(dead_code)]
pub async fn with_token_account(
&mut self,
token_mint: &Pubkey,
owner: &Pubkey,
token_mint_authority: &Keypair,
amount: u64,
) -> TokenAccountCookie {
let token_account_keypair = Keypair::new();
self.create_empty_token_account(&token_account_keypair, token_mint, owner)
.await;
self.mint_tokens(
token_mint,
token_mint_authority,
&token_account_keypair.pubkey(),
amount,
)
.await;
TokenAccountCookie {
address: token_account_keypair.pubkey(),
}
}
pub async fn mint_tokens(
&mut self,
token_mint: &Pubkey,
token_mint_authority: &Keypair,
token_account: &Pubkey,
amount: u64,
) {
let mint_instruction = spl_token::instruction::mint_to(
&spl_token::id(),
token_mint,
token_account,
&token_mint_authority.pubkey(),
&[],
amount,
)
.unwrap();
self.process_transaction(&[mint_instruction], Some(&[token_mint_authority]))
.await
.unwrap();
}
#[allow(dead_code)]
pub async fn create_token_account_with_transfer_authority(
&mut self,
token_account_keypair: &Keypair,
token_mint: &Pubkey,
token_mint_authority: &Keypair,
amount: u64,
owner: &Keypair,
transfer_authority: &Pubkey,
) {
let create_account_instruction = system_instruction::create_account(
&self.context.payer.pubkey(),
&token_account_keypair.pubkey(),
self.rent
.minimum_balance(spl_token::state::Account::get_packed_len()),
spl_token::state::Account::get_packed_len() as u64,
&spl_token::id(),
);
let initialize_account_instruction = spl_token::instruction::initialize_account(
&spl_token::id(),
&token_account_keypair.pubkey(),
token_mint,
&owner.pubkey(),
)
.unwrap();
let mint_instruction = spl_token::instruction::mint_to(
&spl_token::id(),
token_mint,
&token_account_keypair.pubkey(),
&token_mint_authority.pubkey(),
&[],
amount,
)
.unwrap();
let approve_instruction = spl_token::instruction::approve(
&spl_token::id(),
&token_account_keypair.pubkey(),
transfer_authority,
&owner.pubkey(),
&[],
amount,
)
.unwrap();
self.process_transaction(
&[
create_account_instruction,
initialize_account_instruction,
mint_instruction,
approve_instruction,
],
Some(&[token_account_keypair, token_mint_authority, owner]),
)
.await
.unwrap();
}
#[allow(dead_code)]
pub async fn get_clock(&mut self) -> Clock {
self.get_bincode_account::<Clock>(&sysvar::clock::id())
.await
}
#[allow(dead_code)]
pub async fn get_bincode_account<T: serde::de::DeserializeOwned>(
&mut self,
address: &Pubkey,
) -> T {
self.context
.banks_client
.get_account(*address)
.await
.unwrap()
.map(|a| deserialize::<T>(a.data.borrow()).unwrap())
.unwrap_or_else(|| panic!("GET-TEST-ACCOUNT-ERROR: Account {}", address))
}
/// TODO: Add to SDK
pub async fn get_borsh_account<T: BorshDeserialize>(&mut self, address: &Pubkey) -> T {
self.get_account(address)
.await
.map(|a| try_from_slice_unchecked(&a.data).unwrap())
.unwrap_or_else(|| panic!("GET-TEST-ACCOUNT-ERROR: Account {} not found", address))
}
#[allow(dead_code)]
pub async fn get_account(&mut self, address: &Pubkey) -> Option<Account> {
self.context
.banks_client
.get_account(*address)
.await
.unwrap()
}
}

View File

@ -3,7 +3,7 @@ use std::convert::TryFrom;
use solana_program::{instruction::InstructionError, program_error::ProgramError};
use solana_sdk::{signature::Keypair, transaction::TransactionError, transport::TransportError};
/// TODO: Add to SDK
/// TODO: Add to Solana SDK
/// Instruction errors not mapped in the sdk
pub enum ProgramInstructionError {
/// Incorrect authority provided