diff --git a/packages/proposal-sdk/src/resolvers.ts b/packages/proposal-sdk/src/resolvers.ts index 723683f..a28fedc 100644 --- a/packages/proposal-sdk/src/resolvers.ts +++ b/packages/proposal-sdk/src/resolvers.ts @@ -20,7 +20,10 @@ export const proposalResolvers: anchor.CustomAccountResolver = combineResol args[0].seed, programId )[0]; - } else if (path[path.length - 1] == "owner") { + } else if ( + path[path.length - 1] == "owner" || + path[path.length - 1] == "authority" + ) { if ((provider as anchor.AnchorProvider).wallet) { return (provider as anchor.AnchorProvider).wallet.publicKey; } diff --git a/programs/proposal/src/instructions/initialize_proposal_config_v0.rs b/programs/proposal/src/instructions/initialize_proposal_config_v0.rs index de488ca..1dd3460 100644 --- a/programs/proposal/src/instructions/initialize_proposal_config_v0.rs +++ b/programs/proposal/src/instructions/initialize_proposal_config_v0.rs @@ -15,6 +15,7 @@ pub struct InitializeProposalConfigArgsV0 { /// Optional program that will be called with `on_vote_v0` after every vote /// Defaults to the owner of `resolution_settings`, which allows it to reactively call resolve_v0 pub on_vote_hook: Pubkey, + pub authority: Pubkey, } #[derive(Accounts)] @@ -44,6 +45,7 @@ pub fn handler( state_controller: args.state_controller, on_vote_hook: args.on_vote_hook, bump_seed: ctx.bumps["proposal_config"], + authority: args.authority, }); Ok(()) } diff --git a/programs/proposal/src/instructions/mod.rs b/programs/proposal/src/instructions/mod.rs index e4b3443..8867184 100644 --- a/programs/proposal/src/instructions/mod.rs +++ b/programs/proposal/src/instructions/mod.rs @@ -1,9 +1,11 @@ pub mod initialize_proposal_config_v0; pub mod initialize_proposal_v0; +pub mod update_proposal_config_v0; pub mod update_state_v0; pub mod vote_v0; pub use initialize_proposal_config_v0::*; pub use initialize_proposal_v0::*; +pub use update_proposal_config_v0::*; pub use update_state_v0::*; pub use vote_v0::*; diff --git a/programs/proposal/src/instructions/update_proposal_config_v0.rs b/programs/proposal/src/instructions/update_proposal_config_v0.rs new file mode 100644 index 0000000..7766470 --- /dev/null +++ b/programs/proposal/src/instructions/update_proposal_config_v0.rs @@ -0,0 +1,42 @@ +use crate::state::*; +use anchor_lang::prelude::*; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Default)] +pub struct UpdateProposalConfigArgsV0 { + pub vote_controller: Option, + pub state_controller: Option, + pub on_vote_hook: Option, + pub authority: Option, +} + +#[derive(Accounts)] +pub struct UpdateProposalConfigV0<'info> { + #[account(mut)] + pub payer: Signer<'info>, + #[account( + mut, + has_one = authority, + )] + pub proposal_config: Box>, + pub authority: Signer<'info>, +} + +pub fn handler( + ctx: Context, + args: UpdateProposalConfigArgsV0, +) -> Result<()> { + if let Some(vote_controller) = args.vote_controller { + ctx.accounts.proposal_config.vote_controller = vote_controller; + } + if let Some(state_controller) = args.state_controller { + ctx.accounts.proposal_config.state_controller = state_controller; + } + if let Some(on_vote_hook) = args.on_vote_hook { + ctx.accounts.proposal_config.on_vote_hook = on_vote_hook; + } + if let Some(authority) = args.authority { + ctx.accounts.proposal_config.authority = authority; + } + + Ok(()) +} diff --git a/programs/proposal/src/lib.rs b/programs/proposal/src/lib.rs index 6f01cef..48fbb98 100644 --- a/programs/proposal/src/lib.rs +++ b/programs/proposal/src/lib.rs @@ -34,6 +34,13 @@ pub mod proposal { pub fn update_state_v0(ctx: Context, args: UpdateStateArgsV0) -> Result<()> { update_state_v0::handler(ctx, args) } + + pub fn update_proposal_config_v0( + ctx: Context, + args: UpdateProposalConfigArgsV0, + ) -> Result<()> { + update_proposal_config_v0::handler(ctx, args) + } } #[derive(Accounts)] diff --git a/programs/proposal/src/state.rs b/programs/proposal/src/state.rs index 7eaf8df..abdd7aa 100644 --- a/programs/proposal/src/state.rs +++ b/programs/proposal/src/state.rs @@ -58,6 +58,7 @@ pub struct ProposalConfigV0 { #[max_len(32)] pub name: String, pub bump_seed: u8, + pub authority: Pubkey, } #[account] diff --git a/tests/proposal.ts b/tests/proposal.ts index 2ac9e21..c8d0c09 100644 --- a/tests/proposal.ts +++ b/tests/proposal.ts @@ -1,7 +1,7 @@ import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { Proposal } from "../target/types/proposal"; -import { PublicKey } from "@solana/web3.js"; +import { Keypair, PublicKey } from "@solana/web3.js"; import { PROGRAM_ID, init } from "@helium/proposal-sdk"; import { expect } from "chai"; import { makeid } from "./utils"; @@ -20,6 +20,32 @@ describe("proposal", () => { }); it("Creates a proposal config", async () => { + const voteController = Keypair.generate().publicKey; + const stateController = Keypair.generate().publicKey; + const onVoteHook = Keypair.generate().publicKey; + + const { + pubkeys: { proposalConfig }, + } = await program.methods + .initializeProposalConfigV0({ + name, + voteController, + stateController, + onVoteHook, + }) + .rpcAndKeys(); + + const acct = await program.account.proposalConfigV0.fetch(proposalConfig!); + + expect(acct.voteController.toBase58()).to.eq(voteController.toBase58()); + expect(acct.stateController.toBase58()).to.eq(stateController.toBase58()); + expect(acct.onVoteHook.toBase58()).to.eq(onVoteHook.toBase58()); + expect(acct.authority.toBase58()).to.eq(PublicKey.default.toBase58()); + }); + + it("Creates a proposal config with authority", async () => { + const authority = Keypair.generate().publicKey; + const { pubkeys: { proposalConfig }, } = await program.methods @@ -28,6 +54,7 @@ describe("proposal", () => { voteController: me, stateController: me, onVoteHook: PublicKey.default, + authority, }) .rpcAndKeys(); @@ -36,6 +63,7 @@ describe("proposal", () => { expect(acct.voteController.toBase58()).to.eq(me.toBase58()); expect(acct.stateController.toBase58()).to.eq(me.toBase58()); expect(acct.onVoteHook.toBase58()).to.eq(PublicKey.default.toBase58()); + expect(acct.authority.toBase58()).to.eq(authority.toBase58()); }); describe("with proposal config", () => { @@ -49,10 +77,109 @@ describe("proposal", () => { voteController: me, stateController: me, onVoteHook: PublicKey.default, + authority: me, }) .rpcAndKeys()); }); + it("Updates a proposal config", async () => { + const voteController = Keypair.generate().publicKey; + const stateController = Keypair.generate().publicKey; + const onVoteHook = Keypair.generate().publicKey; + const authority = Keypair.generate().publicKey; + + await program.methods + .updateProposalConfigV0({ + voteController, + stateController, + onVoteHook, + authority, + }) + .accounts({ proposalConfig }) + .rpc(); + + const acct = await program.account.proposalConfigV0.fetch( + proposalConfig! + ); + + expect(acct.voteController.toBase58()).to.eq(voteController.toBase58()); + expect(acct.stateController.toBase58()).to.eq(stateController.toBase58()); + expect(acct.onVoteHook.toBase58()).to.eq(onVoteHook.toBase58()); + expect(acct.authority.toBase58()).to.eq(authority.toBase58()); + }); + + it("Updates an arbitrary prop in a proposal config", async () => { + const props = [ + "voteController", + "stateController", + "onVoteHook", + "authority", + ]; + const prop = props[Math.floor(Math.random() * props.length)]; + + const pubkey = Keypair.generate().publicKey; + const args = Object.fromEntries( + props.map((p) => [p, p === prop ? pubkey : null]) + ); + + await program.methods + .updateProposalConfigV0(args as any) + .accounts({ proposalConfig }) + .rpc(); + + const acct = await program.account.proposalConfigV0.fetch( + proposalConfig! + ); + + for (const p of props) { + if (p === prop) { + expect(acct[p].toBase58()).to.eq(pubkey.toBase58()); + } else { + if (p === "onVoteHook") { + expect(acct[p].toBase58()).to.eq(PublicKey.default.toBase58()); + } else { + expect(acct[p].toBase58()).to.eq(me.toBase58()); + } + } + } + }); + + it("Fails to update a proposal config with a wrong authority", async () => { + let logs: string | undefined; + + const authority = Keypair.generate().publicKey; + + await program.methods + .updateProposalConfigV0({ + voteController: null, + stateController: null, + onVoteHook: null, + authority, + }) + .accounts({ proposalConfig }) + .rpc(); + + try { + await program.methods + .updateProposalConfigV0({ + voteController: me, + stateController: me, + onVoteHook: PublicKey.default, + authority: me, + }) + .accounts({ + proposalConfig, + }) + .simulate(); + } catch (err) { + logs = err.simulationResponse?.logs; + } + + expect(logs).to.match( + /caused by account: proposal_config\..*ConstraintHasOne/ + ); + }); + it("Creates a proposal", async () => { const { pubkeys: { proposal },