489 lines
17 KiB
Rust
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(())
|
|
}
|
|
}
|
|
}
|