From 842f3b3e2d6be587474b727d59cf28774ddbf6fa Mon Sep 17 00:00:00 2001 From: Kassaking7 <96991820+Kassaking7@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:40:35 -0400 Subject: [PATCH] Cosign v1 merge conflict 2 (#8268) --- .cspell.config.yaml | 2 + .../scripts/levelization/results/ordering.txt | 4 +- .github/scripts/strategy-matrix/generate.py | 46 +- .github/scripts/strategy-matrix/linux.json | 13 + .github/workflows/on-pr.yml | 7 + .github/workflows/on-trigger.yml | 1 + .../workflows/reusable-build-test-config.yml | 2 +- .../reusable-package-test-install.yml | 120 ++ .github/workflows/reusable-package.yml | 185 +- .github/workflows/reusable-rust.yml | 2 +- API-CHANGELOG.md | 1 + cmake/XrplPackaging.cmake | 8 +- conanfile.py | 1 + docs/install.md | 23 +- include/xrpl/basics/StringUtilities.h | 85 + include/xrpl/basics/base_uint.h | 2 +- include/xrpl/core/PerfLog.h | 22 +- include/xrpl/ledger/entries/AMMEntry.h | 45 + .../xrpl/ledger/entries/AccountRootEntry.h | 35 + include/xrpl/ledger/entries/AmendmentsEntry.h | 33 + include/xrpl/ledger/entries/BridgeEntry.h | 36 + include/xrpl/ledger/entries/CheckEntry.h | 46 + include/xrpl/ledger/entries/CredentialEntry.h | 47 + include/xrpl/ledger/entries/DIDEntry.h | 35 + include/xrpl/ledger/entries/DelegateEntry.h | 36 + .../xrpl/ledger/entries/DepositPreauthEntry.h | 58 + .../xrpl/ledger/entries/DirectoryNodeEntry.h | 50 + include/xrpl/ledger/entries/EscrowEntry.h | 37 + .../xrpl/ledger/entries/FeeSettingsEntry.h | 33 + .../xrpl/ledger/entries/LedgerHashesEntry.h | 33 + include/xrpl/ledger/entries/LoanBrokerEntry.h | 46 + include/xrpl/ledger/entries/LoanEntry.h | 45 + include/xrpl/ledger/entries/MPTokenEntry.h | 55 + .../ledger/entries/MPTokenIssuanceEntry.h | 56 + .../xrpl/ledger/entries/NFTokenOfferEntry.h | 46 + .../xrpl/ledger/entries/NFTokenPageEntry.h | 37 + .../xrpl/ledger/entries/NegativeUNLEntry.h | 33 + include/xrpl/ledger/entries/OfferEntry.h | 46 + include/xrpl/ledger/entries/OracleEntry.h | 38 + include/xrpl/ledger/entries/PayChannelEntry.h | 38 + .../ledger/entries/PermissionedDomainEntry.h | 46 + .../xrpl/ledger/entries/RippleStateEntry.h | 48 + include/xrpl/ledger/entries/SLEBase.h | 503 +++++ include/xrpl/ledger/entries/SignerListEntry.h | 35 + .../xrpl/ledger/entries/SponsorshipEntry.h | 36 + include/xrpl/ledger/entries/TicketEntry.h | 46 + .../ledger/entries/TransactionProposalEntry.h | 38 + include/xrpl/ledger/entries/VaultEntry.h | 46 + .../ledger/entries/XChainOwnedClaimIDEntry.h | 38 + .../XChainOwnedCreateAccountClaimIDEntry.h | 39 + include/xrpl/ledger/helpers/LendingHelpers.h | 10 +- include/xrpl/ledger/helpers/VaultHelpers.h | 4 +- .../xrpl/nodestore/detail/DatabaseNodeImp.h | 2 +- include/xrpl/peerfinder/Config.h | 3 +- include/xrpl/protocol/ConfidentialTransfer.h | 82 + include/xrpl/protocol/PathAsset.h | 29 +- include/xrpl/protocol/Protocol.h | 7 +- include/xrpl/protocol/STPathSet.h | 161 +- include/xrpl/protocol/STValidation.h | 7 + include/xrpl/protocol/Serializer.h | 192 +- include/xrpl/protocol/detail/STVar.h | 3 +- .../xrpl/protocol/detail/ledger_entries.macro | 2 + .../xrpl/protocol/detail/transactions.macro | 15 +- include/xrpl/protocol/jss.h | 3 + .../protocol_autogen/ledger_entries/MPToken.h | 70 + .../ConfidentialMPTMirrorUpdate.h | 266 +++ .../transactions/TransactionProposalCreate.h | 2 +- include/xrpl/shamap/SHAMap.h | 145 +- include/xrpl/tx/ApplyContext.h | 9 + include/xrpl/tx/applySteps.h | 18 +- include/xrpl/tx/invariants/LoanInvariant.h | 8 +- .../token/ConfidentialMPTMirrorUpdate.h | 98 + package/README.md | 115 +- package/build_pkg.py | 100 +- package/debian/{control => control.in} | 5 +- package/debian/{xrpld.docs => docs} | 0 package/debian/{xrpld.links => links} | 0 package/debian/lintian-overrides.in | 6 + package/debian/rules | 41 +- package/debian/xrpld.lintian-overrides | 6 - package/docker/publish_pkg.py | 2 +- package/rpm/xrpld.spec | 49 +- src/libxrpl/ledger/helpers/LendingHelpers.cpp | 17 +- src/libxrpl/peerfinder/Config.cpp | 8 +- src/libxrpl/protocol/BuildInfo.cpp | 2 +- src/libxrpl/protocol/ConfidentialTransfer.cpp | 75 + src/libxrpl/protocol/STPathSet.cpp | 67 +- src/libxrpl/protocol/STValidation.cpp | 43 +- src/libxrpl/protocol/Serializer.cpp | 143 +- src/libxrpl/shamap/SHAMap.cpp | 146 +- src/libxrpl/shamap/SHAMapSync.cpp | 2 +- src/libxrpl/tx/ApplyContext.cpp | 9 + src/libxrpl/tx/applySteps.cpp | 50 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 27 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 60 +- .../tx/transactors/check/CheckCash.cpp | 6 + src/libxrpl/tx/transactors/dex/AMMCreate.cpp | 4 +- .../tx/transactors/dex/OfferCreate.cpp | 5 +- .../tx/transactors/lending/LoanPay.cpp | 23 +- .../tx/transactors/lending/LoanSet.cpp | 6 +- .../tx/transactors/payment/Payment.cpp | 25 +- .../sponsor/SponsorshipTransfer.cpp | 8 + src/libxrpl/tx/transactors/system/Batch.cpp | 15 +- .../token/ConfidentialMPTClawback.cpp | 7 + .../token/ConfidentialMPTConvert.cpp | 16 + .../token/ConfidentialMPTConvertBack.cpp | 9 + .../token/ConfidentialMPTMirrorUpdate.cpp | 273 +++ .../transactors/token/ConfidentialMPTSend.cpp | 9 + .../app/ConfidentialMPTKeyRotation_test.cpp | 1866 ++++++++++++++++- src/test/app/ConfidentialTransfer_test.cpp | 182 +- src/test/app/Delegate_test.cpp | 85 +- src/test/app/LedgerNodeHelpers_test.cpp | 1 + src/test/app/LedgerReplay_test.cpp | 130 +- src/test/app/Path_test.cpp | 219 +- src/test/app/Sponsor_test.cpp | 19 + .../app/invariants/InvariantsMPT_test.cpp | 69 + .../app/invariants/InvariantsVault_test.cpp | 86 +- src/test/app/lending/LendingHelpers_test.cpp | 53 +- src/test/app/lending/LoanCashBasis_test.cpp | 57 +- src/test/app/lending/LoanRounding_test.cpp | 41 +- src/test/app/lending/LoanSet_test.cpp | 102 + src/test/app/lending/LoanTestBase.h | 2 +- src/test/app/vault/VaultBugs_test.cpp | 12 +- src/test/app/vault/VaultClosedEnded_test.cpp | 6 +- src/test/app/vault/VaultLifecycle_test.cpp | 80 + src/test/basics/PerfLog_test.cpp | 72 +- src/test/core/Workers_test.cpp | 7 +- src/test/jtx/ConfidentialTransfer.h | 198 ++ src/test/jtx/PeerStub.h | 185 ++ src/test/jtx/TestHelpers.h | 8 - src/test/jtx/impl/ConfidentialTransfer.cpp | 37 + src/test/jtx/impl/mpt.cpp | 1172 ++++------- src/test/jtx/impl/utility.cpp | 2 +- src/test/jtx/mpt.h | 83 +- src/test/overlay/CapturePeer.h | 252 +++ src/test/overlay/ProtocolMessage_test.cpp | 296 +++ src/test/overlay/TMGetLedger_test.cpp | 135 ++ src/test/overlay/TMGetObjectByHash_test.cpp | 147 +- src/test/overlay/TMTransaction_test.cpp | 45 + src/test/overlay/TMTransactions_test.cpp | 82 + src/test/overlay/cluster_test.cpp | 24 + src/test/overlay/overlay_limit_test.cpp | 83 + src/test/overlay/reduce_relay_test.cpp | 138 +- src/test/overlay/tx_reduce_relay_test.cpp | 208 +- src/test/rpc/Handler_test.cpp | 159 +- src/test/rpc/LedgerRPC_test.cpp | 94 + src/test/rpc/NoRippleCheck_test.cpp | 12 +- src/test/rpc/RPCCall_test.cpp | 63 + src/tests/libxrpl/CMakeLists.txt | 1 + src/tests/libxrpl/ledger/AMMEntry.cpp | 25 + src/tests/libxrpl/ledger/AccountRootEntry.cpp | 21 + src/tests/libxrpl/ledger/AmendmentsEntry.cpp | 17 + src/tests/libxrpl/ledger/BridgeEntry.cpp | 41 + src/tests/libxrpl/ledger/CheckEntry.cpp | 23 + src/tests/libxrpl/ledger/CredentialEntry.cpp | 39 + src/tests/libxrpl/ledger/DIDEntry.cpp | 17 + src/tests/libxrpl/ledger/DelegateEntry.cpp | 29 + .../libxrpl/ledger/DepositPreauthEntry.cpp | 54 + .../libxrpl/ledger/DirectoryNodeEntry.cpp | 28 + src/tests/libxrpl/ledger/EntryTestHelpers.h | 123 ++ src/tests/libxrpl/ledger/EscrowEntry.cpp | 21 + src/tests/libxrpl/ledger/FeeSettingsEntry.cpp | 17 + .../libxrpl/ledger/LedgerHashesEntry.cpp | 17 + src/tests/libxrpl/ledger/LoanBrokerEntry.cpp | 24 + src/tests/libxrpl/ledger/LoanEntry.cpp | 29 + src/tests/libxrpl/ledger/MPTokenEntry.cpp | 34 + .../libxrpl/ledger/MPTokenIssuanceEntry.cpp | 33 + .../libxrpl/ledger/NFTokenOfferEntry.cpp | 24 + src/tests/libxrpl/ledger/NFTokenPageEntry.cpp | 25 + src/tests/libxrpl/ledger/NegativeUNLEntry.cpp | 17 + src/tests/libxrpl/ledger/OfferEntry.cpp | 23 + src/tests/libxrpl/ledger/OracleEntry.cpp | 24 + src/tests/libxrpl/ledger/PayChannelEntry.cpp | 33 + .../ledger/PermissionedDomainEntry.cpp | 28 + src/tests/libxrpl/ledger/RippleStateEntry.cpp | 43 + src/tests/libxrpl/ledger/SLEBase.cpp | 429 ++++ src/tests/libxrpl/ledger/SignerListEntry.cpp | 18 + src/tests/libxrpl/ledger/SponsorshipEntry.cpp | 29 + src/tests/libxrpl/ledger/TicketEntry.cpp | 27 + src/tests/libxrpl/ledger/VaultEntry.cpp | 23 + .../ledger/XChainOwnedClaimIDEntry.cpp | 29 + .../XChainOwnedCreateAccountClaimIDEntry.cpp | 35 + src/tests/libxrpl/peerfinder/PeerFinder.cpp | 12 +- .../ledger_entries/MPTokenTests.cpp | 54 + .../ConfidentialMPTMirrorUpdateTests.cpp | 255 +++ src/tests/libxrpl/shamap/SHAMap.cpp | 406 ++++ src/xrpld/app/ledger/detail/LedgerToJson.cpp | 16 + .../app/ledger/detail/TransactionAcquire.h | 1 + src/xrpld/app/main/Application.cpp | 2 + src/xrpld/app/misc/NetworkOPs.cpp | 20 +- src/xrpld/app/misc/TxQ.h | 5 +- src/xrpld/app/misc/detail/TxQ.cpp | 71 +- src/xrpld/overlay/Cluster.h | 13 + src/xrpld/overlay/detail/Cluster.cpp | 8 + src/xrpld/overlay/detail/OverlayImpl.cpp | 2 +- src/xrpld/overlay/detail/PeerImp.cpp | 30 +- src/xrpld/overlay/detail/PeerImp.h | 9 +- src/xrpld/overlay/detail/ProtocolMessage.h | 2 + src/xrpld/overlay/detail/ProtocolVersion.cpp | 8 + src/xrpld/overlay/detail/ProtocolVersion.h | 9 + src/xrpld/perflog/detail/PerfLogImp.cpp | 105 +- src/xrpld/perflog/detail/PerfLogImp.h | 43 +- src/xrpld/rpc/MethodNames.h | 95 + src/xrpld/rpc/RPCCall.h | 11 + src/xrpld/rpc/RPCHandler.h | 4 +- src/xrpld/rpc/detail/Handler.cpp | 983 +++++---- src/xrpld/rpc/detail/Handler.h | 97 +- src/xrpld/rpc/detail/Pathfinder.cpp | 31 +- src/xrpld/rpc/detail/Pathfinder.h | 2 +- src/xrpld/rpc/detail/RPCCall.cpp | 779 ++++--- src/xrpld/rpc/detail/RPCHandler.cpp | 35 +- src/xrpld/rpc/detail/ServerHandler.cpp | 2 - src/xrpld/rpc/detail/TransactionSign.cpp | 5 +- .../rpc/handlers/account/NoRippleCheck.cpp | 101 +- src/xrpld/rpc/handlers/ledger/Ledger.h | 8 +- src/xrpld/rpc/handlers/server_info/Version.h | 5 +- 216 files changed, 13195 insertions(+), 2824 deletions(-) create mode 100644 .github/workflows/reusable-package-test-install.yml create mode 100644 include/xrpl/ledger/entries/AMMEntry.h create mode 100644 include/xrpl/ledger/entries/AccountRootEntry.h create mode 100644 include/xrpl/ledger/entries/AmendmentsEntry.h create mode 100644 include/xrpl/ledger/entries/BridgeEntry.h create mode 100644 include/xrpl/ledger/entries/CheckEntry.h create mode 100644 include/xrpl/ledger/entries/CredentialEntry.h create mode 100644 include/xrpl/ledger/entries/DIDEntry.h create mode 100644 include/xrpl/ledger/entries/DelegateEntry.h create mode 100644 include/xrpl/ledger/entries/DepositPreauthEntry.h create mode 100644 include/xrpl/ledger/entries/DirectoryNodeEntry.h create mode 100644 include/xrpl/ledger/entries/EscrowEntry.h create mode 100644 include/xrpl/ledger/entries/FeeSettingsEntry.h create mode 100644 include/xrpl/ledger/entries/LedgerHashesEntry.h create mode 100644 include/xrpl/ledger/entries/LoanBrokerEntry.h create mode 100644 include/xrpl/ledger/entries/LoanEntry.h create mode 100644 include/xrpl/ledger/entries/MPTokenEntry.h create mode 100644 include/xrpl/ledger/entries/MPTokenIssuanceEntry.h create mode 100644 include/xrpl/ledger/entries/NFTokenOfferEntry.h create mode 100644 include/xrpl/ledger/entries/NFTokenPageEntry.h create mode 100644 include/xrpl/ledger/entries/NegativeUNLEntry.h create mode 100644 include/xrpl/ledger/entries/OfferEntry.h create mode 100644 include/xrpl/ledger/entries/OracleEntry.h create mode 100644 include/xrpl/ledger/entries/PayChannelEntry.h create mode 100644 include/xrpl/ledger/entries/PermissionedDomainEntry.h create mode 100644 include/xrpl/ledger/entries/RippleStateEntry.h create mode 100644 include/xrpl/ledger/entries/SLEBase.h create mode 100644 include/xrpl/ledger/entries/SignerListEntry.h create mode 100644 include/xrpl/ledger/entries/SponsorshipEntry.h create mode 100644 include/xrpl/ledger/entries/TicketEntry.h create mode 100644 include/xrpl/ledger/entries/TransactionProposalEntry.h create mode 100644 include/xrpl/ledger/entries/VaultEntry.h create mode 100644 include/xrpl/ledger/entries/XChainOwnedClaimIDEntry.h create mode 100644 include/xrpl/ledger/entries/XChainOwnedCreateAccountClaimIDEntry.h create mode 100644 include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h create mode 100644 include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h rename package/debian/{control => control.in} (93%) rename package/debian/{xrpld.docs => docs} (100%) rename package/debian/{xrpld.links => links} (100%) create mode 100644 package/debian/lintian-overrides.in delete mode 100644 package/debian/xrpld.lintian-overrides create mode 100644 src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp create mode 100644 src/test/jtx/PeerStub.h create mode 100644 src/test/jtx/impl/ConfidentialTransfer.cpp create mode 100644 src/test/overlay/CapturePeer.h create mode 100644 src/test/overlay/ProtocolMessage_test.cpp create mode 100644 src/test/overlay/TMGetLedger_test.cpp create mode 100644 src/test/overlay/TMTransaction_test.cpp create mode 100644 src/test/overlay/TMTransactions_test.cpp create mode 100644 src/test/overlay/overlay_limit_test.cpp create mode 100644 src/tests/libxrpl/ledger/AMMEntry.cpp create mode 100644 src/tests/libxrpl/ledger/AccountRootEntry.cpp create mode 100644 src/tests/libxrpl/ledger/AmendmentsEntry.cpp create mode 100644 src/tests/libxrpl/ledger/BridgeEntry.cpp create mode 100644 src/tests/libxrpl/ledger/CheckEntry.cpp create mode 100644 src/tests/libxrpl/ledger/CredentialEntry.cpp create mode 100644 src/tests/libxrpl/ledger/DIDEntry.cpp create mode 100644 src/tests/libxrpl/ledger/DelegateEntry.cpp create mode 100644 src/tests/libxrpl/ledger/DepositPreauthEntry.cpp create mode 100644 src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp create mode 100644 src/tests/libxrpl/ledger/EntryTestHelpers.h create mode 100644 src/tests/libxrpl/ledger/EscrowEntry.cpp create mode 100644 src/tests/libxrpl/ledger/FeeSettingsEntry.cpp create mode 100644 src/tests/libxrpl/ledger/LedgerHashesEntry.cpp create mode 100644 src/tests/libxrpl/ledger/LoanBrokerEntry.cpp create mode 100644 src/tests/libxrpl/ledger/LoanEntry.cpp create mode 100644 src/tests/libxrpl/ledger/MPTokenEntry.cpp create mode 100644 src/tests/libxrpl/ledger/MPTokenIssuanceEntry.cpp create mode 100644 src/tests/libxrpl/ledger/NFTokenOfferEntry.cpp create mode 100644 src/tests/libxrpl/ledger/NFTokenPageEntry.cpp create mode 100644 src/tests/libxrpl/ledger/NegativeUNLEntry.cpp create mode 100644 src/tests/libxrpl/ledger/OfferEntry.cpp create mode 100644 src/tests/libxrpl/ledger/OracleEntry.cpp create mode 100644 src/tests/libxrpl/ledger/PayChannelEntry.cpp create mode 100644 src/tests/libxrpl/ledger/PermissionedDomainEntry.cpp create mode 100644 src/tests/libxrpl/ledger/RippleStateEntry.cpp create mode 100644 src/tests/libxrpl/ledger/SLEBase.cpp create mode 100644 src/tests/libxrpl/ledger/SignerListEntry.cpp create mode 100644 src/tests/libxrpl/ledger/SponsorshipEntry.cpp create mode 100644 src/tests/libxrpl/ledger/TicketEntry.cpp create mode 100644 src/tests/libxrpl/ledger/VaultEntry.cpp create mode 100644 src/tests/libxrpl/ledger/XChainOwnedClaimIDEntry.cpp create mode 100644 src/tests/libxrpl/ledger/XChainOwnedCreateAccountClaimIDEntry.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdateTests.cpp create mode 100644 src/xrpld/rpc/MethodNames.h diff --git a/.cspell.config.yaml b/.cspell.config.yaml index c1af739255..48afdc2b7a 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -64,6 +64,7 @@ words: - blindings - bookdir - Bougalis + - bthomee - Britto - Btrfs - Buildx @@ -141,6 +142,7 @@ words: - hwrap - ifndef - inequation + - Injectivity - insuf - insuff - invasively diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index 5577c363fd..284fc9cdb7 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -82,7 +82,6 @@ test.app > xrpl.tx test.basics > test.jtx test.basics > xrpl.basics test.basics > xrpl.core -test.basics > xrpld.rpc test.basics > xrpl.json test.basics > xrpl.protocol test.beast > xrpl.basics @@ -113,6 +112,7 @@ test.jtx > xrpl.config test.jtx > xrpl.core test.jtx > xrpld.app test.jtx > xrpld.core +test.jtx > xrpld.overlay test.jtx > xrpld.rpc test.jtx > xrpl.json test.jtx > xrpl.ledger @@ -286,10 +286,10 @@ xrpld.perflog > xrpl.basics xrpld.perflog > xrpl.config xrpld.perflog > xrpl.core xrpld.perflog > xrpld.app -xrpld.perflog > xrpld.rpc xrpld.perflog > xrpl.json xrpld.perflog > xrpl.nodestore xrpld.perflog > xrpl.protocol +xrpld.perflog > xrpl.server xrpld.rpc > xrpl.basics xrpld.rpc > xrpl.config xrpld.rpc > xrpl.core diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 65671dbd11..5528d6442e 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -15,6 +15,14 @@ _BASE_CMAKE_ARGS = [ "-Drust=ON", ] +# The package formats a config can be packaged as, each with its own +# install-test job in reusable-package.yml. +PACKAGE_TYPES = ("deb", "rpm") + +# The package name a variant suffixes, as build_pkg.py's BASE_NAME spells it: +# the two have to agree, or the artifact globs miss what was built. +BASE_NAME = "xrpld" + # Maps sanitizer names (as used in cmake) to short config-name suffixes. _SANITIZER_SUFFIX: dict[str, str] = { "address": "asan", @@ -62,10 +70,20 @@ def get_cmake_args(build_type: str, extra_args: str) -> str: class PackageConfig: """The 'package' map of a config whose binaries are also packaged.""" - type: str # "deb" or "rpm"; has to match what the image provides + type: str # has to match what the image provides # The packaging container image: a vanilla distro image, not the nix image # the config itself builds in. image: str + # A flavour of the package, named xrpld-, for a config whose + # binaries are not the plain release build. A variant needs no counterpart + # in the other format. + variant: str = "" + + def __post_init__(self) -> None: + assert self.type in PACKAGE_TYPES, ( + f"unsupported package type {self.type!r}: " + f"use one of {', '.join(PACKAGE_TYPES)}." + ) @dataclasses.dataclass @@ -178,6 +196,8 @@ class PackagingEntry: validator_keys_artifact_name: str image: str package_type: str # "deb" or "rpm"; drives the format-specific steps + package_variant: str # passed to build_pkg.py --variant; empty for xrpld + package_name: str # the name it builds under, which the artifact globs use # --------------------------------------------------------------------------- @@ -267,12 +287,32 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: validator_keys_artifact_name=f"validator-keys-{name}", image=cfg.package.image, package_type=cfg.package.type, + package_variant=cfg.package.variant, + package_name=( + f"{BASE_NAME}-{cfg.package.variant}" + if cfg.package.variant + else BASE_NAME + ), ) ) return entries +def package_names_by_type(entries: list[PackagingEntry]) -> dict[str, list[str]]: + """The names of the packages in 'entries', keyed by format. + + Derived from the packaging matrix rather than listed again, so the packages + the install-test jobs look for are the packages that were built. + """ + return { + package_type: sorted( + {e.package_name for e in entries if e.package_type == package_type} + ) + for package_type in PACKAGE_TYPES + } + + def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry]: """Expand a PlatformFile (macOS or Windows) into matrix entries. @@ -341,6 +381,10 @@ if __name__ == "__main__": if args.packaging: matrix = expand_linux_packaging(LinuxFile.load(THIS_DIR / "linux.json")) + # One list per format, so each install-test job installs the packages its + # own format produced. + for package_type, names in package_names_by_type(matrix).items(): + print(f"{package_type}_package_names={json.dumps(names)}") else: if args.config in ("linux", None): matrix += expand_linux_matrix( diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 2229332e41..9b069f0ce3 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -76,6 +76,19 @@ "type": "deb", "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-49cdc10" } + }, + { + "compiler": ["gcc"], + "build_type": ["Release"], + "arch": ["amd64"], + "minimal": false, + "suffix": "assert", + "extra_cmake_args": "-Dvalidator_keys=ON -Dassert=ON", + "package": { + "type": "deb", + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-49cdc10", + "variant": "assert" + } } ], diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 933c7b8a54..62a995d864 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -85,6 +85,7 @@ jobs: .github/workflows/reusable-build-test.yml .github/workflows/reusable-check-autogen.yml .github/workflows/reusable-clang-tidy.yml + .github/workflows/reusable-package-test-install.yml .github/workflows/reusable-package.yml .github/workflows/reusable-rust.yml .github/workflows/reusable-strategy-matrix.yml @@ -189,6 +190,12 @@ jobs: # matrix (i.e. not yet labeled "Ready to merge" or "Full CI build"). if: ${{ needs.should-run.outputs.go == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'Ready to merge') || contains(github.event.pull_request.labels.*.name, 'Full CI build')) }} uses: ./.github/workflows/reusable-package.yml + with: + # A pull request builds packages to prove they still build, and publishes + # nothing. Stated rather than left to the input's default, so that changing + # that default cannot start publishing from pull requests. No secrets are + # passed either, which is the second reason a publish here cannot succeed. + publish: false upload-recipe: needs: diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 2099f5f739..2bf73332d2 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -23,6 +23,7 @@ on: - ".github/workflows/reusable-build-test.yml" - ".github/workflows/reusable-check-autogen.yml" - ".github/workflows/reusable-clang-tidy.yml" + - ".github/workflows/reusable-package-test-install.yml" - ".github/workflows/reusable-package.yml" - ".github/workflows/reusable-rust.yml" - ".github/workflows/reusable-strategy-matrix.yml" diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index a37ab386b8..2a0c9332f3 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -439,7 +439,7 @@ jobs: - name: Upload coverage report if: ${{ github.repository_owner == 'XRPLF' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: disable_search: true disable_telem: true diff --git a/.github/workflows/reusable-package-test-install.yml b/.github/workflows/reusable-package-test-install.yml new file mode 100644 index 0000000000..4f1b6d7cb5 --- /dev/null +++ b/.github/workflows/reusable-package-test-install.yml @@ -0,0 +1,120 @@ +# Install one package format on every distro family it targets, one job per +# package name and image, and run the binaries there. Called once per format by +# reusable-package.yml, which owns the names and the image lists. +name: Install packages + +on: + workflow_call: + inputs: + package_type: + description: 'The package format to install ("deb" or "rpm").' + required: true + type: string + package_names: + description: "JSON array of package names built for this format." + required: true + type: string + images: + description: "JSON array of container images to install in." + required: true + type: string + +defaults: + run: + shell: bash + +env: + PACKAGE_DIR: packages + +jobs: + install: + strategy: + fail-fast: false + matrix: + package_name: ${{ fromJson(inputs.package_names) }} + image: ${{ fromJson(inputs.images) }} + name: "${{ matrix.package_name }} on ${{ matrix.image }}" + permissions: + contents: read + runs-on: ubuntu-latest + container: ${{ matrix.image }} + timeout-minutes: 5 + + steps: + # Every package lands in one directory; the step below picks its own, + # which keeps this independent of the artifact names. + - name: Download package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: "*-pkg" + merge-multiple: true + path: ${{ env.PACKAGE_DIR }} + + - name: Find the package + id: find + env: + PACKAGE_NAME: ${{ matrix.package_name }} + PACKAGE_TYPE: ${{ inputs.package_type }} + run: | + # The version follows the name, separated by '_' in a DEB and '-' in an + # RPM. Requiring a digit after it is what keeps 'xrpld' from picking up + # another package, such as 'xrpld-assert'. + pattern="${PACKAGE_NAME}[_-][0-9]*.${PACKAGE_TYPE}" + package="$(find "${PACKAGE_DIR}" -type f -name "${pattern}" -print -quit)" + test -n "${package}" || { + echo "no ${pattern} found in ${PACKAGE_DIR}" >&2 + exit 1 + } + echo "package=${package}" >>"${GITHUB_OUTPUT}" + + # Debian 11 went end-of-life on 2026-08-31 + # (https://www.debian.org/News/2026/20260831) and its packages are + # already partly gone from deb.debian.org, so switch to the + # snapshot.debian.org entries the image ships commented out in its + # sources.list: they are pinned to the snapshot the image was built + # from, so they serve every version it needs and never go away. + # Snapshots keep their original, long-passed Valid-Until, hence the + # disabled check; the retries absorb snapshot.debian.org's throttling. + - name: Switch Debian 11 to snapshot.debian.org + if: ${{ matrix.image == 'debian:11' }} + run: | + sed -i 's|^deb |# deb |; s|^# deb http://snapshot|deb http://snapshot|' /etc/apt/sources.list + printf '%s\n' \ + 'Acquire::Check-Valid-Until "false";' \ + 'Acquire::Retries "3";' \ + >/etc/apt/apt.conf.d/99snapshot + + - name: Install the DEB + if: ${{ inputs.package_type == 'deb' }} + env: + DEBIAN_FRONTEND: noninteractive + PACKAGE: ${{ steps.find.outputs.package }} + run: | + # Stock Debian and Ubuntu images carry no package lists, so apt has + # nothing to resolve the systemd dependency from until it fetches them. + apt-get update -qq + apt-get install -y "./${PACKAGE}" + + - name: Install the RPM + if: ${{ inputs.package_type == 'rpm' }} + env: + PACKAGE: ${{ steps.find.outputs.package }} + run: dnf install -y "./${PACKAGE}" + + - name: Run xrpld + run: xrpld --version + + - name: Run validator-keys + run: validator-keys --version + + - name: Run rippled, the legacy compatibility symlink + run: rippled --version + + - name: Check the service account + run: id xrpld + + - name: Check the state directory + run: test -d /var/lib/xrpld + + - name: Check the log directory + run: test -d /var/log/xrpld diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 9dbc027176..700ec9180b 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -3,8 +3,10 @@ # # - 'package' builds and signs one format per config that carries a "package" # map in linux.json; that map names the container image and the format -# - 'test-install' installs what was built on a range of distros and runs the -# binaries there, so a package that cannot be installed never reaches Nexus +# - 'test-install-deb' and 'test-install-rpm' call +# reusable-package-test-install.yml to install what was built on a range of +# distros and run the binaries there, so a package that cannot be installed +# never reaches Nexus # - 'publish' uploads with the image's publish_pkg.py, doing a --dry-run # unless 'publish: true' # @@ -23,7 +25,7 @@ on: description: "The base URL of the Nexus instance hosting the deb and rpm repositories." required: false type: string - default: https://packages.xrplf.org + default: https://packages-upload.xrplf.org secrets: remote_username: @@ -49,6 +51,8 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate.outputs.matrix }} + deb_package_names: ${{ steps.generate.outputs.deb_package_names }} + rpm_package_names: ${{ steps.generate.outputs.rpm_package_names }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -107,6 +111,7 @@ jobs: - name: Build package env: PACKAGE_TYPE: ${{ matrix.package_type }} + PACKAGE_VARIANT: ${{ matrix.package_variant }} PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} CHANNEL: ${{ steps.release_info.outputs.channel }} run: | @@ -114,6 +119,7 @@ jobs: --package-type "${PACKAGE_TYPE}" \ --build-dir "${BUILD_DIR}" \ --pkg-release "${PKG_RELEASE}" \ + --variant "${PACKAGE_VARIANT}" \ --channel "${CHANNEL}" # Before the upload, so the artifact, the tested package and the published @@ -125,14 +131,17 @@ jobs: run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}" # Split from the debug symbols, which are an order of magnitude larger, so - # that test-install downloads only what it installs. + # that test-install downloads only what it installs. In the globs below the + # version follows the name, separated by '_' in a DEB and '-' in an RPM. A + # version starts with a digit and a longer name does not, so that one digit + # is what tells 'xrpld-3.4.1-...' from 'xrpld-assert-3.4.1-...'. - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.xrpld_artifact_name }}-pkg path: | - ${{ env.BUILD_DIR }}/debbuild/xrpld_*.deb - ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/xrpld-[0-9]*.rpm + ${{ env.BUILD_DIR }}/debbuild/${{ matrix.package_name }}_[0-9]*.deb + ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/${{ matrix.package_name }}-[0-9]*.rpm if-no-files-found: error - name: Upload debug symbol artifact @@ -140,133 +149,59 @@ jobs: with: name: ${{ matrix.xrpld_artifact_name }}-pkg-debug path: | - ${{ env.BUILD_DIR }}/debbuild/xrpld-dbgsym_*.deb - ${{ env.BUILD_DIR }}/debbuild/xrpld-dbgsym_*.ddeb - ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/xrpld-debuginfo-*.rpm + ${{ env.BUILD_DIR }}/debbuild/${{ matrix.package_name }}-dbgsym_[0-9]*.deb + ${{ env.BUILD_DIR }}/debbuild/${{ matrix.package_name }}-dbgsym_[0-9]*.ddeb + ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/${{ matrix.package_name }}-debuginfo-[0-9]*.rpm if-no-files-found: error - # Every distro family the packages target, oldest release first, so both ends - # of the dependency range they declare are exercised. - test-install: - needs: [package] - strategy: - fail-fast: false - matrix: - include: - - package_type: deb - image: debian:11 - - package_type: deb - image: debian:12 - - package_type: deb - image: debian:13 - - package_type: deb - image: ubuntu:20.04 - - package_type: deb - image: ubuntu:22.04 - - package_type: deb - image: ubuntu:24.04 - - package_type: deb - image: ubuntu:26.04 + # One call per format, so a variant packaged for one format is installed for + # that format alone. The images are every distro family that format targets, + # oldest release first, so both ends of the dependency range the packages + # declare are exercised. + test-install-deb: + needs: [generate-matrix, package] + name: install deb + uses: ./.github/workflows/reusable-package-test-install.yml + with: + package_type: deb + package_names: ${{ needs.generate-matrix.outputs.deb_package_names }} + images: | + [ + "debian:11", + "debian:12", + "debian:13", + "ubuntu:20.04", + "ubuntu:22.04", + "ubuntu:24.04", + "ubuntu:26.04" + ] - - package_type: rpm - image: almalinux:9 - - package_type: rpm - image: almalinux:10 - - package_type: rpm - image: rockylinux/rockylinux:9 - - package_type: rpm - image: rockylinux/rockylinux:10 - - package_type: rpm - image: registry.access.redhat.com/ubi9/ubi - - package_type: rpm - image: registry.access.redhat.com/ubi10/ubi - name: "install ${{ matrix.package_type }} on ${{ matrix.image }}" - permissions: - contents: read - runs-on: ubuntu-latest - container: ${{ matrix.image }} - timeout-minutes: 5 - - steps: - # Both formats land in one directory; the step below picks its own by - # extension, so this stays independent of the artifact names. - - name: Download package artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: "*-pkg" - merge-multiple: true - path: ${{ env.PACKAGE_DIR }} - - - name: Find the package - id: find - env: - PACKAGE_TYPE: ${{ matrix.package_type }} - run: | - package="$(find "${PACKAGE_DIR}" -type f -name "*.${PACKAGE_TYPE}" -print -quit)" - test -n "${package}" || { - echo "no .${PACKAGE_TYPE} found in ${PACKAGE_DIR}" >&2 - exit 1 - } - echo "package=${package}" >>"${GITHUB_OUTPUT}" - - # Debian 11 went end-of-life on 2026-08-31 - # (https://www.debian.org/News/2026/20260831) and its packages are - # already partly gone from deb.debian.org, so switch to the - # snapshot.debian.org entries the image ships commented out in its - # sources.list: they are pinned to the snapshot the image was built - # from, so they serve every version it needs and never go away. - # Snapshots keep their original, long-passed Valid-Until, hence the - # disabled check; the retries absorb snapshot.debian.org's throttling. - - name: Switch Debian 11 to snapshot.debian.org - if: ${{ matrix.image == 'debian:11' }} - run: | - sed -i 's|^deb |# deb |; s|^# deb http://snapshot|deb http://snapshot|' /etc/apt/sources.list - printf '%s\n' \ - 'Acquire::Check-Valid-Until "false";' \ - 'Acquire::Retries "3";' \ - >/etc/apt/apt.conf.d/99snapshot - - - name: Install the DEB - if: ${{ matrix.package_type == 'deb' }} - env: - DEBIAN_FRONTEND: noninteractive - PACKAGE: ${{ steps.find.outputs.package }} - run: | - # Stock Debian and Ubuntu images carry no package lists, so apt has - # nothing to resolve the systemd dependency from until it fetches them. - apt-get update -qq - apt-get install -y "./${PACKAGE}" - - - name: Install the RPM - if: ${{ matrix.package_type == 'rpm' }} - env: - PACKAGE: ${{ steps.find.outputs.package }} - run: dnf install -y "./${PACKAGE}" - - - name: Run xrpld - run: xrpld --version - - - name: Run validator-keys - run: validator-keys --version - - - name: Run rippled, the legacy compatibility symlink - run: rippled --version - - - name: Check the service account - run: id xrpld - - - name: Check the state directory - run: test -d /var/lib/xrpld - - - name: Check the log directory - run: test -d /var/log/xrpld + test-install-rpm: + needs: [generate-matrix, package] + name: install rpm + uses: ./.github/workflows/reusable-package-test-install.yml + with: + package_type: rpm + package_names: ${{ needs.generate-matrix.outputs.rpm_package_names }} + images: | + [ + "almalinux:9", + "almalinux:10", + "rockylinux/rockylinux:9", + "rockylinux/rockylinux:10", + "registry.access.redhat.com/ubi9/ubi", + "registry.access.redhat.com/ubi10/ubi" + ] publish: - needs: [generate-matrix, package, test-install] + needs: [generate-matrix, package, test-install-deb, test-install-rpm] strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - name: "publish ${{ matrix.xrpld_artifact_name }}" + # The name says which of the two this is, because the job runs either way: + # with publish false it passes --dry-run and uploads nothing, and a job + # called "publish ..." succeeding on a pull request reads like a release. + name: "publish ${{ matrix.xrpld_artifact_name }}${{ !inputs.publish && ' (dry run)' || '' }}" permissions: contents: read runs-on: ["self-hosted", "Linux", "X64", "heavy"] diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml index 12a31aacbd..bdd5f75ca6 100644 --- a/.github/workflows/reusable-rust.yml +++ b/.github/workflows/reusable-rust.yml @@ -57,7 +57,7 @@ jobs: - name: Upload coverage report if: ${{ github.repository == 'XRPLF/rippled' }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: disable_search: true disable_telem: true diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index ed27312023..192aa7272e 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -41,6 +41,7 @@ Version 3.4.0 is not yet released. These changes are available in the 3.4.0 beta - `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655) - `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) - `ledger`: `delivered_amount` is now included in the metadata of successful `AccountDelete` transactions when transactions are expanded (`expand`, or admin-only `full`). Previously it was only added for `Payment` and `CheckCash`, which made `ledger` inconsistent with `tx` and `account_tx`. [#5706](https://github.com/XRPLF/rippled/pull/5706) +- `noripple_check`: The `transactions` field is no longer included in error responses; it is still returned (possibly as an empty array) whenever `transactions` is `true` and the request succeeds. A malformed `account` is now rejected before the ledger is looked up, so that error response no longer carries the `ledger_hash`, `ledger_index`, and `validated` fields ([#6303](https://github.com/XRPLF/rippled/pull/6303)). ## XRP Ledger server version 3.3.0 diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index e2f7029ad2..0fdeae0d0d 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -44,12 +44,18 @@ else() set(pkg_type rpm) endif() +# Unquoted below, so an empty value adds no argument at all. +set(pkg_variant_option "") +if(assert) + set(pkg_variant_option --variant=assert) +endif() + add_custom_target( package COMMAND ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type=${pkg_type} --build-dir=${CMAKE_BINARY_DIR} --pkg-release=${pkg_release} - --channel=UNRELEASED + ${pkg_variant_option} --channel=UNRELEASED WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS xrpld validator-keys COMMENT "Building Linux ${pkg_type} package" diff --git a/conanfile.py b/conanfile.py index 0683a3779f..52d4da4286 100644 --- a/conanfile.py +++ b/conanfile.py @@ -149,6 +149,7 @@ class Xrpl(ConanFile): self.requires("xxhash/0.8.3", transitive_headers=True) exports_sources = ( + "bin/default-loader-path.sh", "CMakeLists.txt", "cfg/*", "cmake/*", diff --git a/docs/install.md b/docs/install.md index 4c52b587b6..34606fbe11 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,7 +6,8 @@ `xrpld` is published as DEB and RPM packages for 64-bit x86 Linux. Use APT on Debian-based distributions such as Debian and Ubuntu, -and YUM on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux. +and DNF on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux, +where `yum` is a symlink to `dnf`. To build from source instead, see [BUILD.md](../BUILD.md). ## Release channels @@ -81,7 +82,7 @@ wherever it appears in the repository configuration. sudo apt -y install xrpld ``` -### With the YUM package manager +### With the DNF package manager 1. Add the XRPL Foundation package-signing key: @@ -109,9 +110,23 @@ wherever it appears in the repository configuration. 3. Install the `xrpld` package: ```bash - sudo yum install -y xrpld + sudo dnf install -y xrpld ``` +### Optional: the assert-enabled build + +Every channel also carries `xrpld-assert` as a DEB, the same build with assertions +enabled, for diagnosing a problem on a non-production server. +It installs the same files as `xrpld` and replaces it, so install one or the other: + +```bash +sudo apt -y install xrpld-assert # APT removes xrpld itself +``` + +Switching stops the service, since it is a removal and an installation rather than an upgrade, +and APT starts it again. +Install `xrpld` the same way to switch back. + ## The xrpld service Both package managers install a systemd unit and enable it, so `xrpld` starts on boot. @@ -121,7 +136,7 @@ Check whether it is already running: systemctl status xrpld.service ``` -The APT packages start it immediately as well; the YUM packages do not, so start it yourself: +The DEB packages start it immediately as well; the RPM packages do not, so start it yourself: ```bash sudo systemctl start xrpld.service diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index e3b91c2f25..7cb67cb15b 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -162,4 +162,89 @@ toUInt64(std::string const& s); bool isProperlyFormedTomlDomain(std::string_view domain); +/** + * Whether a view can be passed on as a C string. + * + * A reader given only data() stops at the first null, so the view must reach the + * terminating null. The test rebuilds the view from data() and compares: a view + * that stops earlier rebuilds longer, and so compares unequal. + * + * consteval because reading the byte after the view is only defined when @p str + * points into storage holding a null at or after its end, such as a string + * literal. An unterminated view is then a compile error, not an out-of-bounds + * read. + * + * @param str The view to test. + * @return Whether @p str is null-terminated. A view with no data is not. + */ +consteval bool +isNullTerminated(std::string_view str) +{ + if (str.data() == nullptr) + return false; + + // Reading past the view is the point, so the usual data() warning does not + // apply. + // NOLINTNEXTLINE(bugprone-suspicious-stringview-data-usage) + return std::string_view{str.data()} == str; +} + +/** + * A string that is known to reach its terminating null. + * + * Converts to std::string_view, so it compares and hashes as one. Unlike a + * view, asCString() may be handed to a reader that expects a C string, such + * as json::StaticString. + * + * The only constructor is consteval and rejects a view that stops before the + * null, so the property holds by construction and no caller asserts it. + */ +class NullTerminatedView +{ +public: + /** + * Build a view from one that reaches its terminating null. + * + * Explicit, so that a plain view cannot become a proof of termination by + * accident. The conversion the other way stays implicit. + * + * @param view The string to hold. Rejected at compile time if it stops + * before its terminating null, or has no data. + */ + explicit consteval NullTerminatedView(std::string_view view) + : data_(view.data()), size_(view.size()) + { + if (!isNullTerminated(view)) + throw "xrpl::NullTerminatedView : view does not reach a null"; + } + + constexpr + operator std::string_view() const noexcept + { + return view(); + } + + /** + * @return The string as a view. + */ + [[nodiscard]] constexpr std::string_view + view() const noexcept + { + return {data_, size_}; + } + + /** + * @return The string as a C string. Never null. + */ + [[nodiscard]] constexpr char const* + asCString() const noexcept + { + return data_; + } + +private: + char const* data_; + std::size_t size_; +}; + } // namespace xrpl diff --git a/include/xrpl/basics/base_uint.h b/include/xrpl/basics/base_uint.h index bee8b8b945..3786f28c6a 100644 --- a/include/xrpl/basics/base_uint.h +++ b/include/xrpl/basics/base_uint.h @@ -518,7 +518,7 @@ public: * The input must be precisely `2 * bytes` hexadecimal characters * long, with one exception: the value '0'. * - * @param sv A null-terminated string of hexadecimal characters + * @param sv A string of hexadecimal characters * @return true if the input was parsed properly; false otherwise. */ [[nodiscard]] constexpr bool diff --git a/include/xrpl/core/PerfLog.h b/include/xrpl/core/PerfLog.h index dd78a8f9a6..0c544c9aa5 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -9,7 +10,8 @@ #include #include #include -#include +#include +#include namespace beast { class Journal; @@ -67,7 +69,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcStart(std::string const& method, std::uint64_t requestId) = 0; + rpcStart(std::string_view method, std::uint64_t requestId) = 0; /** * Log successful finish of RPC call @@ -76,7 +78,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcFinish(std::string const& method, std::uint64_t requestId) = 0; + rpcFinish(std::string_view method, std::uint64_t requestId) = 0; /** * Log errored RPC call @@ -85,7 +87,7 @@ public: * @param requestId Unique identifier to track command */ virtual void - rpcError(std::string const& method, std::uint64_t requestId) = 0; + rpcError(std::string_view method, std::uint64_t requestId) = 0; /** * Log queued job @@ -150,10 +152,20 @@ public: PerfLog::Setup setupPerfLog(Section const& section, std::filesystem::path const& configDir); +/** + * @param methodNames The RPC methods to count, one counter per name. Reported + * as JSON keys that borrow each name and read it as a C string, which is + * why the parameter type requires one that reaches its terminating null. + * The names must outlive the returned object, which holds views of them. + * The range itself need not: it is copied. + * Passed in rather than looked up here, so that this layer needs no + * knowledge of the dispatch table. + */ std::unique_ptr makePerfLog( PerfLog::Setup const& setup, Application& app, + std::span methodNames, beast::Journal journal, std::function&& signalStop); @@ -161,7 +173,7 @@ template auto measureDurationAndLog( Func&& func, - std::string const& actionDescription, + std::string_view actionDescription, std::chrono::duration maxDelay, beast::Journal const& journal) { diff --git a/include/xrpl/ledger/entries/AMMEntry.h b/include/xrpl/ledger/entries/AMMEntry.h new file mode 100644 index 0000000000..0c1c0fe629 --- /dev/null +++ b/include/xrpl/ledger/entries/AMMEntry.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class AMMEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit AMMEntry( + Asset const& issue1, + Asset const& issue2, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::amm(issue1, issue2), view, j) + { + } + + explicit AMMEntry( + uint256 const& ammID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::amm(ammID), view, j) + { + } +}; + +using AMMEntryR = AMMEntry; +using AMMEntryW = AMMEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/AccountRootEntry.h b/include/xrpl/ledger/entries/AccountRootEntry.h new file mode 100644 index 0000000000..5555ea8c50 --- /dev/null +++ b/include/xrpl/ledger/entries/AccountRootEntry.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class AccountRootEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit AccountRootEntry( + AccountID const& id, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::account(id), view, j) + { + } +}; + +using AccountRootEntryR = AccountRootEntry; +using AccountRootEntryW = AccountRootEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/AmendmentsEntry.h b/include/xrpl/ledger/entries/AmendmentsEntry.h new file mode 100644 index 0000000000..ce4dca7e80 --- /dev/null +++ b/include/xrpl/ledger/entries/AmendmentsEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class AmendmentsEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit AmendmentsEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::amendments(), view, j) + { + } +}; + +using AmendmentsEntryR = AmendmentsEntry; +using AmendmentsEntryW = AmendmentsEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/BridgeEntry.h b/include/xrpl/ledger/entries/BridgeEntry.h new file mode 100644 index 0000000000..a51fbe0c12 --- /dev/null +++ b/include/xrpl/ledger/entries/BridgeEntry.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class BridgeEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit BridgeEntry( + STXChainBridge const& bridge, + STXChainBridge::ChainType chainType, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::bridge(bridge, chainType), view, j) + { + } +}; + +using BridgeEntryR = BridgeEntry; +using BridgeEntryW = BridgeEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/CheckEntry.h b/include/xrpl/ledger/entries/CheckEntry.h new file mode 100644 index 0000000000..bf6a188ff5 --- /dev/null +++ b/include/xrpl/ledger/entries/CheckEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class CheckEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit CheckEntry( + AccountID const& id, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::check(id, seq), view, j) + { + } + + explicit CheckEntry( + uint256 const& checkID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::check(checkID), view, j) + { + } +}; + +using CheckEntryR = CheckEntry; +using CheckEntryW = CheckEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/CredentialEntry.h b/include/xrpl/ledger/entries/CredentialEntry.h new file mode 100644 index 0000000000..4b6a3386a1 --- /dev/null +++ b/include/xrpl/ledger/entries/CredentialEntry.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class CredentialEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit CredentialEntry( + AccountID const& subject, + AccountID const& issuer, + Slice const& credType, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::credential(subject, issuer, credType), view, j) + { + } + + explicit CredentialEntry( + uint256 const& credentialID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::credential(credentialID), view, j) + { + } +}; + +using CredentialEntryR = CredentialEntry; +using CredentialEntryW = CredentialEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DIDEntry.h b/include/xrpl/ledger/entries/DIDEntry.h new file mode 100644 index 0000000000..ee3eaf8153 --- /dev/null +++ b/include/xrpl/ledger/entries/DIDEntry.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class DIDEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DIDEntry( + AccountID const& account, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::did(account), view, j) + { + } +}; + +using DIDEntryR = DIDEntry; +using DIDEntryW = DIDEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DelegateEntry.h b/include/xrpl/ledger/entries/DelegateEntry.h new file mode 100644 index 0000000000..de908d4dcd --- /dev/null +++ b/include/xrpl/ledger/entries/DelegateEntry.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class DelegateEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DelegateEntry( + AccountID const& account, + AccountID const& authorizedAccount, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::delegate(account, authorizedAccount), view, j) + { + } +}; + +using DelegateEntryR = DelegateEntry; +using DelegateEntryW = DelegateEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DepositPreauthEntry.h b/include/xrpl/ledger/entries/DepositPreauthEntry.h new file mode 100644 index 0000000000..783363084d --- /dev/null +++ b/include/xrpl/ledger/entries/DepositPreauthEntry.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +template +class DepositPreauthEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DepositPreauthEntry( + AccountID const& owner, + AccountID const& preauthorized, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::depositPreauth(owner, preauthorized), view, j) + { + } + + explicit DepositPreauthEntry( + AccountID const& owner, + std::set> const& authCreds, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::depositPreauth(owner, authCreds), view, j) + { + } + + explicit DepositPreauthEntry( + uint256 const& preauthID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::depositPreauth(preauthID), view, j) + { + } +}; + +using DepositPreauthEntryR = DepositPreauthEntry; +using DepositPreauthEntryW = DepositPreauthEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/DirectoryNodeEntry.h b/include/xrpl/ledger/entries/DirectoryNodeEntry.h new file mode 100644 index 0000000000..b57bec7348 --- /dev/null +++ b/include/xrpl/ledger/entries/DirectoryNodeEntry.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class DirectoryNodeEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit DirectoryNodeEntry( + AccountID const& id, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::ownerDir(id), view, j) + { + } + + /** + * Resolve a specific page of the directory rooted at @p root. + */ + explicit DirectoryNodeEntry( + uint256 const& root, + std::uint64_t index, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::page(root, index), view, j) + { + } +}; + +using DirectoryNodeEntryR = DirectoryNodeEntry; +using DirectoryNodeEntryW = DirectoryNodeEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/EscrowEntry.h b/include/xrpl/ledger/entries/EscrowEntry.h new file mode 100644 index 0000000000..9eb1418a4c --- /dev/null +++ b/include/xrpl/ledger/entries/EscrowEntry.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class EscrowEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit EscrowEntry( + AccountID const& src, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::escrow(src, seq), view, j) + { + } +}; + +using EscrowEntryR = EscrowEntry; +using EscrowEntryW = EscrowEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/FeeSettingsEntry.h b/include/xrpl/ledger/entries/FeeSettingsEntry.h new file mode 100644 index 0000000000..66d61892e5 --- /dev/null +++ b/include/xrpl/ledger/entries/FeeSettingsEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class FeeSettingsEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit FeeSettingsEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::feeSettings(), view, j) + { + } +}; + +using FeeSettingsEntryR = FeeSettingsEntry; +using FeeSettingsEntryW = FeeSettingsEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/LedgerHashesEntry.h b/include/xrpl/ledger/entries/LedgerHashesEntry.h new file mode 100644 index 0000000000..ab54d820a1 --- /dev/null +++ b/include/xrpl/ledger/entries/LedgerHashesEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class LedgerHashesEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit LedgerHashesEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::skip(), view, j) + { + } +}; + +using LedgerHashesEntryR = LedgerHashesEntry; +using LedgerHashesEntryW = LedgerHashesEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/LoanBrokerEntry.h b/include/xrpl/ledger/entries/LoanBrokerEntry.h new file mode 100644 index 0000000000..77e6b660ed --- /dev/null +++ b/include/xrpl/ledger/entries/LoanBrokerEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class LoanBrokerEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit LoanBrokerEntry( + AccountID const& owner, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loanBroker(owner, seq), view, j) + { + } + + explicit LoanBrokerEntry( + uint256 const& loanBrokerID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loanBroker(loanBrokerID), view, j) + { + } +}; + +using LoanBrokerEntryR = LoanBrokerEntry; +using LoanBrokerEntryW = LoanBrokerEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/LoanEntry.h b/include/xrpl/ledger/entries/LoanEntry.h new file mode 100644 index 0000000000..124c5915fc --- /dev/null +++ b/include/xrpl/ledger/entries/LoanEntry.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class LoanEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit LoanEntry( + uint256 const& loanBrokerID, + SeqProxy const& loanSeq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loan(loanBrokerID, loanSeq), view, j) + { + } + + explicit LoanEntry( + uint256 const& loanID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::loan(loanID), view, j) + { + } +}; + +using LoanEntryR = LoanEntry; +using LoanEntryW = LoanEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/MPTokenEntry.h b/include/xrpl/ledger/entries/MPTokenEntry.h new file mode 100644 index 0000000000..dcd6c5adb1 --- /dev/null +++ b/include/xrpl/ledger/entries/MPTokenEntry.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class MPTokenEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit MPTokenEntry( + MPTID const& issuanceID, + AccountID const& holder, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptoken(issuanceID, holder), view, j) + { + } + + explicit MPTokenEntry( + uint256 const& issuanceKey, + AccountID const& holder, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptoken(issuanceKey, holder), view, j) + { + } + + explicit MPTokenEntry( + uint256 const& mptokenKey, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptoken(mptokenKey), view, j) + { + } +}; + +using MPTokenEntryR = MPTokenEntry; +using MPTokenEntryW = MPTokenEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/MPTokenIssuanceEntry.h b/include/xrpl/ledger/entries/MPTokenIssuanceEntry.h new file mode 100644 index 0000000000..52dac53217 --- /dev/null +++ b/include/xrpl/ledger/entries/MPTokenIssuanceEntry.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class MPTokenIssuanceEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit MPTokenIssuanceEntry( + std::uint32_t seq, + AccountID const& issuer, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptokenIssuance(makeMptID(seq, issuer)), view, j) + { + } + + explicit MPTokenIssuanceEntry( + MPTID const& issuanceID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptokenIssuance(issuanceID), view, j) + { + } + + explicit MPTokenIssuanceEntry( + uint256 const& issuanceKey, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::mptokenIssuance(issuanceKey), view, j) + { + } +}; + +using MPTokenIssuanceEntryR = MPTokenIssuanceEntry; +using MPTokenIssuanceEntryW = MPTokenIssuanceEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/NFTokenOfferEntry.h b/include/xrpl/ledger/entries/NFTokenOfferEntry.h new file mode 100644 index 0000000000..a2e71652cd --- /dev/null +++ b/include/xrpl/ledger/entries/NFTokenOfferEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class NFTokenOfferEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit NFTokenOfferEntry( + AccountID const& owner, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::nftokenOffer(owner, seq), view, j) + { + } + + explicit NFTokenOfferEntry( + uint256 const& offerID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::nftokenOffer(offerID), view, j) + { + } +}; + +using NFTokenOfferEntryR = NFTokenOfferEntry; +using NFTokenOfferEntryW = NFTokenOfferEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/NFTokenPageEntry.h b/include/xrpl/ledger/entries/NFTokenPageEntry.h new file mode 100644 index 0000000000..11701f17ea --- /dev/null +++ b/include/xrpl/ledger/entries/NFTokenPageEntry.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class NFTokenPageEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit NFTokenPageEntry( + Keylet const& page, + uint256 const& token, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::nftokenPage(page, token), view, j) + { + } +}; + +using NFTokenPageEntryR = NFTokenPageEntry; +using NFTokenPageEntryW = NFTokenPageEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/NegativeUNLEntry.h b/include/xrpl/ledger/entries/NegativeUNLEntry.h new file mode 100644 index 0000000000..9c61058622 --- /dev/null +++ b/include/xrpl/ledger/entries/NegativeUNLEntry.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class NegativeUNLEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit NegativeUNLEntry( + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::negativeUNL(), view, j) + { + } +}; + +using NegativeUNLEntryR = NegativeUNLEntry; +using NegativeUNLEntryW = NegativeUNLEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/OfferEntry.h b/include/xrpl/ledger/entries/OfferEntry.h new file mode 100644 index 0000000000..2180d3c89c --- /dev/null +++ b/include/xrpl/ledger/entries/OfferEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class OfferEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit OfferEntry( + AccountID const& id, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::offer(id, seq), view, j) + { + } + + explicit OfferEntry( + uint256 const& offerID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::offer(offerID), view, j) + { + } +}; + +using OfferEntryR = OfferEntry; +using OfferEntryW = OfferEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/OracleEntry.h b/include/xrpl/ledger/entries/OracleEntry.h new file mode 100644 index 0000000000..69013a2745 --- /dev/null +++ b/include/xrpl/ledger/entries/OracleEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class OracleEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit OracleEntry( + AccountID const& account, + std::uint32_t documentID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::oracle(account, documentID), view, j) + { + } +}; + +using OracleEntryR = OracleEntry; +using OracleEntryW = OracleEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/PayChannelEntry.h b/include/xrpl/ledger/entries/PayChannelEntry.h new file mode 100644 index 0000000000..3540474633 --- /dev/null +++ b/include/xrpl/ledger/entries/PayChannelEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class PayChannelEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit PayChannelEntry( + AccountID const& src, + AccountID const& dst, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::payChannel(src, dst, seq), view, j) + { + } +}; + +using PayChannelEntryR = PayChannelEntry; +using PayChannelEntryW = PayChannelEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/PermissionedDomainEntry.h b/include/xrpl/ledger/entries/PermissionedDomainEntry.h new file mode 100644 index 0000000000..0668a8f8b6 --- /dev/null +++ b/include/xrpl/ledger/entries/PermissionedDomainEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class PermissionedDomainEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit PermissionedDomainEntry( + AccountID const& account, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::permissionedDomain(account, seq), view, j) + { + } + + explicit PermissionedDomainEntry( + uint256 const& domainID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::permissionedDomain(domainID), view, j) + { + } +}; + +using PermissionedDomainEntryR = PermissionedDomainEntry; +using PermissionedDomainEntryW = PermissionedDomainEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/RippleStateEntry.h b/include/xrpl/ledger/entries/RippleStateEntry.h new file mode 100644 index 0000000000..f9bd8aa703 --- /dev/null +++ b/include/xrpl/ledger/entries/RippleStateEntry.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class RippleStateEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit RippleStateEntry( + AccountID const& id0, + AccountID const& id1, + Currency const& currency, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::trustLine(id0, id1, currency), view, j) + { + } + + explicit RippleStateEntry( + AccountID const& id, + Issue const& issue, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::trustLine(id, issue), view, j) + { + } +}; + +using RippleStateEntryR = RippleStateEntry; +using RippleStateEntryW = RippleStateEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/SLEBase.h b/include/xrpl/ledger/entries/SLEBase.h new file mode 100644 index 0000000000..182e03b55d --- /dev/null +++ b/include/xrpl/ledger/entries/SLEBase.h @@ -0,0 +1,503 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl { + +// Concept to distinguish read-only vs writable view types +template +concept IsWritableView = std::derived_from; + +namespace detail { + +/** + * Resolves a keylet for a read-only entry. + * + * ReadView::read() on an ApplyView returns the underlying ledger's entry + * whenever the view is not already tracking one, while peek() installs the + * view's own copy and returns that. A read-only entry built with read() + * would therefore hold an SLE that goes stale the moment anything peeks the + * same key and modifies it. Resolve through peek() whenever the view really is + * an ApplyView, so every entry over that view shares one SLE. + * + * @note The const_cast is what makes reaching ApplyView::peek() possible, and + * it is defined behavior only when the view really is a non-const + * object that the caller merely observes through a const reference. + * That holds for every production view today, but it is not a + * guarantee the codebase makes: the unit tests already build + * genuinely const ApplyView-derived objects (`Sandbox const` in + * Directory_test.cpp and View_test.cpp, `PaymentSandbox const` in + * TheoreticalQuality_test.cpp and View_test.cpp). Constructing a + * read-only entry over one of those would be undefined behavior, so + * do not, until #8069 removes the cast -- by giving ApplyView a + * const-qualified peek(), which needs no amendment because + * Action::Cache is invisible to apply(), visit() and metadata. + * + * @note Consequently a "read-only" entry over an ApplyView is not free of + * side effects: peek() installs an Action::Cache entry in the apply + * state table. That is benign for transaction metadata -- Cache entries + * are skipped in ApplyStateTable::apply(), ::visit() and in metadata + * generation -- but it does cost one deep SLE copy on first touch. + */ +inline SLE::const_pointer +resolveEntry(ReadView const& view, Keylet const& key) +{ + // Safe only for a view that is not itself a const object -- see the + // note above. The entry holds a const reference because it does not + // modify the view, not because the view is const. + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) + if (auto const applyView = dynamic_cast(const_cast(&view))) + return applyView->peek(key); + return view.read(key); +} + +} // namespace detail + +/** + * View-parameterized base class for all ledger entries. + * + * SLEBase — read-only: holds shared_ptr + ReadView const& + * SLEBase — writable: holds shared_ptr + ApplyView& + Keylet, + * plus insert/update/erase operations + * + * Write-only members are gated by `requires` clauses, providing compile-time + * guarantees that read-only entries cannot mutate state. + * + * @tparam EntryType the ledger entry type this entry is statically bound to. + * Derived per-type entries pass their own type (e.g. ltACCOUNT_ROOT); the + * generic ReadOnlySLE / WritableSLE aliases leave it at ltANY, which opts out + * of the static type check. Binding the type here is what keeps an entry for + * one entry type from being constructed or converted from another -- see the + * converting constructor below. + * + * Derived classes should provide domain-specific accessors that hide + * implementation details of the underlying ledger entry format. + */ +template +class SLEBase +{ +public: + static constexpr bool kIsWritable = IsWritableView; + + // The ledger entry type this entry is bound to, and whether that binding + // is meaningful (ltANY means "any type", i.e. no static check). + static constexpr LedgerEntryType kEntryType = EntryType; + static constexpr bool kIsTyped = (EntryType != ltANY); + + // SLE pointer type: mutable for writable views, const for read-only + using SlePtrType = std::conditional_t; + + // View reference type: ApplyView& for writable, ReadView const& for + // read-only + using ViewRefType = std::conditional_t; + + // Non-virtual by design: these entries are parameterized on the view and + // entry type, never used polymorphically through a base pointer. A vptr + // would be 8 bytes of pure overhead on a type meant to be as cheap as the + // shared_ptr it wraps. See the static_assert below the class. + // + // The destructor is public because the ReadOnlySLE / WritableSLE aliases + // name this class directly and are used as value types. Since it is not + // virtual, never delete a derived entry through an SLEBase*. + ~SLEBase() = default; + + SLEBase(SLEBase const&) + requires(!kIsWritable) + = default; + SLEBase(SLEBase&&) = default; + SLEBase& + operator=(SLEBase const&) = delete; + SLEBase& + operator=(SLEBase&&) = delete; + SLEBase() = delete; + + // --- Constructors that adopt/resolve an SLE (public so the ReadOnlySLE / + // WritableSLE aliases and the per-type entries can be built directly + // from a keylet, or -- read-only only -- from an already-fetched + // SLE). --- + + /** + * Constructor for read-only context (adopt an already-fetched SLE). + * + * There is deliberately no writable equivalent: a writable entry needs + * a Keylet so that newSLE() can still build an entry when none exists, + * and that cannot be recovered from a null SLE. + */ + explicit SLEBase( + SLE::const_pointer sle, + ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires(!kIsWritable) + : view_(view), sle_(std::move(sle)), j_(j) + { + XRPL_ASSERT( + !kIsTyped || !sle_ || sle_->getType() == kEntryType, + "xrpl::SLEBase::SLEBase : adopted SLE matches bound entry type"); + } + + /** + * Constructor for read-only context (read from view by keylet) + */ + explicit SLEBase( + Keylet const& key, + ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires(!kIsWritable) + : view_(view), sle_(detail::resolveEntry(view, key)), j_(j) + { + XRPL_ASSERT( + !kIsTyped || key.type == kEntryType, + "xrpl::SLEBase::SLEBase : keylet matches bound entry type"); + } + + /** + * Converting constructor: writable → read-only. + * + * Enables implicit conversion from SLEBase to + * SLEBase, so functions taking ReadOnlySLE const& can accept + * WritableSLE. + * + * Constrained to the same entry type (or to a ltANY target, i.e. widening + * a typed entry to a generic ReadOnlySLE). The constraint is load-bearing: + * this constructor is inherited into every per-type entry, and unconstrained + * it would bind any writable entry that slices to SLEBase, so an OfferEntryW + * would convert to an AccountRootEntryR with no cast at the call site. + */ + template + SLEBase(SLEBase const& other) + requires(!kIsWritable && IsWritableView && + (OtherType == EntryType || EntryType == ltANY)) + : view_(other.readView()), sle_(other.rawSle()), j_(other.journal()) + { + } + + /** + * Constructor for writable context (peek from view by keylet) + */ + explicit SLEBase( + Keylet const& key, + ApplyView& view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires kIsWritable + : view_(view), key_(key), sle_(view_.peek(key)), j_(j) + { + XRPL_ASSERT( + !kIsTyped || key.type == kEntryType, + "xrpl::SLEBase::SLEBase : keylet matches bound entry type"); + } + + /** + * Constructor for writable context, for call sites that hold an + * ApplyViewContext (peek from ctx.view by keylet). + * + * ctx.tx is not retained: this exists purely so transactors can pass the + * context they already have instead of spelling out ctx.view. If an entry + * ever needs the applying transaction, store it here rather than adding + * another overload. + */ + explicit SLEBase( + Keylet const& key, + ApplyViewContext const& ctx, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + requires kIsWritable + : SLEBase(key, ctx.view, j) + { + } + + // --- Common interface (always available) --- + + /** + * Returns true if the ledger entry exists + */ + [[nodiscard]] bool + exists() const + { + return sle_ != nullptr; + } + + /** + * Explicit conversion to bool for convenient existence checking + */ + explicit + operator bool() const + { + return exists(); + } + + /** + * Returns the underlying SLE for read access. + * + * Prefer operator-> / operator* for field access; this is for the call + * sites that need the shared_ptr itself. + */ + [[nodiscard]] SLE::const_pointer + rawSle() const + { + return sle_; + } + + /** + * Returns the ledger entry type of this entry. + * + * For a per-type entry this is kEntryType, known at compile time and + * valid whether or not the entry exists. Only the generic ReadOnlySLE / + * WritableSLE aliases have to read it back out of the SLE. + * + * @throws std::logic_error for a generic (ltANY) entry if exists() is + * false. + */ + [[nodiscard]] LedgerEntryType + type() const + { + if constexpr (kIsTyped) + { + return kEntryType; + } + else + { + if (!exists()) + Throw("xrpl::SLEBase::type : entry does not exist"); + return sle_->getType(); + } + } + + /** + * Returns the keylet identifying this entry. + * + * Writable entries keep the keylet they were built from, so it is valid + * even before newSLE(). Read-only entries derive it from the SLE, which + * must therefore exist. + * + * @throws std::logic_error for a read-only entry if exists() is false. + */ + [[nodiscard]] Keylet + keylet() const + { + if constexpr (kIsWritable) + { + return key_; + } + else + { + if (!exists()) + Throw("xrpl::SLEBase::keylet : entry does not exist"); + // Take the type from the SLE, not from kEntryType: the adopt-SLE + // constructor's type check is assert-only, so a Release build can + // be holding an SLE whose type disagrees with the binding, and the + // SLE is the one telling the truth. + return Keylet(sle_->getType(), sle_->key()); + } + } + + /** + * Returns the ledger key of this entry. + * + * @throws std::logic_error same as keylet(): for read-only entries, + * if exists() is false. + */ + [[nodiscard]] uint256 + key() const + { + return keylet().key; + } + + /** + * Returns the read view (always available; ApplyView inherits ReadView) + */ + [[nodiscard]] ReadView const& + readView() const + { + return view_; + } + + /** + * Const dereference operators (always available) + * + * @throws std::logic_error if exists() is false. + */ + STLedgerEntry const* + operator->() const + { + if (!exists()) + Throw("xrpl::SLEBase::operator-> : entry does not exist"); + return sle_.get(); + } + + STLedgerEntry const& + operator*() const + { + if (!exists()) + Throw("xrpl::SLEBase::operator* : entry does not exist"); + return *sle_; + } + + // --- Writable interface (compile-time gated) --- + // + // Everything that hands out mutable access (or mutates) is non-const, so + // that a `FooEntryW const&` is as inert as a `FooEntryR`. Use readView() + // when a const entry only needs to inspect the view. + + /** + * Returns the underlying SLE for write access. + * + * Prefer operator-> / operator* for field access; this is for the call + * sites that need the shared_ptr itself. + */ + [[nodiscard]] SlePtrType const& + mutableRawSle() + requires kIsWritable + { + return sle_; + } + + /** + * Returns the apply view for write operations + */ + [[nodiscard]] ApplyView& + applyView() + requires kIsWritable + { + return view_; + } + + /** + * Mutable dereference operators + * + * @throws std::logic_error if exists() is false. + */ + STLedgerEntry* + operator->() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::operator-> : entry does not exist"); + return sle_.get(); + } + + STLedgerEntry& + operator*() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::operator* : entry does not exist"); + return *sle_; + } + + /** + * Inserts the entry into the view. + * + * @throws std::logic_error if exists() is false. + */ + void + insert() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::insert : entry does not exist"); + view_.insert(sle_); + } + + /** + * Erases the entry from the view. + * + * Drops the SLE afterwards, so the entry reports !exists() and any + * further use throws here rather than either throwing from deep inside + * ApplyStateTable or -- worse -- silently succeeding. For an + * entry that already existed, ApplyStateTable::erase keeps holding this + * exact SLE and builds the DeletedNode's FinalFields from it, so a write + * through the entry after erase() would land in transaction metadata + * with no diagnostic at all. + * + * @throws std::logic_error if exists() is false. + */ + void + erase() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::erase : entry does not exist"); + view_.erase(sle_); + sle_ = nullptr; + } + + /** + * @throws std::logic_error if exists() is false. + */ + void + update() + requires kIsWritable + { + if (!exists()) + Throw("xrpl::SLEBase::update : entry does not exist"); + view_.update(sle_); + } + + /** + * @throws std::logic_error if exists() is true: newSLE() would otherwise + * silently discard the SLE already held. + */ + void + newSLE() + requires kIsWritable + { + if (exists()) + Throw("xrpl::SLEBase::newSLE : entry already exists"); + sle_ = std::make_shared(key_); + } + + [[nodiscard]] beast::Journal + journal() const + { + return j_; + } + +protected: + ViewRefType view_; + + // Keylet is only meaningful for writable views, which need it to build an + // SLE that does not exist yet; read-only entries derive it from the SLE. + struct Empty + { + }; + + // No default member initializer: Keylet is not default-constructible, so + // every writable constructor must initialize key_ explicitly. + [[no_unique_address]] + std::conditional_t key_; + + SlePtrType sle_{}; + beast::Journal j_; +}; + +/** + * Generic (any-entry-type) SLE entries. + * + * Use these when the concrete ledger entry type is not known at a given site; + * otherwise prefer the per-type entries (e.g. AccountRootEntry.h), which + * additionally enforce the entry type at compile time. + * + * SLE::const_pointer / SLE::const_ref -> ReadOnlySLE + * SLE::pointer / SLE::ref -> WritableSLE + */ +using ReadOnlySLE = SLEBase; +using WritableSLE = SLEBase; + +static_assert( + !std::is_polymorphic_v && !std::is_polymorphic_v, + "SLEBase must stay a thin value type; it must not acquire a vtable"); + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/SignerListEntry.h b/include/xrpl/ledger/entries/SignerListEntry.h new file mode 100644 index 0000000000..f6f881cd69 --- /dev/null +++ b/include/xrpl/ledger/entries/SignerListEntry.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class SignerListEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit SignerListEntry( + AccountID const& account, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::signerList(account), view, j) + { + } +}; + +using SignerListEntryR = SignerListEntry; +using SignerListEntryW = SignerListEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/SponsorshipEntry.h b/include/xrpl/ledger/entries/SponsorshipEntry.h new file mode 100644 index 0000000000..98445479d9 --- /dev/null +++ b/include/xrpl/ledger/entries/SponsorshipEntry.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class SponsorshipEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit SponsorshipEntry( + AccountID const& sponsor, + AccountID const& sponsee, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::sponsorship(sponsor, sponsee), view, j) + { + } +}; + +using SponsorshipEntryR = SponsorshipEntry; +using SponsorshipEntryW = SponsorshipEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/TicketEntry.h b/include/xrpl/ledger/entries/TicketEntry.h new file mode 100644 index 0000000000..16d9a9f3d9 --- /dev/null +++ b/include/xrpl/ledger/entries/TicketEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class TicketEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit TicketEntry( + AccountID const& id, + SeqProxy const& ticketSeq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::ticket(id, ticketSeq), view, j) + { + } + + explicit TicketEntry( + uint256 const& ticketID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::ticket(ticketID), view, j) + { + } +}; + +using TicketEntryR = TicketEntry; +using TicketEntryW = TicketEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/TransactionProposalEntry.h b/include/xrpl/ledger/entries/TransactionProposalEntry.h new file mode 100644 index 0000000000..b7729f7a2b --- /dev/null +++ b/include/xrpl/ledger/entries/TransactionProposalEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class TransactionProposalEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit TransactionProposalEntry( + AccountID const& target, + std::uint32_t ticketSequence, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::txProposal(target, ticketSequence), view, j) + { + } +}; + +using TransactionProposalEntryR = TransactionProposalEntry; +using TransactionProposalEntryW = TransactionProposalEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/VaultEntry.h b/include/xrpl/ledger/entries/VaultEntry.h new file mode 100644 index 0000000000..897a9117b1 --- /dev/null +++ b/include/xrpl/ledger/entries/VaultEntry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +template +class VaultEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit VaultEntry( + AccountID const& owner, + SeqProxy const& seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::vault(owner, seq), view, j) + { + } + + explicit VaultEntry( + uint256 const& vaultID, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::vault(vaultID), view, j) + { + } +}; + +using VaultEntryR = VaultEntry; +using VaultEntryW = VaultEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/XChainOwnedClaimIDEntry.h b/include/xrpl/ledger/entries/XChainOwnedClaimIDEntry.h new file mode 100644 index 0000000000..9cec1fcec9 --- /dev/null +++ b/include/xrpl/ledger/entries/XChainOwnedClaimIDEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class XChainOwnedClaimIDEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit XChainOwnedClaimIDEntry( + STXChainBridge const& bridge, + std::uint64_t seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::xChainClaimID(bridge, seq), view, j) + { + } +}; + +using XChainOwnedClaimIDEntryR = XChainOwnedClaimIDEntry; +using XChainOwnedClaimIDEntryW = XChainOwnedClaimIDEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/entries/XChainOwnedCreateAccountClaimIDEntry.h b/include/xrpl/ledger/entries/XChainOwnedCreateAccountClaimIDEntry.h new file mode 100644 index 0000000000..e9494e702f --- /dev/null +++ b/include/xrpl/ledger/entries/XChainOwnedCreateAccountClaimIDEntry.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class XChainOwnedCreateAccountClaimIDEntry + : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit XChainOwnedCreateAccountClaimIDEntry( + STXChainBridge const& bridge, + std::uint64_t seq, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::xChainCreateAccountClaimID(bridge, seq), view, j) + { + } +}; + +using XChainOwnedCreateAccountClaimIDEntryR = XChainOwnedCreateAccountClaimIDEntry; +using XChainOwnedCreateAccountClaimIDEntryW = XChainOwnedCreateAccountClaimIDEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index f3fc82eacb..3887b10120 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -338,9 +338,9 @@ struct AccountingDeltas Number debtTotalDelta; }; -// Whole-life (pre-LendingProtocolV1_1) recognition model: interest is -// recognized into AssetsTotal/DebtTotal up front, at origination. -namespace accrual { +// Instant interest recognition (pre-LendingProtocolV1_1): interest is +// recognized into AssetsTotal/DebtTotal immediately, at origination. +namespace instant_recognition { // LoanSet origination: what's added to Vault.AssetsTotal and LoanBroker.DebtTotal AccountingDeltas @@ -362,7 +362,7 @@ loanVaultExposure(SLE::const_ref loanSle); AccountingDeltas loanPaymentDeltas(LoanPaymentParts const& parts); -} // namespace accrual +} // namespace instant_recognition // Cash-basis (LendingProtocolV1_1) recognition model: AssetsTotal/DebtTotal // are principal-only, interest is recognized only as it's actually paid. @@ -381,7 +381,7 @@ loanPaymentDeltas(LoanPaymentParts const& parts); // Public dispatchers: pick cash_basis:: if featureLendingProtocolV1_1 is // enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is -// VaultVersion::CashBasis, else accrual::. These are the only entry points +// VaultVersion::CashBasis, else instant_recognition::. These are the only entry points // transactors call. AccountingDeltas loanOriginationDeltas( diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index b42f349b95..acbcb8ec04 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -172,8 +172,8 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref /** * Resolves a Vault's LEVersion, the single point every accounting touch - * point should call to determine which recognition model (accrual vs. - * cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 + * point should call to determine which recognition model (instant interest + * recognition vs. cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 * activated never have sfLEVersion set, which resolves here to * VaultVersion::Legacy. * diff --git a/include/xrpl/nodestore/detail/DatabaseNodeImp.h b/include/xrpl/nodestore/detail/DatabaseNodeImp.h index 33a2e27939..9ba81a7323 100644 --- a/include/xrpl/nodestore/detail/DatabaseNodeImp.h +++ b/include/xrpl/nodestore/detail/DatabaseNodeImp.h @@ -92,7 +92,7 @@ public: void importDatabase(Database& source) override { - importInternal(*backend_.get(), source); + importInternal(*backend_, source); } void diff --git a/include/xrpl/peerfinder/Config.h b/include/xrpl/peerfinder/Config.h index 3326ae8a97..fa2d2d78e8 100644 --- a/include/xrpl/peerfinder/Config.h +++ b/include/xrpl/peerfinder/Config.h @@ -26,7 +26,8 @@ struct Config /** * The largest number of public peer slots to allow. * This includes both inbound and outbound, but does not include - * fixed peers. + * fixed peers. A configuration built by `makeConfig` always holds + * `maxPeers == inPeers + outPeers`. */ std::size_t maxPeers{tuning::kDefaultMaxPeers}; diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index ecf7970aba..5c52fb0ba3 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -6,6 +6,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include #include @@ -301,6 +302,87 @@ verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 co NotTEC checkEncryptedAmountFormat(STObject const& object); +/** + * @brief Checks whether a holder's issuer mirror is encrypted under the + * issuance's currently registered issuer key. + * + * Verifies that the holder's issuer mirror epoch matches the active issuer key + * epoch on the issuance. An absent mirror epoch defaults to epoch 0. A holder without an issuer + * mirror is considered stale, as there is no key anchor for future re-encryptions. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger object. + * @return true if the MPToken's issuer mirror is current. false if stale. + */ +[[nodiscard]] bool +isIssuerMirrorCurrent(SLE const& issuance, SLE const& mptoken); + +/** + * @brief Checks whether a holder's auditor mirror is encrypted under the + * issuance's currently registered auditor key. + * + * Verifies that the holder's auditor mirror epoch matches the active auditor key + * epoch on the issuance. An absent mirror epoch defaults to epoch 0. An issuance + * without an auditor key requires no auditor mirror and is considered current. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger object. + * @return true if the auditor mirror is current or not required. + */ +[[nodiscard]] bool +isAuditorMirrorCurrent(SLE const& issuance, SLE const& mptoken); + +/** + * @brief Checks whether each mirror a holder is required to have is encrypted + * under the issuance's currently registered ElGamal keys. + * + * Verifies that both the issuer mirror and the auditor mirror (if required) + * are current. This serves as a combined check, ensuring all necessary + * holder mirror epochs match the active key epochs on the issuance. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger object. + * @return true if the required mirrors are current. + */ +[[nodiscard]] bool +areMirrorsCurrent(SLE const& issuance, SLE const& mptoken); + +/** + * @brief Set the holder's issuer mirror epoch to match the issuance's current issuer key epoch. + * + * Call this after writing the issuer mirror ciphertext under the issuance's + * currently registered issuer key, so that the mirror reads as current afterwards. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken); + +/** + * @brief Set the holder's auditor mirror epoch to match the issuance's current auditor key epoch. + * + * Call this after writing the auditor mirror ciphertext under the issuance's + * currently registered auditor key. Does nothing when the holder has no auditor mirror. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken); + +/** + * @brief Set the holder's MPToken mirror epochs to match the issuance's current key epochs. + * + * Call this after writing mirror ciphertexts under the issuance's currently + * registered keys, so that the mirrors read as current afterwards. + * + * @param issuance The MPTokenIssuance ledger object. + * @param mptoken The holder's MPToken ledger entry to update. + */ +void +setMirrorEpochs(SLE const& issuance, SLE& mptoken); + /** * @brief Verifies revealed amount encryptions for all recipients. * diff --git a/include/xrpl/protocol/PathAsset.h b/include/xrpl/protocol/PathAsset.h index ebf6fb68a4..02de9aa7df 100644 --- a/include/xrpl/protocol/PathAsset.h +++ b/include/xrpl/protocol/PathAsset.h @@ -5,9 +5,11 @@ #include #include +#include #include #include #include +#include #include namespace xrpl { @@ -121,9 +123,32 @@ operator==(PathAsset const& lhs, PathAsset const& rhs) template void -hash_append(Hasher& h, PathAsset const& pathAsset) +hash_append(Hasher& h, PathAsset const& pathAsset) noexcept { - std::visit([&](T const& e) { hash_append(h, e); }, pathAsset.value()); + using beast::hash_append; + using Variant = std::remove_cvref_t; + + static_assert( + std::variant_size_v < 0xFFu, + "PathAsset's discriminant must fit in a byte, leaving 0xFF reserved."); + + // std::visit is not noexcept: it throws bad_variant_access when the variant + // is valueless_by_exception. + if (pathAsset.value().valueless_by_exception()) [[unlikely]] + { + hash_append(h, static_cast(0xFFu)); + return; + } + + hash_append(h, static_cast(pathAsset.value().index())); + std::visit( + [&](T const& e) noexcept { + static_assert( + noexcept(hash_append(h, e)), + "Every PathAsset alternative must be nothrow-hashable."); + hash_append(h, e); + }, + pathAsset.value()); } inline bool diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 1b88eea456..61f246c752 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -322,7 +322,7 @@ constexpr std::uint8_t kVaultMaximumIouScale = 18; * Vault ledger-entry schema versions. Assigned to newly created * Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before * activation are left without LEVersion (implicit legacy version 0, - * accrual-basis accounting). + * instant interest recognition). */ enum class VaultVersion : uint8_t { Legacy = 0, @@ -540,6 +540,11 @@ constexpr std::size_t kEcConvertBackProofLength = */ constexpr std::size_t kEcClawbackProofLength = SECP256K1_COMPACT_CLAWBACK_PROOF_SIZE; +/** + * Length of compact equality proof. + */ +constexpr std::size_t kEcEqualityProofLength = 128; + /** * Extra base fee multiplier charged to confidential MPT transactions. */ diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index 5768721111..b91d899071 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -12,6 +12,8 @@ #include #include +#include +#include #include #include #include @@ -65,7 +67,7 @@ public: PathAsset const& asset, AccountID const& issuer); - [[nodiscard]] auto + [[nodiscard]] std::uint32_t getNodeType() const; [[nodiscard]] bool @@ -109,9 +111,6 @@ public: [[nodiscard]] bool isType(Type const& pe) const; - [[nodiscard]] size_t - getHash() const; - bool operator==(STPathElement const& t) const; @@ -120,6 +119,17 @@ private: getHash(STPathElement const& element); }; +template +void +hash_append(Hasher& h, STPathElement const& e) noexcept +{ + using beast::hash_append; + hash_append(h, (e.getNodeType() & STPathElement::TypeAccount) != 0u); + hash_append(h, e.getAccountID()); + hash_append(h, e.getPathAsset()); + hash_append(h, e.getIssuerID()); +} + class STPath final : public CountedObject { std::vector path_; @@ -176,9 +186,10 @@ template void hash_append(Hasher& h, STPath const& p) noexcept { + using beast::hash_append; for (auto const& e : p) { - beast::hash_append(h, e.getHash()); + hash_append(h, e); } } @@ -188,13 +199,39 @@ hash_append(Hasher& h, STPath const& p) noexcept class STPathSet final : public STBase, public CountedObject { std::vector value_; - xrpl::hardened_hash_set seenHashes_; + + /** + * Deduplication index over `value_`, for pathfinding. + * The use of a std::unique_ptr is intentional as it + * only requires 8 additional bytes of storage for the pointer + * as opposed to 64 bytes with an optional. This keeps the size + * of the STPathSet to within the `STVar::kMaxSize` limit of 72 bytes. + */ + std::unique_ptr> seen_; public: + struct DeduplicationTag + { + }; + STPathSet() = default; + /** + * Deduplication tagged constructor. + * Use when you want to ensure that the STPathSet does not contain duplicate paths. + */ + explicit STPathSet(DeduplicationTag); STPathSet(SField const& n); STPathSet(SerialIter& sit, SField const& name); + STPathSet(STPathSet const& other); + STPathSet(STPathSet&&) = default; + + STPathSet& + operator=(STPathSet const& other); + STPathSet& + operator=(STPathSet&&) = default; + + ~STPathSet() override = default; void add(Serializer& s) const override; @@ -204,6 +241,16 @@ public: [[nodiscard]] SerializedTypeID getSType() const override; + /** + * @brief assembleAdd adds a path to the set by combining a base path and a tail element. + * + * @param base The base path. + * @param tail The tail element. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks Requires the STPathSet to be constructed with the DeduplicationTag. The return value + * indicates whether the combined path was inserted (true) or rejected as a duplicate (false). + * It is fine for callers to ignore the return value. + */ bool assembleAdd(STPath const& base, STPathElement const& tail); @@ -229,22 +276,61 @@ public: [[nodiscard]] bool empty() const; - void + /** + * @brief pushBack adds a path to the set. + * + * @param e The path to add. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks If the STPathSet was constructed with the DeduplicationTag, then this method will + * check for duplicates and only add the path if it is not already present in the + * set. If the STPathSet was constructed without the DeduplicationTag, + * then this method will always add the path to the set, regardless of duplicates. + * It is fine for callers to ignore the return value. + */ + bool pushBack(STPath const& e); + /** + * @brief emplaceBack adds a path to the set. + * + * @param args The arguments to construct the path with. + * @return true if the path was added, false if it was a duplicate and not added. + * @remarks If the STPathSet was constructed with the DeduplicationTag, then this method will + * check for duplicates and only add the path if it is not already present in the + * set. If the STPathSet was constructed without the DeduplicationTag, + * then this method will always add the path to the set, regardless of duplicates. + * It is fine for callers to ignore the return value. + * @note The path is constructed before the duplicate check, so on a false + * return the constructed path is discarded and any argument + * forwarded as an rvalue is left in a moved-from state. Use + * pushBack when the caller needs to keep its path on rejection. + */ template - void + bool emplaceBack(Args&&... args); - [[nodiscard]] bool - contains(STPath const& path) const; - private: STBase* copy(std::size_t n, void* buf) const override; STBase* move(std::size_t n, void* buf) override; + /** + * @brief Append a path via `append`, then register it in the deduplication index. + * + * @param append Invoked with `value_`; must append exactly one path to it. + * @return true if the path was kept, false if it was a duplicate and was rolled back. + * @remarks Appends to the vector before touching the index, so that a failed allocation + * there leaves both containers untouched rather than leaving the index holding + * a path the vector does not. If the index insert reports a duplicate, or + * throws, the append is rolled back so the two containers stay consistent; in + * the throwing case the exception propagates. With no index (constructed + * without the DeduplicationTag) the append is unconditional. + */ + template + bool + appendUnique(Append&& append); + friend class detail::STVar; }; @@ -336,7 +422,7 @@ inline STPathElement::STPathElement( hashValue_ = getHash(*this); } -inline auto +inline std::uint32_t STPathElement::getNodeType() const { return type_; @@ -545,25 +631,50 @@ STPathSet::empty() const return value_.empty(); } -inline void -STPathSet::pushBack(STPath const& e) +template +inline bool +STPathSet::appendUnique(Append&& append) { - value_.push_back(e); - seenHashes_.emplace(value_.back()); -} + // Append to the vector first, so that a failed allocation there leaves both + // containers untouched rather than leaving the index holding a path the + // vector does not. + append(value_); -template -inline void -STPathSet::emplaceBack(Args&&... args) -{ - value_.emplace_back(std::forward(args)...); - seenHashes_.emplace(value_.back()); + if (seen_ == nullptr) + { + return true; + } + + try + { + if (!seen_->insert(value_.back()).second) + { + // Already present: roll back the append. + value_.pop_back(); + return false; + } + } + catch (...) + { + // The index insert failed, so roll back the append to keep the vector + // and the index consistent. + value_.pop_back(); + throw; + } + return true; } inline bool -STPathSet::contains(STPath const& path) const +STPathSet::pushBack(STPath const& e) { - return seenHashes_.contains(path); + return appendUnique([&](auto& value) { value.push_back(e); }); +} + +template +inline bool +STPathSet::emplaceBack(Args&&... args) +{ + return appendUnique([&](auto& value) { value.emplace_back(std::forward(args)...); }); } } // namespace xrpl diff --git a/include/xrpl/protocol/STValidation.h b/include/xrpl/protocol/STValidation.h index 8101b27341..f70e971f87 100644 --- a/include/xrpl/protocol/STValidation.h +++ b/include/xrpl/protocol/STValidation.h @@ -124,6 +124,13 @@ public: [[nodiscard]] NodeID const& getNodeID() const noexcept; + /** + * Whether this validation carries a good signature. + * + * Reports false if the signature cannot be checked at all, so a caller + * cannot tell that apart from a bad signature. Either way the validation is + * unusable, and the reason is logged. Only a computed answer is remembered. + */ [[nodiscard]] bool isValid() const noexcept; diff --git a/include/xrpl/protocol/Serializer.h b/include/xrpl/protocol/Serializer.h index c1ea5c16ba..997199629a 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,101 @@ private: Blob data_; public: + /** + * A header is never longer than this. The encoder fills a buffer of this + * size and writes only the bytes it used. + */ + static constexpr int kMaxNumberOfBytesInHeader = 3; + + // A field whose size varies is stored as a header holding its length, then + // the field data. The header is 1, 2 or 3 bytes long. Nothing outside it says + // which, so the decoder reads the first byte and its value says how long the + // header is: + // + // 0 ... 192 kMin/kMaxValueOfFirstByteFor1ByteHeader + // 193 ... 240 kMin/kMaxValueOfFirstByteFor2ByteHeader + // 241 ... 254 kMin/kMaxValueOfFirstByteFor3ByteHeader + // 255 belongs to no header + // + // Each range starts one past the end of the range before it. + + static constexpr int kMinValueOfFirstByteFor1ByteHeader = 0; + static constexpr int kMaxValueOfFirstByteFor1ByteHeader = 192; + + static constexpr int kMinValueOfFirstByteFor2ByteHeader = + kMaxValueOfFirstByteFor1ByteHeader + 1; + static constexpr int kMaxValueOfFirstByteFor2ByteHeader = 240; + + static constexpr int kMinValueOfFirstByteFor3ByteHeader = + kMaxValueOfFirstByteFor2ByteHeader + 1; + + static constexpr int kMaxValueOfFirstByteFor3ByteHeader = 254; + + // A length x too big for one byte is split across the header. For 2 bytes: + // + // first byte = 193 + (x - 193) / 256 + // second byte = (x - 193) % 256 + // + // so 300 is stored as 193, 107. For 3 bytes it is the same, from 241, with + // the remainder split across two bytes: 20,000 is stored as 241, 29, 95. + + static constexpr int kNumberOfValuesInOneByte = 256; + static constexpr int kNumberOfValuesInTwoBytes = + kNumberOfValuesInOneByte * kNumberOfValuesInOneByte; + + // Each header length therefore covers a range of field lengths: + // + // 0 ... 192 kMin/kMaxValueOfLengthFor1ByteHeader + // 193 ... 12,480 kMin/kMaxValueOfLengthFor2ByteHeader + // 12,481 ... 918,744 kMin/kMaxValueOfLengthFor3ByteHeader + // + // The encoder always uses the shortest header that fits. + + /** + * A 1 byte header holds the length in the byte itself, so both ends of + * this range are the same numbers as the first byte's own range. + */ + static constexpr int kMinValueOfLengthFor1ByteHeader = kMinValueOfFirstByteFor1ByteHeader; + static constexpr int kMaxValueOfLengthFor1ByteHeader = kMaxValueOfFirstByteFor1ByteHeader; + + static constexpr int kMinValueOfLengthFor2ByteHeader = kMaxValueOfLengthFor1ByteHeader + 1; + + /** + * 48 values of the first byte mean a 2 byte header, and each of them covers + * 256 lengths. The 48 is worked out from the two range ends above, so it + * stays right if either of them changes. + */ + static constexpr int kMaxValueOfLengthFor2ByteHeader = kMinValueOfLengthFor2ByteHeader + + ((kMaxValueOfFirstByteFor2ByteHeader - kMaxValueOfFirstByteFor1ByteHeader) * + kNumberOfValuesInOneByte) - + 1; + + static constexpr int kMinValueOfLengthFor3ByteHeader = kMaxValueOfLengthFor2ByteHeader + 1; + + /** + * 14 values of the first byte mean a 3 byte header, and each of them covers + * 65,536 lengths. Counted the same way, that gives the largest length any + * header can state. + * + * Nothing is accepted or rejected against this. The assertion below uses it + * to check that every length the encoder writes is one a header can state. + */ + static constexpr int kMaxRepresentableLength = kMinValueOfLengthFor3ByteHeader + + ((kMaxValueOfFirstByteFor3ByteHeader - kMaxValueOfFirstByteFor2ByteHeader) * + kNumberOfValuesInTwoBytes) - + 1; + + /** + * The largest length the encoder will write. This is the one number here + * that is picked rather than worked out. The decoder accepts nothing above + * it, so both sides agree on the same set of lengths. + */ + static constexpr int kMaxValueOfLengthFor3ByteHeader = 918744; + + static_assert( + kMaxValueOfLengthFor3ByteHeader <= kMaxRepresentableLength, + "a length the encoder writes must be one a header can state"); + explicit Serializer(int n = 256) { data_.reserve(n); @@ -61,7 +157,7 @@ public: // assemble functions int - add8(unsigned char i); + add8(unsigned char byteValue); int add16(std::uint16_t i); @@ -270,18 +366,90 @@ public: return v.data_ == data_; } + /** + * Works out how long a header is, from its first byte. + * + * Each overload of decodeVLLength below reads one header length, so call + * this first to learn which of them to call. + * + * @param firstByte First byte of the header, as read from the stream. + * @return How many bytes the whole header takes, counting firstByte: 1, 2 + * or 3. + * @throws std::overflow_error if firstByte is the one value that starts no + * header. + */ static int - decodeLengthLength(int b1); + decodeLengthLength(std::byte firstByte); + + /** + * Reads the field length out of a 1 byte header. + * + * @param firstByte The single header byte, which is the length itself. + * @return Field length in bytes, from kMinValueOfLengthFor1ByteHeader to + * kMaxValueOfLengthFor1ByteHeader. + * @throws std::overflow_error if firstByte is big enough to mean a longer + * header, in which case it is not a length by itself. + */ static int - decodeVLLength(int b1); + decodeVLLength(std::byte firstByte); + + /** + * Reads the field length out of a 2 byte header. + * + * @param firstByte First header byte. Its value means a 2 byte header, and + * how far it sits into that range gives the top part of the length. + * @param secondByte Second header byte, holding the rest of the length. + * @return Field length in bytes, from kMinValueOfLengthFor2ByteHeader to + * kMaxValueOfLengthFor2ByteHeader. + * @throws std::overflow_error if firstByte is outside the range that means + * a 2 byte header. + */ static int - decodeVLLength(int b1, int b2); + decodeVLLength(std::byte firstByte, std::byte secondByte); + + /** + * Reads the field length out of a 3 byte header. + * + * @param firstByte First header byte. Its value means a 3 byte header, and + * how far it sits into that range gives the top part of the length. + * @param secondByte Second header byte, holding the middle part of the + * length. + * @param thirdByte Third header byte, holding the low part. + * @return Field length in bytes, from kMinValueOfLengthFor3ByteHeader to + * kMaxValueOfLengthFor3ByteHeader. + * @throws std::overflow_error if firstByte is outside the range that means + * a 3 byte header, or if the three bytes together state a length above + * kMaxValueOfLengthFor3ByteHeader, which the encoder would not write back. + */ static int - decodeVLLength(int b1, int b2, int b3); + decodeVLLength(std::byte firstByte, std::byte secondByte, std::byte thirdByte); private: + /** + * Works out how many bytes the header needs for the given length. + * + * This deliberately repeats the width choice addEncoded makes, so that + * addVL's assertion can compare the two. It has no other caller; do not + * reach for it as a utility. + * + * @param length Field length in bytes. + * @return How many header bytes it needs: 1, 2 or 3. + * @throws std::overflow_error if length is negative, or above + * kMaxValueOfLengthFor3ByteHeader. + */ static int - encodeLengthLength(int length); // length to encode length + encodeLengthLength(int length); + + /** + * Appends the length header for a field of the given length. + * + * The field's own data is not written; the caller appends it next. + * + * @param length Field length in bytes. + * @return Offset within this Serializer at which the header was written. + * @throws std::overflow_error if length is negative, or above + * kMaxValueOfLengthFor3ByteHeader. + */ int addEncoded(int length); }; @@ -390,9 +558,15 @@ public: void getFieldID(int& type, int& name); - // Returns the size of the VL if the - // next object is a VL. Advances the iterator - // to the beginning of the VL. + /** + * Reads the length header at the read position and steps past it. + * + * @return Field length in bytes. The iterator is left on the first byte of + * the field data. + * @throws std::overflow_error if the header states a length the encoder could + * not have written. + * @throws std::runtime_error if the data runs out before the header does. + */ int getVLDataLength(); diff --git a/include/xrpl/protocol/detail/STVar.h b/include/xrpl/protocol/detail/STVar.h index 56f868b665..72a310546e 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -34,10 +34,11 @@ concept ValidConstructSTArgs = // and includes a small-object allocation optimization. class STVar { -private: +public: // The largest "small object" we can accommodate static constexpr std::size_t kMaxSize = 72; +private: alignas(std::max_align_t) std::byte d_[kMaxSize] = {}; STBase* p_ = nullptr; diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 1675d05e2d..e642c1e8c6 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -436,6 +436,8 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ {sfConfidentialBalanceVersion, SoeDefault}, {sfIssuerEncryptedBalance, SoeOptional}, {sfAuditorEncryptedBalance, SoeOptional}, + {sfIssuerKeyMirrorEpoch, SoeOptional}, + {sfAuditorKeyMirrorEpoch, SoeOptional}, {sfHolderEncryptionKey, SoeOptional}, })) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 55ab63f1f7..72ea19309b 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1134,12 +1134,25 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCountDelta, SoeOptional}, })) +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialMPTKeyRotation}), + ({ + {sfMPTokenIssuanceID, SoeRequired}, + {sfHolder, SoeOptional}, + {sfIssuerEncryptedAmount, SoeOptional}, + {sfAuditorEncryptedAmount, SoeOptional}, + {sfZKProof, SoeRequired}, +})) + /** This transaction posts an unsigned transaction on-ledger as a TransactionProposal, pending multi-signature collection. */ #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 92, TransactionProposalCreate, +TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 93, TransactionProposalCreate, ({.amendment = featureCosign}), ({ {sfProposedTransaction, SoeRequired}, diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca31..b294a846da 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -278,6 +278,7 @@ JSS(frozen_balances); // out: GatewayBalances JSS(full); // in: LedgerClearer, handlers/Ledger JSS(full_reply); // out: PathFind JSS(fullbelow_size); // out: GetCounts +JSS(gateway); // in: noripple_check JSS(git); // out: server_info JSS(good); // out: RPCVersion JSS(hash); // out: NetworkOPs, InboundLedger, LedgerToJson, STTx; field @@ -481,6 +482,7 @@ JSS(ports); // out: NetworkOPs JSS(previous); // out: Reservations JSS(previous_ledger); // out: LedgerPropose JSS(price); // out: amm_info, AuctionSlot +JSS(problems); // out: noripple_check JSS(proof); // in: BookOffers JSS(propose_seq); // out: LedgerPropose JSS(proposers); // out: NetworkOPs, LedgerConsensus @@ -660,6 +662,7 @@ JSS(url); // in/out: Subscribe, Unsubscribe JSS(url_password); // in: Subscribe JSS(url_username); // in: Subscribe JSS(urlgravatar); // +JSS(user); // in: noripple_check JSS(username); // in: Subscribe JSS(validated); // out: NetworkOPs, RPCHelpers, AccountTx*, Tx JSS(validator_list_expires); // out: NetworkOps, ValidatorList diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h index 874d779d09..c1601fc2fa 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPToken.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPToken.h @@ -268,6 +268,54 @@ public: return this->sle_->isFieldPresent(sfAuditorEncryptedBalance); } + /** + * @brief Get sfIssuerKeyMirrorEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerKeyMirrorEpoch() const + { + if (hasIssuerKeyMirrorEpoch()) + return this->sle_->at(sfIssuerKeyMirrorEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfIssuerKeyMirrorEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerKeyMirrorEpoch() const + { + return this->sle_->isFieldPresent(sfIssuerKeyMirrorEpoch); + } + + /** + * @brief Get sfAuditorKeyMirrorEpoch (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorKeyMirrorEpoch() const + { + if (hasAuditorKeyMirrorEpoch()) + return this->sle_->at(sfAuditorKeyMirrorEpoch); + return std::nullopt; + } + + /** + * @brief Check if sfAuditorKeyMirrorEpoch is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorKeyMirrorEpoch() const + { + return this->sle_->isFieldPresent(sfAuditorKeyMirrorEpoch); + } + /** * @brief Get sfHolderEncryptionKey (SoeOptional) * @return The field value, or std::nullopt if not present. @@ -471,6 +519,28 @@ public: return *this; } + /** + * @brief Set sfIssuerKeyMirrorEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setIssuerKeyMirrorEpoch(std::decay_t const& value) + { + object_[sfIssuerKeyMirrorEpoch] = value; + return *this; + } + + /** + * @brief Set sfAuditorKeyMirrorEpoch (SoeOptional) + * @return Reference to this builder for method chaining. + */ + MPTokenBuilder& + setAuditorKeyMirrorEpoch(std::decay_t const& value) + { + object_[sfAuditorKeyMirrorEpoch] = value; + return *this; + } + /** * @brief Set sfHolderEncryptionKey (SoeOptional) * @return Reference to this builder for method chaining. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..1fc25bca99 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,266 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class ConfidentialMPTMirrorUpdateBuilder; + +/** + * @brief Transaction: ConfidentialMPTMirrorUpdate + * + * Type: ttCONFIDENTIAL_MPT_MIRROR_UPDATE (92) + * Delegable: Delegation::Delegable + * Amendment: featureConfidentialMPTKeyRotation + * Privileges: Privilege::NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use ConfidentialMPTMirrorUpdateBuilder to construct new transactions. + */ +class ConfidentialMPTMirrorUpdate : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttCONFIDENTIAL_MPT_MIRROR_UPDATE; + + /** + * @brief Construct a ConfidentialMPTMirrorUpdate transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit ConfidentialMPTMirrorUpdate(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdate"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfMPTokenIssuanceID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT192::type::value_type + getMPTokenIssuanceID() const + { + return this->tx_->at(sfMPTokenIssuanceID); + } + + /** + * @brief Get sfHolder (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getHolder() const + { + if (hasHolder()) + { + return this->tx_->at(sfHolder); + } + return std::nullopt; + } + + /** + * @brief Check if sfHolder is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasHolder() const + { + return this->tx_->isFieldPresent(sfHolder); + } + + /** + * @brief Get sfIssuerEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getIssuerEncryptedAmount() const + { + if (hasIssuerEncryptedAmount()) + { + return this->tx_->at(sfIssuerEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfIssuerEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasIssuerEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfIssuerEncryptedAmount); + } + + /** + * @brief Get sfAuditorEncryptedAmount (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAuditorEncryptedAmount() const + { + if (hasAuditorEncryptedAmount()) + { + return this->tx_->at(sfAuditorEncryptedAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAuditorEncryptedAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAuditorEncryptedAmount() const + { + return this->tx_->isFieldPresent(sfAuditorEncryptedAmount); + } + + /** + * @brief Get sfZKProof (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_VL::type::value_type + getZKProof() const + { + return this->tx_->at(sfZKProof); + } +}; + +/** + * @brief Builder for ConfidentialMPTMirrorUpdate transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class ConfidentialMPTMirrorUpdateBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new ConfidentialMPTMirrorUpdateBuilder with required fields. + * @param account The account initiating the transaction. + * @param mPTokenIssuanceID The sfMPTokenIssuanceID field value. + * @param zKProof The sfZKProof field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + ConfidentialMPTMirrorUpdateBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& mPTokenIssuanceID, std::decay_t const& zKProof, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, account, sequence, fee) + { + setMPTokenIssuanceID(mPTokenIssuanceID); + setZKProof(zKProof); + } + + /** + * @brief Construct a ConfidentialMPTMirrorUpdateBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + ConfidentialMPTMirrorUpdateBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttCONFIDENTIAL_MPT_MIRROR_UPDATE) + { + throw std::runtime_error("Invalid transaction type for ConfidentialMPTMirrorUpdateBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfMPTokenIssuanceID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setMPTokenIssuanceID(std::decay_t const& value) + { + object_[sfMPTokenIssuanceID] = value; + return *this; + } + + /** + * @brief Set sfHolder (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setHolder(std::decay_t const& value) + { + object_[sfHolder] = value; + return *this; + } + + /** + * @brief Set sfIssuerEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setIssuerEncryptedAmount(std::decay_t const& value) + { + object_[sfIssuerEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfAuditorEncryptedAmount (SoeOptional) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setAuditorEncryptedAmount(std::decay_t const& value) + { + object_[sfAuditorEncryptedAmount] = value; + return *this; + } + + /** + * @brief Set sfZKProof (SoeRequired) + * @return Reference to this builder for method chaining. + */ + ConfidentialMPTMirrorUpdateBuilder& + setZKProof(std::decay_t const& value) + { + object_[sfZKProof] = value; + return *this; + } + + /** + * @brief Build and return the ConfidentialMPTMirrorUpdate wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + ConfidentialMPTMirrorUpdate + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return ConfidentialMPTMirrorUpdate{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h index b436fda506..4da2deed00 100644 --- a/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h +++ b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h @@ -18,7 +18,7 @@ class TransactionProposalCreateBuilder; /** * @brief Transaction: TransactionProposalCreate * - * Type: ttTRANSACTION_PROPOSAL_CREATE (92) + * Type: ttTRANSACTION_PROPOSAL_CREATE (93) * Delegable: Delegation::NotDelegable * Amendment: featureCosign * Privileges: Privilege::NoPriv diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index 05de33ddf3..0baea78931 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -34,7 +35,6 @@ namespace xrpl { -class SHAMapNodeID; class SHAMapSyncFilter; /** @@ -420,7 +420,107 @@ public: invariants() const; private: - using SharedPtrNodeStack = std::stack>; + /** + * A path from the root of the map down to some node, pairing each node with the ID naming its + * position. + * + * The two halves of an entry must agree, and the only way to get that wrong is to compute an ID + * from the wrong branch. So this type does not accept an ID at all: every push takes the branch + * being descended and derives the ID itself, so a node and its ID cannot disagree. Reads are + * exposed through the same accessors a std::stack would offer. + */ + class NodePathStack + { + public: + [[nodiscard]] bool + empty() const + { + return stack_.empty(); + } + + [[nodiscard]] std::size_t + size() const + { + return stack_.size(); + } + + [[nodiscard]] std::pair const& + top() const + { + XRPL_ASSERT(!stack_.empty(), "xrpl::SHAMap::NodePathStack::top : non-empty stack"); + return stack_.top(); + } + + void + pop() + { + XRPL_ASSERT(!stack_.empty(), "xrpl::SHAMap::NodePathStack::pop : non-empty stack"); + stack_.pop(); + } + + void + clear() + { + stack_ = {}; + } + + /** + * Start a path at the root of the map, whose ID is the zero-depth ID by definition. + */ + void + pushRoot(SHAMapTreeNodePtr node) + { + XRPL_ASSERT(stack_.empty(), "xrpl::SHAMap::NodePathStack::pushRoot : empty stack"); + stack_.emplace(std::move(node), SHAMapNodeID{}); + } + + /** + * Extend the path to the child of the current node reached by `branch`. + * + * A node keeps the depth it was reached at, never a normalized kLeafDepth. Only a leaf may + * sit at kLeafDepth, since an inner node there would have no branch left to select. + */ + void + pushChild(SHAMapTreeNodePtr node, unsigned int branch) + { + XRPL_ASSERT(node, "xrpl::SHAMap::NodePathStack::pushChild : non-null node input"); + XRPL_ASSERT( + !stack_.empty(), "xrpl::SHAMap::NodePathStack::pushChild : non-empty stack"); + auto childID = stack_.top().second.getChildNodeID(branch); + XRPL_ASSERT_IF( + node->isInner(), + childID.getDepth() < kLeafDepth, + "xrpl::SHAMap::NodePathStack::pushChild : inner node above leaf depth"); + XRPL_ASSERT_IF( + node->isLeaf(), + childID.isPrefixOf(leafKey(*node)), + "xrpl::SHAMap::NodePathStack::pushChild : leaf key below branch"); + stack_.emplace(std::move(node), std::move(childID)); + } + + /** + * Extend the path to a node lying on the path to `target`. + * + * For nodes not reached by descending a known branch: the walk tracks only the key it is + * heading for, or the node is newly created. Either way `target` selects the branch. + */ + void + pushNode(SHAMapTreeNodePtr node, uint256 const& target) + { + if (stack_.empty()) + { + pushRoot(std::move(node)); + } + else + { + pushChild(std::move(node), selectBranch(stack_.top().second, target)); + } + } + + private: + std::stack> stack_; + }; + using DeltaRef = std::pair, boost::intrusive_ptr>; @@ -447,7 +547,7 @@ private: * Update hashes up to the root */ void - dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNodePtr terminal); + dirtyUp(NodePathStack& stack, uint256 const& target, SHAMapTreeNodePtr terminal); /** * Walk towards the specified id, returning the node. Caller must check @@ -455,7 +555,7 @@ private: * id */ SHAMapLeafNode* - walkTowardsKey(uint256 const& id, SharedPtrNodeStack* stack = nullptr) const; + walkTowardsKey(uint256 const& id, NodePathStack* stack = nullptr) const; /** * Return nullptr if key not found */ @@ -482,27 +582,15 @@ private: SHAMapTreeNodePtr writeNode(NodeObjectType t, SHAMapTreeNodePtr node) const; - // returns the first item at or below this node - SHAMapLeafNode* - firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch = 0u) const; - - // returns the last item at or below this node - SHAMapLeafNode* - lastBelow( - SHAMapTreeNodePtr node, - SharedPtrNodeStack& stack, - unsigned int branch = kBranchFactor) const; - - // direction in which belowHelper scans an inner node's branches + // direction in which a scan walks an inner node's branches enum class BelowDirection { First, Last }; - // helper function for firstBelow and lastBelow + /** + * Returns the first or last item at or below the node already on top of `stack`, extending + * `stack` with the path walked to reach it. + */ SHAMapLeafNode* - belowHelper( - SHAMapTreeNodePtr node, - SharedPtrNodeStack& stack, - unsigned int branch, - BelowDirection direction) const; + belowHelper(NodePathStack& stack, BelowDirection direction) const; // Simple descent // Get a child of the specified node @@ -550,9 +638,9 @@ private: hasLeafNode(uint256 const& tag, SHAMapHash const& hash) const; SHAMapLeafNode const* - peekFirstItem(SharedPtrNodeStack& stack) const; + peekFirstItem(NodePathStack& stack) const; SHAMapLeafNode const* - peekNextItem(uint256 const& id, SharedPtrNodeStack& stack) const; + peekNextItem(uint256 const& id, NodePathStack& stack) const; bool walkBranch( SHAMapTreeNode* node, @@ -697,7 +785,7 @@ public: using pointer = value_type const*; private: - SharedPtrNodeStack stack_; + NodePathStack stack_; SHAMap const* map_ = nullptr; pointer item_ = nullptr; @@ -723,7 +811,7 @@ public: private: explicit ConstIterator(SHAMap const* map); ConstIterator(SHAMap const* map, std::nullptr_t); - ConstIterator(SHAMap const* map, pointer item, SharedPtrNodeStack&& stack); + ConstIterator(SHAMap const* map, pointer item, NodePathStack&& stack); friend bool operator==(ConstIterator const& x, ConstIterator const& y); @@ -742,10 +830,7 @@ inline SHAMap::ConstIterator::ConstIterator(SHAMap const* map, std::nullptr_t) : { } -inline SHAMap::ConstIterator::ConstIterator( - SHAMap const* map, - pointer item, - SharedPtrNodeStack&& stack) +inline SHAMap::ConstIterator::ConstIterator(SHAMap const* map, pointer item, NodePathStack&& stack) : stack_(std::move(stack)), map_(map), item_(item) { } diff --git a/include/xrpl/tx/ApplyContext.h b/include/xrpl/tx/ApplyContext.h index e827e69f01..7be7f34b0b 100644 --- a/include/xrpl/tx/ApplyContext.h +++ b/include/xrpl/tx/ApplyContext.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -129,6 +130,14 @@ public: view_->rawDestroyXRP(fee); } + /** + * Registers a newly-created order book directory with the shared, + * process-wide OrderBookDB, unless this transaction is being applied + * under TapDryRun. + */ + void + addOrderBook(Book const& book); + ApplyViewContext getApplyViewContext() { diff --git a/include/xrpl/tx/applySteps.h b/include/xrpl/tx/applySteps.h index 19afb740f8..f4137f29d3 100644 --- a/include/xrpl/tx/applySteps.h +++ b/include/xrpl/tx/applySteps.h @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -410,16 +411,21 @@ invokeCheckPermission(ReadView const& view, STTx const& tx); * * No validation is done or implied by this function. * - * Caller is responsible for handling any exceptions. - * Since none should be thrown, that will usually - * mean terminating. - * + * Callers do not expect this function to throw; exceptions from a transactor's + * `calculateBaseFee` are caught and reported as an error instead. * @param view The current open ledger. * @param tx The transaction to be checked. * - * @return The base fee. + * @return The base fee on success. Returns `std::unexpected(temUNKNOWN)` if the transaction + * type is not recognized, and `std::unexpected(tefEXCEPTION)` if the transactor's + * `calculateBaseFee` threw. + * + * @note Failure is reported as an error rather than a fee of zero because a + * zero (or default) fee would pass checkFee and let the transaction be + * applied for less than it owes. Callers that only need a fee hint may fall + * back to a default; callers deciding whether to apply should reject. */ -XRPAmount +[[nodiscard]] std::expected calculateBaseFee(ReadView const& view, STTx const& tx); /** diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 34ce1a4dc2..8cbdefc911 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -38,9 +38,11 @@ namespace xrpl { * f. A Loan must reference a live `ltLOAN_BROKER`, and that broker must * reference a live `ltVAULT`. * g. Post-conditions for the Loan paid down by a successful `ttLOAN_PAY`: - * `PaymentRemaining > 0` after: `PrincipalOutstanding` and - * `PaymentRemaining` strictly decrease; `NextPaymentDueDate` - * advances by N * `PaymentInterval`, N > 0. + * `PaymentRemaining > 0` after: neither `PrincipalOutstanding` nor + * `TotalValueOutstanding` increases, and at least one of them + * strictly decreases; + * `PaymentRemaining` strictly decreases; + * `NextPaymentDueDate` advances by N * `PaymentInterval`, N > 0. * `PaymentRemaining == 0` after: pinned by checks 1 and 5b. * */ diff --git a/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h new file mode 100644 index 0000000000..12ad5c8f28 --- /dev/null +++ b/include/xrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +/** + * @brief Updates the encrypted mirror balances of a Confidential MPToken. + * + * @details + * This transaction updates a single holder's mirrored confidential balances + * (`sfIssuerEncryptedBalance` and/or `sfAuditorEncryptedBalance`) with the latest + * ElGamal public keys defined on the `MPTokenIssuance`. + * + * It supports both issuer and holder self-migration modes, each mode supports multiple flows: + * - Issuer mode: Submitted by the issuer. + * 1. Issuer Key Rotation Migration: Re-encrypts the + * holder's `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 2. Auditor Key Rotation Migration: Re-encrypts the + * holder's `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key. + * + * 3. Simultaneous Rotation Migration: Updates both the issuer + * and auditor encrypted balances in a single transaction to optimize network throughput. + * + * 4. Auditor Late-Registration Migration: When the issuer ElGamal + * public key is already registered on the `MPTokenIssuance` object, the issuer can + * register an auditor key at a later time through `MPTokenIssuanceSet`. Then the issuer uses this + * flow to set the holder's initial `sfAuditorEncryptedBalance` on `MPToken` object. + * + * - Holder self-migration mode: Submitted by the holder. The holder decrypts their own + * `sfConfidentialBalanceSpending` with holder's private key to recover the balance and + * re-encrypts it under the relevant new ElGamal public key(s). This mode is always + * available to the holder and is not conditioned on the issuer being unable to migrate + * them: the ledger cannot verify whether an issuer has really lost its private key. That + * loss is only the expected motivation, since an issuer that still holds its key can + * migrate holders itself in issuer mode. + * @note All holder migration flows strictly require the holder's + * `sfConfidentialBalanceInbox` to be canonically zero; the holder must run + * `ConfidentialMPTMergeInbox` first so the spending balance reflects the + * full balance. + * + * 5. Holder Issuer-Mirror Migration: Re-encrypts the holder's + * `sfIssuerEncryptedBalance` under the issuer's new ElGamal public key. + * + * 6. Holder Auditor-Mirror Migration: Re-encrypts the holder's + * `sfAuditorEncryptedBalance` under the auditor's new ElGamal public key, or + * sets it for the first time when the auditor key was late-registered. This is the + * holder-driven counterpart to flows 2 and 4, for when the issuer does not migrate + * the holder itself. + * + * 7. Simultaneous Holder Self-Migration: Updates both the issuer and auditor + * encrypted balances in a single transaction (both keys have rotated). + */ +class ConfidentialMPTMirrorUpdate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit ConfidentialMPTMirrorUpdate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/package/README.md b/package/README.md index 6e88309ecd..50c12734e0 100644 --- a/package/README.md +++ b/package/README.md @@ -15,7 +15,8 @@ package/ publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI, and shipped in that image) rpm/ xrpld.spec RPM spec - debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, xrpld.lintian-overrides, source/format) + debian/ Debian control files (control.in, lintian-overrides.in, rules, copyright, docs, links, source/format). + The `.in` files are templates rendered by `build_pkg.py`; `docs` and `links` are staged under the package name shared/ xrpld.service systemd unit file (used by both RPM and DEB) xrpld.sysusers sysusers.d config (used by both RPM and DEB) @@ -32,20 +33,74 @@ packaging job cannot drift apart. Today only `linux/amd64` is emitted. The map pins the full container image in `image` — edit that field to move to a new image and both CI and local builds pick it up — and names the format that image builds in `type`, which CI passes to `build_pkg.py` as `--package-type`; the two -have to stay in step. +have to stay in step. An optional `variant` names a flavour of the package (see +[Package variants](#package-variants)), and CI passes it as `--variant`. | Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | -------------------------------------------------------------- | | RPM | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-` | `rpmbuild`, `rpmsign` | | DEB | `ghcr.io/xrplf/xrpld/packaging-debian:sha-` | `dpkg-buildpackage`, debhelper with compat level 13, `lintian` | -To print the full packaging matrix (artifact names and images) for the current -`linux.json`: +To print the full packaging matrix (artifact names, images and package names) +for the current `linux.json`: ```bash ./.github/scripts/strategy-matrix/generate.py --packaging ``` +## Package variants + +A config whose binaries are not the plain release build cannot be packaged as +`xrpld`: both would carry the same name and version, so whichever published last +would win. It is packaged as a **variant** instead — `variant: "assert"` in its +`package` map, which CI passes to `build_pkg.py` as `--variant assert`, +producing `xrpld-assert`. What the build option itself does is a build concern, +not a packaging one; see the options table in [`BUILD.md`](../BUILD.md). + +A variant ships the same paths as `xrpld` — `/usr/bin/xrpld`, `/etc/xrpld`, +`xrpld.service`, `/etc/logrotate.d/xrpld` — differing only in the per-package +documentation directory, so it declares itself a stand-in for the plain package +rather than something installable next to it: `Conflicts`, `Replaces` and a +versioned `Provides: xrpld` on Debian, `Conflicts` and `Provides` on RPM. +Neither format declares `Obsoletes`, so `apt upgrade` and `dnf upgrade` keep an +installed flavour on its own flavour, and switching is always explicit: + +```bash +apt-get install xrpld-assert # apt removes the plain package itself +dnf swap xrpld xrpld-VARIANT # 'dnf install' alone stops at the conflict +``` + +Only the DEB packages carry a variant today — `xrpld-assert` comes from the +`debian` config alone, there being no call for an assert build on RHEL-based +distributions — but the RPM side works the same way if one is added. + +A switch is a removal plus an installation rather than an upgrade, so unlike a +version upgrade it stops the service: Debian's scriptlets start it again, while +on RPM the operator runs `systemctl start xrpld`. Configuration survives either +way, being conffiles on Debian and `%config(noreplace)` on RPM. + +`dnf` installs the replacement before erasing the old flavour, whose `%preun` +would leave `xrpld.service` disabled, so `%postun` re-applies the preset when +the unit file outlives the erase — which, since rpm keeps a file another +installed package owns, happens only during a swap. The cost is that a +deliberate `systemctl disable` is not carried across an RPM switch. + +The alternative is an `xrpld-common` package owning the unit, the sysusers and +tmpfiles snippets and the configuration, required by both flavours at an exact +version: nothing is erased mid-swap, so no scriptlet has to detect one. It is +not worth it for a single variant — it moves files out of the production +package, and a sanitizer flavour would likely need its own unit anyway, putting +the lifecycle back where it is now. + +Adding a variant is the flavour in `VARIANTS` in `build_pkg.py`, which is the +list `--variant` accepts, plus a config in `linux.json` with the CMake arguments +and a `package` map naming it, for one format or for both: `generate.py +--packaging` emits the package names per format, and the `test-install-deb` and +`test-install-rpm` jobs install what their own format produced. + +Operators switch between the flavours as described in +[`docs/install.md`](../docs/install.md#optional-the-assert-enabled-build). + ## Building packages ### Via CI @@ -56,9 +111,11 @@ Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call 1. `package` fans out one job per config carrying a `package` map, building and signing in that config's container, and uploading `-pkg` alongside `-pkg-debug` for the much larger debug symbols. -2. `test-install` installs `-pkg` in the container of every distro the - packages target and runs the binaries there, so one that cannot be installed - never reaches Nexus. +2. `test-install-deb` and `test-install-rpm` call + [`reusable-package-test-install.yml`](../.github/workflows/reusable-package-test-install.yml) + with their format's package names and distro images, installing each package + in the container of every distro that format targets and running the binaries + there, so one that cannot be installed never reaches Nexus. 3. `publish` uploads both artifacts, or lists what it would upload. The packaging script derives the package version from the downloaded binary's @@ -104,6 +161,9 @@ docker run --rm \ # build/rpmbuild/RPMS/x86_64/*.rpm ``` +Add `--variant assert` to package binaries built with `-Dassert=ON`; the package +is then named `xrpld-assert`. + ### Via CMake (host-side target) If you run CMake configure on a host that has `rpmbuild` or `dpkg-buildpackage` @@ -133,11 +193,17 @@ The package version is not a CMake input on this path: `build_pkg.py` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. +`-Dassert=ON` passes `--variant assert`, so such a build packages as +`xrpld-assert` without anything else being asked for. + ## Publishing packages -Packages are published to the XRPLF repositories on Sonatype Nexus at -`https://packages.xrplf.org`. The `release-info` action decides the channel from -the event, and `publish_pkg.py` maps that channel to its repositories: +Packages are published to the XRPLF repositories on Sonatype Nexus through +`https://packages-upload.xrplf.org`. Reads go through +`https://packages.xrplf.org`, which Cloudflare proxies to cache them and which +rejects request bodies over 100 MB, so uploads use the DNS-only host instead. +The `release-info` action decides the channel from the event, and +`publish_pkg.py` maps that channel to its repositories: | Event | Version | Channel | DEB repository | RPM upload repository | | ------------------------ | ----------------- | --------- | -------------- | --------------------- | @@ -147,6 +213,9 @@ the event, and `publish_pkg.py` maps that channel to its repositories: | push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop-hosted` | | tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private-hosted` | +A variant is published to the same channel under its own name, so +`xrpld-assert` never overwrites `xrpld`. + Only a tag names a channel — do not extend that to `develop`, where `BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final version during a release cycle, which would send develop builds into `stable`. @@ -160,7 +229,7 @@ the last, and the date and hash say which commit a package on `packages.xrplf.org` came from. Both reach the packaging scripts as arguments, so neither script derives anything itself. -Publishing is its own job, gated behind `test-install`, uploading from the same +Publishing is its own job, gated behind the install tests, uploading from the same image that built the packages with the `publish_pkg.py` shipped in it — the same copy other repositories run. Without `publish: true` the job is a `--dry-run`, listing the uploads it would make without needing credentials, so @@ -175,7 +244,7 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing - Each apt-hosted repository needs a distribution (ours use `any`) and a PGP signing keypair configured in Nexus, which rejects one created without a keypair. Nexus signs the apt metadata with it, never the packages. -- Hosted yum repositories cannot be signed by Nexus, so each `rpm--hosted` +- yum-hosted repositories cannot be signed by Nexus, so each `rpm--hosted` repository sits behind a `rpm-` yum group repository whose metadata Nexus signs. Uploads go to the hosted repository; clients point at the group and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs @@ -244,6 +313,19 @@ pre-release ordering convention, so RPM filenames/NVRs begin with forms like `xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding pre-releases with an older `0..` RPM `Release` value. +`--variant` is the flavour of the package, empty by default and accepting only +the flavours in `VARIANTS`; see [Package variants](#package-variants). The RPM +path passes it to the spec as the `pkg_variant` macro, which suffixes `Name` and +adds the `Conflicts`/`Provides` pair. Debian control files have no conditionals, so the DEB path renders +`debian/control.in` and `debian/lintian-overrides.in` instead, substituting +`@PKG@` with the package name and `@VARIANT_FIELDS@` with the +`Conflicts`/`Replaces`/`Provides` block, empty for the plain package; a token +with no value fails the build rather than reaching dpkg. The files debhelper +keys by package name (`docs`, `links`, and the units) are staged under that same +name. The paths inside the package are unchanged either way, so `debian/rules` +reads its package name from `dh_listpackages` and names the unit, sysusers, +tmpfiles and logrotate files with `--name xrpld`. + The package format is `--package-type`, either `deb` or `rpm`. It is required, so a job never silently builds the wrong format for the image it runs in; the matching build tool still has to be on PATH. @@ -286,8 +368,13 @@ service restart. 1. Creates a staging source tree at `debbuild/source/` inside the build directory. 2. Stages the binaries, configs, `README.md`, `LICENSE.md`, and `validator-keys-LICENSE`. -3. Copies `package/debian/` control files into `debbuild/source/debian/`. -4. Copies shared service/sysusers/tmpfiles/logrotate into `debian/` where `dh_installsystemd`, `dh_installsysusers`, `dh_installtmpfiles` and `dh_installlogrotate` pick them up automatically. +3. Stages `package/debian/` into `debbuild/source/debian/`: the `.in` templates + are rendered, and the files debhelper keys by package name (`docs`, `links`, + `lintian-overrides`) are staged under the name being built. +4. Copies shared service/sysusers/tmpfiles/logrotate into `debian/` as + `.xrpld.*`, which `dh_installsystemd`, `dh_installsysusers`, + `dh_installtmpfiles` and `dh_installlogrotate` read because `debian/rules` + passes them `--name xrpld`. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, where `pkg_version` is derived from the binary-reported `xrpld` version. 6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. diff --git a/package/build_pkg.py b/package/build_pkg.py index 1aaf53d5ff..77ef8f3120 100755 --- a/package/build_pkg.py +++ b/package/build_pkg.py @@ -21,6 +21,14 @@ SRC_DIR = Path(__file__).resolve().parents[1] PRE_RELEASE = re.compile(r"^(b|rc)(0|[1-9][0-9]*)(\+.*)?$") +# The package name a variant suffixes, and the name every variant keeps for its +# on-disk paths (/usr/bin/xrpld, /etc/xrpld, xrpld.service). +BASE_NAME = "xrpld" + +# The flavours that can be built, '' being the plain xrpld package. A variant +# needs a config in linux.json to be built by CI; see package/README.md. +VARIANTS = ("", "assert") + # Files both packaging systems consume, staged under the same names. STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") STAGED_FROM_SRC = { @@ -31,6 +39,18 @@ STAGED_FROM_SRC = { } STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate") +# debian/ files debhelper keys by package name, staged as '.'. +DEBIAN_PKG_FILES = ("docs", "links") + +# Debian control files have no conditionals, so what makes a variant replace the +# plain package is rendered into control.in rather than written there. +DEB_VARIANT_FIELDS = """\ +Conflicts: xrpld +Replaces: xrpld +Provides: xrpld (= ${binary:Version})""" + +TOKEN = re.compile(r"@[A-Z_]+@") + def run(*command: object, cwd: Path | None = None) -> None: """Echo a command and run it.""" @@ -75,6 +95,28 @@ def package_version(reported: str) -> str: return version +def render(template: Path, dest: Path, values: dict[str, str]) -> None: + """Write template to dest with its @TOKEN@ placeholders substituted. + + A token left without a value fails the build rather than reaching dpkg. + """ + text = template.read_text() + for token, value in values.items(): + text = text.replace(f"@{token}@", value) + + missing = sorted(set(TOKEN.findall(text))) + assert not missing, f"{template}: no value for {', '.join(missing)}" + + # An empty value at the end of a stanza would otherwise leave a blank line, + # which is what ends a stanza. + dest.write_text(text.rstrip("\n") + "\n") + + +def package_name(variant: str) -> str: + """The binary package name for a variant: '' -> xrpld, 'assert' -> xrpld-assert.""" + return f"{BASE_NAME}-{variant}" if variant else BASE_NAME + + def read_version(xrpld: Path) -> str: """Read the version from the binary that is about to be packaged.""" fields = capture(xrpld, "--version").partition("\n")[0].split() @@ -135,17 +177,18 @@ def stage_common(build_dir: Path, dest: Path) -> None: shutil.copy2(SRC_DIR / source, dest / name) -def stage_units(dest: Path) -> None: +def stage_units(dest: Path, *, prefix: str = "") -> None: """Copy the systemd, sysusers, tmpfiles and logrotate files into dest. - Each format wants them somewhere else: rpmbuild reads them from SOURCES, - debhelper from debian/. + Each format wants them somewhere else: rpmbuild reads them from SOURCES by + path, debhelper from debian/ by package name -- hence 'prefix', which makes + the copies 'xrpld-assert.xrpld.service' and so on. """ for name in STAGED_UNITS: - shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / f"{prefix}{name}") -def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: +def build_rpm(build_dir: Path, *, version: str, pkg_release: str, variant: str) -> None: """Stage the spec and its sources, then build the binary RPMs.""" topdir = build_dir / "rpmbuild" for name in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): @@ -156,6 +199,9 @@ def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: stage_common(build_dir, topdir / "SOURCES") stage_units(topdir / "SOURCES") + # The spec defaults it to nothing, so a plain build is unchanged. + variant_defines = ["--define", f"pkg_variant {variant}"] if variant else [] + run( "rpmbuild", "-bb", @@ -168,10 +214,29 @@ def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: # The image tracks the newest distro, but the packages target el9. "--define", "dist .el9", + *variant_defines, spec, ) +def stage_debian(dest: Path, name: str) -> None: + """Stage the debian directory for the package name being built.""" + source = SRC_DIR / "package" / "debian" + shutil.copytree( + source, dest, ignore=shutil.ignore_patterns("*.in", *DEBIAN_PKG_FILES) + ) + + values = { + "PKG": name, + "VARIANT_FIELDS": "" if name == BASE_NAME else DEB_VARIANT_FIELDS, + } + render(source / "control.in", dest / "control", values) + render(source / "lintian-overrides.in", dest / f"{name}.lintian-overrides", values) + + for suffix in DEBIAN_PKG_FILES: + shutil.copy2(source / suffix, dest / f"{name}.{suffix}") + + def build_deb( build_dir: Path, *, @@ -180,21 +245,23 @@ def build_deb( pkg_release: str, channel: str, epoch: int, + name: str, ) -> None: """Stage the debian directory and its sources, then build the binary DEBs.""" staging = build_dir / "debbuild" / "source" stage_common(build_dir, staging) - shutil.copytree(SRC_DIR / "package" / "debian", staging / "debian") + stage_debian(staging / "debian", name) - # debhelper picks these up from debian/ automatically. - stage_units(staging / "debian") + # Prefixed whether it is a variant's name or not: debian/rules names them + # explicitly either way. + stage_units(staging / "debian", prefix=f"{name}.") date = datetime.fromtimestamp(epoch, timezone.utc).strftime( "%a, %d %b %Y %H:%M:%S %z" ) # The leading spaces are significant to dpkg. changelog = textwrap.dedent(f"""\ - xrpld ({version}-{pkg_release}) {channel}; urgency=medium + {name} ({version}-{pkg_release}) {channel}; urgency=medium * Release {reported}. -- XRPL Foundation {date} @@ -223,6 +290,14 @@ def main() -> None: default="1", help="package release iteration (default: %(default)s)", ) + parser.add_argument( + "--variant", + default="", + choices=VARIANTS, + help="the flavour of the package to build: 'assert' produces " + "xrpld-assert, which ships the same paths as xrpld and replaces it " + "(default: the plain xrpld package)", + ) parser.add_argument( "--channel", required=True, @@ -234,6 +309,8 @@ def main() -> None: build_dir: Path = args.build_dir.resolve() pkg_release: str = args.pkg_release channel: str = args.channel + variant: str = args.variant + name = package_name(variant) assert build_dir.is_dir(), ( f"build directory not found: {build_dir}. Build the binaries before " @@ -253,6 +330,8 @@ def main() -> None: for tree in ("debbuild", "rpmbuild"): shutil.rmtree(build_dir / tree, ignore_errors=True) + print(f"Building {package_type} {name} {version}-{pkg_release}", flush=True) + if package_type == "deb": build_deb( build_dir, @@ -261,9 +340,10 @@ def main() -> None: pkg_release=pkg_release, channel=channel, epoch=epoch, + name=name, ) else: - build_rpm(build_dir, version=version, pkg_release=pkg_release) + build_rpm(build_dir, version=version, pkg_release=pkg_release, variant=variant) if __name__ == "__main__": diff --git a/package/debian/control b/package/debian/control.in similarity index 93% rename from package/debian/control rename to package/debian/control.in index 359f39f770..20486efc9a 100644 --- a/package/debian/control +++ b/package/debian/control.in @@ -1,4 +1,4 @@ -Source: xrpld +Source: @PKG@ Section: net Priority: optional Maintainer: XRPL Foundation @@ -11,7 +11,7 @@ Homepage: https://github.com/XRPLF/rippled Vcs-Git: https://github.com/XRPLF/rippled.git Vcs-Browser: https://github.com/XRPLF/rippled -Package: xrpld +Package: @PKG@ Architecture: any Depends: ${shlibs:Depends}, @@ -22,3 +22,4 @@ Description: XRP Ledger daemon transactions, and maintains the ledger database. This package also includes the validator-keys tool for validator key management. +@VARIANT_FIELDS@ diff --git a/package/debian/xrpld.docs b/package/debian/docs similarity index 100% rename from package/debian/xrpld.docs rename to package/debian/docs diff --git a/package/debian/xrpld.links b/package/debian/links similarity index 100% rename from package/debian/xrpld.links rename to package/debian/links diff --git a/package/debian/lintian-overrides.in b/package/debian/lintian-overrides.in new file mode 100644 index 0000000000..5e72a5ef6b --- /dev/null +++ b/package/debian/lintian-overrides.in @@ -0,0 +1,6 @@ +# The /usr/local/bin/rippled symlink is deliberate compatibility for pre-FHS +# layouts, so the Policy 9.1.2 tags it raises are expected. +# TODO: remove alongside debian/links after rippled fully deprecated. +@PKG@: dir-in-usr-local [usr/local/bin/] +@PKG@: file-in-usr-local [usr/local/bin/rippled] +@PKG@: file-in-unusual-dir [usr/local/bin/rippled] diff --git a/package/debian/rules b/package/debian/rules index dd6d1e66b9..bc12f54218 100755 --- a/package/debian/rules +++ b/package/debian/rules @@ -8,33 +8,58 @@ export DH_VERBOSE = 1 # the binaries actually run on. LIBC_MIN = 2.31 +# The binary package's name, which a variant build changes to e.g. xrpld-assert, +# and the directory debhelper expects its files staged in. +PKG := $(firstword $(shell dh_listpackages)) +PKG_DIR = debian/$(PKG) + +# The base name, which every package ships under whatever it is called itself. +BASE_NAME = xrpld + +# What build_pkg.py stages beside this directory, each installed under its own +# name. The binaries are also the ones checked against LIBC_MIN below. +BINARIES = $(BASE_NAME) validator-keys +CONFIGS = $(BASE_NAME).cfg validators.txt + %: dh $@ override_dh_auto_configure override_dh_auto_build override_dh_auto_test: @: +# The unit, sysusers, tmpfiles and logrotate files are named after the daemon +# rather than after the package, so a variant still ships xrpld.service and +# /etc/logrotate.d/xrpld. debhelper only reads debian/$(PKG).$(BASE_NAME).* when told +# the name. override_dh_installsystemd: - dh_installsystemd --no-stop-on-upgrade xrpld.service + dh_installsystemd --no-stop-on-upgrade --name $(BASE_NAME) # The tmpfiles snippet sets ownership to the xrpld user, so the sysusers snippet # has to be emitted first: run it early and make its own sequence slot a no-op. execute_before_dh_installtmpfiles: - dh_installsysusers + dh_installsysusers --name $(BASE_NAME) override_dh_installsysusers: +override_dh_installtmpfiles: + dh_installtmpfiles --name $(BASE_NAME) + +override_dh_installlogrotate: + dh_installlogrotate --name $(BASE_NAME) + override_dh_install: - install -D -m 0755 xrpld debian/xrpld/usr/bin/xrpld - install -D -m 0755 validator-keys debian/xrpld/usr/bin/validator-keys - install -D -m 0644 xrpld.cfg debian/xrpld/etc/xrpld/xrpld.cfg - install -D -m 0644 validators.txt debian/xrpld/etc/xrpld/validators.txt + for binary in $(BINARIES); do \ + install -D -m 0755 "$$binary" "$(PKG_DIR)/usr/bin/$$binary"; \ + done + for config in $(CONFIGS); do \ + install -D -m 0644 "$$config" "$(PKG_DIR)/etc/$(BASE_NAME)/$$config"; \ + done override_dh_shlibdeps: dh_shlibdeps # Guards against the toolchain moving past LIBC_MIN and the packages then # claiming a floor they do not meet. - for binary in xrpld validator-keys; do \ + for binary in $(BINARIES); do \ needed=$$(readelf --dyn-syms --wide $$binary \ | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1); \ if [ -z "$$needed" ]; then \ @@ -46,7 +71,7 @@ override_dh_shlibdeps: exit 1; \ fi; \ done - sed -i 's/libc6 (>= [0-9.]*)/libc6 (>= $(LIBC_MIN))/' debian/xrpld.substvars + sed -i 's/libc6 (>= [0-9.]*)/libc6 (>= $(LIBC_MIN))/' debian/$(PKG).substvars override_dh_dwz: @: diff --git a/package/debian/xrpld.lintian-overrides b/package/debian/xrpld.lintian-overrides deleted file mode 100644 index a0b3f583ed..0000000000 --- a/package/debian/xrpld.lintian-overrides +++ /dev/null @@ -1,6 +0,0 @@ -# The /usr/local/bin/rippled symlink is deliberate compatibility for pre-FHS -# layouts, so the Policy 9.1.2 tags it raises are expected. -# TODO: remove alongside debian/xrpld.links after rippled fully deprecated. -xrpld: dir-in-usr-local [usr/local/bin/] -xrpld: file-in-usr-local [usr/local/bin/rippled] -xrpld: file-in-unusual-dir [usr/local/bin/rippled] diff --git a/package/docker/publish_pkg.py b/package/docker/publish_pkg.py index 84a0448e7b..a112e284aa 100755 --- a/package/docker/publish_pkg.py +++ b/package/docker/publish_pkg.py @@ -98,7 +98,7 @@ def main() -> None: ) parser.add_argument( "--nexus-url", - default="https://packages.xrplf.org", + default="https://packages-upload.xrplf.org", help="the Nexus instance to publish to (default: %(default)s)", ) parser.add_argument( diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec index 5139cd54e5..45e7a78e42 100644 --- a/package/rpm/xrpld.spec +++ b/package/rpm/xrpld.spec @@ -6,10 +6,14 @@ %{error:pkg_release must be defined} %endif -Name: xrpld +# The base name, which every package ships under. A variant build +# (build_pkg.py --variant) only suffixes the package name, e.g. xrpld-assert. +%global base_name xrpld + +Name: %{base_name}%{?pkg_variant:-%{pkg_variant}} Version: %{pkg_version} Release: %{pkg_release}%{?dist} -Summary: XRP Ledger daemon +Summary: XRP Ledger daemon%{?pkg_variant: (%{pkg_variant} build)} License: ISC URL: https://github.com/XRPLF/rippled @@ -17,6 +21,12 @@ URL: https://github.com/XRPLF/rippled ExclusiveArch: x86_64 aarch64 BuildRequires: systemd-rpm-macros +# A variant owns the same paths, so it stands in for the plain package. +%if "%{?pkg_variant}" != "" +Conflicts: %{base_name} +Provides: %{base_name} = %{version}-%{release} +%endif + # These have to precede %%debug_package: it opens the debuginfo subpackage, and # any tag after it is silently dropped from the main package. %{?systemd_requires} @@ -52,22 +62,22 @@ management. : %install -install -Dm0755 %{_sourcedir}/xrpld %{buildroot}%{_bindir}/%{name} +install -Dm0755 %{_sourcedir}/xrpld %{buildroot}%{_bindir}/%{base_name} install -Dm0755 %{_sourcedir}/validator-keys %{buildroot}%{_bindir}/validator-keys -install -Dm0644 %{_sourcedir}/xrpld.cfg %{buildroot}%{_sysconfdir}/%{name}/xrpld.cfg -install -Dm0644 %{_sourcedir}/validators.txt %{buildroot}%{_sysconfdir}/%{name}/validators.txt +install -Dm0644 %{_sourcedir}/xrpld.cfg %{buildroot}%{_sysconfdir}/%{base_name}/xrpld.cfg +install -Dm0644 %{_sourcedir}/validators.txt %{buildroot}%{_sysconfdir}/%{base_name}/validators.txt # systemd units, sysusers, tmpfiles, preset install -Dm0644 %{_sourcedir}/xrpld.service %{buildroot}%{_unitdir}/xrpld.service install -Dm0644 %{_sourcedir}/xrpld.sysusers %{buildroot}%{_sysusersdir}/xrpld.conf install -Dm0644 %{_sourcedir}/xrpld.tmpfiles %{buildroot}%{_tmpfilesdir}/xrpld.conf install -d %{buildroot}%{_presetdir} -cat >%{buildroot}%{_presetdir}/50-xrpld.preset <<'EOF' +cat >%{buildroot}%{_presetdir}/50-%{base_name}.preset <<'EOF' enable xrpld.service EOF # Logrotate config -install -Dm0644 %{_sourcedir}/xrpld.logrotate %{buildroot}%{_sysconfdir}/logrotate.d/%{name} +install -Dm0644 %{_sourcedir}/xrpld.logrotate %{buildroot}%{_sysconfdir}/logrotate.d/%{base_name} # Docs install -Dm0644 %{_sourcedir}/LICENSE.md %{buildroot}%{_docdir}/%{name}/LICENSE.md @@ -78,13 +88,13 @@ install -Dm0644 %{_sourcedir}/validator-keys-LICENSE %{buildroot}%{_docdir}/%{na # Legacy compatibility for pre-FHS package layouts. # TODO: remove after rippled fully deprecated. install -d %{buildroot}/usr/local/bin -ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled +ln -s %{_bindir}/%{base_name} %{buildroot}/usr/local/bin/rippled %pre -%sysusers_create_package %{name} %{_sourcedir}/xrpld.sysusers +%sysusers_create_package %{base_name} %{_sourcedir}/xrpld.sysusers %post -%tmpfiles_create_package %{name} %{_sourcedir}/xrpld.tmpfiles +%tmpfiles_create_package %{base_name} %{_sourcedir}/xrpld.tmpfiles %systemd_post xrpld.service %preun @@ -92,6 +102,13 @@ ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled %postun %systemd_postun xrpld.service +# A flavour swap installs the replacement before erasing this package, so the +# %%preun above has just disabled a unit the replacement still owns. rpm keeps a +# file that another installed package owns, so the unit outliving our own erase +# means exactly that; a plain erase takes it with us and re-presets nothing. +if [ $1 -eq 0 ] && [ -f %{_unitdir}/xrpld.service ]; then + systemctl preset xrpld.service >/dev/null 2>&1 || : +fi %files %attr(0755,root,root) %dir %{_docdir}/%{name} @@ -99,18 +116,18 @@ ln -s %{_bindir}/%{name} %{buildroot}/usr/local/bin/rippled %license %{_docdir}/%{name}/validator-keys-LICENSE %doc %{_docdir}/%{name}/README.md -%attr(0755,root,root) %dir %{_sysconfdir}/%{name} +%attr(0755,root,root) %dir %{_sysconfdir}/%{base_name} -%{_bindir}/%{name} +%{_bindir}/%{base_name} %{_bindir}/validator-keys -%config(noreplace) %{_sysconfdir}/%{name}/xrpld.cfg -%config(noreplace) %{_sysconfdir}/%{name}/validators.txt -%config(noreplace) %{_sysconfdir}/logrotate.d/%{name} +%config(noreplace) %{_sysconfdir}/%{base_name}/xrpld.cfg +%config(noreplace) %{_sysconfdir}/%{base_name}/validators.txt +%config(noreplace) %{_sysconfdir}/logrotate.d/%{base_name} %{_unitdir}/xrpld.service -%attr(0644,root,root) %{_presetdir}/50-xrpld.preset +%attr(0644,root,root) %{_presetdir}/50-%{base_name}.preset %{_sysusersdir}/xrpld.conf %{_tmpfilesdir}/xrpld.conf %ghost %dir /var/lib/xrpld diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 10c7e62c6c..2728a3b86f 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -179,7 +179,7 @@ isPaymentLate(ReadView const& view, SLE::const_ref loanSle) : ExpiryComparison::Inclusive); } -namespace accrual { +namespace instant_recognition { AccountingDeltas loanOriginationDeltas(Number const& principalRequested, Number const& interestDue) @@ -217,7 +217,7 @@ loanPaymentDeltas(LoanPaymentParts const& parts) .debtTotalDelta = (parts.principalPaid + parts.interestPaid) - parts.valueChange}; } -} // namespace accrual +} // namespace instant_recognition namespace cash_basis { @@ -250,8 +250,8 @@ namespace { // Cash-basis accounting applies only when featureLendingProtocolV1_1 is // enabled AND the specific Vault was created under it (LEVersion == -// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis -// accounting forever, even after the amendment later turns on. +// VaultVersion::CashBasis). Vaults created before activation keep instant +// interest recognition forever, even after the amendment later turns on. bool cashBasisEnabled(SLE::const_ref vaultSle) { @@ -268,7 +268,7 @@ loanOriginationDeltas( { return cashBasisEnabled(vaultSle) ? cash_basis::loanOriginationDeltas(principalRequested) - : accrual::loanOriginationDeltas(principalRequested, interestDue); + : instant_recognition::loanOriginationDeltas(principalRequested, interestDue); } bool @@ -283,21 +283,22 @@ loanOriginationExceedsVaultMaximum( return false; auto const vaultMaximum = vaultSle->at(sfAssetsMaximum); - return accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue); + return instant_recognition::loanOriginationExceedsVaultMaximum( + vaultMaximum, vaultTotal, interestDue); } Number loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle) { return cashBasisEnabled(vaultSle) ? cash_basis::loanVaultExposure(loanSle) - : accrual::loanVaultExposure(loanSle); + : instant_recognition::loanVaultExposure(loanSle); } AccountingDeltas loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts) { return cashBasisEnabled(vaultSle) ? cash_basis::loanPaymentDeltas(parts) - : accrual::loanPaymentDeltas(parts); + : instant_recognition::loanPaymentDeltas(parts); } namespace detail { diff --git a/src/libxrpl/peerfinder/Config.cpp b/src/libxrpl/peerfinder/Config.cpp index 60ac0ca547..2e0f793a3a 100644 --- a/src/libxrpl/peerfinder/Config.cpp +++ b/src/libxrpl/peerfinder/Config.cpp @@ -107,8 +107,12 @@ Config::makeConfig( else { config.outPeers = *limits.outPeers; - config.inPeers = *limits.inPeers; - config.maxPeers = 0; + + // Inbound slots only exist if we accept incoming connections, and + // `maxPeers` is the total across both directions. The legacy branch + // above upholds the same two invariants. + config.inPeers = config.wantIncoming ? *limits.inPeers : 0; + config.maxPeers = config.inPeers + config.outPeers; } // This will cause servers configured as validators to request that diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index bf67defa3b..9788b4c025 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.4.0-rc1" +char const* const versionString = "3.5.0-b0" // clang-format on ; diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index ecd4832928..a3e48b5f31 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -6,9 +6,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -397,6 +399,79 @@ checkEncryptedAmountFormat(STObject const& object) return tesSUCCESS; } +bool +isIssuerMirrorCurrent(SLE const& issuance, SLE const& mptoken) +{ + XRPL_ASSERT( + issuance.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::isIssuerMirrorCurrent : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::isIssuerMirrorCurrent : mptoken MPToken object"); + + return mptoken.isFieldPresent(sfIssuerEncryptedBalance) && + mptoken[~sfIssuerKeyMirrorEpoch].value_or(0) == issuance[~sfIssuerKeyEpoch].value_or(0); +} + +bool +isAuditorMirrorCurrent(SLE const& issuance, SLE const& mptoken) +{ + XRPL_ASSERT( + issuance.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::isAuditorMirrorCurrent : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::isAuditorMirrorCurrent : mptoken MPToken object"); + + if (!issuance.isFieldPresent(sfAuditorEncryptionKey)) + return true; + + return mptoken.isFieldPresent(sfAuditorEncryptedBalance) && + mptoken[~sfAuditorKeyMirrorEpoch].value_or(0) == issuance[~sfAuditorKeyEpoch].value_or(0); +} + +bool +areMirrorsCurrent(SLE const& issuance, SLE const& mptoken) +{ + return isIssuerMirrorCurrent(issuance, mptoken) && isAuditorMirrorCurrent(issuance, mptoken); +} + +void +setIssuerMirrorEpoch(SLE const& issuance, SLE& mptoken) +{ + XRPL_ASSERT( + issuance.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::setIssuerMirrorEpoch : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::setIssuerMirrorEpoch : mptoken MPToken object"); + + // Unlike the auditor mirror, the issuer mirror is not optional: every + // confidential MPToken carries one, so there is no existence check here. + if (auto const epoch = issuance[~sfIssuerKeyEpoch].value_or(0); epoch != 0) + mptoken[sfIssuerKeyMirrorEpoch] = epoch; +} + +void +setAuditorMirrorEpoch(SLE const& issuance, SLE& mptoken) +{ + XRPL_ASSERT( + issuance.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::setAuditorMirrorEpoch : issuance MPTokenIssuance object"); + XRPL_ASSERT( + mptoken.getType() == ltMPTOKEN, "xrpl::setAuditorMirrorEpoch : mptoken MPToken object"); + + if (!mptoken.isFieldPresent(sfAuditorEncryptedBalance)) + return; + + if (auto const epoch = issuance[~sfAuditorKeyEpoch].value_or(0); epoch != 0) + mptoken[sfAuditorKeyMirrorEpoch] = epoch; +} + +void +setMirrorEpochs(SLE const& issuance, SLE& mptoken) +{ + setIssuerMirrorEpoch(issuance, mptoken); + setAuditorMirrorEpoch(issuance, mptoken); +} + TER verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash) { diff --git a/src/libxrpl/protocol/STPathSet.cpp b/src/libxrpl/protocol/STPathSet.cpp index 658aaa65dd..2c074c3f2f 100644 --- a/src/libxrpl/protocol/STPathSet.cpp +++ b/src/libxrpl/protocol/STPathSet.cpp @@ -1,6 +1,8 @@ #include +#include #include +#include #include #include #include @@ -11,10 +13,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -31,6 +35,11 @@ STPathElement::getHash(STPathElement const& element) // NIKB NOTE: This doesn't have to be a secure hash as speed is more // important. We don't even really need to fully hash the whole // base_uint here, as a few bytes would do for our use. + // + // The note above is only true because the result of this function reaches + // nothing but STPathElement::operator==, where it is a fast-reject + // prefilter ahead of the field comparisons that decide the answer. Do not + // use it to key a container. for (auto const x : element.getAccountID()) hashAccount += (hashAccount * 257) ^ x; @@ -51,10 +60,49 @@ STPathElement::getHash(STPathElement const& element) return (hashAccount ^ hashCurrency ^ hashIssuer); } -[[nodiscard]] size_t -STPathElement::getHash() const +// For guidance on deciding which option to pursue: +// 1. Try to decrease the size of the STPathSet first. For instance, if a std::optional was +// injected into the type, could you get the same functionality using a std::unique_ptr instead? +// 2. If the size of the STPathSet is already as small as it can be, then consider what the cost +// of increasing STVar::kMaxSize would be on all the other STVar types. Each of those types +// will carry the additional cost of accommodating the larger STPathSet in their SBO. +// 3. If the cost of increasing STVar::kMaxSize is too high, then heap allocate the STPathSet and +// remove this static_assert. +static_assert( + sizeof(STPathSet) <= detail::STVar::kMaxSize, + "STPathSet is too large to fit in STVar's small object optimization. Please verify if it " + "should, if the kMaxSize should be increased, or if STPathSet should be stored on the heap " + "instead of in STVar."); + +STPathSet::STPathSet(DeduplicationTag) : seen_{std::make_unique>()} { - return STPathElement::getHash(*this); +} + +STPathSet::STPathSet(STPathSet const& other) + : STBase{other} + , CountedObject{other} + , value_{other.value_} + , seen_{ + other.seen_ != nullptr ? std::make_unique>(*other.seen_) + : nullptr} +{ +} + +STPathSet& +STPathSet::operator=(STPathSet const& other) +{ + if (this == &other) + { + return *this; + } + auto newSeen = other.seen_ != nullptr + ? std::make_unique>(*other.seen_) + : nullptr; + STBase::operator=(other); + CountedObject::operator=(other); + value_ = other.value_; + seen_ = std::move(newSeen); + return *this; } STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) @@ -72,7 +120,8 @@ STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) Throw("empty path"); } - pushBack(path); + // Move rather than converting the vector to an STPath by copy. + value_.emplace_back(std::move(path)); path.clear(); if (iType == STPathElement::TypeNone) @@ -132,16 +181,10 @@ STPathSet::move(std::size_t n, void* buf) bool STPathSet::assembleAdd(STPath const& base, STPathElement const& tail) { // assemble base+tail and add it to the set if it's not a duplicate + XRPL_ASSERT(seen_ != nullptr, "xrpl::STPathSet::assembleAdd : DeduplicationTag"); STPath combined = base; combined.pushBack(tail); - - if (!seenHashes_.insert(combined).second) - { - return false; - } - - value_.push_back(std::move(combined)); - return true; + return appendUnique([&](auto& value) { value.push_back(std::move(combined)); }); } bool diff --git a/src/libxrpl/protocol/STValidation.cpp b/src/libxrpl/protocol/STValidation.cpp index 9fdb6e4cee..1fad610c83 100644 --- a/src/libxrpl/protocol/STValidation.cpp +++ b/src/libxrpl/protocol/STValidation.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include +#include #include namespace xrpl { @@ -108,11 +110,42 @@ STValidation::isValid() const noexcept publicKeyType(getSignerPublic()) == KeyType::Secp256k1, "xrpl::STValidation::isValid : valid key type"); - valid_ = verifyDigest( - getSignerPublic(), - getSigningHash(), - makeSlice(getFieldVL(sfSignature)), - (getFlags() & kVfFullyCanonicalSig) != 0u); + // Log that the signature was never checked, so an operator does not + // read this as a bad key. The log is guarded because it can throw too. + auto reportUncheckable = [this](char const* reason) noexcept { + try + { + JLOG(debugLog().error()) + << "Cannot check the signature of the validation for ledger " << getLedgerHash() + << ": " << reason; + } + catch (...) // NOLINT(bugprone-empty-catch) + { + // Nothing can be reported when reporting is what failed. + } + }; + + // The signing hash re-serializes the fields, which can fail. This + // function is noexcept, so report the validation as invalid instead of + // throwing. valid_ stays unset, so a later call checks again. + try + { + valid_ = verifyDigest( + getSignerPublic(), + getSigningHash(), + makeSlice(getFieldVL(sfSignature)), + (getFlags() & kVfFullyCanonicalSig) != 0u); + } + catch (std::exception const& e) + { + reportUncheckable(e.what()); + return false; + } + catch (...) + { + reportUncheckable("unknown exception"); + return false; + } } return valid_.value(); diff --git a/src/libxrpl/protocol/Serializer.cpp b/src/libxrpl/protocol/Serializer.cpp index 80ecdee6c8..7f6fe625c2 100644 --- a/src/libxrpl/protocol/Serializer.cpp +++ b/src/libxrpl/protocol/Serializer.cpp @@ -143,10 +143,10 @@ Serializer::addFieldID(int type, int name) } int -Serializer::add8(unsigned char byte) +Serializer::add8(unsigned char byteValue) { int const ret = data_.size(); - data_.push_back(byte); + data_.push_back(byteValue); return ret; } @@ -210,109 +210,138 @@ Serializer::addVL(void const* ptr, int len) int Serializer::addEncoded(int length) { - std::array bytes{}; + // Without this, a negative length would fall into the 1 byte case below and + // be cast to a first byte no header uses. A size too big for int arrives + // here negative as well, since callers pass sizes through this parameter. + if (length < kMinValueOfLengthFor1ByteHeader) + Throw("addEncoded: length is negative or did not fit in an int"); + + std::array bytes{}; int numBytes = 0; - if (length <= 192) + if (length <= kMaxValueOfLengthFor1ByteHeader) { - bytes[0] = static_cast(length); + bytes[0] = static_cast(length); numBytes = 1; } - else if (length <= 12480) + else if (length <= kMaxValueOfLengthFor2ByteHeader) { - length -= 193; - bytes[0] = 193 + static_cast(length >> 8); - bytes[1] = static_cast(length & 0xff); + // Count from the smallest length a 2 byte header covers. + int const offset = length - kMinValueOfLengthFor2ByteHeader; + bytes[0] = static_cast( + kMinValueOfFirstByteFor2ByteHeader + (offset / kNumberOfValuesInOneByte)); + bytes[1] = static_cast(offset % kNumberOfValuesInOneByte); numBytes = 2; } - else if (length <= 918744) + else if (length <= kMaxValueOfLengthFor3ByteHeader) { - length -= 12481; - bytes[0] = 241 + static_cast(length >> 16); - bytes[1] = static_cast((length >> 8) & 0xff); - bytes[2] = static_cast(length & 0xff); + int const offset = length - kMinValueOfLengthFor3ByteHeader; + bytes[0] = static_cast( + kMinValueOfFirstByteFor3ByteHeader + (offset / kNumberOfValuesInTwoBytes)); + bytes[1] = + static_cast((offset / kNumberOfValuesInOneByte) % kNumberOfValuesInOneByte); + bytes[2] = static_cast(offset % kNumberOfValuesInOneByte); numBytes = 3; } else { - Throw("lenlen"); + Throw("addEncoded: length is too large to encode"); } - return addRaw(&bytes[0], numBytes); + return addRaw(bytes.data(), numBytes); } int Serializer::encodeLengthLength(int length) { - if (length < 0) - Throw("len<0"); + if (length < kMinValueOfLengthFor1ByteHeader) + { + Throw( + "encodeLengthLength: length is negative or did not fit in an int"); + } - if (length <= 192) + if (length <= kMaxValueOfLengthFor1ByteHeader) return 1; - if (length <= 12480) + if (length <= kMaxValueOfLengthFor2ByteHeader) return 2; - if (length <= 918744) + if (length <= kMaxValueOfLengthFor3ByteHeader) return 3; - Throw("len>918744"); - return 0; // Silence compiler warning. + Throw("encodeLengthLength: length is too large to encode"); } int -Serializer::decodeLengthLength(int b1) +Serializer::decodeLengthLength(std::byte firstByte) { - if (b1 < 0) - Throw("b1<0"); + int const firstByteValue = std::to_integer(firstByte); - if (b1 <= 192) + if (firstByteValue <= kMaxValueOfFirstByteFor1ByteHeader) return 1; - if (b1 <= 240) + if (firstByteValue <= kMaxValueOfFirstByteFor2ByteHeader) return 2; - if (b1 <= 254) + if (firstByteValue <= kMaxValueOfFirstByteFor3ByteHeader) return 3; - Throw("b1>254"); - return 0; // Silence compiler warning. + Throw("decodeLengthLength: first byte does not start any header"); } int -Serializer::decodeVLLength(int b1) +Serializer::decodeVLLength(std::byte firstByte) { - if (b1 < 0) - Throw("b1<0"); + int const length = std::to_integer(firstByte); - if (b1 > 254) - Throw("b1>254"); + // A bigger value means a longer header, so it is not a length by itself. + if (length > kMaxValueOfLengthFor1ByteHeader) + Throw("decodeVLLength 1 byte: first byte is not a length"); - return b1; + return length; } int -Serializer::decodeVLLength(int b1, int b2) +Serializer::decodeVLLength(std::byte firstByte, std::byte secondByte) { - if (b1 < 193) - Throw("b1<193"); + int const firstByteValue = std::to_integer(firstByte); - if (b1 > 240) - Throw("b1>240"); + if (firstByteValue < kMinValueOfFirstByteFor2ByteHeader) + Throw("decodeVLLength 2 byte: first byte is below the range"); - return 193 + ((b1 - 193) * 256) + b2; + if (firstByteValue > kMaxValueOfFirstByteFor2ByteHeader) + Throw("decodeVLLength 2 byte: first byte is above the range"); + + // Both bytes are bounded by their own type, and the first one is bounded to + // the 2 byte range above, so this cannot leave the range the header covers. + return kMinValueOfLengthFor2ByteHeader + + ((firstByteValue - kMinValueOfFirstByteFor2ByteHeader) * kNumberOfValuesInOneByte) + + std::to_integer(secondByte); } int -Serializer::decodeVLLength(int b1, int b2, int b3) +Serializer::decodeVLLength(std::byte firstByte, std::byte secondByte, std::byte thirdByte) { - if (b1 < 241) - Throw("b1<241"); + int const firstByteValue = std::to_integer(firstByte); - if (b1 > 254) - Throw("b1>254"); + if (firstByteValue < kMinValueOfFirstByteFor3ByteHeader) + Throw("decodeVLLength 3 byte: first byte is below the range"); - return 12481 + ((b1 - 241) * 65536) + (b2 * 256) + b3; + if (firstByteValue > kMaxValueOfFirstByteFor3ByteHeader) + Throw("decodeVLLength 3 byte: first byte is above the range"); + + int const length = kMinValueOfLengthFor3ByteHeader + + ((firstByteValue - kMinValueOfFirstByteFor3ByteHeader) * kNumberOfValuesInTwoBytes) + + (std::to_integer(secondByte) * kNumberOfValuesInOneByte) + + std::to_integer(thirdByte); + + // A 3 byte header reaches further than kMaxValueOfLengthFor3ByteHeader, which + // is as far as the encoder goes. Refuse the rest, so every length accepted + // here is one that can be written back. + if (length > kMaxValueOfLengthFor3ByteHeader) + Throw("decodeVLLength 3 byte: length is too large to re-encode"); + + return length; } //------------------------------------------------------------------------------ @@ -471,24 +500,24 @@ SerialIter::getRaw(int size) int SerialIter::getVLDataLength() { - int const b1 = get8(); + std::byte const firstByte{get8()}; int datLen = 0; - int const lenLen = Serializer::decodeLengthLength(b1); + int const lenLen = Serializer::decodeLengthLength(firstByte); if (lenLen == 1) { - datLen = Serializer::decodeVLLength(b1); + datLen = Serializer::decodeVLLength(firstByte); } else if (lenLen == 2) { - int const b2 = get8(); - datLen = Serializer::decodeVLLength(b1, b2); + std::byte const secondByte{get8()}; + datLen = Serializer::decodeVLLength(firstByte, secondByte); } else { XRPL_ASSERT(lenLen == 3, "xrpl::SerialIter::getVLDataLength : lenLen is 3"); - int const b2 = get8(); - int const b3 = get8(); - datLen = Serializer::decodeVLLength(b1, b2, b3); + std::byte const secondByte{get8()}; + std::byte const thirdByte{get8()}; + datLen = Serializer::decodeVLLength(firstByte, secondByte, thirdByte); } return datLen; } diff --git a/src/libxrpl/shamap/SHAMap.cpp b/src/libxrpl/shamap/SHAMap.cpp index 3fa8d66be0..0e28c0222a 100644 --- a/src/libxrpl/shamap/SHAMap.cpp +++ b/src/libxrpl/shamap/SHAMap.cpp @@ -97,7 +97,7 @@ SHAMap::snapShot(bool isMutable) const } void -SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNodePtr child) +SHAMap::dirtyUp(NodePathStack& stack, uint256 const& target, SHAMapTreeNodePtr child) { // walk the tree up from through the inner nodes to the root_ // update hashes and links @@ -126,29 +126,34 @@ SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNode } SHAMapLeafNode* -SHAMap::walkTowardsKey(uint256 const& id, SharedPtrNodeStack* stack) const +SHAMap::walkTowardsKey(uint256 const& id, NodePathStack* stack) const { XRPL_ASSERT( stack == nullptr || stack->empty(), "xrpl::SHAMap::walkTowardsKey : empty stack input"); auto inNode = root_; SHAMapNodeID nodeID; + // Every node on this walk lies on the path to `id`, so the stack can derive each ID from the + // branch `id` selects at the node above it. + auto pushCurrent = [&] { + if (stack != nullptr) + stack->pushNode(inNode, id); + }; + while (inNode->isInner()) { - if (stack != nullptr) - stack->emplace(inNode, nodeID); + pushCurrent(); - auto const inner = intr_ptr::staticPointerCast(inNode); + auto& inner = safeDowncast(*inNode); auto const branch = selectBranch(nodeID, id); - if (inner->isEmptyBranch(branch)) + if (inner.isEmptyBranch(branch)) return nullptr; - inNode = descendThrow(*inner, branch); + inNode = descendThrow(inner, branch); nodeID = nodeID.getChildNodeID(branch); } - if (stack != nullptr) - stack->emplace(inNode, nodeID); + pushCurrent(); return safeDowncast(inNode.get()); } @@ -428,65 +433,40 @@ SHAMap::unshareNode(intr_ptr::SharedPtr node, SHAMapNodeID const& nodeID) } SHAMapLeafNode* -SHAMap::belowHelper( - SHAMapTreeNodePtr node, - SharedPtrNodeStack& stack, - unsigned int branch, - BelowDirection direction) const +SHAMap::belowHelper(NodePathStack& stack, BelowDirection direction) const { - if (node->isLeaf()) - { - auto n = intr_ptr::staticPointerCast(node); - stack.push({node, {kLeafDepth, n->peekItem()->key()}}); - return n.get(); - } - auto inner = intr_ptr::staticPointerCast(node); - if (stack.empty()) - { - stack.emplace(inner, SHAMapNodeID{}); - } - else - { - stack.emplace(inner, stack.top().second.getChildNodeID(branch)); - } - // `scanned` counts how many branches of `inner` we have examined; the branch we look at is - // derived from it, so no index ever goes out of range. + XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::belowHelper : non-empty stack input"); + if (auto const& top = stack.top().first; top->isLeaf()) + return safeDowncast(top.get()); + + // The stack owns the node/ID pairing, so descending is only ever "push the branch we took". + // `scanned` counts how many branches of the current node we have examined; the branch we look + // at is derived from it, so no index ever goes out of range. `inner` tracks the node on top of + // the stack, which keeps it alive, so it only needs recomputing after a push. + auto* inner = safeDowncast(stack.top().first.get()); for (auto scanned = 0u; scanned < kBranchFactor;) { auto const childBranch = (direction == BelowDirection::Last) ? (kBranchFactor - 1u - scanned) : scanned; - if (!inner->isEmptyBranch(childBranch)) - { - node.adopt(descendThrow(inner.get(), childBranch)); - XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::belowHelper : non-empty stack"); - if (node->isLeaf()) - { - auto n = intr_ptr::staticPointerCast(node); - stack.push({n, {kLeafDepth, n->peekItem()->key()}}); - return n.get(); - } - inner = intr_ptr::staticPointerCast(node); - stack.emplace(inner, stack.top().second.getChildNodeID(branch)); - scanned = 0u; // descend and restart the scan on the new node - } - else + if (inner->isEmptyBranch(childBranch)) { ++scanned; // scan next branch + continue; } + + stack.pushChild(descendThrow(*inner, childBranch), childBranch); + + auto const& child = stack.top().first; + if (child->isLeaf()) + return safeDowncast(child.get()); + + inner = safeDowncast(child.get()); + scanned = 0u; // descend and restart the scan on the new node } return nullptr; } -SHAMapLeafNode* -SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const -{ - return belowHelper(node, stack, branch, BelowDirection::Last); -} -SHAMapLeafNode* -SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const -{ - return belowHelper(node, stack, branch, BelowDirection::First); -} + static boost::intrusive_ptr const kNoItem; boost::intrusive_ptr const& @@ -529,36 +509,36 @@ SHAMap::onlyBelow(SHAMapTreeNode* node) const } SHAMapLeafNode const* -SHAMap::peekFirstItem(SharedPtrNodeStack& stack) const +SHAMap::peekFirstItem(NodePathStack& stack) const { XRPL_ASSERT(stack.empty(), "xrpl::SHAMap::peekFirstItem : empty stack input"); - SHAMapLeafNode const* node = firstBelow(root_, stack); + stack.pushRoot(root_); + SHAMapLeafNode const* node = belowHelper(stack, BelowDirection::First); if (node == nullptr) { - while (!stack.empty()) - stack.pop(); + stack.clear(); return nullptr; } return node; } SHAMapLeafNode const* -SHAMap::peekNextItem(uint256 const& id, SharedPtrNodeStack& stack) const +SHAMap::peekNextItem(uint256 const& id, NodePathStack& stack) const { XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::peekNextItem : non-empty stack input"); XRPL_ASSERT(stack.top().first->isLeaf(), "xrpl::SHAMap::peekNextItem : stack starts with leaf"); stack.pop(); while (!stack.empty()) { - auto [node, nodeID] = stack.top(); + auto const [node, nodeID] = stack.top(); XRPL_ASSERT(!node->isLeaf(), "xrpl::SHAMap::peekNextItem : another node is not leaf"); - auto inner = intr_ptr::staticPointerCast(node); + auto& inner = safeDowncast(*node); for (auto i = selectBranch(nodeID, id) + 1; i < kBranchFactor; ++i) { - if (!inner->isEmptyBranch(i)) + if (!inner.isEmptyBranch(i)) { - node = descendThrow(*inner, i); - auto leaf = firstBelow(node, stack, i); + stack.pushChild(descendThrow(inner, i), i); + auto leaf = belowHelper(stack, BelowDirection::First); if (leaf == nullptr) Throw(type_, id); XRPL_ASSERT(leaf->isLeaf(), "xrpl::SHAMap::peekNextItem : leaf is valid"); @@ -597,11 +577,11 @@ SHAMap::peekItem(uint256 const& id, SHAMapHash& hash) const SHAMap::ConstIterator SHAMap::upperBound(uint256 const& id) const { - SharedPtrNodeStack stack; + NodePathStack stack; walkTowardsKey(id, &stack); while (!stack.empty()) { - auto [node, nodeID] = stack.top(); + auto const [node, nodeID] = stack.top(); if (node->isLeaf()) { auto leaf = safeDowncast(node.get()); @@ -610,13 +590,13 @@ SHAMap::upperBound(uint256 const& id) const } else { - auto inner = intr_ptr::staticPointerCast(node); + auto& inner = safeDowncast(*node); for (auto branch = selectBranch(nodeID, id) + 1; branch < kBranchFactor; ++branch) { - if (!inner->isEmptyBranch(branch)) + if (!inner.isEmptyBranch(branch)) { - node = descendThrow(*inner, branch); - auto leaf = firstBelow(node, stack, branch); + stack.pushChild(descendThrow(inner, branch), branch); + auto leaf = belowHelper(stack, BelowDirection::First); if (leaf == nullptr) Throw(type_, id); return ConstIterator(this, leaf->peekItem().get(), std::move(stack)); @@ -630,11 +610,11 @@ SHAMap::upperBound(uint256 const& id) const SHAMap::ConstIterator SHAMap::lowerBound(uint256 const& id) const { - SharedPtrNodeStack stack; + NodePathStack stack; walkTowardsKey(id, &stack); while (!stack.empty()) { - auto [node, nodeID] = stack.top(); + auto const [node, nodeID] = stack.top(); if (node->isLeaf()) { auto leaf = safeDowncast(node.get()); @@ -643,14 +623,14 @@ SHAMap::lowerBound(uint256 const& id) const } else { - auto inner = intr_ptr::staticPointerCast(node); + auto& inner = safeDowncast(*node); for (auto branch = selectBranch(nodeID, id); branch > 0u;) { --branch; - if (!inner->isEmptyBranch(branch)) + if (!inner.isEmptyBranch(branch)) { - node = descendThrow(*inner, branch); - auto leaf = lastBelow(node, stack, branch); + stack.pushChild(descendThrow(inner, branch), branch); + auto leaf = belowHelper(stack, BelowDirection::Last); if (leaf == nullptr) Throw(type_, id); return ConstIterator(this, leaf->peekItem().get(), std::move(stack)); @@ -675,7 +655,7 @@ SHAMap::delItem(uint256 const& id) // delete the item with this ID XRPL_ASSERT(state_ != SHAMapState::Immutable, "xrpl::SHAMap::delItem : not immutable"); - SharedPtrNodeStack stack; + NodePathStack stack; walkTowardsKey(id, &stack); if (stack.empty()) @@ -761,7 +741,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr // add the specified item, does not update uint256 const tag = item->key(); - SharedPtrNodeStack stack; + NodePathStack stack; walkTowardsKey(tag, &stack); if (stack.empty()) @@ -801,7 +781,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr while ((b1 = selectBranch(nodeID, tag)) == (b2 = selectBranch(nodeID, otherItem->key()))) { - stack.emplace(node, nodeID); + stack.pushNode(node, tag); // we need a new inner node, since both go on same branch at this // level @@ -848,7 +828,7 @@ SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptrisLeaf(), "xrpl::SHAMap::invariants : root node is not leaf"); - SharedPtrNodeStack stack; + NodePathStack stack; for (auto leaf = peekFirstItem(stack); leaf != nullptr; leaf = peekNextItem(leaf->peekItem()->key(), stack)) ; diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp index 4319d0bcd4..602d8e629c 100644 --- a/src/libxrpl/shamap/SHAMapSync.cpp +++ b/src/libxrpl/shamap/SHAMapSync.cpp @@ -793,7 +793,7 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const std::optional> SHAMap::getProofPath(uint256 const& key) const { - SharedPtrNodeStack stack; + NodePathStack stack; walkTowardsKey(key, &stack); if (stack.empty()) diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp index 50f46fceef..96dcd5f587 100644 --- a/src/libxrpl/tx/ApplyContext.cpp +++ b/src/libxrpl/tx/ApplyContext.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -54,6 +56,13 @@ ApplyContext::apply(TER ter) return view_->apply(base_, tx, ter, parentBatchId_, (flags_ & TapDryRun) != 0u, journal); } +void +ApplyContext::addOrderBook(Book const& book) +{ + if ((flags_ & TapDryRun) == TapNone) + registry.get().getOrderBookDB().addOrderBook(book); +} + std::size_t ApplyContext::size() { diff --git a/src/libxrpl/tx/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp index fb237861d7..9a1636eff1 100644 --- a/src/libxrpl/tx/applySteps.cpp +++ b/src/libxrpl/tx/applySteps.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -195,7 +196,12 @@ invokePreclaim(PreclaimContext const& ctx) }()) return preSigResult; - if (TER const result = T::checkFee(ctx, calculateBaseFee(ctx.view, ctx.tx))) + // We can't check the fee if we can't compute it, so reject. + auto const baseFee = calculateBaseFee(ctx.view, ctx.tx); + if (!baseFee) + return baseFee.error(); + + if (TER const result = T::checkFee(ctx, *baseFee)) return result; } @@ -223,13 +229,12 @@ invokePreclaim(PreclaimContext const& ctx) * * @param view The ledger view to use for fee calculation. * @param tx The transaction for which the base fee is to be calculated. - * @return The calculated base fee as an XRPAmount. + * @return The calculated base fee. Returns `std::unexpected(temUNKNOWN)` if the transaction + * type is not recognized, and `std::unexpected(tefEXCEPTION)` if the transactor's + * `calculateBaseFee` threw. * - * @throws std::exception If an error occurs during fee calculation, including - * but not limited to unknown transaction types or internal errors, the function - * logs an error and returns an XRPAmount of zero. */ -static XRPAmount +static std::expected invokeCalculateBaseFee(ReadView const& view, STTx const& tx) { try @@ -238,13 +243,25 @@ invokeCalculateBaseFee(ReadView const& view, STTx const& tx) return T::calculateBaseFee(view, tx); }); } - catch (UnknownTxnType const& e) + catch (UnknownTxnType const&) { // LCOV_EXCL_START UNREACHABLE("xrpl::invoke_calculateBaseFee : unknown transaction type"); - return XRPAmount{0}; + return std::unexpected(temUNKNOWN); // LCOV_EXCL_STOP } + catch (std::exception const& e) + { + JLOG(debugLog().error()) << "calculateBaseFee: " << tx.getTransactionID() + << " threw an exception: " << e.what(); + return std::unexpected(tefEXCEPTION); + } + catch (...) + { + JLOG(debugLog().error()) << "calculateBaseFee: " << tx.getTransactionID() + << " threw an unknown exception"; + return std::unexpected(tefEXCEPTION); + } } TxConsequences::TxConsequences(NotTEC pfResult) @@ -460,7 +477,7 @@ preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, Open } } -XRPAmount +std::expected calculateBaseFee(ReadView const& view, STTx const& tx) { return invokeCalculateBaseFee(view, tx); @@ -485,13 +502,26 @@ doApply(PreclaimResult const& preclaimResult, ServiceRegistry& registry, OpenVie { if (!preclaimResult.likelyToClaimFee) return {preclaimResult.ter, false}; + + // For any tx with a real account, preclaim already computed this fee + // successfully against this same view. + auto const baseFee = calculateBaseFee(view, preclaimResult.tx); + if (!baseFee) + { + // LCOV_EXCL_START + JLOG(preclaimResult.j.error()) + << "apply: could not compute base fee: " << transToken(baseFee.error()); + return {tefINTERNAL, false}; + // LCOV_EXCL_STOP + } + ApplyContext ctx( registry, view, preclaimResult.parentBatchId, preclaimResult.tx, preclaimResult.ter, - calculateBaseFee(view, preclaimResult.tx), + *baseFee, preclaimResult.flags, preclaimResult.j); return invokeApply(ctx); diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index b34d7088be..f87a7620af 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -231,17 +231,38 @@ ValidLoan::finalize( // must show that payment in its balance and schedule. A payment that clears // the loan outright instead drives PaymentRemaining to zero, which the // fully-paid-off and zero due-date checks above pin. + // + // PrincipalOutstanding may stay put on a non-final pay: at integer + // scale, fixCleanup3_2_0 rounds principal up so a fractional + // amortization step does not reduce it. Interest (TVO) still falls. + // Neither balance may grow: a payment never adds to what is owed, + // since late-payment penalties are charged in the same transaction + // rather than tracked in TotalValueOutstanding. if (isTesSuccess(result) && txType == ttLOAN_PAY) { if (before && after->at(sfPaymentRemaining) != 0) { - if (!(after->at(sfPrincipalOutstanding) < before->at(sfPrincipalOutstanding))) + if (after->at(sfPrincipalOutstanding) > before->at(sfPrincipalOutstanding)) { - JLOG(j.fatal()) << "Invariant failed: loan pay must strictly decrease " + JLOG(j.fatal()) << "Invariant failed: loan pay must not increase " "PrincipalOutstanding on a non-full-repayment"; return false; } - if (!(after->at(sfPaymentRemaining) < before->at(sfPaymentRemaining))) + if (after->at(sfTotalValueOutstanding) > before->at(sfTotalValueOutstanding)) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must not increase " + "TotalValueOutstanding on a non-full-repayment"; + return false; + } + if (after->at(sfPrincipalOutstanding) == before->at(sfPrincipalOutstanding) && + after->at(sfTotalValueOutstanding) == before->at(sfTotalValueOutstanding)) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must decrease " + "PrincipalOutstanding or TotalValueOutstanding " + "on a non-full-repayment"; + return false; + } + if (after->at(sfPaymentRemaining) >= before->at(sfPaymentRemaining)) { JLOG(j.fatal()) << "Invariant failed: loan pay must decrease " "PaymentRemaining on a non-full-repayment"; diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index e38e8f2b93..060673c6e1 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -40,6 +40,7 @@ constexpr auto kConfidentialMptTxTypes = std::to_array({ ttCONFIDENTIAL_MPT_CONVERT_BACK, ttCONFIDENTIAL_MPT_MERGE_INBOX, ttCONFIDENTIAL_MPT_CLAWBACK, + ttCONFIDENTIAL_MPT_MIRROR_UPDATE, }); // Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant @@ -299,27 +300,46 @@ ValidMPTIssuance::finalize( return false; } } - else if (lendingProtocolEnabled && (mptokensCreated_ + mptokensDeleted_) > 1) + else { - JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " - "but created/deleted bad number mptokens"; - return false; - } - else if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0)) - { - JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer " - "succeeded but created/deleted mptokens"; - return false; - } - else if ( - !submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) && - (mptokensCreated_ + mptokensDeleted_ != 1)) - { - // if the holder submitted this tx, then a mptoken must be - // either created or deleted. - JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder " - "succeeded but created/deleted bad number of mptokens"; - return false; + // Cap on MPToken creates and deletes while featureLendingProtocol is enabled. + // - LoanSet: at most two creates and no deletes. + // - VaultWithdraw: at most one create and one delete. + // - Other MayAuthorizeMpt types: created + deleted <= 1. + // - MustAuthorizeMpt still requires exactly one create or delete below. + auto const mptokensExceedAuthorizeCap = [&] { + if (!lendingProtocolEnabled) + return false; + if (rules.enabled(fixCleanup3_4_0)) + { + if (txnType == ttLOAN_SET) + return mptokensDeleted_ != 0 || mptokensCreated_ > 2; + if (txnType == ttVAULT_WITHDRAW) + return mptokensCreated_ > 1 || mptokensDeleted_ > 1; + } + return (mptokensCreated_ + mptokensDeleted_) > 1; + }; + if (mptokensExceedAuthorizeCap()) + { + JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " + "but created/deleted bad number mptokens"; + return false; + } + if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0)) + { + JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer " + "succeeded but created/deleted mptokens"; + return false; + } + if (!submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) && + (mptokensCreated_ + mptokensDeleted_ != 1)) + { + // if the holder submitted this tx, then a mptoken must be + // either created or deleted. + JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder " + "succeeded but created/deleted bad number of mptokens"; + return false; + } } return true; diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp index 857f759752..f753f604f0 100644 --- a/src/libxrpl/tx/transactors/check/CheckCash.cpp +++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp @@ -439,6 +439,12 @@ CheckCash::doApply() AccountID const& deliverIssuer = flowDeliver.getIssuer(); auto const err = flowDeliver.asset().visit( [&](Issue const& issue) -> std::optional { + // An issuer needs no holder-limit waiver to receive its own currency. + if (deliverIssuer == accountID_ && ctx_.view().rules().enabled(fixCleanup3_4_0)) + { + return std::nullopt; + } + // If a trust line does not exist yet create one. Issue const& trustLineIssue = issue; AccountID const truster = deliverIssuer == accountID_ ? srcId : accountID_; diff --git a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp index 7c7d35497a..2bc9aa5ba1 100644 --- a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp @@ -2,8 +2,6 @@ #include #include -#include -#include #include #include #include @@ -397,7 +395,7 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou Book const book{assetIn, assetOut, std::nullopt}; auto const dir = keylet::quality(keylet::book(book), uRate); if (auto const bookExisted = static_cast(sb.read(dir)); !bookExisted) - ctx.registry.get().getOrderBookDB().addOrderBook(book); + ctx.addOrderBook(book); }; addOrderBook(amount.asset(), amount2.asset(), getRate(amount2, amount)); addOrderBook(amount2.asset(), amount.asset(), getRate(amount, amount2)); diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp index 57ba6eff0d..6c3c04f1a0 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -634,7 +633,7 @@ OfferCreate::applyHybrid( bookArr.pushBack(std::move(bookInfo)); if (!bookExists) - ctx_.registry.get().getOrderBookDB().addOrderBook(book); + ctx_.addOrderBook(book); sleOffer->setFieldArray(sfAdditionalBooks, bookArr); return tesSUCCESS; @@ -1014,7 +1013,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) sb.insert(sleOffer); if (!bookExisted) - ctx_.registry.get().getOrderBookDB().addOrderBook(book); + ctx_.addOrderBook(book); JLOG(j_.debug()) << "final result: success"; diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 18886b2682..624c4d0a84 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -36,6 +36,15 @@ namespace xrpl { namespace { +// Returns true if the transaction's payment amount is malformed. A loan +// payment must be strictly positive: zero would move nothing, and a negative +// amount is not a payment at all. +bool +isPaymentAmountInvalid(STAmount const& amount) +{ + return amount <= beast::kZero; +} + // Returns the account's true, unclamped balance in `asset`, for use only in // fund-conservation checks. accountHolds(..., SpendableHandling::FullBalance) // cannot be used for this: for XRP it always defers to xrpLiquid, which @@ -81,7 +90,7 @@ LoanPay::preflight(PreflightContext const& ctx) if (ctx.tx[sfLoanID] == beast::kZero) return temINVALID; - if (ctx.tx[sfAmount] <= beast::kZero) + if (isPaymentAmountInvalid(ctx.tx[sfAmount])) return temBAD_AMOUNT; // The loan payment flags are all mutually exclusive. If more than one is @@ -103,10 +112,19 @@ LoanPay::preflight(PreflightContext const& ctx) XRPAmount LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) { + auto fixEnabled313 = view.rules().enabled(fixCleanup3_1_3); + auto fixEnabled340 = view.rules().enabled(fixCleanup3_4_0); + using namespace lending; auto const normalCost = Transactor::calculateBaseFee(view, tx); + if (fixEnabled340 && isPaymentAmountInvalid(tx[sfAmount])) + { + // Let preflight worry about the error for this + return normalCost; + } + if (tx.isFlag(tfLoanFullPayment) || tx.isFlag(tfLoanLatePayment)) { // The loan will be making one set of calculations for one full or late @@ -179,8 +197,7 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) static constexpr std::int64_t kMaxFeeIncrements = kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; - if (view.rules().enabled(fixCleanup3_1_3) && - amount >= regularPayment * kLoanMaximumPaymentsPerTransaction) + if (fixEnabled313 && amount >= regularPayment * kLoanMaximumPaymentsPerTransaction) { // The payment handler will never process more than // loanMaximumPaymentsPerTransaction payments (including overpayments), diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 1b2113e1fe..85de6332d9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -347,10 +347,10 @@ LoanSet::preclaim(PreclaimContext const& ctx) } } - // Accrual origination credits interestDue into AssetsTotal, so a vault + // Instant interest recognition credits interestDue into AssetsTotal, so a vault // already at AssetsMaximum cannot take another loan. Cash-basis origination // does not change AssetsTotal (see cash_basis::loanOriginationDeltas), so - // this leftover accrual gate must not apply there. + // this leftover instant-recognition gate must not apply there. if (getVaultVersion(vault) != VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { @@ -503,7 +503,7 @@ LoanSet::doApply() getVaultVersion(vaultSle) == VaultVersion::CashBasis || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, "xrpl::LoanSet::doApply", - "accrual vault is below maximum limit"); + "instant-recognition vault is below maximum limit"); if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) { diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp index c4c2f9227b..d2da173345 100644 --- a/src/libxrpl/tx/transactors/payment/Payment.cpp +++ b/src/libxrpl/tx/transactors/payment/Payment.cpp @@ -339,17 +339,36 @@ Payment::checkGranularSemantics( bool const accountIsHolder = accountIsLow ? rawBalance > beast::kZero : rawBalance < beast::kZero; + bool const mayIssue = + heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero; + // PaymentMint requires the destination to be the holder and the account to be the // issuer. destLimit > 0: destination is willing to hold account's IOUs (account is the // issuer). !accountIsHolder: DirectStepI will issue, not redeem. - if (heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero && - !accountIsHolder) + if (mayIssue && !accountIsHolder) return tesSUCCESS; // PaymentBurn requires the source account to be the holder and the destination to be // the issuer. accountIsHolder: DirectStepI will redeem, not issue. if (heldGranularPermissions.contains(PaymentBurn) && accountIsHolder) - return tesSUCCESS; + { + if (view.rules().enabled(fixCleanup3_4_0)) + { + // Redeeming stops at the balance held; beyond that the payment engine + // crosses zero and issues the account's own IOUs, which is a mint. So with + // only PaymentBurn we must check the amount against the balance held. The + // granular template forbids sfPaths, tfPartialPayment and a cross-asset + // sfSendMax, so this is a single direct step, sfAmount is what the + // trustline is debited. + STAmount const held = accountIsLow ? rawBalance : -rawBalance; + if (dstAmount <= held || mayIssue) + return tesSUCCESS; + } + else + { + return tesSUCCESS; + } + } return terNO_DELEGATE_PERMISSION; }); diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp index c463e4f305..46c6ad4800 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -210,6 +211,13 @@ SponsorshipTransfer::preflight(PreflightContext const& ctx) return temMALFORMED; } + if (auto const objectID = ctx.tx[~sfObjectID]; + ctx.rules.enabled(fixCleanup3_5_0) && objectID && *objectID == beast::kZero) + { + JLOG(ctx.j.debug()) << "preflight: sfObjectID must not be zero"; + return temMALFORMED; + } + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp index b17d835ff0..e14611917e 100644 --- a/src/libxrpl/tx/transactors/system/Batch.cpp +++ b/src/libxrpl/tx/transactors/system/Batch.cpp @@ -73,14 +73,23 @@ Batch::calculateBaseFeeImpl(ReadView const& view, STTx const& tx) for (auto const& stx : tx.getBatchTransactions()) { auto const fee = xrpl::calculateBaseFee(view, *stx); - // LCOV_EXCL_START - if (txnFees > maxAmount - fee) + if (!fee) { + JLOG(debugLog().error()) + << "BatchTrace: base fee of inner transaction " << stx->getTransactionID() + << " could not be computed: " << transToken(fee.error()); + return std::nullopt; + } + + // LCOV_EXCL_START + if (txnFees > maxAmount - *fee) + { + UNREACHABLE("XRPAmount overflow in txnFees calculation"); JLOG(debugLog().error()) << "BatchTrace: XRPAmount overflow in txnFees calculation."; return std::nullopt; } // LCOV_EXCL_STOP - txnFees += fee; + txnFees += *fee; } // Calculate the Signers/BatchSigners Fees diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp index 19ec99702a..b3bd276e5f 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -209,6 +210,12 @@ ConfidentialMPTClawback::doApply() (*sleHolderMPToken)[sfAuditorEncryptedBalance] = std::move(*encZeroForAuditor); } + // Allow clawback on stale mirrors since the issuer can still generate the + // proof using the corresponding stale private key. The mirrors are updated + // to the current epoch during execution. + if (view().rules().enabled(featureConfidentialMPTKeyRotation)) + setMirrorEpochs(*sleIssuance, *sleHolderMPToken); + // Decrease Global Confidential Outstanding Amount auto const oldCOA = (*sleIssuance)[sfConfidentialOutstandingAmount]; if (clawAmount > oldCOA) diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp index 5be3892151..0855f3230d 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -111,6 +112,17 @@ ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx) if (!sleMptoken) return tecOBJECT_NOT_FOUND; + // An already-initialized holder has their new ciphertexts homomorphically + // added to their existing mirrors, so those mirrors must be encrypted under + // the currently registered keys. A first-time convert creates the mirrors + // under those keys instead, and has nothing to be stale. + if (ctx.view.rules().enabled(featureConfidentialMPTKeyRotation) && + sleMptoken->isFieldPresent(sfIssuerEncryptedBalance) && + !areMirrorsCurrent(*sleIssuance, *sleMptoken)) + { + return tecNO_PERMISSION; + } + auto const mptIssue = MPTIssue{issuanceID}; // Explicit freeze and auth checks are required because accountHolds @@ -331,6 +343,10 @@ ConfidentialMPTConvert::doApply() if (auditorEc) (*sleMptoken)[sfAuditorEncryptedBalance] = *auditorEc; + // Initialize key epochs when registering the keys. + if (view().rules().enabled(featureConfidentialMPTKeyRotation)) + setMirrorEpochs(*sleIssuance, *sleMptoken); + // Spending balance starts at zero. Must use canonical zero encryption // (deterministic ciphertext) so the ledger state is reproducible. auto zeroBalance = encryptCanonicalZeroAmount( diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp index 1e3617ffbd..0c9b5d3345 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -197,6 +198,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } + // Converting back homomorphically subtracts from the holder's mirrors, so + // those mirrors must be current. + if (ctx.view.rules().enabled(featureConfidentialMPTKeyRotation) && + !areMirrorsCurrent(*sleIssuance, *sleMptoken)) + { + return tecNO_PERMISSION; + } + // Sanity check: holder's MPToken must have auditor balance field if auditing // is enabled if (requiresAuditor && !sleMptoken->isFieldPresent(sfAuditorEncryptedBalance)) diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp new file mode 100644 index 0000000000..c00486e5e6 --- /dev/null +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMirrorUpdate.cpp @@ -0,0 +1,273 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +bool +ConfidentialMPTMirrorUpdate::checkExtraFeatures(PreflightContext const& ctx) +{ + // Key rotation makes sense only when featureConfidentialTransfer is enabled. + return ctx.rules.enabled(featureConfidentialTransfer); +} + +NotTEC +ConfidentialMPTMirrorUpdate::preflight(PreflightContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + auto const issuer = MPTIssue(ctx.tx[sfMPTokenIssuanceID]).getIssuer(); + auto const holder = ctx.tx[~sfHolder]; + bool const hasHolder = holder.has_value(); + + // The rotation mode is determined by the presence of the + // Holder field: Holder present is issuer mode, Holder absent is + // holder self-migration. + if (hasHolder) + { + // Issuer mode: account must be the issuer + if (account != issuer) + return temMALFORMED; + + if (account == *holder) + return temMALFORMED; + } + else + { + // Holder self-migration: the submitter is the holder, account must not be the issuer. + if (account == issuer) + return temMALFORMED; + } + + // At least one ciphertext will be updated. + bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount); + bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + if (!hasIssuerAmount && !hasAuditorAmount) + return temMALFORMED; + + // Check the length of the encrypted amounts. Length check is cheaper than format check so put + // it before the format check. + if (hasIssuerAmount && ctx.tx[sfIssuerEncryptedAmount].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + if (hasAuditorAmount && + ctx.tx[sfAuditorEncryptedAmount].length() != kEcGamalEncryptedTotalLength) + return temBAD_CIPHERTEXT; + + // Check proof length. + if (ctx.tx[sfZKProof].length() != kEcEqualityProofLength) + return temMALFORMED; + + // Check the encrypted amount formats. It is more expensive so put it at the end of preflight. + if (hasIssuerAmount && !isValidCiphertext(ctx.tx[sfIssuerEncryptedAmount])) + return temBAD_CIPHERTEXT; + + if (hasAuditorAmount && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount])) + return temBAD_CIPHERTEXT; + + return tesSUCCESS; +} + +XRPAmount +ConfidentialMPTMirrorUpdate::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier); +} + +TER +ConfidentialMPTMirrorUpdate::preclaim(PreclaimContext const& ctx) +{ + // Check if account exists + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; // LCOV_EXCL_LINE + + // The issuance must exist and have confidential balances enabled with a + // registered issuer encryption key; otherwise there is no mirror to update. + auto const mptIssuanceID = ctx.tx[sfMPTokenIssuanceID]; + auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + // The issuance must have confidential balances enabled with a registered issuer encryption key. + if (!sleIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) || + !sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) + return tecNO_PERMISSION; + + // Sanity check: preflight already enforced the issuer holder combination + // under different rotation modes. + auto const holder = ctx.tx[~sfHolder]; + bool const hasHolder = holder.has_value(); + auto const issuer = sleIssuance->getAccountID(sfIssuer); + if (hasHolder ? (issuer != account) : (issuer == account)) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : invalid issuer holder combination"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // The holder is sfHolder in issuer mode and is sfAccount in holder mode. + auto const holderID = hasHolder ? *holder : account; + + // In issuer mode, the holder must exist. In holder mode, the account existence was checked + // already. + if (hasHolder && !ctx.view.exists(keylet::account(holderID))) + return tecNO_TARGET; + + // In either issuer or holder mode, check the existence of the MPToken object. + auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, holderID)); + if (!sleMptoken) + return tecOBJECT_NOT_FOUND; + + // The holder must already hold an issuer confidential balance. + if (!sleMptoken->isFieldPresent(sfIssuerEncryptedBalance)) + return tecNO_PERMISSION; + + bool const hasIssuerAmount = ctx.tx.isFieldPresent(sfIssuerEncryptedAmount); + bool const hasAuditorAmount = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); + + // Migrating the auditor mirror requires the issuance to have a registered + // auditor encryption key. + if (hasAuditorAmount && !sleIssuance->isFieldPresent(sfAuditorEncryptionKey)) + return tecNO_PERMISSION; + + // An issuer mirror may only be re-encrypted while it is stale, reject if it is already current. + if (hasIssuerAmount && isIssuerMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + + if (hasAuditorAmount) + { + // An issuer-mode auditor-only migration: the issuer mirror must already be up to date. + if (hasHolder && !hasIssuerAmount && !isIssuerMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + + // An auditor mirror may only be re-encrypted while it is stale, reject if it is already + // current. isAuditorMirrorCurrent reports an absent auditor mirror as stale, which is what + // allows an auditor-only migration to create one for the first time. + if (isAuditorMirrorCurrent(*sleIssuance, *sleMptoken)) + return tecNO_PERMISSION; + } + + // Holder self-migration re-encrypts the mirror from the holder's own + // spending balance, which reflects the holder's full balance only once the + // inbox has been merged into it. Require the inbox to be canonical zero, + // i.e. ConfidentialMPTMergeInbox has already been applied. + if (!hasHolder) + { + // Sanity check: a holder that already carries an issuer mirror + // necessarily has a holder encryption key and a spending balance + if (!sleMptoken->isFieldPresent(sfHolderEncryptionKey) || + !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : an issuer mirror implies a holder " + "key and spending balance"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + auto const expectedZeroInbox = encryptCanonicalZeroAmount( + (*sleMptoken)[sfHolderEncryptionKey], holderID, mptIssuanceID); + if (!expectedZeroInbox) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::preclaim : canonical zero encryption cannot " + "fail for an already-valid holder public key"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + bool const inboxIsCanonicalZero = sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) && + Slice((*sleMptoken)[sfConfidentialBalanceInbox]) == Slice(*expectedZeroInbox); + if (!inboxIsCanonicalZero) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +TER +ConfidentialMPTMirrorUpdate::doApply() +{ + auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; + + auto const sleIssuance = view().read(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleIssuance) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the " + "issuance exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + // The holderID is sfHolder in issuer mode and sfAccount in holder mode. + auto const holder = ctx_.tx[~sfHolder]; + auto const holderID = holder.value_or(accountID_); + + auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, holderID)); + if (!sleMptoken) + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMirrorUpdate::doApply : preclaim already validated the " + "MPToken exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + // Re-encrypt the requested mirror(s) and advance the corresponding mirror + // epoch to match the issuance key epoch. Each mirror is stamped separately + // because this transaction may migrate either one or both. + if (ctx_.tx.isFieldPresent(sfIssuerEncryptedAmount)) + { + (*sleMptoken)[sfIssuerEncryptedBalance] = ctx_.tx[sfIssuerEncryptedAmount]; + setIssuerMirrorEpoch(*sleIssuance, *sleMptoken); + } + + if (ctx_.tx.isFieldPresent(sfAuditorEncryptedAmount)) + { + (*sleMptoken)[sfAuditorEncryptedBalance] = ctx_.tx[sfAuditorEncryptedAmount]; + setAuditorMirrorEpoch(*sleIssuance, *sleMptoken); + } + + view().update(sleMptoken); + return tesSUCCESS; +} + +void +ConfidentialMPTMirrorUpdate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ +} + +bool +ConfidentialMPTMirrorUpdate::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp index e713ae5029..14ab874102 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp @@ -247,6 +247,15 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; } + // A send homomorphically updates the mirrors of both parties, so both must + // be current. + if (ctx.view.rules().enabled(featureConfidentialMPTKeyRotation) && + (!areMirrorsCurrent(*sleIssuance, *sleSenderMPToken) || + !areMirrorsCurrent(*sleIssuance, *sleDestinationMPToken))) + { + return tecNO_PERMISSION; + } + // Sanity check: Both MPTokens' auditor fields must be present if auditing // is enabled if (requiresAuditor && diff --git a/src/test/app/ConfidentialMPTKeyRotation_test.cpp b/src/test/app/ConfidentialMPTKeyRotation_test.cpp index c4e8e607da..0db9e29d27 100644 --- a/src/test/app/ConfidentialMPTKeyRotation_test.cpp +++ b/src/test/app/ConfidentialMPTKeyRotation_test.cpp @@ -3,9 +3,12 @@ #include #include +#include +#include #include #include #include +#include #include #include #include @@ -17,6 +20,7 @@ #include #include #include +#include namespace xrpl { @@ -604,6 +608,1853 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase BEAST_EXPECT(mptAlice.checkKeyEpochs(kMaxKeyEpoch, kMaxKeyEpoch)); } + void + testConfidentialMPTConvertEpoch(FeatureBitset features) + { + testcase("ConfidentialMPTConvert mirror epoch"); + using namespace test::jtx; + + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const auditor("auditor"); + + // A first-time convert with no rotation leaves both mirror + // epochs absent. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor}); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + + // Both mirrors are current, so converting again is allowed and + // leaves the epochs untouched. + mptAlice.convert({ + .account = bob, + .amt = 20, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + } + + // Every remaining case needs key rotation to be enabled. + if (!features[featureConfidentialMPTKeyRotation]) + return; + + // A first-time convert stamps the mirrors with whatever epochs the + // issuance currently sits at. Only issuer key rotated in this case. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor}); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // Ten rotations, issuance's issuer epoch is 10. + for (int i = 0; i < 10; ++i) + { + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + } + + BEAST_EXPECT(mptAlice.checkKeyEpochs(10u, std::nullopt)); + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor)); + + // carol converts for the first time, and her mirrors are stamped with the current + // issuer epoch of 10. + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 10u, std::nullopt)); + } + + // A first-time convert stamps the mirrors with whatever epochs the + // issuance currently sits at. Both keys rotated in this case. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor}); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // 100 rotations of both keys, so both epochs are 100. + for (int i = 0; i < 100; ++i) + { + mptAlice.generateKeyPair(alice); + mptAlice.generateKeyPair(auditor); + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + } + + // 5 more rotations of the auditor key alone, so the auditor epoch is 105 now. + for (int i = 0; i < 5; ++i) + { + mptAlice.generateKeyPair(auditor); + mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)}); + } + + BEAST_EXPECT(mptAlice.checkKeyEpochs(100u, 105u)); + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor)); + + // carol converts for the first time, and each of her mirrors is stamped with the epoch + // of the key it was encrypted under. + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 100u, 105u)); + } + + // An issuer key rotation leaves an existing holder's issuer mirror + // behind, converting will be blocked until the holder's mirror is updated to the new epoch. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor}); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // carol initializes before any rotation, so her mirrors carry no epoch + // at all, the state every holder is in before the amendment. + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt)); + + // Rotate the issuer key to epoch 1. + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt)); + + // bob converts for the first time which is allowed when registering the key. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + + // carol's absent epoch reads as 0 which is stale. + mptAlice.convert({ + .account = carol, + .amt = 20, + .err = tecNO_PERMISSION, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt)); + + // Rotate the issuer key to epoch 2, leaving bob's issuer mirror stale. + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(2u, std::nullopt)); + + // This is not the first time convert, and bob's issuer mirror is behind the current + // epoch, so the convert is rejected. + mptAlice.convert({ + .account = bob, + .amt = 20, + .err = tecNO_PERMISSION, + }); + + // The rejected convert leaves bob's mirrors exactly as they were. + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + + // carol still cannot convert. + mptAlice.convert({ + .account = carol, + .amt = 20, + .err = tecNO_PERMISSION, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt)); + } + + // The auditor mirror is checked the same way, so rotating only the + // auditor key blocks the convert on its own, with the issuer epoch + // untouched. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor}); + + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // bob initializes his confidential balance at epoch 0, so both of his + // mirrors are current. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + + // Rotate the auditor key only, leaving bob's auditor mirror behind + // while his issuer mirror stays current. + mptAlice.generateKeyPair(auditor); + mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u)); + BEAST_EXPECT(mptAlice.checkEncryptionKeys(alice, auditor)); + + mptAlice.convert({ + .account = bob, + .amt = 20, + .err = tecNO_PERMISSION, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + + // Carol converts for the first time, and her auditor mirror is stamped with the current + // auditor epoch of 1. + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, 1u)); + } + + // A late auditor key registration bumps no epoch. + // Although both epochs are still zero, the convert is blocked + // because auditor mirror is missing. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor}); + + // Register the issuer key only. + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + }); + + // The issuance has no auditor yet, so no auditor mirror is created. + mptAlice.convert({ + .account = bob, + .amt = 50, + .fillAuditorEncryptedAmt = false, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + + // Register the auditor key later, which bumps no epoch. + mptAlice.set({ + .account = alice, + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, std::nullopt)); + + // bob's auditor mirror is still missing, so the convert is rejected. + mptAlice.convert({ + .account = bob, + .amt = 20, + .err = tecNO_PERMISSION, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + } + } + + void + testConfidentialMPTSendEpoch(FeatureBitset features) + { + testcase("ConfidentialMPTSend mirror epoch"); + using namespace test::jtx; + + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const auditor("auditor"); + + // Two holders that both initialized after a rotation are current, so a + // send between them succeeds and leaves both mirrors untouched. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + setupConfidentialIssuance(mptAlice, alice, {bob, carol}); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Rotate the issuer key to epoch 1 before anyone holds a confidential + // balance. + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt)); + + for (auto const& holder : {bob, carol}) + { + mptAlice.convert({ + .account = holder, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(holder), + }); + mptAlice.mergeInbox({.account = holder}); + } + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 1u, std::nullopt)); + + mptAlice.send({.account = bob, .dest = carol, .amt = 10}); + + // The epochs are unchanged after send. + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, 1u, std::nullopt)); + } + + // Either the sender or the destination being stale will be rejected. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + setupConfidentialIssuance(mptAlice, alice, {bob, carol}); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // carol initializes at epoch 0. + mptAlice.convert({ + .account = carol, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(carol), + }); + mptAlice.mergeInbox({.account = carol}); + + // Rotate the issuer key to epoch 1, leaving carol behind. + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // bob initializes after the rotation, so his mirrors are current. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({.account = bob}); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt)); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + + // This is rejected because the sender is the stale even though the destination is + // current. + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + // This is rejected because the destination is the stale even though the sender is + // current. + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + // The rejected sends leave both mirrors as they were. + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt)); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + } + + // Auditor mirror is stale, the send will be rejected. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob, carol}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob, carol}, {auditor}); + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + for (auto const& holder : {bob, carol}) + { + mptAlice.convert({ + .account = holder, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(holder), + }); + mptAlice.mergeInbox({.account = holder}); + } + + // Rotate the auditor key only + mptAlice.generateKeyPair(auditor); + mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u)); + + mptAlice.send({ + .account = bob, + .dest = carol, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + mptAlice.send({ + .account = carol, + .dest = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(carol, std::nullopt, std::nullopt)); + } + } + + void + testConfidentialMPTConvertBackEpoch(FeatureBitset features) + { + testcase("ConfidentialMPTConvertBack mirror epoch"); + using namespace test::jtx; + + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + + // A holder who initialized after a rotation is current, so converting + // back is allowed and leaves the epoch it was stamped with alone. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}}); + setupConfidentialIssuance(mptAlice, alice, {bob}); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Rotate the issuer key to epoch 1 before bob holds a confidential + // balance. + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt)); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({.account = bob}); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + + mptAlice.convertBack({.account = bob, .amt = 20}); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 1u, std::nullopt)); + } + + // Converting back with stale mirrors is rejected. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}}); + setupConfidentialIssuance(mptAlice, alice, {bob}); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // bob initializes at epoch 0. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({.account = bob}); + + // Converting back is allowed while his mirrors are still current. + mptAlice.convertBack({.account = bob, .amt = 20}); + + // Rotate the issuer key to epoch 1, leaving bob behind. + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + // The rejected convert back leaves bob's mirrors as they were. + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + } + + // Converting back with a stale auditor mirror is rejected, even if the issuer mirror is + // current. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor}); + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + mptAlice.mergeInbox({.account = bob}); + + // Rotate the auditor key only, leaving bob behind on that mirror alone. + mptAlice.generateKeyPair(auditor); + mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 1u)); + + mptAlice.convertBack({ + .account = bob, + .amt = 10, + .err = tecNO_PERMISSION, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + } + } + + void + testConfidentialMPTClawbackEpoch(FeatureBitset features) + { + testcase("ConfidentialMPTClawback mirror epoch"); + using namespace test::jtx; + + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + + std::uint32_t const clawbackFlags = + tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance | tfMPTCanClawback; + + // Clawback is not blocked on + // a stale auditor mirror. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}, .auditor = auditor}); + setupConfidentialIssuance(mptAlice, alice, {bob}, {auditor}, clawbackFlags); + mptAlice.set({ + .account = alice, + .issuerPubKey = mptAlice.getPubKey(alice), + .auditorPubKey = mptAlice.getPubKey(auditor), + }); + + // bob initializes both mirrors at epoch 0. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + // Rotate the auditor key twice, leaving bob's auditor mirror behind. + for (int i = 0; i < 2; ++i) + { + mptAlice.generateKeyPair(auditor); + mptAlice.set({.account = alice, .auditorPubKey = mptAlice.getPubKey(auditor)}); + } + + BEAST_EXPECT(mptAlice.checkKeyEpochs(std::nullopt, 2u)); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + + mptAlice.confidentialClaw({.account = alice, .holder = bob, .amt = 50}); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, 2u)); + } + + // A holder who initialized after a rotation is clawed back successfully, and + // the issuer mirror is updated to the current epoch. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}}); + setupConfidentialIssuance(mptAlice, alice, {bob}, {}, clawbackFlags); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Rotate the issuer key five times, issuance's issuer epoch is 5. + for (int i = 0; i < 5; ++i) + { + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + } + + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 5u, std::nullopt)); + + mptAlice.confidentialClaw({.account = alice, .holder = bob, .amt = 50}); + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, 5u, std::nullopt)); + } + + // Clawback is not blocked on + // a stale issuer mirror. For now the proof cannot verify: it is checked + // against the key registered on the issuance, while the mirror is still + // encrypted under the key it was written with, and that older key is + // nowhere on the ledger yet. This will be added in a separate PR. + { + Env env{*this, features}; + MPTTester mptAlice(env, alice, {.holders = {bob}}); + setupConfidentialIssuance(mptAlice, alice, {bob}, {}, clawbackFlags); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // bob initializes at epoch 0. + mptAlice.convert({ + .account = bob, + .amt = 50, + .holderPubKey = mptAlice.getPubKey(bob), + }); + + // Rotate the issuer key to epoch 1, leaving bob behind. + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + BEAST_EXPECT(mptAlice.checkKeyEpochs(1u, std::nullopt)); + + mptAlice.confidentialClaw({ + .account = alice, + .holder = bob, + .amt = 50, + .err = tecBAD_PROOF, + }); + + BEAST_EXPECT(mptAlice.checkMirrorEpochs(bob, std::nullopt, std::nullopt)); + } + } + + void + testConfidentialMPTMirrorUpdatePreflight(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate preflight"); + using namespace test::jtx; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); + + // A well-formed 66-byte ElGamal ciphertext + Buffer const& validCipher = getTrivialCiphertext(); + + // Both amendments are required: ConfidentialMPTKeyRotation and ConfidentialTransfer. + if (!features[featureConfidentialMPTKeyRotation] || !features[featureConfidentialTransfer]) + { + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); + mptAlice.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = temDISABLED, + }); + return; + } + + mptAlice.create({ + .ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + // Issuer mode but account is not the issuer. + mptAlice.mirrorUpdate({ + .account = bob, + .holder = carol, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // Issuer mode but the holder is the same as the issuer. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = alice, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // Issuer mode but holder is not provided. + mptAlice.mirrorUpdate({ + .account = alice, + .issuerEncryptedAmount = validCipher, + .err = temMALFORMED, + }); + + // At least one of issuer or auditor amount must be present. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .err = temMALFORMED, + }); + + // Issuer amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = gMakeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = gMakeZeroBuffer(10), + .err = temBAD_CIPHERTEXT, + }); + + // The proof has the wrong length. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .zkProof = gMakeZeroBuffer(kEcEqualityProofLength - 1), + .err = temMALFORMED, + }); + + // Issuer amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + + // Auditor amount is the right length but not a valid ciphertext. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = getBadCiphertext(), + .err = temBAD_CIPHERTEXT, + }); + } + + void + testConfidentialMPTMirrorUpdatePreclaim(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate preclaim"); + using namespace test::jtx; + + Buffer const& validCipher = getTrivialCiphertext(); + + // The issuance does not exist. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + + mptAlice.create({ + .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, + }); + // Destroy the issuance to test issuance not found. + mptAlice.destroy(); + + mptAlice.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The issuance has not enabled confidential balances. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // The issuer encryption key was not already registered. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.authorize({.account = bob}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // In issuer mode, the specified holder account does not exist. + { + Env env{*this, features}; + Account const alice("alice"); + Account const carol("carol"); + MPTTester mptAlice(env, alice); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + // Carol never got funded so it does not exist. + mptAlice.mirrorUpdate({ + .account = alice, + .holder = carol, + .issuerEncryptedAmount = validCipher, + .err = tecNO_TARGET, + }); + } + + // The holder's MPToken does not exist (holder never authorized). + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecOBJECT_NOT_FOUND, + }); + } + + // The holder has an MPToken but no confidential issuer balance (sfIssuerEncryptedBalance). + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create( + {.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptAlice.authorize({.account = bob}); + mptAlice.generateKeyPair(alice); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(alice)}); + + mptAlice.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Auditor mirror migration on an issuance with no auditor key. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + + // This setup has issuer key but no auditor key. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Issuer mirror is already most up-to-date so + // there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Issuer-mode auditor-only migration while the issuer mirror is stale: + // the issuer mirror must be brought up to date before the auditor + // mirror can be migrated. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + // Issuance has both an issuer key and an auditor key, and bob holds + // both mirrors at epoch 0. + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate the issuer key: issuer key epoch 0 -> 1, while bob's + // issuer-mirror epoch stays 0 (stale). + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Auditor mirror is already current (the auditor key has not rotated), + // so there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + + // Issuance has both keys and bob holds both mirrors at epoch 0. + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // No key has rotated, so the auditor mirror is up to date + // so there is nothing to migrate. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // In an issuer-mode simultaneous migration, both mirrors must be stale. Here + // only the issuer key has rotated so its mirror is stale but the auditor mirror is not. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key + // epoch stays 0. The issuer mirror is now stale but the auditor + // mirror is still current. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // In an issuer-mode simultaneous migration, both mirrors must be stale. + // Here only the auditor key has rotated so its mirror is stale but the + // issuer mirror is not. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key + // epoch stays 0. The auditor mirror is now stale but the issuer + // mirror is still current. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode runs the same staleness checks. + // No key has rotated, so the holder's own issuer mirror is current and + // there is nothing to migrate. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode, simultaneously migrating both keys: only the issuer key + // has rotated, so the holder's issuer mirror is stale but the auditor + // mirror is still current. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the issuer key: issuer key epoch 0 -> 1, auditor key + // epoch stays 0. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // Holder mode (no Holder field) needs no previous issuer key. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration mode, simultaneously migrating both keys: + // only the auditor key has rotated, so the holder's auditor mirror is stale but the issuer + // mirror is still current. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1, issuer key + // epoch stays 0. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // Auditor mirror is stale but issuer mirror is current so this is rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .auditorEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + } + + // Holder self-migration requires the holder's inbox to be canonical + // zero, because the cross-key equality proof anchors on the spending + // balance, which only reflects the full balance after the inbox is + // merged. A holder with a non-zero inbox is rejected. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const newIssuerKey("newIssuerKey"); + + ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}}; + + // Carol sends Bob a confidential amount; Bob does NOT merge it, so + // his inbox is no longer canonical zero. + ct.mpt.send({.account = carol, .dest = bob, .amt = 10}); + + // Rotate the issuer key so the issuer mirror is stale and the + // migration gets past the epoch check to reach the inbox check. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tecNO_PERMISSION, + }); + + // Merging the inbox makes the migration succeed. + ct.mpt.mergeInbox({.account = bob}); + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + } + + // A lock does not block a migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const newIssuerKey("newIssuerKey"); + Account const newerIssuerKey("newerIssuerKey"); + + ConfidentialEnv ct{env, alice, {{.account = bob}, {.account = carol}}}; + ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTLock}); + ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTLock}); + + // Rotate the issuer key so both holders' mirrors are stale. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // The issuer migrates an individually locked holder. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + // An individually locked holder migrates itself. + ct.mpt.mirrorUpdate({ + .account = carol, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + // Release the individual locks and lock the whole issuance instead. Rotate again so + // both mirrors are stale once more. + ct.mpt.set({.account = alice, .holder = bob, .flags = tfMPTUnlock}); + ct.mpt.set({.account = alice, .holder = carol, .flags = tfMPTUnlock}); + ct.mpt.set({.account = alice, .flags = tfMPTLock}); + ct.mpt.generateKeyPair(newerIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newerIssuerKey)}); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + + ct.mpt.mirrorUpdate({ + .account = carol, + .issuerEncryptedAmount = validCipher, + .err = tesSUCCESS, + }); + } + } + + void + testConfidentialMPTMirrorUpdateDoApply(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate doApply"); + using namespace test::jtx; + + // The holder's confidential balance, matching the ConfidentialEnv default + // convertAmount. The migration re-encrypts this amount under the new key. + std::uint64_t const amount = 100; + + // Issuer mode issuer-mirror migration. The new issuer mirror is written + // and the auditor mirror epoch advances to the issuer key epoch. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const newIssuerKey("newIssuerKey"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Rotate the issuer key: issuer key epoch 0 -> 1. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // Re-encrypt Bob's balance under the new issuer key. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor()); + + // The previous issuer key is the pre-rotation issuer key (alice's), + // no longer on-ledger after the rotation, provide it in the transaction. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + + // The issuer mirror is now current, so re-migrating it is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .err = tecNO_PERMISSION, + }); + } + + // Issuer mode auditor-mirror migration. The new auditor mirror is written + // and the auditor mirror epoch advances to the auditor key epoch. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate only the auditor key: auditor key epoch 0 -> 1. + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // Re-encrypt Bob's balance under the new auditor key. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Issuer mode simultaneous migration: both mirrors are written in one transaction and + // both epochs advance. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate both keys: both key epochs 0 -> 1. + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(newIssuerKey), + .auditorPubKey = ct.mpt.getPubKey(newAuditorKey), + }); + + // Re-encrypt Bob's balance under each new key. + Buffer const bf = generateBlindingFactor(); + Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf); + Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Issuer mode auditor late-registration: the auditor key is registered for the first + // time (key epoch absent), so setting the initial auditor mirror leaves + // the auditor mirror epoch absent as well. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + // No auditor in the confidential setup, so bob has no auditor mirror. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Register an auditor key for the first time (auditor key epoch stays + // absent). + ct.mpt.generateKeyPair(auditor); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditor)}); + + // Encrypt Bob's balance under the newly registered auditor key. + Buffer const auditorCipher = + ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = auditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(auditorCipher)); + // First-time registration leaves the mirror epoch absent (== 0). + BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch)); + } + + // Holder self-migration migrates from the holder's own spending balance + // (Holder being Account field, no Holder field, and no previous issuer key in any flow + // because the anchor is the spending balance, not the old issuer mirror). ConfidentialEnv + // already merged the inbox so the holder's inbox is canonical zero. + + // Holder issuer-mirror migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const newIssuerKey("newIssuerKey"); + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(newIssuerKey)}); + + // The holder re-encrypts their own balance under the new issuer key. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(newIssuerKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + } + + // Holder auditor-mirror migration. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(newAuditorKey)}); + + // The holder re-encrypts their own balance under the new auditor key. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(newAuditorKey, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Holder simultaneous migration of both mirrors. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const newIssuerKey("newIssuerKey"); + Account const newAuditorKey("newAuditorKey"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + ct.mpt.generateKeyPair(newIssuerKey); + ct.mpt.generateKeyPair(newAuditorKey); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(newIssuerKey), + .auditorPubKey = ct.mpt.getPubKey(newAuditorKey), + }); + + Buffer const bf = generateBlindingFactor(); + Buffer const newIssuerCipher = ct.mpt.encryptAmount(newIssuerKey, amount, bf); + Buffer const newAuditorCipher = ct.mpt.encryptAmount(newAuditorKey, amount, bf); + + // Holder mode needs no previous issuer key even for the issuer mirror. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + .auditorEncryptedAmount = newAuditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 1u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 1u); + } + + // Holder auditor late-registration: the auditor key is registered for the first time (key + // epoch absent), so the holder setting their initial auditor mirror leaves the auditor + // mirror epoch absent as well. + { + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + // No auditor in the confidential setup, so bob has no auditor mirror. + ConfidentialEnv ct{env, alice, {{.account = bob}}}; + + // Register an auditor key for the first time (auditor key epoch stays + // absent). + ct.mpt.generateKeyPair(auditor); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditor)}); + + // The holder encrypts their own balance under the newly registered auditor key. + Buffer const auditorCipher = + ct.mpt.encryptAmount(auditor, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = auditorCipher, + }); + + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(auditorCipher)); + // First-time registration leaves the mirror epoch absent. + BEAST_EXPECT(!sle->isFieldPresent(sfAuditorKeyMirrorEpoch)); + } + } + + void + testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate issuer migrates after several rotations"); + using namespace test::jtx; + + std::uint64_t const amount = 100; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const issuerKey1("issuerKey1"); + Account const issuerKey2("issuerKey2"); + Account const issuerKey3("issuerKey3"); + Account const issuerKey4("issuerKey4"); + Account const issuerKey5("issuerKey5"); + Account const auditorKey1("auditorKey1"); + Account const auditorKey2("auditorKey2"); + Account const auditorKey3("auditorKey3"); + Account const auditorKey4("auditorKey4"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // Rotate the issuer key three times: issuer key epoch 0 -> 3. Bob never + // migrates in between, so his issuer mirror stays at mirror epoch 0 and + // is still encrypted under the original issuer key (alice's). + ct.mpt.generateKeyPair(issuerKey1); + ct.mpt.generateKeyPair(issuerKey2); + ct.mpt.generateKeyPair(issuerKey3); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)}); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u); + } + + // A single migration re-encrypts the mirror under the newest key and + // jumps the mirror epoch straight to the current key epoch (3), rather + // than advancing one rotation at a time. The previous issuer key is the + // original key (alice's) that the stale mirror is still encrypted under, + // not any intermediate rotation. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The issuer mirror is now current (epoch 3 == key epoch 3), so a second + // issuer migration is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = newIssuerCipher, + .err = tecNO_PERMISSION, + }); + + // Now rotate the auditor key twice: auditor key epoch 0 -> 2. Bob's + // auditor mirror is still at mirror epoch 0, under the original auditor + // key. The issuer key and its epoch are untouched. + ct.mpt.generateKeyPair(auditorKey1); + ct.mpt.generateKeyPair(auditorKey2); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)}); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)}); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 2u); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 3u); + } + + // A single auditor-only migration jumps the auditor mirror epoch straight + // to the current auditor key epoch (2). This is an issuer-mode + // auditor-only migration, which is allowed because the issuer mirror is + // already current; no previous issuer key is needed for an auditor + // migration. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u); + // The issuer mirror and its epoch are unaffected by the auditor + // migration. + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The auditor mirror is now current (epoch 2 == key epoch 2), so a second + // auditor migration is rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .auditorEncryptedAmount = newAuditorCipher, + .err = tecNO_PERMISSION, + }); + + // Now rotate BOTH keys together twice: issuer key epoch 3 -> 5, auditor + // key epoch 2 -> 4. Bob's mirrors stay at epoch 3 / 2 (stale again). + ct.mpt.generateKeyPair(issuerKey4); + ct.mpt.generateKeyPair(issuerKey5); + ct.mpt.generateKeyPair(auditorKey3); + ct.mpt.generateKeyPair(auditorKey4); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey4), + .auditorPubKey = ct.mpt.getPubKey(auditorKey3), + }); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey5), + .auditorPubKey = ct.mpt.getPubKey(auditorKey4), + }); + + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(ct.mpt.issuanceID())); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfIssuerKeyEpoch] == 5u); + BEAST_EXPECT(sleIssuance && (*sleIssuance)[~sfAuditorKeyEpoch] == 4u); + } + + // A single simultaneous migration brings both mirrors current in one + // transaction: issuer mirror epoch 3 -> 5, auditor mirror epoch 2 -> 4. + // The previous issuer key is issuerKey3, which is the key Bob's current + // (stale) issuer mirror is encrypted under after the earlier issuer + // migration, not alice's original key nor any intermediate rotation. + Buffer const bothIssuerCipher = + ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor()); + Buffer const bothAuditorCipher = + ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u); + } + + // Both mirrors are current now, so a second simultaneous migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = alice, + .holder = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + .err = tecNO_PERMISSION, + }); + } + + void + testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(FeatureBitset features) + { + testcase("ConfidentialMPTMirrorUpdate holder migrates after several rotations"); + using namespace test::jtx; + + std::uint64_t const amount = 100; + + Env env{*this, features}; + Account const alice("alice"); + Account const bob("bob"); + Account const auditor("auditor"); + Account const issuerKey1("issuerKey1"); + Account const issuerKey2("issuerKey2"); + Account const issuerKey3("issuerKey3"); + Account const issuerKey4("issuerKey4"); + Account const issuerKey5("issuerKey5"); + Account const auditorKey1("auditorKey1"); + Account const auditorKey2("auditorKey2"); + Account const auditorKey3("auditorKey3"); + Account const auditorKey4("auditorKey4"); + ConfidentialEnv ct{ + env, + alice, + {{.account = bob}}, + tfMPTCanHoldConfidentialBalance | tfMPTCanTransfer, + auditor}; + + // In holder self-migration mode the holder submits (account = bob, no + // Holder field) and never provides a previous issuer key. + // Bob's inbox is canonical zero after the ConfidentialEnv merge. + + // Rotate the issuer key three times: issuer key epoch 0 -> 3. + ct.mpt.generateKeyPair(issuerKey1); + ct.mpt.generateKeyPair(issuerKey2); + ct.mpt.generateKeyPair(issuerKey3); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey1)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey2)}); + ct.mpt.set({.account = alice, .issuerPubKey = ct.mpt.getPubKey(issuerKey3)}); + + // A single holder migration jumps the issuer mirror epoch straight to 3. + Buffer const newIssuerCipher = + ct.mpt.encryptAmount(issuerKey3, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The issuer mirror is current, so a second holder issuer migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = newIssuerCipher, + .err = tecNO_PERMISSION, + }); + + // Rotate the auditor key twice: auditor key epoch 0 -> 2. + ct.mpt.generateKeyPair(auditorKey1); + ct.mpt.generateKeyPair(auditorKey2); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey1)}); + ct.mpt.set({.account = alice, .auditorPubKey = ct.mpt.getPubKey(auditorKey2)}); + + // A single holder auditor migration jumps the auditor mirror epoch to 2. + Buffer const newAuditorCipher = + ct.mpt.encryptAmount(auditorKey2, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(newAuditorCipher)); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 2u); + // The issuer mirror is unaffected. + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(newIssuerCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 3u); + } + + // The auditor mirror is current, so a second holder auditor migration is + // rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .auditorEncryptedAmount = newAuditorCipher, + .err = tecNO_PERMISSION, + }); + + // Rotate both keys together twice: issuer key epoch 3 -> 5, auditor key + // epoch 2 -> 4. + ct.mpt.generateKeyPair(issuerKey4); + ct.mpt.generateKeyPair(issuerKey5); + ct.mpt.generateKeyPair(auditorKey3); + ct.mpt.generateKeyPair(auditorKey4); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey4), + .auditorPubKey = ct.mpt.getPubKey(auditorKey3), + }); + ct.mpt.set({ + .account = alice, + .issuerPubKey = ct.mpt.getPubKey(issuerKey5), + .auditorPubKey = ct.mpt.getPubKey(auditorKey4), + }); + + // A single holder migration brings both mirrors current: issuer mirror + // epoch 3 -> 5, auditor mirror epoch 2 -> 4. Still no previous issuer key. + Buffer const bothIssuerCipher = + ct.mpt.encryptAmount(issuerKey5, amount, generateBlindingFactor()); + Buffer const bothAuditorCipher = + ct.mpt.encryptAmount(auditorKey4, amount, generateBlindingFactor()); + + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + }); + + { + auto const sle = env.le(keylet::mptoken(ct.mpt.issuanceID(), bob.id())); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(strHex((*sle)[sfIssuerEncryptedBalance]) == strHex(bothIssuerCipher)); + BEAST_EXPECT(strHex((*sle)[sfAuditorEncryptedBalance]) == strHex(bothAuditorCipher)); + BEAST_EXPECT((*sle)[~sfIssuerKeyMirrorEpoch] == 5u); + BEAST_EXPECT((*sle)[~sfAuditorKeyMirrorEpoch] == 4u); + } + + // Both mirrors are current, so a second holder migration is rejected. + ct.mpt.mirrorUpdate({ + .account = bob, + .issuerEncryptedAmount = bothIssuerCipher, + .auditorEncryptedAmount = bothAuditorCipher, + .err = tecNO_PERMISSION, + }); + } + +public: void testMPTokenIssuanceSetWithFeats(FeatureBitset features) { @@ -617,7 +2468,6 @@ class ConfidentialMPTKeyRotation_test : public ConfidentialTransferTestBase testMPTokenIssuanceSetKeyEpochAtMax(features); } -public: void run() override { @@ -626,6 +2476,20 @@ public: testMPTokenIssuanceSetWithFeats(all); testMPTokenIssuanceSetWithFeats(all - featureConfidentialMPTKeyRotation); + + testConfidentialMPTConvertEpoch(all); + testConfidentialMPTConvertEpoch(all - featureConfidentialMPTKeyRotation); + testConfidentialMPTSendEpoch(all); + testConfidentialMPTConvertBackEpoch(all); + testConfidentialMPTClawbackEpoch(all); + + testConfidentialMPTMirrorUpdatePreflight(all); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialMPTKeyRotation); + testConfidentialMPTMirrorUpdatePreflight(all - featureConfidentialTransfer); + testConfidentialMPTMirrorUpdatePreclaim(all); + testConfidentialMPTMirrorUpdateDoApply(all); + testConfidentialMPTMirrorUpdateMultipleRotationsIssuerMode(all); + testConfidentialMPTMirrorUpdateMultipleRotationsHolderMode(all); } }; diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index a964193c1a..9ff9270a7d 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -2191,6 +2191,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .account = bob, .dest = bob, .amt = 10, + .proof = getTrivialSendProofHex(), .err = temMALFORMED, }); @@ -2897,22 +2898,6 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase auto& mptAlice = confEnv.mpt; { - // Bob has 60, tries to send 70. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = 70, - .err = tecBAD_PROOF, - }); - - // Bob has 60, tries to send 61. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = 61, - .err = tecBAD_PROOF, - }); - // Bob has 60, sends 60. Remainder is exactly 0. Valid remaining balance. mptAlice.send({ .account = bob, @@ -2933,12 +2918,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase }); // Bob has 100, tries to send 2^64-1. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = std::numeric_limits::max(), - .err = tecBAD_PROOF, - }); + { + ConfidentialSendSetup const setup( + mptAlice, bob, carol, alice, std::numeric_limits::max()); + auto const forged = getForgedSendProof(mptAlice, env, bob, carol, setup); + mptAlice.send(setup.sendArgs(bob, carol, forged, tecBAD_PROOF)); + } // Bob sends 1, remaining 99. mptAlice.send({ @@ -2947,14 +2932,6 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .amt = 1, .err = tesSUCCESS, }); - - // Bob sends 100, but only has 99. Invalid remaining balance. - mptAlice.send({ - .account = bob, - .dest = carol, - .amt = 100, - .err = tecBAD_PROOF, - }); } // send when spending balance is 0 (key registered, inbox merged, but nothing converted) @@ -2971,18 +2948,13 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // Trying to send any amount with 0 spending balance must fail: // the range proof for < 0 is invalid. - mptAlice2.send({ - .account = bob2, - .dest = carol2, - .amt = 1, - .err = tecBAD_PROOF, - }); + ConfidentialSendSetup const setup(mptAlice2, bob2, carol2, alice2, 1); + auto const forged = getForgedSendProof(mptAlice2, env2, bob2, carol2, setup); + mptAlice2.send(setup.sendArgs(bob2, carol2, forged, tecBAD_PROOF)); BEAST_EXPECT( mptAlice2.getDecryptedBalance(bob2, MPTTester::holderEncryptedSpending) == 0); } - - // todo: test m exceeding range, require using scala and refactor } /* The equality proof library and range proof library do not @@ -3462,7 +3434,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const convertBackContextHash = getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, convertBackAmt, convertBackContextHash, @@ -3472,6 +3444,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; { json::Value jv; @@ -3483,7 +3457,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase jv[sfIssuerEncryptedAmount.jsonName] = strHex(convertBackIssuerCiphertext); jv[sfBlindingFactor.jsonName] = strHex(convertBackBlindingFactor); jv[sfBalanceCommitment.jsonName] = strHex(pedersenCommitment); - jv[sfZKProof.jsonName] = strHex(proof); + jv[sfZKProof.jsonName] = strHex(requireOptionalRef(proof, "Missing proof")); env(jv, Ter(tesSUCCESS)); } @@ -5283,7 +5257,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const badPedersenCommitment = mptAlice.getPedersenCommitment(1, pcBlindingFactor); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5293,6 +5267,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5313,7 +5289,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5323,6 +5299,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = generateBlindingFactor(), // wrong blinding factor }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5337,22 +5315,26 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase } // Test 3: Proof generated with wrong balance value. - // The proof claims balance=1 but the encrypted spending balance contains - // the actual balance. Verification fails because the values don't match. + // The sigma proof claims balance=20 but the pedersen commitment and + // encrypted spending balance were built for the actual balance (40). + // we cannot call mpt_get_convert_back_proof because it has client-side + // verification. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + uint64_t constexpr claimedBalance = 20; // wrong: real balance is 40 + + auto const proof = getForgedConvertBackProof( + mptAlice, bob, + claimedBalance, + spendingBalance, amt, - contextHash, - { - .pedersenCommitment = pedersenCommitment, - .amt = 1, // wrong balance - .encryptedAmt = encryptedSpendingBalance, - .blindingFactor = pcBlindingFactor, - }); + pedersenCommitment, + encryptedSpendingBalance, + pcBlindingFactor, + contextHash); mptAlice.convertBack({ .account = bob, @@ -5375,7 +5357,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); Buffer const badPedersenCommitment = mptAlice.getPedersenCommitment(1, pcBlindingFactor); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5385,6 +5367,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5405,7 +5389,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase { uint256 const badContextHash{1}; - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, badContextHash, // wrong context hash @@ -5415,6 +5399,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5434,7 +5420,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5444,6 +5430,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5919,22 +5907,26 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // linkage, and that the remaining balance is non-negative. // Test 1: Proof generated with wrong balance value. - // The sigma proof claims balance=1 but the spending balance contains the - // actual balance. The compact proof's balance-linkage check fails. + // The sigma proof claims balance=20 but the pedersen commitment and + // encrypted spending balance were built for the actual balance (40). + // we cannot call mpt_get_convert_back_proof because it has client-side + // verification. { uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + uint64_t constexpr claimedBalance = 20; // wrong: real balance is 40 + + auto const proof = getForgedConvertBackProof( + mptAlice, bob, + claimedBalance, + spendingBalance, amt, - contextHash, - { - .pedersenCommitment = pedersenCommitment, - .amt = 1, // wrong balance (actual balance is ~40) - .encryptedAmt = encryptedSpendingBalance, - .blindingFactor = pcBlindingFactor, - }); + pedersenCommitment, + encryptedSpendingBalance, + pcBlindingFactor, + contextHash); mptAlice.convertBack({ .account = bob, @@ -5956,7 +5948,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -5966,6 +5958,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = generateBlindingFactor(), // wrong blinding factor }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -5985,7 +5979,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // makes the proof invalid for this transaction, preventing replay attacks. { uint256 const badContextHash{1}; - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, badContextHash, // wrong context hash @@ -5995,6 +5989,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -6014,7 +6010,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), version); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -6024,6 +6020,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -6073,7 +6071,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Buffer const bobCiphertext = mptAlice.encryptAmount(bob, amt, blindingFactor); auto const version = mptAlice.getMPTokenVersion(bob); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, makeContextHash(env, mptAlice, alice, bob, carol, version), @@ -6084,6 +6082,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase encryptedSpendingBalance, "Missing encrypted spending balance"), .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -6173,7 +6173,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHashA = getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, version); - Buffer const proofA = mptAlice.getConvertBackProof( + auto const proofA = mptAlice.getConvertBackProof( bob, amtA, contextHashA, @@ -6183,6 +6183,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalance, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(proofA.has_value())) + return; // Construct Transaction B with Amount m2 = 20 and attach Proof pi uint64_t const amtB = 20; @@ -6254,7 +6256,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const oldContextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), currentSeq, versionV); - Buffer const oldProof = mptAlice.getConvertBackProof( + auto const oldProof = mptAlice.getConvertBackProof( bob, amt, oldContextHash, @@ -6264,6 +6266,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpendingBalanceV, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(oldProof.has_value())) + return; // Submit and verify failure mptAlice.convertBack({ @@ -6326,7 +6330,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, amt, contextHash, @@ -6336,6 +6340,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = spendingBalEnc, .blindingFactor = pcBf, }); + if (!BEAST_EXPECT(proof.has_value())) + return; // Submit transaction with Divergent Ciphertexts // Holder Ciphertext encrypts 11. Issuer Ciphertext encrypts 10. @@ -6469,7 +6475,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const contextHash = getConvertBackContextHash(bob, mptAlice.issuanceID(), env.seq(bob), currentVersion); - Buffer const proof = mptAlice.getConvertBackProof( + auto const proof = mptAlice.getConvertBackProof( bob, 1, contextHash, @@ -6479,6 +6485,8 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = underflowedCt, .blindingFactor = pcBf, }); + if (!BEAST_EXPECT(proof.has_value())) + return; mptAlice.convertBack({ .account = bob, @@ -7123,6 +7131,18 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.confidentialClaw( {.account = alice, .holder = carol, .amt = 15, .fee = expectedFee}); }); + + // Check fee for the mirror update transaction. + Account const newIssuerKey("newIssuerKey"); + mptAlice.generateKeyPair(newIssuerKey); + mptAlice.set({.account = alice, .issuerPubKey = mptAlice.getPubKey(newIssuerKey)}); + checkFee(alice, [&]() { + mptAlice.mirrorUpdate( + {.account = alice, + .holder = bob, + .issuerEncryptedAmount = getTrivialCiphertext(), + .fee = expectedFee}); + }); } // test insufficient fee for confidential transactions @@ -7154,6 +7174,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .amt = 1, .fee = baseFee, .err = telINSUF_FEE_P}); + mptAlice.mirrorUpdate( + {.account = alice, + .holder = bob, + .issuerEncryptedAmount = getTrivialCiphertext(), + .fee = baseFee, + .err = telINSUF_FEE_P}); } // test excessive fee for confidential transactions @@ -7741,7 +7767,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase uint256 const convertBackCtxHash = getConvertBackContextHash(bob.id(), mptAlice.issuanceID(), env.seq(bob), version); - Buffer const convertBackProof = mptAlice.getConvertBackProof( + auto const convertBackProof = mptAlice.getConvertBackProof( bob, sendAmount, convertBackCtxHash, @@ -7751,14 +7777,18 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase .encryptedAmt = encryptedSpending, .blindingFactor = pcBlindingFactor, }); + if (!BEAST_EXPECT(convertBackProof.has_value())) + return; // Resize the convertBack proof to match the expected send proof // size so it passes preflight's size check and reaches the actual // ZK verification in doApply. auto const expectedSendSize = kEcSendProofLength; Buffer resizedProof(expectedSendSize); - auto const copyLen = std::min(convertBackProof.size(), expectedSendSize); - std::memcpy(resizedProof.data(), convertBackProof.data(), copyLen); + Buffer const& convertBackProofRef = + requireOptionalRef(convertBackProof, "Missing proof"); + auto const copyLen = std::min(convertBackProofRef.size(), expectedSendSize); + std::memcpy(resizedProof.data(), convertBackProofRef.data(), copyLen); // Zero-pad the rest (if convertBack proof is shorter) if (copyLen < expectedSendSize) std::memset(resizedProof.data() + copyLen, 0, expectedSendSize - copyLen); diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 3ff90c2a8f..5f57cfa21b 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -1144,6 +1144,88 @@ class Delegate_test : public beast::unit_test::Suite env.require(Balance(gw, aliceUSD(-20))); } + // PaymentBurn must not exceed the balance the account holds. Redeeming past + // zero makes the payment engine issue the account's own IOUs, which is a mint. + { + Env env(*this, features); + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const gw{"gateway"}; + auto const gwUSD = gw["USD"]; + auto const aliceUSD = alice["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.trust(gwUSD(200), alice); + env.close(); + + env(pay(gw, alice, gwUSD(50))); + env.close(); + env.require(Balance(alice, gwUSD(50))); + + // gw accepts alice-issued USD, so the engine has issuing liquidity + // available once the trustline reaches zero. + env(trust(gw, aliceUSD(200))); + env.close(); + + env(delegate::set(alice, bob, {"PaymentBurn"})); + env.close(); + + if (!features[fixCleanup3_4_0]) + { + // Pre-fixCleanup3_4_0: the balance direction alone authorizes the payment, so it + // redeems alice's 50 and then mints 50 alice-issued USD. + env(pay(alice, gw, gwUSD(100)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(-50))); + env.require(Balance(gw, aliceUSD(50))); + } + else + { + // Post-fixCleanup3_4_0: Rejected because it exceeds what alice holds. + env(pay(alice, gw, gwUSD(100)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION)); + env.require(Balance(alice, gwUSD(50))); + env.require(Balance(gw, aliceUSD(-50))); + + // Allowed because it is less than what alice holds. + env(pay(alice, gw, gwUSD(20)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(30))); + env.close(); + + // Exactly what alice holds: allowed, and settles at zero. + env(pay(alice, gw, gwUSD(30)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(0))); + env.close(); + + // Nothing left to burn: rejected. + env(pay(alice, gw, gwUSD(1)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION)); + env.require(Balance(gw, aliceUSD(0))); + } + } + + // A delegate holding both PaymentMint and PaymentBurn may cross zero. + { + Env env(*this, features); + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const gw{"gateway"}; + auto const gwUSD = gw["USD"]; + auto const aliceUSD = alice["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.trust(gwUSD(200), alice); + env.close(); + + env(pay(gw, alice, gwUSD(50))); + env(trust(gw, aliceUSD(200))); + env.close(); + + env(delegate::set(alice, bob, {"PaymentBurn", "PaymentMint"})); + env.close(); + + env(pay(alice, gw, gwUSD(100)), delegate::As(bob)); + env.require(Balance(alice, gwUSD(-50))); + env.require(Balance(gw, aliceUSD(50))); + } + // Test invalid fields or flags not allowed in granular permission template { Env env(*this, features); @@ -2756,7 +2838,7 @@ class Delegate_test : public beast::unit_test::Suite // DO NOT modify expectedDelegableCount unless all scenarios, including // edge cases, have been fully tested and verified. // ==================================================================== - std::size_t const expectedDelegableCount = 56; + std::size_t const expectedDelegableCount = 57; BEAST_EXPECTS( delegableCount == expectedDelegableCount, @@ -2916,6 +2998,7 @@ class Delegate_test : public beast::unit_test::Suite testAccountDelete(); testDelegateTransaction(); testPaymentGranular(all); + testPaymentGranular(all - fixCleanup3_4_0); testTrustSetGranular(); testAccountSetGranular(); testMPTokenIssuanceSetGranular(); diff --git a/src/test/app/LedgerNodeHelpers_test.cpp b/src/test/app/LedgerNodeHelpers_test.cpp index a9e4e3ebfc..76c3e3cc52 100644 --- a/src/test/app/LedgerNodeHelpers_test.cpp +++ b/src/test/app/LedgerNodeHelpers_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index 0853affab7..bb0c790e37 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -21,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -29,19 +29,14 @@ #include #include #include -#include #include #include #include #include #include -#include -#include #include -#include #include #include -#include #include #include @@ -268,136 +263,33 @@ enum class PeerFeature { * Simulate a network peer. * Depending on the configured PeerFeature, * it either supports the ProtocolFeature::LedgerReplay or not + * + * `PeerStub` supplies the rest of the `Peer` interface as no-ops. */ -class TestPeer : public Peer +class TestPeer : public PeerStub { public: - TestPeer(bool enableLedgerReplay) - : ledgerReplayEnabled_(enableLedgerReplay) - , nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey())) + // Arbitrary but fixed: the replay code only compares ids. + explicit TestPeer(bool enableLedgerReplay) + : PeerStub(1234), ledgerReplayEnabled_(enableLedgerReplay) { } - void - send(std::shared_ptr const& m) override - { - } - [[nodiscard]] beast::ip::Endpoint - getRemoteAddress() const override - { - return {}; - } - void - charge(resource::Charge const& fee, std::string const& context = {}) override - { - } - [[nodiscard]] id_t - id() const override - { - return 1234; - } - [[nodiscard]] bool - cluster() const override - { - return false; - } - [[nodiscard]] bool - isHighLatency() const override - { - return false; - } - [[nodiscard]] int - getScore(bool) const override - { - return 0; - } - [[nodiscard]] PublicKey const& - getNodePublic() const override - { - return nodePublicKey_; - } - json::Value - json() override - { - return {}; - } [[nodiscard]] bool supportsFeature(ProtocolFeature f) const override { return f == ProtocolFeature::LedgerReplay && ledgerReplayEnabled_; } - [[nodiscard]] std::optional - publisherListSequence(PublicKey const&) const override - { - return {}; - } - void - setPublisherListSequence(PublicKey const&, std::size_t const) override - { - } - [[nodiscard]] uint256 - getClosedLedgerHash() const override - { - static uint256 const kHash{}; - return kHash; - } + + // The replay code only asks peers that already have the ledger. [[nodiscard]] bool - hasLedger(uint256 const& hash, std::uint32_t seq) const override + hasLedger(uint256 const&, std::uint32_t) const override { return true; } - void - ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override - { - } - [[nodiscard]] bool - hasTxSet(uint256 const& hash) const override - { - return false; - } - void - cycleStatus() override - { - } - bool - hasRange(std::uint32_t uMin, std::uint32_t uMax) override - { - return false; - } - [[nodiscard]] bool - compressionEnabled() const override - { - return false; - } - void - sendTxQueue() override - { - } - void - addTxQueue(uint256 const&) override - { - } - void - removeTxQueue(uint256 const&) override - { - } - [[nodiscard]] bool - txReduceRelayEnabled() const override - { - return false; - } - [[nodiscard]] std::string const& - fingerprint() const override - { - return fingerprint_; - } - - // NOLINTBEGIN(readability-identifier-naming) - std::string fingerprint_; +private: bool ledgerReplayEnabled_; - PublicKey nodePublicKey_; - // NOLINTEND(readability-identifier-naming) }; enum class PeerSetBehavior { diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp index cd61668b03..5ecad1a420 100644 --- a/src/test/app/Path_test.cpp +++ b/src/test/app/Path_test.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -46,16 +47,20 @@ #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include #include +#include namespace xrpl::test { @@ -1943,7 +1948,7 @@ public: static constexpr AccountID kAccountID7{kAccount7}; static constexpr AccountID kAccountID8{kAccount8}; - auto ps = STPathSet{}; + auto ps = STPathSet{STPathSet::DeduplicationTag{}}; auto createPathElements = [](auto const& account1, auto const& account2) { auto base = STPath{}; @@ -2017,6 +2022,215 @@ public: BEAST_EXPECT(ps.size() == 6); } + void + testPushBackDeduplication() + { + testcase("STPathSet::pushBack/emplaceBack deduplication"); + + // pushBack and emplaceBack reject duplicates on a set built with the + // DeduplicationTag, and append unconditionally without it. Both + // report which happened. The unconditional case is the one the wire + // and JSON paths rely on: collapsing duplicates there would change the + // signed content of a transaction. + + static constexpr AccountID kAccountID1{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"}; + static constexpr AccountID kAccountID2{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"}; + static constexpr AccountID kAccountID3{"F08C36A1D95E27B40CA1F63E8D204B7950E1C3A6"}; + + auto makePath = [](AccountID const& account) { + auto p = STPath{}; + p.pushBack(STPathElement{STPathElement::TypeAccount, account, xrpCurrency(), account}); + return p; + }; + + auto const first = makePath(kAccountID1); + auto const second = makePath(kAccountID2); + auto const third = makePath(kAccountID3); + + // Deduplicating set: the second insert of a path is rejected, and the + // rejection is reported rather than silently swallowed. + { + auto ps = STPathSet{STPathSet::DeduplicationTag{}}; + + BEAST_EXPECT(ps.pushBack(first)); + BEAST_EXPECT(ps.size() == 1); + + BEAST_EXPECT(!ps.pushBack(first)); + BEAST_EXPECT(ps.size() == 1); + + // emplaceBack sees paths registered by pushBack... + BEAST_EXPECT(!ps.emplaceBack(first)); + BEAST_EXPECT(ps.size() == 1); + + BEAST_EXPECT(ps.emplaceBack(second)); + BEAST_EXPECT(ps.size() == 2); + + // ...and pushBack sees paths registered by emplaceBack. + BEAST_EXPECT(!ps.pushBack(second)); + BEAST_EXPECT(ps.size() == 2); + + // emplaceBack's forwarding form registers the same way. + BEAST_EXPECT(ps.emplaceBack(std::vector{third.front()})); + BEAST_EXPECT(ps.size() == 3); + BEAST_EXPECT(!ps.pushBack(third)); + BEAST_EXPECT(ps.size() == 3); + + // A rejected duplicate must not disturb what is already stored. + BEAST_EXPECT(ps[0] == first); + BEAST_EXPECT(ps[1] == second); + BEAST_EXPECT(ps[2] == third); + } + + // Without the tag there is no index, so duplicates are appended and + // both methods report success every time. + { + auto plain = STPathSet{}; + BEAST_EXPECT(plain.pushBack(first)); + BEAST_EXPECT(plain.pushBack(first)); + BEAST_EXPECT(plain.emplaceBack(first)); + BEAST_EXPECT(plain.size() == 3); + + auto named = STPathSet{sfPaths}; + BEAST_EXPECT(named.pushBack(first)); + BEAST_EXPECT(named.pushBack(first)); + BEAST_EXPECT(named.size() == 2); + } + } + + void + testPathHashInjectivity() + { + testcase("STPathElement hash injectivity"); + + auto const zeroCurrency = + STPathElement{AccountID{}, PathAsset{Currency{}}, AccountID{}, true}; + auto const zeroMPT = STPathElement{AccountID{}, PathAsset{MPTID{}}, AccountID{}, true}; + + BEAST_EXPECT(!(zeroCurrency == zeroMPT)); + + auto path = [](std::vector const& elements) { + auto p = STPath{}; + for (auto const& element : elements) + p.pushBack(element); + return p; + }; + + auto const currencyFirst = path({zeroCurrency, zeroMPT}); + auto const mptFirst = path({zeroMPT, zeroCurrency}); + + BEAST_EXPECT(!(currencyFirst == mptFirst)); + + auto const hasher = HardenedHash<>{}; + BEAST_EXPECT(hasher(currencyFirst) != hasher(mptFirst)); + + auto mask = std::vector{0, 0, 1, 1}; + auto hashes = std::set{}; + auto orderings = 0uz; + do + { + auto elements = std::vector{}; + for (auto const isMPT : mask) + { + elements.push_back(isMPT != 0 ? zeroMPT : zeroCurrency); + } + hashes.insert(hasher(path(elements))); + ++orderings; + } while (std::ranges::next_permutation(mask).found); + + BEAST_EXPECT(orderings == 6); + BEAST_EXPECT(hashes.size() == orderings); + + auto seen = hardened_hash_set{}; + for (auto const& p : {currencyFirst, mptFirst}) + { + seen.emplace(p); + } + BEAST_EXPECT(seen.size() == 2); + + // The other half of the invariant: equal elements must hash equally. + // STPathElement::operator== masks type_ down to the TypeAccount bit, so + // elements whose remaining type bits differ still compare equal -- + // hashing the full type_ would give them distinct hashes and silently + // defeat deduplication. + static constexpr AccountID kAccount{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"}; + static constexpr AccountID kIssuer{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"}; + + auto const equivalent = std::vector>{ + // forceAsset toggles TypeCurrency on an XRP asset. + {STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, true}, + STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, false}}, + // An explicit type mask vs. one derived from the populated fields. + {STPathElement{STPathElement::TypeAccount, kAccount, xrpCurrency(), kIssuer}, + STPathElement{kAccount, PathAsset{xrpCurrency()}, kIssuer, false}}, + }; + + for (auto const& [lhs, rhs] : equivalent) + { + BEAST_EXPECT(lhs.getNodeType() != rhs.getNodeType()); + BEAST_EXPECT(lhs == rhs); + + auto const lhsPath = path({lhs}); + auto const rhsPath = path({rhs}); + BEAST_EXPECT(hasher(lhsPath) == hasher(rhsPath)); + + auto equal = hardened_hash_set{}; + equal.emplace(lhsPath); + equal.emplace(rhsPath); + BEAST_EXPECT(equal.size() == 1); + } + } + + void + testDeserializationPreservesDuplicates() + { + testcase("STPathSet deserialization preserves duplicate paths"); + + // The `Paths` field of a signed transaction must round-trip byte for + // byte. The deduplication index exists solely for pathfinding, so the + // deserializing constructor must never engage it: collapsing duplicates + // on parse would silently change the signed content of a transaction. + + static constexpr AccountID kAccountID1{"A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"}; + static constexpr AccountID kAccountID2{"1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"}; + + auto const element = + STPathElement{kAccountID1, PathAsset{xrpCurrency()}, kAccountID2, true}; + + auto path = STPath{}; + path.pushBack(element); + + static constexpr auto kDuplicates = 64uz; + + auto original = STPathSet{sfPaths}; + for (auto i = 0uz; i < kDuplicates; ++i) + { + original.pushBack(path); + } + + // No index was requested, so nothing is deduplicated on the way in. + BEAST_EXPECT(original.size() == kDuplicates); + + auto s = Serializer{}; + original.add(s); + + auto sit = SerialIter{s.slice()}; + auto const parsed = STPathSet{sit, sfPaths}; + + // The duplicates survive the round trip... + BEAST_EXPECT(parsed.size() == kDuplicates); + BEAST_EXPECT(parsed.isEquivalent(original)); + + // ...and re-serializing reproduces the original bytes exactly. + auto serialized = Serializer{}; + parsed.add(serialized); + BEAST_EXPECT(serialized.getData() == s.getData()); + + // A parsed set holds no index, so appending to it stays append-only. + auto appended = parsed; + appended.pushBack(path); + BEAST_EXPECT(appended.size() == kDuplicates + 1); + } + void run() override { @@ -2031,6 +2245,9 @@ public: issuesPathNegativeRippleClientIssue23Larger(); qualityPathsQualitySetAndTest(); testAssembleAddDeduplication(); + testPushBackDeduplication(); + testPathHashInjectivity(); + testDeserializationPreservesDuplicates(); trustAutoClearTrustNormalClear(); trustAutoClearTrustAutoClear(); norippleCombinations(); diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index 71d968014f..22b5d0bcdc 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -1163,6 +1163,25 @@ public: sponsor::SponseeAcc(alice), Ter(temMALFORMED)); } + + // Post-fixCleanup3_5_0, a zero ObjectID is malformed. + // Pre-fixCleanup3_5_0 path is unreachable so it is not testable. + if (features[fixCleanup3_5_0]) + { + uint256 const zeroObjectID{}; + + env(sponsor::transfer(alice, tfSponsorshipEnd, zeroObjectID), Ter(temMALFORMED)); + + env(sponsor::transfer(alice, tfSponsorshipCreate, zeroObjectID), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(temMALFORMED)); + + env(sponsor::transfer(alice, tfSponsorshipReassign, zeroObjectID), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(temMALFORMED)); + } } { diff --git a/src/test/app/invariants/InvariantsMPT_test.cpp b/src/test/app/invariants/InvariantsMPT_test.cpp index 4692463baa..91984f2021 100644 --- a/src/test/app/invariants/InvariantsMPT_test.cpp +++ b/src/test/app/invariants/InvariantsMPT_test.cpp @@ -967,6 +967,75 @@ class InvariantsMPT_test : public InvariantsBase }); } + // LoanSet / VaultWithdraw MayAuthorizeMpt caps (fixCleanup3_4_0): + // LoanSet allows at most two creates and no deletes; VaultWithdraw + // allows at most one of each. Fabricate one extra mutation so a + // too-loose cap would miss these. + { + auto const insertHolderTokens = + [](Account const& issuer, Account const& holder, ApplyContext& ac, int n) { + auto const sle = ac.view().peek(keylet::account(issuer.id())); + if (!sle) + return false; + auto seq = sle->getFieldU32(sfSequence); + for (int i = 0; i < n; ++i) + { + MPTIssue const mpt{makeMptID(seq + i, issuer)}; + auto sleNew = + std::make_shared(keylet::mptoken(mpt.getMptID(), holder)); + (*sleNew)[sfAccount] = holder.id(); + (*sleNew)[sfMPTokenIssuanceID] = mpt.getMptID(); + ac.view().insert(sleNew); + } + return true; + }; + + std::array, 2> const createOverCap{ + {{ttLOAN_SET, 3}, {ttVAULT_WITHDRAW, 2}}}; + for (auto const& [txnType, nTokens] : createOverCap) + { + doInvariantCheck( + {{"MPT authorize succeeded but created/deleted bad number mptokens"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + return insertHolderTokens(a1, a2, ac, nTokens); + }, + XRPAmount{}, + STTx{txnType, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + MPTID id; + auto const precloseTwoHolders = [&id](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}}); + id = mpt.issuanceID(); + return true; + }; + std::array, 2> const deleteOverCap{ + {{ttLOAN_SET, 1}, {ttVAULT_WITHDRAW, 2}}}; + for (auto const& [txnType, nTokens] : deleteOverCap) + { + doInvariantCheck( + {{"MPT authorize succeeded but created/deleted bad number mptokens"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + std::array const holders{a1, a2}; + for (int i = 0; i < nTokens; ++i) + { + auto sle = ac.view().peek(keylet::mptoken(id, holders[i])); + if (!sle) + return false; + ac.view().erase(sle); + } + return true; + }, + XRPAmount{}, + STTx{txnType, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseTwoHolders); + } + } + // sfReferenceHolding can only be set on creation by VaultCreate. A // non-VaultCreate transaction that creates an MPTokenIssuance with // sfReferenceHolding present must trip the invariant. diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index dcf783a1b5..e264b91cb1 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -1224,33 +1224,51 @@ class InvariantsVault_test : public InvariantsBase // ttLOAN_PAY success post-conditions. A loan left with payments still // remaining after a successful payment must show that payment in its - // balance and schedule: PrincipalOutstanding and PaymentRemaining both - // strictly decrease, and NextPaymentDueDate advances by a positive - // multiple of PaymentInterval. Each case seeds the same loan, then applies + // balance and schedule: neither PrincipalOutstanding nor + // TotalValueOutstanding may increase, at least one of them must + // strictly decrease, PaymentRemaining must strictly decrease, and + // NextPaymentDueDate must advance by a positive multiple of + // PaymentInterval. Each failing case seeds the same loan, then applies // an after-image that breaks exactly one of those conditions. { struct Case { Number principal; + Number totalValue; std::uint32_t remaining; std::uint32_t dueDate; std::string expected; }; auto const cases = std::to_array({ {.principal = Number(100), + .totalValue = Number(150), .remaining = 1, .dueDate = 110, - .expected = "loan pay must strictly decrease PrincipalOutstanding"}, + .expected = "loan pay must decrease PrincipalOutstanding or " + "TotalValueOutstanding"}, + {.principal = Number(110), + .totalValue = Number(150), + .remaining = 1, + .dueDate = 110, + .expected = "loan pay must not increase PrincipalOutstanding"}, {.principal = Number(50), + .totalValue = Number(160), + .remaining = 1, + .dueDate = 110, + .expected = "loan pay must not increase TotalValueOutstanding"}, + {.principal = Number(50), + .totalValue = Number(150), .remaining = 2, .dueDate = 110, .expected = "loan pay must decrease PaymentRemaining"}, {.principal = Number(50), + .totalValue = Number(150), .remaining = 1, .dueDate = 100, .expected = "loan pay must advance NextPaymentDueDate"}, // Advanced, but not by a whole number of payment intervals. {.principal = Number(50), + .totalValue = Number(150), .remaining = 1, .dueDate = 105, .expected = "loan pay must advance NextPaymentDueDate"}, @@ -1291,6 +1309,7 @@ class InvariantsVault_test : public InvariantsBase if (!BEAST_EXPECT(sleLoan)) continue; sleLoan->at(sfPrincipalOutstanding) = c.principal; + sleLoan->at(sfTotalValueOutstanding) = c.totalValue; sleLoan->setFieldU32(sfPaymentRemaining, c.remaining); sleLoan->setFieldU32(sfNextPaymentDueDate, c.dueDate); ac.view().update(sleLoan); @@ -1303,6 +1322,65 @@ class InvariantsVault_test : public InvariantsBase BEAST_EXPECT(result == tecINVARIANT_FAILED); BEAST_EXPECT(sink.messages().str().contains(c.expected)); } + + // Principal may stick while TotalValueOutstanding falls. This + // after-image is only a Loan mutation, so other (vault) invariants + // still fail under Full scope; ValidLoan itself must not. + { + Env env{*this, all_}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + auto const keys = createClosedXrpBroker(a1, env); + if (!keys) + { + fail(); + } + else + { + auto const& brokerKeylet = keys->second; + OpenView ov{*env.current()}; + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id()); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->at(sfPaymentInterval) = 10u; + sleLoan->setFieldU32(sfPaymentRemaining, 2); + sleLoan->setFieldU32(sfNextPaymentDueDate, 100); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_PAY, [](STObject& t) { t.setFieldAmount(sfAmount, XRPAmount(50)); }}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sleLoan = ac.view().peek(loanKeylet); + if (BEAST_EXPECT(sleLoan)) + { + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(140); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + sleLoan->setFieldU32(sfNextPaymentDueDate, 110); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (BEAST_EXPECT(transactor)) + { + std::ignore = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + auto const logs = sink.messages().str(); + BEAST_EXPECT(!logs.contains("Invariant failed: Loan")); + BEAST_EXPECT(!logs.contains("loan pay")); + } + } + } + } } // ttLOAN_MANAGE (default): the write-off is rounded downward at the diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 96adfd5254..c509469de9 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -1482,9 +1482,9 @@ class LendingHelpers_test : public beast::unit_test::Suite } void - testAccrualLoanOriginationDeltas() + testInstantRecognitionLoanOriginationDeltas() { - using namespace xrpl::accrual; + using namespace xrpl::instant_recognition; struct TestCase { @@ -1504,7 +1504,7 @@ class LendingHelpers_test : public beast::unit_test::Suite for (auto const& tc : testCases) { - testcase("accrual::loanOriginationDeltas: " + tc.name); + testcase("instant_recognition::loanOriginationDeltas: " + tc.name); auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue); BEAST_EXPECTS( @@ -1540,9 +1540,9 @@ class LendingHelpers_test : public beast::unit_test::Suite } void - testAccrualLoanOriginationExceedsVaultMaximum() + testInstantRecognitionLoanOriginationExceedsVaultMaximum() { - using namespace xrpl::accrual; + using namespace xrpl::instant_recognition; struct TestCase { @@ -1578,7 +1578,7 @@ class LendingHelpers_test : public beast::unit_test::Suite for (auto const& tc : testCases) { - testcase("accrual::loanOriginationExceedsVaultMaximum: " + tc.name); + testcase("instant_recognition::loanOriginationExceedsVaultMaximum: " + tc.name); BEAST_EXPECT( loanOriginationExceedsVaultMaximum( tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected); @@ -1620,12 +1620,12 @@ class LendingHelpers_test : public beast::unit_test::Suite } void - testAccrualLoanVaultExposure() + testInstantRecognitionLoanVaultExposure() { - testcase("accrual::loanVaultExposure"); + testcase("instant_recognition::loanVaultExposure"); auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); - BEAST_EXPECT(xrpl::accrual::loanVaultExposure(sle) == Number{950}); + BEAST_EXPECT(xrpl::instant_recognition::loanVaultExposure(sle) == Number{950}); } void @@ -1642,7 +1642,7 @@ class LendingHelpers_test : public beast::unit_test::Suite { // principalPaid, interestPaid, feePaid, valueChange are all distinct // and nonzero, with a nonzero valueChange simulating a late-payment - // penalty, so Accrual's formula is meaningfully exercised. + // penalty, so InstantRecognition's formula is meaningfully exercised. LoanPaymentParts const parts{ .principalPaid = Number{100}, .interestPaid = Number{20}, @@ -1650,8 +1650,8 @@ class LendingHelpers_test : public beast::unit_test::Suite .feePaid = Number{3}}; { - testcase("accrual::loanPaymentDeltas: nonzero valueChange"); - auto const deltas = xrpl::accrual::loanPaymentDeltas(parts); + testcase("instant_recognition::loanPaymentDeltas: nonzero valueChange"); + auto const deltas = xrpl::instant_recognition::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange); BEAST_EXPECT( deltas.debtTotalDelta == @@ -1680,11 +1680,11 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks " - "Accrual"); + "InstantRecognition"); Env const env{*this}; auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue); auto const expected = - xrpl::accrual::loanOriginationDeltas(principalRequested, interestDue); + xrpl::instant_recognition::loanOriginationDeltas(principalRequested, interestDue); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); } @@ -1709,7 +1709,7 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const vaultMaximum{1'000}; Number const vaultTotal{900}; - // Exceeds Accrual's headroom (100), but must never trip CashBasis. + // Exceeds InstantRecognition's headroom (100), but must never trip CashBasis. Number const interestDue{101}; auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal); @@ -1718,11 +1718,11 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase( "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault " - "picks Accrual"); + "picks InstantRecognition"); Env const env{*this}; BEAST_EXPECT( loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) == - xrpl::accrual::loanOriginationExceedsVaultMaximum( + xrpl::instant_recognition::loanOriginationExceedsVaultMaximum( vaultMaximum, vaultTotal, interestDue)); } @@ -1746,11 +1746,14 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); { - testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual"); + testcase( + "loanVaultExposure dispatcher: amendment enabled, legacy vault picks " + "InstantRecognition"); Env const env{*this}; auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(legacyVault, sle) == xrpl::accrual::loanVaultExposure(sle)); + loanVaultExposure(legacyVault, sle) == + xrpl::instant_recognition::loanVaultExposure(sle)); } { @@ -1780,10 +1783,12 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); { - testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual"); + testcase( + "loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks " + "InstantRecognition"); Env const env{*this}; auto const deltas = loanPaymentDeltas(legacyVault, parts); - auto const expected = xrpl::accrual::loanPaymentDeltas(parts); + auto const expected = xrpl::instant_recognition::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); } @@ -1999,10 +2004,10 @@ public: testComputeInterestAndFeeParts(); testCanApplyToBrokerCover(); - testAccrualLoanOriginationDeltas(); + testInstantRecognitionLoanOriginationDeltas(); testCashBasisLoanOriginationDeltas(); - testAccrualLoanOriginationExceedsVaultMaximum(); - testAccrualLoanVaultExposure(); + testInstantRecognitionLoanOriginationExceedsVaultMaximum(); + testInstantRecognitionLoanVaultExposure(); testCashBasisLoanVaultExposure(); testLoanPaymentDeltas(); testLoanOriginationDeltasDispatcher(); diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp index e238838306..f9f5948db9 100644 --- a/src/test/app/lending/LoanCashBasis_test.cpp +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -39,13 +39,13 @@ namespace xrpl::test { // amendment. They are called once, directly, from // runAmendmentIndependent() -- not looped through // runAmendmentSensitive()/amendmentCombinations(), since doing so would -// require re-deriving whole-life-specific expected values for ~15 +// require re-deriving instant-recognition-specific expected values for ~15 // unrelated regression tests. class LoanCashBasis_test : public LoanTestBase { private: // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, - // and the AssetsMaximum/DebtMaximum guards. Accrual AssetsMaximum still + // and the AssetsMaximum/DebtMaximum guards. Instant-recognition AssetsMaximum still // requires headroom for interestDue; cash-basis AssetsMaximum does not, // because origination does not credit interest into AssetsTotal. void @@ -147,16 +147,16 @@ private: BEAST_EXPECTS( assetsTotalDelta == interestDue, - "whole-life origination must add interestDue to AssetsTotal; delta=" + + "instant-recognition origination must add interestDue to AssetsTotal; delta=" + to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); BEAST_EXPECTS( debtTotalDelta == principalOutstanding + interestDue, - "whole-life origination must add principal+interest to DebtTotal; delta=" + + "instant-recognition origination must add principal+interest to DebtTotal; delta=" + to_string(debtTotalDelta)); } // AssetsMaximum guard checks interestDue headroom only under - // whole-life accounting; DebtMaximum guard also varies by model. + // instant interest recognition; DebtMaximum guard also varies by model. auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { Env env(*this, features); @@ -217,7 +217,8 @@ private: Number const oneDrop = xrpAsset(1).value(); { - testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); + testcase( + "instant-recognition: LoanSet AssetsMaximum guard checks interestDue headroom"); // Guard rejects when there's not quite enough headroom for the // interest. runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); @@ -230,19 +231,19 @@ private: // Even far less headroom than interestDue still succeeds, since // cash-basis origination never adds interest to AssetsTotal. runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); - // Fully subscribed: AssetsTotal == AssetsMaximum. Accrual preclaim + // Fully subscribed: AssetsTotal == AssetsMaximum. Instant-recognition preclaim // used to refuse this; origination must still succeed because it // does not change AssetsTotal. runVaultGuard(all_ | featureLendingProtocolV1_1, Number{0}, tesSUCCESS); } // DebtMaximum guard: cash-basis projects principal-only DebtTotal; - // whole-life projects principal + interestDue. + // instant recognition projects principal + interestDue. for (auto const cashBasis : {true, false}) { testcase( std::string("LoanSet DebtMaximum guard (") + - (cashBasis ? "cash-basis)" : "whole-life)")); + (cashBasis ? "cash-basis)" : "instant-recognition)")); auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; Number const newDebtTotal = principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); @@ -254,9 +255,9 @@ private: // 2. LoanPay: regular, late, overpayment, and full-payment types. // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match // interestPaid/principalPaid under cash-basis, and cross-check the - // amendment-disabled run's deltas against the documented whole-life + // amendment-disabled run's deltas against the documented instant-recognition // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own - // TotalValueOutstanding delta exactly, since whole-life debt recognition + // TotalValueOutstanding delta exactly, since instant-recognition debt recognition // tracks total loan value). void testCashBasisLoanPay() @@ -360,7 +361,7 @@ private: .totalValueDelta = totalValueAfter - totalValueBefore}; }; - // Compares the disabled (whole-life) and enabled (cash-basis) runs + // Compares the disabled (instant-recognition) and enabled (cash-basis) runs // of the same payment scenario, and asserts the documented // relationships between them. auto checkScenario = [&](std::string const& label, @@ -381,12 +382,12 @@ private: // does. BEAST_EXPECTS( off.debtTotalDelta == off.totalValueDelta, - "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " + "instant-recognition DebtTotal delta must mirror TotalValueOutstanding delta; " "debtTotalDelta=" + to_string(off.debtTotalDelta) + " totalValueDelta=" + to_string(off.totalValueDelta)); - // Derive interestPaid from the whole-life run's independent + // Derive interestPaid from the instant-recognition run's independent // ledger deltas: // assetsTotalDelta_off == valueChange // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) @@ -423,10 +424,11 @@ private: // Regular, on-time payments never change the loan's value beyond // normal amortization (production asserts valueChange == 0), so - // AssetsTotal must be unaffected in the whole-life run. + // AssetsTotal must be unaffected in the instant-recognition run. BEAST_EXPECTS( off.assetsTotalDelta == beast::kZero, - "regular on-time payment must not change AssetsTotal under whole-life; delta=" + + "regular on-time payment must not change AssetsTotal under instant recognition; " + "delta=" + to_string(off.assetsTotalDelta)); checkScenario("regular payment", off, on); @@ -932,14 +934,15 @@ private: } // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 - // activates (LEVersion absent) must keep whole-life (accrual) accounting + // activates (LEVersion absent) must keep instant interest recognition // forever, even after the amendment is later enabled -- the switch is // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment // flag. void - testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + testLegacyVaultKeepsInstantRecognitionAfterAmendmentEnabled() { - testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + testcase( + "LEVersion: legacy vault keeps instant interest recognition after amendment enabled"); using namespace jtx; using namespace loan; @@ -977,7 +980,7 @@ private: } // Now enable the amendment -- production dispatch must still treat - // this specific Vault as accrual-basis, since its LEVersion is + // this specific Vault as instant interest recognition, since its LEVersion is // (and remains) absent. env.enableFeature(featureLendingProtocolV1_1); env.close(); @@ -997,7 +1000,7 @@ private: auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); - // ---- LoanSet origination: whole-life formulas expected ---- + // ---- LoanSet origination: instant-recognition formulas expected ---- auto const vaultBeforeSet = env.le(broker.vaultKeylet()); auto const brokerBeforeSet = env.le(broker.brokerKeylet()); BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); @@ -1034,7 +1037,7 @@ private: LoanState const state = getCurrentState(env, broker, loanKeylet); env.close(); - // ---- LoanPay: whole-life formulas expected ---- + // ---- LoanPay: instant-recognition formulas expected ---- auto const vaultBeforePay = env.le(broker.vaultKeylet()); auto const brokerBeforePay = env.le(broker.brokerKeylet()); auto const loanBeforePay = env.le(loanKeylet); @@ -1059,7 +1062,7 @@ private: Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; - // A regular, on-time payment has valueChange == 0, so whole-life + // A regular, on-time payment has valueChange == 0, so instant-recognition // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. BEAST_EXPECTS( assetsTotalDeltaPay == beast::kZero, @@ -1071,7 +1074,7 @@ private: "debtTotalDelta=" + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); - // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + // ---- LoanManage: impair, then default -- instant-recognition exposure expected ---- auto const loanBeforeImpair = env.le(loanKeylet); BEAST_EXPECT(loanBeforeImpair); Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); @@ -1110,7 +1113,7 @@ private: BEAST_EXPECTS( debtTotalDeltaDefault == -expectedExposure, - "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + "legacy vault default must reduce DebtTotal by instant-recognition exposure; delta=" + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); BEAST_EXPECTS( lossDeltaDefault == -expectedExposure, @@ -1131,7 +1134,7 @@ private: // entirely under the amendment, with independently hand-computed // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at // each step. 0% interest keeps the arithmetic exact and tractable; the - // divergence from whole-life accounting is already covered directly by + // divergence from instant interest recognition is already covered directly by // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test // focuses purely on an independent, from-scratch trajectory check. void @@ -1270,7 +1273,7 @@ public: testVaultSetWhileAssetsTotalExceedsMaximum(); testCashBasisLoanSetAfterInterestExceedsCap(); testCashBasisLoanManage(); - testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); + testLegacyVaultKeepsInstantRecognitionAfterAmendmentEnabled(); testCashBasisEndToEndTrajectory(); } }; diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index ded1c816a2..4a2063ed77 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -415,6 +415,9 @@ private: // The test pays one period at a time across three LoanPay // transactions and verifies the loan completes (paymentRemaining=0) // with totals matching the loan's economics (1 principal + 2 interest). + // Also run under featureLendingProtocolV1_1: ValidLoan must allow the + // two sticking pays (TVO falls, PO does not) and the final clear + // (PaymentRemaining 0, NextPaymentDueDate 0). void testIntegerScalePrincipalSticks(FeatureBitset features) { @@ -446,28 +449,19 @@ private: env(pay(issuer, borrower, asset(10'000))); env.close(); - Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(vaultTx); - env.close(); + // createVaultAndBroker promotes the vault to ClosedEnded under + // featureLendingProtocolV1_1 (LoanBrokerSet rejects open-ended). + BrokerParameters const params{ + .vaultDeposit = Number{5'000}, + .debtMax = Number{100}, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker = createVaultAndBroker(env, asset, lender, params); - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); - env.close(); - - auto const brokerKeylet = - keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); - env(loan_broker::set(lender, vaultKeylet.key), - loan_broker::kDebtMaximum(Number{100}), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const brokerStateBefore = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerStateBefore)) - return; - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(loanSequence)); - - env(loan::set(borrower, brokerKeylet.key, Number{1}), + auto const loanKeylet = nextLoanKeylet(env, broker); + env(loan::set(borrower, broker.brokerID, Number{1}), Sig(sfCounterpartySignature, lender), loan::kInterestRate(TenthBips32{50'000}), loan::kPaymentTotal(3), @@ -499,6 +493,8 @@ private: BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); + if (expectedRemaining[i] == 0) + BEAST_EXPECT(sle->at(~sfNextPaymentDueDate).value_or(0) == 0); } // Borrower paid 3 total regardless of fee split (1 principal + 2 @@ -1226,6 +1222,9 @@ private: testBugVaultWithdrawDustVsAssetsTotal(all_ - fixCleanup3_4_0); testBugVaultWithdrawDustVsAssetsTotal(all_); testBugInterestDueDeltaCrash(); + // all_ excludes V1.1; amendmentCombinations never pairs it with the + // sticking schedule. Run that combination explicitly. + testIntegerScalePrincipalSticks(all_ | featureLendingProtocolV1_1); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 5eea6f83fe..469c1662ad 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -597,6 +598,105 @@ private: nullptr); } + void + testLoanSetOriginationFeeTwoMptCreates(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + + bool const fix340Enabled = features[fixCleanup3_4_0]; + testcase << "LoanSet: borrower and broker owner missing MPToken" + << (fix340Enabled ? "" : " pre-fixCleanup3_4_0"); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + Env env(*this, features); + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + + env(pay(issuer, lender, asset(10'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + // Delete borrower's asset MPToken. + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + // Pay out and delete the broker owner's asset MPToken. + auto const lenderMPToken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleLenderMPT = env.le(lenderMPToken); + if (!BEAST_EXPECT(sleLenderMPT)) + return; + env(pay(lender, issuer, asset(sleLenderMPT->at(sfMPTAmount)))); + env.close(); + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const borrowerMPToken = keylet::mptoken(mptt.issuanceID(), borrower); + auto const brokerKeylet = keylet::loanBroker(broker.brokerID); + auto const sleBrokerBefore = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerBefore)) + return; + auto const loanSequence = sleBrokerBefore->at(sfLoanSequence); + auto const debtTotalBefore = sleBrokerBefore->at(sfDebtTotal); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); + + auto const sleVaultBefore = env.le(keylet::vault(broker.vaultID)); + if (!BEAST_EXPECT(sleVaultBefore)) + return; + auto const assetsAvailableBefore = sleVaultBefore->at(sfAssetsAvailable); + + env(set(borrower, broker.brokerID, asset(1'000).value()), + kLoanOriginationFee(asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{fix340Enabled ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}}); + env.close(); + + auto const sleBorrowerAfter = env.le(borrowerMPToken); + auto const sleLenderAfter = env.le(lenderMPToken); + auto const sleLoanAfter = env.le(loanKeylet); + auto const sleBrokerAfter = env.le(brokerKeylet); + auto const sleVaultAfter = env.le(keylet::vault(broker.vaultID)); + if (!BEAST_EXPECT(sleVaultAfter)) + return; + if (fix340Enabled) + { + if (!BEAST_EXPECT(sleBorrowerAfter && sleLenderAfter && sleLoanAfter && sleBrokerAfter)) + return; + BEAST_EXPECT(sleBorrowerAfter->at(sfMPTAmount) == 999); + BEAST_EXPECT(sleLenderAfter->at(sfMPTAmount) == 1); + BEAST_EXPECT(sleLoanAfter->at(sfPrincipalOutstanding) == Number{1'000}); + BEAST_EXPECT(sleBrokerAfter->at(sfLoanSequence) == loanSequence + 1); + BEAST_EXPECT( + sleVaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore - Number{1'000}); + } + else + { + // The whole transaction must roll back. + BEAST_EXPECT(!sleBorrowerAfter); + BEAST_EXPECT(!sleLenderAfter); + BEAST_EXPECT(!sleLoanAfter); + if (!BEAST_EXPECT(sleBrokerAfter)) + return; + BEAST_EXPECT(sleBrokerAfter->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(sleBrokerAfter->at(sfDebtTotal) == debtTotalBefore); + BEAST_EXPECT(sleVaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore); + } + } + // LoanSet in a closed-ended vault — phase gating and maturity bound. void testLoanSetClosedEnded() @@ -838,6 +938,8 @@ public: testLoanSetClosedEnded(); testLoanSetExistingLineAfterIssuerClearsDefaultRipple(); + testLoanSetOriginationFeeTwoMptCreates(all_); + testLoanSetOriginationFeeTwoMptCreates(all_ - fixCleanup3_4_0); } }; diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 13dffb6b9e..ab74ab6811 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -85,7 +85,7 @@ protected: // // featureLendingProtocolV1_1 is excluded from the default set: it changes // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and - // most of this file's tests assert whole-life-specific expected values + // most of this file's tests assert instant-interest-recognition-specific expected values // for those fields. Tests that specifically exercise the amendment opt // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index cc30bd6091..43f2b0354c 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -1586,14 +1586,10 @@ private: // which for an integral MPT asset the destination check would reject if // it were reached. // - // ValidMPTIssuance is a separate checker and still runs. It only trips on - // the one arm that both creates and deletes an MPToken: Alice's last - // share with the asset MPToken missing, where addEmptyHolding creates the - // asset token while her share token is deleted (created + deleted > 1). - // Leftover shares with the token missing is create-only, and a last share - // with the token present is delete-only; neither exceeds one. Bob still - // owns shares throughout, so this is never the vault's final outstanding - // share. + // ValidMPTIssuance: pre-fixCleanup3_4_0, a VaultWithdraw that both + // creates and deletes an MPToken fails. Post-fixCleanup3_4_0 that is + // allowed. + // // Post-fixCleanup3_4_0, doWithdraw skips addEmptyHolding on a zero // payout and zeroDeltaIsLegitimate lets the vault-delta and diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp index 5ed242f8a4..6909a1bffa 100644 --- a/src/test/app/vault/VaultClosedEnded_test.cpp +++ b/src/test/app/vault/VaultClosedEnded_test.cpp @@ -699,9 +699,9 @@ private: env.close(); // A real loan is originated during Investment (permitted only in this phase). Zero-interest - // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis - // accounting recognise no interest at origination); AssetsAvailable drops by the loan - // principal. + // one-payment schedule keeps AssetsTotal unchanged (both instant interest recognition and + // cash-basis accounting recognise no interest at origination); AssetsAvailable drops by + // the loan principal. env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), loan::kInterestRate(TenthBips32(0)), kGracePeriod(60), diff --git a/src/test/app/vault/VaultLifecycle_test.cpp b/src/test/app/vault/VaultLifecycle_test.cpp index ce91ca857a..8d7afe67f2 100644 --- a/src/test/app/vault/VaultLifecycle_test.cpp +++ b/src/test/app/vault/VaultLifecycle_test.cpp @@ -795,6 +795,86 @@ private: }, {.requireAuth = false}); + auto const redeemAllNoAssetMpt = [this](TER expected) { + return [this, expected]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase << "MPT non-owner redeems all shares with no asset MPToken" + << (isTesSuccess(expected) ? "" : " pre-fixCleanup3_4_0"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit( + {.depositor = depositor, + .id = keylet.key, + .amount = asset(1000)}); // all assets held by depositor + env(tx); + env.close(); + + auto const vaultSle = env.le(keylet); + if (!BEAST_EXPECT(vaultSle)) + return; + auto const shareMPTID = vaultSle->at(sfShareMPTID); + + // Depositor's asset MPToken balance is now zero; delete it. + mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor); + + auto const shareKeylet = keylet::mptoken(shareMPTID, depositor.id()); + auto const sleShareBefore = env.le(shareKeylet); + if (!BEAST_EXPECT(sleShareBefore)) + return; + auto const shareAmountBefore = sleShareBefore->at(sfMPTAmount); + auto const assetsTotalBefore = vaultSle->at(sfAssetsTotal); + auto const assetsAvailableBefore = vaultSle->at(sfAssetsAvailable); + + // Redeeming ALL shares in one transaction both erases the + // now-empty share MPToken and re-creates the asset MPToken. + tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx, Ter{expected}); + env.close(); + + auto const sleAsset = env.le(mptoken); + auto const sleShare = env.le(shareKeylet); + auto const vaultAfter = env.le(keylet); + if (!BEAST_EXPECT(vaultAfter)) + return; + if (isTesSuccess(expected)) + { + if (!BEAST_EXPECT(sleAsset)) + return; + BEAST_EXPECT(sleAsset->at(sfMPTAmount) == 1000); + BEAST_EXPECT(!sleShare); + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == beast::kZero); + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == beast::kZero); + } + else + { + BEAST_EXPECT(!sleAsset); + if (!BEAST_EXPECT(sleShare)) + return; + BEAST_EXPECT(sleShare->at(sfMPTAmount) == shareAmountBefore); + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore); + BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == assetsAvailableBefore); + } + }; + }; + + testCase(redeemAllNoAssetMpt(tesSUCCESS), {.requireAuth = false}); + testCase( + redeemAllNoAssetMpt(tecINVARIANT_FAILED), + {.requireAuth = false, .features = testableAmendments() - fixCleanup3_4_0}); + auto const [acctReserve, incReserve] = [this]() -> std::pair { Env const env{*this, testableAmendments()}; return { diff --git a/src/test/basics/PerfLog_test.cpp b/src/test/basics/PerfLog_test.cpp index f7679dc488..5e790810f7 100644 --- a/src/test/basics/PerfLog_test.cpp +++ b/src/test/basics/PerfLog_test.cpp @@ -1,9 +1,7 @@ #include -#include #include -#include - +#include #include #include #include @@ -16,6 +14,7 @@ #include #include +#include #include #include #include @@ -26,7 +25,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -42,6 +43,21 @@ class PerfLog_test : public beast::unit_test::Suite using path = std::filesystem::path; + // The method names to count. PerfLog treats them as opaque keys, so these are + // made up rather than taken from the dispatch table: this test then needs no + // knowledge of the RPC layer, and does not change shape when a method is + // added or removed. + // + // String literals because PerfLog reads them back as C strings, which is what + // NullTerminatedView requires, and they must outlive the PerfLog. Sorted, + // because the counters are reported in sorted order. + static constexpr std::array kMethodNames{ + NullTerminatedView{"method_a"}, + NullTerminatedView{"method_b"}, + NullTerminatedView{"method_c"}, + NullTerminatedView{"method_d"}, + NullTerminatedView{"method_e"}}; + // We're only using Env for its Journal. That Journal gives better // coverage in unit tests. test::jtx::Env env_{*this, test::jtx::envconfig(), nullptr, beast::Severity::Disabled}; @@ -114,7 +130,7 @@ class PerfLog_test : public beast::unit_test::Suite { perf::PerfLog::Setup const setup{ .perfLog = withFile == WithFile::No ? "" : logFile(), .logInterval = logInterval()}; - return perf::makePerfLog(setup, app, j, [this]() { + return perf::makePerfLog(setup, app, kMethodNames, j, [this]() { signalStop(); return; }); @@ -310,9 +326,11 @@ public: auto perfLog{fixture.perfLog(withFile)}; perfLog->start(); - // Get the all the labels we can use for RPC interfaces without - // causing an assert. - std::vector labels = test::jtx::makeVector(xrpl::rpc::getHandlerNames()); + // The only labels the RPC interface accepts: those the PerfLog was + // constructed with, since rpcStart() reaches UNREACHABLE for any other. + // Copied into a vector because they are shuffled below, then paired + // positionally with the request ids. + auto labels = std::ranges::to(kMethodNames); std::shuffle(labels.begin(), labels.end(), defaultPrng()); // Get two IDs to associate with each label. Errors tend to happen at @@ -347,7 +365,7 @@ public: for (auto& label : labels) { // Expect every label in labels to have the same contents. - json::Value const& counter{countersJson[label]}; + json::Value const& counter{countersJson[std::string{label}]}; BEAST_EXPECT(counter[jss::duration_us] == "0"); BEAST_EXPECT(counter[jss::errored] == "0"); BEAST_EXPECT(counter[jss::finished] == "0"); @@ -370,7 +388,7 @@ public: std::uint64_t prevDur = std::numeric_limits::max(); for (int i = 0; i < currents.size(); ++i) { - BEAST_EXPECT(currents[i].name == labels[i / 2]); + BEAST_EXPECT(currents[i].name == labels[i / 2].view()); BEAST_EXPECT(prevDur > currents[i].dur); prevDur = currents[i].dur; } @@ -404,7 +422,7 @@ public: // their durations with the appropriate labels. { // The first label is special. It should have "errored" : "0". - json::Value const& first = rpc[labels[0]]; + json::Value const& first = rpc[std::string{labels[0]}]; BEAST_EXPECT(first[jss::duration_us] != "0"); BEAST_EXPECT(first[jss::errored] == "0"); BEAST_EXPECT(first[jss::finished] == "1"); @@ -415,7 +433,7 @@ public: std::uint64_t prevDur = std::numeric_limits::max(); for (int i = 1; i < labels.size(); ++i) { - json::Value const& counter{rpc[labels[i]]}; + json::Value const& counter{rpc[std::string{labels[i]}]}; std::uint64_t const dur{jsonToUInt64(counter[jss::duration_us])}; BEAST_EXPECT(dur != 0 && dur < prevDur); prevDur = dur; @@ -447,7 +465,7 @@ public: BEAST_EXPECT(only.size() == 2); BEAST_EXPECT(only.isObject()); BEAST_EXPECT(only[jss::duration_us] != "0"); - BEAST_EXPECT(only[jss::method] == labels[0]); + BEAST_EXPECT(only[jss::method] == std::string{labels[0]}); }; // Validate the final state of the PerfLog. @@ -1012,6 +1030,35 @@ public: } } + // makePerfLog() copies the range of names it is given, so only the names have + // to outlive the PerfLog. Here the range does not: it is destroyed before the + // counters are read. Retaining it instead is a use-after-free, which a + // sanitizer build reports directly and which otherwise surfaces as a failed + // assertion or a Debug-mode heap-corruption abort, not a silent pass. + void + testCallerRangeNeedNotOutlive() + { + testcase("Caller's range need not outlive the PerfLog"); + + Fixture const fixture{env_.app(), j_}; + + std::unique_ptr perfLog; + { + std::vector const names{kMethodNames.begin(), kMethodNames.end()}; + perf::PerfLog::Setup const setup{.perfLog = "", .logInterval = fixture.logInterval()}; + perfLog = perf::makePerfLog(setup, env_.app(), names, j_, []() {}); + } + + perfLog->start(); + perfLog->rpcStart(kMethodNames[0], 1); + perfLog->rpcFinish(kMethodNames[0], 1); + + // Reads the retained names, which is where a dangling range would surface. + json::Value const counters{perfLog->countersJson()[jss::rpc]}; + BEAST_EXPECT(counters.isMember(std::string{kMethodNames[0].view()})); + perfLog->stop(); + } + void run() override { @@ -1024,6 +1071,7 @@ public: testInvalidID(WithFile::Yes); testRotate(WithFile::No); testRotate(WithFile::Yes); + testCallerRangeNeedNotOutlive(); } }; diff --git a/src/test/core/Workers_test.cpp b/src/test/core/Workers_test.cpp index fe3820b84a..6824b94769 100644 --- a/src/test/core/Workers_test.cpp +++ b/src/test/core/Workers_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace xrpl { @@ -21,17 +22,17 @@ namespace perf { class PerfLogTest : public PerfLog { void - rpcStart(std::string const& method, std::uint64_t requestId) override + rpcStart(std::string_view method, std::uint64_t requestId) override { } void - rpcFinish(std::string const& method, std::uint64_t requestId) override + rpcFinish(std::string_view method, std::uint64_t requestId) override { } void - rpcError(std::string const& method, std::uint64_t dur) override + rpcError(std::string_view method, std::uint64_t requestId) override { } diff --git a/src/test/jtx/ConfidentialTransfer.h b/src/test/jtx/ConfidentialTransfer.h index 465bac03db..5c1b90328b 100644 --- a/src/test/jtx/ConfidentialTransfer.h +++ b/src/test/jtx/ConfidentialTransfer.h @@ -124,6 +124,87 @@ protected: return proof; } + // Forges a ConvertBack proof (compact sigma + single bulletproof) whose + // sigma component claims claimedBalance (which may be wrong) while binding + // to the real pedersen commitment and encrypted spending balance + // ciphertext already on the ledger. The bulletproof component is built + // from realBalance so it stays honest. + // mpt_get_convert_back_proof does not allow to build a proof whose amount + // exceeds the holder's claimed balance. + static Buffer + getForgedConvertBackProof( + test::jtx::MPTTester& mpt, + test::jtx::Account const& holder, + uint64_t claimedBalance, + uint64_t realBalance, + uint64_t amt, + Buffer const& pedersenCommitment, + Buffer const& encryptedSpendingBalance, + Buffer const& pcBlindingFactor, + uint256 const& contextHash) + { + if (pedersenCommitment.size() != kCompressedEcPointLength) + Throw("getForgedConvertBackProof: bad pedersenCommitment length"); + if (encryptedSpendingBalance.size() != kEcGamalEncryptedTotalLength) + { + Throw( + "getForgedConvertBackProof: bad encryptedSpendingBalance length"); + } + if (amt > realBalance) + Throw("getForgedConvertBackProof: amt exceeds realBalance"); + + auto* const ctx = mpt_secp256k1_context(); + auto const holderPubKey = requireOptional(mpt.getPubKey(holder), "Missing holder pubkey"); + auto const holderPrivKey = + requireOptional(mpt.getPrivKey(holder), "Missing holder privkey"); + + secp256k1_pubkey pkHolder; + if (secp256k1_ec_pubkey_parse( + ctx, &pkHolder, holderPubKey.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse holder's public key"); + + secp256k1_pubkey pcB; + if (secp256k1_ec_pubkey_parse( + ctx, &pcB, pedersenCommitment.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse pedersen commitment"); + + secp256k1_pubkey b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &b1, encryptedSpendingBalance.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + encryptedSpendingBalance.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse balance ciphertext"); + + Buffer sigmaProof(SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + if (secp256k1_compact_convertback_prove( + ctx, + sigmaProof.data(), + claimedBalance, + holderPrivKey.data(), + pcBlindingFactor.data(), + &pkHolder, + &b1, + &b2, + &pcB, + contextHash.data()) != 1) + Throw("Failed to generate convertback sigma proof"); + + auto const forgedBulletproof = + getForgedSingleBulletproof(realBalance - amt, pcBlindingFactor, contextHash); + + Buffer proof(kEcConvertBackProofLength); + std::memcpy(proof.data(), sigmaProof.data(), SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE); + std::memcpy( + proof.data() + SECP256K1_COMPACT_CONVERTBACK_PROOF_SIZE, + forgedBulletproof.data(), + kEcSingleBulletproofLength); + + return proof; + } + // Get a bad ciphertext with valid structure but cryptographic invalid for // testing purposes. For preflight test purposes. static Buffer const& @@ -347,6 +428,111 @@ protected: } }; + // Forges a ConfidentialMPTSend proof (compact sigma + double bulletproof) + // for setup.sendAmount against setup's real balance commitment/ciphertext. + // mpt_get_confidential_send_proof does not allow to build a proof whose amount + // exceeds the sender's claimed balance. + static Buffer + getForgedSendProof( + test::jtx::MPTTester& mpt, + test::jtx::Env& env, + test::jtx::Account const& sender, + test::jtx::Account const& dest, + ConfidentialSendSetup const& setup) + { + auto* const ctx = mpt_secp256k1_context(); + + secp256k1_pubkey c1; + std::vector c2Vec(setup.recipients.size()); + std::vector pkVec(setup.recipients.size()); + for (std::size_t i = 0; i < setup.recipients.size(); ++i) + { + auto const& r = setup.recipients[i]; + if (i == 0 && + secp256k1_ec_pubkey_parse( + ctx, &c1, r.encryptedAmount.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse C1"); + if (secp256k1_ec_pubkey_parse( + ctx, + &c2Vec[i], + r.encryptedAmount.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse C2"); + if (secp256k1_ec_pubkey_parse( + ctx, &pkVec[i], r.publicKey.data(), kCompressedEcPointLength) != 1) + Throw("Failed to parse recipient pubkey"); + } + + secp256k1_pubkey pkSender, pcAmount, pcBalance, b1, b2; + if (secp256k1_ec_pubkey_parse( + ctx, &pkSender, setup.senderPubKey.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcAmount, setup.amountCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &pcBalance, setup.balanceCommitment.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, &b1, setup.prevEncryptedSpending.data(), kCompressedEcPointLength) != 1 || + secp256k1_ec_pubkey_parse( + ctx, + &b2, + setup.prevEncryptedSpending.data() + kCompressedEcPointLength, + kCompressedEcPointLength) != 1) + Throw("Failed to parse commitments/ciphertext"); + + Buffer const senderPrivKey = + requireOptional(mpt.getPrivKey(sender), "Missing sender privkey"); + auto const ctxHash = getSendContextHash( + sender.id(), mpt.issuanceID(), env.seq(sender), dest.id(), setup.version); + + Buffer sigmaProof(SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + if (secp256k1_compact_standard_prove( + ctx, + sigmaProof.data(), + setup.sendAmount, + setup.prevSpending, + setup.blindingFactor.data(), + senderPrivKey.data(), + setup.balanceBlindingFactor.data(), + setup.recipients.size(), + &c1, + c2Vec.data(), + pkVec.data(), + &pcAmount, + &pkSender, + &pcBalance, + &b1, + &b2, + ctxHash.data()) != 1) + Throw("Failed to generate sigma proof"); + + // Wraps (mod 2^64) for overdrafts, unlike the ledger's own homomorphic + // commitment subtraction (mod the curve order) — that mismatch is + // exactly what makes the forged proof fail verification. + // Computed without a wrapping `uint64` subtract: Clang UBSan treats + // unsigned overflow as fatal (see incrementConfidentialVersion). + std::uint64_t const remaining = setup.sendAmount <= setup.prevSpending + ? setup.prevSpending - setup.sendAmount + : ~setup.sendAmount + setup.prevSpending + 1; + + Buffer negAmountBf(kEcBlindingFactorLength); + Buffer remainingBf(kEcBlindingFactorLength); + secp256k1_mpt_scalar_negate(negAmountBf.data(), setup.amountBlindingFactor.data()); + secp256k1_mpt_scalar_add( + remainingBf.data(), setup.balanceBlindingFactor.data(), negAmountBf.data()); + + auto const forgedBulletproof = getForgedBulletproof( + {setup.sendAmount, remaining}, {setup.amountBlindingFactor, remainingBf}, ctxHash); + + Buffer combinedProof(kEcSendProofLength); + std::memcpy(combinedProof.data(), sigmaProof.data(), SECP256K1_COMPACT_STANDARD_PROOF_SIZE); + std::memcpy( + combinedProof.data() + SECP256K1_COMPACT_STANDARD_PROOF_SIZE, + forgedBulletproof.data(), + kEcDoubleBulletproofLength); + + return combinedProof; + } + // Helper that wraps the boilerplate setup: Env + MPT creation, funding, key // generation, and seeding each holder with a confidential balance. // The caller supplies the issuer and any number of holders. @@ -418,6 +604,18 @@ protected: } }; + // Create an issuance that can hold confidential balances, with the listed + // holders funded and authorized, and a key pair generated for the issuer, + // every holder, and every extra key owner. The keys are + // generated but not registered. + static void + setupConfidentialIssuance( + test::jtx::MPTTester& mpt, + test::jtx::Account const& issuer, + std::vector const& holders, + std::vector const& keyOwners = {}, + std::uint32_t flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance); + // Set up an MPT environment suitable for batch testing. // alice is issuer; bob has 'bobAmt' in confidential spending; carol has // 'carolAmt' in confidential spending; dave is initialised with pubkey but diff --git a/src/test/jtx/PeerStub.h b/src/test/jtx/PeerStub.h new file mode 100644 index 0000000000..cabe94f351 --- /dev/null +++ b/src/test/jtx/PeerStub.h @@ -0,0 +1,185 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * A `Peer` whose every method is a no-op returning a default. + * + * Derive from this and override only the methods a test cares about. Adding a + * method to `Peer` then costs one stub here, not one per double. + * + * The id and the node public key are real, because the code under test routes + * and deduplicates on both. + */ +class PeerStub : public Peer +{ +public: + /** + * @param id The connection id reported by `id()`. + */ + explicit PeerStub(id_t id = 0) + : id_(id), nodePublicKey_(derivePublicKey(KeyType::Ed25519, randomSecretKey())) + { + } + + ~PeerStub() override = default; + + void + send(std::shared_ptr const&) override + { + } + + [[nodiscard]] beast::ip::Endpoint + getRemoteAddress() const override + { + return {}; + } + + void + sendTxQueue() override + { + } + + void + addTxQueue(uint256 const&) override + { + } + + void + removeTxQueue(uint256 const&) override + { + } + + void + charge(resource::Charge const&, std::string const&) override + { + } + + [[nodiscard]] id_t + id() const override + { + return id_; + } + + [[nodiscard]] bool + cluster() const override + { + return false; + } + + [[nodiscard]] bool + isHighLatency() const override + { + return false; + } + + [[nodiscard]] int + getScore(bool) const override + { + return 0; + } + + [[nodiscard]] PublicKey const& + getNodePublic() const override + { + return nodePublicKey_; + } + + json::Value + json() override + { + return {}; + } + + [[nodiscard]] bool + supportsFeature(ProtocolFeature) const override + { + return false; + } + + [[nodiscard]] std::optional + publisherListSequence(PublicKey const&) const override + { + return {}; + } + + void + setPublisherListSequence(PublicKey const&, std::size_t const) override + { + } + + [[nodiscard]] std::string const& + fingerprint() const override + { + return fingerprint_; + } + + [[nodiscard]] uint256 + getClosedLedgerHash() const override + { + return {}; + } + + [[nodiscard]] bool + hasLedger(uint256 const&, std::uint32_t) const override + { + return false; + } + + void + ledgerRange(std::uint32_t&, std::uint32_t&) const override + { + } + + [[nodiscard]] bool + hasTxSet(uint256 const&) const override + { + return false; + } + + void + cycleStatus() override + { + } + + bool + hasRange(std::uint32_t, std::uint32_t) override + { + return false; + } + + [[nodiscard]] bool + compressionEnabled() const override + { + return false; + } + + [[nodiscard]] bool + txReduceRelayEnabled() const override + { + return false; + } + +private: + id_t const id_; + PublicKey const nodePublicKey_; + std::string const fingerprint_; +}; + +} // namespace xrpl::test diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 801c3627b8..382a6fe333 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -43,7 +43,6 @@ #include #include #include -#include #include #include #include @@ -316,13 +315,6 @@ auto const kData = JTxFieldWrapper(sfData); auto const kAmount = JTxFieldWrapper(sfAmount); -template -auto -makeVector(Input const& input) -{ - return std::vector(std::ranges::begin(input), std::ranges::end(input)); -} - // Functions used in debugging json::Value getAccountOffers(Env& env, AccountID const& acct, bool current = false); diff --git a/src/test/jtx/impl/ConfidentialTransfer.cpp b/src/test/jtx/impl/ConfidentialTransfer.cpp new file mode 100644 index 0000000000..6c1538316c --- /dev/null +++ b/src/test/jtx/impl/ConfidentialTransfer.cpp @@ -0,0 +1,37 @@ +#include + +#include +#include + +#include +#include + +namespace xrpl { + +void +ConfidentialTransferTestBase::setupConfidentialIssuance( + test::jtx::MPTTester& mpt, + test::jtx::Account const& issuer, + std::vector const& holders, + std::vector const& keyOwners, + std::uint32_t flags) +{ + using namespace test::jtx; + mpt.create({ + .ownerCount = 1, + .flags = flags, + }); + + for (auto const& holder : holders) + { + mpt.authorize({.account = holder}); + mpt.pay(issuer, holder, 100); + mpt.generateKeyPair(holder); + } + + mpt.generateKeyPair(issuer); + for (auto const& keyOwner : keyOwners) + mpt.generateKeyPair(keyOwner); +} + +} // namespace xrpl diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index 0c1ff14eab..542043015b 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -63,14 +64,23 @@ constexpr std::uint64_t kElGamalDecryptRangeHigh = 3000; * * @param opt The optional to unwrap. * @param what Description used in the thrown exception if opt is empty. + * @param loc The call site to report in the thrown exception, defaulting to + * the immediate caller. * @return A const reference to the contained value. */ template [[nodiscard]] T const& -requireValue(std::optional const& opt, char const* what) +requireValue( + std::optional const& opt, + char const* what, + std::source_location const& loc = std::source_location::current()) { if (!opt) - Throw(what); + { + Throw( + std::string(what) + " must be present (called from " + + std::string(loc.function_name()) + ")"); + } return *opt; } @@ -92,6 +102,77 @@ makePedersenParams(PedersenProofParams const& params) return res; } +/** + * @brief Sets sfAccount on jv to the given account. + * + * @param jv The JSON object to set the field on. + * @param account The account to set. Throws if not present. + * @return The resolved account. + */ +Account const& +setAccountField(json::Value& jv, std::optional const& account) +{ + Account const& act = requireValue(account, "account"); + jv[sfAccount] = act.human(); + return act; +} + +/** + * @brief Sets sfDestination on jv to the given account. + * + * @param jv The JSON object to set the field on. + * @param dest The destination account to set. Throws if not present. + * @return The resolved account. + */ +Account const& +setDestinationField(json::Value& jv, std::optional const& dest) +{ + Account const& act = requireValue(dest, "dest"); + jv[sfDestination] = act.human(); + return act; +} + +/** + * @brief Sets sfZKProof to the given proof if present, otherwise to a + * zero-filled placeholder of the given length. + * + * @param jv The JSON object to set the field on. + * @param proof The real proof to use, if generated. + * @param dummyLen The length of the placeholder buffer to use when proof is not set. + */ +void +setProofOrDummy(json::Value& jv, std::optional const& proof, std::size_t dummyLen) +{ + jv[sfZKProof.jsonName] = strHex(proof ? *proof : gMakeZeroBuffer(dummyLen)); +} + +/** + * @brief Looks up an account's key at a given key epoch. + * + * @param keys accounts' history of keys, indexed by key epoch. + * @param account The account whose key is being looked up. + * @param epoch The key epoch, or std::nullopt for the account's latest key. + * @return The key, or std::nullopt if the account has no key at that epoch. + */ +[[nodiscard]] std::optional +keyAtEpoch( + std::unordered_map> const& keys, + AccountID const& account, + std::optional epoch) +{ + auto const it = keys.find(account); + if (it == keys.end() || it->second.empty()) + return std::nullopt; + + if (!epoch) + return it->second.back(); + + if (*epoch >= it->second.size()) + return std::nullopt; + + return it->second[*epoch]; +} + } // namespace void @@ -659,6 +740,33 @@ MPTTester::checkKeyEpochs( }); } +[[nodiscard]] bool +MPTTester::checkMirrorEpochs( + Account const& holder, + std::optional issuerKeyMirrorEpoch, + std::optional auditorKeyMirrorEpoch) const +{ + return forObject( + [&](SLEP const& sle) -> bool { + return (*sle)[~sfIssuerKeyMirrorEpoch] == issuerKeyMirrorEpoch && + (*sle)[~sfAuditorKeyMirrorEpoch] == auditorKeyMirrorEpoch; + }, + holder); +} + +[[nodiscard]] std::optional +MPTTester::getMirrorEpoch(Account const& holder, SF_UINT32 const& field) const +{ + std::optional epoch; + forObject( + [&](SLEP const& sle) -> bool { + epoch = (*sle)[~field]; + return true; + }, + holder); + return epoch; +} + [[nodiscard]] bool MPTTester::checkEncryptionKeys( std::optional const& issuerKeyOwner, @@ -949,7 +1057,7 @@ MPTTester::getPedersenCommitment(std::uint64_t const amount, Buffer const& peder return buf; } -Buffer +std::optional MPTTester::getConvertBackProof( Account const& holder, std::uint64_t const amount, @@ -961,13 +1069,13 @@ MPTTester::getConvertBackProof( auto const sleMptoken = env_.le(keylet::mptoken(issuanceID(), holder.id())); if (!sleMptoken || !sleMptoken->isFieldPresent(sfConfidentialBalanceSpending)) - return gMakeZeroBuffer(kExpectedProofLength); + return std::nullopt; auto const holderPubKey = getPubKey(holder); auto const holderPrivKey = getPrivKey(holder); if (!holderPubKey || !holderPrivKey) - return gMakeZeroBuffer(kExpectedProofLength); + return std::nullopt; auto const pedersenParams = makePedersenParams(pcParams); Buffer proof(kExpectedProofLength); @@ -979,7 +1087,7 @@ MPTTester::getConvertBackProof( amount, &pedersenParams, proof.data()) != 0) - return gMakeZeroBuffer(kExpectedProofLength); + return std::nullopt; return proof; } @@ -990,33 +1098,32 @@ MPTTester::getEncryptedBalance(Account const& account, EncryptedBalanceType opti if (!id_) Throw("MPT has not been created"); - if (auto const sle = env_.le(keylet::mptoken(*id_, account.id()))) + auto const sle = env_.le(keylet::mptoken(*id_, account.id())); + if (!sle) + return {}; + + SField const* field = nullptr; + switch (option) { - if (option == holderEncryptedInbox && sle->isFieldPresent(sfConfidentialBalanceInbox)) - { - return Buffer( - (*sle)[sfConfidentialBalanceInbox].data(), - (*sle)[sfConfidentialBalanceInbox].size()); - } - if (option == holderEncryptedSpending && sle->isFieldPresent(sfConfidentialBalanceSpending)) - { - return Buffer( - (*sle)[sfConfidentialBalanceSpending].data(), - (*sle)[sfConfidentialBalanceSpending].size()); - } - if (option == issuerEncryptedBalance && sle->isFieldPresent(sfIssuerEncryptedBalance)) - { - return Buffer( - (*sle)[sfIssuerEncryptedBalance].data(), (*sle)[sfIssuerEncryptedBalance].size()); - } - if (option == auditorEncryptedBalance && sle->isFieldPresent(sfAuditorEncryptedBalance)) - { - return Buffer( - (*sle)[sfAuditorEncryptedBalance].data(), (*sle)[sfAuditorEncryptedBalance].size()); - } + case holderEncryptedInbox: + field = &sfConfidentialBalanceInbox; + break; + case holderEncryptedSpending: + field = &sfConfidentialBalanceSpending; + break; + case issuerEncryptedBalance: + field = &sfIssuerEncryptedBalance; + break; + case auditorEncryptedBalance: + field = &sfAuditorEncryptedBalance; + break; } - return {}; + if (field == nullptr || !sle->isFieldPresent(*field)) + return {}; + + auto const blob = sle->getFieldVL(*field); + return Buffer(blob.data(), blob.size()); } std::uint32_t @@ -1033,6 +1140,33 @@ MPTTester::getFlags(std::optional const& holder) const return flags; } +void +MPTTester::setIssuanceIdField(json::Value& jv, std::optional const& id) const +{ + if (id) + { + jv[sfMPTokenIssuanceID] = to_string(*id); + } + else if (id_) + { + jv[sfMPTokenIssuanceID] = to_string(*id_); + } + else + { + Throw("MPT has not been created"); + } +} + +std::uint32_t +MPTTester::ticketOrSeq( + std::optional const& ticketSeq, + std::optional const& account) const +{ + if (ticketSeq) + return *ticketSeq; + return env_.seq(requireValue(account, "account")); +} + MPT MPTTester::operator[](std::string const& name) const { @@ -1050,47 +1184,37 @@ void MPTTester::fillConversionCiphertexts( T const& arg, json::Value& jv, - Buffer& holderCiphertext, - Buffer& issuerCiphertext, - std::optional& auditorCiphertext, - Buffer& blindingFactor) const + Account const& account, + std::uint64_t const amount) const { - blindingFactor = arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); + Buffer const blindingFactor = + arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); + jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); // Handle Holder - if (arg.holderEncryptedAmt) - { - holderCiphertext = *arg.holderEncryptedAmt; - } - else - { - holderCiphertext = encryptAmount( - requireValue(arg.account, "account"), requireValue(arg.amt, "amt"), blindingFactor); - } + Buffer const holderCiphertext = arg.holderEncryptedAmt + ? *arg.holderEncryptedAmt + : encryptAmount(account, amount, blindingFactor); jv[sfHolderEncryptedAmount.jsonName] = strHex(holderCiphertext); // Handle Issuer - if (arg.issuerEncryptedAmt) - { - issuerCiphertext = *arg.issuerEncryptedAmt; - } - else - { - issuerCiphertext = encryptAmount(issuer_, requireValue(arg.amt, "amt"), blindingFactor); - } + Buffer const issuerCiphertext = arg.issuerEncryptedAmt + ? *arg.issuerEncryptedAmt + : encryptAmount(issuer_, amount, blindingFactor); jv[sfIssuerEncryptedAmount.jsonName] = strHex(issuerCiphertext); // Handle Auditor + std::optional auditorCiphertext; if (arg.auditorEncryptedAmt) { auditorCiphertext = *arg.auditorEncryptedAmt; } else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false)) { - auditorCiphertext = encryptAmount( - requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor); + auditorCiphertext = + encryptAmount(requireValue(auditor_, "auditor"), amount, blindingFactor); } // Update auditor JSON only if ciphertext exists @@ -1101,81 +1225,30 @@ MPTTester::fillConversionCiphertexts( void MPTTester::convert(MPTConvert const& arg) { - json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + json::Value const jv = convertJV(arg, ticketOrSeq(arg.ticketSeq, arg.account)); - jv[jss::TransactionType] = jss::ConfidentialMPTConvert; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + Account const& account = requireValue(arg.account, "account"); + auto const amt = requireValue(arg.amt, "amt"); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); - if (arg.holderPubKey) - jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey); - - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; - - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); - if (arg.proof) - { - jv[sfZKProof.jsonName] = *arg.proof; - } - else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value())) - { - // whether to automatically generate and attach a Schnorr proof: - // if fillSchnorrProof is explicitly set, follow its value; - // otherwise, default to generating the proof only if holder pub key is - // present. - auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); - auto const contextHash = - getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq); - - auto const proof = getSchnorrProof(*arg.account, contextHash); - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength)); - } - } - - auto const holderAmt = getBalance(*arg.account); + auto const holderAmt = getBalance(account); auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance(); - auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); + auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) Throw("Failed to get Pre-convert balance"); + // The auditor mirror is only touched if the transaction carries an auditor + // ciphertext, which mirrors the condition convertJV fills it under. + bool const hasAuditorAmt = + arg.auditorEncryptedAmt || (auditor_ && arg.fillAuditorEncryptedAmt.value_or(false)); + std::optional prevAuditorBalance; - if (arg.auditorEncryptedAmt || auditor_) + if (hasAuditorAmt) { - prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance); + prevAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!prevAuditorBalance) Throw("Failed to get Pre-convert balance"); } @@ -1186,59 +1259,57 @@ MPTTester::convert(MPTConvert const& arg) { auto const postConfidentialOutstanding = getIssuanceConfidentialBalance(); auto const postOutstanding = getIssuanceOutstandingBalance(); - env_.require(MptBalance( - *this, requireValue(arg.account, "account"), holderAmt - requireValue(arg.amt, "amt"))); + env_.require(MptBalance(*this, account, holderAmt - amt)); env_.require(RequireAny([&]() -> bool { return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding; })); env_.require(RequireAny([&]() -> bool { - return prevConfidentialOutstanding + *arg.amt == postConfidentialOutstanding; + return prevConfidentialOutstanding + amt == postConfidentialOutstanding; })); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, holderEncryptedInbox).has_value(); + return getEncryptedBalance(account, holderEncryptedInbox).has_value(); })); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, holderEncryptedSpending).has_value(); + return getEncryptedBalance(account, holderEncryptedSpending).has_value(); })); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, issuerEncryptedBalance).has_value(); + return getEncryptedBalance(account, issuerEncryptedBalance).has_value(); })); - auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance) Throw("Failed to get post-convert balance"); - if (arg.auditorEncryptedAmt || auditor_) + if (hasAuditorAmt) { - auto const postAuditorBalance = - getDecryptedBalance(*arg.account, auditorEncryptedBalance); + auto const postAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!postAuditorBalance) Throw("Failed to get post-convert auditor balance"); env_.require(RequireAny([&]() -> bool { - return getEncryptedBalance(*arg.account, auditorEncryptedBalance).has_value(); + return getEncryptedBalance(account, auditorEncryptedBalance).has_value(); })); // auditor's encrypted balance is updated correctly env_.require(RequireAny( - [&]() -> bool { return *prevAuditorBalance + *arg.amt == *postAuditorBalance; })); + [&]() -> bool { return *prevAuditorBalance + amt == *postAuditorBalance; })); } // spending balance should not change env_.require( RequireAny([&]() -> bool { return *postSpendingBalance == *prevSpendingBalance; })); // issuer's encrypted balance is updated correctly - env_.require(RequireAny( - [&]() -> bool { return *prevIssuerBalance + *arg.amt == *postIssuerBalance; })); + env_.require( + RequireAny([&]() -> bool { return *prevIssuerBalance + amt == *postIssuerBalance; })); // holder's inbox balance is updated correctly - env_.require(RequireAny( - [&]() -> bool { return *prevInboxBalance + *arg.amt == *postInboxBalance; })); + env_.require( + RequireAny([&]() -> bool { return *prevInboxBalance + amt == *postInboxBalance; })); // sum of holder's inbox and spending balance should equal to issuer's // encrypted balance @@ -1253,7 +1324,7 @@ MPTTester::convert(MPTConvert const& arg) [&](SLEP const& sle) -> bool { if (sle) { - auto const holderPubKey = getPubKey(*arg.account); + auto const holderPubKey = getPubKey(account); if (!holderPubKey) { Throw( @@ -1265,7 +1336,7 @@ MPTTester::convert(MPTConvert const& arg) } return false; }, - arg.account); + account); })); } } @@ -1275,41 +1346,17 @@ json::Value MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq) { json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + Account const& account = setAccountField(jv, arg.account); jv[jss::TransactionType] = jss::ConfidentialMPTConvert; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setIssuanceIdField(jv, arg.id); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + auto const amt = requireValue(arg.amt, "amt"); + jv[sfMPTAmount.jsonName] = std::to_string(amt); if (arg.holderPubKey) jv[sfHolderEncryptionKey.jsonName] = strHex(*arg.holderPubKey); - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; - - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor.jsonName] = strHex(blindingFactor); + fillConversionCiphertexts(arg, jv, account, amt); if (arg.proof) { @@ -1317,17 +1364,8 @@ MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq) } else if (arg.fillSchnorrProof.value_or(arg.holderPubKey.has_value())) { - auto const contextHash = - getConvertContextHash(requireValue(arg.account, "account").id(), issuanceID(), seq); - auto const proof = getSchnorrProof(*arg.account, contextHash); - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSchnorrProofLength)); - } + auto const contextHash = getConvertContextHash(account.id(), issuanceID(), seq); + setProofOrDummy(jv, getSchnorrProof(account, contextHash), kEcSchnorrProofLength); } return jv; @@ -1336,253 +1374,48 @@ MPTTester::convertJV(MPTConvert const& arg, std::uint32_t seq) void MPTTester::send(MPTConfidentialSend const& arg) { - json::Value jv; - jv[jss::TransactionType] = jss::ConfidentialMPTSend; + json::Value const jv = sendJV(arg, ticketOrSeq(arg.ticketSeq, arg.account)); - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } - - if (arg.dest) - { - jv[sfDestination] = arg.dest->human(); - } - else - { - Throw("Destination not specified"); - } - - if (!arg.amt) - Throw("Amount not specified for testing purposes"); - - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } - - Buffer const blindingFactor = - arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); - - // fill in the encrypted amounts if not provided - auto const senderAmt = arg.senderEncryptedAmt - ? *arg.senderEncryptedAmt - : encryptAmount(*arg.account, *arg.amt, blindingFactor); - auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt - : encryptAmount(*arg.dest, *arg.amt, blindingFactor); - auto const issuerAmt = arg.issuerEncryptedAmt - ? *arg.issuerEncryptedAmt - : encryptAmount(issuer_, *arg.amt, blindingFactor); - - std::optional auditorAmt; - if (arg.auditorEncryptedAmt) - { - auditorAmt = arg.auditorEncryptedAmt; - } - else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false)) - { - auditorAmt = encryptAmount( - requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor); - } - - jv[sfSenderEncryptedAmount] = strHex(senderAmt); - jv[sfDestinationEncryptedAmount] = strHex(destAmt); - jv[sfIssuerEncryptedAmount] = strHex(issuerAmt); - if (auditorAmt) - jv[sfAuditorEncryptedAmount] = strHex(*auditorAmt); - - if (arg.credentials) - { - auto& arr(jv[sfCredentialIDs.jsonName] = json::ValueType::Array); - for (auto const& hash : *arg.credentials) - arr.append(hash); - } + Account const& account = requireValue(arg.account, "account"); + Account const& dest = requireValue(arg.dest, "dest"); + auto const amt = requireValue(arg.amt, "amt"); // Version counters before send - auto const prevSenderVersion = getMPTokenVersion(*arg.account); - auto const prevDestVersion = getMPTokenVersion(*arg.dest); + auto const prevSenderVersion = getMPTokenVersion(account); + auto const prevDestVersion = getMPTokenVersion(dest); // Sender's previous confidential state - auto const prevSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const prevSenderInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSenderSpendingEncrypted = - getEncryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevSenderIssuerEncrypted = - getEncryptedBalance(*arg.account, issuerEncryptedBalance); + auto const prevSenderInbox = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSenderSpending = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevSenderIssuer = getDecryptedBalance(account, issuerEncryptedBalance); if (!prevSenderInbox || !prevSenderSpending || !prevSenderIssuer) Throw("Failed to get Pre-send balance"); std::optional prevSenderAuditor; - auto const prevSenderAuditorEncrypted = - getEncryptedBalance(*arg.account, auditorEncryptedBalance); if (arg.auditorEncryptedAmt || auditor_) { - prevSenderAuditor = getDecryptedBalance(*arg.account, auditorEncryptedBalance); + prevSenderAuditor = getDecryptedBalance(account, auditorEncryptedBalance); if (!prevSenderAuditor) Throw("Failed to get Pre-send balance"); } // Destination's previous confidential state - auto const prevDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox); - auto const prevDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending); - auto const prevDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance); - auto const prevDestInboxEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedInbox); - auto const prevDestSpendingEncrypted = getEncryptedBalance(*arg.dest, holderEncryptedSpending); - auto const prevDestIssuerEncrypted = getEncryptedBalance(*arg.dest, issuerEncryptedBalance); + auto const prevDestInbox = getDecryptedBalance(dest, holderEncryptedInbox); + auto const prevDestSpending = getDecryptedBalance(dest, holderEncryptedSpending); + auto const prevDestIssuer = getDecryptedBalance(dest, issuerEncryptedBalance); if (!prevDestInbox || !prevDestSpending || !prevDestIssuer) Throw("Failed to get Pre-send balance"); std::optional prevDestAuditor; - auto const prevDestAuditorEncrypted = getEncryptedBalance(*arg.dest, auditorEncryptedBalance); if (arg.auditorEncryptedAmt || auditor_) { - prevDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance); + prevDestAuditor = getDecryptedBalance(dest, auditorEncryptedBalance); if (!prevDestAuditor) Throw("Failed to get Pre-send balance"); } - // Fill in the commitment if not provided - // The amount commitment must use the same blinding factor as the ElGamal - // encryption. The sigma proof links the two, so using different randomness - // for each would cause proof verification to fail. - Buffer amountCommitment, balanceCommitment; - if (arg.amountCommitment) - { - amountCommitment = *arg.amountCommitment; - } - else - { - amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor); - } - - jv[sfAmountCommitment] = strHex(amountCommitment); - - auto const balanceBlindingFactor = generateBlindingFactor(); - if (arg.balanceCommitment) - { - balanceCommitment = *arg.balanceCommitment; - } - else - { - balanceCommitment = getPedersenCommitment(*prevSenderSpending, balanceBlindingFactor); - } - - jv[sfBalanceCommitment] = strHex(balanceCommitment); - - // Fill in the proof if not provided - if (arg.proof) - { - jv[sfZKProof] = *arg.proof; - } - else - { - auto const version = getMPTokenVersion(*arg.account); - auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); - auto const ctxHash = getSendContextHash( - requireValue(arg.account, "account").id(), - issuanceID(), - seq, - requireValue(arg.dest, "dest").id(), - version); - - std::vector recipients; - - auto const senderPubKey = getPubKey(*arg.account); - auto const destPubKey = getPubKey(*arg.dest); - auto const issuerPubKey = getPubKey(issuer_); - - // If a key is missing, we skip adding the recipient. This intentionally - // causes proof generation to fail, triggering the dummy proof fallback. - if (senderPubKey) - { - recipients.push_back({ - .publicKey = Slice(*senderPubKey), - .encryptedAmount = senderAmt, - }); - } - if (destPubKey) - { - recipients.push_back({ - .publicKey = Slice(*destPubKey), - .encryptedAmount = destAmt, - }); - } - if (issuerPubKey) - { - recipients.push_back({ - .publicKey = Slice(*issuerPubKey), - .encryptedAmount = issuerAmt, - }); - } - - std::optional auditorPubKey; - if (auditorAmt) - { - if (!auditor_) - Throw("Auditor not registered"); - - auditorPubKey = getPubKey(*auditor_); - if (auditorPubKey) - { - recipients.push_back({ - .publicKey = Slice(*auditorPubKey), - .encryptedAmount = *auditorAmt, - }); - } - } - - std::optional proof; - - // Skip proof generation if encrypted balance is missing (e.g., - // feature disabled), when the sender and destination are the same - // (malformed case causing pcm to be zero), or when spending balance - // is 0 - if (arg.account != arg.dest && prevSenderSpendingEncrypted && *prevSenderSpending > 0) - { - proof = getConfidentialSendProof( - *arg.account, - *arg.amt, - recipients, - blindingFactor, - ctxHash, - { - .pedersenCommitment = amountCommitment, - .amt = *arg.amt, - .encryptedAmt = senderAmt, - .blindingFactor = blindingFactor, - }, - { - .pedersenCommitment = balanceCommitment, - .amt = *prevSenderSpending, - .encryptedAmt = *prevSenderSpendingEncrypted, - .blindingFactor = balanceBlindingFactor, - }); - } - - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength)); - } - } - - auto const senderPubAmt = getBalance(*arg.account); - auto const destPubAmt = getBalance(*arg.dest); + auto const senderPubAmt = getBalance(account); + auto const destPubAmt = getBalance(dest); auto const prevCOA = getIssuanceConfidentialBalance(); auto const prevOA = getIssuanceOutstandingBalance(); @@ -1592,24 +1425,24 @@ MPTTester::send(MPTConfidentialSend const& arg) auto const postOA = getIssuanceOutstandingBalance(); // Sender's post confidential state - auto const postSenderInbox = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postSenderSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const postSenderIssuer = getDecryptedBalance(*arg.account, issuerEncryptedBalance); + auto const postSenderInbox = getDecryptedBalance(account, holderEncryptedInbox); + auto const postSenderSpending = getDecryptedBalance(account, holderEncryptedSpending); + auto const postSenderIssuer = getDecryptedBalance(account, issuerEncryptedBalance); if (!postSenderInbox || !postSenderSpending || !postSenderIssuer) Throw("Failed to get Post-send balance"); // Destination's post confidential state - auto const postDestInbox = getDecryptedBalance(*arg.dest, holderEncryptedInbox); - auto const postDestSpending = getDecryptedBalance(*arg.dest, holderEncryptedSpending); - auto const postDestIssuer = getDecryptedBalance(*arg.dest, issuerEncryptedBalance); + auto const postDestInbox = getDecryptedBalance(dest, holderEncryptedInbox); + auto const postDestSpending = getDecryptedBalance(dest, holderEncryptedSpending); + auto const postDestIssuer = getDecryptedBalance(dest, issuerEncryptedBalance); if (!postDestInbox || !postDestSpending || !postDestIssuer) Throw("Failed to get Post-send balance"); // Public balances unchanged - env_.require(MptBalance(*this, *arg.account, senderPubAmt)); - env_.require(MptBalance(*this, *arg.dest, destPubAmt)); + env_.require(MptBalance(*this, account, senderPubAmt)); + env_.require(MptBalance(*this, dest, destPubAmt)); // OA and COA unchanged env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; })); @@ -1617,21 +1450,18 @@ MPTTester::send(MPTConfidentialSend const& arg) // Verify sender changes env_.require(RequireAny([&]() -> bool { - return *prevSenderSpending >= *arg.amt && - *postSenderSpending == *prevSenderSpending - *arg.amt; + return *prevSenderSpending >= amt && *postSenderSpending == *prevSenderSpending - amt; })); env_.require(RequireAny([&]() -> bool { return postSenderInbox == prevSenderInbox; })); env_.require(RequireAny([&]() -> bool { - return *prevSenderIssuer >= *arg.amt && - *postSenderIssuer == *prevSenderIssuer - *arg.amt; + return *prevSenderIssuer >= amt && *postSenderIssuer == *prevSenderIssuer - amt; })); // Verify destination changes - env_.require( - RequireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + *arg.amt; })); + env_.require(RequireAny([&]() -> bool { return *postDestInbox == *prevDestInbox + amt; })); env_.require(RequireAny([&]() -> bool { return *postDestSpending == *prevDestSpending; })); env_.require( - RequireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + *arg.amt; })); + RequireAny([&]() -> bool { return *postDestIssuer == *prevDestIssuer + amt; })); // Cross checks env_.require(RequireAny( @@ -1641,15 +1471,14 @@ MPTTester::send(MPTConfidentialSend const& arg) // Version: sender increments by 1; receiver version is unchanged by incoming sends env_.require(RequireAny( - [&]() -> bool { return getMPTokenVersion(*arg.account) == prevSenderVersion + 1; })); + [&]() -> bool { return getMPTokenVersion(account) == prevSenderVersion + 1; })); env_.require( - RequireAny([&]() -> bool { return getMPTokenVersion(*arg.dest) == prevDestVersion; })); + RequireAny([&]() -> bool { return getMPTokenVersion(dest) == prevDestVersion; })); if (arg.auditorEncryptedAmt || auditor_) { - auto const postSenderAuditor = - getDecryptedBalance(*arg.account, auditorEncryptedBalance); - auto const postDestAuditor = getDecryptedBalance(*arg.dest, auditorEncryptedBalance); + auto const postSenderAuditor = getDecryptedBalance(account, auditorEncryptedBalance); + auto const postDestAuditor = getDecryptedBalance(dest, auditorEncryptedBalance); if (!postSenderAuditor || !postDestAuditor) Throw("Failed to get Post-send balance"); @@ -1660,13 +1489,12 @@ MPTTester::send(MPTConfidentialSend const& arg) // verify sender env_.require(RequireAny([&]() -> bool { - return prevSenderAuditor >= *arg.amt && - *postSenderAuditor == *prevSenderAuditor - *arg.amt; + return *prevSenderAuditor >= amt && *postSenderAuditor == *prevSenderAuditor - amt; })); // verify dest - env_.require(RequireAny( - [&]() -> bool { return *postDestAuditor == *prevDestAuditor + *arg.amt; })); + env_.require( + RequireAny([&]() -> bool { return *postDestAuditor == *prevDestAuditor + amt; })); } } } @@ -1680,49 +1508,21 @@ MPTTester::sendJV( json::Value jv; jv[jss::TransactionType] = jss::ConfidentialMPTSend; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + Account const& account = setAccountField(jv, arg.account); + Account const& dest = setDestinationField(jv, arg.dest); + auto const amt = requireValue(arg.amt, "amt"); - if (arg.dest) - { - jv[sfDestination] = arg.dest->human(); - } - else - { - Throw("Destination not specified"); - } - - if (!arg.amt) - Throw("Amount not specified for testing purposes"); - - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setIssuanceIdField(jv, arg.id); Buffer const blindingFactor = arg.blindingFactor ? *arg.blindingFactor : generateBlindingFactor(); - auto const senderAmt = arg.senderEncryptedAmt - ? *arg.senderEncryptedAmt - : encryptAmount(*arg.account, *arg.amt, blindingFactor); - auto const destAmt = arg.destEncryptedAmt ? *arg.destEncryptedAmt - : encryptAmount(*arg.dest, *arg.amt, blindingFactor); - auto const issuerAmt = arg.issuerEncryptedAmt - ? *arg.issuerEncryptedAmt - : encryptAmount(issuer_, *arg.amt, blindingFactor); + auto const senderAmt = arg.senderEncryptedAmt ? *arg.senderEncryptedAmt + : encryptAmount(account, amt, blindingFactor); + auto const destAmt = + arg.destEncryptedAmt ? *arg.destEncryptedAmt : encryptAmount(dest, amt, blindingFactor); + auto const issuerAmt = arg.issuerEncryptedAmt ? *arg.issuerEncryptedAmt + : encryptAmount(issuer_, amt, blindingFactor); std::optional auditorAmt; if (arg.auditorEncryptedAmt) @@ -1731,8 +1531,7 @@ MPTTester::sendJV( } else if (auditor_.has_value() && arg.fillAuditorEncryptedAmt.value_or(false)) { - auditorAmt = encryptAmount( - requireValue(auditor_, "auditor"), requireValue(arg.amt, "amt"), blindingFactor); + auditorAmt = encryptAmount(requireValue(auditor_, "auditor"), amt, blindingFactor); } jv[sfSenderEncryptedAmount] = strHex(senderAmt); @@ -1759,12 +1558,12 @@ MPTTester::sendJV( } else { - auto const ledgerSpending = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const ledgerSpending = getDecryptedBalance(account, holderEncryptedSpending); if (!ledgerSpending) Throw("Failed to get sender spending balance"); prevSenderSpending = *ledgerSpending; - prevEncryptedSenderSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending); - version = getMPTokenVersion(*arg.account); + prevEncryptedSenderSpending = getEncryptedBalance(account, holderEncryptedSpending); + version = getMPTokenVersion(account); } // The amount commitment must use the same blinding factor as the tx ElGamal @@ -1776,7 +1575,7 @@ MPTTester::sendJV( } else { - amountCommitment = getPedersenCommitment(*arg.amt, blindingFactor); + amountCommitment = getPedersenCommitment(amt, blindingFactor); } jv[sfAmountCommitment] = strHex(amountCommitment); @@ -1799,17 +1598,13 @@ MPTTester::sendJV( } else { - auto const ctxHash = getSendContextHash( - requireValue(arg.account, "account").id(), - issuanceID(), - seq, - requireValue(arg.dest, "dest").id(), - version); + auto const ctxHash = + getSendContextHash(account.id(), issuanceID(), seq, dest.id(), version); std::vector recipients; - auto const senderPubKey = getPubKey(*arg.account); - auto const destPubKey = getPubKey(*arg.dest); + auto const senderPubKey = getPubKey(account); + auto const destPubKey = getPubKey(dest); auto const issuerPubKey = getPubKey(issuer_); if (senderPubKey) @@ -1852,17 +1647,17 @@ MPTTester::sendJV( std::optional proof; // Skip proof generation when spending balance is 0 - if (arg.account != arg.dest && prevEncryptedSenderSpending && prevSenderSpending > 0) + if (prevEncryptedSenderSpending && prevSenderSpending > 0) { proof = getConfidentialSendProof( - *arg.account, - *arg.amt, + account, + amt, recipients, blindingFactor, ctxHash, { .pedersenCommitment = amountCommitment, - .amt = *arg.amt, + .amt = amt, .encryptedAmt = senderAmt, .blindingFactor = blindingFactor, }, @@ -1874,14 +1669,7 @@ MPTTester::sendJV( }); } - if (proof) - { - jv[sfZKProof.jsonName] = strHex(*proof); - } - else - { - jv[sfZKProof.jsonName] = strHex(gMakeZeroBuffer(kEcSendProofLength)); - } + setProofOrDummy(jv, proof, kEcSendProofLength); } return jv; @@ -1944,31 +1732,14 @@ MPTTester::confidentialClaw(MPTConfidentialClawback const& arg) auto const account = arg.account ? *arg.account : issuer_; jv[sfAccount] = account.human(); - if (arg.holder) - { - jv[sfHolder] = arg.holder->human(); - } - else - { - Throw("Holder not specified"); - } + Account const& holder = requireValue(arg.holder, "holder"); + jv[sfHolder] = holder.human(); jv[jss::TransactionType] = jss::ConfidentialMPTClawback; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else if (id_) - { - jv[sfMPTokenIssuanceID] = to_string(*id_); - } - else - { - Throw("MPT has not been created"); - } + setIssuanceIdField(jv, arg.id); - if (arg.amt) - jv[sfMPTAmount] = std::to_string(*arg.amt); + auto const amt = requireValue(arg.amt, "amt"); + jv[sfMPTAmount] = std::to_string(amt); if (arg.proof) { @@ -1977,69 +1748,56 @@ MPTTester::confidentialClaw(MPTConfidentialClawback const& arg) else { auto const seq = arg.ticketSeq ? *arg.ticketSeq : env_.seq(account); - auto const contextHash = getClawbackContextHash( - account.id(), issuanceID(), seq, requireValue(arg.holder, "holder").id()); + auto const contextHash = + getClawbackContextHash(account.id(), issuanceID(), seq, holder.id()); auto const privKey = getPrivKey(account); if (!privKey || privKey->size() != kEcPrivKeyLength) Throw("Failed to get clawback private key"); - auto const proof = getClawbackProof( - requireValue(arg.holder, "holder"), - requireValue(arg.amt, "amt"), - requireValue(privKey, "privKey"), - contextHash); + auto const proof = + getClawbackProof(holder, amt, requireValue(privKey, "privKey"), contextHash); - if (proof) - { - jv[sfZKProof] = strHex(*proof); - } - else - { - jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcClawbackProofLength)); - } + setProofOrDummy(jv, proof, kEcClawbackProofLength); } - auto const holderPubAmt = getBalance(*arg.holder); + auto const holderPubAmt = getBalance(holder); auto const prevCOA = getIssuanceConfidentialBalance(); auto const prevOA = getIssuanceOutstandingBalance(); - auto const prevVersion = getMPTokenVersion(*arg.holder); + auto const prevVersion = getMPTokenVersion(holder); if (submit(arg, jv) == tesSUCCESS) { auto const postCOA = getIssuanceConfidentialBalance(); auto const postOA = getIssuanceOutstandingBalance(); - auto const postVersion = getMPTokenVersion(*arg.holder); + auto const postVersion = getMPTokenVersion(holder); // Verify holder's public balance is unchanged - env_.require(MptBalance(*this, *arg.holder, holderPubAmt)); + env_.require(MptBalance(*this, holder, holderPubAmt)); // Verify COA and OA are reduced correctly - env_.require(RequireAny( - [&]() -> bool { return prevCOA >= *arg.amt && postCOA == prevCOA - *arg.amt; })); + env_.require( + RequireAny([&]() -> bool { return prevCOA >= amt && postCOA == prevCOA - amt; })); env_.require(RequireAny([&]() -> bool { - return prevOA && postOA && *prevOA >= *arg.amt && *postOA == *prevOA - *arg.amt; + return prevOA && postOA && *prevOA >= amt && *postOA == *prevOA - amt; })); // Verify holder's confidential balances are zeroed out env_.require(RequireAny( - [&]() -> bool { return getDecryptedBalance(*arg.holder, holderEncryptedInbox) == 0; })); - env_.require(RequireAny([&]() -> bool { - return getDecryptedBalance(*arg.holder, holderEncryptedSpending) == 0; - })); - env_.require(RequireAny([&]() -> bool { - return getDecryptedBalance(*arg.holder, issuerEncryptedBalance) == 0; - })); - env_.require(RequireAny([&]() -> bool { - return getDecryptedBalance(*arg.holder, auditorEncryptedBalance) == 0; - })); + [&]() -> bool { return getDecryptedBalance(holder, holderEncryptedInbox) == 0; })); + env_.require(RequireAny( + [&]() -> bool { return getDecryptedBalance(holder, holderEncryptedSpending) == 0; })); + env_.require(RequireAny( + [&]() -> bool { return getDecryptedBalance(holder, issuerEncryptedBalance) == 0; })); + env_.require(RequireAny( + [&]() -> bool { return getDecryptedBalance(holder, auditorEncryptedBalance) == 0; })); // Verify version is incremented env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; })); } } -void +std::uint32_t MPTTester::generateKeyPair(Account const& account) { unsigned char privKey[kEcPrivKeyLength]; @@ -2057,26 +1815,23 @@ MPTTester::generateKeyPair(Account const& account) Throw("failed to serialize public key"); } - pubKeys_.insert({account.id(), Buffer{compressedPubKey, kEcPubKeyLength}}); - privKeys_.insert({account.id(), Buffer{privKey, kEcPrivKeyLength}}); + auto& pubKeyEpochs = pubKeys_[account.id()]; + pubKeyEpochs.emplace_back(compressedPubKey, kEcPubKeyLength); + privKeys_[account.id()].emplace_back(privKey, kEcPrivKeyLength); + + return static_cast(pubKeyEpochs.size() - 1); } std::optional -MPTTester::getPubKey(Account const& account) const +MPTTester::getPubKey(Account const& account, std::optional epoch) const { - if (auto const it = pubKeys_.find(account.id()); it != pubKeys_.end()) - return it->second; - - return std::nullopt; + return keyAtEpoch(pubKeys_, account.id(), epoch); } std::optional -MPTTester::getPrivKey(Account const& account) const +MPTTester::getPrivKey(Account const& account, std::optional epoch) const { - if (auto const it = privKeys_.find(account.id()); it != privKeys_.end()) - return it->second; - - return std::nullopt; + return keyAtEpoch(privKeys_, account.id(), epoch); } Buffer @@ -2095,7 +1850,10 @@ MPTTester::encryptAmount(Account const& account, uint64_t const amt, Buffer cons } std::optional -MPTTester::decryptAmount(Account const& account, Buffer const& amt) const +MPTTester::decryptAmount( + Account const& account, + Buffer const& amt, + std::optional epoch) const { if (amt.size() != kEcGamalEncryptedTotalLength) return std::nullopt; @@ -2104,7 +1862,7 @@ MPTTester::decryptAmount(Account const& account, Buffer const& amt) const if (!pair) return std::nullopt; - auto const privKey = getPrivKey(account); + auto const privKey = getPrivKey(account, epoch); if (!privKey || privKey->size() != kEcPrivKeyLength) return std::nullopt; @@ -2136,42 +1894,32 @@ MPTTester::getDecryptedBalance(Account const& account, EncryptedBalanceType bala Account decryptor = account; + // A mirror stays encrypted under the key it was written with, so a rotation + // leaves it readable only by that generation of the key, not the latest one. + std::optional epoch; + if (balanceType == issuerEncryptedBalance) { decryptor = issuer_; + epoch = getMirrorEpoch(account, sfIssuerKeyMirrorEpoch).value_or(0); } else if (balanceType == auditorEncryptedBalance) { if (!auditor_) return std::nullopt; decryptor = *auditor_; + epoch = getMirrorEpoch(account, sfAuditorKeyMirrorEpoch).value_or(0); } - return decryptAmount(decryptor, *encryptedAmt); -}; + return decryptAmount(decryptor, *encryptedAmt, epoch); +} json::Value MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const { json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setAccountField(jv, arg.account); + setIssuanceIdField(jv, arg.id); jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox; return jv; } @@ -2179,36 +1927,18 @@ MPTTester::mergeInboxJV(MPTMergeInbox const& arg) const void MPTTester::mergeInbox(MPTMergeInbox const& arg) { - json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + json::Value const jv = mergeInboxJV(arg); + Account const& account = requireValue(arg.account, "account"); - jv[sfTransactionType] = jss::ConfidentialMPTMergeInbox; - auto const holderPubAmt = getBalance(*arg.account); + auto const holderPubAmt = getBalance(account); auto const prevCOA = getIssuanceConfidentialBalance(); auto const prevOA = getIssuanceOutstandingBalance(); - auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const prevIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance); - auto const prevAuditorEncrypted = getEncryptedBalance(*arg.account, auditorEncryptedBalance); - auto const prevVersion = getMPTokenVersion(*arg.account); + auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const prevIssuerEncrypted = getEncryptedBalance(account, issuerEncryptedBalance); + auto const prevAuditorEncrypted = getEncryptedBalance(account, auditorEncryptedBalance); + auto const prevVersion = getMPTokenVersion(account); if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) Throw("Failed to get pre-mergeInbox balances"); @@ -2217,20 +1947,19 @@ MPTTester::mergeInbox(MPTMergeInbox const& arg) { auto const postCOA = getIssuanceConfidentialBalance(); auto const postOA = getIssuanceOutstandingBalance(); - auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postInboxEncrypted = getEncryptedBalance(*arg.account, holderEncryptedInbox); - auto const postIssuerEncrypted = getEncryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postAuditorEncrypted = - getEncryptedBalance(*arg.account, auditorEncryptedBalance); - auto const postVersion = getMPTokenVersion(*arg.account); + auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const postInboxEncrypted = getEncryptedBalance(account, holderEncryptedInbox); + auto const postIssuerEncrypted = getEncryptedBalance(account, issuerEncryptedBalance); + auto const postAuditorEncrypted = getEncryptedBalance(account, auditorEncryptedBalance); + auto const postVersion = getMPTokenVersion(account); if (!postInboxBalance || !postSpendingBalance || !postIssuerBalance || !prevIssuerEncrypted || !postInboxEncrypted || !postIssuerEncrypted) Throw("Failed to get post-mergeInbox balances"); - env_.require(MptBalance(*this, *arg.account, holderPubAmt)); + env_.require(MptBalance(*this, account, holderPubAmt)); env_.require(RequireAny([&]() -> bool { return prevOA && postOA && *prevOA == *postOA; })); env_.require(RequireAny([&]() -> bool { return prevCOA == postCOA; })); @@ -2242,14 +1971,12 @@ MPTTester::mergeInbox(MPTMergeInbox const& arg) env_.require( RequireAny([&]() -> bool { return *prevIssuerBalance == *postIssuerBalance; })); - auto const holderPubKey = getPubKey(*arg.account); + auto const holderPubKey = getPubKey(account); if (!holderPubKey) Throw("Failed to get holder public key"); auto const expectedInbox = encryptCanonicalZeroAmount( - requireValue(holderPubKey, "holderPubKey"), - requireValue(arg.account, "account").id(), - issuanceID()); + requireValue(holderPubKey, "holderPubKey"), account.id(), issuanceID()); if (!expectedInbox) Throw("Failed to get canonical zero encryption"); @@ -2301,158 +2028,74 @@ MPTTester::getMPTokenVersion(Account const account) const void MPTTester::convertBack(MPTConvertBack const& arg) { - json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + json::Value const jv = convertBackJV(arg, ticketOrSeq(arg.ticketSeq, arg.account)); - jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + Account const& account = requireValue(arg.account, "account"); + auto const amt = requireValue(arg.amt, "amt"); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); - - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; - - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor] = strHex(blindingFactor); - - auto const prevInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); - auto const prevIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); + auto const prevInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); + auto const prevIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); if (!prevInboxBalance || !prevSpendingBalance || !prevIssuerBalance) Throw("Failed to get Pre-convertBack balance"); - Buffer pedersenCommitment; - Buffer const pcBlindingFactor = generateBlindingFactor(); - if (arg.pedersenCommitment) - { - pedersenCommitment = *arg.pedersenCommitment; - } - else - { - pedersenCommitment = getPedersenCommitment(*prevSpendingBalance, pcBlindingFactor); - } - - jv[sfBalanceCommitment] = strHex(pedersenCommitment); - - if (arg.proof) - { - jv[sfZKProof.jsonName] = strHex(*arg.proof); - } - else - { - auto const version = getMPTokenVersion(*arg.account); - - // if the caller generated ciphertexts themselves, they should also - // generate the proof themselves from the blinding factor - auto const seq = arg.ticketSeq.value_or(env_.seq(*arg.account)); - auto const contextHash = getConvertBackContextHash( - requireValue(arg.account, "account").id(), issuanceID(), seq, version); - auto const prevEncryptedSpendingBalance = - getEncryptedBalance(*arg.account, holderEncryptedSpending); - - Buffer proof; - // generate a dummy proof if no encrypted amount field, so that other - // preflight/preclaim are checked - if (!prevEncryptedSpendingBalance) - { - proof = gMakeZeroBuffer(kEcConvertBackProofLength); - } - else - { - proof = getConvertBackProof( - *arg.account, - requireValue(arg.amt, "amt"), - contextHash, - { - .pedersenCommitment = pedersenCommitment, - .amt = *prevSpendingBalance, - .encryptedAmt = *prevEncryptedSpendingBalance, - .blindingFactor = pcBlindingFactor, - }); - } - jv[sfZKProof] = strHex(proof); - } - - auto const holderAmt = getBalance(*arg.account); + auto const holderAmt = getBalance(account); auto const prevConfidentialOutstanding = getIssuanceConfidentialBalance(); std::optional prevAuditorBalance; if (arg.auditorEncryptedAmt || auditor_) { - prevAuditorBalance = getDecryptedBalance(*arg.account, auditorEncryptedBalance); + prevAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!prevAuditorBalance) Throw("Failed to get Pre-convertBack balance"); } auto const prevOutstanding = getIssuanceOutstandingBalance(); - auto const prevVersion = getMPTokenVersion(*arg.account); + auto const prevVersion = getMPTokenVersion(account); if (submit(arg, jv) == tesSUCCESS) { auto const postConfidentialOutstanding = getIssuanceConfidentialBalance(); auto const postOutstanding = getIssuanceOutstandingBalance(); - auto const postVersion = getMPTokenVersion(*arg.account); - env_.require(MptBalance( - *this, requireValue(arg.account, "account"), holderAmt + requireValue(arg.amt, "amt"))); + auto const postVersion = getMPTokenVersion(account); + env_.require(MptBalance(*this, account, holderAmt + amt)); env_.require(RequireAny([&]() -> bool { return prevOutstanding && postOutstanding && *prevOutstanding == *postOutstanding; })); env_.require(RequireAny([&]() -> bool { - return prevConfidentialOutstanding - *arg.amt == postConfidentialOutstanding; + return prevConfidentialOutstanding - amt == postConfidentialOutstanding; })); - auto const postInboxBalance = getDecryptedBalance(*arg.account, holderEncryptedInbox); - auto const postIssuerBalance = getDecryptedBalance(*arg.account, issuerEncryptedBalance); - auto const postSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const postInboxBalance = getDecryptedBalance(account, holderEncryptedInbox); + auto const postIssuerBalance = getDecryptedBalance(account, issuerEncryptedBalance); + auto const postSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); if (!postInboxBalance || !postIssuerBalance || !postSpendingBalance) Throw("Failed to get post-convertBack balance"); if (arg.auditorEncryptedAmt || auditor_) { - auto const postAuditorBalance = - getDecryptedBalance(*arg.account, auditorEncryptedBalance); + auto const postAuditorBalance = getDecryptedBalance(account, auditorEncryptedBalance); if (!postAuditorBalance) Throw("Failed to get post-convertBack balance"); // auditor's encrypted balance is updated correctly env_.require(RequireAny( - [&]() -> bool { return *prevAuditorBalance - *arg.amt == *postAuditorBalance; })); + [&]() -> bool { return *prevAuditorBalance - amt == *postAuditorBalance; })); } // inbox balance should not change env_.require(RequireAny([&]() -> bool { return *postInboxBalance == *prevInboxBalance; })); // issuer's encrypted balance is updated correctly - env_.require(RequireAny( - [&]() -> bool { return *prevIssuerBalance - *arg.amt == *postIssuerBalance; })); + env_.require( + RequireAny([&]() -> bool { return *prevIssuerBalance - amt == *postIssuerBalance; })); // holder's spending balance is updated correctly env_.require(RequireAny( - [&]() -> bool { return *prevSpendingBalance - *arg.amt == *postSpendingBalance; })); + [&]() -> bool { return *prevSpendingBalance - amt == *postSpendingBalance; })); // holder's confidential balance version is updated correctly env_.require(RequireAny([&]() -> bool { return postVersion == prevVersion + 1; })); @@ -2469,41 +2112,17 @@ json::Value MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) { json::Value jv; - if (arg.account) - { - jv[sfAccount] = arg.account->human(); - } - else - { - Throw("Account not specified"); - } + Account const& account = setAccountField(jv, arg.account); jv[jss::TransactionType] = jss::ConfidentialMPTConvertBack; - if (arg.id) - { - jv[sfMPTokenIssuanceID] = to_string(*arg.id); - } - else - { - if (!id_) - Throw("MPT has not been created"); - jv[sfMPTokenIssuanceID] = to_string(*id_); - } + setIssuanceIdField(jv, arg.id); - if (arg.amt) - jv[sfMPTAmount.jsonName] = std::to_string(*arg.amt); + auto const amt = requireValue(arg.amt, "amt"); + jv[sfMPTAmount.jsonName] = std::to_string(amt); - Buffer holderCiphertext; - Buffer issuerCiphertext; - std::optional auditorCiphertext; - Buffer blindingFactor; + fillConversionCiphertexts(arg, jv, account, amt); - fillConversionCiphertexts( - arg, jv, holderCiphertext, issuerCiphertext, auditorCiphertext, blindingFactor); - - jv[sfBlindingFactor] = strHex(blindingFactor); - - auto const prevSpendingBalance = getDecryptedBalance(*arg.account, holderEncryptedSpending); + auto const prevSpendingBalance = getDecryptedBalance(account, holderEncryptedSpending); if (!prevSpendingBalance) Throw("convertBackJV: failed to read spending balance from ledger"); @@ -2526,21 +2145,17 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) } else { - auto const version = getMPTokenVersion(*arg.account); - auto const prevEncSpending = getEncryptedBalance(*arg.account, holderEncryptedSpending); - auto const contextHash = getConvertBackContextHash( - requireValue(arg.account, "account").id(), issuanceID(), seq, version); + auto const version = getMPTokenVersion(account); + auto const prevEncSpending = getEncryptedBalance(account, holderEncryptedSpending); + auto const contextHash = + getConvertBackContextHash(account.id(), issuanceID(), seq, version); - Buffer proof; - if (!prevEncSpending) - { - proof = gMakeZeroBuffer(kEcConvertBackProofLength); - } - else + std::optional proof; + if (prevEncSpending) { proof = getConvertBackProof( - *arg.account, - requireValue(arg.amt, "amt"), + account, + amt, contextHash, { .pedersenCommitment = pedersenCommitment, @@ -2550,10 +2165,39 @@ MPTTester::convertBackJV(MPTConvertBack const& arg, std::uint32_t seq) }); } - jv[sfZKProof] = strHex(proof); + setProofOrDummy(jv, proof, kEcConvertBackProofLength); } return jv; } +void +MPTTester::mirrorUpdate(MPTMirrorUpdate const& arg) +{ + json::Value jv; + jv[jss::TransactionType] = jss::ConfidentialMPTMirrorUpdate; + + setAccountField(jv, arg.account); + setIssuanceIdField(jv, arg.id); + + if (arg.holder) + jv[sfHolder] = arg.holder->human(); + if (arg.issuerEncryptedAmount) + jv[sfIssuerEncryptedAmount] = strHex(*arg.issuerEncryptedAmount); + if (arg.auditorEncryptedAmount) + jv[sfAuditorEncryptedAmount] = strHex(*arg.auditorEncryptedAmount); + + // Placeholder for proof, the logic will be added in the future + if (arg.zkProof) + { + jv[sfZKProof] = strHex(*arg.zkProof); + } + else + { + jv[sfZKProof] = strHex(gMakeZeroBuffer(kEcEqualityProofLength)); + } + + submit(arg, jv); +} + } // namespace xrpl::test::jtx diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp index f83cb7772c..6b2c9b69b9 100644 --- a/src/test/jtx/impl/utility.cpp +++ b/src/test/jtx/impl/utility.cpp @@ -78,7 +78,7 @@ fillFee(json::Value& jv, ReadView const& view) auto const txType = jv[jss::TransactionType].asString(); if (txType == jss::ConfidentialMPTConvert || txType == jss::ConfidentialMPTConvertBack || txType == jss::ConfidentialMPTSend || txType == jss::ConfidentialMPTMergeInbox || - txType == jss::ConfidentialMPTClawback) + txType == jss::ConfidentialMPTClawback || txType == jss::ConfidentialMPTMirrorUpdate) { jv[jss::Fee] = to_string(base * (kConfidentialFeeMultiplier + 1)); } diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 26329ad78c..a737e76320 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -361,6 +362,24 @@ struct MPTConfidentialClawback std::optional err = std::nullopt; }; +/** + * @brief Arguments for building a ConfidentialMPTMirrorUpdate test transaction. + */ +struct MPTMirrorUpdate +{ + std::optional account = std::nullopt; + std::optional holder = std::nullopt; + std::optional id = std::nullopt; + std::optional issuerEncryptedAmount = std::nullopt; + std::optional auditorEncryptedAmount = std::nullopt; + std::optional zkProof = std::nullopt; + std::optional fee = std::nullopt; + std::optional flags = std::nullopt; + std::optional ownerCount = std::nullopt; + std::optional holderCount = std::nullopt; + std::optional err = std::nullopt; +}; + /** * @brief Stores the parameters that are exclusively used to generate a * Pedersen linkage proof. @@ -451,8 +470,10 @@ class MPTTester std::optional const auditor_; std::optional id_; bool close_; - std::unordered_map pubKeys_; - std::unordered_map privKeys_; + // Keys generated for each account. Buffer vector's index is the key epoch: index 0 is + // the initial pair and each rotation appends. + std::unordered_map> pubKeys_; + std::unordered_map> privKeys_; public: enum class EncryptedBalanceType { @@ -581,6 +602,9 @@ public: void confidentialClaw(MPTConfidentialClawback const& arg = MPTConfidentialClawback{}); + void + mirrorUpdate(MPTMirrorUpdate const& arg = MPTMirrorUpdate{}); + [[nodiscard]] bool checkDomainID(std::optional expected) const; @@ -619,6 +643,15 @@ public: std::optional issuerKeyEpoch, std::optional auditorKeyEpoch) const; + // Checks both mirror epochs on a holder's MPToken. Pass std::nullopt for an + // epoch that is expected to be absent, which means the mirror was written + // under the issuance's epoch 0 key. + [[nodiscard]] bool + checkMirrorEpochs( + Account const& holder, + std::optional issuerKeyMirrorEpoch, + std::optional auditorKeyMirrorEpoch) const; + // Checks that the issuance carries the encryption keys of the given // accounts. Pass std::nullopt for a key that is expected to be absent, // which means the key is never registered. @@ -678,20 +711,31 @@ public: operator Asset() const; - void + // Generates the account's next key pair and returns the key epoch it landed + // at, leaving the earlier ones retrievable. + std::uint32_t generateKeyPair(Account const& account); + // Returns the account's public key at the given key epoch, or its latest key when + // no epoch is given. [[nodiscard]] std::optional - getPubKey(Account const& account) const; + getPubKey(Account const& account, std::optional epoch = std::nullopt) const; + // Returns the account's private key at the given key epoch, or its latest key when + // no epoch is given. [[nodiscard]] std::optional - getPrivKey(Account const& account) const; + getPrivKey(Account const& account, std::optional epoch = std::nullopt) const; [[nodiscard]] Buffer encryptAmount(Account const& account, uint64_t const amt, Buffer const& blindingFactor) const; + // Decrypts with the account's key at the given key epoch, or its latest key + // when no epoch is given. [[nodiscard]] std::optional - decryptAmount(Account const& account, Buffer const& amt) const; + decryptAmount( + Account const& account, + Buffer const& amt, + std::optional epoch = std::nullopt) const; [[nodiscard]] std::optional getDecryptedBalance(Account const& account, EncryptedBalanceType balanceType) const; @@ -719,7 +763,7 @@ public: PedersenProofParams const& amountParams, PedersenProofParams const& balanceParams) const; - [[nodiscard]] Buffer + [[nodiscard]] std::optional getConvertBackProof( Account const& holder, std::uint64_t const amount, @@ -745,6 +789,10 @@ private: std::function const& cb, std::optional const& holder = std::nullopt) const; + // Reads one of the holder's mirror key epochs off their MPToken. + [[nodiscard]] std::optional + getMirrorEpoch(Account const& holder, SF_UINT32 const& field) const; + template TER submit(A const& arg, json::Value jv) @@ -814,15 +862,28 @@ private: [[nodiscard]] std::uint32_t getFlags(std::optional const& holder) const; + /** + * @brief Sets sfMPTokenIssuanceID on jv, falling back to id_ if arg's id is + * not set. + * + * @param jv The JSON object to set the field on. + * @param id The explicit issuance ID override from the caller, if any. + */ + void + setIssuanceIdField(json::Value& jv, std::optional const& id) const; + + [[nodiscard]] std::uint32_t + ticketOrSeq( + std::optional const& ticketSeq, + std::optional const& account) const; + template void fillConversionCiphertexts( T const& arg, json::Value& jv, - Buffer& holderCiphertext, - Buffer& issuerCiphertext, - std::optional& auditorCiphertext, - Buffer& blindingFactor) const; + Account const& account, + std::uint64_t const amount) const; }; } // namespace xrpl::test::jtx diff --git a/src/test/overlay/CapturePeer.h b/src/test/overlay/CapturePeer.h new file mode 100644 index 0000000000..28a26d01e7 --- /dev/null +++ b/src/test/overlay/CapturePeer.h @@ -0,0 +1,252 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * A real `PeerImp` that captures the messages it would have sent. + * + * Only `send` and `run` are overridden, so `onMessage` runs production code. + * Derive from this to reach a `protected` `PeerImp` member. + */ +class CapturePeer : public PeerImp +{ +public: + using MiddleType = boost::beast::tcp_stream; + using StreamType = boost::beast::ssl_stream; + using SocketType = boost::asio::ip::tcp::socket; + + /** + * Takes `PeerImp`'s two rvalue-reference parameters by value instead, so a + * derived double can inherit this constructor without a never-moved-from + * warning. + * + * @param app The application owning the peer. + * @param id The connection id, unique among the overlay's peers. + * @param slot The peer finder slot; must be seated. + * @param request The handshake request. + * @param publicKey The peer's node public key. + * @param protocol The negotiated protocol version. + * @param consumer The resource manager endpoint for the peer. + * @param streamPtr The connection's ssl stream. + * @param overlay The overlay to register with. + */ + CapturePeer( + Application& app, + Peer::id_t id, + std::shared_ptr const& slot, + http_request_type request, + PublicKey const& publicKey, + ProtocolVersion protocol, + resource::Consumer consumer, + std::unique_ptr streamPtr, + OverlayImpl& overlay) + : PeerImp( + app, + id, + slot, + std::move(request), + publicKey, + protocol, + consumer, // copy-only, so `std::move` would be a copy anyway + std::move(streamPtr), + overlay) + { + } + + ~CapturePeer() override = default; + + /** + * Does nothing, so the peer stays registered. The real `run()` reaches + * `PeerImp::doAccept`, which fails on an unconnected socket and detaches. + */ + void + run() override + { + } + + /** + * Captures the message instead of writing it, so replies are observable. + */ + void + send(std::shared_ptr const& m) override + { + sent_.push_back(m); + } + + /** + * @return Every message sent to this peer, in order. + */ + std::vector> const& + sent() const + { + return sent_; + } + + /** + * @return The most recent message sent, or null if there was none. + */ + std::shared_ptr + lastSent() const + { + return sent_.empty() ? nullptr : sent_.back(); + } + + /** + * Reads the accumulated charge without draining it through `charge()`. + * + * @return The charge accumulated on the peer so far. + */ + resource::Charge + feeCharge() const + { + return currentFeeCharge(); + } + +private: + std::vector> sent_; +}; + +namespace detail { + +// `inline` so the functions below name one entity across translation units. +inline constexpr std::uint16_t kCapturePeerPort = 51235; + +/** + * Non-template, so all `makeCapturePeer` instantiations share one counter. A + * per-instantiation counter would give two peer types the same id, and + * `addActive` would silently drop the second from `ids_`. + * + * @return The next unused connection id. + */ +inline Peer::id_t +nextCapturePeerId() +{ + static Peer::id_t id{0}; + return ++id; +} + +/** + * Non-template for the same reason as `nextCapturePeerId`. Each peer needs its + * own address, not just its own port: the peer finder caps inbound connections + * per address at `ipLimit`, which is at most 2 unless configured. + * + * @return The next unused remote endpoint. + */ +inline beast::ip::Endpoint +nextCapturePeerRemote() +{ + // From 172.2.0.1 upward, so ~900k fit before reaching 172.16/12, where the + // peer finder would treat them as private rather than as real inbound. + static std::uint32_t next{0xAC020001}; + return beast::ip::Endpoint(boost::asio::ip::address_v4(next++), kCapturePeerPort); +} + +/** + * Outlives every peer, whose stream keeps a reference to it. `PeerImp::charge` + * posts a handler holding the peer, so a peer can outlive its caller's scope. + * + * @return The ssl context every test peer's stream is built on. + */ +inline boost::asio::ssl::context& +capturePeerSslContext() +{ + static std::shared_ptr const kContext{makeSslContext("")}; + return *kContext; +} + +} // namespace detail + +/** + * Build an active `CapturePeer` and register it with the overlay. + * + * @tparam PeerType The peer class to build; must derive from `CapturePeer` and + * inherit its constructor. + * @param env The environment owning the overlay. + * @param key The peer's node public key, or unseated for a fresh random + * one. + * @param request The handshake request. `PeerImp` reads its `X-Protocol-Ctl` + * header in the constructor to negotiate features. + * @return The peer, already registered with the overlay. Throws if the peer + * finder refused a slot. + */ +template +std::shared_ptr +makeCapturePeer( + jtx::Env& env, + std::optional key = std::nullopt, + http_request_type request = {}) +{ + auto& overlay = dynamic_cast(env.app().getOverlay()); + auto streamPtr = std::make_unique( + CapturePeer::SocketType(env.app().getIOContext()), detail::capturePeerSslContext()); + + beast::ip::Endpoint const local( + boost::asio::ip::make_address("172.1.1.1"), detail::kCapturePeerPort); + auto const remote = detail::nextCapturePeerRemote(); + + auto consumer = overlay.resourceManager().newInboundEndpoint(remote); + auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); + + // Unseated when the endpoint is already connected or at the per-address + // limit. `PeerImp` dereferences the slot, so fail here, not there. + if (!slot) + { + Throw("makeCapturePeer: no slot for " + to_string(remote)); + } + + if (!key) + key = PublicKey(std::get<0>(randomKeyPair(KeyType::Ed25519))); + + auto peer = std::make_shared( + env.app(), + detail::nextCapturePeerId(), + slot, + std::move(request), + *key, + // An unsupported version fails every `supportsFeature` test, so a + // version-gated reply would only ever take its legacy branch. + newestSupportedProtocolVersion(), + consumer, + std::move(streamPtr), + overlay); + + overlay.addActive(peer); + return peer; +} + +} // namespace xrpl::test diff --git a/src/test/overlay/ProtocolMessage_test.cpp b/src/test/overlay/ProtocolMessage_test.cpp new file mode 100644 index 0000000000..08e039f606 --- /dev/null +++ b/src/test/overlay/ProtocolMessage_test.cpp @@ -0,0 +1,296 @@ +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class ProtocolMessage_test : public beast::unit_test::Suite +{ + struct TestHandler + { + bool compression = false; + int beginCount = 0; + int messageCount = 0; + int endCount = 0; + int unknownCount = 0; + std::uint16_t lastType = 0; + + [[nodiscard]] bool + compressionEnabled() const + { + return compression; + } + + void + onMessageUnknown(std::uint16_t type) + { + ++unknownCount; + lastType = type; + } + + void + onMessageBegin( + std::uint16_t type, + std::shared_ptr<::google::protobuf::Message> const&, + std::size_t, + std::size_t, + bool) + { + ++beginCount; + lastType = type; + } + + template + void + onMessage(std::shared_ptr const&) + { + ++messageCount; + } + + void + onMessageEnd(std::uint16_t, std::shared_ptr<::google::protobuf::Message> const&) + { + ++endCount; + } + + [[nodiscard]] static std::size_t + maxManifestsMessageSize() + { + return std::numeric_limits::max(); + } + }; + + // Wire bytes: `type` (2 bytes) + unknown field tag (2 bytes) + length varint (2 bytes, as these + // tests all use unknownFieldSize >= 128). + static constexpr std::size_t kPingProtoOverheadWithUnknownLen = 6; + static constexpr std::size_t kMinimumPingSizeWithEmptyUnknownField = + compression::kHeaderBytes + kPingProtoOverheadWithUnknownLen; + static constexpr std::size_t kMinimumPingSizeCompressedWithEmptyUnknownField = + compression::kHeaderBytesCompressed + kPingProtoOverheadWithUnknownLen; + + static std::vector + makePingBuffer(std::size_t unknownFieldSize, bool compressed = false) + { + auto ping = protocol::TMPing{}; + ping.set_type(protocol::TMPing::ptPING); + if (unknownFieldSize > 0) + { + ping.mutable_unknown_fields()->AddLengthDelimited( + 42, std::string(unknownFieldSize, 'A')); + } + + if (!compressed) + { + auto m = Message{ping, protocol::mtPING}; + return m.getBuffer(compression::Compressed::Off); + } + + // Message::compress() refuses to compress pings (mtPING is not in its + // allow-list), so getBuffer(Compressed::On) would just return the + // uncompressed bytes. Roll it by hand here to get a compressed + // ping message on the wire. + auto payload = std::string{}; + ping.SerializeToString(&payload); + + auto deflated = std::vector{}; + auto const deflatedSize = compression::compress( + payload.data(), + payload.size(), + [&](std::size_t sz) { + deflated.resize(sz); + return deflated.data(); + }, + compression::Algorithm::LZ4); + deflated.resize(deflatedSize); + + auto const type = static_cast(protocol::mtPING); + auto buffer = std::vector{}; + auto pack = [&buffer](std::uint32_t value) { + buffer.push_back(static_cast((value >> 24) & 0x0F)); + buffer.push_back(static_cast((value >> 16) & 0xFF)); + buffer.push_back(static_cast((value >> 8) & 0xFF)); + buffer.push_back(static_cast(value & 0xFF)); + }; + + pack(static_cast(deflated.size())); // compressed payload size + buffer.push_back(static_cast((type >> 8) & 0xFF)); + buffer.push_back(static_cast(type & 0xFF)); + pack(static_cast(payload.size())); // uncompressed size + buffer[0] |= static_cast(compression::Algorithm::LZ4); + + buffer.insert(buffer.end(), deflated.begin(), deflated.end()); + return buffer; + } + + static std::optional + declaredPingSize(std::vector const& buffer) + { + auto ec = boost::system::error_code{}; + auto const seq = std::array{boost::asio::buffer(buffer)}; + if (auto const header = xrpl::detail::parseMessageHeader(ec, seq, buffer.size())) + { + return header->uncompressedSize + header->headerSize; + } + return std::nullopt; + } + + static std::pair + invoke(std::vector const& buffer, TestHandler& handler) + { + auto const seq = std::array{boost::asio::buffer(buffer)}; + auto hint = 0uz; + return invokeProtocolMessage(seq, handler, hint); + } + + void + testOversizedPingRejected() + { + testcase("oversized ping rejected before dispatch"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const buffer = makePingBuffer(size, compressed); + auto const declared = declaredPingSize(buffer); + if (BEAST_EXPECT(declared.has_value())) + BEAST_EXPECT(*declared > kMaximumPingMessageSize); + BEAST_EXPECT(buffer.size() < kMaximumMessageSize); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(buffer, handler); + + BEAST_EXPECT(ec == make_error_code(boost::system::errc::message_size)); + BEAST_EXPECT(bytes == 0); + BEAST_EXPECT(handler.beginCount == 0); + BEAST_EXPECT(handler.messageCount == 0); + BEAST_EXPECT(handler.endCount == 0); + }; + // Just over the cap, and comfortably over it. + runLocalTest(kMaximumPingMessageSize + 1 - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest((2 * kMaximumPingMessageSize) - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest( + kMaximumPingMessageSize + 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + (2 * kMaximumPingMessageSize) - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + } + + void + testOversizedPingRejectedFromHeaderAlone() + { + testcase("oversized ping rejected from header alone"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const full = makePingBuffer(size, compressed); + auto const headerSize = + compressed ? compression::kHeaderBytesCompressed : compression::kHeaderBytes; + + // Only the header has arrived; the declared payload is still in flight. + auto const headerOnly = + std::vector{full.begin(), full.begin() + headerSize}; + BEAST_EXPECT(headerOnly.size() < full.size()); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(headerOnly, handler); + + BEAST_EXPECT(ec == make_error_code(boost::system::errc::message_size)); + BEAST_EXPECT(bytes == 0); + BEAST_EXPECT(handler.beginCount == 0); + BEAST_EXPECT(handler.messageCount == 0); + BEAST_EXPECT(handler.endCount == 0); + }; + runLocalTest(kMaximumPingMessageSize + 1 - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest((2 * kMaximumPingMessageSize) - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest( + kMaximumPingMessageSize + 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + (2 * kMaximumPingMessageSize) - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + } + + void + testNormalPingDispatched() + { + testcase("normal ping dispatched"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const buffer = makePingBuffer(size, compressed); + auto const declared = declaredPingSize(buffer); + if (BEAST_EXPECT(declared.has_value())) + BEAST_EXPECT(*declared <= kMaximumPingMessageSize); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(buffer, handler); + + BEAST_EXPECT(!ec); + BEAST_EXPECT(bytes == buffer.size()); + BEAST_EXPECT(handler.beginCount == 1); + BEAST_EXPECT(handler.messageCount == 1); + BEAST_EXPECT(handler.endCount == 1); + }; + runLocalTest(0); + runLocalTest(0, true); + } + + void + testPingWithSmallUnknownFieldDispatched() + { + testcase("ping with small unknown field still dispatched"); + + auto runLocalTest = [&](std::size_t size, bool compressed = false) { + auto const buffer = makePingBuffer(size, compressed); + auto const declared = declaredPingSize(buffer); + if (BEAST_EXPECT(declared.has_value())) + BEAST_EXPECT(*declared <= kMaximumPingMessageSize); + + auto handler = TestHandler{}; + handler.compression = compressed; + auto const [bytes, ec] = invoke(buffer, handler); + + BEAST_EXPECT(!ec); + BEAST_EXPECT(bytes == buffer.size()); + BEAST_EXPECT(handler.beginCount == 1); + BEAST_EXPECT(handler.messageCount == 1); + BEAST_EXPECT(handler.endCount == 1); + }; + // Well under the cap, one byte under it, and exactly at it. + runLocalTest((kMaximumPingMessageSize / 2) - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest(kMaximumPingMessageSize - 1 - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest(kMaximumPingMessageSize - kMinimumPingSizeWithEmptyUnknownField); + runLocalTest( + (kMaximumPingMessageSize / 2) - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + kMaximumPingMessageSize - 1 - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + runLocalTest( + kMaximumPingMessageSize - kMinimumPingSizeCompressedWithEmptyUnknownField, true); + } + + void + run() override + { + testOversizedPingRejected(); + testOversizedPingRejectedFromHeaderAlone(); + testNormalPingDispatched(); + testPingWithSmallUnknownFieldDispatched(); + } +}; + +BEAST_DEFINE_TESTSUITE(ProtocolMessage, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/TMGetLedger_test.cpp b/src/test/overlay/TMGetLedger_test.cpp new file mode 100644 index 0000000000..eac1a24e24 --- /dev/null +++ b/src/test/overlay/TMGetLedger_test.cpp @@ -0,0 +1,135 @@ +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +using namespace jtx; + +class TMGetLedger_test : public beast::unit_test::Suite +{ + /** + * Calls the JtLedgerReq-dispatched processor synchronously, so the reply is + * visible through `lastSent()`. + */ + class GetLedgerPeer : public CapturePeer + { + public: + using CapturePeer::CapturePeer; + + void + runProcessLedgerRequest( + std::shared_ptr const& m, + std::vector nodeIDs) + { + processLedgerRequest(m, std::move(nodeIDs)); + } + }; + + // Build a well-formed TMGetLedger node request carrying `numNodeIds` node + // IDs. + static std::shared_ptr + createRequest(std::size_t const numNodeIds) + { + auto request = std::make_shared(); + request->set_itype(protocol::liTX_NODE); + + // A uint256-sized ledger hash, as a well-formed request carries. + uint256 const ledgerHash{1}; + request->set_ledgerhash(ledgerHash.data(), ledgerHash.size()); + + // Valid, deserializable SHAMap node IDs. + auto const rootNodeId = SHAMapNodeID{}.getRawString(); + for (std::size_t i = 0; i < numNodeIds; ++i) + { + request->add_nodeids(rootNodeId); + } + + return request; + } + + void + testNodeIdCountAccepted(std::size_t const numNodeIds, bool const expectRejected) + { + testcase("Node ID Count Accepted"); + + Env env{*this}; + + auto peer = makeCapturePeer(env); + peer->onMessage(createRequest(numNodeIds)); + + // A request outside the accepted node-ID count is charged kFeeInvalidData; one inside + // it is not. The JobQueue handler may run concurrently and update the fee in the + // accepted case. + BEAST_EXPECT( + expectRejected ? (peer->feeCharge() == resource::kFeeInvalidData) + : !(peer->feeCharge() == resource::kFeeInvalidData)); + } + + void + testProcessLedgerRequestNodeCount(std::size_t const numNodeIds) + { + testcase("Process Ledger Request Node Count"); + + Env env{*this}; + env.close(); + + auto peer = makeCapturePeer(env); + + // Ask for the account-state root node of the closed ledger. + auto request = createRequest(numNodeIds); + request->clear_ledgerhash(); + request->set_itype(protocol::liAS_NODE); + request->set_ltype(protocol::ltCLOSED); + + peer->runProcessLedgerRequest(request, std::vector(numNodeIds)); + + auto sentMessage = peer->lastSent(); + BEAST_EXPECT(sentMessage != nullptr); + if (!sentMessage) + { + return; + } + + auto const& buffer = sentMessage->getBuffer(compression::Compressed::Off); + BEAST_EXPECT(buffer.size() > 6); + + // Skip the message header (6 bytes: 4 for size, 2 for type). + protocol::TMLedgerData reply; + BEAST_EXPECT(reply.ParseFromArray(buffer.data() + 6, buffer.size() - 6) == true); + + BEAST_EXPECT(reply.type() == protocol::liAS_NODE); + BEAST_EXPECT(reply.nodes_size() > 0); + BEAST_EXPECT(reply.nodes_size() <= static_cast(tuning::kHardMaxReplyNodes)); + } + + void + run() override + { + auto const limit = static_cast(tuning::kHardMaxReplyNodes); + testNodeIdCountAccepted(limit + 1, true); + testNodeIdCountAccepted(limit, false); + testNodeIdCountAccepted(limit - 1, false); + testProcessLedgerRequestNodeCount(limit + 1); + testProcessLedgerRequestNodeCount(limit); + testProcessLedgerRequestNodeCount(limit - 1); + } +}; + +BEAST_DEFINE_TESTSUITE(TMGetLedger, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/TMGetObjectByHash_test.cpp b/src/test/overlay/TMGetObjectByHash_test.cpp index 6c9105164a..1aafd77f2f 100644 --- a/src/test/overlay/TMGetObjectByHash_test.cpp +++ b/src/test/overlay/TMGetObjectByHash_test.cpp @@ -1,33 +1,16 @@ #include +#include #include #include -#include -#include #include -#include -#include #include #include #include -#include -#include #include #include -#include -#include -#include -#include #include -#include -#include - -#include -#include -#include -#include -#include #include @@ -41,119 +24,30 @@ namespace xrpl::test { using namespace jtx; /** - * Test for TMGetObjectByHash reply size limiting. + * Coverage for the TMGetObjectByHash object-count bound. * - * This verifies the fix that limits TMGetObjectByHash replies to - * tuning::hardMaxReplyNodes to prevent excessive memory usage and - * potential DoS attacks from peers requesting large numbers of objects. + * A generic query names some number of objects; the number of entries the + * reply carries is bounded by tuning::kHardMaxReplyNodes. These cases pin that + * bound at and either side of its boundary. */ class TMGetObjectByHash_test : public beast::unit_test::Suite { - using middle_type = boost::beast::tcp_stream; - using stream_type = boost::beast::ssl_stream; - using socket_type = boost::asio::ip::tcp::socket; - using shared_context = std::shared_ptr; /** - * Test peer that captures sent messages for verification. + * Calls the JtLedgerReq-dispatched processor synchronously, so the reply is + * visible through `sent()`. */ - class PeerTest : public PeerImp + class GetObjectPeer : public CapturePeer { public: - PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay) - : PeerImp( - app, - id++, - slot, - std::move(request), - publicKey, - protocol, - consumer, - std::move(streamPtr), - overlay) - { - } + using CapturePeer::CapturePeer; - ~PeerTest() override = default; - - void - run() override - { - } - - void - send(std::shared_ptr const& m) override - { - lastSentMessage_ = m; - } - - std::shared_ptr - getLastSentMessage() const - { - return lastSentMessage_; - } - - // Synchronous test access to the JobQueue-dispatched processor. - // The production path runs this on JtLedgerReq; tests need a - // synchronous entry point to inspect the reply via send(). - // PeerImp::processGetObjectByHash is `protected` so the derived - // test subclass can call it directly. void runProcessGetObjectByHash(std::shared_ptr const& m) { processGetObjectByHash(m); } - - static void - resetId() - { - id = 0; - } - - private: - inline static Peer::id_t id = 0; - std::shared_ptr lastSentMessage_; }; - shared_context context_{makeSslContext("")}; - ProtocolVersion protocolVersion_{1, 7}; - - std::shared_ptr - createPeer(jtx::Env& env) - { - auto& overlay = dynamic_cast(env.app().getOverlay()); - boost::beast::http::request request; - auto streamPtr = - std::make_unique(socket_type(env.app().getIOContext()), *context_); - - beast::ip::Endpoint const local(boost::asio::ip::make_address("172.1.1.1"), 51235); - beast::ip::Endpoint const remote(boost::asio::ip::make_address("172.1.1.2"), 51235); - - PublicKey const key(std::get<0>(randomKeyPair(KeyType::Ed25519))); - auto consumer = overlay.resourceManager().newInboundEndpoint(remote); - auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); - - auto peer = std::make_shared( - env.app(), - slot, - std::move(request), - key, - protocolVersion_, - consumer, - std::move(streamPtr), - overlay); - - overlay.addActive(peer); - return peer; - } - static std::shared_ptr createRequest(size_t const numObjects, Env& env) { @@ -173,7 +67,7 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite NodeObjectType::Ledger, std::move(data), hash, nodeStore.earliestLedgerSeq()); } - // Create a request with more objects than hardMaxReplyNodes + // Name every stored object in a single generic query. auto request = std::make_shared(); request->set_type(protocol::TMGetObjectByHash_ObjectType_otLEDGER); request->set_query(true); @@ -188,28 +82,25 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite } /** - * Test that reply is limited to hardMaxReplyNodes when more objects - * are requested than the limit allows. + * Check the object count a generic-query reply carries. * * `onMessage(TMGetObjectByHash)` dispatches the generic-query path * to the JobQueue, so tests invoke the synchronous processor * directly via `runProcessGetObjectByHash`. */ void - testReplyLimit(size_t const numObjects, int const expectedReplySize) + testReplyObjectCount(size_t const numObjects, int const expectedReplySize) { - testcase("Reply Limit"); + testcase("Reply Object Count"); Env env(*this); - PeerTest::resetId(); - - auto peer = createPeer(env); + auto peer = makeCapturePeer(env); auto request = createRequest(numObjects, env); peer->runProcessGetObjectByHash(request); // Verify that a reply was sent - auto sentMessage = peer->getLastSentMessage(); + auto sentMessage = peer->lastSent(); BEAST_EXPECT(sentMessage != nullptr); // Parse the reply message @@ -220,7 +111,7 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite protocol::TMGetObjectByHash reply; BEAST_EXPECT(reply.ParseFromArray(buffer.data() + 6, buffer.size() - 6) == true); - // Verify the reply is limited to expectedReplySize + // The reply carries the expected number of objects. BEAST_EXPECT(reply.objects_size() == expectedReplySize); } @@ -228,9 +119,9 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite run() override { int const limit = static_cast(tuning::kHardMaxReplyNodes); - testReplyLimit(limit + 1, limit); - testReplyLimit(limit, limit); - testReplyLimit(limit - 1, limit - 1); + testReplyObjectCount(limit + 1, limit); + testReplyObjectCount(limit, limit); + testReplyObjectCount(limit - 1, limit - 1); } }; diff --git a/src/test/overlay/TMTransaction_test.cpp b/src/test/overlay/TMTransaction_test.cpp new file mode 100644 index 0000000000..5a23e25005 --- /dev/null +++ b/src/test/overlay/TMTransaction_test.cpp @@ -0,0 +1,45 @@ +#include +#include +#include + +#include +#include + +#include + +#include + +namespace xrpl::test { + +using namespace jtx; + +class TMTransaction_test : public beast::unit_test::Suite +{ + void + testFailureDeserializingTransactionIsCharged() + { + testcase("Undeserializable Transaction Is Charged"); + + Env env{*this, envconfig()}; + + auto peer = makeCapturePeer(env); + auto tx = std::make_shared(); + tx->set_status(protocol::tsNEW); + + // Bytes that are not a serialized transaction, so deserialization fails. + tx->set_rawtransaction("\x01\x02\x03", 3); + + peer->onMessage(tx); + BEAST_EXPECT(peer->feeCharge() == resource::kFeeInvalidData); + } + + void + run() override + { + testFailureDeserializingTransactionIsCharged(); + } +}; + +BEAST_DEFINE_TESTSUITE(TMTransaction, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/TMTransactions_test.cpp b/src/test/overlay/TMTransactions_test.cpp new file mode 100644 index 0000000000..87c09498f2 --- /dev/null +++ b/src/test/overlay/TMTransactions_test.cpp @@ -0,0 +1,82 @@ +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +using namespace jtx; + +class TMTransactions_test : public beast::unit_test::Suite +{ + static std::shared_ptr + createRequest(std::size_t const numTransactions) + { + auto request = std::make_shared(); + for (std::size_t i = 0; i < numTransactions; ++i) + { + request->mutable_transactions()->Add(protocol::TMTransaction{}); + } + return request; + } + + void + testTransactionCountAccepted(std::size_t const numTransactions, bool const expectRejected) + { + testcase("Transaction Count Accepted"); + + static constexpr auto kLimitExceededMessage = "TMTransactions: transaction list too large"; + auto foundExpectedLog = false; + Env env{ + *this, + envconfig(), + std::make_unique(kLimitExceededMessage, &foundExpectedLog)}; + + // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from + // the config and the handshake header, so set this first. + env.app().config().txReduceRelayEnable = true; + http_request_type request; + request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)); + + auto peer = makeCapturePeer(env, std::nullopt, std::move(request)); + peer->onMessage(createRequest(numTransactions)); + + auto fee = peer->feeCharge(); + if (expectRejected) + { + BEAST_EXPECT(fee == resource::kFeeMalformedRequest); + BEAST_EXPECT(foundExpectedLog); + } + else + { + BEAST_EXPECT(!foundExpectedLog); + } + } + + void + run() override + { + auto const limit = reduce_relay::kMaxTxQueueSize; + testTransactionCountAccepted(limit + 1, true); + testTransactionCountAccepted(limit, false); + testTransactionCountAccepted(limit - 1, false); + } +}; + +BEAST_DEFINE_TESTSUITE(TMTransactions, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/cluster_test.cpp b/src/test/overlay/cluster_test.cpp index 0a51f98594..06df4fb73a 100644 --- a/src/test/overlay/cluster_test.cpp +++ b/src/test/overlay/cluster_test.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -96,6 +97,29 @@ public: } } + { + testcase("Membership: isMember agrees with member"); + + // Number of network nodes that also belong to the cluster. + std::size_t const overlapCount = 16; + + // Total size of the cluster once padded with non-network nodes. + std::size_t const clusterSize = 32; + + std::vector cluster(network.begin(), network.begin() + overlapCount); + + while (cluster.size() != clusterSize) + cluster.push_back(randomNode()); + + auto c = create(cluster); + + for (auto const& n : cluster) + BEAST_EXPECT(c->isMember(n)); + + for (auto const& n : network) + BEAST_EXPECT(c->isMember(n) == static_cast(c->member(n))); + } + { testcase("Membership: Non-empty cluster and all present"); diff --git a/src/test/overlay/overlay_limit_test.cpp b/src/test/overlay/overlay_limit_test.cpp new file mode 100644 index 0000000000..11bbdc2377 --- /dev/null +++ b/src/test/overlay/overlay_limit_test.cpp @@ -0,0 +1,83 @@ +#include +#include + +#include +#include + +#include +#include + +#include + +namespace xrpl::test { + +using namespace jtx; + +/** + * Tests for `Overlay::limit()`, the configured peer allowance reported once + * `OverlayImpl::start()` applies the computed `peer_finder::Config`. + * + * `ApplicationImp::fdRequired()` runs before `OverlayImpl::start()` does, so it + * always sees the peer finder manager's default-constructed configuration and + * never this value; `Overlay::limit()` instead surfaces through the PeerFinder + * property stream and other post-startup callers. + * + * `jtx::Env` runs standalone, and `ServerHandler` strips the `peer` protocol + * from every configured port under `config.standalone()`, so the peer port + * declared here is never bound and incoming connections are disabled + * throughout; every limit in this suite is an outbound-only allowance. The + * inbound cases live alongside `peer_finder::Config::makeConfig`, which takes + * the port as a parameter. + */ +class OverlayLimit_test : public beast::unit_test::Suite +{ + void + testLegacyPeersMax() + { + testcase("Legacy peers_max is reported"); + + auto config = jtx::envconfig(); + config->peersMax = 40; + + Env env(*this, std::move(config)); + BEAST_EXPECT(env.app().getOverlay().limit() == 40); + } + + void + testPerDirectionPeerLimits() + { + testcase("Per-direction peer limits are reported"); + + // With incoming connections disabled the 50 inbound slots are dropped + // and only the outbound allowance remains, so neither zero (the value + // `maxPeers` used to hold in this branch of makeConfig) nor 70 (the + // unconditional sum of both directions) is correct. + auto config = jtx::envconfig(); + config->peersInMax = 50; + config->peersOutMax = 20; + + Env env(*this, std::move(config)); + BEAST_EXPECT(env.app().getOverlay().limit() == 20); + } + + void + testDefaultConfig() + { + testcase("A default configuration reports the default limit"); + + Env env(*this); + BEAST_EXPECT(env.app().getOverlay().limit() == peer_finder::tuning::kDefaultMaxPeers); + } + + void + run() override + { + testLegacyPeersMax(); + testPerDirectionPeerLimits(); + testDefaultConfig(); + } +}; + +BEAST_DEFINE_TESTSUITE(OverlayLimit, overlay, xrpl); + +} // namespace xrpl::test diff --git a/src/test/overlay/reduce_relay_test.cpp b/src/test/overlay/reduce_relay_test.cpp index 4091efa0ca..ff3eb51a4d 100644 --- a/src/test/overlay/reduce_relay_test.cpp +++ b/src/test/overlay/reduce_relay_test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -12,10 +13,8 @@ #include #include #include -#include #include #include -#include #include #include #include @@ -27,7 +26,6 @@ #include #include #include -#include #include #include #include @@ -67,16 +65,16 @@ static constexpr std::uint32_t kMaxMessages = 200000; /** * Simulate two entities - peer directly connected to the server * (via squelch in PeerSim) and PeerImp (via Overlay) + * + * `PeerStub` supplies the rest of the `Peer` interface as no-ops. */ -class PeerPartial : public Peer +class PeerPartial : public PeerStub { public: - PeerPartial() : nodePublicKey(derivePublicKey(KeyType::Ed25519, randomSecretKey())) - { - } + using PeerStub::PeerStub; + // Keep the base overload visible; the one below would otherwise hide it. + using PeerStub::send; - PublicKey nodePublicKey; - ~PeerPartial() override = default; virtual void onMessage(MessageSPtr const& m, SquelchCB f) = 0; virtual void @@ -86,111 +84,6 @@ public: { onMessage(squelch); } - - // dummy implementation - void - send(std::shared_ptr const& m) override - { - } - [[nodiscard]] beast::ip::Endpoint - getRemoteAddress() const override - { - return {}; - } - void - charge(resource::Charge const& fee, std::string const& context = {}) override - { - } - [[nodiscard]] bool - cluster() const override - { - return false; - } - [[nodiscard]] bool - isHighLatency() const override - { - return false; - } - [[nodiscard]] int - getScore(bool) const override - { - return 0; - } - [[nodiscard]] PublicKey const& - getNodePublic() const override - { - return nodePublicKey; - } - json::Value - json() override - { - return {}; - } - [[nodiscard]] bool - supportsFeature(ProtocolFeature f) const override - { - return false; - } - [[nodiscard]] std::optional - publisherListSequence(PublicKey const&) const override - { - return {}; - } - void - setPublisherListSequence(PublicKey const&, std::size_t const) override - { - } - [[nodiscard]] uint256 - getClosedLedgerHash() const override - { - static uint256 const kHash{}; - return kHash; - } - [[nodiscard]] bool - hasLedger(uint256 const& hash, std::uint32_t seq) const override - { - return false; - } - void - ledgerRange(std::uint32_t& minSeq, std::uint32_t& maxSeq) const override - { - } - [[nodiscard]] bool - hasTxSet(uint256 const& hash) const override - { - return false; - } - void - cycleStatus() override - { - } - bool - hasRange(std::uint32_t uMin, std::uint32_t uMax) override - { - return false; - } - [[nodiscard]] bool - compressionEnabled() const override - { - return false; - } - [[nodiscard]] bool - txReduceRelayEnabled() const override - { - return false; - } - void - sendTxQueue() override - { - } - void - addTxQueue(uint256 const&) override - { - } - void - removeTxQueue(uint256 const&) override - { - } }; /** @@ -466,24 +359,13 @@ class PeerSim : public PeerPartial, public std::enable_shared_from_this { public: using id_t = Peer::id_t; - PeerSim(Overlay& overlay, beast::Journal journal) : overlay_(overlay), squelch_(journal) + PeerSim(Overlay& overlay, beast::Journal journal) + : PeerPartial(sid++), overlay_(overlay), squelch_(journal) { } ~PeerSim() override = default; - id_t - id() const override - { - return id_; - } - - std::string const& - fingerprint() const override - { - return fingerprint_; - } - static void resetId() { @@ -525,8 +407,6 @@ public: private: inline static id_t sid = 0; - std::string fingerprint_; - id_t id_{sid++}; Overlay& overlay_; reduce_relay::Squelch squelch_; }; diff --git a/src/test/overlay/tx_reduce_relay_test.cpp b/src/test/overlay/tx_reduce_relay_test.cpp index 8626d3e19c..e97fba88e5 100644 --- a/src/test/overlay/tx_reduce_relay_test.cpp +++ b/src/test/overlay/tx_reduce_relay_test.cpp @@ -1,38 +1,24 @@ #include #include +#include #include #include -#include #include #include #include #include -#include #include -#include -#include #include -#include #include #include #include #include -#include #include -#include -#include -#include -#include -#include -#include -#include - #include -#include #include #include #include @@ -47,13 +33,6 @@ namespace xrpl::test { class tx_reduce_relay_test : public beast::unit_test::Suite { -public: - using socket_type = boost::asio::ip::tcp::socket; - using middle_type = boost::beast::tcp_stream; - using stream_type = boost::beast::ssl_stream; - using shared_context = std::shared_ptr; - -private: void doTest(std::string const& msg, bool log, std::function f) { @@ -116,107 +95,83 @@ private: }); } - class PeerTest : public PeerImp + /** + * Counts queued transaction hashes. Relayed messages are counted through + * the inherited `sent()`. + */ + class TxReducePeer : public CapturePeer { public: - PeerTest( - Application& app, - std::shared_ptr const& slot, - http_request_type&& request, - PublicKey const& publicKey, - ProtocolVersion protocol, - resource::Consumer consumer, - std::unique_ptr&& streamPtr, - OverlayImpl& overlay) - : PeerImp( - app, - sid, - slot, - std::move(request), - publicKey, - protocol, - consumer, - std::move(streamPtr), - overlay) - { - sid++; - } - ~PeerTest() override = default; + using CapturePeer::CapturePeer; void - run() override + addTxQueue(uint256 const&) override { + ++queued_; } - void - send(std::shared_ptr const&) override + + /** + * @return The number of transaction hashes queued for this peer. + */ + std::size_t + queued() const { - sendTx++; + return queued_; } - void - addTxQueue(uint256 const& hash) override - { - queueTx++; - } - static void - init() - { - queueTx = 0; - sendTx = 0; - sid = 0; - } - inline static std::size_t sid = 0; - inline static std::uint16_t queueTx = 0; - inline static std::uint16_t sendTx = 0; + + private: + std::size_t queued_{0}; }; - std::uint16_t lid_{0}; - std::uint16_t rid_{1}; - shared_context context_; - ProtocolVersion protocolVersion_; - boost::beast::multi_buffer readBuf_; - -public: - tx_reduce_relay_test() : context_(makeSslContext("")), protocolVersion_{1, 7} - { - } - -private: + /** + * Build one peer and register it with the overlay. + * + * The first `nDisabled` peers get no `X-Protocol-Ctl` header, which leaves + * tx reduce-relay disabled on them. Built first, they sit at the front of + * `peers`, where `testRelay`'s skip set expects them. + * + * @param env The environment owning the overlay. + * @param peers Receives the peer; the overlay holds only a weak + * pointer, so the caller keeps it alive. + * @param nDisabled How many more peers to leave disabled; decremented + * per peer built. + */ void - addPeer(jtx::Env& env, std::vector>& peers, std::uint16_t& nDisabled) + addPeer( + jtx::Env& env, + std::vector>& peers, + std::uint16_t& nDisabled) { auto& overlay = dynamic_cast(env.app().getOverlay()); - boost::beast::http::request request; - (nDisabled == 0) - ? request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)) - : (void)nDisabled--; - auto streamPtr = std::make_unique( - socket_type(std::forward(env.app().getIOContext())), - *context_); - beast::ip::Endpoint const local( - boost::asio::ip::make_address("172.1.1." + std::to_string(lid_))); - beast::ip::Endpoint const remote( - boost::asio::ip::make_address("172.1.1." + std::to_string(rid_))); PublicKey const key(std::get<0>(randomKeyPair(KeyType::Ed25519))); - auto consumer = overlay.resourceManager().newInboundEndpoint(remote); - auto [slot, _] = overlay.peerFinder().newInboundSlot(local, remote); - auto const peer = std::make_shared( - env.app(), - slot, - std::move(request), - key, - protocolVersion_, - consumer, - std::move(streamPtr), - overlay); + + bool const disabled = nDisabled > 0; + if (disabled) + --nDisabled; + + http_request_type request; + if (!disabled) + request.insert("X-Protocol-Ctl", makeFeaturesRequestHeader(false, false, true, false)); + BEAST_EXPECT(overlay.findPeerByPublicKey(key) == std::shared_ptr{}); - overlay.addActive(peer); + auto const peer = makeCapturePeer(env, key, std::move(request)); BEAST_EXPECT(overlay.findPeerByPublicKey(key) == peer); - peers.emplace_back(peer); // overlay stores week ptr to PeerImp - lid_ += 2; - rid_ += 2; - assert(lid_ <= 254); + peers.emplace_back(peer); } + /** + * Relay one transaction to `nPeers` peers and check the split. + * + * @param test The testcase name. + * @param txRREnabled The `tx_enable` config value. + * @param nPeers How many peers to attach to the overlay. + * @param nDisabled How many of those peers have reduce-relay disabled. + * @param minPeers The `tx_min_peers` config value. + * @param relayPercentage The `tx_relay_percentage` config value. + * @param expectRelay The expected number of peers relayed to. + * @param expectQueue The expected number of peers queued for. + * @param nSkip How many of the first-built peers to skip. + */ void testRelay( std::string const& test, @@ -227,20 +182,30 @@ private: std::uint16_t relayPercentage, std::uint16_t expectRelay, std::uint16_t expectQueue, - std::set const& toSkip = {}) + std::size_t nSkip = 0) { testcase(test); jtx::Env env(*this); - std::vector> peers; + std::vector> peers; + // `PeerImp` decides `txReduceRelayEnabled()` in its constructor, from + // the config and the handshake header, so set these first. env.app().config().txReduceRelayEnable = txRREnabled; env.app().config().txReduceRelayMinPeers = minPeers; env.app().config().txRelayPercentage = relayPercentage; - PeerTest::init(); - lid_ = 0; - rid_ = 0; for (int i = 0; i < nPeers; i++) addPeer(env, peers, nDisabled); + // An under-filled skip set would also fail the relay counts below, for + // a reason that looks unrelated. + if (!BEAST_EXPECT(nSkip <= peers.size())) + return; + + // Skip the peers built first, so the skip set overlaps the disabled + // peers as the expected counts assume. + std::set toSkip; + for (std::size_t i = 0; i < nSkip; ++i) + toSkip.insert(peers[i]->id()); + auto const jtx = env.jt(noop(env.master)); if (BEAST_EXPECT(jtx.stx)) { @@ -251,7 +216,15 @@ private: m.set_deferred(false); m.set_status(protocol::TransactionStatus::tsNEW); env.app().getOverlay().relay(uint256{0}, m, toSkip); - BEAST_EXPECT(PeerTest::sendTx == expectRelay && PeerTest::queueTx == expectQueue); + + std::size_t sendTx = 0; + std::size_t queueTx = 0; + for (auto const& peer : peers) + { + sendTx += peer->sent().size(); + queueTx += peer->queued(); + } + BEAST_EXPECT(sendTx == expectRelay && queueTx == expectQueue); } } @@ -259,12 +232,11 @@ private: run() override { bool const log = false; - std::set skip = {0, 1, 2, 3, 4}; testConfig(log); // relay to all peers, no hash queue testRelay("feature disabled", false, 10, 0, 10, 25, 10, 0); // relay to nPeers - skip (10-5=5) - testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, skip); + testRelay("feature disabled & skip", false, 10, 0, 10, 25, 5, 0, 5); // relay to all peers because min is greater than nPeers testRelay("relay all 1", true, 10, 0, 20, 25, 10, 0); // relay to all peers because min + disabled is greater thant nPeers @@ -275,24 +247,22 @@ private: // relay to minPeers + 25% of (nPeers - nPeers) - skip // (20+0.25*(60-20)-5=25), queue the rest, skip counts towards relayed // (60-25-5=30) - testRelay("skip", true, 60, 0, 20, 25, 25, 30, skip); + testRelay("skip", true, 60, 0, 20, 25, 25, 30, 5); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // (20+10+0.25*(70-20-10)=40), queue the rest (30) testRelay("disabled", true, 70, 10, 20, 25, 40, 30); // relay to minPeers + disabled-not-in-skip + 25% of (nPeers - minPeers // - disabled) (20+5+0.25*(70-20-10)=35), queue the rest, skip counts // towards relayed (70-35-5=30)) - testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, skip); + testRelay("disabled & skip", true, 70, 10, 20, 25, 35, 30, 5); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // - skip (10+5+0.25*(15-10-5)-10=5), queue the rest, skip counts // towards relayed (15-5-10=0) - skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; - testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, skip); + testRelay("disabled & skip, no queue", true, 15, 5, 10, 25, 5, 0, 10); // relay to minPeers + disabled + 25% of (nPeers - minPeers - disabled) // - skip (10+2+0.25*(20-10-2)-14=0), queue the rest, skip counts // towards relayed (20-14=6) - skip = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; - testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, skip); + testRelay("disabled & skip, no relay", true, 20, 2, 10, 25, 0, 6, 14); } }; diff --git a/src/test/rpc/Handler_test.cpp b/src/test/rpc/Handler_test.cpp index be78864cac..16838d3f5a 100644 --- a/src/test/rpc/Handler_test.cpp +++ b/src/test/rpc/Handler_test.cpp @@ -1,9 +1,8 @@ -#include - #include #include +#include #include #include @@ -12,7 +11,11 @@ #include #include #include +#include +#include #include +#include +#include #include #include // cspell: words stdev @@ -88,21 +91,50 @@ class Handler_test : public beast::unit_test::Suite std::random_device dev; std::ranlux48 prng(dev()); - std::vector names = test::jtx::makeVector(xrpl::rpc::getHandlerNames()); + // The lowest version still served. Outside the supported range getHandler() + // returns at its bounds check without searching, so the benchmark would + // time that check instead of a lookup. + constexpr unsigned kVersion = rpc::kApiMinimumSupportedVersion; + + // Only the names that answer at kVersion, so that every timed call does a + // whole lookup: a method served from a later version only would not. + // Contiguous, so that picking one by index costs nothing. + std::vector names; + std::ranges::copy_if( + rpc::getHandlerNames(), std::back_inserter(names), [](std::string_view name) { + return rpc::getHandler(kVersion, false, name) != nullptr; + }); + + if (!BEAST_EXPECTS( + !names.empty(), + "no handler answers at API version " + std::to_string(kVersion) + + ", so there is nothing to measure")) + return; std::uniform_int_distribution distr{0, names.size() - 1}; std::size_t dummy = 0; + std::size_t misses = 0; auto const [mean, stdev, n] = time( 1'000'000, [&](std::size_t i) { - auto const d = rpc::getHandler(1, false, names[i]); + auto const d = rpc::getHandler(kVersion, false, names[i]); + if (d == nullptr) + { + ++misses; + return; + } dummy = dummy + i + (int)d->role; }, [&]() -> std::size_t { return distr(prng); }); std::cout << "mean=" << mean << " stdev=" << stdev << " N=" << n << '\n'; + // Every name answered once already, so a miss here cannot happen. + BEAST_EXPECTS( + misses == 0, + std::to_string(misses) + " of " + std::to_string(n) + " lookups at API version " + + std::to_string(kVersion) + " found no handler, so nothing was measured"); BEAST_EXPECT(dummy != 0); } @@ -114,6 +146,125 @@ public: } }; +// Manual: the suite only reports a timing, which says nothing on a CI runner. +// The table invariants are static_asserts in Handler.cpp. BEAST_DEFINE_TESTSUITE_MANUAL(Handler, rpc, xrpl); +// What getHandler() answers, as opposed to how fast it answers. A lookup needs no +// Application, so these cases run as an automatic suite. +// +// The bounds check they cover is unreachable from a request: getAPIVersionNumber() +// applies the same predicate first, and every caller rejects an invalid version +// before it asks for a handler. That is why it is checked here directly, and why +// it is worth checking at all rather than deleting as unreachable. +class HandlerLookup_test : public beast::unit_test::Suite +{ + /** + * Find a method that is served at a given API version. + * + * The name comes from the table, so a case below does not name a method that a + * later API version may retire. + * + * @param version The API version to answer at. + * @param betaEnabled Whether the beta API version is enabled. + * @return A name that answers, or nullopt if none does. + */ + static std::optional + nameServedAt(unsigned version, bool betaEnabled) + { + for (std::string_view name : rpc::getHandlerNames()) + { + if (rpc::getHandler(version, betaEnabled, name) != nullptr) + return name; + } + + return std::nullopt; + } + + void + testUnservedVersion() + { + testcase("An unserved API version has no handler"); + + // A name the table certainly holds, so that a null answer below can only + // come from the version and not from the name. + auto const name = nameServedAt(rpc::kApiMinimumSupportedVersion, false); + if (!BEAST_EXPECTS( + name.has_value(), + "no handler answers at API version " + + std::to_string(rpc::kApiMinimumSupportedVersion) + + ", so there is no name to ask about")) + return; + + // Below the minimum, which no setting serves. + BEAST_EXPECT( + rpc::getHandler(rpc::kApiMinimumSupportedVersion - 1, false, *name) == nullptr); + BEAST_EXPECT(rpc::getHandler(rpc::kApiMinimumSupportedVersion - 1, true, *name) == nullptr); + + // Above the maximum each setting serves. Both values stay outside the + // served range however the version constants move, so neither case can + // become vacuous. + BEAST_EXPECT( + rpc::getHandler(rpc::kApiMaximumSupportedVersion + 1, false, *name) == nullptr); + BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion + 1, true, *name) == nullptr); + } + + void + testBetaVersionGate() + { + testcase("The beta API version is served only where it is enabled"); + + // Between betas the beta version is the maximum supported one, leaving the + // two settings nothing to tell apart. Compiled out rather than asserted, so + // that the case arms itself again when a later beta version arrives. + if constexpr (rpc::kApiBetaVersion > rpc::kApiMaximumSupportedVersion) + { + auto const name = nameServedAt(rpc::kApiBetaVersion, true); + if (!BEAST_EXPECTS( + name.has_value(), + "no handler answers at API version " + std::to_string(rpc::kApiBetaVersion) + + ", so there is nothing for the gate to reject")) + return; + + // The handler serves this version, so only the server's own range can + // turn the answer into a null one. + BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion, true, *name) != nullptr); + BEAST_EXPECT(rpc::getHandler(rpc::kApiBetaVersion, false, *name) == nullptr); + } + else + { + log << "the beta API version is the maximum supported version, so no gate " + "separates them\n"; + pass(); + } + } + + void + testUnknownMethod() + { + testcase("An unknown method has no handler"); + + constexpr unsigned kVersion = rpc::kApiMinimumSupportedVersion; + + BEAST_EXPECT(rpc::getHandler(kVersion, false, "no such method") == nullptr); + BEAST_EXPECT(rpc::getHandler(kVersion, false, "") == nullptr); + + // A method name holds lowercase letters and underscores, so a tilde sorts + // after every entry. This runs the search off the end of the table, which + // no other case here does. + BEAST_EXPECT(rpc::getHandler(kVersion, false, "~") == nullptr); + } + +public: + void + run() override + { + testUnservedVersion(); + testBetaVersionGate(); + testUnknownMethod(); + } +}; + +BEAST_DEFINE_TESTSUITE(HandlerLookup, rpc, xrpl); + } // namespace xrpl::test diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp index e7c5dd4a80..deb19e3a3f 100644 --- a/src/test/rpc/LedgerRPC_test.cpp +++ b/src/test/rpc/LedgerRPC_test.cpp @@ -13,17 +13,21 @@ #include #include +#include #include #include #include +#include #include #include #include +#include #include #include #include +#include #include #include #include @@ -807,6 +811,95 @@ class LedgerRPC_test : public beast::unit_test::Suite } } + void + testLedgerExpandedTransactionsCTID() + { + testcase("Expanded Transactions CTID"); + using namespace test::jtx; + + Env env{*this}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + uint32_t const netID = env.app().getNetworkIDService().getNetworkID(); + + // API v2 non-binary: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + auto const expectedCtid = rpc::encodeCTID( + jrr[jss::ledger][jss::ledger_index].asUInt(), + txn[jss::meta][sfTransactionIndex.jsonName].asUInt(), + netID); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + if (BEAST_EXPECT(expectedCtid.has_value())) + BEAST_EXPECT(txn[jss::ctid] == expectedCtid.value()); + // NOLINTEND(bugprone-unchecked-optional-access) + } + } + + // API v1 non-binary: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + } + } + + // Binary expanded: CTID present + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::expand] = true; + jvParams[jss::binary] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isMember(jss::ctid)); + } + } + + // Non-expanded: transactions are plain hash strings, no CTID + { + json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::transactions] = true; + jvParams[jss::api_version] = 2; + auto const jrr = env.rpc("json", "ledger", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::status] == "success"); + auto const& txns = jrr[jss::ledger][jss::transactions]; + BEAST_EXPECT(txns.isArray() && txns.size() > 0); + for (auto const& txn : txns) + { + BEAST_EXPECT(txn.isString()); + } + } + } + public: void run() override @@ -822,6 +915,7 @@ public: testNoQueue(); testQueue(); testLedgerAccountsOption(); + testLedgerExpandedTransactionsCTID(); } }; diff --git a/src/test/rpc/NoRippleCheck_test.cpp b/src/test/rpc/NoRippleCheck_test.cpp index 8e719e6407..3a4ddcf5c4 100644 --- a/src/test/rpc/NoRippleCheck_test.cpp +++ b/src/test/rpc/NoRippleCheck_test.cpp @@ -126,9 +126,16 @@ class NoRippleCheck_test : public beast::unit_test::Suite params[jss::account] = toBase58(TokenType::NodePrivate, alice.sk()); params[jss::role] = "user"; params[jss::ledger] = "current"; + params[jss::transactions] = true; auto const result = env.rpc("json", "noripple_check", to_string(params))[jss::result]; BEAST_EXPECT(result[jss::error] == "actMalformed"); BEAST_EXPECT(result[jss::error_message] == "Account malformed."); + // The changelog promises malformed-account responses carry + // neither `transactions` nor any ledger metadata. + BEAST_EXPECT(!result.isMember(jss::transactions)); + BEAST_EXPECT(!result.isMember(jss::ledger_hash)); + BEAST_EXPECT(!result.isMember(jss::ledger_index)); + BEAST_EXPECT(!result.isMember(jss::validated)); } { @@ -194,6 +201,7 @@ class NoRippleCheck_test : public beast::unit_test::Suite if (!BEAST_EXPECT(pa.isArray())) return; + BEAST_EXPECT(!result.isMember(jss::transactions)); if (problems) { if (!BEAST_EXPECT(pa.size() == 2)) @@ -219,12 +227,12 @@ class NoRippleCheck_test : public beast::unit_test::Suite // time. params[jss::transactions] = true; result = env.rpc("json", "noripple_check", to_string(params))[jss::result]; - if (!BEAST_EXPECT(result[jss::transactions].isArray())) - return; auto const txs = result[jss::transactions]; if (problems) { + if (!BEAST_EXPECT(result[jss::transactions].isArray())) + return; if (!BEAST_EXPECT(txs.size() == (user ? 1 : 2))) return; diff --git a/src/test/rpc/RPCCall_test.cpp b/src/test/rpc/RPCCall_test.cpp index ef3213008c..e09d95f99a 100644 --- a/src/test/rpc/RPCCall_test.cpp +++ b/src/test/rpc/RPCCall_test.cpp @@ -3,6 +3,9 @@ #include #include +#include +#include +#include #include #include @@ -12,11 +15,14 @@ #include +#include +#include #include #include #include #include #include +#include #include #include @@ -5923,10 +5929,67 @@ public: } } + // The command-line table and the dispatch table must agree. + // + // Forwards: every name the command line accepts must reach a handler at the + // version the command-line client requests. Presence in the dispatch table is + // not enough: a handler whose API range excludes kApiCommandLineVersion parses + // the command and then answers RpcUnknownCommand. + // + // Backwards: a handler that claims a command-line form must have one, and + // one that denies it must not, so that Handler::hasCommandLineForm cannot go + // stale. + // + // Three command-line names are exempt from the forward check because they + // are wrappers that forward a caller-supplied method rather than naming one + // themselves, so they have no handler of their own. + void + testCommandLineTableMatchesHandlers() + { + testcase("Command-line and dispatch tables agree"); + + static constexpr std::array kWrappers{ + rpc::method::kInternal, rpc::method::kJson, rpc::method::kJson2}; + + auto const commandLine = commandLineMethodNames(); + auto const handlers = rpc::getHandlerNames(); + BEAST_EXPECT(!commandLine.empty()); + BEAST_EXPECT(!handlers.empty()); + + // The command-line client always requests this version, so this is the + // only version at which its commands have to be dispatchable. Beta + // methods are off: a command must work against a stock server. + auto const handlerFor = [](std::string_view name) { + return rpc::getHandler(rpc::kApiCommandLineVersion, false, name); + }; + + for (auto const& name : commandLine) + { + if (std::ranges::find(kWrappers, name) != kWrappers.end()) + continue; + + auto const* handler = handlerFor(name); + if (BEAST_EXPECTS(handler != nullptr, std::string{name})) + BEAST_EXPECTS(handler->hasCommandLineForm, std::string{name}); + } + + for (auto const& name : handlers) + { + auto const* handler = handlerFor(name); + bool const claimsCommandLine = handler != nullptr && handler->hasCommandLineForm; + + // Both name lists are sorted, so a binary search suffices. + BEAST_EXPECTS( + claimsCommandLine == std::ranges::binary_search(commandLine, name.view()), + std::string{name}); + } + } + void run() override { forAllApiVersions([this](unsigned apiVersion) { testRPCCall(apiVersion); }); + testCommandLineTableMatchesHandlers(); } }; diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index 9cbfb8ca10..81f4abc126 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -31,6 +31,7 @@ set(test_modules consensus crypto json + ledger nodestore peerfinder protocol diff --git a/src/tests/libxrpl/ledger/AMMEntry.cpp b/src/tests/libxrpl/ledger/AMMEntry.cpp new file mode 100644 index 0000000000..6189d2b3e6 --- /dev/null +++ b/src/tests/libxrpl/ledger/AMMEntry.cpp @@ -0,0 +1,25 @@ +#include + +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +TEST(AMMEntryTests, Constructors) +{ + EntryTestEnv e; + + Asset const xrp{xrpIssue()}; + Asset const usd{IOU("USD", e.alice).issue()}; + + expectKeylet(e, keylet::amm(xrp, usd), "amm(asset, asset)", xrp, usd); + + expectKeylet(e, keylet::amm(e.someID()), "amm(uint256)", e.someID()); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/AccountRootEntry.cpp b/src/tests/libxrpl/ledger/AccountRootEntry.cpp new file mode 100644 index 0000000000..964d0f2f43 --- /dev/null +++ b/src/tests/libxrpl/ledger/AccountRootEntry.cpp @@ -0,0 +1,21 @@ +#include + +#include + +#include +#include +#include + +namespace xrpl::test { + +TEST(AccountRootEntryTests, Constructors) +{ + EntryTestEnv e; + + expectKeylet(e, keylet::account(e.alice.id()), "account(id)", e.alice.id()); + + expectKeylet( + e, keylet::account(Account("nobody").id()), "account(id) absent", Account("nobody").id()); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/AmendmentsEntry.cpp b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp new file mode 100644 index 0000000000..ec45b291ec --- /dev/null +++ b/src/tests/libxrpl/ledger/AmendmentsEntry.cpp @@ -0,0 +1,17 @@ +#include + +#include + +#include +#include + +namespace xrpl::test { + +TEST(AmendmentsEntryTests, Constructors) +{ + EntryTestEnv e; + + expectKeylet(e, keylet::amendments(), "amendments()"); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/BridgeEntry.cpp b/src/tests/libxrpl/ledger/BridgeEntry.cpp new file mode 100644 index 0000000000..bae65ea8b5 --- /dev/null +++ b/src/tests/libxrpl/ledger/BridgeEntry.cpp @@ -0,0 +1,41 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +TEST(BridgeEntryTests, Constructors) +{ + EntryTestEnv e; + + STXChainBridge const bridge{e.alice.id(), xrpIssue(), e.bob.id(), IOU("USD", e.bob).issue()}; + + expectKeylet( + e, + keylet::bridge(bridge, STXChainBridge::ChainType::Locking), + "bridge(bridge, Locking)", + bridge, + STXChainBridge::ChainType::Locking); + + expectKeylet( + e, + keylet::bridge(bridge, STXChainBridge::ChainType::Issuing), + "bridge(bridge, Issuing)", + bridge, + STXChainBridge::ChainType::Issuing); + + // The two chain types must not collide, or the assertions above would + // pass with chainType ignored entirely. + EXPECT_NE( + keylet::bridge(bridge, STXChainBridge::ChainType::Locking).key, + keylet::bridge(bridge, STXChainBridge::ChainType::Issuing).key); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/CheckEntry.cpp b/src/tests/libxrpl/ledger/CheckEntry.cpp new file mode 100644 index 0000000000..da51da047e --- /dev/null +++ b/src/tests/libxrpl/ledger/CheckEntry.cpp @@ -0,0 +1,23 @@ +#include + +#include +#include + +#include +#include + +namespace xrpl::test { + +TEST(CheckEntryTests, Constructors) +{ + EntryTestEnv e; + + SeqProxy const seq = SeqProxy::rawSequence(7); + + expectKeylet( + e, keylet::check(e.alice.id(), seq), "check(id, seq)", e.alice.id(), seq); + + expectKeylet(e, keylet::check(e.someID()), "check(uint256)", e.someID()); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/CredentialEntry.cpp b/src/tests/libxrpl/ledger/CredentialEntry.cpp new file mode 100644 index 0000000000..ce3f80dca0 --- /dev/null +++ b/src/tests/libxrpl/ledger/CredentialEntry.cpp @@ -0,0 +1,39 @@ +#include + +#include +#include +#include + +#include +#include + +#include + +namespace xrpl::test { + +TEST(CredentialEntryTests, Constructors) +{ + EntryTestEnv e; + + std::string const credTypeStr = "termsandconditions"; + Slice const credType = makeSlice(credTypeStr); + + expectKeylet( + e, + keylet::credential(e.alice.id(), e.bob.id(), credType), + "credential(subject, issuer, credType)", + e.alice.id(), + e.bob.id(), + credType); + + expectKeylet( + e, keylet::credential(e.someID()), "credential(uint256)", e.someID()); + + // Subject and issuer are both AccountIDs, so the assertion above only + // has teeth if their order matters. + EXPECT_NE( + keylet::credential(e.alice.id(), e.bob.id(), credType).key, + keylet::credential(e.bob.id(), e.alice.id(), credType).key); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/DIDEntry.cpp b/src/tests/libxrpl/ledger/DIDEntry.cpp new file mode 100644 index 0000000000..41b27a486c --- /dev/null +++ b/src/tests/libxrpl/ledger/DIDEntry.cpp @@ -0,0 +1,17 @@ +#include + +#include + +#include +#include + +namespace xrpl::test { + +TEST(DIDEntryTests, Constructors) +{ + EntryTestEnv e; + + expectKeylet(e, keylet::did(e.alice.id()), "did(account)", e.alice.id()); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/DelegateEntry.cpp b/src/tests/libxrpl/ledger/DelegateEntry.cpp new file mode 100644 index 0000000000..a27299df46 --- /dev/null +++ b/src/tests/libxrpl/ledger/DelegateEntry.cpp @@ -0,0 +1,29 @@ +#include + +#include +#include + +#include +#include + +namespace xrpl::test { + +TEST(DelegateEntryTests, Constructors) +{ + EntryTestEnv e; + + expectKeylet( + e, + keylet::delegate(e.alice.id(), e.bob.id()), + "delegate(account, authorizedAccount)", + e.alice.id(), + e.bob.id()); + + // Both arguments are AccountIDs, so the assertion above only has teeth + // if their order matters. + EXPECT_NE( + keylet::delegate(e.alice.id(), e.bob.id()).key, + keylet::delegate(e.bob.id(), e.alice.id()).key); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp new file mode 100644 index 0000000000..bba2a58c8a --- /dev/null +++ b/src/tests/libxrpl/ledger/DepositPreauthEntry.cpp @@ -0,0 +1,54 @@ +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +TEST(DepositPreauthEntryTests, Constructors) +{ + EntryTestEnv e; + + std::string const credTypeStr = "termsandconditions"; + std::set> const authCreds{{e.bob.id(), makeSlice(credTypeStr)}}; + + expectKeylet( + e, + keylet::depositPreauth(e.alice.id(), e.bob.id()), + "depositPreauth(owner, preauthorized)", + e.alice.id(), + e.bob.id()); + + expectKeylet( + e, + keylet::depositPreauth(e.alice.id(), authCreds), + "depositPreauth(owner, authCreds)", + e.alice.id(), + authCreds); + + expectKeylet( + e, keylet::depositPreauth(e.someID()), "depositPreauth(uint256)", e.someID()); + + // Owner and preauthorized are both AccountIDs, so the assertion above + // only has teeth if their order matters. + EXPECT_NE( + keylet::depositPreauth(e.alice.id(), e.bob.id()).key, + keylet::depositPreauth(e.bob.id(), e.alice.id()).key); + + // The credential-set overload must not collide with the single-account + // one. + EXPECT_NE( + keylet::depositPreauth(e.alice.id(), authCreds).key, + keylet::depositPreauth(e.alice.id(), e.bob.id()).key); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp new file mode 100644 index 0000000000..0efcb365f6 --- /dev/null +++ b/src/tests/libxrpl/ledger/DirectoryNodeEntry.cpp @@ -0,0 +1,28 @@ +#include + +#include +#include + +#include +#include + +#include + +namespace xrpl::test { + +TEST(DirectoryNodeEntryTests, Constructors) +{ + EntryTestEnv e; + + expectKeylet( + e, keylet::ownerDir(e.alice.id()), "ownerDir(id)", e.alice.id()); + + expectKeylet( + e, keylet::page(e.someID(), 3u), "page(root, index)", e.someID(), std::uint64_t{3}); + + // The two overloads reach different keylet:: functions; a copy-paste + // slip between them would be invisible otherwise. + EXPECT_NE(keylet::ownerDir(e.alice.id()).key, keylet::page(e.someID(), 3u).key); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/EntryTestHelpers.h b/src/tests/libxrpl/ledger/EntryTestHelpers.h new file mode 100644 index 0000000000..d98b40795e --- /dev/null +++ b/src/tests/libxrpl/ledger/EntryTestHelpers.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace xrpl::test { + +/** + * Scaffolding shared by the per-entry-type suites. + * + * Each of those suites needs the same three things: a ledger with a few funded + * accounts, a throwaway ApplyView that is never applied, and some arbitrary + * uint256 to stand in for an object ID. Build one of these per test case -- + * TxTest construction dominates the runtime of these tests by a wide margin, + * and none of the assertions mutate the ledger. + */ +class EntryTestEnv +{ +public: + TxTest env; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + + EntryTestEnv() : av_(&fundAndClose(), TapNone) + { + } + + /** + * The closed ledger apply() was built over. Nothing here closes another + * ledger or submits a transaction afterward, so this and apply() never + * diverge. + */ + [[nodiscard]] ReadView const& + read() const + { + return env.getClosedLedger(); + } + + [[nodiscard]] ApplyView& + apply() + { + return av_; + } + + /** + * An arbitrary but stable uint256, for the entry constructors that take + * an object ID directly. Nothing in the ledger has this key, which is the + * point: those overloads should resolve to a non-existent entry. + */ + [[nodiscard]] uint256 + someID() const + { + return read().header().parentHash; + } + +private: + // Runs from the av_ member initializer, so it may only touch env and the + // accounts -- everything declared above av_. + ReadView const& + fundAndClose() + { + env.createAccount(alice, XRP(10'000)); + env.createAccount(bob, XRP(10'000)); + env.createAccount(carol, XRP(10'000)); + env.close(); + return env.getClosedLedger(); + } + + ApplyViewImpl av_; +}; + +/** + * Assert that both flavors of @p Entry built from @p args resolve the ledger + * object that @p expected names. + * + * The entry classes are near identical, so the defect they invite is a + * copy-paste one: a constructor that reaches the wrong keylet:: function, or + * that transposes two same-typed arguments. Comparing against an independently + * spelled-out keylet at the call site catches exactly that. + * + * @p what names the overload under test, so a failure says which one broke. + */ +template