Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/canisters/proposals_bot/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [unreleased]

### Added

- Submit an SNS proposal for each relevant NNS proposal ([#5446](https://github.com/open-chat-labs/open-chat/pull/5446))

### Changed

- Expose `liquid_cycles_balance` in metrics ([#8350](https://github.com/open-chat-labs/open-chat/pull/8350))
Expand Down
4 changes: 2 additions & 2 deletions backend/canisters/proposals_bot/impl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ nns_governance_canister = { path = "../../../external_canisters/nns_governance/a
nns_governance_canister_c2c_client = { path = "../../../external_canisters/nns_governance/c2c_client" }
proposals_bot_canister = { path = "../api" }
rand = { workspace = true }
registry_canister = { path = "../../../canisters/registry/api" }
registry_canister_c2c_client = { path = "../../../canisters/registry/c2c_client" }
registry_canister = { path = "../../registry/api" }
registry_canister_c2c_client = { path = "../../registry/c2c_client" }
serde = { workspace = true }
sha2 = { workspace = true }
sns_governance_canister = { path = "../../../external_canisters/sns_governance/api" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
use crate::jobs::{push_proposals, update_proposals};
use crate::proposals::{REWARD_STATUS_ACCEPT_VOTES, REWARD_STATUS_READY_TO_SETTLE, RawProposal};
use crate::timer_job_types::{ProcessUserRefundJob, TimerJob, TopUpNeuronJob, VoteOnNnsProposalJob};
use crate::timer_job_types::{ProcessUserRefundJob, SubmitProposalJob, TimerJob, TopUpNeuronJob, VoteOnNnsProposalJob};
use crate::{RuntimeState, mutate_state};
use canister_timer_jobs::Job;
use constants::MINUTE_IN_MS;
use nns_governance_canister::types::{ListProposalInfo, ProposalInfo};
use constants::{MINUTE_IN_MS, SNS_GOVERNANCE_CANISTER_ID};
use nns_governance_canister::types::manage_neuron::{Command, RegisterVote};
use nns_governance_canister::types::{ListProposalInfo, ManageNeuron, ProposalInfo};
use proposals_bot_canister::{ExecuteGenericNervousSystemFunction, ProposalToSubmit, ProposalToSubmitAction};
use sns_governance_canister::types::ProposalData;
use std::collections::HashSet;
use std::time::Duration;
use types::{C2CError, CanisterId, Proposal};
use types::{C2CError, CanisterId, NnsNeuronId, Proposal, ProposalId, SnsNeuronId};
use utils::canister::delay_if_should_retry_failed_c2c_call;

pub const NNS_TOPIC_NEURON_MANAGEMENT: i32 = 1;
Expand Down Expand Up @@ -129,13 +131,15 @@ fn handle_proposals_response<R: RawProposal>(governance_canister_id: CanisterId,
}

mutate_state(|state| {
let now = state.env.now();

if governance_canister_id == state.data.nns_governance_canister_id
&& let Some(neuron_id) = state.data.nns_neuron_to_vote_with
{
for proposal in proposals.iter() {
if let Proposal::NNS(nns) = proposal
&& NNS_TOPICS_TO_PUSH_SNS_PROPOSALS_FOR.contains(&nns.topic)
&& state.data.nns_proposals_scheduled_to_vote_on.insert(nns.id)
&& state.data.nns_proposals_requiring_manual_vote.insert(nns.id)
{
// Set up a job to reject the proposal 10 minutes before its deadline.
// In parallel, we will submit an SNS proposal instructing the SNS governance
Expand All @@ -149,8 +153,16 @@ fn handle_proposals_response<R: RawProposal>(governance_canister_id: CanisterId,
vote: false,
}),
nns.deadline.saturating_sub(10 * MINUTE_IN_MS),
state.env.now(),
now,
);

if let Some(oc_neuron_id) = state
.data
.nervous_systems
.get_neuron_id_for_submitting_proposals(&SNS_GOVERNANCE_CANISTER_ID)
{
submit_oc_proposal_for_nns_proposal(nns.id, neuron_id, nns.title.clone(), oc_neuron_id);
}
}
}
}
Expand Down Expand Up @@ -183,12 +195,11 @@ fn handle_proposals_response<R: RawProposal>(governance_canister_id: CanisterId,
.nervous_systems
.take_newly_decided_user_submitted_proposals(governance_canister_id);

let now = state.env.now();
for proposal in decided_user_submitted_proposals {
if let Some(ns) = state.data.nervous_systems.get(&governance_canister_id) {
let ledger_canister_id = ns.ledger_canister_id();
let amount = ns.proposal_rejection_fee().into();
let fee = ns.transaction_fee().into();
if let Some(ns) = state.data.nervous_systems.get(&governance_canister_id) {
let ledger_canister_id = ns.ledger_canister_id();
let amount = ns.proposal_rejection_fee().into();
let fee = ns.transaction_fee().into();
for proposal in decided_user_submitted_proposals {
if proposal.adopted {
let job = ProcessUserRefundJob {
user_id: proposal.user_id,
Expand Down Expand Up @@ -235,3 +246,45 @@ fn handle_proposals_response<R: RawProposal>(governance_canister_id: CanisterId,
}
}
}

fn submit_oc_proposal_for_nns_proposal(
nns_proposal_id: ProposalId,
nns_neuron_id: NnsNeuronId,
nns_proposal_title: String,
oc_neuron_id: SnsNeuronId,
) {
// If this proposal passes, the proposal will call `manage_neuron` on the NNS governance
// canister instructing it to vote to approve the NNS proposal
let manage_neuron_args = ManageNeuron {
id: Some(nns_neuron_id.into()),
neuron_id_or_subaccount: None,
command: Some(Command::RegisterVote(RegisterVote {
proposal: Some(nns_proposal_id.into()),
vote: 1,
})),
};

let payload = candid::encode_one(manage_neuron_args).unwrap();
let nns_proposal_url = format!("https://dashboard.internetcomputer.org/proposal/{nns_proposal_id}");

let job = SubmitProposalJob {
governance_canister_id: SNS_GOVERNANCE_CANISTER_ID,
neuron_id: oc_neuron_id,
proposal: ProposalToSubmit {
title: format!("Instruct the OpenChat NNS named neuron to approve NNS proposal {nns_proposal_id}"),
summary: format!(
"NNS proposal title: \"{nns_proposal_title}\"

The [OpenChat named neuron](https://dashboard.internetcomputer.org/neuron/17682165960669268263) will vote to either approve or reject [NNS proposal {nns_proposal_id}]({nns_proposal_url}) based on the outcome of this proposal."
),
url: nns_proposal_url,
action: ProposalToSubmitAction::ExecuteGenericNervousSystemFunction(ExecuteGenericNervousSystemFunction {
function_id: 102000,
payload,
}),
},
user_id_and_payment: None,
};

job.execute();
}
15 changes: 12 additions & 3 deletions backend/canisters/proposals_bot/impl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::cell::RefCell;
use std::collections::{BTreeMap, HashSet, VecDeque};
use types::{
BuildVersion, CanisterId, Cycles, MessageId, Milliseconds, MultiUserChat, NnsNeuronId, ProposalId, TimestampMillis,
Timestamped,
Timestamped, UserId,
};
use utils::env::Environment;

Expand Down Expand Up @@ -83,7 +83,8 @@ struct Data {
pub timer_jobs: TimerJobs<TimerJob>,
pub registry_synced_up_to: TimestampMillis,
pub fire_and_forget_handler: FireAndForgetHandler,
pub nns_proposals_scheduled_to_vote_on: HashSet<ProposalId>,
#[serde(default, alias = "nns_proposals_scheduled_to_vote_on")]
pub nns_proposals_requiring_manual_vote: HashSet<ProposalId>,
pub nns_neuron_to_vote_with: Option<NnsNeuronId>,
pub rng_seed: [u8; 32],
pub test_mode: bool,
Expand Down Expand Up @@ -111,7 +112,7 @@ impl Data {
timer_jobs: TimerJobs::default(),
registry_synced_up_to: 0,
fire_and_forget_handler: FireAndForgetHandler::default(),
nns_proposals_scheduled_to_vote_on: HashSet::new(),
nns_proposals_requiring_manual_vote: HashSet::new(),
nns_neuron_to_vote_with: None,
rng_seed: [0; 32],
test_mode,
Expand Down Expand Up @@ -178,3 +179,11 @@ fn generate_message_id(governance_canister_id: CanisterId, proposal_id: Proposal
let array8: [u8; 8] = array32[..8].try_into().unwrap();
u64::from_ne_bytes(array8).into()
}

#[derive(Serialize, Deserialize, Clone)]
pub struct UserIdAndPayment {
pub user_id: UserId,
pub ledger_canister_id: CanisterId,
pub amount: u128,
pub fee: u128,
}
12 changes: 3 additions & 9 deletions backend/canisters/proposals_bot/impl/src/timer_job_types.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::mutate_state;
use crate::updates::submit_proposal::submit_proposal;
use crate::{UserIdAndPayment, mutate_state};
use canister_timer_jobs::Job;
use constants::{MINUTE_IN_MS, SECOND_IN_MS};
use icrc_ledger_types::icrc1::{account::Account, transfer::TransferArg};
Expand All @@ -23,12 +23,9 @@ pub enum TimerJob {
#[derive(Serialize, Deserialize, Clone)]
pub struct SubmitProposalJob {
pub governance_canister_id: CanisterId,
pub user_id: UserId,
pub neuron_id: SnsNeuronId,
pub proposal: ProposalToSubmit,
pub ledger: CanisterId,
pub payment_amount: u128,
pub transaction_fee: u128,
pub user_id_and_payment: Option<UserIdAndPayment>,
}

#[derive(Serialize, Deserialize, Clone)]
Expand Down Expand Up @@ -78,13 +75,10 @@ impl Job for SubmitProposalJob {
fn execute(self) {
ic_cdk::futures::spawn(async move {
submit_proposal(
self.user_id,
self.user_id_and_payment,
self.governance_canister_id,
self.neuron_id,
self.proposal,
self.ledger,
self.payment_amount,
self.transaction_fee,
)
.await;
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::model::nervous_systems::ValidateSubmitProposalPaymentError;
use crate::timer_job_types::{ProcessUserRefundJob, SubmitProposalJob, TimerJob};
use crate::{RuntimeState, mutate_state, read_state};
use crate::{RuntimeState, UserIdAndPayment, mutate_state, read_state};
use candid::Principal;
use canister_api_macros::update;
use canister_timer_jobs::Job;
Expand Down Expand Up @@ -61,13 +61,15 @@ async fn submit_proposal_impl(args: Args) -> Response {
let proposal = prepare_proposal(args.proposal, user_id, username, chat);

submit_proposal(
user_id,
Some(UserIdAndPayment {
user_id,
ledger_canister_id: args.transaction.ledger,
amount: args.transaction.amount,
fee: args.transaction.fee,
}),
args.governance_canister_id,
neuron_id,
proposal,
args.transaction.ledger,
args.transaction.amount,
args.transaction.fee,
)
.await
}
Expand Down Expand Up @@ -128,13 +130,10 @@ fn prepare_proposal(
}

pub(crate) async fn submit_proposal(
user_id: UserId,
user_id_and_payment: Option<UserIdAndPayment>,
governance_canister_id: CanisterId,
neuron_id: SnsNeuronId,
proposal: ProposalToSubmit,
ledger_canister_id: CanisterId,
payment_amount: u128,
transaction_fee: u128,
) -> Response {
let make_proposal_args = sns_governance_canister::manage_neuron::Args {
subaccount: neuron_id.to_vec(),
Expand All @@ -145,51 +144,54 @@ pub(crate) async fn submit_proposal(
action: Some(convert_proposal_action(proposal.action.clone())),
})),
};
let user_id = user_id_and_payment.as_ref().map(|u| u.user_id);
let user_id_string = user_id.map_or("none".to_string(), |id| id.to_string());
match sns_governance_canister_c2c_client::manage_neuron(governance_canister_id, &make_proposal_args).await {
Ok(response) => {
if let Some(command) = response.command {
return match command {
manage_neuron_response::Command::MakeProposal(p) => {
let proposal_id = p.proposal_id.unwrap().id;
mutate_state(|state| {
state.data.nervous_systems.record_user_submitted_proposal(
governance_canister_id,
user_id,
proposal_id,
)
if let Some(user_id) = user_id {
state.data.nervous_systems.record_user_submitted_proposal(
governance_canister_id,
user_id,
proposal_id,
)
}
});
info!(proposal_id, %user_id, "Proposal submitted");
info!(proposal_id, user_id = user_id_string, "Proposal submitted");
Success
}
manage_neuron_response::Command::Error(error) => {
ProcessUserRefundJob {
user_id,
ledger_canister_id,
amount: payment_amount.saturating_sub(transaction_fee),
fee: transaction_fee,
if let Some(user_and_payment) = user_id_and_payment {
ProcessUserRefundJob {
user_id: user_and_payment.user_id,
ledger_canister_id: user_and_payment.ledger_canister_id,
amount: user_and_payment.amount.saturating_sub(user_and_payment.fee),
fee: user_and_payment.fee,
}
.execute();

error!(?error, user_id = user_id_string, "Failed to submit proposal, refunding user");
}
.execute();

error!(?error, %user_id, "Failed to submit proposal, refunding user");
InternalError(format!("{error:?}"))
}
_ => unreachable!(),
};
}
error!(%user_id, "Failed to submit proposal, response was empty");
error!(user_id = user_id_string, "Failed to submit proposal, response was empty");
InternalError("Empty response from `manage_neuron`".to_string())
}
Err(error) => {
mutate_state(|state| {
enqueue_job(
TimerJob::SubmitProposal(Box::new(SubmitProposalJob {
user_id,
governance_canister_id,
neuron_id,
proposal,
ledger: ledger_canister_id,
payment_amount,
transaction_fee,
user_id_and_payment,
})),
state,
)
Expand Down
Loading