Contract Cookbooks
End-to-end recipes for interacting with Morse smart contracts directly via Sui Programmable Transaction Blocks (PTBs). Each recipe shows the raw tx.moveCall pattern using @mysten/sui/transactions. For the high-level SDK wrappers, see SDK Cookbooks. For the full function reference, see Contract Reference.
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 | Transaction signing. Export from your wallet's settings |
| Morse SDK | @arcadiasystems/morse-sdk | Provides morseConfig() for package ID, registry ID, and RPC URL |
Make sure the SDK and its peer dependencies are installed before continuing. See SDK Setup for the install command.
Setup
All recipes below assume the following imports and configuration are in scope. For browser environments, replace Ed25519Keypair with a wallet signer.
tx.moveCall with no SDK wrappers. The SDK is only used for configuration and adapters: morseConfig() resolves deployed addresses, DefaultWalrusWriteAdapter uploads blobs, and DefaultSealAdapter handles encryption. See SDK Client Setup for details.import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction } from "@mysten/sui/transactions"; import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"; import { bcs } from "@mysten/sui/bcs"; import { morseConfig, DefaultWalrusWriteAdapter, DefaultSealAdapter } from "@arcadiasystems/morse-sdk"; // Resolve package ID, registry ID, and RPC endpoint for testnet const config = morseConfig({ network: "testnet" }); const PACKAGE_ID = config.packageId; const REGISTRY_ID = config.registryId; const client = new SuiGrpcClient({ network: "testnet", baseUrl: config.rpcUrl, }); const keypair = Ed25519Keypair.fromSecretKey("<your-private-key>"); // Second keypair for delegated write-access examples const coAuthorKeypair = Ed25519Keypair.fromSecretKey("<co-author-private-key>"); const walrusWriter = DefaultWalrusWriteAdapter.fromConfig( { network: "testnet", suiClient: client }, keypair, ); // Empty {} uses the default key-server list from config const seal = DefaultSealAdapter.fromMorseConfig(config, {}, client);
Creating a publication
Creates a publication, shares it, and transfers the resulting capabilities to the caller in one atomic PTB. new_publication returns a tuple; destructure it as three separate transaction results. The slug is globally unique and immutable after creation.
key-onlystore ability and cannot be moved with tx.transferObjects. Use the contract's typed transfer wrappers transfer_owner_cap and transfer_publisher_cap instead.const tx = new Transaction(); // new_publication returns (Publication, OwnerCap, PublisherCap). const created = tx.moveCall({ target: `${PACKAGE_ID}::publication::new_publication`, arguments: [ tx.object(REGISTRY_ID), tx.pure.string("My Blog"), tx.pure.string(`my-blog-${Date.now()}`), // slugs are globally unique on-chain ], }); const publicationArg = created[0]; const ownerCapArg = created[1]; const publisherCapArg = created[2]; // Share the publication so other transactions can access it. tx.moveCall({ target: `${PACKAGE_ID}::publication::share_publication`, arguments: [publicationArg], }); // Transfer caps using the contract's typed wrappers (key-only objects). tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_owner_cap`, arguments: [ownerCapArg, tx.pure.address(keypair.toSuiAddress())], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_publisher_cap`, arguments: [publisherCapArg, tx.pure.address(keypair.toSuiAddress())], }); const result = await client.signAndExecuteTransaction({ transaction: tx, signer: keypair, include: { effects: true, objectTypes: true }, }); // Extract created object IDs from the transaction effects. const { effects, objectTypes } = result.Transaction; const createdObjects = effects.changedObjects .filter((c) => c.idOperation === "Created" && objectTypes?.[c.objectId]) .map((c) => ({ objectId: c.objectId, type: objectTypes[c.objectId] })); console.log("Created objects:", createdObjects);
See also: SDK Cookbooks → Creating a publication
Adding a collection
Collections are added to an existing publication. The storage mode is immutable after creation: 0 for blob mode and 1 for quilt mode. Use blob mode for growing content like posts; use quilt mode for small, rarely-changing content like config files.
const PUBLISHER_CAP_ID = "0xPUBLISHER_CAP_ID"; const tx = new Transaction(); // Blob mode (storage_mode = 0): each entry holds a standalone Walrus Blob object ID. tx.moveCall({ target: `${PACKAGE_ID}::publication::create_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u8(0), // STORAGE_MODE_BLOB ], }); // Quilt mode (storage_mode = 1): entries are addressed by 37-byte QuiltPatchId. tx.moveCall({ target: `${PACKAGE_ID}::publication::create_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("config"), tx.pure.u8(1), // STORAGE_MODE_QUILT ], }); // A blob-mode collection for encrypted content (used in the Encrypted Content section below). tx.moveCall({ target: `${PACKAGE_ID}::publication::create_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), tx.pure.u8(0), // STORAGE_MODE_BLOB ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
See also: SDK Cookbooks → Adding a collection
Adding an entry
Upload content to Walrus first, then pass the resulting Blob object ID to add_entry_to_collection. The blob argument is a reference to a Walrus Blob Sui object (&Blob), not a blob ID hash. Only deletable blobs are accepted. For blob-mode collections, pass None for quilt_patch_id using BCS serialization as shown below.
const content = new TextEncoder().encode( JSON.stringify({ title: "My First Post", body: "Hello from Morse." }) ); const { blobObjectId: BLOB_OBJECT_ID } = await walrusWriter.uploadBlob(content, { epochs: 3, deletable: true, }); // BCS-encode Option<vector<u8>>::None for quilt_patch_id and seal_id. const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); const tx = new Transaction(); const entryId = tx.moveCall({ target: `${PACKAGE_ID}::publication::add_entry_to_collection`, arguments: [ tx.object(PUBLICATION_ID), // &mut Publication tx.object(PUBLISHER_CAP_ID), // &PublisherCap tx.pure.string("posts"), // collection_name: String tx.pure.string("my-first-post"), // name: String tx.object(BLOB_OBJECT_ID), // blob: &Blob (must be deletable) tx.pure(optionNone), // quilt_patch_id: Option<vector<u8>> = None tx.pure.string("text/plain"), // content_type: String tx.pure.bool(false), // encrypted: bool tx.pure.u8(0), // access_policy: u8 - 0 = PUBLIC tx.pure(optionNone), // seal_id: Option<vector<u8>> = None ], }); // entryId is the returned u64 - the stable monotonic ID for this entry (0, 1, 2, ...). await client.signAndExecuteTransaction({ transaction: tx, signer: keypair, include: { events: true }, });
See also: SDK Cookbooks → Adding an entry
Publishing a new revision
Revisions are appended to an existing entry. Two paths are available: publish directly in one call, or stage a draft first and promote it.
Option A: publish direct
Append a non-encrypted public revision in a single call, bypassing the draft stage. Advances public_head immediately.
const ENTRY_ID = 0; // u64 entry ID returned by add_entry_to_collection const updatedContent = new TextEncoder().encode( JSON.stringify({ title: "My First Post", body: "Updated content." }) ); const { blobObjectId: NEW_BLOB_ID } = await walrusWriter.uploadBlob(updatedContent, { epochs: 3, deletable: true, }); const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::publish_collection_entry_direct`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(ENTRY_ID), tx.object(NEW_BLOB_ID), // blob: &Blob tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("text/plain"), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
Option B: draft then publish
Stage a draft revision first (advances draft_head only), then promote it to a public revision. The published revision takes a fresh blob; draft_revision_id is used only to validate the draft exists on-chain.
const ENTRY_ID = 0; // u64 entry ID returned by add_entry_to_collection const draftContent = new TextEncoder().encode(JSON.stringify({ title: "Draft", body: "WIP" })); const { blobObjectId: DRAFT_BLOB_ID } = await walrusWriter.uploadBlob(draftContent, { epochs: 3, deletable: true, }); const finalContent = new TextEncoder().encode(JSON.stringify({ title: "Final", body: "Done" })); const { blobObjectId: FINAL_BLOB_ID } = await walrusWriter.uploadBlob(finalContent, { epochs: 3, deletable: true, }); const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); const tx = new Transaction(); // Step 1: append a draft revision (advances draft_head, does not touch public_head). const draftRevisionId = tx.moveCall({ target: `${PACKAGE_ID}::publication::append_collection_entry_draft_revision`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(ENTRY_ID), tx.object(DRAFT_BLOB_ID), // blob: &Blob tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("text/plain"), tx.pure.bool(false), // encrypted tx.pure.u8(0), // access_policy: PUBLIC tx.pure(optionNone), // seal_id: None ], }); // Step 2: promote the draft to a public revision in the same PTB. tx.moveCall({ target: `${PACKAGE_ID}::publication::publish_collection_entry_from_draft`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(ENTRY_ID), draftRevisionId, // chain the return value from step 1 tx.object(FINAL_BLOB_ID), // blob: &Blob for the published revision tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("text/plain"), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
See also: SDK Cookbooks → Publishing a new revision
Encrypted content
Encrypted entries require encrypted=true, access_policy=1 (Publisher), and a Seal identity in seal_id. Encrypt content with Seal first, then upload the ciphertext to Walrus. The Seal identity is a vector<u8>: [publication_id_bytes (32)] || [0x01] || [nonce]. Each entry should use a fresh nonce.
// Build a Seal identity: publication ID bytes (32) || policy tag 0x01 || nonce. function buildPublisherSealId(publicationId: string, nonce: Uint8Array): Uint8Array { // publicationId is a 0x-prefixed hex string; strip and decode to 32 bytes. const hex = publicationId.startsWith("0x") ? publicationId.slice(2) : publicationId; const idBytes = new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16))); const result = new Uint8Array(idBytes.length + 1 + nonce.length); result.set(idBytes, 0); result[idBytes.length] = 0x01; // SEAL_POLICY_TAG_PUBLISHER result.set(nonce, idBytes.length + 1); return result; } const nonce = crypto.getRandomValues(new Uint8Array(16)); const sealId = buildPublisherSealId(PUBLICATION_ID, nonce); // BCS-encode Option<vector<u8>>::Some(sealId) and Option::None. const sealIdOption = bcs.option(bcs.vector(bcs.u8())).serialize([...sealId]).toBytes(); const optionNone = bcs.option(bcs.vector(bcs.u8())).serialize(null).toBytes(); // Encrypt plaintext with Seal using the sealId above. const plaintext = new TextEncoder().encode("secret content"); const { ciphertext } = await seal.encrypt(plaintext, { sealId }); const { blobObjectId: ENCRYPTED_BLOB_ID } = await walrusWriter.uploadBlob(ciphertext, { epochs: 3, deletable: true, }); const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::add_entry_to_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), tx.pure.string("private-post"), tx.object(ENCRYPTED_BLOB_ID), // blob: &Blob holding the ciphertext tx.pure(optionNone), // quilt_patch_id: None tx.pure.string("application/octet-stream"), tx.pure.bool(true), // encrypted: true tx.pure.u8(1), // access_policy: 1 = PUBLISHER tx.pure(sealIdOption), // seal_id: Some(sealId) ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
encrypted=false on the public path; they cannot promote encrypted content to a public head. Read encrypted entries via seal.decrypt using the sealId stored in the revision metadata.See also: SDK Cookbooks → Encrypted content
Delegating write access
The owner issues PublisherCap objects to grant write access to co-authors. Revocation adds the cap ID to an on-chain denylist; all subsequent writes using that cap abort with EPublisherCapRevoked. See Access Control for the full capability model.
Issuing a PublisherCap
Only the OwnerCap holder can issue new caps. The cap's holder field is fixed at issuance; the bound address is what grants write rights, not object ownership.
const PUBLICATION_ID = "0xPUBLICATION_ID"; const OWNER_CAP_ID = "0xOWNER_CAP_ID"; const HOLDER_ADDRESS = "0xCO_AUTHOR_ADDRESS"; const tx = new Transaction(); // issue_publisher_cap returns the new PublisherCap. const newCap = tx.moveCall({ target: `${PACKAGE_ID}::publication::issue_publisher_cap`, arguments: [ tx.object(PUBLICATION_ID), tx.object(OWNER_CAP_ID), tx.pure.address(HOLDER_ADDRESS), ], }); // Transfer the cap to the co-author using the typed wrapper (key-only object). tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_publisher_cap`, arguments: [newCap, tx.pure.address(HOLDER_ADDRESS)], }); const result = await client.signAndExecuteTransaction({ transaction: tx, signer: keypair, include: { effects: true, objectTypes: true }, }); const { effects, objectTypes } = result.Transaction; const publisherCapId = effects.changedObjects .find((c) => c.idOperation === "Created" && objectTypes?.[c.objectId]?.includes("PublisherCap")) ?.objectId; console.log("Issued PublisherCap:", publisherCapId);
holder address is baked in at issuance. Transferring the cap object to a different address does not grant write rights to the new holder. To rotate authority, destroy the old cap and issue a fresh one bound to the new address.Revoking a PublisherCap
Revocation adds the cap's ID to an on-chain denylist. The physical object is not consumed; only the cap's ID is passed via tx.pure.id. The holder retains the object but any subsequent write attempt aborts with EPublisherCapRevoked.
const PUBLISHER_CAP_ID_TO_REVOKE = "0xCAP_ID"; const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::revoke_publisher_cap`, arguments: [ tx.object(PUBLICATION_ID), tx.object(OWNER_CAP_ID), tx.pure.id(PUBLISHER_CAP_ID_TO_REVOKE), // ID only, not the object itself ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
Destroying a PublisherCap
The cap holder voluntarily surrenders write access. Only the address bound as holder at issuance can call this. If the cap was previously revoked, destroying it also removes the entry from the denylist (storage cleanup).
const MY_PUBLISHER_CAP_ID = "0xPUBLISHER_CAP_ID"; const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::destroy_publisher_cap`, arguments: [ tx.object(PUBLICATION_ID), tx.object(MY_PUBLISHER_CAP_ID), ], }); // Signed by the co-author (holder), not the publication owner. await client.signAndExecuteTransaction({ transaction: tx, signer: coAuthorKeypair });
See also: SDK Cookbooks → Delegating write access
Transferring ownership
Transfer the OwnerCap to a new address. The recipient gains full governance rights: issuing and revoking publisher caps, deleting the publication, and transferring ownership again. Because OwnerCap is key-only (no store), you must use the typed transfer_owner_cap wrapper; tx.transferObjects will not work.
const NEW_OWNER = "0xNEW_OWNER_ADDRESS"; const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::transfer_owner_cap`, arguments: [ tx.object(OWNER_CAP_ID), tx.pure.address(NEW_OWNER), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
See also: SDK Cookbooks → Transferring ownership
Deleting and tearing down
Deletion is strictly bottom-up. The Move layer enforces this: delete_collection calls table::destroy_empty on the entries table and aborts if any entries remain. delete_publication calls collections.destroy_empty() and aborts if any collections remain. Entry IDs are permanently retired on deletion and never reused.
Delete entries
// Delete all entries from both collections in a single PTB. const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_entry_from_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), tx.pure.u64(0), ], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_entry_from_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), tx.pure.u64(0), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
Delete a collection
The collection must be empty (all entries deleted) before this call succeeds.
const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("posts"), ], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("config"), ], }); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_collection`, arguments: [ tx.object(PUBLICATION_ID), tx.object(PUBLISHER_CAP_ID), tx.pure.string("secret-blog"), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
Delete a publication
Requires the OwnerCap. All collections must be deleted first. Both the publication object and the OwnerCap are consumed. The slug is released from the registry and becomes available for reuse.
const tx = new Transaction(); tx.moveCall({ target: `${PACKAGE_ID}::publication::delete_publication`, arguments: [ tx.object(REGISTRY_ID), tx.object(PUBLICATION_ID), tx.object(OWNER_CAP_ID), ], }); await client.signAndExecuteTransaction({ transaction: tx, signer: keypair });
See also: SDK Cookbooks → Deleting and tearing down
Troubleshooting
Move aborts surface as MoveAbort errors in the RPC response with a module name and numeric code. The table below maps the most common codes to their cause.
| Code | Error | Module | Cause |
|---|---|---|---|
6 |
ESlugAlreadyExists |
publication |
A publication with that slug already exists in the registry. |
2 |
EUnauthorized |
publication |
The cap's publication_id does not match the publication object passed in the call. |
4 |
EPublisherCapWrongHolder |
publication |
The transaction sender does not match cap.holder. |
5 |
EPublisherCapRevoked |
publication |
The cap ID is on the publication's revoked denylist. Request a new PublisherCap from the owner. |
0 |
ECollectionAlreadyExists |
publication |
A collection with that name already exists in the publication. |
0 |
EEntryNotFound |
collection |
No entry exists for the given entry_id in that collection. |
8 |
EBlobNotDeletable |
entry |
The Walrus Blob object was not registered as deletable. Re-upload with deletable: true. |
5 |
EInvalidAccessPolicy |
entry |
encrypted, access_policy, and seal_id are inconsistent. Unencrypted requires (false, 0, None); encrypted requires (true, 1, Some(sealId)). |
12 |
ESealInvalidId |
publication |
The Seal identity prefix does not match the publication's on-chain ID bytes. |
13 |
ESealWrongPolicyTag |
publication |
The byte at position 32 of the Seal identity is not 0x01 (the publisher policy tag). |