npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@arcadiasystems/morse-sdk

v0.1.4

Published

TypeScript SDK for Morse dCMS on the Sui blockchain.

Readme

morse-sdk

TypeScript SDK for Morse, a decentralized content management system on the Sui blockchain. Wraps the Move contract surface, Walrus storage, and Seal threshold encryption behind a typed adapter pattern.

Status

Pre-release. Testnet only. The Move contract addresses are baked in via morseConfig({ network: "testnet" }) and re-pinned on every contract redeploy. Mainnet support arrives once the contracts are frozen.

Install

Bun:

bun add @arcadiasystems/morse-sdk @mysten/sui
# Optional - install only what you use:
bun add @mysten/walrus     # for DefaultWalrusWriteAdapter
bun add @mysten/seal       # for DefaultSealAdapter and encrypted entries

npm:

npm install @arcadiasystems/morse-sdk @mysten/sui
npm install @mysten/walrus    # optional
npm install @mysten/seal      # optional

pnpm:

pnpm add @arcadiasystems/morse-sdk @mysten/sui
pnpm add @mysten/walrus       # optional
pnpm add @mysten/seal         # optional

@mysten/sui is required: the SDK takes types from it (Transaction, Signer) and you construct the gRPC client and keypairs directly. @mysten/walrus and @mysten/seal are optional peer dependencies; you only pay the install cost for the surface you actually import.

Compatibility

morse-sdk is built and tested against specific minor 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 — bun install will warn if you try to use a different minor.

| morse-sdk | @mysten/sui | @mysten/walrus | @mysten/seal | Sui network | Verified | | --------- | ------------- | ---------------- | -------------- | ----------- | --------- | | 0.1.x | 2.16.2-2.16.x | 1.1.6-1.1.x | 1.1.3-1.1.x | testnet | 2026-05-10 |

Mysten ships breaking changes inside major version boundaries. When @mysten/[email protected] (or any minor bump on these libraries) is released, morse-sdk needs a coordinated minor bump and re-verification before the new minor is supported. Pin via bun add @arcadiasystems/morse-sdk@~0.1.0 if you want patch updates without surprise minors.

The verification protocol is documented in CONTRIBUTING.md: every Mysten dep bump runs the full scripts/phase-N-*.ts smoke suite against testnet before the bump lands.

Runtime requirements

morse-sdk is ESM-only ("type": "module" in package.json); CommonJS require is not supported.

| Runtime | Supported | Notes | | ------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Bun | >= 1.2.0 | Primary development runtime. Enforced via engines.bun. Smoke scripts (bun run scripts/phase-N-*.ts) require Bun. | | Node | >= 18.0 | Enforced via engines.node. Library code uses ES2022 features (private class fields, Error.cause), TextEncoder / crypto.getRandomValues / BigInt (all stable on Node 18+). Consumers install with npm install @arcadiasystems/morse-sdk or pnpm add. | | Browser | Evergreen | Chrome / Edge / Firefox / Safari recent stable. Bundlers (Vite, Webpack, esbuild) handle the rest. No require-based polyfills needed. |

The SDK does not pull in Node-specific APIs (fs, path, process, crypto from node:crypto); the public surface is portable across both runtimes. A handful of @mysten/* substrate libraries reach into Node-shaped APIs internally — consult their documentation for browser polyfill requirements (typically zero with modern bundlers).

Quick start

Setup once at startup:

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

const config = morseConfig({ network: "testnet" });
const client = new SuiGrpcClient({ network: "testnet", baseUrl: config.rpcUrl });
const adapter = KeypairAdapter.fromSecretKey(privateKey, client);
// Browser apps swap KeypairAdapter for a WalletAdapter impl against the
// connected wallet's signer - see "Adapter pattern" below.
const reader = RpcPublicationReader.fromMorseConfig(config, client);

Then create a publication, add an entry, read it back:

import {
  addEntryFromBytes,
  createCollection,
  createPublication,
  DefaultWalrusWriteAdapter,
  StorageMode,
} from "@arcadiasystems/morse-sdk";

const created = await createPublication(adapter, config, {
  name: "My Publication",
  slug: "my-publication",
});
await createCollection(adapter, config, {
  publicationId: created.publicationId,
  publisherCapId: created.publisherCapId,
  name: "blog",
  storageMode: StorageMode.Blob,
});

const walrus = DefaultWalrusWriteAdapter.fromConfig(
  { network: "testnet", suiClient: client },
  keypair,
);

const entry = await addEntryFromBytes(adapter, config, {
  walrus,
  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 },
});
const fetched = await reader.getEntry(created.publicationId, "blog", entry.entryId);

addEntryFromBytes runs in 2 wallet popups (one for register_blob, one for the combined certify_blob + add_entry_to_collection PTB) instead of the 3 popups a separate uploadBlob + addEntry would emit. See "Choosing the right entry path" below for when to prefer the lower-level split form.

The compile-checked end-to-end version is in examples/quickstart.ts.

Walrus access patterns

morse-sdk ships two pairs of Walrus adapters. They implement the same interfaces (WalrusReadAdapter, WalrusWriteAdapter) and the rest of the SDK is unchanged whichever pair you pick.

| Pair | Trust model | Browser CORS | Popup count for upload + addEntry | Storage cost paid by | | ----------------------------------------------------------------- | ---------------- | --------------- | --------------------------------- | -------------------- | | DefaultWalrusReadAdapter + DefaultWalrusWriteAdapter | Trustless (direct fanout to ~30 storage nodes) | Spotty on testnet | 2 (with addEntryFromBytes) or 3 (split) | Consumer wallet (WAL + gas) | | HttpAggregatorReadAdapter + HttpPublisherWriteAdapter | Operator-trusted | Reliable | 1 (uploadBlob is a publisher HTTP call, only addEntry signs) | Publisher operator (WAL); consumer (Sui gas only) |

When to pick which:

  • Default direct-protocol pair: trustless reads, full control. Best for CLI smokes, server-side dapps, or browser dapps that don't hit CORS gaps. The flow-aware optimization (addEntryFromBytes) cuts popups from 3 to 2.
  • HTTP pair: reliable browser reads (one CORS-friendly endpoint instead of ~30), and a "publisher pays storage" UX where the user signs only the on-chain addEntry. Trade trustless reads for operator trust; use verifyBlobIntegrity on the read adapter for a trust-but-verify path.

The HTTP adapters are NOT compatible with addEntryFromBytes / addEncryptedEntryFromBytes. Those functions require WalrusFlowCapable for the 2-popup combined PTB; the publisher-paid path is naturally 1-popup through standard uploadBlob + addEntry.

// Default (direct, trustless, 2-3 popups)
const reader = DefaultWalrusReadAdapter.fromConfig({ network: "testnet", suiClient });
const writer = DefaultWalrusWriteAdapter.fromConfig({ network: "testnet", suiClient }, signer);

// HTTP (operator-trusted, 1 popup for upload+addEntry)
const reader = HttpAggregatorReadAdapter.fromMorseConfig(config, suiClient);
const writer = HttpPublisherWriteAdapter.fromConfig({
  publisherUrl: "https://walrus-testnet-publisher.nami.cloud",
  ownerAddress: account.address,
});

The aggregator URL for testnet is baked into morseConfig.walrusEndpoints.aggregator (Mysten's canonical service). The publisher URL is intentionally not baked in — publishers are operator-specific and consumers pick one explicitly.

HttpPublisherWriteAdapter parses Mysten's published publisher binary (camelCase JSON) and the documented OpenAPI schema (snake_case fallback). For non-standard publisher forks that serve a different shape, pass parseResponse to HttpPublisherWriteAdapter.fromConfig({ ..., parseResponse }) — it receives the raw decoded JSON and returns an UploadBlobResult, replacing the built-in parser. Throws from the callback propagate verbatim.

Choosing the right entry path

The SDK ships two ways to publish content. The high-level addEntryFromBytes (and its encrypted twin addEncryptedEntryFromBytes) is the recommended default; the split form (uploadBlob + addEntry) is for cases the high-level shape doesn't cover.

| Use | Function | Wallet popups | | ---------------------------------------------------- | --------------------------------------------------- | ------------- | | 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 (e.g. draft-then-attach UX) | uploadBlob (upload time), addEntry (publish time) | 2 + 1 | | Server pre-uploads, browser only adds entries | uploadBlob (server), addEntry (browser) | 0 server + 1 browser |

addEntryFromBytes requires a WalrusWriteAdapter that also implements WalrusFlowCapable (the optimization uses its flow-aware startBlobUpload API). The default DefaultWalrusWriteAdapter implements both; custom adapters that don't implement the capability are rejected with TransportError before any IO and should use the split form.

If addEntryFromBytes succeeds in popup 1 (register + upload) but fails in popup 2 (the combined certify + add_entry tx — user rejected, contract aborted, network blip), it throws UncertifiedBlobError carrying the blobObjectId and blobId of the orphaned blob. The blob is on storage nodes and you've paid for it but it's uncertified; storage releases on registration expiry. Surface the error to your user or log the IDs for support.

Examples

Per-concern, compile-checked illustrative code. Each file is short, focused, and intended to be read alongside the JSDoc on the public exports.

| Concern | File | Covers | | ------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | Setup | examples/setup.ts | morseConfig, gRPC client, KeypairAdapter, reader | | Quick start | examples/quickstart.ts | End-to-end happy path | | Publication lifecycle | examples/publication-lifecycle.ts | createPublication, transferOwnership, deletePublication | | Publisher cap roles | examples/publisher-caps.ts | issuePublisherCap, transferPublisherCap, revokePublisherCap, destroyPublisherCap | | Collections | examples/collections.ts | createCollection (Blob and Quilt modes), deleteCollection | | Entries (revisions, draft → publish) | examples/entries.ts | addEntry, appendDraftRevision, publishFromDraft, publishDirect, deleteEntry | | Encrypted entries | examples/encrypted-entries.ts | buildPublisherSealId, encrypt, addEncryptedEntry, appendEncryptedDraftRevision, decrypt | | Reading | examples/reading.ts | getPublication, getEntry, getRevision, listEntries, scanEntries | | Browser wallet integration | examples/wallet-standard.ts | WalletAdapter impl against @mysten/dapp-kit hooks (or any wallet-standard signer) | | React + dapp-kit + Suiet | examples/wallet-standard-react.md | Worked walkthrough: providers, connect button, hook, adapter wiring, Seal SessionKey | | Walrus HTTP adapters | examples/walrus-http-adapters.ts | HttpAggregatorReadAdapter + HttpPublisherWriteAdapter (browser-friendly, operator-paid storage) |

API reference

The full public surface, grouped by concern. Every export carries a JSDoc on its definition; this table is the index, not the documentation.

Configuration

| Export | Purpose | | --- | --- | | morseConfig({ network }) | Build a NetworkConfig for testnet (canonical addresses baked in) or supply override fields for forks / local nodes. | | Network | Const enum-like: "mainnet" \| "testnet" \| "localnet". Mainnet currently throws ConfigurationError (gates v1.0.0). | | DEFAULT_RPC_URLS | Public Sui fullnode URLs per network. Read-only. | | TESTED_SUBSTRATE | Mysten substrate versions verified end-to-end. Diagnostic constant. |

Domain ops (write paths)

| Export | Purpose | | --- | --- | | createPublication(adapter, config, args) | Create + share publication; returns { publicationId, ownerCapId, publisherCapId }. | | transferOwnership(adapter, config, args) | Transfer the OwnerCap to a new address. | | deletePublication(reader, adapter, config, args) | Delete an empty publication; pre-flight checks for collections. | | issuePublisherCap / revokePublisherCap / destroyPublisherCap / transferPublisherCap | PublisherCap lifecycle. Issue + transfer-to-holder is atomic. | | createCollection / deleteCollection | Collection lifecycle in blob or quilt mode. | | addEntryFromBytes(adapter, config, args) | Recommended. Upload + add entry in 2 wallet popups. | | addEncryptedEntryFromBytes(adapter, config, args) | Encrypt + upload + add encrypted entry in 2 wallet popups. | | addEntry / addEncryptedEntry | Lower-level: add entry against a pre-uploaded blobObjectId. | | appendDraftRevision / appendEncryptedDraftRevision / publishFromDraft / publishDirect | Revision lifecycle on existing entries. | | deleteEntry | Remove an entry and its revisions. |

Reader (RPC-backed)

| Export | Purpose | | --- | --- | | RpcPublicationReader.fromMorseConfig(config, client) | Construct a reader bound to the canonical originalPackageId for type filters. | | reader.getPublication / getEntry / getRevision / getPublisherCap | Single-object reads. | | reader.listPublicationsOwnedBy / listPublisherCapsOwnedBy / listEntries | Paginated lists. | | reader.scanEntries | Async-iterator over every entry in a collection. |

Adapters

| Export | Purpose | | --- | --- | | KeypairAdapter | Server / CLI WalletAdapter wrapping a raw Ed25519Keypair. | | WalletStandardSigner.fromAccount(account, callbacks) | Browser-side Signer for @mysten/walrus and @mysten/seal; wraps wallet-standard wallets without ever holding the user's key. Sync; throws UnsupportedWalletSchemeError on non-canonical account.publicKey (e.g. Phantom). | | WalletStandardSigner.fromAccountAsync(account, callbacks, options?) | Same as fromAccount, with signature-based pubkey recovery for wallets that return a non-canonical account.publicKey (Phantom). Pass options.pubkeyCache to skip the recovery probe across sessions. One extra wallet popup on first session per address; subsequent sessions are zero-popup when a cache is supplied. | | BrowserStoragePubkeyCache({ storage?, prefix? }) | PubkeyCache backed by browser localStorage. Customizable storage backend (for tests, sessionStorage, or polyfills) and key prefix. | | DefaultWalrusWriteAdapter.fromConfig(config, signer) | Walrus uploads (blob + quilt). Implements WalrusFlowCapable (the 2-popup optimization). | | DefaultWalrusReadAdapter.fromConfig(config) | Walrus reads (readBlob, readBlobByObjectId, readQuiltPatch, readBlobRef). | | HttpPublisherWriteAdapter.fromConfig({ publisherUrl, ownerAddress }) | Walrus uploads via a publisher HTTP service (operator pays storage; 1 popup for upload + addEntry). | | HttpAggregatorReadAdapter.fromMorseConfig(config, suiClient) / .fromConfig({ aggregatorUrl, suiClient }) | Walrus reads via a single CORS-friendly aggregator endpoint instead of fanout to ~30 storage nodes. | | DefaultSealAdapter.fromMorseConfig(config, options, suiClient) | Threshold encryption / decryption. Defaults canonical testnet key servers. | | WalletAdapter / WalrusWriteAdapter / WalrusReadAdapter / SealAdapter | Interfaces for substituting custom implementations. | | WalrusFlowCapable / isWalrusFlowCapable | Optional capability for the 2-popup addEntryFromBytes path. |

Seal identity

| Export | Purpose | | --- | --- | | buildPublisherSealId(publicationId, nonce) | Build a publisher-policy Seal identity (pubId(32) \|\| tag(1) \|\| nonce). | | decodePublisherSealId(sealId) | Inspect an existing identity. Throws ValidationError on tampered tags. |

Codecs (branded ID constructors)

| Export | Purpose | | --- | --- | | toPackageId / toRegistryId / toPublicationId / toOwnerCapId / toPublisherCapId / toBlobObjectId / toSuiAddress / toSuiObjectId | Validate and normalize Sui object IDs to canonical 64-char hex. | | toWalrusBlobId | Validate Walrus content-addressed blob ID (43-char URL-safe-base64). | | toQuiltPatchId | Validate 37-byte quilt patch ID. | | accessPolicyToU8 / accessPolicyFromU8 / storageModeToU8 / storageModeFromU8 | Move enum ↔ TypeScript enum conversion. | | encodeQuiltPatchId / decodeQuiltPatchId / quiltPatchIdToString / quiltPatchIdFromString | Quilt patch ID structural codec ({quiltBlobId, version, startIndex, endIndex}). |

Errors

| Export | Purpose | | --- | --- | | MorseError | Abstract base. Every SDK throw extends it. | | ValidationError (field) | Client-side input rejection. | | NotFoundError (resource, identifier) | Object missing on-chain or on Walrus. | | UnauthorizedError | Client-side auth check failed. | | ContractAbortError (module, abortCode, reason) | Move VM aborted; ABORT_CODES table maps codes to names. | | SealError (code) | Seal authorization or decryption failure (no-access / decrypt-failed / session-expired / rate-limited). | | TransportError | RPC, network, or response-parsing failure. | | ConfigurationError | SDK config gap (e.g. unsupported network, raw-byte sign on WalletStandardSigner). | | UncertifiedBlobError (blobObjectId, blobId) | addEntryFromBytes upload succeeded but second popup failed. |

Types

Publication, Collection, Entry, Revision, PublisherCap, OwnerCap, BlobRef, AccessPolicy, StorageMode, SealPolicyTag, branded ID types (PublicationId, BlobObjectId, WalrusBlobId, QuiltPatchId, etc.).

Conceptual model

PublicationRegistry              (one shared object, name-uniqueness index)
  Publication                    (one shared object per publication)
    Collection × N               (inline VecMap; storage mode fixed at create)
      Entry × N                  (dynamic-field table; monotonic u64 ids)
        Revision × N             (append-only vector; never mutated in place)
  • Publication: top-level container with a globally-unique slug. Holds collections inline. Owned via OwnerCap; write access delegated via PublisherCap.
  • Collection: named bucket for entries. storageMode (Blob or Quilt) is immutable after creation.
  • Entry: identified by a stable monotonic u64. Carries a name, append-only revisions, and draftHead / publicHead pointers.
  • Revision: immutable. Carries a BlobRef (Walrus blob object or quilt patch id), contentType, encrypted flag, accessPolicy, sealId, and author.

Adapter pattern

Three abstractions; the SDK ships default impls and accepts substitutions:

  • WalletAdapter signs and submits Sui transactions. Default: KeypairAdapter. Browser apps implement against a wallet-standard signer.
  • WalrusWriteAdapter uploads bytes to Walrus, returns the resulting blob's Sui object id. Default: DefaultWalrusWriteAdapter wrapping @mysten/walrus.
  • SealAdapter encrypts and decrypts under a publisher Seal identity. Default: DefaultSealAdapter wrapping @mysten/seal.

Reader pattern is parallel: PublicationReader is the interface, RpcPublicationReader is the gRPC-backed default. An indexer-backed reader could implement the same shape.

Always construct readers and seal adapters via fromMorseConfig (e.g. RpcPublicationReader.fromMorseConfig(config, client)); the raw constructors take originalPackageId directly and passing the wrong value silently empties type-filtered list results.

Wallet scheme support

WalletStandardSigner.fromAccount(account, callbacks) takes a wallet-standard WalletAccount and produces a Sui Signer for @mysten/walrus and @mysten/seal. It tries every plausible interpretation of account.publicKey (raw bytes and Sui's canonical with-flag encoding) and picks the one whose derived address matches account.address.

| Scheme | Status | Verified against | Notes | | --------- | ----------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ED25519 | Supported (verified) | Slush + imported keypair, @mysten/[email protected], @mysten/[email protected], 2026-05-10 | Accepts raw 32-byte key (Suiet) and Sui canonical 0x00 \|\| 32 raw (Slush). Most common configuration. | | Secp256k1 | Supported (decoder) | - | Accepts raw 33-byte key and 0x01 \|\| 33 raw. End-to-end behavior on Walrus + Seal not yet verified against a wallet that exposes Secp256k1 accounts. | | Secp256r1 | Supported (decoder) | - | Accepts raw 33-byte key and 0x02 \|\| 33 raw. Disambiguated from Secp256k1 / Passkey by address derivation. | | Passkey | Supported (decoder) | - | Accepts raw 33-byte key and 0x06 \|\| 33 raw. WebAuthn signing inside the wallet; Signer surface unchanged. | | ZkLogin | Decoder ships, E2E unverified | - | Variable-length [1 iss-len][iss][32 addressSeed] identifier (auto-detects modern vs legacy address derivation). Walrus and Seal SessionKey flows have not been smoke-tested with zkLogin signatures; fall back to a keypair account if you see errors. | | MultiSig | Refused | - | Variable-length aggregation of multiple keys; signing semantics differ from Signer and have not been wired up. Implement a custom Signer subclass if you need it. | | Phantom (Sui) | Supported via fromAccountAsync | 2026-05-21 | Phantom returns a 59-byte non-canonical blob in account.publicKey. fromAccount rejects it; fromAccountAsync recovers the real Ed25519 key from a probe signature. See subsection below. |

Refused schemes throw UnsupportedWalletSchemeError (a ConfigurationError subclass) at construction time. The error carries the raw publicKeyBytes, the reported address, and an optional walletName, so consumer dapps can render a wallet-specific CTA without parsing message strings.

Wallets with non-canonical publicKey (Phantom)

Phantom's Sui adapter returns a 59-byte opaque blob in account.publicKey instead of the canonical 32 / 33-byte form mandated by wallet-standard. This is a documented Phantom quirk (see the Sui developer forum thread from August 2025), not an SDK gap.

For Phantom-class wallets, use WalletStandardSigner.fromAccountAsync instead of fromAccount, and pass a PubkeyCache to avoid re-prompting on every page reload:

import {
  WalletStandardSigner,
  BrowserStoragePubkeyCache,
  UnsupportedWalletSchemeError,
} from "@arcadiasystems/morse-sdk";

try {
  const signer = await WalletStandardSigner.fromAccountAsync(account, callbacks, {
    pubkeyCache: new BrowserStoragePubkeyCache(),
  });
} catch (err) {
  if (err instanceof UnsupportedWalletSchemeError) {
    // Render: "Your wallet uses an unsupported scheme. Try Slush or Suiet."
    // err.code, err.publicKeyBytes, err.address, err.walletName available.
  }
}

fromAccountAsync tries the sync decoder first (no extra IO for compliant wallets), and on failure recovers the real Ed25519 public key by asking the wallet to sign a domain-separated probe message. Sui's canonical signature is flag || sig || pk (97 bytes for Ed25519); the last 32 bytes are the raw key. The recovered key is verified to derive to account.address before the signer is constructed.

Without a cache, the probe popup fires every session (every page reload, every component remount). With a BrowserStoragePubkeyCache, the popup fires once per address; subsequent sessions read from localStorage and skip the probe. The cached pubkey is still verified to derive to account.address on every load, so stale entries (wallet switch, planted bytes) heal automatically: the SDK calls cache.clear?.(address) and re-probes.

For non-browser contexts (SSR, Node, native), implement the PubkeyCache interface against IndexedDB, Redis, or any KV store. Methods may be sync or async.

The probe message is "morse-sdk:wallet-pubkey-recovery:" + address, wrapped by Sui's signPersonalMessage "Sui Message:" prefix, so it cannot collide with a real transaction. Compliant wallets pay no extra cost; they take the sync fromAccount path inside fromAccountAsync and skip both the probe and the cache.

Error taxonomy

All errors extend MorseError. Narrow by class:

| Class | Carries | Thrown when | | -------------------- | -------------------------------- | -------------------------------------------------------------------- | | ValidationError | field | Client-side input failed a precondition. | | NotFoundError | resource, identifier | Object doesn't exist on-chain. | | UnauthorizedError | - | Client-side auth check failed before submit. | | ContractAbortError | module, abortCode, reason | Move VM aborted (e.g. ESlugAlreadyExists). | | SealError | code (no-access / decrypt-failed / session-expired / rate-limited) | Seal authorization or decryption failed. | | TransportError | operation? (e.g. sui.getObject, walrus.publisher.uploadBlob, seal.decrypt) | RPC, network, or response-parsing failure. | | ConfigurationError | - | SDK config gap (e.g. unsupported network). | | UnsupportedWalletSchemeError | code (non-canonical-pubkey / malformed-zklogin / recovery-sig-length / recovery-non-ed25519 / recovery-address-mismatch), publicKeyBytes, address, optional walletName | WalletStandardSigner.fromAccount rejected the account's publicKey shape, or the async recovery flow could not extract a key that derives to account.address. | | UncertifiedBlobError | blobObjectId, blobId | addEntryFromBytes upload succeeded but the combined certify+add_entry tx failed; the blob is uploaded but uncertified. |

try {
  await addEntry(adapter, config, args);
} catch (err) {
  if (err instanceof ContractAbortError && err.reason === "EPublisherCapRevoked") {
    // your cap was revoked - issue a new one
  } else if (err instanceof SealError && err.code === "no-access") {
    // identity rejected by key servers
  } else if (err instanceof NotFoundError && err.resource === "entry") {
    // entry was deleted between read and write
  } else if (err instanceof TransportError) {
    // network blip - retry; err.operation names the failing call (e.g. "sui.getObject")
  } else {
    throw err;
  }
}

formatUserMessage: UI-ready translation

For consumer dapps that surface SDK errors in toasts, dialogs, or banners, formatUserMessage(err) translates any throw (MorseError or otherwise) into a { title, description, cause } triple with domain-neutral copy. Use it as the default branch after your own domain-specific handlers:

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

try {
  await addEntryFromBytes(adapter, config, args);
} catch (err) {
  if (err instanceof SealError && err.code === "no-access") {
    toast.error("You don't have permission", { description: "Ask the author for access." });
    return;
  }
  const { title, description } = formatUserMessage(err);
  toast.error(title, { description });
  // `cause` is preserved on the formatted object for logging.
}

Copy uses the protocol's own terminology ("publication", "entry", "PublisherCap"). For consumer domains (blog → post, gallery → image, docs → article), narrow on the error class first and write your own message; formatUserMessage is the fallback for everything else.

Network configuration

const config = morseConfig({ network: "testnet" });
// {
//   network, rpcUrl, packageId, originalPackageId, registryId,
//   sealKeyServers: [{ objectId, weight }, ...]   // canonical testnet allowlist
// }

Override individual fields for forks or local nodes:

const config = morseConfig({
  network: "localnet",
  packageId: "0x...",     // required: no canonical localnet deployment
  registryId: "0x...",    // required
  rpcUrl: "http://127.0.0.1:9000",
});

packageId is the published-at address (used for Move calls). originalPackageId is the genesis publish address (used for Sui type filters and Seal package binding). Always thread both through morseConfig and let the SDK pick the right one per call site.

Known limitations

  • Testnet only at v0.x. Mainnet config lands once the contracts are frozen.
  • No encrypted publish path. The Move contract hardcodes encrypted=false on publish_from_draft and publish_direct. Encrypted content stays as drafts.
  • Subscription access policy is reserved, not enforced.
  • listEntries ordering is dynamic-field object-store order, not chronological. Sort by entry.id for insertion order.
  • Walrus testnet flakiness. NotEnoughBlobConfirmationsError from the underlying client is environmental; rerun. The SDK preserves the original error as the cause (use instanceof for narrowing — Walrus error classes don't set .name). Browser consumers may additionally see NoBlobMetadataReceivedError on reads from testnet due to CORS gaps on a subset of Walrus storage nodes; the CLI smoke scripts hit the full node pool and are more reliable for verification.
  • Walrus uploads need WAL, not just SUI. Fund the address from the Walrus testnet faucet in addition to the Sui faucet. Uploads error with Insufficient balance of ::wal::WAL if you skip this.
  • gRPC client only at v0.1.0. The reader and adapter interfaces are typed against Pick<SuiGrpcClient, ...> from @mysten/sui/grpc. SuiJsonRpcClient from @mysten/sui/jsonRpc has differently-named methods (getDynamicFields vs listDynamicFields, etc.) and is not yet a drop-in alternative. JSON-RPC fallback is planned for v0.2.0; for now, environments that block gRPC need to proxy or use a gRPC-compatible RPC endpoint.

Smoke scripts

The scripts/ directory has end-to-end testnet smokes that cost real WAL and SUI. They're the canonical "this works against the live deployment" checks:

| Script | Exercises | | ------------------------- | -------------------------------------------------- | | phase-2-publication.ts | Publication CRUD | | phase-3-cap.ts | Cap issue / revoke / destroy | | phase-4-collection.ts | Blob and quilt-mode collection lifecycle | | phase-5-walrus.ts | Walrus blob and quilt upload | | phase-6-blob.ts | Entry lifecycle in a Blob collection | | phase-6-quilt.ts | Entry lifecycle in a Quilt collection | | phase-7-encrypted.ts | Seal encrypt + addEncryptedEntry + decrypt | | phase-6-blob-http.ts | HTTP publisher upload + aggregator read; skips when WALRUS_PUBLISHER_URL unset | | phase-7-encrypted-http.ts | HTTP variant of phase-7; skips when WALRUS_PUBLISHER_URL unset |

Each requires PRIVATE_KEY (Bech32 suiprivkey1...) on an address with testnet SUI; phase-5 onward also needs WAL on the same address. Phase-7 picks up Seal key servers from morseConfig.sealKeyServers (canonical testnet allowlist baked in) by default — pass SEAL_KEY_SERVERS only if you want to override with a custom set.

Development

# from the repo root
bun install

# from morse-sdk/
bun run lint
bun run typecheck
bun run test
bun run test:coverage   # 265 tests, ~97% line / ~96% function coverage at v0.1.0
bun run build

bun test is the unit test runner; bun run test:coverage adds a per-file coverage report. CI gates require all four (lint, typecheck, test, build) to pass; coverage is informational. End-to-end testnet smokes live in scripts/ (above).

License

MIT. See LICENSE.