Waldrop SDK · Integration

Migrating from S3 to Waldrop

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

S3Waldrop
BucketBlobStore (per-user Sui object)
Object key (s3://bucket/path/to/file)blob_id (content-addressed Walrus hash)
PutObjectclient.blob.upload()
GetObjectclient.blob.fetch()
ListObjectsV2client.blob.list()
DeleteObjectrelease_blob Move call (SDK doesn't expose yet)
Bucket policy / IAMBlobStore viewer ACL (Sui addresses)
Pre-signed URLSEAL-encrypted blob + per-blob share
Object versioningNot supported — re-upload with same content = same blob_id
Server-side encryption (SSE)SEAL (client-side, threshold)
Cost: $/GB-monthCost: 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 preservedBlobRef.contentType is recorded on-chain.
  • Filenames preservedBlobRef.originalName.
  • Content hashes — Waldrop records SHA-256 of pre-encryption bytes.
  • HTTP semantics for downloadsclient.blob.fetch is a GET with retry + timeout, same shape as S3 GETs.

Code comparison

Upload

// Before — S3
import { 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 — Waldrop
import { 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 — S3
const 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 — S3
const 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 URL
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const url = await getSignedUrl(
  s3,
  new GetObjectCommand({ Bucket, Key }),
  { expiresIn: 3600 },
);

// After — Waldrop SEAL + per-blob share
const { 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.

import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
import { waldrop, senderAddress, signer } from "../src/lib/waldrop";   // see Node integration

const BUCKET = "my-app-uploads";
const STATE = "migrated.json";          // map: s3-key → blobId
const s3 = new S3Client({ region: "us-east-1" });
const migrated: Record<string, string> = existsSync(STATE)
  ? JSON.parse(readFileSync(STATE, "utf8"))
  : {};

async function* listAllKeys() {
  let token: string | undefined = undefined;
  do {
    const r = await s3.send(new ListObjectsV2Command({
      Bucket: BUCKET, ContinuationToken: token,
    }));
    for (const obj of r.Contents ?? []) yield obj.Key!;
    token = r.NextContinuationToken;
  } while (token);
}

for await (const key of listAllKeys()) {
  if (migrated[key]) {
    console.log(`skip ${key}${migrated[key]}`);
    continue;
  }

  const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
  const bytes = new Uint8Array(await obj.Body!.transformToByteArray());

  const r = await waldrop.blob.upload({
    data: bytes,
    fileName: key.split("/").pop()!,
    contentType: obj.ContentType ?? "application/octet-stream",
    epochs: 26,
    senderAddress,
    subscriptionId: process.env.WALDROP_SUBSCRIPTION_ID!,
    signer,
    onProgress: (e) => process.stdout.write(`\r${key} [${e.stage}] ${e.percent}%   `),
  });

  console.log(`\n${key}${r.blobId}`);
  migrated[key] = r.blobId;
  writeFileSync(STATE, JSON.stringify(migrated, null, 2));
}

console.log(`\nMigrated ${Object.keys(migrated).length} objects.`);

After the script finishes, swap your DB's s3_key column for a waldrop_blob_id column and update read paths to use waldrop.blob.fetch.

Cost comparison

Walrus testnet (May 2026 pricing): 0.001658 WAL per 10 MiB · 26 epochs. That's:

  • ~$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.

Edit this page on GitHub ↗
Waldrop · 2026cryptokarigar