Introduction
seal-sdk-rs gives you a Seal client that works with any Sui framework. The
crate focuses on developer experience. It ships safe defaults, but you can
replace every layer: HTTP transport, Sui client, signer, and cache. Use the
full sui_sdk stack or a lighter experimental client. The API stays simple so
you can control how requests are built, signed, cached, and executed.
Key features:
- Modular design:
BaseSealClientaccepts generic types for the HTTP client, Sui RPC adapter, caches, and error types. You can mix and match implementations without forking the crate. - Helpful defaults: The provided
SealClientvariants combinesui_sdk::SuiClient,reqwest, and different cache strategies (includingmoka) so most projects can start quickly. - Parallel performance: Batch helpers such as
encrypt_multiple_bytesreuse fetched metadata and run remote calls in parallel when possible. This keeps round trips to Mysten key servers short. - Friendly helpers: Conversion traits, generic object IDs/addresses, and BCS serializers simplify data handling. Advanced users can still manage serialization manually when they need to.
Quick Start
This guide uses the crate with the default features. In this mode the
SealClient specialization is active, combining sui_sdk::SuiClient,
reqwest, and the no-op cache adapters.
Install
Add the crate to your project:
[dependencies]
seal-sdk-rs = { git = "https://github.com/gfusee/seal-sdk-rs", tag = "0.0.5" }
Info: The examples build a
WalletContextfrom a normal Sui CLI config (WalletContext::new("<path to the config file>")). This approach is easy, but not required. Any signer that implements the crate’sSignertrait can callSessionKey::new, so you can bring your own signing logic if you prefer.
Example setup
The snippets follow the flow of the integration tests with fewer moving parts. Both examples rely on the same inputs:
- One key server identified by
setup.key_server_id. - A Seal package deployed at
setup.approve_package_id. - A wallet context that can sign personal messages.
All helpers return Result<_, SealClientError>, so the ? operator propagates
any failure.
Encrypting a string
#![allow(unused)]
fn main() {
use seal_sdk_rs::error::SealClientError;
use seal_sdk_rs::native_sui_sdk::client::seal_client::SealClient;
use sui_sdk::SuiClientBuilder;
struct DemoSetup {
approve_package_id: seal_sdk_rs::generic_types::ObjectID,
key_server_id: seal_sdk_rs::generic_types::ObjectID,
}
async fn encrypt_message(
setup: &DemoSetup,
) -> Result<seal_sdk_rs::crypto::EncryptedObject, SealClientError> {
let sui_client = SuiClientBuilder::default()
.build("https://fullnode.testnet.sui.io:443")
.await?;
let client = SealClient::new(sui_client);
let (encrypted, recovery_key) = client
.encrypt_bytes(
setup.approve_package_id,
b"my_id".to_vec(),
1,
vec![seal_sdk_rs::base_client::KeyServerConfig::new(setup.key_server_id, None)],
b"hello from seal".to_vec(),
)
.await?;
drop(recovery_key);
Ok(encrypted)
}
}
Decrypting the ciphertext
Assume the package at setup.approve_package_id contains a wildcard module
with a seal_approve function that approves requests for my_id. Use the
EncryptedObject returned by encrypt_message to rebuild the approval
transaction and recover the original string.
#![allow(unused)]
fn main() {
use seal_sdk_rs::error::SealClientError;
use seal_sdk_rs::native_sui_sdk::client::seal_client::SealClient;
use seal_sdk_rs::session_key::SessionKey;
use sui_sdk::SuiClientBuilder;
use sui_sdk::wallet_context::WalletContext;
use sui_types::Identifier;
use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
use std::str::FromStr;
struct DemoSetup {
approve_package_id: seal_sdk_rs::generic_types::ObjectID,
key_server_id: seal_sdk_rs::generic_types::ObjectID,
}
async fn decrypt_message(
setup: &DemoSetup,
encrypted: seal_sdk_rs::crypto::EncryptedObject,
) -> Result<(), SealClientError> {
let sui_client = SuiClientBuilder::default()
.build("https://fullnode.testnet.sui.io:443")
.await?;
let client = SealClient::new(sui_client);
let mut wallet = WalletContext::new("<path to the config file>").unwrap();
let session_key = SessionKey::new(
setup.approve_package_id,
5,
&mut wallet,
)
.await?;
let mut builder = ProgrammableTransactionBuilder::new();
let id_arg = builder.pure(b"my_id".to_vec())?;
builder.programmable_move_call(
setup.approve_package_id.into(),
Identifier::from_str("wildcard")?,
Identifier::from_str("seal_approve")?,
vec![],
vec![id_arg],
);
let approve_ptb = builder.finish();
let plaintext = client
.decrypt_object_bytes(
&bcs::to_bytes(&encrypted)?,
approve_ptb,
&session_key,
std::collections::HashMap::new(),
)
.await?;
assert_eq!(plaintext, b"hello from seal");
Ok(())
}
}
Concepts & Architecture
This chapter explains how the SDK fits together: what powers BaseSealClient,
how the ready-made specializations behave, and which operational details matter
when you run the client in production.
BaseSealClient
BaseSealClient (see src/base_client.rs) is the generic core. It exposes six
type parameters that let you decide which pieces to plug in:
- key-server info cache implementation
- derived-keys cache implementation
- Sui RPC error type
- Sui client implementation
- HTTP error type
- HTTP client implementation
You can supply any types that implement the required traits:
SealCachedefines a simple async cache API.SuiClientoutlines the Sui RPC calls the client needs.HttpClientasks for a singlepostmethod to talk to Seal key servers.
Because the generics stay abstract, you can swap components without editing the rest of the crate—use a mock HTTP client in tests, replace the cache with a shared service, or point to a different Sui SDK version.
Specializations
src/native_sui_sdk/client offers ready-to-use type aliases:
SealClientusessui_sdk::SuiClient,reqwest::Client, and theNoCacheadapters. Theclientandnative-sui-sdkfeatures enable it by default.SealClientLeakingCacheaddsArc<Mutex<HashMap<...>>>caches. These caches never evict, so use them only for short-lived tools.SealClientMokaCache(behind themoka-clientfeature) relies onmoka::future::Cache, giving you configurable eviction for long-lived services.
If you need something else, create your own alias that wires the right HTTP
client, Sui client, and caches into BaseSealClient.
Caching strategies
Caching is optional but useful. The client can cache two kinds of data:
- key-server metadata fetched from Sui
- derived keys fetched from the Seal servers
NoCache skips caching. The HashMap and moka adapters show how to keep
results in memory. To integrate a different cache (Redis, a database, etc.),
implement SealCache.
Session keys (JWT analogy)
SessionKey lives in src/session_key.rs. Instead of signing every decrypt
request with a wallet, you sign once to mint a short-lived key. Think of it like
a JWT:
- A signer that implements
Signercreates the session key. - During the TTL window, decrypt calls use that key without asking the wallet again.
Handle the session key like a bearer token: keep it safe in memory and drop it when you no longer need it.
Independent and committee key servers
Seal key servers come in two flavors. An independent server holds the full master secret and serves key requests directly. A committee distributes the secret across multiple participants via threshold cryptography; an aggregator collects partial responses and returns the combined result.
The ServerType enum lets you tell the two apart programmatically:
use seal_sdk_rs::base_client::ServerType;
match &info.server_type {
ServerType::Independent { url } => { /* server URL is available */ }
ServerType::Committee { .. } => { /* needs an external aggregator URL */ }
}
You can retrieve a key server’s metadata (name, public key, server type) at any
time with get_key_server_info:
let info = client.get_key_server_info(key_server_id).await?;
println!("name: {}, type: {:?}", info.name, info.server_type);
For encryption and decryption, the SDK handles both types transparently through
KeyServerConfig. Pass aggregator_url: None for independent servers and
aggregator_url: Some(url) for committees. During decryption, provide a
HashMap<ObjectID, String> mapping committee key server IDs to their aggregator
URLs. See the Committee Servers chapter for details
and examples.
Recovery keys and operational security
Every encrypt helper returns (EncryptedObject, [u8; 32]). The second value is
an emergency recovery key. Store it if you want a break-glass option when key
servers go offline. Drop it if you never want a single authority to decrypt all
payloads without the key-server quorum.
Supported Sui SDKs and bridging types
Today you can choose between two Sui SDK families:
MystenLabs/suiis mature but heavy. It pulls in a large dependency graph and build toolchain.MystenLabs/sui-rust-sdkis lightweight but still experimental.
seal-sdk-rs already bridges both worlds. src/generic_types.rs defines
ObjectID and SuiAddress, and the
BCSSerializableProgrammableTransaction trait hides differences between the SDKs.
Conversions run in both directions and all types support serde.
The built-in specializations (SealClient, SealClientLeakingCache,
SealClientMokaCache) currently target MystenLabs/sui and use JSON-RPC. gRPC
support is on the roadmap because the JSON-RPC endpoints have started their
phase-out. When the lightweight SDK stabilizes, new specializations can land
without changing the overall design.
Feature flags overview
Cargo.toml exposes several public features:
default=client,native-tls,native-sui-sdkclientenables the HTTP layer (reqwest+http).native-tlsswitchesreqwestto native TLS. Disable it if you want to opt intorustlsmanually.native-sui-sdkpulls insui_sdk,sui_types,sui_keys, andshared_crypto, plus the Sui-specific adapters.moka-clientadds themokacache specialization.
Disable the defaults if you want to bring your own implementations and re-enable only the pieces you need.
Committee Servers
Seal supports two key server modes: independent and committee. This chapter explains how they differ and how the SDK handles each.
Independent vs committee
An independent key server is a single operator that holds the master secret
and responds to key requests directly. The on-chain KeyServer object stores
the server URL, and the SDK calls that URL to fetch derived keys.
A committee key server distributes the master secret across multiple participants using threshold cryptography (MPC). No single member holds the complete secret. An aggregator service coordinates key requests: it fans out to individual members, collects partial responses until the threshold is met, combines them, and returns the result. From the SDK’s perspective, the aggregator is the single endpoint to call.
On-chain representation
Both modes use the same KeyServer Move object. The difference lies in the
KeyServerV2 dynamic field’s ServerType enum:
ServerType::Independent { url }stores the server URL directly.ServerType::Committee { version, threshold, partial_key_servers }stores the threshold and a vector ofPartialKeyServerentries (each with their own URL, partial public key, and party ID). There is no single URL on-chain for committees because the aggregator endpoint is provided by the caller.
The SDK reads the V2 dynamic field and falls back to V1 for older key servers.
Querying key server metadata
Before encrypting or decrypting, you can inspect a key server’s metadata with
get_key_server_info. The returned KeyServerInfo includes a server_type
field — a ServerType enum that mirrors the on-chain Move type:
use seal_sdk_rs::base_client::ServerType;
let info = client.get_key_server_info(key_server_id).await?;
match &info.server_type {
ServerType::Independent { url } => {
println!("Independent server at {url}");
}
ServerType::Committee { version, threshold, partial_key_servers } => {
println!("Committee v{version}, threshold {threshold}");
println!("{} partial key servers", partial_key_servers.len());
}
}
This is useful when your application needs to decide at runtime whether an aggregator URL is required, or when you want to display server details to the user.
Querying committee details
If you need to fan out requests to individual partial key servers (e.g. to build
your own aggregator), use get_committee_info. It returns
Some(ServerType::Committee { .. }) for committee servers and None for
independent ones:
use seal_sdk_rs::base_client::ServerType;
let committee_info = client.get_committee_info(key_server_id).await?;
match committee_info {
Some(ServerType::Committee { version, threshold, partial_key_servers }) => {
println!("Committee v{version}, threshold: {threshold}");
for member in &partial_key_servers {
println!(
" Party {}: {} at {}",
member.party_id, member.name, member.url
);
}
}
_ => {
println!("Not a committee server");
}
}
The partial public key bytes (partial_pk) are raw BLS12-381 G2 elements that
can be used to verify each member’s partial key response before aggregation.
KeyServerConfig
KeyServerConfig wraps a key server’s object ID with an optional aggregator URL:
use seal_sdk_rs::base_client::KeyServerConfig;
// Independent server: no aggregator URL needed.
let independent = KeyServerConfig::new(key_server_id, None);
// Committee server: provide the aggregator URL.
let committee = KeyServerConfig::new(
key_server_id,
Some("https://aggregator.example.com".to_string()),
);
During encryption, the aggregator URL has no effect; only the key server’s on-chain public key matters. During decryption, the SDK uses the aggregator URL (when present) instead of the on-chain server URL to fetch derived keys.
Encrypting with a committee
Encryption works the same way for both modes. The SDK fetches the public key
from the on-chain KeyServer object and encrypts locally:
#![allow(unused)]
fn main() {
use seal_sdk_rs::base_client::KeyServerConfig;
use seal_sdk_rs::error::SealClientError;
use seal_sdk_rs::generic_types::ObjectID;
use seal_sdk_rs::native_sui_sdk::client::seal_client::SealClient;
use sui_sdk::SuiClientBuilder;
async fn encrypt_with_committee(
package_id: ObjectID,
key_server_id: ObjectID,
aggregator_url: String,
) -> Result<seal_sdk_rs::crypto::EncryptedObject, SealClientError> {
let sui_client = SuiClientBuilder::default()
.build("https://fullnode.testnet.sui.io:443")
.await?;
let client = SealClient::new(sui_client);
let key_server = KeyServerConfig::new(key_server_id, Some(aggregator_url));
let (encrypted, _recovery_key) = client
.encrypt_bytes(
package_id,
b"my_id".to_vec(),
1,
vec![key_server],
b"secret data".to_vec(),
)
.await?;
Ok(encrypted)
}
}
Decrypting with a committee
During decryption, pass a map of key server object IDs to their aggregator URLs. The SDK routes the key fetch request to the aggregator instead of the on-chain URL:
#![allow(unused)]
fn main() {
use seal_sdk_rs::error::SealClientError;
use seal_sdk_rs::generic_types::ObjectID;
use seal_sdk_rs::native_sui_sdk::client::seal_client::SealClient;
use seal_sdk_rs::session_key::SessionKey;
use std::collections::HashMap;
use sui_sdk::SuiClientBuilder;
use sui_sdk::wallet_context::WalletContext;
use sui_types::Identifier;
use sui_types::programmable_transaction_builder::ProgrammableTransactionBuilder;
use std::str::FromStr;
async fn decrypt_with_committee(
package_id: ObjectID,
key_server_id: ObjectID,
aggregator_url: String,
encrypted: seal_sdk_rs::crypto::EncryptedObject,
) -> Result<Vec<u8>, SealClientError> {
let sui_client = SuiClientBuilder::default()
.build("https://fullnode.testnet.sui.io:443")
.await?;
let client = SealClient::new(sui_client);
let mut wallet = WalletContext::new("<path to config>").unwrap();
let session_key = SessionKey::new(package_id, 5, &mut wallet).await?;
let mut builder = ProgrammableTransactionBuilder::new();
let id_arg = builder.pure(b"my_id".to_vec())?;
builder.programmable_move_call(
package_id.into(),
Identifier::from_str("wildcard")?,
Identifier::from_str("seal_approve")?,
vec![],
vec![id_arg],
);
let aggregator_urls = HashMap::from([
(key_server_id, aggregator_url),
]);
let plaintext = client
.decrypt_object_bytes(
&bcs::to_bytes(&encrypted)?,
builder.finish(),
&session_key,
aggregator_urls,
)
.await?;
Ok(plaintext)
}
}
For independent servers, pass HashMap::new() (or omit the key server from the
map) and the SDK will use the on-chain URL as before.
Mixing independent and committee servers
You can encrypt with multiple key servers where some are independent and others are committee-based. During decryption, only include the committee servers in the aggregator URL map; independent servers will automatically use their on-chain URL:
let aggregator_urls = HashMap::from([
(committee_key_server_id, "https://aggregator.example.com".to_string()),
// independent_key_server_id is NOT in the map, so its on-chain URL is used.
]);
let plaintext = client
.decrypt_object_bytes(&encrypted_bytes, ptb, &session_key, aggregator_urls)
.await?;
Error handling
When the aggregator is unreachable or returns an error, the SDK treats it the
same as a failed independent server: the response is excluded from the threshold
count. If too few servers respond, decryption fails with
SealClientError::InsufficientKeys.
Advanced Topics
This chapter shows how to plug in custom clients, caches, and transports, plus how to pick the right feature flags and error strategy when you deploy the SDK.
Custom Sui client
You can run BaseSealClient with a different version of the Sui SDK. Implement
the SuiClient trait for the client type you want to
use and update your Cargo.toml to point at that version.
The built-in implementation tries the V2 dynamic field first (key 2) and falls
back to V1 (key 1). V2 supports both independent and committee key servers via
a ServerType enum. For committee servers the on-chain URL is empty because
the aggregator URL is provided externally through KeyServerConfig.
If you write your own implementation, handle both versions to stay compatible
with older and newer key servers. See the default implementation in
src/native_sui_sdk/client/sui_client.rs for reference.
Compile seal-sdk-rs against your chosen dependency version and the new
implementation becomes active.
Custom HTTP client
HttpClient defines a single method. Implement it
for your preferred transport (hyper, surf, a custom blocking client, etc.):
#![allow(unused)]
fn main() {
use std::collections::HashMap;
async fn post<S: ToString + Send + Sync>(
&self,
url: &str,
headers: HashMap<String, String>,
body: S,
) -> Result<PostResponse, Self::PostError>;
}
Seal key servers only expect HTTP POST requests, so you do not need anything
else.
Custom caching
To plug in your own cache, implement SealCache. The key
method, try_get_with, either returns a cached value or runs the provided
future to populate the cache:
#![allow(unused)]
fn main() {
use std::future::Future;
use std::sync::Arc;
async fn try_get_with<Fut, Error>(
&self,
key: Self::Key,
init: Fut,
) -> Result<Self::Value, Arc<Error>>
where
Fut: Future<Output = Result<Self::Value, Error>> + Send,
Error: Send + Sync + 'static;
}
Whenever possible, add request coalescing so you collapse duplicate misses into one in-flight future. This reduces unnecessary parallel calls, keeps you away from Seal server rate limits, and lightens the load on Sui RPC endpoints.
Error handling strategies
Public helpers return Result<_, SealClientError>. Examples and tests sometimes
use anyhow::Result<_>. Both styles support the ? operator, so you can bubble
errors up or wrap them in your own enums for richer diagnostics.
Deployment considerations
- Feature gating: Disable the default feature set when you want a custom
stack, then re-enable only what you need (for example,
--no-default-features --features client,moka-client). - Parallel behavior: Encrypt and decrypt helpers send requests in parallel. Keep an eye on quotas for key servers and Sui RPC. Caches plus coalescing help control the traffic.
- Recovery keys: Decide whether to store the
[u8; 32]recovery key that encrypt helpers return. Dropping it removes a potential backdoor. Storing it gives you an emergency path if key servers become unavailable.