Learning Xahau: PriceOracle and IOURewardClaim, On-Chain Prices and Custom Reward Programmes

If you’ve been following this series, you already know that Xahau is more than a ledger, it’s a programmable settlement layer. We’ve explored, and built several things during this series.

The new 2026.06.21 xahaud release shipped two amendments that open up entirely new design space for DeFi builders on Xahau:

  • PriceOracle — brings the XLS-47d on-chain price feed standard to Xahau
  • IOURewardClaim — extends the ClaimReward transaction to IOU currencies, enabling any account to run a custom token reward programme

All code in this article targets the Xahau Testnet (wss://xahau-test.net) and requires xahau.js 4.1.0 or later.

Clone the companion repository and install dependencies:

git clone https://github.com/Ekiserrepe/learningxahau20260621.git
cd learningxahau20260621
npm install

Copy .env.example to .env and fill in your testnet seeds:

ISSUER_SEED=your_issuer_seed_here
RESERVE_SEED=your_reserve_seed_here
HOLDER_SEED=your_holder_seed_here
ORACLE_SEED=your_holder_seed_here

Get funded testnet accounts at xahau-test.net.

PriceOracle: On-Chain Price Feeds

The Problem It Solves

DeFi contracts need prices. The naive approach (hard-coding a value or trusting a single off-chain feed) is fragile: stale data or a compromised source can break the whole system. The standard solution is an oracle network: multiple independent providers publish prices on-chain, and consumers aggregate them, discarding outliers.

The PriceOracle amendment ports the XLS-47d standard to Xahau. Any account can become an oracle provider by submitting an OracleSet transaction. Consumers query prices via a single get_aggregate_price RPC call that aggregates across as many providers as they list, with built-in outlier trimming.

Key Concepts

Concept Description
OracleDocumentID A uint32 that uniquely identifies one oracle object per account. One account can run multiple oracles (different IDs).
PriceDataSeries Array of { BaseAsset, QuoteAsset, AssetPrice, Scale } entries. One oracle can publish up to 10 trading pairs simultaneously.
Scale Decimal exponent for the price. AssetPrice: 450000, Scale: 6 means 0.45.
LastUpdateTime Unix timestamp of the price data. Must be updated each time the price changes.
Provider Hex-encoded UTF-8 string identifying the data source (e.g. your service name).
AssetClass Hex-encoded UTF-8 string classifying the asset type (e.g. "currency", "commodity").

Multiple Oracle Objects per Account

A single account is not limited to one oracle object. By using different OracleDocumentID values you can publish separate feeds, for example, one object for currency pairs and another for commodity prices, each with its own Provider label, AssetClass, and PriceDataSeries. Each oracle object costs XAH in reserve, so the practical limit is determined by your account’s available balance.

Each oracle object can hold up to 10 price pairs in its PriceDataSeries array. If you need more pairs than that, split them across additional oracle objects with different OracleDocumentID values on the same account.

Publishing a Price (01-price-oracle-set.js)

Any account can publish an oracle object by submitting an OracleSet transaction:

require('dotenv').config()
const { Client, Wallet } = require('xahau')

async function setOraclePrice() {
  const client = new Client('wss://xahau-test.net')
  await client.connect()

  // Set ORACLE_SEED in your .env file. The signing account becomes the
  // oracle provider on the ledger. Get funds at https://xahau-test.net/
  const wallet = Wallet.fromSeed(process.env.ORACLE_SEED, { algorithm: 'secp256k1' })
  console.log(`Oracle account: ${wallet.address}`)

  // OracleSet creates or updates a price feed object on the ledger.
  // Multiple OracleSet transactions from different accounts can be aggregated
  // by consumers using the get_aggregate_price RPC (see 08-price-oracle-query.js).
  const oracleSetTx = await client.autofill({
    TransactionType: 'OracleSet',
    Account: wallet.address,
    OracleDocumentID: 1,  // unique ID per oracle; allows multiple feeds per account

    // Provider and AssetClass must be hex-encoded UTF-8 strings
    Provider:   Buffer.from('MyOracle',  'utf8').toString('hex'),
    AssetClass: Buffer.from('currency', 'utf8').toString('hex'),

    // Ripple-epoch timestamp of the price data (seconds since 2000-01-01)
    LastUpdateTime: Math.floor(Date.now() / 1000),

    // Each entry in PriceDataSeries represents one trading pair.
    // AssetPrice is an integer; divide by 10^Scale to get the real price.
    PriceDataSeries: [
      {
        PriceData: {
          BaseAsset:  'XAH',
          QuoteAsset: 'USD',
          AssetPrice: 450000,
          Scale: 6           // actual price = 450000 × 10⁻⁶ = 0.45 USD
        }
      }
    ]
  })

  const { tx_blob } = wallet.sign(oracleSetTx)
  const result = await client.submitAndWait(tx_blob)
  console.log(`OracleSet: ${result.result.meta.TransactionResult}`)
  console.log(`Oracle object: https://xaman.app/explorer/21338/${result.result.hash}`)

  await client.disconnect()
}

setOraclePrice().catch(console.error)

This is what you should get:

Oracle account: r35gjkjZL4mhqyrabpxVUE9K9T5JW1nng9
OracleSet: tesSUCCESS
Oracle object: https://xaman.app/explorer/21338/5F312C3B5CB2F2D3745D8CE4F4A0EFFFD7770C0C51401DDB52256D28DA019527

To update the price, submit another OracleSet with the same OracleDocumentID and a new LastUpdateTime. Only the fields you include are changed — any price pairs you omit are preserved from the previous submission.

To add a second trading pair to the same oracle object, include both entries in PriceDataSeries:

PriceDataSeries: [
  { PriceData: { BaseAsset: 'XAH', QuoteAsset: 'USD', AssetPrice: 450000, Scale: 6 } },
  { PriceData: { BaseAsset: 'XAH', QuoteAsset: 'EUR', AssetPrice: 410000, Scale: 6 } }
]

Querying the Aggregate Price (02-price-oracle-query.js)

Once multiple oracle accounts have published prices for the same pair, any application can request the on-chain aggregate via the get_aggregate_price RPC. This is a read-only query — no transaction fee required:

require('dotenv').config()
const { Client } = require('xahau')

// Oracle accounts to include in the aggregate calculation.
// Each entry must identify the oracle account and the document ID
// of the price feed published by that account (via OracleSet).
// Add or remove entries to include different oracle providers.
const ORACLES = [
  { account: 'rEhZSNh9pVRTcA79tQjYezg9V44HfcToR1', oracle_document_id: 1 },
  { account: 'rD1rh9ffewxVb9QBqkr5ph98QXqCM1xsEP', oracle_document_id: 1 },
  { account: 'r35gjkjZL4mhqyrabpxVUE9K9T5JW1nng9', oracle_document_id: 1 },
]

async function getAggregatePrice() {
  const client = new Client('wss://xahau-test.net')
  await client.connect()

  // get_aggregate_price is a Xahau ledger RPC that reads all listed oracle
  // objects and computes statistical aggregates over their reported prices.
  // No transaction or fee is required — this is a read-only query.
  const response = await client.request({
    command: 'get_aggregate_price',
    ledger_index: 'current',
    base_asset:   'XAH',
    quote_asset:  'USD',
    trim: 20,   // remove the bottom and top 20% of values before averaging
                // to reduce the impact of outlier oracles
    oracles: ORACLES
  })

  const stats = response.result

  // entire_set covers all oracle values before trimming
  // trimmed_set covers only the values remaining after the trim
  console.log('Median:       ', stats.median)
  console.log('Mean:         ', stats.entire_set?.mean)
  console.log('Trimmed mean: ', stats.trimmed_set?.mean)
  console.log('Oracle count: ', stats.entire_set?.size)

  await client.disconnect()
}

getAggregatePrice().catch(console.error)

You should get something like this:

Median:        0.45
Mean:          0.4333333333333334
Trimmed mean:  0.4333333333333334
Oracle count:  3

A few things to know about get_aggregate_price:

  • You can include up to 200 oracle accounts in the oracles array per request.
  • The trim parameter removes outliers symmetrically before computing the mean. trim: 20 discards the bottom 20 % and top 20 % of values — useful when you expect a minority of providers to be stale or adversarial. The untrimmed mean and median are always included in the response alongside the trimmed result.
  • The optional time_threshold field (not shown above) lets you exclude feeds whose LastUpdateTime is older than N seconds relative to the freshest oracle in the set. For example, time_threshold: 60 drops any oracle that hasn’t updated in the last minute compared to the most recent provider. In a production environment where stale prices carry real risk, this is worth setting.

Removing an Oracle (03-price-oracle-delete.js)

To remove an oracle object from the ledger and recover its XAH reserve, submit an OracleDelete transaction:

require('dotenv').config()
const { Client, Wallet } = require('xahau')

// Must match the OracleDocumentID used when the oracle was created
// (via 01-price-oracle-set.js). Deleting removes the oracle object from
// the ledger entirely and releases the reserve XAH locked by it.
const ORACLE_DOCUMENT_ID = 1

async function deleteOracle() {
  const client = new Client('wss://xahau-test.net')
  await client.connect()

  // Only the account that originally submitted OracleSet can delete it.
  // Set ORACLE_SEED in your .env file.
  const wallet = Wallet.fromSeed(process.env.ORACLE_SEED, { algorithm: 'secp256k1' })
  console.log(`Account:          ${wallet.address}`)
  console.log(`OracleDocumentID: ${ORACLE_DOCUMENT_ID}`)

  // OracleDelete removes the oracle price feed object from the ledger.
  // After deletion, this account's prices will no longer appear in
  // get_aggregate_price responses that list this account.
  const oracleDeleteTx = await client.autofill({
    TransactionType: 'OracleDelete',
    Account: wallet.address,
    OracleDocumentID: ORACLE_DOCUMENT_ID
  })

  const { tx_blob } = wallet.sign(oracleDeleteTx)
  const result = await client.submitAndWait(tx_blob)
  console.log(`OracleDelete: ${result.result.meta.TransactionResult}`)
  console.log(`Explorer: https://xaman.app/explorer/21338/${result.result.hash}`)

  await client.disconnect()
}

deleteOracle().catch(console.error)

Only the account that created the oracle can delete it. After deletion, its prices will no longer appear in get_aggregate_price responses that list this account.

IOURewardClaim: Custom Token Reward Programmes

What Is ClaimReward?

Xahau has a built-in reward mechanism for network participation. Accounts that hold XAH can periodically submit a ClaimReward transaction to receive genesis rewards, a fraction of newly generated XAH, distributed based on how long the account has been staking and participating. This is Xahau’s native yield for XAH holders.

Until the IOURewardClaim amendment, this mechanism was exclusive to XAH. If you issued a custom token, a loyalty point, a yield-bearing asset, a staking receipt, there was no native way to tie rewards to it. You’d have to build your own off-chain tracker or use periodic manual payments.

What IOURewardClaim Adds

The IOURewardClaim amendment extends ClaimReward with two new fields:

  • Issuer — the address of the account running the reward programme
  • ClaimCurrency — the IOU currency object { currency, issuer } that identifies which reward counter to claim from

This is the full transaction structure:

{
  "TransactionType": "ClaimReward",
  "Account": "rHOLDER...",
  "Issuer": "rRESERVE...",
  "ClaimCurrency": {
    "currency": "RWD",
    "issuer": "rISSUER..."
  }
}

Important: The first Issuer field in this transaction does not refer to the account that created the IOU token. It refers to the account from which you are requesting the reward, the account whose Hook (the reward program) will be triggered. That account can be any Xahau account, completely independent from the token creator. The only requirement is that it has a Hook installed that activates on ttClaimReward transactions. In our setup this is the RESERVE account, which holds the token supply and has the reward Hook installed — but in another programme design it could be a DAO treasury, a smart vault, or any account with the appropriate Hook.

When both fields are present, the ledger looks for a Hook installed on the Issuer account and fires it. The Hook defines all the reward logic: how much to pay, how often, to whom, and under what conditions. This means any account can run its own reward programme for any IOU, all settled natively on-chain via trustlines.

The Three Accounts

This setup involves three distinct accounts, each with a specific role:

Account Seed in .env Role
ISSUER ISSUER_SEED Creates and signs the IOU token. Enables DefaultRipple so the currency can flow freely through trustlines.
RESERVE RESERVE_SEED Holds the token supply received from the issuer. Has the reward Hook installed. Is the Issuer in ClaimReward transactions.
HOLDER HOLDER_SEED Any account that wants to claim a reward. Submits the ClaimReward transaction.

The separation between ISSUER and RESERVE keeps the token signing key isolated from the operational reward-distribution account. The ISSUER signs the initial token creation; after that, the RESERVE handles all day-to-day reward operations.

Step 1 — Create and Distribute the Token (04-create-distribute-token.js)

Before any reward can be paid out, the token must exist on the ledger and the RESERVE account must hold a supply of it. The script 04-create-distribute-token.js does this in three steps.

Before you run it, open the file and set your token details:

const TOKEN_CURRENCY_INPUT = "RWD"; // your 3-char code or a longer name
const TOTAL_SUPPLY         = "1000000";

If your currency name is 3 characters or fewer, Xahau uses it directly (e.g. "RWD"). If it is longer, the script converts it to a 40-character hex representation padded with zeros, the format Xahau uses internally for non-standard currency codes.

require("dotenv").config();
const { Client, Wallet } = require("xahau");

// Required .env variables:
//   ISSUER_SEED  – seed of the account that will create and sign IOU tokens
//   RESERVE_SEED – seed of the account that will hold the supply and distribute rewards
// Get testnet funds at https://xahau-test.net

// Converts a human-readable currency name to the 40-char hex format required
// by Xahau for non-standard (>3 char) currency codes.
// Standard 3-char codes (e.g. "RWD") are returned as-is.
function normalizeCurrency(token_currency) {
  if (typeof token_currency !== "string") return token_currency;

  const cur = token_currency.trim();

  // Standard 3-char currency codes need no conversion
  if (cur.length <= 3) return cur;

  // Convert UTF-8 string to uppercase hex and right-pad to 40 chars (20 bytes)
  const hex = Buffer.from(cur, "utf8").toString("hex").toUpperCase();

  if (hex.length > 40)
    throw new Error(
      `Currency too long: "${cur}" → hex ${hex.length} chars (max 40)`
    );

  return hex.padEnd(40, "0");
}

async function createAndDistributeToken() {
  const client = new Client("wss://xahau-test.net");
  await client.connect();

  // Load both wallets from environment seeds
  const issuer  = Wallet.fromSeed(process.env.ISSUER_SEED,  { algorithm: "secp256k1" });
  const reserve = Wallet.fromSeed(process.env.RESERVE_SEED, { algorithm: "secp256k1" });

  // ── Token configuration ────────────────────────────────────────────────
  // Change TOKEN_CURRENCY_INPUT to your desired currency code or name.
  // Change TOTAL_SUPPLY to the amount you want to mint.
  const TOKEN_CURRENCY_INPUT = "YourTokenName"; // 3-char code or longer name
  const TOTAL_SUPPLY         = "1000000";

  const TOKEN_CURRENCY = normalizeCurrency(TOKEN_CURRENCY_INPUT);

  console.log("=== Token creation ===");
  console.log("Issuer:", issuer.address);
  console.log("Reserve:", reserve.address);
  console.log("Token:", TOKEN_CURRENCY);
  console.log("Supply:", TOTAL_SUPPLY);

  // ── Step 1: Enable DefaultRipple on the issuer ─────────────────────────
  // DefaultRipple (asfDefaultRipple, flag 8) allows the issuer's trustlines
  // to be used for rippling, which is required for IOU tokens to flow
  // between accounts that both trust the same issuer.
  console.log("--- Step 1: DefaultRipple ---");
  const prep1 = await client.autofill({
    TransactionType: "AccountSet",
    Account: issuer.address,
    SetFlag: 8, // asfDefaultRipple
  });
  const result1 = await client.submitAndWait(issuer.sign(prep1).tx_blob);
  console.log("DefaultRipple:", result1.result.meta.TransactionResult);
  if (result1.result.meta.TransactionResult !== "tesSUCCESS") {
    await client.disconnect();
    return;
  }

  // ── Step 2: Reserve account opens a TrustLine toward the issuer ────────
  // A TrustLine must exist before any tokens can be sent. The reserve account
  // signals it is willing to hold up to TOTAL_SUPPLY units of TOKEN_CURRENCY
  // issued by the issuer account.
  console.log("--- Step 2: TrustLine (reserve → issuer) ---");
  const prep2 = await client.autofill({
    TransactionType: "TrustSet",
    Account: reserve.address,
    LimitAmount: {
      currency: TOKEN_CURRENCY,
      issuer: issuer.address,
      value: TOTAL_SUPPLY, // maximum amount the reserve is willing to hold
    },
  });
  const result2 = await client.submitAndWait(reserve.sign(prep2).tx_blob);
  console.log("TrustLine:", result2.result.meta.TransactionResult);
  if (result2.result.meta.TransactionResult !== "tesSUCCESS") {
    await client.disconnect();
    return;
  }

  // ── Step 3: Issuer mints and sends the full supply to the reserve ───────
  // Issuing tokens means sending them from the issuer account to a
  // trustline holder. The issuer never "holds" its own tokens — sending
  // them to the reserve creates them on the ledger for the first time.
  console.log("--- Step 3: Issue tokens (issuer → reserve) ---");
  const prep3 = await client.autofill({
    TransactionType: "Payment",
    Account: issuer.address,
    Destination: reserve.address,
    Amount: {
      currency: TOKEN_CURRENCY,
      issuer: issuer.address,
      value: TOTAL_SUPPLY,
    },
  });
  const result3 = await client.submitAndWait(issuer.sign(prep3).tx_blob);
  console.log("Issuance:", result3.result.meta.TransactionResult);
  if (result3.result.meta.TransactionResult !== "tesSUCCESS") {
    await client.disconnect();
    return;
  }

  console.log("Token created and distributed to the reserve account!");
  console.log("Total supply:", TOTAL_SUPPLY, TOKEN_CURRENCY);

  // ── Verification: query the reserve's trustline balance ────────────────
  // Confirms the tokens arrived correctly by reading account_lines for the
  // reserve and finding the line that matches our currency and issuer.
  console.log("--- Verification ---");
  const lines = await client.request({
    command: "account_lines",
    account: reserve.address,
    ledger_index: "validated",
  });
  const line = lines.result.lines.find(
    (l) => l.currency === TOKEN_CURRENCY && l.account === issuer.address
  );
  if (line) {
    console.log("Reserve balance:", line.balance, TOKEN_CURRENCY);
    console.log("Issuer:", line.account);
    console.log("Limit:", line.limit, TOKEN_CURRENCY);
  }

  await client.disconnect();
}

createAndDistributeToken();

Three concepts worth understanding here:

DefaultRipple (SetFlag: 8) is an account setting that allows the issuer’s trustlines to be used for rippling, passing IOU balances through the issuer from one trustline holder to another. Without it, the RESERVE cannot forward tokens to the HOLDER later. This is a one-time setup on the ISSUER account.

TrustLine is a bilateral agreement on the ledger between two accounts for a specific currency. The RESERVE creates a TrustLine toward the ISSUER saying “I am willing to hold up to 1,000,000 RWD issued by this account.” Until this line exists, the ISSUER cannot send tokens to the RESERVE.

Issuing tokens on Xahau works by sending them from the issuer account outward. The ISSUER never “holds” its own tokens, sending them to the RESERVE creates them on the ledger for the first time.

Step 2 — The Reward Hook (iou_reward_hook.c)

If you’re new to Hooks, here’s the short version: a Hook is a small WebAssembly program installed on a Xahau account that executes automatically when that account is involved in a transaction. The Hook can accept the transaction, reject it, or, most powerfully, emit new transactions of its own. Think of it as a smart contract that lives on an account rather than at a separate address.

For our reward programme, we install a Hook on the RESERVE account (To simplify the process, I recommend using a separate account for the rewards program on Mainnet). Every time a HOLDER submits a ClaimReward transaction targeting RESERVE, the Hook fires and automatically emits a payment of 1 token back to the claimant, provided all conditions are met.

The Hook (iou_reward_hook.c) receives three configuration parameters set at install time:

Parameter Key Size Description
Currency ticker TC 3 bytes The ASCII currency code, e.g. "RWD"
Issuer AccountID IS 20 bytes The raw 20-byte AccountID of the ISSUER account
Cooldown CD 4 bytes Seconds between claims (big-endian uint32)

The Hook logic runs in this order on every ClaimReward:

  1. Filter — if the incoming transaction is not a ClaimReward, accept silently and do nothing.
  2. Read claimant — get the Account field from the transaction (the HOLDER submitting the claim).
  3. Load parameters — read TC, IS, and CD from the hook parameters.
  4. Cooldown check — look up the RippleState (trustline) object between the claimant and the issuer and read the sfLowReward / sfHighReward sub-object to find the timestamp of the last reward claim. If a claim was made within the cooldown window, rollback with an error.
  5. Balance check — look up the RESERVE’s trustline to ISSUER and verify it holds at least 1 token. If not, rollback.
  6. Emit payment — serialize 1 token as an IOU STAmount and emit a Payment transaction to the claimant.
#include "hookapi.h"

/*
 * Field definitions for IOURewardClaim fields that may not yet be present
 * in the version of hookapi.h shipped with this hooks-builder release.
 * The guard (#ifndef) ensures they are only defined if missing.
 *
 * sfRewardTime  – UInt32 (type 2, field 98): Ripple-epoch timestamp of the
 *                 last reward claim stored inside the RippleState object.
 * sfHighReward  – STObject (type 14, field 98): reward tracking sub-object
 *                 on the high-balance side of a trustline.
 * sfLowReward   – STObject (type 14, field 99): reward tracking sub-object
 *                 on the low-balance side of a trustline.
 */
#ifndef sfRewardTime
#define sfRewardTime ((2U << 16U) + 98U)
#endif

#ifndef sfHighReward
#define sfHighReward ((14U << 16U) + 98U)
#endif

#ifndef sfLowReward
#define sfLowReward ((14U << 16U) + 99U)
#endif

/*
 * Hook entry point.
 *
 * This hook is installed on the RESERVE account (the one that holds
 * the IOU token supply). It fires on every ClaimReward transaction
 * that targets this account as Issuer, validates a cooldown period
 * and a sufficient token balance, then emits 1 token to the claimant.
 *
 * Hook parameters (set at install time via 12-install-hook.js):
 *   TC (2 bytes)  – 3-char currency ticker, e.g. "RWD"
 *   IS (20 bytes) – AccountID of the IOU issuer (the token creator)
 *   CD (4 bytes)  – big-endian uint32 cooldown in seconds between claims
 */
int64_t hook(uint32_t reserved)
{
    /* Guard call — must be the very first statement.
       The hook guard checker rejects any hook where a loop is reachable
       before _g() / GUARD() has been called at least once.             */
    _g(1, 1);

    /* ── 1. FILTER: only act on ClaimReward transactions ────────────── */
    if (otxn_type() != ttCLAIM_REWARD)
        accept(SBUF("DBG: not ClaimReward"), __LINE__);

    /* ── 2. READ THE CLAIMANT ACCOUNT ────────────────────────────────── */
    /* sfAccount is the originating account of the ClaimReward tx —
       the holder who wants to receive the IOU reward.                   */
    uint8_t claimant[20];
    if (otxn_field(SBUF(claimant), sfAccount) != 20)
        rollback(SBUF("ERR: missing claimant"), __LINE__);

    /* ── 3. READ HOOK PARAMETERS ─────────────────────────────────────── */

    /* TC – 3-byte currency ticker set when the hook was installed */
    uint8_t ticker_key[2] = {'T', 'C'};
    uint8_t ticker[3];
    if (hook_param(SBUF(ticker), SBUF(ticker_key)) != 3)
        rollback(SBUF("ERR: missing TC"), __LINE__);

    /* IS – 20-byte AccountID of the IOU issuer */
    uint8_t issuer_key[2] = {'I', 'S'};
    uint8_t issuer[20];
    if (hook_param(SBUF(issuer), SBUF(issuer_key)) != 20)
        rollback(SBUF("ERR: missing IS"), __LINE__);

    /* CD – cooldown duration in seconds (big-endian uint32) */
    uint8_t cooldown_key[2] = {'C', 'D'};
    uint8_t cooldown_buf[4];
    if (hook_param(SBUF(cooldown_buf), SBUF(cooldown_key)) != 4)
        rollback(SBUF("ERR: missing CD"), __LINE__);

    /* Decode big-endian uint32 from the 4-byte parameter */
    uint32_t cooldown_seconds =
        ((uint32_t)cooldown_buf[0] << 24) |
        ((uint32_t)cooldown_buf[1] << 16) |
        ((uint32_t)cooldown_buf[2] << 8)  |
        ((uint32_t)cooldown_buf[3]);

    /* ── 4. BUILD THE 20-BYTE CURRENCY REPRESENTATION ───────────────── */
    /* XRPL encodes 3-char currency codes as 20 bytes where bytes 12-14
       hold the ASCII characters and all other bytes are 0x00.          */
    uint8_t currency[20] = {
        0,0,0,0,0,0,0,0,0,0,0,0,
        0,0,0,
        0,0,0,0,0
    };
    currency[12] = ticker[0];
    currency[13] = ticker[1];
    currency[14] = ticker[2];

    /* ── 5. COOLDOWN CHECK ───────────────────────────────────────────── */
    /*
     * The last reward timestamp is stored inside the RippleState (trustline)
     * object between the claimant and the issuer, under sfLowReward or
     * sfHighReward depending on which side has the numerically lower
     * AccountID. If no RewardTime exists yet, this is a first-time claim
     * and we skip the check.
     *
     * RippleState keylets require the accounts in a canonical order
     * (lower AccountID first), so we compare byte-by-byte first.
     */
    int claim_cmp = 0;
    for (int i = 0; GUARD(20), i < 20; ++i)
    {
        if (claimant[i] < issuer[i]) { claim_cmp = -1; break; }
        if (claimant[i] > issuer[i]) { claim_cmp =  1; break; }
    }

    /* Build the keylet for the claimant↔issuer trustline */
    uint8_t claim_line_keylet[34];
    if (claim_cmp > 0)
    {
        /* claimant is the high-account; pass (claimant, issuer) */
        util_keylet(
            SBUF(claim_line_keylet), KEYLET_LINE,
            (uint32_t)claimant, 20,
            (uint32_t)issuer,   20,
            (uint32_t)currency, 20
        );
    }
    else
    {
        /* issuer is the high-account; pass (issuer, claimant) */
        util_keylet(
            SBUF(claim_line_keylet), KEYLET_LINE,
            (uint32_t)issuer,   20,
            (uint32_t)claimant, 20,
            (uint32_t)currency, 20
        );
    }

    /* Load the trustline into slot 10; if it exists, inspect RewardTime */
    if (slot_set(SBUF(claim_line_keylet), 10) == 10)
    {
        int64_t reward_slot = 0;

        /* Try the LowReward sub-object first, then HighReward */
        reward_slot = slot_subfield(10, sfLowReward, 11);
        if (reward_slot != 11)
            reward_slot = slot_subfield(10, sfHighReward, 11);

        if (reward_slot == 11)
        {
            /* Read sfRewardTime (4-byte Ripple-epoch timestamp) */
            if (slot_subfield(11, sfRewardTime, 12) == 12)
            {
                uint8_t rt_buf[4];
                if (slot(SBUF(rt_buf), 12) == 4)
                {
                    uint32_t last_reward_time =
                        ((uint32_t)rt_buf[0] << 24) |
                        ((uint32_t)rt_buf[1] << 16) |
                        ((uint32_t)rt_buf[2] << 8)  |
                        ((uint32_t)rt_buf[3]);

                    uint32_t now = ledger_last_time();

                    /* Reject if the cooldown window has not elapsed */
                    if (cooldown_seconds > 0 && (now - last_reward_time) < cooldown_seconds)
                        rollback(SBUF("ERR: cooldown active"), __LINE__);
                }
            }
        }
    }

    /* ── 6. BALANCE CHECK ────────────────────────────────────────────── */
    /*
     * Confirm the hook account (the reserve) holds at least 1 token
     * before attempting to emit. This prevents the hook from firing
     * when the reserve is empty, which would cause the emit to fail.
     *
     * reward_amount = 1.0 token (float_set(exponent=0, mantissa=1))
     */
    int64_t reward_amount = float_set(0, 1);

    uint8_t hook_acc[20];
    hook_account(SBUF(hook_acc));

    /* Determine canonical ordering for the hook account ↔ issuer keylet */
    int hook_cmp = 0;
    for (int i = 0; GUARD(20), i < 20; ++i)
    {
        if (hook_acc[i] < issuer[i]) { hook_cmp = -1; break; }
        if (hook_acc[i] > issuer[i]) { hook_cmp =  1; break; }
    }

    uint8_t hook_line_keylet[34];
    if (hook_cmp > 0)
    {
        if (util_keylet(
            SBUF(hook_line_keylet), KEYLET_LINE,
            (uint32_t)hook_acc, 20,
            (uint32_t)issuer,   20,
            (uint32_t)currency, 20
        ) != 34)
            rollback(SBUF("ERR: hook keylet high"), __LINE__);
    }
    else
    {
        if (util_keylet(
            SBUF(hook_line_keylet), KEYLET_LINE,
            (uint32_t)issuer,   20,
            (uint32_t)hook_acc, 20,
            (uint32_t)currency, 20
        ) != 34)
            rollback(SBUF("ERR: hook keylet low"), __LINE__);
    }

    /* Load the hook account's trustline into slot 1 */
    if (slot_set(SBUF(hook_line_keylet), 1) != 1)
        rollback(SBUF("ERR: hook RippleState not found"), __LINE__);

    /* Read the Balance sub-field from slot 1 into slot 2 */
    if (slot_subfield(1, sfBalance, 2) != 2)
        rollback(SBUF("ERR: hook balance not found"), __LINE__);

    /* Convert the slot value to an XLS-17d float */
    int64_t balance = slot_float(2);

    /* RippleState balances are stored from the low-account's perspective.
       If the hook account is on the high side, negate to get its true balance. */
    if (hook_cmp > 0)
        balance = float_negate(balance);

    /* Reject if the reserve does not have at least 1 token left */
    if (float_compare(balance, reward_amount, COMPARE_LESS))
        rollback(SBUF("ERR: insufficient token balance"), __LINE__);

    /* ── 7. EMIT THE REWARD PAYMENT ──────────────────────────────────── */
    /* Reserve one emitted transaction slot */
    etxn_reserve(1);

    /*
     * Serialize the IOU amount into a 48-byte STAmount buffer.
     * float_sto writes the amount starting at (amt_out - 1) with write_len=49
     * so that the field-code prefix byte lands just before amt_out[0],
     * leaving amt_out[0..47] as the raw 48-byte value that
     * PREPARE_PAYMENT_SIMPLE_TRUSTLINE expects as its tlamt argument.
     */
    uint8_t amt_out[48];
    if (float_sto(
        (uint32_t)(amt_out - 1), 49,
        SBUF(currency),
        SBUF(issuer),
        reward_amount,
        sfAmount
    ) < 0)
        rollback(SBUF("ERR: amount serialize failed"), __LINE__);

    /* Build the full Payment transaction using the trustline macro.
       Arguments: output buffer, IOU amount (48 bytes), destination,
       destination tag (0), source tag (0).                          */
    uint8_t txn[PREPARE_PAYMENT_SIMPLE_TRUSTLINE_SIZE];
    PREPARE_PAYMENT_SIMPLE_TRUSTLINE(txn, amt_out, claimant, 0, 0);

    /* Emit the transaction; emit() returns 32 (the hash length) on success */
    uint8_t emithash[32];
    if (emit(SBUF(emithash), SBUF(txn)) != 32)
        rollback(SBUF("ERR: emit failed"), __LINE__);

    accept(SBUF("OK: emitted 1 token"), __LINE__);
}

You can also paste iou_reward_hook.c directly into builder.xahau.network and compile there. Once you have iou_reward_hook.wasm, place it in the project root alongside 05-install-hook.js. You can download iou_reward_hook.wasm from the repository too.

Step 3 — Install the Hook (05-install-hook.js)

With the WASM file ready, 05-install-hook.js submits a SetHook transaction on the RESERVE account. Before running it, update these three values at the top of the file to match your setup:

const rewardIssuer   = "rYOUR_ISSUER_ADDRESS_HERE"; // your ISSUER account address
const ticker         = "RWD";                        // must match TOKEN_CURRENCY_INPUT
const cooldownSeconds = 300;                         // seconds between claims (here: 5 min)
require("dotenv").config();
const fs = require("fs");
const crypto = require("crypto");
const { Client, Wallet, classicAddressToXAddress, decodeAccountID } = require("xahau");

const WASM_PATH = "./iou_reward_hook.wasm";

const HOOK_ON = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFBFFFFFFFFFFFFFFFFFFBFFFFF";

const HOOK_NAMESPACE = crypto
  .createHash("sha256")
  .update("IOUReward")
  .digest("hex")
  .toUpperCase();

function hex(str) {
  return Buffer.from(str, "utf8").toString("hex").toUpperCase();
}

function uint32ToHex(value) {
  const b = Buffer.alloc(4);
  b.writeUInt32BE(value);
  return b.toString("hex").toUpperCase();
}

function accountToAccountIDHex(address) {
  return Buffer.from(decodeAccountID(address)).toString("hex").toUpperCase();
}

async function installHook() {
  const client = new Client("wss://xahau-test.net");
  await client.connect();

  const hookAccount = Wallet.fromSeed(process.env.RESERVE_SEED, {
    algorithm: "secp256k1",
  });

  const rewardIssuer = "IssuerAddress";
  const ticker = "YourTokenTicker";
  const cooldownSeconds = 300;

  if (ticker.length !== 3) {
    throw new Error("Ticker must be exactly 3 characters");
  }

  const issuerAccountIdHex = accountToAccountIDHex(rewardIssuer);

  console.log("=== Hook installation ===");
  console.log("Hook account:", hookAccount.address);
  console.log("Reward issuer:", rewardIssuer);
  console.log("Reward issuer AccountID:", issuerAccountIdHex);
  console.log("Ticker:", ticker);
  console.log("Cooldown seconds:", cooldownSeconds);
  console.log("Namespace:", HOOK_NAMESPACE);
  console.log("HookOn:", HOOK_ON);

  if (!fs.existsSync(WASM_PATH)) {
    console.error(`WASM file not found: ${WASM_PATH}`);
    await client.disconnect();
    return;
  }

  const wasmHex = fs.readFileSync(WASM_PATH).toString("hex").toUpperCase();

  const setHookTx = await client.autofill({
    TransactionType: "SetHook",
    Account: hookAccount.address,
    Hooks: [
      {
        Hook: {
          CreateCode: wasmHex,
          HookApiVersion: 0,
          HookNamespace: HOOK_NAMESPACE,
          HookOn: HOOK_ON,
          Flags: 1, // hsfOVERRIDE

          HookParameters: [
            {
              HookParameter: {
                HookParameterName: hex("TC"),
                HookParameterValue: hex(ticker), // RWD = 525744
              },
            },
            {
              HookParameter: {
                HookParameterName: hex("IS"),
                HookParameterValue: issuerAccountIdHex, // 20 bytes / 40 hex chars
              },
            },
            {
              HookParameter: {
                HookParameterName: hex("CD"),
                HookParameterValue: uint32ToHex(cooldownSeconds), // 300 = 0000012C
              },
            },
          ],
        },
      },
    ],
  });

  const { tx_blob } = hookAccount.sign(setHookTx);
  const result = await client.submitAndWait(tx_blob);

  console.log("SetHook:", result.result.meta.TransactionResult);
  console.log(`Explorer: https://xaman.app/explorer/21338/${result.result.hash}`);

  await client.disconnect();
}

installHook().catch(console.error);

A few things to note:

HookNamespace is a 32-byte identifier that groups all state entries written by this hook. We derive it deterministically from the string "IOUReward" using SHA-256 so it is always reproducible and unique to this hook.

HookOn is a 64-character hex bitmask where each bit corresponds to a transaction type. A bit set to 1 means the hook does not fire for that transaction. Our value has all bits set to 1 except the bit for ClaimReward, so the hook fires exclusively on that transaction type.

HookParameters passes the three configuration values into the hook at install time. The hook reads these on every execution using hook_param(). Parameters are stored in the ledger as key-value pairs where both key and value are hex-encoded bytes.

Step 4 — Claiming IOU Rewards (06-claim-reward-iou.js)

With the hook installed on the RESERVE account and the HOLDER ready to claim, 07-claim-reward-iou.js handles the full claim flow. Set TOKEN_CURRENCY_INPUT to match what you used in 04-create-distribute-token.js.

The script does two things before submitting the ClaimReward:

  1. Checks for a TrustLine between the HOLDER and the ISSUER. The HOLDER needs this line to be able to receive the IOU tokens that the hook will emit.
  2. Creates the TrustLine automatically if it does not already exist, so the holder does not have to do a separate manual step.
require("dotenv").config();
const { Client, Wallet } = require("xahau");

// Required .env variables:
//   ISSUER_SEED  – seed of the IOU token creator (used for TrustLine setup)
//   RESERVE_SEED – seed of the account that holds the supply and has the hook installed
//   HOLDER_SEED  – seed of the account claiming the reward (must have testnet funds)
// Get testnet funds at https://xahau-test.net

// Must match the currency code used in 04-create-distribute-token.js
const TOKEN_CURRENCY_INPUT = "YourTokenAddress";

// Converts a human-readable currency name to the 40-char hex format required
// by Xahau for non-standard (>3 char) currency codes.
function normalizeCurrency(token_currency) {
  if (typeof token_currency !== "string") return token_currency;
  const cur = token_currency.trim();
  // Standard 3-char codes need no conversion
  if (cur.length <= 3) return cur;
  const hex = Buffer.from(cur, "utf8").toString("hex").toUpperCase();
  if (hex.length > 40)
    throw new Error(`Currency too long: "${cur}" → hex ${hex.length} chars (max 40)`);
  return hex.padEnd(40, "0");
}

const TOKEN_CURRENCY = normalizeCurrency(TOKEN_CURRENCY_INPUT);

async function claimIouReward() {
  const client = new Client("wss://xahau-test.net");
  await client.connect();

  // Load all three wallets involved in the flow
  const issuer  = Wallet.fromSeed(process.env.ISSUER_SEED,  { algorithm: "secp256k1" });
  const reserve = Wallet.fromSeed(process.env.RESERVE_SEED, { algorithm: "secp256k1" });
  const holder  = Wallet.fromSeed(process.env.HOLDER_SEED,  { algorithm: "secp256k1" });

  console.log("=== IOU Reward Claim ===");
  console.log("Holder:", holder.address);
  console.log("Issuer:", issuer.address);
  console.log("Token: ", TOKEN_CURRENCY);

  // ── Step 1: Ensure the holder has a TrustLine to the issuer ───────────
  // The holder needs a TrustLine toward the ISSUER (the token creator)
  // in order to receive IOU tokens as the reward payment emitted by the hook.
  // We query existing lines first to avoid submitting a redundant TrustSet.
  const lines = await client.request({
    command: "account_lines",
    account: holder.address,
    peer: issuer.address,   // filter lines between holder and issuer only
    ledger_index: "validated",
  });

  // Check whether a line for this specific currency already exists
  const hasTrustLine = lines.result.lines.some(
    (l) => l.currency === TOKEN_CURRENCY && l.account === issuer.address
  );

  if (!hasTrustLine) {
    // TrustLine does not exist — create it so the holder can receive tokens
    console.log("--- TrustLine not found, creating it ---");
    const trustSetTx = await client.autofill({
      TransactionType: "TrustSet",
      Account: holder.address,
      LimitAmount: {
        currency: TOKEN_CURRENCY,
        issuer: issuer.address,
        value: "1000000", // maximum tokens the holder is willing to hold
      },
    });
    const trustResult = await client.submitAndWait(holder.sign(trustSetTx).tx_blob);
    console.log("TrustSet:", trustResult.result.meta.TransactionResult);
    if (trustResult.result.meta.TransactionResult !== "tesSUCCESS") {
      console.error("TrustLine creation failed, aborting.");
      await client.disconnect();
      return;
    }
  } else {
    console.log("--- TrustLine already exists, skipping ---");
  }

  // ── Step 2: Submit the ClaimReward transaction ─────────────────────────
  // Issuer  – the RESERVE account, which has the reward hook installed.
  //           The ledger fires the hook on this account when it processes
  //           the ClaimReward.
  // ClaimCurrency – identifies which IOU trustline's reward counter to claim.
  //           Both currency and issuer point to the RESERVE account because
  //           that is the account distributing the reward tokens.
  console.log("--- Submitting ClaimReward ---");
  const claimTx = await client.autofill({
    TransactionType: "ClaimReward",
    Account: holder.address,
    Issuer: reserve.address,          // account whose hook fires
    ClaimCurrency: {
      currency: TOKEN_CURRENCY,
      issuer: issuer.address,        // issuer of the reward currency
    },
  });

  const { tx_blob } = holder.sign(claimTx);
  const result = await client.submitAndWait(tx_blob);
  console.log("ClaimReward:", result.result.meta.TransactionResult);
  console.log(`Explorer: https://xaman.app/explorer/21338/${result.result.hash}`);

  await client.disconnect();
}

claimIouReward().catch(console.error);

Let’s unpack the ClaimReward transaction fields:

  • Account — the HOLDER, the account submitting the claim and receiving the reward.
  • Issuer — the RESERVE address. This tells the ledger which account’s Hook to fire. The ledger looks up any Hook installed on this account and executes it with the ClaimReward as the originating transaction.
  • ClaimCurrency — identifies the specific IOU reward counter to claim from, using the token’s currency code and the RESERVE address. This field is what distinguishes an IOU claim from a genesis XAH claim.

When the ledger processes this transaction, it fires the Hook on the RESERVE account. The Hook validates the cooldown and balance, then emits a Payment of 1 RWD token from RESERVE to the HOLDER. Because RESERVE holds RWD on a trustline to ISSUER, and HOLDER also has a trustline to ISSUER, the payment ripples through the ISSUER account, RESERVE’s balance decreases, HOLDER’s balance increases, and the ISSUER acts as the bridge (which is why DefaultRipple was required in step 1).

The Full Picture

Here is a summary of the complete IOURewardClaim flow from start to finish:

ISSUER account
  └─ AccountSet(DefaultRipple)                  ← one time only
  └─ ← TrustSet from RESERVE                    ← RESERVE opens line to ISSUER
  └─ Payment → RESERVE (1,000,000 RWD)          ← mints and distributes supply

RESERVE account
  └─ SetHook(iou_reward_hook.wasm)              ← install hook with TC/IS/CD params
  └─ holds 1,000,000 RWD from ISSUER

HOLDER account
  └─ TrustSet → ISSUER (auto, via script 07)    ← opens line to receive tokens
  └─ ClaimReward(Issuer: RESERVE, ClaimCurrency: RWD/ISSUER)
       ↓ ledger fires Hook on RESERVE
       ↓ Hook checks cooldown + balance
       ↓ Hook emits Payment(1 RWD → HOLDER)
  └─ receives 1 RWD via rippling through ISSUER

Summary

Feature Amendment Transaction Types What It Adds
PriceOracle featurePriceOracle OracleSet, OracleDelete + get_aggregate_price RPC On-chain price feeds, up to 10 pairs per oracle, up to 200 oracles per aggregate query
IOURewardClaim featureIOURewardClaim ClaimReward (extended) Issuer + ClaimCurrency fields enable custom IOU reward programmes via Hooks

Resources

If this is your first time here, I recommend starting with What is Xahau? and working your way through the series before diving in.

Have questions about PriceOracle or IOURewardClaim? Leave a comment below or find us in the Xahau Discord.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Why Wall Street thinks US memory maker Micron is the next Nvidia

Next Post

Ford rehires ‘gray beard’ engineers after AI falls short

Related Posts