The publisher PUT succeeded — your bytes are on Walrus, storage is paid for
— but the on-chain register_blob transaction failed. Don't re-upload.
Persist the checkpoint and retry just the tx.
The failure modes
01
InsufficientGasError
Wallet ran out of SUI for gas. Top up via faucet, retry.
Both carry .checkpoint — that's the only thing you need to keep.
Recipe (catch + persist + retry)
import { WaldropClient, RegistrationError, InsufficientGasError, type UploadCheckpoint,} from "@waldrop/sdk";const client = new WaldropClient({ network: "testnet", suiClient });async function uploadWithResume(args: { /* … */ }) { // ── Try the full upload first ──────────────────────────────────── try { return await client.blob.upload({ /* … */ }); } catch (err) { if (!(err instanceof RegistrationError)) throw err; if (err instanceof InsufficientGasError) { console.warn( `Wallet ${err.address} needs ${err.requiredMist} MIST — top up SUI.`, ); } // ── Persist the checkpoint so it survives a process restart ──── persistCheckpoint(err.checkpoint); // ── Wait for the user to top up / fix the cause ──────────────── await waitForUserToFixIt(); // ── Retry register only ──────────────────────────────────────── const checkpoint = loadCheckpoint(); if (!checkpoint) throw new Error("Lost checkpoint"); const result = await client.blob.registerOnly({ checkpoint, epochs: args.epochs, senderAddress: args.senderAddress, subscriptionId: args.subscriptionId, signer: args.signer, blobStoreId: args.blobStoreId, }); clearCheckpoint(); return result; }}
Filesystem persistence (Node)
The checkpoint is plain JSON — except contentHash, which is a Uint8Array.
Serialize it as a number array and reconstitute on load:
import { writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";import type { UploadCheckpoint } from "@waldrop/sdk";const PATH = ".waldrop-checkpoint.json";function persistCheckpoint(c: UploadCheckpoint) { writeFileSync( PATH, JSON.stringify({ ...c, contentHash: Array.from(c.contentHash) }, null, 2), );}function loadCheckpoint(): UploadCheckpoint | null { if (!existsSync(PATH)) return null; const raw = JSON.parse(readFileSync(PATH, "utf8")); return { ...raw, contentHash: new Uint8Array(raw.contentHash) };}function clearCheckpoint() { if (existsSync(PATH)) unlinkSync(PATH);}
Browser persistence
Swap fs for localStorage:
function persistCheckpoint(c: UploadCheckpoint) { localStorage.setItem( "waldrop:checkpoint", JSON.stringify({ ...c, contentHash: Array.from(c.contentHash) }), );}function loadCheckpoint(): UploadCheckpoint | null { const raw = localStorage.getItem("waldrop:checkpoint"); if (!raw) return null; const parsed = JSON.parse(raw); return { ...parsed, contentHash: new Uint8Array(parsed.contentHash) };}function clearCheckpoint() { localStorage.removeItem("waldrop:checkpoint");}
What's in the checkpoint
interface UploadCheckpoint { blobId: string; // Walrus content-addressed id sizeBytes: number; contentHash: Uint8Array; // SHA-256 of original bytes fileName: string; contentType: string; encrypted: boolean; sealMarker?: string; // hex, only set if encrypted}
Everything the on-chain register_blob step needs. No bytes — the bytes
live on Walrus, identified by blobId.
Why this is idempotent
Walrus storage is paid at PUT time, not register time. A failed register
doesn't waste storage tokens — the blob persists on Walrus until its
expiry. Retry as many times as you need.
One thing that's NOT recoverable
The Walrus PUT failing partway is not covered here — that's a separate
problem. But Walrus is content-addressed, so a "retry from byte 0" is
cheap if the bytes already partially landed (the publisher returns
alreadyCertified immediately).