solana-program-library/feature-proposal/cli/src/main.rs

489 lines
17 KiB
Rust

use {
chrono::{DateTime, NaiveDateTime, SecondsFormat, Utc},
clap::{
crate_description, crate_name, crate_version, value_t_or_exit, App, AppSettings, Arg,
SubCommand,
},
solana_clap_utils::{
input_parsers::{keypair_of, pubkey_of},
input_validators::{is_keypair, is_url, is_valid_percentage, is_valid_pubkey},
},
solana_client::rpc_client::RpcClient,
solana_sdk::{
clock::UnixTimestamp,
commitment_config::CommitmentConfig,
program_pack::Pack,
pubkey::Pubkey,
signature::{read_keypair_file, Keypair, Signer},
transaction::Transaction,
},
spl_feature_proposal::state::{AcceptanceCriteria, FeatureProposal},
std::{
collections::HashMap,
fs::File,
io::Write,
time::{Duration, SystemTime, UNIX_EPOCH},
},
};
struct Config {
keypair: Keypair,
json_rpc_url: String,
verbose: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let app_matches = App::new(crate_name!())
.about(crate_description!())
.version(crate_version!())
.setting(AppSettings::SubcommandRequiredElseHelp)
.arg({
let arg = Arg::with_name("config_file")
.short("C")
.long("config")
.value_name("PATH")
.takes_value(true)
.global(true)
.help("Configuration file to use");
if let Some(ref config_file) = *solana_cli_config::CONFIG_FILE {
arg.default_value(&config_file)
} else {
arg
}
})
.arg(
Arg::with_name("keypair")
.long("keypair")
.value_name("KEYPAIR")
.validator(is_keypair)
.takes_value(true)
.global(true)
.help("Filepath or URL to a keypair [default: client keypair]"),
)
.arg(
Arg::with_name("verbose")
.long("verbose")
.short("v")
.takes_value(false)
.global(true)
.help("Show additional information"),
)
.arg(
Arg::with_name("json_rpc_url")
.long("url")
.value_name("URL")
.takes_value(true)
.global(true)
.validator(is_url)
.help("JSON RPC URL for the cluster [default: value from configuration file]"),
)
.subcommand(
SubCommand::with_name("address")
.about("Display address information for the feature proposal")
.arg(
Arg::with_name("feature_proposal")
.value_name("FEATURE_PROPOSAL_ADDRESS")
.validator(is_valid_pubkey)
.index(1)
.required(true)
.help("The address of the feature proposal"),
),
)
.subcommand(
SubCommand::with_name("propose")
.about("Initiate a feature proposal")
.arg(
Arg::with_name("feature_proposal")
.value_name("FEATURE_PROPOSAL_KEYPAIR")
.validator(is_keypair)
.index(1)
.required(true)
.help("The keypair of the feature proposal"),
)
.arg(
Arg::with_name("percent_stake_required")
.long("percent-stake-required")
.value_name("PERCENTAGE")
.validator(is_valid_percentage)
.required(true)
.default_value("67")
.help("Percentage of the active stake required for the proposal to pass"),
)
.arg(
Arg::with_name("distribution_file")
.long("distribution-file")
.value_name("FILENAME")
.required(true)
.default_value("feature-proposal.csv")
.help("Allocations CSV file for use with solana-tokens"),
)
.arg(
Arg::with_name("confirm")
.long("confirm")
.help("Confirm that the feature proposal should actually be initiated"),
),
)
.subcommand(
SubCommand::with_name("tally")
.about("Tally the current results for a proposed feature")
.arg(
Arg::with_name("feature_proposal")
.value_name("FEATURE_PROPOSAL_ADDRESS")
.validator(is_valid_pubkey)
.index(1)
.required(true)
.help("The address of the feature proposal"),
),
)
.get_matches();
let (sub_command, sub_matches) = app_matches.subcommand();
let matches = sub_matches.unwrap();
let config = {
let cli_config = if let Some(config_file) = matches.value_of("config_file") {
solana_cli_config::Config::load(config_file).unwrap_or_default()
} else {
solana_cli_config::Config::default()
};
Config {
json_rpc_url: matches
.value_of("json_rpc_url")
.unwrap_or(&cli_config.json_rpc_url)
.to_string(),
keypair: read_keypair_file(
matches
.value_of("keypair")
.unwrap_or(&cli_config.keypair_path),
)?,
verbose: matches.is_present("verbose"),
}
};
solana_logger::setup_with_default("solana=info");
let rpc_client =
RpcClient::new_with_commitment(config.json_rpc_url.clone(), CommitmentConfig::confirmed());
match (sub_command, sub_matches) {
("address", Some(arg_matches)) => {
let feature_proposal_address = pubkey_of(arg_matches, "feature_proposal").unwrap();
println!(
"Feature Id: {}",
spl_feature_proposal::get_feature_id_address(&feature_proposal_address)
);
println!(
"Token Mint Address: {}",
spl_feature_proposal::get_mint_address(&feature_proposal_address)
);
println!(
"Acceptance Token Address: {}",
spl_feature_proposal::get_acceptance_token_address(&feature_proposal_address)
);
Ok(())
}
("propose", Some(arg_matches)) => {
let feature_proposal_keypair = keypair_of(arg_matches, "feature_proposal").unwrap();
let distribution_file = value_t_or_exit!(arg_matches, "distribution_file", String);
let percent_stake_required =
value_t_or_exit!(arg_matches, "percent_stake_required", u8);
// Hard code deadline for now...
let fortnight = Duration::from_secs(60 * 60 * 24 * 14);
let deadline = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.checked_add(fortnight)
.unwrap()
.as_secs() as UnixTimestamp;
process_propose(
&rpc_client,
&config,
&feature_proposal_keypair,
distribution_file,
percent_stake_required,
deadline,
arg_matches.is_present("confirm"),
)
}
("tally", Some(arg_matches)) => {
if config.verbose {
println!("JSON RPC URL: {}", config.json_rpc_url);
}
let feature_proposal_address = pubkey_of(arg_matches, "feature_proposal").unwrap();
process_tally(&rpc_client, &config, &feature_proposal_address)
}
_ => unreachable!(),
}
}
fn get_feature_proposal(
rpc_client: &RpcClient,
feature_proposal_address: &Pubkey,
) -> Result<FeatureProposal, String> {
let account = rpc_client
.get_multiple_accounts(&[*feature_proposal_address])
.map_err(|err| err.to_string())?
.into_iter()
.next()
.unwrap();
match account {
None => Err(format!(
"Feature proposal {} does not exist",
feature_proposal_address
)),
Some(account) => FeatureProposal::unpack_from_slice(&account.data).map_err(|err| {
format!(
"Failed to deserialize feature proposal {}: {}",
feature_proposal_address, err
)
}),
}
}
fn unix_timestamp_to_string(unix_timestamp: UnixTimestamp) -> String {
format!(
"{} (UnixTimestamp: {})",
match NaiveDateTime::from_timestamp_opt(unix_timestamp, 0) {
Some(ndt) =>
DateTime::<Utc>::from_utc(ndt, Utc).to_rfc3339_opts(SecondsFormat::Secs, true),
None => "unknown".to_string(),
},
unix_timestamp,
)
}
fn process_propose(
rpc_client: &RpcClient,
config: &Config,
feature_proposal_keypair: &Keypair,
distribution_file: String,
percent_stake_required: u8,
deadline: UnixTimestamp,
confirm: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let distributor_token_address =
spl_feature_proposal::get_distributor_token_address(&feature_proposal_keypair.pubkey());
let feature_id_address =
spl_feature_proposal::get_feature_id_address(&feature_proposal_keypair.pubkey());
let acceptance_token_address =
spl_feature_proposal::get_acceptance_token_address(&feature_proposal_keypair.pubkey());
let mint_address = spl_feature_proposal::get_mint_address(&feature_proposal_keypair.pubkey());
println!("Feature Id: {}", feature_id_address);
println!("Token Mint Address: {}", mint_address);
println!("Distributor Token Address: {}", distributor_token_address);
println!("Acceptance Token Address: {}", acceptance_token_address);
let vote_accounts = rpc_client.get_vote_accounts()?;
let mut distribution = HashMap::new();
for (pubkey, activated_stake) in vote_accounts
.current
.into_iter()
.chain(vote_accounts.delinquent)
.map(|vote_account| (vote_account.node_pubkey, vote_account.activated_stake))
{
distribution
.entry(pubkey)
.and_modify(|e| *e += activated_stake)
.or_insert(activated_stake);
}
let tokens_to_mint: u64 = distribution.iter().map(|x| x.1).sum();
let tokens_required = tokens_to_mint * percent_stake_required as u64 / 100;
println!("Number of validators: {}", distribution.len());
println!(
"Tokens to be minted: {}",
spl_feature_proposal::amount_to_ui_amount(tokens_to_mint)
);
println!(
"Tokens required for acceptance: {} ({}%)",
spl_feature_proposal::amount_to_ui_amount(tokens_required),
percent_stake_required
);
println!("Token distribution file: {}", distribution_file);
{
let mut file = File::create(&distribution_file)?;
file.write_all(b"recipient,amount\n")?;
for (node_address, activated_stake) in distribution.iter() {
file.write_all(format!("{},{}\n", node_address, activated_stake).as_bytes())?;
}
}
let mut transaction = Transaction::new_with_payer(
&[spl_feature_proposal::instruction::propose(
&config.keypair.pubkey(),
&feature_proposal_keypair.pubkey(),
tokens_to_mint,
AcceptanceCriteria {
tokens_required,
deadline,
},
)],
Some(&config.keypair.pubkey()),
);
let blockhash = rpc_client.get_recent_blockhash()?.0;
transaction.try_sign(&[&config.keypair, &feature_proposal_keypair], blockhash)?;
println!("JSON RPC URL: {}", config.json_rpc_url);
println!();
println!("Distribute the proposal tokens to all validators by running:");
println!(
" $ solana-tokens distribute-spl-tokens \
--from {} \
--input-csv {} \
--db-path db.{} \
--fee-payer ~/.config/solana/id.json \
--owner <FEATURE_PROPOSAL_KEYPAIR>",
distributor_token_address,
distribution_file,
&feature_proposal_keypair.pubkey().to_string()[..8]
);
println!(
" $ solana-tokens spl-token-balances \
--mint {} --input-csv {}",
mint_address, distribution_file
);
println!();
println!(
"Once the distribution is complete, request validators vote for \
the proposal by first looking up their token account address:"
);
println!(
" $ spl-token --owner ~/validator-keypair.json accounts {}",
mint_address
);
println!("and then submit their vote by running:");
println!(
" $ spl-token --owner ~/validator-keypair.json transfer <TOKEN_ACCOUNT_ADDRESS> ALL {}",
acceptance_token_address
);
println!();
println!("Periodically the votes must be tallied by running:");
println!(
" $ spl-feature-proposal tally {}",
feature_proposal_keypair.pubkey()
);
println!("Tallying is permissionless and may be run by anybody.");
println!("Once this feature proposal is accepted, the {} feature will be activated at the next epoch.", feature_id_address);
println!();
println!(
"Proposal will expire at {}",
unix_timestamp_to_string(deadline)
);
println!();
if !confirm {
println!("Add --confirm flag to initiate the feature proposal");
return Ok(());
}
rpc_client.send_and_confirm_transaction_with_spinner(&transaction)?;
println!();
println!("Feature proposal created!");
Ok(())
}
fn process_tally(
rpc_client: &RpcClient,
config: &Config,
feature_proposal_address: &Pubkey,
) -> Result<(), Box<dyn std::error::Error>> {
let feature_proposal = get_feature_proposal(rpc_client, feature_proposal_address)?;
let feature_id_address =
spl_feature_proposal::get_feature_id_address(&feature_proposal_address);
let acceptance_token_address =
spl_feature_proposal::get_acceptance_token_address(&feature_proposal_address);
println!("Feature Id: {}", feature_id_address);
println!("Acceptance Token Address: {}", acceptance_token_address);
match feature_proposal {
FeatureProposal::Uninitialized => {
return Err("Feature proposal is uninitialized".into());
}
FeatureProposal::Pending(acceptance_criteria) => {
let acceptance_token_address =
spl_feature_proposal::get_acceptance_token_address(feature_proposal_address);
let acceptance_token_balance = rpc_client
.get_token_account_balance(&acceptance_token_address)?
.amount
.parse::<u64>()
.unwrap_or(0);
println!();
println!(
"{} tokens required to accept the proposal",
spl_feature_proposal::amount_to_ui_amount(acceptance_criteria.tokens_required)
);
println!(
"{} tokens have been received",
spl_feature_proposal::amount_to_ui_amount(acceptance_token_balance)
);
println!(
"Proposal will expire at {}",
unix_timestamp_to_string(acceptance_criteria.deadline)
);
println!();
// Don't bother issuing a transaction if it's clear the Tally won't succeed
if acceptance_token_balance < acceptance_criteria.tokens_required
&& (SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as UnixTimestamp)
< acceptance_criteria.deadline
{
println!("Feature proposal pending");
return Ok(());
}
}
FeatureProposal::Accepted { .. } => {
println!("Feature proposal accepted");
return Ok(());
}
FeatureProposal::Expired => {
println!("Feature proposal expired");
return Ok(());
}
}
let mut transaction = Transaction::new_with_payer(
&[spl_feature_proposal::instruction::tally(
feature_proposal_address,
)],
Some(&config.keypair.pubkey()),
);
let blockhash = rpc_client.get_recent_blockhash()?.0;
transaction.try_sign(&[&config.keypair], blockhash)?;
rpc_client.send_and_confirm_transaction_with_spinner(&transaction)?;
// Check the status of the proposal after the tally completes
let feature_proposal = get_feature_proposal(rpc_client, feature_proposal_address)?;
match feature_proposal {
FeatureProposal::Uninitialized => Err("Feature proposal is uninitialized".into()),
FeatureProposal::Pending { .. } => {
println!("Feature proposal pending");
Ok(())
}
FeatureProposal::Accepted { .. } => {
println!("Feature proposal accepted");
Ok(())
}
FeatureProposal::Expired => {
println!("Feature proposal expired");
Ok(())
}
}
}