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:
| Path | Use case |
|---|
| GraphQL | Complex queries, aggregations, multi-contract joins |
| REST Classic | Simple 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;
};
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
| Plan | Network | Price |
|---|
| Free | Testnet | Free |
| Builder | Mainnet | $79/month |
Sign up at mercurydata.app.