diff --git a/src/NEAR/Serialization.cpp b/src/NEAR/Serialization.cpp index 16ec53f02b3..0a7cc3f7f84 100644 --- a/src/NEAR/Serialization.cpp +++ b/src/NEAR/Serialization.cpp @@ -9,8 +9,14 @@ #include "../BinaryCoding.h" #include "../PrivateKey.h" +#include + namespace TW::NEAR { +using json = nlohmann::json; + +static constexpr auto tokenTransferMethodName = "ft_transfer"; + static void writeU8(Data& data, uint8_t number) { data.push_back(number); } @@ -105,8 +111,31 @@ static void writeDeleteAccount(Data& data, const Proto::DeleteAccount& deleteAcc writeString(data, deleteAccount.beneficiary_id()); } +static void writeTokenTransfer(Data& data, const Proto::TokenTransfer& tokenTransfer) { + writeString(data, tokenTransferMethodName); + + json functionCallArgs = { + {"amount", tokenTransfer.token_amount()}, + {"receiver_id", tokenTransfer.receiver_id()}, + }; + auto functionCallArgsStr = functionCallArgs.dump(); + + writeU32(data, static_cast(functionCallArgsStr.size())); + writeRawBuffer(data, functionCallArgsStr); + + writeU64(data, tokenTransfer.gas()); + writeU128(data, tokenTransfer.deposit()); +} + static void writeAction(Data& data, const Proto::Action& action) { - writeU8(data, action.payload_case() - Proto::Action::kCreateAccount); + uint8_t actionByte = action.payload_case() - Proto::Action::kCreateAccount; + // `TokenTransfer` action is actually a `FunctionCall`, + // so we need to set the actionByte to the proper value. + if (action.payload_case() == Proto::Action::kTokenTransfer) { + actionByte = Proto::Action::kFunctionCall - Proto::Action::kCreateAccount; + } + + writeU8(data, actionByte); switch (action.payload_case()) { case Proto::Action::kFunctionCall: writeFunctionCall(data, action.function_call()); @@ -126,6 +155,9 @@ static void writeAction(Data& data, const Proto::Action& action) { case Proto::Action::kDeleteAccount: writeDeleteAccount(data, action.delete_account()); return; + case Proto::Action::kTokenTransfer: + writeTokenTransfer(data, action.token_transfer()); + return; default: return; } diff --git a/src/proto/NEAR.proto b/src/proto/NEAR.proto index c557e93ed21..a08668da60c 100644 --- a/src/proto/NEAR.proto +++ b/src/proto/NEAR.proto @@ -93,6 +93,21 @@ message DeleteAccount { string beneficiary_id = 1; } +// Fungible token transfer +message TokenTransfer { + // Token amount. Base-10 decimal string. + string token_amount = 1; + + // ID of the receiver. + string receiver_id = 2; + + // Gas. + uint64 gas = 3; + + // NEAR deposit amount; uint128 / big endian byte order. + bytes deposit = 4; +} + // Represents an action message Action { oneof payload { @@ -104,6 +119,8 @@ message Action { AddKey add_key = 6; DeleteKey delete_key = 7; DeleteAccount delete_account = 8; + // Gap in field numbering is intentional as it's not a standard NEAR action. + TokenTransfer token_transfer = 13; } } diff --git a/tests/chains/NEAR/TWAnySignerTests.cpp b/tests/chains/NEAR/TWAnySignerTests.cpp index 94b0af6b112..cc9192f58f2 100644 --- a/tests/chains/NEAR/TWAnySignerTests.cpp +++ b/tests/chains/NEAR/TWAnySignerTests.cpp @@ -127,4 +127,38 @@ TEST(TWAnySignerNEAR, SignUnstakeMainnetReplication) { ASSERT_EQ(Base64::encode(data(output.signed_transaction())), "QAAAAGI4ZDVkZjI1MDQ3ODQxMzY1MDA4ZjMwZmI2YjMwZGQ4MjBlOWE4NGQ4NjlmMDU2MjNkMTE0ZTk2ODMxZjJmYmYAzgCT6NK76nb1mB7pToefgkGUHfUe5BKvvr3gW/nq+MgGuu1Mq0YAABEAAABhdmFkby5wb29sdjEubmVhcq0YnhRlt+TTtagkoy0qKn56zAfGhE+jkTJW6PR5k5r8AQAAAAILAAAAdW5zdGFrZV9hbGwCAAAAe30A0JjUr3EAAAAAAAAAAAAAAAAAAAAAAAAABaFP0EkfJU3VQZ4QAiTwq9ebWDJ7jx7TxbA+VGH4hwKX3gWnmDHVve+LK7/UbbffjF/y8vn0KrPxdh3ONAG0Ag=="); } +/// Implements NEP-141: +/// https://nomicon.io/Standards/Tokens/FungibleToken/Core +/// +/// Successfully broadcasted tx: +/// https://explorer.near.org/transactions/ABQY6nfLdNrRVynHYNjYkfUM6Up5pDHHpuhRJe6FCMRu +TEST(TWAnySignerNEAR, SignTokenTransfer) { + auto privateKey = parse_hex("77006e227658c18da47546413926a26b839204b1b19e807c4a13d994d661c72e"); + + auto blockHash = Base58::decode("2dQBYs8XjprLLgtH7eVsJ3e58A5QuRcbuqFisSk9fFWQ"); + + // Deposit should be 1 yocto NEAR for security purposes. + auto deposit = parse_hex("01000000000000000000000000000000"); + + Proto::SigningInput input; + input.set_signer_id("105396228ac2e0ef144b93bcc5322fca1167d524422bb73d17440d35c714a58f"); + input.set_nonce(93062928000003); + input.set_receiver_id("token.paras.near"); + input.set_private_key(privateKey.data(), 32); + input.set_block_hash(blockHash.data(), blockHash.size()); + + auto& action = *input.add_actions(); + auto& tokenTransfer = *action.mutable_token_transfer(); + tokenTransfer.set_token_amount("100000000000000000"); + tokenTransfer.set_receiver_id("c6d5e3e8f328436f595856a598239b691d3d136b24c05a4614f9e9716edc14fe"); + tokenTransfer.set_gas(15000000000000); + tokenTransfer.set_deposit(deposit.data(), deposit.size()); + + Proto::SigningOutput output; + ANY_SIGN(input, TWCoinTypeNEAR); + + ASSERT_EQ(Base58::encode(data(output.hash())), "ABQY6nfLdNrRVynHYNjYkfUM6Up5pDHHpuhRJe6FCMRu"); + ASSERT_EQ(Base64::encode(data(output.signed_transaction())), "QAAAADEwNTM5NjIyOGFjMmUwZWYxNDRiOTNiY2M1MzIyZmNhMTE2N2Q1MjQ0MjJiYjczZDE3NDQwZDM1YzcxNGE1OGYAEFOWIorC4O8US5O8xTIvyhFn1SRCK7c9F0QNNccUpY8D5MPmo1QAABAAAAB0b2tlbi5wYXJhcy5uZWFyGC7O0jXN2b4SH1XfMtNISEnU8XATKOhZwxx0pLLZqTEBAAAAAgsAAABmdF90cmFuc2ZlcnAAAAB7ImFtb3VudCI6IjEwMDAwMDAwMDAwMDAwMDAwMCIsInJlY2VpdmVyX2lkIjoiYzZkNWUzZThmMzI4NDM2ZjU5NTg1NmE1OTgyMzliNjkxZDNkMTM2YjI0YzA1YTQ2MTRmOWU5NzE2ZWRjMTRmZSJ9APCrdaQNAAABAAAAAAAAAAAAAAAAAAAAANUjO7fmnTebSNW9EcHHwYwPNlQJcReGWJfJUuxWzPDAGEeo4JTcLB8pLCkqxKKsI0NE1Szv2+GAt5mCBum5mQY="); +} + } // namespace TW::NEAR