Getting Started with iWalrusSDK: Store and Retrieve Data on Walrus from iOS and macOS

iWalrusSDK is a community-built Swift SDK that saves developer time by giving them a native path to upload, download, and cache data on Walrus.

Getting Started with iWalrusSDK: Store and Retrieve Data on Walrus from iOS and macOS

Building native Walrus storage into an iOS or macOS app from scratch means writing your own HTTP client around the publisher and aggregator endpoints, handling multipart uploads and timeouts, building a local cache layer, and threading JWT auth through every request — easily a few days of plumbing. iWalrusSDK is a community-built Swift SDK that cuts this work down to an afternoon, giving you a native path to upload, download, and cache data on Walrus.

iWalrusSDK currently focuses on HTTP mode, it talks to publisher and aggregator endpoints over HTTPS rather than signing Sui transactions directly. That makes it the simplest path to get blobs flowing in an iOS app, with no wallet integration required. If your app needs the user to sign storage transactions with their own Sui wallet, you'll want to combine iWalrusSDK with a Sui wallet SDK or wait for direct mode to land in a future release.

What you'll learn

  • How to add iWalrusSDK to an Xcode project (SPM or embedded framework)
  • Configuring WalrusClient with publisher and aggregator endpoints
  • Uploading blobs from memory and from disk
  • Downloading blobs with automatic local caching
  • Fetching blob metadata without downloading the body
  • Adding JWT authentication for protected publishers
  • Handling errors gracefully with WalrusAPIError
  • Verifying your setup end-to-end with a CLI smoke test before wiring up UI

Prerequisites

  • Xcode 14+ (tested with the Xcode 26 toolchain)
  • Swift 5.7+
  • iOS 15.0+ or macOS 12.0+ deployment target
  • Network access to a Walrus publisher and aggregator (Testnet endpoints work fine)

No Sui or blockchain experience is required. If you've made a network request in Swift before, you have everything you need.

A quick mental model

Before any code, it helps to know what's on the other end of the wire.

Walrus splits storage into two types of nodes you'll interact with through this SDK:

  • Publishers accept your blob, run erasure coding on it, distribute the slivers to storage nodes, and pay the on-chain cost of registering the blob. From your app's point of view, a publisher is just an HTTPS endpoint that accepts PUTrequests.
  • Aggregators serve blobs back. They reassemble slivers from storage nodes and stream the original bytes to the client. From your app's point of view, an aggregator is just an HTTPS endpoint that accepts GET requests.

That's the whole model for HTTP mode. iWalrusSDK wraps both endpoints in a single WalrusClient, adds local caching for downloads, and gives you Swift-native error types and async/await ergonomics on top.

Adding iWalrusSDK to your project

The repo ships as an Xcode project containing the WalrusSDK framework target and a demo app. You have two integration paths.

Option A: Swift Package Manager

This is the path of least resistance. Clone the SDK locally:

git clone https://github.com/akhtarshahnawaz/iWalrusSDK.git

Then in Xcode: File → Add Packages... → Add Local... and pick the iWalrusSDK/WalrusSDK directory (the one containing Package.swift).

A note on local SPM dependencies. SPM has a long-standing bug where adding a local package via .package(path:) from another package can produce an "unable to override package" identity error. If you hit this when integrating into an existing multi-package workspace, the cleanest workaround is to embed the SDK source directly into your project rather than adding it as a path dependency.

Option B: Embedded Framework

If you'd rather drop in a built framework:

  1. Open the iWalrusSDK Xcode project.
  2. Select the WalrusSDK framework target and build it (Product → Build or ⌘B).
  3. Locate the built WalrusSDK.framework bundle in your DerivedData directory (Xcode → Settings → Locations).
  4. Drag WalrusSDK.framework into your app's project, into the Frameworks group.
  5. In your app target's General tab, find Frameworks, Libraries, and Embedded Content and set the embedding option for WalrusSDK.framework to Embed & Sign.

Either way, you import it the same way:

import WalrusSDK

Sendable warnings on newer toolchains. When building against the Swift 6 toolchain in Xcode 26, you'll see a couple of Sendable-conformance warnings about FileManager. They're cosmetic, the SDK builds and runs fine. You can silence them by lowering strict-concurrency settings, but most apps just ignore them.

Creating a WalrusClient

WalrusClient is the entry point for everything. You configure it once with publisher and aggregator URLs plus a few client-wide options.

import WalrusSDK
import Foundation

let publisherURL = URL(string: "https://publisher.walrus-testnet.walrus.space")!
let aggregatorURL = URL(string: "https://aggregator.walrus-testnet.walrus.space")!

do {
    let client = try WalrusClient(
        publisherBaseURL: publisherURL,
        aggregatorBaseURL: aggregatorURL,
        timeout: 60,                 // seconds; default is 30
        cacheDir: nil,               // see note below — nil = ephemeral
        cacheMaxSize: 100,           // megabytes
        useSecureConnection: true    // default is false; set true for HTTPS validation
    )
    // Hold onto this client — it's safe to reuse across requests.
} catch {
    print("Failed to create WalrusClient:", error)
}

A few parameters worth knowing up front:

  • timeout applies to all network operations. On flaky mobile connections, 60 seconds is a reasonable default; for batch uploads of large blobs, even higher. Public testnet publishers occasionally take a while because they're doing the erasure coding plus an on-chain registration round trip.
  • cacheDir: nil does not use a persistent cache directory, it creates a unique temp directory per WalrusClient instance that gets wiped when the instance is deallocated. If you want a cache that survives app launches, pass a stable URL (for example, a path inside your app's caches directory or a shared App Group container).
  • cacheMaxSize is the upper bound for the local download cache, in megabytes. The SDK evicts older blobs when this is exceeded.
  • useSecureConnection: true is what you want in production. The SDK's default is false, which lets it accept self-signed certificates, useful for local testing against your own publisher, but not what you want against public endpoints. Always set it explicitly.

Verifying your endpoints

Before you spend time debugging mysterious timeouts, confirm the publisher and aggregator are actually serving traffic:

curl -sS -o /dev/null -w "publisher: HTTP %{http_code}\n" \
  --max-time 10 https://publisher.walrus-testnet.walrus.space/v1/blobs

curl -sS -o /dev/null -w "aggregator: HTTP %{http_code}\n" \
  --max-time 10 https://aggregator.walrus-testnet.walrus.space/v1/api

A 405 on the publisher is correct — curl defaults to GET but the endpoint expects PUT, so a 405 means routing works. A 200 from the aggregator means it's healthy. Anything else (timeout, 5xx, DNS failure) means the endpoint is having a moment, trying a different operator from the awesome-walrus list.

Your first upload

The simplest case: take some bytes you already have in memory, push them to a publisher, and get back a blob ID you can later use to retrieve them.

func uploadBlob(client: WalrusClient, data: Data) async {
    do {
        let response = try await client.putBlob(
            data: data,
            epochs: 1,                   // how many storage epochs to keep the blob alive
            deletable: true,             // can be explicitly deleted before expiry
            sendObjectTo: nil            // optionally transfer the on-chain blob object
        )
        print("Upload succeeded:", response)
    } catch {
        print("Upload failed:", error)
    }
}

What the publisher gives you back is a JSON object with a shape that varies depending on whether the blob is new or already certified. For a fresh upload you'll see something like this (formatted for readability):

{
    newlyCreated = {
        blobObject = {
            blobId = "zz47BSUiH-BBW0uC83yFMLehIzoJKeFrvxlR50dAa-g";
            certifiedEpoch = "<null>";
            deletable = 1;
            encodingType = RS2;
            id = 0x3afeeb801f4b17f4ec1ff310ec487370d7f3313941fd2edc4f9046fe5ded77b8;
            registeredEpoch = 387;
            size = 102;
            storage = {
                endEpoch = 388;
                startEpoch = 387;
                storageSize = 66034000;
            };
        };
        cost = 262143;
        resourceOperation = {
            registerFromScratch = { encodedLength = 66034000; epochsAhead = 1; };
        };
    };
}

Two things worth noticing:

  • storageSize is much larger than your payload (66 MB for a 102-byte blob in this run). That's not a bug — it's the encoded length after Red Stuff erasure coding, which has a per-blob minimum. Cost scales with the encoded size, not your raw bytes, so very small blobs are relatively expensive. Pack them into a single larger blob if you can.
  • certifiedEpoch is null on first response. The publisher has registered and stored your blob, but certification happens asynchronously. You can read the blob immediately, but if you query the on-chain object right away, certification won't yet be reflected.

Extracting the blob ID

The blob ID lives at newlyCreated.blobObject.blobId for new uploads and alreadyCertified.blobId for re-uploads of the same content. Rather than hardcoding both paths, recursively walk the response, this is what the official iWalrusDemo does, and it survives any future changes to the response shape:

func findBlobId(in json: Any) -> String? {
    if let dict = json as? [String: Any] {
        for (key, value) in dict {
            if key == "blobId", let id = value as? String { return id }
            if let found = findBlobId(in: value) { return found }
        }
    } else if let array = json as? [Any] {
        for item in array {
            if let found = findBlobId(in: item) { return found }
        }
    }
    return nil
}

let response = try await client.putBlob(data: data, epochs: 1, deletable: true)
guard let blobId = findBlobId(in: response) else {
    throw NSError(domain: "MyApp", code: -1)
}

Uploading directly from a file

For files already on disk, photos from the photo picker, downloaded documents, recorded video — you don't need to load the bytes into memory yourself:

func uploadBlobFromFile(client: WalrusClient, fileURL: URL) async {
    do {
        let response = try await client.putBlobFromFile(fileURL: fileURL)
        print("File upload succeeded:", response)
    } catch {
        print("File upload failed:", error)
    }
}

For very large files where even reading the whole file into memory is wasteful, use the streaming variant. It uses URLSessionUploadTask under the hood and pipes bytes from disk to network without buffering the whole file:

let response = try await client.putBlobStreaming(fileURL: fileURL, epochs: 1)

Downloading a blob

Once you have a blob ID, read the data back through the aggregator:

func downloadBlob(client: WalrusClient, blobId: String) async {
    do {
        let data = try await client.getBlob(blobId: blobId)
        print("Blob downloaded, size:", data.count)
    } catch {
        print("Download failed:", error)
    }
}

The first call hits the aggregator and writes the blob to the local cache. Subsequent calls for the same blob ID return from the cache without a network round trip, until the cache evicts it under pressure from cacheMaxSize, or until your WalrusClient instance is deallocated if you didn't pass an explicit cacheDir.

For larger assets you don't want to hold in memory, write straight to disk:

try await client.getBlobAsFile(blobId: blobId, destinationURL: destinationURL)

This is the right shape for video, large archives, or anything you want to expose to other apps via a UIDocumentPickerViewController or share sheet. There's also a streaming variant — getBlobAsFileStreaming — that uses URLSessionDownloadTask for very large blobs.

If you want to bypass the cache and force a network round trip, for example, to confirm a blob is actually retrievable rather than reading a stale local copy — use:

let data = try await client.getBlobByObjectId(objectId: blobId)

Despite the name, this works with the same blob ID, it just skips the cache lookup.

Checking metadata without downloading

Sometimes you just want to know if a blob exists, or read its size and content type, without paying the cost of pulling the full body. iWalrusSDK exposes this through HTTP HEAD requests:

let metadata = try await client.getBlobMetadata(blobId: blobId)
// metadata is a [AnyHashable: Any] dictionary of HTTP headers

A response from a public testnet aggregator looks like this:

Etag: zz47BSUiH-BBW0uC83yFMLehIzoJKeFrvxlR50dAa-g
Content-Length: 102
Cache-Control: public, max-age=86400
Access-Control-Allow-Origin: *
cf-cache-status: MISS

Useful patterns:

  • Existence check. Confirm a blob is still available on the network before showing a download button.
  • Pre-flight UI. Read Content-Length to show a progress bar with an accurate total before kicking off the actual download.
  • Edge cache awareness. Aggregators commonly sit behind a CDN (Cloudflare in the case above). The cf-cache-status header tells you whether you're hitting the edge or going to origin, useful when measuring tail latency.

Authentication

Public testnet publishers are unauthenticated, but anything you run in production probably shouldn't be. iWalrusSDK supports JWT bearer tokens out of the box.

Setting a client-wide token

client.setJWTToken("your.jwt.token.here")

Once set, every subsequent request includes:

Authorization: Bearer your.jwt.token.here

Per-request override

If you have a use case where different requests need different tokens, for example, a token issued specifically for a single upload, pass one inline:

let response = try await client.putBlob(
    data: data,
    jwtToken: "temporary.token.here"
)

This overrides the client-wide token for that one call only.

Clearing a token

On logout or session end:

client.clearJWTToken()

A few things to keep in mind:

  • The SDK doesn't persist tokens between launches. Storing and refreshing them is your app's job, Keychain is the right place.
  • Expired or invalid tokens come back as WalrusAPIError with HTTP 401 or 403 status codes. Handle those by triggering your refresh flow.

Tokens travel in plaintext across the wire, so useSecureConnection: true (HTTPS) is non-negotiable in production.

Error handling

Every method on WalrusClient throws on failure. The thrown errors are typed as WalrusAPIError and carry enough structure for production error handling:

  • code - the HTTP status code or an internal error code
  • status - a string status identifier
  • message - a human-readable description
  • details - extra context provided by the publisher or aggregator
  • context - a custom field you can use to attach your own debugging hints

In practice:

do {
    let data = try await client.getBlob(blobId: blobId)
    // success path
} catch let error as WalrusAPIError {
    switch error.code {
    case 401, 403:
        // refresh JWT and retry
    case 404:
        // blob expired or never existed — surface to user
    case 408, 504:
        // timeout — retry with backoff
    default:
        // log and report
    }
} catch {
    // non-API errors: networking, file I/O, etc.
}

Treat 5xx responses as transient and retry with exponential backoff. Treat 4xx responses (except auth) as terminal, the same request will keep failing.

Putting it together — A SwiftUI Image Uploader

Here's an end-to-end SwiftUI screen that lets the user pick a photo, uploads it to Walrus, and then downloads it back from the blob ID to confirm the round trip worked.

import SwiftUI
import PhotosUI
import WalrusSDK

@MainActor
final class WalrusViewModel: ObservableObject {
    @Published var blobId: String?
    @Published var downloadedImage: UIImage?
    @Published var status: String = "Ready"
    @Published var isWorking = false

    private let client: WalrusClient

    init() {
        self.client = try! WalrusClient(
            publisherBaseURL: URL(string: "https://publisher.walrus-testnet.walrus.space")!,
            aggregatorBaseURL: URL(string: "https://aggregator.walrus-testnet.walrus.space")!,
            timeout: 60,
            cacheDir: nil,
            cacheMaxSize: 200,
            useSecureConnection: true
        )
    }

    func upload(_ data: Data) async {
        isWorking = true
        defer { isWorking = false }
        status = "Uploading…"

        do {
            let response = try await client.putBlob(
                data: data,
                epochs: 1,
                deletable: true,
                sendObjectTo: nil
            )
            if let id = findBlobId(in: response) {
                self.blobId = id
                self.status = "Uploaded — blob ID: \(id.prefix(12))…"
            } else {
                self.status = "Upload returned no blob ID"
            }
        } catch {
            self.status = "Upload failed: \(error)"
        }
    }

    func download() async {
        guard let id = blobId else { return }
        isWorking = true
        defer { isWorking = false }
        status = "Downloading…"

        do {
            let data = try await client.getBlob(blobId: id)
            self.downloadedImage = UIImage(data: data)
            self.status = "Downloaded \(data.count) bytes"
        } catch {
            self.status = "Download failed: \(error)"
        }
    }

    private func findBlobId(in json: Any) -> String? {
        if let dict = json as? [String: Any] {
            for (key, value) in dict {
                if key == "blobId", let id = value as? String { return id }
                if let found = findBlobId(in: value) { return found }
            }
        } else if let array = json as? [Any] {
            for item in array {
                if let found = findBlobId(in: item) { return found }
            }
        }
        return nil
    }
}

struct WalrusUploadScreen: View {
    @StateObject private var viewModel = WalrusViewModel()
    @State private var selection: PhotosPickerItem?

    var body: some View {
        VStack(spacing: 24) {
            PhotosPicker("Pick an image", selection: $selection, matching: .images)
                .buttonStyle(.borderedProminent)
                .disabled(viewModel.isWorking)

            Button("Download from blob ID") {
                Task { await viewModel.download() }
            }
            .disabled(viewModel.blobId == nil || viewModel.isWorking)

            if let image = viewModel.downloadedImage {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(maxHeight: 300)
                    .cornerRadius(12)
            }

            Text(viewModel.status)
                .font(.footnote)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
        }
        .padding()
        .onChange(of: selection) { _, newItem in
            guard let newItem else { return }
            Task {
                if let data = try? await newItem.loadTransferable(type: Data.self) {
                    await viewModel.upload(data)
                }
            }
        }
    }
}

That's the full loop: pick → upload → blob ID → download → render. Roughly 80 lines of view-model code, almost all of which is plumbing rather than Walrus-specific logic.

Verifying your setup with a CLI Smoke Test

Before wiring up the UI, it's worth confirming the SDK can actually reach a publisher from your machine. The simplest way is a tiny SPM executable target inside the SDK clone itself.

From the cloned iWalrusSDK/WalrusSDK  directory, create a folder for the test:

mkdir -p SmokeTest

Drop this into SmokeTest/main.swift:

import Foundation
import WalrusSDK

let publisherURL = URL(string: "https://publisher.walrus-testnet.walrus.space")!
let aggregatorURL = URL(string: "https://aggregator.walrus-testnet.walrus.space")!

func findBlobId(in json: Any) -> String? {
    if let dict = json as? [String: Any] {
        for (key, value) in dict {
            if key == "blobId", let id = value as? String { return id }
            if let found = findBlobId(in: value) { return found }
        }
    } else if let array = json as? [Any] {
        for item in array {
            if let found = findBlobId(in: item) { return found }
        }
    }
    return nil
}

@main
struct SmokeTest {
    static func main() async {
        do {
            let client = try WalrusClient(
                publisherBaseURL: publisherURL,
                aggregatorBaseURL: aggregatorURL,
                timeout: 180,
                cacheDir: nil,
                cacheMaxSize: 50,
                useSecureConnection: true
            )

            let payload = "Hello from iWalrusSDK at \(Date()) — \(UUID().uuidString)"
            let uploadData = payload.data(using: .utf8)!

            let response = try await client.putBlob(
                data: uploadData, epochs: 1, deletable: true
            )
            guard let blobId = findBlobId(in: response) else {
                print("✗ No blobId in response"); exit(1)
            }
            print("✓ Uploaded, blob ID: \(blobId)")

            try await Task.sleep(nanoseconds: 2_000_000_000)
            let downloaded = try await client.getBlob(blobId: blobId)

            if downloaded == uploadData {
                print("✓ Round trip confirmed")
            } else {
                print("✗ Bytes mismatch"); exit(1)
            }
        } catch {
            print("✗ \(error)"); exit(1)
        }
    }
}

Add an executable target to the SDK's Package.swift:

.executableTarget(
    name: "SmokeTest",
    dependencies: ["WalrusSDK"],
    path: "SmokeTest"
)

Then run it:

swift run SmokeTest

You should see two green check marks — one for the upload, one for the byte-for-byte download match. If the publisher times out, retry with a longer timeout, or check the awesome-walrus operator list for an alternate endpoint.

What's next?

Here’s a few directions worth exploring once the basics are working:

  • Run your own publisher. The Walrus docs cover deploying a publisher you control, which is what you'll want for production apps where you don't want to depend on a public gateway and where you want full control over authentication and rate limits.
  • On-chain ownership. Use sendObjectTo to transfer the on-chain blob object to a user's Sui wallet, that wallet then owns the blob's lifecycle (deletion, attribute updates) without your app sitting in the middle.
  • Combine with Seal. Walrus stores ciphertext just as happily as plaintext. Encrypt sensitive blobs client-side before upload and use Seal for on-chain access control.
  • Explore other Walrus SDKs. If you're building cross-platform, the Dartus SDK covers Flutter, the TypeScript SDK covers web, and walrus-python covers backend Python.