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:
PAYMENT ACCEPTANCE
DISTRIBUTION RULES
STATE MANAGEMENT
EMISSIONS
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
- BASIC FUNCTIONALITY
- EDGE CASES
- ERROR HANDLING
- 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
- □ Compile to WASM
- □ Run guard-checker
- □ Deploy to testnet
- □ Configure recipients (via state)
- □ Test full workflow
- □ Verify statistics tracking
- □ Test edge cases on testnet
- □ 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:
Design Document (20%)
Complete Code (40%)
Test Plan (20%)
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 4When 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
Design before coding.
Define state schema, error handling, and test plan first.
Integrate incrementally.
Build one feature at a time, test, then combine.
Handle all errors.
Every operation that can fail should be checked.
Document thoroughly.
State layout, configuration, and deployment steps.
Test comprehensively.
Cover normal operation, edge cases, and error conditions. ---