Transaction Submission and Monitoring - The Complete Flow
Learning Objectives
Construct valid transactions with all required fields
Implement the complete submission flow (autofill → sign → submit → confirm)
Manage sequence numbers correctly for single and concurrent submissions
Interpret transaction result codes and determine appropriate actions
Build reliable transaction monitoring that handles all edge cases
Querying the ledger is safe—you're just reading data. Transaction submission is different:
WHAT CAN GO WRONG:
1. Transaction never submitted (network error)
1. Transaction submitted but rejected (validation error)
1. Transaction submitted, pending, then fails (consensus rejection)
1. Transaction submitted twice (retry logic bug)
1. Transaction succeeds but you don't see confirmation
1. Transaction expires (LastLedgerSequence passed)
The patterns in this lesson handle all these cases reliably.
---
Every XRPL transaction has common fields:
{
// REQUIRED - Set by you
"TransactionType": "Payment", // What kind of transaction
"Account": "rSender...", // Who is sending (signing account)
// REQUIRED - Usually autofilled
"Fee": "12", // Transaction cost in drops
"Sequence": 42, // Sender's sequence number
// HIGHLY RECOMMENDED
"LastLedgerSequence": 87654400, // Expiration ledger
// TYPE-SPECIFIC - Depends on TransactionType
"Destination": "rRecipient...", // For Payment
"Amount": "25000000", // For Payment (25 XRP in drops)
}
- `Payment` - Send XRP or issued currencies
- `OfferCreate` - Create DEX order
- `OfferCancel` - Cancel DEX order
- `TrustSet` - Modify trust line
- `AccountSet` - Change account settings
- `EscrowCreate`, `EscrowFinish`, `EscrowCancel` - Escrow operations
- `NFTokenMint`, `NFTokenCreateOffer`, etc. - NFT operations
Account: The address initiating the transaction. Must match the signing key.
Fee: Transaction cost in drops. Minimum is typically 10-12 drops, but increases under load.
Sequence: A counter that increments with each transaction from an account. Must exactly match the account's current sequence number.
LastLedgerSequence: The last ledger where this transaction can be included. After this ledger closes, the transaction expires and is guaranteed to never succeed. This is your safety net.
Payment Example:
{
"TransactionType": "Payment",
"Account": "rSender...",
"Destination": "rRecipient...",
"Amount": "25000000", // 25 XRP
// Optional
"DestinationTag": 12345, // Required by some recipients
"InvoiceID": "ABC123...", // 256-bit hash for reference
"Paths": [...], // For cross-currency
"SendMax": "...", // Maximum to send (for cross-currency)
}OfferCreate Example:
{
"TransactionType": "OfferCreate",
"Account": "rTrader...",
"TakerGets": "100000000", // 100 XRP (what taker receives)
"TakerPays": { // What taker pays
"currency": "USD",
"issuer": "rIssuer...",
"value": "50"
},
// Optional flags
"Flags": 65536 // tfPassive
}TRANSACTION SUBMISSION FLOW:
1. CONSTRUCT Build transaction object with required fields
2. AUTOFILL Add Fee, Sequence, LastLedgerSequence
3. SIGN Cryptographically sign with private key
4. SUBMIT Send to network
5. MONITOR Watch for inclusion in validated ledger
6. CONFIRM Verify final result
Build the transaction with fields you control:
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: xrpl.xrpToDrops('25'),
DestinationTag: 12345 // If recipient requires
}Validation Before Proceeding:
function validatePayment(tx) {
const errors = []
if (!xrpl.isValidAddress(tx.Account)) {
errors.push('Invalid Account address')
}
if (!xrpl.isValidAddress(tx.Destination)) {
errors.push('Invalid Destination address')
}
if (tx.Account === tx.Destination) {
errors.push('Cannot send to self')
}
const amount = parseInt(tx.Amount)
if (isNaN(amount) || amount <= 0) {
errors.push('Invalid Amount')
}
if (amount < 1) {
errors.push('Amount too small (minimum 1 drop)')
}
return errors
}
- **Fee:** Current transaction cost
- **Sequence:** Your account's next sequence number
- **LastLedgerSequence:** Current ledger + buffer (usually 20)
// Using xrpl.js
const prepared = await client.autofill(payment)
console.log(prepared)
// {
// TransactionType: 'Payment',
// Account: 'rSender...',
// Destination: 'rRecipient...',
// Amount: '25000000',
// Fee: '12',
// Sequence: 42,
// LastLedgerSequence: 87654420
// }
```
Manual Autofill (Understanding What Happens):
async function manualAutofill(client, tx) {
// Get account info for sequence
const accountInfo = await client.request({
command: 'account_info',
account: tx.Account,
ledger_index: 'current'
})
// Get fee
const feeResult = await client.request({ command: 'fee' })
// Get current ledger for LastLedgerSequence
const serverInfo = await client.request({ command: 'server_info' })
const currentLedger = serverInfo.result.info.validated_ledger.seq
return {
...tx,
Fee: feeResult.result.drops.median_fee,
Sequence: accountInfo.result.account_data.Sequence,
LastLedgerSequence: currentLedger + 20 // ~60-100 seconds
}
}
Signing creates a cryptographic proof that you authorized the transaction:
// Using xrpl.js Wallet
const signed = wallet.sign(prepared)
console.log(signed)
// {
// tx_blob: '1200002280000000240000002A2E00000...', // Serialized + signed
// hash: 'ABC123...' // Transaction hash (ID)
// }
Critical: Once signed, the transaction cannot be modified. The hash identifies this exact transaction.
Send the signed transaction to the network:
const submitResult = await client.request({
command: 'submit',
tx_blob: signed.tx_blob
})
console.log(submitResult.result)
// {
// engine_result: 'tesSUCCESS', // or 'terQUEUED', 'tefPAST_SEQ', etc.
// engine_result_code: 0,
// engine_result_message: 'The transaction was applied.',
// tx_blob: '...',
// tx_json: { ... }
// }
Understanding Submit Response:
The submit response tells you the preliminary result—whether the transaction was accepted for processing. This is NOT the final result.
SUBMIT RESPONSE (engine_result):
tesSUCCESS Transaction applied to current ledger (pending validation)
terQUEUED Queued for later (sequence gap or load)
tefPAST_SEQ Sequence already used (might be duplicate)
tefMAX_LEDGER Transaction expired (LastLedgerSequence passed)
temINVALID Transaction malformed
tecPATH_DRY No path for cross-currency payment
After submission, monitor for the transaction in validated ledgers:
async function waitForValidation(client, txHash, lastLedger) {
while (true) {
// Check transaction status
try {
const result = await client.request({
command: 'tx',
transaction: txHash
})
if (result.result.validated) {
return result.result // Final result!
}
} catch (error) {
// Transaction not found yet - might still be pending
}
// Check if expired
const serverInfo = await client.request({ command: 'server_info' })
const currentLedger = serverInfo.result.info.validated_ledger.seq
if (currentLedger > lastLedger) {
throw new Error('Transaction expired (LastLedgerSequence passed)')
}
// Wait before checking again
await sleep(1000)
}
}
Once validated, check the final result:
function interpretResult(txResult) {
const resultCode = txResult.meta.TransactionResult
if (resultCode === 'tesSUCCESS') {
return {
success: true,
message: 'Transaction succeeded',
// For payments, get actual delivered amount
deliveredAmount: txResult.meta.delivered_amount
}
}
// Transaction was included but failed
// Fee was consumed, but action didn't happen
return {
success: false,
message: `Transaction failed: ${resultCode}`,
code: resultCode
}
}
PREFIX MEANING FEE CONSUMED? ACTION
──────────────────────────────────────────────────────────────
tes Success Yes Done!
tec Claim (failed but valid) Yes Handle failure
tef Failure (not applied) No May retry
tem Malformed (invalid) No Fix and retry
ter Retry (temporary issue) No Retry later- `tesSUCCESS` - Transaction succeeded
- `tecNO_DST` - Destination account doesn't exist
- `tecNO_DST_INSUF_XRP` - Would create account but amount too low
- `tecUNFUNDED_PAYMENT` - Insufficient balance
- `tecPATH_DRY` - No path for cross-currency
- `tecDST_TAG_NEEDED` - Destination requires tag
- `tecNO_PERMISSION` - Not authorized
- `tefPAST_SEQ` - Sequence already used
- `tefMAX_LEDGER` - LastLedgerSequence exceeded
- `tefALREADY` - Transaction already in ledger
- `temBAD_AMOUNT` - Invalid amount
- `temBAD_SEQUENCE` - Invalid sequence
- `temINVALID` - Generally invalid
- `temREDUNDANT` - Does nothing
- `terQUEUED` - Queued for future ledger
- `terPRE_SEQ` - Waiting for earlier sequence
- `terINSUF_FEE_B` - Fee too low for current load
function handleTransactionResult(engineResult, txResult) {
const prefix = engineResult.substring(0, 3)
switch (prefix) {
case 'tes':
return { status: 'success', retry: false }
case 'tec':
// Failed but fee claimed - don't retry same transaction
return {
status: 'failed',
retry: false,
reason: engineResult
}
case 'tef':
if (engineResult === 'tefPAST_SEQ') {
// Might be duplicate, check if already succeeded
return { status: 'check_duplicate', retry: false }
}
if (engineResult === 'tefMAX_LEDGER') {
// Expired, safe to retry with new sequence
return { status: 'expired', retry: true }
}
return { status: 'failed', retry: false }
case 'tem':
// Fix the transaction, don't retry as-is
return { status: 'malformed', retry: false }
case 'ter':
// Temporary, retry after delay
return { status: 'temporary', retry: true, delay: 5000 }
default:
return { status: 'unknown', retry: false }
}
}
```
Each account has a sequence number that increments with each transaction:
Account Sequence: 42
Transaction A (Seq 42) → Succeeds → Account Sequence: 43
Transaction B (Seq 43) → Succeeds → Account Sequence: 44
Transaction C (Seq 42) → Fails (tefPAST_SEQ) - sequence already used
- Concurrent transactions (two with same sequence)
- Failed transactions (sequence consumed or not?)
- Network issues (don't know if transaction succeeded)
For one transaction at a time, autofill handles sequence:
// Simple case: one transaction, wait for result before next
const tx1 = await client.autofill(payment1)
const signed1 = wallet.sign(tx1)
const result1 = await client.submitAndWait(signed1.tx_blob)
// Now safe to send next transaction
const tx2 = await client.autofill(payment2)
// ...
For multiple transactions simultaneously, you must manage sequences:
class SequenceManager {
constructor(client, account) {
this.client = client
this.account = account
this.localSequence = null
this.lock = new AsyncLock()
}
async initialize() {
const info = await this.client.request({
command: 'account_info',
account: this.account,
ledger_index: 'current'
})
this.localSequence = info.result.account_data.Sequence
}
async getNextSequence() {
return this.lock.acquire('sequence', async () => {
if (this.localSequence === null) {
await this.initialize()
}
const seq = this.localSequence
this.localSequence++
return seq
})
}
releaseSequence(seq) {
// Call if transaction definitely failed (no fee consumed)
// Be careful - wrong usage causes issues
this.lock.acquire('sequence', () => {
if (seq === this.localSequence - 1) {
this.localSequence--
}
})
}
async resync() {
// Resync with network if unsure of state
await this.lock.acquire('sequence', async () => {
const info = await this.client.request({
command: 'account_info',
account: this.account,
ledger_index: 'validated'
})
this.localSequence = info.result.account_data.Sequence
})
}
}
SEQUENCE MANAGEMENT RULES:
1. For simple use cases, let autofill handle it
1. For concurrent submissions, manage locally
1. When uncertain, resync with network
1. Use LastLedgerSequence religiously
1. Idempotency through deduplication
---
class ReliableSubmitter {
constructor(client, wallet, options = {}) {
this.client = client
this.wallet = wallet
this.options = {
maxRetries: 3,
retryDelay: 2000,
ledgerBuffer: 20, // LastLedgerSequence = current + buffer
...options
}
}
async submit(transaction) {
let lastError = null
for (let attempt = 0; attempt < this.options.maxRetries; attempt++) {
try {
return await this.attemptSubmission(transaction, attempt)
} catch (error) {
lastError = error
if (!error.retryable) {
throw error
}
console.log(Attempt ${attempt + 1} failed, retrying...)
await sleep(this.options.retryDelay * (attempt + 1))
}
}
throw lastError
}
async attemptSubmission(transaction, attempt) {
// Step 1: Autofill
const prepared = await this.client.autofill(transaction)
// Adjust LastLedgerSequence for retries
const serverInfo = await this.client.request({ command: 'server_info' })
const currentLedger = serverInfo.result.info.validated_ledger.seq
prepared.LastLedgerSequence = currentLedger + this.options.ledgerBuffer
// Step 2: Sign
const signed = this.wallet.sign(prepared)
const txHash = signed.hash
console.log(Submitting ${txHash} (attempt ${attempt + 1}))
// Step 3: Submit
const submitResult = await this.client.request({
command: 'submit',
tx_blob: signed.tx_blob
})
const engineResult = submitResult.result.engine_result
// Handle immediate failures
if (engineResult.startsWith('tem')) {
const error = new Error(Transaction malformed: ${engineResult})
error.retryable = false
throw error
}
if (engineResult === 'tefPAST_SEQ') {
// Check if this exact transaction already succeeded
const existing = await this.checkExisting(txHash)
if (existing) {
return existing
}
// Different transaction used the sequence
const error = new Error('Sequence already used')
error.retryable = true // Will get new sequence on retry
throw error
}
// Step 4 & 5: Monitor and Confirm
return await this.waitForResult(
txHash,
prepared.LastLedgerSequence
)
}
async waitForResult(txHash, lastLedger) {
const startTime = Date.now()
const timeout = 120000 // 2 minutes max
while (Date.now() - startTime < timeout) {
// Check transaction status
try {
const result = await this.client.request({
command: 'tx',
transaction: txHash
})
if (result.result.validated) {
const txResult = result.result.meta.TransactionResult
if (txResult === 'tesSUCCESS') {
return {
success: true,
hash: txHash,
result: result.result
}
} else {
// Transaction included but failed
const error = new Error(Transaction failed: ${txResult})
error.retryable = false
error.result = result.result
throw error
}
}
} catch (error) {
if (error.data?.error !== 'txnNotFound') {
throw error
}
// Transaction not found yet - continue waiting
}
// Check expiration
const serverInfo = await this.client.request({ command: 'server_info' })
const currentLedger = serverInfo.result.info.validated_ledger.seq
if (currentLedger > lastLedger) {
const error = new Error('Transaction expired')
error.retryable = true
throw error
}
await sleep(1000)
}
const error = new Error('Timeout waiting for validation')
error.retryable = true
throw error
}
async checkExisting(txHash) {
try {
const result = await this.client.request({
command: 'tx',
transaction: txHash
})
if (result.result.validated) {
return {
success: result.result.meta.TransactionResult === 'tesSUCCESS',
hash: txHash,
result: result.result,
wasExisting: true
}
}
} catch (error) {
// Not found
}
return null
}
}
// Usage
const submitter = new ReliableSubmitter(client, wallet)
const result = await submitter.submit({
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: xrpl.xrpToDrops('25')
})
console.log(Transaction ${result.hash} succeeded!)
```
xrpl.js provides submitAndWait which handles much of this:
// Simplified approach using xrpl.js
const payment = {
TransactionType: 'Payment',
Account: wallet.address,
Destination: 'rRecipient...',
Amount: xrpl.xrpToDrops('25')
}
try {
const result = await client.submitAndWait(payment, { wallet })
console.log('Success:', result.result.meta.TransactionResult)
} catch (error) {
console.error('Failed:', error)
}
Note `submitAndWait` is convenient but may not handle all edge cases for your specific needs. Understand what it does so you can extend it.
- Prevent spam (minimum cost per transaction)
- Prioritize during congestion (higher fee = higher priority)
Current Fee Levels:
BASE FEE: 10 drops (0.00001 XRP) - minimum in normal conditions
MEDIAN FEE: 12 drops - typical fee
OPEN LEDGER: Variable - current fee for immediate inclusion
QUEUE FEE: Variable - minimum fee to enter queueconst feeResult = await client.request({ command: 'fee' })
console.log(feeResult.result)
// {
// drops: {
// base_fee: '10',
// median_fee: '12',
// minimum_fee: '10',
// open_ledger_fee: '12'
// },
// current_queue_size: '0',
// expected_ledger_size: '1000',
// ledger_current_index: 87654321,
// levels: { ... },
// max_queue_size: '2000'
// }
```
function calculateFee(feeResult, priority = 'normal') {
const fees = feeResult.result.drops
switch (priority) {
case 'low':
// Minimum - may queue during congestion
return fees.minimum_fee
case 'normal':
// Median - usually sufficient
return fees.median_fee
case 'high':
// Open ledger - for immediate inclusion
return fees.open_ledger_fee
case 'urgent':
// Above open ledger for guaranteed priority
return String(parseInt(fees.open_ledger_fee) * 2)
default:
return fees.median_fee
}
}
```
During congestion, the network applies a load_factor multiplier:
async function getEffectiveFee(client, baseFee = 12) {
const serverInfo = await client.request({ command: 'server_info' })
const loadFactor = serverInfo.result.info.load_factor || 1
// Fee is multiplied by load factor during congestion
const effectiveFee = Math.ceil(baseFee * loadFactor)
return String(effectiveFee)
}
✅ LastLedgerSequence prevents limbo: With expiration, you always know the outcome eventually
✅ Sequence management is essential: Incorrect sequences cause immediate failures or duplicate transactions
✅ Result codes are reliable: The categorization (tes/tec/tef/tem/ter) indicates correct handling
✅ submitAndWait works for simple cases: Library method handles common scenarios well
⚠️ Optimal retry strategies: Network conditions vary; tune delays and attempts for your use case
⚠️ Fee prediction during spikes: Load can change between fee query and submission
⚠️ Concurrent submission limits: How many parallel transactions are safe varies by infrastructure
🔴 Not using LastLedgerSequence: Transactions could be pending indefinitely—you'll never know
🔴 Blind retries: Retrying without checking if original succeeded can cause double-spends
🔴 Ignoring tec results: Fee was consumed, action failed—don't assume partial success
🔴 Trusting submit response as final: Submit only means "accepted for processing"
Transaction submission is the most complex part of XRPL integration. The patterns in this lesson have been refined through real-world failures. Use LastLedgerSequence always, handle all result code categories, and never assume success without validation confirmation. The reliable submission pattern isn't over-engineering—it's the minimum for production safety.
Assignment: Build a production-ready transaction submission module.
Requirements:
Part 1: Core Submission (40%)
- Handles the complete 6-step flow
- Sets appropriate LastLedgerSequence
- Waits for validation
- Returns structured result (success/failure with details)
Part 2: Error Handling (30%)
tes*- Return successtec*- Return failure with reasontef*- Handle appropriately (duplicate check, expiration)tem*- Return malformed error with detailster*- Retry logic
Part 3: Retry Logic (20%)
- Retry on expiration with new sequence
- Retry on temporary errors with backoff
- Don't retry on permanent failures
- Maximum retry limit
Part 4: Testing (10%)
Successful payment
Payment to non-existent account
Insufficient balance
Transaction expiration (use very low LastLedgerSequence)
Correctness of submission flow: 30%
Error handling completeness: 30%
Retry logic appropriateness: 20%
Code quality and documentation: 20%
Time Investment: 3-4 hours
Submission: Code module with tests and usage examples
Value: This is production code. You'll use this module (or its patterns) in every XRPL application you build.
1. Result Code Interpretation (Tests Understanding):
A transaction returns tecUNFUNDED_PAYMENT. What does this mean and what should your code do?
A) Transaction succeeded with partial funding - credit the partial amount
B) Transaction failed, fee was consumed - don't retry, report insufficient funds
C) Transaction failed, no fee consumed - retry with more funds
D) Transaction is pending - wait for validation
Correct Answer: B
Explanation: tec prefix means the transaction was included in a validated ledger (fee consumed) but the action failed. tecUNFUNDED_PAYMENT specifically means insufficient balance. The fee was claimed, but no payment occurred. Don't retry the same transaction; inform the user of insufficient funds.
2. Sequence Management (Tests Application):
Your application submits two payments concurrently. Both use sequence 42. What happens?
A) Both succeed with the same transaction hash
B) One succeeds, one fails with tefPAST_SEQ
C) Both fail due to conflict
D) The network merges them into one transaction
Correct Answer: B
Explanation: Each sequence number can only be used once. When two transactions with the same sequence are submitted, one will be included in a ledger (sequence consumed), and the other will fail with tefPAST_SEQ (sequence already used). If they're identical transactions, they'll have the same hash and only one can succeed (duplicate).
3. LastLedgerSequence Purpose (Tests Knowledge):
Why is LastLedgerSequence critical for reliable transaction handling?
A) It makes transactions process faster
B) It guarantees you'll know the final outcome within a bounded time
C) It reduces transaction fees
D) It prevents duplicate transactions
Correct Answer: B
Explanation: Without LastLedgerSequence, a transaction could theoretically be pending forever—you'd never know if it will eventually succeed or fail. With LastLedgerSequence, once that ledger passes, the transaction is guaranteed to never succeed, making it safe to retry or report failure. This bounded uncertainty is essential for reliable applications.
4. Submit Response (Tests Critical Thinking):
client.submit() returns engine_result: 'tesSUCCESS'. Is the transaction complete?
A) Yes - tesSUCCESS means the transaction succeeded
B) No - this is preliminary; must wait for validation
C) Maybe - depends on the transaction type
D) Yes - but only for payments, not other transaction types
Correct Answer: B
Explanation: The submit response indicates how the server's transaction engine initially processed the transaction. tesSUCCESS means it was applied to the current (non-validated) ledger. However, until consensus validates that ledger, the result isn't final—the transaction could still fail or the ledger could be different. Only check validated: true in a tx query response to confirm success.
5. Retry Strategy (Tests Comprehension):
When should you automatically retry a transaction?
A) On any error - persistence leads to success
B) On tefMAX_LEDGER (expired) - safe to retry with new sequence
C) On tecUNFUNDED_PAYMENT - maybe funds arrived
D) Never - manual intervention is always required
Correct Answer: B
Explanation: tefMAX_LEDGER means the transaction expired without being included—it definitely didn't succeed and never will. It's safe to retry with a fresh autofill (new sequence, new LastLedgerSequence). Option A is dangerous (would retry permanent failures). Option C is wrong (tec means fee was consumed; the specific transaction failed permanently). Option D is overly conservative.
- submit: https://xrpl.org/submit.html
- tx: https://xrpl.org/tx.html
- Transaction Types: https://xrpl.org/transaction-types.html
- Transaction Results: https://xrpl.org/transaction-results.html
- Transaction Lifecycle: https://xrpl.org/transaction-basics.html
- Sequence Numbers: https://xrpl.org/basic-data-types.html#account-sequence
- Fees: https://xrpl.org/transaction-cost.html
- Reliable Transaction Submission: https://xrpl.org/reliable-transaction-submission.html
For Next Lesson:
Lesson 7 builds on transaction submission to cover payment integration patterns—from simple XRP transfers to cross-currency payments with pathfinding. We'll address the critical partial payment vulnerability and build safe payment handling.
End of Lesson 6
Total words: ~5,400
Estimated completion time: 60 minutes reading + 3-4 hours for deliverable
Key Takeaways
Follow the six-step flow:
Construct → Autofill → Sign → Submit → Monitor → Confirm. Each step has a purpose; skipping steps causes problems.
LastLedgerSequence is mandatory:
It guarantees you'll know the outcome. Without it, transactions can be pending indefinitely.
Understand result code categories:
tes = success, tec = failed (fee consumed), tef = failed (no fee), tem = malformed, ter = retry. Handle each appropriately.
Manage sequences carefully:
For single transactions, autofill works. For concurrent transactions, track sequences locally and handle failures.
Don't trust submit response alone:
It's preliminary. Only `validated: true` with `tesSUCCESS` means the transaction succeeded. ---