Skip to content
LogoLogo

TypeScript SDK

ESM-only TypeScript clients for the Flint perps API. They speak gRPC-Web directly to the Flint server, run in any modern browser and in Node 20.18+, and ship pre-generated code — no build-time codegen for consumers.

PackageWhat it covers
@superis-labs/flint-margin-user-clientMarket and account services, owner-authenticated notifications, plus passkey-delegate and account-rename helpers.
@superis-labs/flint-margin-maker-clientflint.margin.maker.v1 — signed-nonce AuthFlow, MakerService, TxService, and a Solana-RPC fallback for TxService. The other maker services ship as generated descriptors.
@superis-labs/flint-idlThe margin program's Codama IDL and @solana/kit client. Re-exported by both packages above. See On-chain clients.

Install

The TypeScript packages are restricted on GitHub Packages. Ask the operator to grant your GitHub account access to the client and its flint-api-client and flint-idl dependencies. A Kellnr token does not grant npm access.

npm login --scope=@superis-labs --auth-type=legacy --registry=https://npm.pkg.github.com

Use your GitHub username and an operator-approved token with read:packages. See Maker onboarding for the access check.

Install the client you need with its peer dependencies:

npm install @superis-labs/flint-margin-user-client@7.0.4 @bufbuild/protobuf @solana/kit
npm install @superis-labs/flint-margin-maker-client@7.0.4 @bufbuild/protobuf @solana/kit

@bufbuild/protobuf (^2.2) handles serialization and @solana/kit (^5.5) handles keys, addresses, and transactions. The gRPC-Web transport ships in-tree.

Public data — flint-margin-user-client

One factory per service; each returns a typed client whose unary methods return a Promise and whose streaming methods return an AsyncIterable.

FactoryService
createPerpMarketClientPerpMarketService
createMarketDataClientMarketDataService
createOrderClientOrderService
createFundingClientFundingService
createAccountClientAccountService
createMarketClientMarketService
createRiskClientRiskService
createSponsorshipClientSponsorshipService
import {
  DEVNET_PUBLIC_ENDPOINT,
  createAccountClient,
  createMarketDataClient,
  createPerpMarketClient,
} from "@superis-labs/flint-margin-user-client";
 
// This example selects devnet explicitly; use the operator's URL for another network.
const options = { baseUrl: DEVNET_PUBLIC_ENDPOINT };
 
const perps = createPerpMarketClient(options);
const { markets } = await perps.listPerpMarkets({});
for (const m of markets) console.log(m.symbol, m.tickSize?.value);
 
const accounts = createAccountClient(options);
const { account } = await accounts.getAccount({ traderId: { id: 42n } });
console.log(account?.health?.netEquity?.value);
 
// Server stream: a snapshot, then sequenced deltas.
const marketData = createMarketDataClient(options);
for await (const update of marketData.subscribeOrderBook({
  perpId: markets[0].perpId,
  depth: 20,
})) {
  console.log(update.snapshot, update.sequence, update.orderBook?.bids);
}

Every factory takes the same ClientOptions:

OptionMeaning
baseUrlEndpoint; defaults to DEFAULT_PUBLIC_ENDPOINT (mainnet).
timeoutMsAbort every call — streams included — after this many milliseconds. Default: none.
interceptorsExtra gRPC-Web interceptors.
fetchCustom fetch (SSR, request signing, …).
logger, logMessagesLifecycle and payload logging — see Logging.
rpcA @solana/kit RPC, exposed as client.rpc for direct chain reads.

uint64 fields are bigint (traderId: { id: 42n }); uint32 ids such as PerpId and SpotId are number.

Racing several endpoints

To pin to the fastest of several hosts, build a transport with endpoints and pair it with a service descriptor. The transport probes each candidate with NodeService.GetNodeInfo (3 s timeout by default) and pins to the fastest responder; calls queue until the race finishes.

import {
  GrpcWebTransport,
  PerpMarketService,
  createServiceClient,
} from "@superis-labs/flint-margin-user-client";
 
const transport = new GrpcWebTransport({
  endpoints: ["https://devnet.api.flint.trade", "https://other-node.example"],
});
const perps = createServiceClient(PerpMarketService, transport);

No silent chain fallback

The user client never substitutes chain reads for server responses. Account health, rates, and valuations are computed server-side; if the server is down, read raw accounts explicitly with client.rpc and the re-exported IDL fetchers, and show health as unavailable rather than recomputing it.

Margin account notifications

The margin-user package exposes NotificationService; its RPCs require a wallet-signed margin session for the selected trading account's current owner. Use the margin-maker package's AuthFlow to keep the bearer and server endpoint together. The wallet signature should follow an explicit user action.

import { AuthFlow, createServiceClient } from "@superis-labs/flint-margin-maker-client";
import { NotificationService } from "@superis-labs/flint-margin-user-client";
 
const auth = new AuthFlow({ baseUrl, traderId, signer: { address, signMessage } });
await auth.token();
const notifications = createServiceClient(NotificationService, auth.authenticatedTransport());
const settings = await notifications.getSettings({});
const challenge = await notifications.requestEmailVerification({ email });
// After the user enters the code sent to that address:
await notifications.verifyEmail({ challengeId: challenge.challengeId, code });

Requesting a code leaves the current verified destination unchanged. Verifying the single-use code enables email for new fills, replacing any previous destination without a historical backlog. disableEmail({}) disables email and cancels pending verification; enabling email again requires a fresh code. The notification bell uses public AccountService.GetFills history and client-local read/dismiss state, which never suppresses email delivery. Clear account-scoped state when the wallet, trading account, or network changes. Keep session tokens in memory and reauthenticate on a different server endpoint.

expiresAtMs and retryAfterMs are bigint milliseconds. emailAddress is empty when email is disabled; deliveryAvailable reports whether the server can send mail.

Maker client

flint-margin-maker-client authenticates a Solana key that owns a trader account or is an active TRADE- or QUOTE-scoped delegate on one. Point it at the authed host — TxService exists only there.

The TypeScript client is thinner than the Rust one: no node discovery, managed heartbeat, or long-running quoting core. It does include a quote builder.

Authentication

AuthFlow runs Challenge → sign → Authenticate, signing FLINT-MARGIN-AUTH-V1: followed by the nonce. It caches the session, refreshes 30 s before expiry (expirySkewMs), and shares one handshake across concurrent callers. The signer is either a @solana/kit message signer or any object with an address and a signMessage(bytes) that returns the 64-byte signature — the shape browser wallets expose.

import { createKeyPairSignerFromBytes } from "@solana/kit";
import {
  AuthFlow,
  DEVNET_MAKER_ENDPOINT,
  TxService,
  createMakerClient,
  createServiceClient,
} from "@superis-labs/flint-margin-maker-client";
 
const signer = await createKeyPairSignerFromBytes(secretKeyBytes);
 
const auth = new AuthFlow({
  baseUrl: DEVNET_MAKER_ENDPOINT,
  signer,
  // traderId: 7n, // omit to select the lowest trader id the key controls
});
 
const maker = createMakerClient({ auth });
const { entries } = await maker.getQuotingState({ perpIds: [] });
 
// Any other maker service: reuse the flow's authenticated transport.
const tx = createServiceClient(TxService, auth.authenticatedTransport());
for await (const event of tx.subscribeTxStatus({})) {
  console.log(event.kind.case, event.kind.value); // "ack" | "landed" | "failed"
}
AuthFlow methodDoes
token()Returns a fresh Session (token, traderIds, traderId, expiresAt), authenticating if needed.
refresh()Forces a new signed challenge. Use after an unexpected UNAUTHENTICATED.
session()The cached session, or null.
revoke()Revokes the token server-side and clears it.
clear()Drops the local session without an RPC.
interceptor()Interceptor that attaches authorization: Bearer … to each call.
authenticatedTransport()A transport sharing the flow's endpoint and connection, with the interceptor installed.

Already hold a session token from elsewhere? Pass bearerInterceptor(token) as auth to createMakerClient instead of an AuthFlow.

There is no maker balance RPC; read collateral and health with the user client's AccountService.getAccount or subscribeAccount.

The package root exports descriptors for AuthService, MakerService, and TxService. The rest are generated under the /gen subpath — for example the spread-protection feed:

import { SpreadProtectService } from "@superis-labs/flint-margin-maker-client/gen/flint/margin/maker/v1/spread_protect/service_pb.js";
 
const spread = createServiceClient(SpreadProtectService, auth.authenticatedTransport());
for await (const event of spread.subscribeSpreadProtect({})) {
  console.log(event.kind.case, event.kind.value); // "resetState" | "widenWindow"
}

node/service_pb.js and escalation/service_pb.js work the same way. SessionService.Session is bidirectional, which gRPC-Web can't carry, so the session heartbeat isn't available from TypeScript.

Submitting transactions

Build and sign the transaction with @solana/kit and the re-exported program builders, then submit its wire bytes. The session's key must be one of the transaction's required signers.

import { getTransactionEncoder } from "@solana/kit";
 
const wire = getTransactionEncoder().encode(signedTx);
const { signature } = await tx.submitTx({ transaction: new Uint8Array(wire) });

submitTx returns on acceptance; follow subscribeTxStatus for ack → landed / failed. subscribeBlockhash streams a recent blockhash with lastValidBlockHeight and a priority-fee recommendation.

withTxFallback(tx, rpcUrl) wraps a TxService client so that submitTx, subscribeBlockhash, and subscribeSlots fall back to a plain Solana RPC when the Flint server is unreachable (UNAVAILABLE, DEADLINE_EXCEEDED, UNKNOWN, INTERNAL, or a network error). subscribeTxStatus has no faithful RPC equivalent and is not covered; pollTxStatus is the opt-in per-signature substitute.

Quote builder

QuoteBuilder builds updateQuoting instructions with the same methods as the Rust and Python builders — update, reset, enable, densityFair, densityBands, relativeDensityBands, and orderList — and allocates sequence numbers above the ones the chain has seen. Seed it with quotingMarketsFromState, which turns MakerService.getQuotingState entries into each admitted market's entry index and sequences:

import { QuoteBuilder, quotingMarketsFromState } from "@superis-labs/flint-margin-maker-client";
 
const session = await auth.token();
const { entries } = await maker.getQuotingState({ perpIds: [] });
let markets = quotingMarketsFromState(entries, session.traderId);
 
const built = await new QuoteBuilder()
  .orderList(
    0,
    [{ price: 120_000, quantity: 5, orderId: 1n, slotExpiry: slot + 30n }],
    [{ price: 120_100, quantity: 5, orderId: 2n, slotExpiry: slot + 30n }],
  )
  .build({ traderId: session.traderId, signer, markets });
markets = built.markets;

built.groups holds one instruction list per market, in builder order; send each group as one transaction through your signer and tx.submitTx. built.markets carries the reserved sequences — pass it to the next build, and pass the previous map as quotingMarketsFromState's third argument when you refresh, so sequences never move backwards. A market you aren't admitted to, or a sequence that isn't newer than the chain's, throws QuoteBuilderError. Set delegateTraderAccount when the signer is a delegate. Signing, submission, retries, and receipts stay with you: unlike Rust and Python, the TypeScript SDK has no QuotingCore, the same split as the spot SDKs.

Browser wallets

createTransactionSigner({ address, signTransaction }) and createSendingSigner({ address, signAndSendTransaction }) adapt a wallet's callbacks into @solana/kit signers, so wallet-signed transactions go through the same kit pipeline. Both packages export them.

Preparing perpetual orders

preparePerpOrder reads maker quoting, the resting order book, and the Clock sysvar together from Solana RPC, filters live counterparties within the price bound, derives their writable trader accounts in ascending ID order, and returns an instruction. It does not sign, simulate, or submit the transaction.

import { address, createNoopSigner } from "@solana/kit";
import { preparePerpOrder, resolveRpc, FLINT_PROGRAM_ADDRESS } from "@superis-labs/flint-margin-user-client";
 
const controller = new AbortController();
const prepared = await preparePerpOrder({
  programAddress: address(FLINT_PROGRAM_ADDRESS),
  signer: createNoopSigner(address(walletAddress)),
  traderId: 42n,
  clientOrderId: 123n,
  linearPerpId: market.linearPerpId,
  side: "buy",
  sizeBase: "0.01",
  minimumOrderSizeBase: market.minimumOrderSizeBase,
  minimumPriceIncrement: market.minimumPriceIncrement,
  referencePriceTicks: market.markPriceTicks,
  maxSlippageBps: 50,
  timeInForce: "FOK",
}, resolveRpc(rpcUrl), { signal: controller.signal });

Use prepared.instruction with your transaction signer and sender.

Market orders require an explicit slippage bound. The default time in force is FOK for market orders and GTC for limit orders; explicit IOC is supported. GTD requires a future expiresAtSlot, and post-only requires GTC or GTD. clientOrderId defaults to zero. Closes can use buildPerpCloseInstruction, which rounds an integer position fraction down to lots and forces reduce-only FOK.

Preparation defaults to 16 attempts, a 300 ms retry delay, a 5 s request timeout, and an 8-slot expiry buffer. Configure these through PerpPreparationOptions. onWaitingForQuotes fires once when retrying unavailable liquidity. Live quotes outside the bound fail immediately; the SDK never widens the bound. The returned slot is the RPC read context, suitable for simulation's minContextSlot. A prepared order can expire while awaiting a signature.

For already-loaded chain data, pass both slot and chain unixSeconds to buildPerpOrderInstruction or selectCrossingMakers so timestamp expiries use the chain clock rather than a slot or the device clock.

buildPerpOrderInstruction, selectCrossingMakers, decodeQuoteEntrySide, decodeAggregatedQuoteBook, and readRestingOrders are pure helpers exported by @superis-labs/flint-idl and re-exported by the margin clients. Quote depth is indicative: maker margin caps and reduce-only resting quantities are enforced by the program. Density expansion is bounded to 64 levels per side, and aggregated depth to 64 prices; availableLots is not a fill guarantee. Simulate before sending.

Streaming and reconnects

Server streams are plain AsyncIterables. Cancel with an AbortSignal in the call options. For long-lived subscriptions, streamWithBackoff reconnects on a 500 ms → 30 s exponential schedule and yields connected, item, and disconnected events:

import { streamWithBackoff } from "@superis-labs/flint-margin-user-client";
 
const ctrl = new AbortController();
for await (const ev of streamWithBackoff(
  (signal) => accounts.subscribeAccount({ traderId: { id: 42n } }, { signal }),
  ctrl.signal,
)) {
  if (ev.kind === "item") render(ev.value);
  else if (ev.kind === "disconnected") console.warn("dropped", ev.error);
}

Each reconnect starts with a fresh snapshot — replace local state rather than merging.

Decimal handling

Prices, sizes, balances, and rates arrive as Decimal { value: string }. Parse with bignumber.js or decimal.js — never Number or parseFloat:

import BigNumber from "bignumber.js";
 
const price = new BigNumber(level.price!.value);
const quantity = new BigNumber(level.quantity!.value);
const notional = price.multipliedBy(quantity);

Timestamps are Timestamp { micros: bigint }.

Errors

Every failure is a GrpcError with a numeric code (GrpcCode). statusCode(err), statusMessage(err), asGrpcError(err), and responseMetadata(err) narrow an unknown safely.

import { GrpcCode, statusCode } from "@superis-labs/flint-margin-user-client";
 
try {
  await perps.listPerpMarkets({});
} catch (err) {
  if (statusCode(err) === GrpcCode.ResourceExhausted) await sleep(backoff);
  else throw err;
}

See Errors for the error codes.

Logging

The SDKs are silent unless you pass a logger with optional debug and trace functions. At debug, transport construction logs the SDK version, proto hash, and proto revision it stamps on requests.

const perps = createPerpMarketClient({
  baseUrl: DEVNET_PUBLIC_ENDPOINT,
  logger: { debug: console.debug, trace: console.debug },
  logMessages: { enabled: true, maxMessageChars: 50_000 },
});

Payload logging is opt-in and emitted at trace. Auth fields (nonce, signature, session_token) are redacted, and bearer headers are never logged. logMessages.redact can rewrite a payload after the built-in redaction.

Wire format

The transport speaks application/grpc-web+proto over fetch. Every request carries x-flint-sdk: ts-web/<version>, x-flint-proto-hash, and x-flint-proto-rev; the same values are exported as SDK_VERSION, PROTO_HASH, and PROTO_REV. Client- and bidi-streaming RPCs are not supported over gRPC-Web, and none exist in the margin API.