Seed Protocol
Tutorial: build a blog

Publishing your first item

Install @seedprotocol/publish, connect a wallet, and publish a blog post from the local database to Arweave and EAS on the OP Sepolia testnet.

So far every post in the blog app has lived only in the browser's local database. This page publishes one: its body goes to Arweave, and a signed record of the post goes to Ethereum Attestation Service (EAS), where any Seed-compatible app can read it. You'll add @seedprotocol/publish to the app, sign in with a wallet, publish a post, follow its progress, and look it up on EAS Scan.

Seed Protocol publishes to the OP Sepolia testnet only. There's no mainnet deployment yet, so everything you publish here is test data on a test network. See Permanence for what that means for your data.

What publishing does

Publishing uploads the post's Html, Image and File values (here, its body) to Arweave, and turns the post into EAS attestations made by the user's account, which is the post's author: a Seed attestation (the post's identity), a Version attestation, and one per property value. The local post stays where it is and gains a seedUid, the UID of its Seed attestation. How Seed Protocol works walks through the records.

Publishing can't be undone. You can revoke a published post later, but the revoked records and the Arweave files stay readable. Don't publish anything you'd need to delete.

Before you start

You'll need:

  • The blog app from the earlier pages, with the blog schema from Your first schema and the BlogApp component from Adding reactivity in React.
  • A thirdweb client ID. The publish package signs users in with thirdweb's in-app wallets. Create a project in the thirdweb dashboard and copy its client ID. Client IDs are usually locked to the origins you list, so add your dev server's origin (for example localhost:5173).
  • An upload API you can reach (see step 2).

You don't need AR or an Arweave wallet: the upload API's bundler pays for the Arweave storage. Whether you need testnet ETH depends on your thirdweb project. The publish package asks thirdweb to sponsor gas by default (thirdweb.sponsorGas defaults to true), but sponsorship only happens if the project behind your client ID has it turned on for OP Sepolia. If it doesn't, turn it on there, or add payment: { gas: 'user' } to the config in step 2 and send testnet ETH to both of the user's accounts (step 4): the in-app wallet sends the account deployment and any schema registrations, and the ManagedAccount sends the attestations. See Bundler and paymaster.

1. Install the publish package

Install @seedprotocol/publish and thirdweb, which the wallet components are built on:

npm install @seedprotocol/publish thirdweb

If your app isn't on the latest release, install it at the version your other @seedprotocol/* packages are on, for example npm install @seedprotocol/publish@<that version> thirdweb (one version for every package).

Put the client ID and the upload API's URL in .env.local at the project root, so Vite exposes them on import.meta.env:

.env.local
VITE_THIRDWEB_CLIENT_ID=your-client-id
VITE_SEED_UPLOAD_API_URL=https://upload.example.com

2. Configure publishing

Create src/publish.ts. It calls initPublish once, when the module loads:

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

export const publishConfig: PublishConfig = {
  thirdwebClientId: import.meta.env.VITE_THIRDWEB_CLIENT_ID,
  uploadApiBaseUrl: import.meta.env.VITE_SEED_UPLOAD_API_URL,
  useModularExecutor: true,
  autoDeployManagedAccount: true,
}

initPublish(publishConfig)

There's no public upload API to point at yet: the Seed Protocol team's own gateway is private (Seed's gateway). Use an upload API you have access to, or the Turbo option below.

Without an upload API, you can have each user pay for their own storage through ArDrive's Turbo service instead, with payment: { storage: 'user' }. See User-paid storage through Turbo.

These are the only settings the blog app needs:

SettingRequired?What it's for
uploadApiBaseUrlYes, unless users pay through TurboThe upload API that receives the Arweave uploads, signed ANS-104 data items, and passes them to its bundler (Uploads and ANS-104 data items). It has no default: without it, and without payment: { storage: 'user' }, initPublish throws "initPublish: uploadApiBaseUrl is required…", and the app fails as it loads. It must be an absolute http(s) URL or a path on the page's origin, such as /seed-api
useModularExecutorSet it to trueConnectButton signs the user in with their in-app wallet, and the publish attests from their ManagedAccount through Seed's contract (step 4)
autoDeployManagedAccountSet it to trueDeploys a new user's ManagedAccount during their first publish. With useModularExecutor and without this, that publish fails with "Your publishing smart account is not deployed on OP Sepolia yet"
thirdwebClientIdFor thirdweb wallets, including ConnectButtonthirdweb's in-app wallets, bundler and paymaster
chainNoDefaults to OP Sepolia, where Seed's contracts are deployed. Leave it unset

initPublish also points the SDK's EAS reads at the publish chain and registers what item.publish() and item.unpublish() hand their work to. Until it runs, both throw.

3. Wrap the app in PublishProvider

The PublishProvider from @seedprotocol/publish/thirdweb includes thirdweb's provider and Seed's SeedProvider, so it replaces SeedProvider in src/main.tsx:

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SeedClientGate } from '@seedprotocol/react'
import { PublishProvider } from '@seedprotocol/publish/thirdweb'
import './publish'
import { config } from './seed'
import { BlogApp } from './BlogApp'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <PublishProvider>
      <SeedClientGate initConfig={{ config }}>
        <BlogApp />
      </SeedClientGate>
    </PublishProvider>
  </StrictMode>,
)

import './publish' runs initPublish before anything renders. Without a config prop, PublishProvider uses the config initPublish registered. It starts the Seed client only when you pass it seedConfig; here SeedClientGate starts it.

4. Connect a wallet

ConnectButton from @seedprotocol/publish/thirdweb renders thirdweb's connect button, set up for Seed. Add it to the top of the app:

src/WalletBar.tsx
import { ConnectButton } from '@seedprotocol/publish/thirdweb'

export function WalletBar() {
  return (
    <header>
      <ConnectButton />
    </header>
  )
}
src/BlogApp.tsx
import { WalletBar } from './WalletBar'

export function BlogApp() {
  const [selectedId, setSelectedId] = useState<string | null>(null)

  return (
    <>
      <WalletBar />
      <div style={{ display: 'flex', gap: '1rem' }}>
        <PostSidebar selectedId={selectedId} onSelect={setSelectedId} />
        {selectedId ? <PostEditor seedLocalId={selectedId} /> : <p>Select a post</p>}
      </div>
    </>
  )
}

Users sign in with email, phone, a passkey or Farcaster. That gives them a thirdweb in-app wallet and a ManagedAccount, a smart account controlled by that wallet. The ManagedAccount publishes, so it's the author of everything the user publishes. Its contract isn't deployed until their first publish. Identity and signing explains the two accounts.

When a wallet connects, ConnectButton also sets up the SDK:

  • It registers the publish wallet: the in-app wallet, with the ManagedAccount as its publisher address. The in-app wallet's key also signs the Arweave uploads, because a smart account's signatures can't. getPublishWallet() returns this registration.
  • It sets the client's owned addresses to the in-app wallet's and the ManagedAccount's addresses, replacing the owned list (watched addresses are kept).
  • It claims the drafts that have no publisher yet, such as the posts you made before connecting, so the ManagedAccount becomes their publisher.

Ownership and drafts covers owned addresses and publishers.

5. Give the post an author

The blog schema marks Post.author as required. Saving a draft doesn't check that, but publishing does: a post with no author fails with "Required relation author has no value". The author is published together with the post, because a publish carries along every unpublished item the post points to.

If your posts don't have authors yet, this button creates one and assigns it:

src/AddAuthorButton.tsx
import { useCreateItem, useItem } from '@seedprotocol/react'
import type { Post } from './types'

export function AddAuthorButton({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  const { createItem, isLoading } = useCreateItem()

  if (!post || post.author) return null

  const handleClick = async () => {
    const author = await createItem('Author', { name: 'Jane Doe' })
    if (author) post.author = author.seedLocalId
  }

  return (
    <button onClick={handleClick} disabled={isLoading}>
      Add an author
    </button>
  )
}

Post is the type from Reading and writing data.

6. Publish the post

Call publish() on the item:

await post.publish()

It uses the publish wallet ConnectButton registered: the ManagedAccount makes the attestations, and the in-app wallet signs the uploads. It resolves when the publish has finished. It rejects with the reason when the publish fails, when no wallet is connected, when the post isn't yours, or when the post is already being published in this tab or another. usePublishItem from @seedprotocol/react wraps it for components.

By default a publish attests only the properties you've changed since the last one. To publish a new version instead, and for the other options, see Publishing and revoking items.

7. Show progress and the result

useItemPublishStatus(seedLocalId) follows the post's latest publish:

  • publishProcess is the running publish in this tab, or null when none is running.
  • publishValue is the step it's on, and 'success' or 'failure' once it's over.
  • latestRecord is the publish's saved row. Its errorMessage says why a publish failed.

When a publish succeeds, getSeedPublishState from the SDK reads the new Seed UID from the local database, and getPublishConfig().easChain.explorerUrl is the EAS explorer for the publish chain.

Here's the whole publish button:

src/PublishPostButton.tsx
import { useEffect, useState } from 'react'
import { useActiveAccount } from 'thirdweb/react'
import { getSeedPublishState } from '@seedprotocol/sdk'
import { useItem } from '@seedprotocol/react'
import { PublishManager, getPublishConfig, useItemPublishStatus } from '@seedprotocol/publish'
import type { Post } from './types'

const STEPS: Record<string, string> = {
  authorizing: 'Starting',
  checking: 'Checking the post and registering its EAS schemas',
  preparingHtmlEmbedded: 'Preparing the body',
  creatingArweaveDataItems: 'Signing the uploads',
  uploadingViaBundler: 'Uploading to Arweave',
  rewritingHtmlEmbedded: 'Pointing the body at its uploaded images',
  creatingArweaveDataItemsPhase2: 'Signing the body',
  uploadingViaBundlerPhase2: 'Uploading the body',
  creatingAttestations: 'Writing attestations',
}

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

export function PublishPostButton({ seedLocalId }: { seedLocalId: string }) {
  const { item } = useItem({ modelName: 'Post', seedLocalId })
  const post = item as Post | undefined
  const account = useActiveAccount()
  const { publishProcess, publishValue, latestRecord } = useItemPublishStatus(seedLocalId)
  const running = publishProcess != null
  const retryable = RETRYABLE.includes(publishValue ?? '')
  const failed = publishValue === 'failure' || retryable
  const [seedUid, setSeedUid] = useState<string | null>(null)
  const [notice, setNotice] = useState<string | null>(null)

  useEffect(() => {
    if (publishValue !== 'success') return
    getSeedPublishState({ seedLocalId }).then((state) => setSeedUid(state.seedAttestationUid))
  }, [publishValue, seedLocalId])

  async function handlePublish() {
    if (!post) return
    setNotice(null)
    try {
      await post.publish()
    } catch (error) {
      setNotice(error instanceof Error ? error.message : String(error))
    }
  }

  function handleRetry() {
    setNotice(null)
    PublishManager.retryAttestations(seedLocalId)
  }

  const explorerUrl = getPublishConfig().easChain.explorerUrl

  return (
    <section>
      <button onClick={handlePublish} disabled={!account || !post?.author || running}>
        {running ? 'Publishing…' : 'Publish'}
      </button>
      {!post?.author && <p>Add an author before publishing.</p>}
      {running && publishValue && STEPS[publishValue] && <p>{STEPS[publishValue]}…</p>}
      {failed ? (
        <p role="alert">Publishing failed: {latestRecord?.errorMessage ?? notice ?? 'unknown error'}</p>
      ) : (
        notice && <p role="alert">{notice}</p>
      )}
      {retryable && <button onClick={handleRetry}>Retry</button>}
      {publishValue === 'success' && seedUid && (
        <p>
          Published as <code>{seedUid}</code>.{' '}
          <a href={`${explorerUrl}/attestation/view/${seedUid}`} target="_blank" rel="noreferrer">
            View it on EAS Scan
          </a>
        </p>
      )}
    </section>
  )
}

A few details:

  • useActiveAccount() comes from thirdweb. It's undefined until the user connects, which disables the button.
  • The button shows progress from useItemPublishStatus, not from the publish() promise. The promise only supplies a message for failures that happen before a publish starts, such as a post that belongs to another account.
  • The steps in STEPS are the publish machine's states on this page's upload path, in order. authorizing calls an authorizePublish hook when you configure one, so your app can approve each publish before anything is spent; this page doesn't set one, so it passes straight through. Posts without a body skip straight from checking to the attestations. The three steps after uploadingViaBundler only run when the body has pasted-in images (data: URIs): the images upload first, then the body, rewritten to point at them. On a new user's first publish, creatingAttestations also deploys their ManagedAccount.
  • If writing the attestations fails, including a failed account deployment, publish() rejects but the publish keeps its uploads and waits. Retry calls PublishManager.retryAttestations(seedLocalId) to try the attestations again without uploading again. A publish that revokes a retracted property waits the same way when the revocation fails, and Retry sends it again. Any other failure ends the publish; click Publish to start over.
  • The button keys off publishProcess, not useItemPublishStatus's isActive. isActive is also true when a saved publish is marked in_progress but nothing is running it (see Troubleshooting), and the button would stay disabled.

Add the button and the author button to PostEditor in src/BlogApp.tsx:

src/BlogApp.tsx
function PostEditor({ seedLocalId }: { seedLocalId: string }) {
  // ...as before
  return (
    <main>
      {/* ...the title input and published checkbox */}
      <AddAuthorButton seedLocalId={seedLocalId} />
      <PublishPostButton seedLocalId={seedLocalId} />
    </main>
  )
}

useItems lists only local drafts by default, so a post disappears from the sidebar as soon as it's published. Pass includeEas: true in PostSidebar to keep published posts in the list:

const { items: posts, isLoading } = useItems({ modelName: 'Post', includeEas: true })

8. Run it

npm run dev
  1. Click Connect and sign in.
  2. Select a post and give it a title and an author.
  3. Click Publish and watch the steps.
  4. When it says Published, open the EAS Scan link. It goes to https://optimism-sepolia.easscan.org/attestation/view/<seedUid>, the post's Seed attestation on OP Sepolia. The attester is the user's ManagedAccount.

A user's first publish is slower: it deploys their ManagedAccount, and it registers any missing EAS schemas. Every model and property has an EAS schema, such as bytes32 post and string title. Schemas are shared by everyone on the chain, so they're often registered already. When one isn't, the checking step registers it and names it so EAS Scan can show it, which takes extra transactions.

A post with a body also goes through the upload steps. The publish moves on as soon as the upload API has accepted the data items; its bundler posts them to Arweave afterwards. Until then, gateways other than the one it went through may not serve the body yet.

Each step is saved to the publish_processes table in the local database as it goes. That's how useItemPublishStatus follows it, and how the app can pick up a publish that a reload interrupted.

Troubleshooting

  • The app shows nothing, and the console has "initPublish: uploadApiBaseUrl is required…". VITE_SEED_UPLOAD_API_URL isn't set. Check .env.local and restart the dev server, which reads env files only at startup.
  • "thirdwebClientId or thirdwebSecretKey is required". VITE_THIRDWEB_CLIENT_ID isn't set. Fix it the same way.
  • "Call initPublish() or ensure PublishProvider is mounted with config". initPublish hasn't run before render. Check that src/main.tsx imports ./publish and that PublishProvider comes from @seedprotocol/publish/thirdweb.
  • "Publishing is not configured. item.publish() needs @seedprotocol/publish…". publish() ran before initPublish. Import ./publish before anything that publishes.
  • The connect dialog fails or thirdweb returns 401. The client ID doesn't allow your origin. Add it in the thirdweb dashboard.
  • "Item is read-only: you do not own this item". The post's publisher is another account, such as one you were signed in as when you created it. Sign in with that account to publish it. See Ownership and drafts.
  • "Required relation author has no value". Give the post an author (step 5).
  • "Your publishing smart account is not deployed on OP Sepolia yet. Complete wallet setup, enable autoDeployManagedAccount…". autoDeployManagedAccount: true is missing. Add it and publish again.
  • "Automatic deployment of the managed publishing account failed". The deployment transaction didn't go through, usually because gas isn't sponsored (see Before you start). Fix that and click Retry.
  • The publish fails at uploadingViaBundler. "Batch upload failed" with a status code, or a timeout after 120 seconds, means the upload API couldn't be reached or answered with an error. "Arweave batch upload failed: 1 of 1 item(s) failed" means its bundler refused the data item; the upload API's operator decides which signers it accepts.
  • A publish stops partway after you switch accounts or disconnect. ConnectButton stops the publishes running in its tab when another wallet takes over or the user disconnects. The stopped publish stays saved as in_progress. Click Publish to start it over.

Where to go next

On this page