From a8cdba7464d9dc8c4f94f6b1035905eb8d45bcf6 Mon Sep 17 00:00:00 2001 From: Alfonso Bribiesca Date: Mon, 4 May 2026 06:35:46 -0600 Subject: [PATCH] feat: add missing crypto sdk methods --- .../crypto/identities/Address.java | 4 ++ .../crypto/identities/PrivateKey.java | 21 ++++++++ .../crypto/identities/PublicKey.java | 8 ++++ .../builder/AbstractTransactionBuilder.java | 7 +++ .../types/AbstractTransaction.java | 21 ++++++-- .../crypto/identities/AddressTest.java | 33 +++++++++++++ .../crypto/identities/PrivateKeyTest.java | 48 +++++++++++++++++++ .../crypto/identities/PublicKeyTest.java | 25 +++++++--- .../builder/TransferBuilderTest.java | 33 +++++++++++++ 9 files changed, 188 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/arkecosystem/crypto/identities/Address.java b/src/main/java/org/arkecosystem/crypto/identities/Address.java index bc9de785..1cafe8b7 100644 --- a/src/main/java/org/arkecosystem/crypto/identities/Address.java +++ b/src/main/java/org/arkecosystem/crypto/identities/Address.java @@ -40,4 +40,8 @@ public static String fromPrivateKey(ECKey privateKey) { byte[] publicKeyBytes = privateKey.getPubKey(); return fromPublicKey(Hex.encode(publicKeyBytes)); } + + public static boolean validate(String address) { + return address != null && address.matches("^0x[a-fA-F0-9]{40}$"); + } } diff --git a/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java b/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java index 9e4ea627..3e14fe39 100644 --- a/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java +++ b/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java @@ -1,5 +1,8 @@ package org.arkecosystem.crypto.identities; +import java.util.Arrays; +import org.arkecosystem.crypto.configuration.Network; +import org.arkecosystem.crypto.encoding.Base58; import org.arkecosystem.crypto.encoding.Hex; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.Sha256Hash; @@ -14,4 +17,22 @@ public static ECKey fromPassphrase(String passphrase) { public static ECKey fromHex(String privateKey) { return ECKey.fromPrivate(Hex.decode(privateKey), true); } + + public static ECKey fromWif(String wif) { + byte[] decoded = Base58.decodeChecked(wif); + + if (decoded.length < 33) { + throw new IllegalArgumentException("Invalid WIF: payload too short."); + } + + int expectedVersion = Network.get().wif() & 0xff; + if ((decoded[0] & 0xff) != expectedVersion) { + throw new IllegalArgumentException( + "Invalid WIF: version byte does not match the active network."); + } + + byte[] privateKeyBytes = Arrays.copyOfRange(decoded, 1, 33); + + return ECKey.fromPrivate(privateKeyBytes, true); + } } diff --git a/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java b/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java index 22c884d8..dd87088a 100644 --- a/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java +++ b/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java @@ -1,7 +1,15 @@ package org.arkecosystem.crypto.identities; +import org.arkecosystem.crypto.encoding.Hex; +import org.bitcoinj.core.ECKey; + public class PublicKey { public static String fromPassphrase(String passphrase) { return PrivateKey.fromPassphrase(passphrase).getPublicKeyAsHex(); } + + public static ECKey fromHex(String publicKey) { + ECKey key = ECKey.fromPublicOnly(Hex.decode(publicKey)); + return ECKey.fromPublicOnly(key.getPubKeyPoint().getEncoded(true)); + } } diff --git a/src/main/java/org/arkecosystem/crypto/transactions/builder/AbstractTransactionBuilder.java b/src/main/java/org/arkecosystem/crypto/transactions/builder/AbstractTransactionBuilder.java index a891f1ba..8d2076b5 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/builder/AbstractTransactionBuilder.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/builder/AbstractTransactionBuilder.java @@ -55,6 +55,13 @@ public TBuilder sign(String passphrase) { return this.instance(); } + public TBuilder legacySecondSign(String passphrase, String secondPassphrase) { + this.transaction.sign(passphrase); + this.transaction.legacySecondSign(secondPassphrase); + this.transaction.computeId(); + return this.instance(); + } + public boolean verify() { return this.transaction.verify(); } 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 92d4c800..7c7ded88 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java @@ -33,6 +33,7 @@ public abstract class AbstractTransaction { public List multipaymentRecipients; public List multipaymentAmounts; public String username; + public String legacySecondSignature; public AbstractTransaction() {} @@ -105,11 +106,23 @@ public byte[] hash(boolean skipSignature) { } public AbstractTransaction sign(String passphrase) { - byte[] hash = this.hash(true); - ECKey privateKey = PrivateKey.fromPassphrase(passphrase); this.senderPublicKey = privateKey.getPublicKeyAsHex(); + this.signature = signHash(this.hash(true), privateKey); + + return this; + } + + public AbstractTransaction legacySecondSign(String secondPassphrase) { + ECKey privateKey = PrivateKey.fromPassphrase(secondPassphrase); + + this.legacySecondSignature = signHash(this.hash(true), privateKey); + + return this; + } + + private static String signHash(byte[] hash, ECKey privateKey) { ECKey.ECDSASignature signature = privateKey.sign(Sha256Hash.wrap(hash)); int recId = -1; @@ -135,9 +148,7 @@ public AbstractTransaction sign(String passphrase) { System.arraycopy(signatureBytes, 0, signatureWithRecId, 0, 64); signatureWithRecId[64] = (byte) recId; - this.signature = Hex.encode(signatureWithRecId); - - return this; + return Hex.encode(signatureWithRecId); } private static byte[] bigIntegerToBytes(BigInteger b, int numBytes) { diff --git a/src/test/java/org/arkecosystem/crypto/identities/AddressTest.java b/src/test/java/org/arkecosystem/crypto/identities/AddressTest.java index 647d0149..b57579fe 100644 --- a/src/test/java/org/arkecosystem/crypto/identities/AddressTest.java +++ b/src/test/java/org/arkecosystem/crypto/identities/AddressTest.java @@ -1,6 +1,8 @@ package org.arkecosystem.crypto.identities; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.bitcoinj.core.ECKey; import org.junit.jupiter.api.Test; @@ -27,4 +29,35 @@ public void fromPrivateKey() { String actual = Address.fromPrivateKey(privateKey); assertEquals("0xb0FF9213f7226bBB72b84dE16af86e56f1f38B01", actual); } + + @Test + public void validate_accepts_valid_checksum_address() { + assertTrue(Address.validate("0xb0FF9213f7226bBB72b84dE16af86e56f1f38B01")); + } + + @Test + public void validate_accepts_lowercase_address() { + assertTrue(Address.validate("0xb0ff9213f7226bbb72b84de16af86e56f1f38b01")); + } + + @Test + public void validate_rejects_missing_prefix() { + assertFalse(Address.validate("b0FF9213f7226bBB72b84dE16af86e56f1f38B01")); + } + + @Test + public void validate_rejects_wrong_length() { + assertFalse(Address.validate("0xb0FF9213f7226bBB72b84dE16af86e56f1f38B0")); + assertFalse(Address.validate("0xb0FF9213f7226bBB72b84dE16af86e56f1f38B011")); + } + + @Test + public void validate_rejects_non_hex_characters() { + assertFalse(Address.validate("0xb0FF9213f7226bBB72b84dE16af86e56f1f38BZZ")); + } + + @Test + public void validate_rejects_null() { + assertFalse(Address.validate(null)); + } } diff --git a/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java b/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java index 9363c423..300262ac 100644 --- a/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java +++ b/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java @@ -1,11 +1,28 @@ package org.arkecosystem.crypto.identities; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import java.io.IOException; +import org.arkecosystem.crypto.configuration.Network; +import org.arkecosystem.crypto.networks.Devnet; +import org.arkecosystem.crypto.networks.Mainnet; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public class PrivateKeyTest { + @BeforeEach + void setUp() { + Network.set(new Devnet()); + } + + @AfterEach + void tearDown() { + Network.set(new Devnet()); + } + @Test public void fromPassphrase() { String actual = @@ -21,4 +38,35 @@ public void fromHex() { .getPrivateKeyAsHex(); assertEquals("d8839c2432bfd0a67ef10a804ba991eabba19f154a3d707917681d45822a5712", actual); } + + @Test + public void fromWif_round_trips_with_fromPassphrase() throws IOException { + String wif = WIF.fromPassphrase("this is a top secret passphrase"); + + String fromWif = PrivateKey.fromWif(wif).getPrivateKeyAsHex(); + String fromPassphrase = + PrivateKey.fromPassphrase("this is a top secret passphrase").getPrivateKeyAsHex(); + + assertEquals(fromPassphrase, fromWif); + } + + @Test + public void fromWif_rejects_when_version_byte_belongs_to_another_network() throws IOException { + String mainnetWif; + try { + Network.set(new Mainnet()); + mainnetWif = WIF.fromPassphrase("this is a top secret passphrase"); + } finally { + Network.set(new Devnet()); + } + + // Mainnet and Devnet share the same wif byte (170) so this branch only + // triggers when the prefix differs, e.g. against testnet (186). + Network.set(new org.arkecosystem.crypto.networks.Testnet()); + try { + assertThrows(IllegalArgumentException.class, () -> PrivateKey.fromWif(mainnetWif)); + } finally { + Network.set(new Devnet()); + } + } } diff --git a/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java b/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java index 274b48e3..57c156df 100644 --- a/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java +++ b/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java @@ -12,12 +12,23 @@ public void fromPassphrase() { } @Test - public void fromHex() { - String actual = - PrivateKey.fromHex( - "d8839c2432bfd0a67ef10a804ba991eabba19f154a3d707917681d45822a5712") - .getPrivateKeyAsHex(); - Assertions.assertEquals( - "d8839c2432bfd0a67ef10a804ba991eabba19f154a3d707917681d45822a5712", actual); + public void fromHex_round_trips_to_same_compressed_hex() { + String hex = "034151a3ec46b5670a682b0a63394f863587d1bc97483b1b6c70eb58e7f0aed192"; + + String actual = PublicKey.fromHex(hex).getPublicKeyAsHex(); + + Assertions.assertEquals(hex, actual); + } + + @Test + public void fromHex_accepts_uncompressed_public_key() { + String compressed = "034151a3ec46b5670a682b0a63394f863587d1bc97483b1b6c70eb58e7f0aed192"; + String uncompressed = + org.arkecosystem.crypto.encoding.Hex.encode( + PublicKey.fromHex(compressed).getPubKeyPoint().getEncoded(false)); + + String actual = PublicKey.fromHex(uncompressed).getPublicKeyAsHex(); + + Assertions.assertEquals(compressed, actual); } } diff --git a/src/test/java/org/arkecosystem/crypto/transactions/builder/TransferBuilderTest.java b/src/test/java/org/arkecosystem/crypto/transactions/builder/TransferBuilderTest.java index 1fa6abf6..1f18c094 100644 --- a/src/test/java/org/arkecosystem/crypto/transactions/builder/TransferBuilderTest.java +++ b/src/test/java/org/arkecosystem/crypto/transactions/builder/TransferBuilderTest.java @@ -32,4 +32,37 @@ public void it_should_sign_it_with_a_passphrase() throws Exception { assertEquals(data.get("id"), builder.transaction.getId()); assertTrue(builder.verify()); } + + @Test + public void it_should_attach_a_legacy_second_signature() { + TransferBuilder builder = + new TransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21000) + .nonce(1L) + .recipientAddress("0xb693449AdDa7EFc015D87944EAE8b7C37EB1690A") + .value("100000000") + .legacySecondSign(this.passphrase, "second secret passphrase"); + + assertNotNull(builder.transaction.signature); + assertNotNull(builder.transaction.legacySecondSignature); + assertEquals(130, builder.transaction.legacySecondSignature.length()); + assertNotEquals(builder.transaction.signature, builder.transaction.legacySecondSignature); + assertTrue(builder.verify()); + } + + @Test + public void legacy_second_signature_is_not_set_by_a_regular_sign() { + TransferBuilder builder = + new TransferBuilder() + .gasPrice(5_000_000_000L) + .gasLimit(21000) + .nonce(1L) + .recipientAddress("0xb693449AdDa7EFc015D87944EAE8b7C37EB1690A") + .value("100000000") + .sign(this.passphrase); + + assertNotNull(builder.transaction.signature); + assertNull(builder.transaction.legacySecondSignature); + } }