A mental model + concrete migration script for moving an existing
S3-backed app to Waldrop. The two object stores are more alike than
different — but the access-control story is genuinely different.
The mental map
S3
Waldrop
Bucket
BlobStore (per-user Sui object)
Object key (s3://bucket/path/to/file)
blob_id (content-addressed Walrus hash)
PutObject
client.blob.upload()
GetObject
client.blob.fetch()
ListObjectsV2
client.blob.list()
DeleteObject
release_blob Move call (SDK doesn't expose yet)
Bucket policy / IAM
BlobStore viewer ACL (Sui addresses)
Pre-signed URL
SEAL-encrypted blob + per-blob share
Object versioning
Not supported — re-upload with same content = same blob_id
Server-side encryption (SSE)
SEAL (client-side, threshold)
Cost: $/GB-month
Cost: FROST per encoded-unit per epoch
What changes
01
Content addressing
You don't pick the id. Walrus hashes your bytes and returns the
blob_id. Uploading the same bytes twice returns the same id
(idempotent, free on the second PUT).
02
No folders or keys
Waldrop's "name" for a blob is the on-chain BlobRef.originalName
— that's app-level metadata, not part of the addressing scheme.
03
Time-bounded storage
Walrus storage expires (expiryEpoch). You pay up-front for N
epochs. Renew or accept garbage collection. No infinite retention.
04
Access control via SEAL
No pre-signed URLs. Private access = SEAL-encrypted blob + viewer
ACL keyed on Sui addresses (so the recipient needs a wallet).
What stays the same
Bytes are bytes — same Uint8Array going in, same coming out.
MIME types preserved — BlobRef.contentType is recorded on-chain.
Filenames preserved — BlobRef.originalName.
Content hashes — Waldrop records SHA-256 of pre-encryption bytes.
HTTP semantics for downloads — client.blob.fetch is a GET with
retry + timeout, same shape as S3 GETs.
Code comparison
Upload
// Before — S3import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";const s3 = new S3Client({ region: "us-east-1" });await s3.send(new PutObjectCommand({ Bucket: "my-app-uploads", Key: `users/${userId}/report.pdf`, Body: bytes, ContentType: "application/pdf",}));// After — Waldropimport { WaldropClient } from "@waldrop/sdk";const waldrop = new WaldropClient({ network: "testnet" });const { blobId } = await waldrop.blob.upload({ data: bytes, fileName: "report.pdf", contentType: "application/pdf", epochs: 26, senderAddress, subscriptionId, signer,});// store `blobId` against userId in your DB — it's the new "key"
Download
// Before — S3const r = await s3.send(new GetObjectCommand({ Bucket: "my-app-uploads", Key: `users/${userId}/report.pdf`,}));const bytes = await r.Body!.transformToByteArray();// After — Waldrop (look up blobId from your DB by userId, then:)const { bytes } = await waldrop.blob.fetch({ blobId });
List a user's files
// Before — S3const objects = await s3.send(new ListObjectsV2Command({ Bucket: "my-app-uploads", Prefix: `users/${userId}/`,}));// After — Waldrop (one BlobStore per user)const blobs = await waldrop.blob.list({ owner: userAddress });// blobs is BlobRef[] — already typed, no need to parse keys
Private sharing
// Before — S3 pre-signed URLimport { getSignedUrl } from "@aws-sdk/s3-request-presigner";const url = await getSignedUrl( s3, new GetObjectCommand({ Bucket, Key }), { expiresIn: 3600 },);// After — Waldrop SEAL + per-blob shareconst { blobId, sealMarker } = await waldrop.blob.upload({ // … usual upload args encrypted: true, blobStoreId, initialShareViewers: [recipientAddress],});// `recipientAddress` can fetch + decrypt; nobody else can.
Migration script
The minimal "drain S3, fill Waldrop" — for batch migrations of historical
buckets. Resumes across runs via a per-key checkpoint file.
~$0.000XX per GB-month at current WAL price (check current spot — see
the Cost guide for live pricing)
vs S3 Standard ~$0.023 per GB-month
For long-lived data the math favors Walrus; for hot short-lived data
(thumbnails regenerated every hour) S3 is still cheaper. Forecast both
sides before committing to a full migration.
Things to think about
Garbage collection is real
Walrus deletes blob bytes after expiryEpoch. The on-chain BlobRef stays
but the aggregator returns 404 (BlobNotFoundError). Build a renewal
cron — or accept rolling expiry for ephemeral data.
Public addressing
Walrus is content-addressed and public. The blob_id doesn't include
any access-control bits — anyone with the id can fetch the bytes. Use
SEAL for anything you'd rather not leak.
No object versioning
S3 versioning is gone. If you re-upload "the same" file with different
contents, you get a different blob_id. Build versioning at the app
layer (DB row stores current_blob_id + history table).
Hybrid stays valid
You don't have to migrate everything. Many apps end up: cold storage in
Walrus (compliance, archives), hot reads still in S3 (thumbnails, CDN
origin). The SDK + S3 SDK coexist fine in one process.