This content originally appeared on DEV Community and was authored by Ekiserrepe
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
ClaimRewardtransaction 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:
const { Client, Wallet } = require('xahau')
async function setOraclePrice() {
const client = new Client('wss://xahau-test.net')
await client.connect()
const wallet = Wallet.fromSeed(process.env.ORACLE_SEED, { algorithm: 'secp256k1' })
const oracleSetTx = await client.autofill({
TransactionType: 'OracleSet',
Account: wallet.address,
OracleDocumentID: 1,
Provider: Buffer.from('MyOracle', 'utf8').toString('hex'),
AssetClass: Buffer.from('currency', 'utf8').toString('hex'),
LastUpdateTime: Math.floor(Date.now() / 1000),
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(`Explorer: 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:
const { Client } = require('xahau')
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()
const response = await client.request({
command: 'get_aggregate_price',
ledger_index: 'current',
base_asset: 'XAH',
quote_asset: 'USD',
trim: 20,
oracles: ORACLES
})
const stats = response.result
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
oraclesarray per request. - The
trimparameter removes outliers symmetrically before computing the mean.trim: 20discards the bottom 20 % and top 20 % of values — useful when you expect a minority of providers to be stale or adversarial. The untrimmedmeanandmedianare always included in the response alongside the trimmed result. - The optional
time_thresholdfield (not shown above) lets you exclude feeds whoseLastUpdateTimeis older than N seconds relative to the freshest oracle in the set. For example,time_threshold: 60drops 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')
async function deleteOracle() {
const client = new Client('wss://xahau-test.net')
await client.connect()
const wallet = Wallet.fromSeed(process.env.ORACLE_SEED, { algorithm: 'secp256k1' })
console.log(`Account: ${wallet.address}`)
console.log(`OracleDocumentID: ${ORACLE_DOCUMENT_ID}`)
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": "rRESERVE..."
}
}
Important: The first
Issuerfield 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 onttClaimRewardtransactions. 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");
function normalizeCurrency(token_currency) {
if (typeof token_currency !== "string") return token_currency;
const cur = token_currency.trim();
if (cur.length <= 3) return cur;
const hex = Buffer.from(cur, "utf8").toString("hex").toUpperCase();
return hex.padEnd(40, "0");
}
async function createAndDistributeToken() {
const client = new Client("wss://xahau-test.net");
await client.connect();
const issuer = Wallet.fromSeed(process.env.ISSUER_SEED, { algorithm: "secp256k1" });
const reserve = Wallet.fromSeed(process.env.RESERVE_SEED, { algorithm: "secp256k1" });
const TOKEN_CURRENCY = normalizeCurrency("RWD");
const TOTAL_SUPPLY = "1000000";
// Step 1: Enable DefaultRipple on the issuer
const prep1 = await client.autofill({
TransactionType: "AccountSet",
Account: issuer.address,
SetFlag: 8, // asfDefaultRipple
});
await client.submitAndWait(issuer.sign(prep1).tx_blob);
// Step 2: Reserve account opens a TrustLine toward the issuer
const prep2 = await client.autofill({
TransactionType: "TrustSet",
Account: reserve.address,
LimitAmount: { currency: TOKEN_CURRENCY, issuer: issuer.address, value: TOTAL_SUPPLY },
});
await client.submitAndWait(reserve.sign(prep2).tx_blob);
// Step 3: Issuer sends the entire supply to the reserve
const prep3 = await client.autofill({
TransactionType: "Payment",
Account: issuer.address,
Destination: reserve.address,
Amount: { currency: TOKEN_CURRENCY, issuer: issuer.address, value: TOTAL_SUPPLY },
});
await client.submitAndWait(issuer.sign(prep3).tx_blob);
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:
-
Filter — if the incoming transaction is not a
ClaimReward, accept silently and do nothing. -
Read claimant — get the
Accountfield from the transaction (the HOLDER submitting the claim). -
Load parameters — read
TC,IS, andCDfrom the hook parameters. -
Cooldown check — look up the RippleState (trustline) object between the claimant and the issuer and read the
sfLowReward/sfHighRewardsub-object to find the timestamp of the last reward claim. If a claim was made within the cooldown window, rollback with an error. - Balance check — look up the RESERVE's trustline to ISSUER and verify it holds at least 1 token. If not, rollback.
- 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, decodeAccountID } = require("xahau");
const HOOK_NAMESPACE = crypto
.createHash("sha256")
.update("IOUReward")
.digest("hex")
.toUpperCase();
// HookOn: fires only on ClaimReward transactions (all other bits set to 1)
const HOOK_ON = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFBFFFFFFFFFFFFFFFFFFBFFFFF";
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 = "rYOUR_ISSUER_ADDRESS_HERE";
const ticker = "RWD";
const cooldownSeconds = 300;
const wasmHex = fs.readFileSync("./iou_reward_hook.wasm").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: Buffer.from("TC", "utf8").toString("hex").toUpperCase(),
HookParameterValue: Buffer.from(ticker, "utf8").toString("hex").toUpperCase()
}},
{ HookParameter: {
HookParameterName: Buffer.from("IS", "utf8").toString("hex").toUpperCase(),
HookParameterValue: Buffer.from(decodeAccountID(rewardIssuer)).toString("hex").toUpperCase()
}},
{ HookParameter: {
HookParameterName: Buffer.from("CD", "utf8").toString("hex").toUpperCase(),
HookParameterValue: (() => { const b = Buffer.alloc(4); b.writeUInt32BE(cooldownSeconds); return b.toString("hex").toUpperCase(); })()
}},
],
},
}],
});
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:
- 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.
- 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");
const TOKEN_CURRENCY = "RWD"; // must match 04-create-distribute-token.js
async function claimIouReward() {
const client = new Client("wss://xahau-test.net");
await client.connect();
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" });
// Check if the holder already has a TrustLine to the issuer
const lines = await client.request({
command: "account_lines",
account: holder.address,
peer: issuer.address,
ledger_index: "validated",
});
const hasTrustLine = lines.result.lines.some(
(l) => l.currency === TOKEN_CURRENCY && l.account === issuer.address
);
if (!hasTrustLine) {
// Open a TrustLine so the holder can receive reward tokens
const trustSetTx = await client.autofill({
TransactionType: "TrustSet",
Account: holder.address,
LimitAmount: { currency: TOKEN_CURRENCY, issuer: issuer.address, value: "1000000" },
});
await client.submitAndWait(holder.sign(trustSetTx).tx_blob);
}
// Submit the ClaimReward targeting the RESERVE account
const claimTx = await client.autofill({
TransactionType: "ClaimReward",
Account: holder.address,
Issuer: reserve.address, // the account with the hook installed
ClaimCurrency: {
currency: TOKEN_CURRENCY,
issuer: issuer.address, // identifies the reward counter
},
});
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
- Xahau Release 2026.6.21+3350
- xahau.js 4.1.0 Release Notes
- XLS-47d PriceOracle Standard
- Xahau Protocol Reference — Oracles
- Xahau Protocol Reference — ClaimReward
- Xahau Hooks Documentation
- builder.xahau.network — online hook compiler
- Xahau Testnet Faucet
- Xahau Builders Discord
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.
This content originally appeared on DEV Community and was authored by Ekiserrepe
Ekiserrepe | Sciencx (2026-06-28T17:09:45+00:00) Learning Xahau: PriceOracle and IOURewardClaim, On-Chain Prices and Custom Reward Programmes. Retrieved from https://www.scien.cx/2026/06/28/learning-xahau-priceoracle-and-iourewardclaim-on-chain-prices-and-custom-reward-programmes/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.