Developers

TypeScript SDK

@moxie/sdk builds Moxie instructions, sizes orders, replicates the matcher’s quote and decodes on-chain accounts. It has no runtime dependencies and works with any Solana client library.

Install#

The SDK lives in the Moxie monorepo at packages/sdk and isn’t published to npm yet. Inside the workspace, add it as a dependency:

package.json
"dependencies": {
  "@moxie/sdk": "workspace:*",
  "@solana/web3.js": "^1.98.0"
}
Entry pointContents
@moxie/sdk/tradingOrder sizing, margin, matcher quotes and price limits.
@moxie/sdk/instructionsComplete instructions (accounts + data) for portfolio, deposit, withdraw, trade and crank.
@moxie/sdk/accountsDecoders for imported-market records and portfolios.
@moxie/sdk/percolatorRaw instruction-data encoders for the Percolator engine.
@moxie/sdk/imported-marketOracle encoders for listing, pricing and resolving markets (operator use; Node only).
@moxie/sdkEverything above. Pulls in node:crypto, so prefer the subpaths in browser code.

Addresses you need#

Instructions take addresses as base58 strings. On devnet:

config.ts
export const MOXIE = {
  program: "9GnPynrGbmQqQw8715neTB6xqeEw7ZcpRTHCT3AEDM4e",        // Percolator engine
  matcherProgram: "H6z6Z5KSQtXj7sYvqX7oCwskBuhWvUDDB8KGNqeuNWXS",
  market: "AihBkr17D9hSSyWRLK5cetZC2P5xMVbft112bHjZWgc2",         // market group
  usdcMint: "5yV4TPjnwvAGTmqJdjeM7rFZUVcrxSfPVVK87YNK3NRS",       // test USDC
  // Deployment-specific: collateral vault, LP portfolio, matcher context
  // and matcher delegate. Take them from the deployment's .env
  // (MOXIE_COLLATERAL_VAULT, MOXIE_LP_PORTFOLIO, MOXIE_MATCHER_CONTEXT,
  // MOXIE_MATCHER_DELEGATE).
  api: "http://localhost:8787",                                   // indexer
};

Converting instructions#

Builders return a library-neutral MoxieInstruction (programAddress, accounts, data). With @solana/web3.js:

to-web3.ts
import { PublicKey, TransactionInstruction } from "@solana/web3.js";
import type { MoxieInstruction } from "@moxie/sdk/instructions";

export const toWeb3 = (ix: MoxieInstruction) =>
  new TransactionInstruction({
    programId: new PublicKey(ix.programAddress),
    keys: ix.accounts.map((a) => ({ pubkey: new PublicKey(a.address), isSigner: a.isSigner, isWritable: a.isWritable })),
    data: Buffer.from(ix.data),
  });

Create a portfolio#

A portfolio is a 9,563-byte account owned by the engine. Allocate it with the System Program in the same transaction, then initialize it. The new account’s keypair must sign.

create-portfolio.ts
import { Keypair, PublicKey, SystemProgram, Transaction } from "@solana/web3.js";
import { createPortfolioInstruction } from "@moxie/sdk/instructions";

const PORTFOLIO_SIZE = 9563;
const portfolio = Keypair.generate();
const rent = await connection.getMinimumBalanceForRentExemption(PORTFOLIO_SIZE); // ~0.07 SOL

const tx = new Transaction().add(
  SystemProgram.createAccount({
    fromPubkey: owner.publicKey,
    newAccountPubkey: portfolio.publicKey,
    lamports: rent,
    space: PORTFOLIO_SIZE,
    programId: new PublicKey(MOXIE.program),
  }),
  toWeb3(createPortfolioInstruction({
    program: MOXIE.program,
    owner: owner.publicKey.toBase58(),
    market: MOXIE.market,
    portfolio: portfolio.publicKey.toBase58(),
  })),
);
await sendAndConfirmTransaction(connection, tx, [owner, portfolio]);

Deposit and withdraw#

Both need the portfolio’s current portfolioId and sequence, which you can read from the indexer (GET /v1/portfolios/:address) or decode yourself with decodePortfolioSummary.

deposit.ts
import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { depositInstruction } from "@moxie/sdk/instructions";

const p = await fetch(`${MOXIE.api}/v1/portfolios/${portfolioAddress}`).then((r) => r.json());

const ix = depositInstruction({
  program: MOXIE.program,
  owner: owner.publicKey.toBase58(),
  market: MOXIE.market,
  portfolio: portfolioAddress,
  sourceToken: getAssociatedTokenAddressSync(new PublicKey(MOXIE.usdcMint), owner.publicKey).toBase58(),
  vault: COLLATERAL_VAULT,
  tokenProgram: TOKEN_PROGRAM_ID.toBase58(),
  portfolioId: BigInt(p.portfolioId),
  sequence: BigInt(p.sequence),
  amount: 100_000_000n, // 100 USDC
});

withdrawInstruction takes the same fields plus destinationToken and vaultAuthority, the PDA["vault", market] under the engine program:

const [vaultAuthority] = PublicKey.findProgramAddressSync(
  [Buffer.from("vault"), new PublicKey(MOXIE.market).toBytes()],
  new PublicKey(MOXIE.program),
);

Place a trade#

A trade is four steps: size the order, quote it against the matcher, set a limit, and send.

trade.ts
import { planOrder, quoteMatcher, limitFor, FEE_BPS, MatcherCapacityError } from "@moxie/sdk/trading";
import { tradeInstruction } from "@moxie/sdk/instructions";

// 1. Market, trader and LP state from the indexer.
const market = await fetch(`${MOXIE.api}/v1/markets/${marketAddress}`).then((r) => r.json());
const trader = await fetch(`${MOXIE.api}/v1/portfolios/${portfolioAddress}`).then((r) => r.json());
const lp = await fetch(`${MOXIE.api}/v1/portfolios/${LP_PORTFOLIO}`).then((r) => r.json());

// 2. Size: spend up to $50 of free collateral going long at the current price.
const markE6 = BigInt(market.markE6);
const plan = planOrder("long", 50, Number(markE6) / 10_000);
if (!plan) throw new Error("Nothing to trade");

// 3. Quote exactly as the on-chain matcher will, then allow $0.02 of slippage.
const ctx = await connection.getAccountInfo(new PublicKey(MATCHER_CONTEXT));
let quote: bigint;
try {
  quote = quoteMatcher(ctx!.data, markE6, plan.sizeQ);
} catch (e) {
  if (e instanceof MatcherCapacityError) throw new Error("Too large for the LP right now; trade smaller.");
  throw e;
}

// 4. Build and send.
const ix = tradeInstruction({
  program: MOXIE.program,
  traderAuthority: owner.publicKey.toBase58(),
  market: MOXIE.market,
  traderPortfolio: portfolioAddress,
  lpPortfolio: LP_PORTFOLIO,
  matcherProgram: MOXIE.matcherProgram,
  matcherContext: MATCHER_CONTEXT,
  matcherDelegate: MATCHER_DELEGATE,
  traderPortfolioId: BigInt(trader.portfolioId),
  traderPositionEpoch: BigInt(trader.positionEpoch),
  lpPortfolioId: BigInt(lp.portfolioId),
  lpPositionEpoch: BigInt(lp.positionEpoch),
  lpMatcherSequence: BigInt(lp.sequence),
  assetIndex: market.assetIndex,
  marketId: BigInt(market.marketId),
  sizeQ: plan.sizeQ,
  feeBps: FEE_BPS,
  limitPriceE6: limitFor(quote, plan.sizeQ),
});

const tx = new Transaction().add(
  ComputeBudgetProgram.requestHeapFrame({ bytes: 128 * 1024 }),
  ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }),
  toWeb3(ix),
);
await sendAndConfirmTransaction(connection, tx, [owner]);

Closing a position

Closing is a trade of the opposite size. Positions decoded with decodePortfolioSummary carry a signed sizeQ, so pass -position.sizeQ as the trade size and quote it the same way.

Order sizing and margin#

FunctionWhat it does
planOrder(side, amountUsd, priceCents, leverage?)Largest order whose margin + fee fits in amountUsd. Returns contracts, signed sizeQ, notional, margin, fee and maxPayoutUsd, or null.
marginPerContract(side, price, leverage?)Initial margin per contract at price (0–1): long = price + 0.05; short = max(1 − price + 0.05, price).
quoteMatcher(ctx, oracleE6, sizeQ)The matcher’s fill price in E6 for this size, from the 320-byte matcher context. Throws MatcherCapacityError over the fill or inventory limit.
limitFor(quoteE6, sizeQ, slippageE6?)Limit price: the quote moved $0.02 (SLIPPAGE_E6) against the trader, clamped to (0, 1).
FEE_BPS, BINARY_RISK_BUFFER, SLIPPAGE_E630 bps, 0.05, 20,000 E6.

Decoding accounts#

read-portfolio.ts
import { decodePortfolioSummary, decodeImportedMarket, marketLifecycle } from "@moxie/sdk/accounts";

const info = await connection.getAccountInfo(new PublicKey(portfolioAddress));
const p = decodePortfolioSummary(info!.data);
console.log(Number(p.capital) / 1e6, "USDC");
for (const pos of p.positions) console.log(pos.assetIndex, pos.marketId, pos.sizeQ);
console.log("healthy:", p.health.valid);
  • decodePortfolioSummary: owner, capital, PnL, funding paid/received per side, portfolioId, positionEpoch, sequence, up to 16 positions and the cached health certificate.
  • decodeImportedMarket: one 384-byte market record — status, assetIndex, marketId, close time, mark, index, external and local bid/ask, basis TWAP and funding values.
  • marketLifecycle(status): "active" | "restricted" | "reduce-only" | "locked" | "resolved".
  • discoverActivePerps / discoverTradablePerps: filter decoded markets to those listed, or those accepting new risk.

Running a keeper#

Cranks are permissionless. Anyone can keep a market current with crankInstruction, passing the current slot and the asset indexes to update. The authority doesn’t need to sign; any portfolio in the group can be used as the crank portfolio.

crank.ts
import { crankInstruction } from "@moxie/sdk/instructions";

const slot = BigInt(await connection.getSlot());
const ix = crankInstruction({
  program: MOXIE.program,
  authority: payer.publicKey.toBase58(),
  market: MOXIE.market,
  portfolio: anyPortfolioAddress,
  nowSlot: slot,
  assets: [1, 2],
});

Low-level encoders#

@moxie/sdk/percolator exposes the raw instruction data if you assemble accounts yourself. Layouts are covered by byte-exact tests in packages/sdk/test/wire-layout.test.ts.

EncoderTagBytes
buildCreatePortfolioData()11
buildDepositData(id, seq, amount)333
buildWithdrawData(id, seq, amount)433
buildPermissionlessCrankData(slot, assets)510 + 3 per asset
buildTradeCpiData(request)1085
buildCloseResolvedData(feeRatePerSlot?)3017
buildHardFlatData(…)7159

Operators listing markets use @moxie/sdk/imported-market: encodeActivateImportedPerp, encodePricingObservation, encodeResolutionObservation and hashIdentity.

NextIndexer APIRead markets, charts, fills and portfolios over HTTP.