SDK Cookbooks
End-to-end recipes for morse-sdk: publish content, revise entries, encrypt and decrypt, delegate write access, and tear down resources. For installation and adapter wiring, see SDK Setup.
Prerequisites
| Requirement | Details | Purpose |
|---|---|---|
| Bun or Node | v1.2+ / ≥ 18 | JavaScript runtime |
| SUI + WAL tokens | SUI faucet · Swap SUI for WAL | Gas fees (SUI) and Walrus storage fees (WAL) |
| Bech32 private key | A suiprivkey1... string | SDK authentication. Export from your wallet's settings |
Make sure the SDK is installed and configured before continuing. See SDK Setup for details.
Lifecycle
Publications hold collections; collections hold entries; entries hold an append-only vector of revisions. Permission management lives in Sui; payloads live in Walrus.
The examples below assume you have built a context as shown in SDK Setup. Browser flows carry a signer (via WalletStandardSigner.fromAccount) instead of keypair. Walrus adapters (walrusWriter, walrusReader) are constructed where needed. See Picking a Walrus Adapter for options.
Creating a publication
Returns the publication's ID along with an OwnerCap for governance and a PublisherCap for write access, both transferred to the caller's address atomically.
import { ContractAbortError, createPublication } from "@arcadiasystems/morse-sdk"; try { const created = await createPublication(adapter, config, { name: "My Publication", slug: "my-publication", }); // created.publicationId, created.ownerCapId, created.publisherCapId } catch (err) { if (err instanceof ContractAbortError && err.reason === "ESlugAlreadyExists") { // slug is globally unique - pick another or look up the existing publication } throw err; }
Adding a collection
storageMode is fixed at creation; switching the mode for the same collection name requires deleting and recreating.
import { createCollection } from "@arcadiasystems/morse-sdk"; await createCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "blog", storageMode: "blob", // or "quilt" for packed small payloads });
Adding an entry
Uploads bytes to Walrus and attaches them as the first revision of a new entry in 2 transactions. Returns the assigned entryId (a monotonic u64) used to reference the entry in all subsequent operations.
import { addEntryFromBytes, DefaultWalrusWriteAdapter, } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, // or `signer` in the browser ); const entry = await addEntryFromBytes(adapter, config, { walrus: walrusWriter, publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", name: "first-post", bytes: new TextEncoder().encode("hello world"), contentType: "text/plain", upload: { epochs: 3, deletable: true }, }); // entry.entryId is the stable monotonic ID (0, 1, 2, ...)
addEntryFromBytes uses the Walrus flow-aware capability to combine certify_blob and add_entry_to_collection into a single PTB, reducing the total from 3 transactions to 2. The HTTP publisher path collapses this further to 1 transaction.
UncertifiedBlobErrorUncertifiedBlobError carrying blobObjectId and blobId. The blob exists on storage nodes but remains uncertified and is released when the registration expires. Log the IDs or surface the error for debugging.See also: CLI Cookbooks → Publishing your first entry
Encrypted content
Encrypts content with Seal, uploads ciphertext to Walrus, and attaches it as the first revision of an encrypted entry in 2 transactions. Returns the same entryId as the plaintext path. Encrypted entries are always created as drafts and accessed via Seal decryption, not the public read path.
import { addEncryptedEntryFromBytes, buildPublisherSealId, createCollection, DefaultSealAdapter, DefaultWalrusWriteAdapter, } from "@arcadiasystems/morse-sdk"; // Create a blob-mode collection for encrypted content. await createCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "secret-blog", storageMode: "blob", }); const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // Fresh per-entry identity. publicId(32) || tag(1=Publisher) || nonce. const nonce = crypto.getRandomValues(new Uint8Array(16)); const sealId = buildPublisherSealId(created.publicationId, nonce); const plaintext = new TextEncoder().encode("secret content"); const result = await addEncryptedEntryFromBytes(adapter, config, { walrus: walrusWriter, seal, publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "secret-blog", name: "private-doc", plaintext, contentType: "application/octet-stream", sealId, upload: { epochs: 3, deletable: true }, });
buildPublisherSealId constructs the Seal identity from the publication ID and a random nonce. Each entry needs a fresh nonce; reusing a nonce produces the same identity. When creating a SessionKey, always pass config.originalPackageId ?? config.packageId as the package ID. Seal uses the original (genesis) package address, not the current upgrade address.
publishFromDraft and publishDirect always write non-encrypted revisions; the contract hardcodes encrypted=false on the public path. For encrypted content, use addEncryptedEntryFromBytes / appendEncryptedDraftRevision and read via seal.decrypt.Reading encrypted entries
The full read flow for encrypted content combines entry metadata lookup, Walrus blob fetch, and Seal decryption. Decryption requires three things: a Seal SessionKey (built via wallet personal-message signature), the sealId bound to the revision, and a non-revoked PublisherCap ID.
import { SessionKey } from "@mysten/seal"; import { DefaultSealAdapter, HttpAggregatorReadAdapter } from "@arcadiasystems/morse-sdk"; const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client); // 1. Read entry metadata to get the draft revision. const fetched = await reader.getEntry( created.publicationId, "secret-blog", result.entryId, ); const revision = fetched.revisions[fetched.draftHead]; // 2. Extract the blobRef and sealId from the revision. const blobRef = revision.blobRef; // { kind: "blob", blobObjectId: "0x..." } const sealId = revision.sealId; // Uint8Array // 3. Fetch ciphertext from Walrus. const ciphertext = await walrusReader.readBlobRef(blobRef); // 4. Create a SessionKey and decrypt with Seal. const sessionKey = await SessionKey.create({ address: keypair.toSuiAddress(), packageId: config.originalPackageId ?? config.packageId, ttlMin: 10, signer: keypair, // or `signer` in the browser suiClient: client, }); const plaintext = await seal.decrypt(ciphertext, { sessionKey, sealId, publisherCapId: created.publisherCapId, }); console.log(new TextDecoder().decode(plaintext));
Encrypted draft revisions
After creating an encrypted entry with addEncryptedEntryFromBytes, use appendEncryptedDraftRevision to add subsequent encrypted revisions to the same entry. You encrypt and upload the blob yourself, then pass the blobObjectId to the SDK.
import { appendEncryptedDraftRevision, buildPublisherSealId, DefaultSealAdapter, DefaultWalrusWriteAdapter, } from "@arcadiasystems/morse-sdk"; const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // 1. Build a fresh Seal identity for the new revision. const nonce = crypto.getRandomValues(new Uint8Array(16)); const sealId = buildPublisherSealId(created.publicationId, nonce); // 2. Encrypt the updated content and upload to Walrus. const updatedPlaintext = new TextEncoder().encode("updated secret content"); const { ciphertext } = await seal.encrypt(updatedPlaintext, { sealId }); const blob = await walrusWriter.uploadBlob(ciphertext, { epochs: 3, deletable: true, }); // 3. Append the encrypted revision to the existing entry. const revision = await appendEncryptedDraftRevision(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "secret-blog", entryId: result.entryId, // from addEncryptedEntryFromBytes blobObjectId: blob.blobObjectId, contentType: "application/octet-stream", sealId, }); console.log("Revision", revision.revisionId, "appended");
See also: CLI Cookbooks → Encrypted content
Reading it back
Single-object reads, paginated lists, and an async iterator for full-collection scans. The reader is gRPC-backed.
import { HttpAggregatorReadAdapter } from "@arcadiasystems/morse-sdk"; const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client); // Single entry by ID. const fetched = await reader.getEntry( created.publicationId, "blog", entry.entryId, ); const firstRevision = fetched.revisions[0]; // Fetch the actual content bytes from Walrus. const content = await walrusReader.readBlobRef(firstRevision.blobRef); console.log(new TextDecoder().decode(content)); // Paginated list. const page = await reader.listEntries( created.publicationId, "blog", { limit: 50 }, ); console.log(page.results.length, "entries, next cursor:", page.nextCursor); // Stream every entry in a collection. for await (const e of reader.scanEntries(created.publicationId, "blog")) { console.log(e.id, e.name); } // Single revision by entry ID and revision index. const revision = await reader.getRevision( created.publicationId, "blog", entry.entryId, 0, );
listEntries and scanEntries return entries in dynamic-field object-store order, not chronological order. Sort by entry.id if you need insertion order.Publication and cap lookups
Beyond entries, the reader resolves publications and the caps that control them, useful for dashboards ("which publications does this wallet own?") and for verifying delegated write access:
// Publication metadata: name, slug, collections. const publication = await reader.getPublication(created.publicationId); // Publications owned (via OwnerCap) by an address. const owned = await reader.listPublicationsOwnedBy(adapter.address); // owned.results: { ownerCapId, publicationId } handles (OwnedPublication) // PublisherCaps held by an address, and a single cap by ID. const caps = await reader.listPublisherCapsOwnedBy(adapter.address); const cap = await reader.getPublisherCap(created.publisherCapId); // cap.publicationId, cap.holder
All list methods return one page per call (EntryListPage, PublicationListPage, and PublisherCapListPage), each with a results array and a nextCursor (null when exhausted). They accept { limit, cursor, signal } options (default limit 50; the cursor is opaque: feed nextCursor back in to get the next page). scanEntries auto-paginates and takes { pageSize, signal } instead. Single-object reads (getPublication, getPublisherCap, getEntry, getRevision) throw NotFoundError when the target does not exist.
See also: CLI Cookbooks → Reading it back
Publishing a new revision
Append a new public revision directly, or stage a draft first and promote it. Revisions are append-only; existing revisions are never edited in place. Two heads track progress: draftHead (work in progress) and publicHead (the live version).
Option A: publish direct
Skip the draft stage and append a public revision in one step. Requires a pre-uploaded blob (use uploadBlob first).
import { publishDirect, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); const blob = await walrusWriter.uploadBlob( new TextEncoder().encode("updated content"), { epochs: 3, deletable: true }, ); const result = await publishDirect(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, blobObjectId: blob.blobObjectId, contentType: "text/plain", }); // result.revisionId is the index of the new public revision
Option B: draft then publish
Stage a draft revision (updates draftHead only), iterate on it, then promote to a public revision. Both steps require a pre-uploaded blob (use uploadBlob first). The promotion appends a fresh public revision; the draft blob is referenced for validation but not reused.
import { appendDraftRevision, publishFromDraft, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // 1. Upload the draft content const draftBlob = await walrusWriter.uploadBlob( new TextEncoder().encode("draft content"), { epochs: 3, deletable: true }, ); // 2. Stage a draft revision const draft = await appendDraftRevision(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, blobObjectId: draftBlob.blobObjectId, contentType: "text/plain", }); // 3. Upload the final content and promote the draft const finalBlob = await walrusWriter.uploadBlob( new TextEncoder().encode("final content"), { epochs: 3, deletable: true }, ); const published = await publishFromDraft(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, draftRevisionId: draft.revisionId, blobObjectId: finalBlob.blobObjectId, contentType: "text/plain", });
See also: CLI Cookbooks → Publishing a new revision
Delegating write access
Morse separates ownership (OwnerCap) from publishing (PublisherCap). The owner issues a cap bound to a co-author's address, granting them write access. Once issued, the co-author can publish entries using their own key. Revoking the cap causes further writes to abort on-chain. See Access Control → Issuing publisher caps for details.
import { issuePublisherCap, revokePublisherCap, destroyPublisherCap, } from "@arcadiasystems/morse-sdk"; // Owner issues a cap bound to a co-author's address const issued = await issuePublisherCap(adapter, config, { publicationId: created.publicationId, ownerCapId: created.ownerCapId, holder: "0xCO_AUTHOR_ADDRESS", }); // issued.publisherCapId // Owner revokes; further writes with this cap abort with EPublisherCapRevoked await revokePublisherCap(adapter, config, { publicationId: created.publicationId, ownerCapId: created.ownerCapId, publisherCapId: issued.publisherCapId, }); // Co-author destroys their own cap (requires an adapter built from the co-author's keypair; see SDK Setup) await destroyPublisherCap(coAuthorAdapter, config, { publicationId: created.publicationId, publisherCapId: issued.publisherCapId, });
holder address at issue time. Transferring the Sui object to a different address does NOT grant write rights. To rotate authority to a new address, destroy the old cap and issue a fresh one.See also: CLI Cookbooks → Delegating write access
Transferring ownership
Transfer the OwnerCap to a new address. The recipient gains governance rights: issuing/revoking publisher caps and deleting the publication. Existing publisher caps remain usable until individually revoked.
import { transferOwnership } from "@arcadiasystems/morse-sdk"; await transferOwnership(adapter, config, { ownerCapId: created.ownerCapId, recipient: "0xNEW_OWNER_ADDRESS", });
See also: CLI Cookbooks → Transferring ownership
Deleting and tearing down
Deletion works bottom-up: remove entries first, then the collections, then the publication. The Move layer enforces this: deleting a non-empty collection or publication aborts on-chain. This assumes the caller still holds both a PublisherCap and the OwnerCap.
Entry IDs are permanently retired on deletion and never reused. See Entries → Deleting an entry for details.
import { deleteEntry, deleteCollection, deletePublication, } from "@arcadiasystems/morse-sdk"; // 1. Remove all entries from each collection await deleteEntry(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", entryId: entry.entryId, }); await deleteEntry(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "secret-blog", entryId: result.entryId, }); // 2. Remove the empty collections await deleteCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "blog", }); await deleteCollection(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, name: "secret-blog", }); // 3. Delete the publication (requires OwnerCap; reader verifies it's empty) await deletePublication(reader, adapter, config, { publicationId: created.publicationId, ownerCapId: created.ownerCapId, });
See also: CLI Cookbooks → Deleting and tearing down
Entry path options
The SDK provides two ways to publish content. addEntryFromBytes (and addEncryptedEntryFromBytes for encrypted content) handles upload and on-chain registration in one call and is the recommended default. For cases that need more control, use the split form: uploadBlob followed by addEntry.
| Use case | Function | Transactions |
|---|---|---|
| Publish raw bytes as a new entry (typical case) | addEntryFromBytes |
2 |
| Publish encrypted bytes as a new entry | addEncryptedEntryFromBytes |
2 |
| Reuse one blob across many entries (deduplication) | uploadBlob once, then addEntry N times |
2 + N |
| Decouple upload and add-entry (draft-then-attach UX) | uploadBlob at upload time, addEntry at publish time |
2 + 1 |
| Server pre-uploads; browser only adds entries | uploadBlob (server), addEntry (browser) |
2 + 1 |
Split upload path
When you need control over the upload step (for example, to retry blob registration independently or reuse a blob across entries), split the operation into two calls:
import { addEntry, DefaultWalrusWriteAdapter } from "@arcadiasystems/morse-sdk"; const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); const content = new TextEncoder().encode("hello world"); // 1. Upload blob to Walrus (2 wallet popups: register + certify). const blob = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); // 2. Reference the blob in a new entry (1 wallet popup). const entry = await addEntry(adapter, config, { publicationId: created.publicationId, publisherCapId: created.publisherCapId, collectionName: "blog", name: "first-post", blobObjectId: blob.blobObjectId, contentType: "text/plain", });
addEntryFromBytes requires an adapter that implements WalrusFlowCapable, which combines the certify and add-entry steps into a single transaction. DefaultWalrusWriteAdapter supports this; the HTTP publisher adapter does not. Passing an incompatible adapter throws a TransportError before any I/O. Use the split path (uploadBlob + addEntry) instead. To branch at runtime, use the isWalrusFlowCapable(walrusWriter) type guard, which detects the capability on any WalrusWriteAdapter.