Auth flow
AuthService issues short-lived session tokens bound to a Solana pubkey. Every authenticated RPC (BalanceService, TxService, HistoricalService) accepts the token via authorization: Bearer <token> metadata. MarketDataService is public — no token needed.
The flow 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.
Each SDK ships an AuthFlow helper that drives all five steps — you implement a WalletSigner (sign-arbitrary-bytes + return-pubkey) and the helper handles the rest.
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.
use async_trait::async_trait;
use superis::auth::{AuthError, WalletSigner};
use ed25519_dalek::{Signer as _, SigningKey};
struct KeypairFile(SigningKey);
#[async_trait]
impl WalletSigner for KeypairFile {
async fn sign(&self, bytes: &[u8]) -> Result<Vec<u8>, AuthError> {
Ok(self.0.sign(bytes).to_bytes().to_vec())
}
fn public_key(&self) -> [u8; 32] {
self.0.verifying_key().to_bytes()
}
}import "crypto/ed25519"
type keypairFile struct {
priv ed25519.PrivateKey
pub [32]byte
}
func (k *keypairFile) Sign(_ context.Context, bytes []byte) ([]byte, error) {
return ed25519.Sign(k.priv, bytes), nil
}
func (k *keypairFile) PublicKey() [32]byte { return k.pub }import * as ed25519 from "@noble/ed25519";
import type { WalletSigner } from "@superis/sweetspot-client";
const priv = ed25519.utils.randomPrivateKey();
const pub = await ed25519.getPublicKeyAsync(priv);
const signer: WalletSigner = {
async sign(bytes) {
return ed25519.signAsync(bytes, priv);
},
publicKey() {
return pub;
},
};Drive the flow
use std::sync::Arc;
use superis::auth::AuthFlow;
use tonic::transport::Channel;
let channel = Channel::from_static("https://api.example.com").connect().await?;
let auth = AuthFlow::new(channel.clone(), Arc::new(my_signer));
// Run Challenge → sign → Authenticate; cache the bearer + maker_id.
let session = auth.token().await?;
println!("authenticated as maker_id={}", session.maker_id);
// Authenticated client: pipe RPCs through the AuthInterceptor.
let mut balances = superis::proto::balance_service_client::BalanceServiceClient::with_interceptor(
channel,
auth.interceptor(),
);
// Optional: keep the session fresh in the background.
let _refresher = auth.spawn_refresh_loop();auth := superis.NewAuthFlowOnConn(conn, mySigner)
session, err := auth.Token(ctx)
if err != nil { log.Fatal(err) }
fmt.Println("maker_id:", session.MakerID)
// Use the interceptors on a per-service connection. The unary + stream
// interceptors both attach `authorization: Bearer <token>`.
authedConn, _ := grpc.NewClient(addr,
grpc.WithTransportCredentials(creds),
grpc.WithUnaryInterceptor(auth.UnaryInterceptor()),
grpc.WithStreamInterceptor(auth.StreamInterceptor()),
)
bal := pb.NewBalanceServiceClient(authedConn)
// Optional: refresh in the background.
stop := auth.StartRefreshLoop(ctx)
defer stop()import { createConnectTransport } from "@connectrpc/connect-web";
import { createClient } from "@connectrpc/connect";
import { AuthFlow, BalanceService } from "@superis/sweetspot-client";
const transport = createConnectTransport({
baseUrl: "https://api.example.com",
useBinaryFormat: true,
});
const auth = new AuthFlow({ transport, signer });
const session = await auth.token();
console.log("maker_id:", session.makerId.toString());
// Authenticated client: layer the AuthFlow interceptor on a fresh transport.
const authedTransport = createConnectTransport({
baseUrl: "https://api.example.com",
useBinaryFormat: true,
interceptors: [auth.interceptor()],
});
const balances = createClient(BalanceService, authedTransport);
// Optional: refresh in the background.
const stop = auth.startRefreshLoop();
window.addEventListener("beforeunload", stop);Refresh strategy
| SDK | Strategy |
|---|---|
| Rust | spawn_refresh_loop() returns a tokio::JoinHandle |
| Go | StartRefreshLoop(ctx) returns a func() { cancel } |
| TypeScript | startRefreshLoop() returns a () => void stop fn |
All three default to a 30 s skew before expiry. Override with with_skew (Rust) / WithSkew (Go) / skewMs constructor option (TS).
Revoke
If you need to invalidate a token before its natural expiration (e.g. on logout), call revoke():
auth.revoke().await?;err := auth.Revoke(ctx)await auth.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.
The SDK helpers surface these as language-specific error types (AuthError::Service in Rust, returned error in Go, thrown ConnectError in TS) with the gRPC status preserved so callers can branch on the code.