diff --git a/Cargo.lock b/Cargo.lock index d60986d255..78081d332d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4945,6 +4945,7 @@ dependencies = [ "indicatif", "pickledb", "serde", + "solana-account-decoder", "solana-banks-client", "solana-banks-server", "solana-clap-utils", @@ -4952,11 +4953,15 @@ dependencies = [ "solana-client", "solana-core", "solana-logger 1.4.10", + "solana-program-test", "solana-remote-wallet", "solana-runtime", "solana-sdk", "solana-stake-program", + "solana-transaction-status", "solana-version", + "spl-associated-token-account", + "spl-token", "tempfile", "thiserror", "tokio 0.3.2", @@ -5109,9 +5114,9 @@ dependencies = [ [[package]] name = "solana_rbpf" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a95dbe2b00920ac4e1524b7442cf5319f01e8fa5742930ac60148882fd7738b" +checksum = "14a45ec96d6902676708f52d180229ea3933df93eadb3e96e356377d467831b6" dependencies = [ "byteorder", "combine", @@ -5131,6 +5136,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spl-associated-token-account" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a25d15fe67b755f95c575ce074e6e39c809fea86b2edb1bf2ae8b0473d5a1d" +dependencies = [ + "solana-program 1.4.4", + "spl-token", +] + [[package]] name = "spl-memo" version = "2.0.0" diff --git a/account-decoder/src/parse_token.rs b/account-decoder/src/parse_token.rs index 1592293001..2b416a519b 100644 --- a/account-decoder/src/parse_token.rs +++ b/account-decoder/src/parse_token.rs @@ -23,6 +23,16 @@ pub fn spl_token_v2_0_native_mint() -> Pubkey { Pubkey::from_str(&spl_token_v2_0::native_mint::id().to_string()).unwrap() } +// A helper function to convert a solana_sdk::pubkey::Pubkey to spl_sdk::pubkey::Pubkey +pub fn spl_token_v2_0_pubkey(pubkey: &Pubkey) -> SplTokenPubkey { + SplTokenPubkey::from_str(&pubkey.to_string()).unwrap() +} + +// A helper function to convert a spl_sdk::pubkey::Pubkey to solana_sdk::pubkey::Pubkey +pub fn pubkey_from_spl_token_v2_0(pubkey: &SplTokenPubkey) -> Pubkey { + Pubkey::from_str(&pubkey.to_string()).unwrap() +} + pub fn parse_token( data: &[u8], mint_decimals: Option, diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 8bfa5831f1..449d3999c0 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -53,7 +53,7 @@ if [[ -n $CI || -z $1 ]]; then fi RUST_LOG=solana=trace _ "$cargo" nightly test --target-dir target/cov --no-run "${packages[@]}" -if RUST_LOG=solana=trace _ "$cargo" nightly test --target-dir target/cov "${packages[@]}" 2> target/cov/coverage-stderr.log; then +if RUST_LOG=solana=trace,solana_rbpf::vm=debug _ "$cargo" nightly test --target-dir target/cov "${packages[@]}" 2> target/cov/coverage-stderr.log; then test_status=0 else test_status=$? diff --git a/tokens/Cargo.toml b/tokens/Cargo.toml index ec2427ffe6..4b04639a70 100644 --- a/tokens/Cargo.toml +++ b/tokens/Cargo.toml @@ -18,6 +18,7 @@ indexmap = "1.5.1" indicatif = "0.15.0" pickledb = "0.4.1" serde = { version = "1.0", features = ["derive"] } +solana-account-decoder = { path = "../account-decoder", version = "1.4.10" } solana-banks-client = { path = "../banks-client", version = "1.4.10" } solana-clap-utils = { path = "../clap-utils", version = "1.4.10" } solana-cli-config = { path = "../cli-config", version = "1.4.10" } @@ -26,7 +27,10 @@ solana-remote-wallet = { path = "../remote-wallet", version = "1.4.10" } solana-runtime = { path = "../runtime", version = "1.4.10" } solana-sdk = { path = "../sdk", version = "1.4.10" } solana-stake-program = { path = "../programs/stake", version = "1.4.10" } +solana-transaction-status = { path = "../transaction-status", version = "1.4.10" } solana-version = { path = "../version", version = "1.4.10" } +spl-associated-token-account-v1-0 = { package = "spl-associated-token-account", version = "=1.0.1" } +spl-token-v2-0 = { package = "spl-token", version = "=3.0.0", features = ["no-entrypoint"] } tempfile = "3.1.0" thiserror = "1.0" tokio = { version = "0.3", features = ["full"] } @@ -37,4 +41,5 @@ bincode = "1.3.1" solana-banks-server = { path = "../banks-server", version = "1.4.10" } solana-core = { path = "../core", version = "1.4.10" } solana-logger = { path = "../logger", version = "1.4.10" } +solana-program-test = { path = "../program-test", version = "1.4.10" } solana-runtime = { path = "../runtime", version = "1.4.10" } diff --git a/tokens/README.md b/tokens/README.md index bb4853f1ed..65fc83edc1 100644 --- a/tokens/README.md +++ b/tokens/README.md @@ -38,7 +38,7 @@ solana-tokens distribute-tokens --from --input-csv -- Example output: ```text -Recipient Expected Balance (◎) +Recipient Expected Balance 3ihfUy1n9gaqihM5bJCiTAGLgWc5zo3DqVUS6T736NLM 42 UKUcTXgbeTYh65RaVV5gSf6xBHevqHvAXMo3e8Q6np8k 43 ``` @@ -77,7 +77,7 @@ recipient,amount,lockup_date Example output: ```text -Recipient Expected Balance (◎) +Recipient Expected Balance 6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv 10 7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr 42 ``` @@ -102,7 +102,7 @@ solana-tokens distribute-tokens --transfer-amount 10 --from --input-cs Example output: ```text -Recipient Expected Balance (◎) +Recipient Expected Balance 6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv 10 7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr 10 CYRJWqiSjLitBAcRxPvWpgX3s5TvmN2SuRY3eEYypFvT 10 @@ -125,3 +125,115 @@ recipient address. That SOL can be used to pay transaction fees on staking operations such as delegating stake. The rest of the allocation is put in a stake account. The new stake account address is output in the transaction log. + +## Distribute SPL tokens + +Distributing SPL Tokens works very similarly to distributing SOL, but requires +the `--owner` parameter to sign transactions. Each recipient account must be an +system account that will own an Associated Token Account for the SPL Token mint. +The Associated Token Account will be created, and funded by the fee_payer, if it +does not already exist. + +Send SPL tokens to the recipients in ``. + +Example recipients.csv: + +```text +recipient,amount +CYRJWqiSjLitBAcRxPvWpgX3s5TvmN2SuRY3eEYypFvT,75.4 +C56nwrDVFpPrqwGYsTgQxv1ZraTh81H14PV4RHvZe36s,10 +7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr,42.1 +7qQPmVAQxEQ5djPDCtiEUrxaPf8wKtLG1m6SB1brejJ1,20 +``` + +You can check the status of the recipients before beginning a distribution. You +must include the SPL Token mint address: + +```bash +solana-tokens spl-token-balances --mint
--input-csv +``` + +Example output: + +```text +Token: JDte736XZ1jGUtfAS32DLpBUWBR7WGSHy1hSZ36VRQ5V +Recipient Expected Balance Actual Balance Difference +CYRJWqiSjLitBAcRxPvWpgX3s5TvmN2SuRY3eEYypFvT 75.40 0.00 -75.40 +C56nwrDVFpPrqwGYsTgQxv1ZraTh81H14PV4RHvZe36s 10.000 Associated token account not yet created +7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr 42.10 0.00 -42.10 +7qQPmVAQxEQ5djPDCtiEUrxaPf8wKtLG1m6SB1brejJ1 20.000 Associated token account not yet created +``` + +To run the distribution: + +```bash +solana-tokens distribute-spl-tokens --from
--owner \ + --input-csv --fee-payer +``` + +Example output: + +```text +Total in input_csv: 147.5 tokens +Distributed: 0 tokens +Undistributed: 147.5 tokens +Total: 147.5 tokens +Recipient Expected Balance +CYRJWqiSjLitBAcRxPvWpgX3s5TvmN2SuRY3eEYypFvT 75.400 +C56nwrDVFpPrqwGYsTgQxv1ZraTh81H14PV4RHvZe36s 10.000 +7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr 42.100 +7qQPmVAQxEQ5djPDCtiEUrxaPf8wKtLG1m6SB1brejJ1 20.000 +``` + +### Calculate what tokens should be sent + +As with SOL, you can List the differences between a list of expected +distributions and the record of what transactions have already been sent using +the `--dry-run` parameter, or `solana-tokens balances`. + +Example updated recipients.csv: + +```text +recipient,amount +CYRJWqiSjLitBAcRxPvWpgX3s5TvmN2SuRY3eEYypFvT,100 +C56nwrDVFpPrqwGYsTgQxv1ZraTh81H14PV4RHvZe36s,100 +7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr,100 +7qQPmVAQxEQ5djPDCtiEUrxaPf8wKtLG1m6SB1brejJ1,100 +``` + +Using dry-run: + +```bash +solana-tokens distribute-tokens --dry-run --input-csv +``` + +Example output: + +```text +Total in input_csv: 400 tokens +Distributed: 147.5 tokens +Undistributed: 252.5 tokens +Total: 400 tokens +Recipient Expected Balance +CYRJWqiSjLitBAcRxPvWpgX3s5TvmN2SuRY3eEYypFvT 24.600 +C56nwrDVFpPrqwGYsTgQxv1ZraTh81H14PV4RHvZe36s 90.000 +7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr 57.900 +7qQPmVAQxEQ5djPDCtiEUrxaPf8wKtLG1m6SB1brejJ1 80.000 +``` + +Or: + +```bash +solana-tokens balances --mint
--input-csv +``` + +Example output: + +```text +Token: JDte736XZ1jGUtfAS32DLpBUWBR7WGSHy1hSZ36VRQ5V +Recipient Expected Balance Actual Balance Difference +CYRJWqiSjLitBAcRxPvWpgX3s5TvmN2SuRY3eEYypFvT 100.000 75.400 -24.600 +C56nwrDVFpPrqwGYsTgQxv1ZraTh81H14PV4RHvZe36s 100.000 10.000 -90.000 +7aHDubg5FBYj1SgmyBgU3ZJdtfuqYCQsJQK2pTR5JUqr 100.000 42.100 -57.900 +7qQPmVAQxEQ5djPDCtiEUrxaPf8wKtLG1m6SB1brejJ1 100.000 20.000 -80.000 +``` diff --git a/tokens/src/arg_parser.rs b/tokens/src/arg_parser.rs index 35f6b54829..710d03b666 100644 --- a/tokens/src/arg_parser.rs +++ b/tokens/src/arg_parser.rs @@ -1,11 +1,11 @@ use crate::args::{ - Args, BalancesArgs, Command, DistributeTokensArgs, StakeArgs, TransactionLogArgs, + Args, BalancesArgs, Command, DistributeTokensArgs, SplTokenArgs, StakeArgs, TransactionLogArgs, }; use clap::{ crate_description, crate_name, value_t, value_t_or_exit, App, Arg, ArgMatches, SubCommand, }; use solana_clap_utils::{ - input_parsers::value_of, + input_parsers::{pubkey_of_signer, value_of}, input_validators::{is_amount, is_valid_pubkey, is_valid_signer}, keypair::{pubkey_from_path, signer_from_path}, }; @@ -42,7 +42,7 @@ where ) .subcommand( SubCommand::with_name("distribute-tokens") - .about("Distribute tokens") + .about("Distribute SOL") .arg( Arg::with_name("db_path") .long("db-path") @@ -201,6 +201,78 @@ where .help("Fee payer"), ), ) + .subcommand( + SubCommand::with_name("distribute-spl-tokens") + .about("Distribute SPL tokens") + .arg( + Arg::with_name("db_path") + .long("db-path") + .required(true) + .takes_value(true) + .value_name("FILE") + .help( + "Location for storing distribution database. \ + The database is used for tracking transactions as they are finalized \ + and preventing double spends.", + ), + ) + .arg( + Arg::with_name("input_csv") + .long("input-csv") + .required(true) + .takes_value(true) + .value_name("FILE") + .help("Allocations CSV file"), + ) + .arg( + Arg::with_name("dry_run") + .long("dry-run") + .help("Do not execute any transfers"), + ) + .arg( + Arg::with_name("transfer_amount") + .long("transfer-amount") + .takes_value(true) + .value_name("AMOUNT") + .validator(is_amount) + .help("The amount of SPL tokens to send to each recipient"), + ) + .arg( + Arg::with_name("output_path") + .long("output-path") + .short("o") + .value_name("FILE") + .takes_value(true) + .help("Write the transaction log to this file"), + ) + .arg( + Arg::with_name("token_account_address") + .long("from") + .required(true) + .takes_value(true) + .value_name("TOKEN_ACCOUNT_ADDRESS") + .validator(is_valid_pubkey) + .help("SPL token account to send from"), + ) + .arg( + Arg::with_name("token_owner") + .long("owner") + .required(true) + .takes_value(true) + .value_name("TOKEN_ACCOUNT_OWNER_KEYPAIR") + .validator(is_valid_signer) + .help("SPL token account owner"), + ) + .arg( + Arg::with_name("fee_payer") + .long("fee-payer") + .required(true) + .takes_value(true) + .value_name("KEYPAIR") + .validator(is_valid_signer) + .help("Fee payer"), + ), + ) .subcommand( SubCommand::with_name("balances") .about("Balance of each account") @@ -213,6 +285,27 @@ where .help("Allocations CSV file"), ), ) + .subcommand( + SubCommand::with_name("spl-token-balances") + .about("Balance of SPL token associated accounts") + .arg( + Arg::with_name("input_csv") + .long("input-csv") + .required(true) + .takes_value(true) + .value_name("FILE") + .help("Allocations CSV file"), + ) + .arg( + Arg::with_name("mint_address") + .long("mint") + .required(true) + .takes_value(true) + .value_name("MINT_ADDRESS") + .validator(is_valid_pubkey) + .help("SPL token mint of distribution"), + ), + ) .subcommand( SubCommand::with_name("transaction-log") .about("Print the database to a CSV file") @@ -266,6 +359,7 @@ fn parse_distribute_tokens_args( sender_keypair, fee_payer, stake_args: None, + spl_token_args: None, transfer_amount: value_of(matches, "transfer_amount"), }) } @@ -342,14 +436,68 @@ fn parse_distribute_stake_args( sender_keypair, fee_payer, stake_args: Some(stake_args), + spl_token_args: None, transfer_amount: None, }) } -fn parse_balances_args(matches: &ArgMatches<'_>) -> BalancesArgs { - BalancesArgs { +fn parse_distribute_spl_tokens_args( + matches: &ArgMatches<'_>, +) -> Result> { + let mut wallet_manager = maybe_wallet_manager()?; + let signer_matches = ArgMatches::default(); // No default signer + + let token_owner_str = value_t_or_exit!(matches, "token_owner", String); + let token_owner = signer_from_path( + &signer_matches, + &token_owner_str, + "owner", + &mut wallet_manager, + )?; + + let fee_payer_str = value_t_or_exit!(matches, "fee_payer", String); + let fee_payer = signer_from_path( + &signer_matches, + &fee_payer_str, + "fee-payer", + &mut wallet_manager, + )?; + + let token_account_address_str = value_t_or_exit!(matches, "token_account_address", String); + let token_account_address = pubkey_from_path( + &signer_matches, + &token_account_address_str, + "token account address", + &mut wallet_manager, + )?; + + Ok(DistributeTokensArgs { input_csv: value_t_or_exit!(matches, "input_csv", String), - } + transaction_db: value_t_or_exit!(matches, "db_path", String), + output_path: matches.value_of("output_path").map(|path| path.to_string()), + dry_run: matches.is_present("dry_run"), + sender_keypair: token_owner, + fee_payer, + stake_args: None, + spl_token_args: Some(SplTokenArgs { + token_account_address, + ..SplTokenArgs::default() + }), + transfer_amount: value_of(matches, "transfer_amount"), + }) +} + +fn parse_balances_args(matches: &ArgMatches<'_>) -> Result> { + let mut wallet_manager = maybe_wallet_manager()?; + let spl_token_args = + pubkey_of_signer(matches, "mint_address", &mut wallet_manager)?.map(|mint| SplTokenArgs { + mint, + ..SplTokenArgs::default() + }); + Ok(BalancesArgs { + input_csv: value_t_or_exit!(matches, "input_csv", String), + spl_token_args, + }) } fn parse_transaction_log_args(matches: &ArgMatches<'_>) -> TransactionLogArgs { @@ -375,7 +523,11 @@ where ("distribute-stake", Some(matches)) => { Command::DistributeTokens(parse_distribute_stake_args(matches)?) } - ("balances", Some(matches)) => Command::Balances(parse_balances_args(matches)), + ("distribute-spl-tokens", Some(matches)) => { + Command::DistributeTokens(parse_distribute_spl_tokens_args(matches)?) + } + ("balances", Some(matches)) => Command::Balances(parse_balances_args(matches)?), + ("spl-token-balances", Some(matches)) => Command::Balances(parse_balances_args(matches)?), ("transaction-log", Some(matches)) => { Command::TransactionLog(parse_transaction_log_args(matches)) } diff --git a/tokens/src/args.rs b/tokens/src/args.rs index 9d161f66fd..93b2f53f3a 100644 --- a/tokens/src/args.rs +++ b/tokens/src/args.rs @@ -8,6 +8,7 @@ pub struct DistributeTokensArgs { pub sender_keypair: Box, pub fee_payer: Box, pub stake_args: Option, + pub spl_token_args: Option, pub transfer_amount: Option, } @@ -19,8 +20,16 @@ pub struct StakeArgs { pub lockup_authority: Option>, } +#[derive(Default)] +pub struct SplTokenArgs { + pub token_account_address: Pubkey, + pub mint: Pubkey, + pub decimals: u8, +} + pub struct BalancesArgs { pub input_csv: String, + pub spl_token_args: Option, } pub struct TransactionLogArgs { diff --git a/tokens/src/commands.rs b/tokens/src/commands.rs index efb7d36df6..92964be373 100644 --- a/tokens/src/commands.rs +++ b/tokens/src/commands.rs @@ -1,5 +1,9 @@ -use crate::args::{BalancesArgs, DistributeTokensArgs, StakeArgs, TransactionLogArgs}; -use crate::db::{self, TransactionInfo}; +use crate::{ + args::{BalancesArgs, DistributeTokensArgs, StakeArgs, TransactionLogArgs}, + db::{self, TransactionInfo}, + spl_token::*, + token_display::Token, +}; use chrono::prelude::*; use console::style; use csv::{ReaderBuilder, Trim}; @@ -7,6 +11,7 @@ use indexmap::IndexMap; use indicatif::{ProgressBar, ProgressStyle}; use pickledb::PickleDb; use serde::{Deserialize, Serialize}; +use solana_account_decoder::parse_token::{pubkey_from_spl_token_v2_0, spl_token_v2_0_pubkey}; use solana_banks_client::{BanksClient, BanksClientExt}; use solana_sdk::{ commitment_config::CommitmentLevel, @@ -22,6 +27,8 @@ use solana_stake_program::{ stake_instruction::{self, LockupArgs}, stake_state::{Authorized, Lockup, StakeAuthorize}, }; +use spl_associated_token_account_v1_0::get_associated_token_address; +use spl_token_v2_0::solana_program::program_error::ProgramError; use std::{ cmp::{self}, io, @@ -30,15 +37,16 @@ use std::{ use tokio::time::sleep; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -struct Allocation { - recipient: String, - amount: f64, - lockup_date: String, +pub struct Allocation { + pub recipient: String, + pub amount: f64, + pub lockup_date: String, } #[derive(Debug, PartialEq)] pub enum FundingSource { FeePayer, + SplTokenAccount, StakeAccount, SystemAccount, } @@ -83,6 +91,8 @@ pub enum Error { MissingLockupAuthority, #[error("insufficient funds in {0:?}, requires {1} SOL")] InsufficientFunds(FundingSources, f64), + #[error("Program error")] + ProgramError(#[from] ProgramError), } fn merge_allocations(allocations: &[Allocation]) -> Vec { @@ -125,7 +135,7 @@ fn apply_previous_transactions( } } } - allocations.retain(|x| x.amount > 0.5); + allocations.retain(|x| x.amount > f64::EPSILON); } async fn transfer( @@ -150,8 +160,9 @@ fn distribution_instructions( new_stake_account_address: &Pubkey, args: &DistributeTokensArgs, lockup_date: Option>, + do_create_associated_token_account: bool, ) -> Vec { - if args.stake_args.is_none() { + if args.stake_args.is_none() && args.spl_token_args.is_none() { let from = args.sender_keypair.pubkey(); let to = allocation.recipient.parse().unwrap(); let lamports = sol_to_lamports(allocation.amount); @@ -159,6 +170,10 @@ fn distribution_instructions( return vec![instruction]; } + if args.spl_token_args.is_some() { + return build_spl_token_instructions(allocation, args, do_create_associated_token_account); + } + let stake_args = args.stake_args.as_ref().unwrap(); let unlocked_sol = stake_args.unlocked_sol; let sender_pubkey = args.sender_keypair.pubkey(); @@ -225,34 +240,65 @@ async fn distribute_allocations( args: &DistributeTokensArgs, ) -> Result<(), Error> { type StakeExtras = Vec<(Keypair, Option>)>; - let (messages, stake_extras): (Vec, StakeExtras) = allocations - .iter() - .map(|allocation| { - let new_stake_account_keypair = Keypair::new(); - let lockup_date = if allocation.lockup_date == "" { - None - } else { - Some(allocation.lockup_date.parse::>().unwrap()) - }; + let mut messages: Vec = vec![]; + let mut stake_extras: StakeExtras = vec![]; + let mut created_accounts = 0; + for allocation in allocations.iter() { + let new_stake_account_keypair = Keypair::new(); + let lockup_date = if allocation.lockup_date == "" { + None + } else { + Some(allocation.lockup_date.parse::>().unwrap()) + }; - println!("{:<44} {:>24.9}", allocation.recipient, allocation.amount); - let instructions = distribution_instructions( - allocation, - &new_stake_account_keypair.pubkey(), - args, - lockup_date, - ); - let fee_payer_pubkey = args.fee_payer.pubkey(); - let message = Message::new(&instructions, Some(&fee_payer_pubkey)); - (message, (new_stake_account_keypair, lockup_date)) - }) - .unzip(); + let (decimals, do_create_associated_token_account) = + if let Some(spl_token_args) = &args.spl_token_args { + let wallet_address = allocation.recipient.parse().unwrap(); + let associated_token_address = get_associated_token_address( + &wallet_address, + &spl_token_v2_0_pubkey(&spl_token_args.mint), + ); + let do_create_associated_token_account = client + .get_account(pubkey_from_spl_token_v2_0(&associated_token_address)) + .await? + .is_none(); + if do_create_associated_token_account { + created_accounts += 1; + } + ( + spl_token_args.decimals as usize, + do_create_associated_token_account, + ) + } else { + (9, false) + }; + println!( + "{:<44} {:>24.2$}", + allocation.recipient, allocation.amount, decimals + ); + let instructions = distribution_instructions( + allocation, + &new_stake_account_keypair.pubkey(), + args, + lockup_date, + do_create_associated_token_account, + ); + let fee_payer_pubkey = args.fee_payer.pubkey(); + let message = Message::new(&instructions, Some(&fee_payer_pubkey)); + messages.push(message); + stake_extras.push((new_stake_account_keypair, lockup_date)); + } let num_signatures = messages .iter() .map(|message| message.header.num_required_signatures as usize) .sum(); - check_payer_balances(num_signatures, allocations, client, args).await?; + if args.spl_token_args.is_some() { + check_spl_token_balances(num_signatures, allocations, client, args, created_accounts) + .await?; + } else { + check_payer_balances(num_signatures, allocations, client, args).await?; + } for ((allocation, message), (new_stake_account_keypair, lockup_date)) in allocations.iter().zip(messages).zip(stake_extras) @@ -304,7 +350,11 @@ async fn distribute_allocations( Ok(()) } -fn read_allocations(input_csv: &str, transfer_amount: Option) -> io::Result> { +fn read_allocations( + input_csv: &str, + transfer_amount: Option, + require_lockup_heading: bool, +) -> io::Result> { let mut rdr = ReaderBuilder::new().trim(Trim::All).from_path(input_csv)?; let allocations = if let Some(amount) = transfer_amount { let recipients: Vec = rdr @@ -319,8 +369,21 @@ fn read_allocations(input_csv: &str, transfer_amount: Option) -> io::Result lockup_date: "".to_string(), }) .collect() - } else { + } else if require_lockup_heading { rdr.deserialize().map(|entry| entry.unwrap()).collect() + } else { + let recipients: Vec<(String, f64)> = rdr + .deserialize() + .map(|recipient| recipient.unwrap()) + .collect(); + recipients + .into_iter() + .map(|(recipient, amount)| Allocation { + recipient, + amount, + lockup_date: "".to_string(), + }) + .collect() }; Ok(allocations) } @@ -337,11 +400,17 @@ pub async fn process_allocations( client: &mut BanksClient, args: &DistributeTokensArgs, ) -> Result, Error> { - let mut allocations: Vec = read_allocations(&args.input_csv, args.transfer_amount)?; + let require_lockup_heading = args.stake_args.is_some(); + let mut allocations: Vec = read_allocations( + &args.input_csv, + args.transfer_amount, + require_lockup_heading, + )?; + let is_sol = args.spl_token_args.is_none(); - let starting_total_tokens: f64 = allocations.iter().map(|x| x.amount).sum(); + let starting_total_tokens = Token::from(allocations.iter().map(|x| x.amount).sum(), is_sol); println!( - "{} ◎{}", + "{} {}", style("Total in input_csv:").bold(), starting_total_tokens, ); @@ -359,27 +428,23 @@ pub async fn process_allocations( return Ok(confirmations); } - let distributed_tokens: f64 = transaction_infos.iter().map(|x| x.amount).sum(); - let undistributed_tokens: f64 = allocations.iter().map(|x| x.amount).sum(); - println!("{} ◎{}", style("Distributed:").bold(), distributed_tokens,); + let distributed_tokens = Token::from(transaction_infos.iter().map(|x| x.amount).sum(), is_sol); + let undistributed_tokens = Token::from(allocations.iter().map(|x| x.amount).sum(), is_sol); + println!("{} {}", style("Distributed:").bold(), distributed_tokens,); println!( - "{} ◎{}", + "{} {}", style("Undistributed:").bold(), undistributed_tokens, ); println!( - "{} ◎{}", + "{} {}", style("Total:").bold(), distributed_tokens + undistributed_tokens, ); println!( "{}", - style(format!( - "{:<44} {:>24}", - "Recipient", "Expected Balance (◎)" - )) - .bold() + style(format!("{:<44} {:>24}", "Recipient", "Expected Balance",)).bold() ); distribute_allocations(client, &mut db, &allocations, args).await?; @@ -563,33 +628,41 @@ async fn check_payer_balances( Ok(()) } -pub async fn process_balances( - client: &mut BanksClient, - args: &BalancesArgs, -) -> Result<(), csv::Error> { - let allocations: Vec = read_allocations(&args.input_csv, None)?; +pub async fn process_balances(client: &mut BanksClient, args: &BalancesArgs) -> Result<(), Error> { + let allocations: Vec = read_allocations(&args.input_csv, None, false)?; let allocations = merge_allocations(&allocations); + let token = if let Some(spl_token_args) = &args.spl_token_args { + spl_token_args.mint.to_string() + } else { + "◎".to_string() + }; + println!("{} {}", style("Token:").bold(), token); + println!( "{}", style(format!( "{:<44} {:>24} {:>24} {:>24}", - "Recipient", "Expected Balance (◎)", "Actual Balance (◎)", "Difference (◎)" + "Recipient", "Expected Balance", "Actual Balance", "Difference" )) .bold() ); for allocation in &allocations { - let address = allocation.recipient.parse().unwrap(); - let expected = lamports_to_sol(sol_to_lamports(allocation.amount)); - let actual = lamports_to_sol(client.get_balance(address).await.unwrap()); - println!( - "{:<44} {:>24.9} {:>24.9} {:>24.9}", - allocation.recipient, - expected, - actual, - actual - expected - ); + if let Some(spl_token_args) = &args.spl_token_args { + print_token_balances(client, allocation, spl_token_args).await?; + } else { + let address: Pubkey = allocation.recipient.parse().unwrap(); + let expected = lamports_to_sol(sol_to_lamports(allocation.amount)); + let actual = lamports_to_sol(client.get_balance(address).await.unwrap()); + println!( + "{:<44} {:>24.9} {:>24.9} {:>24.9}", + allocation.recipient, + expected, + actual, + actual - expected, + ); + } } Ok(()) @@ -665,6 +738,7 @@ pub async fn test_process_distribute_tokens_with_client( transaction_db: transaction_db.clone(), output_path: Some(output_path.clone()), stake_args: None, + spl_token_args: None, transfer_amount, }; let confirmations = process_allocations(client, &args).await.unwrap(); @@ -788,6 +862,7 @@ pub async fn test_process_distribute_stake_with_client( transaction_db: transaction_db.clone(), output_path: Some(output_path.clone()), stake_args: Some(stake_args), + spl_token_args: None, sender_keypair: Box::new(sender_keypair), transfer_amount: None, }; @@ -841,7 +916,7 @@ pub async fn test_process_distribute_stake_with_client( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use solana_banks_client::start_client; use solana_banks_server::banks_server::start_local_server; @@ -909,11 +984,78 @@ mod tests { wtr.flush().unwrap(); assert_eq!( - read_allocations(&input_csv, None).unwrap(), + read_allocations(&input_csv, None, false).unwrap(), + vec![allocation.clone()] + ); + assert_eq!( + read_allocations(&input_csv, None, true).unwrap(), vec![allocation] ); } + #[test] + fn test_read_allocations_no_lockup() { + let pubkey0 = solana_sdk::pubkey::new_rand(); + let pubkey1 = solana_sdk::pubkey::new_rand(); + let file = NamedTempFile::new().unwrap(); + let input_csv = file.path().to_str().unwrap().to_string(); + let mut wtr = csv::WriterBuilder::new().from_writer(file); + wtr.serialize(("recipient".to_string(), "amount".to_string())) + .unwrap(); + wtr.serialize((&pubkey0.to_string(), 42.0)).unwrap(); + wtr.serialize((&pubkey1.to_string(), 43.0)).unwrap(); + wtr.flush().unwrap(); + + let expected_allocations = vec![ + Allocation { + recipient: pubkey0.to_string(), + amount: 42.0, + lockup_date: "".to_string(), + }, + Allocation { + recipient: pubkey1.to_string(), + amount: 43.0, + lockup_date: "".to_string(), + }, + ]; + assert_eq!( + read_allocations(&input_csv, None, false).unwrap(), + expected_allocations + ); + } + + #[test] + #[should_panic] + fn test_read_allocations_malformed() { + let pubkey0 = solana_sdk::pubkey::new_rand(); + let pubkey1 = solana_sdk::pubkey::new_rand(); + let file = NamedTempFile::new().unwrap(); + let input_csv = file.path().to_str().unwrap().to_string(); + let mut wtr = csv::WriterBuilder::new().from_writer(file); + wtr.serialize(("recipient".to_string(), "amount".to_string())) + .unwrap(); + wtr.serialize((&pubkey0.to_string(), 42.0)).unwrap(); + wtr.serialize((&pubkey1.to_string(), 43.0)).unwrap(); + wtr.flush().unwrap(); + + let expected_allocations = vec![ + Allocation { + recipient: pubkey0.to_string(), + amount: 42.0, + lockup_date: "".to_string(), + }, + Allocation { + recipient: pubkey1.to_string(), + amount: 43.0, + lockup_date: "".to_string(), + }, + ]; + assert_eq!( + read_allocations(&input_csv, None, true).unwrap(), + expected_allocations + ); + } + #[test] fn test_read_allocations_transfer_amount() { let pubkey0 = solana_sdk::pubkey::new_rand(); @@ -948,7 +1090,7 @@ mod tests { }, ]; assert_eq!( - read_allocations(&input_csv, Some(amount)).unwrap(), + read_allocations(&input_csv, Some(amount), false).unwrap(), expected_allocations ); } @@ -1058,6 +1200,7 @@ mod tests { transaction_db: "".to_string(), output_path: None, stake_args: Some(stake_args), + spl_token_args: None, sender_keypair: Box::new(Keypair::new()), transfer_amount: None, }; @@ -1067,6 +1210,7 @@ mod tests { &new_stake_account_address, &args, Some(lockup_date), + false, ); let lockup_instruction = bincode::deserialize(&instructions[SET_LOCKUP_INDEX].data).unwrap(); @@ -1079,7 +1223,7 @@ mod tests { } } - fn tmp_file_path(name: &str, pubkey: &Pubkey) -> String { + pub fn tmp_file_path(name: &str, pubkey: &Pubkey) -> String { use std::env; let out_dir = env::var("FARF_DIR").unwrap_or_else(|_| "farf".to_string()); @@ -1106,6 +1250,7 @@ mod tests { transaction_db: "".to_string(), output_path: None, stake_args, + spl_token_args: None, transfer_amount: None, }; (allocations, args) diff --git a/tokens/src/lib.rs b/tokens/src/lib.rs index ad25864bbe..a864f200e7 100644 --- a/tokens/src/lib.rs +++ b/tokens/src/lib.rs @@ -2,3 +2,5 @@ pub mod arg_parser; pub mod args; pub mod commands; mod db; +pub mod spl_token; +pub mod token_display; diff --git a/tokens/src/main.rs b/tokens/src/main.rs index f677772bf8..a32e89ef24 100644 --- a/tokens/src/main.rs +++ b/tokens/src/main.rs @@ -1,6 +1,6 @@ use solana_banks_client::start_tcp_client; use solana_cli_config::{Config, CONFIG_FILE}; -use solana_tokens::{arg_parser::parse_args, args::Command, commands}; +use solana_tokens::{arg_parser::parse_args, args::Command, commands, spl_token}; use std::{env, error::Error, path::Path, process}; use tokio::runtime::Runtime; use url::Url; @@ -26,10 +26,18 @@ fn main() -> Result<(), Box> { let mut banks_client = runtime.block_on(start_tcp_client(&host_port))?; match command_args.command { - Command::DistributeTokens(args) => { + Command::DistributeTokens(mut args) => { + runtime.block_on(spl_token::update_token_args( + &mut banks_client, + &mut args.spl_token_args, + ))?; runtime.block_on(commands::process_allocations(&mut banks_client, &args))?; } - Command::Balances(args) => { + Command::Balances(mut args) => { + runtime.block_on(spl_token::update_decimals( + &mut banks_client, + &mut args.spl_token_args, + ))?; runtime.block_on(commands::process_balances(&mut banks_client, &args))?; } Command::TransactionLog(args) => { diff --git a/tokens/src/spl_token.rs b/tokens/src/spl_token.rs new file mode 100644 index 0000000000..b1e0f1dc31 --- /dev/null +++ b/tokens/src/spl_token.rs @@ -0,0 +1,698 @@ +use crate::{ + args::{DistributeTokensArgs, SplTokenArgs}, + commands::{Allocation, Error, FundingSource}, +}; +use console::style; +use solana_account_decoder::parse_token::{ + pubkey_from_spl_token_v2_0, spl_token_v2_0_pubkey, token_amount_to_ui_amount, +}; +use solana_banks_client::{BanksClient, BanksClientExt}; +use solana_sdk::{instruction::Instruction, native_token::lamports_to_sol}; +use solana_transaction_status::parse_token::spl_token_v2_0_instruction; +use spl_associated_token_account_v1_0::{ + create_associated_token_account, get_associated_token_address, +}; +use spl_token_v2_0::{ + solana_program::program_pack::Pack, + state::{Account as SplTokenAccount, Mint}, +}; + +pub async fn update_token_args( + client: &mut BanksClient, + args: &mut Option, +) -> Result<(), Error> { + if let Some(spl_token_args) = args { + let sender_account = client + .get_account(spl_token_args.token_account_address) + .await? + .unwrap_or_default(); + let mint_address = + pubkey_from_spl_token_v2_0(&SplTokenAccount::unpack(&sender_account.data)?.mint); + spl_token_args.mint = mint_address; + update_decimals(client, args).await?; + } + Ok(()) +} + +pub async fn update_decimals( + client: &mut BanksClient, + args: &mut Option, +) -> Result<(), Error> { + if let Some(spl_token_args) = args { + let mint_account = client + .get_account(spl_token_args.mint) + .await? + .unwrap_or_default(); + let mint = Mint::unpack(&mint_account.data)?; + spl_token_args.decimals = mint.decimals; + } + Ok(()) +} + +pub fn spl_token_amount(amount: f64, decimals: u8) -> u64 { + (amount * 10_usize.pow(decimals as u32) as f64) as u64 +} + +pub fn build_spl_token_instructions( + allocation: &Allocation, + args: &DistributeTokensArgs, + do_create_associated_token_account: bool, +) -> Vec { + let spl_token_args = args + .spl_token_args + .as_ref() + .expect("spl_token_args must be some"); + let wallet_address = allocation.recipient.parse().unwrap(); + let associated_token_address = get_associated_token_address( + &wallet_address, + &spl_token_v2_0_pubkey(&spl_token_args.mint), + ); + let mut instructions = vec![]; + if do_create_associated_token_account { + let create_associated_token_account_instruction = create_associated_token_account( + &spl_token_v2_0_pubkey(&args.fee_payer.pubkey()), + &wallet_address, + &spl_token_v2_0_pubkey(&spl_token_args.mint), + ); + instructions.push(spl_token_v2_0_instruction( + create_associated_token_account_instruction, + )); + } + let spl_instruction = spl_token_v2_0::instruction::transfer_checked( + &spl_token_v2_0::id(), + &spl_token_v2_0_pubkey(&spl_token_args.token_account_address), + &spl_token_v2_0_pubkey(&spl_token_args.mint), + &associated_token_address, + &spl_token_v2_0_pubkey(&args.sender_keypair.pubkey()), + &[], + spl_token_amount(allocation.amount, spl_token_args.decimals), + spl_token_args.decimals, + ) + .unwrap(); + instructions.push(spl_token_v2_0_instruction(spl_instruction)); + instructions +} + +pub async fn check_spl_token_balances( + num_signatures: usize, + allocations: &[Allocation], + client: &mut BanksClient, + args: &DistributeTokensArgs, + created_accounts: u64, +) -> Result<(), Error> { + let spl_token_args = args + .spl_token_args + .as_ref() + .expect("spl_token_args must be some"); + let undistributed_tokens: f64 = allocations.iter().map(|x| x.amount).sum(); + let allocation_amount = spl_token_amount(undistributed_tokens, spl_token_args.decimals); + + let (fee_calculator, _blockhash, _last_valid_slot) = client.get_fees().await?; + let fees = fee_calculator + .lamports_per_signature + .checked_mul(num_signatures as u64) + .unwrap(); + + let rent = client.get_rent().await?; + let token_account_rent_exempt_balance = rent.minimum_balance(SplTokenAccount::LEN); + let account_creation_amount = created_accounts * token_account_rent_exempt_balance; + let fee_payer_balance = client.get_balance(args.fee_payer.pubkey()).await?; + if fee_payer_balance < fees + account_creation_amount { + return Err(Error::InsufficientFunds( + vec![FundingSource::FeePayer].into(), + lamports_to_sol(fees + account_creation_amount), + )); + } + let source_token_account = client + .get_account(spl_token_args.token_account_address) + .await? + .unwrap_or_default(); + let source_token = SplTokenAccount::unpack(&source_token_account.data)?; + if source_token.amount < allocation_amount { + return Err(Error::InsufficientFunds( + vec![FundingSource::SplTokenAccount].into(), + token_amount_to_ui_amount(allocation_amount, spl_token_args.decimals).ui_amount, + )); + } + Ok(()) +} + +pub async fn print_token_balances( + client: &mut BanksClient, + allocation: &Allocation, + spl_token_args: &SplTokenArgs, +) -> Result<(), Error> { + let address = allocation.recipient.parse().unwrap(); + let expected = allocation.amount; + let associated_token_address = get_associated_token_address( + &spl_token_v2_0_pubkey(&address), + &spl_token_v2_0_pubkey(&spl_token_args.mint), + ); + let recipient_account = client + .get_account(pubkey_from_spl_token_v2_0(&associated_token_address)) + .await? + .unwrap_or_default(); + let (actual, difference) = + if let Ok(recipient_token) = SplTokenAccount::unpack(&recipient_account.data) { + let actual = token_amount_to_ui_amount(recipient_token.amount, spl_token_args.decimals) + .ui_amount; + ( + style(format!( + "{:>24.1$}", + actual, spl_token_args.decimals as usize + )), + format!( + "{:>24.1$}", + actual - expected, + spl_token_args.decimals as usize + ), + ) + } else { + ( + style("Associated token account not yet created".to_string()).yellow(), + "".to_string(), + ) + }; + println!( + "{:<44} {:>24.4$} {:>24} {:>24}", + allocation.recipient, expected, actual, difference, spl_token_args.decimals as usize + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + commands::{process_allocations, tests::tmp_file_path, Allocation}, + db::{self, check_output_file}, + spl_token::spl_token_amount, + }; + use solana_account_decoder::parse_token::{spl_token_id_v2_0, spl_token_v2_0_pubkey}; + use solana_program_test::*; + use solana_sdk::{ + hash::Hash, + signature::{read_keypair_file, write_keypair_file, Keypair, Signer}, + system_instruction, + transaction::Transaction, + }; + use solana_transaction_status::parse_token::spl_token_v2_0_instruction; + use spl_associated_token_account_v1_0::{ + create_associated_token_account, get_associated_token_address, + }; + use spl_token_v2_0::{ + instruction::{initialize_account, initialize_mint, mint_to}, + solana_program::pubkey::Pubkey, + }; + use tempfile::{tempdir, NamedTempFile}; + + fn program_test() -> ProgramTest { + // Add SPL Associated Token program + let mut pc = ProgramTest::new( + "spl_associated_token_account", + pubkey_from_spl_token_v2_0(&spl_associated_token_account_v1_0::id()), + None, + ); + // Add SPL Token program + pc.add_program("spl_token", spl_token_id_v2_0(), None); + pc + } + + async fn initialize_test_mint( + banks_client: &mut BanksClient, + fee_payer: &Keypair, + mint: &Keypair, + decimals: u8, + recent_blockhash: Hash, + ) { + let rent = banks_client.get_rent().await.unwrap(); + let expected_mint_balance = rent.minimum_balance(Mint::LEN); + let instructions = vec![ + system_instruction::create_account( + &fee_payer.pubkey(), + &mint.pubkey(), + expected_mint_balance, + Mint::LEN as u64, + &spl_token_id_v2_0(), + ), + spl_token_v2_0_instruction( + initialize_mint( + &spl_token_v2_0::id(), + &spl_token_v2_0_pubkey(&mint.pubkey()), + &spl_token_v2_0_pubkey(&mint.pubkey()), + None, + decimals, + ) + .unwrap(), + ), + ]; + let mut transaction = Transaction::new_with_payer(&instructions, Some(&fee_payer.pubkey())); + transaction.sign(&[fee_payer, mint], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + } + + async fn initialize_token_account( + banks_client: &mut BanksClient, + fee_payer: &Keypair, + sender_account: &Keypair, + mint: &Keypair, + owner: &Keypair, + recent_blockhash: Hash, + ) { + let rent = banks_client.get_rent().await.unwrap(); + let expected_token_account_balance = rent.minimum_balance(SplTokenAccount::LEN); + let instructions = vec![ + system_instruction::create_account( + &fee_payer.pubkey(), + &sender_account.pubkey(), + expected_token_account_balance, + SplTokenAccount::LEN as u64, + &spl_token_id_v2_0(), + ), + spl_token_v2_0_instruction( + initialize_account( + &spl_token_v2_0::id(), + &spl_token_v2_0_pubkey(&sender_account.pubkey()), + &spl_token_v2_0_pubkey(&mint.pubkey()), + &spl_token_v2_0_pubkey(&owner.pubkey()), + ) + .unwrap(), + ), + ]; + let mut transaction = Transaction::new_with_payer(&instructions, Some(&fee_payer.pubkey())); + transaction.sign(&[fee_payer, sender_account], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + } + + async fn mint_to_account( + banks_client: &mut BanksClient, + fee_payer: &Keypair, + sender_account: &Keypair, + mint: &Keypair, + recent_blockhash: Hash, + ) { + let instructions = vec![spl_token_v2_0_instruction( + mint_to( + &spl_token_v2_0::id(), + &spl_token_v2_0_pubkey(&mint.pubkey()), + &spl_token_v2_0_pubkey(&sender_account.pubkey()), + &spl_token_v2_0_pubkey(&mint.pubkey()), + &[], + 200_000, + ) + .unwrap(), + )]; + let mut transaction = Transaction::new_with_payer(&instructions, Some(&fee_payer.pubkey())); + transaction.sign(&[fee_payer, mint], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + } + + async fn test_process_distribute_spl_tokens_with_client( + banks_client: &mut BanksClient, + fee_payer: Keypair, + transfer_amount: Option, + recent_blockhash: Hash, + ) { + // Initialize Token Mint + let decimals = 2; + let mint = Keypair::new(); + initialize_test_mint(banks_client, &fee_payer, &mint, decimals, recent_blockhash).await; + + // Initialize Sender Token Account and Mint + let sender_account = Keypair::new(); + let owner = Keypair::new(); + initialize_token_account( + banks_client, + &fee_payer, + &sender_account, + &mint, + &owner, + recent_blockhash, + ) + .await; + + mint_to_account( + banks_client, + &fee_payer, + &sender_account, + &mint, + recent_blockhash, + ) + .await; + + // Initialize one recipient Associated Token Account + let wallet_address_0 = Pubkey::new_unique(); + let instructions = vec![spl_token_v2_0_instruction(create_associated_token_account( + &spl_token_v2_0_pubkey(&fee_payer.pubkey()), + &wallet_address_0, + &spl_token_v2_0_pubkey(&mint.pubkey()), + ))]; + let mut transaction = Transaction::new_with_payer(&instructions, Some(&fee_payer.pubkey())); + transaction.sign(&[&fee_payer], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let wallet_address_1 = Pubkey::new_unique(); + + // Create allocations csv + let allocation_amount = if let Some(amount) = transfer_amount { + amount + } else { + 1000.0 + }; + let allocations_file = NamedTempFile::new().unwrap(); + let input_csv = allocations_file.path().to_str().unwrap().to_string(); + let mut wtr = csv::WriterBuilder::new().from_writer(allocations_file); + let allocation = Allocation { + recipient: wallet_address_0.to_string(), + amount: allocation_amount, + lockup_date: "".to_string(), + }; + wtr.serialize(&allocation).unwrap(); + let allocation = Allocation { + recipient: wallet_address_1.to_string(), + amount: allocation_amount, + lockup_date: "".to_string(), + }; + wtr.serialize(&allocation).unwrap(); + wtr.flush().unwrap(); + + let dir = tempdir().unwrap(); + let transaction_db = dir + .path() + .join("transactions.db") + .to_str() + .unwrap() + .to_string(); + + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_str().unwrap().to_string(); + + let args = DistributeTokensArgs { + sender_keypair: Box::new(owner), + fee_payer: Box::new(fee_payer), + dry_run: false, + input_csv, + transaction_db: transaction_db.clone(), + output_path: Some(output_path.clone()), + stake_args: None, + spl_token_args: Some(SplTokenArgs { + token_account_address: sender_account.pubkey(), + mint: mint.pubkey(), + decimals, + }), + transfer_amount, + }; + + // Distribute Allocations + let confirmations = process_allocations(banks_client, &args).await.unwrap(); + assert_eq!(confirmations, None); + + let associated_token_address_0 = + get_associated_token_address(&wallet_address_0, &spl_token_v2_0_pubkey(&mint.pubkey())); + let associated_token_address_1 = + get_associated_token_address(&wallet_address_1, &spl_token_v2_0_pubkey(&mint.pubkey())); + + let transaction_infos = + db::read_transaction_infos(&db::open_db(&transaction_db, true).unwrap()); + assert_eq!(transaction_infos.len(), 2); + assert!(transaction_infos + .iter() + .any(|info| info.recipient == pubkey_from_spl_token_v2_0(&wallet_address_0))); + assert!(transaction_infos + .iter() + .any(|info| info.recipient == pubkey_from_spl_token_v2_0(&wallet_address_1))); + let expected_amount = spl_token_amount(allocation.amount, decimals); + assert_eq!( + spl_token_amount(transaction_infos[0].amount, decimals), + expected_amount + ); + assert_eq!( + spl_token_amount(transaction_infos[1].amount, decimals), + expected_amount + ); + + let recipient_account_0 = banks_client + .get_account(pubkey_from_spl_token_v2_0(&associated_token_address_0)) + .await + .unwrap() + .unwrap_or_default(); + assert_eq!( + SplTokenAccount::unpack(&recipient_account_0.data) + .unwrap() + .amount, + expected_amount, + ); + let recipient_account_1 = banks_client + .get_account(pubkey_from_spl_token_v2_0(&associated_token_address_1)) + .await + .unwrap() + .unwrap_or_default(); + assert_eq!( + SplTokenAccount::unpack(&recipient_account_1.data) + .unwrap() + .amount, + expected_amount, + ); + + check_output_file(&output_path, &db::open_db(&transaction_db, true).unwrap()); + + // Now, run it again, and check there's no double-spend. + process_allocations(banks_client, &args).await.unwrap(); + let transaction_infos = + db::read_transaction_infos(&db::open_db(&transaction_db, true).unwrap()); + assert_eq!(transaction_infos.len(), 2); + assert!(transaction_infos + .iter() + .any(|info| info.recipient == pubkey_from_spl_token_v2_0(&wallet_address_0))); + assert!(transaction_infos + .iter() + .any(|info| info.recipient == pubkey_from_spl_token_v2_0(&wallet_address_1))); + let expected_amount = spl_token_amount(allocation.amount, decimals); + assert_eq!( + spl_token_amount(transaction_infos[0].amount, decimals), + expected_amount + ); + assert_eq!( + spl_token_amount(transaction_infos[1].amount, decimals), + expected_amount + ); + + let recipient_account_0 = banks_client + .get_account(pubkey_from_spl_token_v2_0(&associated_token_address_0)) + .await + .unwrap() + .unwrap_or_default(); + assert_eq!( + SplTokenAccount::unpack(&recipient_account_0.data) + .unwrap() + .amount, + expected_amount, + ); + let recipient_account_1 = banks_client + .get_account(pubkey_from_spl_token_v2_0(&associated_token_address_1)) + .await + .unwrap() + .unwrap_or_default(); + assert_eq!( + SplTokenAccount::unpack(&recipient_account_1.data) + .unwrap() + .amount, + expected_amount, + ); + + check_output_file(&output_path, &db::open_db(&transaction_db, true).unwrap()); + } + + #[tokio::test] + async fn test_process_spl_token_allocations() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + test_process_distribute_spl_tokens_with_client( + &mut banks_client, + payer, + None, + recent_blockhash, + ) + .await; + } + + #[tokio::test] + async fn test_process_spl_token_transfer_amount_allocations() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + test_process_distribute_spl_tokens_with_client( + &mut banks_client, + payer, + Some(105.5), + recent_blockhash, + ) + .await; + } + + #[tokio::test] + async fn test_check_check_spl_token_balances() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + + let (fee_calculator, _, _) = banks_client.get_fees().await.unwrap(); + let signatures = 2; + let fees = fee_calculator.lamports_per_signature * signatures; + let fees_in_sol = lamports_to_sol(fees); + + let rent = banks_client.get_rent().await.unwrap(); + let expected_token_account_balance = rent.minimum_balance(SplTokenAccount::LEN); + let expected_token_account_balance_sol = lamports_to_sol(expected_token_account_balance); + + // Initialize Token Mint + let decimals = 2; + let mint = Keypair::new(); + initialize_test_mint(&mut banks_client, &payer, &mint, decimals, recent_blockhash).await; + + // Initialize Sender Token Account and Mint + let sender_account = Keypair::new(); + let owner = Keypair::new(); + let owner_keypair_file = tmp_file_path("keypair_file", &owner.pubkey()); + write_keypair_file(&owner, &owner_keypair_file).unwrap(); + + initialize_token_account( + &mut banks_client, + &payer, + &sender_account, + &mint, + &owner, + recent_blockhash, + ) + .await; + + let unfunded_fee_payer = Keypair::new(); + + let allocation_amount = 42.0; + let allocations = vec![Allocation { + recipient: Pubkey::new_unique().to_string(), + amount: allocation_amount, + lockup_date: "".to_string(), + }]; + let mut args = DistributeTokensArgs { + sender_keypair: read_keypair_file(&owner_keypair_file).unwrap().into(), + fee_payer: Box::new(unfunded_fee_payer), + dry_run: false, + input_csv: "".to_string(), + transaction_db: "".to_string(), + output_path: None, + stake_args: None, + spl_token_args: Some(SplTokenArgs { + token_account_address: sender_account.pubkey(), + mint: mint.pubkey(), + decimals, + }), + transfer_amount: None, + }; + + // Unfunded fee_payer + let err_result = check_spl_token_balances( + signatures as usize, + &allocations, + &mut banks_client, + &args, + 1, + ) + .await + .unwrap_err(); + if let Error::InsufficientFunds(sources, amount) = err_result { + assert_eq!(sources, vec![FundingSource::FeePayer].into()); + assert!( + (amount - (fees_in_sol + expected_token_account_balance_sol)).abs() < f64::EPSILON + ); + } else { + panic!("check_spl_token_balances should have errored"); + } + + // Unfunded sender SPL Token account + let fee_payer = Keypair::new(); + + let instruction = system_instruction::transfer( + &payer.pubkey(), + &fee_payer.pubkey(), + fees + expected_token_account_balance, + ); + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + args.fee_payer = Box::new(fee_payer); + let err_result = check_spl_token_balances( + signatures as usize, + &allocations, + &mut banks_client, + &args, + 1, + ) + .await + .unwrap_err(); + if let Error::InsufficientFunds(sources, amount) = err_result { + assert_eq!(sources, vec![FundingSource::SplTokenAccount].into()); + assert!((amount - allocation_amount).abs() < f64::EPSILON); + } else { + panic!("check_spl_token_balances should have errored"); + } + + // Fully funded payers + mint_to_account( + &mut banks_client, + &payer, + &sender_account, + &mint, + recent_blockhash, + ) + .await; + + check_spl_token_balances( + signatures as usize, + &allocations, + &mut banks_client, + &args, + 1, + ) + .await + .unwrap(); + + // Partially-funded fee payer can afford fees, but not to create Associated Token Account + let partially_funded_fee_payer = Keypair::new(); + + let instruction = system_instruction::transfer( + &payer.pubkey(), + &partially_funded_fee_payer.pubkey(), + fees, + ); + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + args.fee_payer = Box::new(partially_funded_fee_payer); + let err_result = check_spl_token_balances( + signatures as usize, + &allocations, + &mut banks_client, + &args, + 1, + ) + .await + .unwrap_err(); + if let Error::InsufficientFunds(sources, amount) = err_result { + assert_eq!(sources, vec![FundingSource::FeePayer].into()); + assert!( + (amount - (fees_in_sol + expected_token_account_balance_sol)).abs() < f64::EPSILON + ); + } else { + panic!("check_spl_token_balances should have errored"); + } + + // Succeeds if no account creation required + check_spl_token_balances( + signatures as usize, + &allocations, + &mut banks_client, + &args, + 0, + ) + .await + .unwrap(); + } +} diff --git a/tokens/src/token_display.rs b/tokens/src/token_display.rs new file mode 100644 index 0000000000..ed71e696f2 --- /dev/null +++ b/tokens/src/token_display.rs @@ -0,0 +1,62 @@ +use std::{ + fmt::{Debug, Display, Formatter, Result}, + ops::Add, +}; + +const SOL_SYMBOL: &str = "◎"; + +#[derive(PartialEq)] +pub enum TokenType { + Sol, + SplToken, +} + +pub struct Token { + amount: f64, + token_type: TokenType, +} + +impl Token { + fn write_with_symbol(&self, f: &mut Formatter) -> Result { + match &self.token_type { + TokenType::Sol => write!(f, "{}{}", SOL_SYMBOL, self.amount,), + TokenType::SplToken => write!(f, "{} tokens", self.amount,), + } + } + + pub fn from(amount: f64, is_sol: bool) -> Self { + let token_type = if is_sol { + TokenType::Sol + } else { + TokenType::SplToken + }; + Self { amount, token_type } + } +} + +impl Display for Token { + fn fmt(&self, f: &mut Formatter) -> Result { + self.write_with_symbol(f) + } +} + +impl Debug for Token { + fn fmt(&self, f: &mut Formatter) -> Result { + self.write_with_symbol(f) + } +} + +impl Add for Token { + type Output = Token; + + fn add(self, other: Self) -> Self { + if self.token_type == other.token_type { + Self { + amount: self.amount + other.amount, + token_type: self.token_type, + } + } else { + self + } + } +} diff --git a/tokens/tests/fixtures/spl_associated_token_account.so b/tokens/tests/fixtures/spl_associated_token_account.so new file mode 100644 index 0000000000..c50d9191ac Binary files /dev/null and b/tokens/tests/fixtures/spl_associated_token_account.so differ diff --git a/tokens/tests/fixtures/spl_token.so b/tokens/tests/fixtures/spl_token.so new file mode 100644 index 0000000000..8d0d7071a8 Binary files /dev/null and b/tokens/tests/fixtures/spl_token.so differ diff --git a/transaction-status/src/parse_token.rs b/transaction-status/src/parse_token.rs index 4336935db9..42cae39451 100644 --- a/transaction-status/src/parse_token.rs +++ b/transaction-status/src/parse_token.rs @@ -2,11 +2,14 @@ use crate::parse_instruction::{ check_num_accounts, ParsableProgram, ParseInstructionError, ParsedInstructionEnum, }; use serde_json::{json, Map, Value}; -use solana_account_decoder::parse_token::token_amount_to_ui_amount; -use solana_sdk::{instruction::CompiledInstruction, pubkey::Pubkey}; +use solana_account_decoder::parse_token::{pubkey_from_spl_token_v2_0, token_amount_to_ui_amount}; +use solana_sdk::{ + instruction::{AccountMeta, CompiledInstruction, Instruction}, + pubkey::Pubkey, +}; use spl_token_v2_0::{ instruction::{AuthorityType, TokenInstruction}, - solana_program::program_option::COption, + solana_program::{instruction::Instruction as SplTokenInstruction, program_option::COption}, }; pub fn parse_token( @@ -410,6 +413,22 @@ fn check_num_token_accounts(accounts: &[u8], num: usize) -> Result<(), ParseInst check_num_accounts(accounts, num, ParsableProgram::SplToken) } +pub fn spl_token_v2_0_instruction(instruction: SplTokenInstruction) -> Instruction { + Instruction { + program_id: pubkey_from_spl_token_v2_0(&instruction.program_id), + accounts: instruction + .accounts + .iter() + .map(|meta| AccountMeta { + pubkey: pubkey_from_spl_token_v2_0(&meta.pubkey), + is_signer: meta.is_signer, + is_writable: meta.is_writable, + }) + .collect(), + data: instruction.data, + } +} + #[cfg(test)] mod test { use super::*;