Distribute spl tokens (#13559)

* Add helpers to covert between sdk types

* Add distribute-spl-tokens to args and arg-parsing

* Build spl-token transfer-checked instructions

* Check spl-token balances properly

* Add display handling to support spl-token

* Small refactor to allow failures in allocation iter

* Use Associated Token Account for spl-token distributions

* Add spl token support to balances command

* Update readme

* Add spl-token tests

* Rename spl-tokens file

* Move a couple more things out of commands

* Stop requiring lockup_date heading for non-stake distributions

* Use epsilon for allocation retention
This commit is contained in:
Tyera Eulberg 2020-11-19 10:32:31 -07:00 committed by GitHub
parent 1ffab5de77
commit 2ef4369237
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 793 additions and 75 deletions

14
Cargo.lock generated
View File

@ -4971,17 +4971,21 @@ dependencies = [
"indicatif",
"pickledb",
"serde",
"solana-account-decoder",
"solana-clap-utils",
"solana-cli-config",
"solana-client",
"solana-core",
"solana-logger 1.5.0",
"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",
]
@ -5154,6 +5158,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"

View File

@ -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<u8>,

View File

@ -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.5.0" }
solana-clap-utils = { path = "../clap-utils", version = "1.5.0" }
solana-cli-config = { path = "../cli-config", version = "1.5.0" }
solana-client = { path = "../client", version = "1.5.0" }
@ -27,6 +28,8 @@ solana-sdk = { path = "../sdk", version = "1.5.0" }
solana-stake-program = { path = "../programs/stake", version = "1.5.0" }
solana-transaction-status = { path = "../transaction-status", version = "1.5.0" }
solana-version = { path = "../version", version = "1.5.0" }
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"
@ -34,3 +37,4 @@ thiserror = "1.0"
bincode = "1.3.1"
solana-core = { path = "../core", version = "1.5.0" }
solana-logger = { path = "../logger", version = "1.5.0" }
solana-program-test = { path = "../program-test", version = "1.5.0" }

View File

@ -38,7 +38,7 @@ solana-tokens distribute-tokens --from <KEYPAIR> --input-csv <RECIPIENTS_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 <KEYPAIR> --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 `<RECIPIENTS_CSV>`.
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 <ADDRESS> --input-csv <RECIPIENTS_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 <ADDRESS> --owner <KEYPAIR> \
--input-csv <RECIPIENTS_CSV> --fee-payer <KEYPAIR>
```
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 <RECIPIENTS_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 <ADDRESS> --input-csv <RECIPIENTS_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
```

View File

@ -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<DistributeTokensArgs, Box<dyn Error>> {
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<BalancesArgs, Box<dyn Error>> {
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))
}

View File

@ -8,6 +8,7 @@ pub struct DistributeTokensArgs {
pub sender_keypair: Box<dyn Signer>,
pub fee_payer: Box<dyn Signer>,
pub stake_args: Option<StakeArgs>,
pub spl_token_args: Option<SplTokenArgs>,
pub transfer_amount: Option<f64>,
}
@ -19,8 +20,16 @@ pub struct StakeArgs {
pub lockup_authority: Option<Box<dyn Signer>>,
}
#[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<SplTokenArgs>,
}
pub struct TransactionLogArgs {

View File

@ -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_client::{
client_error::{ClientError, Result as ClientResult},
rpc_client::RpcClient,
@ -25,6 +30,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,
@ -33,15 +40,16 @@ use std::{
};
#[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,
}
@ -86,6 +94,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<Allocation> {
@ -128,7 +138,7 @@ fn apply_previous_transactions(
}
}
}
allocations.retain(|x| x.amount > 0.5);
allocations.retain(|x| x.amount > f64::EPSILON);
}
fn transfer<S: Signer>(
@ -153,8 +163,9 @@ fn distribution_instructions(
new_stake_account_address: &Pubkey,
args: &DistributeTokensArgs,
lockup_date: Option<DateTime<Utc>>,
do_create_associated_token_account: bool,
) -> Vec<Instruction> {
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);
@ -162,6 +173,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();
@ -228,34 +243,65 @@ fn distribute_allocations(
args: &DistributeTokensArgs,
) -> Result<(), Error> {
type StakeExtras = Vec<(Keypair, Option<DateTime<Utc>>)>;
let (messages, stake_extras): (Vec<Message>, 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::<DateTime<Utc>>().unwrap())
};
let mut messages: Vec<Message> = 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::<DateTime<Utc>>().unwrap())
};
println!("{:<44} {:>24.9}", allocation.recipient, allocation.amount);
let instructions = distribution_instructions(
allocation,
&new_stake_account_keypair.pubkey(),
args,
lockup_date,
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 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 do_create_associated_token_account = client
.get_multiple_accounts(&[pubkey_from_spl_token_v2_0(&associated_token_address)])?
[0]
.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)?;
if args.spl_token_args.is_some() {
check_spl_token_balances(num_signatures, allocations, client, args, created_accounts)?;
} else {
check_payer_balances(num_signatures, allocations, client, args)?;
}
for ((allocation, message), (new_stake_account_keypair, lockup_date)) in
allocations.iter().zip(messages).zip(stake_extras)
@ -313,7 +359,11 @@ fn distribute_allocations(
Ok(())
}
fn read_allocations(input_csv: &str, transfer_amount: Option<f64>) -> io::Result<Vec<Allocation>> {
fn read_allocations(
input_csv: &str,
transfer_amount: Option<f64>,
require_lockup_heading: bool,
) -> io::Result<Vec<Allocation>> {
let mut rdr = ReaderBuilder::new().trim(Trim::All).from_path(input_csv)?;
let allocations = if let Some(amount) = transfer_amount {
let recipients: Vec<String> = rdr
@ -328,8 +378,21 @@ fn read_allocations(input_csv: &str, transfer_amount: Option<f64>) -> 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)
}
@ -346,11 +409,17 @@ pub fn process_allocations(
client: &RpcClient,
args: &DistributeTokensArgs,
) -> Result<Option<usize>, Error> {
let mut allocations: Vec<Allocation> = read_allocations(&args.input_csv, args.transfer_amount)?;
let require_lockup_heading = args.stake_args.is_some();
let mut allocations: Vec<Allocation> = 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,
);
@ -368,27 +437,23 @@ pub 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)?;
@ -572,30 +637,41 @@ fn check_payer_balances(
Ok(())
}
pub fn process_balances(client: &RpcClient, args: &BalancesArgs) -> Result<(), csv::Error> {
let allocations: Vec<Allocation> = read_allocations(&args.input_csv, None)?;
pub fn process_balances(client: &RpcClient, args: &BalancesArgs) -> Result<(), Error> {
let allocations: Vec<Allocation> = 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).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)?;
} 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).unwrap());
println!(
"{:<44} {:>24.9} {:>24.9} {:>24.9}",
allocation.recipient,
expected,
actual,
actual - expected,
);
}
}
Ok(())
@ -666,6 +742,7 @@ pub 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).unwrap();
@ -777,6 +854,7 @@ pub fn test_process_distribute_stake_with_client(client: &RpcClient, sender_keyp
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,
};
@ -910,11 +988,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();
@ -949,7 +1094,7 @@ mod tests {
},
];
assert_eq!(
read_allocations(&input_csv, Some(amount)).unwrap(),
read_allocations(&input_csv, Some(amount), false).unwrap(),
expected_allocations
);
}
@ -1059,6 +1204,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,
};
@ -1068,6 +1214,7 @@ mod tests {
&new_stake_account_address,
&args,
Some(lockup_date),
false,
);
let lockup_instruction =
bincode::deserialize(&instructions[SET_LOCKUP_INDEX].data).unwrap();
@ -1107,6 +1254,7 @@ mod tests {
transaction_db: "".to_string(),
output_path: None,
stake_args,
spl_token_args: None,
transfer_amount: None,
};
(allocations, args)

View File

@ -2,3 +2,5 @@ pub mod arg_parser;
pub mod args;
pub mod commands;
mod db;
pub mod spl_token;
pub mod token_display;

View File

@ -1,6 +1,6 @@
use solana_cli_config::{Config, CONFIG_FILE};
use solana_client::rpc_client::RpcClient;
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};
fn main() -> Result<(), Box<dyn Error>> {
@ -19,10 +19,12 @@ fn main() -> Result<(), Box<dyn Error>> {
let client = RpcClient::new(json_rpc_url);
match command_args.command {
Command::DistributeTokens(args) => {
Command::DistributeTokens(mut args) => {
spl_token::update_token_args(&client, &mut args.spl_token_args)?;
commands::process_allocations(&client, &args)?;
}
Command::Balances(args) => {
Command::Balances(mut args) => {
spl_token::update_decimals(&client, &mut args.spl_token_args)?;
commands::process_balances(&client, &args)?;
}
Command::TransactionLog(args) => {

184
tokens/src/spl_token.rs Normal file
View File

@ -0,0 +1,184 @@
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_client::rpc_client::RpcClient;
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 fn update_token_args(client: &RpcClient, args: &mut Option<SplTokenArgs>) -> Result<(), Error> {
if let Some(spl_token_args) = args {
let sender_account = client
.get_account(&spl_token_args.token_account_address)
.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)?;
}
Ok(())
}
pub fn update_decimals(client: &RpcClient, args: &mut Option<SplTokenArgs>) -> Result<(), Error> {
if let Some(spl_token_args) = args {
let mint_account = client.get_account(&spl_token_args.mint).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<Instruction> {
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 fn check_spl_token_balances(
num_signatures: usize,
allocations: &[Allocation],
client: &RpcClient,
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 = client.get_recent_blockhash()?.1;
let fees = fee_calculator
.lamports_per_signature
.checked_mul(num_signatures as u64)
.unwrap();
let token_account_rent_exempt_balance =
client.get_minimum_balance_for_rent_exemption(SplTokenAccount::LEN)?;
let account_creation_amount = created_accounts * token_account_rent_exempt_balance;
let fee_payer_balance = client.get_balance(&args.fee_payer.pubkey())?;
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)
.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 fn print_token_balances(
client: &RpcClient,
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))
.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 {
// The following unit tests were written for v1.4 using the ProgramTest framework, passing its
// BanksClient into the `solana-tokens` methods. With the revert to RpcClient in this module
// (https://github.com/solana-labs/solana/pull/13623), that approach was no longer viable.
// These tests were removed rather than rewritten to avoid accruing technical debt. Once a new
// rpc/client framework is implemented, they should be restored.
//
// async fn test_process_spl_token_allocations()
// async fn test_process_spl_token_transfer_amount_allocations()
// async fn test_check_spl_token_balances()
//
// TODO: link to v1.4 tests
}

View File

@ -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
}
}
}

View File

@ -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::*;