Skip to content

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:

  1. Challenge(pubkey) — server returns a single-use random nonce bound to your pubkey, valid for a short TTL.
  2. Sign AUTH_DOMAIN_PREFIX || nonce (the raw bytes — no extra encoding) with the keypair owning the pubkey. The constant is b"SWEETSPOT-AUTH-V1:".
  3. Authenticate(pubkey, signature) — server verifies the signature, maps the pubkey to a maker_id via its registry, and returns a session_token plus an expires_at.
  4. Use the token as authorization: Bearer <token> on subsequent RPCs.
  5. Re-auth before expires_at. The SDK helpers cache the token and refresh skew ahead 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.

rust
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()
    }
}
go
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 }
ts
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

rust
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();
go
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()
ts
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

SDKStrategy
Rustspawn_refresh_loop() returns a tokio::JoinHandle
GoStartRefreshLoop(ctx) returns a func() { cancel }
TypeScriptstartRefreshLoop() 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():

rust
auth.revoke().await?;
go
err := auth.Revoke(ctx)
ts
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.

Apache 2.0