Add solana-tokens (#10011)
* Initial commit * Execute transfers * Refactor for testing * Cleanup readme * Rewrite * Cleanup * Cleanup * Cleanup client * Use a Null Client to move prints closer to where messages are sent * Upgrade Solana * Move core functionality into its own module * Handle transaction errors * Merge allocations * Fixes * Cleanup readme * Fix markdown * Add example input * Add integration test - currently fails * Add integration test * Add metrics * Use RpcClient in dry-run, just don't send messages * More metrics * Fix dry run with no keys * Only require one approval if fee-payer is the sender keypair * Fix bugs * Don't create the transaction log if nothing to put into it; otherwise the next innvocation won't add the header * Apply previous transactions to allocations with matching recipients * Bail out of any account already has a balance * Polish * Add new 'balances' command * 9 decimal places * Add missing file * Better dry-run; keypair options now optional * Change field name from 'bid' to 'accepted' Also, tolerate precision change from 2 decimal places to 4 * Write to transaction log immediately * Rename allocations_csv to bids_csv So that we can bypass bids_csv with an allocations CSV file * Upgrade Solana * Remove faucet from integration test * Cleaner integration test Won't work until this lands and is released: https://github.com/solana-labs/solana/pull/9717 * Update README * Add TravicCI script to build and test (#1) * Add distribute-stake command (#2) * Distribute -> DistributeTokens (#3) * Cache cargo deps (#4) * Add docs (#5) * Switch to latest Solana 1.1 release (#7) * distribute -> distribute-tokens (#9) * Switch from CSV to a pickledb database (#8) * Switch from CSV to a pickledb database * Allow PickleDb errors to bubble up * Dedup * Hoist db * Add finalized field to TransactionInfo * Don't allow RPC client to resign transactions * Remove dead code * Use transport::Result * Record unconfirmed transaction * Fix: separate stake account per allocation * Catch transport errors * Panic if we attempt to replay a transaction that hasn't been finalized * Attempt to fix CI PickleDb isn't calling flush() or close() after writing to files. No issue on MacOS, but looks racy in CI. * Revert "Attempt to fix CI" This reverts commit 1632394f636c54402b3578120e8817dd1660e19b. * Poll for signature before returning * Add --sol-for-fees option for stake distributions * Add --allocations-csv option (#14) * Add allocations-csv option * Add tests or GTFO * Apply review feedback * apply feedback * Add read_allocations function * Update arg_parser.rs * Fix balances command (#17) * Fix balances command * Fix readme * Add --force to transfer to non-empty accounts (#18) * Add --no-wait (#16) * Add ThinClient methods to implement --no-wait * Plumb --no-wait through No tests yet * Check transaction status on startup * Easier to test * Wait until transaction is finalized before checking if it failed with an error It's possible that a minority fork thinks it failed. * Add unit tests * Remove dead code and rustfmt * Don't flush database to file if doing a dry-run * Continue when transactions not yet finalized (#20) If those transactions are dropped, the next run will execute them. * Return the number of confirmations (#21) * Add read_allocations() unit-test (#22) Delete the copy-pasted top-level test. Fixes #19 * Add a CSV printer (#23) * Remove all the copypasta (#24) * Move resolve_distribute_stake_args into its own function * Add stake args to token args * Unify option names * Move Command::DistributeStake into DistributeTokens * Remove process_distribute_stake * Only unique signers * Use sender keypair to fund new fee-payer accounts * Unify distribute_tokens and distribute_stake * Rename print-database command to transaction-log (#25) * Send all transactions as quickly as possible, then wait (#26) * Send all transactions as quickly as possible, then wait * Exit when finalized or blockhashes have expired * Don't need blockhash in the CSV output * Better types CSV library was choking on Pubkey as a type. PickleDb doesn't have that problem. * Resend if blockhash has not expired * Attempt to fix CI * Move log to stderr * Add constructor, tuck away client (#30) * Add constructor, tuck away client * Fix unwrap() caught by CI * Fix optional option flagged as required * Bunch of cleanup (#31) * Remove untested --no-wait feature * Make --transactions-db an option, not an arg So that in the future, we can make it optional * Remove more untested features Too many false positives in that santity check. Use --dry-run instead. * Add dry-run mode to ThinClient * Cleaner dry-run * Make key parameters required Just don't use them in --dry-run * Add option to write the transaction log --dry-run doesn't write to the database. Use this option if you want a copy of the transaction log before the final run. * Revert --transaction-log addition Implement #27 first * Fix CI * Update readme * Fix CI in copypasta * Sort transaction log by finalized date (#33) * Make --transaction-db option implicit (#34) * Move db functionality into its own module (#35) * Move db functionality into its own module * Rename tokens module to commands * Version bump * Upgrade Solana * Add solana-tokens to build * Remove Cargo.lock * Remove vscode file * Remove TravisCI build script * Install solana-tokens Co-authored-by: Dan Albert <dan@solana.com>
This commit is contained in:
parent
1eb40c3fe0
commit
e09f517094
|
@ -1358,6 +1358,12 @@ dependencies = [
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d36fab90f82edc3c747f9d438e06cf0a491055896f2a279638bb5beed6c40177"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hash32"
|
name = "hash32"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
@ -2572,6 +2578,19 @@ dependencies = [
|
||||||
"siphasher",
|
"siphasher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pickledb"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9161694d67f6c5163519d42be942ae36bbdb55f439460144f105bc4f9f7d1d61"
|
||||||
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
|
"serde",
|
||||||
|
"serde_cbor",
|
||||||
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
|
@ -3427,6 +3446,16 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_cbor"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e18acfa2f90e8b735b2836ab8d538de304cbb6729a7360729ea5a895d15a622"
|
||||||
|
dependencies = [
|
||||||
|
"half",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.110"
|
version = "1.0.110"
|
||||||
|
@ -4826,6 +4855,33 @@ dependencies = [
|
||||||
"users",
|
"users",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "solana-tokens"
|
||||||
|
version = "1.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"console 0.10.3",
|
||||||
|
"csv",
|
||||||
|
"dirs 2.0.2",
|
||||||
|
"indexmap",
|
||||||
|
"indicatif",
|
||||||
|
"itertools 0.9.0",
|
||||||
|
"pickledb",
|
||||||
|
"serde",
|
||||||
|
"solana-clap-utils",
|
||||||
|
"solana-cli-config",
|
||||||
|
"solana-client",
|
||||||
|
"solana-core",
|
||||||
|
"solana-remote-wallet",
|
||||||
|
"solana-runtime",
|
||||||
|
"solana-sdk",
|
||||||
|
"solana-stake-program",
|
||||||
|
"solana-transaction-status",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solana-transaction-status"
|
name = "solana-transaction-status"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
|
@ -56,6 +56,7 @@ members = [
|
||||||
"stake-accounts",
|
"stake-accounts",
|
||||||
"stake-monitor",
|
"stake-monitor",
|
||||||
"sys-tuner",
|
"sys-tuner",
|
||||||
|
"tokens",
|
||||||
"transaction-status",
|
"transaction-status",
|
||||||
"upload-perf",
|
"upload-perf",
|
||||||
"net-utils",
|
"net-utils",
|
||||||
|
|
|
@ -67,6 +67,7 @@ if [[ $CI_OS_NAME = windows ]]; then
|
||||||
solana-install-init
|
solana-install-init
|
||||||
solana-keygen
|
solana-keygen
|
||||||
solana-stake-accounts
|
solana-stake-accounts
|
||||||
|
solana-tokens
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
./fetch-perf-libs.sh
|
./fetch-perf-libs.sh
|
||||||
|
@ -100,6 +101,7 @@ else
|
||||||
solana-stake-accounts
|
solana-stake-accounts
|
||||||
solana-stake-monitor
|
solana-stake-monitor
|
||||||
solana-sys-tuner
|
solana-sys-tuner
|
||||||
|
solana-tokens
|
||||||
solana-validator
|
solana-validator
|
||||||
solana-watchtower
|
solana-watchtower
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
target/
|
||||||
|
*.csv
|
|
@ -0,0 +1,34 @@
|
||||||
|
[package]
|
||||||
|
name = "solana-tokens"
|
||||||
|
description = "Blockchain, Rebuilt for Scale"
|
||||||
|
authors = ["Solana Maintainers <maintainers@solana.com>"]
|
||||||
|
edition = "2018"
|
||||||
|
version = "1.2.0"
|
||||||
|
repository = "https://github.com/solana-labs/solana"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
homepage = "https://solana.com/"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
clap = "2.33.0"
|
||||||
|
console = "0.10.3"
|
||||||
|
csv = "1.1.3"
|
||||||
|
dirs = "2.0.2"
|
||||||
|
indexmap = "1.3.2"
|
||||||
|
indicatif = "0.14.0"
|
||||||
|
itertools = "0.9.0"
|
||||||
|
pickledb = "0.4.1"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
solana-clap-utils = { path = "../clap-utils", version = "1.2.0" }
|
||||||
|
solana-cli-config = { path = "../cli-config", version = "1.2.0" }
|
||||||
|
solana-client = { path = "../client", version = "1.2.0" }
|
||||||
|
solana-remote-wallet = { path = "../remote-wallet", version = "1.2.0" }
|
||||||
|
solana-runtime = { path = "../runtime", version = "1.2.0" }
|
||||||
|
solana-sdk = { path = "../sdk", version = "1.2.0" }
|
||||||
|
solana-stake-program = { path = "../programs/stake", version = "1.2.0" }
|
||||||
|
solana-transaction-status = { path = "../transaction-status", version = "1.2.0" }
|
||||||
|
tempfile = "3.1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
solana-core = { path = "../core", version = "1.2.0" }
|
|
@ -0,0 +1,105 @@
|
||||||
|
# Distribute Solana tokens
|
||||||
|
|
||||||
|
A user may want to make payments to multiple accounts over multiple iterations.
|
||||||
|
The user will have a spreadsheet listing public keys and token amounts, and
|
||||||
|
some process for transferring tokens to them, and ensuring that no more than the
|
||||||
|
expected amount are sent. The command-line tool here automates that process.
|
||||||
|
|
||||||
|
## Distribute tokens
|
||||||
|
|
||||||
|
Send tokens to the recipients in `<BIDS_CSV>`.
|
||||||
|
|
||||||
|
Example bids.csv:
|
||||||
|
|
||||||
|
```text
|
||||||
|
primary_address,bid_amount_dollars
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv,6.6
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
solana-tokens distribute-tokens --from <KEYPAIR> --dollars-per-sol <NUMBER> --from-bids --input-csv <BIDS_CSV> --fee-payer <KEYPAIR>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example transaction log before:
|
||||||
|
|
||||||
|
```text
|
||||||
|
recipient,amount,signature
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv,30,1111111111111111111111111111111111111111111111111111111111111111
|
||||||
|
```
|
||||||
|
|
||||||
|
Send tokens to the recipients in `<BIDS_CSV>` if the distribution is
|
||||||
|
not already recordered in the transaction log.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
solana-tokens distribute-tokens --from <KEYPAIR> --dollars-per-sol <NUMBER> --from-bids --input-csv <BIDS_CSV> --fee-payer <KEYPAIR>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Recipient Amount
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv 70
|
||||||
|
3ihfUy1n9gaqihM5bJCiTAGLgWc5zo3DqVUS6T736NLM 42
|
||||||
|
UKUcTXgbeTYh65RaVV5gSf6xBHevqHvAXMo3e8Q6np8k 43
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Example transaction log after:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
solana-tokens transaction-log --output-path transactions.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
recipient,amount,signature
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv,30,1111111111111111111111111111111111111111111111111111111111111111
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv,70,1111111111111111111111111111111111111111111111111111111111111111
|
||||||
|
3ihfUy1n9gaqihM5bJCiTAGLgWc5zo3DqVUS6T736NLM,42,1111111111111111111111111111111111111111111111111111111111111111
|
||||||
|
UKUcTXgbeTYh65RaVV5gSf6xBHevqHvAXMo3e8Q6np8k,43,1111111111111111111111111111111111111111111111111111111111111111
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calculate what tokens should be sent
|
||||||
|
|
||||||
|
List the differences between a list of expected distributions and the record of what
|
||||||
|
transactions have already been sent.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
solana-tokens distribute-tokens --dollars-per-sol <NUMBER> --dry-run --from-bids --input-csv <BIDS_CSV>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example bids.csv:
|
||||||
|
|
||||||
|
```text
|
||||||
|
primary_address,bid_amount_dollars
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv,6.6
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv,15.4
|
||||||
|
3ihfUy1n9gaqihM5bJCiTAGLgWc5zo3DqVUS6T736NLM,9.24
|
||||||
|
UKUcTXgbeTYh65RaVV5gSf6xBHevqHvAXMo3e8Q6np8k,9.46
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Recipient Amount
|
||||||
|
6Vo87BaDhp4v4GHwVDhw5huhxVF8CyxSXYtkUwVHbbPv 70
|
||||||
|
3ihfUy1n9gaqihM5bJCiTAGLgWc5zo3DqVUS6T736NLM 42
|
||||||
|
UKUcTXgbeTYh65RaVV5gSf6xBHevqHvAXMo3e8Q6np8k 43
|
||||||
|
```
|
||||||
|
|
||||||
|
## Distribute stake accounts
|
||||||
|
|
||||||
|
Distributing tokens via stake accounts works similarly to how tokens are distributed. The
|
||||||
|
big difference is that new stake accounts are split from existing ones. By splitting,
|
||||||
|
the new accounts inherit any lockup or custodian settings of the original.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
solana-tokens distribute-stake --stake-account-address <ACCOUNT_ADDRESS> \
|
||||||
|
--input-csv <ALLOCATIONS_CSV> \
|
||||||
|
--stake-authority <KEYPAIR> --withdraw-authority <KEYPAIR> --fee-payer <KEYPAIR>
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently, this will subtract 1 SOL from each allocation and store it the
|
||||||
|
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.
|
|
@ -0,0 +1,305 @@
|
||||||
|
use crate::args::{
|
||||||
|
Args, BalancesArgs, Command, DistributeTokensArgs, StakeArgs, TransactionLogArgs,
|
||||||
|
};
|
||||||
|
use clap::{value_t, value_t_or_exit, App, Arg, ArgMatches, SubCommand};
|
||||||
|
use solana_clap_utils::input_validators::{is_valid_pubkey, is_valid_signer};
|
||||||
|
use solana_cli_config::CONFIG_FILE;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::process::exit;
|
||||||
|
|
||||||
|
fn get_matches<'a, I, T>(args: I) -> ArgMatches<'a>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = T>,
|
||||||
|
T: Into<OsString> + Clone,
|
||||||
|
{
|
||||||
|
let default_config_file = CONFIG_FILE.as_ref().unwrap();
|
||||||
|
App::new("solana-tokens")
|
||||||
|
.about("about")
|
||||||
|
.version("version")
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("config_file")
|
||||||
|
.long("config")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("FILEPATH")
|
||||||
|
.default_value(default_config_file)
|
||||||
|
.help("Config file"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("url")
|
||||||
|
.long("url")
|
||||||
|
.global(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("URL")
|
||||||
|
.help("RPC entrypoint address. i.e. http://devnet.solana.com"),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
SubCommand::with_name("distribute-tokens")
|
||||||
|
.about("Distribute tokens")
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("campaign_name")
|
||||||
|
.long("campaign-name")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("NAME")
|
||||||
|
.help("Campaign name for storing transaction data"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("from_bids")
|
||||||
|
.long("from-bids")
|
||||||
|
.help("Input CSV contains bids in dollars, not allocations in SOL"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("input_csv")
|
||||||
|
.long("input-csv")
|
||||||
|
.required(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("FILE")
|
||||||
|
.help("Input CSV file"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("dollars_per_sol")
|
||||||
|
.long("dollars-per-sol")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("NUMBER")
|
||||||
|
.help("Dollars per SOL, if input CSV contains bids"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("dry_run")
|
||||||
|
.long("dry-run")
|
||||||
|
.help("Do not execute any transfers"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("sender_keypair")
|
||||||
|
.long("from")
|
||||||
|
.required(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("SENDING_KEYPAIR")
|
||||||
|
.validator(is_valid_signer)
|
||||||
|
.help("Keypair to fund accounts"),
|
||||||
|
)
|
||||||
|
.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("distribute-stake")
|
||||||
|
.about("Distribute stake accounts")
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("campaign_name")
|
||||||
|
.long("campaign-name")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("NAME")
|
||||||
|
.help("Campaign name for storing transaction data"),
|
||||||
|
)
|
||||||
|
.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("sender_keypair")
|
||||||
|
.long("from")
|
||||||
|
.required(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("SENDING_KEYPAIR")
|
||||||
|
.validator(is_valid_signer)
|
||||||
|
.help("Keypair to fund accounts"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("stake_account_address")
|
||||||
|
.required(true)
|
||||||
|
.long("stake-account-address")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("ACCOUNT_ADDRESS")
|
||||||
|
.validator(is_valid_pubkey)
|
||||||
|
.help("Stake Account Address"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("sol_for_fees")
|
||||||
|
.default_value("1.0")
|
||||||
|
.long("sol-for-fees")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("SOL_AMOUNT")
|
||||||
|
.help("Amount of SOL to put in system account to pay for fees"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("stake_authority")
|
||||||
|
.long("stake-authority")
|
||||||
|
.required(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("KEYPAIR")
|
||||||
|
.validator(is_valid_signer)
|
||||||
|
.help("Stake Authority Keypair"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("withdraw_authority")
|
||||||
|
.long("withdraw-authority")
|
||||||
|
.required(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("KEYPAIR")
|
||||||
|
.validator(is_valid_signer)
|
||||||
|
.help("Withdraw Authority Keypair"),
|
||||||
|
)
|
||||||
|
.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")
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("input_csv")
|
||||||
|
.long("input-csv")
|
||||||
|
.required(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("FILE")
|
||||||
|
.help("Bids CSV file"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("from_bids")
|
||||||
|
.long("from-bids")
|
||||||
|
.help("Input CSV contains bids in dollars, not allocations in SOL"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("dollars_per_sol")
|
||||||
|
.long("dollars-per-sol")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("NUMBER")
|
||||||
|
.help("Dollars per SOL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
SubCommand::with_name("transaction-log")
|
||||||
|
.about("Print the database to a CSV file")
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("campaign_name")
|
||||||
|
.long("campaign-name")
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("NAME")
|
||||||
|
.help("Campaign name for storing transaction data"),
|
||||||
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::with_name("output_path")
|
||||||
|
.long("output-path")
|
||||||
|
.required(true)
|
||||||
|
.takes_value(true)
|
||||||
|
.value_name("FILE")
|
||||||
|
.help("Output file"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get_matches_from(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_db_path(campaign_name: Option<String>) -> String {
|
||||||
|
let (prefix, hyphen) = if let Some(name) = campaign_name {
|
||||||
|
(name, "-")
|
||||||
|
} else {
|
||||||
|
("".to_string(), "")
|
||||||
|
};
|
||||||
|
let path = dirs::home_dir().unwrap();
|
||||||
|
let filename = format!("{}{}transactions.db", prefix, hyphen);
|
||||||
|
path.join(".config")
|
||||||
|
.join("solana-tokens")
|
||||||
|
.join(filename)
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_distribute_tokens_args(matches: &ArgMatches<'_>) -> DistributeTokensArgs<String, String> {
|
||||||
|
DistributeTokensArgs {
|
||||||
|
input_csv: value_t_or_exit!(matches, "input_csv", String),
|
||||||
|
from_bids: matches.is_present("from_bids"),
|
||||||
|
transaction_db: create_db_path(value_t!(matches, "campaign_name", String).ok()),
|
||||||
|
dollars_per_sol: value_t!(matches, "dollars_per_sol", f64).ok(),
|
||||||
|
dry_run: matches.is_present("dry_run"),
|
||||||
|
sender_keypair: value_t_or_exit!(matches, "sender_keypair", String),
|
||||||
|
fee_payer: value_t_or_exit!(matches, "fee_payer", String),
|
||||||
|
stake_args: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_distribute_stake_args(matches: &ArgMatches<'_>) -> DistributeTokensArgs<String, String> {
|
||||||
|
let stake_args = StakeArgs {
|
||||||
|
stake_account_address: value_t_or_exit!(matches, "stake_account_address", String),
|
||||||
|
sol_for_fees: value_t_or_exit!(matches, "sol_for_fees", f64),
|
||||||
|
stake_authority: value_t_or_exit!(matches, "stake_authority", String),
|
||||||
|
withdraw_authority: value_t_or_exit!(matches, "withdraw_authority", String),
|
||||||
|
};
|
||||||
|
DistributeTokensArgs {
|
||||||
|
input_csv: value_t_or_exit!(matches, "input_csv", String),
|
||||||
|
from_bids: false,
|
||||||
|
transaction_db: create_db_path(value_t!(matches, "campaign_name", String).ok()),
|
||||||
|
dollars_per_sol: None,
|
||||||
|
dry_run: matches.is_present("dry_run"),
|
||||||
|
sender_keypair: value_t_or_exit!(matches, "sender_keypair", String),
|
||||||
|
fee_payer: value_t_or_exit!(matches, "fee_payer", String),
|
||||||
|
stake_args: Some(stake_args),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_balances_args(matches: &ArgMatches<'_>) -> BalancesArgs {
|
||||||
|
BalancesArgs {
|
||||||
|
input_csv: value_t_or_exit!(matches, "input_csv", String),
|
||||||
|
from_bids: matches.is_present("from_bids"),
|
||||||
|
dollars_per_sol: value_t!(matches, "dollars_per_sol", f64).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_transaction_log_args(matches: &ArgMatches<'_>) -> TransactionLogArgs {
|
||||||
|
TransactionLogArgs {
|
||||||
|
transaction_db: value_t_or_exit!(matches, "transaction_db", String),
|
||||||
|
output_path: value_t_or_exit!(matches, "output_path", String),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_args<I, T>(args: I) -> Args<String, String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = T>,
|
||||||
|
T: Into<OsString> + Clone,
|
||||||
|
{
|
||||||
|
let matches = get_matches(args);
|
||||||
|
let config_file = matches.value_of("config_file").unwrap().to_string();
|
||||||
|
let url = matches.value_of("url").map(|x| x.to_string());
|
||||||
|
|
||||||
|
let command = match matches.subcommand() {
|
||||||
|
("distribute-tokens", Some(matches)) => {
|
||||||
|
Command::DistributeTokens(parse_distribute_tokens_args(matches))
|
||||||
|
}
|
||||||
|
("distribute-stake", Some(matches)) => {
|
||||||
|
Command::DistributeTokens(parse_distribute_stake_args(matches))
|
||||||
|
}
|
||||||
|
("balances", Some(matches)) => Command::Balances(parse_balances_args(matches)),
|
||||||
|
("transaction-log", Some(matches)) => {
|
||||||
|
Command::TransactionLog(parse_transaction_log_args(matches))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("{}", matches.usage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Args {
|
||||||
|
config_file,
|
||||||
|
url,
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
use clap::ArgMatches;
|
||||||
|
use solana_clap_utils::keypair::{pubkey_from_path, signer_from_path};
|
||||||
|
use solana_remote_wallet::remote_wallet::{maybe_wallet_manager, RemoteWalletManager};
|
||||||
|
use solana_sdk::{pubkey::Pubkey, signature::Signer};
|
||||||
|
use std::{error::Error, sync::Arc};
|
||||||
|
|
||||||
|
pub struct DistributeTokensArgs<P, K> {
|
||||||
|
pub input_csv: String,
|
||||||
|
pub from_bids: bool,
|
||||||
|
pub transaction_db: String,
|
||||||
|
pub dollars_per_sol: Option<f64>,
|
||||||
|
pub dry_run: bool,
|
||||||
|
pub sender_keypair: K,
|
||||||
|
pub fee_payer: K,
|
||||||
|
pub stake_args: Option<StakeArgs<P, K>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StakeArgs<P, K> {
|
||||||
|
pub sol_for_fees: f64,
|
||||||
|
pub stake_account_address: P,
|
||||||
|
pub stake_authority: K,
|
||||||
|
pub withdraw_authority: K,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BalancesArgs {
|
||||||
|
pub input_csv: String,
|
||||||
|
pub from_bids: bool,
|
||||||
|
pub dollars_per_sol: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TransactionLogArgs {
|
||||||
|
pub transaction_db: String,
|
||||||
|
pub output_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Command<P, K> {
|
||||||
|
DistributeTokens(DistributeTokensArgs<P, K>),
|
||||||
|
Balances(BalancesArgs),
|
||||||
|
TransactionLog(TransactionLogArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Args<P, K> {
|
||||||
|
pub config_file: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub command: Command<P, K>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_stake_args(
|
||||||
|
wallet_manager: &mut Option<Arc<RemoteWalletManager>>,
|
||||||
|
args: StakeArgs<String, String>,
|
||||||
|
) -> Result<StakeArgs<Pubkey, Box<dyn Signer>>, Box<dyn Error>> {
|
||||||
|
let matches = ArgMatches::default();
|
||||||
|
let resolved_args = StakeArgs {
|
||||||
|
stake_account_address: pubkey_from_path(
|
||||||
|
&matches,
|
||||||
|
&args.stake_account_address,
|
||||||
|
"stake account address",
|
||||||
|
wallet_manager,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
sol_for_fees: args.sol_for_fees,
|
||||||
|
stake_authority: signer_from_path(
|
||||||
|
&matches,
|
||||||
|
&args.stake_authority,
|
||||||
|
"stake authority",
|
||||||
|
wallet_manager,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
withdraw_authority: signer_from_path(
|
||||||
|
&matches,
|
||||||
|
&args.withdraw_authority,
|
||||||
|
"withdraw authority",
|
||||||
|
wallet_manager,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
};
|
||||||
|
Ok(resolved_args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_command(
|
||||||
|
command: Command<String, String>,
|
||||||
|
) -> Result<Command<Pubkey, Box<dyn Signer>>, Box<dyn Error>> {
|
||||||
|
match command {
|
||||||
|
Command::DistributeTokens(args) => {
|
||||||
|
let mut wallet_manager = maybe_wallet_manager()?;
|
||||||
|
let matches = ArgMatches::default();
|
||||||
|
let resolved_stake_args = args
|
||||||
|
.stake_args
|
||||||
|
.map(|args| resolve_stake_args(&mut wallet_manager, args));
|
||||||
|
let resolved_args = DistributeTokensArgs {
|
||||||
|
input_csv: args.input_csv,
|
||||||
|
from_bids: args.from_bids,
|
||||||
|
transaction_db: args.transaction_db,
|
||||||
|
dollars_per_sol: args.dollars_per_sol,
|
||||||
|
dry_run: args.dry_run,
|
||||||
|
sender_keypair: signer_from_path(
|
||||||
|
&matches,
|
||||||
|
&args.sender_keypair,
|
||||||
|
"sender",
|
||||||
|
&mut wallet_manager,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
fee_payer: signer_from_path(
|
||||||
|
&matches,
|
||||||
|
&args.fee_payer,
|
||||||
|
"fee-payer",
|
||||||
|
&mut wallet_manager,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
stake_args: resolved_stake_args.map_or(Ok(None), |r| r.map(Some))?,
|
||||||
|
};
|
||||||
|
Ok(Command::DistributeTokens(resolved_args))
|
||||||
|
}
|
||||||
|
Command::Balances(args) => Ok(Command::Balances(args)),
|
||||||
|
Command::TransactionLog(args) => Ok(Command::TransactionLog(args)),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,688 @@
|
||||||
|
use crate::args::{BalancesArgs, DistributeTokensArgs, StakeArgs, TransactionLogArgs};
|
||||||
|
use crate::db::{self, TransactionInfo};
|
||||||
|
use crate::thin_client::{Client, ThinClient};
|
||||||
|
use console::style;
|
||||||
|
use csv::{ReaderBuilder, Trim};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use pickledb::PickleDb;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solana_sdk::{
|
||||||
|
message::Message,
|
||||||
|
native_token::{lamports_to_sol, sol_to_lamports},
|
||||||
|
signature::{Signature, Signer},
|
||||||
|
system_instruction,
|
||||||
|
transport::TransportError,
|
||||||
|
};
|
||||||
|
use solana_stake_program::{
|
||||||
|
stake_instruction,
|
||||||
|
stake_state::{Authorized, Lockup, StakeAuthorize},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
cmp::{self},
|
||||||
|
io,
|
||||||
|
thread::sleep,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct Bid {
|
||||||
|
accepted_amount_dollars: f64,
|
||||||
|
primary_address: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
|
struct Allocation {
|
||||||
|
recipient: String,
|
||||||
|
amount: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("I/O error")]
|
||||||
|
IoError(#[from] io::Error),
|
||||||
|
#[error("CSV error")]
|
||||||
|
CsvError(#[from] csv::Error),
|
||||||
|
#[error("PickleDb error")]
|
||||||
|
PickleDbError(#[from] pickledb::error::Error),
|
||||||
|
#[error("Transport error")]
|
||||||
|
TransportError(#[from] TransportError),
|
||||||
|
#[error("Signature not found")]
|
||||||
|
SignatureNotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_signers(signers: Vec<&dyn Signer>) -> Vec<&dyn Signer> {
|
||||||
|
signers.into_iter().unique_by(|s| s.pubkey()).collect_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_allocations(allocations: &[Allocation]) -> Vec<Allocation> {
|
||||||
|
let mut allocation_map = IndexMap::new();
|
||||||
|
for allocation in allocations {
|
||||||
|
allocation_map
|
||||||
|
.entry(&allocation.recipient)
|
||||||
|
.or_insert(Allocation {
|
||||||
|
recipient: allocation.recipient.clone(),
|
||||||
|
amount: 0.0,
|
||||||
|
})
|
||||||
|
.amount += allocation.amount;
|
||||||
|
}
|
||||||
|
allocation_map.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_previous_transactions(
|
||||||
|
allocations: &mut Vec<Allocation>,
|
||||||
|
transaction_infos: &[TransactionInfo],
|
||||||
|
) {
|
||||||
|
for transaction_info in transaction_infos {
|
||||||
|
let mut amount = transaction_info.amount;
|
||||||
|
for allocation in allocations.iter_mut() {
|
||||||
|
if allocation.recipient != transaction_info.recipient.to_string() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if allocation.amount >= amount {
|
||||||
|
allocation.amount -= amount;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
amount -= allocation.amount;
|
||||||
|
allocation.amount = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allocations.retain(|x| x.amount > 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_allocation(bid: &Bid, dollars_per_sol: f64) -> Allocation {
|
||||||
|
Allocation {
|
||||||
|
recipient: bid.primary_address.clone(),
|
||||||
|
amount: bid.accepted_amount_dollars / dollars_per_sol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distribute_tokens<T: Client>(
|
||||||
|
client: &ThinClient<T>,
|
||||||
|
db: &mut PickleDb,
|
||||||
|
allocations: &[Allocation],
|
||||||
|
args: &DistributeTokensArgs<Pubkey, Box<dyn Signer>>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
for allocation in allocations {
|
||||||
|
let new_stake_account_keypair = Keypair::new();
|
||||||
|
let new_stake_account_address = new_stake_account_keypair.pubkey();
|
||||||
|
|
||||||
|
let mut signers = vec![&*args.fee_payer, &*args.sender_keypair];
|
||||||
|
if let Some(stake_args) = &args.stake_args {
|
||||||
|
signers.push(&*stake_args.stake_authority);
|
||||||
|
signers.push(&*stake_args.withdraw_authority);
|
||||||
|
signers.push(&new_stake_account_keypair);
|
||||||
|
}
|
||||||
|
let signers = unique_signers(signers);
|
||||||
|
|
||||||
|
println!("{:<44} {:>24.9}", allocation.recipient, allocation.amount);
|
||||||
|
let instructions = if let Some(stake_args) = &args.stake_args {
|
||||||
|
let sol_for_fees = stake_args.sol_for_fees;
|
||||||
|
let sender_pubkey = args.sender_keypair.pubkey();
|
||||||
|
let stake_authority = stake_args.stake_authority.pubkey();
|
||||||
|
let withdraw_authority = stake_args.withdraw_authority.pubkey();
|
||||||
|
|
||||||
|
let mut instructions = stake_instruction::split(
|
||||||
|
&stake_args.stake_account_address,
|
||||||
|
&stake_authority,
|
||||||
|
sol_to_lamports(allocation.amount - sol_for_fees),
|
||||||
|
&new_stake_account_address,
|
||||||
|
);
|
||||||
|
|
||||||
|
let recipient = allocation.recipient.parse().unwrap();
|
||||||
|
|
||||||
|
// Make the recipient the new stake authority
|
||||||
|
instructions.push(stake_instruction::authorize(
|
||||||
|
&new_stake_account_address,
|
||||||
|
&stake_authority,
|
||||||
|
&recipient,
|
||||||
|
StakeAuthorize::Staker,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Make the recipient the new withdraw authority
|
||||||
|
instructions.push(stake_instruction::authorize(
|
||||||
|
&new_stake_account_address,
|
||||||
|
&withdraw_authority,
|
||||||
|
&recipient,
|
||||||
|
StakeAuthorize::Withdrawer,
|
||||||
|
));
|
||||||
|
|
||||||
|
instructions.push(system_instruction::transfer(
|
||||||
|
&sender_pubkey,
|
||||||
|
&recipient,
|
||||||
|
sol_to_lamports(sol_for_fees),
|
||||||
|
));
|
||||||
|
|
||||||
|
instructions
|
||||||
|
} else {
|
||||||
|
let from = args.sender_keypair.pubkey();
|
||||||
|
let to = allocation.recipient.parse().unwrap();
|
||||||
|
let lamports = sol_to_lamports(allocation.amount);
|
||||||
|
let instruction = system_instruction::transfer(&from, &to, lamports);
|
||||||
|
vec![instruction]
|
||||||
|
};
|
||||||
|
|
||||||
|
let fee_payer_pubkey = args.fee_payer.pubkey();
|
||||||
|
let message = Message::new_with_payer(&instructions, Some(&fee_payer_pubkey));
|
||||||
|
match client.send_message(message, &signers) {
|
||||||
|
Ok(transaction) => {
|
||||||
|
db::set_transaction_info(
|
||||||
|
db,
|
||||||
|
&allocation.recipient.parse().unwrap(),
|
||||||
|
allocation.amount,
|
||||||
|
&transaction,
|
||||||
|
Some(&new_stake_account_address),
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error sending tokens to {}: {}", allocation.recipient, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_allocations(
|
||||||
|
input_csv: &str,
|
||||||
|
from_bids: bool,
|
||||||
|
dollars_per_sol: Option<f64>,
|
||||||
|
) -> Vec<Allocation> {
|
||||||
|
let rdr = ReaderBuilder::new().trim(Trim::All).from_path(input_csv);
|
||||||
|
if from_bids {
|
||||||
|
let bids: Vec<Bid> = rdr.unwrap().deserialize().map(|bid| bid.unwrap()).collect();
|
||||||
|
bids.into_iter()
|
||||||
|
.map(|bid| create_allocation(&bid, dollars_per_sol.unwrap()))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
rdr.unwrap()
|
||||||
|
.deserialize()
|
||||||
|
.map(|entry| entry.unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_spinner_progress_bar() -> ProgressBar {
|
||||||
|
let progress_bar = ProgressBar::new(42);
|
||||||
|
progress_bar
|
||||||
|
.set_style(ProgressStyle::default_spinner().template("{spinner:.green} {wide_msg}"));
|
||||||
|
progress_bar.enable_steady_tick(100);
|
||||||
|
progress_bar
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_distribute_tokens<T: Client>(
|
||||||
|
client: &ThinClient<T>,
|
||||||
|
args: &DistributeTokensArgs<Pubkey, Box<dyn Signer>>,
|
||||||
|
) -> Result<Option<usize>, Error> {
|
||||||
|
let mut allocations: Vec<Allocation> =
|
||||||
|
read_allocations(&args.input_csv, args.from_bids, args.dollars_per_sol);
|
||||||
|
|
||||||
|
let starting_total_tokens: f64 = allocations.iter().map(|x| x.amount).sum();
|
||||||
|
println!(
|
||||||
|
"{} ◎{}",
|
||||||
|
style("Total in input_csv:").bold(),
|
||||||
|
starting_total_tokens,
|
||||||
|
);
|
||||||
|
if let Some(dollars_per_sol) = args.dollars_per_sol {
|
||||||
|
println!(
|
||||||
|
"{} ${}",
|
||||||
|
style("Total in input_csv:").bold(),
|
||||||
|
starting_total_tokens * dollars_per_sol,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut db = db::open_db(&args.transaction_db, args.dry_run)?;
|
||||||
|
|
||||||
|
// Start by finalizing any transactions from the previous run.
|
||||||
|
let confirmations = finalize_transactions(client, &mut db)?;
|
||||||
|
|
||||||
|
let transaction_infos = db::read_transaction_infos(&db);
|
||||||
|
apply_previous_transactions(&mut allocations, &transaction_infos);
|
||||||
|
|
||||||
|
if allocations.is_empty() {
|
||||||
|
eprintln!("No work to do");
|
||||||
|
return Ok(confirmations);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style(format!(
|
||||||
|
"{:<44} {:>24}",
|
||||||
|
"Recipient", "Expected Balance (◎)"
|
||||||
|
))
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
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,);
|
||||||
|
if let Some(dollars_per_sol) = args.dollars_per_sol {
|
||||||
|
println!(
|
||||||
|
"{} ${}",
|
||||||
|
style("Distributed:").bold(),
|
||||||
|
distributed_tokens * dollars_per_sol,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{} ◎{}",
|
||||||
|
style("Undistributed:").bold(),
|
||||||
|
undistributed_tokens,
|
||||||
|
);
|
||||||
|
if let Some(dollars_per_sol) = args.dollars_per_sol {
|
||||||
|
println!(
|
||||||
|
"{} ${}",
|
||||||
|
style("Undistributed:").bold(),
|
||||||
|
undistributed_tokens * dollars_per_sol,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{} ◎{}",
|
||||||
|
style("Total:").bold(),
|
||||||
|
distributed_tokens + undistributed_tokens,
|
||||||
|
);
|
||||||
|
if let Some(dollars_per_sol) = args.dollars_per_sol {
|
||||||
|
println!(
|
||||||
|
"{} ${}",
|
||||||
|
style("Total:").bold(),
|
||||||
|
(distributed_tokens + undistributed_tokens) * dollars_per_sol,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
distribute_tokens(client, &mut db, &allocations, args)?;
|
||||||
|
|
||||||
|
let opt_confirmations = finalize_transactions(client, &mut db)?;
|
||||||
|
Ok(opt_confirmations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize_transactions<T: Client>(
|
||||||
|
client: &ThinClient<T>,
|
||||||
|
db: &mut PickleDb,
|
||||||
|
) -> Result<Option<usize>, Error> {
|
||||||
|
let mut opt_confirmations = update_finalized_transactions(client, db)?;
|
||||||
|
|
||||||
|
let progress_bar = new_spinner_progress_bar();
|
||||||
|
|
||||||
|
while opt_confirmations.is_some() {
|
||||||
|
if let Some(confirmations) = opt_confirmations {
|
||||||
|
progress_bar.set_message(&format!(
|
||||||
|
"[{}/{}] Finalizing transactions",
|
||||||
|
confirmations, 32,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep for about 1 slot
|
||||||
|
sleep(Duration::from_millis(500));
|
||||||
|
let opt_conf = update_finalized_transactions(client, db)?;
|
||||||
|
opt_confirmations = opt_conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(opt_confirmations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the finalized bit on any transactions that are now rooted
|
||||||
|
// Return the lowest number of confirmations on the unfinalized transactions or None if all are finalized.
|
||||||
|
fn update_finalized_transactions<T: Client>(
|
||||||
|
client: &ThinClient<T>,
|
||||||
|
db: &mut PickleDb,
|
||||||
|
) -> Result<Option<usize>, Error> {
|
||||||
|
let transaction_infos = db::read_transaction_infos(db);
|
||||||
|
let unconfirmed_transactions: Vec<_> = transaction_infos
|
||||||
|
.iter()
|
||||||
|
.filter_map(|info| {
|
||||||
|
if info.finalized_date.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&info.transaction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let unconfirmed_signatures = unconfirmed_transactions
|
||||||
|
.iter()
|
||||||
|
.map(|tx| tx.signatures[0])
|
||||||
|
.filter(|sig| *sig != Signature::default()) // Filter out dry-run signatures
|
||||||
|
.collect_vec();
|
||||||
|
let transaction_statuses = client.get_signature_statuses(&unconfirmed_signatures)?;
|
||||||
|
let recent_blockhashes = client.get_recent_blockhashes()?;
|
||||||
|
|
||||||
|
let mut confirmations = None;
|
||||||
|
for (transaction, opt_transaction_status) in unconfirmed_transactions
|
||||||
|
.into_iter()
|
||||||
|
.zip(transaction_statuses.into_iter())
|
||||||
|
{
|
||||||
|
match db::update_finalized_transaction(
|
||||||
|
db,
|
||||||
|
&transaction.signatures[0],
|
||||||
|
opt_transaction_status,
|
||||||
|
&transaction.message.recent_blockhash,
|
||||||
|
&recent_blockhashes,
|
||||||
|
) {
|
||||||
|
Ok(Some(confs)) => {
|
||||||
|
confirmations = Some(cmp::min(confs, confirmations.unwrap_or(usize::MAX)));
|
||||||
|
}
|
||||||
|
result => {
|
||||||
|
result?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(confirmations)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_balances<T: Client>(
|
||||||
|
client: &ThinClient<T>,
|
||||||
|
args: &BalancesArgs,
|
||||||
|
) -> Result<(), csv::Error> {
|
||||||
|
let allocations: Vec<Allocation> =
|
||||||
|
read_allocations(&args.input_csv, args.from_bids, args.dollars_per_sol);
|
||||||
|
let allocations = merge_allocations(&allocations);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
style(format!(
|
||||||
|
"{:<44} {:>24} {:>24} {:>24}",
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_transaction_log(args: &TransactionLogArgs) -> Result<(), Error> {
|
||||||
|
let db = db::open_db(&args.transaction_db, true)?;
|
||||||
|
db::write_transaction_log(&db, &args.output_path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
use solana_sdk::{pubkey::Pubkey, signature::Keypair};
|
||||||
|
use tempfile::{tempdir, NamedTempFile};
|
||||||
|
pub fn test_process_distribute_tokens_with_client<C: Client>(client: C, sender_keypair: Keypair) {
|
||||||
|
let thin_client = ThinClient::new(client, false);
|
||||||
|
let fee_payer = Keypair::new();
|
||||||
|
let transaction = thin_client
|
||||||
|
.transfer(sol_to_lamports(1.0), &sender_keypair, &fee_payer.pubkey())
|
||||||
|
.unwrap();
|
||||||
|
thin_client
|
||||||
|
.poll_for_confirmation(&transaction.signatures[0])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let alice_pubkey = Pubkey::new_rand();
|
||||||
|
let allocation = Allocation {
|
||||||
|
recipient: alice_pubkey.to_string(),
|
||||||
|
amount: 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);
|
||||||
|
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 args: DistributeTokensArgs<Pubkey, Box<dyn Signer>> = DistributeTokensArgs {
|
||||||
|
sender_keypair: Box::new(sender_keypair),
|
||||||
|
fee_payer: Box::new(fee_payer),
|
||||||
|
dry_run: false,
|
||||||
|
input_csv,
|
||||||
|
from_bids: false,
|
||||||
|
transaction_db: transaction_db.clone(),
|
||||||
|
dollars_per_sol: None,
|
||||||
|
stake_args: None,
|
||||||
|
};
|
||||||
|
let confirmations = process_distribute_tokens(&thin_client, &args).unwrap();
|
||||||
|
assert_eq!(confirmations, None);
|
||||||
|
|
||||||
|
let transaction_infos =
|
||||||
|
db::read_transaction_infos(&db::open_db(&transaction_db, true).unwrap());
|
||||||
|
assert_eq!(transaction_infos.len(), 1);
|
||||||
|
assert_eq!(transaction_infos[0].recipient, alice_pubkey);
|
||||||
|
let expected_amount = sol_to_lamports(allocation.amount);
|
||||||
|
assert_eq!(
|
||||||
|
sol_to_lamports(transaction_infos[0].amount),
|
||||||
|
expected_amount
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
thin_client.get_balance(&alice_pubkey).unwrap(),
|
||||||
|
expected_amount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now, run it again, and check there's no double-spend.
|
||||||
|
process_distribute_tokens(&thin_client, &args).unwrap();
|
||||||
|
let transaction_infos =
|
||||||
|
db::read_transaction_infos(&db::open_db(&transaction_db, true).unwrap());
|
||||||
|
assert_eq!(transaction_infos.len(), 1);
|
||||||
|
assert_eq!(transaction_infos[0].recipient, alice_pubkey);
|
||||||
|
let expected_amount = sol_to_lamports(allocation.amount);
|
||||||
|
assert_eq!(
|
||||||
|
sol_to_lamports(transaction_infos[0].amount),
|
||||||
|
expected_amount
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
thin_client.get_balance(&alice_pubkey).unwrap(),
|
||||||
|
expected_amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn test_process_distribute_stake_with_client<C: Client>(client: C, sender_keypair: Keypair) {
|
||||||
|
let thin_client = ThinClient::new(client, false);
|
||||||
|
let fee_payer = Keypair::new();
|
||||||
|
let transaction = thin_client
|
||||||
|
.transfer(sol_to_lamports(1.0), &sender_keypair, &fee_payer.pubkey())
|
||||||
|
.unwrap();
|
||||||
|
thin_client
|
||||||
|
.poll_for_confirmation(&transaction.signatures[0])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stake_account_keypair = Keypair::new();
|
||||||
|
let stake_account_address = stake_account_keypair.pubkey();
|
||||||
|
let stake_authority = Keypair::new();
|
||||||
|
let withdraw_authority = Keypair::new();
|
||||||
|
|
||||||
|
let authorized = Authorized {
|
||||||
|
staker: stake_authority.pubkey(),
|
||||||
|
withdrawer: withdraw_authority.pubkey(),
|
||||||
|
};
|
||||||
|
let lockup = Lockup::default();
|
||||||
|
let instructions = stake_instruction::create_account(
|
||||||
|
&sender_keypair.pubkey(),
|
||||||
|
&stake_account_address,
|
||||||
|
&authorized,
|
||||||
|
&lockup,
|
||||||
|
sol_to_lamports(3000.0),
|
||||||
|
);
|
||||||
|
let message = Message::new(&instructions);
|
||||||
|
let signers = [&sender_keypair, &stake_account_keypair];
|
||||||
|
thin_client.send_message(message, &signers).unwrap();
|
||||||
|
|
||||||
|
let alice_pubkey = Pubkey::new_rand();
|
||||||
|
let allocation = Allocation {
|
||||||
|
recipient: alice_pubkey.to_string(),
|
||||||
|
amount: 1000.0,
|
||||||
|
};
|
||||||
|
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(&allocation).unwrap();
|
||||||
|
wtr.flush().unwrap();
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let transaction_db = dir
|
||||||
|
.path()
|
||||||
|
.join("transactions.db")
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let stake_args: StakeArgs<Pubkey, Box<dyn Signer>> = StakeArgs {
|
||||||
|
stake_account_address,
|
||||||
|
stake_authority: Box::new(stake_authority),
|
||||||
|
withdraw_authority: Box::new(withdraw_authority),
|
||||||
|
sol_for_fees: 1.0,
|
||||||
|
};
|
||||||
|
let args: DistributeTokensArgs<Pubkey, Box<dyn Signer>> = DistributeTokensArgs {
|
||||||
|
fee_payer: Box::new(fee_payer),
|
||||||
|
dry_run: false,
|
||||||
|
input_csv,
|
||||||
|
transaction_db: transaction_db.clone(),
|
||||||
|
stake_args: Some(stake_args),
|
||||||
|
from_bids: false,
|
||||||
|
sender_keypair: Box::new(sender_keypair),
|
||||||
|
dollars_per_sol: None,
|
||||||
|
};
|
||||||
|
let confirmations = process_distribute_tokens(&thin_client, &args).unwrap();
|
||||||
|
assert_eq!(confirmations, None);
|
||||||
|
|
||||||
|
let transaction_infos =
|
||||||
|
db::read_transaction_infos(&db::open_db(&transaction_db, true).unwrap());
|
||||||
|
assert_eq!(transaction_infos.len(), 1);
|
||||||
|
assert_eq!(transaction_infos[0].recipient, alice_pubkey);
|
||||||
|
let expected_amount = sol_to_lamports(allocation.amount);
|
||||||
|
assert_eq!(
|
||||||
|
sol_to_lamports(transaction_infos[0].amount),
|
||||||
|
expected_amount
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
thin_client.get_balance(&alice_pubkey).unwrap(),
|
||||||
|
sol_to_lamports(1.0),
|
||||||
|
);
|
||||||
|
let new_stake_account_address = transaction_infos[0].new_stake_account_address.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
thin_client.get_balance(&new_stake_account_address).unwrap(),
|
||||||
|
expected_amount - sol_to_lamports(1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now, run it again, and check there's no double-spend.
|
||||||
|
process_distribute_tokens(&thin_client, &args).unwrap();
|
||||||
|
let transaction_infos =
|
||||||
|
db::read_transaction_infos(&db::open_db(&transaction_db, true).unwrap());
|
||||||
|
assert_eq!(transaction_infos.len(), 1);
|
||||||
|
assert_eq!(transaction_infos[0].recipient, alice_pubkey);
|
||||||
|
let expected_amount = sol_to_lamports(allocation.amount);
|
||||||
|
assert_eq!(
|
||||||
|
sol_to_lamports(transaction_infos[0].amount),
|
||||||
|
expected_amount
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
thin_client.get_balance(&alice_pubkey).unwrap(),
|
||||||
|
sol_to_lamports(1.0),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
thin_client.get_balance(&new_stake_account_address).unwrap(),
|
||||||
|
expected_amount - sol_to_lamports(1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use solana_runtime::{bank::Bank, bank_client::BankClient};
|
||||||
|
use solana_sdk::{genesis_config::create_genesis_config, transaction::Transaction};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_process_distribute_tokens() {
|
||||||
|
let (genesis_config, sender_keypair) = create_genesis_config(sol_to_lamports(9_000_000.0));
|
||||||
|
let bank = Bank::new(&genesis_config);
|
||||||
|
let bank_client = BankClient::new(bank);
|
||||||
|
test_process_distribute_tokens_with_client(bank_client, sender_keypair);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_process_distribute_stake() {
|
||||||
|
let (genesis_config, sender_keypair) = create_genesis_config(sol_to_lamports(9_000_000.0));
|
||||||
|
let bank = Bank::new(&genesis_config);
|
||||||
|
let bank_client = BankClient::new(bank);
|
||||||
|
test_process_distribute_stake_with_client(bank_client, sender_keypair);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_allocations() {
|
||||||
|
let alice_pubkey = Pubkey::new_rand();
|
||||||
|
let allocation = Allocation {
|
||||||
|
recipient: alice_pubkey.to_string(),
|
||||||
|
amount: 42.0,
|
||||||
|
};
|
||||||
|
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(&allocation).unwrap();
|
||||||
|
wtr.flush().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_allocations(&input_csv, false, None), vec![allocation]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_allocations_from_bids() {
|
||||||
|
let alice_pubkey = Pubkey::new_rand();
|
||||||
|
let bid = Bid {
|
||||||
|
primary_address: alice_pubkey.to_string(),
|
||||||
|
accepted_amount_dollars: 42.0,
|
||||||
|
};
|
||||||
|
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(&bid).unwrap();
|
||||||
|
wtr.flush().unwrap();
|
||||||
|
|
||||||
|
let allocation = Allocation {
|
||||||
|
recipient: bid.primary_address,
|
||||||
|
amount: 84.0,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
read_allocations(&input_csv, true, Some(0.5)),
|
||||||
|
vec![allocation]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_previous_transactions() {
|
||||||
|
let alice = Pubkey::new_rand();
|
||||||
|
let bob = Pubkey::new_rand();
|
||||||
|
let mut allocations = vec![
|
||||||
|
Allocation {
|
||||||
|
recipient: alice.to_string(),
|
||||||
|
amount: 1.0,
|
||||||
|
},
|
||||||
|
Allocation {
|
||||||
|
recipient: bob.to_string(),
|
||||||
|
amount: 1.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let transaction_infos = vec![TransactionInfo {
|
||||||
|
recipient: bob,
|
||||||
|
amount: 1.0,
|
||||||
|
new_stake_account_address: None,
|
||||||
|
finalized_date: None,
|
||||||
|
transaction: Transaction::new_unsigned_instructions(&[]),
|
||||||
|
}];
|
||||||
|
apply_previous_transactions(&mut allocations, &transaction_infos);
|
||||||
|
assert_eq!(allocations.len(), 1);
|
||||||
|
|
||||||
|
// Ensure that we applied the transaction to the allocation with
|
||||||
|
// a matching recipient address (to bob, not alice).
|
||||||
|
assert_eq!(allocations[0].recipient, alice.to_string());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,351 @@
|
||||||
|
use chrono::prelude::*;
|
||||||
|
use pickledb::{error::Error, PickleDb, PickleDbDumpPolicy};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use solana_sdk::{hash::Hash, pubkey::Pubkey, signature::Signature, transaction::Transaction};
|
||||||
|
use solana_transaction_status::TransactionStatus;
|
||||||
|
use std::{cmp::Ordering, fs, io, path::Path};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||||
|
pub struct TransactionInfo {
|
||||||
|
pub recipient: Pubkey,
|
||||||
|
pub amount: f64,
|
||||||
|
pub new_stake_account_address: Option<Pubkey>,
|
||||||
|
pub finalized_date: Option<DateTime<Utc>>,
|
||||||
|
pub transaction: Transaction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
|
||||||
|
struct SignedTransactionInfo {
|
||||||
|
recipient: String,
|
||||||
|
amount: f64,
|
||||||
|
new_stake_account_address: String,
|
||||||
|
finalized_date: Option<DateTime<Utc>>,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransactionInfo {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut transaction = Transaction::new_unsigned_instructions(&[]);
|
||||||
|
transaction.signatures.push(Signature::default());
|
||||||
|
Self {
|
||||||
|
recipient: Pubkey::default(),
|
||||||
|
amount: 0.0,
|
||||||
|
new_stake_account_address: None,
|
||||||
|
finalized_date: None,
|
||||||
|
transaction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_db(path: &str, dry_run: bool) -> Result<PickleDb, Error> {
|
||||||
|
let policy = if dry_run {
|
||||||
|
PickleDbDumpPolicy::NeverDump
|
||||||
|
} else {
|
||||||
|
PickleDbDumpPolicy::AutoDump
|
||||||
|
};
|
||||||
|
let path = Path::new(path);
|
||||||
|
let db = if path.exists() {
|
||||||
|
PickleDb::load_yaml(path, policy)?
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).unwrap();
|
||||||
|
}
|
||||||
|
PickleDb::new_yaml(path, policy)
|
||||||
|
};
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compare_transaction_infos(a: &TransactionInfo, b: &TransactionInfo) -> Ordering {
|
||||||
|
let ordering = match (a.finalized_date, b.finalized_date) {
|
||||||
|
(Some(a), Some(b)) => a.cmp(&b),
|
||||||
|
(None, Some(_)) => Ordering::Greater,
|
||||||
|
(Some(_), None) => Ordering::Less, // Future finalized date will be greater
|
||||||
|
_ => Ordering::Equal,
|
||||||
|
};
|
||||||
|
if ordering == Ordering::Equal {
|
||||||
|
return a.recipient.to_string().cmp(&b.recipient.to_string());
|
||||||
|
}
|
||||||
|
ordering
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_transaction_log<P: AsRef<Path>>(db: &PickleDb, path: &P) -> Result<(), io::Error> {
|
||||||
|
let mut wtr = csv::WriterBuilder::new().from_path(path).unwrap();
|
||||||
|
let mut transaction_infos = read_transaction_infos(db);
|
||||||
|
transaction_infos.sort_by(compare_transaction_infos);
|
||||||
|
for info in transaction_infos {
|
||||||
|
let signed_info = SignedTransactionInfo {
|
||||||
|
recipient: info.recipient.to_string(),
|
||||||
|
amount: info.amount,
|
||||||
|
new_stake_account_address: info
|
||||||
|
.new_stake_account_address
|
||||||
|
.map(|x| x.to_string())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
finalized_date: info.finalized_date,
|
||||||
|
signature: info.transaction.signatures[0].to_string(),
|
||||||
|
};
|
||||||
|
wtr.serialize(&signed_info)?;
|
||||||
|
}
|
||||||
|
wtr.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_transaction_infos(db: &PickleDb) -> Vec<TransactionInfo> {
|
||||||
|
db.iter()
|
||||||
|
.map(|kv| kv.get_value::<TransactionInfo>().unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_transaction_info(
|
||||||
|
db: &mut PickleDb,
|
||||||
|
recipient: &Pubkey,
|
||||||
|
amount: f64,
|
||||||
|
transaction: &Transaction,
|
||||||
|
new_stake_account_address: Option<&Pubkey>,
|
||||||
|
finalized: bool,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let finalized_date = if finalized { Some(Utc::now()) } else { None };
|
||||||
|
let transaction_info = TransactionInfo {
|
||||||
|
recipient: *recipient,
|
||||||
|
amount,
|
||||||
|
new_stake_account_address: new_stake_account_address.cloned(),
|
||||||
|
finalized_date,
|
||||||
|
transaction: transaction.clone(),
|
||||||
|
};
|
||||||
|
let signature = transaction.signatures[0];
|
||||||
|
db.set(&signature.to_string(), &transaction_info)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the finalized bit in the database if the transaction is rooted.
|
||||||
|
// Remove the TransactionInfo from the database if the transaction failed.
|
||||||
|
// Return the number of confirmations on the transaction or None if finalized.
|
||||||
|
pub fn update_finalized_transaction(
|
||||||
|
db: &mut PickleDb,
|
||||||
|
signature: &Signature,
|
||||||
|
opt_transaction_status: Option<TransactionStatus>,
|
||||||
|
blockhash: &Hash,
|
||||||
|
recent_blockhashes: &[Hash],
|
||||||
|
) -> Result<Option<usize>, Error> {
|
||||||
|
if opt_transaction_status.is_none() {
|
||||||
|
if !recent_blockhashes.contains(blockhash) {
|
||||||
|
eprintln!("Signature not found {} and blockhash expired", signature);
|
||||||
|
eprintln!("Discarding transaction record");
|
||||||
|
db.rem(&signature.to_string())?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return zero to signal the transaction may still be in flight.
|
||||||
|
return Ok(Some(0));
|
||||||
|
}
|
||||||
|
let transaction_status = opt_transaction_status.unwrap();
|
||||||
|
|
||||||
|
if let Some(confirmations) = transaction_status.confirmations {
|
||||||
|
// The transaction was found but is not yet finalized.
|
||||||
|
return Ok(Some(confirmations));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = &transaction_status.status {
|
||||||
|
// The transaction was finalized, but execution failed. Drop it.
|
||||||
|
eprintln!(
|
||||||
|
"Error in transaction with signature {}: {}",
|
||||||
|
signature,
|
||||||
|
e.to_string()
|
||||||
|
);
|
||||||
|
eprintln!("Discarding transaction record");
|
||||||
|
db.rem(&signature.to_string())?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction is rooted. Set finalized in the database.
|
||||||
|
let mut transaction_info = db.get::<TransactionInfo>(&signature.to_string()).unwrap();
|
||||||
|
transaction_info.finalized_date = Some(Utc::now());
|
||||||
|
db.set(&signature.to_string(), &transaction_info)?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use csv::{ReaderBuilder, Trim};
|
||||||
|
use solana_sdk::transaction::TransactionError;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_transaction_infos_finalized_first() {
|
||||||
|
let info0 = TransactionInfo {
|
||||||
|
finalized_date: Some(Utc.ymd(2014, 7, 8).and_hms(9, 10, 11)),
|
||||||
|
..TransactionInfo::default()
|
||||||
|
};
|
||||||
|
let info1 = TransactionInfo {
|
||||||
|
finalized_date: Some(Utc.ymd(2014, 7, 8).and_hms(9, 10, 42)),
|
||||||
|
..TransactionInfo::default()
|
||||||
|
};
|
||||||
|
let info2 = TransactionInfo::default();
|
||||||
|
let info3 = TransactionInfo {
|
||||||
|
recipient: Pubkey::new_rand(),
|
||||||
|
..TransactionInfo::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sorted first by date
|
||||||
|
assert_eq!(compare_transaction_infos(&info0, &info1), Ordering::Less);
|
||||||
|
|
||||||
|
// Finalized transactions should be before unfinalized ones
|
||||||
|
assert_eq!(compare_transaction_infos(&info1, &info2), Ordering::Less);
|
||||||
|
|
||||||
|
// Then sorted by recipient
|
||||||
|
assert_eq!(compare_transaction_infos(&info2, &info3), Ordering::Less);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_write_transaction_log() {
|
||||||
|
let mut db =
|
||||||
|
PickleDb::new_yaml(NamedTempFile::new().unwrap(), PickleDbDumpPolicy::NeverDump);
|
||||||
|
let signature = Signature::default();
|
||||||
|
let transaction_info = TransactionInfo::default();
|
||||||
|
db.set(&signature.to_string(), &transaction_info).unwrap();
|
||||||
|
|
||||||
|
let csv_file = NamedTempFile::new().unwrap();
|
||||||
|
write_transaction_log(&db, &csv_file).unwrap();
|
||||||
|
|
||||||
|
let mut rdr = ReaderBuilder::new().trim(Trim::All).from_reader(csv_file);
|
||||||
|
let signed_infos: Vec<SignedTransactionInfo> =
|
||||||
|
rdr.deserialize().map(|entry| entry.unwrap()).collect();
|
||||||
|
|
||||||
|
let signed_info = SignedTransactionInfo {
|
||||||
|
recipient: Pubkey::default().to_string(),
|
||||||
|
signature: Signature::default().to_string(),
|
||||||
|
..SignedTransactionInfo::default()
|
||||||
|
};
|
||||||
|
assert_eq!(signed_infos, vec![signed_info]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_finalized_transaction_not_landed() {
|
||||||
|
// Keep waiting for a transaction that hasn't landed yet.
|
||||||
|
let mut db =
|
||||||
|
PickleDb::new_yaml(NamedTempFile::new().unwrap(), PickleDbDumpPolicy::NeverDump);
|
||||||
|
let signature = Signature::default();
|
||||||
|
let blockhash = Hash::default();
|
||||||
|
let transaction_info = TransactionInfo::default();
|
||||||
|
db.set(&signature.to_string(), &transaction_info).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
update_finalized_transaction(&mut db, &signature, None, &blockhash, &[blockhash])
|
||||||
|
.unwrap(),
|
||||||
|
Some(0)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Unchanged
|
||||||
|
assert_eq!(
|
||||||
|
db.get::<TransactionInfo>(&signature.to_string()).unwrap(),
|
||||||
|
transaction_info
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same as before, but now with an expired blockhash
|
||||||
|
assert_eq!(
|
||||||
|
update_finalized_transaction(&mut db, &signature, None, &blockhash, &[]).unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure TransactionInfo has been purged.
|
||||||
|
assert_eq!(db.get::<TransactionInfo>(&signature.to_string()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_finalized_transaction_confirming() {
|
||||||
|
// Keep waiting for a transaction that is still being confirmed.
|
||||||
|
let mut db =
|
||||||
|
PickleDb::new_yaml(NamedTempFile::new().unwrap(), PickleDbDumpPolicy::NeverDump);
|
||||||
|
let signature = Signature::default();
|
||||||
|
let blockhash = Hash::default();
|
||||||
|
let transaction_info = TransactionInfo::default();
|
||||||
|
db.set(&signature.to_string(), &transaction_info).unwrap();
|
||||||
|
let transaction_status = TransactionStatus {
|
||||||
|
slot: 0,
|
||||||
|
confirmations: Some(1),
|
||||||
|
status: Ok(()),
|
||||||
|
err: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
update_finalized_transaction(
|
||||||
|
&mut db,
|
||||||
|
&signature,
|
||||||
|
Some(transaction_status),
|
||||||
|
&blockhash,
|
||||||
|
&[blockhash]
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unchanged
|
||||||
|
assert_eq!(
|
||||||
|
db.get::<TransactionInfo>(&signature.to_string()).unwrap(),
|
||||||
|
transaction_info
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_finalized_transaction_failed() {
|
||||||
|
// Don't wait if the transaction failed to execute.
|
||||||
|
let mut db =
|
||||||
|
PickleDb::new_yaml(NamedTempFile::new().unwrap(), PickleDbDumpPolicy::NeverDump);
|
||||||
|
let signature = Signature::default();
|
||||||
|
let blockhash = Hash::default();
|
||||||
|
let transaction_info = TransactionInfo::default();
|
||||||
|
db.set(&signature.to_string(), &transaction_info).unwrap();
|
||||||
|
let status = Err(TransactionError::AccountNotFound);
|
||||||
|
let transaction_status = TransactionStatus {
|
||||||
|
slot: 0,
|
||||||
|
confirmations: None,
|
||||||
|
status,
|
||||||
|
err: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
update_finalized_transaction(
|
||||||
|
&mut db,
|
||||||
|
&signature,
|
||||||
|
Some(transaction_status),
|
||||||
|
&blockhash,
|
||||||
|
&[blockhash]
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure TransactionInfo has been purged.
|
||||||
|
assert_eq!(db.get::<TransactionInfo>(&signature.to_string()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_finalized_transaction_finalized() {
|
||||||
|
// Don't wait once the transaction has been finalized.
|
||||||
|
let mut db =
|
||||||
|
PickleDb::new_yaml(NamedTempFile::new().unwrap(), PickleDbDumpPolicy::NeverDump);
|
||||||
|
let signature = Signature::default();
|
||||||
|
let blockhash = Hash::default();
|
||||||
|
let transaction_info = TransactionInfo::default();
|
||||||
|
db.set(&signature.to_string(), &transaction_info).unwrap();
|
||||||
|
let transaction_status = TransactionStatus {
|
||||||
|
slot: 0,
|
||||||
|
confirmations: None,
|
||||||
|
status: Ok(()),
|
||||||
|
err: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
update_finalized_transaction(
|
||||||
|
&mut db,
|
||||||
|
&signature,
|
||||||
|
Some(transaction_status),
|
||||||
|
&blockhash,
|
||||||
|
&[blockhash]
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(db
|
||||||
|
.get::<TransactionInfo>(&signature.to_string())
|
||||||
|
.unwrap()
|
||||||
|
.finalized_date
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod arg_parser;
|
||||||
|
pub mod args;
|
||||||
|
pub mod commands;
|
||||||
|
mod db;
|
||||||
|
pub mod thin_client;
|
|
@ -0,0 +1,44 @@
|
||||||
|
use solana_cli_config::Config;
|
||||||
|
use solana_cli_config::CONFIG_FILE;
|
||||||
|
use solana_client::rpc_client::RpcClient;
|
||||||
|
use solana_tokens::{
|
||||||
|
arg_parser::parse_args,
|
||||||
|
args::{resolve_command, Command},
|
||||||
|
commands,
|
||||||
|
thin_client::ThinClient,
|
||||||
|
};
|
||||||
|
use std::env;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let command_args = parse_args(env::args_os());
|
||||||
|
let config = if Path::new(&command_args.config_file).exists() {
|
||||||
|
Config::load(&command_args.config_file)?
|
||||||
|
} else {
|
||||||
|
let default_config_file = CONFIG_FILE.as_ref().unwrap();
|
||||||
|
if command_args.config_file != *default_config_file {
|
||||||
|
eprintln!("Error: config file not found");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
Config::default()
|
||||||
|
};
|
||||||
|
let json_rpc_url = command_args.url.unwrap_or(config.json_rpc_url);
|
||||||
|
let client = RpcClient::new(json_rpc_url);
|
||||||
|
|
||||||
|
match resolve_command(command_args.command)? {
|
||||||
|
Command::DistributeTokens(args) => {
|
||||||
|
let thin_client = ThinClient::new(client, args.dry_run);
|
||||||
|
commands::process_distribute_tokens(&thin_client, &args)?;
|
||||||
|
}
|
||||||
|
Command::Balances(args) => {
|
||||||
|
let thin_client = ThinClient::new(client, false);
|
||||||
|
commands::process_balances(&thin_client, &args)?;
|
||||||
|
}
|
||||||
|
Command::TransactionLog(args) => {
|
||||||
|
commands::process_transaction_log(&args)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,174 @@
|
||||||
|
use solana_client::rpc_client::RpcClient;
|
||||||
|
use solana_runtime::bank_client::BankClient;
|
||||||
|
use solana_sdk::{
|
||||||
|
account::Account,
|
||||||
|
client::{AsyncClient, SyncClient},
|
||||||
|
fee_calculator::FeeCalculator,
|
||||||
|
hash::Hash,
|
||||||
|
message::Message,
|
||||||
|
pubkey::Pubkey,
|
||||||
|
signature::{Signature, Signer},
|
||||||
|
signers::Signers,
|
||||||
|
system_instruction,
|
||||||
|
sysvar::{
|
||||||
|
recent_blockhashes::{self, RecentBlockhashes},
|
||||||
|
Sysvar,
|
||||||
|
},
|
||||||
|
transaction::Transaction,
|
||||||
|
transport::{Result, TransportError},
|
||||||
|
};
|
||||||
|
use solana_transaction_status::TransactionStatus;
|
||||||
|
|
||||||
|
pub trait Client {
|
||||||
|
fn send_transaction1(&self, transaction: Transaction) -> Result<Signature>;
|
||||||
|
fn get_signature_statuses1(
|
||||||
|
&self,
|
||||||
|
signatures: &[Signature],
|
||||||
|
) -> Result<Vec<Option<TransactionStatus>>>;
|
||||||
|
fn get_balance1(&self, pubkey: &Pubkey) -> Result<u64>;
|
||||||
|
fn get_recent_blockhash1(&self) -> Result<(Hash, FeeCalculator)>;
|
||||||
|
fn get_account1(&self, pubkey: &Pubkey) -> Result<Option<Account>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client for RpcClient {
|
||||||
|
fn send_transaction1(&self, transaction: Transaction) -> Result<Signature> {
|
||||||
|
self.send_transaction(&transaction)
|
||||||
|
.map_err(|e| TransportError::Custom(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_signature_statuses1(
|
||||||
|
&self,
|
||||||
|
signatures: &[Signature],
|
||||||
|
) -> Result<Vec<Option<TransactionStatus>>> {
|
||||||
|
self.get_signature_statuses(signatures)
|
||||||
|
.map(|response| response.value)
|
||||||
|
.map_err(|e| TransportError::Custom(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_balance1(&self, pubkey: &Pubkey) -> Result<u64> {
|
||||||
|
self.get_balance(pubkey)
|
||||||
|
.map_err(|e| TransportError::Custom(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_recent_blockhash1(&self) -> Result<(Hash, FeeCalculator)> {
|
||||||
|
self.get_recent_blockhash()
|
||||||
|
.map_err(|e| TransportError::Custom(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_account1(&self, pubkey: &Pubkey) -> Result<Option<Account>> {
|
||||||
|
self.get_account(pubkey)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| TransportError::Custom(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client for BankClient {
|
||||||
|
fn send_transaction1(&self, transaction: Transaction) -> Result<Signature> {
|
||||||
|
self.async_send_transaction(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_signature_statuses1(
|
||||||
|
&self,
|
||||||
|
signatures: &[Signature],
|
||||||
|
) -> Result<Vec<Option<TransactionStatus>>> {
|
||||||
|
signatures
|
||||||
|
.iter()
|
||||||
|
.map(|signature| {
|
||||||
|
self.get_signature_status(signature).map(|opt| {
|
||||||
|
opt.map(|status| TransactionStatus {
|
||||||
|
slot: 0,
|
||||||
|
confirmations: None,
|
||||||
|
status,
|
||||||
|
err: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_balance1(&self, pubkey: &Pubkey) -> Result<u64> {
|
||||||
|
self.get_balance(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_recent_blockhash1(&self) -> Result<(Hash, FeeCalculator)> {
|
||||||
|
self.get_recent_blockhash()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_account1(&self, pubkey: &Pubkey) -> Result<Option<Account>> {
|
||||||
|
self.get_account(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ThinClient<C: Client> {
|
||||||
|
client: C,
|
||||||
|
dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: Client> ThinClient<C> {
|
||||||
|
pub fn new(client: C, dry_run: bool) -> Self {
|
||||||
|
Self { client, dry_run }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_transaction(&self, transaction: Transaction) -> Result<Signature> {
|
||||||
|
if self.dry_run {
|
||||||
|
return Ok(Signature::default());
|
||||||
|
}
|
||||||
|
self.client.send_transaction1(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll_for_confirmation(&self, signature: &Signature) -> Result<()> {
|
||||||
|
while self.get_signature_statuses(&[*signature])?[0].is_none() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_signature_statuses(
|
||||||
|
&self,
|
||||||
|
signatures: &[Signature],
|
||||||
|
) -> Result<Vec<Option<TransactionStatus>>> {
|
||||||
|
self.client.get_signature_statuses1(signatures)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_message<S: Signers>(&self, message: Message, signers: &S) -> Result<Transaction> {
|
||||||
|
if self.dry_run {
|
||||||
|
return Ok(Transaction::new_unsigned(message));
|
||||||
|
}
|
||||||
|
let (blockhash, _fee_caluclator) = self.get_recent_blockhash()?;
|
||||||
|
let transaction = Transaction::new(signers, message, blockhash);
|
||||||
|
self.send_transaction(transaction.clone())?;
|
||||||
|
Ok(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transfer<S: Signer>(
|
||||||
|
&self,
|
||||||
|
lamports: u64,
|
||||||
|
sender_keypair: &S,
|
||||||
|
to_pubkey: &Pubkey,
|
||||||
|
) -> Result<Transaction> {
|
||||||
|
let create_instruction =
|
||||||
|
system_instruction::transfer(&sender_keypair.pubkey(), &to_pubkey, lamports);
|
||||||
|
let message = Message::new(&[create_instruction]);
|
||||||
|
self.send_message(message, &[sender_keypair])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recent_blockhash(&self) -> Result<(Hash, FeeCalculator)> {
|
||||||
|
self.client.get_recent_blockhash1()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_balance(&self, pubkey: &Pubkey) -> Result<u64> {
|
||||||
|
self.client.get_balance1(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_account(&self, pubkey: &Pubkey) -> Result<Option<Account>> {
|
||||||
|
self.client.get_account1(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recent_blockhashes(&self) -> Result<Vec<Hash>> {
|
||||||
|
let opt_blockhashes_account = self.get_account(&recent_blockhashes::id())?;
|
||||||
|
let blockhashes_account = opt_blockhashes_account.unwrap();
|
||||||
|
let recent_blockhashes = RecentBlockhashes::from_account(&blockhashes_account).unwrap();
|
||||||
|
let hashes = recent_blockhashes.iter().map(|x| x.blockhash).collect();
|
||||||
|
Ok(hashes)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
use solana_client::rpc_client::RpcClient;
|
||||||
|
use solana_core::validator::{TestValidator, TestValidatorOptions};
|
||||||
|
use solana_sdk::native_token::sol_to_lamports;
|
||||||
|
use solana_tokens::commands::test_process_distribute_tokens_with_client;
|
||||||
|
use std::fs::remove_dir_all;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_process_distribute_with_rpc_client() {
|
||||||
|
let validator = TestValidator::run_with_options(TestValidatorOptions {
|
||||||
|
mint_lamports: sol_to_lamports(9_000_000.0),
|
||||||
|
..TestValidatorOptions::default()
|
||||||
|
});
|
||||||
|
let rpc_client = RpcClient::new_socket(validator.leader_data.rpc);
|
||||||
|
test_process_distribute_tokens_with_client(rpc_client, validator.alice);
|
||||||
|
|
||||||
|
validator.server.close().unwrap();
|
||||||
|
remove_dir_all(validator.ledger_path).unwrap();
|
||||||
|
}
|
Loading…
Reference in New Issue