diff --git a/rust/tw_memory/src/ffi/tw_string.rs b/rust/tw_memory/src/ffi/tw_string.rs index b434715b10d..49932593f6c 100644 --- a/rust/tw_memory/src/ffi/tw_string.rs +++ b/rust/tw_memory/src/ffi/tw_string.rs @@ -2,6 +2,7 @@ // // Copyright © 2017 Trust Wallet. +use crate::ffi::c_byte_array_ref::CByteArrayRef; use crate::ffi::RawPtrTrait; use std::ffi::{c_char, CStr, CString}; @@ -14,6 +15,13 @@ use std::ffi::{c_char, CStr, CString}; pub struct TWString(CString); impl TWString { + pub unsafe fn is_utf8_string(bytes: *const u8, size: usize) -> bool { + let Some(bytes) = CByteArrayRef::new(bytes, size).to_vec() else { + return false; + }; + String::from_utf8(bytes).is_ok() + } + /// Returns an empty `TWString` instance. pub fn new() -> TWString { TWString(CString::default()) @@ -70,6 +78,13 @@ pub unsafe extern "C" fn tw_string_utf8_bytes(str: *const TWString) -> *const c_ .unwrap_or_else(std::ptr::null) } +/// Checks whether the C byte array is a UTF8 string. +/// \return true if the given C byte array is UTF-8 string, otherwise false. +#[no_mangle] +pub unsafe extern "C" fn tw_string_is_utf8_bytes(bytes: *const u8, size: usize) -> bool { + TWString::is_utf8_string(bytes, size) +} + /// Deletes a string created with a `TWStringCreate*` method and frees the memory. /// \param str a `TWString` pointer. #[no_mangle] diff --git a/src/Cardano/Transaction.cpp b/src/Cardano/Transaction.cpp index fa8fd157d53..eb6c41aa48d 100644 --- a/src/Cardano/Transaction.cpp +++ b/src/Cardano/Transaction.cpp @@ -2,6 +2,8 @@ // // Copyright © 2017 Trust Wallet. +#include + #include "Transaction.h" #include "AddressV3.h" @@ -9,13 +11,14 @@ #include "Hash.h" #include "HexCoding.h" #include "Numeric.h" +#include "rust/Wrapper.h" namespace TW::Cardano { TokenAmount TokenAmount::fromProto(const Proto::TokenAmount& proto) { - std::string assetName; + Data assetName; if (!proto.asset_name().empty()) { - assetName = proto.asset_name(); + assetName = data(proto.asset_name()); } else if (!proto.asset_name_hex().empty()) { auto assetNameData = parse_hex(proto.asset_name_hex()); assetName.assign(assetNameData.data(), assetNameData.data() + assetNameData.size()); @@ -29,13 +32,32 @@ Proto::TokenAmount TokenAmount::toProto() const { Proto::TokenAmount tokenAmount; tokenAmount.set_policy_id(policyId.data(), policyId.size()); - tokenAmount.set_asset_name(assetName.data(), assetName.size()); tokenAmount.set_asset_name_hex(assetNameHex.data(), assetNameHex.size()); const auto amountData = store(amount); tokenAmount.set_amount(amountData.data(), amountData.size()); + + if (const auto assetNameStr = assetNameToString(); assetNameStr.has_value()) { + tokenAmount.set_asset_name(assetNameStr.value().data(), assetNameStr.value().size()); + } return tokenAmount; } +std::string TokenAmount::displayAssetName() const { + if (const auto assetNameStr = assetNameToString(); assetNameStr.has_value()) { + return std::move(assetNameStr.value()); + } + return hex(assetName); +} + +std::optional TokenAmount::assetNameToString() const { + if (!Rust::tw_string_is_utf8_bytes(assetName.data(), assetName.size())) { + return std::nullopt; + } + std::string assetNameStr; + assetNameStr.assign(assetName.data(), assetName.data() + assetName.size()); + return assetNameStr; +} + TokenBundle TokenBundle::fromProto(const Proto::TokenBundle& proto) { TokenBundle ret; const auto addFunctor = [&ret](auto&& cur) { ret.add(TokenAmount::fromProto(cur)); }; @@ -108,18 +130,18 @@ uint64_t TokenBundle::minAdaAmount() const { } std::unordered_set policyIdRegistry; - std::unordered_set assetNameRegistry; + std::unordered_set assetNameRegistry; uint64_t sumAssetNameLengths = 0; for (const auto& t : bundle) { policyIdRegistry.emplace(t.second.policyId); - if (t.second.assetName.length() > 0) { + if (!t.second.assetName.empty()) { assetNameRegistry.emplace(t.second.assetName); } } auto numPids = uint64_t(policyIdRegistry.size()); auto numAssets = uint64_t(assetNameRegistry.size()); - for_each(assetNameRegistry.begin(), assetNameRegistry.end(), [&sumAssetNameLengths](auto&& a){ sumAssetNameLengths += a.length(); }); + for_each(assetNameRegistry.begin(), assetNameRegistry.end(), [&sumAssetNameLengths](auto&& a){ sumAssetNameLengths += a.size(); }); return minAdaAmountHelper(numPids, numAssets, sumAssetNameLengths); } @@ -256,7 +278,7 @@ Cbor::Encode cborizeOutputAmounts(const Amount& amount, const TokenBundle& token std::map subTokensMap; for (const auto& token : subTokens) { subTokensMap.emplace( - Cbor::Encode::bytes(data(token.assetName)), + Cbor::Encode::bytes(token.assetName), Cbor::Encode::uint(uint64_t(token.amount)) // 64 bits ); } diff --git a/src/Cardano/Transaction.h b/src/Cardano/Transaction.h index afdd47b4cb4..c0bb4dfe559 100644 --- a/src/Cardano/Transaction.h +++ b/src/Cardano/Transaction.h @@ -25,17 +25,20 @@ typedef uint64_t Amount; class TokenAmount { public: std::string policyId; - std::string assetName; + Data assetName; uint256_t amount; TokenAmount() = default; - TokenAmount(std::string policyId, std::string assetName, uint256_t amount) + TokenAmount(std::string policyId, Data assetName, uint256_t amount) : policyId(std::move(policyId)), assetName(std::move(assetName)), amount(std::move(amount)) {} static TokenAmount fromProto(const Proto::TokenAmount& proto); Proto::TokenAmount toProto() const; /// Key used in TokenBundle - std::string key() const { return policyId + "_" + assetName; } + std::string key() const { return policyId + "_" + displayAssetName(); } + std::string displayAssetName() const; + /// Tries to convert the `assetName` to a UTF-8 string. Returns `std::nullopt` otherwise. + std::optional assetNameToString() const; }; class TokenBundle { diff --git a/src/Data.h b/src/Data.h index 0d853173f32..7fe4a5112db 100644 --- a/src/Data.h +++ b/src/Data.h @@ -70,4 +70,14 @@ inline bool has_prefix(const Data& data, T& prefix) { return std::equal(prefix.begin(), prefix.end(), data.begin(), data.begin() + std::min(data.size(), prefix.size())); } +// Custom hash function for `Data` type. +struct DataHash { + std::size_t operator()(const Data& data) const { + // Create a string_view from the vector's data. + std::string_view ss(reinterpret_cast(data.data()), data.size()); + // Use the hash function for std::string_view + return std::hash{}(ss); + } +}; + } // namespace TW diff --git a/tests/chains/Cardano/SigningTests.cpp b/tests/chains/Cardano/SigningTests.cpp index ccc689580eb..83be54b7926 100644 --- a/tests/chains/Cardano/SigningTests.cpp +++ b/tests/chains/Cardano/SigningTests.cpp @@ -279,14 +279,14 @@ TEST(CardanoSigning, ExtraOutputPlan) { const auto toAddress = AddressV3(txOutput1.address); EXPECT_EQ(toAddress.string(), "addr1v9jxgu33wyunycmdddnh5a3edq6x2dt3xakkuun6wd6hsar8v9uhvee5w9erw7fnvauhswfhw44k673nv3n8sdmj89n82denweckuv34xvmnw6m9xeerq7rt8ymh5aesxaj8zu3e0y6k67tcd3nkzervxfenqer8ddjn27jkkrj"); EXPECT_EQ(txOutput1.tokenBundle.getByPolicyId(sundaeTokenPolicy)[0].amount, 3000000); - EXPECT_EQ(txOutput1.tokenBundle.getByPolicyId(sundaeTokenPolicy)[0].assetName, "CUBY"); + EXPECT_EQ(txOutput1.tokenBundle.getByPolicyId(sundaeTokenPolicy)[0].assetName, data("CUBY")); EXPECT_EQ(txOutput1.tokenBundle.getByPolicyId(sundaeTokenPolicy)[0].policyId, sundaeTokenPolicy); } { // also test proto toProto / toProto const auto toAddress = AddressV3("addr1q92cmkgzv9h4e5q7mnrzsuxtgayvg4qr7y3gyx97ukmz3dfx7r9fu73vqn25377ke6r0xk97zw07dqr9y5myxlgadl2s0dgke5"); std::vector tokenAmount; - tokenAmount.emplace_back(sundaeTokenPolicy, "CUBY", 3000000); + tokenAmount.emplace_back(sundaeTokenPolicy, data("CUBY"), 3000000); const Proto::TxOutput txOutputProto = TxOutput(toAddress.data(), 2000000, TokenBundle(tokenAmount)).toProto(); EXPECT_EQ(txOutputProto.amount(), 2000000ul); EXPECT_EQ(txOutputProto.address(), "addr1q92cmkgzv9h4e5q7mnrzsuxtgayvg4qr7y3gyx97ukmz3dfx7r9fu73vqn25377ke6r0xk97zw07dqr9y5myxlgadl2s0dgke5"); @@ -907,6 +907,80 @@ TEST(CardanoSigning, SignTransferTokenMaxAmount_620b71) { EXPECT_EQ(hex(txid), "620b719338efb419b0e1417bfbe01fc94a62d5669a4b8cbbf4e32ecc1ca3b872"); } +TEST(CardanoSigning, SignTransferTokenAmountNonUtf8) { + const auto ownAddress = "addr1q83kuum4jhwu3gxdwftdv2vezr0etmt3tp7phw5assltzl6t4afzguegnkcrdzp79vdcqswly775f33jvtpayl280qeqts960l"; + const auto privateKey = "009aba22621d98e008c266a8d19c493f5f80a3a4f55048a83168a9c856726852fc240e6e95d7dc4e8ea599d09d64f84fdbe951b2282f5e5ed374252d17be9507643b2d078e607b5327397f212e4f6607ff0b6dfc93bdc9ad2bd0a682887edb9f304a573e99c7c2022c925511f004c7c9b89e8569080d09e2c53dfb1d53726852d4735794e3d32eac2b17d4d7c94742a77b7400b66fa11eaeb6ae38ba2dea84612f0c38fd68b9751ed4cb4ac48fb5e19f985f809fff1cfe5303fbfd29aca43d66"; + const auto gensTokenPolicy = "dda5fdb1002f7389b33e036b6afee82a8189becb6cba852e8b79b4fb"; + // Non UTF-8 assetName according to https://github.com/cardano-foundation/CIPs/tree/master/CIP-0067 + const auto gensTokenNameHex = "0014df1047454e53"; + const auto currentSlot = 138'888'357ul; + + Proto::SigningInput input; + auto* utxo1 = input.add_utxos(); + const auto txHash1 = parse_hex("7b377e0cf7b83d67bb6919008c38e1a63be86c4831a93ad0cb45778b9f2f7e28"); + utxo1->mutable_out_point()->set_tx_hash(txHash1.data(), txHash1.size()); + utxo1->mutable_out_point()->set_output_index(4); + utxo1->set_address(ownAddress); + utxo1->set_amount(1'700'000ul); + // GENS token (asset1266q2ewhgul7jh3xqpvjzqarrepfjuler20akr). + auto* token1 = utxo1->add_token_amount(); + token1->set_policy_id(gensTokenPolicy); + token1->set_asset_name_hex(gensTokenNameHex); + const auto tokenAmount1 = store(uint256_t(44'660'987ul)); + token1->set_amount(tokenAmount1.data(), tokenAmount1.size()); + + const auto privateKeyData = parse_hex(privateKey); + input.add_private_key(privateKeyData.data(), privateKeyData.size()); + input.mutable_transfer_message()->set_to_address("addr1q875r037fjeqveg6xv5wke922ff897eyrnshlj3ryp4mypzt4afzguegnkcrdzp79vdcqswly775f33jvtpayl280qeq7zgptp"); + input.mutable_transfer_message()->set_change_address(ownAddress); + input.mutable_transfer_message()->set_amount(666ul); // doesn't matter, max is used + auto* toToken = input.mutable_transfer_message()->mutable_token_amount()->add_token(); + toToken->set_policy_id(gensTokenPolicy); + toToken->set_asset_name_hex(gensTokenNameHex); + const auto toTokenAmount = store(uint256_t(666ul)); // doesn't matter, max is used + input.mutable_transfer_message()->set_use_max_amount(true); + input.set_ttl(currentSlot + 7200ul); + + Proto::TransactionPlan plan; + ANY_PLAN(input, plan, TWCoinTypeCardano); + + EXPECT_EQ(plan.error(), Common::Proto::SigningError::OK); + { + EXPECT_EQ(plan.available_amount(), 1'700'000ul); + EXPECT_EQ(plan.amount(), 1'700'000ul - 167'818ul); + EXPECT_EQ(plan.fee(), 167'818ul); + EXPECT_EQ(plan.change(), 0ul); + EXPECT_EQ(plan.utxos_size(), 1); + EXPECT_EQ(plan.available_tokens_size(), 1); + + EXPECT_EQ(load(plan.available_tokens(0).amount()), 44'660'987ul); + // `assetName` must be empty as it's not a UTF-8 string. + EXPECT_EQ(plan.available_tokens(0).asset_name(), ""); + EXPECT_EQ(plan.available_tokens(0).asset_name_hex(), gensTokenNameHex); + + EXPECT_EQ(plan.output_tokens_size(), 1); + EXPECT_EQ(load(plan.output_tokens(0).amount()), 44'660'987ul); + // `assetName` must be empty as it's not a UTF-8 string. + EXPECT_EQ(plan.output_tokens(0).asset_name(), ""); + EXPECT_EQ(plan.output_tokens(0).asset_name_hex(), gensTokenNameHex); + EXPECT_EQ(plan.change_tokens_size(), 0); + } + + // set plan with specific fee, to match the real transaction + *input.mutable_plan() = plan; + + Proto::SigningOutput output; + ANY_SIGN(input, TWCoinTypeCardano); + + // https://cardanoscan.io/transaction/df89e81fbaec7485ba65ac3a2ffe4121a888f4937d085f3ad4f7e8e5192dea74 + // curl -d '{"txHash":"620b71..b872","txBody":"83a400..08f6"}' -H "Content-Type: application/json" https:///api/txs/submit + EXPECT_EQ(output.error(), Common::Proto::OK); + const auto encoded = data(output.encoded()); + EXPECT_EQ(hex(encoded), "83a400818258207b377e0cf7b83d67bb6919008c38e1a63be86c4831a93ad0cb45778b9f2f7e2804018182583901fd41be3e4cb206651a3328eb64aa525272fb241ce17fca23206bb2044baf522473289db036883e2b1b8041df27bd44c63262c3d27d477832821a00176116a1581cdda5fdb1002f7389b33e036b6afee82a8189becb6cba852e8b79b4fba1480014df1047454e531a02a978fb021a00028f8a031a084760c5a10081825820748022805ee71f9fa31d06e60f14f0715a37c278c0690b565f26e1e1e83f930e5840386c5d05fb5cfdb11f1296e909a80314616cdd2779e5be5ea583e1a938ee8409f58b585c90248e1c0633638cc0f4517c03fdb59f17434267c2955e0fbbb3b609f6"); + const auto txid = data(output.tx_id()); + EXPECT_EQ(hex(txid), "df89e81fbaec7485ba65ac3a2ffe4121a888f4937d085f3ad4f7e8e5192dea74"); +} + TEST(CardanoSigning, SignTransferTwoTokens) { auto input = createSampleInput(7000000); input.mutable_transfer_message()->set_amount(1500000); diff --git a/tests/chains/Cardano/TransactionTests.cpp b/tests/chains/Cardano/TransactionTests.cpp index 8f9a64a038c..58f8f987883 100644 --- a/tests/chains/Cardano/TransactionTests.cpp +++ b/tests/chains/Cardano/TransactionTests.cpp @@ -61,20 +61,20 @@ TEST(CardanoTransaction, minAdaAmount) { } { // 1 policyId, 1 6-char asset name - const auto tb = TokenBundle({TokenAmount(policyId, "TOKEN1", 0)}); + const auto tb = TokenBundle({TokenAmount(policyId, data("TOKEN1"), 0)}); EXPECT_EQ(tb.minAdaAmount(), 1444443ul); } { // 2 policyId, 2 4-char asset names auto tb = TokenBundle(); - tb.add(TokenAmount("012345678901234567890POLICY1", "TOK1", 20)); - tb.add(TokenAmount("012345678901234567890POLICY2", "TOK2", 20)); + tb.add(TokenAmount("012345678901234567890POLICY1", data("TOK1"), 20)); + tb.add(TokenAmount("012345678901234567890POLICY2", data("TOK2"), 20)); EXPECT_EQ(tb.minAdaAmount(), 1629628ul); } { // 10 policyId, 10 6-char asset names auto tb = TokenBundle(); for (auto i = 0; i < 10; ++i) { std::string policyId1 = + "012345678901234567890123456" + std::to_string(i); - std::string name = "ASSET" + std::to_string(i); + Data name = data("ASSET" + std::to_string(i)); tb.add(TokenAmount(policyId1, name, 0)); } EXPECT_EQ(tb.minAdaAmount(), 3370367ul); @@ -96,9 +96,9 @@ TEST(CardanoTransaction, getPolicyIDs) { const auto policyId1 = "012345678901234567890POLICY1"; const auto policyId2 = "012345678901234567890POLICY2"; const auto tb = TokenBundle({ - TokenAmount(policyId1, "TOK1", 10), - TokenAmount(policyId2, "TOK2", 20), - TokenAmount(policyId2, "TOK3", 30), // duplicate policyId + TokenAmount(policyId1, data("TOK1"), 10), + TokenAmount(policyId2, data("TOK2"), 20), + TokenAmount(policyId2, data("TOK3"), 30), // duplicate policyId }); ASSERT_EQ(tb.getPolicyIds().size(), 2ul); EXPECT_TRUE(tb.getPolicyIds().contains(policyId1)); @@ -116,8 +116,8 @@ TEST(TWCardanoTransaction, minAdaAmount) { } { // 2 policyId, 2 4-char asset names auto bundle = TokenBundle(); - bundle.add(TokenAmount("012345678901234567890POLICY1", "TOK1", 20)); - bundle.add(TokenAmount("012345678901234567890POLICY2", "TOK2", 20)); + bundle.add(TokenAmount("012345678901234567890POLICY1", data("TOK1"), 20)); + bundle.add(TokenAmount("012345678901234567890POLICY2", data("TOK2"), 20)); const auto bundleProto = bundle.toProto(); const auto bundleProtoData = data(bundleProto.SerializeAsString()); EXPECT_EQ(TWCardanoMinAdaAmount(&bundleProtoData), 1629628ul); @@ -145,7 +145,7 @@ TEST(TWCardanoTransaction, outputMinAdaAmount) { } { // 1 NFT auto bundle = TokenBundle(); - bundle.add(TokenAmount("219820e6cb04316f41a337fea356480f412e7acc147d28f175f21b5e", "coolcatssociety4567", 1)); + bundle.add(TokenAmount("219820e6cb04316f41a337fea356480f412e7acc147d28f175f21b5e", data("coolcatssociety4567"), 1)); const auto bundleProto = bundle.toProto(); const auto bundleProtoData = data(bundleProto.SerializeAsString()); const auto actual = WRAPS(TWCardanoOutputMinAdaAmount(toAddress.get(), &bundleProtoData, coinsPerUtxoByte.get())); @@ -153,8 +153,8 @@ TEST(TWCardanoTransaction, outputMinAdaAmount) { } { // 2 policyId, 2 4-char asset names auto bundle = TokenBundle(); - bundle.add(TokenAmount("8fef2d34078659493ce161a6c7fba4b56afefa8535296a5743f69587", "AADA", 20)); - bundle.add(TokenAmount("6ac8ef33b510ec004fe11585f7c5a9f0c07f0c23428ab4f29c1d7d10", "MELD", 20)); + bundle.add(TokenAmount("8fef2d34078659493ce161a6c7fba4b56afefa8535296a5743f69587", data("AADA"), 20)); + bundle.add(TokenAmount("6ac8ef33b510ec004fe11585f7c5a9f0c07f0c23428ab4f29c1d7d10", data("MELD"), 20)); const auto bundleProto = bundle.toProto(); const auto bundleProtoData = data(bundleProto.SerializeAsString()); const auto actual = WRAPS(TWCardanoOutputMinAdaAmount(toAddress.get(), &bundleProtoData, coinsPerUtxoByte.get()));