Appearance
Auth flow
AuthService issues two kinds of bearer session tokens, and MakerService additionally accepts a static organization API key. Pick the path that matches the caller:
| Path | Header | Minted by | Lifetime | Accepted by |
|---|---|---|---|---|
| Keypair session | authorization: Bearer <token> | Authenticate (signed nonce) | 6 h | TxService, MakerService, EscalationService |
| Organization session | authorization: Bearer <token> | VerifyLoginCode (passwordless email) | 7 d | MakerService, EscalationService |
| Organization API key | x-api-key: <key> | static, hashed server-side | n/a | MakerService, EscalationService |
MarketDataService, StatsService, HistoricalService, and every AuthService RPC are unauthenticated. MakerService and EscalationService additionally require that the resolved maker_id be registered in the server's organizations table. maker_id provisioning is done by Flint Labs — contact us if you haven't been set up yet.
MakerService.GetSdkCredentials — the RPC behind the dashboard's registry token card — requires an organization session; demo sessions are rejected with PERMISSION_DENIED. The returned token is stable and re-fetchable: every call returns the same credentials, so there is nothing to store.
The keypair path is multi-step and signed:
Challenge(pubkey)— server returns a single-use randomnoncebound to your pubkey, valid for a short TTL.- Sign
AUTH_DOMAIN_PREFIX || nonce(the raw bytes — no extra encoding) with the keypair owning the pubkey. The constant isb"SWEETSPOT-AUTH-V1:". Authenticate(pubkey, signature)— server verifies the signature, maps the pubkey to amaker_idvia its registry, and returns asession_tokenplus anexpires_at. If the pubkey is a quoting authority for more than one maker (a shared quoting key), pass the optionalmaker_idto pick which one — see Shared quoting keys.- Use the token as
authorization: Bearer <token>on subsequent RPCs. - Re-auth before
expires_at. The SDK helpers cache the token and refreshskewahead of expiry automatically.
The passwordless email path is two RPCs:
RequestLoginCode(email)— server emails a 6-digit code, rate-limited to one request per email per minute.VerifyLoginCode(email, code)— server returns a 7-daysession_token+maker_id+expires_at.
The Rust and Python SDKs each ship an AuthFlow helper that drives the keypair handshake — the same shape in both (token / refresh / revoke, refreshing skew ahead of expiry). The TypeScript SDK ships an AuthFlow for the email path and an apiKeyInterceptor for the API key path; the keypair signed-nonce flow is not exposed in TS (use a wallet adapter directly, or the Rust/Python SDK).
Wallet signer
The signer is intentionally narrow: produce a 64-byte ed25519 signature over an arbitrary byte slice, and return the corresponding 32-byte public key. This lets you back the same AuthFlow with a keypair file, hardware wallet, remote signer, or browser wallet adapter.
rust
// The Rust SDK takes any `solana_sdk::signer::Signer` (`Keypair`,
// hardware wallet, presigner, remote signer) directly — typed as
// `Wallet = Arc<dyn Signer + Send + Sync>`.
use std::sync::Arc;
use solana_sdk::signer::keypair::{read_keypair_file, Keypair};
use flint_api_client::api::auth::Wallet;
// Local keypair file.
let kp: Keypair = read_keypair_file("/path/to/id.json")
.map_err(|e| anyhow::anyhow!("{e}"))?;
let wallet: Wallet = Arc::new(kp);
// Hardware wallets / remote signers: implement `solana_sdk::signer::Signer`
// (`pubkey()`, `try_sign_message(...)`) and wrap with `Arc::new(...)`.python
from solders.keypair import Keypair
from flint import Client
keypair = Keypair.from_json(open("/path/to/id.json").read())
client = Client(keypair=keypair)Shared quoting keys
A quoting key is usually a quoting authority for a single maker, in which case the server resolves the maker_id for you and nothing extra is needed. When the same key is registered as a quoting authority for more than one maker, pass the maker you want to authenticate as — the server scopes the session (and its rate-limit bucket) to exactly that maker, and rejects a maker_id the key isn't an authority for with UNAUTHENTICATED. If you omit maker_id for a shared key the server does not fail: it defaults to the lowest of the key's maker ids and logs a warning, so set maker_id to land on the maker you intend.
rust
// On the client builder...
let client = Client::builder()
.wallet(wallet)
.maker_id(3) // only needed for a key shared across makers
.build()
.await?;
// Or directly on the AuthFlow.
let auth = AuthFlow::new(channel, wallet).with_maker_id(3);python
client = Client(
keypair=Keypair.from_json(open("/path/to/id.json").read()),
maker_id=3, # only needed for a key shared across makers
)Refresh strategy
spawn_refresh_loop() returns a tokio::JoinHandle and defaults to a 30 s skew before expiry. Override with with_skew.token() refreshes the cached session before expiry. Configure the skew with skew_seconds.
Revoke
If you need to invalidate a token before its natural expiration (e.g. on logout), call revoke():
rust
auth.revoke().await?;python
await client.revoke()The cached session is dropped; subsequent calls re-authenticate from scratch.
Errors
AuthService.Authenticate returns UNAUTHENTICATED when:
- The pubkey has no outstanding nonce.
- The nonce has expired (TTL exceeded).
- The signature is invalid for the nonce.
- The pubkey is not registered as a quoting authority.
- A
maker_idwas supplied that the pubkey is not a quoting authority for.
A pubkey that is a quoting authority for more than one maker does not error when maker_id is omitted — the server defaults to the lowest of its maker ids and logs a warning (see Shared quoting keys).
The SDK surfaces these as AuthError::Servicegrpc.RpcError, with the gRPC status preserved so callers can branch on the code.
Python: keypair signed-nonce
The Python AuthFlow drives the same handshake. Client.authenticate() runs it once; token() returns the cached session and re-authenticates skew seconds (default 30) before expires_at; revoke() invalidates the token server-side and drops the cache. Omit the keypair when constructing a Python Client for public market-data, stats, or historical calls. Pass a keypair for maker or tx RPCs. Endpoints default to mainnet — see Overriding the endpoint.
python
import asyncio
from solders.keypair import Keypair
from flint import Client
async def main() -> None:
client = Client(
keypair=Keypair.from_json(open("/path/to/id.json").read()),
)
session = await client.authenticate() # Challenge -> sign -> Authenticate
print("authenticated as maker_id", session.maker_id)
await client.token() # cached; re-auths near expiry
await client.revoke() # invalidate + drop the cache
await client.close()
asyncio.run(main())To wire it up by hand, construct AuthFlow over an AuthServiceStub and stamp await auth.auth_metadata() ([("authorization", "Bearer <token>")]) on each authed RPC. Pass skew_seconds= to tune the refresh window.
TypeScript: passwordless email login
Browser apps authenticate against MakerService with the passwordless email flow. AuthFlow drives the two RPCs, caches the resulting session, and produces an interceptor that stamps authorization: Bearer <token> on every outbound call.
ts
import {
AuthFlow,
createMakerClient,
} from "@superis-labs/flint-api-client";
// baseUrl defaults to the mainnet public endpoint.
const auth = new AuthFlow({
storage: globalThis.localStorage, // optional — survives reload
onExpired: (session) => router.push("/login"),
});
await auth.requestLoginCode("trader@example.com");
// …prompt the user…
const session = await auth.verifyLoginCode("trader@example.com", code);
console.log("logged in as maker", session.makerId);
// baseUrl defaults to the mainnet maker endpoint.
const maker = createMakerClient({ auth });
const { balances } = await maker.getBalance({ spotIds: [] });Session persistence
AuthFlow accepts any localStorage-shaped store (getItem / setItem / removeItem). Pass globalThis.localStorage in a browser, globalThis.sessionStorage for tab-scoped persistence, or any custom adapter (IndexedDB-backed, cookie-backed, in-memory) that implements the same three methods.
ts
const auth = new AuthFlow({
storage: globalThis.localStorage,
storageKey: "sweetspot.session", // default
});On construction AuthFlow rehydrates a session from storage if one is present. On verifyLoginCode() it overwrites; on clear() and on detected expiry it removes the entry.
Expiry callback
AuthFlow does not auto-refresh — organization sessions are minted by a user-driven email flow, not a signer. Subscribe to onExpired and re-prompt:
ts
const auth = new AuthFlow({
onExpired: () => {
// Storage already cleared. Send the user back to the login form.
router.push("/login");
},
expirySkewMs: 60_000, // treat as expired this long before expiresAt; default 30s
});The callback fires once per session, inside the interceptor, just before it throws GrpcError(Unauthenticated) — wrapping for await loops will unwind on the next emission.
Organization API key
For server-side Node consumers (or any context where embedding a long-lived secret in client JS is acceptable), pass an API key directly via apiKeyInterceptor:
ts
import {
apiKeyInterceptor,
createMakerClient,
} from "@superis-labs/flint-api-client";
const maker = createMakerClient({
auth: apiKeyInterceptor(process.env.SUPERIS_API_KEY!),
});API keys are hashed server-side (SHA-256) against organizations.api_key_hashes. They are accepted by MakerService, but not by TxService, and never expire.
Browser apps
Don't ship an API key in browser JavaScript. Anyone viewing the page can read it and impersonate the organization. Use AuthFlow instead; API keys belong on a trusted server.
