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 |
| Organization session | authorization: Bearer <token> | VerifyLoginCode (passwordless email) | 7 d | MakerService |
| Organization API key | x-api-key: <key> | static, hashed server-side | n/a | MakerService only |
MarketDataService, StatsService, HistoricalService, and every AuthService RPC are unauthenticated. MakerService additionally requires that the resolved maker_id be registered in the server's organizations table.
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.- 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.
// 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 sweetspot_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(...)`.Refresh strategy
spawn_refresh_loop() returns a tokio::JoinHandle and defaults to a 30 s skew before expiry. Override with with_skew.
Revoke
If you need to invalidate a token before its natural expiration (e.g. on logout), call revoke():
auth.revoke().await?;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.
The SDK surfaces these as AuthError::Service, 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 and auth_url when constructing a Python Client for public market-data, stats, or historical calls. Pass both for maker or tx RPCs.
import asyncio
from solders.keypair import Keypair
from flint import Client, Endpoints
async def main() -> None:
client = Client(
Endpoints(
public_url="https://api.superis.exchange",
auth_url="https://auth.api.superis.exchange",
),
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.
import {
AuthFlow,
createMakerClient,
} from "@superis-labs/sweetspot-client";
const auth = new AuthFlow({
baseUrl: "https://api.superis.exchange",
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);
const maker = createMakerClient({
baseUrl: "https://auth.api.superis.exchange",
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.
const auth = new AuthFlow({
baseUrl,
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:
const auth = new AuthFlow({
baseUrl,
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:
import {
apiKeyInterceptor,
createMakerClient,
} from "@superis-labs/sweetspot-client";
const maker = createMakerClient({
baseUrl: "https://auth.api.superis.exchange",
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.
