Skip to content

Commit

Permalink
[Evmos]: Fix signing, prehash, compile for Native Evmos (#3406)
Browse files Browse the repository at this point in the history
  • Loading branch information
satoshiotomakan authored Sep 1, 2023
1 parent 9deedbb commit a369164
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 12 deletions.
2 changes: 1 addition & 1 deletion samples/kmp/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("com.trustwallet:wallet-core-kotlin:3.2.13")
implementation("com.trustwallet:wallet-core-kotlin:3.2.17")
}
}
val commonTest by getting {
Expand Down
21 changes: 16 additions & 5 deletions src/Cosmos/ProtobufSerialization.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,21 @@
#include "Base64.h"
#include "uint256.h"

#include <google/protobuf/util/json_util.h>

using namespace TW;

namespace TW::Cosmos::Protobuf {

namespace internal {

// Some of the Cosmos blockchains use different public key types for address deriving and transaction signing.
// `registry.json` contains the public key required to derive an address,
// while this function prepares the given public key to use it for transaction signing/compiling.
inline PublicKey preparePublicKey(const PublicKey& publicKey, TWCoinType coin) {
return coin == TWCoinTypeNativeEvmos ? publicKey.compressed() : publicKey;
}

} // namespace internal

using json = nlohmann::json;
using string = std::string;
const auto ProtobufAnyNamespacePrefix = ""; // to override default 'type.googleapis.com'
Expand Down Expand Up @@ -407,6 +416,8 @@ std::string buildAuthInfo(const Proto::SigningInput& input, TWCoinType coin) {
}

std::string buildAuthInfo(const Proto::SigningInput& input, const PublicKey& publicKey, TWCoinType coin) {
const auto pbk = internal::preparePublicKey(publicKey, coin);

if (input.messages_size() >= 1 && input.messages(0).has_sign_direct_message()) {
return input.messages(0).sign_direct_message().auth_info_bytes();
}
Expand All @@ -419,19 +430,19 @@ std::string buildAuthInfo(const Proto::SigningInput& input, const PublicKey& pub
switch(coin) {
case TWCoinTypeNativeEvmos: {
auto pubKey = ethermint::crypto::v1::ethsecp256k1::PubKey();
pubKey.set_key(publicKey.bytes.data(), publicKey.bytes.size());
pubKey.set_key(pbk.bytes.data(), pbk.bytes.size());
signerInfo->mutable_public_key()->PackFrom(pubKey, ProtobufAnyNamespacePrefix);
break;
}
case TWCoinTypeNativeInjective: {
auto pubKey = injective::crypto::v1beta1::ethsecp256k1::PubKey();
pubKey.set_key(publicKey.bytes.data(), publicKey.bytes.size());
pubKey.set_key(pbk.bytes.data(), pbk.bytes.size());
signerInfo->mutable_public_key()->PackFrom(pubKey, ProtobufAnyNamespacePrefix);
break;
}
default: {
auto pubKey = cosmos::crypto::secp256k1::PubKey();
pubKey.set_key(publicKey.bytes.data(), publicKey.bytes.size());
pubKey.set_key(pbk.bytes.data(), pbk.bytes.size());
signerInfo->mutable_public_key()->PackFrom(pubKey, ProtobufAnyNamespacePrefix);
}
}
Expand Down
5 changes: 1 addition & 4 deletions src/Cosmos/Signer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,13 @@ Proto::SigningOutput Signer::sign(const Proto::SigningInput& input, TWCoinType c
}

std::string Signer::signaturePreimage(const Proto::SigningInput& input, const Data& publicKey, TWCoinType coin) const {
auto isEvmCosmosChain = [coin]() {
return coin == TWCoinTypeNativeInjective || coin == TWCoinTypeNativeEvmos || coin == TWCoinTypeNativeCanto;
};
switch (input.signing_mode()) {
case Proto::JSON:
return Json::signaturePreimageJSON(input).dump();

case Proto::Protobuf:
default:
auto pbk = isEvmCosmosChain() ? PublicKey(publicKey, TWPublicKeyTypeSECP256k1Extended) : PublicKey(publicKey, TWPublicKeyTypeSECP256k1);
auto pbk = PublicKey(publicKey, TWCoinTypePublicKeyType(coin));
return Protobuf::signaturePreimageProto(input, pbk, coin);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/Greenfield/ProtobufSerialization.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ static SigningResult<Any> convertMessage(const Proto::Message& msg) {
any.PackFrom(msgTransferOut, ProtobufAnyNamespacePrefix);
break;
}
default: {
return SigningResult<Any>::failure(Common::Proto::SigningError::Error_invalid_params);
}
}

return SigningResult<Any>::success(std::move(any));
Expand Down
2 changes: 1 addition & 1 deletion swift/Tests/Blockchains/EvmosTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class EvmosTests: XCTestCase {
let output: CosmosSigningOutput = AnySigner.sign(input: input, coin: .nativeEvmos)
// https://www.mintscan.io/evmos/txs/B05D2047086B158665EC552879270AEF40AEAAFEE7D275B63E9674E3CC4C4E55
let expected = """
{"mode":"BROADCAST_MODE_BLOCK","tx_bytes":"CpoBCpcBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEncKLGV2bW9zMXJrMzlkazN3ZmY1bnBzN2VtdWh2M250a24zbnN6NnoyZXJxZnIwEixldm1vczEwazlscnJydWFwOW51OTZteHd3eWUyZjZhNXdhemVoMzNrcTY3ehoZCgZhZXZtb3MSDzIwMDAwMDAwMDAwMDAwMBKbAQp3Cm8KKC9ldGhlcm1pbnQuY3J5cHRvLnYxLmV0aHNlY3AyNTZrMS5QdWJLZXkSQwpBBJR1yfoj7Gk2Z7qnbE2mm0nMz98FjE3LJ7pnz7yQgtntkHR4ZWCqaYsZu5cpUmscdZNPPUp4975xnkOGt0mzYxASBAoCCAESIAoaCgZhZXZtb3MSEDE0MDAwMDAwMDAwMDAwMDAQ4MUIGkDBSFkYlIx1/6ZvRtEHyq6r0EEFDb/5T2Cb9zVYWtYZE1e7YteG2GuYMX546PvBQ7CYAFZEkr+rU6okC0nAT0UK"}
{"mode":"BROADCAST_MODE_BLOCK","tx_bytes":"CpoBCpcBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEncKLGV2bW9zMXJrMzlkazN3ZmY1bnBzN2VtdWh2M250a24zbnN6NnoyZXJxZnIwEixldm1vczEwazlscnJydWFwOW51OTZteHd3eWUyZjZhNXdhemVoMzNrcTY3ehoZCgZhZXZtb3MSDzIwMDAwMDAwMDAwMDAwMBJ7ClcKTwooL2V0aGVybWludC5jcnlwdG8udjEuZXRoc2VjcDI1NmsxLlB1YktleRIjCiEClHXJ+iPsaTZnuqdsTaabSczP3wWMTcsnumfPvJCC2e0SBAoCCAESIAoaCgZhZXZtb3MSEDE0MDAwMDAwMDAwMDAwMDAQ4MUIGkAz9vh1EutbLrLZmRA4eK72bA6bhfMX0YnhtRl5jeaL3AYmk0qdrwG9XzzleBsZ++IokJIk47cgOOyvEjl92Jhj"}
"""
XCTAssertJSONEqual(output.serialized, expected)
XCTAssertEqual(output.errorMessage, "")
Expand Down
103 changes: 103 additions & 0 deletions tests/chains/Cosmos/NativeInjective/TransactionCompilerTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright © 2017-2023 Trust Wallet.
//
// This file is part of Trust. The full Trust copyright notice, including
// terms governing use, modification, and redistribution, is contained in the
// file LICENSE at the root of the source code distribution tree.

#include "Base64.h"
#include "Cosmos/Signer.h"
#include "HexCoding.h"
#include "proto/Cosmos.pb.h"
#include "proto/TransactionCompiler.pb.h"
#include "TrustWalletCore/TWAnySigner.h"
#include "TestUtilities.h"
#include "TransactionCompiler.h"

namespace TW::Cosmos::nativeInjective::tests {

TEST(NativeInjectiveCompiler, CompileWithSignatures) {
// Successfully broadcasted: https://www.mintscan.io/injective/transactions/B77D61590353C4AEDEAE2BBFF9E406DCF90E8D3A1A723BF22860F1E0A2617058

const auto coin = TWCoinTypeNativeInjective;
TW::Cosmos::Proto::SigningInput input;

PrivateKey privateKey =
PrivateKey(parse_hex("727513ec3c54eb6fae24f2ff756bbc4c89b82945c6538bbd173613ae3de719d3"));
input.set_private_key(privateKey.bytes.data(), privateKey.bytes.size());

/// Step 1: Prepare transaction input (protobuf)
input.set_account_number(88701);
input.set_chain_id("injective-1");
input.set_memo("");
input.set_sequence(0);

PublicKey publicKey = privateKey.getPublicKey(TWCoinTypePublicKeyType(coin));
const auto pubKeyBz = publicKey.bytes;
ASSERT_EQ(hex(pubKeyBz), "04088ac2919987d927368cb2be2ade44cd0ed3616745a9699cae264b3fc5a7c3607d99f441b8340990ee990cb3eaf560f1f0bafe600c7e94a4be8392166984f728");
input.set_public_key(pubKeyBz.data(), pubKeyBz.size());

auto msg = input.add_messages();
auto& message = *msg->mutable_send_coins_message();
message.set_from_address("inj1d0jkrsd09c7pule43y3ylrul43lwwcqaky8w8c");
message.set_to_address("inj1xmpkmxr4as00em23tc2zgmuyy2gr4h3wgcl6vd");
auto amountOfTx = message.add_amounts();
amountOfTx->set_denom("inj");
amountOfTx->set_amount("10000000000");

auto& fee = *input.mutable_fee();
fee.set_gas(110000);
auto amountOfFee = fee.add_amounts();
amountOfFee->set_denom("inj");
amountOfFee->set_amount("100000000000000");

/// Step 2: Obtain protobuf preimage hash
input.set_signing_mode(TW::Cosmos::Proto::Protobuf);
auto protoInputString = input.SerializeAsString();
auto protoInputData = TW::Data(protoInputString.begin(), protoInputString.end());

const auto preImageHashData = TransactionCompiler::preImageHashes(coin, protoInputData);
auto preSigningOutput = TW::TxCompiler::Proto::PreSigningOutput();
ASSERT_TRUE(
preSigningOutput.ParseFromArray(preImageHashData.data(), (int)preImageHashData.size()));
ASSERT_EQ(preSigningOutput.error(), Common::Proto::OK);
auto preImage = preSigningOutput.data();
auto preImageHash = preSigningOutput.data_hash();

EXPECT_EQ(
hex(preImage),
"0a8f010a8c010a1c2f636f736d6f732e62616e6b2e763162657461312e4d736753656e64126c0a2a696e6a3164306a6b7273643039633770756c6534337933796c72756c34336c77776371616b7938773863122a696e6a31786d706b6d78723461733030656d32337463327a676d7579793267723468337767636c3676641a120a03696e6a120b3130303030303030303030129c010a7c0a740a2d2f696e6a6563746976652e63727970746f2e763162657461312e657468736563703235366b312e5075624b657912430a4104088ac2919987d927368cb2be2ade44cd0ed3616745a9699cae264b3fc5a7c3607d99f441b8340990ee990cb3eaf560f1f0bafe600c7e94a4be8392166984f72812040a020801121c0a160a03696e6a120f31303030303030303030303030303010b0db061a0b696e6a6563746976652d3120fdb405");
EXPECT_EQ(hex(preImageHash),
"57dc10c3d1893ff16e1f5a47fa4b2e96f37b9c57d98a42d88c971debb4947ec9");


auto expectedTx = R"({"mode":"BROADCAST_MODE_BLOCK","tx_bytes":"Co8BCowBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmwKKmluajFkMGprcnNkMDljN3B1bGU0M3kzeWxydWw0M2x3d2NxYWt5OHc4YxIqaW5qMXhtcGtteHI0YXMwMGVtMjN0YzJ6Z211eXkyZ3I0aDN3Z2NsNnZkGhIKA2luahILMTAwMDAwMDAwMDASnAEKfAp0Ci0vaW5qZWN0aXZlLmNyeXB0by52MWJldGExLmV0aHNlY3AyNTZrMS5QdWJLZXkSQwpBBAiKwpGZh9knNoyyvireRM0O02FnRalpnK4mSz/Fp8NgfZn0Qbg0CZDumQyz6vVg8fC6/mAMfpSkvoOSFmmE9ygSBAoCCAESHAoWCgNpbmoSDzEwMDAwMDAwMDAwMDAwMBCw2wYaQPep7ApSEXC7VWbKlz08c6G2mxYtmc4CIFkYmZHsRAY3MzOU/xyedfrYTrEUOTlp8gmJsDbx3+0olJ6QbcAHdCE="})";
Data signature;

{
TW::Cosmos::Proto::SigningOutput output;
ANY_SIGN(input, coin);
assertJSONEqual(
output.serialized(),
expectedTx);

signature = data(output.signature());
EXPECT_EQ(hex(signature),
"f7a9ec0a521170bb5566ca973d3c73a1b69b162d99ce022059189991ec440637333394ff1c9e75fad84eb114393969f20989b036f1dfed28949e906dc0077421");

ASSERT_TRUE(publicKey.verify(signature, data(preImageHash.data())));
}

{
const Data outputData = TransactionCompiler::compileWithSignatures(
coin, protoInputData, {signature}, {publicKey.bytes});
Cosmos::Proto::SigningOutput output;
ASSERT_TRUE(output.ParseFromArray(outputData.data(), (int)outputData.size()));

EXPECT_EQ(output.error(), Common::Proto::OK);
EXPECT_EQ(output.serialized(), expectedTx);
EXPECT_EQ(output.signature(), "");
EXPECT_EQ(hex(output.signature()), "");
}
}

}
2 changes: 1 addition & 1 deletion tests/chains/Evmos/SignerTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ TEST(EvmosSigner, CompoundingAuthz) {
auto output = Signer::sign(input, TWCoinTypeNativeEvmos);
auto expected = R"(
{
"mode":"BROADCAST_MODE_BLOCK","tx_bytes":"CvUBCvIBCh4vY29zbW9zLmF1dGh6LnYxYmV0YTEuTXNnR3JhbnQSzwEKLGV2bW9zMTJtOWdyZ2FzNjB5azBrdWx0MDc2dnhuc3Jxejh4cGp5OXJwZjNlEixldm1vczE4ZnpxNG5hYzI4Z2ZtYTZncWZ2a3B3cmdwbTVjdGFyMno5bXhmMxpxCmcKKi9jb3Ntb3Muc3Rha2luZy52MWJldGExLlN0YWtlQXV0aG9yaXphdGlvbhI5EjUKM2V2bW9zdmFsb3BlcjF1bWs0MDdlZWQ3YWY2YW52dXQ2bGxnMnpldm5mMGRuMGZlcXFueSABEgYI4LD6pgYSnQEKeQpvCigvZXRoZXJtaW50LmNyeXB0by52MS5ldGhzZWNwMjU2azEuUHViS2V5EkMKQQSAdlh24+rB/xlhO8/2FuT0Z12BRmhkQKL+4GWSNhb9p0uO6cUkpw8dpkYb5Wx+YsQgPvyUNSmiaO75siYg72ylEgQKAggBGAMSIAoaCgZhZXZtb3MSEDQ1MjE0NzUwMDAwMDAwMDAQ+4QLGkArFy2yWnZbP9aqxyx9KN8xlTVyWT5jolnrY5fbMtXeijVOGZIrFCtEg+xgjv6XpMTKK9A3cMMMcBAcuv2S+nN8"
"mode":"BROADCAST_MODE_BLOCK","tx_bytes":"CvUBCvIBCh4vY29zbW9zLmF1dGh6LnYxYmV0YTEuTXNnR3JhbnQSzwEKLGV2bW9zMTJtOWdyZ2FzNjB5azBrdWx0MDc2dnhuc3Jxejh4cGp5OXJwZjNlEixldm1vczE4ZnpxNG5hYzI4Z2ZtYTZncWZ2a3B3cmdwbTVjdGFyMno5bXhmMxpxCmcKKi9jb3Ntb3Muc3Rha2luZy52MWJldGExLlN0YWtlQXV0aG9yaXphdGlvbhI5EjUKM2V2bW9zdmFsb3BlcjF1bWs0MDdlZWQ3YWY2YW52dXQ2bGxnMnpldm5mMGRuMGZlcXFueSABEgYI4LD6pgYSfQpZCk8KKC9ldGhlcm1pbnQuY3J5cHRvLnYxLmV0aHNlY3AyNTZrMS5QdWJLZXkSIwohA4B2WHbj6sH/GWE7z/YW5PRnXYFGaGRAov7gZZI2Fv2nEgQKAggBGAMSIAoaCgZhZXZtb3MSEDQ1MjE0NzUwMDAwMDAwMDAQ+4QLGkAm17CZgB7m+CPVlITnrHosklMTL9zrUeGRs8FL8N0GcRami9zdJ+e3xuXOtJmwP7G6QNh85CRYjFj8a8lpmmJM"
})";
assertJSONEqual(output.serialized(), expected);
}
Expand Down
103 changes: 103 additions & 0 deletions tests/chains/Evmos/TransactionCompilerTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright © 2017-2023 Trust Wallet.
//
// This file is part of Trust. The full Trust copyright notice, including
// terms governing use, modification, and redistribution, is contained in the
// file LICENSE at the root of the source code distribution tree.

#include "Base64.h"
#include "Cosmos/Signer.h"
#include "HexCoding.h"
#include "proto/Cosmos.pb.h"
#include "proto/TransactionCompiler.pb.h"
#include "TrustWalletCore/TWAnySigner.h"
#include "TestUtilities.h"
#include "TransactionCompiler.h"

namespace TW::Cosmos::evmos::tests {

TEST(EvmosCompiler, CompileWithSignatures) {
// Successfully broadcasted: https://www.mintscan.io/evmos/transactions/02105B186FCA473C9F467B2D3BF487F6CE5DB26EE54BCD1667DDB7A2DA0E2489

const auto coin = TWCoinTypeNativeEvmos;
TW::Cosmos::Proto::SigningInput input;

PrivateKey privateKey =
PrivateKey(parse_hex("727513ec3c54eb6fae24f2ff756bbc4c89b82945c6538bbd173613ae3de719d3"));
input.set_private_key(privateKey.bytes.data(), privateKey.bytes.size());

/// Step 1: Prepare transaction input (protobuf)
input.set_account_number(106619981);
input.set_chain_id("evmos_9001-2");
input.set_memo("");
input.set_sequence(0);

PublicKey publicKey = privateKey.getPublicKey(TWCoinTypePublicKeyType(coin));
const auto pubKeyBz = publicKey.bytes;
ASSERT_EQ(hex(pubKeyBz), "04088ac2919987d927368cb2be2ade44cd0ed3616745a9699cae264b3fc5a7c3607d99f441b8340990ee990cb3eaf560f1f0bafe600c7e94a4be8392166984f728");
input.set_public_key(pubKeyBz.data(), pubKeyBz.size());

auto msg = input.add_messages();
auto& message = *msg->mutable_send_coins_message();
message.set_from_address("evmos1d0jkrsd09c7pule43y3ylrul43lwwcqa7vpy0g");
message.set_to_address("evmos17dh3frt0m6kdd3m9lr6e6sr5zz0rz8cvxd7u5t");
auto amountOfTx = message.add_amounts();
amountOfTx->set_denom("aevmos");
amountOfTx->set_amount("10000000000000000");

auto& fee = *input.mutable_fee();
fee.set_gas(137840);
auto amountOfFee = fee.add_amounts();
amountOfFee->set_denom("aevmos");
amountOfFee->set_amount("5513600000000000");

/// Step 2: Obtain protobuf preimage hash
input.set_signing_mode(TW::Cosmos::Proto::Protobuf);
auto protoInputString = input.SerializeAsString();
auto protoInputData = TW::Data(protoInputString.begin(), protoInputString.end());

const auto preImageHashData = TransactionCompiler::preImageHashes(coin, protoInputData);
auto preSigningOutput = TW::TxCompiler::Proto::PreSigningOutput();
ASSERT_TRUE(
preSigningOutput.ParseFromArray(preImageHashData.data(), (int)preImageHashData.size()));
ASSERT_EQ(preSigningOutput.error(), Common::Proto::OK);
auto preImage = preSigningOutput.data();
auto preImageHash = preSigningOutput.data_hash();

EXPECT_EQ(
hex(preImage),
"0a9c010a99010a1c2f636f736d6f732e62616e6b2e763162657461312e4d736753656e6412790a2c65766d6f733164306a6b7273643039633770756c6534337933796c72756c34336c7777637161377670793067122c65766d6f733137646833667274306d366b6464336d396c723665367372357a7a30727a3863767864377535741a1b0a066165766d6f7312113130303030303030303030303030303030127b0a570a4f0a282f65746865726d696e742e63727970746f2e76312e657468736563703235366b312e5075624b657912230a2102088ac2919987d927368cb2be2ade44cd0ed3616745a9699cae264b3fc5a7c36012040a02080112200a1a0a066165766d6f7312103535313336303030303030303030303010f0b4081a0c65766d6f735f393030312d3220cdc8eb32");
EXPECT_EQ(hex(preImageHash),
"9912eb629e215027b8d587939b1af72a9f70ae326bcaf48dfe77a729fc4ac632");


auto expectedTx = R"({"mode":"BROADCAST_MODE_BLOCK","tx_bytes":"CpwBCpkBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEnkKLGV2bW9zMWQwamtyc2QwOWM3cHVsZTQzeTN5bHJ1bDQzbHd3Y3FhN3ZweTBnEixldm1vczE3ZGgzZnJ0MG02a2RkM205bHI2ZTZzcjV6ejByejhjdnhkN3U1dBobCgZhZXZtb3MSETEwMDAwMDAwMDAwMDAwMDAwEnsKVwpPCigvZXRoZXJtaW50LmNyeXB0by52MS5ldGhzZWNwMjU2azEuUHViS2V5EiMKIQIIisKRmYfZJzaMsr4q3kTNDtNhZ0WpaZyuJks/xafDYBIECgIIARIgChoKBmFldm1vcxIQNTUxMzYwMDAwMDAwMDAwMBDwtAgaQKrmMaaSKnohf3ahyCOYdRJKBKJjr4WkkA/cbn6FRdF0Gd6FHSzBP8S4v4VNiy3KC47TD0C+sUBO413gCzjo8/U="})";
Data signature;

{
TW::Cosmos::Proto::SigningOutput output;
ANY_SIGN(input, coin);
assertJSONEqual(
output.serialized(),
expectedTx);

signature = data(output.signature());
EXPECT_EQ(hex(signature),
"aae631a6922a7a217f76a1c8239875124a04a263af85a4900fdc6e7e8545d17419de851d2cc13fc4b8bf854d8b2dca0b8ed30f40beb1404ee35de00b38e8f3f5");

ASSERT_TRUE(publicKey.verify(signature, data(preImageHash.data())));
}

{
const Data outputData = TransactionCompiler::compileWithSignatures(
coin, protoInputData, {signature}, {publicKey.bytes});
Cosmos::Proto::SigningOutput output;
ASSERT_TRUE(output.ParseFromArray(outputData.data(), (int)outputData.size()));

EXPECT_EQ(output.error(), Common::Proto::OK);
EXPECT_EQ(output.serialized(), expectedTx);
EXPECT_EQ(output.signature(), "");
EXPECT_EQ(hex(output.signature()), "");
}
}

}

0 comments on commit a369164

Please sign in to comment.