diff --git a/include/xrpl/protocol/Feature.h b/include/xrpl/protocol/Feature.h index 90a81c55ef4..df071c69864 100644 --- a/include/xrpl/protocol/Feature.h +++ b/include/xrpl/protocol/Feature.h @@ -80,7 +80,7 @@ namespace detail { // Feature.cpp. Because it's only used to reserve storage, and determine how // large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than // the actual number of amendments. A LogicError on startup will verify this. -static constexpr std::size_t numFeatures = 83; +static constexpr std::size_t numFeatures = 84; /** Amendments that this server supports and the default voting behavior. Whether they are enabled depends on the Rules defined in the validated diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 72cf0b527b1..3aaedb46eed 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -329,6 +329,11 @@ mptoken(uint256 const& mptokenKey) Keylet mptoken(uint256 const& issuanceKey, AccountID const& holder) noexcept; +Keylet +permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; + +Keylet +permissionedDomain(uint256 const& domainID) noexcept; } // namespace keylet // Everything below is deprecated and should be removed in favor of keylets: diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index a9bd10a6fd1..dda8c24238f 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -104,6 +104,10 @@ std::size_t constexpr maxCredentialTypeLength = 64; /** The maximum number of credentials can be passed in array */ std::size_t constexpr maxCredentialsArraySize = 8; +/** The maximum number of credentials can be passed in array for permissioned + * domain */ +std::size_t constexpr maxPermissionedDomainCredentialsArraySize = 10; + /** The maximum length of MPTokenMetadata */ std::size_t constexpr maxMPTokenMetadataLength = 1024; diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 24c6e72ae34..e2d1fb4ed68 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -98,6 +98,7 @@ XRPL_FIX (1513, Supported::yes, VoteBehavior::DefaultYe XRPL_FEATURE(FlowCross, Supported::yes, VoteBehavior::DefaultYes) XRPL_FEATURE(Flow, Supported::yes, VoteBehavior::DefaultYes) XRPL_FEATURE(OwnerPaysFee, Supported::no, VoteBehavior::DefaultNo) +XRPL_FEATURE(PermissionedDomains, Supported::no, VoteBehavior::DefaultNo) // The following amendments are obsolete, but must remain supported diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 0cb1ec3416a..b75fb2dd84d 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -436,3 +436,16 @@ LEDGER_ENTRY(ltCREDENTIAL, 0x0081, Credential, ({ {sfPreviousTxnID, soeREQUIRED}, {sfPreviousTxnLgrSeq, soeREQUIRED}, })) + +/** A ledger object which tracks PermissionedDomain + \sa keylet::permissionedDomain + */ + +LEDGER_ENTRY(ltPERMISSIONED_DOMAIN, 0x0082, PermissionedDomain, ({ + {sfOwner, soeREQUIRED}, + {sfSequence, soeREQUIRED}, + {sfAcceptedCredentials, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, +})) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index ccf6350cbfc..b51b52c9682 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -190,6 +190,7 @@ TYPED_SFIELD(sfHookStateKey, UINT256, 30) TYPED_SFIELD(sfHookHash, UINT256, 31) TYPED_SFIELD(sfHookNamespace, UINT256, 32) TYPED_SFIELD(sfHookSetTxnID, UINT256, 33) +TYPED_SFIELD(sfDomainID, UINT256, 34) // currency amount (common) TYPED_SFIELD(sfAmount, AMOUNT, 1) @@ -372,3 +373,4 @@ UNTYPED_SFIELD(sfPriceDataSeries, ARRAY, 24) UNTYPED_SFIELD(sfAuthAccounts, ARRAY, 25) UNTYPED_SFIELD(sfAuthorizeCredentials, ARRAY, 26) UNTYPED_SFIELD(sfUnauthorizeCredentials, ARRAY, 27) +UNTYPED_SFIELD(sfAcceptedCredentials, ARRAY, 28) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 4f4c8f12595..6925b36778a 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -447,6 +447,17 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, ({ {sfCredentialType, soeREQUIRED}, })) +/** This transaction type creates or modifies a Permissioned Domain */ +TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 61, PermissionedDomainSet, ({ + {sfDomainID, soeOPTIONAL}, + {sfAcceptedCredentials, soeREQUIRED}, +})) + +/** This transaction type deletes a Permissioned Domain */ +TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 62, PermissionedDomainDelete, ({ + {sfDomainID, soeREQUIRED}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index f9e0db24949..20466f3e9c7 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -96,6 +96,7 @@ JSS(OracleDocumentID); // field JSS(Owner); // field JSS(Paths); // in/out: TransactionSign JSS(PayChannel); // ledger type. +JSS(PermissionedDomain); // ledger type. JSS(PriceDataSeries); // field. JSS(PriceData); // field. JSS(Provider); // field. @@ -521,6 +522,7 @@ JSS(peers); // out: InboundLedger, handlers/Peers, Overlay JSS(peer_disconnects); // Severed peer connection counter. JSS(peer_disconnects_resources); // Severed peer connections because of // excess resource consumption. +JSS(permissioned_domain); // out: AccountObjects JSS(port); // in: Connect, out: NetworkOPs JSS(ports); // out: NetworkOPs JSS(previous); // out: Reservations diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 12142879ad5..261c253c8cb 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -77,6 +77,7 @@ enum class LedgerNameSpace : std::uint16_t { MPTOKEN_ISSUANCE = '~', MPTOKEN = 't', CREDENTIAL = 'D', + PERMISSIONED_DOMAIN = 'm', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -519,6 +520,20 @@ credential( indexHash(LedgerNameSpace::CREDENTIAL, subject, issuer, credType)}; } +Keylet +permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept +{ + return { + ltPERMISSIONED_DOMAIN, + indexHash(LedgerNameSpace::PERMISSIONED_DOMAIN, account, seq)}; +} + +Keylet +permissionedDomain(uint256 const& domainID) noexcept +{ + return {ltPERMISSIONED_DOMAIN, domainID}; +} + } // namespace keylet } // namespace ripple diff --git a/src/test/app/PermissionedDomains_test.cpp b/src/test/app/PermissionedDomains_test.cpp new file mode 100644 index 00000000000..52c9e02aa03 --- /dev/null +++ b/src/test/app/PermissionedDomains_test.cpp @@ -0,0 +1,496 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ripple { +namespace test { +namespace jtx { + +static std::string +exceptionExpected(Env& env, Json::Value const& jv) +{ + try + { + env(jv, ter(temMALFORMED)); + } + catch (std::exception const& ex) + { + return ex.what(); + } + return {}; +} + +class PermissionedDomains_test : public beast::unit_test::suite +{ + FeatureBitset withFeature_{ + supported_amendments() | featurePermissionedDomains}; + FeatureBitset withoutFeature_{supported_amendments()}; + + // Verify that each tx type can execute if the feature is enabled. + void + testEnabled() + { + testcase("Enabled"); + Account const alice("alice"); + Env env(*this, withFeature_); + env.fund(XRP(1000), alice); + pd::Credentials credentials{{alice, "first credential"}}; + env(pd::setTx(alice, credentials)); + BEAST_EXPECT(pd::ownerInfo(alice, env)["OwnerCount"].asUInt() == 1); + auto objects = pd::getObjects(alice, env); + BEAST_EXPECT(objects.size() == 1); + // Test that account_objects is correct without passing it the type + BEAST_EXPECT(objects == pd::getObjects(alice, env, false)); + auto const domain = objects.begin()->first; + env(pd::deleteTx(alice, domain)); + } + + // Verify that each tx does not execute if feature is disabled + void + testDisabled() + { + testcase("Disabled"); + Account const alice("alice"); + Env env(*this, withoutFeature_); + env.fund(XRP(1000), alice); + pd::Credentials credentials{{alice, "first credential"}}; + env(pd::setTx(alice, credentials), ter(temDISABLED)); + env(pd::deleteTx(alice, uint256(75)), ter(temDISABLED)); + } + + // Verify that bad inputs fail for each of create new and update + // behaviors of PermissionedDomainSet + void + testBadData( + Account const& account, + Env& env, + std::optional domain = std::nullopt) + { + Account const alice2("alice2"); + Account const alice3("alice3"); + Account const alice4("alice4"); + Account const alice5("alice5"); + Account const alice6("alice6"); + Account const alice7("alice7"); + Account const alice8("alice8"); + Account const alice9("alice9"); + Account const alice10("alice10"); + Account const alice11("alice11"); + Account const alice12("alice12"); + auto const setFee(drops(env.current()->fees().increment)); + + // Test empty credentials. + env(pd::setTx(account, pd::Credentials(), domain), ter(temMALFORMED)); + + // Test 11 credentials. + pd::Credentials const credentials11{ + {alice2, "credential1"}, + {alice3, "credential2"}, + {alice4, "credential3"}, + {alice5, "credential4"}, + {alice6, "credential5"}, + {alice7, "credential6"}, + {alice8, "credential7"}, + {alice9, "credential8"}, + {alice10, "credential9"}, + {alice11, "credential10"}, + {alice12, "credential11"}}; + BEAST_EXPECT( + credentials11.size() == + maxPermissionedDomainCredentialsArraySize + 1); + env(pd::setTx(account, credentials11, domain), ter(temMALFORMED)); + + // Test credentials including non-existent issuer. + Account const nobody("nobody"); + pd::Credentials const credentialsNon{ + {alice2, "credential1"}, + {alice3, "credential2"}, + {alice4, "credential3"}, + {nobody, "credential4"}, + {alice5, "credential5"}, + {alice6, "credential6"}, + {alice7, "credential7"}}; + env(pd::setTx(account, credentialsNon, domain), ter(tecNO_ISSUER)); + + // Test bad fee + env(pd::setTx(account, credentials11, domain), + fee(1, true), + ter(temBAD_FEE)); + + pd::Credentials const credentials4{ + {alice2, "credential1"}, + {alice3, "credential2"}, + {alice4, "credential3"}, + {alice5, "credential4"}, + }; + auto txJsonMutable = pd::setTx(account, credentials4, domain); + auto const credentialOrig = txJsonMutable["AcceptedCredentials"][2u]; + + // Remove Issuer from a credential and apply. + txJsonMutable["AcceptedCredentials"][2u][jss::Credential].removeMember( + jss::Issuer); + BEAST_EXPECT( + exceptionExpected(env, txJsonMutable).starts_with("invalidParams")); + + txJsonMutable["AcceptedCredentials"][2u] = credentialOrig; + // Make an empty CredentialType. + txJsonMutable["AcceptedCredentials"][2u][jss::Credential] + ["CredentialType"] = ""; + env(txJsonMutable, ter(temMALFORMED)); + + // Remove Credentialtype from a credential and apply. + txJsonMutable["AcceptedCredentials"][2u][jss::Credential].removeMember( + "CredentialType"); + BEAST_EXPECT( + exceptionExpected(env, txJsonMutable).starts_with("invalidParams")); + + // Remove both + txJsonMutable["AcceptedCredentials"][2u][jss::Credential].removeMember( + jss::Issuer); + BEAST_EXPECT( + exceptionExpected(env, txJsonMutable).starts_with("invalidParams")); + + // Make 2 identical credentials. The duplicate should be silently + // removed. + { + pd::Credentials const credentialsDup{ + {alice7, "credential6"}, + {alice2, "credential1"}, + {alice3, "credential2"}, + {alice2, "credential1"}, + {alice5, "credential4"}, + }; + + std::unordered_map pubKey2Acc; + for (auto const& c : credentialsDup) + pubKey2Acc.emplace(c.issuer.human(), c.issuer); + + auto const sorted = pd::sortCredentials(credentialsDup); + BEAST_EXPECT(sorted.size() == 4); + env(pd::setTx(account, credentialsDup, domain), ter(temMALFORMED)); + + env.close(); + env(pd::setTx(account, sorted, domain)); + + uint256 d; + if (domain) + d = *domain; + else + d = pd::getNewDomain(env.meta()); + env.close(); + auto objects = pd::getObjects(account, env); + auto const fromObject = + pd::credentialsFromJson(objects[d], pubKey2Acc); + auto const sortedCreds = pd::sortCredentials(credentialsDup); + BEAST_EXPECT(fromObject == sortedCreds); + } + + // Have equal issuers but different credentials and make sure they + // sort correctly. + { + pd::Credentials const credentialsSame{ + {alice2, "credential3"}, + {alice3, "credential2"}, + {alice2, "credential9"}, + {alice5, "credential4"}, + {alice2, "credential6"}, + }; + std::unordered_map pubKey2Acc; + for (auto const& c : credentialsSame) + pubKey2Acc.emplace(c.issuer.human(), c.issuer); + + BEAST_EXPECT( + credentialsSame != pd::sortCredentials(credentialsSame)); + env(pd::setTx(account, credentialsSame, domain)); + + uint256 d; + if (domain) + d = *domain; + else + d = pd::getNewDomain(env.meta()); + env.close(); + auto objects = pd::getObjects(account, env); + auto const fromObject = + pd::credentialsFromJson(objects[d], pubKey2Acc); + auto const sortedCreds = pd::sortCredentials(credentialsSame); + BEAST_EXPECT(fromObject == sortedCreds); + } + } + + // Test PermissionedDomainSet + void + testSet() + { + testcase("Set"); + Env env(*this, withFeature_); + + const int accNum = 12; + Account const alice[accNum] = { + "alice", + "alice2", + "alice3", + "alice4", + "alice5", + "alice6", + "alice7", + "alice8", + "alice9", + "alice10", + "alice11", + "alice12"}; + std::unordered_map pubKey2Acc; + for (auto const& c : alice) + pubKey2Acc.emplace(c.human(), c); + + for (int i = 0; i < accNum; ++i) + env.fund(XRP(1000), alice[i]); + + // Create new from existing account with a single credential. + pd::Credentials const credentials1{{alice[2], "credential1"}}; + { + env(pd::setTx(alice[0], credentials1)); + BEAST_EXPECT( + pd::ownerInfo(alice[0], env)["OwnerCount"].asUInt() == 1); + auto tx = env.tx()->getJson(JsonOptions::none); + BEAST_EXPECT(tx[jss::TransactionType] == "PermissionedDomainSet"); + BEAST_EXPECT(tx["Account"] == alice[0].human()); + auto objects = pd::getObjects(alice[0], env); + auto domain = objects.begin()->first; + auto object = objects.begin()->second; + BEAST_EXPECT(object["LedgerEntryType"] == "PermissionedDomain"); + BEAST_EXPECT(object["Owner"] == alice[0].human()); + BEAST_EXPECT(object["Sequence"] == tx["Sequence"]); + BEAST_EXPECT( + pd::credentialsFromJson(object, pubKey2Acc) == credentials1); + } + + // Create new from existing account with 10 credentials. + pd::Credentials const credentials10{ + {alice[2], "credential1"}, + {alice[3], "credential2"}, + {alice[4], "credential3"}, + {alice[5], "credential4"}, + {alice[6], "credential5"}, + {alice[7], "credential6"}, + {alice[8], "credential7"}, + {alice[9], "credential8"}, + {alice[10], "credential9"}, + {alice[11], "credential10"}, + }; + uint256 domain2; + { + BEAST_EXPECT( + credentials10.size() == + maxPermissionedDomainCredentialsArraySize); + BEAST_EXPECT(credentials10 != pd::sortCredentials(credentials10)); + env(pd::setTx(alice[0], credentials10)); + auto tx = env.tx()->getJson(JsonOptions::none); + domain2 = pd::getNewDomain(env.meta()); + auto objects = pd::getObjects(alice[0], env); + auto object = objects[domain2]; + BEAST_EXPECT( + pd::credentialsFromJson(object, pubKey2Acc) == + pd::sortCredentials(credentials10)); + } + + // Update with 1 credential. + env(pd::setTx(alice[0], credentials1, domain2)); + BEAST_EXPECT( + pd::credentialsFromJson( + pd::getObjects(alice[0], env)[domain2], pubKey2Acc) == + credentials1); + + // Update with 10 credentials. + env(pd::setTx(alice[0], credentials10, domain2)); + env.close(); + BEAST_EXPECT( + pd::credentialsFromJson( + pd::getObjects(alice[0], env)[domain2], pubKey2Acc) == + pd::sortCredentials(credentials10)); + + // Update from the wrong owner. + env(pd::setTx(alice[2], credentials1, domain2), ter(tecNO_PERMISSION)); + + // Update a uint256(0) domain + env(pd::setTx(alice[0], credentials1, uint256(0)), ter(temMALFORMED)); + + // Update non-existent domain + env(pd::setTx(alice[0], credentials1, uint256(75)), ter(tecNO_ENTRY)); + + // Test bad data when creating a domain. + testBadData(alice[0], env); + // Test bad data when updating a domain. + testBadData(alice[0], env, domain2); + + // Try to delete the account with domains. + auto const acctDelFee(drops(env.current()->fees().increment)); + constexpr std::size_t deleteDelta = 255; + { + // Close enough ledgers to make it potentially deletable if empty. + std::size_t ownerSeq = + pd::ownerInfo(alice[0], env)["Sequence"].asUInt(); + while (deleteDelta + ownerSeq > env.current()->seq()) + env.close(); + env(acctdelete(alice[0], alice[2]), + fee(acctDelFee), + ter(tecHAS_OBLIGATIONS)); + } + + { + // Delete the domains and then the owner account. + for (auto const& objs : pd::getObjects(alice[0], env)) + env(pd::deleteTx(alice[0], objs.first)); + env.close(); + std::size_t ownerSeq = + pd::ownerInfo(alice[0], env)["Sequence"].asUInt(); + while (deleteDelta + ownerSeq > env.current()->seq()) + env.close(); + env(acctdelete(alice[0], alice[2]), fee(acctDelFee)); + } + } + + // Test PermissionedDomainDelete + void + testDelete() + { + testcase("Delete"); + Env env(*this, withFeature_); + Account const alice("alice"); + + env.fund(XRP(1000), alice); + auto const setFee(drops(env.current()->fees().increment)); + pd::Credentials credentials{{alice, "first credential"}}; + env(pd::setTx(alice, credentials)); + env.close(); + auto objects = pd::getObjects(alice, env); + BEAST_EXPECT(objects.size() == 1); + auto const domain = objects.begin()->first; + + // Delete a domain that doesn't belong to the account. + Account const bob("bob"); + env.fund(XRP(1000), bob); + env(pd::deleteTx(bob, domain), ter(tecNO_PERMISSION)); + + // Delete a non-existent domain. + env(pd::deleteTx(alice, uint256(75)), ter(tecNO_ENTRY)); + + // Test bad fee + env(pd::deleteTx(alice, uint256(75)), ter(temBAD_FEE), fee(1, true)); + + // Delete a zero domain. + env(pd::deleteTx(alice, uint256(0)), ter(temMALFORMED)); + + // Make sure owner count reflects the existing domain. + BEAST_EXPECT(pd::ownerInfo(alice, env)["OwnerCount"].asUInt() == 1); + auto const objID = pd::getObjects(alice, env).begin()->first; + BEAST_EXPECT(pd::objectExists(objID, env)); + // Delete domain that belongs to user. + env(pd::deleteTx(alice, domain), ter(tesSUCCESS)); + auto const tx = env.tx()->getJson(JsonOptions::none); + BEAST_EXPECT(tx[jss::TransactionType] == "PermissionedDomainDelete"); + // Make sure the owner count goes back to 0. + BEAST_EXPECT(pd::ownerInfo(alice, env)["OwnerCount"].asUInt() == 0); + // The object needs to be gone. + BEAST_EXPECT(pd::getObjects(alice, env).empty()); + BEAST_EXPECT(!pd::objectExists(objID, env)); + } + + void + testAccountReserve() + { + // Verify that the reserve behaves as expected for minting. + testcase("Account Reserve"); + + using namespace test::jtx; + + Env env(*this, withFeature_); + Account const alice("alice"); + + // Fund alice enough to exist, but not enough to meet + // the reserve. + auto const acctReserve = env.current()->fees().accountReserve(0); + auto const incReserve = env.current()->fees().increment; + env.fund(acctReserve, alice); + env.close(); + BEAST_EXPECT(env.balance(alice) == acctReserve); + BEAST_EXPECT(pd::ownerInfo(alice, env)["OwnerCount"].asUInt() == 0); + + // alice does not have enough XRP to cover the reserve. + pd::Credentials credentials{{alice, "first credential"}}; + env(pd::setTx(alice, credentials), ter(tecINSUFFICIENT_RESERVE)); + BEAST_EXPECT(pd::ownerInfo(alice, env)["OwnerCount"].asUInt() == 0); + BEAST_EXPECT(pd::getObjects(alice, env).size() == 0); + env.close(); + + auto const baseFee = env.current()->fees().base.drops(); + + // Pay alice almost enough to make the reserve. + env(pay(env.master, alice, incReserve + drops(2 * baseFee) - drops(1))); + BEAST_EXPECT( + env.balance(alice) == + acctReserve + incReserve + drops(baseFee) - drops(1)); + env.close(); + + // alice still does not have enough XRP for the reserve. + env(pd::setTx(alice, credentials), ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(pd::ownerInfo(alice, env)["OwnerCount"].asUInt() == 0); + + // Pay alice enough to make the reserve. + env(pay(env.master, alice, drops(baseFee) + drops(1))); + env.close(); + + // Now alice can create a PermissionedDomain. + env(pd::setTx(alice, credentials)); + env.close(); + BEAST_EXPECT(pd::ownerInfo(alice, env)["OwnerCount"].asUInt() == 1); + } + +public: + void + run() override + { + testEnabled(); + testDisabled(); + testSet(); + testDelete(); + testAccountReserve(); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(PermissionedDomains, app, ripple, 2); + +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx.h b/src/test/jtx.h index b7b9a9fa05c..f3c69ce33c3 100644 --- a/src/test/jtx.h +++ b/src/test/jtx.h @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/jtx/Account.h b/src/test/jtx/Account.h index bcf117d9357..f49070dff03 100644 --- a/src/test/jtx/Account.h +++ b/src/test/jtx/Account.h @@ -158,10 +158,10 @@ hash_append(Hasher& h, Account const& v) noexcept hash_append(h, v.id()); } -inline bool -operator<(Account const& lhs, Account const& rhs) noexcept +inline auto +operator<=>(Account const& lhs, Account const& rhs) noexcept { - return lhs.id() < rhs.id(); + return lhs.id() <=> rhs.id(); } } // namespace jtx diff --git a/src/test/jtx/deposit.h b/src/test/jtx/deposit.h index 9de3416367c..9bd73d383dd 100644 --- a/src/test/jtx/deposit.h +++ b/src/test/jtx/deposit.h @@ -43,6 +43,9 @@ struct AuthorizeCredentials jtx::Account issuer; std::string credType; + auto + operator<=>(const AuthorizeCredentials&) const = default; + Json::Value toJson() const { diff --git a/src/test/jtx/fee.h b/src/test/jtx/fee.h index c671e0b2a1e..4e29fad1521 100644 --- a/src/test/jtx/fee.h +++ b/src/test/jtx/fee.h @@ -53,7 +53,8 @@ class fee Throw("fee: not XRP"); } - explicit fee(std::uint64_t amount) : fee{STAmount{amount}} + explicit fee(std::uint64_t amount, bool negative = false) + : fee{STAmount{amount, negative}} { } diff --git a/src/test/jtx/impl/permissioned_domains.cpp b/src/test/jtx/impl/permissioned_domains.cpp new file mode 100644 index 00000000000..8ec4f9842c6 --- /dev/null +++ b/src/test/jtx/impl/permissioned_domains.cpp @@ -0,0 +1,172 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include + +namespace ripple { +namespace test { +namespace jtx { +namespace pd { + +// helpers +// Make json for PermissionedDomainSet transaction +Json::Value +setTx( + AccountID const& account, + Credentials const& credentials, + std::optional domain) +{ + Json::Value jv; + jv[sfTransactionType.jsonName] = jss::PermissionedDomainSet; + jv[sfAccount.jsonName] = to_string(account); + if (domain) + jv[sfDomainID.jsonName] = to_string(*domain); + Json::Value a(Json::arrayValue); + for (auto const& credential : credentials) + { + Json::Value obj(Json::objectValue); + obj[sfCredential.jsonName] = credential.toJson(); + a.append(std::move(obj)); + } + jv[sfAcceptedCredentials.jsonName] = a; + return jv; +} + +// Make json for PermissionedDomainDelete transaction +Json::Value +deleteTx(AccountID const& account, uint256 const& domain) +{ + Json::Value jv{Json::objectValue}; + jv[sfTransactionType.jsonName] = jss::PermissionedDomainDelete; + jv[sfAccount.jsonName] = to_string(account); + jv[sfDomainID.jsonName] = to_string(domain); + return jv; +} + +// Get PermissionedDomain objects by type from account_objects rpc call +std::map +getObjects(Account const& account, Env& env, bool withType) +{ + std::map ret; + Json::Value params; + params[jss::account] = account.human(); + if (withType) + params[jss::type] = jss::permissioned_domain; + auto const& resp = env.rpc("json", "account_objects", to_string(params)); + Json::Value a(Json::arrayValue); + a = resp[jss::result][jss::account_objects]; + for (auto const& object : a) + { + if (object["LedgerEntryType"] != "PermissionedDomain") + continue; + uint256 index; + std::ignore = index.parseHex(object[jss::index].asString()); + ret[index] = object; + } + return ret; +} + +// Check if ledger object is there +bool +objectExists(uint256 const& objID, Env& env) +{ + Json::Value params; + params[jss::index] = to_string(objID); + auto const& resp = + env.rpc("json", "ledger_entry", to_string(params))["result"]["status"] + .asString(); + if (resp == "success") + return true; + if (resp == "error") + return false; + throw std::runtime_error("Error getting ledger_entry RPC result."); +} + +// Extract credentials from account_object object +Credentials +credentialsFromJson( + Json::Value const& object, + std::unordered_map const& pubKey2Acc) +{ + Credentials ret; + Json::Value a(Json::arrayValue); + a = object["AcceptedCredentials"]; + for (auto const& credential : a) + { + Json::Value obj(Json::objectValue); + obj = credential[jss::Credential]; + auto const& issuer = obj[jss::Issuer]; + auto const& credentialType = obj["CredentialType"]; + auto blob = strUnHex(credentialType.asString()).value(); + ret.push_back( + {pubKey2Acc.at(issuer.asString()), + std::string(blob.begin(), blob.end())}); + } + return ret; +} + +// Sort credentials the same way as PermissionedDomainSet. Silently +// remove duplicates. +Credentials +sortCredentials(Credentials const& input) +{ + std::set credentialsSet; + for (auto const& c : input) + credentialsSet.insert(c); + return {credentialsSet.begin(), credentialsSet.end()}; +} + +// Get account_info +Json::Value +ownerInfo(Account const& account, Env& env) +{ + Json::Value params; + params[jss::account] = account.human(); + auto const& resp = env.rpc("json", "account_info", to_string(params)); + return env.rpc( + "json", "account_info", to_string(params))["result"]["account_data"]; +} + +uint256 +getNewDomain(std::shared_ptr const& meta) +{ + uint256 ret; + auto metaJson = meta->getJson(JsonOptions::none); + Json::Value a(Json::arrayValue); + a = metaJson["AffectedNodes"]; + + for (auto const& node : a) + { + if (!node.isMember("CreatedNode") || + node["CreatedNode"]["LedgerEntryType"] != "PermissionedDomain") + { + continue; + } + std::ignore = + ret.parseHex(node["CreatedNode"]["LedgerIndex"].asString()); + } + + return ret; +} + +} // namespace pd +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/permissioned_domains.h b/src/test/jtx/permissioned_domains.h new file mode 100644 index 00000000000..ba610ec3ecc --- /dev/null +++ b/src/test/jtx/permissioned_domains.h @@ -0,0 +1,75 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include + +namespace ripple { +namespace test { +namespace jtx { +namespace pd { + +// Helpers for PermissionedDomains testing +using Credential = ripple::test::jtx::deposit::AuthorizeCredentials; +using Credentials = std::vector; + +// helpers +// Make json for PermissionedDomainSet transaction +Json::Value +setTx( + AccountID const& account, + Credentials const& credentials, + std::optional domain = std::nullopt); + +// Make json for PermissionedDomainDelete transaction +Json::Value +deleteTx(AccountID const& account, uint256 const& domain); + +// Get PermissionedDomain objects from account_objects rpc call +std::map +getObjects(Account const& account, Env& env, bool withType = true); + +// Check if ledger object is there +bool +objectExists(uint256 const& objID, Env& env); + +// Extract credentials from account_object object +Credentials +credentialsFromJson( + Json::Value const& object, + std::unordered_map const& pubKey2Acc); + +// Sort credentials the same way as PermissionedDomainSet +Credentials +sortCredentials(Credentials const& input); + +// Get account_info +Json::Value +ownerInfo(Account const& account, Env& env); + +// Get newly created domain from transaction metadata. +uint256 +getNewDomain(std::shared_ptr const& meta); + +} // namespace pd +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/ledger/Invariants_test.cpp b/src/test/ledger/Invariants_test.cpp index 8d7b08fa1ab..52263d2dd80 100644 --- a/src/test/ledger/Invariants_test.cpp +++ b/src/test/ledger/Invariants_test.cpp @@ -798,6 +798,51 @@ class Invariants_test : public beast::unit_test::suite }); } + void + testPermissionedDomainInvariants() + { + using namespace test::jtx; + testcase << "PermissionedDomain"; + doInvariantCheck( + {{"permissioned domain with no rules."}}, + [](Account const& A1, Account const&, ApplyContext& ac) { + Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); + auto slePd = std::make_shared(pdKeylet); + slePd->setAccountID(sfOwner, A1); + slePd->setFieldU32(sfSequence, 10); + + ac.view().insert(slePd); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + doInvariantCheck( + {{"permissioned domain bad credentials size 11"}}, + [](Account const& A1, Account const& A2, ApplyContext& ac) { + Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); + auto slePd = std::make_shared(pdKeylet); + slePd->setAccountID(sfOwner, A1); + slePd->setFieldU32(sfSequence, 10); + + STArray credentials(sfAcceptedCredentials, 11); + for (std::size_t n = 0; n < 11; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, A2); + cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); + credentials.push_back(std::move(cred)); + } + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().insert(slePd); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + public: void run() override @@ -813,6 +858,7 @@ class Invariants_test : public beast::unit_test::suite testNoZeroEscrow(); testValidNewAccountRoot(); testNFTokenPageInvariants(); + testPermissionedDomainInvariants(); } }; diff --git a/src/test/rpc/AccountObjects_test.cpp b/src/test/rpc/AccountObjects_test.cpp index 7326fff0c76..69f47e084f2 100644 --- a/src/test/rpc/AccountObjects_test.cpp +++ b/src/test/rpc/AccountObjects_test.cpp @@ -627,6 +627,7 @@ class AccountObjects_test : public beast::unit_test::suite BEAST_EXPECT(acctObjsIsSize(acctObjs(gw, jss::ticket), 0)); BEAST_EXPECT(acctObjsIsSize(acctObjs(gw, jss::amm), 0)); BEAST_EXPECT(acctObjsIsSize(acctObjs(gw, jss::did), 0)); + BEAST_EXPECT(acctObjsIsSize(acctObjs(gw, jss::permissioned_domain), 0)); // we expect invalid field type reported for the following types BEAST_EXPECT(acctObjsTypeIsInvalid(acctObjs(gw, jss::amendments))); diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp index 41657468666..99b0f0a23fc 100644 --- a/src/test/rpc/LedgerRPC_test.cpp +++ b/src/test/rpc/LedgerRPC_test.cpp @@ -3055,6 +3055,115 @@ class LedgerRPC_test : public beast::unit_test::suite } } + void + testLedgerEntryPermissionedDomain() + { + testcase("ledger_entry PermissionedDomain"); + + using namespace test::jtx; + + Env env(*this, supported_amendments() | featurePermissionedDomains); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + + env.fund(XRP(5000), issuer, alice, bob); + env.close(); + + auto const seq = env.seq(alice); + env(pd::setTx(alice, {{alice, "first credential"}})); + env.close(); + auto const objects = pd::getObjects(alice, env); + BEAST_EXPECT(objects.size() == 1); + if (objects.empty()) + return; + // env(pd::deleteTx(alice, domain)); + + { + // Succeed + Json::Value params; + params[jss::ledger_index] = jss::validated; + params[jss::permissioned_domain][jss::account] = alice.human(); + params[jss::permissioned_domain][jss::seq] = seq; + auto jv = env.rpc("json", "ledger_entry", to_string(params)); + BEAST_EXPECT( + jv.isObject() && jv.isMember(jss::result) && + !jv[jss::result].isMember(jss::error) && + jv[jss::result].isMember(jss::node) && + jv[jss::result][jss::node].isMember( + sfLedgerEntryType.jsonName) && + jv[jss::result][jss::node][sfLedgerEntryType.jsonName] == + jss::PermissionedDomain); + + std::string const pdIdx = jv[jss::result][jss::index].asString(); + BEAST_EXPECT( + strHex(keylet::permissionedDomain(alice, seq).key) == pdIdx); + + params.clear(); + params[jss::ledger_index] = jss::validated; + params[jss::permissioned_domain] = pdIdx; + jv = env.rpc("json", "ledger_entry", to_string(params)); + BEAST_EXPECT( + jv.isObject() && jv.isMember(jss::result) && + !jv[jss::result].isMember(jss::error) && + jv[jss::result].isMember(jss::node) && + jv[jss::result][jss::node].isMember( + sfLedgerEntryType.jsonName) && + jv[jss::result][jss::node][sfLedgerEntryType.jsonName] == + jss::PermissionedDomain); + } + + { + // Fail, invalid permissioned domain index + Json::Value params; + params[jss::ledger_index] = jss::validated; + params[jss::permissioned_domain] = + "12F1F1F1F180D67377B2FAB292A31C922470326268D2B9B74CD1E582645B9A" + "DE"; + auto const jrr = env.rpc("json", "ledger_entry", to_string(params)); + checkErrorValue(jrr[jss::result], "entryNotFound", ""); + } + + { + // Fail, invalid permissioned domain index + Json::Value params; + params[jss::ledger_index] = jss::validated; + params[jss::permissioned_domain] = "NotAHexString"; + auto const jrr = env.rpc("json", "ledger_entry", to_string(params)); + checkErrorValue(jrr[jss::result], "malformedRequest", ""); + } + + { + // Fail, invalid account + Json::Value params; + params[jss::ledger_index] = jss::validated; + params[jss::permissioned_domain][jss::account] = 1; + params[jss::permissioned_domain][jss::seq] = seq; + auto const jrr = env.rpc("json", "ledger_entry", to_string(params)); + checkErrorValue(jrr[jss::result], "malformedRequest", ""); + } + + { + // Fail, no account + Json::Value params; + params[jss::ledger_index] = jss::validated; + params[jss::permissioned_domain][jss::account] = ""; + params[jss::permissioned_domain][jss::seq] = seq; + auto const jrr = env.rpc("json", "ledger_entry", to_string(params)); + checkErrorValue(jrr[jss::result], "malformedRequest", ""); + } + + { + // Fail, invalid sequence + Json::Value params; + params[jss::ledger_index] = jss::validated; + params[jss::permissioned_domain][jss::account] = alice.human(); + params[jss::permissioned_domain][jss::seq] = "12g"; + auto const jrr = env.rpc("json", "ledger_entry", to_string(params)); + checkErrorValue(jrr[jss::result], "malformedRequest", ""); + } + } + public: void run() override @@ -3085,6 +3194,7 @@ class LedgerRPC_test : public beast::unit_test::suite testInvalidOracleLedgerEntry(); testOracleLedgerEntry(); testLedgerEntryMPT(); + testLedgerEntryPermissionedDomain(); forAllApiVersions(std::bind_front( &LedgerRPC_test::testLedgerEntryInvalidParams, this)); diff --git a/src/xrpld/app/misc/CredentialHelpers.cpp b/src/xrpld/app/misc/CredentialHelpers.cpp index 08b5d804d4b..8e9a902a20a 100644 --- a/src/xrpld/app/misc/CredentialHelpers.cpp +++ b/src/xrpld/app/misc/CredentialHelpers.cpp @@ -19,6 +19,7 @@ #include #include +#include #include @@ -213,10 +214,10 @@ authorized(ApplyContext const& ctx, AccountID const& dst) } std::set> -makeSorted(STArray const& in) +makeSorted(STArray const& credentials) { std::set> out; - for (auto const& cred : in) + for (auto const& cred : credentials) { auto [it, ins] = out.emplace(cred[sfIssuer], cred[sfCredentialType]); if (!ins) @@ -225,6 +226,50 @@ makeSorted(STArray const& in) return out; } +NotTEC +checkArray(STArray const& credentials, unsigned maxSize, beast::Journal j) +{ + if (credentials.empty() || (credentials.size() > maxSize)) + { + JLOG(j.trace()) << "Malformed transaction: " + "Invalid credentials size: " + << credentials.size(); + return temMALFORMED; + } + + std::unordered_set duplicates; + for (auto const& credential : credentials) + { + auto const& issuer(credential[sfIssuer]); + if (!issuer) + { + JLOG(j.trace()) << "Malformed transaction: " + "Issuer account is invalid: " + << to_string(issuer); + return temINVALID_ACCOUNT_ID; + } + + auto const ct = credential[sfCredentialType]; + if (ct.empty() || (ct.size() > maxCredentialTypeLength)) + { + JLOG(j.trace()) << "Malformed transaction: " + "Invalid credentialType size: " + << ct.size(); + return temMALFORMED; + } + + auto [it, ins] = duplicates.insert(sha512Half(issuer, ct)); + if (!ins) + { + JLOG(j.trace()) << "Malformed transaction: " + "duplicates in credenentials."; + return temMALFORMED; + } + } + + return tesSUCCESS; +} + } // namespace credentials TER diff --git a/src/xrpld/app/misc/CredentialHelpers.h b/src/xrpld/app/misc/CredentialHelpers.h index 3291fc1daa6..acc4f2621db 100644 --- a/src/xrpld/app/misc/CredentialHelpers.h +++ b/src/xrpld/app/misc/CredentialHelpers.h @@ -21,8 +21,6 @@ #include -#include - namespace ripple { namespace credentials { @@ -60,9 +58,14 @@ valid(PreclaimContext const& ctx, AccountID const& src); TER authorized(ApplyContext const& ctx, AccountID const& dst); -// return empty set if there are duplicates +// Sort credentials array, return empty set if there are duplicates std::set> -makeSorted(STArray const& in); +makeSorted(STArray const& credentials); + +// Check credentials array passed to DepositPreauth/PermissionedDomainSet +// transactions +NotTEC +checkArray(STArray const& credentials, unsigned maxSize, beast::Journal j); } // namespace credentials diff --git a/src/xrpld/app/tx/detail/DepositPreauth.cpp b/src/xrpld/app/tx/detail/DepositPreauth.cpp index 73cd19e4120..599fcd60526 100644 --- a/src/xrpld/app/tx/detail/DepositPreauth.cpp +++ b/src/xrpld/app/tx/detail/DepositPreauth.cpp @@ -24,11 +24,9 @@ #include #include #include -#include #include #include -#include namespace ripple { @@ -94,45 +92,14 @@ DepositPreauth::preflight(PreflightContext const& ctx) } else { - STArray const& arr(ctx.tx.getFieldArray( - authArrPresent ? sfAuthorizeCredentials - : sfUnauthorizeCredentials)); - if (arr.empty() || (arr.size() > maxCredentialsArraySize)) - { - JLOG(ctx.j.trace()) << "Malformed transaction: " - "Invalid AuthorizeCredentials size: " - << arr.size(); - return temMALFORMED; - } - - std::unordered_set duplicates; - for (auto const& o : arr) - { - auto const& issuer(o[sfIssuer]); - if (!issuer) - { - JLOG(ctx.j.trace()) - << "Malformed transaction: " - "AuthorizeCredentials Issuer account is invalid."; - return temINVALID_ACCOUNT_ID; - } - - auto const ct = o[sfCredentialType]; - if (ct.empty() || (ct.size() > maxCredentialTypeLength)) - { - JLOG(ctx.j.trace()) - << "Malformed transaction: invalid size of CredentialType."; - return temMALFORMED; - } - - auto [it, ins] = duplicates.insert(sha512Half(issuer, ct)); - if (!ins) - { - JLOG(ctx.j.trace()) - << "Malformed transaction: duplicates in credentials."; - return temMALFORMED; - } - } + if (auto err = credentials::checkArray( + ctx.tx.getFieldArray( + authArrPresent ? sfAuthorizeCredentials + : sfUnauthorizeCredentials), + maxCredentialsArraySize, + ctx.j); + !isTesSuccess(err)) + return err; } return preflight2(ctx); diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index 90fc399b344..99d8aa5c17b 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -19,7 +19,9 @@ #include +#include #include +#include #include #include #include @@ -482,6 +484,7 @@ LedgerEntryTypesMatch::visitEntry( case ltMPTOKEN_ISSUANCE: case ltMPTOKEN: case ltCREDENTIAL: + case ltPERMISSIONED_DOMAIN: break; default: invalidTypeAdded_ = true; @@ -1120,4 +1123,76 @@ ValidMPTIssuance::finalize( mptokensCreated_ == 0 && mptokensDeleted_ == 0; } +//------------------------------------------------------------------------------ + +void +ValidPermissionedDomain::visitEntry( + bool, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (after->getType() != ltPERMISSIONED_DOMAIN) + return; + auto const& credentials = after->getFieldArray(sfAcceptedCredentials); + credentialsSize_ = credentials.size(); + auto const sorted = credentials::makeSorted(credentials); + isUnique_ = !sorted.empty(); + + if (isUnique_) + { + unsigned i = 0; + for (auto const& cred : sorted) + { + auto const& credTx(credentials[i++]); + isSorted_ = (cred.first == credTx[sfIssuer]) && + (cred.second == credTx[sfCredentialType]); + if (!isSorted_) + break; + } + } +} + +bool +ValidPermissionedDomain::finalize( + STTx const& tx, + TER const result, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + if (tx.getTxnType() != ttPERMISSIONED_DOMAIN_SET || result != tesSUCCESS) + return true; + + if (!credentialsSize_) + { + JLOG(j.fatal()) << "Invariant failed: permissioned domain with " + "no rules."; + return false; + } + + if (credentialsSize_ > maxPermissionedDomainCredentialsArraySize) + { + JLOG(j.fatal()) << "Invariant failed: permissioned domain bad " + "credentials size " + << credentialsSize_; + return false; + } + + if (!isSorted_) + { + JLOG(j.fatal()) << "Invariant failed: permissioned domain credentials " + "aren't sorted"; + return false; + } + + if (!isUnique_) + { + JLOG(j.fatal()) << "Invariant failed: permissioned domain credentials " + "aren't unique"; + return false; + } + + return true; +} + } // namespace ripple diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index 23ec8005556..d8f2dece0ec 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -28,6 +28,7 @@ #include #include +#include #include #include @@ -475,6 +476,34 @@ class ValidMPTIssuance beast::Journal const&); }; +/** + * @brief Invariants: Permissioned Domains must have some rules and + * AcceptedCredentials must have length between 1 and 10 inclusive. + * + * Since only permissions constitute rules, an empty credentials list + * means that there are no rules and the invariant is violated. + */ +class ValidPermissionedDomain +{ + std::size_t credentialsSize_{0}; + bool isSorted_ = false, isUnique_ = false; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + // additional invariant checks can be declared above and then added to this // tuple using InvariantChecks = std::tuple< @@ -491,7 +520,8 @@ using InvariantChecks = std::tuple< ValidNFTokenPage, NFTokenCountTracking, ValidClawback, - ValidMPTIssuance>; + ValidMPTIssuance, + ValidPermissionedDomain>; /** * @brief get a tuple of all invariant checks diff --git a/src/xrpld/app/tx/detail/PermissionedDomainDelete.cpp b/src/xrpld/app/tx/detail/PermissionedDomainDelete.cpp new file mode 100644 index 00000000000..321f054dbcd --- /dev/null +++ b/src/xrpld/app/tx/detail/PermissionedDomainDelete.cpp @@ -0,0 +1,76 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include + +namespace ripple { + +NotTEC +PermissionedDomainDelete::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featurePermissionedDomains)) + return temDISABLED; + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + assert(ctx.tx.isFieldPresent(sfDomainID)); + auto const domain = ctx.tx.getFieldH256(sfDomainID); + if (domain == beast::zero) + return temMALFORMED; + + return preflight2(ctx); +} + +TER +PermissionedDomainDelete::preclaim(PreclaimContext const& ctx) +{ + auto const domain = ctx.tx.getFieldH256(sfDomainID); + auto const sleDomain = ctx.view.read({ltPERMISSIONED_DOMAIN, domain}); + if (!sleDomain) + return tecNO_ENTRY; + assert( + sleDomain->isFieldPresent(sfOwner) && ctx.tx.isFieldPresent(sfAccount)); + if (sleDomain->getAccountID(sfOwner) != ctx.tx.getAccountID(sfAccount)) + return tecNO_PERMISSION; + return tesSUCCESS; +} + +/** Attempt to delete the Permissioned Domain. */ +TER +PermissionedDomainDelete::doApply() +{ + assert(ctx_.tx.isFieldPresent(sfDomainID)); + auto const slePd = + view().peek({ltPERMISSIONED_DOMAIN, ctx_.tx.at(sfDomainID)}); + auto const page{(*slePd)[sfOwnerNode]}; + if (!view().dirRemove(keylet::ownerDir(account_), page, slePd->key(), true)) + { + JLOG(j_.fatal()) + << "Unable to delete permissioned domain directory entry."; + return tefBAD_LEDGER; + } + auto const ownerSle = view().peek(keylet::account(account_)); + assert(ownerSle && ownerSle->getFieldU32(sfOwnerCount) > 0); + adjustOwnerCount(view(), ownerSle, -1, ctx_.journal); + view().erase(slePd); + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/PermissionedDomainDelete.h b/src/xrpld/app/tx/detail/PermissionedDomainDelete.h new file mode 100644 index 00000000000..ce0d1f14d86 --- /dev/null +++ b/src/xrpld/app/tx/detail/PermissionedDomainDelete.h @@ -0,0 +1,46 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include + +namespace ripple { + +class PermissionedDomainDelete : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit PermissionedDomainDelete(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + /** Attempt to delete the Permissioned Domain. */ + TER + doApply() override; +}; + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/PermissionedDomainSet.cpp b/src/xrpld/app/tx/detail/PermissionedDomainSet.cpp new file mode 100644 index 00000000000..d0ec770e06e --- /dev/null +++ b/src/xrpld/app/tx/detail/PermissionedDomainSet.cpp @@ -0,0 +1,142 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include + +#include + +namespace ripple { + +NotTEC +PermissionedDomainSet::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featurePermissionedDomains)) + return temDISABLED; + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (auto err = credentials::checkArray( + ctx.tx.getFieldArray(sfAcceptedCredentials), + maxPermissionedDomainCredentialsArraySize, + ctx.j); + !isTesSuccess(err)) + return err; + + auto const domain = ctx.tx.at(~sfDomainID); + if (domain && *domain == beast::zero) + return temMALFORMED; + + return preflight2(ctx); +} + +TER +PermissionedDomainSet::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx.getAccountID(sfAccount); + + if (!ctx.view.exists(keylet::account(account))) + return tefINTERNAL; + + auto const& credentials = ctx.tx.getFieldArray(sfAcceptedCredentials); + for (auto const& credential : credentials) + { + if (!ctx.view.exists( + keylet::account(credential.getAccountID(sfIssuer)))) + return tecNO_ISSUER; + } + + if (ctx.tx.isFieldPresent(sfDomainID)) + { + auto const sleDomain = ctx.view.read( + keylet::permissionedDomain(ctx.tx.getFieldH256(sfDomainID))); + if (!sleDomain) + return tecNO_ENTRY; + if (sleDomain->getAccountID(sfOwner) != account) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +/** Attempt to create the Permissioned Domain. */ +TER +PermissionedDomainSet::doApply() +{ + auto const ownerSle = view().peek(keylet::account(account_)); + if (!ownerSle) + return tefINTERNAL; + + auto const sortedTX = + credentials::makeSorted(ctx_.tx.getFieldArray(sfAcceptedCredentials)); + STArray sortedLE(sfAcceptedCredentials, sortedTX.size()); + for (auto const& p : sortedTX) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, p.first); + cred.setFieldVL(sfCredentialType, p.second); + sortedLE.push_back(std::move(cred)); + } + + if (ctx_.tx.isFieldPresent(sfDomainID)) + { + // Modify existing permissioned domain. + auto slePd = view().peek( + keylet::permissionedDomain(ctx_.tx.getFieldH256(sfDomainID))); + if (!slePd) + return tefINTERNAL; + slePd->peekFieldArray(sfAcceptedCredentials) = std::move(sortedLE); + view().update(slePd); + } + else + { + // Create new permissioned domain. + // Check reserve availability for new object creation + auto const balance = STAmount((*ownerSle)[sfBalance]).xrp(); + auto const reserve = + ctx_.view().fees().accountReserve((*ownerSle)[sfOwnerCount] + 1); + if (balance < reserve) + return tecINSUFFICIENT_RESERVE; + + Keylet const pdKeylet = keylet::permissionedDomain( + account_, ctx_.tx.getFieldU32(sfSequence)); + auto slePd = std::make_shared(pdKeylet); + if (!slePd) + return tefINTERNAL; + + slePd->setAccountID(sfOwner, account_); + slePd->setFieldU32(sfSequence, ctx_.tx.getFieldU32(sfSequence)); + slePd->peekFieldArray(sfAcceptedCredentials) = std::move(sortedLE); + auto const page = view().dirInsert( + keylet::ownerDir(account_), pdKeylet, describeOwnerDir(account_)); + if (!page) + return tecDIR_FULL; + slePd->setFieldU64(sfOwnerNode, *page); + // If we succeeded, the new entry counts against the creator's reserve. + adjustOwnerCount(view(), ownerSle, 1, ctx_.journal); + view().insert(slePd); + } + + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/PermissionedDomainSet.h b/src/xrpld/app/tx/detail/PermissionedDomainSet.h new file mode 100644 index 00000000000..7419770946f --- /dev/null +++ b/src/xrpld/app/tx/detail/PermissionedDomainSet.h @@ -0,0 +1,45 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== +#pragma once + +#include + +namespace ripple { + +class PermissionedDomainSet : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit PermissionedDomainSet(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + /** Attempt to create the Permissioned Domain. */ + TER + doApply() override; +}; + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/applySteps.cpp b/src/xrpld/app/tx/detail/applySteps.cpp index b3c711084dc..9ce776931a5 100644 --- a/src/xrpld/app/tx/detail/applySteps.cpp +++ b/src/xrpld/app/tx/detail/applySteps.cpp @@ -52,6 +52,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp index af204eaedf7..82569745e1b 100644 --- a/src/xrpld/rpc/detail/RPCHelpers.cpp +++ b/src/xrpld/rpc/detail/RPCHelpers.cpp @@ -931,7 +931,7 @@ chooseLedgerEntryType(Json::Value const& params) std::pair result{RPC::Status::OK, ltANY}; if (params.isMember(jss::type)) { - static constexpr std::array, 25> + static constexpr std::array, 26> types{ {{jss::account, ltACCOUNT_ROOT}, {jss::amendments, ltAMENDMENTS}, @@ -958,7 +958,8 @@ chooseLedgerEntryType(Json::Value const& params) {jss::xchain_owned_create_account_claim_id, ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID}, {jss::mpt_issuance, ltMPTOKEN_ISSUANCE}, - {jss::mptoken, ltMPTOKEN}}}; + {jss::mptoken, ltMPTOKEN}, + {jss::permissioned_domain, ltPERMISSIONED_DOMAIN}}}; auto const& p = params[jss::type]; if (!p.isString()) diff --git a/src/xrpld/rpc/handlers/AccountObjects.cpp b/src/xrpld/rpc/handlers/AccountObjects.cpp index 538b1d79424..1bdd95a74ca 100644 --- a/src/xrpld/rpc/handlers/AccountObjects.cpp +++ b/src/xrpld/rpc/handlers/AccountObjects.cpp @@ -224,7 +224,8 @@ doAccountObjects(RPC::JsonContext& context) ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID}, {jss::bridge, ltBRIDGE}, {jss::mpt_issuance, ltMPTOKEN_ISSUANCE}, - {jss::mptoken, ltMPTOKEN}}; + {jss::mptoken, ltMPTOKEN}, + {jss::permissioned_domain, ltPERMISSIONED_DOMAIN}}; typeFilter.emplace(); typeFilter->reserve(std::size(deletionBlockers)); diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index 5d03bbb189d..8cb99acb231 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -807,6 +807,36 @@ doLedgerEntry(RPC::JsonContext& context) } } } + else if (context.params.isMember(jss::permissioned_domain)) + { + uNodeIndex = beast::zero; + auto const& pd = context.params[jss::permissioned_domain]; + if (pd.isString()) + { + if (!uNodeIndex.parseHex(pd.asString())) + { + uNodeIndex = beast::zero; + jvResult[jss::error] = "malformedRequest"; + } + } + else if ( + !pd.isObject() || !pd.isMember(jss::account) || + !pd[jss::account].isString() || !pd.isMember(jss::seq) || + (!pd[jss::seq].isInt() && !pd[jss::seq].isUInt())) + { + jvResult[jss::error] = "malformedRequest"; + } + else + { + auto const account = + parseBase58(pd[jss::account].asString()); + unsigned const seq = pd[jss::seq].asUInt(); + if (account) + uNodeIndex = keylet::permissionedDomain(*account, seq).key; + else + jvResult[jss::error] = "malformedRequest"; + } + } else { if (context.params.isMember("params") &&