Skip to main content

Upload a large binary (SOCIAL_BLOB)

Binary payloads > 16 KB (images, audio, files, large text) are stored as chunked SOCIAL_BLOB records. Each chunk is a separate transaction. Once finalized, the blobId can be referenced by IMAGE_MINT, AUDIO_MINT, SOCIAL_DM_SEND, SOCIAL_POST_CREATE, CHAT_TURN, etc.

Constants

MAX_CHUNK_BYTES = 400_000 // ~400 KB per SOCIAL_BLOB_CHUNK tx (after base64 encoding)

A 5 MB image = ~13 chunks = 13 transactions plus INIT + FINALIZE = 15 total.

Flow

1. Compute blobId + contentHash from full payload
2. SOCIAL_BLOB_INIT — declare blobId, totalBytes, totalChunks, contentHash
3. SOCIAL_BLOB_CHUNK × N — upload chunks in order
4. SOCIAL_BLOB_FINALIZE — chain verifies reassembled hash matches
5. Reference blobId from your target tx (IMAGE_MINT etc.)

Full code

import {
OmbraClient, Wallet,
buildSocialBlobInitTx, buildSocialBlobChunkTx, buildSocialBlobFinalizeTx,
} from "@ombrachain/sdk";
import { sha256 } from "@noble/hashes/sha256";
import { readFileSync } from "fs";

const client = new OmbraClient({ endpoint: "https://api.ombra-net.com" });
const wallet = Wallet.fromMnemonic("...");

async function uploadBlob(filePath: string, contentType: string): Promise<string> {
const data = readFileSync(filePath);
const totalBytes = data.length;
const contentHash = bytesToHex(sha256(data));
const blobId = contentHash; // content-addressed

const CHUNK_SIZE = 400_000;
const chunks: Buffer[] = [];
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
chunks.push(data.subarray(i, i + CHUNK_SIZE));
}
const totalChunks = chunks.length;

let acct = await client.chain.getAccount(wallet.address);
let nonce = acct.nonce;

// 1. INIT
const initTx = buildSocialBlobInitTx(
wallet.address, blobId, contentType, totalBytes, totalChunks, contentHash,
nonce++, wallet.privateKey,
);
await client.chain.submitTx(initTx);
console.log("init:", blobId);

// 2. CHUNKS
for (let i = 0; i < chunks.length; i++) {
const chunkTx = buildSocialBlobChunkTx(
wallet.address, blobId, i, chunks[i].toString("base64"),
nonce++, wallet.privateKey,
);
await client.chain.submitTx(chunkTx);
console.log(` chunk ${i + 1}/${totalChunks}`);
// Rate limit: wait 100ms between chunks to avoid mempool flood
await new Promise((r) => setTimeout(r, 100));
}

// 3. FINALIZE
const finalizeTx = buildSocialBlobFinalizeTx(
wallet.address, blobId,
nonce++, wallet.privateKey,
);
await client.chain.submitTx(finalizeTx);
console.log("finalized:", blobId);

return blobId;
}

function bytesToHex(b: Uint8Array): string {
return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");
}

// Usage
const blobId = await uploadBlob("./photo.png", "image/png");
// Now you can use blobId in an IMAGE_MINT tx

Cost

Each tx has implicit chain costs (proposer fees, mempool inclusion). For a 5 MB blob (15 txs), expect:

  • ~15 × MIN_BURN_PER_BLOCK = 10 micro = 150 micro burned (negligible)
  • No explicit fee on SOCIAL_BLOB_* txs

But your account must have enough balance to fund the wallet ops if your custom client charges them.

Verification

After finalize, query the blob:

# Metadata (size, hash, chunks)
curl https://api.ombra-net.com/api/social/blob/BLOB_ID

# Reassembled binary
curl https://api.ombra-net.com/api/social/blob/BLOB_ID/file > restored.png

Compare sha256(restored.png) with the original contentHash — they must match. If not, FINALIZE would have failed.

Race conditions

If two chunks arrive at the chain out of order (e.g., one is delayed in P2P), the chain accepts them by chunkIndex. FINALIZE only succeeds if all [0, totalChunks) are present.

For best reliability:

  • Use nonce++ strictly sequentially (don't parallelize chunk submission from same wallet)
  • After all chunks submitted, wait ~3 blocks (~45s) before FINALIZE to ensure propagation

Re-uploading

Blobs are content-addressed by blobId = sha256(payload). If INIT succeeds for a blobId that already exists, the chain returns 409 Conflict. You can skip the upload and reference the existing blobId directly.