Seed Protocol
@seedprotocol/publish

Components and hooks

The React components and hooks in @seedprotocol/publish: PublishProvider, ConnectButton, PublishModeButtons, useSeedWallet, useItemPublishStatus, the publish history hooks and helpers.

The React side of @seedprotocol/publish: a provider, a thirdweb connect button, a publish-mode control, and hooks that follow publishes through the local publish_processes table. They all assume initPublish has run; see Overview and initPublish.

import {
  PublishProvider,
  PublishModeButtons,
  useSeedWallet,
  useItemPublishStatus,
  usePublishProcess,
  usePublishProcessesForSeed,
  useCanPublishItem,
} from '@seedprotocol/publish'
import { ConnectButton, PublishProvider as ThirdwebPublishProvider } from '@seedprotocol/publish/thirdweb'

PublishProvider

Wraps your app with SeedProvider from @seedprotocol/react and makes the publish config available to usePublishConfig. There are two:

  • @seedprotocol/publish/thirdweb: also wraps thirdweb's ThirdwebProvider. Use this one with ConnectButton.
  • @seedprotocol/publish: no thirdweb. Use it with useSeedWallet and the other wallets.

Either replaces SeedProvider in your tree; don't nest both. Both pass seedConfig, addresses and onInitError to SeedProvider as its config, addresses and onInitError. With seedConfig set, the provider starts the client, as SeedProvider with config does. Without it, nothing starts the client: keep SeedClientGate inside the provider, as in the example below.

const PublishProvider: FC<PublishProviderProps>

interface PublishProviderProps {
  children: React.ReactNode
  config?: PublishConfig
  seedConfig?: SeedConfig
  addresses?: SeedConstructorOptions['addresses']
  onInitError?: (err: unknown) => void
  queryClient?: QueryClient
  queryClientRef?: MutableRefObject<QueryClient | null>
}

Props

NameTypeDefaultDescription
childrenReactNoderequiredYour app.
configPublishConfigthe config initPublish registeredWhen set, the provider calls initPublish(config) during render, once per config object, so its children (ConnectButton included) can read the config on their first render. Pass a stable object: a new one calls initPublish again. Anything initPublish throws is thrown from the provider's render.
seedConfigSeedConfigThe Seed client config. When set, SeedProvider calls client.init({ config: seedConfig, addresses }) on mount, and again when the object changes, so define it outside the component.
addressesSeedConstructorOptions['addresses']Owned and watched addresses for that init. Read only with seedConfig.
onInitError(err: unknown) => voidlogs the errorCalled when that init fails.
queryClientQueryClientSeedProvider creates oneYour own TanStack Query client, passed to SeedProvider.
queryClientRefMutableRefObject<QueryClient | null>Receives the query client, for tests.

Example

This one calls initPublish at module level, in src/publish.ts (the recommended config), and renders the provider without config:

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' // calls initPublish
import { config } from './seed'
import { App } from './App'

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

usePublishConfig

Returns the raw PublishConfig from PublishProvider's context.

function usePublishConfig(): PublishConfig

Throws "usePublishConfig: PublishConfig is missing. Pass config to PublishProvider or call initPublish() before render." when there's no config: no config prop and no initPublish before the provider rendered. It doesn't apply defaults; use getPublishConfig() for resolved values such as easChain.

ConnectButton

thirdweb's connect button, set up for Seed. From @seedprotocol/publish/thirdweb; render it inside the thirdweb PublishProvider. It takes no props.

const ConnectButton: FC

Users sign in with email, phone, a passkey or Farcaster, which gives them a thirdweb in-app wallet and a ManagedAccount smart account (see Identity and signing). The button connects to the publish chain only and reconnects automatically on page load, unless the user chose Disconnect.

When a wallet connects (including the automatic reconnect), it waits for the SDK client to be ready and then:

  1. if another wallet's addresses were synced earlier in this page load, stops every publish running in this tab (PublishManager.stopAll()). The first sync after a page load and a re-sync of the same wallet stop nothing, so publishes resumed from the local database keep running;
  2. registers the connected account as the publish wallet with setPublishWallet, with the kind, dataItemSigner and deploy in the table below. That sets the SDK's owned addresses to the account's addresses, replacing the owned list (watched addresses are kept), and claims unclaimed drafts for the publishing account;
  3. with modularAccountModuleContract set, tries to install the Seed executor on the account. A failure is reported to onWalletSetupWarning and doesn't stop anything.

What it registers depends on useModularExecutor:

useModularExecutor: false (default)useModularExecutor: true
Connected walletThe ManagedAccount (in-app wallet in EIP-4337 mode)The in-app wallet (EIP-7702 or EOA)
publisherAddress (the author)The ManagedAccountThe ManagedAccount it administers
Owned addressesThe ManagedAccountBoth
kind'smart''modular'
dataItemSignerThe in-app wallet (the ManagedAccount's admin)The in-app wallet
deployDeploys the ManagedAccount (deploySmartWalletContract)None
adminAddressThe in-app walletNone

So without useModularExecutor, a new user's first publish deploys their ManagedAccount in checking; with it, autoDeployManagedAccount does, in creatingAttestations. See Attestation routing in Overview and initPublish.

Switching to another account stops this tab's running publishes. Their rows stay in_progress, and a pending item.publish() or usePublishItem call for one of them rejects with "Publish of <id> was stopped before it finished." When the user disconnects, it calls disconnectPublishWallet(), which removes the in-app wallet's sign-in token and clears the publish wallet and the SDK's owned addresses (watched addresses are kept), and it stops every running publish the same way, even if that call fails, which it logs. A reload then doesn't sign the user back in; connecting again does. New drafts are then signed-out drafts that the next connected wallet claims; drafts stamped with the old wallet are read-only until it reconnects.

The publish_processes row of a stopped publish keeps status: 'in_progress', so useItemPublishStatus reports isActive: true with publishProcess: null for it, until the next page load resumes it or the user starts it again.

Example

import { ConnectButton } from '@seedprotocol/publish/thirdweb'

export function WalletBar() {
  return (
    <header>
      <ConnectButton />
    </header>
  )
}

Read the connected account with thirdweb's useActiveAccount(), and the registered publish wallet with getPublishWallet().

Signing out

From @seedprotocol/publish/thirdweb. ConnectButton calls the first two itself; call them when your app has its own disconnect button.

function disconnectPublishWallet(): Promise<void>
function markPublishWalletConnected(): void
function clearInAppPublishSession(): Promise<void>

disconnectPublishWallet() records for the rest of the tab session that the user chose Disconnect, so ConnectButton doesn't reconnect on a reload, removes the in-app wallet's sign-in token (walletToken-<thirdwebClientId> and its seedProtocol:inAppPublish: copy) from localStorage, and clears the publish wallet. It works before initPublish too, but without a thirdwebClientId it can't name the token, so it leaves it. It doesn't stop running publishes; call PublishManager.stopAll() after it, as ConnectButton does.

markPublishWalletConnected() clears the Disconnect choice. Call it when the user connects again; until then the in-app session isn't restored on a reload.

clearInAppPublishSession() removes every localStorage key under seedProtocol:inAppPublish:, the walletToken-<thirdwebClientId> key when publish config has a thirdwebClientId, and the Disconnect choice, then clears the publish wallet. The user has to sign in again. It works without publish config, but then leaves walletToken-<thirdwebClientId>. It leaves thirdweb's other localStorage keys, and a wallet still connected in thirdweb's React state; disconnect that wallet too, or reload. Use it in a "delete local data" action beside the SDK's client.resetLocalData(), which doesn't touch localStorage.

Example

import { useActiveWallet, useDisconnect } from 'thirdweb/react'
import { PublishManager } from '@seedprotocol/publish'
import { disconnectPublishWallet } from '@seedprotocol/publish/thirdweb'

export function SignOutButton() {
  const wallet = useActiveWallet()
  const { disconnect } = useDisconnect()

  async function signOut() {
    if (wallet) disconnect(wallet)
    await disconnectPublishWallet()
    PublishManager.stopAll()
  }

  return <button onClick={signOut}>Sign out</button>
}

useSeedWallet

A headless wallet session without thirdweb, for MetaMask and other EIP-1193 wallets, and for any adapter, such as wagmi's or Privy's.

function useSeedWallet(): UseSeedWalletResult

type UseSeedWalletResult = {
  address: Address | undefined
  signer: SeedSigner | undefined
  txSender: SeedTxSender | undefined
  status: 'disconnected' | 'connecting' | 'connected'
  connect: (input: EIP1193Provider | PublishWallet) => Promise<PublishWallet>
  disconnect: () => void
}

Returns

FieldTypeDescription
addressAddress | undefinedThe signer's address, which is also the publishing account.
signer, txSenderSeedSigner, SeedTxSenderThe session's parts.
status'disconnected' | 'connecting' | 'connected'Starts as 'connected' when a publish wallet is already registered.
connect(input) => Promise<PublishWallet>Takes an EIP-1193 provider, which it wraps with fromEip1193Provider (requesting accounts), or a ready PublishWallet. With accountMode: 'eip7702' and bundlerUrl, it swaps the sender for a permissionless EIP-7702 sender. Then calls setPublishWallet with the signer's address as publisherAddress, which sets the SDK's owned addresses and claims drafts. So to publish as a ManagedAccount, call setPublishWallet yourself (ManagedAccount without thirdweb). It keeps the wallet's kind, so a wallet from fromEip1193Provider or fromEthersWallet is registered as 'eoa' and can attest straight to EAS without useDirectEas. Rejects, and resets status, if any step fails.
disconnect() => voidClears the publish wallet and the SDK's owned addresses (watched addresses are kept), without waiting for it. Doesn't stop running publishes.

Example

import type { EIP1193Provider } from 'viem'
import { useSeedWallet } from '@seedprotocol/publish'

export function ConnectMetaMask() {
  const { status, address, connect, disconnect } = useSeedWallet()
  const ethereum = (window as { ethereum?: EIP1193Provider }).ethereum

  if (status === 'connected') {
    return <button onClick={disconnect}>Disconnect {address}</button>
  }
  return (
    <button disabled={!ethereum || status === 'connecting'} onClick={() => ethereum && connect(ethereum)}>
      Connect wallet
    </button>
  )
}

In 'eoa' mode the user pays gas from that wallet. See Costs and who pays.

connect also takes a ready PublishWallet, which is how wagmi and Privy wallets connect. fromViemWalletClient needs a client with an account, and doesn't switch chains. fromPrivyWallet switches the wallet to the publish chain first.

import { useEffect } from 'react'
import { useWalletClient } from 'wagmi'
import { fromViemWalletClient, useSeedWallet } from '@seedprotocol/publish'

export function ConnectWagmiWallet() {
  const { connect } = useSeedWallet()
  const { data: walletClient } = useWalletClient()

  useEffect(() => {
    if (walletClient) void connect(fromViemWalletClient(walletClient))
  }, [walletClient, connect])

  return null
}
import { useWallets } from '@privy-io/react-auth'
import { fromPrivyWallet, useSeedWallet } from '@seedprotocol/publish'

export function ConnectPrivyWallet() {
  const { connect } = useSeedWallet()
  const { wallets } = useWallets()

  return (
    <button
      disabled={wallets.length === 0}
      onClick={async () => connect(await fromPrivyWallet(wallets[0]))}
    >
      Connect wallet
    </button>
  )
}

useItemPublishStatus

Follows an item's latest publish: the run in this tab, if any, and its newest publish_processes row.

function useItemPublishStatus(seedLocalId: string | undefined): {
  latestRecord: PublishProcessRecord | undefined
  publishProcess: ActorRef<any, any> | null
  isActive: boolean
  publishValue: string | undefined
  arweaveL1Pending: boolean
  arweaveL1Jobs: ArweaveL1FinalizeJobRow[]
  arweaveL1Summary: { hasPendingL1: boolean; pendingCount: number; confirmedCount: number; jobs: ArweaveL1FinalizeJobRow[] }
}

Simplified: publishProcess is an XState actor. The return type isn't exported.

Returns

FieldTypeDescription
publishProcessactor or nullThe publish running in this tab, or null. Use this to decide whether a publish is really running.
publishValuestring | undefinedThe running publish's step (for example 'authorizing', 'checking', 'uploadingViaBundler', 'creatingAttestations', 'attestationFailureRecoverable'). Once no publish is running: 'success' or 'failure' from the newest row, and undefined otherwise (an interrupted row, or an in_progress row nothing is running).
latestRecordPublishProcessRecord | undefinedThe item's newest row, from any tab or earlier page load. Has status, errorMessage, errorStep and errorCode.
isActivebooleantrue when publishProcess is set or the newest row is in_progress, and publishValue isn't 'success', 'failure' or 'stopping'. A publish waiting for a retry stays active. A stalled row makes it true with nothing running.
arweaveL1Pending, arweaveL1Jobs, arweaveL1SummaryUploads still waiting for their Arweave L1 bundle (from useArweaveL1Finalize).

PublishProcessRecord:

FieldTypeDescription
idnumberRow ID
seedLocalId, modelName, schemaId?stringThe item
status'in_progress' | 'completed' | 'failed' | 'interrupted'interrupted comes from stopPublish, or from a reload that finds a publish it can't resume
startedAt, completedAt?, createdAt?, updatedAt?numberMilliseconds
errorMessage?, errorStep?, errorDetails?stringWhy and where it failed
errorCode?stringThe publish package's error code, such as MANAGED_ACCOUNT_UNAVAILABLE
persistedSnapshotstringThe machine snapshot as JSON. It never includes dataItemSigner.

Example

import { PublishManager, useItemPublishStatus } from '@seedprotocol/publish'

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

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

  if (publishValue && RECOVERABLE.includes(publishValue)) {
    return (
      <button onClick={() => PublishManager.retryAttestations(seedLocalId)}>
        Retry ({latestRecord?.errorMessage})
      </button>
    )
  }
  if (publishProcess) return <p>Publishing: {publishValue}</p>
  if (publishValue === 'success') return <p>Published</p>
  if (publishValue === 'failure') return <p role="alert">Failed: {latestRecord?.errorMessage}</p>
  return null
}

usePublishProcess

The lower-level hook behind useItemPublishStatus: the running publish and its display value, without the row.

function usePublishProcess(seedLocalId: string): {
  publishProcess: ActorRef<any, any> | null
  value: string | undefined
}

value follows the same rules as publishValue above. Once a publish ends it keeps 'success' or 'failure' until a new publish starts.

PublishModeButtons

A two-button control for choosing 'patch' ("Publish updates") or 'new_version' ("New version"). It only reports the choice; pass it to createPublish yourself. It has fixed inline styles.

const PublishModeButtons: FC<PublishModeButtonsProps>

Props

NameTypeDefaultDescription
value'patch' | 'new_version'requiredThe selected mode
onChange(mode: PublishMode) => voidrequiredCalled with the clicked mode
disabledbooleanfalseDisables both buttons
aria-labelstring'Publish mode'Label for the button group

'new_version' fails validation on an item that has never been published, so show the control only once the item has a seedUid.

Example

import { useState } from 'react'
import { PublishModeButtons, type PublishMode } from '@seedprotocol/publish'

export function ModePicker({ onPick }: { onPick: (mode: PublishMode) => void }) {
  const [mode, setMode] = useState<PublishMode>('patch')
  return (
    <PublishModeButtons
      value={mode}
      onChange={(next) => {
        setMode(next)
        onPick(next)
      }}
    />
  )
}

useCanPublishItem

Whether the user owns an item, per the SDK's isItemOwned.

function useCanPublishItem(item: Item<any> | null | undefined): boolean

It's false until its check finishes, for a missing item, and when the check throws, including before the SDK client is ready. It checks again, starting from false, when item.seedLocalId changes and when the owned addresses change (a wallet connects or disconnects, or setAddresses runs; it follows useSeedAddressRevision). A check overtaken by a newer one is ignored. It takes the Item class; cast IItem from useItem.

Publish history hooks

Live queries over the publish_processes table, newest run first. Each returns undefined until the query has run, and while the SDK's database isn't ready.

HookReturns
usePublishProcesses()PublishProcessRecord[] | undefined: every run
usePublishProcessesForSeed(seedLocalId)Runs for one item. undefined for seedLocalId: undefined
usePublishProcessesState(){ records, nonActiveCount }: every run, and how many aren't in_progress, from one subscription
usePublishProcessesStateForSeed(seedLocalId)The same for one item
usePublishProcessesNonActiveCount(), usePublishProcessesNonActiveCountForSeed(seedLocalId)Just the count
usePublishProcessById(id){ record: PublishProcessRecord | null; isLoading: boolean }

Clearing history

Plain async functions, not hooks. Each does nothing when the database isn't ready.

FunctionDeletes
clearCompletedPublishProcesses()Every row that isn't in_progress
clearCompletedPublishProcessesForSeed(seedLocalId)The same for one item
deletePublishProcessById(id), deletePublishProcessesByIds(ids)Specific rows
deletePublishProcessesForSeed(seedLocalId)Every row for the item, in_progress ones included
clearAllPublishProcesses()Every row
clearAllUploadProcesses()Every row of the separate upload_processes table

Deleting a row also deletes any arweaveJwk an old row holds (see CreatePublishOptions for which rows). Deleting an in_progress row doesn't stop a publish running in this tab.

getArweaveTransactionIds(record) and getEasPayload(record) read the Arweave transaction IDs and the EAS payload out of a row's persistedSnapshot. They return [] and undefined when there's nothing to read.

useArweaveL1Finalize

Live rows for an item's uploads that are waiting to land in an Arweave L1 bundle. The worker initPublish starts updates them.

function useArweaveL1Finalize(seedLocalId: string | undefined): {
  hasPendingL1: boolean
  pendingCount: number
  confirmedCount: number
  jobs: ArweaveL1FinalizeJobRow[]
}

usePublishCostEstimate

A live publish cost estimate for an item. See Cost estimates and payment.

Display helpers

  • describePublishRun(value) returns { phase, awaitingRetry, terminal } (PublishRunDescription) for a step such as publishValue. phase (PublishRunPhase) is one of 'preparing' (authorizing, checking, preparingHtmlEmbedded), 'uploading' (the Arweave steps, including rewritingHtmlEmbedded), 'attesting', 'retracting' (revokingRetractedProperties), 'done' (success), 'failed' (failure) or 'stopped' (stopping). awaitingRetry is true in attestationFailureRecoverable, attestationFailureRecoverableDirectToEas and retractionFailureRecoverable, which continue on PublishManager.retryAttestations. terminal is true for done, failed and stopped. undefined or an unknown value reads as preparing, not terminal.
  • getDisplayStepId(value) maps 'creatingAttestationsDirectToEas' to 'creatingAttestations', so both attestation routes can share one label.
  • resolvePublishDisplayValue and getPublishMachineValueForUi are the rules usePublishProcess uses to pick a value from the running publish or the row. Use the hooks instead.

Not for apps

  • SeedProvider is re-exported from @seedprotocol/react; PublishProvider already renders it.
  • useActiveSmartWalletContract() (/thirdweb) returns a thirdweb contract for the active account's address, or null.
  • useLocalWalletAccount() (/thirdweb) is deprecated and always returns null. Use thirdweb's useActiveAccount() or useSeedWallet().

On this page