Saltar a contenido

Aster Perps

Connector for Aster Perps (formerly ApolloX) on BSC. aster_perps is the canonical protocol key post-rebrand; it uses broker_id=0 (raw Aster, no attribution). For PancakeSwap-branded access to the same underlying Diamond, see PancakeSwap Perps which uses broker_id=2.

Overview

Aster Perps is an oracle-priced perpetual trading venue. The Almanak SDK integrates it through the intent system, supporting PERP_OPEN (full pipeline) and PERP_CLOSE (intent-level compilation).

The router is an EIP-2535 Diamond proxy at 0x1b6f2d3844c6ae7d56ceb3c3643b9060ba28feb0 on BSC — the same Diamond that powers PancakeSwap Perps. Broker attribution is passed in the open-trade payload; aster_perps sets it to 0, meaning the position is attributed to raw Aster rather than any front-end partner.

Trade-path facets: TradingPortal (open/close), TradingOpen / TradingClose (keeper settle), TradingReader (views), PriceFacade (keeper entry).

Market Format

Markets use the same slash separator convention as GMX V2: "BTC/USD", "ETH/USD", "BNB/USD". Each market resolves to a pairBase BSC ERC-20 address (e.g. BTCB, ETH-bsc, WBNB).

Intent.perp_open(
    market="BTC/USD",
    collateral_token="BNB",          # native BNB margin (auto-wraps to WBNB)
    collateral_amount=Decimal("0.3"),
    size_usd=Decimal("500"),
    is_long=True,
    leverage=Decimal("1.5"),
    max_slippage=Decimal("0.01"),
    protocol="aster_perps",
)

Supported Operations

Intent Description
Intent.perp_open() Open a leveraged long or short position, attributed to broker_id=0
Intent.perp_close(position_id=<tradeHash>, ...) Close a position by its bytes32 tradeHash. v1 accepts full closes only: size_usd (partial close) is rejected at compile time (CompilationStatus.FAILED). Strategies persist the tradeHash emitted at open and pass it back as position_id.

Relationship to pancakeswap_perps

Both protocol keys target the same on-chain Diamond on BSC:

Protocol key Broker id Use when
aster_perps 0 Building a strategy that trades Aster directly (recommended for new strategies).
pancakeswap_perps 2 Building a strategy that specifically needs PancakeSwap front-end attribution, or maintaining compatibility with pre-rebrand strategies. The module is a thin shim that re-exports aster_perps with broker_id=2 defaulted and emits a DeprecationWarning once per process.

Both keys accept PerpOpenIntent / PerpCloseIntent and share the identical event schema (MarketPendingTrade, OpenMarketTrade, CloseTradeSuccessful, CloseTradeReceived, PendingTradeRefund). The only runtime difference is the broker value encoded in the open-trade payload and reflected back in the MarketPendingTrade event.

Keeper Execution Model

Aster uses a two-step oracle-fill execution model (similar in shape to GMX V2 but driven by keepers pushing price callbacks):

  1. Pending trade creation — your transaction calls openMarketTrade / openMarketTradeBNB, which emits a MarketPendingTrade(user, tradeHash, trade) event. This is the transaction the SDK signs and submits.
  2. Keeper settlement — an off-chain keeper holding PRICE_FEEDER_ROLE subsequently calls PriceFacadeFacet.requestPriceCallback(priceRequestId, price). This invokes marketTradeCallback internally, which either fills the position (OpenMarketTrade event) or refunds it (PendingTradeRefund event) based on slippage / oracle-gap checks.

Close follows the same shape: closeTrade(tradeHash) emits a pending close request; keeper settles via closeTradeCallback, emitting CloseTradeSuccessful(user, tradeHash, closeInfo) and one or more CloseTradeReceived(user, tradeHash, token, amount) payout events.

Implications for strategies:

  • on_intent_executed(success=True) fires when the pending TX confirms, not when the keeper settles. The strategy must persist the tradeHash and poll for fill confirmation on subsequent ticks via getPositionByHashV2(tradeHash).

    Extraction shape: ResultEnricher.position_id only accepts integer NFT IDs or 40-char hex addresses, so a 64-hex-char bytes32 tradeHash is surfaced on result.extracted_data["position_id"] rather than result.position_id. Strategies should prefer result.position_id and fall back to result.extracted_data["position_id"]:

    position_id = getattr(result, "position_id", None)
    if position_id is None:
        position_id = (result.extracted_data or {}).get("position_id")
    

  • Filled entry_price is only available after keeper settlement (a separate TX), so extract_entry_price() returns None from the open TX's receipt.
  • The slippage-to-limit-price conversion must produce a fill bound that the keeper considers acceptable AND is within highPriceGapP (1.5% as of v1) of the on-chain oracle's cached beforePrice.

Minimum Position Size

Aster enforces TradingConfig.minNotionalUsd (200 USD as of v1) on every open. Notional is computed as price (1e8) × qty (1e10) ÷ 1e18. Strategies opening positions below this floor will revert at the synchronous open call with TradingCheckerFacet: Position is too small. This is enforced on-chain, not in the SDK compiler — strategies must size their orders above the floor.

Collateral Tokens

Chain Supported Margin
BSC BNB (native, via openMarketTradeBNB), WBNB, USDT, USDC

For native BNB margin, the SDK routes through openMarketTradeBNB with the margin sent as msg.value; the router wraps it to WBNB inside the transaction (verifiable in the receipt's WBNB Deposit event). For ERC-20 margin, the intent compiler prepends an approve() transaction.

Markets (v1)

Market pairBase (BSC)
BTC/USD 0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c (BTCB)
ETH/USD 0x2170Ed0880ac9A755fd29B2688956BD959F933F8 (ETH-bsc)
BNB/USD 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c (WBNB)

Aster also lists additional markets and synthetic equity markets (NVDA, TSLA, etc.) with Aster-issued pairBase contracts. These are out of v1 scope pending gateway-side equity-oracle support.

Known Limitations

  • BSC only in v1: Aster v2 is deployed on Arbitrum, Base, and opBNB with byte-identical function selectors and event topics (see RQ-1 research memo in docs/internal/discussions/aster-dex-rq1-findings-20260418.md). However, user-initiated trading volume on the non-BSC Diamonds is effectively zero as of this writing — Aster routes most non-BSC traffic through their off-chain Pro CLOB. Multi-chain expansion is deferred until on-chain volume on a non-BSC chain warrants adapter work.
  • Keeper delay: Position state is not immediately available after the open call. Wait for the keeper-settled OpenMarketTrade event before relying on filled position data.
  • Refunds: If the keeper-quoted oracle price violates the trader's acceptable price (slippage limit), the keeper emits PendingTradeRefund instead of OpenMarketTrade, and the trader's margin is returned. Your strategy must handle this case (poll getPendingTrade going to zero without a corresponding getPositionByHashV2 populated).
  • No SL/TP in v1: The intent vocabulary doesn't yet carry stop-loss / take-profit. The contract supports them; the integration is deferred to a cross-venue vocabulary RFC.
  • Full-close only via intents: PerpCloseIntent(position_id=<tradeHash>) compiles to closeTrade(bytes32). Partial closes (size_usd set) are rejected at compile time — Aster's closeTrade(bytes32) selector always flattens the whole position. build_close_transaction(trade_hash) remains available for manual transaction construction.

Demo Strategy

A working open → close round-trip demo lives at almanak/demo_strategies/aster_perps_basic/. It targets BNB/USD with 3x leverage and reliably completes both legs on a BNB Anvil fork with keeper impersonation.

API Reference

almanak.framework.connectors.aster_perps

Aster Perps connector (Aster/ApolloX Diamond on BSC).

Aster is the on-chain perpetual trading platform (formerly ApolloX, rebranded March 2025). PancakeSwap Perps runs on top of Aster as broker id = 2; raw Aster use is broker id = 0. The canonical connector lives here; pancakeswap_perps is a thin shim that binds broker_id=2 for backward compatibility.

Phase 1 scope (PRD: docs/internal/discussions/aster-dex-integration-20260418.md): - BSC only - Market orders only - Crypto markets (BTC/USD, ETH/USD, BNB/USD) - No SL/TP, no limit orders - Native BNB margin (openMarketTradeBNB) or ERC20 margin (openMarketTrade)

Multi-chain EVM, spot, Solana, funding-rate data are deferred to later phases gated on named deep-research items (VIB-3044 epic).

Example usage — strategy-author facing::

# Inside an IntentStrategy.decide()
return Intent.perp_open(
    market="BTC/USD",
    collateral_token="BNB",
    collateral_amount=Decimal("0.1"),
    size_usd=Decimal("300"),
    is_long=True,
    max_slippage=Decimal("0.01"),
    protocol="aster_perps",              # canonical key
    leverage=Decimal("3"),
)

# Legacy callers may still pass protocol="pancakeswap_perps"; the compiler
# routes that through the pancakeswap_perps shim, which forces broker_id=2.

SELECTOR_CLOSE_TRADE module-attribute

SELECTOR_CLOSE_TRADE = fromhex('5177fd3b')

closeTrade(bytes32)

SELECTOR_OPEN_MARKET_TRADE module-attribute

SELECTOR_OPEN_MARKET_TRADE = fromhex('703085c7')

openMarketTrade((address,bool,address,uint96,uint80,uint64,uint64,uint64,uint24))

SELECTOR_OPEN_MARKET_TRADE_BNB module-attribute

SELECTOR_OPEN_MARKET_TRADE_BNB = fromhex('b7aeae66')

openMarketTradeBNB((address,bool,address,uint96,uint80,uint64,uint64,uint64,uint24))

AsterPerpsAdapter

AsterPerpsAdapter(config: AsterPerpsConfig)

Translate PERP_OPEN / PERP_CLOSE intents into transaction data.

build_open

build_open(
    *,
    market: str,
    collateral_token: str,
    collateral_amount: Decimal,
    collateral_decimals: int,
    size_usd: Decimal,
    mark_price: Decimal,
    is_long: bool,
    max_slippage: Decimal,
) -> PerpOpenOrderResult

Build an openMarketTrade / openMarketTradeBNB transaction.

Parameters:

Name Type Description Default
market str

market symbol (e.g. 'BTC/USD'). Must be registered in almanak.core.contracts.ASTER_PERPS_MARKETS[chain].

required
collateral_token str

token symbol (e.g. 'BNB', 'USDT', 'USDC') or 0x address.

required
collateral_amount Decimal

margin amount in human decimal terms.

required
collateral_decimals int

decimals of the margin token (resolver-provided).

required
size_usd Decimal

position notional in USD.

required
mark_price Decimal

current oracle mark price (in USD, not scaled) — used to convert the USD size into an 8-decimal qty and to compute the slippage-to-limit-price bound.

required
is_long bool

True for long.

required
max_slippage Decimal

fractional slippage tolerance, e.g. Decimal('0.01') for 1%.

required

Returns:

Type Description
PerpOpenOrderResult

PerpOpenOrderResult. On failure, success=False and error set.

build_close

build_close(*, trade_hash: str | bytes) -> AsterPerpsTx

Build a closeTrade(bytes32) transaction for an open position.

The strategy must supply the tradeHash — PerpCloseIntent's vocabulary keys on (market, is_long, collateral_token) which is insufficient for Aster where multiple positions per market+side are possible. The compiler is responsible for resolving a tradeHash either from intent metadata or from a reader-side lookup (out of v1 scope — strategy stores the hash itself).

AsterPerpsConfig dataclass

AsterPerpsConfig(
    broker_id: int,
    chain: str = "bsc",
    wallet_address: str | None = None,
)

Minimal connector config.

Attributes:

Name Type Description
broker_id int

broker attribution id (REQUIRED — no default). PancakeSwap Perps = 2 (supplied by the pancakeswap_perps shim), raw Aster = 0 (supplied by the compiler for protocol="aster_perps"). Other partner brokers use their own assigned ids.

chain str

chain key (Phase 1 = 'bsc').

wallet_address str | None

trader EOA — not used on-chain by the open call (the router derives user from msg.sender) but recorded on the adapter so the compiler can pass it through to ActionBundle metadata.

AsterPerpsTx dataclass

AsterPerpsTx(
    to: str,
    value: int,
    data: bytes,
    gas_estimate: int,
    description: str,
)

Built transaction for the compiler to wrap in TransactionData.

PerpOpenOrderResult dataclass

PerpOpenOrderResult(
    success: bool,
    error: str | None = None,
    tx: AsterPerpsTx | None = None,
    pair_base: str | None = None,
    margin_token_address: str | None = None,
    qty: int = 0,
    limit_price: int = 0,
    native: bool = False,
    amount_in_wei: int = 0,
)

Adapter output for a compiled PERP_OPEN.

Attributes:

Name Type Description
success bool

False if validation fails before tx construction.

error str | None

description when success is False.

tx AsterPerpsTx | None

the built transaction.

pair_base str | None

resolved pairBase address for the market.

margin_token_address str | None

resolved margin-token address (or NATIVE sentinel).

qty int

computed on-wire qty (uint80, 10-decimal).

limit_price int

computed acceptable limit price (uint64, 8-decimal).

native bool

True if the transaction uses openMarketTradeBNB (value-carrying).

amount_in_wei int

margin amount in token-wei (matches OpenTradeStruct.amountIn).

AsterPerpsReceiptParser

AsterPerpsReceiptParser(chain: str = 'bsc', **_: Any)

Receipt parser for Aster Perps (ApolloX Diamond).

Accept the chain kwarg that ReceiptParserRegistry passes in.

Aster Perps is BSC-only in Phase 1; the chain argument is accepted for registry-interface compatibility and stored for logging/diagnostic use.

parse_receipt

parse_receipt(receipt: dict[str, Any]) -> ParsedReceipt

Decode all Aster-Perps events present in a TX receipt.

Safe to call on receipts with no Aster-Perps events (returns an empty ParsedReceipt). Never raises on malformed logs — logs a warning and skips.

extract_position_id

extract_position_id(receipt: dict[str, Any]) -> str | None

Return the tradeHash from MarketPendingTrade (v1 OPEN path).

If a filled open event (OpenMarketTrade) is present in the same receipt prefer that — but in practice keeper settlement happens in a separate TX, so MarketPendingTrade is the authoritative source for the OPEN-intent return value.

extract_size_delta

extract_size_delta(
    receipt: dict[str, Any],
) -> Decimal | None

Return the qty (position size in base units, 10-decimal — see sdk.QTY_DECIMALS) from the open event.

extract_collateral

extract_collateral(
    receipt: dict[str, Any],
) -> Decimal | None

Return the amountIn (raw margin, in the margin-token's smallest units).

Note: the caller is responsible for applying token decimals — we expose the raw uint96 as a Decimal. (The token's decimal count isn't in the event; it lives in the token registry.)

extract_entry_price

extract_entry_price(
    receipt: dict[str, Any],
) -> Decimal | None

Return the keeper-filled entry price (only present if keeper settlement is in-receipt).

extract_collateral_returned

extract_collateral_returned(
    receipt: dict[str, Any],
) -> Decimal | None

Sum of all payout tokens emitted in CloseTradeReceived events for this receipt.

CloseTradeReceivedEvent dataclass

CloseTradeReceivedEvent(
    user: str,
    trade_hash: str,
    token: str,
    amount: int,
    log_index: int = 0,
)

TradingCloseFacet.CloseTradeReceived — payout leg of a close.

CloseTradeSuccessfulEvent dataclass

CloseTradeSuccessfulEvent(
    user: str,
    trade_hash: str,
    close_price: int,
    funding_fee: int,
    close_fee: int,
    pnl: int,
    holding_fee: int,
    log_index: int = 0,
)

TradingCloseFacet.CloseTradeSuccessful — fires when keeper fills a close.

MarketPendingTradeEvent dataclass

MarketPendingTradeEvent(
    user: str,
    trade_hash: str,
    pair_base: str,
    is_long: bool,
    token_in: str,
    amount_in: int,
    qty: int,
    price: int,
    stop_loss: int,
    take_profit: int,
    broker: int,
    log_index: int = 0,
)

TradingPortalFacet.MarketPendingTrade — fires on user-signed openMarketTrade.

OpenMarketTradeEvent dataclass

OpenMarketTradeEvent(
    user: str,
    trade_hash: str,
    entry_price: int,
    pair_base: str,
    token_in: str,
    margin: int,
    qty: int,
    is_long: bool,
    open_fee: int,
    execution_fee: int,
    timestamp: int,
    log_index: int = 0,
)

TradingOpenFacet.OpenMarketTrade — fires when keeper fills a pending open.

ParsedReceipt dataclass

ParsedReceipt(
    market_pending_trades: list[
        MarketPendingTradeEvent
    ] = list(),
    open_market_trades: list[OpenMarketTradeEvent] = list(),
    pending_trade_refunds: list[
        PendingTradeRefundEvent
    ] = list(),
    close_trade_successful: list[
        CloseTradeSuccessfulEvent
    ] = list(),
    close_trade_received: list[
        CloseTradeReceivedEvent
    ] = list(),
)

Aggregate view of decoded Aster Perps events in a receipt.

PendingTradeRefundEvent dataclass

PendingTradeRefundEvent(
    user: str,
    trade_hash: str,
    refund_code: int,
    log_index: int = 0,
)

TradingOpenFacet.PendingTradeRefund — fires when keeper refunds a pending trade.

OpenTradeStruct dataclass

OpenTradeStruct(
    pair_base: str,
    is_long: bool,
    token_in: str,
    amount_in: int,
    qty: int,
    price: int,
    broker: int,
    stop_loss: int = 0,
    take_profit: int = 0,
)

Python mirror of the Aster openMarketTrade input struct.

All integer fields use the on-wire units the contract expects
  • amountIn: collateral-token smallest units (wei-equivalent)
  • qty: 10-decimal fixed-point (e.g. 0.15 BTC = 1500000000)
  • price: 8-decimal fixed-point limit / acceptable price
  • stopLoss: 8-decimal fixed-point (0 = no SL)
  • takeProfit: 8-decimal fixed-point (0 = no TP)
  • broker: uint24 broker id (PancakeSwap = 2, raw Aster = 0)

build_close_transaction

build_close_transaction(
    *,
    trade_hash: str | bytes,
    broker_id: int = ASTER_BROKER_RAW,
    chain: str = "bsc",
    wallet_address: str | None = None,
) -> AsterPerpsTx

Build a close transaction without constructing the adapter explicitly.

The close path does not emit a broker-attributed fee; the broker_id is plumbed through for consistency and defaulted to ASTER_BROKER_RAW (0).

build_open_transaction

build_open_transaction(
    *,
    broker_id: int,
    chain: str = "bsc",
    wallet_address: str | None = None,
    **open_kwargs: Any,
) -> PerpOpenOrderResult

Build an open transaction without constructing the adapter explicitly.

Parameters:

Name Type Description Default
broker_id int

REQUIRED — no default. Pass ASTER_BROKER_RAW (0) for raw Aster use or PANCAKESWAP_PERPS_BROKER_ID (2) for the PancakeSwap Perps attribution path.

required

encode_close_trade_calldata

encode_close_trade_calldata(
    trade_hash: str | bytes,
) -> bytes

Encode calldata for closeTrade(bytes32).

Parameters:

Name Type Description Default
trade_hash str | bytes

32-byte position identifier (hex string or raw bytes).

required

Returns:

Type Description
bytes

4-byte selector + abi-encoded bytes32.

encode_get_pending_trade_calldata

encode_get_pending_trade_calldata(
    trade_hash: str | bytes,
) -> bytes

Encode calldata for the getPendingTrade(bytes32) view.

encode_get_position_by_hash_calldata

encode_get_position_by_hash_calldata(
    trade_hash: str | bytes,
) -> bytes

Encode calldata for the getPositionByHashV2(bytes32) view.

encode_open_market_trade_calldata

encode_open_market_trade_calldata(
    trade: OpenTradeStruct, *, native: bool = False
) -> bytes

Encode calldata for openMarketTrade or openMarketTradeBNB.

Parameters:

Name Type Description Default
trade OpenTradeStruct

populated OpenTradeStruct

required
native bool

when True uses openMarketTradeBNB (native BNB margin via msg.value), when False uses openMarketTrade (ERC20 margin, requires prior approve).

False

Returns:

Type Description
bytes

4-byte selector + abi-encoded tuple.

get_margin_token_address

get_margin_token_address(
    symbol: str, chain: str = "bsc", token_resolver=None
) -> str

Resolve a margin-token symbol (WBNB/USDT/USDC) to its BSC ERC-20 address.

Accepts either a symbol or a 0x-prefixed EVM address (passed through after validation). For native BNB, pass symbol='BNB' or 'NATIVE' and the NATIVE_BNB_ADDRESS sentinel is returned (the caller must use encode_open_market_trade_calldata(native=True)).

Symbol resolution goes through the framework's unified :func:almanak.framework.data.tokens.get_token_resolver so the connector stays in sync with the rest of the token-metadata surface (aliases, on-chain fallbacks, disk cache). The local :data:ASTER_PERPS_TOKENS allowlist is only consulted to reject tokens that the Aster router does not accept as margin; the address for allowed symbols comes from the resolver.

Parameters:

Name Type Description Default
symbol str

Margin token symbol (e.g. 'WBNB', 'USDT', 'USDC', 'BNB', 'NATIVE') or a 0x-prefixed EVM address.

required
chain str

Chain key (default 'bsc').

'bsc'
token_resolver

Optional TokenResolver override; defaults to the singleton returned by get_token_resolver().

None

Raises:

Type Description
ValueError

If the symbol is not one of Aster Perps' supported margin tokens.

TokenNotFoundError

If the resolver cannot resolve an allowed symbol to an on-chain address on chain (propagated unchanged).

get_pair_base

get_pair_base(market: str, chain: str = 'bsc') -> str

Resolve a market symbol (e.g. 'BTC/USD') to the on-chain pairBase address.

Accepts either a registered symbol (v1: BTC/USD, ETH/USD, BNB/USD) or a 0x-prefixed EVM address (passed through after validation — lets PerpOpenIntent.market carry the pairBase address directly for synthetic / non-registered markets).

Raises ValueError if the symbol is not registered and is not a valid address. Non-crypto markets (NVDA, TSLA, ...) use synthetic ApolloX-issued pairBases and are deferred to v2 per the design doc; callers wanting to hit them today must pass the pairBase address explicitly.

get_router_address

get_router_address(chain: str = 'bsc') -> str

Return the Aster Perps router address for the given chain.

slippage_to_limit_price

slippage_to_limit_price(
    mark_price: Decimal, slippage: Decimal, *, is_long: bool
) -> int

Convert (mark_price, slippage_fraction) into the price field the router expects.

For longs: acceptable price = mark_price * (1 + slippage) — trader willing to pay up to this. For shorts: acceptable price = mark_price * (1 - slippage) — trader willing to receive down to this.

Returned value is uint64 with 8-decimal scaling.

usd_size_to_qty

usd_size_to_qty(
    size_usd: Decimal, mark_price: Decimal
) -> int

Convert a USD notional to on-wire qty (uint80, 10-decimal fixed-point).

qty = size_usd / mark_price, then scale to 1e10 (see QTY_DECIMALS).

Raises ValueError on non-positive inputs (keeps the connector fail-fast — silently coercing zero/negative sizes into bogus positions is exactly the class of bug the 'no quick patches' guardrail forbids).