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.
| Package | What it covers |
|---|---|
@superis-labs/flint-margin-user-client | Market and account services, owner-authenticated notifications, plus passkey-delegate and account-rename helpers. |
@superis-labs/flint-margin-maker-client | flint.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-idl | The 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.comUse 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.
| Factory | Service |
|---|---|
createPerpMarketClient | PerpMarketService |
createMarketDataClient | MarketDataService |
createOrderClient | OrderService |
createFundingClient | FundingService |
createAccountClient | AccountService |
createMarketClient | MarketService |
createRiskClient | RiskService |
createSponsorshipClient | SponsorshipService |
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:
| Option | Meaning |
|---|---|
baseUrl | Endpoint; defaults to DEFAULT_PUBLIC_ENDPOINT (mainnet). |
timeoutMs | Abort every call — streams included — after this many milliseconds. Default: none. |
interceptors | Extra gRPC-Web interceptors. |
fetch | Custom fetch (SSR, request signing, …). |
logger, logMessages | Lifecycle and payload logging — see Logging. |
rpc | A @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 method | Does |
|---|---|
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.