Lesson 14: Building a Complete Hook Project | 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
beginner90 min

Lesson 14: Building a Complete Hook Project

Learning Objectives

Design a Hook from requirements to implementation

Integrate all core Hook capabilities into one project

Structure code professionally with clear sections and error handling

Test comprehensively with multiple scenarios

Document and deploy a production-ready Hook

Individual Hook features are powerful. Combined, they enable sophisticated applications. This lesson builds a real-world project that showcases:

INTEGRATED FEATURES

Transaction Inspection
├── Validate payment type
├── Check amount thresholds
└── Extract sender info

State Management
├── Store recipient configuration
├── Track distribution history
└── Maintain statistics

Emissions
├── Split payment to recipients
├── Handle multiple outputs
└── Manage fees

Ledger Access
├── Verify recipient accounts exist
├── Check Hook account balance
└── Validate before emission

Error Handling
├── Graceful degradation
├── Informative messages
└── Callback handling
```


Purpose: Automatically distribute incoming XRP payments to multiple recipients based on predefined percentage shares.

Functional Requirements:

  1. PAYMENT ACCEPTANCE

  2. DISTRIBUTION RULES

  3. STATE MANAGEMENT

  4. EMISSIONS

  5. ERROR HANDLING

STATE LAYOUT

Key: "config"
Value: 1 byte (number of recipients, 1-4)

Key: "r0" through "r3"
Value: 20 bytes (recipient account ID)

Key: "s0" through "s3"
Value: 1 byte (share percentage, 1-100)

Key: "total_dist"
Value: 8 bytes (int64_t total distributed)

Key: "dist_count"
Value: 8 bytes (int64_t distribution count)

Key: "fail_count"
Value: 8 bytes (int64_t failed emission count)
```


/**
 * Payment Splitter Hook
 * 
 * Automatically distributes incoming XRP payments to configured recipients.
 * 
 * Features:
 * - Up to 4 recipients with configurable percentage shares
 * - Minimum payment threshold (10 XRP)
 * - Distribution statistics tracking
 * - Comprehensive error handling
 * 
 * State Keys:
 * - "config": Number of recipients (1 byte)
 * - "r0"-"r3": Recipient account IDs (20 bytes each)
 * - "s0"-"s3": Share percentages (1 byte each)
 * - "total_dist": Total XRP distributed (8 bytes)
 * - "dist_count": Number of distributions (8 bytes)
 * - "fail_count": Failed emissions (8 bytes)
 */

#include "hookapi.h"

// Configuration
#define MAX_RECIPIENTS 4
#define MIN_PAYMENT_DROPS 10000000 // 10 XRP
#define ACCOUNT_SIZE 20

// State keys
#define KEY_CONFIG "config"
#define KEY_TOTAL "total_dist"
#define KEY_COUNT "dist_count"
#define KEY_FAILS "fail_count"

// Transaction types
#define TT_PAYMENT 0
```

/**
 * Load configuration from state
 * Returns number of configured recipients (0 if not configured)
 */
int64_t load_config(
    uint8_t recipients[MAX_RECIPIENTS][ACCOUNT_SIZE],
    uint8_t shares[MAX_RECIPIENTS]
) {
    // Get number of recipients
    uint8_t config_buf[1];
    int64_t len = state(SBUF(config_buf), SBUF(KEY_CONFIG));

if (len != 1) {
return 0; // Not configured
}

uint8_t num_recipients = config_buf[0];
if (num_recipients == 0 || num_recipients > MAX_RECIPIENTS) {
return 0; // Invalid config
}

// Load each recipient
uint8_t key[2] = {'r', '0'};
uint8_t share_key[2] = {'s', '0'};

for (int i = 0; GUARD(MAX_RECIPIENTS), i < num_recipients; ++i) {
key[1] = '0' + i;
share_key[1] = '0' + i;

// Load recipient address
len = state(recipients[i], ACCOUNT_SIZE, key, 2);
if (len != ACCOUNT_SIZE) {
return 0; // Missing recipient
}

// Load share percentage
uint8_t share_buf[1];
len = state(SBUF(share_buf), share_key, 2);
if (len != 1) {
return 0; // Missing share
}
shares[i] = share_buf[0];
}

return num_recipients;
}

/**

  • Update distribution statistics
    */
    void update_stats(int64_t amount) {
    // Update total distributed
    uint8_t total_buf[8];
    int64_t len = state(SBUF(total_buf), SBUF(KEY_TOTAL));

int64_t total = (len == 8) ? INT64_FROM_BUF(total_buf) : 0;
total += amount;
INT64_TO_BUF(total_buf, total);
state_set(SBUF(total_buf), SBUF(KEY_TOTAL));

// Update distribution count
uint8_t count_buf[8];
len = state(SBUF(count_buf), SBUF(KEY_COUNT));

int64_t count = (len == 8) ? INT64_FROM_BUF(count_buf) : 0;
count++;
INT64_TO_BUF(count_buf, count);
state_set(SBUF(count_buf), SBUF(KEY_COUNT));
}

/**

  • Check if Hook account has sufficient balance
    */
    int has_sufficient_balance(int64_t needed) {
    // Get Hook account
    uint8_t hook_acc[ACCOUNT_SIZE];
    hook_account(SBUF(hook_acc));

// Load account from ledger
uint8_t keylet[34];
util_keylet(SBUF(keylet), KEYLET_ACCOUNT,
(uint32_t)hook_acc, ACCOUNT_SIZE, 0, 0, 0, 0);

int64_t slot = slot_set(SBUF(keylet), 1);
if (slot < 0) {
return 0; // Shouldn't happen for Hook account
}

int64_t balance = slot_subfield(1, sfBalance, 0);
int64_t owner_count = slot_subfield(1, sfOwnerCount, 0);

// Calculate reserve (10 XRP base + 2 XRP per owned object)
int64_t reserve = 10000000 + (owner_count * 2000000);
int64_t available = balance - reserve;

return available >= needed;
}
```

int64_t hook(uint32_t reserved) {
    _g(1, 1);

trace(SBUF("=== Payment Splitter Hook ==="), 0);

// ========================================
// STEP 1: Check transaction type
// ========================================
int64_t txn_type = otxn_type();

if (txn_type != TT_PAYMENT) {
trace(SBUF("Not a payment, accepting"), txn_type);
accept(SBUF("Non-payment accepted"), 0);
}

// ========================================
// STEP 2: Check if incoming payment
// ========================================
uint8_t hook_acc[ACCOUNT_SIZE];
hook_account(SBUF(hook_acc));

uint8_t dest[ACCOUNT_SIZE];
int64_t dest_len = otxn_field(SBUF(dest), sfDestination);

if (dest_len != ACCOUNT_SIZE) {
rollback(SBUF("Could not get destination"), 1);
}

// Check if payment is TO this account
int incoming = 1;
for (int i = 0; GUARD(ACCOUNT_SIZE), i < ACCOUNT_SIZE; ++i) {
if (dest[i] != hook_acc[i]) {
incoming = 0;
break;
}
}

if (!incoming) {
trace(SBUF("Outgoing payment, accepting"), 0);
accept(SBUF("Outgoing payment OK"), 0);
}

// ========================================
// STEP 3: Validate payment amount
// ========================================
int64_t amount = otxn_amount();
trace(SBUF("Payment amount (drops):"), amount);

if (amount < 0) {
rollback(SBUF("IOU payments not supported"), 2);
}

if (amount < MIN_PAYMENT_DROPS) {
rollback(SBUF("Payment below minimum (10 XRP)"), 3);
}

// ========================================
// STEP 4: Load configuration
// ========================================
uint8_t recipients[MAX_RECIPIENTS][ACCOUNT_SIZE];
uint8_t shares[MAX_RECIPIENTS];

int64_t num_recipients = load_config(recipients, shares);

if (num_recipients == 0) {
rollback(SBUF("No recipients configured"), 4);
}

trace(SBUF("Recipients configured:"), num_recipients);

// Verify shares total 100%
int total_shares = 0;
for (int i = 0; GUARD(MAX_RECIPIENTS), i < num_recipients; ++i) {
total_shares += shares[i];
}

if (total_shares != 100) {
rollback(SBUF("Shares must total 100%"), 5);
}

// ========================================
// STEP 5: Calculate distribution amounts
// ========================================
int64_t amounts[MAX_RECIPIENTS];
int64_t distributed = 0;

for (int i = 0; GUARD(MAX_RECIPIENTS), i < num_recipients; ++i) {
amounts[i] = (amount * shares[i]) / 100;
distributed += amounts[i];
trace(SBUF("Recipient share:"), amounts[i]);
}

// Handle rounding remainder (give to first recipient)
int64_t remainder = amount - distributed;
if (remainder > 0) {
amounts[0] += remainder;
trace(SBUF("Rounding remainder to r0:"), remainder);
}

// ========================================
// STEP 6: Check balance for emissions
// ========================================
// Estimate fees (rough: 20 drops per emission)
int64_t estimated_fees = num_recipients * 20;

if (!has_sufficient_balance(estimated_fees)) {
rollback(SBUF("Insufficient balance for fees"), 6);
}

// ========================================
// STEP 7: Reserve and emit payments
// ========================================
int64_t emit_reserved = etxn_reserve(num_recipients);

if (emit_reserved != num_recipients) {
rollback(SBUF("Could not reserve emissions"), 7);
}

int successful_emissions = 0;

for (int i = 0; GUARD(MAX_RECIPIENTS), i < num_recipients; ++i) {
if (amounts[i] == 0) continue; // Skip zero amounts

uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx, amounts[i], recipients[i], 0, 0);

uint8_t hash[32];
int64_t result = emit(SBUF(hash), SBUF(tx));

if (result == 32) {
successful_emissions++;
trace(SBUF("Emission successful, amount:"), amounts[i]);
} else {
trace(SBUF("Emission failed for recipient"), i);
}
}

trace(SBUF("Total successful emissions:"), successful_emissions);

// ========================================
// STEP 8: Update statistics
// ========================================
update_stats(amount);

// ========================================
// STEP 9: Accept the transaction
// ========================================
accept(SBUF("Payment distributed"), 0);
return 0;
}
```

int64_t cbak(uint32_t reserved) {
    _g(1, 1);

trace(SBUF("=== Emission Callback ==="), 0);
trace(SBUF("An emitted payment failed"), 0);

// Update failure count
uint8_t fail_buf[8];
int64_t len = state(SBUF(fail_buf), SBUF(KEY_FAILS));

int64_t fail_count = (len == 8) ? INT64_FROM_BUF(fail_buf) : 0;
fail_count++;

INT64_TO_BUF(fail_buf, fail_count);
state_set(SBUF(fail_buf), SBUF(KEY_FAILS));

trace(SBUF("Total failed emissions:"), fail_count);

return 0;
}
```


To configure the Hook, you need to set state values. This is typically done via another transaction or a setup script:

// JavaScript setup script example (using xrpl.js)

const setupRecipients = async (client, wallet, hookAccount) => {
// Define recipients and shares
const config = [
{ address: "rRecipient1Address...", share: 50 },
{ address: "rRecipient2Address...", share: 30 },
{ address: "rRecipient3Address...", share: 20 }
];

// Set number of recipients
const configTx = {
TransactionType: "Invoke",
Account: hookAccount,
// ... set state "config" = 3
};

// Set each recipient and share
for (let i = 0; i < config.length; i++) {
// Set state "r{i}" = account ID
// Set state "s{i}" = share
}
};
```

For simpler deployment, use Hook parameters at install time:

// Read configuration from parameters instead of state

int64_t load_config_from_params(
    uint8_t recipients[MAX_RECIPIENTS][ACCOUNT_SIZE],
    uint8_t shares[MAX_RECIPIENTS]
) {
    // Try to read recipient0 parameter
    int64_t len = otxn_param(recipients[0], ACCOUNT_SIZE, SBUF("recipient0"));
    if (len != ACCOUNT_SIZE) return 0;

uint8_t share_buf[1];
    len = otxn_param(SBUF(share_buf), SBUF("share0"));
    if (len != 1) return 0;
    shares[0] = share_buf[0];

// ... continue for other recipients

return num_found;
}

TEST CASES
  1. BASIC FUNCTIONALITY
  1. EDGE CASES
  1. ERROR HANDLING
  1. EMISSION HANDLING
// Test script structure

async function runTests() {
// Setup: Configure 2 recipients with 50/50 split
await setupRecipients(client, admin, hookAccount, [
{ address: recipient1, share: 50 },
{ address: recipient2, share: 50 }
]);

// TC1.1: Basic 100 XRP split
console.log("Test: 100 XRP split...");
await sendPayment(sender, hookAccount, "100");

const bal1 = await getBalance(recipient1);
const bal2 = await getBalance(recipient2);

assert(bal1 >= 50, "Recipient 1 should have ~50 XRP");
assert(bal2 >= 50, "Recipient 2 should have ~50 XRP");

// TC2.2: Below minimum
console.log("Test: Below minimum...");
try {
await sendPayment(sender, hookAccount, "9");
assert(false, "Should have rejected");
} catch (e) {
assert(e.message.includes("minimum"), "Should reject below minimum");
}

// ... more tests
}
```


PRE-DEPLOYMENT CHECKLIST

□ Code compiled without warnings
□ All guards present and correct
□ All test cases passing
□ Error messages are helpful
□ State schema documented
□ Configuration documented

DEPLOYMENT STEPS

  1. □ Compile to WASM
  2. □ Run guard-checker
  3. □ Deploy to testnet
  4. □ Configure recipients (via state)
  5. □ Test full workflow
  6. □ Verify statistics tracking
  7. □ Test edge cases on testnet
  8. □ Document final configuration

POST-DEPLOYMENT

□ Monitor first transactions
□ Verify distributions
□ Check callback behavior
□ Document any issues
```

PRODUCTION READINESS

Security:
├── Validate all inputs
├── Check balances before emissions
├── Handle all error cases
└── Audit state management

Performance:
├── Minimize state reads
├── Efficient guard values
├── Consider emission limits
└── Monitor execution cost

Maintenance:
├── Document state schema
├── Provide configuration guide
├── Plan for updates
└── Monitor failed emissions
```


Integration works. All Hook features can be combined effectively.

State + Emissions pattern is powerful. Enables sophisticated automation.

Error handling prevents failures. Comprehensive checks improve reliability.

⚠️ Emission timing. Recipients receive funds in subsequent ledgers, not immediately.

⚠️ Fee fluctuations. Network fees vary; hardcoded estimates may be wrong.

🔴 Misconfigured shares. Shares not totaling 100% should be rejected (we do this).

🔴 Balance depletion. Emission fees deplete Hook account over time.

🔴 Recipient changes. No mechanism to update recipients after deployment (could add).

Building complete Hook projects requires integrating many concepts, but the patterns are consistent. The Payment Splitter demonstrates a real-world use case with proper error handling. The main challenges are configuration management and emission reliability—both solvable with careful design.


Assignment: Build a complete Hook project of your choosing.

Requirements:

Choose ONE of the following projects:

  • Accept payments with memo specifying release time

  • Store escrow details in state

  • Auto-release after time passes (check ledger sequence)

  • Track customer purchases in state

  • Award bonus (emit payment) when threshold reached

  • Reset counter after reward

  • Monitor incoming payments

  • If > 100 XRP, emit offer to buy specific token

  • Track offers in state

For your chosen project, provide:

  1. Design Document (20%)

  2. Complete Code (40%)

  3. Test Plan (20%)

  4. Documentation (20%)

  • Functionality (30%)
  • Code quality (25%)
  • Error handling (20%)
  • Documentation (15%)
  • Testing (10%)

Time investment: 8-12 hours
Value: Complete portfolio piece demonstrating Hook mastery


Knowledge Check

Question 1 of 4

When building a Hook that uses both state and emissions, which order is safest?

  • Hooks Builder example library
  • XRPL-Labs Hook examples
  • State management patterns
  • Emission patterns
  • Error handling patterns

For Next Lesson:
We'll move to advanced topics—security auditing, optimization, and preparing for production deployment.


End of Lesson 14

Total words: ~4,600
Estimated completion time: 90 minutes reading + 8-12 hours for deliverable

Key Takeaways

1

Design before coding.

Define state schema, error handling, and test plan first.

2

Integrate incrementally.

Build one feature at a time, test, then combine.

3

Handle all errors.

Every operation that can fail should be checked.

4

Document thoroughly.

State layout, configuration, and deployment steps.

5

Test comprehensively.

Cover normal operation, edge cases, and error conditions. ---

Further Reading & Sources