morseDOCS

SDK Setup

Install morse-sdk, wire a wallet adapter (server or browser), pick a Walrus access pattern, and learn how to narrow errors.

Compatibility

@arcadiasystems/morse-sdk is built and tested against specific versions of its Mysten substrate. Newer or older versions are not validated and may produce runtime errors. The peer-dependency ranges in package.json enforce these bounds. Version 0.11.0 moved all three ranges, so upgrading from 0.10.x means upgrading the three Mysten packages with it.

Package Version Required
@arcadiasystems/morse-sdk0.11.x(this package)
@mysten/sui>=2.38.0 <2.39.0Yes: types, gRPC client, keypairs
@mysten/walrus>=1.2.37 <1.2.38Yes: default Walrus adapters (read + write)
@mysten/seal>=1.5.2 <1.5.3Yes: encryption
Pin to the verified minor version
Mysten packages may ship breaking changes within the same major version. When a new version like @mysten/walrus@1.3.0 is released, morse-sdk needs a coordinated bump and re-verification. Use @arcadiasystems/morse-sdk@~0.11.0, as in the install command below, to lock to patch-level updates only.

The verified matrix is also exported programmatically as TESTED_SUBSTRATE, a frozen record of the tested @mysten/sui, @mysten/seal, and @mysten/walrus versions plus verifiedOn and suiNetwork. Log it during initialization or compare it against bundler-resolved versions in CI:

typescript
import { TESTED_SUBSTRATE } from "@arcadiasystems/morse-sdk";

console.log(TESTED_SUBSTRATE["@mysten/walrus"]);  // "1.2.37"

Runtime requirements

ESM-only ("type": "module"). CommonJS require is not supported.

RuntimeSupportedNotes
Bun≥ 1.2.0Primary development runtime. End-to-end test scripts use Bun.
Node≥ 18.0Uses ES2022 (private fields, Error.cause), TextEncoder, crypto.getRandomValues, BigInt.
BrowserEvergreenChrome / Edge / Firefox / Safari recent stable. Vite, Webpack, esbuild bundlers all handle the SDK with zero polyfills.

The SDK works in both Node and browser environments. It does not depend on Node-specific APIs like fs, path, process, or node:crypto. Some @mysten/* packages may need polyfills in the browser. Check their docs if needed, though modern bundlers usually handle this automatically.

Installing

@mysten/sui, @mysten/walrus, and @mysten/seal are all required peer dependencies. The SDK's main entry imports the default Walrus and Seal adapters, so both packages must be installed even for workflows that never upload or encrypt. The @mysten/* peers are pinned to the exact versions the SDK accepts: it takes one @mysten/walrus and one @mysten/seal release, because a later patch of either can need a newer @mysten/sui than the SDK supports.

bash
bun add @arcadiasystems/morse-sdk@~0.11.0 @mysten/sui@2.38.0 @mysten/walrus@1.2.37 @mysten/seal@1.5.2

morseConfig() shape

morseConfig({ network }) takes the target network ("mainnet", "testnet", or "localnet") and returns a read-only object with the contract addresses and endpoints the SDK needs. All other fields have built-in defaults but can be overridden for custom deployments. Every SDK function takes this object as its config argument.

The returned object contains the following fields:

FieldTypeDescription
network"mainnet" | "testnet" | "localnet"Sui network the config targets.
rpcUrlstringSui fullnode URL to pass as baseUrl when you construct SuiGrpcClient. The SDK never reads it itself; its readers and adapters take the client you build. Defaults to the public fullnode for the chosen network; override for a custom node.
packageIdPackageIdCurrent published-at address of the Morse Move package. Used as the target for all transaction calls.
originalPackageIdPackageId | undefinedGenesis publish address (original-id). Sui uses this in objectType strings and type filters. Defaults to packageId when the package has never been upgraded.
registryIdRegistryIdSui object ID of the shared PublicationRegistry. Required for publication creation and deletion.
sealKeyServersreadonly KeyServerConfig[]Seal threshold-encryption key servers for the network. Each entry has an objectId and weight. Used by DefaultSealAdapter when no explicit server list is provided.
walrusEndpointsWalrusEndpointsWalrus HTTP endpoints. Contains aggregator (base URL for reads) and an optional publisher (base URL for HTTP-publisher writes). The publisher is intentionally unset by default; supply it explicitly for your operator.

The options shape is exported as MorseConfigOptions, the accepted network names as the Network constant, and the per-network public fullnode defaults as DEFAULT_RPC_URLS (useful when you need the default endpoint without building a full config).

PackageId and RegistryId are branded string types: compile-time guards that prevent passing one kind of Sui address where another is expected. Cast a raw hex string with the toPackageId() / toRegistryId() codec helpers.

For quilt-mode collections, the SDK also ships codecs for the 37-byte QuiltPatchId (QUILT_PATCH_ID_LENGTH = 37): quiltPatchIdFromString / quiltPatchIdToString convert between the URL-safe base64 string Walrus emits and the branded bytes, and encodeQuiltPatchId / decodeQuiltPatchId convert between the bytes and their structured parts (quiltBlobId, version, startIndex, endIndex; the QuiltPatchIdParts type).

Server setup

RpcPublicationReader provides read-only access to publications, collections, and entries via RPC. No keypair required.

To set up a server-side client, create a testnet config, a gRPC client, a KeypairAdapter for signing transactions, and an RpcPublicationReader for querying on-chain data.

typescript
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import {
  KeypairAdapter,
  morseConfig,
  RpcPublicationReader,
} from "@arcadiasystems/morse-sdk";

const privateKey = process.env.PRIVATE_KEY!;  // Bech32: "suiprivkey1..."

const config = morseConfig({ network: "testnet" });
const client = new SuiGrpcClient({
  network: "testnet",
  baseUrl: config.rpcUrl,
});
const keypair = Ed25519Keypair.fromSecretKey(privateKey);
const adapter = KeypairAdapter.fromSecretKey(privateKey, client);
const reader = RpcPublicationReader.fromMorseConfig(config, client);

PRIVATE_KEY must be a Bech32 suiprivkey1... string. Export it from your wallet's settings. Hex keys are not supported by Ed25519Keypair.fromSecretKey.

Browser setup

For browser apps, the SDK connects to the user's wallet extension (e.g. Slush, Suiet) instead of KeypairAdapter. The private key stays in the wallet and is never exposed to the SDK.

Providers

To wire the providers, add SuiClientProvider and WalletProvider to the root of the React component tree. @mysten/dapp-kit also requires @tanstack/react-query as a peer dependency.

tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SuiClientProvider, WalletProvider } from "@mysten/dapp-kit";
import { getFullnodeUrl } from "@mysten/sui/client";
import "@mysten/dapp-kit/dist/index.css";

const queryClient = new QueryClient();
const networks = { testnet: { url: getFullnodeUrl("testnet") } };

<QueryClientProvider client={queryClient}>
  <SuiClientProvider networks={networks} defaultNetwork="testnet">
    <WalletProvider autoConnect>
      <App />
    </WalletProvider>
  </SuiClientProvider>
</QueryClientProvider>

useMorse hook

Build the SDK context once per connected account. WalletStandardSigner.fromAccount wraps the account's public key and sign callbacks into a Sui Signer, so libraries like @mysten/walrus and @mysten/seal work without the SDK ever touching the user's key.

gRPC transport required
The SDK is typed against SuiGrpcClient from @mysten/sui/grpc, not the JSON-RPC SuiClient used by most Sui browser apps. SuiGrpcClient works in modern evergreen browsers without a proxy. The SDK makes no JSON-RPC calls, so environments that block gRPC must proxy it or use a gRPC endpoint they can reach.
typescript
import {
  useCurrentAccount,
  useSignAndExecuteTransaction,
  useSignPersonalMessage,
  useSignTransaction,
} from "@mysten/dapp-kit";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import {
  morseConfig,
  RpcPublicationReader,
  WalletStandardSigner,
} from "@arcadiasystems/morse-sdk";
import { useMemo } from "react";

import { WalletStandardAdapter } from "./WalletStandardAdapter";
// ^ Create this file in your project - see full source below

export function useMorse() {
  const account = useCurrentAccount();
  const { mutateAsync: signAndExecute } = useSignAndExecuteTransaction();
  const { mutateAsync: signTransaction } = useSignTransaction();
  const { mutateAsync: signPersonalMessage } = useSignPersonalMessage();

  return useMemo(() => {
    if (!account) return null;
    const config = morseConfig({ network: "testnet" });
    const client = new SuiGrpcClient({
      network: "testnet",
      baseUrl: config.rpcUrl,
    });

    // For @arcadiasystems/morse-sdk ops (createPublication, addEntry, ...).
    const adapter = new WalletStandardAdapter(
      account.address,
      ({ transaction }) => signAndExecute({ transaction }),
      client,
    );

    // For Walrus uploads and Seal SessionKey - both want a Sui `Signer`.
    // Decodes the scheme from the wallet's public key; refuses unsupported accounts.
    const signer = WalletStandardSigner.fromAccount(account, {
      signTransaction: ({ transaction }) => signTransaction({ transaction }),
      signPersonalMessage: ({ message }) => signPersonalMessage({ message }),
    });

    const reader = RpcPublicationReader.fromMorseConfig(config, client);
    return { adapter, signer, reader, config, client };
  }, [account, signAndExecute, signTransaction, signPersonalMessage]);
}

The WalletStandardAdapter wraps a dapp-kit wallet connection into the adapter interface the SDK expects. It bridges wallet-standard signing to the SDK's SuiAdapter, so the SDK can build and execute transactions without ever holding the user's private key. Create a WalletStandardAdapter.ts file in your project with the following adapter code:

WalletStandardAdapter.ts
import {
  type ObjectReader,
  type SimulationReturnValues,
  type SuiAddress,
  type SuiObjectId,
  type TransactionExecutor,
  TransportError,
  type TxCreatedObject,
  type TxDeletedObject,
  type TxReceipt,
  toSuiAddress,
  toSuiObjectId,
  type WalletAdapter,
} from "@arcadiasystems/morse-sdk";
import type { SuiClientTypes } from "@mysten/sui/client";
import type { Transaction } from "@mysten/sui/transactions";

export type WalletSignAndExecute = (input: {
  transaction: Transaction;
}) => Promise<{ digest: string }>;

export class WalletStandardAdapter implements WalletAdapter {
  readonly address: SuiAddress;

  constructor(
    address: string,
    private readonly signAndExecute: WalletSignAndExecute,
    private readonly client: TransactionExecutor,
  ) {
    this.address = toSuiAddress(address);
  }

  // Execute via wallet extension, then wait for finality and parse effects.
  async signAndExecuteTransaction(
    tx: Transaction,
    signal?: AbortSignal,
  ): Promise<TxReceipt> {
    let digest: string;
    try {
      const result = await this.signAndExecute({ transaction: tx });
      digest = result.digest;
    } catch (cause) {
      throw new TransportError(
        `wallet sign-and-execute failed: ${cause instanceof Error ? cause.message : String(cause)}`,
        { cause },
      );
    }

    const final = await this.client.waitForTransaction({
      digest,
      include: { effects: true, objectTypes: true },
      ...(signal === undefined ? {} : { signal }),
    });

    if (final.$kind === "FailedTransaction") {
      throw new TransportError(
        failedTransactionMessage(final.FailedTransaction),
      );
    }
    return parseTxReceipt(final.Transaction);
  }

  // Dry-run the transaction to get return values without executing.
  async simulateTransaction(
    tx: Transaction,
    signal?: AbortSignal,
  ): Promise<SimulationReturnValues> {
    tx.setSenderIfNotSet(this.address);
    const result = await this.client.simulateTransaction({
      transaction: tx,
      include: { effects: true, commandResults: true },
      ...(signal === undefined ? {} : { signal }),
    });
    if (result.$kind === "FailedTransaction") {
      throw new TransportError(
        failedTransactionMessage(result.FailedTransaction),
      );
    }
    const commandResults = result.commandResults ?? [];
    return commandResults.map((cmd) => cmd.returnValues.map((rv) => rv.bcs));
  }
}

// Convert raw transaction effects into the SDK's TxReceipt shape.
function parseTxReceipt(
  tx: SuiClientTypes.Transaction<{ effects: true; objectTypes: true }>,
): TxReceipt {
  const effects = tx.effects;
  if (!effects) {
    throw new TransportError("transaction effects missing despite include flag");
  }
  const objectTypes = tx.objectTypes ?? {};
  return {
    digest: tx.digest,
    gasUsedMist: computeGasUsedMist(effects.gasUsed),
    createdObjects: collectCreated(effects.changedObjects, objectTypes),
    deletedObjects: collectDeleted(effects.changedObjects),
  };
}

// Net gas = computation + storage - rebate (in MIST).
function computeGasUsedMist(gas: SuiClientTypes.GasCostSummary): bigint {
  return (
    BigInt(gas.computationCost) +
    BigInt(gas.storageCost) -
    BigInt(gas.storageRebate)
  );
}

// Filter changed objects for newly created ones and attach their types.
function collectCreated(
  changes: readonly SuiClientTypes.ChangedObject[],
  objectTypes: Record<string, string>,
): TxCreatedObject[] {
  const out: TxCreatedObject[] = [];
  for (const change of changes) {
    if (change.idOperation !== "Created") continue;
    const objectType = objectTypes[change.objectId];
    if (objectType === undefined) continue;
    out.push({ objectId: toSuiObjectId(change.objectId), objectType });
  }
  return out;
}

// Filter changed objects for deleted ones.
function collectDeleted(
  changes: readonly SuiClientTypes.ChangedObject[],
): TxDeletedObject[] {
  const out: TxDeletedObject[] = [];
  for (const change of changes) {
    if (change.idOperation !== "Deleted") continue;
    out.push({ objectId: toSuiObjectId(change.objectId) });
  }
  return out;
}

function failedTransactionMessage(
  failed: SuiClientTypes.Transaction<{ effects: true }>,
): string {
  const error = failed.effects?.status?.error;
  if (error) {
    return `transaction failed: ${error.message}`;
  }
  return "transaction failed without effects status";
}

Slush, Suiet, and Sui Wallet (Ed25519) are verified. Secp256k1, Secp256r1, and Passkey decoders ship but Walrus + Seal end-to-end behavior is unverified. Fall back to Ed25519 if you see scheme-specific errors. ZkLogin is decoded but unverified; MultiSig is refused with ConfigurationError at fromAccount time. Wrap the call in try/catch and render unsupported accounts as product copy.
See the full table in the SDK README compatibility section.

Picking a Walrus adapter

The SDK ships two Walrus read adapters and two write adapters with different trade-offs. Read and write adapters can be mixed independently; for example, DefaultWalrusWriteAdapter (for the addEntryFromBytes optimization) with HttpAggregatorReadAdapter (for reliable testnet reads).

Pair Trust Transactions (upload + addEntry) Storage paid by Browser CORS
DefaultWalrusReadAdapter + DefaultWalrusWriteAdapter Trustless (direct fanout to ~30 storage nodes) 2 with addEntryFromBytes, 3 if split Consumer wallet (WAL + gas) Spotty on testnet
HttpAggregatorReadAdapter + HttpPublisherWriteAdapter Operator-trusted 1 (publisher uploads server-side; consumer only signs the addEntry transaction) Publisher operator (WAL); consumer pays Sui gas only Reliable (one endpoint)

Default adapter

Best for CLI scripts, server-side dapps, and browser dapps that do not hit testnet CORS gaps. The flow-aware optimization (addEntryFromBytes) cuts transactions from 3 to 2.

typescript
import {
  DefaultWalrusReadAdapter,
  DefaultWalrusWriteAdapter,
} from "@arcadiasystems/morse-sdk";

const walrusReader = DefaultWalrusReadAdapter.fromConfig({
  network: "testnet",
  suiClient: client,
});
const walrusWriter = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,  // or `signer` in the browser
);

HTTP adapter

Best for browser dapps that need reliable CORS-friendly reads (one endpoint instead of ~30 nodes) and a "publisher pays storage" model where the user only signs the on-chain addEntry. Reads go through a trusted operator. To trust-but-verify, pass a verifyBlobIntegrity callback (type WalrusBlobIntegrityCheck) as a construction option. The adapter invokes it with the fetched bytes and the expected blob ID after every successful blob fetch, and it must throw on a mismatch (rewrapped as TransportError). Quilt patch reads are not verified.

typescript
import {
  HttpAggregatorReadAdapter,
  HttpPublisherWriteAdapter,
} from "@arcadiasystems/morse-sdk";

const walrusReader = HttpAggregatorReadAdapter.fromMorseConfig(config, client, {
  // Optional trust-but-verify: runs after every blob fetch; throw on mismatch.
  verifyBlobIntegrity: async (bytes, expectedBlobId) => {
    // e.g. recompute via WalrusClient.computeBlobMetadata and compare blob IDs
  },
});
const walrusWriter = HttpPublisherWriteAdapter.fromConfig({
  publisherUrl: "https://walrus-testnet-publisher.nami.cloud",
  ownerAddress: account.address,
});
HTTP adapters skip the 2-transaction optimization
The HTTP adapters are NOT compatible with addEntryFromBytes / addEncryptedEntryFromBytes. Those functions require WalrusFlowCapable for the combined PTB; the publisher-paid path is naturally 1 transaction through uploadBlob + addEntry. Check any WalrusWriteAdapter at runtime with the isWalrusFlowCapable(walrus) type guard.

Verifying setup

After completing either the server or browser setup above, confirm your connection works by listing publications owned by your address:

typescript
const { results } = await reader.listPublicationsOwnedBy(adapter.address);
console.log(`Connected! Found ${results.length} publication(s).`);

A successful call confirms the client, config, and RPC endpoint are wired correctly, even if the count is 0. Follow the SDK Cookbooks to create your first publication.

Error handling

Every error thrown by the SDK extends MorseError. Each error class carries structured properties, so errors can be handled by type without parsing message strings. All error classes are listed below; the first four are the most common.

ClassCarriesThrown when
ValidationErrorfieldClient-side input failed a precondition.
ContractAbortErrormodule, abortCode, reasonMove VM aborted (e.g. ESlugAlreadyExists, EPublisherCapRevoked).
TransportErroroperation?RPC, network, or response-parsing failure. Usually retryable.
UncertifiedBlobErrorblobObjectId, blobIdaddEntryFromBytes upload succeeded but the second transaction failed; blob is on Walrus but unattached.
NotFoundErrorresource, identifierObject does not exist on-chain. For resource: "blob", Walrus has no stored copy of the content, and the subclass BlobExpiredError means its storage lease has ended.
UnauthorizedErrorClient-side auth check failed before submitting the transaction.
SealErrorcodeSeal authorization or decryption failed. Codes: no-access, decrypt-failed, session-expired, rate-limited.
ConfigurationErrorSDK config issue, e.g. unsupported network.
UnsupportedWalletSchemeErrorcode, publicKeyBytes, address, walletName?WalletStandardSigner.fromAccount rejected the account's public key shape or the recovery flow could not derive a matching address.

To catch and inspect errors by type:

typescript
import {
  ContractAbortError,
  TransportError,
  UncertifiedBlobError,
  ValidationError,
} from "@arcadiasystems/morse-sdk";

try {
  await quickstart(privateKey);
} catch (err) {
  if (err instanceof UncertifiedBlobError) {
    console.error(
      `uncertified blob ${err.blobObjectId} (id ${err.blobId}); upload succeeded but the entry was not created`,
    );
  } else if (err instanceof ContractAbortError) {
    console.error(`move abort: ${err.module}::${err.reason}`);
  } else if (err instanceof ValidationError) {
    console.error(`bad input on ${err.field}: ${err.message}`);
  } else if (err instanceof TransportError) {
    console.error(`transport: ${err.message}`);
  } else {
    throw err;
  }
}

Formatting errors for UI

For end-user surfaces (toasts, dialogs, banners), formatUserMessage(err) translates any thrown value into a FormattedError: { title, description, cause }. It accepts unknown, so it works directly in a catch block without narrowing first; non-MorseError throws fall back to a generic message, and the original error is preserved in cause.

typescript
import { formatUserMessage } from "@arcadiasystems/morse-sdk";

try {
  await addEntryFromBytes(adapter, config, args);
} catch (err) {
  const { title, description } = formatUserMessage(err);
  toast.error(title, { description });  // e.g. "Slug already taken"
}

The copy uses the protocol's own terminology ("publication", "entry", "PublisherCap"). To translate into your app's domain (blog → post, gallery → image), narrow on the error class first and write your own message, then fall back to formatUserMessage for the rest.

Next steps