[PAN-2789] Add ethSigner acceptance test (#1655)

Signed-off-by: Adrian Sutton <adrian.sutton@consensys.net>
pull/2/head
Ivaylo Kirilov 5 years ago committed by MadelineMurray
parent 378821e36e
commit 0500d18950
  1. 5
      acceptance-tests/build.gradle
  2. 4
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/condition/eea/ExpectSuccessfulEeaGetTransactionReceipt.java
  3. 107
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/ethsigner/EthSignerClient.java
  4. 122
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/ethsigner/EthSignerClientTest.java
  5. 101
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/ethsigner/PrivateTransactionRequest.java
  6. 93
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/ethsigner/testutil/EthSignerConfig.java
  7. 28
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/ethsigner/testutil/EthSignerTestHarness.java
  8. 84
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/ethsigner/testutil/EthSignerTestHarnessFactory.java
  9. 8
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/PantheonNode.java
  10. 2
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/transaction/eea/PrivateTransactionBuilder.java
  11. 81
      acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/web3j/privacy/EthSignerAcceptanceTest.java
  12. 5
      gradle/versions.gradle
  13. 1
      testutil/build.gradle
  14. 1
      testutil/src/main/resources/ethSignerKey--fe3b557e8fb62b89f4916b721be55ceb828dbd73.json

@ -49,8 +49,11 @@ dependencies {
testImplementation 'org.awaitility:awaitility'
testImplementation 'org.java-websocket:Java-WebSocket'
testImplementation 'org.web3j:abi'
testImplementation 'org.web3j:core'
testImplementation 'org.web3j:eea'
testImplementation 'org.web3j:crypto'
testImplementation 'tech.pegasys.ethsigner.internal:core'
testImplementation 'tech.pegasys.ethsigner.internal:file-based'
testImplementation 'tech.pegasys.ethsigner.internal:signing-api'
testImplementation 'tech.pegasys.pantheon:plugin-api'
}

@ -13,6 +13,7 @@
package tech.pegasys.pantheon.tests.acceptance.dsl.condition.eea;
import static org.assertj.core.api.Assertions.assertThat;
import static tech.pegasys.pantheon.tests.acceptance.dsl.WaitUtils.waitFor;
import tech.pegasys.pantheon.tests.acceptance.dsl.condition.Condition;
import tech.pegasys.pantheon.tests.acceptance.dsl.node.Node;
@ -30,7 +31,10 @@ public class ExpectSuccessfulEeaGetTransactionReceipt implements Condition {
@Override
public void verify(final Node node) {
waitFor(
() -> {
final PrivateTransactionReceipt response = node.execute(transaction);
assertThat(response.getContractAddress()).isNotEqualTo("0x");
});
}
}

@ -0,0 +1,107 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.web3j.protocol.Web3jService;
import org.web3j.protocol.core.Request;
import org.web3j.protocol.core.methods.request.Transaction;
import org.web3j.protocol.core.methods.response.EthSendTransaction;
import org.web3j.protocol.eea.Eea;
import org.web3j.protocol.http.HttpService;
public class EthSignerClient {
private static final Logger LOG = LogManager.getLogger();
private final Web3jService web3jService;
private final Eea web3j;
private final String from;
public EthSignerClient(final URI ethSignerUri) throws IOException {
this.web3jService = new HttpService(ethSignerUri.toString());
this.web3j = Eea.build(web3jService);
this.from = resolveFrom(ethSignerUri);
}
private String resolveFrom(final URI ethSignerUri) throws IOException {
final List<String> accounts;
try {
accounts = ethAccounts();
return accounts.get(0);
} catch (IOException e) {
LOG.info("Failed to connect to EthSigner at {}", ethSignerUri);
throw e;
} catch (Exception e) {
LOG.info("Falling back to signing with node key");
}
return null;
}
public List<String> ethAccounts() throws IOException {
return web3j.ethAccounts().send().getAccounts();
}
public String ethSendTransaction(
final String to,
final BigInteger gas,
final BigInteger gasPrice,
final BigInteger value,
final String data,
final BigInteger nonce)
throws IOException {
return web3j
.ethSendTransaction(new Transaction(from, nonce, gasPrice, gas, to, value, data))
.send()
.getTransactionHash();
}
public String eeaSendTransaction(
final String to,
final BigInteger gas,
final BigInteger gasPrice,
final String data,
final BigInteger nonce,
final String privateFrom,
final List<String> privateFor,
final String restriction)
throws IOException {
final PrivateTransactionRequest transaction =
new PrivateTransactionRequest(
from,
nonce,
gasPrice,
gas,
to,
BigInteger.ZERO,
data,
privateFrom,
privateFor,
restriction);
// temporary until implemented in web3j
return new Request<>(
"eea_sendTransaction",
Collections.singletonList(transaction),
web3jService,
EthSendTransaction.class)
.send()
.getTransactionHash();
}
}

@ -0,0 +1,122 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertEquals;
import tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.testutil.EthSignerTestHarness;
import tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.testutil.EthSignerTestHarnessFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.util.Collections;
import java.util.List;
import com.sun.net.httpserver.HttpServer;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
public class EthSignerClientTest {
@ClassRule public static final TemporaryFolder folder = new TemporaryFolder();
private static final String MOCK_RESPONSE = "mock_transaction_hash";
private static final String MOCK_SEND_TRANSACTION_RESPONSE =
"{\n"
+ " \"id\":67,\n"
+ " \"jsonrpc\":\"2.0\",\n"
+ " \"result\": \""
+ MOCK_RESPONSE
+ "\"\n"
+ "}";
private static final String ENCLAVE_PUBLIC_KEY = "A1aVtMxLCUHmBVHXoZzzBgPbW/wj5axDpW9X8l91SGo=";
private static EthSignerClient ethSignerClient;
private static EthSignerTestHarness testHarness;
// The downstream server EthSigner should proxy requests to
private static HttpServer mockServer;
@BeforeClass
public static void setUpOnce() throws Exception {
folder.create();
testHarness =
EthSignerTestHarnessFactory.create(
folder.newFolder().toPath(),
"ethSignerKey--fe3b557e8fb62b89f4916b721be55ceb828dbd73.json",
1111,
8545,
2018);
ethSignerClient = new EthSignerClient(testHarness.getHttpListeningUrl());
mockServer = HttpServer.create(new InetSocketAddress(1111), 0);
mockServer.createContext(
"/",
exchange -> {
byte[] response = MOCK_SEND_TRANSACTION_RESPONSE.getBytes(UTF_8);
exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
mockServer.start();
}
@Test
public void testEthAccounts() throws IOException {
final List<String> accounts = ethSignerClient.ethAccounts();
assertEquals(1, accounts.size());
assertEquals("0xfe3b557e8fb62b89f4916b721be55ceb828dbd73", accounts.get(0));
}
@Test
public void testEthSendTransaction() throws IOException {
final String response =
ethSignerClient.ethSendTransaction(
"0xfe3b557e8fb62b89f4916b721be55ceb828dbd73",
BigInteger.ZERO,
BigInteger.ZERO,
BigInteger.ZERO,
"",
BigInteger.ZERO);
assertEquals(MOCK_RESPONSE, response);
}
@Test
public void testEeaSendTransaction() throws IOException {
final String response =
ethSignerClient.eeaSendTransaction(
"0xfe3b557e8fb62b89f4916b721be55ceb828dbd73",
BigInteger.ZERO,
BigInteger.ZERO,
"",
BigInteger.ZERO,
ENCLAVE_PUBLIC_KEY,
Collections.emptyList(),
"");
assertEquals(MOCK_RESPONSE, response);
}
@AfterClass
public static void bringDownOnce() {
mockServer.stop(0);
}
}

@ -0,0 +1,101 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner;
import java.math.BigInteger;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.web3j.utils.Numeric;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PrivateTransactionRequest {
private final String from;
private final BigInteger nonce;
private final BigInteger gasPrice;
private final BigInteger gas;
private final String to;
private final BigInteger value;
private final String data;
private final String privateFrom;
private final List<String> privateFor;
private final String restriction;
public PrivateTransactionRequest(
final String from,
final BigInteger nonce,
final BigInteger gasPrice,
final BigInteger gasLimit,
final String to,
final BigInteger value,
final String data,
final String privateFrom,
final List<String> privateFor,
final String restriction) {
this.from = from;
this.to = to;
this.gas = gasLimit;
this.gasPrice = gasPrice;
this.value = value;
this.data = data == null ? null : Numeric.prependHexPrefix(data);
this.nonce = nonce;
this.privateFrom = privateFrom;
this.privateFor = privateFor;
this.restriction = restriction;
}
public String getFrom() {
return from;
}
public String getTo() {
return to;
}
public String getGas() {
return convert(gas);
}
public String getGasPrice() {
return convert(gasPrice);
}
public String getValue() {
return convert(value);
}
public String getData() {
return data;
}
public String getNonce() {
return convert(nonce);
}
private String convert(final BigInteger value) {
return value == null ? null : Numeric.encodeQuantity(value);
}
public String getPrivateFrom() {
return privateFrom;
}
public List<String> getPrivateFor() {
return privateFor;
}
public String getRestriction() {
return restriction;
}
}

@ -0,0 +1,93 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.testutil;
import tech.pegasys.ethsigner.core.Config;
import tech.pegasys.ethsigner.core.signing.ChainIdProvider;
import java.net.InetAddress;
import java.nio.file.Path;
import java.time.Duration;
import org.apache.logging.log4j.Level;
public class EthSignerConfig implements Config {
private final Level logLevel;
private final InetAddress downstreamHttpHost;
private final Integer downStreamHttpPort;
private Duration downstreamHttpRequestTimeout;
private final InetAddress httpListenHost;
private final Integer httpListenPort;
private final ChainIdProvider chainId;
private final Path dataDirectory;
public EthSignerConfig(
final Level logLevel,
final InetAddress downstreamHttpHost,
final Integer downStreamHttpPort,
final Duration downstreamHttpRequestTimeout,
final InetAddress httpListenHost,
final Integer httpListenPort,
final ChainIdProvider chainId,
final Path dataDirectory) {
this.logLevel = logLevel;
this.downstreamHttpHost = downstreamHttpHost;
this.downStreamHttpPort = downStreamHttpPort;
this.downstreamHttpRequestTimeout = downstreamHttpRequestTimeout;
this.httpListenHost = httpListenHost;
this.httpListenPort = httpListenPort;
this.chainId = chainId;
this.dataDirectory = dataDirectory;
}
@Override
public Level getLogLevel() {
return logLevel;
}
@Override
public InetAddress getDownstreamHttpHost() {
return downstreamHttpHost;
}
@Override
public Integer getDownstreamHttpPort() {
return downStreamHttpPort;
}
@Override
public Duration getDownstreamHttpRequestTimeout() {
return downstreamHttpRequestTimeout;
}
@Override
public InetAddress getHttpListenHost() {
return httpListenHost;
}
@Override
public Integer getHttpListenPort() {
return httpListenPort;
}
@Override
public ChainIdProvider getChainId() {
return chainId;
}
@Override
public Path getDataPath() {
return dataDirectory;
}
}

@ -0,0 +1,28 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.testutil;
import java.net.URI;
public class EthSignerTestHarness {
private final EthSignerConfig config;
public EthSignerTestHarness(final EthSignerConfig config) {
this.config = config;
}
public URI getHttpListeningUrl() {
return URI.create(
"http://" + config.getHttpListenHost().getHostAddress() + ":" + config.getHttpListenPort());
}
}

@ -0,0 +1,84 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.testutil;
import static net.consensys.cava.io.file.Files.copyResource;
import static tech.pegasys.pantheon.tests.acceptance.dsl.WaitUtils.waitFor;
import tech.pegasys.ethsigner.core.EthSigner;
import tech.pegasys.ethsigner.core.signing.ConfigurationChainId;
import tech.pegasys.ethsigner.signer.filebased.CredentialTransactionSigner;
import java.io.IOException;
import java.net.InetAddress;
import java.nio.file.Path;
import java.time.Duration;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.web3j.crypto.CipherException;
import org.web3j.crypto.WalletUtils;
public class EthSignerTestHarnessFactory {
private static final Logger LOG = LogManager.getLogger();
private static final String HOST = "127.0.0.1";
public static EthSignerTestHarness create(
final Path tempDir,
final String keyPath,
final Integer pantheonPort,
final Integer ethsignerPort,
final long chainId)
throws IOException, CipherException {
final Path keyFilePath = copyResource(keyPath, tempDir.resolve(keyPath));
final EthSignerConfig config =
new EthSignerConfig(
Level.DEBUG,
InetAddress.getByName(HOST),
pantheonPort,
Duration.ofSeconds(10),
InetAddress.getByName(HOST),
ethsignerPort,
new ConfigurationChainId(chainId),
tempDir);
final EthSigner ethSigner =
new EthSigner(
config,
new CredentialTransactionSigner(
WalletUtils.loadCredentials("", keyFilePath.toAbsolutePath().toFile())));
ethSigner.run();
final OkHttpClient client = new OkHttpClient.Builder().build();
final Request request =
new Request.Builder()
.url("http://" + HOST + ":" + ethsignerPort + "/upcheck")
.get()
.build();
waitFor(
() -> {
client.newCall(request).execute();
});
LOG.info("EthSigner port: {}", config.getHttpListenPort());
return new EthSignerTestHarness(config);
}
}

@ -250,6 +250,14 @@ public class PantheonNode implements NodeConfiguration, RunnableNode, AutoClosea
}
}
public Optional<Integer> getJsonRpcSocketPort() {
if (isWebSocketsRpcEnabled()) {
return Optional.of(Integer.valueOf(portsProperties.getProperty("json-rpc")));
} else {
return Optional.empty();
}
}
@Override
public String getHostName() {
return LOCALHOST;

@ -27,7 +27,7 @@ import java.util.Optional;
public class PrivateTransactionBuilder {
private static BytesValue EVENT_EMITTER_CONSTRUCTOR =
public static BytesValue EVENT_EMITTER_CONSTRUCTOR =
BytesValue.fromHexString(
"0x608060405234801561001057600080fd5b5060008054600160a06"
+ "0020a03191633179055610199806100326000396000f3fe6080"

@ -0,0 +1,81 @@
/*
* Copyright 2019 ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package tech.pegasys.pantheon.tests.web3j.privacy;
import static tech.pegasys.pantheon.tests.acceptance.dsl.transaction.eea.PrivateTransactionBuilder.EVENT_EMITTER_CONSTRUCTOR;
import static tech.pegasys.pantheon.tests.web3j.privacy.PrivacyGroup.generatePrivacyGroup;
import tech.pegasys.pantheon.ethereum.core.Address;
import tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.EthSignerClient;
import tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.testutil.EthSignerTestHarness;
import tech.pegasys.pantheon.tests.acceptance.dsl.ethsigner.testutil.EthSignerTestHarnessFactory;
import tech.pegasys.pantheon.tests.acceptance.dsl.privacy.PrivacyAcceptanceTestBase;
import tech.pegasys.pantheon.tests.acceptance.dsl.privacy.PrivacyNet;
import tech.pegasys.pantheon.util.bytes.BytesValue;
import java.io.IOException;
import java.math.BigInteger;
import java.util.Collections;
import org.junit.Before;
import org.junit.Test;
public class EthSignerAcceptanceTest extends PrivacyAcceptanceTestBase {
private PrivacyNet privacyNet;
private EthSignerClient ethSignerClient;
@Before
public void setUp() throws Exception {
privacyNet =
PrivacyNet.builder(privacy, privacyPantheon, cluster, false).addMinerNode("Alice").build();
privacyNet.startPrivacyNet();
final EthSignerTestHarness ethsigner =
EthSignerTestHarnessFactory.create(
privacy.newFolder().toPath(),
"ethSignerKey--fe3b557e8fb62b89f4916b721be55ceb828dbd73.json",
privacyNet.getNode("Alice").getJsonRpcSocketPort().orElse(8545),
23606,
2018);
ethSignerClient = new EthSignerClient(ethsigner.getHttpListeningUrl());
}
@Test
public void privateSmartContractMustDeploy() throws IOException {
final BytesValue privacyGroupId = generatePrivacyGroup(privacyNet, "Alice");
final long nonce = privacyNet.getNode("Alice").nextNonce(privacyGroupId);
final String transactionHash =
ethSignerClient.eeaSendTransaction(
null,
BigInteger.valueOf(63992),
BigInteger.valueOf(1000),
EVENT_EMITTER_CONSTRUCTOR.toString(),
BigInteger.valueOf(nonce),
privacyNet.getEnclave("Alice").getPublicKeys().get(0),
Collections.emptyList(),
"restricted");
privacyNet.getNode("Alice").verify(eea.expectSuccessfulTransactionReceipt(transactionHash));
final String expectedContractAddress =
Address.privateContractAddress(
privacyNet.getNode("Alice").getAddress(), nonce, privacyGroupId)
.toString();
privateTransactionVerifier
.validPrivateContractDeployed(expectedContractAddress)
.verify(privacyNet.getNode("Alice"), transactionHash);
}
}

@ -86,9 +86,14 @@ dependencyManagement {
dependency 'org.web3j:abi:4.3.1'
dependency 'org.web3j:core:4.3.1'
dependency 'org.web3j:crypto:4.3.1'
dependency 'org.web3j:eea:4.3.1'
dependency 'org.xerial.snappy:snappy-java:1.1.7.3'
dependency "tech.pegasys.ethsigner.internal:core:0.2.0"
dependency "tech.pegasys.ethsigner.internal:file-based:0.2.0"
dependency "tech.pegasys.ethsigner.internal:signing-api:0.2.0"
dependency "tech.pegasys.pantheon:plugin-api:1.2.2"
}
}

@ -31,4 +31,5 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp'
implementation 'net.consensys:orion'
implementation 'org.mockito:mockito-core'
implementation 'org.web3j:core'
}

@ -0,0 +1 @@
{"address":"fe3b557e8fb62b89f4916b721be55ceb828dbd73","id":"004bac44-2955-40bf-8b1f-5376f428644f","version":3,"crypto":{"cipher":"aes-128-ctr","ciphertext":"28e23ffb25d1ef6a665a5a61866f353c2640b2ec55bd080f557ac4da8c8ba1d1","cipherparams":{"iv":"07bf8e210d7fbb5c4731ac15388a7939"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":4096,"p":6,"r":8,"salt":"8d454299a7ac29cbcd8817a8d1f12723592421d3e0a8bc56aff4719bf78bcec9"},"mac":"9073b2a8f454b0942b9350da96b1431e15edfdddb796ece0ab3fc6e14ca18190"}}
Loading…
Cancel
Save