diff --git a/.github/workflows/ci_airdrop.yml b/.github/workflows/ci_airdrop.yml new file mode 100644 index 0000000..b5245f5 --- /dev/null +++ b/.github/workflows/ci_airdrop.yml @@ -0,0 +1,28 @@ +name: airdrop_pytest + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v1 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests with pytest + run: | + pip install pytest + pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15a031d --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# dev env +.vscode/ +.idea + +# db +*.sqlite \ No newline at end of file diff --git a/README.md b/README.md index 6f19d0e..70d7223 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,59 @@ +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Build Status - GitHub](https://github.com/OnGridSystems/erc20_airdrop_cli/workflows/airdrop_pytest/badge.svg)](https://github.com/OnGridSystems/erc20_airdrop_cli/actions?query=workflow%3Aairdrop_pytest) + + # Simple CLI app for ERC-20 Airdrops on Ethereum -* compose and verify the list of token recipients -* generate transactions -* execute them +Allows controllable ERC-20/BEP-20 token airdrops by the given list of recipients and amounts. Simplifies control of sending and eliminates human mistakes. The process of airdrop looks like this: + +* fill in the list of recipients and amounts for each +* verify the list, number of recepients and total sum +* generate and sign transactions +* top up sending account with tokens and ETH/BNB +* execute (send) them + + +## Installation + +Clone repositiry. +Create venv: + +```sh +python -m venv venv +``` + +Install dependencies: +```sh +pip install -r requirements.txt +``` + + +## Using app + +At any time run ```./airdrop.py help``` to call help menu. +To show current status ```./airdrop.py show```. + +1. ```./airdrop.py init``` to initialize database - creates SQLite fine in the directory +2. ```./airdrop.py import``` to set sender's private key +3. ```./airdrop.py token ``` to set token address for airdrop. +4. ```./airdrop.py update``` to update balances and current account's nonce. You can run it anytime. + +5. Add recipietns for airdrop, one recipient per command. Amounts are in decimal format. If you specify 1.49, it means you'll send 1490000000000000000 of token units (decimals=18 assumed). + +```./airdrop.py add
``` + + +6. Sign **all** your transactions ```./airdrop.py sign``` + +7. Send transactions on the wire, one at a time ```./airdrop.py sign``` - sends **first** SIGNED transaction and it becomes SENT. And now you can check transaction hash in show menu. + +8. If the execution was interrupted and the receipt was not received. You can request it: +```./airdrop.py receipt``` + + +#### Testing + +```sh +pip install pytest +pytest +``` diff --git a/airdrop.py b/airdrop.py new file mode 100755 index 0000000..3d5910a --- /dev/null +++ b/airdrop.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python + +import sys +import json +from web3 import Web3 +from web3.exceptions import ContractLogicError, BadFunctionCallOutput +from peewee import fn +from peewee import OperationalError + +from models import ( + db, + Recipient, + Config, + Tx, +) + +from pretty_table import print_pretty_table + + +def send_raw_tx(web3_endpoint, signed_tx): + w3 = Web3(Web3.HTTPProvider(web3_endpoint, request_kwargs={"timeout": 20})) + return w3.eth.send_raw_transaction(signed_tx) + + +def get_tx_receipt(web3_endpoint, tx_hash): + w3 = Web3(Web3.HTTPProvider(web3_endpoint, request_kwargs={"timeout": 20})) + return w3.eth.wait_for_transaction_receipt(tx_hash) + + +def initialize(): + db.connect() + db.create_tables([Config, Recipient, Tx]) + Config.create(address="0x0000000000000000000000000000000000000000", + private_key="0000000000000000000000000000000000000000000000000000000000000000", + gas_price=10000000000, + web3_node="https://data-seed-prebsc-2-s1.binance.org:8545/", + token="0x0000000000000000000000000000000000000000") + print('Db was created!') + + +def import_key(): + private_key = input("Paste your private key in hex format: ") + + w3 = Web3(Web3.HTTPProvider(Config.get(1).web3_node, request_kwargs={"timeout": 20})) + address = w3.eth.account.from_key(private_key).address + config = Config.get(1) + config.address = address + config.private_key = private_key + config.save() + print(f'Sender address: {address}') + + +def set_token(token_address): + if not Web3.isChecksumAddress(token_address): + print('Wrong token address. Try again.') + return + + config = Config.get(1) + config.token = token_address + config.save() + print(f"Now token for airdrop is {token_address}") + + +def set_node_address(node_address): + try: + w3 = Web3(Web3.HTTPProvider(node_address, request_kwargs={"timeout": 20})) + assert w3.isConnected() == True + except AssertionError: + print('Wrong node URL or connection error. Try again.') + return + config = Config.get(1) + config.web3_node = node_address + config.save() + print(f'New node address: {node_address}') + + +def update_data(): + config = Config.get(1) + # balances + w3 = Web3(Web3.HTTPProvider(config.web3_node, request_kwargs={"timeout": 20})) + config.eth_balance = w3.eth.get_balance(config.address) + with open("tokens_abi/ERC20.abi", "r") as file: + erc20_abi = json.load(file) + + token_contract = w3.eth.contract(address=Web3.toChecksumAddress(config.token), abi=erc20_abi) + try: + config.token_balance = token_contract.functions.balanceOf(config.address).call() + except BadFunctionCallOutput: + print("Error. Can't update token address. Check token address.") + return + + # nonce + config.current_nonce = w3.eth.get_transaction_count(config.address) + config.save() + + # update tx nonces + txs = Tx.select().where(Tx.status == "SIGNED").order_by(Tx.nonce) + if txs and (txs[0].nonce != config.current_nonce): + nonce = config.current_nonce + for tx in txs: + tx.nonce = nonce + updated_raw_tx = json.loads(tx.raw_tx.replace("\'", "\"")) + updated_raw_tx['nonce'] = nonce + tx.raw_tx = updated_raw_tx + tx.signed_tx = w3.eth.account.sign_transaction(updated_raw_tx, + config.private_key).rawTransaction + tx.save() + nonce += 1 + print('Balance and nonce have been updated.') + + +def show(): + config = Config.get(1) + print(f"Sender address: {config.address}") + print(f"Sender ETH balance: {config.eth_balance} Wei") + print(f"Token address: {config.token}") + print(f"Token balance: {config.token_balance} Wei") + print(f"Sender nonce: {config.current_nonce}") + print(f"Web3 endpoint: {config.web3_node}") + print(f"Gas Price: {config.gas_price}\n") + + print(f"Recipients:") + token_sum = Recipient.select(fn.SUM(Recipient.amount))[0].amount + recipients = Recipient.select() + + header = [['Address', 'Tokens', 'Nonce', 'Status', 'Tx Hash']] + values = [ + [rec.address, + str(rec.amount), + str(rec.txes[0].nonce), + rec.txes[0].status, + rec.txes[0].tx_hash.hex()] for rec in recipients + ] + data_to_print = header + values + print_pretty_table(data_to_print) + + print(f"\nTotal {len(recipients)} recipients, {token_sum} ERC-20 Tokens.\n") + + +def add_recepient(recipient_address, amount): + if not Web3.isChecksumAddress(recipient_address): + print('Wrong recipient address. Try again.') + return + try: + float(amount) + assert float(amount) * 10**18 >= 1 + except ValueError: + print('Wrong amount') + return + except AssertionError: + print('Too less amount') + return + + recipient = Recipient.create(address=recipient_address, amount=amount) + Tx.create(recipient=recipient, status="NEW") + print(f"{recipient_address} was added with amount {amount}!") + + +def set_gas_price(new_gas_price): + try: + int(new_gas_price) + except ValueError: + print("Wrong gas value. Try again") + return + config = Config.get(1) + config.gas_price = new_gas_price + config.save() + print(f'New gas price {new_gas_price}') + + +def sign(): + config = Config.get(1) + w3 = Web3(Web3.HTTPProvider(config.web3_node, request_kwargs={"timeout": 20})) + with open("tokens_abi/ERC20.abi", "r") as file: + erc20_abi = json.load(file) + token = w3.eth.contract(address=Web3.toChecksumAddress(config.token), abi=erc20_abi) + nonce = config.current_nonce + + recipients = Recipient.select(Recipient, Tx).join(Tx).where(Tx.status == 'NEW') + for recipient in recipients: + tx = recipient.txes[0] + try: + raw_tx = token.functions.transfer( + recipient.address, + int(float(recipient.amount) * 10**18), + ).buildTransaction({ + "from": config.address, + "nonce": nonce, + "gasPrice": config.gas_price, + }) + except ValueError: + print('Not enough ETH or Token balance. Fill your balance and try again.') + return + tx.raw_tx = raw_tx + + signed_tx = w3.eth.account.sign_transaction(raw_tx, config.private_key).rawTransaction + tx.signed_tx = signed_tx + tx.nonce = nonce + tx.status = 'SIGNED' + tx.save() + + nonce += 1 + print('TXs have been signed.') + + +def send(): + config = Config.get(1) + w3 = Web3(Web3.HTTPProvider(config.web3_node, request_kwargs={"timeout": 20})) + + sending_tx = Tx.select().where(Tx.status == 'SIGNED').order_by(Tx.nonce, Tx.id).first() + + try: + w3.eth.call(json.loads(sending_tx.raw_tx.replace("\'", "\""))) + except ContractLogicError: + print("""Contract logic error (probably insufficient token balance). Sending aborted.""") + return + except AttributeError: + print("Nothing to send.") + return + + try: + tx_hash = send_raw_tx(config.web3_node, sending_tx.signed_tx) + except ValueError as e: + if str(e) == "{'code': -32000, 'message': 'insufficient funds for gas * price + value'}": + print('Not enough ETH Balance. Fill your balance and try again.') + elif str(e) == "{'code': -32000, 'message': 'nonce too low'}": + print("Needs to update account nonce. Use command 'update'.") + return + except AttributeError as e: + print(e) + print("Nothing to send.") + return + sending_tx.tx_hash = tx_hash + sending_tx.status = 'SENT' + sending_tx.save() + print(f"Tx with {sending_tx.nonce} nonce was sent. Waiting for receipt...") + + config.current_nonce += 1 + config.save() + + sending_tx.tx_receipt = get_tx_receipt(config.web3_node, tx_hash) + sending_tx.status = "MINED" + sending_tx.save() + print('Tx was successfully mined!') + + +def get_receipt(): + config = Config.get(1) + w3 = Web3(Web3.HTTPProvider(config.web3_node, request_kwargs={"timeout": 20})) + + tx = Tx.select().where(Tx.status == "SENT").order_by(Tx.id).first() + tx.tx_receipt= w3.eth.wait_for_transaction_receipt(tx.tx_hash) + tx.status = "MINED" + tx.save() + print(f"Tx with nonce {tx.nonce} was successfully mined!") + + +def help(): + print(""" + init Starts project, calls at once. + import Import admin private key, returns user address. + token
Set token address for airdrop. + web3 Specify web3 node address. + update Retrievs latest balances and user nonce. Also updates nonce for 'SIGNED' tx, if necceessary. + show Shows current status. + add
Adds recipient with amount. + gasprice Set new gasprice value for tx in Wei. + sign Signs all transactions. + send Sedns first signed tx. + receipt Queries receipt for transaction with status 'SENT' + help Returns this info + """) + + +command_dict = { + "init": initialize, + 'import': import_key, + 'token': set_token, + "web3": set_node_address, + "update": update_data, + "show": show, + "add": add_recepient, + "gasprice": set_gas_price, + "sign": sign, + "send": send, + "receipt": get_receipt, + "help": help, +} + + +if __name__ == "__main__": + + if len(sys.argv) < 2: + print("Command reqired. Input 'help' for additional info.") + else: + # parse command + try: + command = sys.argv[1] + args = sys.argv[2:] + except IndexError: + print("Invalid command. Try again or input 'help' for help.") + + # execute command + try: + command_dict[command](*args) + except TypeError: + print(f'Please specify all neccessary argument(s) for command "{command}"') + except KeyError: + print("Invalid command. Try again or input 'help' for help.") + except OperationalError: + print("Invalid command. Try again or input 'help' for help.") diff --git a/models.py b/models.py new file mode 100644 index 0000000..9bb89ed --- /dev/null +++ b/models.py @@ -0,0 +1,46 @@ +import peewee as pw + + +DBFILE = 'db.sqlite' +db = pw.SqliteDatabase(DBFILE) + + +class BaseModel(pw.Model): + class Meta: + database = db + + +class Config(BaseModel): + address = pw.CharField(max_length=42) + private_key = pw.CharField(max_length=64) + gas_price = pw.IntegerField() + web3_node = pw.CharField(max_length=255) + token = pw.CharField(max_length=42) + + current_nonce = pw.IntegerField(default=0) + eth_balance = pw.CharField(default=0) + token_balance = pw.CharField(default=0) + + +class Recipient(BaseModel): + address = pw.CharField(max_length=42) + amount = pw.DecimalField(max_digits=36, decimal_places=18) + + +class Tx(BaseModel): + + choices = ( + ('NEW', 'NEW'), + ('SIGNED', 'SIGNED'), + ('SENT', 'SENT'), + ('MINED', 'MINED'), + ) + + raw_tx = pw.TextField(default="") + signed_tx = pw.BlobField(default="") + tx_hash = pw.BlobField(default="") + tx_receipt = pw.TextField(default="") + + nonce = pw.IntegerField(null=True) + recipient = pw.ForeignKeyField(Recipient, backref='txes') + status = pw.CharField(choices=choices) diff --git a/pretty_table.py b/pretty_table.py new file mode 100644 index 0000000..b7e6bfa --- /dev/null +++ b/pretty_table.py @@ -0,0 +1,21 @@ +def print_pretty_table(data, cell_sep=' | ', header_separator=True): + rows = len(data) + cols = len(data[0]) + + col_width = [] + for col in range(cols): + columns = [data[row][col] for row in range(rows)] + col_width.append(len(max(columns, key=len))) + + separator = "-+-".join('-' * n for n in col_width) + + for i, row in enumerate(range(rows)): + if i == 1 and header_separator: + print(separator) + + result = [] + for col in range(cols): + item = data[row][col].ljust(col_width[col]) + result.append(item) + + print(cell_sep.join(result)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d66925 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,39 @@ +aiohttp==3.7.4.post0 +async-timeout==3.0.1 +attrs==21.2.0 +base58==2.1.0 +bitarray==1.2.2 +certifi==2021.5.30 +chardet==4.0.0 +cytoolz==0.11.0 +eth-abi==2.1.1 +eth-account==0.5.4 +eth-hash==0.3.1 +eth-keyfile==0.5.1 +eth-keys==0.3.3 +eth-rlp==0.2.1 +eth-typing==2.2.2 +eth-utils==1.10.0 +hexbytes==0.2.1 +idna==2.10 +ipfshttpclient==0.7.0 +jsonschema==3.2.0 +lru-dict==1.1.7 +multiaddr==0.0.9 +multidict==5.1.0 +netaddr==0.8.0 +parsimonious==0.8.1 +peewee==3.14.4 +protobuf==3.17.3 +pycryptodome==3.10.1 +pyrsistent==0.17.3 +requests==2.25.1 +rlp==2.0.1 +six==1.16.0 +toolz==0.11.1 +typing-extensions==3.10.0.0 +urllib3==1.26.5 +varint==1.0.2 +web3==5.20.0 +websockets==8.1 +yarl==1.6.3 diff --git a/test_airdrop.py b/test_airdrop.py new file mode 100644 index 0000000..bd43a40 --- /dev/null +++ b/test_airdrop.py @@ -0,0 +1,365 @@ +import json +import peewee as pw +import airdrop as ad +from models import BaseModel, Config, Recipient, Tx + + +# Note. +# All of addresses are valid the for binance testnet (97). +# But no one transaction will happend due to using pytest monkeypatch in +# the methods send (tranfer) and get receipts. + + +DBFILE = "db_test.sqlite" + + +def delete_tables(): + BaseModel._meta.database.init(DBFILE) + Config.drop_table() + Recipient.drop_table() + Tx.drop_table() + + +def test_create_empty_db(): + BaseModel._meta.database.init(DBFILE) + db = pw.SqliteDatabase(DBFILE) + db.connect() + db.create_tables([Config, Recipient, Tx]) + + assert db.table_exists('config') + assert db.table_exists('recipient') + assert db.table_exists('tx') + assert db.get_tables() == ['config', 'recipient', 'tx'] + assert len(Config.select()) == 0 + assert len(Recipient.select()) == 0 + assert len(Tx.select()) == 0 + + delete_tables() + db.close() + + +def test_init_command(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + + ad.initialize() + assert db.get_tables() == ['config', 'recipient', 'tx'] + + config = Config.get(1) + assert config.address == "0x0000000000000000000000000000000000000000" + assert config.private_key == "0000000000000000000000000000000000000000000000000000000000000000" + assert config.gas_price == 10000000000 + assert config.web3_node == "https://data-seed-prebsc-2-s1.binance.org:8545/" + assert config.token == "0x0000000000000000000000000000000000000000" + assert config.current_nonce == 0 + assert config.eth_balance == '0' + assert config.token_balance == '0' + delete_tables() + db.close() + + +def test_import_command(monkeypatch): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + ad.initialize() + + monkeypatch.setattr('builtins.input', + lambda _: "a181ad022696f68244129bc35559d9fe28005d5289fca5961d3ce91dc29d13b3") + ad.import_key() + + config = Config.get(1) + assert config.address == "0xB0718e1085E1E34537ff9fdAeeC5Ec1AfFe1872c" + assert config.private_key == "a181ad022696f68244129bc35559d9fe28005d5289fca5961d3ce91dc29d13b3" + + # check again + assert config.gas_price == 10000000000 + assert config.web3_node == "https://data-seed-prebsc-2-s1.binance.org:8545/" + assert config.token == "0x0000000000000000000000000000000000000000" + assert config.current_nonce == 0 + assert config.eth_balance == '0' + assert config.token_balance == '0' + + db.close() + + +def test_token_command(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + ad.set_token("0x688ce8a97d5f1193261DB2271f542193D1dFd866") + + config = Config.get(1) + assert config.token == "0x688ce8a97d5f1193261DB2271f542193D1dFd866" + + db.close() + + +def test_token_command_with_wrong_address(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + token_before = Config.get(1).token + + ad.set_token("0x688ce8a97d5f1193261DB2271f542193D83742323234ac1dFd86693849") + assert Config.get(1).token == token_before + + ad.set_token("0xce8a97d5f1193261D") + assert Config.get(1).token == token_before + + ad.set_token("12e8a97d5f1193261D") + assert Config.get(1).token == token_before + + ad.set_token(12345678901234567890) + assert Config.get(1).token == token_before + + db.close() + + +def test_web3_command(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + ad.set_node_address("https://data-seed-prebsc-1-s3.binance.org:8545/") + + config = Config.get(1) + assert config.web3_node == "https://data-seed-prebsc-1-s3.binance.org:8545/" + + db.close() + + +def test_web3_command_dont_change_to_wrong_address(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + wen3_before = Config.get(1).web3_node + + ad.set_node_address("https://") + assert Config.get(1).web3_node == wen3_before + + ad.set_node_address("https://google.com/") + assert Config.get(1).web3_node == wen3_before + + ad.set_node_address("abcdefg") + assert Config.get(1).web3_node == wen3_before + + db.close() + + +def test_update_command(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + config = Config.get(1) + + # before update + assert config.current_nonce == 0 + assert int(config.eth_balance) == 0 + assert int(config.token_balance) == 0 + assert config.token == "0x688ce8a97d5f1193261DB2271f542193D1dFd866" + assert config.address == "0xB0718e1085E1E34537ff9fdAeeC5Ec1AfFe1872c" + assert config.private_key == "a181ad022696f68244129bc35559d9fe28005d5289fca5961d3ce91dc29d13b3" + + # updated + ad.update_data() + config = Config.get(1) + assert config.current_nonce > 0 + assert int(config.eth_balance) > 0 + assert int(config.token_balance) > 0 + + db.close() + + +def test_gasprice_command(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + # In 'init' project's default gasprice is 10000000000 + assert Config.get(1).gas_price == 10000000000 + + # Let's switch gas price to 20000000002 + ad.set_gas_price(20000000002) + assert Config.get(1).gas_price == 20000000002 + + # Let's switch gas price to 30000000003 + ad.set_gas_price("30000000003") + assert Config.get(1).gas_price == 30000000003 + + # Switching back + ad.set_gas_price(10000000000) + assert Config.get(1).gas_price == 10000000000 + + db.close() + + +def test_gasprice_command_with_wrong_value(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + gas_before = Config.get(1).gas_price + + ad.set_gas_price(" wrong value ") + assert Config.get(1).gas_price == gas_before + + ad.set_gas_price("3434340430d") + assert Config.get(1).gas_price == gas_before + + ad.set_gas_price("0.0000001") + assert Config.get(1).gas_price == gas_before + + db.close() + + +def test_add_command(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + adr1, amount1 = ("0x754a2bAe5b5eEE723409A1d0013377927Fd5F539", 1.234567890123456789) + adr2, amount2 = ("0x754a2bAe5b5eEE723409A1d0013377927Fd5F539", 0.123456789012345678) + adr3, amount3 = ("0x754a2bAe5b5eEE723409A1d0013377927Fd5F539", 0.012345678901234567) + + # before + assert len(Recipient.select()) == 0 + assert len(Tx.select()) == 0 + + # add one + ad.add_recepient(adr1, amount1) + assert len(Recipient.select()) == 1 + recipient1 = Recipient.get(1) + assert recipient1.address == adr1 + assert float(recipient1.amount) == amount1 + + assert len(Tx.select()) == 1 + tx1 = Tx.get(1) + assert tx1.raw_tx == "" + assert tx1.signed_tx == b'' + assert tx1.tx_hash == b'' + assert tx1.tx_receipt == "" + assert tx1.nonce == None + assert tx1.recipient.address == "0x754a2bAe5b5eEE723409A1d0013377927Fd5F539" + assert tx1.status == 'NEW' + + + # add two more + ad.add_recepient(adr2, amount2) + ad.add_recepient(adr3, amount3) + assert len(Recipient.select()) == 3 + recipient2 = Recipient.get(2) + recipient3 = Recipient.get(3) + assert recipient2.address == adr2 + assert float(recipient2.amount) == amount2 + assert recipient3.address == adr3 + assert float(recipient3.amount) == amount3 + + assert len(Tx.select()) == 3 + assert Tx.get(2).status == 'NEW' + assert Tx.get(3).status == 'NEW' + assert bool(Tx.get(2).signed_tx) == False + + db.close() + + +def test_sign_command(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + nonce_before = Config.get(1).current_nonce + ad.sign() + + tx1, tx2, tx3 = Tx.select() + assert tx1.status == 'SIGNED' + assert tx2.status == 'SIGNED' + assert tx3.status == 'SIGNED' + assert tx1.nonce == nonce_before + assert tx2.nonce == nonce_before + 1 + assert tx3.nonce == nonce_before + 2 + assert json.loads(tx1.raw_tx.replace("\'", "\""))['from'] == Config.get(1).address + assert json.loads(tx2.raw_tx.replace("\'", "\""))['from'] == Config.get(1).address + assert json.loads(tx3.raw_tx.replace("\'", "\""))['from'] == Config.get(1).address + assert json.loads(tx1.raw_tx.replace("\'", "\""))['to'] == Config.get(1).token + assert json.loads(tx2.raw_tx.replace("\'", "\""))['to'] == Config.get(1).token + assert json.loads(tx3.raw_tx.replace("\'", "\""))['to'] == Config.get(1).token + assert bool(json.loads(tx1.raw_tx.replace("\'", "\""))['data']) == True + assert bool(json.loads(tx2.raw_tx.replace("\'", "\""))['data']) == True + assert bool(json.loads(tx3.raw_tx.replace("\'", "\""))['data']) == True + assert bool(tx1.signed_tx) == True + assert bool(tx2.signed_tx) == True + assert bool(tx3.signed_tx) == True + + db.close() + + +def test_send_command(monkeypatch): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + db.connect() + + monkeypatch.setattr('airdrop.send_raw_tx', + lambda _, __: bytes.fromhex('593ebcf5700420b1')) + monkeypatch.setattr('airdrop.get_tx_receipt', + lambda _, __: {'blockNumber': 100, 'status': 1}) + ad.send() + + tx = Tx.get(1) + assert tx.tx_hash == bytes.fromhex('593ebcf5700420b1') + assert tx.status == 'MINED' + assert tx.tx_receipt == "{'blockNumber': 100, 'status': 1}" + + db.close() + + # db cleanup + delete_tables() + + +def test_dont_update_without_token(monkeypatch): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + + ad.initialize() + monkeypatch.setattr('builtins.input', + lambda _: "a181ad022696f68244129bc35559d9fe28005d5289fca5961d3ce91dc29d13b3") + ad.import_key() + + nonce_before = Config.get(1).current_nonce + token_balance_before = Config.get(1).token_balance + + ad.update_data() + assert nonce_before == Config.get(1).current_nonce + assert token_balance_before == Config.get(1).token_balance + + db.close() + delete_tables() + + +def test_add_with_wrong_inputs_dont_adds_data_to_db(): + db = pw.SqliteDatabase(DBFILE) + BaseModel._meta.database.init(DBFILE) + + ad.initialize() + + adr1, amount1 = ("0x0000000000000000000000000000hd1100000000", 1.2345) + adr2, amount2 = ("0x754a2bAe5b5eEE723409A1d0013377927Fd5F539", "") + adr3, amount3 = ("0x754a2bAe5b5eEE723409A1d0013377927Fd5F539", "0.00000000000000000001") + + # before + assert len(Recipient.select()) == 0 + assert len(Tx.select()) == 0 + + ad.add_recepient(adr1, amount1) + ad.add_recepient(adr2, amount2) + ad.add_recepient(adr3, amount3) + + # after + assert len(Recipient.select()) == 0 + assert len(Tx.select()) == 0 + + db.close() + delete_tables() diff --git a/tokens_abi/ERC20.abi b/tokens_abi/ERC20.abi new file mode 100644 index 0000000..13e246c --- /dev/null +++ b/tokens_abi/ERC20.abi @@ -0,0 +1 @@ +[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"guy","type":"address"},{"name":"wad","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"src","type":"address"},{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"wad","type":"uint256"}],"name":"withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"deposit","outputs":[],"payable":true,"stateMutability":"payable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"},{"name":"","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"payable":true,"stateMutability":"payable","type":"fallback"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"guy","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Deposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Withdrawal","type":"event"}] \ No newline at end of file