Lesson 12: Emitting Transactions - Hooks That Send Payments | 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
beginner65 min

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
  1. TRIGGER
  1. HOOK EXECUTION
  1. EMITTED TRANSACTION
  1. 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 5

What 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

1

Reserve before emitting.

etxn_reserve(n) must be called before any emit().

2

Use PREPARE_PAYMENT_SIMPLE for basic payments.

Handles serialization automatically.

3

Fees increase with burden/generation.

Design shallow emission chains.

4

Emissions aren't atomic.

The trigger tx succeeds before emissions execute.

5

Handle failures in cbak().

Emitted transactions can fail—prepare for it. ---