|
| 1 | +--- |
| 2 | +title: "Stellar Troubleshooting Guide" |
| 3 | +description: "A comprehensive reference for common Stellar, Soroban, and Stealth errors" |
| 4 | +--- |
| 5 | + |
| 6 | +_Last Verified: June 24, 2026_ |
| 7 | + |
| 8 | +When building on Stellar or Soroban with Wraith, you might encounter opaque errors. This guide catalogs the most common errors, what they mean, and how to fix them. |
| 9 | + |
| 10 | +## Account & Balance Errors |
| 11 | + |
| 12 | +### 1. `tx_bad_seq` |
| 13 | +**Meaning**: The transaction's sequence number does not match the account's current sequence number on the ledger. |
| 14 | +**Cause**: Sending multiple transactions from the same account in parallel without incrementing the sequence number correctly, or a previous transaction failed/was dropped but the sequence number was incremented locally. |
| 15 | +**Fix**: Refetch the account from Horizon/RPC to get the current sequence number before building the transaction. |
| 16 | +```typescript |
| 17 | +const account = await server.loadAccount(publicKey); |
| 18 | +// Build transaction using the fresh account object |
| 19 | +const tx = new StellarSdk.TransactionBuilder(account, { fee: "100" }) |
| 20 | + // ... |
| 21 | + .build(); |
| 22 | +``` |
| 23 | + |
| 24 | +### 2. `op_underfunded` |
| 25 | +**Meaning**: The account does not have enough XLM (or the specific asset) to execute the operation. |
| 26 | +**Cause**: Attempting to send more funds than the account holds, or not accounting for the base reserve and transaction fees. |
| 27 | +**Fix**: Check the account balance and ensure it is greater than the transfer amount + fees + base reserve. |
| 28 | +```typescript |
| 29 | +const account = await server.loadAccount(publicKey); |
| 30 | +const xlmBalance = account.balances.find(b => b.asset_type === 'native').balance; |
| 31 | +console.log(`Available XLM: ${xlmBalance}`); |
| 32 | +// Ensure transferAmount < (xlmBalance - baseReserve - fees) |
| 33 | +``` |
| 34 | + |
| 35 | +### 3. `op_low_reserve` / Base Reserve Below Threshold |
| 36 | +**Meaning**: The operation would drop the account's balance below the minimum required base reserve. |
| 37 | +**Cause**: Creating new trustlines, signers, or data entries requires an additional base reserve (currently 0.5 XLM per entry). |
| 38 | +**Fix**: Send additional XLM to the account to cover the reserve for the new entries. |
| 39 | +```typescript |
| 40 | +// Fund the account with extra XLM for the new trustline |
| 41 | +const fundTx = new StellarSdk.TransactionBuilder(sourceAccount, { fee: "100" }) |
| 42 | + .addOperation(StellarSdk.Operation.payment({ |
| 43 | + destination: targetPublicKey, |
| 44 | + asset: StellarSdk.Asset.native(), |
| 45 | + amount: "2.0" // Cover the 0.5 XLM reserve per entry + fees |
| 46 | + })) |
| 47 | + .setTimeout(30) |
| 48 | + .build(); |
| 49 | +``` |
| 50 | + |
| 51 | +### 4. `op_no_destination` / Account Not Found |
| 52 | +**Meaning**: The destination account does not exist on the ledger. |
| 53 | +**Cause**: Attempting a standard `payment` operation to an unfunded or non-existent account instead of `createAccount`. |
| 54 | +**Fix**: If the account doesn't exist, use `createAccount` instead of `payment`. |
| 55 | +```typescript |
| 56 | +try { |
| 57 | + await server.loadAccount(destinationKey); |
| 58 | + // Account exists, use standard payment |
| 59 | +} catch (e) { |
| 60 | + // Account doesn't exist, use createAccount |
| 61 | + builder.addOperation(StellarSdk.Operation.createAccount({ |
| 62 | + destination: destinationKey, |
| 63 | + startingBalance: "5.0" |
| 64 | + })); |
| 65 | +} |
| 66 | +``` |
| 67 | + |
| 68 | +## Network Errors |
| 69 | + |
| 70 | +### 5. `429 Too Many Requests` (Friendbot / Horizon) |
| 71 | +**Meaning**: You are hitting the rate limits for Friendbot or the public Horizon nodes. |
| 72 | +**Cause**: Requesting too many testnet funds or querying Horizon too frequently. |
| 73 | +**Fix**: Add exponential backoff, batch your requests, or use a private RPC/Horizon instance. |
| 74 | +```typescript |
| 75 | +async function fundWithRetry(publicKey, retries = 3) { |
| 76 | + for (let i = 0; i < retries; i++) { |
| 77 | + try { |
| 78 | + await fetch(`https://friendbot.stellar.org?addr=${publicKey}`); |
| 79 | + return; |
| 80 | + } catch (e) { |
| 81 | + if (i === retries - 1) throw e; |
| 82 | + await new Promise(res => setTimeout(res, 1000 * Math.pow(2, i))); // Exponential backoff |
| 83 | + } |
| 84 | + } |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +### 6. `502 Bad Gateway` / `504 Gateway Timeout` (Horizon) |
| 89 | +**Meaning**: The Horizon server is down, overloaded, or unreachable. |
| 90 | +**Cause**: Transient network issues or node maintenance. |
| 91 | +**Fix**: Implement retry logic and ensure you have fallback Horizon/RPC URLs configured. |
| 92 | +```typescript |
| 93 | +const primaryServer = new StellarSdk.Server('https://horizon.stellar.org'); |
| 94 | +const fallbackServer = new StellarSdk.Server('https://your-custom-horizon.com'); |
| 95 | + |
| 96 | +try { |
| 97 | + await primaryServer.submitTransaction(tx); |
| 98 | +} catch (e) { |
| 99 | + if (e.response && (e.response.status === 502 || e.response.status === 504)) { |
| 100 | + await fallbackServer.submitTransaction(tx); |
| 101 | + } |
| 102 | +} |
| 103 | +``` |
| 104 | + |
| 105 | +### 7. `Soroban RPC: retention window exceeded` |
| 106 | +**Meaning**: The requested historical data is no longer available on the RPC node. |
| 107 | +**Cause**: Querying events or transactions that occurred before the node's configured retention window. |
| 108 | +**Fix**: Use an archiver node or data indexer like Hubble to fetch historical data. |
| 109 | +```typescript |
| 110 | +// Instead of querying Soroban RPC for old events, query an indexer API |
| 111 | +const response = await fetch(`https://indexer.example.com/events?contract=${contractId}`); |
| 112 | +const oldEvents = await response.json(); |
| 113 | +``` |
| 114 | + |
| 115 | +### 8. `tx_too_late` |
| 116 | +**Meaning**: The transaction was submitted after its specified timebounds expired. |
| 117 | +**Cause**: Network congestion delayed the transaction, or the timebound maxTime was set too short. |
| 118 | +**Fix**: Increase the maximum timebound limit. |
| 119 | +```typescript |
| 120 | +const tx = new StellarSdk.TransactionBuilder(account, { fee: "100" }) |
| 121 | + // ... operations |
| 122 | + .setTimeout(300) // Increase from default to 300 seconds (5 minutes) |
| 123 | + .build(); |
| 124 | +``` |
| 125 | + |
| 126 | +## Signing Errors |
| 127 | + |
| 128 | +### 9. `Freighter not installed` |
| 129 | +**Meaning**: The web app cannot detect the Freighter wallet extension. |
| 130 | +**Cause**: User has not installed Freighter, or the extension has not injected the `freighterApi` into the page yet. |
| 131 | +**Fix**: Check `isConnected()` and prompt the user to install Freighter if unavailable. |
| 132 | +```typescript |
| 133 | +import { isConnected } from '@stellar/freighter-api'; |
| 134 | + |
| 135 | +const connected = await isConnected(); |
| 136 | +if (!connected) { |
| 137 | + alert("Please install the Freighter browser extension to continue."); |
| 138 | + window.open("https://freighter.app", "_blank"); |
| 139 | +} |
| 140 | +``` |
| 141 | + |
| 142 | +### 10. `Network mismatch` |
| 143 | +**Meaning**: The transaction is intended for one network (e.g., Testnet) but Freighter is connected to another (e.g., Public). |
| 144 | +**Cause**: User switched networks in their wallet, or the dApp didn't specify the correct network. |
| 145 | +**Fix**: Request the correct network before signing. |
| 146 | +```typescript |
| 147 | +import { getNetwork, signTransaction } from '@stellar/freighter-api'; |
| 148 | + |
| 149 | +const network = await getNetwork(); |
| 150 | +if (network !== 'TESTNET') { |
| 151 | + alert("Please switch your wallet to the Stellar Testnet."); |
| 152 | + return; |
| 153 | +} |
| 154 | +const signedTx = await signTransaction(xdr, { network: 'TESTNET' }); |
| 155 | +``` |
| 156 | + |
| 157 | +### 11. `User rejected signature` |
| 158 | +**Meaning**: The user clicked "Reject" in the wallet popup. |
| 159 | +**Cause**: Normal user behavior, or the user didn't recognize the transaction. |
| 160 | +**Fix**: Catch the specific rejection error and handle it gracefully in the UI. |
| 161 | +```typescript |
| 162 | +try { |
| 163 | + const signedTx = await signTransaction(xdr, { network: 'PUBLIC' }); |
| 164 | +} catch (e) { |
| 165 | + if (e.message.includes("User declined")) { |
| 166 | + console.log("Transaction cancelled by user."); |
| 167 | + showToast("Transaction was cancelled."); |
| 168 | + } else { |
| 169 | + throw e; |
| 170 | + } |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +### 12. `tx_bad_auth` / `op_bad_auth` |
| 175 | +**Meaning**: The transaction lacks the required signatures for its operations. |
| 176 | +**Cause**: The transaction was modified after being signed, or a required multi-sig signer is missing. |
| 177 | +**Fix**: Ensure all necessary parties sign the exact final transaction hash. |
| 178 | +```typescript |
| 179 | +// Multi-sig scenario: ensure both signers sign the SAME transaction object |
| 180 | +tx.sign(keypair1); |
| 181 | +tx.sign(keypair2); // Make sure the weight meets the threshold |
| 182 | +await server.submitTransaction(tx); |
| 183 | +``` |
| 184 | + |
| 185 | +## Stealth-Specific Errors |
| 186 | + |
| 187 | +### 13. `Derived address matches recipient` |
| 188 | +**Meaning**: The calculated stealth address is identical to the recipient's public key. |
| 189 | +**Cause**: The recipient's scan key or spend key wasn't properly configured, or entropy generation failed. |
| 190 | +**Fix**: Ensure cryptographically secure random entropy is used when deriving the ephemeral key. |
| 191 | +```typescript |
| 192 | +import { randomBytes } from 'crypto'; |
| 193 | +import { deriveStealthAddress } from '@wraith/stealth'; |
| 194 | + |
| 195 | +const entropy = randomBytes(32); // Use true randomness |
| 196 | +const stealthInfo = deriveStealthAddress(recipientMeta, entropy); |
| 197 | +if (stealthInfo.address === recipientMeta.publicKey) { |
| 198 | + throw new Error("Invalid stealth derivation"); |
| 199 | +} |
| 200 | +``` |
| 201 | + |
| 202 | +### 14. `Zero-balance scan returning matches` |
| 203 | +**Meaning**: The stealth scan function is finding addresses that belong to the user, but they have no balance. |
| 204 | +**Cause**: Dusting attacks, or previous stealth payments were fully spent but the ledger still shows the account. |
| 205 | +**Fix**: Filter scan results to only include accounts with a balance greater than 0 (or base reserve). |
| 206 | +```typescript |
| 207 | +const matches = await stealthScanner.scan(startLedger, endLedger); |
| 208 | +const activeMatches = await Promise.all( |
| 209 | + matches.map(async (match) => { |
| 210 | + const account = await server.loadAccount(match.address); |
| 211 | + const balance = parseFloat(account.balances[0].balance); |
| 212 | + return balance > 0 ? match : null; |
| 213 | + }) |
| 214 | +); |
| 215 | +const validMatches = activeMatches.filter(m => m !== null); |
| 216 | +``` |
| 217 | + |
| 218 | +### 15. `Name resolution null` (Federation) |
| 219 | +**Meaning**: A Stellar Federation address (e.g., `user*wraith.com`) could not be resolved to an account ID. |
| 220 | +**Cause**: The federation server is down, or the user does not exist on that domain. |
| 221 | +**Fix**: Fall back to manual address entry or retry the federation lookup. |
| 222 | +```typescript |
| 223 | +try { |
| 224 | + const record = await StellarSdk.FederationServer.resolve('alice*example.com'); |
| 225 | + return record.account_id; |
| 226 | +} catch (e) { |
| 227 | + console.error("Federation resolution failed, please enter the raw G-address"); |
| 228 | + promptForRawAddress(); |
| 229 | +} |
| 230 | +``` |
| 231 | + |
| 232 | +### 16. `Stealth payload too large for memo` |
| 233 | +**Meaning**: The stealth ephemeral public key or metadata exceeds the 32-byte limit of a Stellar `Memo.hash`. |
| 234 | +**Cause**: Attempting to attach uncompressed keys or extra data in the memo field. |
| 235 | +**Fix**: Use compressed public keys or store extra metadata in Soroban contract state/events instead. |
| 236 | +```typescript |
| 237 | +// Ensure the ephemeral key is 32 bytes |
| 238 | +const ephemeralKeyBuffer = getCompressedKey(ephemeralPublicKey); |
| 239 | +const tx = new StellarSdk.TransactionBuilder(account, { fee: "100" }) |
| 240 | + .addMemo(StellarSdk.Memo.hash(ephemeralKeyBuffer.toString('hex'))) |
| 241 | + // ... |
| 242 | + .build(); |
| 243 | +``` |
| 244 | + |
| 245 | +## Soroban Contract Errors |
| 246 | + |
| 247 | +### 17. `HostError: Error(Contract, #)` / Contract Trapped |
| 248 | +**Meaning**: The smart contract executed a `panic!` or returned a specific error code. |
| 249 | +**Cause**: A contract assertion failed (e.g., unauthorized caller, arithmetic overflow). |
| 250 | +**Fix**: Check the Soroban CLI or RPC logs for the exact error code and match it to the contract's source code. |
| 251 | +```rust |
| 252 | +// In your Soroban contract: |
| 253 | +#[contracterror] |
| 254 | +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] |
| 255 | +#[repr(u32)] |
| 256 | +pub enum Error { |
| 257 | + Unauthorized = 1, |
| 258 | + InsufficientBalance = 2, |
| 259 | +} |
| 260 | +// If you see Error(Contract, 2), it's InsufficientBalance. |
| 261 | +``` |
| 262 | + |
| 263 | +### 18. `op_no_trust` / Missing Trustline |
| 264 | +**Meaning**: The Soroban contract attempted to send a Classic asset (like USDC) to an account that doesn't trust it. |
| 265 | +**Cause**: The recipient has not established a trustline for the asset being sent by the contract. |
| 266 | +**Fix**: Have the recipient submit a `ChangeTrust` operation for the asset before invoking the contract. |
| 267 | +```typescript |
| 268 | +// Recipient must submit this transaction first |
| 269 | +const tx = new StellarSdk.TransactionBuilder(recipientAccount, { fee: "100" }) |
| 270 | + .addOperation(StellarSdk.Operation.changeTrust({ |
| 271 | + asset: new StellarSdk.Asset('USDC', usdcIssuerKey) |
| 272 | + })) |
| 273 | + .build(); |
| 274 | +``` |
| 275 | + |
| 276 | +### 19. `Expired auth` / `auth_invalid` |
| 277 | +**Meaning**: The Soroban authorization payload is invalid or has expired. |
| 278 | +**Cause**: A time-bound authorization signature (`SorobanAuthorizationEntry`) expired before the transaction was submitted. |
| 279 | +**Fix**: Re-sign the authorization payload with a fresh expiration ledger. |
| 280 | +```typescript |
| 281 | +// When generating the Soroban auth payload, extend the valid ledger range |
| 282 | +const currentLedger = await getLatestLedger(); |
| 283 | +const auth = createSorobanAuth({ |
| 284 | + validUntilLedger: currentLedger + 100, // Valid for ~10 minutes |
| 285 | + // ... |
| 286 | +}); |
| 287 | +``` |
| 288 | + |
| 289 | +### 20. `Replay rejection` / `nonce_already_used` |
| 290 | +**Meaning**: The contract invocation was rejected because its unique nonce was already used. |
| 291 | +**Cause**: Submitting the same signed Soroban payload twice. |
| 292 | +**Fix**: Query the contract for the latest nonce for the user, and increment it for the new invocation. |
| 293 | +```typescript |
| 294 | +// Always fetch the latest nonce before building the Soroban invocation |
| 295 | +const nextNonce = await myContract.getNonce({ user: userAddress }); |
| 296 | +const invocation = await myContract.myFunction({ |
| 297 | + caller: userAddress, |
| 298 | + nonce: nextNonce, |
| 299 | + // ... |
| 300 | +}); |
| 301 | +``` |
0 commit comments