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):
- Pending trade creation — your transaction calls
openMarketTrade/openMarketTradeBNB, which emits aMarketPendingTrade(user, tradeHash, trade)event. This is the transaction the SDK signs and submits. - Keeper settlement — an off-chain keeper holding
PRICE_FEEDER_ROLEsubsequently callsPriceFacadeFacet.requestPriceCallback(priceRequestId, price). This invokesmarketTradeCallbackinternally, which either fills the position (OpenMarketTradeevent) or refunds it (PendingTradeRefundevent) 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 thetradeHashand poll for fill confirmation on subsequent ticks viagetPositionByHashV2(tradeHash).Extraction shape:
ResultEnricher.position_idonly accepts integer NFT IDs or 40-char hex addresses, so a 64-hex-char bytes32 tradeHash is surfaced onresult.extracted_data["position_id"]rather thanresult.position_id. Strategies should preferresult.position_idand fall back toresult.extracted_data["position_id"]:- Filled
entry_priceis only available after keeper settlement (a separate TX), soextract_entry_price()returnsNonefrom 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 cachedbeforePrice.
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
OpenMarketTradeevent before relying on filled position data. - Refunds: If the keeper-quoted oracle price violates the trader's
acceptable price (slippage limit), the keeper emits
PendingTradeRefundinstead ofOpenMarketTrade, and the trader's margin is returned. Your strategy must handle this case (pollgetPendingTradegoing to zero without a correspondinggetPositionByHashV2populated). - 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 tocloseTrade(bytes32). Partial closes (size_usdset) are rejected at compile time — Aster'scloseTrade(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
¶
closeTrade(bytes32)
SELECTOR_OPEN_MARKET_TRADE
module-attribute
¶
openMarketTrade((address,bool,address,uint96,uint80,uint64,uint64,uint64,uint24))
SELECTOR_OPEN_MARKET_TRADE_BNB
module-attribute
¶
openMarketTradeBNB((address,bool,address,uint96,uint80,uint64,uint64,uint64,uint24))
AsterPerpsAdapter
¶
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 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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
Return the qty (position size in base units, 10-decimal — see sdk.QTY_DECIMALS) from the open event.
extract_collateral
¶
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
¶
Return the keeper-filled entry price (only present if keeper settlement is in-receipt).
extract_collateral_returned
¶
Sum of all payout tokens emitted in CloseTradeReceived events for this receipt.
CloseTradeReceivedEvent
dataclass
¶
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
¶
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 |
required |
encode_close_trade_calldata
¶
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 calldata for the getPendingTrade(bytes32) view.
encode_get_position_by_hash_calldata
¶
Encode calldata for the getPositionByHashV2(bytes32) view.
encode_open_market_trade_calldata
¶
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
¶
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 |
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 |
get_pair_base
¶
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
¶
Return the Aster Perps router address for the given chain.
slippage_to_limit_price
¶
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
¶
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).