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'sThirdwebProvider. Use this one withConnectButton.@seedprotocol/publish: no thirdweb. Use it withuseSeedWalletand 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
| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | required | Your app. |
config | PublishConfig | the config initPublish registered | When 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. |
seedConfig | SeedConfig | The 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. | |
addresses | SeedConstructorOptions['addresses'] | Owned and watched addresses for that init. Read only with seedConfig. | |
onInitError | (err: unknown) => void | logs the error | Called when that init fails. |
queryClient | QueryClient | SeedProvider creates one | Your own TanStack Query client, passed to SeedProvider. |
queryClientRef | MutableRefObject<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:
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(): PublishConfigThrows "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: FCUsers 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:
- 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; - registers the connected account as the publish wallet with
setPublishWallet, with thekind,dataItemSigneranddeployin 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; - with
modularAccountModuleContractset, tries to install the Seed executor on the account. A failure is reported toonWalletSetupWarningand doesn't stop anything.
What it registers depends on useModularExecutor:
useModularExecutor: false (default) | useModularExecutor: true | |
|---|---|---|
| Connected wallet | The ManagedAccount (in-app wallet in EIP-4337 mode) | The in-app wallet (EIP-7702 or EOA) |
publisherAddress (the author) | The ManagedAccount | The ManagedAccount it administers |
| Owned addresses | The ManagedAccount | Both |
kind | 'smart' | 'modular' |
dataItemSigner | The in-app wallet (the ManagedAccount's admin) | The in-app wallet |
deploy | Deploys the ManagedAccount (deploySmartWalletContract) | None |
adminAddress | The in-app wallet | None |
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
| Field | Type | Description |
|---|---|---|
address | Address | undefined | The signer's address, which is also the publishing account. |
signer, txSender | SeedSigner, SeedTxSender | The 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 | () => void | Clears 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
| Field | Type | Description |
|---|---|---|
publishProcess | actor or null | The publish running in this tab, or null. Use this to decide whether a publish is really running. |
publishValue | string | undefined | The 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). |
latestRecord | PublishProcessRecord | undefined | The item's newest row, from any tab or earlier page load. Has status, errorMessage, errorStep and errorCode. |
isActive | boolean | true 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, arweaveL1Summary | Uploads still waiting for their Arweave L1 bundle (from useArweaveL1Finalize). |
PublishProcessRecord:
| Field | Type | Description |
|---|---|---|
id | number | Row ID |
seedLocalId, modelName, schemaId? | string | The 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? | number | Milliseconds |
errorMessage?, errorStep?, errorDetails? | string | Why and where it failed |
errorCode? | string | The publish package's error code, such as MANAGED_ACCOUNT_UNAVAILABLE |
persistedSnapshot | string | The 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
| Name | Type | Default | Description |
|---|---|---|---|
value | 'patch' | 'new_version' | required | The selected mode |
onChange | (mode: PublishMode) => void | required | Called with the clicked mode |
disabled | boolean | false | Disables both buttons |
aria-label | string | '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): booleanIt'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.
| Hook | Returns |
|---|---|
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.
| Function | Deletes |
|---|---|
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 aspublishValue.phase(PublishRunPhase) is one of'preparing'(authorizing,checking,preparingHtmlEmbedded),'uploading'(the Arweave steps, includingrewritingHtmlEmbedded),'attesting','retracting'(revokingRetractedProperties),'done'(success),'failed'(failure) or'stopped'(stopping).awaitingRetryistrueinattestationFailureRecoverable,attestationFailureRecoverableDirectToEasandretractionFailureRecoverable, which continue onPublishManager.retryAttestations.terminalistruefordone,failedandstopped.undefinedor an unknown value reads aspreparing, not terminal.getDisplayStepId(value)maps'creatingAttestationsDirectToEas'to'creatingAttestations', so both attestation routes can share one label.resolvePublishDisplayValueandgetPublishMachineValueForUiare the rulesusePublishProcessuses to pick a value from the running publish or the row. Use the hooks instead.
Not for apps
SeedProvideris re-exported from@seedprotocol/react;PublishProvideralready renders it.useActiveSmartWalletContract()(/thirdweb) returns a thirdweb contract for the active account's address, ornull.useLocalWalletAccount()(/thirdweb) is deprecated and always returnsnull. Use thirdweb'suseActiveAccount()oruseSeedWallet().
Related
- Overview and initPublish:
PublishManagerand the config - Wallets: the adapters, and who signs and sends
- Publishing your first item:
ConnectButtonanduseItemPublishStatusin an app - Publishing and revoking items: failures, retries and multiple tabs
- Keep writing while offline: a publish queue built on these hooks
- Ownership and drafts: what owned addresses and claiming mean
- Publishing and EAS sync: the React package's
usePublishItem, which callsitem.publish()and so publishes through this package
Wallets
The PublishWallet interface and its adapters for viem, wagmi, Privy, EIP-1193, ethers and thirdweb, which sender each config selects, and how to publish from a ManagedAccount without thirdweb.
Cost estimates and payment
estimatePublishCost, usePublishCostEstimate and watchPublishCost for pricing a publish before it runs, the upload limits, and the payment option that states who pays gas and storage, including user-paid storage through Turbo.