Skip to main content
Soroban RPC’s getEvents endpoint has limited retention (a few ledgers). For historical event data — transaction history, borrow records, liquidation logs — Vanna uses Mercury, an off-chain Stellar event indexer.

Mercury Overview

Mercury subscribes to on-chain contract events and stores them in a queryable database. Two query paths:
PathUse case
GraphQLComplex queries, aggregations, multi-contract joins
REST ClassicSimple event streams by contract and topic
Mercury’s GraphQL endpoint on testnet is intermittently unreliable. Use the REST Classic events endpoint for production reliability.

Authentication

Mercury requires a JWT token. Never expose this token to browser clients. Use a server-side proxy (Next.js API route, Edge function, etc.):
Client → /api/mercury/events → Mercury REST API
                              (JWT added server-side)
Required environment variables (server-side only):
MERCURY_URL=https://api.mercurydata.app    # or testnet endpoint
MERCURY_KEY=your_jwt_token_here

REST Events Endpoint

Server-Side Proxy (Next.js)

// app/api/mercury/events/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const contract = searchParams.get('contract');
  const limit = searchParams.get('limit') ?? '100';
  const cursor = searchParams.get('cursor');
  const account = searchParams.get('account'); // optional: filter by account

  if (!contract) {
    return NextResponse.json({ error: 'contract required' }, { status: 400 });
  }

  // Build Mercury URL
  let url = `${process.env.MERCURY_URL}/zephyr/execute`;
  // For REST Classic:
  url = `${process.env.MERCURY_URL}/rest/events/by-contract/${contract}?limit=${limit}`;
  if (cursor) url += `&cursor=${cursor}`;

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${process.env.MERCURY_KEY}`,
      'Content-Type': 'application/json',
    },
  });

  const data = await response.json();
  return NextResponse.json(data);
}

Client-Side Fetch

interface MercuryEvent {
  id: string;
  contract_id: string;
  topic1: string;   // event name (base64 XDR)
  topic2: string;   // first arg (e.g., account address)
  topic3: string;
  topic4: string;
  data: string;     // event payload (base64 XDR)
  tx: string;       // transaction hash
  ledger: number;
  ledger_closed_at: string;
}

async function fetchContractEvents(
  contract: string,
  options: { limit?: number; cursor?: string; account?: string } = {},
): Promise<MercuryEvent[]> {
  const params = new URLSearchParams({
    contract,
    limit: String(options.limit ?? 100),
    ...(options.cursor ? { cursor: options.cursor } : {}),
    ...(options.account ? { account: options.account } : {}),
  });

  const response = await fetch(`/api/mercury/events?${params}`);
  if (!response.ok) throw new Error('Mercury fetch failed');
  return response.json();
}

Event Schemas

AccountManager Events

Account Creation:
topic1: "AccountCreationEvent"
data: { smart_account: Address, creation_time: u64 }
Trader Borrow:
topic1: "TraderBorrowEvent"
topic2: <smart_account_address>
data: { smart_account: Address, token_symbol: Symbol, token_amount: U256 }
Trader Repay:
topic1: "TraderRepayEvent"
topic2: <smart_account_address>
data: { smart_account: Address, token_amount: U256, timestamp: u64, token_symbol: Symbol }
Liquidation:
topic1: "TraderLiquidateEvent"
topic2: <smart_account_address>
data: { smart_account: Address, timestamp: u64 }

LendingPool Events

Deposit:
topic1: "deposit_event"
topic2: <lender_address>
data: { lender: Address, amount: U256, timestamp: u64, asset_symbol: Symbol }
Withdraw:
topic1: "withdraw_event"
topic2: <lender_address>
data: { lender: Address, vtoken_amount: U256, timestamp: u64, asset_symbol: Symbol }
vToken Mint:
topic1: "mint_event"
data: { lender: Address, token_amount: U256, timestamp: u64, token_symbol: Symbol }
vToken Burn:
topic1: "burn_event"
data: { lender: Address, token_amount: U256, timestamp: u64, token_symbol: Symbol }

Decoding Event Data

Mercury returns topic and data fields as base64-encoded XDR ScVal. Decode them with the Stellar SDK:
import { xdr, scValToNative } from '@stellar/stellar-sdk';

function decodeScVal(base64: string): unknown {
  const buffer = Buffer.from(base64, 'base64');
  const scVal = xdr.ScVal.fromXDR(buffer);
  return scValToNative(scVal);
}

// Example: decode a borrow event
const event = events[0];
const eventName = decodeScVal(event.topic1) as string;  // "TraderBorrowEvent"
const smartAccount = decodeScVal(event.topic2) as string;
const payload = decodeScVal(event.data) as {
  smart_account: string;
  token_symbol: string;
  token_amount: bigint;
};

Cursor-Based Pagination

Mercury returns events newest-first (descending ID). Use the last event’s id as the cursor for the next page:
async function fetchAllEvents(contract: string): Promise<MercuryEvent[]> {
  const all: MercuryEvent[] = [];
  let cursor: string | undefined;

  while (true) {
    const page = await fetchContractEvents(contract, { limit: 100, cursor });
    all.push(...page);

    if (page.length < 100) break;              // last page
    cursor = page[page.length - 1].id;         // continue from last event
  }

  return all;
}

Filtering by Account

Mercury supports topic-based filtering. To fetch only events for a specific margin account, pass the account address as account — the proxy encodes it to XDR and forwards as a topics= filter:
// Fetch all borrow events for a specific smart account
const events = await fetchContractEvents(CONTRACTS.ACCOUNT_MANAGER, {
  account: smartAccountAddress,
  limit: 50,
});

Enriching with Ledger Timestamps

Mercury provides ledger_closed_at in ISO format for each event. If you need Unix timestamps or need to join with Horizon data:
import { Horizon } from '@stellar/stellar-sdk';

const horizon = new Horizon.Server('https://horizon-testnet.stellar.org');

// Enrich events with exact close time from Horizon
async function enrichWithTimestamp(event: MercuryEvent) {
  const ledger = await horizon.ledgers().ledger(event.ledger).call();
  return {
    ...event,
    closedAt: new Date(ledger.closed_at),
    closedAtUnix: Math.floor(new Date(ledger.closed_at).getTime() / 1000),
  };
}

Practical Examples

Fetch a User’s Borrow History

async function getUserBorrowHistory(smartAccount: string) {
  const events = await fetchContractEvents(CONTRACTS.ACCOUNT_MANAGER, {
    account: smartAccount,
  });

  return events
    .filter(e => decodeScVal(e.topic1) === 'TraderBorrowEvent')
    .map(e => {
      const payload = decodeScVal(e.data) as {
        token_symbol: string;
        token_amount: bigint;
      };
      return {
        txHash: e.tx,
        ledger: e.ledger,
        closedAt: e.ledger_closed_at,
        symbol: payload.token_symbol,
        amountWad: payload.token_amount,
        amountHuman: Number(payload.token_amount) / 1e18,
      };
    });
}

Fetch All Liquidations

async function getAllLiquidations() {
  const events = await fetchContractEvents(CONTRACTS.ACCOUNT_MANAGER);

  return events
    .filter(e => decodeScVal(e.topic1) === 'TraderLiquidateEvent')
    .map(e => {
      const payload = decodeScVal(e.data) as {
        smart_account: string;
        timestamp: bigint;
      };
      return {
        smartAccount: payload.smart_account,
        timestamp: Number(payload.timestamp),
        txHash: e.tx,
        ledger: e.ledger,
      };
    });
}

Monitor Pool Deposits in Real Time

Combine Mercury for history with Horizon SSE for live updates:
// Historical: Mercury
const history = await fetchContractEvents(CONTRACTS.POOL_XLM, { limit: 200 });

// Live: Horizon ledger-close SSE
const horizon = new Horizon.Server('https://horizon-testnet.stellar.org');
horizon
  .ledgers()
  .cursor('now')
  .stream({
    onmessage: (ledger) => {
      // Invalidate your query cache on each new ledger
      queryClient.invalidateQueries(['pool-deposits']);
    },
  });

Mercury Pricing

PlanNetworkPrice
FreeTestnetFree
BuilderMainnet$79/month
Sign up at mercurydata.app.