Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions include/xrpl/protocol/detail/features.macro
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ XRPL_FIX (NFTokenNegOffer, Supported::yes, VoteBehavior::Obsolete)
XRPL_FIX (NFTokenDirV1, Supported::yes, VoteBehavior::Obsolete)
XRPL_FEATURE(NonFungibleTokensV1, Supported::yes, VoteBehavior::Obsolete)
XRPL_FEATURE(CryptoConditionsSuite, Supported::yes, VoteBehavior::Obsolete)
XRPL_FEATURE(PWABoot, Supported::yes, VoteBehavior::DefaultNo)

// The following amendments have been active for at least two years. Their
// pre-amendment code has been removed and the identifiers are deprecated.
Expand Down
1 change: 1 addition & 0 deletions include/xrpl/protocol/detail/ledger_entries.macro
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
{sfHookStateScale, soeOPTIONAL},
{sfCron, soeOPTIONAL},
{sfAMMID, soeOPTIONAL},
{sfBootBlob, soeOPTIONAL},
}))

/** A ledger object which contains a list of object identifiers.
Expand Down
1 change: 1 addition & 0 deletions include/xrpl/protocol/detail/sfields.macro
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
TYPED_SFIELD(sfProvider, VL, 30)
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
TYPED_SFIELD(sfCredentialType, VL, 32)
TYPED_SFIELD(sfBootBlob, VL, 33)
TYPED_SFIELD(sfHookName, VL, 97)
TYPED_SFIELD(sfRemarkValue, VL, 98)
TYPED_SFIELD(sfRemarkName, VL, 99)
Expand Down
5 changes: 5 additions & 0 deletions include/xrpl/protocol/detail/transactions.macro
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,11 @@ TRANSACTION(ttREMARKS_SET, 94, SetRemarks, ({
{sfRemarks, soeREQUIRED},
}))

/* Store a small on-chain PWA bootloader blob on the sender's account (delete = omit blob) */
TRANSACTION(ttBOOT_SET, 105, SetBoot, ({
{sfBootBlob, soeOPTIONAL},
}))

/* A payment transactor that delivers only the exact amounts specified, creating accounts and TLs as needed
* that the sender pays for. */
TRANSACTION(ttREMIT, 95, Remit, ({
Expand Down
177 changes: 177 additions & 0 deletions src/test/app/SetBoot_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 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...
*/
//==============================================================================

#include <test/jtx.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/jss.h>

namespace ripple {

// Functional tests for the PWA boot-blob transactor (SetBoot / featurePWABoot).
// v0: store / replace / delete a single sfBootBlob on the sender's own AccountRoot.
class SetBoot_test : public beast::unit_test::suite
{
// Maximum allowed blob (mirror SetBoot::maxBootBlobBytes) — kept local to avoid pulling the
// transactor header into the test.
static constexpr std::size_t maxBootBlobBytes = 4096;

static Json::Value
setBoot(test::jtx::Account const& acct, std::optional<Blob> const& blob)
{
Json::Value jv;
jv[jss::TransactionType] = "SetBoot";
jv[jss::Account] = acct.human();
// explicit, generous fee — the per-byte surcharge in calculateBaseFee isn't autofilled.
jv[jss::Fee] = "1000000";
if (blob)
jv[sfBootBlob.jsonName] = strHex(*blob);
return jv;
}

// Same tx but WITHOUT a baked-in fee, so a caller can attach an exact fee() and actually
// exercise calculateBaseFee (base + 1 drop/blob-byte) via Transactor::checkFee.
static Json::Value
setBootNoFee(test::jtx::Account const& acct, std::optional<Blob> const& blob)
{
Json::Value jv;
jv[jss::TransactionType] = "SetBoot";
jv[jss::Account] = acct.human();
if (blob)
jv[sfBootBlob.jsonName] = strHex(*blob);
return jv;
}

void
testDisabled()
{
testcase("amendment disabled => temDISABLED");
using namespace test::jtx;
Env env{*this, supported_amendments() - featurePWABoot};
auto const alice = Account("alice");
env.fund(XRP(1000), alice);
env.close();
env(setBoot(alice, Blob{'h', 'i'}), ter(temDISABLED));
env.close();
}

void
testSetReplaceDelete()
{
testcase("set / replace / delete");
using namespace test::jtx;
Env env{*this, supported_amendments()}; // PWABoot is Supported::yes => included
auto const alice = Account("alice");
env.fund(XRP(1000), alice);
env.close();

Blob const b1{'h', 'e', 'l', 'l', 'o'};
env(setBoot(alice, b1));
env.close();
{
auto const sle = env.le(alice);
BEAST_EXPECT(sle && sle->isFieldPresent(sfBootBlob));
BEAST_EXPECT(sle->getFieldVL(sfBootBlob) == b1);
}

Blob const b2{'w', 'o', 'r', 'l', 'd', '!'};
env(setBoot(alice, b2));
env.close();
BEAST_EXPECT(env.le(alice)->getFieldVL(sfBootBlob) == b2);

env(setBoot(alice, std::nullopt)); // omit blob => delete
env.close();
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfBootBlob));
}

void
testBounds()
{
testcase("empty / oversize => temMALFORMED");
using namespace test::jtx;
Env env{*this, supported_amendments()};
auto const alice = Account("alice");
env.fund(XRP(1000), alice);
env.close();

env(setBoot(alice, Blob{}), ter(temMALFORMED)); // empty (present but zero-length)
env.close();
env(setBoot(alice, Blob(maxBootBlobBytes + 1, 0x41)), ter(temMALFORMED)); // > cap
env.close();
env(setBoot(alice, Blob(maxBootBlobBytes, 0x41))); // exactly at cap => ok
env.close();
BEAST_EXPECT(env.le(alice)->isFieldPresent(sfBootBlob));
}

void
testFeeSurcharge()
{
// calculateBaseFee = Transactor base + 1 drop per blob byte. Submit the EXACT required
// fee (success) and one drop short (telINSUF_FEE_P) so the per-byte surcharge is actually
// exercised — a zero/wrong/sign-flipped surcharge would change one of these outcomes.
testcase("per-byte fee surcharge enforced");
using namespace test::jtx;
Env env{*this, supported_amendments()};
auto const alice = Account("alice");
env.fund(XRP(1000), alice);
env.close();

Blob const blob(200, 0x42); // 200-byte blob => +200 drop surcharge
auto const base = env.current()->fees().base;
auto const required = base + XRPAmount{static_cast<std::int64_t>(blob.size())};

// one drop short of (base + surcharge) => the surcharge is what tips it under.
env(setBootNoFee(alice, blob),
fee(required - XRPAmount{1}),
ter(telINSUF_FEE_P));
env.close();
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfBootBlob)); // rejected, nothing stored

// exactly base + surcharge => succeeds.
env(setBootNoFee(alice, blob), fee(required));
env.close();
BEAST_EXPECT(env.le(alice)->getFieldVL(sfBootBlob) == blob);
}

void
testDeleteWhenAbsentIsNoOp()
{
// Deleting a boot blob that was never set: doApply's fall-through branch. Must be a clean
// no-op (tesSUCCESS, field stays absent), not an error or an unexpected mutation.
testcase("delete when absent => no-op success");
using namespace test::jtx;
Env env{*this, supported_amendments()};
auto const alice = Account("alice");
env.fund(XRP(1000), alice);
env.close();

BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfBootBlob)); // precondition: none set
env(setBoot(alice, std::nullopt)); // delete with nothing present
env.close();
BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfBootBlob)); // still absent, no error
}

public:
void
run() override
{
testDisabled();
testSetReplaceDelete();
testBounds();
testFeeSurcharge();
testDeleteWhenAbsentIsNoOp();
}
};

BEAST_DEFINE_TESTSUITE(SetBoot, app, ripple);

} // namespace ripple
11 changes: 11 additions & 0 deletions src/test/app/SetHookTSH_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5893,6 +5893,17 @@ struct SetHookTSH0_test : public beast::unit_test::suite
}
}

void
testSetBootTSH(FeatureBitset features)
{
testcase("set boot tsh");
// v0 stub: SetBoot mutates only the sender's own AccountRoot. Whether it
// should fire the account's own (strong) TSH hook is an amendment design
// question; full TSH coverage is a follow-up. Stubbed so the per-tx TSH
// dispatch (transactions.macro) compiles.
BEAST_EXPECT(true);
}

void
testSetRemarksTSH(FeatureBitset features)
{
Expand Down
113 changes: 113 additions & 0 deletions src/xrpld/app/tx/detail/SetBoot.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 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 <xrpld/app/tx/detail/SetBoot.h>
#include <xrpld/core/Config.h>
#include <xrpld/ledger/View.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/st.h>

namespace ripple {

TxConsequences
SetBoot::makeTxConsequences(PreflightContext const& ctx)
{
return TxConsequences{ctx.tx, TxConsequences::normal};
}

NotTEC
SetBoot::preflight(PreflightContext const& ctx)
{
if (!ctx.rules.enabled(featurePWABoot))
return temDISABLED;

if (auto const ret = preflight1(ctx); !isTesSuccess(ret))
return ret;

auto& tx = ctx.tx;
auto& j = ctx.j;

if (tx.getFlags() & tfUniversalMask)
{
JLOG(j.warn()) << "SetBoot: invalid flags.";
return temINVALID_FLAG;
}

// sfBootBlob present => set/replace (validate size); absent => delete.
if (tx.isFieldPresent(sfBootBlob))
{
Blob const& blob = tx.getFieldVL(sfBootBlob);
if (blob.size() == 0 || blob.size() > SetBoot::maxBootBlobBytes)
{
JLOG(j.warn()) << "SetBoot: boot blob must be 1.."
<< SetBoot::maxBootBlobBytes << " bytes.";
return temMALFORMED;
}
}

return preflight2(ctx);
}

TER
SetBoot::preclaim(PreclaimContext const& ctx)
{
if (!ctx.view.rules().enabled(featurePWABoot))
return temDISABLED;

auto const id = ctx.tx[sfAccount];
if (!ctx.view.read(keylet::account(id)))
return terNO_ACCOUNT;

return tesSUCCESS;
}

TER
SetBoot::doApply()
{
Sandbox sb(&ctx_.view());

auto sle = sb.peek(keylet::account(account_));
if (!sle)
return tefINTERNAL;

if (ctx_.tx.isFieldPresent(sfBootBlob))
sle->setFieldVL(sfBootBlob, ctx_.tx.getFieldVL(sfBootBlob));
else if (sle->isFieldPresent(sfBootBlob))
sle->makeFieldAbsent(sfBootBlob);

sb.update(sle);
sb.apply(ctx_.rawView());
return tesSUCCESS;
}

XRPAmount
SetBoot::calculateBaseFee(ReadView const& view, STTx const& tx)
{
// one drop per blob byte (mirrors SetRemarks) — prices bloat, keeps the stub small.
XRPAmount blobFee{0};
if (tx.isFieldPresent(sfBootBlob))
blobFee =
XRPAmount{static_cast<std::int64_t>(tx.getFieldVL(sfBootBlob).size())};
return Transactor::calculateBaseFee(view, tx) + blobFee;
}

} // namespace ripple
Loading