diff --git a/build.gradle b/build.gradle index aca5536d..27b5a06a 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,7 @@ allprojects { repositories { mavenLocal() mavenCentral() + maven { url 'https://jitpack.io' } } } diff --git a/crypto/build.gradle b/crypto/build.gradle index f17134d2..70f01dc7 100644 --- a/crypto/build.gradle +++ b/crypto/build.gradle @@ -1,4 +1,6 @@ dependencies { + implementation 'com.github.multiformats:java-multibase:1.1.0' + implementation 'org.bouncycastle:bcprov-jdk15on:1.69' implementation project(":common") implementation project(":types") } diff --git a/crypto/src/main/java/com/strategyobject/substrateclient/crypto/ss58/AddressWithPrefix.java b/crypto/src/main/java/com/strategyobject/substrateclient/crypto/ss58/AddressWithPrefix.java new file mode 100644 index 00000000..47b23c1d --- /dev/null +++ b/crypto/src/main/java/com/strategyobject/substrateclient/crypto/ss58/AddressWithPrefix.java @@ -0,0 +1,14 @@ +package com.strategyobject.substrateclient.crypto.ss58; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(staticName = "from") +@EqualsAndHashCode +@Getter +public class AddressWithPrefix { + private final byte @NonNull [] address; + private final short prefix; +} diff --git a/crypto/src/main/java/com/strategyobject/substrateclient/crypto/ss58/SS58Codec.java b/crypto/src/main/java/com/strategyobject/substrateclient/crypto/ss58/SS58Codec.java new file mode 100644 index 00000000..82dad5e2 --- /dev/null +++ b/crypto/src/main/java/com/strategyobject/substrateclient/crypto/ss58/SS58Codec.java @@ -0,0 +1,93 @@ +package com.strategyobject.substrateclient.crypto.ss58; + +import com.google.common.base.Preconditions; +import io.ipfs.multibase.Base58; +import lombok.NonNull; +import lombok.val; +import org.bouncycastle.jcajce.provider.digest.Blake2b; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +public final class SS58Codec { + private static final byte[] PREFIX = "SS58PRE".getBytes(StandardCharsets.US_ASCII); + private static final int CHECKSUM_LEN = 2; + private static final int ADDRESS_LENGTH = 32; + + private SS58Codec() { + } + + public static AddressWithPrefix decode(@NonNull String encoded) { + Preconditions.checkArgument(encoded.length() > 0); + + val data = Base58.decode(encoded); + if (data.length < 2) { + throw new IllegalArgumentException("Incorrect length of data."); + } + + int typeLen; + short prefix; + + if (data[0] >= 0 && data[0] <= 63) { + typeLen = 1; + prefix = data[0]; + } else if (data[0] >= 64) { + byte lower = (byte) (Byte.toUnsignedInt((byte) (data[0] << 2)) | Byte.toUnsignedInt((byte) (data[1] >> 6))); + byte upper = (byte) (data[1] & 0b00111111); + + typeLen = 2; + prefix = (short) (Byte.toUnsignedInt(lower) | (upper << 8)); + } else { + throw new IllegalArgumentException("Unknown version."); + } + + if (data.length != typeLen + ADDRESS_LENGTH + CHECKSUM_LEN) { + throw new IllegalArgumentException("Incorrect length of data."); + } + + val checkSumData = new byte[PREFIX.length + typeLen + ADDRESS_LENGTH]; + System.arraycopy(PREFIX, 0, checkSumData, 0, PREFIX.length); + System.arraycopy(data, 0, checkSumData, PREFIX.length, typeLen + ADDRESS_LENGTH); + val checksum = new Blake2b.Blake2b512().digest(checkSumData); + if (checksum[0] != data[data.length - CHECKSUM_LEN] || checksum[1] != data[data.length - CHECKSUM_LEN + 1]) { + throw new IllegalArgumentException("Incorrect checksum."); + } + + return AddressWithPrefix.from(Arrays.copyOfRange(data, typeLen, typeLen + ADDRESS_LENGTH), prefix); + } + + public static String encode(byte @NonNull [] address, short prefix) { + Preconditions.checkArgument(address.length == ADDRESS_LENGTH, + "The length of address must be 32, but was: " + address.length); + + val ident = prefix & 0b0011_1111_1111_1111; + Preconditions.checkArgument(ident == prefix, + "The prefix size is restricted by 14 bits."); + + byte[] data; + int typeLen; + + if (ident <= 63) { + typeLen = 1; + data = new byte[PREFIX.length + typeLen + address.length]; + data[PREFIX.length] = (byte) ident; + } else { + typeLen = 2; + data = new byte[PREFIX.length + typeLen + address.length]; + val first = (ident & 0b0000_0000_1111_1100) >> 2; + val second = (ident >> 8) | (ident & 0b0000_0000_0000_0011) << 6; + data[PREFIX.length] = (byte) (first | 0b01000000); + data[PREFIX.length + 1] = (byte) second; + } + + System.arraycopy(PREFIX, 0, data, 0, PREFIX.length); + System.arraycopy(address, 0, data, PREFIX.length + typeLen, address.length); + + val checksum = new Blake2b.Blake2b512().digest(data); + val encodable = new byte[typeLen + address.length + CHECKSUM_LEN]; + System.arraycopy(data, PREFIX.length, encodable, 0, typeLen + address.length); + System.arraycopy(checksum, 0, encodable, typeLen + address.length, CHECKSUM_LEN); + + return Base58.encode(encodable); + } +} diff --git a/crypto/src/test/java/com/strategyobject/substrateclient/crypto/ss58/SS58CodecTests.java b/crypto/src/test/java/com/strategyobject/substrateclient/crypto/ss58/SS58CodecTests.java new file mode 100644 index 00000000..955ea8a5 --- /dev/null +++ b/crypto/src/test/java/com/strategyobject/substrateclient/crypto/ss58/SS58CodecTests.java @@ -0,0 +1,66 @@ +package com.strategyobject.substrateclient.crypto.ss58; + +import com.strategyobject.substrateclient.common.utils.HexConverter; +import lombok.val; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SS58CodecTests { + @ParameterizedTest + @CsvSource(value = { + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:42", + "cEaNSpz4PxFcZ7nT1VEKrKewH67rfx6MfcM6yKojyyPz7qaqp:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:64", + "yGHXkYLYqxijLKKfd9Q2CB9shRVu8rPNBS53wvwGTutYg4zTg:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:255", + "VByeGLMtP8r8BYQpNX1sb2VtAW8GYCbtFAeXJwsA2ur3MNRdq:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:256", + "yNa8JpqfFB3q8A29rCwSgxvdU94ufJw2yKKxDgznS5m1PoFvn:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:16383", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty:0x8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48:42" + }, + delimiterString = ":") + void encode(String expected, String hex, short prefix) { + val actual = SS58Codec.encode(HexConverter.toBytes(hex), prefix); + + assertEquals(expected, actual); + } + + @ParameterizedTest + @CsvSource(value = { + "0xd4:42", + "0x:42", + "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:16384" + }, + delimiterString = ":") + void encodeThrows(String hex, short prefix) { + assertThrows(IllegalArgumentException.class, () -> SS58Codec.encode(HexConverter.toBytes(hex), prefix)); + } + + @ParameterizedTest + @CsvSource(value = { + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:42", + "cEaNSpz4PxFcZ7nT1VEKrKewH67rfx6MfcM6yKojyyPz7qaqp:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:64", + "yGHXkYLYqxijLKKfd9Q2CB9shRVu8rPNBS53wvwGTutYg4zTg:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:255", + "VByeGLMtP8r8BYQpNX1sb2VtAW8GYCbtFAeXJwsA2ur3MNRdq:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:256", + "yNa8JpqfFB3q8A29rCwSgxvdU94ufJw2yKKxDgznS5m1PoFvn:0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d:16383", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty:0x8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48:42" + }, + delimiterString = ":") + void decode(String source, String hex, short prefix) { + val actual = SS58Codec.decode(source); + + val expected = AddressWithPrefix.from(HexConverter.toBytes(hex), prefix); + assertEquals(expected, actual); + } + + @ParameterizedTest + @CsvSource(value = { + "x", // incorrect length (too short) + "yA3vprfzKUKan9P1eXE6iMGCMSMDZEnAtb6wEjTEf86eMi", // incorrect length (last byte is lost) + "SXYSytZ7wxpQHbRo5FzUFA9wjTfWvTQgYzhVEybWRQvBrhwW", // unknown version (first byte is out of range) + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutL8" // incorrect checksum (second to last byte is different) + }) + void decodeThrows(String encoded) { + assertThrows(IllegalArgumentException.class, () -> SS58Codec.decode(encoded)); + } +} diff --git a/rpc/rpc-core/src/main/java/com/strategyobject/substrateclient/rpc/core/MetadataRegistry.java b/rpc/rpc-core/src/main/java/com/strategyobject/substrateclient/rpc/core/MetadataRegistry.java new file mode 100644 index 00000000..006a8e58 --- /dev/null +++ b/rpc/rpc-core/src/main/java/com/strategyobject/substrateclient/rpc/core/MetadataRegistry.java @@ -0,0 +1,33 @@ +package com.strategyobject.substrateclient.rpc.core; + +import java.util.concurrent.atomic.AtomicReference; + +public final class MetadataRegistry { + private static final short DEFAULT_SS58_VERSION = 42; + private static volatile MetadataRegistry instance; + private final AtomicReference ss58AddressFormat = new AtomicReference<>(DEFAULT_SS58_VERSION); // TODO it should be read from Metadata + + private MetadataRegistry() { + } + + public static MetadataRegistry getInstance() { + if (instance == null) { + synchronized (MetadataRegistry.class) { + if (instance == null) { + instance = new MetadataRegistry(); + } + } + } + + return instance; + } + + public short getSS58AddressFormat() { + return ss58AddressFormat.get(); + } + + // TODO it's a workaround to make possible changing network format until metadata is processed + public void setSS58AddressFormat(short value) { + ss58AddressFormat.getAndSet(value); + } +} diff --git a/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/StateTests.java b/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/StateTests.java index 22a52e28..f8539bff 100644 --- a/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/StateTests.java +++ b/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/StateTests.java @@ -59,7 +59,7 @@ void getMetadata() throws ExecutionException, InterruptedException, TimeoutExcep State rpcSection = sectionFactory.create(State.class, wsProvider); assertDoesNotThrow(() -> { - rpcSection.getMetadata().get(WAIT_TIMEOUT, TimeUnit.SECONDS); + rpcSection.getMetadata().get(WAIT_TIMEOUT * 3, TimeUnit.SECONDS); }); } } diff --git a/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/SystemTests.java b/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/SystemTests.java index 3d0945d9..c3dcb610 100644 --- a/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/SystemTests.java +++ b/rpc/rpc-sections/src/test/java/com/strategyobject/substrateclient/rpc/sections/SystemTests.java @@ -1,8 +1,8 @@ package com.strategyobject.substrateclient.rpc.sections; +import com.strategyobject.substrateclient.common.utils.HexConverter; import com.strategyobject.substrateclient.rpc.codegen.sections.RpcGeneratedSectionFactory; import com.strategyobject.substrateclient.rpc.codegen.sections.RpcInterfaceInitializationException; -import com.strategyobject.substrateclient.rpc.core.registries.RpcEncoderRegistry; import com.strategyobject.substrateclient.rpc.types.AccountId; import com.strategyobject.substrateclient.tests.containers.SubstrateVersion; import com.strategyobject.substrateclient.tests.containers.TestSubstrateContainer; @@ -18,7 +18,6 @@ import java.util.concurrent.TimeoutException; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.*; @Testcontainers public class SystemTests { @@ -26,34 +25,21 @@ public class SystemTests { private static final Network network = Network.newNetwork(); @Container - static final TestSubstrateContainer substrate = new TestSubstrateContainer(SubstrateVersion.V3_0_0) - .withNetwork(network); + static final TestSubstrateContainer substrate = new TestSubstrateContainer(SubstrateVersion.V3_0_0).withNetwork(network); @Test void accountNextIndex() throws ExecutionException, InterruptedException, TimeoutException, RpcInterfaceInitializationException { - try (WsProvider wsProvider = WsProvider.builder() - .setEndpoint(substrate.getWsAddress()) - .disableAutoConnect() - .build()) { + try (WsProvider wsProvider = WsProvider.builder().setEndpoint(substrate.getWsAddress()).disableAutoConnect().build()) { wsProvider.connect().get(WAIT_TIMEOUT, TimeUnit.SECONDS); val sectionFactory = new RpcGeneratedSectionFactory(); System rpcSection = sectionFactory.create(System.class, wsProvider); - // TO DO use registered converter - RpcEncoderRegistry encoderRegistry = mock(RpcEncoderRegistry.class); - when(encoderRegistry.resolve(AccountId.class)) - .thenReturn((source, encoders) -> "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"); - try (val utils = mockStatic(RpcEncoderRegistry.class)) { - utils.when(RpcEncoderRegistry::getInstance) - .thenReturn(encoderRegistry); - val result = rpcSection.accountNextIndex(AccountId.fromBytes( - new byte[]{ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 - })).get(WAIT_TIMEOUT, TimeUnit.SECONDS); - - assertEquals(0, result); - } + val alicePublicKey = HexConverter.toBytes("0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"); + val result = rpcSection.accountNextIndex(AccountId.fromBytes(alicePublicKey)) + .get(WAIT_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(0, result); } } } diff --git a/rpc/rpc-types/build.gradle b/rpc/rpc-types/build.gradle index de5df6ad..1ccc775c 100644 --- a/rpc/rpc-types/build.gradle +++ b/rpc/rpc-types/build.gradle @@ -3,6 +3,7 @@ dependencies { implementation project(':scale') implementation project(':rpc:rpc-core') implementation project(':common') + implementation project(':crypto') annotationProcessor project(':rpc:rpc-codegen') annotationProcessor project(':scale:scale-codegen') } \ No newline at end of file diff --git a/rpc/rpc-types/src/main/java/com/strategyobject/substrateclient/rpc/types/AccountIdEncoder.java b/rpc/rpc-types/src/main/java/com/strategyobject/substrateclient/rpc/types/AccountIdEncoder.java new file mode 100644 index 00000000..cd754d79 --- /dev/null +++ b/rpc/rpc-types/src/main/java/com/strategyobject/substrateclient/rpc/types/AccountIdEncoder.java @@ -0,0 +1,18 @@ +package com.strategyobject.substrateclient.rpc.types; + +import com.google.common.base.Preconditions; +import com.strategyobject.substrateclient.crypto.ss58.SS58Codec; +import com.strategyobject.substrateclient.rpc.core.EncoderPair; +import com.strategyobject.substrateclient.rpc.core.MetadataRegistry; +import com.strategyobject.substrateclient.rpc.core.RpcEncoder; +import com.strategyobject.substrateclient.rpc.core.annotations.AutoRegister; + +@AutoRegister(types = AccountId.class) +public class AccountIdEncoder implements RpcEncoder { + @Override + public Object encode(AccountId source, EncoderPair... encoders) { + Preconditions.checkArgument(encoders == null || encoders.length == 0); + + return SS58Codec.encode(source.getData(), MetadataRegistry.getInstance().getSS58AddressFormat()); + } +}