# Transaction Lifecycle

This guide walks through every step of a swap transaction — from requesting a quote to receiving on-chain confirmation — with error handling and edge cases at each step.

***

## Overview

A complete swap involves three services and twelve distinct steps:

```
Client          arb-algo              mx-relayer          MultiversX
  │                │                     │                    │
  │ 1. GET /quote  │                     │                    │
  │───────────────▶│                     │                    │
  │◀───────────────│                     │                    │
  │  txData, amountOutMin               │                    │
  │                                     │                    │
  │ 2. subscribe tx-status/{hash}       │                    │
  │────────────────────────────────────▶│                    │
  │                                     │                    │
  │ 3. relay action                     │                    │
  │────────────────────────────────────▶│                    │
  │                              4. validate + co-sign       │
  │                                     │ 5. P2P broadcast   │
  │                                     │───────────────────▶│
  │                                     │           6. execute tx
  │                                     │◀───────────────────│
  │                              7. SaveBlock from notifier  │
  │                                     │                    │
  │ 8. tx-status event                  │                    │
  │◀────────────────────────────────────│                    │
```

***

## Step-by-Step Guide

### Step 1: Get a swap quote

Call the arb-algo quote endpoint with the tokens and amount to swap. The response includes `txData` (the ready-to-use transaction payload) and `amountOutMin` (the slippage-protected minimum output).

```javascript
async function getQuote({ from, to, amountIn, slippage = 0.01 }) {
  const params = new URLSearchParams({ from, to, amountIn, slippage });
  const res = await fetch(`https://swap.xoxno.com/api/v1/quote?${params}`);

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`Quote failed: ${err.error}`);
  }

  return res.json();
}
```

**Error handling at this step:**

| Error                                              | Cause                                           | Action                                                                                                                             |
| -------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `unknown token_in`                                 | Token identifier is not recognized              | Check token format (`TICKER-hexcode`); use [pair-config](https://docs.xoxno.com/developers/aggregator-api/pair-config) to validate |
| `routing failed: no path between tokens`           | No liquidity path exists between the two tokens | Try routing via an intermediate token (e.g., WEGLD) manually                                                                       |
| `specify either amount_in or amount_out, not both` | Both `amountIn` and `amountOut` were provided   | Provide exactly one                                                                                                                |
| HTTP 429                                           | Rate limit exceeded (100 req/s)                 | Add backoff                                                                                                                        |

**What to extract from the response:**

```javascript
const quote = await getQuote({
  from: 'WEGLD-bd4d79',
  to: 'USDC-c76f1f',
  amountIn: '1000000000000000000'
});

const { txData, amountOutMin, priceImpact } = quote;
```

{% hint style="warning" %}
Pool state refreshes every 5 seconds. If you hold a quote for more than a few seconds before submitting, on-chain reserves may have moved. The `amountOutMin` field protects against this: if the actual output falls below this value, the smart contract reverts automatically.
{% endhint %}

***

### Step 2: Connect to the relayer WebSocket

Open a WebSocket connection and wait for it to be ready.

```javascript
function connectRelayer() {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket('wss://relayer.xoxno.com/ws');

    ws.onopen = () => resolve(ws);
    ws.onerror = (err) => reject(new Error('WebSocket connection failed'));

    // Re-establish ping/pong baseline (relayer sends pings every 30s)
    ws.onclose = () => console.warn('Relayer connection closed');
  });
}

const ws = await connectRelayer();
```

***

### Step 3: Subscribe to gas statistics

Subscribe to `gasStats` before building the transaction. Use the per-shard percentiles to set a competitive `gasPrice` — see [Gas Optimization](https://docs.xoxno.com/developers/guides/gas-optimization) for the full strategy.

```javascript
function subscribeGasStats(ws) {
  return new Promise((resolve) => {
    const stats = new Map(); // shard → data

    ws.addEventListener('message', function handler(event) {
      const msg = JSON.parse(event.data);
      if (msg.type === 'gasStats') {
        stats.set(msg.data.shard, msg.data);
        // Wait until we have at least one shard's data
        if (stats.size >= 1) {
          ws.removeEventListener('message', handler);
          resolve(stats);
        }
      }
    });

    ws.send(JSON.stringify({ action: 'subscribe', topic: 'gasStats' }));
  });
}

const gasStats = await subscribeGasStats(ws);

function getGasPrice(senderShard) {
  const data = gasStats.get(senderShard);
  return data ? data.gasPrice.percentiles.p75 : 1000000000;
}
```

***

### Step 4: Determine relayer address for the sender's shard

Each MultiversX shard has a dedicated relayer wallet. Use the relayer address that matches the sender's shard, and ensure the user signs the transaction with the `relayer` field present.

```javascript
const RELAYER_ADDRESSES = {
  0: 'erd1l0x0n5yxsfcy93gm0vyvx9m9f7cte9h9vuq4am33ugpw3d5r3hvqx6f59h',
  1: 'erd12yxd5phejzw83gn8qh6jfz6q9a0ekyyhkfd3c49r03mxw25l3a5swq3nf7',
  2: 'erd13jxp0yjh7gjvzgrg5mj7e8rzhn5lzcye45l0p6e5996d543r7vrq9e50za'
};

// shard = last byte of public key % 3
function getSenderShard(bech32Address) {
  const decoded = bech32.decode(bech32Address);
  const pubKeyBytes = bech32.fromWords(decoded.words);
  return pubKeyBytes[pubKeyBytes.length - 1] % 3;
}

const senderShard = getSenderShard(senderAddress);
const relayerAddress = RELAYER_ADDRESSES[senderShard];
```

***

### Step 5: Build and sign the transaction

Construct the transaction with the `relayer` field set before signing. The user must sign after the `relayer` field is included.

```javascript
const tx = {
  nonce: accountNonce,          // current nonce from network
  value: '0',                   // EGLD value (0 for ESDT swaps)
  receiver: AGGREGATOR_CONTRACT,
  sender: senderAddress,
  gasPrice: getGasPrice(senderShard),
  gasLimit: 50_000_000,         // use quote.estimatedGasLimit if provided
  data: quote.txData,           // base64 payload from arb-algo
  chainID: '1',
  version: 2,
  relayer: relayerAddress       // must be set BEFORE signing
};

// Sign with user's private key (sdk-wallet or equivalent)
const signature = await userSigner.sign(serializeForSigning(tx));
tx.signature = signature;
```

{% hint style="warning" %}
The `relayer` field must be present before the user signs. Signing without it and adding it afterward produces an invalid signature; the relay will be rejected.
{% endhint %}

***

### Step 6: Subscribe to tx-status BEFORE sending

Compute the transaction hash from the signed transaction and subscribe to `tx-status/{hash}` before sending the relay action. The relayer emits the status event exactly once and never replays it — subscribing after the block finalizes means missing the event.

```javascript
// txHash is deterministic from the signed transaction object
const txHash = computeTxHash(tx); // from @multiversx/sdk-core

// Subscribe before sending
ws.send(JSON.stringify({
  action: 'subscribe',
  topic: `tx-status/${txHash}`
}));

// Set up a listener
const confirmationPromise = new Promise((resolve, reject) => {
  const ttl = setTimeout(() => {
    reject(new Error('tx-status TTL expired: no confirmation within 60 seconds'));
  }, 60_000);

  ws.addEventListener('message', function handler(event) {
    const msg = JSON.parse(event.data);
    if (msg.type === 'tx-status' && msg.data.hash === txHash) {
      clearTimeout(ttl);
      ws.removeEventListener('message', handler);
      resolve(msg.data);
    }
  });
});
```

***

### Step 7: Send the relay action

```javascript
ws.send(JSON.stringify({
  action: 'relay',
  requestId: 'swap-001',    // unique per request for correlation
  tx
}));
```

Wait for the relay acknowledgment (relayer accepted and broadcast the transaction) separately from the tx-status confirmation (transaction executed on-chain):

```javascript
const relayAck = await new Promise((resolve, reject) => {
  const timeout = setTimeout(() => reject(new Error('Relay acknowledgment timeout')), 15_000);

  ws.addEventListener('message', function handler(event) {
    const msg = JSON.parse(event.data);
    if (msg.action === 'relay' && msg.requestId === 'swap-001') {
      clearTimeout(timeout);
      ws.removeEventListener('message', handler);

      if (msg.status === 'failed') {
        reject(new Error(msg.failed?.[0]?.reason || 'Relay failed'));
      } else {
        resolve(msg);
      }
    }
  });
});

console.log('Transaction broadcast, hash:', relayAck.hashes[0]);
```

**Relay acknowledgment errors:**

| Error                     | Cause                                     | Action                                       |
| ------------------------- | ----------------------------------------- | -------------------------------------------- |
| `invalid relayer address` | Wrong relayer for sender's shard          | Recalculate shard and select correct address |
| `relayer field missing`   | Transaction built without `relayer` field | Rebuild transaction with `relayer` included  |
| `invalid user signature`  | Signed before adding `relayer` field      | Re-sign with `relayer` field present         |
| `nonce too low`           | Nonce already used                        | Fetch current nonce from network and retry   |
| `nonce too high`          | Gap in nonce sequence                     | Submit earlier nonces first                  |

***

### Step 8: Wait for on-chain confirmation

```javascript
const status = await confirmationPromise;

if (status.status === 'success') {
  console.log('Swap confirmed. Output received.');
} else {
  console.error('Transaction failed on-chain:', status.reason);
  // Common reasons:
  //   "execution failed" + smart contract revert → amountOut < amountOutMin (slippage)
  //   "insufficient funds" → gas limit too low
}
```

***

## Complete Example

The following self-contained example performs all steps in sequence.

```javascript
import bech32 from 'bech32';
// Assumes @multiversx/sdk-core and @multiversx/sdk-wallet are installed

const AGGREGATOR_CONTRACT = 'erd1qqqqqqqqqqqqqpgqmsgtu8888h0j3nkxvp5kkeydl7hxs00dsq2snk2f5t';

const RELAYER_ADDRESSES = {
  0: 'erd1l0x0n5yxsfcy93gm0vyvx9m9f7cte9h9vuq4am33ugpw3d5r3hvqx6f59h',
  1: 'erd12yxd5phejzw83gn8qh6jfz6q9a0ekyyhkfd3c49r03mxw25l3a5swq3nf7',
  2: 'erd13jxp0yjh7gjvzgrg5mj7e8rzhn5lzcye45l0p6e5996d543r7vrq9e50za'
};

function getSenderShard(bech32Address) {
  const decoded = bech32.decode(bech32Address);
  const pubKeyBytes = bech32.fromWords(decoded.words);
  return pubKeyBytes[pubKeyBytes.length - 1] % 3;
}

async function executeSwap({ senderAddress, senderSigner, accountNonce, from, to, amountIn }) {
  // Step 1: Get quote
  const params = new URLSearchParams({ from, to, amountIn, slippage: '0.01' });
  const quoteRes = await fetch(`https://swap.xoxno.com/api/v1/quote?${params}`);
  if (!quoteRes.ok) throw new Error(`Quote error: ${(await quoteRes.json()).error}`);
  const quote = await quoteRes.json();

  // Step 2: Connect to relayer
  const ws = await new Promise((resolve, reject) => {
    const sock = new WebSocket('wss://relayer.xoxno.com/ws');
    sock.onopen = () => resolve(sock);
    sock.onerror = () => reject(new Error('WebSocket failed'));
  });

  // Step 3: Subscribe to gas stats and wait for first update
  const gasStats = await new Promise((resolve) => {
    const stats = new Map();
    ws.addEventListener('message', function h(e) {
      const msg = JSON.parse(e.data);
      if (msg.type === 'gasStats') {
        stats.set(msg.data.shard, msg.data);
        if (stats.size >= 1) { ws.removeEventListener('message', h); resolve(stats); }
      }
    });
    ws.send(JSON.stringify({ action: 'subscribe', topic: 'gasStats' }));
  });

  // Step 4: Determine relayer address
  const senderShard = getSenderShard(senderAddress);
  const relayerAddress = RELAYER_ADDRESSES[senderShard];
  const shardGasData = gasStats.get(senderShard);
  const gasPrice = shardGasData ? shardGasData.gasPrice.percentiles.p75 : 1_000_000_000;

  // Step 5: Build and sign transaction
  const tx = {
    nonce: accountNonce,
    value: '0',
    receiver: AGGREGATOR_CONTRACT,
    sender: senderAddress,
    gasPrice,
    gasLimit: 50_000_000,
    data: quote.txData,
    chainID: '1',
    version: 2,
    relayer: relayerAddress   // relayer field BEFORE signing
  };
  tx.signature = await senderSigner.sign(serializeForSigning(tx));

  // Step 6: Compute hash and subscribe BEFORE sending
  const txHash = computeTxHash(tx);
  ws.send(JSON.stringify({ action: 'subscribe', topic: `tx-status/${txHash}` }));

  const confirmationPromise = new Promise((resolve, reject) => {
    const ttl = setTimeout(() => reject(new Error('tx-status TTL expired (60s)')), 60_000);
    ws.addEventListener('message', function h(e) {
      const msg = JSON.parse(e.data);
      if (msg.type === 'tx-status' && msg.data?.hash === txHash) {
        clearTimeout(ttl);
        ws.removeEventListener('message', h);
        resolve(msg.data);
      }
    });
  });

  // Step 7: Send relay action
  const requestId = `swap-${Date.now()}`;
  const relayAck = await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error('Relay ack timeout')), 15_000);
    ws.addEventListener('message', function h(e) {
      const msg = JSON.parse(e.data);
      if (msg.action === 'relay' && msg.requestId === requestId) {
        clearTimeout(timeout);
        ws.removeEventListener('message', h);
        if (msg.status === 'failed') reject(new Error(msg.failed?.[0]?.reason ?? 'Relay failed'));
        else resolve(msg);
      }
    });
    ws.send(JSON.stringify({ action: 'relay', requestId, tx }));
  });

  console.log('Broadcast confirmed, tx hash:', relayAck.hashes[0]);

  // Step 8: Wait for on-chain confirmation
  const status = await confirmationPromise;
  ws.close();

  if (status.status !== 'success') {
    throw new Error(`Swap failed on-chain: ${status.reason}`);
  }

  return { txHash: relayAck.hashes[0], status };
}
```

***

## Edge Cases

### tx-status TTL expiry

`tx-status/{hash}` subscriptions expire after 60 seconds. If a transaction takes longer — due to network congestion or cross-shard execution with many metachain rounds — the subscription expires without delivering an event.

When this happens:

1. Poll the MultiversX API: `GET https://api.multiversx.com/transactions/{hash}` until the status changes from `pending`.
2. Re-subscribe to `tx-status/{hash}` on the relayer immediately — the relayer may still receive the event if the block finalizes shortly after your subscription expired.

```javascript
async function pollTransactionStatus(txHash, maxAttempts = 30, intervalMs = 2000) {
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(`https://api.multiversx.com/transactions/${txHash}`);
    if (res.ok) {
      const tx = await res.json();
      if (tx.status !== 'pending') return tx;
    }
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error('Transaction still pending after maximum polling attempts');
}
```

### Cross-shard transactions

When the sender and the aggregator contract are on different shards, the transaction requires two block times: one on the sender's shard and one on the receiver's shard. The `tx-status` event fires after the receiver's shard processes the cross-shard message, not after the sender's block.

Expect cross-shard swaps to take roughly twice the block time of the slower shard. The `networkStats` stream exposes shard block times under `blockTimeMsP50ByShard` — see [Gas Optimization](https://docs.xoxno.com/developers/guides/gas-optimization) for details.

### Smart contract revert

If the actual swap output falls below `amountOutMin` — because price moved between quote and execution — the aggregator smart contract reverts. The transaction hash still appears on-chain with a `fail` status, and token balances are unchanged. Request a new quote and retry.

```javascript
if (status.status === 'fail' && status.reason?.includes('execution failed')) {
  // Likely slippage exceeded — re-quote and retry
  const newQuote = await getQuote({ from, to, amountIn, slippage: 0.02 }); // widen slippage
  // ... retry flow
}
```

***

## Related Pages

* [Quickstart](https://docs.xoxno.com/developers/guides/quickstart) — Condensed end-to-end example
* [Gas Optimization](https://docs.xoxno.com/developers/guides/gas-optimization) — Choosing the right gas price
* [Quote Endpoint](https://docs.xoxno.com/developers/aggregator-api/quote) — Full parameter and response reference
* [Transaction Relaying](https://docs.xoxno.com/developers/relayer-api/transaction-relaying) — Relay action reference
* [Gas Statistics](https://docs.xoxno.com/developers/relayer-api/gas-stats) — gasStats message schema
