diff --git a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java index bfc38ba..cd23b82 100644 --- a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java +++ b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java @@ -5,6 +5,8 @@ public enum AbiFunction { UNVOTE("unvote"), VALIDATOR_REGISTRATION("registerValidator"), VALIDATOR_RESIGNATION("resignValidator"), + USERNAME_REGISTRATION("registerUsername"), + USERNAME_RESIGNATION("resignUsername"), MULTIPAYMENT("pay"), TRANSFER("transfer"), APPROVE("approve"); diff --git a/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java b/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java index bb9c926..d3c8c87 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java @@ -106,6 +106,8 @@ private AbstractTransaction guessTransactionFromTransactionData( return new ValidatorRegistration(transactionData.toHashMap()); } else if (TransactionTypeIdentifier.isValidatorResignation(payload)) { return new ValidatorResignation(transactionData.toHashMap()); + } else if (TransactionTypeIdentifier.isUsernameRegistration(payload)) { + return new UsernameRegistration(transactionData.toHashMap()); } return new EvmCall(); diff --git a/src/main/java/org/arkecosystem/crypto/transactions/builder/UsernameRegistrationBuilder.java b/src/main/java/org/arkecosystem/crypto/transactions/builder/UsernameRegistrationBuilder.java new file mode 100644 index 0000000..42f6048 --- /dev/null +++ b/src/main/java/org/arkecosystem/crypto/transactions/builder/UsernameRegistrationBuilder.java @@ -0,0 +1,55 @@ +package org.arkecosystem.crypto.transactions.builder; + +import org.arkecosystem.crypto.enums.ContractAddresses; +import org.arkecosystem.crypto.transactions.types.AbstractTransaction; +import org.arkecosystem.crypto.transactions.types.UsernameRegistration; + +public class UsernameRegistrationBuilder + extends AbstractTransactionBuilder { + + public UsernameRegistrationBuilder() { + super(); + this.transaction.recipientAddress = ContractAddresses.USERNAMES.address(); + } + + public UsernameRegistrationBuilder username(String username) { + validateUsername(username); + + this.transaction.username = username; + this.transaction.refreshPayloadData(); + + return this.instance(); + } + + private static void validateUsername(String username) { + if (username == null || username.isEmpty() || username.length() > 20) { + throw new IllegalArgumentException( + "Username must be between 1 and 20 characters long. Got " + + (username == null ? 0 : username.length()) + + " characters."); + } + + if (!username.matches("^[a-z0-9_]+$")) { + throw new IllegalArgumentException( + "Username can only contain lowercase letters, numbers and underscores."); + } + + if (username.startsWith("_") || username.endsWith("_")) { + throw new IllegalArgumentException("Username cannot start or end with an underscore."); + } + + if (username.contains("__")) { + throw new IllegalArgumentException("Username cannot contain consecutive underscores."); + } + } + + @Override + protected AbstractTransaction getTransactionInstance() { + return new UsernameRegistration(); + } + + @Override + protected UsernameRegistrationBuilder 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 8505290..92d4c80 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java @@ -32,6 +32,7 @@ public abstract class AbstractTransaction { public String vote; public List multipaymentRecipients; public List multipaymentAmounts; + public String username; public AbstractTransaction() {} diff --git a/src/main/java/org/arkecosystem/crypto/transactions/types/UsernameRegistration.java b/src/main/java/org/arkecosystem/crypto/transactions/types/UsernameRegistration.java new file mode 100644 index 0000000..4021a39 --- /dev/null +++ b/src/main/java/org/arkecosystem/crypto/transactions/types/UsernameRegistration.java @@ -0,0 +1,56 @@ +package org.arkecosystem.crypto.transactions.types; + +import java.util.Collections; +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 UsernameRegistration extends AbstractTransaction { + + public UsernameRegistration() { + super(); + } + + public UsernameRegistration(Map data) { + super(data); + + List payload = decodeUsernamePayload(data); + if (payload != null && !payload.isEmpty()) { + this.username = payload.get(0).toString(); + } + } + + @Override + public String getPayload() { + if (this.username == null || this.username.isEmpty()) { + return ""; + } + + try { + return new AbiEncoder(ContractAbiType.USERNAMES) + .encodeFunctionCall( + AbiFunction.USERNAME_REGISTRATION.toString(), + Collections.singletonList(this.username)); + } catch (Exception e) { + throw new RuntimeException("Error encoding username registration call", e); + } + } + + private static List decodeUsernamePayload(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.USERNAMES); + Map decoded = decoder.decodeFunctionData(payload); + return (List) decoded.get("args"); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/test/java/org/arkecosystem/crypto/transactions/builder/UsernameRegistrationBuilderTest.java b/src/test/java/org/arkecosystem/crypto/transactions/builder/UsernameRegistrationBuilderTest.java new file mode 100644 index 0000000..82f1308 --- /dev/null +++ b/src/test/java/org/arkecosystem/crypto/transactions/builder/UsernameRegistrationBuilderTest.java @@ -0,0 +1,132 @@ +package org.arkecosystem.crypto.transactions.builder; + +import static org.junit.jupiter.api.Assertions.*; + +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.UsernameRegistration; +import org.junit.jupiter.api.Test; + +public class UsernameRegistrationBuilderTest extends AbstractTest { + + private static final String REGISTER_SELECTOR = "0x36a94134"; + + @Test + public void it_should_default_to_the_usernames_well_known_contract() { + UsernameRegistrationBuilder builder = new UsernameRegistrationBuilder(); + + assertEquals(ContractAddresses.USERNAMES.address(), builder.transaction.recipientAddress); + } + + @Test + public void it_should_encode_register_username_selector_and_argument() { + UsernameRegistrationBuilder builder = + new UsernameRegistrationBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(200_000) + .nonce(1L) + .username("alice"); + + assertEquals("alice", builder.transaction.username); + assertTrue( + builder.transaction.data.startsWith(REGISTER_SELECTOR), + "expected payload to start with register selector but was " + + builder.transaction.data); + } + + @Test + public void it_should_sign_and_verify_a_username_registration_transaction() { + UsernameRegistrationBuilder builder = + new UsernameRegistrationBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(200_000) + .nonce(1L) + .username("alice") + .sign(this.passphrase); + + assertNotNull(builder.transaction.signature); + assertNotNull(builder.transaction.id); + assertTrue(builder.verify()); + } + + @Test + public void it_should_round_trip_through_serialization() { + UsernameRegistrationBuilder builder = + new UsernameRegistrationBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(200_000) + .nonce(7L) + .username("bob_42") + .sign(this.passphrase); + + String serialized = Hex.encode(builder.transaction.serialize()); + AbstractTransaction restored = Deserializer.newDeserializer(serialized).deserialize(); + + assertInstanceOf(UsernameRegistration.class, restored); + UsernameRegistration registration = (UsernameRegistration) restored; + assertEquals(builder.transaction.id, registration.id); + assertEquals("bob_42", registration.username); + assertEquals( + builder.transaction.recipientAddress.toLowerCase(), + registration.recipientAddress.toLowerCase()); + } + + @Test + public void it_should_produce_empty_payload_when_no_username_is_set() { + UsernameRegistrationBuilder builder = new UsernameRegistrationBuilder(); + + assertEquals("", builder.transaction.data); + } + + @Test + public void it_should_reject_empty_username() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> new UsernameRegistrationBuilder().username("")); + assertTrue(thrown.getMessage().contains("between 1 and 20")); + } + + @Test + public void it_should_reject_username_longer_than_20_characters() { + assertThrows( + IllegalArgumentException.class, + () -> new UsernameRegistrationBuilder().username("a_very_long_username_xx")); + } + + @Test + public void it_should_reject_uppercase_letters() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> new UsernameRegistrationBuilder().username("Alice")); + assertTrue(thrown.getMessage().contains("lowercase")); + } + + @Test + public void it_should_reject_special_characters() { + assertThrows( + IllegalArgumentException.class, + () -> new UsernameRegistrationBuilder().username("alice!")); + } + + @Test + public void it_should_reject_leading_or_trailing_underscore() { + assertThrows( + IllegalArgumentException.class, + () -> new UsernameRegistrationBuilder().username("_alice")); + assertThrows( + IllegalArgumentException.class, + () -> new UsernameRegistrationBuilder().username("alice_")); + } + + @Test + public void it_should_reject_consecutive_underscores() { + assertThrows( + IllegalArgumentException.class, + () -> new UsernameRegistrationBuilder().username("al__ice")); + } +}