- Rust (edition 2024) -- install via rustup
- protoc --
brew install protobufon macOS,apt install protobuf-compileron Debian - SQLite3 -- typically pre-installed; the Rust
sqlxcrate bundleslibsqlite3-sys
cargo buildThe workspace compiles all four crates. Proto files are compiled by build.rs in both pearl
and oyster (using tonic-prost-build). You need protoc on your PATH.
The project uses a custom chk script that runs formatting and linting:
chkThis runs cargo fmt (with project-specific formatting options) followed by
cargo clippy --fix and other checks. Always run chk before committing.
Important: Always use chk to verify the full workspace, since cross-crate changes are common.
# Pearl unit + integration tests
cargo test -p pearl
# Oyster unit + integration tests
cargo test -p oyster
# E2E tests (boots Sui + Walrus in-process, ~30s startup)
cargo test -p oyster-e2e-testsIntegration tests for both crates spin up in-process gRPC/HTTP servers on random ports with
in-memory SQLite databases. No external services are needed for cargo test -p pearl or
cargo test -p oyster.
Use module.rs and module/submodule.rs, not mod.rs. For example:
db.rs # pub mod accounts; pub mod pending_transactions;
db/
accounts.rs
pending_transactions.rs
Always use cargo add rather than manually editing Cargo.toml:
cargo add -p pearl some-crate --features fooThis ensures you get the latest compatible version. For workspace-level dependencies, add them
to [workspace.dependencies] in the root Cargo.toml.
Use semantic commits:
feat(pearl): add balance tracking
fix(oyster): handle empty bucket list
chore: update dependencies
test(pearl): add pending transaction timeout test
docs: update PLAN.md -- mark phase 12 complete
No attributions in commit messages.
-
Add the route handler in the appropriate module under
crates/oyster/src/routes/(account.rs,blobs.rs, orbuckets.rs). Use utoipa annotations for OpenAPI docs:#[utoipa::path( get, path = "/my-endpoint", tag = "MyTag", responses( (status = 200, description = "Success"), ), security(("bearer" = [])) )] pub async fn my_handler(/* ... */) -> Result<impl IntoResponse, AppError> { // ... }
-
Register the route in
crates/oyster/src/routes.rs:.routes(routes!(my_module::my_handler))
-
Add integration tests in
crates/oyster/tests/integration.rs. Tests use thetest_app()helper which creates an in-memory database andLocalBlobStore.
-
Define the RPC in
crates/pearl/proto/pearl.protowith request/response messages. Use additive field numbers to maintain backward compatibility. -
Add models to
crates/pearl/src/models.rsif new types are needed. -
Add database functions in the appropriate
crates/pearl/src/db/module. Usesqlx::query/sqlx::query_aswith?bind parameters. -
Implement the handler in
crates/pearl/src/grpc.rsin theimpl Pearl for PearlServiceblock. Map errors via theto_statusfunction. -
Update the client in
crates/oyster/src/pearl_client.rs-- add a method toPearlConnectionthat wraps the generated gRPC client call with authentication. -
Add tests in both
crates/pearl/tests/integration.rs(gRPC-level) andcrates/oyster/tests/integration.rs(client-level).
-
Implement the
BlobStoretrait fromcrates/oyster/src/blob_store.rs:pub trait BlobStore: Send + Sync + 'static { fn store(&self, data: &[u8], account_id: Option<&str>) -> BoxFuture<'_, Result<StoreResult, BlobStoreError>>; fn read(&self, blob_id: &BlobId) -> BoxFuture<'_, Result<Vec<u8>, BlobStoreError>>; fn delete(&self, blob_id: &BlobId, sui_object_id: Option<&str>, account_id: Option<&str>) -> BoxFuture<'_, Result<(), BlobStoreError>>; fn exists(&self, blob_id: &BlobId) -> BoxFuture<'_, Result<bool, BlobStoreError>>; }
-
StoreResultreturnsblob_id(content hash) and optionallysui_object_id. Theaccount_idparameter enables per-account signing for on-chain stores. -
Add selection logic in
crates/oyster/src/main.rsbased on configuration.
Both Oyster and Pearl use sqlx::migrate!() which auto-runs migrations from the migrations/
directory on startup. Migrations are embedded in the binary at compile time.
-
Create a new
.sqlfile in the appropriatemigrations/directory with the next sequence number:crates/pearl/migrations/003_my_change.sql crates/oyster/migrations/004_my_change.sql -
Use
ALTER TABLEfor additive changes. SQLite has limitedALTER TABLEsupport -- you can add columns (ADD COLUMN) and create tables/indexes, but cannot drop columns or rename tables without recreating them. -
Always include
NOT NULL DEFAULT <value>for new columns on existing tables so that existing rows are valid. -
After adding a migration, run
chkto verify it compiles (sqlx checks migrations at compile time).
Database tests use in-memory SQLite pools via a test_pool() helper:
async fn test_pool() -> DbPool {
db::create_pool("sqlite::memory:").await.expect("in-memory pool")
}Tests start an in-process gRPC server on a random port, connect a client, and exercise the full RPC flow:
let mut client = start_server().await;
let resp = client.create_account(authenticated(proto::CreateAccountRequest { ... }))
.await.unwrap().into_inner();The authenticated() helper adds the Bearer token to requests. The start_server() helper
creates an in-memory database, binds to a random port, and returns a connected client.
Tests build a full Axum Router with in-memory SQLite and a LocalBlobStore:
let (app, _tmp, _pool) = test_app().await;
let (account_id, api_key) = create_test_account(&app).await;Use json_response() and raw_response() helpers to send requests and inspect responses.
E2E tests boot the full stack in-process (Sui test cluster, Walrus storage nodes, Pearl, and Oyster) — no external Walrus testbed is needed. They take ~30 seconds to start.
cargo test -p oyster-e2e-tests- Oyster:
AppErrorenum maps to HTTP status codes incrates/oyster/src/error.rs. Database errors become 500, not-found becomes 404, etc. - Pearl:
Errorenum maps to gRPCStatuscodes incrates/pearl/src/grpc.rsvia theto_statusfunction. Add new error variants tocrates/pearl/src/error.rsand their status mappings toto_status.
- Generate: 32 random bytes, hex-encoded (64-char string).
- Store: Blake2s-256 hash of the key. The first 8 characters are stored as a prefix for display/audit.
- Verify: Hash the incoming key, look up by hash in the database.
See crates/oyster/src/auth.rs.
A shared secret passed as Bearer {secret} in the gRPC authorization metadata header.
Checked by a tonic interceptor in crates/pearl/src/auth.rs.
For manual testing against a running Walrus local testbed (separate from the e2e tests, which
boot their own in-process cluster), use scripts/local-testbed.sh:
# Start (requires a running Walrus testbed)
./scripts/local-testbed.sh --walrus-working-dir ~/src/walrus/working_dir
# Stop
./scripts/local-testbed.sh --stopThis:
- Extracts Walrus config from the testbed's
client_config.yaml. - Starts Pearl and Oyster in tmux sessions.
- Creates an operator Pearl account.
- Funds the operator wallet with SUI and WAL.
- Prints connection details and a test API key.
The workspace pins specific Sui SDK tags via git dependencies:
sui-types,sui-sdk,shared-crypto,fastcrypto-- fromtestnet-v1.66.1(Pearl)sui-sdk-- fromtestnet-v1.65.1(Oyster, via walrus-sui)- Walrus SDK crates -- from
testnet-v1.42.1
Version conflicts between crates are bridged by Oyster re-exporting sui_types:
pub use sui_types; in crates/oyster/src/lib.rs.
Understanding the full transaction lifecycle helps when debugging:
-
Build: Oyster constructs a
TransactionData(BCS-serializable) using WalrusWalrusPtbBuilderfor operations likereserve_space,register_blob,extend_blob, orcertify_blob. -
Serialize: The
TransactionDatais BCS-encoded into bytes. -
Sign (Pearl):
- Loads the Ed25519 private key from the database.
- Wraps the
TransactionDatain anIntentMessagewithIntent::sui_transaction(). - Signs with
Signature::new_secure(). - Constructs a
Transactionfrom the data + signature. - Returns BCS-encoded
Transactionbytes. - Creates a
pending_transactionrecord and deducts estimated cost from cached balance.
-
Submit: Oyster deserializes the signed
Transactionand submits it to Sui viaexecute_transaction_block. -
Confirm: Oyster calls Pearl's
ConfirmTransactionwith the tx digest and actual gas cost. Pearl corrects the cached balance. -
Reconcile: Pearl's background task periodically queries Sui RPC for true on-chain balances and times out stale pending transactions.