export: close committee review gaps

This commit is contained in:
Nicholas Dudfield
2026-07-16 13:20:42 +07:00
parent b20d135388
commit 80ca259233
14 changed files with 399 additions and 653 deletions

View File

@@ -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(

View File

@@ -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",

View File

@@ -18,6 +18,7 @@
//==============================================================================
#include <test/jtx.h>
#include <xrpl/protocol/ExportCommittee.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/jss.h>
@@ -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);

View File

@@ -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<STObject>();
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<STObject>();
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<STObject>();
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();

View File

@@ -24,6 +24,7 @@
#include <xrpld/app/tx/detail/ApplyContext.h>
#include <xrpld/app/tx/detail/Transactor.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/ExportCommittee.h>
#include <xrpl/protocol/InnerObjectFormats.h>
#include <xrpl/protocol/STLedgerEntry.h>
@@ -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<SLE>(
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();
}
};

View File

@@ -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) {

View File

@@ -1998,153 +1998,6 @@ ConsensusExtensions::clearAcceptedExportSigSet()
acceptedExportSigSetHash_.reset();
}
std::optional<ConsensusExtensions::ExportSignatureSnapshot>
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<SHAMap> 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<SHAMapItem const> 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::ExportWitnessMaterial>
ConsensusExtensions::agreedExportWitness(
STTx const& exportSigningPayload,

View File

@@ -128,7 +128,6 @@ public:
using ActiveValidatorView = ripple::ActiveValidatorView;
using ActiveValidatorViewPtr = std::shared_ptr<ActiveValidatorView const>;
using ExportSignatureSnapshot = std::map<PublicKey, Buffer>;
struct ExportWitnessMaterial
{
ExportResultBuilder::PositionedSignatureSnapshot signatures;
@@ -426,12 +425,6 @@ public:
void
clearAcceptedExportSigSet();
std::optional<ExportSignatureSnapshot>
agreedExportSignatures(
STTx const& exportTx,
uint256 const& txHash,
std::size_t threshold) const;
std::optional<ExportWitnessMaterial>
agreedExportWitness(
STTx const& exportSigningPayload,

View File

@@ -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?

View File

@@ -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.

View File

@@ -1,5 +1,4 @@
#include <xrpld/app/consensus/ConsensusExtensions.h>
#include <xrpld/app/consensus/ExportSignatureHarvester.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/misc/ValidatorKeys.h>
#include <xrpld/app/misc/ValidatorList.h>

View File

@@ -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<uint256> 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

View File

@@ -28,13 +28,6 @@ struct PositionedSignature
using PositionedSignatureSnapshot =
std::map<std::uint16_t, PositionedSignature>;
struct AssembledExportResult
{
STObject metadata;
uint256 signedTxHash;
std::size_t signerCount = 0;
};
Buffer
signExportedTxn(
STTx const& innerTx,
@@ -60,21 +53,6 @@ buildSignatureWitness(
std::optional<PositionedSignatureSnapshot>
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

View File

@@ -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<MPTokenAuthorizeFlags>(ret, "MPTokenAuthorize");
addFlagsToJson<MPTokenIssuanceSetFlags>(ret, "MPTokenIssuanceSet");
addFlagsToJson<AMMClawbackFlags>(ret, "AMMClawback");
addFlagsToJson<ExportFlags>(ret, "Export");
struct FlagData
{
std::string name;