diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c54f834..8b46d1c 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -62,6 +62,8 @@ jobs: with: name: schnorrkel-lib path: polkaj-schnorrkel/build/rust/release + - name: Setup Rust for Android + run: ./cargo_ndk_prep.sh; touch local.properties - name: Check uses: eskatos/gradle-command-action@v1 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e9ae1b5..f477647 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,8 +19,9 @@ jobs: - uses: actions/checkout@v2 - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: + distribution: 'adopt' java-version: 11 - name: Check @@ -38,6 +39,24 @@ jobs: with: file: ./build/reports/jacoco/coverageReport/coverageReport.xml + # Run tests for java 8 targets + java8-test: + name: Java 8 Test + runs-on: "ubuntu-latest" + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK + uses: actions/setup-java@v2 + with: + distribution: 'adopt' + java-version: 8 + + - name: Check + uses: eskatos/gradle-command-action@v1 + with: + arguments: checkJava8 + # Make sure it works with all standard JVMs on main OSes platform-test: name: Java ${{ matrix.java }} on ${{ matrix.os }} @@ -51,8 +70,9 @@ jobs: - uses: actions/checkout@v2 - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: + distribution: 'adopt' java-version: ${{ matrix.java }} - name: Check @@ -62,6 +82,33 @@ jobs: wrapper-cache-enabled: false arguments: check + #Test with android emulators provided + android-platform-test: + runs-on: macos-latest + strategy: + matrix: + api-level: [24, 25, 26, 28, 29, 30] + arch: [x86_64, x86] +# include: +# - api-level: 30 +# arch: arm64-v8a ## Currently this github action isn't able to start any arm emulators + + steps: + - name: checkout + uses: actions/checkout@v2 + + - name: Setup Rust + run: ./cargo_ndk_prep.sh; touch local.properties + + - name: run tests + uses: reactivecircus/android-emulator-runner@v2 + with: +# ndk: 21.4.7075529 ## This would install the correct NDK version, but it's already installed in the image. + api-level: ${{ matrix.api-level }} + arch: ${{ matrix.arch }} + target: google_apis + script: ./gradlew connectedCheck + # Formatter may behave differently on different locales, makes sure tests are not failing locale-test: name: Locale ${{ matrix.locale }} @@ -73,14 +120,18 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Setup Rust + run: ./cargo_ndk_prep.sh + - name: Set Locale run: | sudo locale-gen ${{ matrix.locale }} sudo update-locale LANG=${{ matrix.locale }} - name: Set up JDK - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: + distribution: 'adopt' java-version: 11 - name: Check diff --git a/.gitignore b/.gitignore index f87b4bc..aa177d4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ scripts .gradle build + +local.properties \ No newline at end of file diff --git a/README.adoc b/README.adoc index 028f3a0..e7a238d 100644 --- a/README.adoc +++ b/README.adoc @@ -20,12 +20,14 @@ WARNING: UNDER DEVELOPMENT - `io.emeraldpay.polkaj:polkaj-scale:{lib-version}` - SCALE codec implementation - `io.emeraldpay.polkaj:polkaj-scale-types:{lib-version}` - SCALE mapping for standard Polkadot types - `io.emeraldpay.polkaj:polkaj-schnorrkel:{lib-version}` - Schnorrkel for Java +- `io.emeraldpay.polkaj:polkaj-schnorrkel-android:{lib-version-dev}` - Schnorrkel for Android - `io.emeraldpay.polkaj:polkaj-ss58:{lib-version}` - SS58 codec to encode/decode addresses and pubkeys - `io.emeraldpay.polkaj:polkaj-common-types:{lib-version}` - common types (Address, DotAmount, Hash256, etc) - `io.emeraldpay.polkaj:polkaj-json-types:{lib-version}` - JSON RPC mapping to Java classes - `io.emeraldpay.polkaj:polkaj-api-base:{lib-version}` - RPC base classes - `io.emeraldpay.polkaj:polkaj-api-http:{lib-version}` - JSON RPC HTTP client - `io.emeraldpay.polkaj:polkaj-api-ws:{lib-version}` - JSON RPC WebSocket client +- `io.emeraldpay.polkaj:polkaj-api-okhttp:{lib-version-dev}` - JSON RPC and WebSocket client using OkHttp - `io.emeraldpay.polkaj:polkaj-tx:{lib-version}` - Storage access and Extrinsics == Usage @@ -34,7 +36,7 @@ To use development SNAPSHOT versions you need to install the library into the lo .Install into local Maven ---- -gradle install +./gradlew publishToMavenLocal ---- .Using with Gradle @@ -48,7 +50,7 @@ repositories { } dependencies { - implementation 'io.emeraldpay.polkaj:polkaj-api-http:{lib-version}' + implementation 'io.emeraldpay.polkaj:polkaj-api-http:{lib-version-dev}' } ---- @@ -61,7 +63,7 @@ See link:docs/[Documentation] in `./docs` directory, and a demonstration in `./e .Show current finalized block [source,java] ---- -PolkadotHttpApi client = PolkadotApi.newBuilder() +PolkadotApi client = PolkadotApi.newBuilder() .rpcCallAdapter(JavaHttpAdapter.newBuilder().build()) .build(); Future hashFuture = client.execute( diff --git a/build.gradle b/build.gradle index 3429a52..b340479 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,20 @@ buildscript { + ext.kotlin_version = '1.5.30' + ext.kotlin_coroutine_version = '1.5.2' + + repositories { + google() + maven { + url "https://plugins.gradle.org/m2/" + } + mavenCentral() + } dependencies { classpath 'com.netflix.nebula:gradle-aggregate-javadocs-plugin:3.0.1' + classpath "com.android.tools.build:gradle:4.2.2" + classpath "gradle.plugin.com.github.willir.rust:plugin:0.3.4" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -29,7 +42,7 @@ allprojects { } jacoco { - toolVersion = "0.8.5" + toolVersion = "0.8.7" } task coverageReport(type: JacocoReport) { @@ -48,6 +61,8 @@ task syncJars(type: Sync) { into "${buildDir}/libs/" } +task checkJava8 + // Skip Bintray for the root module bintray { dryRun=true diff --git a/cargo_ndk_prep.sh b/cargo_ndk_prep.sh new file mode 100755 index 0000000..f3fabe6 --- /dev/null +++ b/cargo_ndk_prep.sh @@ -0,0 +1,3 @@ +#!/bin/sh +rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android +cargo install cargo-ndk \ No newline at end of file diff --git a/common_java_app.gradle b/common_java_app.gradle index 5174467..b3b7217 100644 --- a/common_java_app.gradle +++ b/common_java_app.gradle @@ -1,6 +1,5 @@ apply plugin: 'java' apply plugin: 'java-library' -apply plugin: 'maven' apply plugin: 'maven-publish' apply plugin: 'groovy' apply plugin: 'jacoco' @@ -10,13 +9,22 @@ afterEvaluate { rootProject.tasks.coverageReport.additionalSourceDirs.setFrom rootProject.tasks.coverageReport.additionalSourceDirs.files + files(it.sourceSets.main.allSource.srcDirs) rootProject.tasks.coverageReport.sourceDirectories.setFrom rootProject.tasks.coverageReport.sourceDirectories.files + files(it.sourceSets.main.allSource.srcDirs) rootProject.tasks.coverageReport.classDirectories.setFrom rootProject.tasks.coverageReport.classDirectories.files + files(it.sourceSets.main.output) -} + //By default we set to java 8 but some project override this to higher levels so after evaluate we + if(java.sourceCompatibility == JavaVersion.VERSION_1_8){ + rootProject.tasks.checkJava8.dependsOn(test) + } +} tasks.withType(JavaCompile) { options.debug = true } +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + compileJava { targetCompatibility = '8' sourceCompatibility = '8' @@ -79,7 +87,7 @@ artifacts { } jacoco { - toolVersion = "0.8.5" + toolVersion = "0.8.7" } publishing { diff --git a/examples/README.adoc b/examples/README.adoc index 67c8dfc..c28c814 100644 --- a/examples/README.adoc +++ b/examples/README.adoc @@ -25,18 +25,18 @@ cd types == RPC Examples for accessing JSON RPC through HTTP or WebSockets. -The examples expect Polkadot node running and listening for RPC and WS requests on the localhost (i.e. default node options) +The examples expect Polkadot node running and listening for RPC and WS requests on the localhost (i.e. default node options). In any of the following examples you can pass the optional `--args="okhttp"` to the run command to use the Java 8 compatible OkHttp implementation of the RPC and WebSocket adapters. .Show current head ---- cd rpc -./gradlew run +./gradlew run --args="okhttp" ---- .Follow updates to the current head (use `Ctrl+C` to exit) ---- cd rpc -./gradlew run -PmainClass=FollowState +./gradlew run -PmainClass=FollowState --args="okhttp" ---- .Describe runtime @@ -45,6 +45,9 @@ cd rpc ./gradlew run -PmainClass=DescribeRuntime ---- +NOTE: Currently, only runtimes with Metadata are is supported https://github.com/paritytech/polkadot/releases/tag/v0.8.30[v0.8.30] is suitable version to test with. + + == Runtime Explorer A web-based explorer of the Runtime Metadata. @@ -78,6 +81,9 @@ cd balance NOTE: To run a development network use: `polkadot --dev` +NOTE: Currently, only runtimes with Metadata are is supported https://github.com/paritytech/polkadot/releases/tag/v0.8.30[v0.8.30] is suitable version to test with. + + .Transfer using real network (ex. Kusama) ---- cd balance diff --git a/examples/balance/build.gradle b/examples/balance/build.gradle index a41d45e..fc28dee 100644 --- a/examples/balance/build.gradle +++ b/examples/balance/build.gradle @@ -13,6 +13,7 @@ apply from: '../common.gradle' dependencies { implementation "io.emeraldpay.polkaj:polkaj-api-http:$polkajVersion" implementation "io.emeraldpay.polkaj:polkaj-api-ws:$polkajVersion" + implementation "io.emeraldpay.polkaj:polkaj-api-okhttp:$polkajVersion" implementation "io.emeraldpay.polkaj:polkaj-json-types:$polkajVersion" implementation "io.emeraldpay.polkaj:polkaj-scale-types:$polkajVersion" implementation "io.emeraldpay.polkaj:polkaj-tx:$polkajVersion" diff --git a/examples/balance/src/main/java/Balance.java b/examples/balance/src/main/java/Balance.java index fe18618..62b9285 100644 --- a/examples/balance/src/main/java/Balance.java +++ b/examples/balance/src/main/java/Balance.java @@ -1,15 +1,22 @@ import io.emeraldpay.polkaj.api.PolkadotApi; +import io.emeraldpay.polkaj.api.RpcCallAdapter; import io.emeraldpay.polkaj.apihttp.JavaHttpAdapter; +import io.emeraldpay.polkaj.apiokhttp.OkHttpRpcAdapter; import io.emeraldpay.polkaj.scaletypes.AccountInfo; import io.emeraldpay.polkaj.tx.AccountRequests; import io.emeraldpay.polkaj.types.Address; import io.emeraldpay.polkaj.types.DotAmount; import io.emeraldpay.polkaj.types.DotAmountFormatter; +import java.util.Arrays; + public class Balance { public static void main(String[] args) throws Exception { - try (PolkadotApi client = PolkadotApi.newBuilder().rpcCallAdapter(JavaHttpAdapter.newBuilder().build()).build()) { + final boolean useOkhttp = Arrays.asList(args).contains("okhttp"); + final RpcCallAdapter adapter = useOkhttp ? OkHttpRpcAdapter.newBuilder().build() : + JavaHttpAdapter.newBuilder().build(); + try (PolkadotApi client = PolkadotApi.newBuilder().rpcCallAdapter(adapter).build()) { DotAmountFormatter formatter = DotAmountFormatter.autoFormatter(); DotAmount total = AccountRequests.totalIssuance().execute(client).get(); diff --git a/examples/balance/src/main/java/Transfer.java b/examples/balance/src/main/java/Transfer.java index dc74d0d..e34cd3a 100644 --- a/examples/balance/src/main/java/Transfer.java +++ b/examples/balance/src/main/java/Transfer.java @@ -1,4 +1,5 @@ import io.emeraldpay.polkaj.api.*; +import io.emeraldpay.polkaj.apiokhttp.OkHttpSubscriptionAdapter; import io.emeraldpay.polkaj.apiws.JavaHttpSubscriptionAdapter; import io.emeraldpay.polkaj.scale.ScaleExtract; import io.emeraldpay.polkaj.scaletypes.AccountInfo; @@ -49,9 +50,16 @@ public static void main(String[] args) throws Exception { Math.abs(random.nextLong()) % DotAmount.fromDots(0.002).getValue().longValue() ); - final JavaHttpSubscriptionAdapter adapter = JavaHttpSubscriptionAdapter.newBuilder().connectTo(api).build(); + boolean useOkhttp = true; + final SubscriptionAdapter adapter = useOkhttp ? + OkHttpSubscriptionAdapter.newBuilder().connectTo(api).build() : + JavaHttpSubscriptionAdapter.newBuilder().connectTo(api).build(); try (PolkadotApi client = PolkadotApi.newBuilder().subscriptionAdapter(adapter).build()) { - System.out.println("Connected: " + adapter.connect().get()); + if(adapter instanceof JavaHttpSubscriptionAdapter){ + //Connect call not required for OkHttp + CompletableFuture connected = ((JavaHttpSubscriptionAdapter)adapter).connect(); + System.out.println("Connected: " + connected.get()); + } // Subscribe to block heights AtomicLong height = new AtomicLong(0); diff --git a/examples/rpc/build.gradle b/examples/rpc/build.gradle index 867990e..3d09a4f 100644 --- a/examples/rpc/build.gradle +++ b/examples/rpc/build.gradle @@ -13,11 +13,12 @@ apply from: '../common.gradle' dependencies { implementation "io.emeraldpay.polkaj:polkaj-api-http:$polkajVersion" implementation "io.emeraldpay.polkaj:polkaj-api-ws:$polkajVersion" + implementation "io.emeraldpay.polkaj:polkaj-api-okhttp:$polkajVersion" implementation "io.emeraldpay.polkaj:polkaj-json-types:$polkajVersion" implementation "io.emeraldpay.polkaj:polkaj-scale-types:$polkajVersion" implementation 'commons-codec:commons-codec:1.14' } application { - mainClassName = project.hasProperty("mainClass") ? getProperty("mainClass") : "ShowState" + mainClassName = findProperty("mainClass") ?: "ShowState" } \ No newline at end of file diff --git a/examples/rpc/src/main/java/DescribeRuntime.java b/examples/rpc/src/main/java/DescribeRuntime.java index a038328..9a77a7f 100644 --- a/examples/rpc/src/main/java/DescribeRuntime.java +++ b/examples/rpc/src/main/java/DescribeRuntime.java @@ -1,10 +1,13 @@ import io.emeraldpay.polkaj.api.PolkadotApi; +import io.emeraldpay.polkaj.api.RpcCallAdapter; import io.emeraldpay.polkaj.api.StandardCommands; import io.emeraldpay.polkaj.apihttp.JavaHttpAdapter; +import io.emeraldpay.polkaj.apiokhttp.OkHttpRpcAdapter; import io.emeraldpay.polkaj.scale.ScaleExtract; import io.emeraldpay.polkaj.scaletypes.Metadata; import io.emeraldpay.polkaj.scaletypes.MetadataReader; +import java.util.Arrays; import java.util.concurrent.Future; /** @@ -13,8 +16,11 @@ public class DescribeRuntime { public static void main(String[] args) throws Exception { + final boolean useOkhttp = Arrays.stream(args).anyMatch(arg -> arg.equals("okhttp")); + final RpcCallAdapter adapter = useOkhttp ? OkHttpRpcAdapter.newBuilder().build() : + JavaHttpAdapter.newBuilder().build(); PolkadotApi api = PolkadotApi.newBuilder() - .rpcCallAdapter(JavaHttpAdapter.newBuilder().build()) + .rpcCallAdapter(adapter) .build(); Future metadataFuture = api.execute(StandardCommands.getInstance().stateMetadata()) .thenApply(ScaleExtract.fromBytesData(new MetadataReader())); diff --git a/examples/rpc/src/main/java/FollowState.java b/examples/rpc/src/main/java/FollowState.java index 877ffa1..8909575 100644 --- a/examples/rpc/src/main/java/FollowState.java +++ b/examples/rpc/src/main/java/FollowState.java @@ -1,11 +1,13 @@ import io.emeraldpay.polkaj.api.PolkadotApi; import io.emeraldpay.polkaj.api.Subscription; import io.emeraldpay.polkaj.api.SubscriptionAdapter; +import io.emeraldpay.polkaj.apiokhttp.OkHttpSubscriptionAdapter; import io.emeraldpay.polkaj.apiws.JavaHttpSubscriptionAdapter; import io.emeraldpay.polkaj.json.BlockJson; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Arrays; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -15,13 +17,19 @@ public class FollowState { public static void main(String[] args) throws InterruptedException, ExecutionException, TimeoutException { - JavaHttpSubscriptionAdapter wsAdapter = JavaHttpSubscriptionAdapter.newBuilder().build(); + final boolean useOkhttp = Arrays.asList(args).contains("okhttp"); + final SubscriptionAdapter wsAdapter = useOkhttp ? + OkHttpSubscriptionAdapter.newBuilder().connectTo("ws://192.168.68.93:9944").build() : + JavaHttpSubscriptionAdapter.newBuilder().build(); + PolkadotApi api = PolkadotApi.newBuilder() .subscriptionAdapter(wsAdapter) .build(); // IMPORTANT! connect to the node as the first step before making calls or subscriptions. - wsAdapter.connect().get(5, TimeUnit.SECONDS); + // OkHttpSubscriptionAdapter handles this for you + if(wsAdapter instanceof JavaHttpSubscriptionAdapter) + ((JavaHttpSubscriptionAdapter)wsAdapter).connect().get(5, TimeUnit.SECONDS); Future> hashFuture = api.subscribe(SubscriptionAdapter.subscriptions().newHeads()); diff --git a/examples/rpc/src/main/java/ShowState.java b/examples/rpc/src/main/java/ShowState.java index 1a72fef..014a386 100644 --- a/examples/rpc/src/main/java/ShowState.java +++ b/examples/rpc/src/main/java/ShowState.java @@ -1,20 +1,26 @@ import io.emeraldpay.polkaj.api.PolkadotApi; import io.emeraldpay.polkaj.api.PolkadotMethod; import io.emeraldpay.polkaj.api.RpcCall; +import io.emeraldpay.polkaj.api.RpcCallAdapter; import io.emeraldpay.polkaj.apihttp.JavaHttpAdapter; +import io.emeraldpay.polkaj.apiokhttp.OkHttpRpcAdapter; import io.emeraldpay.polkaj.json.BlockResponseJson; import io.emeraldpay.polkaj.json.RuntimeVersionJson; import io.emeraldpay.polkaj.json.SystemHealthJson; import io.emeraldpay.polkaj.types.Hash256; +import java.util.Arrays; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; public class ShowState { public static void main(String[] args) throws Exception { + final boolean useOkhttp = Arrays.asList(args).contains("okhttp"); + final RpcCallAdapter adapter = useOkhttp ? OkHttpRpcAdapter.newBuilder().build() : + JavaHttpAdapter.newBuilder().build(); PolkadotApi api = PolkadotApi.newBuilder() - .rpcCallAdapter(JavaHttpAdapter.newBuilder().build()) + .rpcCallAdapter(adapter) .build(); Future hashFuture = api.execute( @@ -58,17 +64,5 @@ public static void main(String[] args) throws Exception { api.close(); } -// PolkadotApi client() throws URISyntaxException { -// ObjectMapper objectMapper = new ObjectMapper(); -// objectMapper.registerModule(new PolkadotModule()); -// -// PolkadotHttpApi client = PolkadotHttpApi.newBuilder() -// .objectMapper(objectMapper) // <1> -// .connectTo("http://10.0.1.20:9333") // <2> -// .basicAuth("alice", "secret") // <3> -// .build(); -// -// return client; -// } } diff --git a/gradle.properties b/gradle.properties index 2dbe70a..fc2c089 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,4 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 org.gradle.workers.max=3 + +android.useAndroidX = true \ No newline at end of file diff --git a/polkaj-adapter-tests/build.gradle b/polkaj-adapter-tests/build.gradle index 54b154d..8c2f913 100644 --- a/polkaj-adapter-tests/build.gradle +++ b/polkaj-adapter-tests/build.gradle @@ -1,8 +1,16 @@ -//apply from: '../common_java_app.gradle' - apply plugin: 'java-library' apply plugin: 'groovy' +compileJava { + targetCompatibility = '8' + sourceCompatibility = '8' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + dependencies { api project(":polkaj-json-types") api project(":polkaj-api-base") diff --git a/polkaj-adapter-tests/src/main/groovy/io/emeraldpay/polkaj/api/RpcAdapterSpec.groovy b/polkaj-adapter-tests/src/main/groovy/io/emeraldpay/polkaj/api/RpcAdapterSpec.groovy index 779eef2..eca05c0 100644 --- a/polkaj-adapter-tests/src/main/groovy/io/emeraldpay/polkaj/api/RpcAdapterSpec.groovy +++ b/polkaj-adapter-tests/src/main/groovy/io/emeraldpay/polkaj/api/RpcAdapterSpec.groovy @@ -10,7 +10,6 @@ import org.mockserver.model.MediaType import spock.lang.Shared import spock.lang.Specification -import java.net.http.HttpTimeoutException import java.nio.charset.Charset import java.time.Duration import java.util.concurrent.ExecutionException @@ -108,7 +107,7 @@ abstract class RpcAdapterSpec extends Specification{ then: def t = thrown(ExecutionException) - t.cause instanceof HttpTimeoutException + t.cause instanceof InterruptedIOException } diff --git a/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/RpcCoder.java b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/RpcCoder.java index 41bc2fd..562fa60 100644 --- a/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/RpcCoder.java +++ b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/RpcCoder.java @@ -4,8 +4,11 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; public class RpcCoder { @@ -47,14 +50,36 @@ public JavaType responseType(Class resultClazz) { * @throws CompletionException with RpcException details to let executor know that the response is invalid */ final public T decode(int id, String content, JavaType clazz) { + return decodeInternal(id, content, clazz); + } + + /** + * Decode JSON RPC response + * + * @param id expected id + * @param content full JSON content + * @param clazz expected JavaType for the result field + * @param returning type + * @return The decoded result + * @throws CompletionException with RpcException details to let executor know that the response is invalid or an IOException + */ + final public T decode(int id, InputStream content, JavaType clazz){ + return decodeInternal(id, content, clazz); + } + + private T decodeInternal(int id, Object content, JavaType clazz) { JavaType type = objectMapper.getTypeFactory().constructParametricType(RpcResponse.class, clazz); RpcResponse response; try { - response = objectMapper.readerFor(type).readValue(content); + if(content instanceof String) response = objectMapper.readerFor(type).readValue((String)content); + else if(content instanceof InputStream) response = objectMapper.readerFor(type).readValue((InputStream) content); + else throw new IllegalArgumentException("Unsupported content type."); } catch (JsonProcessingException e) { throw new CompletionException( new RpcException(-32603, "Server returned invalid JSON", e) ); + } catch (IOException e){ + throw new CompletionException(e); } if (id != response.getId()) { throw new CompletionException( @@ -72,6 +97,7 @@ final public T decode(int id, String content, JavaType clazz) { /** * Encode RPC request as JSON * + * @param type of returned object * @param id id of the request * @param call the RpcCall to encode * @return full JSON of the request diff --git a/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardCommands.java b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardCommands.java index 88b8f82..696d82e 100644 --- a/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardCommands.java +++ b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardCommands.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; /** @@ -126,174 +127,16 @@ public RpcCall stateGetStorage(ByteData key) { return RpcCall.create(ByteData.class, PolkadotMethod.STATE_GET_STORAGE, key.toString()); } - /** - * @deprecated Use RpcCall.create(Hash256.class, PolkadotMethod.STATE_GET_STORAGE_HASH, ...) - * @param key key - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall stateGetStorageHash(ByteData key, Hash256 at) { - List params = at == null ? List.of(key) : List.of(key, at); - return RpcCall.create(Hash256.class, PolkadotMethod.STATE_GET_STORAGE_HASH, params); - } - - /** - * @deprecated Use RpcCall.create(Hash256.class, PolkadotMethod.STATE_GET_STORAGE_SIZE, ...) - * @param key key - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall stateGetStorageSize(ByteData key, Hash256 at) { - List params = at == null ? List.of(key) : List.of(key, at); - return RpcCall.create(Long.class, PolkadotMethod.STATE_GET_STORAGE_SIZE, params); - } - - /** - * @deprecated Use RpcCall.create(ByteData.class, PolkadotMethod.STATE_CALL, ...) - * @param method method name - * @param data call data - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall stateCall(String method, ByteData data, Hash256 at) { - List params = at == null ? List.of(method, data) : List.of(method, data, at); - return RpcCall.create(ByteData.class, PolkadotMethod.STATE_CALL, params); - } - - /** - * @deprecated Use RpcCall.create(ByteData.class, "state_getChildKeys", ...).expectList() - * @param childStorageKey child key - * @param childDefinition child definition - * @param childType type - * @param key key - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall> stateGetChildKeys(ByteData childStorageKey, ByteData childDefinition, long childType, ByteData key, Hash256 at) { - List params = new ArrayList<>(List.of(childStorageKey, childDefinition, childType, key)); - if (at != null) { - params.add(at); - } - return RpcCall.create(ByteData.class, "state_getChildKeys", params).expectList(); - } - - /** - * @deprecated Use RpcCall.create(ByteData.class, "state_getChildStorage", ...) - * @param childStorageKey child key - * @param childDefinition child definition - * @param childType type - * @param key key - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall stateGetStorageData(ByteData childStorageKey, ByteData childDefinition, long childType, ByteData key, Hash256 at) { - List params = new ArrayList<>(List.of(childStorageKey, childDefinition, childType, key)); - if (at != null) { - params.add(at); - } - return RpcCall.create(ByteData.class, "state_getChildStorage", params); - } - - /** - * @deprecated Use RpcCall.create(Hash256.class, "state_getChildStorageHash", ...) - * @param childStorageKey child key - * @param childDefinition child definition - * @param childType type - * @param key key - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall stateGetChildStorageHash(ByteData childStorageKey, ByteData childDefinition, long childType, ByteData key, Hash256 at) { - List params = new ArrayList<>(List.of(childStorageKey, childDefinition, childType, key)); - if (at != null) { - params.add(at); - } - return RpcCall.create(Hash256.class, "state_getChildStorageHash", params); - } - - /** - * @deprecated RpcCall.create(Long.class, "state_getChildStorageSize", ...) - * @param childStorageKey child key - * @param childDefinition child definition - * @param childType type - * @param key key - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall stateGetChildStorageSize(ByteData childStorageKey, ByteData childDefinition, long childType, ByteData key, Hash256 at) { - List params = new ArrayList<>(List.of(childStorageKey, childDefinition, childType, key)); - if (at != null) { - params.add(at); - } - return RpcCall.create(Long.class, "state_getChildStorageSize", params); - } - - /** - * @deprecated Use RpcCall.create(ByteData.class, PolkadotMethod.STATE_KEYS_PAGED, ...).expectList() - * @param key key - * @param count count - * @param startKey start key (optional) - * @param at block (optional) - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall> stateGetKeys(ByteData key, int count, ByteData startKey, Hash256 at) { - List params = new ArrayList<>(List.of(key, count)); - if (startKey != null) { - params.add(startKey); - } - if (at != null) { - params.add(at); - } - return RpcCall.create(ByteData.class, PolkadotMethod.STATE_KEYS_PAGED, params).expectList(); - } - public RpcCall stateGetReadProof(List keys, Hash256 at) { - List params = new ArrayList<>(List.of(keys)); + List> wrapper = new ArrayList<>(); + wrapper.add(keys); + List params = new ArrayList<>(Collections.unmodifiableList(wrapper)); if (at != null) { params.add(at); } return RpcCall.create(ReadProofJson.class, PolkadotMethod.STATE_GET_READ_PROOF, params); } - /** - * @deprecated Use RpcCall.create(ByteData.class, PolkadotMethod.STATE_QUERY_STORAGE, ...).expectList() - * @param keys keys - * @param fromBlock from block - * @param toBlock to block - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall> stateQueryStorage(List keys, Hash256 fromBlock, Hash256 toBlock) { - List params = new ArrayList<>(List.of(keys, fromBlock)); - if (toBlock != null) { - params.add(toBlock); - } - return RpcCall.create(ByteData.class, PolkadotMethod.STATE_QUERY_STORAGE, params).expectList(); - } - - /** - * @deprecated Use RpcCall.create(ByteData.class, PolkadotMethod.STATE_QUERY_STORAGE_AT, ...).expectList() - * @param keys keys - * @param at block - * @return command - */ - @Deprecated(forRemoval = true) - public RpcCall> stateQueryStorageAt(List keys, Hash256 at) { - List params = new ArrayList<>(List.of(keys)); - if (at != null) { - params.add(at); - } - return RpcCall.create(ByteData.class, PolkadotMethod.STATE_QUERY_STORAGE_AT, params).expectList(); - } - /** * Request data from storage * @param request key (depending on the storage) diff --git a/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardSubscriptions.java b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardSubscriptions.java index f10f1f0..a3ef78f 100644 --- a/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardSubscriptions.java +++ b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/StandardSubscriptions.java @@ -5,6 +5,7 @@ import io.emeraldpay.polkaj.json.StorageChangeSetJson; import io.emeraldpay.polkaj.types.ByteData; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -57,7 +58,9 @@ public SubscribeCall storage(List keys) { if (keys.isEmpty()) { return storage(); } - return SubscribeCall.create(StorageChangeSetJson.class, PolkadotMethod.STATE_SUBSCRIBE_STORAGE, PolkadotMethod.STATE_UNSUBSCRIBE_STORAGE, List.of(keys)); + final List> wrapper = new ArrayList<>(); + wrapper.add(keys); + return SubscribeCall.create(StorageChangeSetJson.class, PolkadotMethod.STATE_SUBSCRIBE_STORAGE, PolkadotMethod.STATE_UNSUBSCRIBE_STORAGE, Collections.unmodifiableList(wrapper)); } public SubscribeCall storage(ByteData key) { diff --git a/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/DecodeResponse.java b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/DecodeResponse.java similarity index 94% rename from polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/DecodeResponse.java rename to polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/DecodeResponse.java index 79ee79d..7f47e42 100644 --- a/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/DecodeResponse.java +++ b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/DecodeResponse.java @@ -1,4 +1,4 @@ -package io.emeraldpay.polkaj.apiws; +package io.emeraldpay.polkaj.api.internal; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; @@ -52,21 +52,21 @@ public WsResponse decode(final String json) throws IOException { preparsed.id = decodeNumber(parser); preparsed.type = findType(rpcMapping, preparsed.id); if (preparsed.isReady()) { - var result = preparsed.build(); + final WsResponse.IdValue result = preparsed.build(); return WsResponse.rpc(new RpcResponse<>(result.getId(), result.getValue())); } } else if ("result".equals(field)) { parser.nextToken(); preparsed.node = parser.readValueAsTree(); if (preparsed.isReady()) { - var result = preparsed.build(); + final WsResponse.IdValue result = preparsed.build(); return WsResponse.rpc(new RpcResponse<>(result.getId(), result.getValue())); } } else if ("error".equals(field)) { //TODO parse error preparsed.error = decodeError(parser); if (preparsed.id != null) { - var result = preparsed.build(); + final WsResponse.IdValue result = preparsed.build(); return WsResponse.rpc(new RpcResponse<>(result.getId(), result.getValue())); } } else if ("method".equals(field)) { @@ -74,14 +74,14 @@ public WsResponse decode(final String json) throws IOException { method = parser.getValueAsString(); if (value != null) { return WsResponse.subscription( - new JavaHttpSubscriptionAdapter.SubscriptionResponse<>(value.getId(), method, value.getValue()) + new SubscriptionResponse<>(value.getId(), method, value.getValue()) ); } } else if ("params".equals(field)) { value = decodeSubscription(subscriptionMapping, parser); if (method != null) { return WsResponse.subscription( - new JavaHttpSubscriptionAdapter.SubscriptionResponse<>(value.getId(), method, value.getValue()) + new SubscriptionResponse<>(value.getId(), method, value.getValue()) ); } } diff --git a/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/SubscriptionResponse.java b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/SubscriptionResponse.java new file mode 100644 index 0000000..ddea0c9 --- /dev/null +++ b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/SubscriptionResponse.java @@ -0,0 +1,42 @@ +package io.emeraldpay.polkaj.api.internal; + +import java.util.Objects; + +public class SubscriptionResponse { + private final String id; + private final String method; + private final T value; + + public SubscriptionResponse(String id, String method, T value) { + this.id = id; + this.method = method; + this.value = value; + } + + public String getId() { + return id; + } + + public String getMethod() { + return method; + } + + public T getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SubscriptionResponse)) return false; + SubscriptionResponse that = (SubscriptionResponse) o; + return id.equals(that.id) && + Objects.equals(method, that.method) && + Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(id, method, value); + } +} diff --git a/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/WsResponse.java b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/WsResponse.java similarity index 85% rename from polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/WsResponse.java rename to polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/WsResponse.java index b40e181..00094e8 100644 --- a/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/WsResponse.java +++ b/polkaj-api-base/src/main/java/io/emeraldpay/polkaj/api/internal/WsResponse.java @@ -1,11 +1,11 @@ -package io.emeraldpay.polkaj.apiws; +package io.emeraldpay.polkaj.api.internal; import io.emeraldpay.polkaj.api.RpcResponse; /** * Container for the WebSocker message. A message may be a subscription event, or a response to a standard RPC call. * - * @see JavaHttpSubscriptionAdapter.SubscriptionResponse + * @see SubscriptionResponse * @see RpcResponse */ public class WsResponse { @@ -38,11 +38,11 @@ public Object getValue() { * Make sure the value is SubscriptionResponse and return it * @return value as event */ - public JavaHttpSubscriptionAdapter.SubscriptionResponse asEvent() { + public SubscriptionResponse asEvent() { if (type != Type.SUBSCRIPTION) { throw new ClassCastException("Not an event"); } - return (JavaHttpSubscriptionAdapter.SubscriptionResponse) value; + return (SubscriptionResponse) value; } /** @@ -62,7 +62,7 @@ public RpcResponse asRpc() { * @param event event data * @return response instance configured for Subscription Event */ - public static WsResponse subscription(JavaHttpSubscriptionAdapter.SubscriptionResponse event) { + public static WsResponse subscription(SubscriptionResponse event) { return new WsResponse(Type.SUBSCRIPTION, event); } diff --git a/polkaj-api-ws/src/test/groovy/io/emeraldpay/polkaj/apiws/DecodeResponseSpec.groovy b/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/DecodeResponseSpec.groovy similarity index 96% rename from polkaj-api-ws/src/test/groovy/io/emeraldpay/polkaj/apiws/DecodeResponseSpec.groovy rename to polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/DecodeResponseSpec.groovy index b13aa01..33fdded 100644 --- a/polkaj-api-ws/src/test/groovy/io/emeraldpay/polkaj/apiws/DecodeResponseSpec.groovy +++ b/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/DecodeResponseSpec.groovy @@ -1,6 +1,9 @@ -package io.emeraldpay.polkaj.apiws +package io.emeraldpay.polkaj.api import com.fasterxml.jackson.databind.ObjectMapper +import io.emeraldpay.polkaj.api.internal.DecodeResponse +import io.emeraldpay.polkaj.api.internal.SubscriptionResponse +import io.emeraldpay.polkaj.api.internal.WsResponse import io.emeraldpay.polkaj.json.StorageChangeSetJson import io.emeraldpay.polkaj.types.ByteData import io.emeraldpay.polkaj.types.Hash256 @@ -151,7 +154,7 @@ class DecodeResponseSpec extends Specification { def act = decoder.decode(json) then: act.type == WsResponse.Type.SUBSCRIPTION - JavaHttpSubscriptionAdapter.SubscriptionResponse event = act.asEvent() + SubscriptionResponse event = act.asEvent() event.method == "chain_newHead" event.id == "EsqruyKPnZvPZ6fr" event.value instanceof BlockJson.Header @@ -187,7 +190,7 @@ class DecodeResponseSpec extends Specification { def act = decoder.decode(json) then: act.type == WsResponse.Type.SUBSCRIPTION - JavaHttpSubscriptionAdapter.SubscriptionResponse event = act.asEvent() + SubscriptionResponse event = act.asEvent() event.method == "chain_newHead" event.id == "EsqruyKPnZvPZ6fr" event.value instanceof BlockJson.Header @@ -224,7 +227,7 @@ class DecodeResponseSpec extends Specification { def act = decoder.decode(json) then: act.type == WsResponse.Type.SUBSCRIPTION - JavaHttpSubscriptionAdapter.SubscriptionResponse event = act.asEvent() + SubscriptionResponse event = act.asEvent() event.method == "state_storage" event.id == "EKMIn5gSrVmo1cgU" event.value instanceof StorageChangeSetJson diff --git a/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/StandardCommandsSpec.groovy b/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/StandardCommandsSpec.groovy index a91b093..a4836d4 100644 --- a/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/StandardCommandsSpec.groovy +++ b/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/StandardCommandsSpec.groovy @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory import io.emeraldpay.polkaj.json.ContractCallRequestJson import io.emeraldpay.polkaj.json.ContractExecResultJson import io.emeraldpay.polkaj.json.MethodsJson +import io.emeraldpay.polkaj.json.ReadProofJson import io.emeraldpay.polkaj.json.RuntimeDispatchInfoJson import io.emeraldpay.polkaj.json.RuntimeVersionJson import io.emeraldpay.polkaj.json.SystemHealthJson @@ -154,6 +155,15 @@ class StandardCommandsSpec extends Specification { act.getResultType(typeFactory).getRawClass() == ByteData.class } + def "State Get Read Proof"() { + when: + def act = StandardCommands.getInstance().stateGetReadProof([ByteData.from("0x00")], Hash256.empty()) + then: + act.method == "state_getReadProof" + act.params.toList() == [[ByteData.from("0x00")], Hash256.empty()] + act.getResultType(typeFactory).getRawClass() == ReadProofJson.class + } + def "Contracts Call"() { setup: def call = new ContractCallRequestJson() diff --git a/polkaj-api-ws/src/test/groovy/io/emeraldpay/polkaj/apiws/WsResponseSpec.groovy b/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/WsResponseSpec.groovy similarity index 57% rename from polkaj-api-ws/src/test/groovy/io/emeraldpay/polkaj/apiws/WsResponseSpec.groovy rename to polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/WsResponseSpec.groovy index e020a20..e659825 100644 --- a/polkaj-api-ws/src/test/groovy/io/emeraldpay/polkaj/apiws/WsResponseSpec.groovy +++ b/polkaj-api-base/src/test/groovy/io/emeraldpay/polkaj/api/WsResponseSpec.groovy @@ -1,7 +1,7 @@ -package io.emeraldpay.polkaj.apiws +package io.emeraldpay.polkaj.api - -import io.emeraldpay.polkaj.api.RpcResponse +import io.emeraldpay.polkaj.api.internal.SubscriptionResponse +import io.emeraldpay.polkaj.api.internal.WsResponse import spock.lang.Specification class WsResponseSpec extends Specification { @@ -17,11 +17,11 @@ class WsResponseSpec extends Specification { def "Creates subscription response"() { when: - def act = WsResponse.subscription(new JavaHttpSubscriptionAdapter.SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test")) + def act = WsResponse.subscription(new SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test")) then: act.getType() == WsResponse.Type.SUBSCRIPTION - act.getValue() == new JavaHttpSubscriptionAdapter.SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test") - act.asEvent() == new JavaHttpSubscriptionAdapter.SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test") + act.getValue() == new SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test") + act.asEvent() == new SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test") } def "Cannot cast rcp to event"() { @@ -33,7 +33,7 @@ class WsResponseSpec extends Specification { def "Cannot cast event to rpc"() { when: - WsResponse.subscription(new JavaHttpSubscriptionAdapter.SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test")).asRpc() + WsResponse.subscription(new SubscriptionResponse("EsqruyKPnZvPZ6fr", "test", "test")).asRpc() then: thrown(ClassCastException) } diff --git a/polkaj-api-http/README.adoc b/polkaj-api-http/README.adoc index 5d533de..ad5874f 100644 --- a/polkaj-api-http/README.adoc +++ b/polkaj-api-http/README.adoc @@ -5,14 +5,14 @@ HTTP client to JSON RPC server provided by a Polkadot node. .Example [source, java] ---- -import io.emeraldpay.polkaj.clientrpc.PolkadotRpcClient; +import io.emeraldpay.polkaj.apihttp.JavaHttpAdapter; import io.emeraldpay.polkaj.types.Hash256; import io.emeraldpay.polkaj.json.BlockResponseJson; -PolkadotRpcClient client = PolkadotRpcClient.newBuilder().build(); +JavaHttpAdapter client = JavaHttpAdapter.newBuilder().build(); Future hash = client.execute(Hash256.class, "chain_getFinalisedHead"); System.out.println("Current head: " + hash.get()); -Future block = client.execute(BlockResponseJson.class, "chain_getBlock", hash.get()); +Future block = client.produceRpcFuture(BlockResponseJson.class, "chain_getBlock", hash.get()); System.out.println("Current height: " + block.get().getBlock().getHeader().getNumber()); ---- \ No newline at end of file diff --git a/polkaj-api-http/build.gradle b/polkaj-api-http/build.gradle index 64c53ec..d7e1bf6 100644 --- a/polkaj-api-http/build.gradle +++ b/polkaj-api-http/build.gradle @@ -5,9 +5,15 @@ compileJava { sourceCompatibility = '11' } +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + dependencies { api project(":polkaj-json-types") api project(":polkaj-api-base") + implementation 'com.spotify:completable-futures:0.3.5' testImplementation 'org.mock-server:mockserver-netty:5.10' testImplementation project(":polkaj-adapter-tests") diff --git a/polkaj-api-http/src/main/java/io/emeraldpay/polkaj/apihttp/JavaHttpAdapter.java b/polkaj-api-http/src/main/java/io/emeraldpay/polkaj/apihttp/JavaHttpAdapter.java index 238e15a..e9f23b8 100644 --- a/polkaj-api-http/src/main/java/io/emeraldpay/polkaj/apihttp/JavaHttpAdapter.java +++ b/polkaj-api-http/src/main/java/io/emeraldpay/polkaj/apihttp/JavaHttpAdapter.java @@ -3,20 +3,21 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; +import com.spotify.futures.CompletableFutures; import io.emeraldpay.polkaj.api.*; import io.emeraldpay.polkaj.json.jackson.PolkadotModule; +import java.io.InterruptedIOException; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.time.Duration; import java.util.Base64; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.*; +import java.util.function.Function; /** * Default JSON RPC HTTP client for Polkadot API. It uses Java 11 HttpClient implementation for requests. @@ -50,7 +51,7 @@ private JavaHttpAdapter(URI target, HttpClient httpClient, String basicAuth, Dur HttpRequest.Builder request = HttpRequest.newBuilder() .uri(target) .timeout(timeout) - .header("User-Agent", "Polkaj/0.3") //TODO generate version during compilation + .header("User-Agent", "Polkaj/java/0.5") //TODO generate version during compilation .header("Content-Type", APPLICATION_JSON); if (basicAuth != null) { @@ -83,10 +84,20 @@ public CompletableFuture produceRpcFuture(RpcCall call) { try { HttpRequest.Builder request = this.request.copy() .POST(HttpRequest.BodyPublishers.ofByteArray(rpcCoder.encode(id, call))); - return httpClient.sendAsync(request.build(), HttpResponse.BodyHandlers.ofString()) - .thenApply(this::verify) - .thenApply(HttpResponse::body) - .thenApply(content -> rpcCoder.decode(id, content, type)); + + final CompletableFuture> sendAsync = httpClient.sendAsync(request.build(), + HttpResponse.BodyHandlers.ofString()); + return CompletableFutures.exceptionallyCompose(sendAsync, ex -> { + if(ex instanceof CompletionException && ex.getCause() instanceof HttpTimeoutException) { + return CompletableFuture.failedFuture(new InterruptedIOException()); + } + else { + return CompletableFuture.failedFuture(ex); + } + }).toCompletableFuture() + .thenApply(this::verify) + .thenApply(HttpResponse::body) + .thenApply(content -> rpcCoder.decode(id, content, type)); } catch (JsonProcessingException e) { return CompletableFuture.failedFuture( new RpcException(-32600, "Unable to encode request as JSON: " + e.getMessage(), e) diff --git a/polkaj-api-okhttp/README.adoc b/polkaj-api-okhttp/README.adoc new file mode 100644 index 0000000..85f5fa4 --- /dev/null +++ b/polkaj-api-okhttp/README.adoc @@ -0,0 +1,32 @@ += OkHttp adapters + +HTTP client to JSON RPC server provided by a Polkadot node. + +.RPC Example +[source, java] +---- +import io.emeraldpay.polkaj.apiokhttp.OkHttpRpcAdapter; +import io.emeraldpay.polkaj.types.Hash256; +import io.emeraldpay.polkaj.json.BlockResponseJson; + +OkHttpRpcAdapter client = OkHttpRpcAdapter.newBuilder().build(); +Future hash = client.produceRpcFuture(Hash256.class, "chain_getFinalisedHead"); +System.out.println("Current head: " + hash.get()); + +Future block = client.produceRpcFuture(BlockResponseJson.class, "chain_getBlock", hash.get()); +System.out.println("Current height: " + block.get().getBlock().getHeader().getNumber()); +---- + +.Subscription Websoocket Example +[source, java] +---- +OkHttpSubscriptionAdapter client = OkHttpSubscriptionAdapter.newBuilder().build(); +AtomicLong height = new AtomicLong(0); +CompletableFuture waitForBlocks = new CompletableFuture<>(); +client.subscribe( + StandardSubscriptions.getInstance().newHeads() +).get().handler((event) -> { + long current = event.getResult().getNumber(); + System.out.println("Current height: " + current); +}); +---- diff --git a/polkaj-api-okhttp/build.gradle b/polkaj-api-okhttp/build.gradle new file mode 100644 index 0000000..151a906 --- /dev/null +++ b/polkaj-api-okhttp/build.gradle @@ -0,0 +1,35 @@ +apply from: '../common_java_app.gradle' + +apply plugin: 'kotlin' + +compileKotlin { + kotlinOptions { + jvmTarget = "1.8" + } +} +compileTestKotlin { + kotlinOptions { + jvmTarget = "1.8" + } +} + +compileJava { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + api project(":polkaj-json-types") + api project(":polkaj-api-base") + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:$kotlin_coroutine_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlin_coroutine_version" + implementation 'com.squareup.okhttp3:okhttp:4.9.1' + + testImplementation 'org.java-websocket:Java-WebSocket:1.5.1' + testImplementation project(":polkaj-adapter-tests") + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_coroutine_version" + testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.0") + testImplementation "io.mockk:mockk:1.12.0" +} \ No newline at end of file diff --git a/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/Constants.kt b/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/Constants.kt new file mode 100644 index 0000000..2658f6a --- /dev/null +++ b/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/Constants.kt @@ -0,0 +1,5 @@ +package io.emeraldpay.polkaj.apiokhttp + +internal object Constants { + const val APPLICATION_JSON = "application/json" +} \ No newline at end of file diff --git a/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/OkHttpRpcAdapter.kt b/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/OkHttpRpcAdapter.kt new file mode 100644 index 0000000..a059941 --- /dev/null +++ b/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/OkHttpRpcAdapter.kt @@ -0,0 +1,217 @@ +package io.emeraldpay.polkaj.apiokhttp + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import io.emeraldpay.polkaj.api.RpcCall +import io.emeraldpay.polkaj.api.RpcCallAdapter +import io.emeraldpay.polkaj.api.RpcCoder +import io.emeraldpay.polkaj.api.RpcException +import io.emeraldpay.polkaj.apiokhttp.Constants.APPLICATION_JSON +import io.emeraldpay.polkaj.json.jackson.PolkadotModule +import kotlinx.coroutines.* +import kotlinx.coroutines.future.future +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.lang.IllegalStateException +import java.time.Duration +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class OkHttpRpcAdapter private constructor( + private val target : HttpUrl, + private val basicAuth : String?, + private val client : OkHttpClient, + private val scope : CoroutineScope, + private val rpcCoder: RpcCoder, + private val onClose : () -> Unit +) : RpcCallAdapter { + + companion object{ + @JvmStatic + fun newBuilder() : Builder = Builder() + } + + private var closed = false + private val baseRequest : Request = Request.Builder().apply { + url(target) + header("User-Agent", "PolkaJ/OkHttp/0.5") + header("Content-Type", APPLICATION_JSON) + if(basicAuth != null) header("Authorization", basicAuth) + }.build() + + override fun close() { + if(closed) return + closed = true + try{ + onClose() + }catch (t : Throwable){ + System.err.println("Error during onClose call: ${t.message}") + } + } + + override fun produceRpcFuture(call: RpcCall): CompletableFuture { + return if(closed){ + CompletableFuture().apply { + completeExceptionally(IllegalStateException("Client is already closed")) + } + } else{ + scope.future{ + await(call) + } + } + } + + fun nextId() : Int = rpcCoder.nextId() + + fun getCall(id: Int, rpcCall: RpcCall) : Call { + return baseRequest.newBuilder().post( + rpcCoder.encode(id, rpcCall).toRequestBody(APPLICATION_JSON.toMediaType()) + ).build().let { + client.newCall(it) + } + } + + fun decodeResponse(id : Int, rpcCall: RpcCall, response: Response) : T { + val type = rpcCall.getResultType(rpcCoder.objectMapper.typeFactory) + return rpcCoder.decode(id, response.body!!.byteStream(), type) + } + + class Builder private constructor( + private var target : HttpUrl = "http://127.0.0.1:9933".toHttpUrl(), + private var basicAuth : String? = null, + private var client : OkHttpClient = OkHttpClient.Builder().apply { + callTimeout(Duration.ofMinutes(1)) + followRedirects(false) + }.build(), + private var scope : CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private var rpcCoder: RpcCoder = RpcCoder(ObjectMapper().registerModule(PolkadotModule())), + private var onClose : () -> Unit = { + scope.cancel() + client.dispatcher.executorService.shutdown() + } + ){ + companion object{ + operator fun invoke(block : Builder.() -> Builder) : OkHttpRpcAdapter { + return Builder().apply { block() }.build() + } + + operator fun invoke() : Builder{ + return Builder() + } + } + + /** + * Server address URL. + * By default, it will be set to "ws://127.0.0.1:9944" + * + * @param target URL + * @return builder + */ + fun target(target: String) = apply { this.target = target.toHttpUrl() } + + /** + * Setup Basic Auth for RPC calls + * + * @param username username + * @param password password + * @return builder + */ + fun basicAuth(username: String, password: String) : Builder{ + return apply { + val combine = "$username:$password".toByteArray() + basicAuth = "Basic ${Base64.getEncoder().encodeToString(combine)}" + } + } + + /** + * Provide a custom OkHttpClient configured + * + * @param client OkHttpClient + * @return builder + */ + fun client(client : OkHttpClient) = apply { this.client = client } + + /** + * CoroutineScope for requests and subscription. + * By default, a new Scope will be created. + */ + fun scope(scope : CoroutineScope) = apply { this.scope = scope } + + /** + * Provide a custom RpcCoder for rpc serialization. + * + * @param rpcCoder rpcCoder + * @return builder + */ + fun rpcCoder(rpcCoder : RpcCoder) = apply { this.rpcCoder = rpcCoder } + + /** + * Provide custom cleanup method. + * By default, it will cancel the [scope] and shutdown the [client] executorService + * + * @param block to be called on close. + * @return builder + */ + fun onClose(block : () -> Unit ) = apply { onClose = block } + + /** + * Provide a custom timeout + * By default, it is 1 minute. + */ + fun timeout(timeout : Duration) = apply { client = client.newBuilder().callTimeout(timeout).build() } + + /** + * Apply configuration and build a new adapter + * @return new instance of [OkHttpRpcAdapter] + */ + fun build() : OkHttpRpcAdapter = OkHttpRpcAdapter(target, basicAuth, client, scope, rpcCoder, onClose) + } + +} + +suspend fun OkHttpRpcAdapter.await(rpcCall : RpcCall): T { + return suspendCancellableCoroutine { continuation -> + val id = nextId() + val call = getCall(id, rpcCall) + continuation.invokeOnCancellation { + call.cancel() + } + call.enqueue(object : Callback{ + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + if(response.code != 200){ + continuation.resumeWithException(RpcException( + -32000, "Server returned error status: ${response.code}" + )) + } else if(response.header("content-type", APPLICATION_JSON)?.startsWith(APPLICATION_JSON) == false){ + continuation.resumeWithException(RpcException( + -32000, "Server returned invalid content-type ${response.header("content-type")}" + )) + } else{ + try{ + decodeResponse(id, rpcCall, response).let { + continuation.resume(it) + } + }catch (e : Throwable){ + when(e){ + is JsonProcessingException -> continuation.resumeWithException(RpcException(-32600, "Unable to encode request as JSON: ${e.message}")) + is CompletionException -> continuation.resumeWithException(e.cause ?: e) + else -> continuation.resumeWithException(e) + } + + } + } + } + }) + + } +} \ No newline at end of file diff --git a/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/OkHttpSubscriptionAdapter.kt b/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/OkHttpSubscriptionAdapter.kt new file mode 100644 index 0000000..52f7594 --- /dev/null +++ b/polkaj-api-okhttp/src/main/kotlin/io/emeraldpay/polkaj/apiokhttp/OkHttpSubscriptionAdapter.kt @@ -0,0 +1,339 @@ +package io.emeraldpay.polkaj.apiokhttp + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import io.emeraldpay.polkaj.api.* +import io.emeraldpay.polkaj.api.internal.DecodeResponse +import io.emeraldpay.polkaj.api.internal.WsResponse +import io.emeraldpay.polkaj.apiokhttp.Constants.APPLICATION_JSON +import io.emeraldpay.polkaj.json.jackson.PolkadotModule +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.future.asCompletableFuture +import okhttp3.* +import java.time.Duration +import java.util.concurrent.CompletableFuture +import java.util.function.Consumer + +/** + * OkHttp Websocket based client to Polkadot API. In addition to standard RPC calls it supports subscription to events, i.e. + * when a call provides multiple responses. + */ +class OkHttpSubscriptionAdapter private constructor( + private val target : String, + private val client : OkHttpClient, + private val scope : CoroutineScope, + private val rpcCoder: RpcCoder, + private val onClose : () -> Unit +) : SubscriptionAdapter { + + companion object{ + @JvmStatic + fun newBuilder() : Builder = Builder() + } + + private data class State( + val socketState : SocketState = SocketState.Idle, + val rpcCalls : List> = emptyList(), + val subscriptionCalls : List> = emptyList(), + val curSocketJob : Job? = null, + val startingSub : Set> = setOf() + ) + + private sealed class SocketState { + object Idle : SocketState() + object Connecting : SocketState() + data class Connected(val webSocket: WebSocket) : SocketState() + object Closing : SocketState() + object Closed : SocketState() + data class Failed(val throwable: Throwable?) : SocketState() + } + + private data class RpcDeferred( + val id : Int, + val call: RpcCall, + val deferred: CompletableDeferred + ) + + private class WebscoketFailedException(cause : Throwable?) : Exception(cause) + + @Suppress("BlockingMethodInNonBlockingContext") + private class FlowSubscription( + val id : String, + val call: SubscribeCall, + private val scope: CoroutineScope, + private val events : Flow>, + private val onClose: (FlowSubscription) -> Unit) : Subscription { + + private var job : Job? = null + + override fun handler(handler: Consumer>?) { + job?.cancel() + if(handler == null) return + @Suppress("UNCHECKED_CAST") + val consumer = handler as Consumer> + job = events.onEach { + consumer.accept(it) + }.launchIn(scope) + } + + override fun close() { + job?.cancel() + onClose(this) + } + } + + private val _state = MutableStateFlow(State()) + private val _messages = MutableSharedFlow(0) + + private val curState = _state.asStateFlow() + private val messages = _messages.asSharedFlow() + private val rpcEvents = messages.filter { it.type == WsResponse.Type.RPC }.map { it.asRpc() } + private val subscriptionEvents = messages.filter { it.type == WsResponse.Type.SUBSCRIPTION }.map { it.asEvent() } + private val decodeResponse : DecodeResponse + + init { + val rpcMapping = { id : Int -> + curState.value.rpcCalls.firstOrNull { it.id == id }?.call?.getResultType(rpcCoder.objectMapper.typeFactory) + } + val subMapping = { id : String -> + runBlocking { + curState.transformWhile { state -> + state.subscriptionCalls.firstOrNull{ it.id == id}?.call?.getResultType(rpcCoder.objectMapper.typeFactory)?.let { + emit(it) + } + state.startingSub.isNotEmpty() + }.first() + } + + } + decodeResponse = DecodeResponse(rpcCoder.objectMapper, rpcMapping, subMapping) + } + + override fun close() { + curState.value.socketState.let { + if(it is SocketState.Connected) it.webSocket.close(1000, "close") + } + onClose() + } + + @Suppress("UNCHECKED_CAST", "BlockingMethodInNonBlockingContext") + override fun produceRpcFuture(call: RpcCall): CompletableFuture { + val result = CompletableDeferred() + val exHandler = CoroutineExceptionHandler { _, throwable -> + result.completeExceptionally(throwable) + } + scope.launch(Dispatchers.IO + exHandler) { + val id = rpcCoder.nextId() + val payload = try { + rpcCoder.encode(id, call) + } catch (e : JsonProcessingException){ + result.completeExceptionally(e) + null + } + if(payload != null) { + val rpc = RpcDeferred(id, call, result) + _state.update { + it.copy(rpcCalls = it.rpcCalls + rpc) + } + curState.socket().send(String(payload)) + rpcEvents.first { it.id == id }.let { + if(it.error != null) result.completeExceptionally(RpcException(it.error.code, it.error.message, it.error.data)) + else result.complete(it.result as T) + }.also { + _state.update { + it.copy(rpcCalls = it.rpcCalls - rpc) + } + } + } + } + + return result.asCompletableFuture() + } + + @Suppress("UNCHECKED_CAST") + override fun subscribe(call: SubscribeCall): CompletableFuture> { + val start = RpcCall.create(String::class.java, call.method, *call.params) + _state.update { it.copy(startingSub = it.startingSub + start) } + return produceRpcFuture(start).thenApply { id -> + val events = subscriptionEvents.filter { it.id == id }.map { Subscription.Event(it.method, it.value as T) } + FlowSubscription(id, call, scope, events) { sub -> + produceRpcFuture(RpcCall.create(Boolean::class.java, call.unsubscribe, id)) + _state.update { + it.copy(subscriptionCalls = it.subscriptionCalls - sub) + } + }.also { sub -> + _state.update { + it.copy(subscriptionCalls = it.subscriptionCalls + sub, startingSub = it.startingSub - start) + } + } + } + } + + private fun createSocket() : Flow { + val request = Request.Builder().apply { + url(target) + header("User-Agent", "PolkaJ/OkHttp/0.5") + header("Content-Type", APPLICATION_JSON) + }.build() + + return callbackFlow { + trySend(SocketState.Connecting) + val webSocket = client.newWebSocket(request, object : WebSocketListener(){ + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + trySend(SocketState.Closed) + close() + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + trySend(SocketState.Closing) + close() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + t.printStackTrace(System.err) + trySend(SocketState.Failed(t)) + close() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val handler = CoroutineExceptionHandler{ _, throwable -> + throwable.printStackTrace(System.err) + } + @Suppress("BlockingMethodInNonBlockingContext") + scope.launch(Dispatchers.IO + handler) { _messages.emit(decodeResponse.decode(text)) } + } + + override fun onOpen(webSocket: WebSocket, response: Response) { + trySend(SocketState.Connected(webSocket)) + } + }) + + awaitClose { + if(curState.value.socketState == SocketState.Connecting) webSocket.cancel() + } + + }.buffer(Channel.UNLIMITED).onEach { + when(it){ + is SocketState.Connected -> _state += it + SocketState.Connecting -> _state += it + is SocketState.Failed -> handleSocketException(it.throwable) + else -> _state += SocketState.Idle + } + } + } + + private fun createSocketIfNeeded() = _state.update { + when(it.socketState){ + is SocketState.Connected, SocketState.Connecting -> { it } + else -> { + it.curSocketJob?.cancel() + val newJob = createSocket().catch { error -> + handleSocketException(error) + }.launchIn(scope) + it.copy(curSocketJob = newJob) + } + } + } + + private fun handleSocketException(t : Throwable?){ + val exception = WebscoketFailedException(t) + _state.update { state -> + state.rpcCalls.forEach { call-> call.deferred.completeExceptionally(exception) } + //TODO update subscription handler to take error events and report error here + state.copy(socketState = SocketState.Idle, rpcCalls = emptyList(), subscriptionCalls = emptyList()) + } + } + + private suspend fun StateFlow.socket() : WebSocket = also { + createSocketIfNeeded() + }.map { + it.socketState + }.transform { + if(it is SocketState.Connected) emit(it.webSocket) + }.first() + + private operator fun MutableStateFlow.plusAssign(socketState: SocketState) { + _state.update { + it.copy(socketState = socketState) + } + } + + class Builder private constructor( + private var target : String = "ws://127.0.0.1:9944", + private var client : OkHttpClient = OkHttpClient.Builder().apply { + callTimeout(Duration.ofMinutes(1)) + followRedirects(false) + }.build(), + private var scope : CoroutineScope = CoroutineScope(SupervisorJob()), + private var rpcCoder: RpcCoder = RpcCoder(ObjectMapper().registerModule(PolkadotModule())), + private var onClose : () -> Unit = { + scope.cancel() + client.dispatcher.executorService.shutdown() + } + ){ + companion object{ + operator fun invoke(block : Builder.() -> Unit) : OkHttpSubscriptionAdapter { + return Builder().apply { block() }.build() + } + + operator fun invoke() : Builder { + return Builder() + } + } + + /** + * Server address URL. + * By default, it will be set to "ws://127.0.0.1:9944" + * + * @param target URL + * @return builder + */ + fun connectTo(target: String) = apply { this.target = target } + + /** + * Provide a custom OkHttpClient configured + * + * @param client OkHttpClient + * @return builder + */ + fun client(client : OkHttpClient) = apply { this.client = client } + + /** + * CoroutineScope for requests and subscription. + * By default, a new Scope will be created. + */ + fun scope(scope : CoroutineScope) = apply { this.scope = scope } + + /** + * Provide a custom RpcCoder for rpc serialization. + * + * @param rpcCoder rpcCoder + * @return builder + */ + fun rpcCoder(rpcCoder : RpcCoder) = apply { this.rpcCoder = rpcCoder } + + /** + * Provide custom cleanup method. + * By default, it will cancel the [scope] and shutdown the [client] executorService + * + * @param block to be called on close. + * @return builder + */ + fun onClose(block : () -> Unit ) = apply { onClose = block } + + /** + * Provide a custom timeout + * By default, it is 1 minute. + */ + fun timeout(timeout : Duration) = apply { client = client.newBuilder().callTimeout(timeout).build() } + + /** + * Apply configuration and build a new adapter + * @return new instance of [OkHttpSubscriptionAdapter] + */ + fun build() : OkHttpSubscriptionAdapter = OkHttpSubscriptionAdapter(target, client, scope, rpcCoder, onClose) + } +} \ No newline at end of file diff --git a/polkaj-api-okhttp/src/test/groovy/io/emeraldpay/polkaj/apiokhttp/OkHttpRpcAdapterSpec.groovy b/polkaj-api-okhttp/src/test/groovy/io/emeraldpay/polkaj/apiokhttp/OkHttpRpcAdapterSpec.groovy new file mode 100644 index 0000000..fb87940 --- /dev/null +++ b/polkaj-api-okhttp/src/test/groovy/io/emeraldpay/polkaj/apiokhttp/OkHttpRpcAdapterSpec.groovy @@ -0,0 +1,17 @@ +package io.emeraldpay.polkaj.apiokhttp + +import io.emeraldpay.polkaj.api.RpcAdapterSpec +import io.emeraldpay.polkaj.api.RpcCallAdapter + +import java.time.Duration + +class OkHttpRpcAdapterSpec extends RpcAdapterSpec { + + @Override + RpcCallAdapter provideAdapter(String connectTo, String username, String password, Duration timeout) { + def builder = OkHttpRpcAdapter.newBuilder() + return builder.target(connectTo) + .timeout(timeout) + .basicAuth(username, password).build() + } +} diff --git a/polkaj-api-okhttp/src/test/groovy/io/emeraldpay/polkaj/apiokhttp/OkHttpSubscriptionAdapterSpec.groovy b/polkaj-api-okhttp/src/test/groovy/io/emeraldpay/polkaj/apiokhttp/OkHttpSubscriptionAdapterSpec.groovy new file mode 100644 index 0000000..ff8a728 --- /dev/null +++ b/polkaj-api-okhttp/src/test/groovy/io/emeraldpay/polkaj/apiokhttp/OkHttpSubscriptionAdapterSpec.groovy @@ -0,0 +1,15 @@ +package io.emeraldpay.polkaj.apiokhttp + +import io.emeraldpay.polkaj.api.SubscriptionAdapter +import io.emeraldpay.polkaj.api.SubscriptionAdapterSpec + +import java.time.Duration + +class OkHttpSubscriptionAdapterSpec extends SubscriptionAdapterSpec { + + @Override + SubscriptionAdapter provideAdapter(String connectTo) { + return OkHttpSubscriptionAdapter.newBuilder().connectTo(connectTo).timeout(Duration.ofSeconds(15)).build() + } + +} \ No newline at end of file diff --git a/polkaj-api-okhttp/src/test/kotlin/io/emeraldpay/polkaj/OkHttpRpcAdapterTests.kt b/polkaj-api-okhttp/src/test/kotlin/io/emeraldpay/polkaj/OkHttpRpcAdapterTests.kt new file mode 100644 index 0000000..0ecc7b2 --- /dev/null +++ b/polkaj-api-okhttp/src/test/kotlin/io/emeraldpay/polkaj/OkHttpRpcAdapterTests.kt @@ -0,0 +1,88 @@ +package io.emeraldpay.polkaj + +import com.fasterxml.jackson.core.JsonGenerationException +import io.emeraldpay.polkaj.api.RpcCall +import io.emeraldpay.polkaj.api.RpcCoder +import io.emeraldpay.polkaj.api.RpcException +import io.emeraldpay.polkaj.apiokhttp.OkHttpRpcAdapter +import io.mockk.* +import kotlinx.coroutines.test.TestCoroutineScope +import okhttp3.Callback +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.InputStream +import java.lang.IllegalStateException +import java.util.concurrent.ExecutionException +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class OkHttpRpcAdapterTests { + + private val target = "http://localhost:18080" + private val client = mockk() + private val scope = TestCoroutineScope() + private val rpcCoder = mockk(relaxed = true) + private val onClose = spyk({}) + private val adapter = OkHttpRpcAdapter.Builder{ + target(target) + client(client) + scope(scope) + rpcCoder(rpcCoder) + onClose(onClose) + basicAuth("alice", "secret") + } + + @Test + fun `the builder uses correct components`(){ + val responseString = "correct" + val call = RpcCall.create(String::class.java, "chain_getFinalisedHead") + val listener = CapturingSlot() + every { client.newCall(any()) } returns mockk(relaxed = true){ + every { enqueue(capture(listener)) } answers { + listener.captured.onResponse(mockk(), mockk(relaxed = true){ + every { code } returns 200 + every { header("content-type", any()) } returns "application/json" + }) + } + } + every { rpcCoder.decode(any(), any(), any()) } returns responseString + val response = adapter.produceRpcFuture(call).get() + adapter.close() + verify { + onClose.invoke() + } + assertEquals(responseString, response) + } + + @Test + fun `handles JsonProcessingException`(){ + val call = RpcCall.create(String::class.java, "chain_getFinalisedHead") + val listener = CapturingSlot() + every { client.newCall(any()) } returns mockk(relaxed = true){ + every { enqueue(capture(listener)) } answers { + listener.captured.onResponse(mockk(), mockk(relaxed = true){ + every { code } returns 200 + every { header("content-type", any()) } returns "application/json" + }) + } + } + every { rpcCoder.decode(any(), any(), any()) } throws JsonGenerationException("") + val throwable = assertThrows { + adapter.produceRpcFuture(call).get() + } + val cause = throwable.cause + assertIs(cause) + assertEquals(-32600, cause.code) + } + + @Test + fun `can not make a call after close`(){ + adapter.close() + val future = adapter.produceRpcFuture(RpcCall.create(String::class.java, "chain_getFinalisedHead")) + val throwable = assertThrows { + future.get() + } + assertIs(throwable.cause) + } +} \ No newline at end of file diff --git a/polkaj-api-okhttp/src/test/kotlin/io/emeraldpay/polkaj/OkHttpSubscriptionAdapterTests.kt b/polkaj-api-okhttp/src/test/kotlin/io/emeraldpay/polkaj/OkHttpSubscriptionAdapterTests.kt new file mode 100644 index 0000000..80ae8e3 --- /dev/null +++ b/polkaj-api-okhttp/src/test/kotlin/io/emeraldpay/polkaj/OkHttpSubscriptionAdapterTests.kt @@ -0,0 +1,158 @@ +package io.emeraldpay.polkaj + +import com.fasterxml.jackson.databind.ObjectMapper +import io.emeraldpay.polkaj.api.RpcCall +import io.emeraldpay.polkaj.api.RpcCoder +import io.emeraldpay.polkaj.api.StandardSubscriptions +import io.emeraldpay.polkaj.api.SubscribeCall +import io.emeraldpay.polkaj.apiokhttp.OkHttpSubscriptionAdapter +import io.emeraldpay.polkaj.json.jackson.PolkadotModule +import io.mockk.* +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Duration +import java.util.concurrent.ExecutionException +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.test.assertEquals + +class OkHttpSubscriptionAdapterTests { + + private val target = "ws://localhost:9999" + private val scope = TestCoroutineScope() + private val rpcCoder = mockk(relaxed = true){ + every { objectMapper } returns ObjectMapper().apply { registerModule(PolkadotModule()) } + } + private val client = mockk(relaxed = true) + private val onClose = spyk({}) + private val webSocket = mockk() + + private val listener = CapturingSlot() + private val request = CapturingSlot() + + private val finalizedHeadResponse = """{ + "jsonrpc" : "2.0", + "result" : "0x5d83f66b61701da4cbd7a60137db89c69469a4f798b62aba9176ab253b423828", + "id" : 0 + } + """.trimIndent() + + private val subscriptionAdapter = OkHttpSubscriptionAdapter.Builder { + timeout(Duration.ofSeconds(1)) + connectTo(target) + scope(scope) + rpcCoder(rpcCoder) + client(client) + onClose(onClose) + } + + @BeforeEach + fun setup(){ + every { + client.newWebSocket(capture(request), capture(listener)) + } answers { + listener.captured.onOpen(webSocket, mockk()) + webSocket + } + + every { rpcCoder.nextId() } returns 0 + } + + @AfterEach + fun cleanup(){ + scope.cleanupTestCoroutines() + } + + @Test + fun `client connects automatically`(){ + mockResponse() + val call = RpcCall.create(String::class.java, "chain_getFinalisedHead") + subscriptionAdapter.produceRpcFuture(call).get() + verify(exactly = 1) { + client.newWebSocket(any(), any()) + } + } + + @Test + fun `client doesn't make a second websocket when already connected`(){ + mockResponse() + val call = RpcCall.create(String::class.java, "chain_getFinalisedHead") + subscriptionAdapter.produceRpcFuture(call).get() + subscriptionAdapter.produceRpcFuture(call).get() + verify(exactly = 1) { + client.newWebSocket(any(), any()) + } + } + + @Test + fun `adapter closes given onClose method`(){ + mockResponse() + every { webSocket.close(any(), any()) } returns true + val call = RpcCall.create(String::class.java, "chain_getFinalisedHead") + subscriptionAdapter.produceRpcFuture(call).get() + subscriptionAdapter.close() + verify { + onClose.invoke() + webSocket.close(eq(1000), eq("close")) + } + } + + @Test + fun `socket failure given to pending request`(){ + val exception = Exception() + val call = RpcCall.create(String::class.java, "chain_getFinalisedHead") + every { webSocket.send(any()) } answers { + listener.captured.onMessage(webSocket, finalizedHeadResponse) + true + } andThenAnswer { + listener.captured.onFailure(webSocket, exception, null) + true + } + subscriptionAdapter.produceRpcFuture(call).get() + val future = subscriptionAdapter.produceRpcFuture(call) + + val caught = assertThrows { + future.get() + } + assertEquals(exception, caught.cause?.cause) + } + + @Test + fun `handles subscribe id race condition`() : Unit = runBlocking { + val subResponse = "{\"jsonrpc\":\"2.0\",\"result\":\"EsqruyKPnZvPZ6fr\",\"id\":0}" + val block = " {\"jsonrpc\":\"2.0\",\"method\":\"chain_newHead\",\"params\":{\"result\":{\"digest\":{\"logs\":[]},\"extrinsicsRoot\":\"0x9869230c3cc05051ce9afef4458d2515fb2141bfd3bdcd88292f41e17ea00ae7\",\"number\":\"0x1d878c\",\"parentHash\":\"0xbe9110f6da6a19ac645a27472e459dcca6eaf4ee4b0b12700ca5d566eea9a638\",\"stateRoot\":\"0x57059722d680b591a469937449df772b95625d4230b39a0a7d855e16d597f168\"},\"subscription\":\"EsqruyKPnZvPZ6fr\"}}\n" + val sub = StandardSubscriptions.getInstance().newHeads() + every { webSocket.send(any()) } answers { + val l = listener.captured + //simulate subscribe response faster than id for type placed in table + l.onMessage(webSocket, block) //will not be able to parse block until we give id on next line + l.onMessage(webSocket, subResponse) // reply with id + true + } + + withTimeout(500){ + suspendCoroutine { cont -> + subscriptionAdapter.subscribe(sub).get().handler { + cont.resume(Unit) + } + } + } + } + + private fun mockResponse(){ + every { webSocket.send(any()) } answers { + listener.captured.onMessage(webSocket, finalizedHeadResponse) + true + } + } +} \ No newline at end of file diff --git a/polkaj-api-ws/build.gradle b/polkaj-api-ws/build.gradle index 496ee64..afdaea3 100644 --- a/polkaj-api-ws/build.gradle +++ b/polkaj-api-ws/build.gradle @@ -5,6 +5,10 @@ compileJava { sourceCompatibility = '11' } +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} dependencies { api project(":polkaj-json-types") diff --git a/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/JavaHttpSubscriptionAdapter.java b/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/JavaHttpSubscriptionAdapter.java index 7b15b52..6c26b99 100644 --- a/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/JavaHttpSubscriptionAdapter.java +++ b/polkaj-api-ws/src/main/java/io/emeraldpay/polkaj/apiws/JavaHttpSubscriptionAdapter.java @@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import io.emeraldpay.polkaj.api.*; +import io.emeraldpay.polkaj.api.internal.DecodeResponse; +import io.emeraldpay.polkaj.api.internal.SubscriptionResponse; +import io.emeraldpay.polkaj.api.internal.WsResponse; import io.emeraldpay.polkaj.json.jackson.PolkadotModule; import java.net.URI; @@ -12,7 +15,6 @@ import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.time.Duration; -import java.util.Objects; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; @@ -193,11 +195,11 @@ public void accept(RpcResponse response) { @SuppressWarnings("unchecked") public void accept(SubscriptionResponse response) { - DefaultSubscription s = (DefaultSubscription) subscriptions.get(response.id); + DefaultSubscription s = (DefaultSubscription) subscriptions.get(response.getId()); if (s == null) { return; } - s.accept(new Subscription.Event<>(response.method, response.value)); + s.accept(new Subscription.Event<>(response.getMethod(), response.getValue())); } public boolean removeSubscription(String id) { @@ -224,45 +226,6 @@ public void close() { } } - static class SubscriptionResponse { - private final String id; - private final String method; - private final T value; - - public SubscriptionResponse(String id, String method, T value) { - this.id = id; - this.method = method; - this.value = value; - } - - public String getId() { - return id; - } - - public String getMethod() { - return method; - } - - public T getValue() { - return value; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof SubscriptionResponse)) return false; - SubscriptionResponse that = (SubscriptionResponse) o; - return id.equals(that.id) && - Objects.equals(method, that.method) && - Objects.equals(value, that.value); - } - - @Override - public int hashCode() { - return Objects.hash(id, method, value); - } - } - static class RequestExpectation { private final JavaType type; private final CompletableFuture handler; @@ -391,9 +354,9 @@ private void initDefaults() { } /** - * Apply configuration and build client + * Apply configuration and build adapter * - * @return new instance of PolkadotRpcClient + * @return new instance of JavaHttpSubscriptionAdapter */ public JavaHttpSubscriptionAdapter build() { initDefaults(); diff --git a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/CompactMode.java b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/CompactMode.java index acc5d7b..4268419 100644 --- a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/CompactMode.java +++ b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/CompactMode.java @@ -9,9 +9,9 @@ public enum CompactMode { FOUR((byte)0b10), BIGINT((byte)0b11); - private static BigInteger MAX = BigInteger.TWO.pow(536).subtract(BigInteger.ONE); + final private static BigInteger MAX = BigInteger.valueOf(2).pow(536).subtract(BigInteger.ONE); - private byte value; + final private byte value; private CompactMode(byte value) { this.value = value; diff --git a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/ScaleCodecWriter.java b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/ScaleCodecWriter.java index e01c355..b8b0106 100644 --- a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/ScaleCodecWriter.java +++ b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/ScaleCodecWriter.java @@ -123,7 +123,7 @@ public void writeOptional(ScaleWriter writer, Optional value) throws I if (writer instanceof BoolOptionalWriter || writer instanceof BoolWriter) { BOOL_OPT.write(this, (Optional) value); } else { - if (value.isEmpty()) { + if (!value.isPresent()) { BOOL.write(this, false); } else { BOOL.write(this, true); diff --git a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/reader/Int32Reader.java b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/reader/Int32Reader.java index 7388a26..32c72b6 100644 --- a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/reader/Int32Reader.java +++ b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/reader/Int32Reader.java @@ -21,6 +21,7 @@ public Integer read(ScaleCodecReader rdr) { buf.put(rdr.readByte()); buf.put(rdr.readByte()); buf.put(rdr.readByte()); - return buf.flip().getInt(); + //https://www.morling.dev/blog/bytebuffer-and-the-dreaded-nosuchmethoderror/ + return ((ByteBuffer)buf.flip()).getInt(); } } diff --git a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/writer/BoolOptionalWriter.java b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/writer/BoolOptionalWriter.java index 5afd498..ce4f886 100644 --- a/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/writer/BoolOptionalWriter.java +++ b/polkaj-scale/src/main/java/io/emeraldpay/polkaj/scale/writer/BoolOptionalWriter.java @@ -10,7 +10,7 @@ public class BoolOptionalWriter implements ScaleWriter> { @Override public void write(ScaleCodecWriter wrt, Optional value) throws IOException { - if (value.isEmpty()) { + if (!value.isPresent()) { wrt.directWrite(0); } else if (value.get()) { wrt.directWrite(2); diff --git a/polkaj-scale/src/test/groovy/io/emeraldpay/polkaj/scale/reader/BoolOptionalReaderSpec.groovy b/polkaj-scale/src/test/groovy/io/emeraldpay/polkaj/scale/reader/BoolOptionalReaderSpec.groovy index 4628365..c2c278d 100644 --- a/polkaj-scale/src/test/groovy/io/emeraldpay/polkaj/scale/reader/BoolOptionalReaderSpec.groovy +++ b/polkaj-scale/src/test/groovy/io/emeraldpay/polkaj/scale/reader/BoolOptionalReaderSpec.groovy @@ -36,7 +36,7 @@ class BoolOptionalReaderSpec extends Specification { when: def act = codec.read(reader) then: - act.isEmpty() + !act.isPresent() !codec.hasNext() } diff --git a/polkaj-schnorrkel-android/.gitignore b/polkaj-schnorrkel-android/.gitignore new file mode 100644 index 0000000..f443164 --- /dev/null +++ b/polkaj-schnorrkel-android/.gitignore @@ -0,0 +1 @@ +src/main/jniLibs \ No newline at end of file diff --git a/polkaj-schnorrkel-android/README.adoc b/polkaj-schnorrkel-android/README.adoc new file mode 100644 index 0000000..8376e0b --- /dev/null +++ b/polkaj-schnorrkel-android/README.adoc @@ -0,0 +1,9 @@ +== Schnorrkel / Ristretto x25519 (aka sr25519) + +Android Library Java wrapper around Rust implementation of the Schnorrkel / Ristretto x25519 + +Implementation is based on WASM code: + +- https://github.com/polkadot-js/wasm/ + +Rust libs are built using https://docs.rs/crate/cargo-ndk/2.4.1[cargo-ndk] see link:../cargo_ndk_prep.sh[cargo_ndk_prep.sh] \ No newline at end of file diff --git a/polkaj-schnorrkel-android/build.gradle b/polkaj-schnorrkel-android/build.gradle new file mode 100644 index 0000000..5ba066e --- /dev/null +++ b/polkaj-schnorrkel-android/build.gradle @@ -0,0 +1,128 @@ +import java.nio.file.Paths + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: "com.github.willir.rust.cargo-ndk-android" +apply plugin: 'maven-publish' +apply plugin: 'com.jfrog.bintray' + +android { + ndkVersion System.getenv("ANDROID_NDK_VERSION") + + compileSdkVersion 30 + + defaultConfig { + minSdkVersion 24 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + sourceSets { + main { + java { + srcDirs = ["../polkaj-schnorrkel-common/src/main/java"] + } + } + + } +} + + +dependencies { + androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" + androidTestImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'commons-codec:commons-codec:20041127.091804' +} + +repositories { + google() +} + +clean.doFirst { + delete "src/main/jniLibs" +} + +cargoNdk { + def rustDir = "${rootDir.absolutePath}/polkaj-schnorrkel-common/src/rust" + def cargoTarget = "$buildDir/rust" + def relativeCargoTarget = Paths.get(rustDir).relativize(Paths.get(cargoTarget)) + + module = "polkaj-schnorrkel-common/src/rust" + targetDirectory = relativeCargoTarget.toString() + extraCargoBuildArguments = ["--target-dir=$relativeCargoTarget"] + verbose = true +} + +afterEvaluate { + publishing { + publications { + // Creates a Maven publication called "release". + mavenAndroid(MavenPublication) { + // Applies the component for the release build variant. + from components.release + + // You can then customize attributes of the publication as shown below. + groupId = project.group + artifactId = project.name + version = project.version + + pom { + name = 'Polkadot Java Client' + description = 'Java client library to access Polkadot based networks' + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + } + } + } + } + +} + +bintray { + user = System.getenv('BINTRAY_USER') + key = System.getenv('BINTRAY_API_KEY') + + dryRun=false + publish=true + override=true + + publications = ['mavenAndroid'] + + pkg { + userOrg = 'emerald' + repo = 'polkaj' + name = project.name + licenses = ['Apache-2.0'] + vcsUrl = 'https://github.com/emeraldpay/polkaj.git' + labels = ['polkadot', 'blockchain'] + publicDownloadNumbers = true + + version { + name = project.version + description = 'PolkaJ ' + project.version + released = new Date() + vcsTag = project.version + + gpg { + sign = true + } + } + } +} \ No newline at end of file diff --git a/polkaj-schnorrkel-android/src/androidTest/java/io/emeralpay/polkaj/schnorrkel/SchnorrkelNativeAndroidTests.kt b/polkaj-schnorrkel-android/src/androidTest/java/io/emeralpay/polkaj/schnorrkel/SchnorrkelNativeAndroidTests.kt new file mode 100644 index 0000000..8ca5a40 --- /dev/null +++ b/polkaj-schnorrkel-android/src/androidTest/java/io/emeralpay/polkaj/schnorrkel/SchnorrkelNativeAndroidTests.kt @@ -0,0 +1,159 @@ +package io.emeralpay.polkaj.schnorrkel + +import io.emeraldpay.polkaj.schnorrkel.Schnorrkel +import io.emeraldpay.polkaj.schnorrkel.SchnorrkelException +import io.emeraldpay.polkaj.schnorrkel.SchnorrkelNative +import org.apache.commons.codec.binary.Hex +import org.junit.Test +import java.math.BigInteger +import java.security.SecureRandom +import kotlin.test.* + +class SchnorrkelNativeAndroidTests { + + private fun ByteArray.encodeHex() : String = String(Hex.encodeHex(this)) + private fun String.decodeHex() : ByteArray = Hex.decodeHex(toCharArray()) + + private val schnorrkel = SchnorrkelNative() + private val key1 = Schnorrkel.KeyPair( + "46ebddef8cd9bb167dc30878d7113b7e168e6f0646beffd77d69d39bad76b47a".decodeHex(), + "28b0ae221c6bb06856b287f60d7ea0d98552ea5a16db16956849aa371db3eb51fd190cce74df356432b410bd64682309d6dedb27c76845daf388557cbac3ca34".decodeHex() + ) + + @Test + fun canSign(){ + val result = schnorrkel.sign("".toByteArray(), key1) + assertNotNull(result) + assertEquals(64, result.size) + } + + @Test + fun throwsErrorOnShortSk(){ + assertFailsWith("SecretKey must be 64 bytes in length"){ + schnorrkel.sign("".toByteArray(), + Schnorrkel.KeyPair( + "46ebddef8cd9bb167dc30878d7113b7e168e6f0646beffd77d69d39bad76b47a".decodeHex(), + "28b0".decodeHex() + ) + ) + } + } + + @Test + fun signatureIsValid(){ + val msg = "hello".toByteArray() + val sig = schnorrkel.sign(msg, key1) + assertTrue { + schnorrkel.verify(sig, msg, key1) + } + } + + @Test + fun modifiedSignatureIsInvalid(){ + val msg = "hello".toByteArray() + val sig = schnorrkel.sign(msg, key1) + assertTrue { + schnorrkel.verify(sig, msg, key1) + } + assertFalse { + sig[0] = (sig[0] + 1).toByte() + schnorrkel.verify(sig, msg, key1) + } + } + + @Test + fun differentSignatureIsInvalid(){ + val msg = "hello".toByteArray() + val sig1 = schnorrkel.sign(msg, key1) + val sig2 = schnorrkel.sign("hello2".toByteArray(), key1) + assertTrue { + schnorrkel.verify(sig1, msg, key1) + } + assertFalse { + schnorrkel.verify(sig2, msg, key1) + } + } + + @Test + fun throwsErrorOnInvalidSignature(){ + val msg = "hello".toByteArray() + assertFailsWith { + schnorrkel.verify("00112233".decodeHex(), msg, key1) + } + } + + @Test + fun throwsErrorOnInvalidPubkey(){ + val msg = "hello".toByteArray() + val sig = schnorrkel.sign(msg, key1) + assertFailsWith { + schnorrkel.verify(sig, msg, Schnorrkel.PublicKey("11223344".decodeHex())) + } + } + + @Test + fun generatesWorkingKey(){ + val random = SecureRandom() + val msg = "hello".toByteArray() + val keypair = schnorrkel.generateKeyPair(random) + + assertNotNull(keypair) + assertEquals(Schnorrkel.PUBLIC_KEY_LENGTH, keypair.publicKey.size) + assertEquals(Schnorrkel.SECRET_KEY_LENGTH, keypair.secretKey.size) + assertNotEquals(BigInteger.ZERO, BigInteger(1, keypair.publicKey)) + assertNotEquals(BigInteger.ZERO, BigInteger(1, keypair.secretKey)) + + val sig = schnorrkel.sign(msg, keypair) + assertTrue { + schnorrkel.verify(sig, msg, keypair) + } + } + + @Test + fun generatesKeyFromDefaultSecureRandom(){ + val keypair = schnorrkel.generateKeyPair() + assertNotNull(keypair) + assertEquals(Schnorrkel.PUBLIC_KEY_LENGTH, keypair.publicKey.size) + assertEquals(Schnorrkel.SECRET_KEY_LENGTH, keypair.secretKey.size) + assertNotEquals(BigInteger.ZERO, BigInteger(1, keypair.publicKey)) + assertNotEquals(BigInteger.ZERO, BigInteger(1, keypair.secretKey)) + } + + @Test + fun generatesFromSeed(){ + val keypair = schnorrkel.generateKeyPairFromSeed("fac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e".decodeHex()) + assertNotNull(keypair) + assertEquals(Schnorrkel.PUBLIC_KEY_LENGTH, keypair.publicKey.size) + assertEquals(Schnorrkel.SECRET_KEY_LENGTH, keypair.secretKey.size) + assertNotEquals(BigInteger.ZERO, BigInteger(1, keypair.publicKey)) + assertNotEquals(BigInteger.ZERO, BigInteger(1, keypair.secretKey)) + assertEquals("46ebddef8cd9bb167dc30878d7113b7e168e6f0646beffd77d69d39bad76b47a", keypair.publicKey.encodeHex()) + } + + @Test + fun deriveKey(){ + val seed = "fac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e".decodeHex() + val cc = Schnorrkel.ChainCode.from("14416c696365".decodeHex()) // Alice + val base = schnorrkel.generateKeyPairFromSeed(seed) + val keypair = schnorrkel.deriveKeyPair(base, cc) + assertEquals("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", keypair.publicKey.encodeHex()) + } + + @Test + fun deriveKeySoft(){ + val seed = "fac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e".decodeHex() + val cc = Schnorrkel.ChainCode("0c666f6f00000000000000000000000000000000000000000000000000000000".decodeHex()) + val base = schnorrkel.generateKeyPairFromSeed(seed) + val keypair = schnorrkel.deriveKeyPairSoft(base, cc) + assertEquals("40b9675df90efa6069ff623b0fdfcf706cd47ca7452a5056c7ad58194d23440a", keypair.publicKey.encodeHex()) + } + + @Test + fun deriveSoftPublicKey(){ + val pub = "46ebddef8cd9bb167dc30878d7113b7e168e6f0646beffd77d69d39bad76b47a".decodeHex() + val cc = Schnorrkel.ChainCode.from("0c666f6f00000000000000000000000000000000000000000000000000000000".decodeHex()) + val softKey = schnorrkel.derivePublicKeySoft(Schnorrkel.PublicKey(pub), cc) + assertEquals("40b9675df90efa6069ff623b0fdfcf706cd47ca7452a5056c7ad58194d23440a", softKey.publicKey.encodeHex()) + } + +} \ No newline at end of file diff --git a/polkaj-schnorrkel-android/src/main/AndroidManifest.xml b/polkaj-schnorrkel-android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e849c2e --- /dev/null +++ b/polkaj-schnorrkel-android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/Schnorrkel.java b/polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/Schnorrkel.java similarity index 95% rename from polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/Schnorrkel.java rename to polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/Schnorrkel.java index 0ce82b1..791c55c 100644 --- a/polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/Schnorrkel.java +++ b/polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/Schnorrkel.java @@ -1,18 +1,7 @@ package io.emeraldpay.polkaj.schnorrkel; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; -import java.lang.invoke.VarHandle; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Arrays; -import java.util.logging.MemoryHandler; /** * Schnorrkel implements Schnorr signature on Ristretto compressed Ed25519 points, as well as related protocols like diff --git a/polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelException.java b/polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelException.java similarity index 100% rename from polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelException.java rename to polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelException.java diff --git a/polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelNative.java b/polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelNative.java similarity index 86% rename from polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelNative.java rename to polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelNative.java index 732f0ee..5b7b29b 100644 --- a/polkaj-schnorrkel/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelNative.java +++ b/polkaj-schnorrkel-common/src/main/java/io/emeraldpay/polkaj/schnorrkel/SchnorrkelNative.java @@ -30,11 +30,19 @@ public boolean verify(byte[] signature, byte[] message, PublicKey publicKey) thr @Override public KeyPair generateKeyPair() throws SchnorrkelException { - try { - return generateKeyPair(SecureRandom.getInstanceStrong()); - } catch (NoSuchAlgorithmException e) { + SecureRandom secureRandom; + try{ + try{ + SecureRandom.class.getMethod("getInstanceStrong"); + //noinspection NewApi + secureRandom = SecureRandom.getInstanceStrong(); + }catch (NoSuchMethodException e){ + secureRandom = new SecureRandom(); //Android 24 & 25 do not have getInstanceStrong + } + }catch(NoSuchAlgorithmException e){ throw new SchnorrkelException("Secure Random is not available"); } + return generateKeyPair(secureRandom); } @Override @@ -107,7 +115,7 @@ private static byte[] encodeKeyPair(Schnorrkel.KeyPair keyPair) { // JVM needs native libraries to be loaded from filesystem, so first we need to extract // files for current OS into a temp dir then load the file. if(!extractAndLoadJNI()) { - // load the native library, this is for running tests + // load the native library, this is for running tests and android System.loadLibrary(LIBNAME); } } catch (IOException e) { @@ -117,8 +125,11 @@ private static byte[] encodeKeyPair(Schnorrkel.KeyPair keyPair) { } } + ////noinspection NewApi added to allow android lint check to succeed for api 24-25 + // these apis will not be called in that case private static boolean extractAndLoadJNI() throws IOException { // define which of files bundled with Jar to extract + if(System.getProperty("java.runtime.name", "unknown").contains("android")) return false; String os = System.getProperty("os.name", "unknown").toLowerCase(); if (os.contains("win")) { os = "windows"; @@ -140,15 +151,21 @@ private static boolean extractAndLoadJNI() throws IOException { System.err.println("Library " + classpathFile + " is not found in the classpath"); return false; } + //noinspection NewApi Path dir = Files.createTempDirectory(LIBNAME); + //noinspection NewApi Path target = dir.resolve(filename); + //noinspection NewApi Files.copy(lib, target); + //noinspection NewApi System.load(target.toFile().getAbsolutePath()); System.out.println("library " + classpathFile + " is loaded"); // setup JVM to delete files on exit, when possible + //noinspection NewApi target.toFile().deleteOnExit(); + //noinspection NewApi dir.toFile().deleteOnExit(); return true; } diff --git a/polkaj-schnorrkel/src/rust/.gitignore b/polkaj-schnorrkel-common/src/rust/.gitignore similarity index 100% rename from polkaj-schnorrkel/src/rust/.gitignore rename to polkaj-schnorrkel-common/src/rust/.gitignore diff --git a/polkaj-schnorrkel/src/rust/Cargo.toml b/polkaj-schnorrkel-common/src/rust/Cargo.toml similarity index 83% rename from polkaj-schnorrkel/src/rust/Cargo.toml rename to polkaj-schnorrkel-common/src/rust/Cargo.toml index daa3d77..1e2c55d 100644 --- a/polkaj-schnorrkel/src/rust/Cargo.toml +++ b/polkaj-schnorrkel-common/src/rust/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "polkaj-schnorrkel" +name = "polkaj_schnorrkel" version = "0.3.0" [lib] diff --git a/polkaj-schnorrkel/src/rust/src/lib.rs b/polkaj-schnorrkel-common/src/rust/src/lib.rs similarity index 100% rename from polkaj-schnorrkel/src/rust/src/lib.rs rename to polkaj-schnorrkel-common/src/rust/src/lib.rs diff --git a/polkaj-schnorrkel/build.gradle b/polkaj-schnorrkel/build.gradle index f959f14..f985c56 100644 --- a/polkaj-schnorrkel/build.gradle +++ b/polkaj-schnorrkel/build.gradle @@ -1,6 +1,11 @@ apply from: '../common_java_app.gradle' -dependencies { +sourceSets { + main { + java { + srcDirs = ["../polkaj-schnorrkel-common/src/main/java"] + } + } } test { @@ -8,8 +13,8 @@ test { } task compileRust(type:Exec) { - workingDir 'src/rust' - commandLine 'cargo', 'build', '--release', '--target-dir=../../build/rust' + workingDir "../polkaj-schnorrkel-common/src/rust" + commandLine 'cargo', 'build', '--release', "--target-dir=${buildDir}/rust" } compileJava.dependsOn(compileRust) diff --git a/polkaj-schnorrkel/src/test/groovy/io/emeraldpay/polkaj/schnorrkel/SchnorrkelSpec.groovy b/polkaj-schnorrkel/src/test/groovy/io/emeraldpay/polkaj/schnorrkel/SchnorrkelSpec.groovy index d532a2b..b668596 100644 --- a/polkaj-schnorrkel/src/test/groovy/io/emeraldpay/polkaj/schnorrkel/SchnorrkelSpec.groovy +++ b/polkaj-schnorrkel/src/test/groovy/io/emeraldpay/polkaj/schnorrkel/SchnorrkelSpec.groovy @@ -2,12 +2,8 @@ package io.emeraldpay.polkaj.schnorrkel import nl.jqno.equalsverifier.EqualsVerifier import nl.jqno.equalsverifier.Warning -import org.apache.commons.codec.binary.Hex import spock.lang.Specification -import java.security.PrivateKey -import java.security.SecureRandom - class SchnorrkelSpec extends Specification { def "Provides native implementation"() { diff --git a/polkaj-tx/build.gradle b/polkaj-tx/build.gradle index b57bf4c..8b8c7e7 100644 --- a/polkaj-tx/build.gradle +++ b/polkaj-tx/build.gradle @@ -5,10 +5,12 @@ dependencies { api project(":polkaj-scale-types") api project(":polkaj-json-types") api project(":polkaj-ss58") - api project(":polkaj-schnorrkel") + compileOnly project(":polkaj-schnorrkel") api project(":polkaj-api-base") // for xxHash api 'net.openhft:zero-allocation-hashing:0.11' api 'org.bouncycastle:bcprov-jdk15on:1.65' + + testImplementation project(":polkaj-schnorrkel") } \ No newline at end of file diff --git a/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/AccountRequests.java b/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/AccountRequests.java index 0ef93fb..8a4ee68 100644 --- a/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/AccountRequests.java +++ b/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/AccountRequests.java @@ -73,7 +73,7 @@ public ByteData encodeRequest() { ByteBuffer buffer = ByteBuffer.allocate(len); Hashing.xxhash128(buffer, key1); Hashing.xxhash128(buffer, key2); - return new ByteData(buffer.flip().array()); + return new ByteData(((ByteBuffer)buffer.flip()).array()); } @Override @@ -100,7 +100,7 @@ public ByteData encodeRequest() { Hashing.xxhash128(buffer, key2); Hashing.blake2128(buffer, address); buffer.put(address.getPubkey()); - return new ByteData(buffer.flip().array()); + return new ByteData(((ByteBuffer)buffer.flip()).array()); } @Override diff --git a/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/ExtrinsicSigner.java b/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/ExtrinsicSigner.java index 7911a53..59f8a4c 100644 --- a/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/ExtrinsicSigner.java +++ b/polkaj-tx/src/main/java/io/emeraldpay/polkaj/tx/ExtrinsicSigner.java @@ -119,6 +119,9 @@ public boolean isValid(ExtrinsicContext ctx, CALL call, Extrinsic.Signature sign catch (SchnorrkelException | SignatureException | NoSuchAlgorithmException | InvalidKeySpecException | IOException | InvalidKeyException e) { throw new SignException("Failed to verify", e); } + catch(NoClassDefFoundError e){ + throw new SignException("Schnorrkel library not found. Did you forget to include it as a dependency?"); + } } private boolean isValidEd25519Signature(byte[] payload, Extrinsic.Signature signature, Address address) diff --git a/settings.gradle b/settings.gradle index 333c1ae..5ca2727 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,10 +5,12 @@ include "polkaj-ss58", "polkaj-json-types", "polkaj-scale-types", "polkaj-schnorrkel", + "polkaj-schnorrkel-android", "polkaj-common-types", "polkaj-api-base", "polkaj-api-http", "polkaj-api-ws", + "polkaj-api-okhttp", "polkaj-tx", "polkaj-adapter-tests"