diff --git a/crypto/enums/abi_function.py b/crypto/enums/abi_function.py index 8352e20..462e1be 100644 --- a/crypto/enums/abi_function.py +++ b/crypto/enums/abi_function.py @@ -11,3 +11,4 @@ class AbiFunction(Enum): UPDATE_VALIDATOR = 'updateValidator' TRANSFER = 'transfer' APPROVE = 'approve' + BATCH_TRANSFER_FROM = 'batchTransferFrom' diff --git a/crypto/enums/contract_abi_type.py b/crypto/enums/contract_abi_type.py index e4872fa..1b3b93b 100644 --- a/crypto/enums/contract_abi_type.py +++ b/crypto/enums/contract_abi_type.py @@ -6,3 +6,4 @@ class ContractAbiType(Enum): MULTIPAYMENT = 'multipayment' TOKEN = 'token' USERNAMES = 'usernames' + ERC20BATCH_TRANSFER = 'erc20BatchTransfer' diff --git a/crypto/enums/contract_addresses.py b/crypto/enums/contract_addresses.py index c7379de..25266d7 100644 --- a/crypto/enums/contract_addresses.py +++ b/crypto/enums/contract_addresses.py @@ -4,3 +4,4 @@ class ContractAddresses(Enum): CONSENSUS = '0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1' MULTIPAYMENT = '0x00EFd0D4639191C49908A7BddbB9A11A994A8527' USERNAMES = '0x2c1DE3b4Dbb4aDebEbB5dcECAe825bE2a9fc6eb6' + BATCH_TRANSFER = '0x5a223F4434D5Bd8478100EEb3b0166a57A26350d' diff --git a/crypto/transactions/builder/batch_transfer_builder.py b/crypto/transactions/builder/batch_transfer_builder.py new file mode 100644 index 0000000..1784657 --- /dev/null +++ b/crypto/transactions/builder/batch_transfer_builder.py @@ -0,0 +1,52 @@ +from crypto.enums.abi_function import AbiFunction +from crypto.enums.contract_abi_type import ContractAbiType +from crypto.enums.contract_addresses import ContractAddresses +from crypto.transactions.builder.abstract_transaction_builder import ( + AbstractTransactionBuilder, +) +from crypto.transactions.types.evm_call import EvmCall +from crypto.utils.abi_encoder import AbiEncoder +from crypto.utils.transaction_utils import TransactionUtils + + +class BatchTransferBuilder(AbstractTransactionBuilder): + def __init__(self, data: dict): + super().__init__(data) + + self._token_address = None + self._recipients = [] + self._amounts = [] + + self.to(ContractAddresses.BATCH_TRANSFER.value) + + def token_address(self, token_address: str): + self._token_address = token_address + return self + + def add_recipient(self, address: str, amount: int): + self._recipients.append(address) + self._amounts.append(amount) + return self + + def sign(self, passphrase: str): + self._encode() + return super().sign(passphrase) + + def _encode(self): + if len(self._recipients) == 0: + raise Exception('Must add at least one recipient before encoding.') + + if self._token_address is None: + raise Exception('Must set tokenAddress before encoding.') + + encoder = AbiEncoder(ContractAbiType.ERC20BATCH_TRANSFER) + payload = encoder.encode_function_call( + AbiFunction.BATCH_TRANSFER_FROM.value, + [self._token_address, self._recipients, self._amounts], + ) + self.transaction.data['data'] = TransactionUtils.parse_hex_from_str( + payload + ) + + def get_transaction_instance(self, data: dict): + return EvmCall(data) diff --git a/crypto/utils/abi/json/Abi.ERC20BatchTransfer.json b/crypto/utils/abi/json/Abi.ERC20BatchTransfer.json new file mode 100644 index 0000000..ed6e939 --- /dev/null +++ b/crypto/utils/abi/json/Abi.ERC20BatchTransfer.json @@ -0,0 +1,59 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "ERC20BatchTransfer", + "sourceName": "config/solidity/ERC20BatchTransfer.sol", + "abi": [ + { + "inputs": [], + "name": "LengthMismatch", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "contract IERC20", + "name": "token", + "type": "address" + }, + { + "internalType": "address[]", + "name": "to", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "amount", + "type": "uint256[]" + } + ], + "name": "batchTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IERC20[]", + "name": "tokens", + "type": "address[]" + }, + { + "internalType": "address[]", + "name": "to", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "amount", + "type": "uint256[]" + } + ], + "name": "multiBatchTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b5061083e8061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610034575f3560e01c80634885b25414610038578063f053e99f14610054575b5f5ffd5b610052600480360381019061004d9190610491565b610070565b005b61006e60048036038101906100699190610577565b6101cd565b005b8181905084849050146100af576040517fff633a3800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f5b848490508110156101c5578573ffffffffffffffffffffffffffffffffffffffff166323b872dd338787858181106100ec576100eb610627565b5b9050602002016020810190610101919061067e565b86868681811061011457610113610627565b5b905060200201356040518463ffffffff1660e01b8152600401610139939291906106d0565b6020604051808303815f875af1158015610155573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610179919061073a565b6101b8576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016101af906107bf565b60405180910390fd5b80806001019150506100b1565b505050505050565b85859050848490501415806101e85750858590508282905014155b1561021f576040517fff633a3800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f5f90505b8686905081101561035f5786868281811061024257610241610627565b5b905060200201602081019061025791906107dd565b73ffffffffffffffffffffffffffffffffffffffff166323b872dd3387878581811061028657610285610627565b5b905060200201602081019061029b919061067e565b8686868181106102ae576102ad610627565b5b905060200201356040518463ffffffff1660e01b81526004016102d3939291906106d0565b6020604051808303815f875af11580156102ef573d5f5f3e3d5ffd5b505050506040513d601f19601f82011682018060405250810190610313919061073a565b610352576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610349906107bf565b60405180910390fd5b8080600101915050610224565b50505050505050565b5f5ffd5b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f61039982610370565b9050919050565b5f6103aa8261038f565b9050919050565b6103ba816103a0565b81146103c4575f5ffd5b50565b5f813590506103d5816103b1565b92915050565b5f5ffd5b5f5ffd5b5f5ffd5b5f5f83601f8401126103fc576103fb6103db565b5b8235905067ffffffffffffffff811115610419576104186103df565b5b602083019150836020820283011115610435576104346103e3565b5b9250929050565b5f5f83601f840112610451576104506103db565b5b8235905067ffffffffffffffff81111561046e5761046d6103df565b5b60208301915083602082028301111561048a576104896103e3565b5b9250929050565b5f5f5f5f5f606086880312156104aa576104a9610368565b5b5f6104b7888289016103c7565b955050602086013567ffffffffffffffff8111156104d8576104d761036c565b5b6104e4888289016103e7565b9450945050604086013567ffffffffffffffff8111156105075761050661036c565b5b6105138882890161043c565b92509250509295509295909350565b5f5f83601f840112610537576105366103db565b5b8235905067ffffffffffffffff811115610554576105536103df565b5b6020830191508360208202830111156105705761056f6103e3565b5b9250929050565b5f5f5f5f5f5f6060878903121561059157610590610368565b5b5f87013567ffffffffffffffff8111156105ae576105ad61036c565b5b6105ba89828a01610522565b9650965050602087013567ffffffffffffffff8111156105dd576105dc61036c565b5b6105e989828a016103e7565b9450945050604087013567ffffffffffffffff81111561060c5761060b61036c565b5b61061889828a0161043c565b92509250509295509295509295565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52603260045260245ffd5b61065d8161038f565b8114610667575f5ffd5b50565b5f8135905061067881610654565b92915050565b5f6020828403121561069357610692610368565b5b5f6106a08482850161066a565b91505092915050565b6106b28161038f565b82525050565b5f819050919050565b6106ca816106b8565b82525050565b5f6060820190506106e35f8301866106a9565b6106f060208301856106a9565b6106fd60408301846106c1565b949350505050565b5f8115159050919050565b61071981610705565b8114610723575f5ffd5b50565b5f8151905061073481610710565b92915050565b5f6020828403121561074f5761074e610368565b5b5f61075c84828501610726565b91505092915050565b5f82825260208201905092915050565b7f5452414e534645525f46524f4d5f4641494c45440000000000000000000000005f82015250565b5f6107a9601483610765565b91506107b482610775565b602082019050919050565b5f6020820190508181035f8301526107d68161079d565b9050919050565b5f602082840312156107f2576107f1610368565b5b5f6107ff848285016103c7565b9150509291505056fea264697066735822122072bc5bd8cacc8452edacb73e0f0321cdfbba275969c6c2476826da50766804ee64736f6c634300081e0033" +} diff --git a/crypto/utils/abi_base.py b/crypto/utils/abi_base.py index c113ddd..74ca0c6 100644 --- a/crypto/utils/abi_base.py +++ b/crypto/utils/abi_base.py @@ -71,4 +71,7 @@ def __contract_abi_path(self, abi_type: ContractAbiType, path: Optional[str] = N if abi_type == ContractAbiType.USERNAMES: return os.path.join(os.path.dirname(__file__), 'abi/json', 'Abi.Usernames.json') + if abi_type == ContractAbiType.ERC20BATCH_TRANSFER: + return os.path.join(os.path.dirname(__file__), 'abi/json', 'Abi.ERC20BatchTransfer.json') + return path diff --git a/tests/fixtures/transactions/batch-transfer.json b/tests/fixtures/transactions/batch-transfer.json new file mode 100644 index 0000000..0da1d76 --- /dev/null +++ b/tests/fixtures/transactions/batch-transfer.json @@ -0,0 +1,21 @@ +{ + "data": { + "nonce": "1", + "gasPrice": "5000000000", + "gasLimit": "21000", + "to": "0x5a223F4434D5Bd8478100EEb3b0166a57A26350d", + "value": "0", + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "data": "4885b254000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006f0182a0cc707b055322ccf6d4cb6a5aff1aeb22000000000000000000000000c3bbe9b1cee1ff85ad72b87414b0e9b7f2366763000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000030d40", + "v": 1, + "r": "dc7cac17d9961b4730ec5426eecb94de93e6bf44a3c6d13b00413948646d18e5", + "s": "35220c92a43698524f9023727b68cd6f01753bf6d3128f0af4b78343ff6897e6", + "senderPublicKey": "0243333347c8cbf4e3cbc7a96964181d02a2b0c854faa2fef86b4b8d92afcf473d", + "hash": "59c1dcace1b2573921909d9ad1ca475f49b890f2cb147030462a5b9076b6a5dd" + }, + "recipients": [ + { "address": "0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22", "amount": 100000 }, + { "address": "0xc3bbe9b1cee1ff85ad72b87414b0e9b7f2366763", "amount": 200000 } + ], + "serialized": "f9018c0185012a05f200825208945a223f4434d5bd8478100eeb3b0166a57a26350d80b901244885b254000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006f0182a0cc707b055322ccf6d4cb6a5aff1aeb22000000000000000000000000c3bbe9b1cee1ff85ad72b87414b0e9b7f2366763000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000030d40825c6ca0dc7cac17d9961b4730ec5426eecb94de93e6bf44a3c6d13b00413948646d18e5a035220c92a43698524f9023727b68cd6f01753bf6d3128f0af4b78343ff6897e6" +} diff --git a/tests/transactions/builder/test_batch_transfer_builder.py b/tests/transactions/builder/test_batch_transfer_builder.py new file mode 100644 index 0000000..a3722ac --- /dev/null +++ b/tests/transactions/builder/test_batch_transfer_builder.py @@ -0,0 +1,110 @@ +import pytest + +from crypto.enums.contract_addresses import ContractAddresses +from crypto.transactions.builder.batch_transfer_builder import ( + BatchTransferBuilder, +) + +RECIPIENT_A = '0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22' +RECIPIENT_B = '0xc3bbe9b1cee1ff85ad72b87414b0e9b7f2366763' +TOKEN_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7' + + +def test_it_should_default_to_the_batch_transfer_contract(): + builder = BatchTransferBuilder.new() + + assert builder.transaction.data['to'] == ContractAddresses.BATCH_TRANSFER.value + + +def test_it_should_sign_it_with_a_passphrase(passphrase, load_transaction_fixture): + fixture = load_transaction_fixture('transactions/batch-transfer') + + builder = ( + BatchTransferBuilder + .new() + .gas_price(fixture['data']['gasPrice']) + .gas_limit(fixture['data']['gasLimit']) + .nonce(fixture['data']['nonce']) + .token_address(TOKEN_ADDRESS) + .add_recipient(RECIPIENT_A, 100000) + .add_recipient(RECIPIENT_B, 200000) + .sign(passphrase) + ) + + assert builder.transaction.data['gasPrice'] == int(fixture['data']['gasPrice']) + assert builder.transaction.data['gasLimit'] == int(fixture['data']['gasLimit']) + assert builder.transaction.data['nonce'] == fixture['data']['nonce'] + assert builder.transaction.data['value'] == 0 + assert builder.transaction.data['to'] == fixture['data']['to'] + assert builder.transaction.data['data'] == fixture['data']['data'] + assert builder.transaction.data['v'] == fixture['data']['v'] + assert builder.transaction.data['r'] == fixture['data']['r'] + assert builder.transaction.data['s'] == fixture['data']['s'] + assert builder.transaction.data['hash'] == fixture['data']['hash'] + + assert builder.transaction.serialize().hex() == fixture['serialized'] + assert builder.verify() + + +def test_it_should_handle_single_recipient(passphrase, load_transaction_fixture): + fixture = load_transaction_fixture('transactions/batch-transfer') + + builder = ( + BatchTransferBuilder + .new() + .gas_price(fixture['data']['gasPrice']) + .gas_limit(fixture['data']['gasLimit']) + .nonce(fixture['data']['nonce']) + .token_address(TOKEN_ADDRESS) + .add_recipient(RECIPIENT_A, 100000) + .sign(passphrase) + ) + + assert builder.transaction.data['data'].startswith('4885b254') + assert builder.transaction.data['value'] == 0 + assert builder.verify() + + +def test_it_should_encode_large_amounts(passphrase, load_transaction_fixture): + fixture = load_transaction_fixture('transactions/batch-transfer') + + builder = ( + BatchTransferBuilder + .new() + .gas_price(fixture['data']['gasPrice']) + .gas_limit(fixture['data']['gasLimit']) + .nonce(fixture['data']['nonce']) + .token_address(TOKEN_ADDRESS) + .add_recipient(RECIPIENT_A, 1000000000000000000000) + .sign(passphrase) + ) + + assert builder.verify() + + +def test_it_should_throw_when_signing_with_no_recipients(passphrase): + builder = ( + BatchTransferBuilder + .new() + .gas_price('5000000000') + .gas_limit('21000') + .nonce('1') + .token_address(TOKEN_ADDRESS) + ) + + with pytest.raises(Exception, match='Must add at least one recipient before encoding.'): + builder.sign(passphrase) + + +def test_it_should_throw_when_signing_without_a_token_address(passphrase): + builder = ( + BatchTransferBuilder + .new() + .gas_price('5000000000') + .gas_limit('21000') + .nonce('1') + .add_recipient(RECIPIENT_A, 100000) + ) + + with pytest.raises(Exception, match='Must set tokenAddress before encoding.'): + builder.sign(passphrase)