Seed Protocol
Guides

Publishing and revoking items

Publish edits and new versions, remove published values, publish related items together, estimate costs, confirm large uploads, recover from failed publishes, publish unattended with session keys, and unpublish.

Publishing your first item got one post from the local database onto Arweave and EAS. This guide covers what comes after: publishing edits, deliberate new versions, removing a published value, posts that point at other unpublished items, cost estimates, upload limits, failures and retries, publishing from a server with a session key, and unpublishing.

You'll need: the blog app as set up in Publishing your first item: initPublish configured with useModularExecutor: true and autoDeployManagedAccount: true, and ConnectButton connected. Everything here runs on the OP Sepolia testnet.

The publish call

As in the tutorial, item.publish() publishes with the publish wallet ConnectButton registered, and resolves when the publish has finished. Its publishMode option chooses what an edit attests (below):

await post.publish({ publishMode: 'new_version' })

The uploads are signed by the publish wallet's data item signer, which ConnectButton registers. The promise rejects before the publish starts when the post isn't yours, is already publishing in this tab or another, or initPublish hasn't run (publish). Errors from the publish arrive with their class and code, so match them with instanceof or code, not the message: a ManagedAccountPublishError when the in-app wallet isn't connected, for example. A publish that fails after it started rejects with an Error whose message names the step, Publish of <id> failed at <step>: …, or, when it waits for a retry (below), Creating attestations for <id> failed: …; its cause is the step's own error, and the code is also on the publish's latestRecord.errorCode (item.publish() and publishItem). In React, usePublishItem calls item.publish() and puts the rejection in its error (usePublishItem).

Start without waiting

PublishManager.createPublish resolves as soon as the publish has started, or with undefined when one is already running for that item; follow the publish with useItemPublishStatus, as in Show progress and the result. It picks the data item signer the way item.publish() does, so with the publish wallet you don't pass one:

src/publishPost.ts
import type { Item } from '@seedprotocol/sdk'
import { PublishManager, getPublishWallet, type PublishMode } from '@seedprotocol/publish'

export async function publishPost(item: Item<any>, publishMode: PublishMode = 'patch') {
  const wallet = getPublishWallet()
  if (!wallet) throw new Error('Connect a wallet first.')
  const address = wallet.publisherAddress ?? wallet.signer.address
  return PublishManager.createPublish(item, address, wallet, { publishMode })
}

Every publish holds its item until it stops (success, failure or stopped), so an item you looked up without a hold doesn't unload after the grace period mid-upload. PublishManager.createPublish lists every option and when it resolves undefined.

Publish edits: patch or new version

Attestations are never edited. Publishing an edit adds new attestations, and readers take the newest one for each property. The publishMode option decides how much a publish adds:

ModeWhat it attestsUse it for
'patch' (default)Only the properties edited since the last publish, against the post's current VersionRoutine edits
'new_version'A new Version attestation, then every property that has a value, against that Version: a full snapshotA deliberate release you want to point at later

'new_version' needs a post that's already published; on a draft it fails validation. It also fails when the post has no property values to attest.

PublishModeButtons is a small two-button control for choosing the mode. Show it once a post has a seedUid:

src/PublishControls.tsx
import { useState } from 'react'
import { useItem } from '@seedprotocol/react'
import { PublishModeButtons, type PublishMode } from '@seedprotocol/publish'

export function PublishControls({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const [mode, setMode] = useState<PublishMode>('patch')
  const [error, setError] = useState<string | null>(null)
  const isPublished = Boolean(item?.seedUid) && !item?.isRevoked

  async function handlePublish() {
    if (!item) return
    setError(null)
    try {
      await item.publish({ publishMode: isPublished ? mode : 'patch' })
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err))
    }
  }

  return (
    <div>
      {isPublished && <PublishModeButtons value={mode} onChange={setMode} />}
      <button disabled={!item} onClick={handlePublish}>
        Publish
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  )
}

Show what an edit will publish

getPublishPendingDiff from the SDK lists the properties whose latest local value isn't attested yet, with the value they had when last published. Each one's change says what the publish does with it: 'set' attests a new value, 'removed' attests the empty value of a cleared property, and 'retracted' revokes the property's attestations.

import { getPublishPendingDiff } from '@seedprotocol/sdk'

const { pendingProperties, lastPublishedVersionUid } = await getPublishPendingDiff(post)
for (const p of pendingProperties) {
  console.log(p.propertyName, p.change, p.previousPublishedValue, '→', p.currentValue)
}

An empty pendingProperties means a patch publish has nothing new to attest. Setting a property back to its published value removes its edit, so it isn't listed and the next publish leaves it out (ItemProperty gives the exceptions). A property cleared before it was ever published isn't listed, because there's nothing on chain to remove. The EAS history keeps every attestation; the local database keeps the ones made from this device, and only the newest per property for data synced from EAS. See Permanence.

Remove a published value

Clearing a property removes its value at the next publish. clear() shows the property empty at once and stores the change locally; the two forms differ in what the next publish does and what other apps read afterwards:

const summary = post.properties.find((p) => p.propertyName === 'summary')
await summary?.clear() // or: await summary?.clear({ retract: true })
await post.publish()
CallThe next publishWhat other apps read
clear()Attests the property's empty value: '' for Text and Number, a zero bytes32 for Image, File, Html, Json and Relation, [] for a ListNo value: @seedprotocol/query leaves out a property whose newest attestation is empty, and devices running the SDK store it with no value at their next sync. Reads of earlier Versions keep the old value.
clear({ retract: true })Revokes the property's attestationsNo value, and reads of earlier Versions stop showing it too. The revoked attestations stay readable on EAS (Permanence).

Boolean and Date properties have no empty value, so only clear({ retract: true }) works on them: clear() rejects with ClearNotSupportedError, and a published Boolean or Date with an empty value fails validation at checking with the code publish_clear_unsupported.

The revocations are sent after the attestations, one transaction per property schema from the account that attested the item; a publisher with a plain wallet (an EOA) signs each one after the attestation transaction. summarizePublishWork counts them in revocationCount (below). A publish with a retracted property fails at checking, before it attests anything, when the connected wallet can't revoke the item's attestations: a session key can't, and nobody can revoke a Seed the executor module attested. If the revocations fail, the attestations are already made and the publish waits in retractionFailureRecoverable for a retry (Handle failures and resume); the next publish also sends any that are still live.

A post that points at items that have never been published takes them along. A publish walks every Relation and List property, at any depth, and publishes each unpublished item it finds in full: the post's author, the storage items behind its Html body and images, and their own relations. Related items that are already published are referenced by their current seedUid and aren't published again.

The publish is still started for the post alone. The author is attested in the same run, and its Seed UID goes into the post's author attestation. A contract publish, which this guide's config uses, sends the post and every related draft in one multiPublish, as one transaction or UserOp, so a failure attests none of them. A publish too big for one block fails with ManagedAccountPublishError (code: 'PUBLISH_TOO_LARGE') before anything is sent; publish some of its related drafts on their own first.

A publish that attests straight to EAS (useDirectEas, or a plain wallet with no contract) sends its requests one at a time, so it isn't atomic: if a later request fails, the Seeds attested before it stay attested, and a retry references them instead of attesting them again. See One transaction for the whole publish.

Two cases stop a publish before anything is uploaded or attested:

  • A required relation is empty. Post.author is required in the blog schema, so a post with no author fails with "Required relation author has no value".
  • A related item has been unpublished. Attesting a revoked Seed's UID would point readers at withdrawn content, so the publish fails with a validation error naming each one (RelatedItemUnpublishedError in the SDK, code related_item_unpublished). Publish those items again, or remove the references.

summarizePublishWork counts what a publish will do, and its unpublishedRelatedItems lists the revoked references, so you can show them before you offer a Publish button. summarizePublishUploads returns that list and the upload counts alone:

import { summarizePublishWork } from '@seedprotocol/sdk'

const work = await summarizePublishWork(post, { publishMode: 'patch' })
console.log(
  `${work.seedCount} items, ${work.newSeedCount} new, ${work.attestationCount} attestations, ` +
    `${work.revocationCount} revocations, ${work.uploadCount} uploads (${work.uploadBytes} bytes)`,
)

for (const ref of work.unpublishedRelatedItems) {
  console.log(`${ref.propertyName} points at unpublished ${ref.modelName} ${ref.seedLocalId}`)
}

When two drafts point at each other, a contract publish attests both sides in its one transaction, and the same goes for a draft that points back at the post. A publish that attests straight to EAS can't: the item attested first doesn't have the other's UID yet, so that reference is left out of this publish, and the next publish of that item attests it. On that route a List with one such member is left out whole: its other new members are still published, but the List isn't attested until that next publish. Modeling relations covers relation design.

Estimate the cost

usePublishCostEstimate(item) (or estimatePublishCost(item) outside React) summarizes the publish and prices it. It re-estimates 15 seconds after the item stops changing (in the browser, after any write to the item's rows, from any tab) and re-prices every 60 seconds.

src/CostEstimate.tsx
import { useItem } from '@seedprotocol/react'
import { usePublishCostEstimate } from '@seedprotocol/publish'

export function CostEstimate({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const { estimate, isEstimating, error } = usePublishCostEstimate(item)

  if (error) return <p>Couldn't estimate: {error.message}</p>
  if (!estimate) return <p>{isEstimating ? 'Estimating…' : null}</p>

  return (
    <p>
      About {estimate.work.attestationCount} attestations and {estimate.arweave.chargedBytes} bytes
      of storage. Gas: {estimate.evm.eth} ETH at {estimate.evm.gas.toString()} gas (heuristic).
    </p>
  )
}

The work counts come from the same walk the publish does, so they're exact. Gas is a heuristic, and on a testnet it's priced at $0. With this guide's config evm.userPays is false, because thirdweb sponsors the gas; with ConnectButton and no useModularExecutor it reads true although the gas is sponsored. Cost estimates and payment covers every field, including how chargedBytes follows each upload path.

Confirm large uploads

A publish adds up the size of its uploads in checking, from file sizes, before it registers a schema or reads a file. Over 100 MiB it fails with UploadTooLargeError. Over 25 MiB it fails with UploadNeedsConfirmError unless the publish has confirmUpload: true, so ask the user and publish again. item.publish() rejects with these two errors themselves, and error.estimate has the numbers:

src/publishWithConfirm.ts
import type { IItem } from '@seedprotocol/sdk'
import { UploadNeedsConfirmError } from '@seedprotocol/publish'

export async function publishWithConfirm(item: IItem<any>) {
  try {
    await item.publish()
  } catch (error) {
    if (!(error instanceof UploadNeedsConfirmError)) throw error
    const { uploadBytes, uploadSeconds } = error.estimate
    const megabytes = Math.round(uploadBytes / 2 ** 20)
    if (window.confirm(`Upload ${megabytes} MiB? It takes about ${uploadSeconds} seconds.`)) {
      await item.publish({ confirmUpload: true })
    }
  }
}

A low-memory device can ask below 25 MiB. A browser publish peaks at about 7 copies of its upload bytes in memory, and in Chromium, the only browser that reports navigator.deviceMemory, a peak over a quarter of that fails the same way. confirmUploadAboveBytes: Infinity doesn't turn this check off, so handle the error even with a raised limit.

The limits apply on Node and with Turbo uploads too. A server pipeline has nobody to ask, so it passes confirmUpload: true, as publishAsUser does, or raises confirmUploadAboveBytes in initPublish. confirmUpload doesn't lift the 100 MiB limit; maxUploadBytes does. To warn before the user presses Publish, read upload from usePublishCostEstimate. Upload limits has the fields and options.

Handle failures and resume

A publish moves through these steps, saving each one to the publish_processes table:

What to do depends on where it stopped:

  • Before the attestations (authorizing, checking or an Arweave step): the publish ends in failure. Nothing was attested. Fix the cause and start a new publish. Uploads that finished stay on Arweave.
  • During the attestations: the publish waits in attestationFailureRecoverable (or attestationFailureRecoverableDirectToEas) with the uploads kept. Call PublishManager.retryAttestations(seedLocalId) to try the attestations again with the current publish wallet, without uploading again. A new publish of that item is refused as already publishing while it waits. A publish that attests straight to EAS then reads its property attestations back from the EAS indexer, every 5 seconds for about 25 seconds; if some are still missing, it waits here with an AttestationVerificationError (METADATA_PROPERTIES_MISSING). When the indexer doesn't answer, each read gives up after 30 seconds, so the check can take about 3.4 minutes before it waits here with the last read's error.
  • While revoking retracted properties: the publish waits in retractionFailureRecoverable with the attestations made, and item.publish() rejects with a PublishRetractionError (code: 'PUBLISH_RETRACTION_FAILED'). retryAttestations sends the revocations again.
src/PublishStatus.tsx
import { PublishManager, useItemPublishStatus } from '@seedprotocol/publish'

const RECOVERABLE = [
  'attestationFailureRecoverable',
  'attestationFailureRecoverableDirectToEas',
  'retractionFailureRecoverable',
]

export function PublishStatus({ seedLocalId }: { seedLocalId: string }) {
  const { publishValue, latestRecord } = useItemPublishStatus(seedLocalId)

  if (publishValue && RECOVERABLE.includes(publishValue)) {
    return (
      <div role="alert">
        <p>The publish stopped before it finished: {latestRecord?.errorMessage}</p>
        <button onClick={() => PublishManager.retryAttestations(seedLocalId)}>Retry</button>
      </div>
    )
  }
  if (publishValue === 'failure') {
    return (
      <div role="alert">
        <p>
          Failed at {latestRecord?.errorStep}: {latestRecord?.errorMessage}
        </p>
      </div>
    )
  }
  return null
}

latestRecord is the item's newest publish_processes row. Besides errorMessage and errorStep, it has errorCode for the publish package's own errors (for example MANAGED_ACCOUNT_UNAVAILABLE).

Reloads resume most publishes. When the app starts, the publish package reloads every publish still marked in_progress and carries on from its last saved step, and the attestations use whatever wallet is registered by then. If no wallet has reconnected yet, the attestation step fails as recoverable, and you can retry once the user is connected. A publish cut off during its bundler or Turbo upload isn't resumed: its signed data items weren't saved, so it's marked interrupted on reload, and the user has to publish again. The signer passed to the publish isn't saved either, so a publish that resumes before its uploads were signed needs a dataItemSigner or signDataItems in the config (CreatePublishOptions).

Stalled publishes. A row can say in_progress with nothing running it. useItemPublishStatus then returns isActive: true with publishProcess: null and publishValue undefined. Treat that as stopped, and let the user publish again, which starts a fresh run from authorizing.

Stopping. PublishManager.stopPublish(seedLocalId) stops a publish, marks its row interrupted and releases its lock. Nothing already uploaded or attested is undone.

History. usePublishProcessesForSeed(seedLocalId) lists an item's runs, newest first. clearCompletedPublishProcessesForSeed and clearCompletedPublishProcesses delete finished runs and keep running ones.

Publish from more than one tab

A publish runs in exactly one tab. The tab that starts it holds a Web Lock named seed:publish:<seedLocalId>; while the lock is held, createPublish in any other tab resolves undefined for that item and item.publish() rejects as already publishing. Show "already publishing" rather than retrying in a loop.

Two cases stop a publish without finishing it:

  • The tab running it closes. The publish stays in_progress. Tabs that are already open don't pick it up; the next tab to load the app does.
  • Another wallet takes over, or the user disconnects. ConnectButton stops every publish running in its tab and leaves their rows in_progress. An item.publish() stopped this way rejects with …was stopped before it finished. Avoid switching accounts during a publish.

Either way the publish is stalled as described above until it's resumed or started again. Multi-tab behavior covers the locks.

Publish unattended with a session key

To publish for a user when they aren't there (scheduled posts, imports, a server pipeline), the user grants your app a session key: a key your server holds that may publish as the user's ManagedAccount until it expires or the user withdraws it. The posts are still attested by the user's ManagedAccount, so they're the user's posts.

A session key can only call the Seed executor module, and the executor can only attest. So a session key:

  • can publish, including edits that change what readers see,
  • can't revoke anything, including what it published, so a publish with a retracted property fails before it attests,
  • can't register EAS schemas: every schema a publish attests under must already be registered on the chain. Before automation starts, have the user's own wallet run ensureEasSchemasForModels(['Post'], wallet) for the models it will publish, and again after a schema version adds a property. A publish by the user doesn't cover it, since it registers only what that item attests and leaves out the properties it leaves empty.

Enroll the key

Set the executor in the publish config. On OP Sepolia it's 0xa4799d6D6AF110D5d69437488aBC31A5ef8ACA5f (Deployments has the current address). To check it at startup, compare it with (await readFactorySeedExecutor())?.executor, the executor the account factory pins:

src/publish.ts
import { initPublish } from '@seedprotocol/publish'

initPublish({
  thirdwebClientId: import.meta.env.VITE_THIRDWEB_CLIENT_ID,
  uploadApiBaseUrl: import.meta.env.VITE_SEED_UPLOAD_API_URL,
  useModularExecutor: true,
  autoDeployManagedAccount: true,
  modularAccountModuleContract: '0xa4799d6D6AF110D5d69437488aBC31A5ef8ACA5f',
})

Then, with the user connected, enroll the session key's address. Your server generates the key pair and keeps the private key:

src/automation.ts
import { getPublishWallet } from '@seedprotocol/publish'
import { enrollPublishAutomation, revokePublishAutomation } from '@seedprotocol/publish/thirdweb'

export async function grantAutomation(sessionKeyAddress: string) {
  const wallet = getPublishWallet()
  if (!wallet?.publisherAddress) throw new Error('Connect a wallet first.')
  const { authorization } = await enrollPublishAutomation({
    managedAddress: wallet.publisherAddress,
    sessionKeyAddress,
    expiresAt: Math.floor(Date.now() / 1000) + 30 * 24 * 3600,
  })
  return authorization.uid // store it: you need it to withdraw the grant
}

export async function withdrawAutomation(sessionKeyAddress: string, authorizationUid: string) {
  const wallet = getPublishWallet()
  if (!wallet?.publisherAddress) throw new Error('Connect a wallet first.')
  await revokePublishAutomation({
    managedAddress: wallet.publisherAddress,
    sessionKeyAddress,
    authorizationUid,
  })
}

enrollPublishAutomation installs the executor on the user's ManagedAccount (a transaction from the account's admin wallet), adds the session key with the executor as its only allowed target and no native value, and records the grant as a revocable seedprotocol.publishAuthorization attestation. Both end at expiresAt, which is optional and defaults to 90 days after enrollment. revokePublishAutomation removes the key on-chain and revokes that attestation.

These functions from @seedprotocol/publish/thirdweb use the connected in-app wallets. The versions in @seedprotocol/publish need no thirdweb, and take a userWallet that sends as the account and, when the executor isn't installed yet, its admin: see Session keys without thirdweb.

To renew a key, enroll it again. That moves its end to the new expiresAt, whether earlier or later, and makes a new attestation, so store the new authorization.uid.

Enrollment fails, with a ManagedAccountPublishError, when:

  • The account holds more than 0.001 ETH across its balance and EntryPoint deposit: AUTOMATION_ACCOUNT_FUNDED, with nothing sent. This refuses a renewal too. Withdraw the ETH, or raise maxAutomationExposureWei. A key already enrolled keeps publishing, and each of its publishes logs a warning.
  • The ManagedAccount isn't deployed yet, which the user's first publish does with autoDeployManagedAccount: true: AUTOMATION_UNSUPPORTED_ACCOUNT.
  • The account pins a different executor than modularAccountModuleContract: EXECUTOR_MODULE_NOT_INSTALLED.
  • expiresAt has passed: MODULAR_SIGNER_ACTIVATION_FAILED.

A key is approved for one executor's address, so it stops working when the factory moves to a new executor, as it did on OP Sepolia on 2026-10-30. Update modularAccountModuleContract and enroll each key again, which installs the new executor and replaces the key's target. After the factory moves to a new executor gives the details.

Publish with the key

On the server, the publish config needs useModularExecutor: true (that's what routes a session key's publish through the executor), the same modularAccountModuleContract, and a thirdweb secret key. The wallet you pass to createPublish has to pair the session key as signer with a sender that submits UserOps from the user's ManagedAccount. The publish package doesn't ship one; thirdweb's smartWallet with the account address overridden is one way to build it:

server/publishAsUser.ts
import { createThirdwebClient } from 'thirdweb'
import { optimismSepolia } from 'thirdweb/chains'
import { privateKeyToAccount, smartWallet } from 'thirdweb/wallets'
import { Item } from '@seedprotocol/sdk'
import {
  MANAGED_ACCOUNT_FACTORY_ADDRESSES,
  PublishManager,
  initPublish,
  waitForPublishActor,
  type PublishWallet,
} from '@seedprotocol/publish'
import { fromThirdwebAccount } from '@seedprotocol/publish/thirdweb'

initPublish({
  thirdwebClientId: process.env.THIRDWEB_CLIENT_ID,
  thirdwebSecretKey: process.env.THIRDWEB_SECRET_KEY,
  rpcUrl: process.env.RPC_URL,
  uploadApiBaseUrl: process.env.SEED_UPLOAD_API_URL!,
  useModularExecutor: true,
  modularAccountModuleContract: '0xa4799d6D6AF110D5d69437488aBC31A5ef8ACA5f',
})

const client = createThirdwebClient({ secretKey: process.env.THIRDWEB_SECRET_KEY! })

export async function publishAsUser(managedAddress: string, seedLocalId: string) {
  const sessionKey = privateKeyToAccount({ client, privateKey: process.env.SESSION_KEY_PRIVATE_KEY! })
  const wallet = smartWallet({
    chain: optimismSepolia,
    factoryAddress: MANAGED_ACCOUNT_FACTORY_ADDRESSES[optimismSepolia.id],
    sponsorGas: true,
    overrides: { accountAddress: managedAddress },
  })
  const managedAccount = await wallet.connect({ client, personalAccount: sessionKey })

  // Signer: the session key. Sender: UserOps from the user's ManagedAccount, signed by that key.
  // No `kind`: with useModularExecutor the publish goes through multiPublish, which doesn't read it.
  const publishWallet: PublishWallet = {
    signer: fromThirdwebAccount(sessionKey, { client }).signer,
    txSender: fromThirdwebAccount(managedAccount, { client }).txSender,
  }

  // Held while this function runs; the publish also holds it until it stops.
  const post = await Item.find({ modelName: 'Post', seedLocalId, hold: true })
  if (!post) throw new Error('No such post')

  try {
    await PublishManager.ready()
    // The session key also signs the Arweave data items, as a plain Ethereum key.
    // confirmUpload: nobody is here to confirm an upload over 25 MiB.
    const run = await PublishManager.createPublish(post as Item<any>, managedAddress, publishWallet, {
      dataItemSigner: publishWallet.signer,
      confirmUpload: true,
    })
    if (!run) throw new Error(`Post ${seedLocalId} is already publishing`)
    await waitForPublishActor(run, seedLocalId)
  } finally {
    post.unload()
  }
}

publishAsUser resolves when the publish succeeds and rejects with the errors listed under item.publish() and publishItem for a publish that started. The publish package recognizes the session key by the signer's address, so the signer has to be the session key, not the ManagedAccount. Before sending, it checks that the executor can act for the account (otherwise AUTOMATION_UNSUPPORTED_ACCOUNT) and that the key is approved for the configured executor and nothing else (otherwise MODULAR_SIGNER_ACTIVATION_FAILED, and the message says to run enrollPublishAutomation again), then simulates the call; a call that would revert fails with AUTOMATION_PREFLIGHT_FAILED. In each case nothing is sent. A publish that needs a schema that isn't registered yet fails before anything is sent, with EasSchemasNotRegisteredError (code: 'EAS_SCHEMAS_NOT_REGISTERED'); its missing lists the definitions to register with ensureEasSchemasForModels and the owner's wallet (Publish overview).

On Node, PublishManager doesn't start on import: await PublishManager.ready() starts it. The server's SDK client applies the same ownership check as the browser, so set the ManagedAccount as an owned address with client.setAddresses. Using Seed in Node and Electron covers running the SDK on a server, including the --import @seedprotocol/sdk/node-eas-lodash flag.

Items with Html, Image or File values need the data item signer. The session key works as one, because it's a plain key. The grant attestation's scopes field reads publish,revoke for compatibility, but the key can't revoke.

Unpublish a post

item.unpublish() revokes the post's attestations on EAS. Only the account that published the post can do it, so the user has to be connected with that account: the SDK checks ownership first, and EAS rejects anyone else.

src/UnpublishButton.tsx
import { useState } from 'react'
import { useItem } from '@seedprotocol/react'

export function UnpublishButton({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [finished, setFinished] = useState(false)

  if (!item?.seedUid) return null
  if (item.isRevoked && finished) return <p>Unpublished</p>

  async function runUnpublish() {
    if (!item) return
    setBusy(true)
    setError(null)
    try {
      await item.unpublish()
      setFinished(true)
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err))
    } finally {
      setBusy(false)
    }
  }

  async function confirmAndUnpublish() {
    const ok = window.confirm(
      'Unpublish this post? It will disappear from feeds and other apps, but anyone can still ' +
        'read the revoked attestations and the files on Arweave. This cannot be undone.',
    )
    if (ok) await runUnpublish()
  }

  const started = error !== null || item.isRevoked
  const label = error ? 'Retry unpublish' : item.isRevoked ? 'Finish unpublishing' : 'Unpublish'

  return (
    <>
      {item.isRevoked && !error && <p>Unpublished</p>}
      <button onClick={started ? runUnpublish : confirmAndUnpublish} disabled={busy}>
        {busy ? 'Unpublishing…' : label}
      </button>
      {error && <p role="alert">{error}</p>}
    </>
  )
}

unpublish() revokes the post's Seed, then its property attestations, then its Versions, from the publishing account. When it resolves, item.isRevoked is true and the SDK, @seedprotocol/query and feeds leave the post out; getSeedPublishState() still reports status: 'onchain', so check item.isRevoked to show the post as unpublished.

It revokes only the post's own attestations that this device's database records. Related items such as the author keep their own Seeds, attestations published from another device can stay live, tool attestations stay until their attester revokes them, and nothing on Arweave changes; see What isn't revoked and Permanence. The messages unpublish() rejects with are under Errors.

If it fails partway, the Seed is already revoked, so @seedprotocol/query leaves the post out, but some of its attestations are still live. That's why the button doesn't disappear when item.isRevoked turns true: a sync can set it before the unpublish has finished. After a failure the button reads Retry unpublish until a call succeeds. When isRevoked is set and this session hasn't seen the unpublish succeed, after a reload for example, it shows "Unpublished" with a Finish unpublishing button, because nothing on the item tells a complete unpublish from a partial one. Calling unpublish() again is safe: it skips what EAS already shows as revoked and revokes the rest, and when everything is revoked it sends no transaction. It still runs its checks first, so the user has to be connected with the publishing account either way (Retrying).

Publish it again

There's no un-revoke. Publishing an unpublished post, with item.publish() or createPublish, creates a new Seed with a new UID and attests every property, as a new_version publish does; the old Seed stays revoked. The local post keeps its seedLocalId, and item.seedUid becomes the new UID.

Other posts that point at the unpublished item are blocked from attesting that reference until it's published again. A publish that would attest it (a first publish, a new_version publish, or a patch that changed the relation) fails with the related-item error described above. After it's republished, their next publish attests the new UID.

Gotchas

  • Wallets connected with useSeedWallet need a kind. Without useModularExecutor, a wallet connected with useSeedWallet().connect is registered with no kind, and every publish from it is refused at checking unless useDirectEas is set. useSeedWallet shows the workaround.
  • Older L1 uploads carry a SHA3-256 digest. Uploads carry a SHA-256 digest in their Content-SHA-256 tag, but files uploaded on the L1 path by 0.7.2 or earlier have a SHA3-256 digest there. contentSha256TagMatches from the SDK accepts either. See Permanence.

On this page