mirror of
https://github.com/Xahau/xahaud.git
synced 2026-07-27 09:00:09 +00:00
877 lines
31 KiB
C++
877 lines
31 KiB
C++
//------------------------------------------------------------------------------
|
|
/*
|
|
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 <test/app/Export_test_hooks.h>
|
|
#include <test/jtx.h>
|
|
#include <test/jtx/hook.h>
|
|
#include <test/jtx/import.h>
|
|
#include <test/jtx/xpop.h>
|
|
#include <xrpld/app/ledger/LedgerMaster.h>
|
|
#include <xrpl/protocol/AccountID.h>
|
|
#include <xrpl/protocol/Feature.h>
|
|
#include <xrpl/protocol/Indexes.h>
|
|
#include <xrpl/protocol/Sign.h>
|
|
#include <xrpl/protocol/jss.h>
|
|
|
|
#include <map>
|
|
|
|
namespace ripple {
|
|
namespace test {
|
|
|
|
using TestHook = std::vector<uint8_t> const&;
|
|
|
|
// Large fee for hook operations
|
|
#define HSFEE fee(100'000'000)
|
|
|
|
struct Export_test : public beast::unit_test::suite
|
|
{
|
|
static std::unique_ptr<Config>
|
|
exportTestConfig()
|
|
{
|
|
auto cfg = jtx::envconfig(jtx::validator, "");
|
|
cfg->NETWORK_ID = 21337;
|
|
return cfg;
|
|
}
|
|
|
|
// Build a minimal unsigned Payment STObject suitable for sfExportedTxn.
|
|
static STObject
|
|
buildExportedPayment(
|
|
AccountID const& src,
|
|
AccountID const& dst,
|
|
std::uint32_t fls,
|
|
std::uint32_t lls,
|
|
std::optional<std::uint32_t> ticketSeq = 1)
|
|
{
|
|
STObject obj(sfExportedTxn);
|
|
obj.setFieldU16(sfTransactionType, ttPAYMENT);
|
|
obj.setFieldU32(sfFlags, tfFullyCanonicalSig);
|
|
obj.setFieldU32(sfSequence, 0);
|
|
if (ticketSeq)
|
|
obj.setFieldU32(sfTicketSequence, *ticketSeq);
|
|
obj.setFieldU32(sfFirstLedgerSequence, fls);
|
|
obj.setFieldU32(sfLastLedgerSequence, lls);
|
|
obj.setFieldAmount(sfAmount, XRPAmount{1000000});
|
|
obj.setFieldAmount(sfFee, XRPAmount{10});
|
|
obj.setFieldVL(sfSigningPubKey, Blob{});
|
|
obj.setAccountID(sfAccount, src);
|
|
obj.setAccountID(sfDestination, dst);
|
|
return obj;
|
|
}
|
|
|
|
// Hook that exports a payment using xport (for cross-chain export)
|
|
TestHook xport_wasm = export_test_wasm[R"[test.hook](
|
|
#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);
|
|
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_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);
|
|
extern int64_t otxn_type(void);
|
|
extern int64_t ledger_seq(void);
|
|
|
|
#define SBUF(x) (uint32_t)(x), sizeof(x)
|
|
#define ASSERT(x) if (!(x)) rollback((uint32_t)#x, sizeof(#x), __LINE__)
|
|
|
|
#define ttPAYMENT 0
|
|
#define tfCANONICAL 0x80000000UL
|
|
|
|
#define amAMOUNT 1
|
|
#define amFEE 8
|
|
#define atACCOUNT 1
|
|
#define atDESTINATION 3
|
|
|
|
#define ENCODE_TT(buf_out, tt) \
|
|
buf_out[0] = 0x12U; \
|
|
buf_out[1] = (tt >> 8) & 0xFFU; \
|
|
buf_out[2] = tt & 0xFFU; \
|
|
buf_out += 3;
|
|
|
|
#define ENCODE_FLAGS(buf_out, flags) \
|
|
buf_out[0] = 0x22U; \
|
|
buf_out[1] = (flags >> 24) & 0xFFU; \
|
|
buf_out[2] = (flags >> 16) & 0xFFU; \
|
|
buf_out[3] = (flags >> 8) & 0xFFU; \
|
|
buf_out[4] = flags & 0xFFU; \
|
|
buf_out += 5;
|
|
|
|
#define ENCODE_SEQUENCE(buf_out, seq) \
|
|
buf_out[0] = 0x24U; \
|
|
buf_out[1] = (seq >> 24) & 0xFFU; \
|
|
buf_out[2] = (seq >> 16) & 0xFFU; \
|
|
buf_out[3] = (seq >> 8) & 0xFFU; \
|
|
buf_out[4] = seq & 0xFFU; \
|
|
buf_out += 5;
|
|
|
|
#define ENCODE_FLS(buf_out, fls) \
|
|
buf_out[0] = 0x20U; \
|
|
buf_out[1] = 0x1AU; \
|
|
buf_out[2] = (fls >> 24) & 0xFFU; \
|
|
buf_out[3] = (fls >> 16) & 0xFFU; \
|
|
buf_out[4] = (fls >> 8) & 0xFFU; \
|
|
buf_out[5] = fls & 0xFFU; \
|
|
buf_out += 6;
|
|
|
|
#define ENCODE_LLS(buf_out, lls) \
|
|
buf_out[0] = 0x20U; \
|
|
buf_out[1] = 0x1BU; \
|
|
buf_out[2] = (lls >> 24) & 0xFFU; \
|
|
buf_out[3] = (lls >> 16) & 0xFFU; \
|
|
buf_out[4] = (lls >> 8) & 0xFFU; \
|
|
buf_out[5] = lls & 0xFFU; \
|
|
buf_out += 6;
|
|
|
|
#define ENCODE_DROPS(buf_out, drops, amt_type) \
|
|
buf_out[0] = 0x60U + amt_type; \
|
|
buf_out[1] = 0x40U + ((drops >> 56) & 0x3FU); \
|
|
buf_out[2] = (drops >> 48) & 0xFFU; \
|
|
buf_out[3] = (drops >> 40) & 0xFFU; \
|
|
buf_out[4] = (drops >> 32) & 0xFFU; \
|
|
buf_out[5] = (drops >> 24) & 0xFFU; \
|
|
buf_out[6] = (drops >> 16) & 0xFFU; \
|
|
buf_out[7] = (drops >> 8) & 0xFFU; \
|
|
buf_out[8] = drops & 0xFFU; \
|
|
buf_out += 9;
|
|
|
|
#define ENCODE_SIGNING_PUBKEY_EMPTY(buf_out) \
|
|
buf_out[0] = 0x73U; \
|
|
buf_out[1] = 0x00U; \
|
|
buf_out += 2;
|
|
|
|
#define ENCODE_ACCOUNT(buf_out, acc, acc_type) \
|
|
buf_out[0] = 0x80U + acc_type; \
|
|
buf_out[1] = 0x14U; \
|
|
for (int i = 0; i < 20; ++i) buf_out[2+i] = acc[i]; \
|
|
buf_out += 22;
|
|
|
|
#define PREPARE_PAYMENT_SIMPLE_SIZE 270U
|
|
|
|
int64_t hook(uint32_t reserved) {
|
|
_g(1, 1);
|
|
|
|
if (otxn_type() != ttPAYMENT)
|
|
return accept(0, 0, 0);
|
|
|
|
ASSERT(xport_reserve(1) == 1);
|
|
|
|
uint8_t dst[20];
|
|
int64_t dst_len = otxn_param(SBUF(dst), "DST", 3);
|
|
ASSERT(dst_len == 20);
|
|
|
|
uint8_t acc[20];
|
|
ASSERT(hook_account(SBUF(acc)) == 20);
|
|
|
|
uint32_t cls = (uint32_t)ledger_seq();
|
|
|
|
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
|
|
uint8_t* buf = tx;
|
|
|
|
ENCODE_TT(buf, ttPAYMENT);
|
|
ENCODE_FLAGS(buf, tfCANONICAL);
|
|
ENCODE_SEQUENCE(buf, 0);
|
|
ENCODE_FLS(buf, cls + 1);
|
|
ENCODE_LLS(buf, cls + 5);
|
|
// sfTicketSequence = UINT32 field 41 = 0x20 0x29
|
|
buf[0] = 0x20U; buf[1] = 0x29U;
|
|
buf[2] = 0; buf[3] = 0; buf[4] = 0; buf[5] = 1;
|
|
buf += 6;
|
|
|
|
uint64_t drops = 1000000;
|
|
ENCODE_DROPS(buf, drops, amAMOUNT);
|
|
ENCODE_DROPS(buf, 10, amFEE);
|
|
|
|
ENCODE_SIGNING_PUBKEY_EMPTY(buf);
|
|
ENCODE_ACCOUNT(buf, acc, atACCOUNT);
|
|
ENCODE_ACCOUNT(buf, dst, atDESTINATION);
|
|
|
|
uint8_t hash[32];
|
|
int64_t xport_result = xport(SBUF(hash), (uint32_t)tx, buf - tx);
|
|
ASSERT(xport_result == 32);
|
|
|
|
return accept(0, 0, 0);
|
|
}
|
|
)[test.hook]"];
|
|
|
|
// Hook that exports a payment WITH sfNetworkID matching local network.
|
|
// Should be rejected by the NetworkID self-target guard.
|
|
TestHook xport_self_target_wasm = export_test_wasm[R"[test.hook](
|
|
#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);
|
|
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_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);
|
|
extern int64_t otxn_type(void);
|
|
extern int64_t ledger_seq(void);
|
|
|
|
#define SBUF(x) (uint32_t)(x), sizeof(x)
|
|
#define ASSERT(x) if (!(x)) rollback((uint32_t)#x, sizeof(#x), __LINE__)
|
|
|
|
#define ttPAYMENT 0
|
|
#define tfCANONICAL 0x80000000UL
|
|
|
|
#define amAMOUNT 1
|
|
#define amFEE 8
|
|
#define atACCOUNT 1
|
|
#define atDESTINATION 3
|
|
|
|
#define ENCODE_TT(buf_out, tt) \
|
|
buf_out[0] = 0x12U; \
|
|
buf_out[1] = (tt >> 8) & 0xFFU; \
|
|
buf_out[2] = tt & 0xFFU; \
|
|
buf_out += 3;
|
|
|
|
#define ENCODE_FLAGS(buf_out, flags) \
|
|
buf_out[0] = 0x22U; \
|
|
buf_out[1] = (flags >> 24) & 0xFFU; \
|
|
buf_out[2] = (flags >> 16) & 0xFFU; \
|
|
buf_out[3] = (flags >> 8) & 0xFFU; \
|
|
buf_out[4] = flags & 0xFFU; \
|
|
buf_out += 5;
|
|
|
|
#define ENCODE_SEQUENCE(buf_out, seq) \
|
|
buf_out[0] = 0x24U; \
|
|
buf_out[1] = (seq >> 24) & 0xFFU; \
|
|
buf_out[2] = (seq >> 16) & 0xFFU; \
|
|
buf_out[3] = (seq >> 8) & 0xFFU; \
|
|
buf_out[4] = seq & 0xFFU; \
|
|
buf_out += 5;
|
|
|
|
// sfNetworkID = UINT32 field 1 = 0x21
|
|
#define ENCODE_NETWORK_ID(buf_out, id) \
|
|
buf_out[0] = 0x21U; \
|
|
buf_out[1] = (id >> 24) & 0xFFU; \
|
|
buf_out[2] = (id >> 16) & 0xFFU; \
|
|
buf_out[3] = (id >> 8) & 0xFFU; \
|
|
buf_out[4] = id & 0xFFU; \
|
|
buf_out += 5;
|
|
|
|
#define ENCODE_FLS(buf_out, fls) \
|
|
buf_out[0] = 0x20U; \
|
|
buf_out[1] = 0x1AU; \
|
|
buf_out[2] = (fls >> 24) & 0xFFU; \
|
|
buf_out[3] = (fls >> 16) & 0xFFU; \
|
|
buf_out[4] = (fls >> 8) & 0xFFU; \
|
|
buf_out[5] = fls & 0xFFU; \
|
|
buf_out += 6;
|
|
|
|
#define ENCODE_LLS(buf_out, lls) \
|
|
buf_out[0] = 0x20U; \
|
|
buf_out[1] = 0x1BU; \
|
|
buf_out[2] = (lls >> 24) & 0xFFU; \
|
|
buf_out[3] = (lls >> 16) & 0xFFU; \
|
|
buf_out[4] = (lls >> 8) & 0xFFU; \
|
|
buf_out[5] = lls & 0xFFU; \
|
|
buf_out += 6;
|
|
|
|
#define ENCODE_DROPS(buf_out, drops, amt_type) \
|
|
buf_out[0] = 0x60U + amt_type; \
|
|
buf_out[1] = 0x40U + ((drops >> 56) & 0x3FU); \
|
|
buf_out[2] = (drops >> 48) & 0xFFU; \
|
|
buf_out[3] = (drops >> 40) & 0xFFU; \
|
|
buf_out[4] = (drops >> 32) & 0xFFU; \
|
|
buf_out[5] = (drops >> 24) & 0xFFU; \
|
|
buf_out[6] = (drops >> 16) & 0xFFU; \
|
|
buf_out[7] = (drops >> 8) & 0xFFU; \
|
|
buf_out[8] = drops & 0xFFU; \
|
|
buf_out += 9;
|
|
|
|
#define ENCODE_SIGNING_PUBKEY_EMPTY(buf_out) \
|
|
buf_out[0] = 0x73U; \
|
|
buf_out[1] = 0x00U; \
|
|
buf_out += 2;
|
|
|
|
#define ENCODE_ACCOUNT(buf_out, acc, acc_type) \
|
|
buf_out[0] = 0x80U + acc_type; \
|
|
buf_out[1] = 0x14U; \
|
|
for (int i = 0; i < 20; ++i) buf_out[2+i] = acc[i]; \
|
|
buf_out += 22;
|
|
|
|
#define PREPARE_PAYMENT_SIMPLE_SIZE 270U
|
|
|
|
int64_t hook(uint32_t reserved) {
|
|
_g(1, 1);
|
|
|
|
if (otxn_type() != ttPAYMENT)
|
|
return accept(0, 0, 0);
|
|
|
|
ASSERT(xport_reserve(1) == 1);
|
|
|
|
uint8_t dst[20];
|
|
int64_t dst_len = otxn_param(SBUF(dst), "DST", 3);
|
|
ASSERT(dst_len == 20);
|
|
|
|
uint8_t acc[20];
|
|
ASSERT(hook_account(SBUF(acc)) == 20);
|
|
|
|
uint32_t cls = (uint32_t)ledger_seq();
|
|
|
|
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
|
|
uint8_t* buf = tx;
|
|
|
|
ENCODE_TT(buf, ttPAYMENT);
|
|
ENCODE_NETWORK_ID(buf, 21337); // must precede Sequence (canonical order)
|
|
ENCODE_FLAGS(buf, tfCANONICAL);
|
|
ENCODE_SEQUENCE(buf, 0);
|
|
ENCODE_FLS(buf, cls + 1);
|
|
ENCODE_LLS(buf, cls + 5);
|
|
|
|
uint64_t drops = 1000000;
|
|
ENCODE_DROPS(buf, drops, amAMOUNT);
|
|
ENCODE_DROPS(buf, 10, amFEE);
|
|
|
|
ENCODE_SIGNING_PUBKEY_EMPTY(buf);
|
|
ENCODE_ACCOUNT(buf, acc, atACCOUNT);
|
|
ENCODE_ACCOUNT(buf, dst, atDESTINATION);
|
|
|
|
uint8_t hash[32];
|
|
int64_t xport_result = xport(SBUF(hash), (uint32_t)tx, buf - tx);
|
|
// xport should return EXPORT_FAILURE (-46), ASSERT will rollback
|
|
ASSERT(xport_result == 32);
|
|
|
|
return accept(0, 0, 0);
|
|
}
|
|
)[test.hook]"];
|
|
|
|
// Helper to build hook params with DST
|
|
static Json::Value
|
|
makeDstParams(AccountID const& dst)
|
|
{
|
|
Json::Value params(Json::arrayValue);
|
|
Json::Value param;
|
|
param[jss::HookParameter] = Json::Value(Json::objectValue);
|
|
param[jss::HookParameter][jss::HookParameterName] =
|
|
strHex(std::string("DST"));
|
|
param[jss::HookParameter][jss::HookParameterValue] = strHex(dst);
|
|
params.append(param);
|
|
return params;
|
|
}
|
|
|
|
void
|
|
testXportPayment(FeatureBitset features)
|
|
{
|
|
testcase("Xport Payment (hook emits ttEXPORT)");
|
|
|
|
using namespace jtx;
|
|
|
|
Env env{*this, exportTestConfig(), features};
|
|
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
Account const carol{"carol"};
|
|
|
|
env.fund(XRP(10000), alice, bob, carol);
|
|
env.close();
|
|
|
|
// Install xport hook on alice
|
|
env(ripple::test::jtx::hook(alice, {{hso(xport_wasm)}}, 0),
|
|
HSFEE,
|
|
ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
// Trigger hook with payment containing DST parameter
|
|
auto params = makeDstParams(carol.id());
|
|
|
|
env(pay(bob, alice, XRP(100)),
|
|
fee(XRP(1)),
|
|
json(jss::HookParameters, params),
|
|
ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
// xport() now emits a ttEXPORT via the emitted txn path.
|
|
// Verify the emitted directory has an entry.
|
|
auto const emittedDirKey = keylet::emittedDir();
|
|
BEAST_EXPECT(!dirIsEmpty(*env.current(), emittedDirKey));
|
|
}
|
|
|
|
void
|
|
testXportRejectsLocalNetworkID(FeatureBitset features)
|
|
{
|
|
testcase("Xport rejects export targeting local NetworkID");
|
|
|
|
using namespace jtx;
|
|
|
|
Env env{*this, exportTestConfig(), features};
|
|
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
Account const carol{"carol"};
|
|
|
|
env.fund(XRP(10000), alice, bob, carol);
|
|
env.close();
|
|
|
|
// Install hook that exports a tx with NetworkID=21337
|
|
env(ripple::test::jtx::hook(alice, {{hso(xport_self_target_wasm)}}, 0),
|
|
HSFEE,
|
|
ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
// Trigger: xport() should reject because exported tx's NetworkID
|
|
// matches the local network → EXPORT_FAILURE → hook rollback
|
|
auto params = makeDstParams(carol.id());
|
|
|
|
env(pay(bob, alice, XRP(100)),
|
|
fee(XRP(1)),
|
|
json(jss::HookParameters, params),
|
|
ter(tecHOOK_REJECTED));
|
|
env.close();
|
|
|
|
// Verify no emitted ttEXPORT was created
|
|
auto const emittedDirKey = keylet::emittedDir();
|
|
BEAST_EXPECT(dirIsEmpty(*env.current(), emittedDirKey));
|
|
}
|
|
|
|
void
|
|
testXportRejectsUnconfiguredNetworkID(FeatureBitset features)
|
|
{
|
|
testcase("Xport rejects export when NETWORK_ID is unconfigured");
|
|
|
|
using namespace jtx;
|
|
|
|
// Default NETWORK_ID=0
|
|
Env env{*this, envconfig(jtx::validator, ""), features};
|
|
|
|
Account const alice{"alice"};
|
|
Account const bob{"bob"};
|
|
Account const carol{"carol"};
|
|
|
|
env.fund(XRP(10000), alice, bob, carol);
|
|
env.close();
|
|
|
|
// Install the normal xport hook (no NetworkID in exported tx)
|
|
env(ripple::test::jtx::hook(alice, {{hso(xport_wasm)}}, 0),
|
|
HSFEE,
|
|
ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
// Trigger: xport() should reject because NETWORK_ID=0 and the
|
|
// exported tx has no sfNetworkID → can't verify it's cross-chain
|
|
auto params = makeDstParams(carol.id());
|
|
|
|
env(pay(bob, alice, XRP(100)),
|
|
fee(XRP(1)),
|
|
json(jss::HookParameters, params),
|
|
ter(tecHOOK_REJECTED));
|
|
env.close();
|
|
|
|
// Verify no emitted ttEXPORT was created
|
|
auto const emittedDirKey = keylet::emittedDir();
|
|
BEAST_EXPECT(dirIsEmpty(*env.current(), emittedDirKey));
|
|
}
|
|
|
|
void
|
|
testExportTxnOpenLedger(FeatureBitset features)
|
|
{
|
|
testcase("ttEXPORT succeeds on open ledger (provisional)");
|
|
|
|
using namespace jtx;
|
|
|
|
Env env{*this, exportTestConfig(), features};
|
|
|
|
Account const alice{"alice"};
|
|
Account const carol{"carol"};
|
|
|
|
env.fund(XRP(10000), alice, carol);
|
|
env.close();
|
|
|
|
auto const seq = env.current()->seq();
|
|
auto innerObj =
|
|
buildExportedPayment(alice.id(), carol.id(), seq + 1, seq + 5);
|
|
|
|
Json::Value jv;
|
|
jv[jss::TransactionType] = jss::Export;
|
|
jv[jss::Account] = alice.human();
|
|
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
|
|
|
|
env(jv, fee(XRP(1)), ter(tesSUCCESS));
|
|
env.close();
|
|
}
|
|
|
|
void
|
|
testShadowTicketLifecycle(FeatureBitset features)
|
|
{
|
|
testcase("Shadow ticket lifecycle");
|
|
|
|
using namespace jtx;
|
|
|
|
Env env{*this, exportTestConfig(), features};
|
|
|
|
Account const alice{"alice"};
|
|
Account const carol{"carol"};
|
|
|
|
env.fund(XRP(10000), alice, carol);
|
|
env.close();
|
|
|
|
auto const seq = env.current()->seq();
|
|
|
|
auto innerObj =
|
|
buildExportedPayment(alice.id(), carol.id(), seq + 1, seq + 5, 42);
|
|
|
|
Json::Value jv;
|
|
jv[jss::TransactionType] = jss::Export;
|
|
jv[jss::Account] = alice.human();
|
|
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
|
|
|
|
env(jv, fee(XRP(1)), ter(tesSUCCESS));
|
|
env.close();
|
|
|
|
// In single-node test env, no validator sigs → no quorum →
|
|
// shadow ticket not created (only created on quorum success).
|
|
// The export retries or expires without creating the ticket.
|
|
}
|
|
|
|
void
|
|
testCancelShadowTicketViaTxn(FeatureBitset features)
|
|
{
|
|
testcase("ttEXPORT cancels shadow ticket via sfCancelTicketSequence");
|
|
|
|
using namespace jtx;
|
|
|
|
Env env{*this, exportTestConfig(), features};
|
|
|
|
Account const alice{"alice"};
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
// Cancel non-existent ticket → tecNO_ENTRY
|
|
Json::Value jvCancel;
|
|
jvCancel[jss::TransactionType] = jss::Export;
|
|
jvCancel[jss::Account] = alice.human();
|
|
jvCancel[sfCancelTicketSequence.jsonName] = 42;
|
|
|
|
env(jvCancel, fee(XRP(1)), ter(tecNO_ENTRY));
|
|
env.close();
|
|
}
|
|
|
|
void
|
|
testExportRejectsNoTicketSequence(FeatureBitset features)
|
|
{
|
|
testcase("ttEXPORT rejects export without TicketSequence");
|
|
|
|
using namespace jtx;
|
|
|
|
Env env{*this, exportTestConfig(), features};
|
|
|
|
Account const alice{"alice"};
|
|
Account const carol{"carol"};
|
|
|
|
env.fund(XRP(10000), alice, carol);
|
|
env.close();
|
|
|
|
auto const seq = env.current()->seq();
|
|
auto innerObj = buildExportedPayment(
|
|
alice.id(), carol.id(), seq + 1, seq + 5, std::nullopt);
|
|
|
|
Json::Value jv;
|
|
jv[jss::TransactionType] = jss::Export;
|
|
jv[jss::Account] = alice.human();
|
|
jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
|
|
|
|
env(jv, fee(XRP(1)), ter(temMALFORMED));
|
|
env.close();
|
|
}
|
|
|
|
void
|
|
testExportRejectsMalformed(FeatureBitset features)
|
|
{
|
|
testcase("ttEXPORT rejects when neither export nor cancel present");
|
|
|
|
using namespace jtx;
|
|
|
|
Env env{*this, exportTestConfig(), features};
|
|
|
|
Account const alice{"alice"};
|
|
env.fund(XRP(10000), alice);
|
|
env.close();
|
|
|
|
Json::Value jv;
|
|
jv[jss::TransactionType] = jss::Export;
|
|
jv[jss::Account] = alice.human();
|
|
|
|
env(jv, fee(XRP(1)), ter(temMALFORMED));
|
|
env.close();
|
|
}
|
|
|
|
void
|
|
testExportImportRoundTrip(FeatureBitset features)
|
|
{
|
|
testcase("Export → XPOP → Import round-trip");
|
|
|
|
using namespace jtx;
|
|
|
|
// The export round-trip is a 3-way handshake:
|
|
// 1. Xahau: ttEXPORT → validators sign the inner tx →
|
|
// shadow ticket + multisigned blob in metadata
|
|
// 2. XRPL: submit the multisigned blob raw (alice's
|
|
// SignerList on XRPL contains the Xahau validator keys)
|
|
// 3. Xahau: build XPOP from execution, import it back →
|
|
// shadow ticket consumed
|
|
//
|
|
// In standalone mode, Export::doApply signs directly with
|
|
// the node's validator keys — no consensus needed.
|
|
|
|
auto const xpopCtx = xpop::TestXPOPContext::create(3);
|
|
|
|
Account const alice{"alice"};
|
|
Account const carol{"carol"};
|
|
|
|
// ── Xahau env: export the inner tx ─────────────────────────────
|
|
Env xahau{*this, xpopCtx.makeEnvConfig(21337), features};
|
|
|
|
xahau.fund(XRP(10000), alice, carol);
|
|
xahau.close();
|
|
|
|
// Burn XRP so B2M crediting works on import.
|
|
auto const master = Account("masterpassphrase");
|
|
xahau(noop(master), fee(10'000'000'000), ter(tesSUCCESS));
|
|
xahau.close();
|
|
|
|
// The inner tx is a payment from alice→carol on XRPL, using
|
|
// TicketSequence (required for exports — avoids sequence jams
|
|
// if the tx bounces on XRPL).
|
|
//
|
|
// OperationLimit tells Import::preflight which network this
|
|
// tx targets (must match Xahau's NETWORK_ID).
|
|
//
|
|
// We'll create the matching ticket on the XRPL side later.
|
|
std::uint32_t const ticketSeq = 2; // alice's first ticket on XRPL
|
|
|
|
// Build the inner tx. Use wide ledger sequence bounds so
|
|
// it's valid regardless of how many ledgers the XRPL env
|
|
// needs for setup. Fee must cover multisign overhead:
|
|
// (1 + signerCount) * baseFee = (1+1)*10 = 20.
|
|
STObject innerObj(sfExportedTxn);
|
|
innerObj.setFieldU16(sfTransactionType, ttPAYMENT);
|
|
innerObj.setFieldU32(sfFlags, tfFullyCanonicalSig);
|
|
innerObj.setFieldU32(sfSequence, 0);
|
|
innerObj.setFieldU32(sfTicketSequence, ticketSeq);
|
|
innerObj.setFieldU32(sfLastLedgerSequence, 100);
|
|
innerObj.setFieldAmount(sfAmount, XRPAmount{1000000});
|
|
innerObj.setFieldAmount(sfFee, XRPAmount{20});
|
|
innerObj.setFieldVL(sfSigningPubKey, Blob{});
|
|
innerObj.setAccountID(sfAccount, alice.id());
|
|
innerObj.setAccountID(sfDestination, carol.id());
|
|
// Note: no sfOperationLimit needed — the export callback path
|
|
// (sfTicketSequence present) skips the OperationLimit check
|
|
// in Import::preflight. OperationLimit is only required for
|
|
// the B2M (burn-to-mint) import path.
|
|
|
|
// Submit ttEXPORT — in standalone mode, Export::doApply
|
|
// signs the inner tx with the node's validator keys and
|
|
// puts the multisigned blob in sfExportResult metadata.
|
|
Json::Value jvExport;
|
|
jvExport[jss::TransactionType] = jss::Export;
|
|
jvExport[jss::Account] = alice.human();
|
|
jvExport[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none);
|
|
|
|
xahau(jvExport, fee(XRP(1)), ter(tesSUCCESS));
|
|
xahau.close();
|
|
|
|
// Extract the multisigned blob from the Export metadata.
|
|
auto const exportMeta = xahau.meta();
|
|
BEAST_EXPECT(exportMeta);
|
|
|
|
Blob multisignedBlob;
|
|
if (exportMeta && exportMeta->isFieldPresent(sfExportResult))
|
|
{
|
|
auto const& result =
|
|
exportMeta->peekAtField(sfExportResult).downcast<STObject>();
|
|
if (result.isFieldPresent(sfExportedTxn))
|
|
{
|
|
// Serialize the nested object to get the raw blob
|
|
// for submission to XRPL.
|
|
auto const& expTxn =
|
|
const_cast<STObject&>(result).peekFieldObject(
|
|
sfExportedTxn);
|
|
Serializer s;
|
|
expTxn.add(s);
|
|
multisignedBlob = s.peekData();
|
|
|
|
log << "Xahau: ExportResult.ExportedTxn = "
|
|
<< expTxn.getJson(JsonOptions::none).toStyledString()
|
|
<< std::endl;
|
|
}
|
|
}
|
|
log << "Xahau: multisigned blob size = " << multisignedBlob.size()
|
|
<< std::endl;
|
|
BEAST_EXPECT(!multisignedBlob.empty());
|
|
|
|
// Verify shadow ticket was created.
|
|
auto const stKey = keylet::shadowTicket(alice.id(), ticketSeq);
|
|
BEAST_EXPECT(xahau.current()->exists(stKey));
|
|
|
|
// ── XRPL env: submit the multisigned blob ──────────────────────
|
|
Env xrpl{*this};
|
|
|
|
xrpl.fund(XRP(10000), alice, carol);
|
|
xrpl.close();
|
|
|
|
// Create a ticket on XRPL for the exported tx.
|
|
std::uint32_t const xrplTicketSeq = xrpl.seq(alice) + 1;
|
|
xrpl(ticket::create(alice, 1));
|
|
xrpl.close();
|
|
BEAST_EXPECT(xrplTicketSeq == ticketSeq);
|
|
|
|
// Set alice's SignerList on XRPL to the Xahau validator key.
|
|
// The validator key is a "phantom signer" — it doesn't need
|
|
// a funded account on XRPL, just an entry in alice's SignerList.
|
|
auto const valPK = xahau.app().getValidationPublicKey();
|
|
auto const valSK = xahau.app().getValidationSecretKey();
|
|
BEAST_EXPECT(valPK.has_value());
|
|
|
|
auto const valAccountID = calcAccountID(*valPK);
|
|
log << "XRPL: validator AccountID = " << toBase58(valAccountID)
|
|
<< std::endl;
|
|
log << "XRPL: validator PK = " << strHex(*valPK) << std::endl;
|
|
|
|
// Use the jtx signers() helper with a phantom Account whose
|
|
// AccountID matches the validator key.
|
|
// We can't use signers() directly because it takes Account
|
|
// objects. Build the JSON manually instead.
|
|
{
|
|
Json::Value jvSigners;
|
|
jvSigners[jss::TransactionType] = jss::SignerListSet;
|
|
jvSigners[jss::Account] = alice.human();
|
|
jvSigners[sfSignerQuorum.jsonName] = 1;
|
|
|
|
Json::Value signerEntry(Json::objectValue);
|
|
signerEntry[sfAccount.jsonName] = toBase58(valAccountID);
|
|
signerEntry[sfSignerWeight.jsonName] = 1;
|
|
|
|
Json::Value entryWrapper(Json::objectValue);
|
|
entryWrapper[sfSignerEntry.jsonName] = signerEntry;
|
|
|
|
Json::Value entries(Json::arrayValue);
|
|
entries.append(entryWrapper);
|
|
jvSigners[sfSignerEntries.jsonName] = entries;
|
|
|
|
log << "XRPL: SignerListSet JSON = " << jvSigners.toStyledString()
|
|
<< std::endl;
|
|
|
|
xrpl(jvSigners, fee(XRP(1)), ter(tesSUCCESS));
|
|
log << "XRPL: SignerListSet result = " << transToken(xrpl.ter())
|
|
<< std::endl;
|
|
xrpl.close();
|
|
}
|
|
|
|
// Smoke test: submit a simple multisigned noop to verify
|
|
// the SignerList works before trying the exported payment.
|
|
{
|
|
// Build a noop AccountSet, multisigned by the validator.
|
|
STObject noop(sfGeneric);
|
|
noop.setFieldU16(sfTransactionType, ttACCOUNT_SET);
|
|
noop.setFieldU32(sfFlags, tfFullyCanonicalSig);
|
|
noop.setFieldU32(sfSequence, xrpl.seq(alice));
|
|
noop.setFieldAmount(sfFee, XRPAmount{20}); // (1+1)*base
|
|
noop.setFieldVL(sfSigningPubKey, Blob{});
|
|
noop.setAccountID(sfAccount, alice.id());
|
|
|
|
auto const sigData = buildMultiSigningData(noop, valAccountID);
|
|
auto const sig = ripple::sign(*valPK, valSK, sigData.slice());
|
|
|
|
STArray signers(sfSigners);
|
|
STObject signer(sfSigner);
|
|
signer.setAccountID(sfAccount, valAccountID);
|
|
signer.setFieldVL(sfSigningPubKey, valPK->slice());
|
|
signer.setFieldVL(sfTxnSignature, sig);
|
|
signers.push_back(std::move(signer));
|
|
|
|
noop.setFieldArray(sfSigners, signers);
|
|
|
|
Serializer ser;
|
|
noop.add(ser);
|
|
auto const jr = xrpl.rpc("submit", strHex(ser.slice()));
|
|
log << "XRPL: smoke test noop result = "
|
|
<< jr[jss::result][jss::engine_result].asString() << std::endl;
|
|
xrpl.close();
|
|
}
|
|
|
|
// Now submit the actual multisigned export blob.
|
|
{
|
|
auto const jr =
|
|
xrpl.rpc("submit", strHex(makeSlice(multisignedBlob)));
|
|
log << "XRPL: export blob submit result = "
|
|
<< jr[jss::result][jss::engine_result].asString() << std::endl;
|
|
if (jr[jss::result][jss::engine_result].asString() != "tesSUCCESS")
|
|
{
|
|
log << "XRPL: full result = " << jr.toStyledString()
|
|
<< std::endl;
|
|
}
|
|
}
|
|
xrpl.close();
|
|
|
|
// Build XPOP from the XRPL ledger.
|
|
auto const xrplLcl = xrpl.app().getLedgerMaster().getClosedLedger();
|
|
BEAST_EXPECT(xrplLcl);
|
|
|
|
uint256 xrplMapKey;
|
|
xrplLcl->txMap().visitLeaves(
|
|
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
|
|
xrplMapKey = item->key();
|
|
});
|
|
|
|
auto const xpopJson = xpopCtx.buildXPOP(*xrplLcl, xrplMapKey);
|
|
log << "XPOP null? " << xpopJson.isNull() << std::endl;
|
|
BEAST_EXPECT(!xpopJson.isNull());
|
|
|
|
// ── Back to Xahau: import the XPOP ────────────────────────────
|
|
auto const feeDrops = xahau.current()->fees().base;
|
|
|
|
xahau(
|
|
import::import(alice, xpopJson),
|
|
fee(feeDrops * 10),
|
|
ter(tesSUCCESS));
|
|
xahau.close();
|
|
|
|
// Shadow ticket should be consumed after import.
|
|
BEAST_EXPECT(!xahau.current()->exists(stKey));
|
|
}
|
|
|
|
void
|
|
run() override
|
|
{
|
|
using namespace test::jtx;
|
|
FeatureBitset const all{supported_amendments()};
|
|
FeatureBitset const allWithExport{all | featureExport};
|
|
|
|
// Hook-based xport tests
|
|
testXportPayment(allWithExport);
|
|
testXportRejectsLocalNetworkID(allWithExport);
|
|
testXportRejectsUnconfiguredNetworkID(allWithExport);
|
|
|
|
// ttEXPORT transactor tests
|
|
testExportTxnOpenLedger(allWithExport);
|
|
testShadowTicketLifecycle(allWithExport);
|
|
testCancelShadowTicketViaTxn(allWithExport);
|
|
testExportRejectsNoTicketSequence(allWithExport);
|
|
testExportRejectsMalformed(allWithExport);
|
|
|
|
// Round-trip test
|
|
testExportImportRoundTrip(allWithExport);
|
|
}
|
|
};
|
|
|
|
BEAST_DEFINE_TESTSUITE(Export, app, ripple);
|
|
|
|
} // namespace test
|
|
} // namespace ripple
|