This guide covers DEX interaction, trading patterns, and building blocks for advanced DeFi applications including perpetual futures.
MetalX is the primary decentralized exchange on XPR Network — an order book DEX for spot trading, backed by the dex contract.
Two product surfaces under the MetalX brand. This section covers the order-book DEX (the
dexcontract). MetalX also exposes a Swap UI for AMM-style liquidity pools, which is a separate contract on chain —proton.swaps. See Proton Swaps (AMM Liquidity Pools) below for the swap/add-liquidity flow. The order-book and AMM surfaces are distinct: order-book trades route throughdex, AMM swaps route throughproton.swaps.
| Network | RPC | DEX API |
|---|---|---|
| Mainnet | https://rpc.api.mainnet.metalx.com |
https://dex.api.mainnet.metalx.com/dex |
| Contract | Purpose |
|---|---|
dex |
Order book and matching engine |
eosio.token |
XPR and wrapped tokens |
xtokens |
Wrapped tokens (XUSDT, XBTC, etc.) |
async function getMarkets() {
const response = await fetch('https://dex.api.mainnet.metalx.com/dex/v1/markets/all');
const { data } = await response.json();
return data;
}
// Response shape (verified live):
// - market_id, symbol (e.g. "XPR_XMD"), type
// - bid_token / ask_token — objects: { code, contract, precision, multiplier }
// - maker_fee, taker_fee
// - order_min (NOT "min_order_size")
// - status_code (NOT "status" — see Market Status Codes in metalx-dex.md)The /orders/depth endpoint takes symbol (e.g. XPR_XUSDC) and step (price aggregation level), not market_id. Verified live against the API; passing market_id returns HTTP 400 {"message":["symbol: Required","step: Expected number, received nan"]}.
async function getOrderBook(symbol: string, step: number = 0.0001) {
const response = await fetch(
`https://dex.api.mainnet.metalx.com/dex/v1/orders/depth?symbol=${symbol}&step=${step}`
);
const { data } = await response.json();
return data; // { bids: [{level, count, bid, ask}, ...], asks: [...] }
}
// Usage
const book = await getOrderBook('XPR_XUSDC');/trades/daily returns the 24h OHLCV for every market, not a single market. Filter client-side by market_id or symbol.
async function getDailyStats(symbol?: string) {
const response = await fetch(
`https://dex.api.mainnet.metalx.com/dex/v1/trades/daily`
);
const { data } = await response.json();
// data is Array<{ market_id, symbol, volume_bid, volume_ask, open, close, high, low, change_percentage }>
return symbol ? data.find((m: any) => m.symbol === symbol) : data;
}
// Usage
const xprUsdc = await getDailyStats('XPR_XUSDC');
console.log(xprUsdc.close, xprUsdc.high, xprUsdc.low, xprUsdc.volume_bid);/trades/recent takes symbol, not market_id. Passing market_id returns HTTP 400.
async function getTradeHistory(symbol: string, limit: number = 50) {
const response = await fetch(
`https://dex.api.mainnet.metalx.com/dex/v1/trades/recent?symbol=${symbol}&limit=${limit}`
);
const { data } = await response.json();
return data;
}
// Usage
const trades = await getTradeHistory('XPR_XUSDC', 20);async function getOpenOrders(account: string, marketId?: number) {
// NOTE: the API ignores a market_id query param (verified live — it returns
// all markets regardless). Filter client-side instead.
const response = await fetch(
`https://dex.api.mainnet.metalx.com/dex/v1/orders/open?account=${account}`
);
const { data } = await response.json();
return marketId ? data.filter((o: any) => o.market_id === marketId) : data;
}async function getDexBalances(account: string) {
const response = await fetch(
`https://dex.api.mainnet.metalx.com/dex/v1/account/balances?account=${account}`
);
const { data } = await response.json();
return data;
}async function placeLimitOrder(
account: string,
marketId: number,
orderSide: 'buy' | 'sell',
price: string,
quantity: string,
bidSymbol: { sym: string; contract: string },
askSymbol: { sym: string; contract: string },
fillType: number = 0 // 0=GTC, 1=IOC, 2=POST_ONLY
) {
const actions = [{
account: 'dex',
name: 'placeorder',
authorization: [{ actor: account, permission: 'active' }],
data: {
market_id: marketId,
account,
order_type: 1, // 1 = limit (only valid type; use trigger_price for stop loss / take profit)
order_side: orderSide === 'buy' ? 1 : 2, // 1=buy, 2=sell
quantity,
price,
bid_symbol: bidSymbol, // extended_symbol e.g. {"sym":"4,XPR","contract":"eosio.token"}
ask_symbol: askSymbol, // extended_symbol e.g. {"sym":"6,XUSDC","contract":"xtokens"}
trigger_price: 0, // optional: set non-zero for stop-loss / take-profit orders
fill_type: fillType, // 0=GTC, 1=IOC, 2=POST_ONLY
referrer: '' // optional: referrer account name
}
}];
return session.transact({ actions }, { broadcast: true });
}Note: There is no market order type on the DEX.
order_typeonly supports1(limit). To simulate a market order, place a limit order withfill_type: 1(IOC — Immediate or Cancel) at an aggressive price that will match immediately. Any unfilled remainder is cancelled.
async function placeMarketOrder(
account: string,
marketId: number,
orderSide: 'buy' | 'sell',
quantity: string,
bidSymbol: { sym: string; contract: string },
askSymbol: { sym: string; contract: string }
) {
// Use a very high price for buys or very low price for sells to ensure fill
const aggressivePrice = orderSide === 'buy' ? '999999999' : '1';
const actions = [{
account: 'dex',
name: 'placeorder',
authorization: [{ actor: account, permission: 'active' }],
data: {
market_id: marketId,
account,
order_type: 1, // 1 = limit (the only valid order type)
order_side: orderSide === 'buy' ? 1 : 2,
quantity,
price: aggressivePrice,
bid_symbol: bidSymbol,
ask_symbol: askSymbol,
trigger_price: 0,
fill_type: 1, // 1 = IOC (Immediate or Cancel) — unfilled portion is cancelled
referrer: ''
}
}];
return session.transact({ actions }, { broadcast: true });
}async function cancelOrder(account: string, orderId: string) {
const actions = [{
account: 'dex',
name: 'cancelorder',
authorization: [{ actor: account, permission: 'active' }],
data: {
account,
order_id: orderId
}
}];
return session.transact({ actions }, { broadcast: true });
}Note: There is no
cancelallaction on the DEX contract. You must cancel orders individually usingcancelorder(account, order_id). To cancel all open orders, fetch them first via the API and then cancel each one.
async function cancelAllOrders(account: string, marketId?: string) {
// 1. Fetch all open orders for this account
const openOrders = await getOpenOrders(account, marketId);
// 2. Cancel each order individually
const actions = openOrders.map((order: any) => ({
account: 'dex',
name: 'cancelorder',
authorization: [{ actor: account, permission: 'active' }],
data: {
account,
order_id: order.order_id
}
}));
if (actions.length === 0) return;
return session.transact({ actions }, { broadcast: true });
}async function depositToDex(account: string, quantity: string, tokenContract: string) {
const actions = [{
account: tokenContract,
name: 'transfer',
authorization: [{ actor: account, permission: 'active' }],
data: {
from: account,
to: 'dex',
quantity,
// WARNING: memo MUST be empty string. Using 'deposit' or other non-empty
// memo will cause the transfer to be treated as a regular transfer, not a DEX deposit.
memo: ''
}
}];
return session.transact({ actions }, { broadcast: true });
}There are two withdraw options:
withdrawall(account)— withdraws all balances at once.withdraw(account, balance)— withdraws a specific amount, wherebalanceis anextended_asset.
// Withdraw all DEX balances
async function withdrawAllFromDex(account: string) {
const actions = [{
account: 'dex',
name: 'withdrawall',
authorization: [{ actor: account, permission: 'active' }],
data: {
account
}
}];
return session.transact({ actions }, { broadcast: true });
}
// Withdraw a specific amount (balance is an extended_asset)
async function withdrawFromDex(
account: string,
quantity: string, // e.g. "100.0000 XPR"
tokenContract: string // e.g. "eosio.token"
) {
const actions = [{
account: 'dex',
name: 'withdraw',
authorization: [{ actor: account, permission: 'active' }],
data: {
account,
balance: {
quantity,
contract: tokenContract
}
}
}];
return session.transact({ actions }, { broadcast: true });
}interface GridConfig {
marketId: string;
lowerPrice: number;
upperPrice: number;
gridLevels: number;
quantityPerGrid: string;
}
class GridBot {
private config: GridConfig;
private gridPrices: number[] = [];
constructor(config: GridConfig) {
this.config = config;
this.calculateGridPrices();
}
private calculateGridPrices() {
const { lowerPrice, upperPrice, gridLevels } = this.config;
const step = (upperPrice - lowerPrice) / (gridLevels - 1);
for (let i = 0; i < gridLevels; i++) {
this.gridPrices.push(lowerPrice + step * i);
}
}
async placeInitialOrders(currentPrice: number) {
const orders: Promise<any>[] = [];
for (const gridPrice of this.gridPrices) {
if (gridPrice < currentPrice) {
// Place buy order below current price
orders.push(this.placeBuyOrder(gridPrice));
} else if (gridPrice > currentPrice) {
// Place sell order above current price
orders.push(this.placeSellOrder(gridPrice));
}
}
return Promise.all(orders);
}
private async placeBuyOrder(price: number) {
return placeLimitOrder(
this.account,
this.config.marketId,
'buy',
price.toFixed(4),
this.config.quantityPerGrid
);
}
private async placeSellOrder(price: number) {
return placeLimitOrder(
this.account,
this.config.marketId,
'sell',
price.toFixed(4),
this.config.quantityPerGrid
);
}
// Called when an order fills
async onOrderFilled(filledOrder: Order) {
const gridIndex = this.findGridIndex(parseFloat(filledOrder.price));
if (filledOrder.side === 'buy') {
// Buy filled - place sell at next grid up
const sellPrice = this.gridPrices[gridIndex + 1];
if (sellPrice) {
await this.placeSellOrder(sellPrice);
}
} else {
// Sell filled - place buy at next grid down
const buyPrice = this.gridPrices[gridIndex - 1];
if (buyPrice) {
await this.placeBuyOrder(buyPrice);
}
}
}
}interface MarketMakerConfig {
marketId: string;
spread: number; // e.g., 0.002 for 0.2%
levels: number; // Orders per side
levelSpacing: number; // e.g., 0.001 for 0.1%
quantityPerLevel: string;
}
class MarketMaker {
private config: MarketMakerConfig;
async updateQuotes(midPrice: number) {
// Cancel existing orders
await cancelAllOrders(this.account, this.config.marketId);
const orders: Promise<any>[] = [];
for (let i = 0; i < this.config.levels; i++) {
const offset = this.config.spread / 2 + i * this.config.levelSpacing;
// Bid (buy)
const bidPrice = midPrice * (1 - offset);
orders.push(placeLimitOrder(
this.account,
this.config.marketId,
'buy',
bidPrice.toFixed(4),
this.config.quantityPerLevel
));
// Ask (sell)
const askPrice = midPrice * (1 + offset);
orders.push(placeLimitOrder(
this.account,
this.config.marketId,
'sell',
askPrice.toFixed(4),
this.config.quantityPerLevel
));
}
return Promise.all(orders);
}
}A perps DEX on XPR Network would require these components:
// Positions table
@table("positions")
class Position extends Table {
constructor(
public id: u64 = 0,
public trader: Name = new Name(),
public market: string = "", // e.g., "BTC-PERP"
public side: u8 = 0, // 1=long, 2=short
public size: u64 = 0, // Position size (base units)
public entry_price: u64 = 0, // Average entry (8 decimals)
public leverage: u8 = 1, // 1-100x
public collateral: u64 = 0, // Margin deposited
public unrealized_pnl: i64 = 0, // Current PnL
public last_funding_time: u64 = 0, // Last funding payment
public liquidation_price: u64 = 0 // Auto-liquidation price
) { super(); }
@primary
get primary(): u64 { return this.id; }
@secondary
get byTrader(): u64 { return this.trader.N; }
}
// Markets configuration
@table("markets")
class Market extends Table {
constructor(
public market_id: string = "",
public oracle_index: u8 = 0, // Oracle feed index
public max_leverage: u8 = 20, // Max allowed leverage
public maintenance_margin: u16 = 500, // 5% in basis points
public initial_margin: u16 = 1000, // 10% in basis points
public funding_interval: u32 = 3600, // 1 hour
public maker_fee: u16 = 10, // 0.1% in basis points
public taker_fee: u16 = 50, // 0.5% in basis points
public open_interest_long: u64 = 0,
public open_interest_short: u64 = 0
) { super(); }
@primary
get primary(): string { return this.market_id; }
}
// Funding rate history
@table("funding")
class FundingRate extends Table {
constructor(
public id: u64 = 0,
public market_id: string = "",
public timestamp: u64 = 0,
public funding_rate: i64 = 0, // Can be negative
public mark_price: u64 = 0,
public index_price: u64 = 0
) { super(); }
@primary
get primary(): u64 { return this.id; }
}
// Insurance fund
@table("insurance", singleton)
class InsuranceFund extends Table {
constructor(
public balance: u64 = 0,
public last_contribution: u64 = 0
) { super(); }
}// Mark price = TWAP of oracle + funding premium
async function getMarkPrice(marketId: string): Promise<u64> {
const market = await getMarket(marketId);
// Get index price from oracle
const indexPrice = await getOraclePrice(market.oracle_index);
// Get order book mid price from DEX
const orderBook = await getOrderBook(marketId);
const midPrice = calculateMidPrice(orderBook);
// Calculate funding premium
const premium = calculatePremium(midPrice, indexPrice);
// Mark price includes premium
return indexPrice + premium;
}
function calculatePremium(midPrice: u64, indexPrice: u64): i64 {
// Premium = (Mid Price - Index Price) / Index Price
// Dampened over time
return ((midPrice - indexPrice) * 10000) / indexPrice;
}@action("openposition")
openPosition(
trader: Name,
market: string,
side: u8, // 1=long, 2=short
size: u64, // Position size
leverage: u8, // 1-100
collateral: Asset // Margin to deposit
): void {
requireAuth(trader);
const marketConfig = this.marketsTable.requireGet(market, "Market not found");
// Validate leverage
check(leverage >= 1 && leverage <= marketConfig.max_leverage, "Invalid leverage");
// Calculate required margin
const markPrice = this.getMarkPrice(market);
const notionalValue = (size * markPrice) / PRICE_PRECISION;
const requiredMargin = (notionalValue * marketConfig.initial_margin) / 10000;
check(collateral.amount >= requiredMargin, "Insufficient margin");
// Calculate liquidation price
const liquidationPrice = this.calculateLiquidationPrice(
side,
markPrice,
leverage,
marketConfig.maintenance_margin
);
// Create position
const position = new Position(
this.getNextPositionId(),
trader,
market,
side,
size,
markPrice,
leverage,
collateral.amount,
0, // unrealized PnL starts at 0
currentTimeSec(),
liquidationPrice
);
this.positionsTable.store(position, trader);
// Update open interest
if (side == 1) {
marketConfig.open_interest_long += size;
} else {
marketConfig.open_interest_short += size;
}
this.marketsTable.update(marketConfig, this.receiver);
}
function calculateLiquidationPrice(
side: u8,
entryPrice: u64,
leverage: u8,
maintenanceMargin: u16
): u64 {
// For longs: Liq Price = Entry * (1 - 1/leverage + maintenance_margin)
// For shorts: Liq Price = Entry * (1 + 1/leverage - maintenance_margin)
const leverageFactor = PRECISION / leverage;
const marginFactor = (maintenanceMargin * PRECISION) / 10000;
if (side == 1) { // Long
return (entryPrice * (PRECISION - leverageFactor + marginFactor)) / PRECISION;
} else { // Short
return (entryPrice * (PRECISION + leverageFactor - marginFactor)) / PRECISION;
}
}@action("applyfunding")
applyFunding(market: string): void {
const marketConfig = this.marketsTable.requireGet(market, "Market not found");
const now = currentTimeSec();
// Check if funding interval has passed
check(
now >= this.lastFundingTime(market) + marketConfig.funding_interval,
"Funding not due yet"
);
// Calculate funding rate
const markPrice = this.getMarkPrice(market);
const indexPrice = this.getOraclePrice(marketConfig.oracle_index);
// Funding Rate = (Mark Price - Index Price) / Index Price * 0.01
// Clamped to max ±0.1% per interval
let fundingRate = ((markPrice - indexPrice) * 100) / indexPrice;
fundingRate = clamp(fundingRate, -1000, 1000); // ±0.1%
// Apply to all positions
let cursor = this.positionsTable.lowerBound(market);
while (cursor && cursor.market == market) {
const payment = this.calculateFundingPayment(cursor, fundingRate);
if (cursor.side == 1) {
// Longs pay when funding > 0
cursor.collateral -= payment;
} else {
// Shorts receive when funding > 0
cursor.collateral += payment;
}
cursor.last_funding_time = now;
this.positionsTable.update(cursor, this.receiver);
cursor = this.positionsTable.next(cursor);
}
// Record funding rate
this.recordFunding(market, fundingRate, markPrice, indexPrice);
}@action("liquidate")
liquidate(positionId: u64, liquidator: Name): void {
requireAuth(liquidator);
const position = this.positionsTable.requireGet(positionId, "Position not found");
const markPrice = this.getMarkPrice(position.market);
// Check if position is liquidatable
const isLiquidatable = this.checkLiquidation(position, markPrice);
check(isLiquidatable, "Position not liquidatable");
// Calculate liquidation penalty
const market = this.marketsTable.get(position.market);
const penalty = (position.collateral * LIQUIDATION_PENALTY) / 10000;
// Pay liquidator reward (portion of penalty)
const liquidatorReward = (penalty * LIQUIDATOR_SHARE) / 100;
this.transferReward(liquidator, liquidatorReward);
// Send remainder to insurance fund
const insuranceContribution = penalty - liquidatorReward;
this.addToInsurance(insuranceContribution);
// Close position
this.closePosition(position, markPrice, true); // true = liquidation
}
function checkLiquidation(position: Position, markPrice: u64): boolean {
// Calculate unrealized PnL
let pnl: i64;
if (position.side == 1) { // Long
pnl = ((markPrice - position.entry_price) * position.size) / PRICE_PRECISION;
} else { // Short
pnl = ((position.entry_price - markPrice) * position.size) / PRICE_PRECISION;
}
// Calculate margin ratio
const equity = position.collateral + pnl;
const notional = (position.size * markPrice) / PRICE_PRECISION;
const marginRatio = (equity * 10000) / notional;
// Liquidate if below maintenance margin
const market = this.marketsTable.get(position.market);
return marginRatio < market.maintenance_margin;
}@table("orders")
class Order extends Table {
constructor(
public id: u64 = 0,
public trader: Name = new Name(),
public market: string = "",
public side: u8 = 0, // 1=long, 2=short
public price: u64 = 0,
public size: u64 = 0,
public filled: u64 = 0,
public reduce_only: bool = false,
public post_only: bool = false,
public timestamp: u64 = 0
) { super(); }
@primary
get primary(): u64 { return this.id; }
@secondary // For order book sorting
get byPrice(): u64 { return this.price; }
}
@action("placeorder")
placeOrder(
trader: Name,
market: string,
side: u8,
price: u64,
size: u64,
reduceOnly: bool,
postOnly: bool
): void {
requireAuth(trader);
// Validate order
// ...
// Check for matching orders
const matchingOrders = this.findMatchingOrders(market, side, price);
for (const match of matchingOrders) {
if (postOnly) {
check(false, "Order would cross - post-only rejected");
}
// Execute match
this.executeMatch(trader, match, size);
}
// Place remainder on book
if (size > 0) {
const order = new Order(
this.getNextOrderId(),
trader,
market,
side,
price,
size,
0,
reduceOnly,
postOnly,
currentTimeSec()
);
this.ordersTable.store(order, trader);
}
}To build a production perps DEX:
| Contract | Purpose |
|---|---|
perps.core |
Positions, orders, matching |
perps.oracle |
Mark price calculation, TWAP |
perps.liquidation |
Liquidation bot rewards |
perps.insurance |
Insurance fund management |
| Service | Purpose |
|---|---|
| Liquidation Bot | Monitor positions, trigger liquidations |
| Funding Bot | Apply funding rates on schedule |
| Oracle Aggregator | Fetch prices, calculate TWAP |
| Order Indexer | Fast order book queries |
- Real-time position tracking
- P&L calculations
- Order book visualization
- Leverage slider with liquidation price preview
- Oracle Manipulation - Use TWAP, multiple sources
- Flash Loan Attacks - Require position to be held for min time
- Cascading Liquidations - Insurance fund, position limits
- Front-Running - Time-weighted execution, commit-reveal
- Smart Contract Risk - Audits, bug bounties, gradual rollout
// Prevent spam orders
const MAX_ORDERS_PER_BLOCK = 10;
@action("placeorder")
placeOrder(...): void {
const userOrdersThisBlock = this.countRecentOrders(trader);
check(userOrdersThisBlock < MAX_ORDERS_PER_BLOCK, "Rate limit exceeded");
// ...
}proton.swaps provides automated market maker (AMM) swap pools — an alternative to the DEX order book for instant trades.
This is what the MetalX "Swap" tab runs on. MetalX does not deploy a separate swap contract on XPR Network; its swap UI is a front-end on top of
proton.swaps. So the pools, liquidity, and on-chain math documented here are the same ones a MetalX user is interacting with — they're not two separate venues.
| Contract | Purpose |
|---|---|
proton.swaps |
AMM swap pools, liquidity provision |
curl -s -X POST https://proton.eosusa.io/v1/chain/get_table_rows \
-H 'Content-Type: application/json' \
-d '{"code":"proton.swaps","scope":"proton.swaps","table":"pools","limit":100,"json":true}'Pool row structure:
| Field | Description |
|---|---|
lt_symbol |
LP token symbol (e.g., XPRUSDC) |
creator |
Pool creator account |
memo |
Pool identifier string |
pool1 |
Reserve of token A (extended_asset) |
pool2 |
Reserve of token B (extended_asset) |
hash |
Pool hash |
fee |
Exchange fee object |
Product spec (canonical): docs.metalx.com → Swap fees and discounts — the per-trade fee on MetalX Swap is 0.3%, split as 0.2% to LPs + 0.1% to XPR burns or XPR Grants (quarterly disposition).
On-chain mechanism (for programmatic integrators talking to proton.swaps without going through the MetalX UI):
- LP slice =
pools[i].fee.exchange_fee— 20 bps on pools exposed in the MetalX UI. - Burns/Grants slice =
globall.exchange_fee_for_protocol— 10 bps, flat. Sent on-chain to thefee.swapsaccount (verified viaget_accountand outbound transfer history); governance-mutable via the contract'sglobalfeeaction. From there, MetalX disposes of accumulated XPR by burning or routing to XPR Grants quarterly (per docs.metalx.com).
The two slices compound (protocol fee comes off the input first, then the AMM math applies the LP fee) — the difference vs adding is negligible at these magnitudes.
Other pools may exist in the pools table with different fee.exchange_fee values, but only the MetalX-exposed set is documented and product-supported. For anything not in the MetalX docs, read pools[i].fee and globall.exchange_fee_for_protocol live from chain and don't assume a total; that pool may not be routable through the MetalX UI at all.
The pools table lists every pool the contract knows about. Pool set rotates — query get_table_rows code=proton.swaps scope=proton.swaps table=pools for the current set rather than hard-coding a list.
For the canonical list of swap routes a MetalX user can actually take, see docs.metalx.com → Swap pools and farms.
Swaps are done via token transfer to proton.swaps with a memo specifying the output token:
# Swap 1000 XPR → XUSDC (minimum 1 XUSDC out)
proton action eosio.token transfer \
'{"from":"myaccount","to":"proton.swaps","quantity":"1000.0000 XPR","memo":"XPRUSDC,1"}' \
myaccount
# Swap 10 XUSDC → XPR (minimum 1 XPR out)
proton action xtokens transfer \
'{"from":"myaccount","to":"proton.swaps","quantity":"10.000000 XUSDC","memo":"XPRUSDC,1"}' \
myaccountMemo format: <POOL_LT_SYMBOL>,<MIN_OUTPUT>
POOL_LT_SYMBOL: The LP token symbol (e.g.,XPRUSDC)MIN_OUTPUT: Minimum amount to receive (slippage protection, use1for no minimum)
The contract automatically determines direction based on which token you send.
For a constant-product AMM (x × y = k):
output = (input_amount × (10000 - exchange_fee) × output_reserve) / (input_reserve × 10000 + input_amount × (10000 - exchange_fee))
The pure AMM formula applies the LP fee to the input. To match on-chain output, deduct the burns/grants fee first, then run the formula with the LP fee — the contract does these in that order. Read both fee values from chain at runtime; the burns/grants slice is governance-mutable.
function calculateSwapOutput(
inputAmount: number,
inputReserve: number,
outputReserve: number,
exchangeFee: number, // pool LP fee in bps — read pools[i].fee.exchange_fee
protocolFee: number // burns/grants slice — read globall.exchange_fee_for_protocol
): number {
// 1) Burns/Grants slice comes off the input first.
const inputAfterProtocol = inputAmount * (10000 - protocolFee) / 10000;
// 2) Then the AMM swap math applies the LP fee.
const inputWithFee = inputAfterProtocol * (10000 - exchangeFee);
return (inputWithFee * outputReserve) / (inputReserve * 10000 + inputWithFee);
}When calling proton.swaps directly (no MetalX UI to compute slippage for you), the memo carries the minimum-acceptable output as an integer (<LT_SYMBOL>,<MIN_OUTPUT_RAW>). If actual on-chain output falls below <MIN_OUTPUT_RAW> the contract reverts the swap and returns your input.
Compute MIN_OUTPUT_RAW from your expected output and a tolerance:
function slippageProtectedMin(
expectedOutput: number, // from calculateSwapOutput, in human units
outputPrecision: number, // e.g. 6 for XUSDC, 4 for XPR, 8 for XBTC
slippageBps: number = 50 // 50 = 0.5%; common defaults: 30 (0.3%), 50 (0.5%), 100 (1%)
): string {
const minHuman = expectedOutput * (10_000 - slippageBps) / 10_000;
// Convert to the raw-integer form the contract expects:
const minRaw = Math.floor(minHuman * Math.pow(10, outputPrecision));
return minRaw.toString();
}
// Example: swap 1000 XPR → XUSDC, expect 2.20 XUSDC, accept up to 0.5% slippage
const minOut = slippageProtectedMin(2.20, 6, 50); // → "2189000"
const memo = `XPRUSDC,${minOut}`;
// "XPRUSDC,2189000" — contract will revert if final XUSDC out is below 2.189 XUSDCRules of thumb:
- Stablecoin → stablecoin swaps: 0.1–0.3% slippage is usually fine.
- Volatile pair with thin liquidity: 0.5–1% to absorb intra-block price drift.
- Multi-hop via
proton.swaps: prefer the official routing API; manual multi-hop slippage compounds across legs and you'll need to widen tolerance per hop. - Don't use
MIN_OUTPUT = 1in production — that disables slippage protection entirely. The doc snippet earlier uses1for clarity, not as a recommendation.
maxSent / maxIn works the same way for EXACT_OUTPUT-style swaps if/when the AMM exposes that mode; the current proton.swaps transfer-memo path is EXACT_INPUT only.
liquidityadd consumes tokens from your deposit balance on proton.swaps — it does not pull from your wallet directly. Calling liquidityadd against an empty deposit balance fails with insufficient balance. The end-to-end flow is three steps:
depositprep— reserve rows in thedepositstable for the two token symbols.transfereach token toproton.swapswithmemo: ""— credits the deposit balance.liquidityadd— moves the deposited amounts into the pool and mints LP tokens.
⚠️ Empty memo when depositing for liquidity.proton.swaps's transfer handler routes by memo:
memo: ""→ credited to your deposit balance (this is what you want forliquidityadd)memo: "<LT_SYMBOL>,<MIN_OUT>"→ executes a swap (see "Execute a Swap" above)- Any other non-empty memo → contract assertion failure or, worse, attempts a swap against a missing pool and reverts the transfer (verify on testnet first).
When prepping for
liquidityadd, usememo: "". Do not pass"addliq:..."or anything similar — there is no add-liquidity memo path.
| Param | Type | Description |
|---|---|---|
owner |
name | Your account |
lt_symbol |
symbol | LP token symbol, e.g. "8,XPRUSDC" |
add_token1 |
extended_asset | Amount of token A to consume from deposit balance |
add_token2 |
extended_asset | Amount of token B to consume from deposit balance |
add_token1_min |
extended_asset | Minimum token A (slippage protection) |
add_token2_min |
extended_asset | Minimum token B (slippage protection) |
# 1) Reserve deposit slots for both symbols.
proton action proton.swaps depositprep \
'{"owner":"myaccount","symbols":[{"sym":"4,XPR","contract":"eosio.token"},{"sym":"6,XUSDC","contract":"xtokens"}]}' \
myaccount
# 2) Transfer each token to proton.swaps with EMPTY memo to credit the deposit balance.
proton action eosio.token transfer \
'{"from":"myaccount","to":"proton.swaps","quantity":"1000.0000 XPR","memo":""}' \
myaccount
proton action xtokens transfer \
'{"from":"myaccount","to":"proton.swaps","quantity":"2.200000 XUSDC","memo":""}' \
myaccount
# 3) Add liquidity — must add both sides proportionally; mins protect against rebalance during the tx.
proton action proton.swaps liquidityadd \
'{"owner":"myaccount","lt_symbol":"8,XPRUSDC","add_token1":{"quantity":"1000.0000 XPR","contract":"eosio.token"},"add_token2":{"quantity":"2.200000 XUSDC","contract":"xtokens"},"add_token1_min":{"quantity":"990.0000 XPR","contract":"eosio.token"},"add_token2_min":{"quantity":"2.178000 XUSDC","contract":"xtokens"}}' \
myaccountIf liquidityadd reverts (e.g. price moved past your _min slippage bounds), the funds remain in your deposit balance — they're not lost. Call withdrawall to pull them back to your wallet, or retry with adjusted parameters.
# Remove liquidity — params are {owner, lt} where lt is an asset (the LP tokens to redeem)
proton action proton.swaps liquidityrmv \
'{"owner":"myaccount","lt":"1000.00000000 XPRUSDC"}' \
myaccount
# Withdraw returned tokens
proton action proton.swaps withdrawall '{"owner":"myaccount"}' myaccount
⚠️ Always callwithdrawallafter removing liquidity to receive your tokens back.
For tokens without a direct pool (e.g., METAL → XUSDC), you need multiple swaps:
METAL → XPR (via METAL/XPR pool) → XUSDC (via XPR/XUSDC pool)
Each hop incurs the pool's exchange fee, making multi-hop trades more expensive.
- Fees eat spread: With ~0.3% total per hop (0.2% LP + 0.1% burns/grants — see Exchange Fees above), a round-trip (buy + sell) costs ~0.6%. Arbitrage only works if price discrepancy exceeds this.
- Triangular routes: 3-hop routes cost ~0.9% minimum in fees. In practice, pools on XPR Network are efficient enough that profitable cycles are rare.
- Pool imbalances: Large swaps can temporarily move pool prices. Watch for whale trades creating imbalances that revert over time.
- DEX vs Swap divergence: The order book (MetalX DEX) and AMM pools can diverge in price. Check both before trading.
- MetalX DEX: https://metalx.com
- XPR DEX Bot: https://github.com/XPRNetwork/dex-bot
- Oracle Feeds:
oraclescontract on XPR Network - Proton Swaps: AMM pools at
proton.swapscontract - SimpleDEX: Token launch + AMM at
simpledex/simplelaunch— seesimpledex.md - RPC Endpoints:
proton.eosusa.io(primary),proton.protonuk.io,proton.cryptolions.io(fallbacks)