Lesson 11: Hook State Management - Persistent Data Storage
Learning Objectives
Read and write Hook state using state() and state_set()
Design state schemas with appropriate key structures
Serialize data types for storage (integers, arrays, complex structures)
Understand namespace isolation and state costs
Implement common patterns: counters, accumulators, registries
Without state, every Hook invocation is identical—same code, same environment, no memory. State transforms Hooks from simple filters into applications:
WITHOUT STATE:
├── Accept/reject based on single transaction
├── No history awareness
├── No counters or totals
├── No learned behavior
└── Useful but limited
WITH STATE:
├── Track transaction counts
├── Maintain running totals
├── Build whitelists dynamically
├── Store configuration
├── Learn from patterns
└── Full application capability
```
Hook state is a key-value store:
HOOK STATE MODEL
Storage: Per-account, per-namespace
Key: Up to 32 bytes
Value: Up to 256 bytes
Isolation: Each Hook namespace is separate
Persistence: Survives across Hook executions
Cost: Reserve increase for state entries
int64_t state(
uint32_t write_ptr, uint32_t write_len, // Output buffer
uint32_t kread_ptr, uint32_t kread_len // Key
);
// Returns: Bytes read, or negative if not found
// Example: Read a counter
uint8_t value[8];
uint8_t key[] = "counter";
int64_t len = state(SBUF(value), SBUF(key));
if (len < 0) {
// State doesn't exist yet
trace(SBUF("Counter not found, initializing"), 0);
// Initialize to 0
} else if (len == 8) {
int64_t counter = INT64_FROM_BUF(value);
trace(SBUF("Current counter:"), counter);
} else {
// Unexpected size
rollback(SBUF("Invalid counter state"), 1);
}
```
int64_t state_set(
uint32_t read_ptr, uint32_t read_len, // Value to store
uint32_t kread_ptr, uint32_t kread_len // Key
);
// Returns: Bytes written, or negative on error
// Example: Write a counter
uint8_t value[8];
int64_t new_count = 42;
INT64_TO_BUF(value, new_count);
uint8_t key[] = "counter";
int64_t result = state_set(SBUF(value), SBUF(key));
if (result < 0) {
rollback(SBUF("Failed to write state"), 1);
}
trace(SBUF("Counter saved:"), new_count);
```
To delete state, set an empty value:
// Delete state entry
uint8_t key[] = "counter";
int64_t result = state_set(0, 0, SBUF(key));
if (result < 0) {
trace(SBUF("Could not delete (may not exist)"), 0);
} else {
trace(SBUF("State deleted"), 0);
}
Hooks use big-endian byte order:
// INT64 to/from buffer
#define INT64_TO_BUF(buf, val) \
(buf)[0] = ((val) >> 56) & 0xFF; \
(buf)[1] = ((val) >> 48) & 0xFF; \
(buf)[2] = ((val) >> 40) & 0xFF; \
(buf)[3] = ((val) >> 32) & 0xFF; \
(buf)[4] = ((val) >> 24) & 0xFF; \
(buf)[5] = ((val) >> 16) & 0xFF; \
(buf)[6] = ((val) >> 8) & 0xFF; \
(buf)[7] = (val) & 0xFF;
#define INT64_FROM_BUF(buf) \
(((int64_t)(buf)[0] << 56) | \
((int64_t)(buf)[1] << 48) | \
((int64_t)(buf)[2] << 40) | \
((int64_t)(buf)[3] << 32) | \
((int64_t)(buf)[4] << 24) | \
((int64_t)(buf)[5] << 16) | \
((int64_t)(buf)[6] << 8) | \
(int64_t)(buf)[7])
// UINT32 versions
#define UINT32_TO_BUF(buf, val) \
(buf)[0] = ((val) >> 24) & 0xFF; \
(buf)[1] = ((val) >> 16) & 0xFF; \
(buf)[2] = ((val) >> 8) & 0xFF; \
(buf)[3] = (val) & 0xFF;
#define UINT32_FROM_BUF(buf) \
(((uint32_t)(buf)[0] << 24) | \
((uint32_t)(buf)[1] << 16) | \
((uint32_t)(buf)[2] << 8) | \
(uint32_t)(buf)[3])
Pack multiple values into one state entry:
// Store count and total in one entry
struct Stats {
int64_t count;
int64_t total;
};
// Write
uint8_t value[16];
int64_t count = 42;
int64_t total = 1000000;
INT64_TO_BUF(value, count);
INT64_TO_BUF(value + 8, total);
state_set(SBUF(value), SBUF("stats"));
// Read
uint8_t buf[16];
int64_t len = state(SBUF(buf), SBUF("stats"));
if (len == 16) {
int64_t count = INT64_FROM_BUF(buf);
int64_t total = INT64_FROM_BUF(buf + 8);
trace(SBUF("Count:"), count);
trace(SBUF("Total:"), total);
}
Use account IDs as keys for per-account state:
// Key: "balance:" + account ID (20 bytes)
uint8_t key[28]; // 8 + 20 = 28
uint8_t prefix[] = "balance:";
// Copy prefix
for (int i = 0; GUARD(8), i < 8; ++i) {
key[i] = prefix[i];
}
// Copy account ID
uint8_t sender[20];
otxn_field(SBUF(sender), sfAccount);
for (int i = 0; GUARD(20), i < 20; ++i) {
key[8 + i] = sender[i];
}
// Now use key for state operations
uint8_t balance_buf[8];
int64_t len = state(SBUF(balance_buf), SBUF(key));
Each Hook runs in a namespace (32-byte identifier):
NAMESPACE ISOLATION
Hook A (namespace: 0x1234...)
├── Key "counter" → Value A
├── Key "total" → Value A
└── Isolated from Hook B
Hook B (namespace: 0x5678...)
├── Key "counter" → Value B (different!)
├── Key "total" → Value B
└── Isolated from Hook A
Namespace is set at Hook installation:
{
"TransactionType": "SetHook",
"Hooks": [{
"Hook": {
"CreateCode": "...",
"HookNamespace": "0000000000000000000000000000000000000000000000000000000000000000",
"HookOn": "...",
"HookApiVersion": 0
}
}]
}- Default namespace: 32 zero bytes
- Different namespace = different state space
- Same Hook code, different namespace = separate state
int64_t state_foreign(
uint32_t write_ptr, uint32_t write_len, // Output
uint32_t kread_ptr, uint32_t kread_len, // Key
uint32_t nread_ptr, uint32_t nread_len, // Namespace (32 bytes)
uint32_t aread_ptr, uint32_t aread_len // Account ID (20 bytes)
);
// Read state from another Hook's namespace
uint8_t value[256];
uint8_t key[] = "config";
uint8_t namespace[32] = { /* other Hook's namespace / };
uint8_t account[20] = { / other account */ };
int64_t len = state_foreign(
SBUF(value),
SBUF(key),
SBUF(namespace),
SBUF(account)
);
// Note: Requires grant from target account
```
// Simple counter: Count all transactions
#include "hookapi.h"
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Read current count
uint8_t count_buf[8];
uint8_t key[] = "txcount";
int64_t len = state(SBUF(count_buf), SBUF(key));
int64_t count;
if (len == 8) {
count = INT64_FROM_BUF(count_buf);
} else {
count = 0; // Initialize
}
// Increment
count++;
trace(SBUF("Transaction count:"), count);
// Save
INT64_TO_BUF(count_buf, count);
int64_t result = state_set(SBUF(count_buf), SBUF(key));
if (result < 0) {
rollback(SBUF("Could not save count"), 1);
}
accept(SBUF("Counted"), 0);
return 0;
}
```
// Track total XRP received
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Only process payments
if (otxn_type() != 0) {
accept(SBUF("Not payment"), 0);
}
// Get amount
int64_t amount = otxn_amount();
if (amount < 0) {
accept(SBUF("Not XRP"), 0);
}
// Read current total
uint8_t total_buf[8];
int64_t len = state(SBUF(total_buf), SBUF("total"));
int64_t total;
if (len == 8) {
total = INT64_FROM_BUF(total_buf);
} else {
total = 0;
}
// Check for overflow
if (amount > INT64_MAX - total) {
rollback(SBUF("Overflow"), 1);
}
// Add
total += amount;
trace(SBUF("New total (drops):"), total);
// Save
INT64_TO_BUF(total_buf, total);
state_set(SBUF(total_buf), SBUF("total"));
accept(SBUF("Accumulated"), 0);
return 0;
}
```
// Track per-sender statistics
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Get sender
uint8_t sender[20];
otxn_field(SBUF(sender), sfAccount);
// Build key: sender account ID directly as key
// (Keys can be up to 32 bytes, account is 20)
// Read sender's stats
uint8_t stats_buf[16]; // count (8) + total (8)
int64_t len = state(SBUF(stats_buf), SBUF(sender));
int64_t count, total;
if (len == 16) {
count = INT64_FROM_BUF(stats_buf);
total = INT64_FROM_BUF(stats_buf + 8);
} else {
count = 0;
total = 0;
}
// Update
count++;
int64_t amount = otxn_amount();
if (amount > 0) {
total += amount;
}
trace(SBUF("Sender tx count:"), count);
trace(SBUF("Sender total:"), total);
// Save
INT64_TO_BUF(stats_buf, count);
INT64_TO_BUF(stats_buf + 8, total);
state_set(SBUF(stats_buf), SBUF(sender));
accept(SBUF("Tracked"), 0);
return 0;
}
```
// Simple registry: Last N senders
#define MAX_REGISTRY 5
#define ENTRY_SIZE 28 // 20 (account) + 8 (timestamp/ledger)
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Get sender
uint8_t sender[20];
otxn_field(SBUF(sender), sfAccount);
// Get current ledger as "timestamp"
int64_t ledger = ledger_seq();
// Read registry
uint8_t registry[MAX_REGISTRY * ENTRY_SIZE];
uint8_t key[] = "registry";
int64_t len = state(SBUF(registry), SBUF(key));
int entries = 0;
if (len > 0) {
entries = len / ENTRY_SIZE;
if (entries > MAX_REGISTRY - 1) {
entries = MAX_REGISTRY - 1;
}
}
// Shift entries down (make room at start)
for (int i = entries; GUARD(MAX_REGISTRY), i > 0; --i) {
for (int j = 0; GUARD(ENTRY_SIZE * MAX_REGISTRY), j < ENTRY_SIZE; ++j) {
registry[i * ENTRY_SIZE + j] = registry[(i-1) * ENTRY_SIZE + j];
}
}
// Add new entry at start
for (int i = 0; GUARD(20), i < 20; ++i) {
registry[i] = sender[i];
}
INT64_TO_BUF(registry + 20, ledger);
entries = entries + 1;
if (entries > MAX_REGISTRY) entries = MAX_REGISTRY;
// Save
state_set(registry, entries * ENTRY_SIZE, SBUF(key));
trace(SBUF("Registry entries:"), entries);
accept(SBUF("Registered"), 0);
return 0;
}
```
State entries increase account reserve:
STATE RESERVE COSTS
Each state entry increases owner reserve
├── Base reserve: 1 XRP (account exists)
├── Per-entry: ~0.2 XRP (varies by network)
├── 100 entries: ~20 XRP additional reserve
└── Reserve is locked, not spent
Example:
├── Account starts: 10 XRP reserve
├── Add 10 state entries: ~12 XRP reserve
├── Account can't go below reserve
└── Deleting entries releases reserve
// Before adding state, consider limits
#define MAX_STATE_ENTRIES 100
// Track entry count
uint8_t count_buf[8];
int64_t len = state(SBUF(count_buf), SBUF("_entry_count"));
int64_t entry_count = (len == 8) ? INT64_FROM_BUF(count_buf) : 0;
if (entry_count >= MAX_STATE_ENTRIES) {
// At limit - either reject or clean up old entries
rollback(SBUF("State limit reached"), 1);
}
// Add new entry...
// Update count
entry_count++;
INT64_TO_BUF(count_buf, entry_count);
state_set(SBUF(count_buf), SBUF("_entry_count"));
```
// Clean up old state entries
// Pattern: Use expiring keys with ledger number
// Key format: "data:" + ledger_seq
int64_t current = ledger_seq();
int64_t cleanup_threshold = current - 10000; // ~8-12 hours old
// Could iterate known keys and delete old ones
// But iterating all state isn't directly supported
// Better pattern: Reuse fixed keys
// Instead of creating new keys, overwrite oldest
```
/**
* Payment Limiter
*
* Enforces:
* - Daily limit per sender
* - Resets every ~24 hours (8640 ledgers)
*/
#include "hookapi.h"
#define DAILY_LIMIT_DROPS 100000000 // 100 XRP per day
#define LEDGERS_PER_DAY 8640 // ~3 sec per ledger
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Only process incoming payments
if (otxn_type() != 0) {
accept(SBUF("Not payment"), 0);
}
int64_t amount = otxn_amount();
if (amount < 0) {
accept(SBUF("Not XRP"), 0);
}
// Get sender
uint8_t sender[20];
otxn_field(SBUF(sender), sfAccount);
// Get current "day" (ledger / LEDGERS_PER_DAY)
int64_t current_ledger = ledger_seq();
int64_t current_day = current_ledger / LEDGERS_PER_DAY;
// Build key from sender
// Read sender's daily state: day (8 bytes) + total (8 bytes)
uint8_t state_buf[16];
int64_t len = state(SBUF(state_buf), SBUF(sender));
int64_t sender_day = 0;
int64_t sender_total = 0;
if (len == 16) {
sender_day = INT64_FROM_BUF(state_buf);
sender_total = INT64_FROM_BUF(state_buf + 8);
// Check if same day
if (sender_day != current_day) {
// New day - reset
sender_day = current_day;
sender_total = 0;
trace(SBUF("New day, resetting limit"), 0);
}
} else {
// First time sender
sender_day = current_day;
sender_total = 0;
trace(SBUF("New sender"), 0);
}
// Check limit
int64_t new_total = sender_total + amount;
if (new_total > DAILY_LIMIT_DROPS) {
trace(SBUF("Daily limit exceeded"), sender_total);
rollback(SBUF("Daily limit: 100 XRP"), 1);
}
// Update state
INT64_TO_BUF(state_buf, sender_day);
INT64_TO_BUF(state_buf + 8, new_total);
int64_t result = state_set(SBUF(state_buf), SBUF(sender));
if (result < 0) {
rollback(SBUF("Could not save state"), 2);
}
trace(SBUF("Daily total now:"), new_total);
int64_t remaining = DAILY_LIMIT_DROPS - new_total;
trace(SBUF("Remaining today:"), remaining);
accept(SBUF("Payment within limit"), 0);
return 0;
}
int64_t cbak(uint32_t reserved) {
return 0;
}
```
✅ State persists reliably. Data survives across Hook executions and ledger closes.
✅ Namespace isolation works. Different Hooks don't interfere with each other's state.
✅ Key-value model is flexible. Can implement many data structures.
⚠️ Optimal state cleanup strategies. No built-in iteration or expiration.
⚠️ Performance with many entries. Large state may affect execution time.
🔴 Unbounded state growth. Without limits, state can grow indefinitely (and lock reserves).
🔴 Integer overflow in accumulation. Always check before adding to totals.
🔴 Forgetting state on rollback. State changes only commit on accept.
State transforms Hooks from simple filters to applications. The API is straightforward—read, write, delete with keys up to 32 bytes and values up to 256 bytes. The challenges are in state schema design: choosing key structures, managing growth, and handling costs. Plan your state layout before implementing.
Assignment: Build a Hook that implements meaningful persistent state.
Requirements:
Implement a "Payment Loyalty System" that:
Track payment count per sender
Track total amount per sender
Use sender account ID as key
Track total payments received (all senders)
Track total amount received (all senders)
Single global state entry
Bronze: < 10 payments or < 100 XRP
Silver: 10+ payments AND 100+ XRP
Gold: 50+ payments AND 500+ XRP
Log current tier for each sender
Bronze: Minimum 1 XRP per payment
Silver: Minimum 0.5 XRP per payment
Gold: No minimum
Reject payments below tier minimum
Properly serialize all values
Handle new senders gracefully
Log tier status with each payment
Update both per-sender and global stats
Correct state operations (30%)
Proper data serialization (20%)
Tier logic accuracy (25%)
Code quality and comments (25%)
Time investment: 3-4 hours
Value: Practical stateful application pattern
Knowledge Check
Question 1 of 4What are the maximum sizes for state keys and values?
- xrpl-hooks.readme.io/docs/state
- Hooks Builder examples with state
- XRPL serialization format
- Big-endian byte order
For Next Lesson:
With state mastered, we'll explore transaction emission—making your Hook send its own transactions.
End of Lesson 11
Total words: ~4,500
Estimated completion time: 60 minutes reading + 3-4 hours for deliverable
Key Takeaways
State is key-value with limits.
Keys up to 32 bytes, values up to 256 bytes.
Namespaces provide isolation.
Each Hook can have its own state space.
Serialize data carefully.
Use macros for integer conversion, pack multiple values when needed.
State costs reserves.
Each entry locks additional XRP—design with growth limits.
State changes commit on accept.
Rollback reverts all state changes from that execution. ---