Gas Type Hook

This commit is contained in:
tequ
2026-01-23 16:37:26 +09:00
parent a8d7b2619e
commit 47beef302c
21 changed files with 1777 additions and 92 deletions

View File

@@ -488,6 +488,7 @@ target_sources (rippled PRIVATE
src/ripple/app/tx/impl/apply.cpp
src/ripple/app/tx/impl/applySteps.cpp
src/ripple/app/hook/impl/applyHook.cpp
src/ripple/app/hook/impl/GasValidator.cpp
src/ripple/app/tx/impl/details/NFTokenUtils.cpp
#[===============================[
main sources:
@@ -909,6 +910,7 @@ if (tests)
src/test/jtx/impl/fee.cpp
src/test/jtx/impl/flags.cpp
src/test/jtx/impl/genesis.cpp
src/test/jtx/impl/hookgas.cpp
src/test/jtx/impl/import.cpp
src/test/jtx/impl/invoice_id.cpp
src/test/jtx/impl/invoke.cpp

View File

@@ -63,6 +63,8 @@
#define sfEmitGeneration ((2U << 16U) + 46U)
#define sfLockCount ((2U << 16U) + 49U)
#define sfFirstNFTokenSequence ((2U << 16U) + 50U)
#define sfHookInstructionCost ((2U << 16U) + 91U)
#define sfHookGas ((2U << 16U) + 92U)
#define sfStartTime ((2U << 16U) + 93U)
#define sfRepeatCount ((2U << 16U) + 94U)
#define sfDelaySeconds ((2U << 16U) + 95U)

View File

@@ -0,0 +1,57 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_APP_HOOK_GASVALIDATOR_H_INCLUDED
#define RIPPLE_APP_HOOK_GASVALIDATOR_H_INCLUDED
#include <ripple/beast/utility/Journal.h>
#include <ripple/protocol/Rules.h>
#include <functional>
#include <optional>
#include <ostream>
#include <string>
#include <vector>
// Forward declaration
using GuardLog =
std::optional<std::reference_wrapper<std::basic_ostream<char>>>;
namespace hook {
/**
* @brief Validate WASM host functions for Gas-type hooks
*
* Validates that a WASM binary only imports allowed host functions
* and does not import the _g (guard) function, which is only for
* Guard-type hooks.
*
* @param wasm The WASM binary to validate
* @param guardLog Logging function for validation errors
* @param guardLogAccStr Account string for logging
* @return std::nullopt if validation succeeds, error message otherwise
*/
std::optional<std::string>
validateWasmHostFunctionsForGas(
std::vector<uint8_t> const& wasm,
ripple::Rules const& rules,
beast::Journal const& j);
} // namespace hook
#endif // RIPPLE_APP_HOOK_GASVALIDATOR_H_INCLUDED

View File

@@ -10,7 +10,6 @@
#include <ripple/protocol/SField.h>
#include <ripple/protocol/TER.h>
#include <ripple/protocol/digest.h>
#include <any>
#include <memory>
#include <optional>
#include <queue>
@@ -462,7 +461,9 @@ apply(
uint32_t wasmParam,
uint8_t hookChainPosition,
// result of apply() if this is weak exec
std::shared_ptr<STObject const> const& provisionalMeta);
std::shared_ptr<STObject const> const& provisionalMeta,
uint16_t hookApiVersion,
uint32_t hookGas);
struct HookContext;
@@ -501,6 +502,7 @@ struct HookResult
std::string exitReason{""};
int64_t exitCode{-1};
uint64_t instructionCount{0};
uint64_t instructionCost{0};
bool hasCallback = false; // true iff this hook wasm has a cbak function
bool isCallback =
false; // true iff this hook execution is a callback in action
@@ -513,6 +515,8 @@ struct HookResult
false; // hook_again allows strong pre-apply to nominate
// additional weak post-apply execution
std::shared_ptr<STObject const> provisionalMeta;
uint16_t hookApiVersion = 0; // 0 = Guard-type, 1 = Gas-type
std::optional<uint64_t> hookGas; // Gas limit for Gas-type hooks
};
class HookExecutor;
@@ -658,6 +662,7 @@ public:
if (!conf)
return;
WasmEdge_ConfigureStatisticsSetInstructionCounting(conf, true);
WasmEdge_ConfigureStatisticsSetCostMeasuring(conf, true);
ctx = WasmEdge_VMCreate(conf, NULL);
}
@@ -758,6 +763,23 @@ public:
return;
}
// Set Gas limit for Gas-type hooks (HookApiVersion == 1)
if (hookCtx.result.hookApiVersion == 1 &&
hookCtx.result.hookGas.has_value())
{
auto* statsCtx = WasmEdge_VMGetStatisticsContext(vm.ctx);
if (statsCtx)
{
// Convert HookGas to cost limit count (1 Gas = 1 cost)
uint32_t gasLimit = *hookCtx.result.hookGas;
WasmEdge_StatisticsSetCostLimit(statsCtx, gasLimit);
JLOG(j.trace())
<< "HookInfo[" << HC_ACC() << "]: Set Gas limit to "
<< gasLimit << " cost limit for Gas-type Hook";
}
}
WasmEdge_Value params[1] = {WasmEdge_ValueGenI32((int64_t)wasmParam)};
WasmEdge_Value returns[1];
@@ -771,16 +793,28 @@ public:
returns,
1);
if (auto err = getWasmError("WASM VM error", res); err)
{
JLOG(j.warn()) << "HookError[" << HC_ACC() << "]: " << *err;
hookCtx.result.exitType = hook_api::ExitType::WASM_ERROR;
return;
}
auto* statsCtx = WasmEdge_VMGetStatisticsContext(vm.ctx);
hookCtx.result.instructionCount =
WasmEdge_StatisticsGetInstrCount(statsCtx);
hookCtx.result.instructionCost =
WasmEdge_StatisticsGetTotalCost(statsCtx);
if (auto err = getWasmError("WASM VM error", res); err)
{
JLOG(j.trace()) << "HookError[" << HC_ACC() << "]: " << *err;
// Check if error is due to Gas limit exceeded for Gas-type hooks
if (hookCtx.result.hookApiVersion == 1 &&
err->find("cost limit exceeded") != std::string::npos)
{
JLOG(j.trace()) << "HookError[" << HC_ACC()
<< "]: Gas limit exceeded. Limit was "
<< *hookCtx.result.hookGas << " instructions";
}
hookCtx.result.exitType = hook_api::ExitType::WASM_ERROR;
return;
}
// RH NOTE: stack unwind will clean up WasmEdgeVM
}

View File

@@ -0,0 +1,156 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2024 XRPL-Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <ripple/app/hook/GasValidator.h>
#include <ripple/app/hook/Guard.h>
#include <ripple/app/hook/Macro.h>
#include <ripple/basics/Log.h>
#include <ripple/protocol/Feature.h>
#include <wasmedge/wasmedge.h>
namespace hook {
std::optional<std::string>
validateWasmHostFunctionsForGas(
std::vector<uint8_t> const& wasm,
Rules const& rules,
beast::Journal const& j)
{
// Create WasmEdge Loader
WasmEdge_LoaderContext* loader = WasmEdge_LoaderCreate(NULL);
if (!loader)
{
return "Failed to create WasmEdge Loader";
}
// Parse WASM binary
WasmEdge_ASTModuleContext* astModule = NULL;
WasmEdge_Result res = WasmEdge_LoaderParseFromBuffer(
loader, &astModule, wasm.data(), wasm.size());
if (!WasmEdge_ResultOK(res))
{
WasmEdge_LoaderDelete(loader);
const char* msg = WasmEdge_ResultGetMessage(res);
return std::string("Failed to parse WASM: ") +
(msg ? msg : "unknown error");
}
// Get import count
uint32_t importCount = WasmEdge_ASTModuleListImportsLength(astModule);
if (importCount == 0)
{
WasmEdge_ASTModuleDelete(astModule);
WasmEdge_LoaderDelete(loader);
JLOG(j.trace()) << "HookSet(" << hook::log::IMPORTS_MISSING
<< "): WASM must import at least hook API functions";
return "WASM must import at least hook API functions";
}
// Get imports (max 256)
const WasmEdge_ImportTypeContext* imports[256];
uint32_t actualImportCount = std::min(importCount, 256u);
actualImportCount =
WasmEdge_ASTModuleListImports(astModule, imports, actualImportCount);
std::optional<std::string> error;
// Check each import
for (uint32_t i = 0; i < actualImportCount; i++)
{
WasmEdge_String moduleName =
WasmEdge_ImportTypeGetModuleName(imports[i]);
WasmEdge_String externalName =
WasmEdge_ImportTypeGetExternalName(imports[i]);
WasmEdge_ExternalType extType =
WasmEdge_ImportTypeGetExternalType(imports[i]);
// Only check function imports
if (extType != WasmEdge_ExternalType_Function)
continue;
// Convert WasmEdge_String to std::string for comparison
std::string modName(moduleName.Buf, moduleName.Length);
std::string extName(externalName.Buf, externalName.Length);
// Check module name is "env"
if (modName != "env")
{
JLOG(j.trace())
<< "HookSet(" << hook::log::IMPORT_MODULE_ENV
<< "): Import module must be 'env', found: " << modName;
error = "Import module must be 'env', found: " + modName;
break;
}
// Check for forbidden _g function (guard function)
if (extName == "_g")
{
JLOG(j.trace())
<< "HookSet(" << hook::log::IMPORT_ILLEGAL
<< "): Gas-type hooks cannot import _g (guard) function";
error = "Gas-type hooks cannot import _g (guard) function";
break;
}
// Check external name length
if (extName.length() < 1 || extName.length() > 64)
{
JLOG(j.trace()) << "HookSet(" << hook::log::IMPORT_NAME_BAD
<< "): Import name length invalid: " << extName;
error = "Import name length invalid: " + extName;
break;
}
// Check against whitelist using find()
bool found = false;
// Check base whitelist (import_whitelist)
if (hook_api::import_whitelist.find(extName) !=
hook_api::import_whitelist.end())
{
found = true;
}
// Check extended whitelist (import_whitelist_1)
if (!found && rules.enabled(featureHooksUpdate1) &&
hook_api::import_whitelist_1.find(extName) !=
hook_api::import_whitelist_1.end())
{
found = true;
}
if (!found)
{
JLOG(j.trace()) << "HookSet(" << hook::log::IMPORT_ILLEGAL
<< "): Import not in whitelist: " << extName;
error = "Import not in whitelist: " + extName;
break;
}
}
// Cleanup
WasmEdge_ASTModuleDelete(astModule);
WasmEdge_LoaderDelete(loader);
return error;
}
} // namespace hook

View File

@@ -1232,7 +1232,9 @@ hook::apply(
bool isStrong,
uint32_t wasmParam,
uint8_t hookChainPosition,
std::shared_ptr<STObject const> const& provisionalMeta)
std::shared_ptr<STObject const> const& provisionalMeta,
uint16_t hookApiVersion,
uint32_t hookGas)
{
HookContext hookCtx = {
.applyCtx = applyCtx,
@@ -1264,7 +1266,9 @@ hook::apply(
.wasmParam = wasmParam,
.hookChainPosition = hookChainPosition,
.foreignStateSetDisabled = false,
.provisionalMeta = provisionalMeta},
.provisionalMeta = provisionalMeta,
.hookApiVersion = hookApiVersion,
.hookGas = hookGas},
.emitFailure = isCallback && wasmParam & 1
? std::optional<ripple::STObject>(
(*(applyCtx.view().peek(keylet::emittedTxn(
@@ -2049,6 +2053,9 @@ hook::finalizeHookResult(
ripple::Slice{
hookResult.exitReason.data(), hookResult.exitReason.size()});
meta.setFieldU64(sfHookInstructionCount, hookResult.instructionCount);
if (hookResult.hookApiVersion == 1)
meta.setFieldU32(sfHookInstructionCost, hookResult.instructionCost);
meta.setFieldU16(
sfHookEmitCount,
emission_txnid.size()); // this will never wrap, hard limit

View File

@@ -20,6 +20,7 @@
#include <ripple/app/tx/impl/SetHook.h>
#include <ripple/app/hook/Enum.h>
#include <ripple/app/hook/GasValidator.h>
#include <ripple/app/hook/Guard.h>
#include <ripple/app/hook/applyHook.h>
#include <ripple/app/ledger/Ledger.h>
@@ -435,7 +436,8 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
}
auto version = hookSetObj.getFieldU16(sfHookApiVersion);
if (version != 0)
if (!ctx.rules.enabled(featureHookGas) && version != 0)
{
// we currently only accept api version 0
JLOG(ctx.j.trace())
@@ -445,6 +447,17 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
return false;
}
// allow only version=0 and version=1
if (version != 0 && version != 1)
{
JLOG(ctx.j.trace())
<< "HookSet(" << ::hook::log::API_INVALID << ")["
<< HS_ACC()
<< "]: Malformed transaction: SetHook "
"sfHook->sfHookApiVersion invalid. (Must be 0 or 1).";
return false;
}
// validate sfHookOn
if (!hookSetObj.isFieldPresent(sfHookOn))
{
@@ -469,6 +482,7 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
return {};
Blob hook = hookSetObj.getFieldVL(sfCreateCode);
auto version = hookSetObj.getFieldU16(sfHookApiVersion);
// RH NOTE: validateGuards has a generic non-rippled specific
// interface so it can be used in other projects (i.e. tooling).
@@ -486,46 +500,83 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
hsacc = ss.str();
}
auto result = validateGuards(
hook, // wasm to verify
logger,
hsacc,
(ctx.rules.enabled(featureHooksUpdate1) ? 1 : 0) +
(ctx.rules.enabled(fix20250131) ? 2 : 0));
uint64_t maxInstrCountHook = 0;
uint64_t maxInstrCountCbak = 0;
if (ctx.j.trace())
if (version == 0) // Guard type
{
// clunky but to get the stream to accept the output
// correctly we will split on new line and feed each line
// one by one into the trace stream beast::Journal should be
// updated to inherit from basic_ostream<char> then this
// wouldn't be necessary.
auto result = validateGuards(
hook, // wasm to verify
logger,
hsacc,
(ctx.rules.enabled(featureHooksUpdate1) ? 1 : 0) +
(ctx.rules.enabled(fix20250131) ? 2 : 0));
// is this a needless copy or does the compiler do copy
// elision here?
std::string s = loggerStream.str();
char* data = s.data();
size_t len = s.size();
char* last = data;
size_t i = 0;
for (; i < len; ++i)
if (ctx.j.trace())
{
if (data[i] == '\n')
// clunky but to get the stream to accept the output
// correctly we will split on new line and feed each
// line one by one into the trace stream beast::Journal
// should be updated to inherit from basic_ostream<char>
// then this wouldn't be necessary.
// is this a needless copy or does the compiler do copy
// elision here?
std::string s = loggerStream.str();
char* data = s.data();
size_t len = s.size();
char* last = data;
size_t i = 0;
for (; i < len; ++i)
{
data[i] = '\0';
ctx.j.trace() << last;
last = data + i;
if (data[i] == '\n')
{
data[i] = '\0';
ctx.j.trace() << last;
last = data + i;
}
}
if (last < data + i)
ctx.j.trace() << last;
}
if (last < data + i)
ctx.j.trace() << last;
}
if (!result)
{
JLOG(ctx.j.trace())
<< "HookSet(" << hook::log::WASM_BAD_MAGIC << ")["
<< HS_ACC()
<< "]: Malformed transaction: SetHook "
"sfCreateCode failed validation.";
return false;
}
if (!result)
return false;
std::tie(maxInstrCountHook, maxInstrCountCbak) = *result;
}
else if (version == 1) // Gas type
{
// validate with GasValidator
auto error = hook::validateWasmHostFunctionsForGas(
hook, ctx.rules, ctx.j);
if (error)
{
JLOG(ctx.j.trace())
<< "HookSet(" << hook::log::IMPORT_ILLEGAL << ")["
<< HS_ACC()
<< "]: Malformed transaction: Gas-type Hook "
"validation failed: "
<< *error;
return false;
}
// Gas type: maxInstrCount is not pre-calculated (use Gas
// limit at runtime)
maxInstrCountHook = 0;
maxInstrCountCbak = 0;
}
JLOG(ctx.j.trace())
<< "HookSet(" << hook::log::WASM_SMOKE_TEST << ")["
@@ -547,7 +598,7 @@ SetHook::validateHookSetEntry(SetHookCtx& ctx, STObject const& hookSetObj)
return false;
}
return *result;
return std::make_pair(maxInstrCountHook, maxInstrCountCbak);
}
}
@@ -1680,10 +1731,11 @@ SetHook::setHook()
newHookDef->setFieldH256(
sfHookSetTxnID, ctx.tx.getTransactionID());
newHookDef->setFieldU64(sfReferenceCount, 1);
newHookDef->setFieldAmount(
sfFee,
XRPAmount{
hook::computeExecutionFee(maxInstrCountHook)});
if (hookSetObj->get().getFieldU16(sfHookApiVersion) != 1)
newHookDef->setFieldAmount(
sfFee,
XRPAmount{
hook::computeExecutionFee(maxInstrCountHook)});
if (maxInstrCountCbak > 0)
newHookDef->setFieldAmount(
sfHookCallbackFee,

View File

@@ -100,6 +100,11 @@ preflight1(PreflightContext const& ctx)
return temMALFORMED;
}
if (ctx.tx.isFieldPresent(sfHookGas) && !ctx.rules.enabled(featureHookGas))
{
return temMALFORMED;
}
auto const ret = preflight0(ctx);
if (!isTesSuccess(ret))
return ret;
@@ -219,6 +224,7 @@ Transactor::calculateHookChainFee(
return XRPAmount{0};
XRPAmount fee{0};
uint32_t gasTypeHookCount = 0; // Gas type hook counter
auto const& hooks = hookSLE->getFieldArray(sfHooks);
@@ -255,18 +261,43 @@ Transactor::calculateHookChainFee(
if (hook::canHook(tx.getTxnType(), hookOn) &&
(!collectCallsOnly || (flags & hook::hsfCOLLECT)))
{
XRPAmount const toAdd{hookDef->getFieldAmount(sfFee).xrp().drops()};
// get HookApiVersion
uint16_t apiVersion = hookDef->getFieldU16(sfHookApiVersion);
// this overflow should never happen, if somehow it does
// fee is set to the largest possible valid xrp value to force
// fail the transaction
if (fee + toAdd < fee)
fee = XRPAmount{INITIAL_XRP.drops()};
else
fee += toAdd;
if (apiVersion == 0) // Guard type
{
// existing logic: read HookDefinition's sfFee
XRPAmount const toAdd{
hookDef->getFieldAmount(sfFee).xrp().drops()};
// this overflow should never happen, if somehow it does
// fee is set to the largest possible valid xrp value to force
// fail the transaction
if (fee + toAdd < fee)
fee = XRPAmount{INITIAL_XRP.drops()};
else
fee += toAdd;
}
else if (apiVersion == 1) // Gas type
{
// Gas type: only count
gasTypeHookCount++;
}
}
}
// Additional cost for Gas type: 0.2 XAH/Hook = 200,000 drops/Hook
if (gasTypeHookCount > 0)
{
// TODO:
auto const baseGasFee = 200000;
XRPAmount const gasTypeFee{gasTypeHookCount * baseGasFee};
if (fee + gasTypeFee < fee)
fee = XRPAmount{INITIAL_XRP.drops()}; // overflow
else
fee += gasTypeFee;
}
return fee;
}
@@ -346,6 +377,10 @@ Transactor::calculateBaseFee(ReadView const& view, STTx const& tx)
if (canRollback)
hookExecutionFee +=
calculateHookChainFee(view, tx, keylet::hook(tshAcc));
if (view.rules().enabled(featureHookGas) &&
tx.isFieldPresent(sfHookGas))
hookExecutionFee += XRPAmount{tx.getFieldU32(sfHookGas)};
}
XRPAmount accumulator = baseFee;
@@ -1194,6 +1229,15 @@ Transactor::executeHookChain(
std::map<uint256, std::map<std::vector<uint8_t>, std::vector<uint8_t>>>
hookParamOverrides{};
// Initialize Gas pool for Gas-type hooks
uint32_t gasPool = 0;
if (ctx_.tx.isFieldPresent(sfHookGas))
{
gasPool = ctx_.tx.getFieldU32(sfHookGas);
JLOG(j_.trace()) << "HookChain: Initialized Gas pool with " << gasPool
<< " instructions";
}
auto const& hooks = hookSLE->getFieldArray(sfHooks);
uint8_t hook_no = 0;
@@ -1262,6 +1306,19 @@ Transactor::executeHookChain(
bool hasCallback = hookDef->isFieldPresent(sfHookCallbackFee);
// Extract HookApiVersion for Gas-type hooks
uint16_t hookApiVersion = hookDef->isFieldPresent(sfHookApiVersion)
? hookDef->getFieldU16(sfHookApiVersion)
: 0;
// Prepare Gas limit for this hook execution
uint32_t hookGas = 0;
if (hookApiVersion == 1)
{
// Pass remaining Gas pool to this hook
hookGas = gasPool;
}
try
{
results.push_back(hook::apply(
@@ -1280,12 +1337,35 @@ Transactor::executeHookChain(
strong,
(strong ? 0 : 1UL), // 0 = strong, 1 = weak
hook_no - 1,
provisionalMeta));
provisionalMeta,
hookApiVersion,
hookGas));
executedHookCount_++;
hook::HookResult& hookResult = results.back();
// Track Gas consumption for Gas-type hooks
if (hookApiVersion == 1)
{
uint64_t consumed = hookResult.instructionCost;
JLOG(j_.trace()) << "HookChain: Hook consumed " << consumed
<< " instructions. Pool before: " << gasPool;
if (consumed >= gasPool)
{
JLOG(j_.trace()) << "HookError: Gas pool exhausted. "
<< "Hook tried to consume " << consumed
<< " but only " << gasPool << " remained.";
return tecHOOK_INSUFFICIENT_GAS;
}
gasPool -= consumed;
JLOG(j_.trace()) << "HookChain: Pool after: " << gasPool;
}
if (hookResult.exitType != hook_api::ExitType::ACCEPT)
{
if (results.back().exitType == hook_api::ExitType::WASM_ERROR)
@@ -1422,6 +1502,17 @@ Transactor::doHookCallback(
{
hook::HookStateMap stateMap;
// Extract HookApiVersion for callback
uint16_t hookApiVersion = hookDef->getFieldU16(sfHookApiVersion);
// Callbacks don't consume HookGas independently, but we pass it
// for consistency
uint32_t hookGas = 0;
if (ctx_.tx.isFieldPresent(sfHookGas))
{
hookGas = ctx_.tx.getFieldU32(sfHookGas);
}
hook::HookResult callbackResult = hook::apply(
hookDef->getFieldH256(sfHookSetTxnID),
callbackHookHash,
@@ -1441,7 +1532,9 @@ Transactor::doHookCallback(
? 1UL
: 0UL,
hook_no - 1,
provisionalMeta);
provisionalMeta,
hookApiVersion,
hookGas);
executedHookCount_++;
@@ -1717,6 +1810,14 @@ Transactor::doAgainAsWeak(
return;
}
// Extract HookApiVersion for aaw execution
uint16_t hookApiVersion = hookDef->getFieldU16(sfHookApiVersion);
// Extract HookGas for Gas-type hooks
uint32_t hookGas = 0;
if (hookApiVersion == 1 && ctx_.tx.isFieldPresent(sfHookGas))
hookGas = ctx_.tx.getFieldU32(sfHookGas);
try
{
hook::HookResult aawResult = hook::apply(
@@ -1735,7 +1836,9 @@ Transactor::doAgainAsWeak(
false,
2UL, // param 2 = aaw
hook_no - 1,
provisionalMeta);
provisionalMeta,
hookApiVersion,
hookGas);
executedHookCount_++;

View File

@@ -74,7 +74,7 @@ namespace detail {
// Feature.cpp. Because it's only used to reserve storage, and determine how
// large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than
// the actual number of amendments. A LogicError on startup will verify this.
static constexpr std::size_t numFeatures = 90;
static constexpr std::size_t numFeatures = 91;
/** Amendments that this server supports and the default voting behavior.
Whether they are enabled depends on the Rules defined in the validated
@@ -378,6 +378,7 @@ extern uint256 const fixInvalidTxFlags;
extern uint256 const featureExtendedHookState;
extern uint256 const fixCronStacking;
extern uint256 const fixHookAPI20251128;
extern uint256 const featureHookGas;
} // namespace ripple
#endif

View File

@@ -414,6 +414,8 @@ extern SF_UINT32 const sfXahauActivationLgrSeq;
extern SF_UINT32 const sfDelaySeconds;
extern SF_UINT32 const sfRepeatCount;
extern SF_UINT32 const sfStartTime;
extern SF_UINT32 const sfHookGas;
extern SF_UINT32 const sfHookInstructionCost;
// 64-bit integers (common)
extern SF_UINT64 const sfIndexNext;

View File

@@ -344,6 +344,7 @@ enum TECcodes : TERUnderlyingType {
tecIMMUTABLE = 188,
tecTOO_MANY_REMARKS = 189,
tecHAS_HOOK_STATE = 190,
tecHOOK_INSUFFICIENT_GAS = 191,
tecLAST_POSSIBLE_ENTRY = 255,
};

View File

@@ -484,6 +484,7 @@ REGISTER_FIX (fixInvalidTxFlags, Supported::yes, VoteBehavior::De
REGISTER_FEATURE(ExtendedHookState, Supported::yes, VoteBehavior::DefaultNo);
REGISTER_FIX (fixCronStacking, Supported::yes, VoteBehavior::DefaultYes);
REGISTER_FIX (fixHookAPI20251128, Supported::yes, VoteBehavior::DefaultYes);
REGISTER_FEATURE(HookGas, Supported::yes, VoteBehavior::DefaultYes);
// The following amendments are obsolete, but must remain supported
// because they could potentially get enabled.

View File

@@ -73,7 +73,8 @@ InnerObjectFormats::InnerObjectFormats()
{sfHookExecutionIndex, soeREQUIRED},
{sfHookStateChangeCount, soeREQUIRED},
{sfHookEmitCount, soeREQUIRED},
{sfFlags, soeOPTIONAL}});
{sfFlags, soeOPTIONAL},
{sfHookInstructionCost, soeOPTIONAL}});
add(sfHookEmission.jsonName.c_str(),
sfHookEmission.getCode(),
@@ -91,7 +92,7 @@ InnerObjectFormats::InnerObjectFormats()
{sfHookCanEmit, soeOPTIONAL},
{sfHookApiVersion, soeREQUIRED},
{sfFlags, soeREQUIRED},
{sfFee, soeREQUIRED}});
{sfFee, soeOPTIONAL}});
add(sfHook.jsonName.c_str(),
sfHook.getCode(),

View File

@@ -158,6 +158,10 @@ CONSTRUCT_TYPED_SFIELD(sfLockCount, "LockCount", UINT32,
CONSTRUCT_TYPED_SFIELD(sfFirstNFTokenSequence, "FirstNFTokenSequence", UINT32, 50);
// 32-bit integers (hook)
CONSTRUCT_TYPED_SFIELD(sfHookInstructionCost, "HookInstructionCost", UINT32, 91);
CONSTRUCT_TYPED_SFIELD(sfHookGas, "HookGas", UINT32, 92);
CONSTRUCT_TYPED_SFIELD(sfStartTime, "StartTime", UINT32, 93);
CONSTRUCT_TYPED_SFIELD(sfRepeatCount, "RepeatCount", UINT32, 94);
CONSTRUCT_TYPED_SFIELD(sfDelaySeconds, "DelaySeconds", UINT32, 95);
@@ -270,6 +274,7 @@ CONSTRUCT_TYPED_SFIELD(sfBaseFeeDrops, "BaseFeeDrops", AMOU
CONSTRUCT_TYPED_SFIELD(sfReserveBaseDrops, "ReserveBaseDrops", AMOUNT, 23);
CONSTRUCT_TYPED_SFIELD(sfReserveIncrementDrops, "ReserveIncrementDrops", AMOUNT, 24);
// variable length (common)
CONSTRUCT_TYPED_SFIELD(sfPublicKey, "PublicKey", VL, 1);
CONSTRUCT_TYPED_SFIELD(sfMessageKey, "MessageKey", VL, 2);

View File

@@ -95,6 +95,7 @@ transResults()
MAKE_ERROR(tecIMMUTABLE, "The remark is marked immutable on the object, and therefore cannot be updated."),
MAKE_ERROR(tecTOO_MANY_REMARKS, "The number of remarks on the object would exceed the limit of 32."),
MAKE_ERROR(tecHAS_HOOK_STATE, "Delete all hook state before reducing scale"),
MAKE_ERROR(tecHOOK_INSUFFICIENT_GAS, "Insufficient hook gas to complete the transaction."),
MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."),
MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."),

View File

@@ -44,6 +44,7 @@ TxFormats::TxFormats()
{sfNetworkID, soeOPTIONAL},
{sfHookParameters, soeOPTIONAL},
{sfOperationLimit, soeOPTIONAL},
{sfHookGas, soeOPTIONAL},
};
add(jss::AccountSet,

View File

@@ -27,6 +27,7 @@
#include <test/app/SetHook_wasm.h>
#include <test/jtx.h>
#include <test/jtx/hook.h>
#include <test/jtx/hookgas.h>
#include <unordered_map>
namespace ripple {
@@ -13377,9 +13378,237 @@ public:
}
}
void
testGasTypeHookDisabled(FeatureBitset features)
{
testcase("Test Gas-type Hook disabled");
using namespace jtx;
Env env{*this, features - featureHookGas};
auto const alice = Account{"alice"};
env.fund(XRP(10000), alice);
env.close();
// Install a Gas-type hook with Version 1
Json::Value jvh = hso(gas_accept_wasm, overrideFlag);
jvh[jss::HookApiVersion] = 0;
env(ripple::test::jtx::hook(alice, {{jvh}}, 0),
M("test gas type hook installation"),
HSFEE,
ter(temMALFORMED));
// Install a Gas-type hook with Version 1
jvh[jss::HookApiVersion] = 1;
env(ripple::test::jtx::hook(alice, {{jvh}}, 0),
M("test gas type hook installation"),
HSFEE,
ter(temMALFORMED));
// HookGas field
env(invoke::invoke(alice),
hookgas(1000000),
M("test gas type hook invocation"),
fee(XRP(1)),
ter(temMALFORMED));
env.close();
}
void
testGasTypeHookInstallation(FeatureBitset features)
{
testcase("Test Gas-type Hook installation");
using namespace jtx;
Env env{*this, features};
auto const alice = Account{"alice"};
env.fund(XRP(10000), alice);
env.close();
// Install a Gas-type hook using gas_accept_wasm
Json::Value jvh = hso(gas_accept_wasm, overrideFlag);
jvh[jss::HookApiVersion] = 1;
env(ripple::test::jtx::hook(alice, {{jvh}}, 0),
M("test gas type hook installation"),
HSFEE);
env.close();
// no HookGas field for Gas-type hook
env(invoke::invoke(alice),
M("test gas type hook invocation"),
fee(XRP(1)),
ter(tecHOOK_INSUFFICIENT_GAS));
env.close();
// insufficient fee for gas type hook
// baseFee + HookGas fee + gas type Hook call fee
auto const expectedGas = 1'000'000;
auto const expectedFee =
env.current()->fees().base + drops(expectedGas) + drops(200'000);
env(invoke::invoke(alice),
hookgas(expectedGas),
fee(expectedFee - drops(1)),
ter(telINSUF_FEE_P));
env.close();
// HookGas field for Gas-type hook
env(invoke::invoke(alice), hookgas(expectedGas), fee(expectedFee));
env.close();
}
void
testGasTypeHookRejects_gFunction(FeatureBitset features)
{
testcase("Test Gas-type Hook rejects _g function");
using namespace jtx;
Env env{*this, features};
auto const alice = Account{"alice"};
env.fund(XRP(10000), alice);
env.close();
// Attempt to install Gas-type hook with _g function (should fail)
Json::Value jv = hso(gas_with_g_wasm, overrideFlag);
jv[jss::HookApiVersion] = 1;
env(ripple::test::jtx::hook(alice, {{jv}}, 0),
M("test gas type hook rejects _g function"),
HSFEE,
ter(temMALFORMED)); // Should fail because Gas-type
// hooks cannot use _g
env.close();
}
void
testGasExecutionSufficient(FeatureBitset features)
{
testcase("Test Gas execution with sufficient gas");
using namespace jtx;
Env env{*this, features};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
env.fund(XRP(100000), alice, bob);
env.close();
// Install Gas-type hook with sufficient gas
Json::Value jvh = hso(gas_long_wasm, overrideFlag);
jvh[jss::HookApiVersion] = 1;
env(ripple::test::jtx::hook(alice, {{jvh}}, 0), HSFEE, ter(tesSUCCESS));
env.close();
// Trigger the hook with a payment
env(pay(bob, alice, XRP(1)),
// insufficient gas for long execution
hookgas(100),
fee(XRP(10000)),
ter(tecHOOK_INSUFFICIENT_GAS));
env.close();
// check exit type
auto meta = env.meta();
BEAST_REQUIRE(meta);
BEAST_REQUIRE(meta->isFieldPresent(sfHookExecutions));
auto hookExecutions = meta->getFieldArray(sfHookExecutions);
BEAST_REQUIRE(hookExecutions.size() == 1);
// validate HookInstructionCost
BEAST_REQUIRE(hookExecutions[0].isFieldPresent(sfHookInstructionCost));
BEAST_REQUIRE(
hookExecutions[0].getFieldU32(sfHookInstructionCost) == 100);
// check exit type
BEAST_REQUIRE(hookExecutions[0].isFieldPresent(sfHookResult));
BEAST_REQUIRE(
hookExecutions[0].getFieldU8(sfHookResult) ==
hook_api::ExitType::WASM_ERROR);
// Trigger the hook with a payment
env(pay(bob, alice, XRP(1)),
// Sufficient gas for long execution
hookgas(10000000),
fee(XRP(10000)),
ter(tesSUCCESS));
env.close();
meta = env.meta();
BEAST_REQUIRE(meta);
BEAST_REQUIRE(meta->isFieldPresent(sfHookExecutions));
hookExecutions = meta->getFieldArray(sfHookExecutions);
BEAST_REQUIRE(hookExecutions.size() == 1);
// validate HookInstructionCost
BEAST_REQUIRE(hookExecutions[0].isFieldPresent(sfHookInstructionCost));
BEAST_REQUIRE(
hookExecutions[0].getFieldU32(sfHookInstructionCost) == 2014);
// check exit type
BEAST_REQUIRE(hookExecutions[0].isFieldPresent(sfHookResult));
BEAST_REQUIRE(
hookExecutions[0].getFieldU8(sfHookResult) ==
hook_api::ExitType::ACCEPT);
}
void
testMultipleGasHooksSharedPool(FeatureBitset features)
{
testcase("Test multiple Gas-type hooks share gas pool");
using namespace jtx;
Env env{*this, features};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
env.fund(XRP(100000), alice, bob);
env.close();
// Install multiple Gas-type hooks
// First hook
Json::Value hook1 = hso(gas_accept_wasm, overrideFlag);
hook1[jss::HookApiVersion] = 1;
// Second hook
Json::Value hook2 = hso(gas_long_wasm, overrideFlag);
hook2[jss::HookApiVersion] = 1;
env(ripple::test::jtx::hook(alice, {{hook1, hook2}}, 0),
M("test multiple gas hooks shared pool"),
HSFEE,
ter(tesSUCCESS));
env.close();
// Trigger hooks with a payment
Json::Value jv = pay(bob, alice, XRP(1));
env(jv, hookgas(10000000), fee(XRP(10000)), ter(tesSUCCESS));
env.close();
auto meta = env.meta();
BEAST_REQUIRE(meta);
BEAST_REQUIRE(meta->isFieldPresent(sfHookExecutions));
auto const hookExecutions = meta->getFieldArray(sfHookExecutions);
BEAST_REQUIRE(hookExecutions.size() == 2);
// validate HookInstructionCost
BEAST_REQUIRE(hookExecutions[0].isFieldPresent(sfHookInstructionCost));
BEAST_REQUIRE(hookExecutions[1].isFieldPresent(sfHookInstructionCost));
BEAST_REQUIRE(
hookExecutions[0].getFieldU32(sfHookInstructionCost) == 14);
BEAST_REQUIRE(
hookExecutions[1].getFieldU32(sfHookInstructionCost) == 2014);
}
void
testWithFeatures(FeatureBitset features)
{
// Gas-type Hook tests
testGasTypeHookDisabled(features);
testGasTypeHookInstallation(features);
testGasTypeHookRejects_gFunction(features);
testGasExecutionSufficient(features);
testMultipleGasHooksSharedPool(features);
return;
testHooksOwnerDir(features);
testHooksDisabled(features);
testTxStructure(features);
@@ -13492,6 +13721,13 @@ public:
test_util_raddr(features); //
test_util_sha512h(features); //
test_util_verify(features); //
// Gas-type Hook tests
testGasTypeHookDisabled(features);
testGasTypeHookInstallation(features);
testGasTypeHookRejects_gFunction(features);
testGasExecutionSufficient(features);
testMultipleGasHooksSharedPool(features);
}
public:
@@ -13662,6 +13898,53 @@ private:
)[test.hook]"];
HASH_WASM(accept2);
TestHook gas_accept_wasm = // WASM: 7
wasm[
R"[test.hook.gas](
#include <stdint.h>
extern int64_t accept (uint32_t read_ptr, uint32_t read_len, int64_t error_code);
int64_t hook(uint32_t reserved )
{
return accept(0,0,0);
}
)[test.hook.gas]"];
HASH_WASM(gas_accept);
TestHook gas_with_g_wasm = // WASM: 8
wasm[
R"[test.hook.gas](
#include <stdint.h>
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);
int64_t hook(uint32_t reserved )
{
_g(1,1);
return accept(0,0,0);
}
)[test.hook.gas]"];
HASH_WASM(gas_with_g);
TestHook gas_long_wasm = // WASM: 9
wasm[
R"[test.hook.gas](
#include <stdint.h>
extern int64_t accept (uint32_t read_ptr, uint32_t read_len, int64_t error_code);
extern int64_t float_one();
#define SBUF(x) (uint32_t)(x), sizeof(x)
#define M_REPEAT_10(X) X X X X X X X X X X
#define M_REPEAT_100(X) M_REPEAT_10(M_REPEAT_10(X))
#define M_REPEAT_1000(X) M_REPEAT_100(M_REPEAT_10(X))
int64_t hook(uint32_t reserved )
{
M_REPEAT_1000(float_one(););
return accept(0, 0, 0);
}
)[test.hook.gas]"];
HASH_WASM(gas_long);
};
#define SETHOOK_TEST(i, last) \

File diff suppressed because it is too large Load Diff

View File

@@ -41,17 +41,28 @@ echo '
namespace ripple {
namespace test {
std::map<std::string, std::vector<uint8_t>> wasm = {' > $OUTPUT_FILE
COUNTER="0"
cat $INPUT_FILE | tr '\n' '\f' |
grep -Po 'R"\[test\.hook\](.*?)\[test\.hook\]"' |
sed -E 's/R"\[test\.hook\]\(//g' |
sed -E 's/\)\[test\.hook\]"[\f \t]*/\/*end*\//g' |
# Counter file for sharing between subshells
COUNTER_FILE=$(mktemp)
echo "0" > $COUNTER_FILE
trap "rm -f $COUNTER_FILE" EXIT
# Process both [test.hook] and [test.hook.gas] blocks
process_block() {
local tag_pattern="$1" # regex pattern: "hook" or "hook\.gas"
local tag_output="$2" # output string: "hook" or "hook.gas"
local skip_cleaner="$3" # "0" for no skip, "1" for skip
cat $INPUT_FILE | tr '\n' '\f' |
grep -Po "R\"\[test\.${tag_pattern}\](.*?)\[test\.${tag_pattern}\]\"" |
sed -E "s/R\"\[test\.${tag_pattern}\]\(//g" |
sed -E "s/\)\[test\.${tag_pattern}\]\"[\f \t]*/\/*end*\//g" |
while read -r line
do
COUNTER=$(cat $COUNTER_FILE)
echo "/* ==== WASM: $COUNTER ==== */" >> $OUTPUT_FILE
echo -n '{ R"[test.hook](' >> $OUTPUT_FILE
echo -n "{ R\"[test.${tag_output}](" >> $OUTPUT_FILE
cat <<< "$line" | sed -E 's/.{7}$//g' | tr -d '\n' | tr '\f' '\n' >> $OUTPUT_FILE
echo ')[test.hook]",' >> $OUTPUT_FILE
echo ")[test.${tag_output}]\"," >> $OUTPUT_FILE
echo "{" >> $OUTPUT_FILE
WAT=`grep -Eo '\(module' <<< $line | wc -l`
if [ "$WAT" -eq "0" ]
@@ -69,10 +80,19 @@ cat $INPUT_FILE | tr '\n' '\f' |
echo "$line"
exit 1
fi
wasmcc -x c /dev/stdin -o /dev/stdout -O2 -Wl,--allow-undefined <<< "`tr '\f' '\n' <<< $line`" |
hook-cleaner - - 2>/dev/null |
xxd -p -u -c 10 |
sed -E 's/../0x&U,/g' | sed -E 's/^/ /g' >> $OUTPUT_FILE
if [ "$skip_cleaner" -eq "1" ]
then
# Skip hook-cleaner for [test.hook.gas]
wasmcc -x c /dev/stdin -o /dev/stdout -O2 -Wl,--allow-undefined <<< "`tr '\f' '\n' <<< $line`" |
xxd -p -u -c 10 |
sed -E 's/../0x&U,/g' | sed -E 's/^/ /g' >> $OUTPUT_FILE
else
# Run hook-cleaner for [test.hook]
wasmcc -x c /dev/stdin -o /dev/stdout -O2 -Wl,--allow-undefined <<< "`tr '\f' '\n' <<< $line`" |
hook-cleaner - - 2>/dev/null |
xxd -p -u -c 10 |
sed -E 's/../0x&U,/g' | sed -E 's/^/ /g' >> $OUTPUT_FILE
fi
else
wat2wasm - -o /dev/stdout <<< "`tr '\f' '\n' <<< $(sed -E 's/.{7}$//g' <<< $line)`" |
xxd -p -u -c 10 |
@@ -85,8 +105,15 @@ cat $INPUT_FILE | tr '\n' '\f' |
fi
echo '}},' >> $OUTPUT_FILE
echo >> $OUTPUT_FILE
COUNTER=`echo $COUNTER + 1 | bc`
echo $((COUNTER + 1)) > $COUNTER_FILE
done
}
# Process [test.hook] blocks (with hook-cleaner)
process_block "hook" "hook" "0"
# Process [test.hook.gas] blocks (without hook-cleaner)
process_block "hook\.gas" "hook.gas" "1"
echo '};
}
}

50
src/test/jtx/hookgas.h Normal file
View File

@@ -0,0 +1,50 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 XRPL Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_TEST_JTX_HOOKGAS_H_INCLUDED
#define RIPPLE_TEST_JTX_HOOKGAS_H_INCLUDED
#include <ripple/basics/contract.h>
#include <test/jtx/Env.h>
#include <test/jtx/tags.h>
namespace ripple {
namespace test {
namespace jtx {
/** Set the HookGas on a JTx. */
class hookgas
{
private:
std::uint32_t gas_;
public:
hookgas(std::uint32_t gas) : gas_{gas}
{
}
void
operator()(Env&, JTx& jt) const;
};
} // namespace jtx
} // namespace test
} // namespace ripple
#endif

View File

@@ -0,0 +1,35 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2025 XRPL Labs
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <ripple/protocol/jss.h>
#include <test/jtx/hookgas.h>
namespace ripple {
namespace test {
namespace jtx {
void
hookgas::operator()(Env&, JTx& jt) const
{
jt[sfHookGas.jsonName] = gas_;
}
} // namespace jtx
} // namespace test
} // namespace ripple