@flash_trade/flash-sdk-v2
v0.2.14
Published
v2 client for the Flash perpetuals program on Solana — MagicBlock Ephemeral Rollups (ER) LP + spot flows
Keywords
Readme
flash-sdk-v2
v2 client for the Flash perpetuals program, built for the MagicBlock Ephemeral
Rollups (ER) LP + spot flows. Mirrors the architecture of the magic-trade
reference client (dual connection, one-file-per-instruction, centralized PDAs,
copied ER wire/rpc plumbing), scoped to what the program currently supports on
ER: the 9 liquidity/staking/spot flows plus delegation, ER admin, and keeper
tooling. The leveraged trading/position engine is intentionally not included
(it has no ER instructions yet).
The flow model (important)
Flash's ER user flows are program-driven, not SDK-driven. You send one
base-layer *WithAction transaction; the MagicBlock validator then runs the
_er step (in the ER) and the _settle/_revert step (back on base) on its
own via queued post-delegation / post-undelegate actions.
your 1 tx ─► swap_with_action (base) you build + send this
└─ validator ─► swap_er (ER) auto
└─ swap_settle / swap_revert (base) autoSo the SDK builds only the *WithAction entry per user op and then polls
the receipt for the outcome — it never builds _er/_settle/_revert.
const client = new FlashClient(provider, programId, { prioritizationFee: 5000 });
const pc = PoolConfig.fromIdsByName("Crypto.1", "mainnet-beta");
const { instructions, swapReceipt } = await client.swapWithAction(pc, {
inSymbol: "USDC", outSymbol: "SOL",
inAccount, outAccount, amountIn, minAmountOut,
});
await client.sendAndConfirmBase(instructions);
const outcome = await client.awaitOutcome("swapReceipt", swapReceipt);
// outcome.status === "settled" | "reverted" | "timeout"Wait — swapWithAction returns the receipt PDA so you can awaitOutcome it.
Trade layer (basket-backed positions & orders)
A second architecture sits alongside the LP flows: per-user baskets hold positions + orders inline, funded through a per-mint trade vault + a user deposit ledger. Setup and trading:
// one-time setup (base layer)
await client.sendBase((await client.trade.initUserDepositLedgerEr(owner)).instructions);
await client.sendBase((await client.trade.initBasketEr(owner)).instructions);
await client.sendBase((await client.trade.depositDirectEr(owner, usdcMint, userUsdcAta, amount)).instructions);
await client.sendBase((await client.trade.delegateBasket(owner, 30_000, client.validatorKey())).instructions);
// trade — DIRECT-ER (basket is delegated): send to the ER RPC
const { instructions } = await client.trade.openPositionEr(pc, {
market, targetSymbol: "SOL", lockSymbol: "USDC", receivingSymbol: "USDC",
priceWithSlippage: { price, exponent }, collateralAmount, sizeAmount,
});
await client.sendEr(instructions, [sessionKeypair]);Execution model by group:
| Group | Where | How to send |
|---|---|---|
| initTradeVaultEr, initBasketEr, initUserDepositLedgerEr, depositDirectEr, delegateBasket, migratePositionToBasket, migrateOrderToBasket | base | sendBase |
| Positions: openPositionEr, closePositionEr, increasePositionSizeEr, decreasePositionSizeEr, addCollateralEr, removeCollateralEr, liquidatePositionEr | direct-ER (delegated basket) | sendEr |
| Orders: placeLimitOrderEr/editLimitOrderEr/cancelLimitOrderEr/executeLimitOrderEr, placeTriggerOrderEr/editTriggerOrderEr/cancelTriggerOrderEr/cancelAllTriggerOrdersEr/executeTriggerOrderEr | direct-ER | sendEr |
| Withdrawal: requestWithdrawalWithAction → (validator: process → execute) | base entry | sendBase, then client.awaitClosed(withdrawalEscrow) |
| Custody settlement: requestCustodySettlementWithAction → (validator) | base entry | sendBase, then client.awaitClosed(settlementReceipt) |
Notes:
- Referral / token-stake benefits (same as flash-sdk v1): the position ops
(
open/close/increase/decrease) andexecute*orders append a 2-slot remaining-account tail[referral, tokenStake]gated byprivilege(Privilege.None | Stake | Referral). ForStakepass the trader'stokenStakeAccount(=client.pdas.tokenStake(owner)); forReferralpassreferralAccount(=client.pdas.referral(owner)) and the referrer'stokenStakeAccount. No referral / no token stake → just usePrivilege.None(the default) and omit the accounts: the tail is skipped and the trade runs at full fee. A missing/invalid account never reverts (the program returns a benefit-status and charges full fee), but the SDK avoids sending phantom accounts. Built via the portedgetReferralAccounts/buildReferralTail(utils/referral.ts), matching flash-sdk v1. - User position/order builders accept session keys: pass
signer(a session key) +sessionToken; omit both to sign asowner. execute*order builders andliquidatePositionErare keeper ops (no session; pass the positionownerto derive the basket).- Custody roles (target/lock/receiving/dispensing/reserve/receive) are given as token symbols and resolved to custody + oracle accounts via PoolConfig.
- Withdrawal/custody-settlement are validator-driven 3-phase (
request_* → process_*_er → execute_*_base_chain_er); the SDK builds only the baserequest_*entry andawaitClosedpolls the escrow/receipt PDA (which the final execute step closes — these receipts have noprocessedfield).
Accounts: fetch, wrappers, subscriptions
Core accounts come back as slim wrapper classes (accounts/wrappers.ts):
a typed .data field (the decoded struct — no IDL field-mirroring, so it never
drifts), the account publicKey, and helper methods. <X>Account.from(pubkey,
decoded) is a pure wrap — fed identically by fetch, subscribe, or any decode.
const pool = await client.fetch.pool(poolPk); // PoolAccount
pool.publicKey; pool.data.lpSupply; // typed
pool.getCustodyId(custodyPk);
const ts = await client.fetch.tokenStake(owner); // TokenStakeAccount | null
ts?.getWithdrawableAmount(); ts?.getLockStatus();fetch.* returns wrappers for pool/poolByName, custody, market,
position, order, tokenStake, govTokenVault. (Transient/aux accounts —
receipts, basket, ledger, escrows, whitelist, flpStake — stay typed POJOs.)
Subscriptions (client.subscribe.*) use raw connection.onAccountChange +
coder.decode + the same wrappers — not anchor's program.account.X.subscribe
(which hides the subscription id, drops slot/raw-info, and is bound to one
connection). Each returns an unsubscribe fn and can target base or ER:
const off = client.subscribe.pool(poolPk, (p, slot) => {
console.log(slot, p.data.lpSupply.toString());
});
// delegated account → read from the ER:
client.subscribe.position(owner, market, (pos) => {...}, { er: true });
off(); // tear downclient.subscribe.raw("basket", pk, cb) is the escape hatch for any account.
Views / quotes (client.views.*)
On-chain view functions — the program computes and the SDK decodes the typed
result (no TS re-implementation of pricing math). Mirrors magic-trade's
ViewHelper: build the view ix → raw simulateTransaction (unbounded wire via
buildUnboundedSimulateBytes, no signing) → decode the Program return: log
with IdlCoder. Not anchor .view() — that uses a 2048-byte
Message.serialize() buffer (breaks at ~55 keys) and can't target the ER
aperture. Views route to the ER ViewHelper when the client has an ER
endpoint (delegated pool/custody/market state lives there), else the base helper.
Each resolves accounts + the verified remaining-account tail:
const fee = await client.views.getSwapAmountAndFees(pc, { receivingSymbol: "USDC", dispensingSymbol: "SOL", amountIn });
const lp = await client.views.getLpTokenPrice(pc); // BN
const q = await client.views.getOpenPositionQuote(pc, { market, targetSymbol:"SOL", collateralSymbol:"USDC", receivingSymbol:"USDC", amountIn, leverage });
const pnl = await client.views.getPnl(pc, { owner, market, targetSymbol:"SOL", collateralSymbol:"USDC" });18 views: liquidity (getAdd/RemoveLiquidityAmountAndFee,
getAdd/RemoveCompoundingLiquidityAmountAndFee, getCompoundingTokenData,
getCompoundingTokenPrice, getLpTokenPrice), swap (getSwapAmountAndFees),
and position quotes (getOpenPositionQuote, getEntryPriceAndFee,
getClosePositionQuote, getExitPriceAndFee, getAddCollateralQuote,
getRemoveCollateralQuote, getLiquidationPrice, getLiquidationState,
getPnl, getPositionData). Position-bound quotes take owner + market
(position PDA ["position", owner, market]).
Notes / verified contracts:
- Liquidity/compounding/lp-price views send the AUM tail
[custodies, oracles, markets](IncludePnl); swap sends[custodies, oracles](ExcludePnl);getOpenPositionQuotetakes an optionalexistingPositionremaining account; the other position quotes take no tail. - Not included:
get_assets_under_managementandget_oracle_pricereturn Rust tuples with no IDLreturns(the IDL can't describe a tuple). Decode those viaclient.viewHelper.decodeReturnWithTypedef(simResult, typeDef)with a hand-built typedef if you need them —ViewHelperis exported. - Routing is automatic: with an
erEndpointset, views simulate against the ER (delegated state); otherwise against the base RPC.client.viewHelper/client.erViewHelperare exposed for custom simulate/decode.
Governance-token staking + referral / rebates (base layer)
Both are base-layer (sendBase). Verified against source — refresh_token_stake
takes a batch of token_stake accounts as writable remaining accounts;
reimburse takes the AUM tail (custodies+oracles+markets, IncludePnl).
// `stakingMint` = the governance/staking token mint; `stakerAta` = the user's
// associated token account for that mint, e.g.
// getAssociatedTokenAddressSync(stakingMint, owner)
await client.tokenStake.depositTokenStake(stakingMint, stakerAta, amount);
await client.tokenStake.unstakeTokenRequest(amount);
await client.tokenStake.collectTokenReward(stakingMint, stakerAta); // rewards -> stakerAta
await client.tokenStake.refreshTokenStake([stakeA, stakeB]); // keeper batch
// referral
await client.referral.createReferral(referrer); // referrer = referrer's wallet
await client.referral.collectRebate(rebateMint, recipientAta);
await client.referral.settleRebates(pc); // keeper; uses pool.reward_custodyclient.tokenStake.*: depositTokenStake, unstakeTokenRequest,
cancelUnstakeTokenRequest, collectTokenReward, refreshTokenStake, plus
admin distributeTokenReward / setTokenReward / setTokenStakeLevel /
withdrawUnclaimedTokens. client.referral.*: createReferral,
collectRebate, settleRebates, plus admin initRebateVault / reimburse.
Fetchers: client.fetch.tokenStake(owner), client.fetch.referral(owner),
client.fetch.govTokenVault(). The staking token mint is supplied by the caller
(one singleton ["token_vault"]); tokenStake/referral/rebate_vault PDAs
are derivable via client.pdas.*.
Note: settleRebates/createReferral use pool.reward_custody / the
referrer's token stake; rewardSymbol defaults to "USDC" (override if your
pool's reward custody differs).
Two interaction styles (LP)
| Style | Where | API |
|---|---|---|
| User flows (self-orchestrating) | base layer, 1 tx | client.swapWithAction, addLiquidityAndStakeWithAction, removeLiquidityWithAction, add/removeCompoundingLiquidityWithAction, collectStakeRewardWithAction, compoundFeesWithAction, migrateStakeWithAction, migrateFlpWithAction |
| Admin delegation / init | base layer | client.admin.delegatePool / delegateCustody / delegateMarket / delegateInternalOracle / delegateReallocVault / initReallocVault / topUpReallocVault / initCustodyAccount / initMarketAccount (+ undelegate*) |
| ER config / price setters (direct-ER) | ER RPC | client.admin.addCustodyEr / addMarketEr / setPoolConfigEr / setCustodyConfigEr / setMarketConfigEr / setCustodyTokenMultiplierEr / setInternalCurrentPriceEr / setInternalEmaPriceEr / setInternalLazerPriceEr — send via client.sendEr(...) |
| Keeper | base + ER | client.keeper.batchDelegateFlpStake (base), undelegateFlpStake (base), refreshStakeEr (ER), fetchPoolFlpStakes |
The *Er config/price setters and refreshStakeEr mutate already-delegated
accounts and must be sent to the ER RPC via client.sendEr(ixs, [keypair])
(legacy wire format; v0/ALT is not supported on the ER). Everything else goes to
the base layer via client.sendBase / sendAndConfirmBase.
Layout
src/
FlashClient.ts dual-connection client; binds all builders
constants.ts program ids, seeds, delegation/magic ids, validator keys
types.ts Side enum + helpers (account types aliased — see note)
PoolConfig.ts/.json ported from v1
OraclePrice.ts ported from v1 (quoting helpers)
utils/
pda.ts centralized PDA derivation (incl. delegation siblings)
delegation.ts #[delegate] injected-account fragments
remainingAccounts.ts AUM + whitelist tail builder
erWire.ts erRpc.ts copied verbatim from magic-trade (ER wire quirks)
rpc.ts math.ts
instructions/
user/*.ts the 9 *WithAction builders
admin/*.ts delegation + ER config
keeper/*.ts batch delegate, refresh, sweep
accounts/receipts.ts receipt outcome pollerTypes
Account/nested types are concrete generated interfaces in
src/idl/generatedTypes.ts (produced by scripts/generateTypes.js from the
IDL), re-exported through types.ts. They use anchor's runtime conventions
(camelCase fields, camelCase enum variant tags, BN for 64/128-bit ints,
PublicKey, Buffer for bytes). Plain interfaces are used instead of anchor's
generic IdlAccounts<Perpetuals> / IdlTypes<Perpetuals> because the latter
trip TS2589 ("type instantiation excessively deep") on this ~1MB IDL — for the
same reason Program is used untyped (as magic-trade does). Regenerate after
an IDL change:
npm run generate-typesKnown follow-ups
awaitReceiptOutcomedistinguishes settle vs revert by the receipt's output field; if the receipt is closed before aprocessed=1read is observed it assumes settled. Decode the*Er/*Settleevent logs if you need certainty.- Cluster: pass
{ cluster: "devnet" }in opts — the program id and validator key resolve automatically (the bundled IDL address is rewritten). An explicitprogramIdconstructor arg overrides the cluster default. PoolConfig is already cluster-keyed viaPoolConfig.fromIdsByName(name, cluster). The validator key is otherwise optional: builders defaultvalidatorto null and the on-chain program falls back to its compiled per-cluster validator.
Build
npm install
npm run build