Lesson 11: Hook State Management - Persistent Data Storage | Hooks & Smart Contracts | XRP Academy - XRP Academy
3 free lessons remaining this month

Free preview access resets monthly

Upgrade for Unlimited
Skip to main content
beginner60 min

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 4

What 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

1

State is key-value with limits.

Keys up to 32 bytes, values up to 256 bytes.

2

Namespaces provide isolation.

Each Hook can have its own state space.

3

Serialize data carefully.

Use macros for integer conversion, pack multiple values when needed.

4

State costs reserves.

Each entry locks additional XRP—design with growth limits.

5

State changes commit on accept.

Rollback reverts all state changes from that execution. ---