Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for mass solana payment #19

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions tools/flat-distributor/README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
# Flat Distributor

## TLDR

Typical usage scenario would look something like the following:

1. **Get an address list** - using the [`address-fetcher`](../address-fetcher) tool, get the file with only the addresses as an input for `flat-distributor`. Note the address type that you used (owner or token).
2. **Setup config.env** - Edit the existing config example template by renaming it to `config.env` and adding the values for RPC API URL, token address and token decimal count.
3. **OPTIONAL: Run check-before** - `./flat-distributor.py check-before -a ADDRESS_LIST --address-type ADDRESS_TYPE -d DROP_AMOUNT`
4. **Connect your wallet to Solana CLI** - [Read this](https://docs.solana.com/wallet-guide/file-system-wallet) if you are not sure how to do this
5. **Run transfer** - `./flat-distributor.py transfer -a ADDRESS_LIST --drop DROP_AMOUNT`
6. **OPTIONAL: Run check-after** - `./flat-distributor.py check-after --address-type ADDRESS_TYPE --before-file before.csv`
6. **OPTIONAL: Run check-after** - `./flat-distributor.py check-after --address-type ADDRESS_TYPE --before-file before.csv`

You can use `-h` or `--help` to view the help text for each subcommand.

## Overview

`flat-distributor` is used to distribute a fixed amount of tokens to each recipient address from an input file. Subcommands included are:
* `transfer` mode for running distributions,
* `check-before` and
* `check-after` optional subcommands for checking whether recipients received the expected amount of tokens.

- `transfer` mode for running distributions,
- `check-before` and
- `check-after` optional subcommands for checking whether recipients received the expected amount of tokens.

The actual distribution commands are ran synchronously, meaning each transaction awaits it's confirmation before moving on to the next one. This might be changed in the future.

Expand All @@ -24,6 +28,7 @@ Run the application with `python3`, or do `chmod +x flat-distributor.py` and run
All application modes share the same config file, an example of which can be found in this directory. This file should be placed in the same directory as the script, and renamed to `config.env`. The app can also be ran without the config, in which case log file locations are set to default (same as in the example config), and the user will be prompted for other variables (token address, token decimals, RPC API URL).

Example `config.env` file:

```
TOKEN_MINT=mx3edW3gRoM9J4sJKtuobQW3ZB1HeuZH8hQeH9HDkF3
TOKEN_DECIMALS=4
Expand All @@ -37,44 +42,58 @@ UNCONFIRMED_LOGS=unconfirmed.log
```

## flat-distributor check-before

`check-before` can be used before a distribution, and will generate a CSV file containing the current balances for all recipients, and their expected balances after the distribution. The generated file will be named **before.csv** and is used as input for the `check-after` command.

It is required to specify the address type used in the address list file (`-t` or `--address-type {owner|token}`) and the amount of tokens that will be distributed.
It is required to specify the address type used in the address list file (`-t` or `--address-type {owner|token}`) and the amount of tokens that will be distributed.

### Usage:

`python3 flat-distributor.py check-before -a address-list.txt --address-type owner --drop 500`

![check-before-gif](https://github.com/praskoson/distribution-tools/blob/main/assets/gifs/check-before.gif)

## flat-distributor transfer

Distribute the tokens to all accounts on the given address list. You should use the same address list as the one in the `check-before` command.

You have to connect your wallet to the `solana` CLI tool. In case of a file-system wallet:
## flat-distributor transferSol

Distribute the solana to all accounts on the given address list. You should use the same address list as the one in the `check-before` command.

You have to connect your wallet to the `solana` CLI tool. In case of a file-system wallet:
`solana config set --keypair /absolute/path/to/wallet.json`.
Also, consider the possible security issues when using file-system wallets ([Solana documentation](https://docs.solana.com/wallet-guide/cli)).

This command will generate multiple log files, the location and names of which can be set in the 'config.env' file.
Use the option`--non-interactive` to run the distribution in non-interactive mode, where each transaction doesn't have to be confirmed.

If sending tokens to owner accounts that do not have a minted associated token address, use `--fund-recipient` option. For accounts that are unfunded (i.e. have 0 SOL), use `--allow-unfunded-recipient`. Both options behave just as they do in the `spl-token transfer` command.
If sending tokens to owner accounts that do not have a minted associated token address, use `--fund-recipient` option. For accounts that are unfunded (i.e. have 0 SOL), use `--allow-unfunded-recipient`. Both options behave just as they do in the `spl-token transfer` command.

The `--retry-on-429` option will retry any transaction if it returns with a HTTP Too Many Requests error (429). This error is NOT a guarantee that the transaction didn't happen, so it can cause double transactions in rare cases, due to a bug in how `spl-token` handles this error. The default behaviour will treat this error as an unconfirmed transaction, so use it at your own risk.

Execution can be interrupted at any time with SIGINT (CTRL+C).

### Usage:
### Usage for SPL:

`python3 flat-distributor.py transfer -a address-list.txt --drop 500 --non-interactive`

### Usage for SOL:

`python3 flat-distributor.py transferSol -a addresses.txt --drop 1 --non-interactive --allow-unfunded-recipient`

![transfer](https://github.com/praskoson/distribution-tools/blob/main/assets/gifs/transfer.gif)

## flat-distributor check-after
`check-after` subcommand is used to ensure all recipients received the expected amount of tokens after a distribution. The input for it is the `before.csv` file generated by the `check-before` subcommand.

This mode also generates a CSV file that can then be read to compare the expected vs actual balances of all recipients.
`check-after` subcommand is used to ensure all recipients received the expected amount of tokens after a distribution. The input for it is the `before.csv` file generated by the `check-before` subcommand.

Address type must be specified with the `-t` or `address-type` argument, where the type can be `owner` or `token`.
This mode also generates a CSV file that can then be read to compare the expected vs actual balances of all recipients.

Address type must be specified with the `-t` or `address-type` argument, where the type can be `owner` or `token`.

### Usage

`python3 flat-distributor.py check-after --address-type owner --before-file before.csv`

![check-after-gif](https://github.com/praskoson/distribution-tools/blob/main/assets/gifs/check-after.gif)
218 changes: 218 additions & 0 deletions tools/flat-distributor/flat-distributor.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ def single_transaction_prompt(full_cmd, amount, recipient, decimals):
else:
return False, False

def single_transaction_sol_prompt(full_cmd, amount, recipient):
msg = f"Sending {bcolors.OKBLUE}{amount}{bcolors.ENDC} tokens to recipient at: {bcolors.OKCYAN}{recipient}{bcolors.ENDC}.\n"
msg += "Cmd to be ran: \n"
msg += f" {bcolors.BOLD}" + full_cmd + f"{bcolors.ENDC}"
print(msg, flush=True)
choice = input(
"Press ENTER to confirm | Type anything to CANCEL | Type ALL to switch to non-interactive mode\n> ")
if choice == "":
return True, False
elif choice == "ALL":
return False, True
else:
return False, False


def gen_logfile(name, current_time, folder_prefix):
filename = "./" + folder_prefix + current_time + "/" + name
Expand Down Expand Up @@ -238,6 +252,29 @@ def to_list(self):
obj.extend(self.options)
return obj

class TransferSolCmd:
def __init__(self, cmd, instruction, drop_amount, recipient, url, options=None):
self.cmd = cmd
self.instruction = instruction
self.drop_amount = drop_amount
self.recipient = recipient
self.url = url
if options is None:
self.options = []
else:
self.options = options

def to_str(self):
return f"{self.cmd} {self.instruction} {self.recipient} {self.drop_amount} {' '.join(self.options)}"

def to_list(self):
#obj = [self.cmd, self.instruction, self.mint_address, str(self.drop_amount), self.recipient]
obj = [self.cmd, self.instruction, self.recipient,
f"{self.drop_amount}", '--url', self.url]
if self.options:
obj.extend(self.options)
return obj


class bcolors:
HEADER = '\033[95m'
Expand Down Expand Up @@ -308,6 +345,16 @@ def main():
transfer(input_path, interactive,drop_amount,
fund_recipient, allow_unfunded_recipient
)
elif mode == 'transferSol':
input_path = args.address_list
interactive = args.interactive
drop_amount = args.drop_amount
fund_recipient = args.fund_recipient
allow_unfunded_recipient = args.allow_unfunded_recipient
RETRY_ON_429 = args.retry_on_429
transfer_sol(input_path, interactive,drop_amount,
fund_recipient, allow_unfunded_recipient
)


def before(input_file, drop, addr_type):
Expand Down Expand Up @@ -505,6 +552,128 @@ def transfer(input_path, interactive, drop_amount,

print("Done!")

def transfer_sol(input_path, interactive, drop_amount,
fund_recipient, allow_unfunded_recipient):
global RPC_URL, LOG_FOLDER_PREFIX, FULL_LOGS, SUCCESS_LOGS, FAILED_LOGS, CANCELED_LOGS, UNCONFIRMED_LOGS
SEPARATOR = "-" * 50
LOG_SEPARATOR = "-" * 30 + "\n"
TOO_MANY_REQUESTS = "429 Too Many Requests"
UNCONFIRMED = "unable to confirm transaction"
RPC_ERROR = "RPC response error -32005"

signal.signal(signal.SIGINT, signal.default_int_handler)
supply_code, current_supply, _ = run(['solana', 'address'])

if supply_code != 0:
sys.exit('Exiting, failed to read the current supply account address. Try checking the output of \'solana address\'.')

print(f"{bcolors.DANGER}WARNING: MAKE SURE YOU ARE USING THE CORRECT WALLET/SUPPLY/ TO DISTRIBUTE.\nYOUR CURRENT WALLET ADDRESS IS: {current_supply.decode('utf-8')}{bcolors.ENDC}")
print(
f"Running airdrop for the solana payout: {bcolors.OKGREEN}{bcolors.ENDC}")
drop = amount_prompt(drop_amount)
print(
f"Airdrop amount: {bcolors.OKGREEN}{drop}{bcolors.ENDC}")

try:
with open(input_path) as f:
address_list = f.read().splitlines()
print(f'Airdropping to {bcolors.OKGREEN}{len(address_list)} users{bcolors.ENDC}')
print(f'Estimated total tokens to be distributed: {bcolors.OKGREEN}{(len(address_list) * drop):,f}{bcolors.ENDC}\n')
except (OSError, IOError) as e:
sys.exit(f"Error opening address list files.\n{e.strerror}")

# region Create log files, print locations, write headers
timestamp = get_current_utc_time_str()
log_success = gen_logfile(SUCCESS_LOGS, timestamp, LOG_FOLDER_PREFIX)
log_canceled = gen_logfile(CANCELED_LOGS, timestamp, LOG_FOLDER_PREFIX)
log_failed = gen_logfile(FAILED_LOGS, timestamp, LOG_FOLDER_PREFIX)
log_unconfirmed = gen_logfile(UNCONFIRMED_LOGS, timestamp, LOG_FOLDER_PREFIX)
log_full = gen_logfile(FULL_LOGS, timestamp, LOG_FOLDER_PREFIX)

print(f" Successful logs: (tail -f {log_success})")
print(f" Canceled logs: (tail -f {log_canceled})")
print(f" Failed logs: (tail -f {log_failed})")
print(f" Unconfirmed logs: (tail -f {log_unconfirmed})")
print(f" Detailed logs: (tail -f {log_full})")

with open(log_success, "a") as ls:
ls.write('recipient,amount,signature\n')
with open(log_canceled, "a") as lc:
lc.write('recipient,amount\n')
with open(log_failed, "a") as lfa:
lfa.write('recipient,amount,error\n')
with open(log_unconfirmed, "a") as lu:
lu.write('recipient,amount,error\n')
# endregion

print()
try:
continue_airdrop_prompt(interactive, SEPARATOR)

i = 0
while i < len(address_list):
addr = (address_list[i]).strip()
options = []
if allow_unfunded_recipient:
options.append('--allow-unfunded-recipient')
cmd = TransferSolCmd("solana", "transfer",
drop, addr, RPC_URL, options)
if not interactive:
log_detail_entry = ''
print(f"{i+1}. Solana transfer to {addr}: ", end="", flush=True)
print("kartun: " + cmd.to_str());
log_detail_entry += f"{i+1}. Cmdline: {cmd.to_str()}\n"
log_detail_entry += try_transfer(
cmd, addr, drop,
log_success, log_unconfirmed, log_failed,
TOO_MANY_REQUESTS, RPC_ERROR, UNCONFIRMED)

with open(log_full, "a") as lf:
lf.write(log_detail_entry + LOG_SEPARATOR)
del cmd
i += 1

elif interactive:
log_detail_entry = ""
print(f"{i+1}. ", end="", flush=True)
log_detail_entry += f"{i+1}. Cmdline: {cmd.to_str()}\n"

confirm, switch_mode = single_transaction_sol_prompt(
cmd.to_str(), drop, addr)
if switch_mode:
print("Switching to non-interactive mode.")
interactive = False
continue

if confirm:
log_detail_entry += try_transfer(
cmd, addr, drop,
log_success, log_unconfirmed, log_failed,
TOO_MANY_REQUESTS, RPC_ERROR, UNCONFIRMED)

with open(log_full, "a") as lf:
lf.write(log_detail_entry + LOG_SEPARATOR)
elif not confirm:
print(
f"{bcolors.DANGER}CANCELED{bcolors.ENDC}", flush=True)
cancel = f"{addr},{drop:f}"
with open(log_canceled, "a") as lc:
lc.write(cancel + "\n")
log_detail_entry += f"Cancel: {cancel}\n"
with open(log_full, "a") as lf:
lf.write(log_detail_entry + LOG_SEPARATOR)

print(f"{bcolors.WARNING}{SEPARATOR}{bcolors.ENDC}")
del cmd
i += 1

except KeyboardInterrupt:
sys.exit("Interrupted, exiting.")
finally:
print("Log file handlers closed.")

print("Done!")


# region Argument parsing
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -615,6 +784,55 @@ def transfer(input_path, interactive, drop_amount,
required=False,
help='Retry when a HTTP 429 error code is encountered. Use this at your own risk.'
)

parser_t = subparsers.add_parser(
'transferSol', help='Distribute a flat amount of tokens to all given addresses.')

parser_t.add_argument(
'-d',
'--drop',
dest="drop_amount",
type=float,
required=False,
help='The amount of tokens that will be distributed to each recipient.'
)
parser_t.add_argument(
'--non-interactive',
dest="interactive",
default=True,
action='store_false',
required=False,
help='Run in non-interactive mode (no confirmation prompts).'
)
parser_t.add_argument(
'-a',
'--address-list',
dest="address_list",
required=True,
help='Path to the file that contains all addresses that will receive the \
airdrop. Each address should be in a seperate line. The file must be UTF-8 encoded.'
)
parser_t.add_argument(
'--fund-recipient',
action='store_true',
required=False,
help='Create the associated token account for the recipient if it does not exist.'
)
parser_t.add_argument(
'--allow-unfunded-recipient',
action='store_true',
required=False,
help='Complete the transfer even if the recipient\'s address is not funded.'
)
parser_t.add_argument(
'--retry-on-429',
dest='retry_on_429',
action='store_true',
default=False,
required=False,
help='Retry when a HTTP 429 error code is encountered. Use this at your own risk.'
)

#endregion

if __name__ == '__main__':
Expand Down