diff --git a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java index cd23b82..b727c60 100644 --- a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java +++ b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java @@ -9,7 +9,8 @@ public enum AbiFunction { USERNAME_RESIGNATION("resignUsername"), MULTIPAYMENT("pay"), TRANSFER("transfer"), - APPROVE("approve"); + APPROVE("approve"), + BATCH_TRANSFER_FROM("batchTransferFrom"); private final String functionName; diff --git a/src/main/java/org/arkecosystem/crypto/enums/ContractAddresses.java b/src/main/java/org/arkecosystem/crypto/enums/ContractAddresses.java index 13fc8d0..6c74faa 100644 --- a/src/main/java/org/arkecosystem/crypto/enums/ContractAddresses.java +++ b/src/main/java/org/arkecosystem/crypto/enums/ContractAddresses.java @@ -3,7 +3,8 @@ public enum ContractAddresses { CONSENSUS("0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1"), MULTIPAYMENT("0x00EFd0D4639191C49908A7BddbB9A11A994A8527"), - USERNAMES("0x2c1DE3b4Dbb4aDebEbB5dcECAe825bE2a9fc6eb6"); + USERNAMES("0x2c1DE3b4Dbb4aDebEbB5dcECAe825bE2a9fc6eb6"), + BATCH_TRANSFER("0x5a223F4434D5Bd8478100EEb3b0166a57A26350d"); private final String address; diff --git a/src/main/java/org/arkecosystem/crypto/transactions/builder/BatchTransferBuilder.java b/src/main/java/org/arkecosystem/crypto/transactions/builder/BatchTransferBuilder.java new file mode 100644 index 0000000..02f2a10 --- /dev/null +++ b/src/main/java/org/arkecosystem/crypto/transactions/builder/BatchTransferBuilder.java @@ -0,0 +1,74 @@ +package org.arkecosystem.crypto.transactions.builder; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import org.arkecosystem.crypto.enums.AbiFunction; +import org.arkecosystem.crypto.enums.ContractAbiType; +import org.arkecosystem.crypto.enums.ContractAddresses; +import org.arkecosystem.crypto.transactions.types.AbstractTransaction; +import org.arkecosystem.crypto.transactions.types.EvmCall; +import org.arkecosystem.crypto.utils.AbiEncoder; + +public class BatchTransferBuilder extends AbstractTransactionBuilder { + private String tokenAddress; + private final List recipients = new ArrayList<>(); + private final List amounts = new ArrayList<>(); + + public BatchTransferBuilder() { + super(); + this.transaction.recipientAddress = ContractAddresses.BATCH_TRANSFER.address(); + } + + public BatchTransferBuilder tokenAddress(String tokenAddress) { + this.tokenAddress = tokenAddress; + return this.instance(); + } + + public BatchTransferBuilder addRecipient(String address, BigInteger amount) { + this.recipients.add(address); + this.amounts.add(amount); + return this.instance(); + } + + @Override + public BatchTransferBuilder sign(String passphrase) { + this.encode(); + return super.sign(passphrase); + } + + private void encode() { + if (this.recipients.isEmpty()) { + throw new RuntimeException("Must add at least one recipient before encoding."); + } + + if (this.tokenAddress == null) { + throw new RuntimeException("Must set tokenAddress before encoding."); + } + + List args = new ArrayList<>(); + args.add(this.tokenAddress); + args.add(new ArrayList(this.recipients)); + args.add(new ArrayList(this.amounts)); + + try { + String payload = + new AbiEncoder(ContractAbiType.ERC20BATCH_TRANSFER) + .encodeFunctionCall(AbiFunction.BATCH_TRANSFER_FROM.toString(), args); + + this.transaction.data = payload.replaceFirst("^0x", ""); + } catch (Exception e) { + throw new RuntimeException("Error encoding batch transfer", e); + } + } + + @Override + protected AbstractTransaction getTransactionInstance() { + return new EvmCall(); + } + + @Override + protected BatchTransferBuilder instance() { + return this; + } +} diff --git a/src/test/java/org/arkecosystem/crypto/transactions/builder/BatchTransferBuilderTest.java b/src/test/java/org/arkecosystem/crypto/transactions/builder/BatchTransferBuilderTest.java new file mode 100644 index 0000000..3c66166 --- /dev/null +++ b/src/test/java/org/arkecosystem/crypto/transactions/builder/BatchTransferBuilderTest.java @@ -0,0 +1,151 @@ +package org.arkecosystem.crypto.transactions.builder; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigInteger; +import java.util.Map; +import org.arkecosystem.crypto.AbstractTest; +import org.arkecosystem.crypto.encoding.Hex; +import org.arkecosystem.crypto.enums.ContractAddresses; +import org.arkecosystem.crypto.transactions.Deserializer; +import org.arkecosystem.crypto.transactions.types.AbstractTransaction; +import org.arkecosystem.crypto.transactions.types.EvmCall; +import org.junit.jupiter.api.Test; + +public class BatchTransferBuilderTest extends AbstractTest { + + private static final String BATCH_TRANSFER_SELECTOR = "4885b254"; + private static final String RECIPIENT_A = "0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22"; + private static final String RECIPIENT_B = "0xc3bbe9b1cee1ff85ad72b87414b0e9b7f2366763"; + private static final String TOKEN_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + + @Test + public void it_should_default_to_the_batch_transfer_well_known_contract() { + BatchTransferBuilder builder = new BatchTransferBuilder(); + + assertEquals( + ContractAddresses.BATCH_TRANSFER.address(), builder.transaction.recipientAddress); + } + + @Test + public void it_should_encode_the_batch_transfer_payload() throws Exception { + Map fixture = loadFixture("batch-transfer"); + Map data = (Map) fixture.get("data"); + + BatchTransferBuilder builder = + new BatchTransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21_000) + .nonce(1L) + .tokenAddress(TOKEN_ADDRESS) + .addRecipient(RECIPIENT_A, new BigInteger("100000")) + .addRecipient(RECIPIENT_B, new BigInteger("200000")) + .sign(this.passphrase); + + assertEquals(data.get("data"), builder.transaction.data); + assertTrue(builder.transaction.data.startsWith(BATCH_TRANSFER_SELECTOR)); + assertEquals("0", builder.transaction.value); + } + + @Test + public void it_should_sign_and_verify_a_batch_transfer_transaction() { + BatchTransferBuilder builder = + new BatchTransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21_000) + .nonce(1L) + .tokenAddress(TOKEN_ADDRESS) + .addRecipient(RECIPIENT_A, new BigInteger("100000")) + .addRecipient(RECIPIENT_B, new BigInteger("200000")) + .sign(this.passphrase); + + assertNotNull(builder.transaction.signature); + assertNotNull(builder.transaction.id); + assertTrue(builder.verify()); + } + + @Test + public void it_should_encode_a_single_recipient() { + BatchTransferBuilder builder = + new BatchTransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21_000) + .nonce(1L) + .tokenAddress(TOKEN_ADDRESS) + .addRecipient(RECIPIENT_A, new BigInteger("100000")) + .sign(this.passphrase); + + assertTrue(builder.transaction.data.startsWith(BATCH_TRANSFER_SELECTOR)); + assertEquals("0", builder.transaction.value); + assertTrue(builder.verify()); + } + + @Test + public void it_should_encode_large_amounts() { + BatchTransferBuilder builder = + new BatchTransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21_000) + .nonce(1L) + .tokenAddress(TOKEN_ADDRESS) + .addRecipient(RECIPIENT_A, new BigInteger("1000000000000000000000")) + .sign(this.passphrase); + + assertTrue(builder.verify()); + } + + @Test + public void it_should_round_trip_through_serialization() throws Exception { + BatchTransferBuilder builder = + new BatchTransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21_000) + .nonce(7L) + .tokenAddress(TOKEN_ADDRESS) + .addRecipient(RECIPIENT_A, new BigInteger("100000")) + .addRecipient(RECIPIENT_B, new BigInteger("200000")) + .sign(this.passphrase); + + String serialized = Hex.encode(builder.transaction.serialize()); + AbstractTransaction restored = Deserializer.newDeserializer(serialized).deserialize(); + + assertInstanceOf(EvmCall.class, restored); + assertEquals(builder.transaction.id, restored.id); + assertEquals(builder.transaction.data, restored.data); + assertEquals( + builder.transaction.recipientAddress.toLowerCase(), + restored.recipientAddress.toLowerCase()); + } + + @Test + public void it_should_throw_when_signing_with_no_recipients() { + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> + new BatchTransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21_000) + .nonce(1L) + .tokenAddress(TOKEN_ADDRESS) + .sign(this.passphrase)); + + assertEquals("Must add at least one recipient before encoding.", exception.getMessage()); + } + + @Test + public void it_should_throw_when_signing_without_a_token_address() { + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> + new BatchTransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21_000) + .nonce(1L) + .addRecipient(RECIPIENT_A, new BigInteger("100000")) + .sign(this.passphrase)); + + assertEquals("Must set tokenAddress before encoding.", exception.getMessage()); + } +} diff --git a/src/test/resources/transactions/batch-transfer.json b/src/test/resources/transactions/batch-transfer.json new file mode 100644 index 0000000..9dc82ae --- /dev/null +++ b/src/test/resources/transactions/batch-transfer.json @@ -0,0 +1,15 @@ +{ + "data": { + "nonce": "1", + "gasPrice": 5000000000, + "gasLimit": 21000, + "value": "0", + "tokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "recipientAddress": "0x5a223F4434D5Bd8478100EEb3b0166a57A26350d", + "data": "4885b254000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000020000000000000000000000006f0182a0cc707b055322ccf6d4cb6a5aff1aeb22000000000000000000000000c3bbe9b1cee1ff85ad72b87414b0e9b7f2366763000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000030d40" + }, + "recipients": [ + { "address": "0x6F0182a0cc707b055322CcF6d4CB6a5Aff1aEb22", "amount": "100000" }, + { "address": "0xc3bbe9b1cee1ff85ad72b87414b0e9b7f2366763", "amount": "200000" } + ] +}