Lesson 12: Emitting Transactions - Hooks That Send Payments
Learning Objectives
Reserve and emit transactions using the etxn_* API
Build transaction payloads using macros and manual serialization
Calculate emission fees correctly
Handle emission callbacks when emitted transactions fail
Understand emission constraints and design within them
Without emissions, Hooks can only accept or reject. With emissions, Hooks become autonomous actors:
WITHOUT EMISSIONS:
├── Accept transaction ✓
├── Reject transaction ✓
├── Modify state ✓
├── Send payments ✗
└── Passive only
WITH EMISSIONS:
├── Accept transaction ✓
├── Reject transaction ✓
├── Modify state ✓
├── Send payments ✓
├── Create offers ✓
├── Any XRPL transaction ✓
└── Active automation
```
EMISSION FLOW
- TRIGGER
- HOOK EXECUTION
- EMITTED TRANSACTION
- CALLBACK (if emission fails)
// Reserve emission slots (REQUIRED before emitting)
int64_t etxn_reserve(int32_t count);
// Get fee for transaction
int64_t etxn_fee_base(uint32_t tx_ptr, uint32_t tx_len);
// Emit the transaction
int64_t emit(
uint32_t write_ptr, uint32_t write_len, // Hash output (32 bytes)
uint32_t read_ptr, uint32_t read_len // Transaction blob
);
// Get emission details
int64_t etxn_details(uint32_t write_ptr, uint32_t write_len);
// Get deterministic nonce for emissions
int64_t etxn_nonce(uint32_t write_ptr, uint32_t write_len);
// Get emission generation number
int64_t etxn_generation(void);
// Calculate burden
int64_t etxn_burden(void);
```
#include "hookapi.h"
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Step 1: Reserve emission slots
int64_t reserved_count = etxn_reserve(1);
if (reserved_count != 1) {
rollback(SBUF("Could not reserve emission"), 1);
}
// Step 2: Prepare destination
uint8_t dest[20];
// ... set destination account ...
// Step 3: Build transaction
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(
tx, // Output buffer
1000000, // Amount: 1 XRP in drops
dest, // Destination account
0, // Destination tag (0 = none)
0 // Source tag (0 = none)
);
// Step 4: Emit
uint8_t hash[32];
int64_t result = emit(SBUF(hash), SBUF(tx));
if (result != 32) {
rollback(SBUF("Emission failed"), 2);
}
trace(SBUF("Emitted tx hash first byte:"), hash[0]);
accept(SBUF("Emission successful"), 0);
return 0;
}
```
The easiest way to build payment transactions:
// Macro signature
PREPARE_PAYMENT_SIMPLE(
buf_out, // uint8_t[PREPARE_PAYMENT_SIMPLE_SIZE]
drops, // int64_t amount in drops
to_address, // uint8_t[20] destination
dest_tag, // uint32_t destination tag (0 for none)
src_tag // uint32_t source tag (0 for none)
);
// Required buffer size
#define PREPARE_PAYMENT_SIMPLE_SIZE 270 // Approximate
// Example
uint8_t payment[PREPARE_PAYMENT_SIMPLE_SIZE];
uint8_t dest[20] = { /* destination account ID */ };
PREPARE_PAYMENT_SIMPLE(payment, 5000000, dest, 0, 0);
// payment now contains serialized 5 XRP payment
Where do you send emitted payments?
// Option 1: Hardcoded destination
uint8_t treasury[20] = {
0x12, 0x34, 0x56, /* ... 17 more bytes ... */
};
// Option 2: Extract from triggering transaction (forward back)
uint8_t sender[20];
otxn_field(SBUF(sender), sfAccount);
// Option 3: From Hook parameters
uint8_t dest[20];
int64_t len = otxn_param(SBUF(dest), SBUF("recipient"));
if (len != 20) {
rollback(SBUF("No recipient parameter"), 1);
}
// Option 4: From state
uint8_t dest[20];
int64_t len = state(SBUF(dest), SBUF("forward_address"));
```
// Emit multiple transactions
int64_t reserved = etxn_reserve(3); // Reserve 3 slots
if (reserved != 3) {
rollback(SBUF("Could not reserve 3 emissions"), 1);
}
// Emit to recipient 1
uint8_t tx1[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx1, 1000000, dest1, 0, 0);
uint8_t hash1[32];
emit(SBUF(hash1), SBUF(tx1));
// Emit to recipient 2
uint8_t tx2[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx2, 2000000, dest2, 0, 0);
uint8_t hash2[32];
emit(SBUF(hash2), SBUF(tx2));
// Emit to recipient 3
uint8_t tx3[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx3, 3000000, dest3, 0, 0);
uint8_t hash3[32];
emit(SBUF(hash3), SBUF(tx3));
accept(SBUF("Emitted 3 payments"), 0);
```
For transactions beyond simple payments:
// Manual serialization (advanced)
// Each transaction is a serialized object with specific fields
// Transaction structure:
// - TransactionType (required)
// - Account (required) - set automatically
// - Fee (required) - calculated via etxn_fee_base
// - Sequence (required) - set automatically
// - Emit details - added automatically
// - Type-specific fields
// Use sto_* functions for manual serialization
// (Complex - see documentation for full details)
Emitted transactions have fees calculated by the ledger:
// Get fee for a transaction
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx, 1000000, dest, 0, 0);
int64_t fee = etxn_fee_base(SBUF(tx));
trace(SBUF("Emission fee (drops):"), fee);
// Fee is paid from Hook account's balance
// Fee is calculated based on:
// - Transaction size
// - Current network load
// - Emission burden
// Burden: Computational cost multiplier
int64_t burden = etxn_burden();
trace(SBUF("Current burden:"), burden);
// Generation: How many Hooks deep
int64_t gen = etxn_generation();
trace(SBUF("Emission generation:"), gen);
// Generation 0: Transaction from user
// Generation 1: Emission from Hook triggered by user tx
// Generation 2: Emission from Hook triggered by emission
// ... limited to prevent cascade
// Burden increases with each generation
// Fees increase exponentially with burden
```
// Check balance before emitting
uint8_t hook_acc[20];
hook_account(SBUF(hook_acc));
// Load account object
uint8_t keylet[34];
util_keylet(SBUF(keylet), KEYLET_ACCOUNT,
(uint32_t)hook_acc, 20, 0, 0, 0, 0);
int64_t slot = slot_set(SBUF(keylet), 1);
if (slot < 0) {
rollback(SBUF("Could not load account"), 1);
}
int64_t balance = slot_subfield(1, sfBalance, 0);
trace(SBUF("Account balance:"), balance);
// Build transaction and check fee
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx, amount_to_send, dest, 0, 0);
int64_t fee = etxn_fee_base(SBUF(tx));
int64_t reserve = 10000000; // Approximate reserve
int64_t needed = amount_to_send + fee + reserve;
if (balance < needed) {
rollback(SBUF("Insufficient balance"), 1);
}
```
// cbak runs when an emitted transaction fails
int64_t cbak(uint32_t reserved) {
// This is called if:
// - Emitted transaction fails validation
// - Destination can't receive
// - Any other emission failure
// reserved parameter contains info about the failure
// You can:
// - Log the failure
// - Update state
// - Clean up
// - Cannot emit new transactions
return 0;
}
```
int64_t cbak(uint32_t reserved) {
_g(1, 1);
trace(SBUF("Emission callback - transaction failed!"), 0);
// Read failure details
uint8_t details[128];
int64_t len = etxn_details(SBUF(details));
if (len > 0) {
trace(SBUF("Details length:"), len);
}
// Update state to record failure
uint8_t count_buf[8];
uint8_t key[] = "failed_emissions";
int64_t slen = state(SBUF(count_buf), SBUF(key));
int64_t count = (slen == 8) ? INT64_FROM_BUF(count_buf) : 0;
count++;
INT64_TO_BUF(count_buf, count);
state_set(SBUF(count_buf), SBUF(key));
trace(SBUF("Failed emission count:"), count);
return 0;
}
```
// IN cbak() YOU CANNOT:
// - Emit new transactions (etxn_reserve fails)
// - Accept/rollback the original transaction (already done)
// IN cbak() YOU CAN:
// - Read state
// - Write state
// - Log/trace
// - Access some transaction info
```
// Forward all incoming payments to another account
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Only process payments
if (otxn_type() != 0) {
accept(SBUF("Not payment"), 0);
}
// Only process incoming (to this account)
uint8_t hook_acc[20];
hook_account(SBUF(hook_acc));
uint8_t dest[20];
otxn_field(SBUF(dest), sfDestination);
int incoming = 1;
for (int i = 0; GUARD(20), i < 20; ++i) {
if (dest[i] != hook_acc[i]) {
incoming = 0;
break;
}
}
if (!incoming) {
accept(SBUF("Outgoing payment"), 0);
}
// Get amount
int64_t amount = otxn_amount();
if (amount <= 0) {
accept(SBUF("Not XRP or zero"), 0);
}
// Forward address (from state or hardcoded)
uint8_t forward[20];
int64_t flen = state(SBUF(forward), SBUF("forward_to"));
if (flen != 20) {
rollback(SBUF("No forward address configured"), 1);
}
// Reserve and emit
etxn_reserve(1);
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx, amount, forward, 0, 0);
uint8_t hash[32];
int64_t result = emit(SBUF(hash), SBUF(tx));
if (result != 32) {
rollback(SBUF("Forward failed"), 2);
}
trace(SBUF("Forwarded:"), amount);
accept(SBUF("Payment forwarded"), 0);
return 0;
}
```
// Split incoming payments: 70% to A, 30% to B
#define SHARE_A_PCT 70
#define SHARE_B_PCT 30
uint8_t recipient_a[20] = { /* account A / };
uint8_t recipient_b[20] = { / account B */ };
int64_t hook(uint32_t reserved) {
_g(1, 1);
if (otxn_type() != 0) {
accept(SBUF("Not payment"), 0);
}
int64_t amount = otxn_amount();
if (amount <= 0) {
accept(SBUF("Not XRP"), 0);
}
// Calculate shares
int64_t share_a = (amount * SHARE_A_PCT) / 100;
int64_t share_b = (amount * SHARE_B_PCT) / 100;
// Handle rounding (give remainder to A)
int64_t remainder = amount - share_a - share_b;
share_a += remainder;
trace(SBUF("Share A:"), share_a);
trace(SBUF("Share B:"), share_b);
// Reserve for 2 emissions
etxn_reserve(2);
// Emit to A
uint8_t tx_a[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx_a, share_a, recipient_a, 0, 0);
uint8_t hash_a[32];
emit(SBUF(hash_a), SBUF(tx_a));
// Emit to B
uint8_t tx_b[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx_b, share_b, recipient_b, 0, 0);
uint8_t hash_b[32];
emit(SBUF(hash_b), SBUF(tx_b));
accept(SBUF("Revenue split"), 0);
return 0;
}
```
// Refund overpayment: Keep 10 XRP, refund excess
#define KEEP_AMOUNT 10000000 // 10 XRP
int64_t hook(uint32_t reserved) {
_g(1, 1);
if (otxn_type() != 0) {
accept(SBUF("Not payment"), 0);
}
int64_t amount = otxn_amount();
if (amount <= KEEP_AMOUNT) {
// No refund needed
accept(SBUF("Under/at limit"), 0);
}
// Calculate refund
int64_t refund = amount - KEEP_AMOUNT;
trace(SBUF("Refunding:"), refund);
// Get sender for refund
uint8_t sender[20];
otxn_field(SBUF(sender), sfAccount);
// Emit refund
etxn_reserve(1);
uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE];
PREPARE_PAYMENT_SIMPLE(tx, refund, sender, 0, 0);
uint8_t hash[32];
emit(SBUF(hash), SBUF(tx));
accept(SBUF("Excess refunded"), 0);
return 0;
}
```
EMISSION CONSTRAINTS
Reservations:
├── Must reserve before emitting
├── Reserve count must match actual emissions
├── Cannot reserve in cbak()
└── Limited by burden/generation
Generations:
├── Max ~3 generations typically
├── Fees increase exponentially
├── Prevents infinite cascade
└── Design shallow emission chains
Fees:
├── Paid from Hook account
├── Calculated based on load/burden
├── Must have sufficient balance
└── Check before emitting
Timing:
├── Emitted tx goes to NEXT ledger
├── Not atomic with triggering tx
├── May fail independently
└── Use cbak() for failure handling
```
// You CAN emit:
// - Payment (most common)
// - EscrowCreate
// - EscrowFinish
// - EscrowCancel
// - OfferCreate
// - OfferCancel
// - TrustSet
// - Many other transaction types
// You CANNOT emit:
// - SetHook (security restriction)
// - AccountDelete
// - Some administrative transactions
```
// DANGEROUS: Infinite loops via emissions
// Hook A emits to Hook B, Hook B emits to Hook A
// → Fees grow exponentially with generation
// → Eventually too expensive to continue
// → But still wastes resources
// SAFE PATTERN: Check generation
int64_t gen = etxn_generation();
if (gen > 2) {
// Don't emit from deeply nested Hook
accept(SBUF("Generation limit"), 0);
}
```
✅ Emissions work reliably. Emitted transactions execute in subsequent ledgers.
✅ Fee model prevents abuse. Exponentially increasing fees stop infinite cascades.
✅ Callbacks handle failures. cbak() provides notification when emissions fail.
⚠️ Complex emission chains. Multi-Hook cascades are hard to reason about.
⚠️ Timing assumptions. Emitted transactions aren't guaranteed to succeed.
🔴 Not reserving before emit. Causes immediate failure.
🔴 Insufficient balance for fees. Emissions fail, possibly silently.
🔴 Infinite emission chains. Fees stop them, but waste resources and XRP.
🔴 Assuming atomicity. Trigger tx and emissions are NOT atomic.
Emissions make Hooks truly powerful—autonomous agents that can move value. But this power requires careful design: always check balances, handle failures, avoid deep chains, and remember that emissions aren't atomic with the triggering transaction. The patterns shown (forward, split, refund) cover most common use cases.
Assignment: Build a Hook that uses emissions for a practical use case.
Requirements:
Implement a "Savings Jar" Hook that:
Accept incoming XRP payments
Track total deposited (state)
Log each deposit
On each deposit, emit 10% to a "savings" address
The savings address is stored in state (configurable)
Log the savings amount
If payment has source tag = 1, it's a withdrawal request
Emit the deposited amount back to sender
Clear the deposit tracking
Implement cbak() to log emission failures
Track failed emission count in state
Don't rollback the original deposit on emission failure
Code structure:
#include "hookapi.h"
#define SAVINGS_PCT 10
#define WITHDRAWAL_TAG 1
int64_t hook(uint32_t reserved) {
_g(1, 1);
// Determine if deposit or withdrawal
// Process accordingly
// Emit savings or withdrawal
return 0;
}
int64_t cbak(uint32_t reserved) {
// Log failure
// Update state
return 0;
}
- Correct emission implementation (35%)
- Proper state management (25%)
- Withdrawal logic works (20%)
- Callback handles failures (20%)
Time investment: 4-5 hours
Value: Practical pattern for financial automation
Knowledge Check
Question 1 of 5What must be called before emit()?
- xrpl-hooks.readme.io/docs/emitted-transactions
- Transaction building in hooks-toolkit
- XRPL fee escalation
- Burden calculation
For Next Lesson:
We'll explore accessing ledger state—reading account balances, trust lines, and other ledger objects from within your Hook.
End of Lesson 12
Total words: ~4,400
Estimated completion time: 65 minutes reading + 4-5 hours for deliverable
Key Takeaways
Reserve before emitting.
etxn_reserve(n) must be called before any emit().
Use PREPARE_PAYMENT_SIMPLE for basic payments.
Handles serialization automatically.
Fees increase with burden/generation.
Design shallow emission chains.
Emissions aren't atomic.
The trigger tx succeeds before emissions execute.
Handle failures in cbak().
Emitted transactions can fail—prepare for it. ---