diff --git a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java index c52942c..bfc38ba 100644 --- a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java +++ b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java @@ -5,6 +5,7 @@ public enum AbiFunction { UNVOTE("unvote"), VALIDATOR_REGISTRATION("registerValidator"), VALIDATOR_RESIGNATION("resignValidator"), + MULTIPAYMENT("pay"), TRANSFER("transfer"), APPROVE("approve"); diff --git a/src/main/java/org/arkecosystem/crypto/enums/ContractAddresses.java b/src/main/java/org/arkecosystem/crypto/enums/ContractAddresses.java new file mode 100644 index 0000000..13fc8d0 --- /dev/null +++ b/src/main/java/org/arkecosystem/crypto/enums/ContractAddresses.java @@ -0,0 +1,17 @@ +package org.arkecosystem.crypto.enums; + +public enum ContractAddresses { + CONSENSUS("0x535B3D7A252fa034Ed71F0C53ec0C6F784cB64E1"), + MULTIPAYMENT("0x00EFd0D4639191C49908A7BddbB9A11A994A8527"), + USERNAMES("0x2c1DE3b4Dbb4aDebEbB5dcECAe825bE2a9fc6eb6"); + + private final String address; + + ContractAddresses(String address) { + this.address = address; + } + + public String address() { + return address; + } +} diff --git a/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java b/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java index fedfa1e..bb9c926 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java @@ -84,11 +84,16 @@ public AbstractTransaction deserialize() { private AbstractTransaction guessTransactionFromTransactionData( AbstractTransaction transactionData) { + String payload = transactionData.data != null ? transactionData.data : ""; + + if (TransactionTypeIdentifier.isMultiPayment(payload)) { + return new Multipayment(transactionData.toHashMap()); + } + if (!"0".equals(transactionData.value) && !"".equals(transactionData.value)) { return new Transfer(); } - String payload = transactionData.data != null ? transactionData.data : ""; if (payload.isEmpty()) { return new EvmCall(); } diff --git a/src/main/java/org/arkecosystem/crypto/transactions/builder/MultipaymentBuilder.java b/src/main/java/org/arkecosystem/crypto/transactions/builder/MultipaymentBuilder.java new file mode 100644 index 0000000..65c85dc --- /dev/null +++ b/src/main/java/org/arkecosystem/crypto/transactions/builder/MultipaymentBuilder.java @@ -0,0 +1,39 @@ +package org.arkecosystem.crypto.transactions.builder; + +import java.math.BigInteger; +import org.arkecosystem.crypto.enums.ContractAddresses; +import org.arkecosystem.crypto.transactions.types.AbstractTransaction; +import org.arkecosystem.crypto.transactions.types.Multipayment; + +public class MultipaymentBuilder extends AbstractTransactionBuilder { + + public MultipaymentBuilder() { + super(); + this.transaction.recipientAddress = ContractAddresses.MULTIPAYMENT.address(); + } + + public MultipaymentBuilder pay(String address, BigInteger amount) { + this.transaction.multipaymentRecipients.add(address); + this.transaction.multipaymentAmounts.add(amount); + + BigInteger currentValue = + this.transaction.value == null || this.transaction.value.isEmpty() + ? BigInteger.ZERO + : new BigInteger(this.transaction.value); + this.transaction.value = currentValue.add(amount).toString(); + + this.transaction.refreshPayloadData(); + + return this.instance(); + } + + @Override + protected AbstractTransaction getTransactionInstance() { + return new Multipayment(); + } + + @Override + protected MultipaymentBuilder instance() { + return this; + } +} diff --git a/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java b/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java index e01d4e1..8505290 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java @@ -30,6 +30,8 @@ public abstract class AbstractTransaction { public long gasPrice; public String validatorPublicKey; public String vote; + public List multipaymentRecipients; + public List multipaymentAmounts; public AbstractTransaction() {} diff --git a/src/main/java/org/arkecosystem/crypto/transactions/types/Multipayment.java b/src/main/java/org/arkecosystem/crypto/transactions/types/Multipayment.java new file mode 100644 index 0000000..0966dad --- /dev/null +++ b/src/main/java/org/arkecosystem/crypto/transactions/types/Multipayment.java @@ -0,0 +1,67 @@ +package org.arkecosystem.crypto.transactions.types; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.arkecosystem.crypto.enums.AbiFunction; +import org.arkecosystem.crypto.enums.ContractAbiType; +import org.arkecosystem.crypto.utils.AbiDecoder; +import org.arkecosystem.crypto.utils.AbiEncoder; + +public class Multipayment extends AbstractTransaction { + + public Multipayment() { + super(); + this.multipaymentRecipients = new ArrayList<>(); + this.multipaymentAmounts = new ArrayList<>(); + } + + public Multipayment(Map data) { + super(data); + this.multipaymentRecipients = new ArrayList<>(); + this.multipaymentAmounts = new ArrayList<>(); + + List payload = decodeMultipaymentPayload(data); + if (payload != null && payload.size() == 2) { + List recipients = (List) payload.get(0); + List amounts = (List) payload.get(1); + this.multipaymentRecipients.addAll(recipients); + for (String amount : amounts) { + this.multipaymentAmounts.add(new BigInteger(amount)); + } + } + } + + @Override + public String getPayload() { + if (this.multipaymentRecipients == null || this.multipaymentRecipients.isEmpty()) { + return ""; + } + + List args = Arrays.asList(this.multipaymentRecipients, this.multipaymentAmounts); + + try { + return new AbiEncoder(ContractAbiType.MULTIPAYMENT) + .encodeFunctionCall(AbiFunction.MULTIPAYMENT.toString(), args); + } catch (Exception e) { + throw new RuntimeException("Error encoding multipayment call", e); + } + } + + private static List decodeMultipaymentPayload(Map data) { + if (data == null || !data.containsKey("data")) return null; + + String payload = (String) data.get("data"); + if (payload == null || payload.isEmpty()) return null; + + try { + AbiDecoder decoder = new AbiDecoder(ContractAbiType.MULTIPAYMENT); + Map decoded = decoder.decodeFunctionData(payload); + return (List) decoded.get("args"); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/org/arkecosystem/crypto/utils/AbiDecoder.java b/src/main/java/org/arkecosystem/crypto/utils/AbiDecoder.java index 2f2d503..880b913 100644 --- a/src/main/java/org/arkecosystem/crypto/utils/AbiDecoder.java +++ b/src/main/java/org/arkecosystem/crypto/utils/AbiDecoder.java @@ -209,8 +209,7 @@ public static Object[] decodeArray( int arrayLength; int cursor; if (length == null) { - int dataOffset = readUInt(bytes, offset).intValue(); - int arrayOffset = offset + dataOffset; + int arrayOffset = readUInt(bytes, offset).intValue(); arrayLength = readUInt(bytes, arrayOffset).intValue(); cursor = arrayOffset + 32; } else { diff --git a/src/test/java/org/arkecosystem/crypto/transactions/builder/MultipaymentBuilderTest.java b/src/test/java/org/arkecosystem/crypto/transactions/builder/MultipaymentBuilderTest.java new file mode 100644 index 0000000..b7e46c3 --- /dev/null +++ b/src/test/java/org/arkecosystem/crypto/transactions/builder/MultipaymentBuilderTest.java @@ -0,0 +1,101 @@ +package org.arkecosystem.crypto.transactions.builder; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigInteger; +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.Multipayment; +import org.junit.jupiter.api.Test; + +public class MultipaymentBuilderTest extends AbstractTest { + + private static final String PAY_SELECTOR = "0x084ce708"; + private static final String RECIPIENT_A = "0xb693449AdDa7EFc015D87944EAE8b7C37EB1690A"; + private static final String RECIPIENT_B = "0x512F366D524157BcF734546eB29a6d687B762255"; + + @Test + public void it_should_default_to_the_multipayment_well_known_contract() { + MultipaymentBuilder builder = new MultipaymentBuilder(); + + assertEquals( + ContractAddresses.MULTIPAYMENT.address(), builder.transaction.recipientAddress); + } + + @Test + public void it_should_aggregate_payment_value_and_encode_pay_selector() { + MultipaymentBuilder builder = + new MultipaymentBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(200_000) + .nonce(1L) + .pay(RECIPIENT_A, new BigInteger("100000000")) + .pay(RECIPIENT_B, new BigInteger("200000000")); + + assertEquals("300000000", builder.transaction.value); + assertTrue( + builder.transaction.data.startsWith(PAY_SELECTOR), + "expected payload to start with pay selector but was " + builder.transaction.data); + assertEquals(2, builder.transaction.multipaymentRecipients.size()); + assertEquals(2, builder.transaction.multipaymentAmounts.size()); + } + + @Test + public void it_should_sign_and_verify_a_multipayment_transaction() { + MultipaymentBuilder builder = + new MultipaymentBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(200_000) + .nonce(1L) + .pay(RECIPIENT_A, new BigInteger("100000000")) + .pay(RECIPIENT_B, new BigInteger("200000000")) + .sign(this.passphrase); + + assertNotNull(builder.transaction.signature); + assertNotNull(builder.transaction.id); + assertTrue(builder.verify()); + } + + @Test + public void it_should_round_trip_through_serialization() throws Exception { + MultipaymentBuilder builder = + new MultipaymentBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(200_000) + .nonce(7L) + .pay(RECIPIENT_A, new BigInteger("100000000")) + .pay(RECIPIENT_B, new BigInteger("200000000")) + .sign(this.passphrase); + + String serialized = Hex.encode(builder.transaction.serialize()); + AbstractTransaction restored = Deserializer.newDeserializer(serialized).deserialize(); + + assertInstanceOf(Multipayment.class, restored); + Multipayment multipayment = (Multipayment) restored; + assertEquals(builder.transaction.id, multipayment.id); + assertEquals(builder.transaction.value, multipayment.value); + assertEquals( + builder.transaction.recipientAddress.toLowerCase(), + multipayment.recipientAddress.toLowerCase()); + assertEquals(2, multipayment.multipaymentRecipients.size()); + assertEquals( + RECIPIENT_A.toLowerCase(), + multipayment.multipaymentRecipients.get(0).toLowerCase()); + assertEquals( + RECIPIENT_B.toLowerCase(), + multipayment.multipaymentRecipients.get(1).toLowerCase()); + assertEquals(new BigInteger("100000000"), multipayment.multipaymentAmounts.get(0)); + assertEquals(new BigInteger("200000000"), multipayment.multipaymentAmounts.get(1)); + } + + @Test + public void it_should_produce_empty_payload_when_no_payments_added() { + MultipaymentBuilder builder = new MultipaymentBuilder(); + + assertEquals("", builder.transaction.data); + assertEquals("0", builder.transaction.value); + } +}