diff --git a/.testnet/scenarios/export/export_helpers.py b/.testnet/scenarios/export/export_helpers.py index c071a05d4..243a0cc38 100644 --- a/.testnet/scenarios/export/export_helpers.py +++ b/.testnet/scenarios/export/export_helpers.py @@ -111,22 +111,22 @@ def export_authority(ctx, *, require_unl_report=True, committee_node_ids=None): """Build an account-owned committee declaration for a direct Export.""" ledger_result = ctx.ledger("validated") or {} ledger = ledger_result.get("ledger", {}) - universe_hash = ledger_result.get("ledger_hash") or ledger.get("hash") - if not universe_hash: + ledger_hash = ledger_result.get("ledger_hash") or ledger.get("hash") + if not ledger_hash: raise AssertionError(f"Validated ledger hash unavailable: {ledger_result}") report = ( ctx.rpc.request( 0, "ledger_entry", - {"index": _unl_report_index(), "ledger_hash": universe_hash}, + {"index": _unl_report_index(), "ledger_hash": ledger_hash}, ) or {} ) active = report.get("node", {}).get("ActiveValidators", []) if not active: if require_unl_report: - raise AssertionError(f"UNLReport active universe unavailable: {report}") + raise AssertionError(f"UNLReport active validators unavailable: {report}") # The negative scenario still submits a structurally valid roster so # source eligibility, rather than client construction, rejects it. masters = _validator_master_keys_by_node(ctx) @@ -163,6 +163,26 @@ def export_authority(ctx, *, require_unl_report=True, committee_node_ids=None): return _export_committee_fields(selected_keys) +async def create_export_committee( + ctx, log, wallet, *, committee_node_ids=None +): + """Create one immutable account-owned committee and return its fields.""" + authority = export_authority(ctx, committee_node_ids=committee_node_ids) + result = await ctx.submit_and_wait( + { + "TransactionType": "Export", + "ExportCommittee": authority["ExportCommittee"], + "Fee": "1000000", + }, + wallet, + ) + meta = result.get("meta", result.get("metaData", {})) + if meta.get("TransactionResult") != "tesSUCCESS": + raise AssertionError(f"Export committee setup failed: {result}") + log(f"Export committee created: {authority['ExportCommitteeHash']}") + return authority + + def find_export_signature_witness(ctx, seq, origin_hash): """Find a later-ledger ExportSignatures witness for an Export origin.""" result = ctx.ledger(seq, transactions=True) @@ -261,7 +281,7 @@ async def submit_direct_export( tx_hash = result.get("hash") or result.get("tx_json", {}).get("hash") if not tx_hash: - raise AssertionError(f"Universe mismatch missing tx hash: {result}") + raise AssertionError(f"Committee eligibility failure missing tx hash: {result}") validated = result if not result.get("validated"): validated = await wait_for_validated_transaction( diff --git a/.testnet/scenarios/export/steady_state_export.py b/.testnet/scenarios/export/steady_state_export.py index b9b9263d3..53aa37709 100644 --- a/.testnet/scenarios/export/steady_state_export.py +++ b/.testnet/scenarios/export/steady_state_export.py @@ -15,6 +15,7 @@ from export_helpers import ( dst_param, assert_hook_accepted, assert_export_latch, + create_export_committee, wait_for_export_signature_witness, ) @@ -25,7 +26,7 @@ XPORT_HOOK_C = r""" extern int32_t _g(uint32_t id, uint32_t maxiter); extern int64_t accept(uint32_t read_ptr, uint32_t read_len, int64_t error_code); extern int64_t rollback(uint32_t read_ptr, uint32_t read_len, int64_t error_code); -extern int64_t xport(uint32_t write_ptr, uint32_t write_len, uint32_t read_ptr, uint32_t read_len); +extern int64_t xport(uint32_t write_ptr, uint32_t write_len, uint32_t read_ptr, uint32_t read_len, uint32_t committee_hash_ptr, uint32_t committee_hash_len); extern int64_t xport_reserve(uint32_t count); extern int64_t hook_account(uint32_t write_ptr, uint32_t write_len); extern int64_t otxn_param(uint32_t write_ptr, uint32_t write_len, uint32_t name_ptr, uint32_t name_len); @@ -118,7 +119,9 @@ int64_t hook(uint32_t reserved) { ENCODE_ACCOUNT(buf, dst, atDESTINATION); uint8_t hash[32]; - int64_t xport_result = xport(SBUF(hash), (uint32_t)tx, buf - tx); + static const uint8_t committee_hash[32] = { COMMITTEE_HASH_BYTES }; + int64_t xport_result = xport( + SBUF(hash), (uint32_t)tx, buf - tx, SBUF(committee_hash)); ASSERT(xport_result == 32); return accept(0, 0, 0); @@ -137,8 +140,13 @@ async def scenario(ctx, log): alice = ctx.account("alice") carol = ctx.account("carol") + authority = await create_export_committee(ctx, log, alice.wallet) + committee_bytes = bytes.fromhex(authority["ExportCommitteeHash"]) + committee_initializer = ", ".join(f"0x{byte:02X}" for byte in committee_bytes) + hook_source = XPORT_HOOK_C.replace("COMMITTEE_HASH_BYTES", committee_initializer) + # Compile and install xport hook on alice - wasm = ctx.compile_hook(XPORT_HOOK_C, label="xport") + wasm = ctx.compile_hook(hook_source, label="xport") await ctx.submit_and_wait( { "TransactionType": "SetHook", diff --git a/src/test/app/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp index 2da63fac9..f0e590deb 100644 --- a/src/test/app/AccountDelete_test.cpp +++ b/src/test/app/AccountDelete_test.cpp @@ -18,6 +18,7 @@ //============================================================================== #include +#include #include #include @@ -469,6 +470,40 @@ public: env.close(); } + void + testExportCommitteeCleanup(FeatureBitset features) + { + using namespace jtx; + + testcase("Export committee cleanup"); + + Env env{*this, features | featureExport}; + Account const alice("alice"); + Account const becky("becky"); + env.fund(XRP(10000), alice, becky); + env.close(); + + auto const roster = serializeExportCommittee({alice.pk()}); + auto const digest = exportCommitteeHash(makeSlice(roster)); + Json::Value setup; + setup[jss::TransactionType] = "Export"; + setup[jss::Account] = alice.human(); + setup[sfExportCommittee.jsonName] = strHex(roster); + env(setup); + env.close(); + + auto const committeeKey = keylet::exportCommittee(alice.id(), digest); + BEAST_EXPECT(env.closed()->exists(committeeKey)); + + incLgrSeqForAccDel(env, alice); + auto const acctDelFee{drops(env.current()->fees().increment)}; + env(acctdelete(alice, becky), fee(acctDelFee)); + env.close(); + + BEAST_EXPECT(!env.closed()->exists(keylet::account(alice.id()))); + BEAST_EXPECT(!env.closed()->exists(committeeKey)); + } + void testResurrection(FeatureBitset features) { @@ -1279,6 +1314,7 @@ public: testBasics(features); testDirectories(features); testOwnedTypes(features); + testExportCommitteeCleanup(features); testResurrection(features); testAmendmentEnable(features); testTooManyOffers(features); diff --git a/src/test/app/ExportResultBuilder_test.cpp b/src/test/app/ExportResultBuilder_test.cpp index ef56d75cf..548025bf7 100644 --- a/src/test/app/ExportResultBuilder_test.cpp +++ b/src/test/app/ExportResultBuilder_test.cpp @@ -117,16 +117,14 @@ class ExportResultBuilder_test : public beast::unit_test::suite { public: void - testAssemblesSignedMetadata() + testAssemblesMultiSignedTransaction() { - testcase("assembles signed metadata"); + testcase("assembles multisigned transaction"); auto const signerA = randomKeyPair(KeyType::secp256k1); auto const signerB = randomKeyPair(KeyType::secp256k1); auto const innerTx = makeExportedPayment( calcAccountID(signerA.first), calcAccountID(signerB.first)); - auto const exportTxHash = makeHash("outer-export-tx"); - ExportResultBuilder::SignatureSnapshot signatures; signatures.emplace( signerA.first, @@ -137,16 +135,9 @@ public: ExportResultBuilder::signExportedTxn( innerTx, signerB.first, signerB.second)); - auto assembled = ExportResultBuilder::assembleDirect( - innerTx, signatures, 123, exportTxHash); - - BEAST_EXPECT(assembled.signerCount == 2); - BEAST_EXPECT(assembled.metadata.getFieldU32(sfLedgerSequence) == 123); - BEAST_EXPECT( - assembled.metadata.getFieldH256(sfTransactionHash) == exportTxHash); - - auto const& multiSigned = - assembled.metadata.peekAtField(sfExportedTxn).downcast(); + auto const multiSigned = + ExportResultBuilder::buildMultiSignedExportedTxn( + innerTx, signatures); BEAST_EXPECT(multiSigned.getFieldVL(sfSigningPubKey).empty()); BEAST_EXPECT(multiSigned.isFieldPresent(sfSigners)); @@ -169,42 +160,14 @@ public: BEAST_EXPECT(verify(pk, sigData.slice(), makeSlice(sigVL))); } - BEAST_EXPECT( - assembled.signedTxHash == - multiSigned.getHash(HashPrefix::transactionID)); + auto const signedTxHash = + multiSigned.getHash(HashPrefix::transactionID); Serializer serialized; multiSigned.add(serialized); SerialIter sit(serialized.slice()); STTx signedTx{std::ref(sit)}; - BEAST_EXPECT(signedTx.getTransactionID() == assembled.signedTxHash); - } - - void - testSkipsEmptySignatures() - { - testcase("skips empty signatures"); - - auto const signer = randomKeyPair(KeyType::secp256k1); - auto const dst = randomKeyPair(KeyType::secp256k1); - auto const innerTx = makeExportedPayment( - calcAccountID(signer.first), calcAccountID(dst.first)); - - ExportResultBuilder::SignatureSnapshot signatures; - signatures.emplace(signer.first, Buffer{}); - - auto assembled = ExportResultBuilder::assembleDirect( - innerTx, signatures, 456, makeHash("empty-sig-export")); - - BEAST_EXPECT(assembled.signerCount == 0); - - auto const& multiSigned = - assembled.metadata.peekAtField(sfExportedTxn).downcast(); - BEAST_EXPECT(multiSigned.getFieldVL(sfSigningPubKey).empty()); - BEAST_EXPECT(!multiSigned.isFieldPresent(sfSigners)); - BEAST_EXPECT( - assembled.signedTxHash == - multiSigned.getHash(HashPrefix::transactionID)); + BEAST_EXPECT(signedTx.getTransactionID() == signedTxHash); } void @@ -336,7 +299,7 @@ public: calcAccountID(src.first), calcAccountID(dst.first)); ExportResultBuilder::SignatureSnapshot signatures; - while (signatures.size() < STTx::maxMultiSigners()) + while (signatures.size() < STTx::maxMultiSigners() + 4) { auto const signer = randomKeyPair(KeyType::secp256k1); signatures.emplace( @@ -345,13 +308,9 @@ public: innerTx, signer.first, signer.second)); } - auto assembled = ExportResultBuilder::assembleDirect( - innerTx, signatures, 789, makeHash("many-sig-export")); - - BEAST_EXPECT(assembled.signerCount == STTx::maxMultiSigners()); - - auto const& multiSigned = - assembled.metadata.peekAtField(sfExportedTxn).downcast(); + auto const multiSigned = + ExportResultBuilder::buildMultiSignedExportedTxn( + innerTx, signatures); BEAST_EXPECT(multiSigned.isFieldPresent(sfSigners)); if (multiSigned.isFieldPresent(sfSigners)) @@ -365,41 +324,6 @@ public: signers[i].getAccountID(sfAccount)); } } - - BEAST_EXPECT( - assembled.signedTxHash == - multiSigned.getHash(HashPrefix::transactionID)); - } - - void - testAssemblesWitnessReferenceMetadata() - { - testcase("assembles witness-reference metadata"); - - auto const signer = randomKeyPair(KeyType::secp256k1); - auto const dst = randomKeyPair(KeyType::secp256k1); - auto const innerTx = makeExportedPayment( - calcAccountID(signer.first), calcAccountID(dst.first)); - auto const exportTxHash = makeHash("outer-export-reference"); - auto const witnessHash = makeHash("export-signature-witness"); - - ExportResultBuilder::SignatureSnapshot signatures; - signatures.emplace( - signer.first, - ExportResultBuilder::signExportedTxn( - innerTx, signer.first, signer.second)); - - auto assembled = ExportResultBuilder::assembleClosedLedger( - innerTx, signatures, 321, exportTxHash, witnessHash); - - BEAST_EXPECT(assembled.signerCount == 1); - BEAST_EXPECT(assembled.metadata.getFieldU32(sfLedgerSequence) == 321); - BEAST_EXPECT( - assembled.metadata.getFieldH256(sfTransactionHash) == exportTxHash); - BEAST_EXPECT( - assembled.metadata.getFieldH256(sfExportSignatureHash) == - witnessHash); - BEAST_EXPECT(!assembled.metadata.isFieldPresent(sfExportedTxn)); } void @@ -812,12 +736,10 @@ public: void run() override { - testAssemblesSignedMetadata(); - testSkipsEmptySignatures(); + testAssemblesMultiSignedTransaction(); testBuildMultiSignedExportedTxnDirect(); testExportIntentHashIgnoresSignerSubset(); testCapsSignerArray(); - testAssemblesWitnessReferenceMetadata(); testSignatureWitnessRoundTrip(); testSparseSignatureWitnessRoundTrip(); testDestinationSignerOrderIsIndependent(); diff --git a/src/test/ledger/Invariants_test.cpp b/src/test/ledger/Invariants_test.cpp index adb6652d0..432132c91 100644 --- a/src/test/ledger/Invariants_test.cpp +++ b/src/test/ledger/Invariants_test.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -1234,6 +1235,29 @@ class Invariants_test : public beast::unit_test::suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); } + void + testValidExportCommittee() + { + using namespace test::jtx; + + testcase << "ValidExportCommittee"; + + doInvariantCheck( + {{"Invariant failed: malformed or mutated Export committee"}}, + [](Account const& A1, Account const& A2, ApplyContext& ac) { + auto const roster = serializeExportCommittee({A2.pk()}); + auto const digest = exportCommitteeHash(makeSlice(roster)); + auto sle = std::make_shared( + keylet::exportCommittee(A1.id(), digest)); + sle->setAccountID(sfAccount, A1.id()); + sle->setFieldH256(sfExportCommitteeHash, uint256{}); + sle->setFieldVL(sfExportCommittee, roster); + sle->setFieldU64(sfOwnerNode, 0); + ac.view().insert(sle); + return true; + }); + } + void testLockedBalance() { @@ -1288,6 +1312,7 @@ public: testValidNewAccountRoot(); testNFTokenPageInvariants(); testPermissionedDomainInvariants(); + testValidExportCommittee(); testLockedBalance(); } }; diff --git a/src/test/rpc/ServerDefinitions_test.cpp b/src/test/rpc/ServerDefinitions_test.cpp index 81fb4c8e3..635b671d4 100644 --- a/src/test/rpc/ServerDefinitions_test.cpp +++ b/src/test/rpc/ServerDefinitions_test.cpp @@ -72,6 +72,17 @@ public: BEAST_EXPECT(result[jss::result][jss::status] == "success"); } + { + auto const& exportFlags = + result[jss::result][jss::TRANSACTION_FLAGS]["Export"]; + BEAST_EXPECT( + exportFlags["tfExportEraseLatch"].asUInt() == + tfExportEraseLatch); + BEAST_EXPECT( + exportFlags["tfExportEraseCommittee"].asUInt() == + tfExportEraseCommittee); + } + // check exception SFields { auto const fieldExists = [&](std::string name) { diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index abb56e5c1..45876e9cc 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -1998,153 +1998,6 @@ ConsensusExtensions::clearAcceptedExportSigSet() acceptedExportSigSetHash_.reset(); } -std::optional -ConsensusExtensions::agreedExportSignatures( - STTx const& exportTx, - uint256 const& txHash, - std::size_t threshold) const -{ - // A local exportSigSetMap_ is only candidate material until the sidecar - // gate accepts its root. Without this guard, a timed-out node with a local - // partial-but-quorum map could mint a different signed export blob from - // the quorum-aligned nodes. - if (!acceptedExportSigSetHash_) - { - JLOG(j_.warn()) << "Export: exportSigSet not accepted" - << " txHash=" << txHash << " threshold=" << threshold; - return std::nullopt; - } - - auto const acceptedHash = *acceptedExportSigSetHash_; - std::shared_ptr agreedMap; - if (exportSigSetMap_ && - exportSigSetMap_->getHash().as_uint256() == acceptedHash) - { - agreedMap = exportSigSetMap_; - } - else - { - agreedMap = app_.getInboundTransactions().getSet(acceptedHash, false); - } - - if (!agreedMap) - { - JLOG(j_.warn()) << "Export: accepted exportSigSet missing" - << " acceptedHash=" << acceptedHash - << " txHash=" << txHash << " threshold=" << threshold; - return std::nullopt; - } - if (agreedMap->mapType() != SHAMapType::SIDECAR) - { - JLOG(j_.warn()) << "Export: accepted exportSigSet has wrong map type" - << " acceptedHash=" << acceptedHash - << " txHash=" << txHash; - return std::nullopt; - } - - auto const agreedHash = agreedMap->getHash().as_uint256(); - if (agreedHash != acceptedHash) - { - JLOG(j_.warn()) << "Export: accepted exportSigSet hash mismatch" - << " setHash=" << agreedHash - << " acceptedHash=" << acceptedHash - << " txHash=" << txHash; - return std::nullopt; - } - - // The accepted root is the membership decision. Candidate construction - // filters live active signers, but materialization must not re-resolve - // signer keys through mutable manifests or nodes can diverge after a - // rotation. Keep cryptographic verification below; drop only the live - // membership re-filter. - ExportSignatureSnapshot signatures; - bool invalid = false; - agreedMap->visitLeaves( - [&](boost::intrusive_ptr const& item) { - if (invalid) - return; - - try - { - auto admitted = admitSidecarLeaf( - item->key(), - item->slice(), - agreedHash, - j_, - "Export", - "exportSigSet", - "agreed", - ExportLimits::maxExportSignatureSidecarBytes, - &invalid); - if (!admitted || admitted->type != sidecarExportSig) - return; - auto const& sidecar = admitted->sidecar; - - if (!sidecar.isFieldPresent(sfTransactionHash) || - !sidecar.isFieldPresent(sfSigningPubKey) || - !sidecar.isFieldPresent(sfTxnSignature)) - return; - - if (sidecar.getFieldH256(sfTransactionHash) != txHash) - return; - - auto const pk = sidecar.getFieldVL(sfSigningPubKey); - if (!publicKeyType(makeSlice(pk))) - return; - - PublicKey const valPK{makeSlice(pk)}; - - auto const sigVL = sidecar.getFieldVL(sfTxnSignature); - auto const sigSlice = makeSlice(sigVL); - if (!verifyExportSignatureAgainstTx( - exportTx, - valPK, - sigSlice, - txHash, - j_, - "agreed exportSigSet")) - { - invalid = true; - return; - } - - Buffer sigBuf(sigSlice.data(), sigSlice.size()); - if (auto const [_, inserted] = - signatures.emplace(valPK, std::move(sigBuf)); - !inserted) - { - JLOG(j_.warn()) - << "Export: accepted exportSigSet duplicate signer" - << " setHash=" << agreedHash << " txHash=" << txHash - << " signer=" << toBase58(TokenType::NodePublic, valPK); - invalid = true; - } - } - catch (std::exception const& e) - { - JLOG(j_.warn()) - << "Export: agreed exportSigSet parse failed" - << " setHash=" << agreedHash << " txHash=" << txHash - << " error=" << e.what(); - invalid = true; - } - }); - - if (invalid) - return std::nullopt; - - if (signatures.size() < threshold) - { - JLOG(j_.info()) << "Export: accepted exportSigSet below quorum" - << " setHash=" << agreedHash << " txHash=" << txHash - << " signers=" << signatures.size() - << " threshold=" << threshold; - return std::nullopt; - } - - return signatures; -} - std::optional ConsensusExtensions::agreedExportWitness( STTx const& exportSigningPayload, diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index fbfe1ed0e..eedbcd750 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -128,7 +128,6 @@ public: using ActiveValidatorView = ripple::ActiveValidatorView; using ActiveValidatorViewPtr = std::shared_ptr; - using ExportSignatureSnapshot = std::map; struct ExportWitnessMaterial { ExportResultBuilder::PositionedSignatureSnapshot signatures; @@ -426,12 +425,6 @@ public: void clearAcceptedExportSigSet(); - std::optional - agreedExportSignatures( - STTx const& exportTx, - uint256 const& txHash, - std::size_t threshold) const; - std::optional agreedExportWitness( STTx const& exportSigningPayload, diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 74d4e1ac0..3bec8b807 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -5,7 +5,7 @@ ConsensusEntropy/RNG, proposal sidecars, and export signature convergence. Read this before changing `ConsensusExtensions`, `ConsensusExtensionsTick`, `ExtendedPosition`, sidecar SHAMap handling, or the related CSF tests. -The short version: extension data may coordinate extra same-ledger features, +The short version: extension data may coordinate extra ledger-visible features, but it must not redefine ordinary transaction-set consensus. When extension state cannot be made safe in time, the extension degrades deterministically and the ledger still closes. @@ -16,7 +16,7 @@ coordination step can avoid it. Fast means those coordination steps stay short and conditional, never becoming an open-ended wait for an extension feature to succeed. Works means missed or late extension material follows that feature's deterministic fallback, such as Tier 1 consensus_fallback entropy for RNG or normal -Export retry/expiry, rather than blocking core consensus. +Export pending/expiry behavior, rather than blocking core consensus. ## Fallback Semantics @@ -70,8 +70,8 @@ sidecar gate has had its bounded chance to use proofed/quorum material. they must not block ledger close indefinitely. If RNG cannot establish an accepted entropy set, it injects the deterministic Tier 1 consensus_fallback digest (labeled `consensus_fallback`, count 0). If export - signatures cannot converge, export retries or expires according to - transaction rules. + signatures cannot converge, the Export remains pending or its publication + window expires without blocking ledger close. The bounded fallback rule is not permission for local shortcuts to decide ledger output. "Cannot establish" means the accepted-hash gate did not @@ -140,7 +140,8 @@ sidecar gate has had its bounded chance to use proofed/quorum material. ## Validator Set And Quorum -The active validator view is the shared denominator for RNG and export: +The active validator view is the shared source-alignment denominator for RNG +and Export qV. Export qC has a separate account-owned committee denominator: - Prefer `UNLReport.sfActiveValidators` from the consensus parent ledger. - If no report is available, fall back to configured trusted validators so @@ -214,7 +215,8 @@ served, fetched, or merged from peers, and generic transaction-set acquisition must reject them. A node that missed proposal-carried material may therefore be unable to materialize the quorum root this round. For RNG it falls back to the explicit Tier 1 consensus digest or accepts a lower locally materialized tier; for -Export the transaction retries or expires. If a quorum of validators did +Export the latch remains pending or its publication window expires. If a quorum +of validators did materialize and validate a richer synthetic ledger, a missing-material validator follows that ledger through the normal validation/LCL path after the round, just as it would after failing to build any other majority ledger. @@ -403,7 +405,7 @@ Sidecar SHAMaps are local immutable snapshots: - Peer-advertised roots are alignment evidence, not payload availability. - Nodes never fetch, advertise, serve, or merge sidecar maps from peers. - If a quorum root cannot be materialized locally before the bounded deadline, - RNG degrades/falls back and Export retries/expires. + RNG degrades/falls back and Export injects no witness in that ledger. Do not use avalanche-style transaction inclusion logic for sidecar inputs. For RNG and export sidecars, the disagreement to resolve is usually timing or @@ -426,50 +428,37 @@ sidecar memory. `featureExport` and `featureConsensusEntropy` are independently amendment gated. -Export can run without ConsensusEntropy and still uses the active validator -view's 80% quorum threshold. Verified export signature sidecars converge -through `ExtendedPosition`, and the `exportSigSetHash` is signed by proposals -whether or not RNG is enabled. Do not make Export liveness depend on unanimity: -one active validator with a missing, delayed, or conflicting sidecar must not -veto an otherwise quorum-aligned export round. +Export can run without ConsensusEntropy. Two thresholds remain deliberately +separate: -Non-standalone Export completion requires a UNLReport-backed active validator -view. If the parent ledger has no `UNLReport`, Export has no safe deterministic -fallback result, so validators do not publish target-chain signature shares and -the export retries or expires rather than finalizing against local -trusted-configuration thresholds. +- qC is `ceil(0.8 * C)` over one explicit account-owned committee of at most 32 + validator master keys. It decides whether one Export has enough target + signatures. +- qV is the source active-validator threshold used to align one exact + `exportSigSetHash`. It decides whether the sidecar snapshot is coordinated + enough to materialize witnesses. -Export's original pre-NegativeUNL validator population must also fit the -32-member target serialization bound. Validators publish no target-chain shares -and Export cannot materialize while it exceeds that bound; temporary NegativeUNL -filtering must not select an implicit bridge committee. +One missing sidecar advertiser must not veto a qV-aligned root, and one missing +committee signer must not lower qC. Neither threshold is unanimity or a count of +locally visible peers. -The bounded deployment contract then mirrors the source validator-derived key -universe, weights, and Export/validation threshold in the destination account's -SignerList. A lower destination threshold permits target execution before the -authority needed for source materialization exists. A higher threshold preserves -that safety direction but can strand a successful source latch. The destination -network's ledger-validation quorum is separate: it validates the authorized -transaction's containing ledger, and XPOP proves that finality on return. A -static destination SignerList should remain anchored to the original source -universe during NegativeUNL periods, trading Export liveness for unchanged -destination authority. +The committee is an immutable `ltEXPORT_COMMITTEE` owned by the exporting +account. It stores one canonical sorted master-key roster and is named by a +network-neutral content digest. Latches store only the digest. There is no +default committee and no implicit capped subset of the full UNL. Bare setup may +pre-stage a roster, but every actual intent checks every roster master against +the exact admitting parent ledger's pre-NegativeUNL `UNLReport`. Inline roster +creation and intent admission perform that check atomically. Committee deletion +is blocked while the owner has any live Export latch. -The bounded MVP deployment requires a submitter co-signer in the destination -SignerList, held by the target-submission process. Choose weights so destination -quorum requires both the source-validator threshold and that key; for validator -weight total `V` and required validator weight `q`, submitter weight `V` with -target quorum `V + q` is the simple construction. Proposal-carried validator -shares then remain inert until the submitter observes a validated source latch -and signs. The key cannot authorize alone but becomes a liveness and censorship -dependency. It consumes one destination SignerList entry, so this deployment -supports at most 31 source validator identities under a 32-entry limit. - -The current code has no committee selector. A future committee must be explicit, -ledger-anchored, and versioned; a locally configured or sorted UNL subset would -reintroduce nondeterministic or unnamed authority. Full source consensus would -still validate the Export, while committee quorum plus the submitter would be -the honestly stated destination authority. +The destination account's SignerList remains the actual remote authority. Its +keys, weights, optional operator signer, and rotation policy are client/operator +configuration that source consensus cannot verify. The committee cap matches +`STTx::maxMultiSigners()`; reserving a separate operator entry reduces the +maximum validator roster usable by that destination account. A lower remote +threshold weakens the claimed custody policy, while a higher threshold may +strand an otherwise sufficient source witness. Target ledger validation and the +returning XPOP are separate from this authorization decision. The extended proposal machinery is enabled when either feature needs signed sidecar fields. Do not make Export depend on RNG availability just because RNG @@ -488,110 +477,88 @@ negotiation before activation. When `featureExport` is disabled, the export sidecar gate is disabled too. Stale collector entries must not keep a stopped amendment active. -Only verified export signatures count toward quorum or enter export sidecar -SHAMaps. Proposal-ingress signatures are sender-bound to the trusted proposal -validator and may be stored as unverified until the matching export transaction -is available for cryptographic verification. +Intent admission creates an origin-keyed `ltEXPORT_LATCH` immediately. The latch +records the normalized identity digest, origin `W` and source sequence, +publication end, destination TicketSequence, committee digest, owner-directory +link, and pending-work link. Lack of signatures does not make the source +transaction retry. The pending directory is durable scheduling state across +rounds and restarts. -The consensus candidate transaction set is the authority for export signature -verification. The open ledger may be used for early proposal ingestion, but -once a candidate tx set exists, only signatures verified against the `ttEXPORT` -in that candidate set may become quorum material or enter `exportSigSetHash`. +Honest signature release is keyed to fully validated history, not to the open +ledger or current transaction candidate set. When an exact origin ledger becomes +validated, eligible committee members reconstruct the target payload, append +the canonical release anchor `(source domain, target domain, W, origin sequence, +origin hash)`, and sign that target with their current manifest signing key. +Ledger building and validation are asynchronous; a later build may already be +in progress. Export waits for finality without waiting or rewinding base +consensus. -Export sidecar publication is local-material only. A node may publish only the -verified export signatures it actually has locally, and only for `ttEXPORT` -transactions in the consensus candidate set. Peer-advertised export roots are -used for quorum alignment; they do not reconstruct missing signature material. -The accepted local snapshot root, once quorum-aligned, is the source for the -ledger witness. A node that cannot materialize the accepted quorum witness -locally retries or expires the export and follows the quorum ledger later through -normal validation if other validators built the witness. -`ttEXPORT_SIGNATURES` is the export signature witness interface. Network mode -derives it from the accepted `exportSigSetHash` sidecar snapshot; standalone/dev -helpers may synthesize the same witness from the local validator key. The pseudo -carries the full source-side validator signature witness and binds it to the -matching `ttEXPORT` via `sfTransactionHash`. It has no ledger-state effect by -itself; it exists so apply/replay sees the same signature material through the -transaction stream regardless of which producer supplied it. +Only structurally valid, attributable, cryptographically verified shares enter +the collector. A share identifies the owner, `W`, exact origin ledger, committee +position, signing key, and ordinary XRPL multisignature. Live admission resolves +the immutable committee master at that position through the manifest cache. A +missing binding is deferred or rejected; it never silently selects another +member. -If the consensus candidate contains a `ttEXPORT` but the node has no eligible -local export signatures yet, the export sidecar gate opens only a bounded -safety window for tx-converged peers to advertise `exportSigSetHash`. This is -not a wait-for-Export-success mechanism; it is a short opportunity to avoid -closing a minority ledger while sidecar convergence is already reachable. If no -advertised sidecar appears by the deadline, the gate stops waiting and the -export retries or expires through normal transaction rules. +The dedicated `mtEXPORT_SHARES` relay is the immediate post-validation flood and +subscription path. Proposal carriage republishes locally retained shares as a +bounded authenticated backup. Both feed the same collector and verification +logic. Proposal-carried material is still untrusted until the proposal and share +semantics verify. -Export success requires quorum alignment on `exportSigSetHash`, not merely a -local collector quorum. Since `featureExport` enables signed extended proposal -fields, a quorum-aligned `exportSigSetHash` is enough to proceed even if a -tx-converged minority peer has not advertised an export sidecar hash. Do not let -one active validator with a missing sidecar force an otherwise quorum-aligned -export round to retry or expire. Full observation remains useful diagnostics; it -is not an Export success precondition. If no export signature hash reaches quorum -alignment by the bounded deadline, do not choose the largest non-quorum set; the -export retries or expires according to normal transaction rules. -Closed-ledger apply consumes the pre-scanned `ttEXPORT_SIGNATURES` witness, not -the live collector and not ephemeral sidecar state. `Export::doApply` rebuilds -the active validator view from the parent ledger, verifies each witness -signature against the `ttEXPORT` inner transaction, requires source-view quorum, -then canonically assembles the target-chain multisigned transaction. In live -consensus builds, `onPreBuild` first removes any pre-existing export witness and -re-materializes the witness from the accepted `exportSigSetHash` root. That -accepted transaction-stream witness supplies signer membership; apply must not -re-resolve those signing keys through the current manifest cache, because -manifest gossip can differ while the parent-ledger active view is the same. -Historical `LedgerReplay` consumes the same persisted witness after manifests -may have rotated. In both modes, apply still checks signatures and threshold. A -node that times out before accepting a root has no witness and retries/expires; -a node that proceeds uses the same transaction-stream witness during live build -and historical replay. The build-scoped witness map is only an index over that -ordered transaction stream, not hidden consensus state. This avoids -successful-but-different export blobs while preserving the bounded wait model. +The collector forms one complete bounded unique-share union over all live +origins, keyed by `(W, committeePosition)`. A second distinct valid contribution +at one position is conflicting and contributes zero for that origin. The union +is projected into an ephemeral sidecar map; its root is advertised in signed +`ExtendedPosition` traffic. qC is not a root filter: below-qC partial material +still participates in the common union, while qC is checked only when deciding +which witnesses can be materialized. -`sfExportResult` metadata stores `sfExportSignatureHash`, a direct reference to -the witness pseudo, rather than duplicating the full signature payload. Clients -assemble the final foreign-chain blob from `ttEXPORT` plus the witness -signatures, or can use a convenience RPC/helper that performs that pure -read-time assembly. That expansion must match `ExportResultBuilder`: canonical -AccountID signer ordering, empty `SigningPubKey`, and the target-chain signer -cap (`STTx::maxMultiSigners()`) before hashing or submitting. The witness can -carry more source-side signatures than the destination transaction may include. -Export currently refuses to sign or materialize when the original UNLReport -validator population exceeds that cap, even if NegativeUNL temporarily shrinks -the effective view below it. The assembly cap remains a defensive serialization -bound, not an implicit committee-selection policy. +Export sidecar publication is local-material only. Peer-advertised roots are qV +alignment evidence; they do not reconstruct missing signatures. A node injects +from an accepted root only when its exact local sidecar snapshot matches that +root and independently verifies. The bounded gate is a short chance for +already-in-flight material to align after ordinary transaction-set convergence, +not a wait for any particular Export to reach qC. Timeout always closes the +ledger and injects no Export witness from an unavailable or unaligned root. -The resulting Export latch stores the normalized target transaction's canonical -signing hash in `sfDigest`, not one assembled multisigned transaction ID. Import -therefore accepts any destination-valid signer subset for that exact signing -intent. A missing latch returns `telEXPORT_LATCH_REQUIRED` before consensus or -Hook execution, so an XPOP that races source materialization can be relayed -later. +For each still-pending origin that reaches qC in the accepted root, pre-build +materializes at most one canonical later-ledger `ttEXPORT_SIGNATURES`. The +pseudo carries `W`, the release-stamped target transaction, a committee-relative +contributor bitmap, and the selected signatures. It is the replay witness for +the accepted sidecar evidence. -Export-latch cancellation is non-revoking. While publication is pending it -unlinks signing work and retains callback readiness because shares already -published to peers cannot be withdrawn. Lifecycle control names the exact -source issuance `W`: no flag always unlinks and retains in every state, while -`tfExportEraseLatch` explicitly erases that exact latch in any state, releases -reserve, and knowingly accepts later target execution without callback -readiness. Publication expiry follows the retain path. Symmetric witness+XPOP -completion also erases. v1 has no automatic terminal-retirement clock, -permanent tombstone, or paid-bump transition. +`Change::applyExportSignatures` reads the referenced immutable committee from +parent ledger state, derives its size and qC, validates the contributor bitmap, +normalization and release anchor, checks every target signature, records the +witness transaction ID on the latch, and removes the pending link. It does not +read current manifests. Historical replay consumes the same witness bytes and +committee SLE and never regenerates shares. An explicitly erased latch makes a +concurrently ordered witness an evidence-only no-op. -Implementation status: origin-keyed creation, W-only lifecycle control, -expiry-unlink, and symmetric witness/XPOP recording are present. +The full release-stamped payload and signatures are stored once in the witness. +The latch stores only `sfExportSignatureHash`. Read-time assembly uses the +witness's target and signatures, empty `SigningPubKey`, canonical AccountID +ordering, and the target's 32-signer cap. The normalized latch digest is +signature-subset-independent, so a different sufficient signer subset does not +orphan the source callback. -This is intentionally leaner than XPOP. XPOP carries its own UNL and manifest -bundle so it can be independently verified as an external proof. Export witnesses -are not external proof bundles; they are inputs that made it into validated -ledger history. Making them self-contained would require embedding manifest -material or equivalent signing-key history in every witness, which is a separate -protocol/storage design. +Import/XPOP and witness arrival are independent monotonic facts. Whichever +arrives second erases the latch and releases reserve. Flagless W-only lifecycle +control unlinks publication but retains callback readiness; `tfExportEraseLatch` +irreversibly erases the exact issuance. Publication expiry follows the retain +path. Committee deletion remains blocked while any owner latch exists. v1 has +no permanent tombstone or paid-bump transition. -Closed-ledger apply must not promote unverified proposal-carried signatures into -current-round quorum material. It may verify and retain them for a future retry, -where they can be published in a sidecar set and converged before use. +The `export_signatures` subscription projects each admitted post-validation +share immediately and republishes retained live shares once per validated +cursor. Clients deduplicate by origin and committee position. Slow subscribers +must not hold collector or consensus locks, and silence after an origin leaves +the pending set is not a durable terminal event. + +This remains intentionally leaner than XPOP. XRPL sees ordinary multisignatures, +not a proof of Xahau finality. XPOP carries the reverse-chain proof material and +drives the matching callback after target finality. Export sig convergence runs in parallel with RNG. An export-side convergence failure must not change RNG semantics; an RNG fallback must not make export @@ -602,9 +569,10 @@ Accept-time cleanup must preserve Export state through `onPreBuild` whenever After the ordered transaction set contains that witness, replay must not need the round's export sidecar convergence state. -CSF consensus tests model the export sidecar gate directly. Testnet scenarios -under `.testnet/scenarios/export/` cover live-node Export+CE behavior and -Export-only quorum behavior. +CSF consensus tests model the Export sidecar gate directly. Unit and testnet +coverage must additionally exercise committee setup/use/deletion, post-validation +relay and subscription, restart recovery, below-qC persistence, and later +witness/XPOP ordering. ## Review Checklist @@ -627,11 +595,18 @@ When changing consensus extension code, check these questions: - Are sidecar entries typed as sidecars, not pseudo-transactions? - Are proposal-visible or validation-visible sidecar fields covered by the relevant signature and duplicate/replay identity? -- Are export signatures verified before they count? -- Does export success require `exportSigSetHash` alignment, not just local - collector quorum? -- Does every ledger-defining export signature enter replay as a - `ttEXPORT_SIGNATURES` witness before `ttEXPORT` applies? -- Can one bad validator deny Export to an honest quorum? It must not. -- Can timeout select a largest-but-below-quorum export sidecar set? It must not. +- Can an Export share be produced before the exact origin ledger validates? It + must not. +- Does every intent name an immutable account-owned committee whose complete + roster is checked against the exact admitting parent's pre-NegativeUNL + `UNLReport`? +- Are qC (committee sufficiency) and qV (source root alignment) kept separate? +- Are Export signatures attributable and verified before collector admission? +- Does witness materialization require local possession of the exact + qV-aligned `exportSigSetHash`, not merely a local qC? +- Does every ledger-defining Export signature enter replay through a later + self-contained `ttEXPORT_SIGNATURES` witness? +- Does accepted witness apply derive qC from the immutable committee SLE without + consulting mutable manifests? +- Can timeout select a largest-but-below-qC signature set? It must not. - Are CE and Export still independently gated and independently stoppable? diff --git a/src/xrpld/app/consensus/ExportIntent.md b/src/xrpld/app/consensus/ExportIntent.md index 55f803e73..099c66dcf 100644 --- a/src/xrpld/app/consensus/ExportIntent.md +++ b/src/xrpld/app/consensus/ExportIntent.md @@ -1,200 +1,176 @@ -# Export — Design Intent (canonical spine) +# Export - Design Intent (canonical spine) -This is the **normative** intent for `featureExport`: the invariants that must -hold regardless of how the implementation is refactored. The verbose mechanics -live in `ConsensusExtensionsDesign.md`; the reviewer-facing walkthrough lives in -the PR description. **Both defer to this file.** +This is the normative intent for `featureExport`: the invariants that must hold +regardless of implementation refactoring. Detailed mechanics live in +`ConsensusExtensionsDesign.md`; both documents must change with any deliberate +protocol change. -How to use it: if code contradicts an invariant below, the *code* is wrong — or -the invariant is being changed and **this file must be consciously edited in the -same change, with the rationale**. In particular, Export is not replay-clean -unless every closed-ledger effect can be rebuilt from the parent ledger and the -closed transaction set alone. +Export is not replay-clean unless every closed-ledger effect can be rebuilt +from the parent ledger and ordered closed transaction set alone. -## Purpose (one line) +## Purpose -Export lets a quorum of active validators produce a foreign-chain-submittable -transaction from an agreed `ttEXPORT`, without letting local sidecar timing, -collector state, or validator silence change the bytes of a successful closed -ledger result. +Export lets an account choose a bounded validator committee whose members sign +an ordinary target-chain transaction only after the exact source intent has +validated. A later source-ledger witness records the sufficient signatures. ## Invariants -**INV-1 — Quorum, not unanimity.** -Export success is gated by active-validator quorum alignment on the export -signature set. A missing, delayed, or silent minority must not veto an otherwise -quorum-aligned export. -*Anti-pattern:* requiring full observation of every tx-converged validator before -success. +**INV-1 - Source intent precedes authority release.** +`ttEXPORT` first validates its target payload, committee reference, source +account, destination TicketSequence, reserved origin Memo, and bounded timing +fields. Successful apply creates a durable origin-keyed latch. It does not +claim that signatures or a witness already exist. -**INV-2 — Fixed active-view denominator.** -Export thresholds are computed over the parent-ledger active validator view. The -denominator must never be derived from locally observed peers, locally available -signatures, or the subset that happened to advertise sidecar hashes. -On networks that use NegativeUNL, `featureNegativeUNLActiveViewCap` should be -active before or with Export so producer-side nUNL voting caps against the same -UNLReport active-source universe that bounds NegativeUNL shrink. Export quorum -itself is computed over the effective post-NegativeUNL active view. Direct -Export apply still rebuilds and defensively caps the parent active view before -checking the witness threshold. -*Anti-pattern:* letting silence shrink the quorum threshold. +*Anti-pattern:* treating provisional open-ledger execution or source +transaction-set agreement as authorization to release destination signatures. -**INV-3 — Accepted sidecar root, not live collector.** -Any successful Export apply path must use the signature set rooted at the -`exportSigSetHash` accepted by the tick gate. Late local collector arrivals, -timeout flags, or unverified proposal-carried signatures must not change the -signer set selected for the ledger. -*Anti-pattern:* assembling from `ExportSigCollector` at apply time. +**INV-2 - Committee selection is explicit and account-owned.** +An Export committee is an immutable canonical roster of at most 32 validator +master keys stored in an account-owned `ltEXPORT_COMMITTEE`. Its identity is a +network-neutral content digest over that roster. A latch stores only the +digest; committee size and qC are derived from the referenced object. -**INV-4 — Replay witness in the transaction stream.** -If export sidecar material changes closed-ledger output, that material must be -represented by canonical ledger input before apply. The closed ledger must be -replayable from `(parent ledger, ordered closed transaction set)` without live -consensus sidecar memory. Export signatures are such a witness: they determine -source quorum success and exported-result metadata, so they must be carried by -a replayable companion pseudo transaction or equivalent transaction-stream -artifact. The Export-latch intent hash is signature-independent. -*Anti-pattern:* using ephemeral accepted sidecar state to create an Export latch -or result that cannot be reconstructed by ledger delta replay. +There is no protocol-default committee. A bare setup transaction may pre-stage +a structurally valid future roster without creating a latch or checking current +membership. Every actual Export intent, including inline create-and-use, +requires every roster master to appear in the exact admitting parent ledger's +pre-NegativeUNL `UNLReport`; otherwise it returns +`tecEXPORT_COMMITTEE_UNAVAILABLE`. A client may offer "copy current UNLReport" +as an explicit template, but the protocol never silently selects it. -Current shape: `ttEXPORT_SIGNATURES` is the signature witness interface. It -carries the full source-validator signature witness and binds it to the matching -`ttEXPORT` via `sfTransactionHash`. Network consensus produces it from the -accepted sidecar set; standalone/dev helpers may produce the same witness from -the local validator key. Ledger build pre-scans the ordered transaction stream -into a build-local index, and `ttEXPORT` apply consumes that pre-scanned witness. -The index is not an extra consensus input; it is only an efficient lookup over -the canonical transaction set. In live consensus builds, `onPreBuild` first -removes any pre-existing export witness and re-materializes the witness from the -accepted sidecar root. That witness is the signer-membership source for apply; -current manifest-cache state must not re-decide which accepted signing keys -count. Historical `LedgerReplay` uses the same membership rule because current -manifests may no longer map old rotated signing keys. Both paths still verify -each signature against the inner transaction and require the parent-view -threshold. Direct apply paths that did not run `onPreBuild` remain conservative -and filter witness signers through the live active-validator view. +Committee deletion is rejected while the owner has any live Export latch. This +coarse rule keeps every accepted witness replayable without storing the roster +again in every latch or maintaining a metadata-expensive reference count. -**INV-5 — Store the witness once.** -The signature witness is canonical input; metadata is output. Metadata may carry -hashes and references for client discovery, but it should not duplicate the full -signature payload merely to avoid a client dereference. A convenience RPC may -expand `ttEXPORT + witness` into the foreign-chain-submittable blob on read. -*Anti-pattern:* storing the same validator signatures once as replay input and -again as a full metadata blob without a separate consensus reason. +**INV-3 - qC and qV answer different questions.** +qC is the content threshold for one account-selected committee: +`ceil(0.8 * committeeSize)`. The intent cannot lower it. Committee positions +are indexes into the immutable canonical roster. -**INV-6 — Bounded retry window.** -An Export that cannot obtain quorum-aligned signatures within its bounded ledger -window retries or expires through normal transaction semantics. It must not wait -unboundedly, pick the largest sub-quorum set, or finalize against local trusted -configuration as a fallback. +qV is the root-alignment threshold over the source consensus active-validator +view. It coordinates which complete bounded sidecar union may materialize +witnesses. qV does not decide committee membership, prove target authorization, +or replace local possession and verification of qC signatures. -The success-vs-retry decision remains a bounded timing edge, like ordinary -consensus convergence: one node may observe the quorum-aligned witness before -its deadline while another retries. Validation resolves that ledger disagreement. -What must never happen is a "successful" export whose signature bytes come from -live collector state, late proposal arrivals, or a node-local sub-quorum set -instead of the accepted witness in the transaction stream. +Neither denominator is derived from locally observed peers or signatures, and +silence never lowers either threshold. -**INV-7 — Export latches are issuance latches, not global tombstones.** -The latch binds the canonical target signing intent and is keyed by `(owner, -origin transaction ID W)`, not by one authorization-envelope-dependent target -transaction ID or by destination TicketSequence alone. The signed release Memo -carries `W`; a fresh Export has a fresh `W`, so erasing one latch cannot re-arm -an old stamped XPOP against a later issuance. The obsolete memo-less callback -and ticket-keyed latch helper are not supported v1 compatibility paths. +**INV-4 - Honest shares are post-validation attestations.** +A validator signs only after its node accepts the exact source ledger containing +the intent as validated. Closing/building that ledger, emitting the node's own +validation, or advancing the build cursor does not unlock signing. Ledger +building may run ahead of the validated cursor; Export waits without holding +base consensus open. + +The release-stamped target binds source domain, target domain, origin +transaction ID `W`, origin ledger sequence, and origin ledger hash in a reserved +final `xahau/export` Memo covered by each ordinary target multisignature. Existing +user Memos remain byte-identical and in their original order. Malformed, +duplicate, misplaced, unsupported, or oversized reserved Memos fail before +signing. + +**INV-5 - Live manifests bind committee masters to signing keys.** +The immutable roster names validator master identities. Live share production +and admission use the manifest cache to map a concrete signing key to the +master at its committee position. Missing or ambiguous attribution contributes +no share. Manifests do not redefine the stored committee and are never read by +accepted witness apply or historical replay. + +Target SignerList compatibility with current validator signing-key AccountIDs +is an operator/client responsibility. Source consensus cannot inspect or prove +that remote configuration. + +**INV-6 - Align the accepted sidecar root, not the live collector.** +Valid shares from the direct post-validation relay and signed proposal carriage +join one bounded, commutative collector keyed by `(W, committeePosition)`. A +second distinct valid contribution at one position is conflicting and that +position contributes nothing to qC. + +The complete bounded admitted-share union is content-addressed and its root is +advertised in signed extended positions. Witness materialization uses only an +exact qV-aligned root that the local node possesses and independently verifies. +Late collector arrivals cannot mutate the accepted root. + +*Anti-pattern:* assembling from the current collector at apply time or treating +peer root support as remote payload availability. + +**INV-7 - Ledger-defining signatures enter the transaction stream.** +Sidecar memory is deliberation state, not replay input. For each eligible latch +that reaches qC under the accepted root, build injects at most one canonical +later-ledger `ttEXPORT_SIGNATURES` pseudo transaction. It contains the exact +release-stamped target, committee-relative contributor bitmap, and signature +entries, bound to `W`. + +Witness apply resolves the immutable committee SLE from the parent state, +derives size and qC, validates the contributor positions and every target +signature, records the witness transaction ID on the latch, and removes the +pending-work link. It does not consult mutable manifest state. Historical +replay consumes the same bytes and parent state and never regenerates shares. + +**INV-8 - Store the signature evidence once.** +The full release-stamped payload and signatures live in the witness transaction. +The latch stores only the witness transaction reference. Read-time tooling may +expand that canonical witness into the ordinary foreign-chain transaction, but +state and metadata must not duplicate its signature payload merely for client +convenience. + +Assembly follows ordinary multisign rules: empty `SigningPubKey`, canonical +signer AccountID ordering, no duplicate signer accounts, and at most +`STTx::maxMultiSigners()` entries. + +**INV-9 - Export latches name issuance, not one assembled target hash.** +The latch key is `(owner, origin transaction ID W)`. Its `sfDigest` commits to +the normalized identity-form target, not a signer-subset-dependent final target +transaction ID. Any valid qC subset for the exact stamped intent may therefore +execute and return without orphaning source state. Witness and XPOP are independent monotonic facts. Whichever arrives second -symmetrically erases the latch and releases reserve. Cancellation while -publication is pending is non-revoking: a lifecycle-control `ttEXPORT` names -the exact source issuance `W` in `sfTransactionHash`, unlinks work, and retains -callback readiness because already-public signatures cannot be withdrawn. The -same bytes retain the latch in every state. `tfExportEraseLatch` is a separate, -explicit election that erases that exact latch in any state, releases reserve, -and knowingly forfeits a later callback. Publication expiry follows the -non-revoking retain path. The undeployed Hook API uses the same W-plus-flags -contract. v1 has neither an automatic terminal-retirement clock nor a permanent -tombstone graveyard. +symmetrically erases the latch and releases reserve. Flagless lifecycle control +names exact `W`, unlinks pending work, and retains callback readiness because +published signatures cannot be revoked. `tfExportEraseLatch` explicitly erases +that latch and knowingly forfeits a later callback. Publication expiry follows +the non-revoking retain path. v1 has no permanent tombstones or paid bump. -Implementation status: origin-keyed creation, W-only lifecycle control, -expiry-unlink, and symmetric witness/XPOP recording are present. +**INV-10 - Released signatures are public capabilities.** +After exact source validation, each admitted share is immediately available on +the `export_signatures` subscription and may also ride proposals. Anyone may +assemble and submit a target transaction once the destination SignerList's +actual threshold is met. The source witness is durable evidence, not a secrecy +or submission gate. -**INV-8 — Export signatures are public capabilities.** -Proposal-carried signature shares may be observed, assembled, and submitted as -soon as destination quorum exists. Source-side witness agreement governs what -Xahau records; it is not a confidentiality or destination-execution gate. -Import therefore waits outside consensus when its Export latch does not yet -exist and matches a later XPOP against the signature-independent intent. -The main defense for exposing shares before source finality is authority -equivalence: destination execution must require the same validator-derived -authority that Xahau requires to validate and materialize the Export. -*Anti-pattern:* relying on proposal timing or canonical signer selection to -hide or delay an otherwise valid destination transaction. +An operator signer may be required by a destination SignerList for assembly +control or defense in depth, but it is not a protocol prerequisite and cannot +replace qC. Reserving such an entry reduces the validator committee that fits +the target's 32-signer limit. -**INV-9 — The active source authority must fit the destination protocol.** -Export does not publish shares without a ledger-anchored `UNLReport`, and does -not publish shares or materialize a result when the source validator population -before NegativeUNL filtering exceeds `STTx::maxMultiSigners()`. Silently -selecting a capped subset would replace source-view authority with an implicit -bridge committee. Any future bounded committee must be an explicit, separately -reviewed policy. +**INV-11 - The destination trust claim is configuration-specific.** +XRPL verifies ordinary keys and signatures, not Xahau finality, committee +eligibility, or qV. The account operator must configure its SignerList so the +chosen committee identities and weight threshold match the intended custody +claim. A lower remote threshold weakens that claim; a higher threshold can +strand an otherwise sufficient source witness. -The current source implementation enforces the 32-signer bound. The bounded MVP -deployment additionally requires one destination submitter co-signer, so its -practical full-view limit is 31 source validator identities. Source code cannot -inspect that remote configuration; activation tooling and monitoring must. +The target network's ledger-validation quorum is separate. It validates the +containing target ledger, and XPOP proves that target finality on return. -**INV-10 — Source and destination authorization must be equivalent.** -The bounded deployment contract uses the same validator-derived key universe -and equivalent weighted threshold for Xahau Export/validation and the target -account's SignerList. A lower destination threshold defeats the pre-finality -share-exposure defense. A higher threshold is safety-conservative but can leave -a successful source latch without enough witness authority to execute. - -The target network's ledger-validation quorum is independent: the SignerList -authorizes the account transaction, target consensus validates the containing -ledger, and XPOP later proves that finality. Because an ordinary target -SignerList is static, configure it against the original pre-NegativeUNL source -universe and accept reduced Export liveness during NegativeUNL periods rather -than lowering destination authority. - -The bounded MVP requires a submitter co-signer held by the target-submission -process. If validator weights total `V`, validator threshold is `q`, submitter -weight is `C`, and target quorum is `C + q > V`, validator shares alone cannot -execute and the submitter still needs validator weight `q`. It signs only after -observing the validated source latch, turning the submitter into a -liveness/censorship dependency rather than a sole safety authority. - -A future committee may decouple total UNL size from the destination cap, but it -must be explicit ledger-anchored source state with versioned membership and -rotation. Full source consensus validates the intent; at most 31 committee keys -supply shares; the submitter releases after validation. The target trust claim -then becomes committee quorum plus submitter, not full-UNL destination authority. +**INV-12 - Work, storage, and waiting are bounded.** +Committee size, account latches, global pending work, per-round lanes, relay +entries, message bytes, sidecar leaves, publication windows, and witness +signatures all have hard caps. Export may use one fixed sidecar alignment +window after ordinary transaction-set convergence, but timeout always permits +base consensus to continue. Below-qC or unaligned work remains pending until +witness, cancellation, explicit erase, or publication expiry. ## Replay Witness Shape -The accepted local export sidecar snapshot is not consumed directly by -`Export::doApply`. -Before ledger build, a producer injects one `ttEXPORT_SIGNATURES` pseudo for -each export that has usable signatures. In network mode that producer is the -consensus extension accept path; in standalone/dev mode it can be a local helper. The -pseudo has no ledger-state effect by itself; it is the ledger's replay witness -for the validator signatures. +`ttEXPORT_SIGNATURES` is a later-ledger protocol pseudo transaction. It is a +self-contained source-history record of the exact target bytes signed and the +committee-relative sufficient signature set. The accepted sidecar root selects +the material before injection; closed-ledger apply sees only transaction bytes +and parent ledger state. -Metadata stores `sfExportSignatureHash`, a direct reference to the witness -pseudo, rather than duplicating the signature payload as an assembled -`sfExportedTxn` blob. Clients assemble the final foreign-chain transaction from -the original `ttEXPORT` inner transaction plus the witness signatures. Assembly -must follow the same deterministic contract as `ExportResultBuilder`: sort -signers canonically by AccountID, use an empty `SigningPubKey`, and cap the -target-chain `Signers` array at `STTx::maxMultiSigners()` before computing or -submitting the blob. The witness may contain extra source-side signatures that -are valid replay input but are not part of the target-chain blob. Source-chain -export does not prove the destination account's SignerList or quorum policy; -that compatibility is an operator/client contract for the chosen target chain. - -This is not an XPOP-style self-contained proof. XPOP embeds its UNL and manifest -bundle because it is imported as external proof material. Export witnesses are -validated-history replay inputs. If we later want trustless historical -re-verification without relying on validated inclusion, the larger design is to -ledger-anchor validator signing-key history (for example via `UNLReport`) or to -embed manifest proof material; that is intentionally out of scope here. +The witness is not an XPOP-style proof that unmodified XRPL can interpret. +Validated inclusion is the source-chain attestation. XPOP remains the reverse +proof: it demonstrates that one assembled target transaction reached target +finality and drives the Hook callback against the matching origin latch. diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index 9f63983cc..f699593c0 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -1,5 +1,4 @@ #include -#include #include #include #include diff --git a/src/xrpld/app/tx/detail/ExportResultBuilder.cpp b/src/xrpld/app/tx/detail/ExportResultBuilder.cpp index 399447cc6..dc1de691f 100644 --- a/src/xrpld/app/tx/detail/ExportResultBuilder.cpp +++ b/src/xrpld/app/tx/detail/ExportResultBuilder.cpp @@ -252,57 +252,5 @@ signaturesFromWitness(STTx const& witness) return signatures; } -namespace { - -AssembledExportResult -assembleImpl( - STTx const& innerTx, - SignatureSnapshot const& signatures, - LedgerIndex currentSeq, - uint256 const& exportTxHash, - std::optional const& exportSignatureHash) -{ - auto multiSigned = buildMultiSignedExportedTxn(innerTx, signatures); - auto const signerCount = multiSigned.isFieldPresent(sfSigners) - ? multiSigned.getFieldArray(sfSigners).size() - : 0; - auto const signedTxHash = multiSigned.getHash(HashPrefix::transactionID); - - STObject exportResult(sfExportResult); - exportResult.setFieldU32(sfLedgerSequence, currentSeq); - exportResult.setFieldH256(sfTransactionHash, exportTxHash); - if (exportSignatureHash) - exportResult.setFieldH256(sfExportSignatureHash, *exportSignatureHash); - else - exportResult.set(std::move(multiSigned)); - - return {std::move(exportResult), signedTxHash, signerCount}; -} - -} // namespace - -AssembledExportResult -assembleDirect( - STTx const& innerTx, - SignatureSnapshot const& signatures, - LedgerIndex currentSeq, - uint256 const& exportTxHash) -{ - return assembleImpl( - innerTx, signatures, currentSeq, exportTxHash, std::nullopt); -} - -AssembledExportResult -assembleClosedLedger( - STTx const& innerTx, - SignatureSnapshot const& signatures, - LedgerIndex currentSeq, - uint256 const& exportTxHash, - uint256 const& exportSignatureHash) -{ - return assembleImpl( - innerTx, signatures, currentSeq, exportTxHash, exportSignatureHash); -} - } // namespace ExportResultBuilder } // namespace ripple diff --git a/src/xrpld/app/tx/detail/ExportResultBuilder.h b/src/xrpld/app/tx/detail/ExportResultBuilder.h index 4ccbfe6a5..21a726db7 100644 --- a/src/xrpld/app/tx/detail/ExportResultBuilder.h +++ b/src/xrpld/app/tx/detail/ExportResultBuilder.h @@ -28,13 +28,6 @@ struct PositionedSignature using PositionedSignatureSnapshot = std::map; -struct AssembledExportResult -{ - STObject metadata; - uint256 signedTxHash; - std::size_t signerCount = 0; -}; - Buffer signExportedTxn( STTx const& innerTx, @@ -60,21 +53,6 @@ buildSignatureWitness( std::optional signaturesFromWitness(STTx const& witness); -AssembledExportResult -assembleDirect( - STTx const& innerTx, - SignatureSnapshot const& signatures, - LedgerIndex currentSeq, - uint256 const& exportTxHash); - -AssembledExportResult -assembleClosedLedger( - STTx const& innerTx, - SignatureSnapshot const& signatures, - LedgerIndex currentSeq, - uint256 const& exportTxHash, - uint256 const& exportSignatureHash); - } // namespace ExportResultBuilder } // namespace ripple diff --git a/src/xrpld/rpc/handlers/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/ServerDefinitions.cpp index 388050be2..925a0c4ca 100644 --- a/src/xrpld/rpc/handlers/ServerDefinitions.cpp +++ b/src/xrpld/rpc/handlers/ServerDefinitions.cpp @@ -79,6 +79,7 @@ MAGIC_ENUM_FLAG(ripple::PaymentChannelClaimFlags); MAGIC_ENUM_FLAG(ripple::NFTokenMintFlags); MAGIC_ENUM_FLAG(ripple::NFTokenCreateOfferFlags); MAGIC_ENUM_FLAG(ripple::ClaimRewardFlags); +MAGIC_ENUM_FLAG(ripple::ExportFlags); MAGIC_ENUM_16(ripple::AccountFlags); namespace ripple { @@ -418,6 +419,7 @@ private: addFlagsToJson(ret, "MPTokenAuthorize"); addFlagsToJson(ret, "MPTokenIssuanceSet"); addFlagsToJson(ret, "AMMClawback"); + addFlagsToJson(ret, "Export"); struct FlagData { std::string name;