feat(hooks): add dice() and random() hook APIs for consensus entropy

Port the Hook API surface from the tt-rng branch, adapted to use our
commit-reveal consensus entropy (ltCONSENSUS_ENTROPY / sfDigest).

Hook APIs:
- dice(sides): returns random int [0, sides) from consensus entropy
- random(write_ptr, write_len): fills buffer with 1-512 random bytes

Internal fairRng() derives per-execution entropy by hashing: ledger
seq + tx ID + hook hash + account + chain position + execution phase
+ consensus entropy + incrementing call counter. This ensures each
call within a single hook execution returns different values.

Quality gate: fairRng returns empty (TOO_LITTLE_ENTROPY) if fewer
than 5 validators contributed, preventing weak entropy from being
consumed by hooks.

Also adds sfEntropyCount and sfLedgerSequence to the consensus
entropy SLE and pseudo-tx, enabling the freshness and quality
checks needed by the Hook API.
This commit is contained in:
Nicholas Dudfield
2026-02-10 17:12:27 +07:00
parent 41a41ec625
commit 61a166bcb0
11 changed files with 153 additions and 4 deletions

View File

@@ -1725,12 +1725,16 @@ RCLConsensus::Adaptor::injectEntropyPseudoTx(
if (hasEntropy)
{
// Account Zero convention for pseudo-transactions (same as ttFEE, etc)
auto const entropyCount = static_cast<std::uint16_t>(
entropyFailed_ || pendingReveals_.empty() ? 0
: pendingReveals_.size());
STTx tx(ttCONSENSUS_ENTROPY, [&](auto& obj) {
obj.setFieldU32(sfLedgerSequence, seq);
obj.setAccountID(sfAccount, AccountID{});
obj.setFieldU32(sfSequence, 0);
obj.setFieldAmount(sfFee, STAmount{});
obj.setFieldH256(sfDigest, finalEntropy);
obj.setFieldU16(sfEntropyCount, entropyCount);
});
retriableTxs.insert(std::make_shared<STTx>(std::move(tx)));

View File

@@ -350,7 +350,8 @@ enum hook_return_code : int64_t {
MEM_OVERLAP = -43, // one or more specified buffers are the same memory
TOO_MANY_STATE_MODIFICATIONS = -44, // more than 5000 modified state
// entires in the combined hook chains
TOO_MANY_NAMESPACES = -45
TOO_MANY_NAMESPACES = -45,
TOO_LITTLE_ENTROPY = -46,
};
enum ExitType : uint8_t {
@@ -469,6 +470,14 @@ static const APIWhitelist import_whitelist_1{
// clang-format on
};
// featureConsensusEntropy
static const APIWhitelist import_whitelist_entropy{
// clang-format off
HOOK_API_DEFINITION(I64, dice, (I32)),
HOOK_API_DEFINITION(I64, random, (I32, I32)),
// clang-format on
};
#undef HOOK_API_DEFINITION
#undef I32
#undef I64

View File

@@ -1034,6 +1034,13 @@ validateGuards(
{
// PASS, this is a version 1 api
}
else if (
(rulesVersion & 0x04U) &&
hook_api::import_whitelist_entropy.find(import_name) !=
hook_api::import_whitelist_entropy.end())
{
// PASS, this is a consensus entropy api
}
else
{
GUARDLOG(hook::log::IMPORT_ILLEGAL)
@@ -1262,8 +1269,13 @@ validateGuards(
hook_api::import_whitelist.find(api_name) !=
hook_api::import_whitelist.end()
? hook_api::import_whitelist.find(api_name)->second
: hook_api::import_whitelist_1.find(api_name)
->second;
: hook_api::import_whitelist_1.find(api_name) !=
hook_api::import_whitelist_1.end()
? hook_api::import_whitelist_1.find(api_name)
->second
: hook_api::import_whitelist_entropy
.find(api_name)
->second;
if (!first_signature)
{

View File

@@ -406,6 +406,9 @@ DECLARE_HOOK_FUNCTION(
uint32_t slot_no_tx,
uint32_t slot_no_meta);
DECLARE_HOOK_FUNCTION(int64_t, dice, uint32_t sides);
DECLARE_HOOK_FUNCTION(int64_t, random, uint32_t write_ptr, uint32_t write_len);
/*
DECLARE_HOOK_FUNCTION(int64_t, str_find, uint32_t hread_ptr,
uint32_t hread_len, uint32_t nread_ptr, uint32_t nread_len, uint32_t mode,
@@ -513,6 +516,8 @@ struct HookResult
false; // hook_again allows strong pre-apply to nominate
// additional weak post-apply execution
std::shared_ptr<STObject const> provisionalMeta;
uint64_t rngCallCounter{
0}; // used to ensure conseq. rng calls don't return same data
};
class HookExecutor;

View File

@@ -6156,6 +6156,117 @@ DEFINE_HOOK_FUNCTION(
HOOK_TEARDOWN();
}
// byteCount must be a multiple of 32
inline std::vector<uint8_t>
fairRng(ApplyContext& applyCtx, hook::HookResult& hr, uint32_t byteCount)
{
if (byteCount > 512)
byteCount = 512;
// force the byte count to be a multiple of 32
byteCount &= ~0b11111;
if (byteCount == 0)
return {};
auto& view = applyCtx.view();
auto const sleEntropy = view.peek(ripple::keylet::consensusEntropy());
auto const seq = view.info().seq;
if (!sleEntropy || sleEntropy->getFieldU32(sfLedgerSequence) != seq ||
sleEntropy->getFieldU16(sfEntropyCount) < 5)
return {};
// we'll generate bytes in lots of 32
uint256 rndData = sha512Half(
view.info().seq,
applyCtx.tx.getTransactionID(),
hr.otxnAccount,
hr.hookHash,
hr.account,
hr.hookChainPosition,
hr.executeAgainAsWeak ? std::string("weak") : std::string("strong"),
sleEntropy->getFieldH256(sfDigest),
hr.rngCallCounter++);
std::vector<uint8_t> bytesOut;
bytesOut.resize(byteCount);
uint8_t* ptr = bytesOut.data();
while (1)
{
std::memcpy(ptr, rndData.data(), 32);
ptr += 32;
if (ptr - bytesOut.data() >= byteCount)
break;
rndData = sha512Half(rndData);
}
return bytesOut;
}
DEFINE_HOOK_FUNCTION(int64_t, dice, uint32_t sides)
{
HOOK_SETUP();
auto vec = fairRng(applyCtx, hookCtx.result, 32);
if (vec.empty())
return TOO_LITTLE_ENTROPY;
if (vec.size() != 32)
return INTERNAL_ERROR;
uint32_t value;
std::memcpy(&value, vec.data(), sizeof(uint32_t));
return value % sides;
HOOK_TEARDOWN();
}
DEFINE_HOOK_FUNCTION(int64_t, random, uint32_t write_ptr, uint32_t write_len)
{
HOOK_SETUP();
if (write_len == 0)
return TOO_SMALL;
if (write_len > 512)
return TOO_BIG;
uint32_t required = write_len;
if ((required & ~0b11111) == required)
{
// already a multiple of 32 bytes
}
else
{
// round up
required &= ~0b11111;
required += 32;
}
if (NOT_IN_BOUNDS(write_ptr, write_len, memory_length))
return OUT_OF_BOUNDS;
auto vec = fairRng(applyCtx, hookCtx.result, required);
if (vec.empty())
return TOO_LITTLE_ENTROPY;
WRITE_WASM_MEMORY_AND_RETURN(
write_ptr, write_len, vec.data(), vec.size(), memory, memory_length);
HOOK_TEARDOWN();
}
/*
DEFINE_HOOK_FUNCTION(

View File

@@ -245,6 +245,8 @@ Change::applyConsensusEntropy()
sle = std::make_shared<SLE>(keylet::consensusEntropy());
sle->setFieldH256(sfDigest, entropy);
sle->setFieldU16(sfEntropyCount, ctx_.tx.getFieldU16(sfEntropyCount));
sle->setFieldU32(sfLedgerSequence, view().info().seq);
// Note: sfPreviousTxnID and sfPreviousTxnLgrSeq are set automatically
// by ApplyStateTable::threadItem() because isThreadedType() returns true
// for ledger entries that have sfPreviousTxnID in their format.

View File

@@ -491,7 +491,8 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
logger,
hsacc,
(ctx.rules.enabled(featureHooksUpdate1) ? 1 : 0) +
(ctx.rules.enabled(fix20250131) ? 2 : 0));
(ctx.rules.enabled(fix20250131) ? 2 : 0) +
(ctx.rules.enabled(featureConsensusEntropy) ? 4 : 0));
if (ctx.j.trace())
{

View File

@@ -355,6 +355,7 @@ extern SF_UINT16 const sfHookEmitCount;
extern SF_UINT16 const sfHookExecutionIndex;
extern SF_UINT16 const sfHookApiVersion;
extern SF_UINT16 const sfHookStateScale;
extern SF_UINT16 const sfEntropyCount;
// 32-bit integers (common)
extern SF_UINT32 const sfNetworkID;

View File

@@ -385,6 +385,8 @@ LedgerFormats::LedgerFormats()
ltCONSENSUS_ENTROPY,
{
{sfDigest, soeREQUIRED}, // The consensus-derived entropy
{sfEntropyCount, soeREQUIRED}, // Number of validators that contributed
{sfLedgerSequence, soeREQUIRED}, // Ledger this entropy is for
{sfPreviousTxnID, soeREQUIRED},
{sfPreviousTxnLgrSeq, soeREQUIRED},
},

View File

@@ -103,6 +103,7 @@ CONSTRUCT_TYPED_SFIELD(sfHookEmitCount, "HookEmitCount", UINT16,
CONSTRUCT_TYPED_SFIELD(sfHookExecutionIndex, "HookExecutionIndex", UINT16, 19);
CONSTRUCT_TYPED_SFIELD(sfHookApiVersion, "HookApiVersion", UINT16, 20);
CONSTRUCT_TYPED_SFIELD(sfHookStateScale, "HookStateScale", UINT16, 21);
CONSTRUCT_TYPED_SFIELD(sfEntropyCount, "EntropyCount", UINT16, 99);
// 32-bit integers (common)
CONSTRUCT_TYPED_SFIELD(sfNetworkID, "NetworkID", UINT32, 1);

View File

@@ -496,6 +496,7 @@ TxFormats::TxFormats()
{
{sfLedgerSequence, soeREQUIRED},
{sfDigest, soeREQUIRED},
{sfEntropyCount, soeREQUIRED},
{sfBlob, soeOPTIONAL}, // Proposal proof for SHAMap entries
},
commonFields);