From a8097cd9a6ef827828dca87cd67afead0e2278b9 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Wed, 18 Mar 2026 17:04:46 +0700 Subject: [PATCH] fix(export): compute emit fee before STTx construction Mutating the fee via const_cast after STTx construction left a stale cached getTransactionID(). When the emitted ttEXPORT was serialised into the emitted directory and later deserialised, the round-tripped txid differed from the original, causing tefNONDIR_EMIT in Transactor::preclaim (the emitted dir entry was keyed with the stale hash). Build a throwaway STTx with fee=0 to calculate the fee size, then construct the real STTx with the correct fee from the start. --- src/xrpld/app/hook/detail/HookAPI.cpp | 66 ++++++++++++++++++++------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/src/xrpld/app/hook/detail/HookAPI.cpp b/src/xrpld/app/hook/detail/HookAPI.cpp index b4844a16c..ebf9f0a20 100644 --- a/src/xrpld/app/hook/detail/HookAPI.cpp +++ b/src/xrpld/app/hook/detail/HookAPI.cpp @@ -1304,14 +1304,61 @@ HookAPI::xport(Slice const& txBlob) const Serializer innerSer; innerTx->add(innerSer); - // Build the ttEXPORT wrapper with EmitDetails. + // Pre-compute the emit fee so we can set it during STTx + // construction. Mutating the fee after construction via + // const_cast leaves a stale cached getTransactionID(), which + // breaks the tefNONDIR_EMIT check in Transactor::preclaim + // after the emitted tx is serialised and deserialised through + // the emitted directory round-trip. + // + // We build a throwaway STTx with fee=0 just for the size + // calculation, then construct the real one with the correct fee. + uint64_t emitFee = 0; + { + STTx tmp(ttEXPORT, [&](auto& obj) { + obj[sfAccount] = hookCtx.result.account; + obj[sfSequence] = 0u; + obj.setFieldVL(sfSigningPubKey, Blob{}); + obj[sfFirstLedgerSequence] = ledgerSeq + 1; + obj[sfLastLedgerSequence] = ledgerSeq + 5; + obj[sfFee] = STAmount{0}; + SerialIter sit(innerSer.slice()); + obj.set(std::make_unique(sit, sfExportedTxn)); + STObject ed(sfEmitDetails); + ed.setFieldU32( + sfEmitGeneration, static_cast(etxn_generation())); + { + auto const b = etxn_burden(); + ed.setFieldU64(sfEmitBurden, b ? uint64_t(*b) : 1ULL); + } + ed.setFieldH256( + sfEmitParentTxnID, applyCtx.tx.getTransactionID()); + ed.setFieldH256(sfEmitNonce, *nonce); + ed.setFieldH256(sfEmitHookHash, hookCtx.result.hookHash); + if (hookCtx.result.hasCallback) + ed.setAccountID(sfEmitCallback, hookCtx.result.account); + obj.set(std::move(ed)); + }); + Serializer feeSer; + tmp.add(feeSer); + auto feeResult = etxn_fee_base(feeSer.slice()); + if (!feeResult) + { + JLOG(j.trace()) << "HookExport[" << HC_ACC() + << "]: Fee calculation failed for ttEXPORT wrapper"; + return Unexpected(EXPORT_FAILURE); + } + emitFee = static_cast(*feeResult); + } + + // Build the ttEXPORT wrapper with the correct fee. STTx exportStx(ttEXPORT, [&](auto& obj) { obj[sfAccount] = hookCtx.result.account; obj[sfSequence] = 0u; obj.setFieldVL(sfSigningPubKey, Blob{}); obj[sfFirstLedgerSequence] = ledgerSeq + 1; obj[sfLastLedgerSequence] = ledgerSeq + 5; - obj[sfFee] = STAmount{0}; // emitted txns have special fee handling + obj[sfFee] = STAmount{emitFee}; // sfExportedTxn inner object SerialIter sit(innerSer.slice()); @@ -1336,21 +1383,6 @@ HookAPI::xport(Slice const& txBlob) const obj.set(std::move(emitDetails)); }); - // Calculate proper fee for the emitted transaction. - { - Serializer feeSer; - exportStx.add(feeSer); - auto feeResult = etxn_fee_base(feeSer.slice()); - if (!feeResult) - { - JLOG(j.trace()) << "HookExport[" << HC_ACC() - << "]: Fee calculation failed for ttEXPORT wrapper"; - return Unexpected(EXPORT_FAILURE); - } - const_cast(exportStx).setFieldAmount( - sfFee, STAmount{static_cast(*feeResult)}); - } - // Preflight the wrapper. auto preflightResult = ripple::preflight( app, view.rules(), exportStx, ripple::ApplyFlags::tapPREFLIGHT_EMIT, j);