Seed Protocol
@seedprotocol/publish

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.

A publish costs gas for its EAS attestations on the publish chain and Arweave storage for its Html, Image and File values. Storage is on Arweave mainnet and costs real AR even though attestations go to a testnet. The estimate APIs price both before a publish runs, and the payment option states who pays each one. Who pays in each wallet setup, and what each party has to fund, is on Costs and who pays.

import {
  estimatePublishCost,
  usePublishCostEstimate,
  watchPublishCost,
  estimateUpload,
  resolvePublishPayment,
} from '@seedprotocol/publish'

estimatePublishCost

Summarizes what a publish of an item would do and prices it in ETH, AR and USD.

function estimatePublishCost(
  item: IItem<any>,
  options?: EstimatePublishCostOptions,
): Promise<PublishCostEstimate>

It calls the SDK's summarizePublishWork(item), the same walk a publish does, then fetches the publish chain's gas price, the Arweave price of 1,000,000 bytes, and ETH-USD and AR-USD prices. The prices share a 60-second cache. The first fetch needs the network and a publish config (initPublish or PublishProvider), and the promise rejects if it fails; after that, a failed fetch reuses the last good prices. Pass both work and rates and it reads neither the item nor the prices. With payment.storage: 'user' it also reads Turbo's free size from Turbo's /v1/info, cached for 10 minutes per upload URL; when Turbo doesn't answer it uses 102,400 bytes and asks again next time.

Who pays comes from resolvePublishPayment, as in a publish. Before initPublish, which works only when you pass rates, it assumes the user pays gas and the upload API pays for storage. The chain is the config's chain, OP Sepolia by default.

Parameters

NameTypeDefaultDescription
itemIItem<any>requiredThe item to estimate. Not read when options.work is set
options.publishMode'patch' | 'new_version''patch'Which publish to estimate
options.workPublishWorkSummaryPrice this summary instead of walking the item
options.rates{ ethUsd; arUsd; fetchedAt; gasPriceWei: bigint; winstonPerByte }Use these prices instead of fetching them

Set getTokenPrices in the publish config to replace the default USD prices, which come from Coinbase's spot price API.

Returns

PublishCostEstimate:

FieldTypeDescription
totalUsdnumberevm.usd + arweave.usd, rounded to cents, whoever pays each part
evm.gasbigintHeuristic gas: 80,000 + 120,000 per new Seed + 80,000 per new Version + 55,000 per attestation + 35,000 per revocation (PUBLISH_GAS_HEURISTICS)
evm.ethstringgas × gasPrice, six significant digits
evm.usdnumber0 on a testnet, else evm.eth × ethUsd
evm.testnetbooleanThe publish chain is a testnet (viem's chain.testnet), so its ETH has no value
evm.userPaysbooleanfalse when thirdweb (useModularExecutor with sponsorGas) or a paymaster ('eip7702' sender) is asked to sponsor; follows payment.gas
arweave.bytesnumberBytes the publish would upload
arweave.chargedBytesnumberBytes that are paid for, measured on the signed data items; see below
arweave.uploadCountnumberUploads the publish would send
arweave.uploadTo'upload-api' | 'turbo'Where the uploads go: 'turbo' with payment: { storage: 'user' }
arweave.freeBytesnumberThe largest data item Turbo stores for free, as read for this estimate; 0 for the upload API
arweave.arstringchargedBytes times the per-byte price from the 1,000,000-byte quote, six significant digits
arweave.usdnumberarweave.ar × arUsd
arweave.path'bundler'Always 'bundler': uploads are signed data items
arweave.userPaysbooleantrue only with payment: { storage: 'user' }; otherwise the upload API's operator pays
prices{ ethUsd; arUsd; fetchedAt }The prices used
uploadUploadEstimateSize, time and memory of the uploads, and what the upload limits will do. It reads the limits in the publish config, not a per-publish maxUploadBytes.
workPublishWorkSummary{ publishMode, seedCount, newSeedCount, newVersionCount, attestationCount, uploadCount, uploadBytes, uploadSizes?, unpublishedRelatedItems? }. attestationCount includes the removals a publish attests for cleared properties. revocationCount is the attestations it would revoke for retracted properties; the SDK's summary always sets it, and a hand-built summary without it counts none
estimatedAtnumberMilliseconds since the epoch

arweave.chargedBytes follows how uploads are billed, on the signed data items: each upload's bytes plus its ANS-104 header and tags (Content-SHA-256, a Content-Type, then arweaveUploadTags). With no extra tags that's 304 bytes over the data. Through the upload API the data items share one bundle, charged for the larger of their total and 256 KiB. With payment.storage: 'user', data items of arweave.freeBytes or less are free on Turbo, and the rest are charged by size. With no uploads it's 0. When work.uploadSizes is missing, as in a hand-built summary, each upload counts as an equal share of uploadBytes.

work.unpublishedRelatedItems, when non-empty, means the publish would fail before sending anything (see Publishing and revoking items).

The estimate leaves some things out:

  • With ConnectButton's default setup, evm.userPays is true although the ManagedAccount's UserOps ask thirdweb to sponsor. The payment resolution counts only useModularExecutor and the 'eip7702' sender as sponsored, and treats the default setup as a plain wallet. With the recommended config it's right. userPays: false means sponsorship is requested; it can't tell whether the paymaster pays.
  • Gas is a heuristic times the L2 gas price. It isn't eth_estimateGas, and it leaves out OP's L1 data fee and schema registrations.

Example

import type { IItem } from '@seedprotocol/sdk'
import { estimatePublishCost } from '@seedprotocol/publish'

export async function describePublish(item: IItem<any>) {
  const { work, arweave } = await estimatePublishCost(item, { publishMode: 'patch' })
  return `${work.attestationCount} attestations, ${arweave.uploadCount} uploads (${arweave.chargedBytes} bytes charged)`
}

usePublishCostEstimate

Runs watchPublishCost for an item and returns its latest snapshot.

function usePublishCostEstimate(
  item: IItem<any> | null | undefined,
  options?: WatchPublishCostOptions,
): UsePublishCostEstimateResult

type UsePublishCostEstimateResult = {
  estimate: PublishCostEstimate | null
  isEstimating: boolean
  isStale: boolean
  error: Error | null
  refresh: () => void
}

It restarts the watch when the item, its seedLocalId, publishMode, priceRefreshMs or itemIdleMs change, and reads the other options only when it restarts. When it restarts for the same seedLocalId and publishMode, such as with a new Item instance after the old one unloaded, it passes its last estimate as previousEstimate, so estimate keeps that value, with isStale: true, until the new watch's first estimate arrives. Otherwise it passes your previousEstimate. With no item, estimate and error are null, both flags are false and refresh does nothing.

Example

import { useItem } from '@seedprotocol/react'
import { usePublishCostEstimate } from '@seedprotocol/publish'

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

  if (error) return <p>Couldn't estimate: {error.message}</p>
  if (!estimate) return <p>{isEstimating ? 'Estimating…' : null}</p>
  return (
    <p style={{ opacity: isStale ? 0.5 : 1 }}>
      {estimate.work.attestationCount} attestations, {estimate.arweave.uploadCount} uploads (
      {estimate.arweave.bytes} bytes)
    </p>
  )
}

watchPublishCost

Keeps an estimate up to date as an item changes.

function watchPublishCost(
  item: IItem<any>,
  onChange: (snapshot: {
    estimate: PublishCostEstimate | null
    isEstimating: boolean
    isStale: boolean
    error: Error | null
    refresh: () => void
  }) => void,
  options?: WatchPublishCostOptions,
): WatchPublishCostHandle // { dispose(): void; refresh(): void }

It estimates at once. If that first estimate fails, it's retried once after firstEstimateRetryMs (default 5,000; 0 turns the retry off), and the snapshot keeps isEstimating: true and error: null until the retry settles. A later failure sets error at once. Then:

  • when the item changes, marks the estimate isStale and re-estimates once the item has been idle for itemIdleMs (default 15,000; 0 re-estimates on every change). In the browser a change is any write to the item's seeds, versions or metadata rows, from this tab or another, including rows the loaded item hadn't read; the SDK's change feed reports them. In Node, which has no change feed, and in the browser until the row watch has started, a change is a difference in the loaded item's Seed UID, latest Version, or a property's UID or value;
  • every priceRefreshMs (default 60,000; 0 turns it off), re-prices the last estimate with freshly fetched prices through repricePublishCostEstimate, without walking the item again: gas, charged bytes, payers, uploadTo, freeBytes, testnet and upload stay as they were. It skips a tick while an estimate is running or before the first one.

refresh() cancels a pending idle wait and re-estimates now. A result from an estimate that a newer one replaced is dropped. A failed re-price sets error and keeps the last estimate; the next successful estimate clears it. Call dispose() when you're done.

WatchPublishCostOptions is EstimatePublishCostOptions plus priceRefreshMs, itemIdleMs, firstEstimateRetryMs, and these:

NameTypeDescription
estimate(item: IItem<any>) => Promise<PublishCostEstimate>Replaces the built-in estimate.
reprice(previous: PublishCostEstimate) => Promise<PublishCostEstimate>Replaces the built-in re-price.
previousEstimatePublishCostEstimate | nullShown as the estimate, with isStale: true, until the first estimate arrives. For a watch that replaces another one on the same item.
watchRows(item: IItem<any>, onChange: () => void) => Promise<(() => void) | null>Replaces the row watch. Call onChange after each write to the item's rows and resolve to the unwatch function, or resolve null to keep comparing loaded values instead.

Upload limits

A publish checks the summed size of its uploads in checking, from file sizes, before it registers a schema or reads a file. This applies on every route, on Node and with Turbo too.

  • Over maxUploadBytes (default 100 MiB) it fails with UploadTooLargeError (code: 'UPLOAD_TOO_LARGE'), with uploadBytes, maxUploadBytes and estimate.
  • Over confirmUploadAboveBytes (default 25 MiB), or when memory.risk is 'high', it fails with UploadNeedsConfirmError (code: 'UPLOAD_NEEDS_CONFIRM'), with estimate, unless the publish has confirmUpload: true. Exactly at the limit passes.

item.publish() rejects with these errors themselves. An unattended pipeline that uploads over 25 MiB passes confirmUpload: true or raises confirmUploadAboveBytes. confirmUpload doesn't lift maxUploadBytes.

Set maxUploadBytes, confirmUploadAboveBytes and uplinkBitsPerSecond in the publish config. maxUploadBytes and confirmUpload also go in the publish options. confirmUploadAboveBytes is config only.

A browser publish peaks at about 7 copies of its upload bytes in memory (PUBLISH_MEMORY_MULTIPLIER, measured in Chromium). memory.risk is 'high' when that peak is over a quarter of navigator.deviceMemory, so a low-memory device can need confirming below 25 MiB. navigator.deviceMemory exists only in Chromium, is rounded to a power of two and capped by the browser; in Safari, Firefox and Node risk is 'unknown' and only the size counts.

Infinity turns maxUploadBytes off. confirmUploadAboveBytes: Infinity turns off the size check only: a publish with memory.risk: 'high' still needs confirmUpload: true.

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 })
    }
  }
}

To ask before the user presses Publish, read upload from estimatePublishCost or usePublishCostEstimate.

estimateUpload

Computes the size, time and memory of a publish's uploads and applies the limits. estimatePublishCost calls it for upload, and checking calls it for the check. It reads no file and needs no publish config.

function estimateUpload(
  work: Pick<PublishWorkSummary, 'uploadCount' | 'uploadBytes' | 'uploadSizes'>,
  limits?: UploadLimits & { deviceMemoryBytes?: number },
): UploadEstimate

type UploadLimits = {
  maxUploadBytes?: number // default DEFAULT_MAX_UPLOAD_BYTES, 104,857,600
  confirmUploadAboveBytes?: number // default DEFAULT_CONFIRM_UPLOAD_ABOVE_BYTES, 26,214,400
  uplinkBitsPerSecond?: number // default DEFAULT_UPLINK_BITS_PER_SECOND, 5,000,000
}

limits.deviceMemoryBytes replaces navigator.deviceMemory; passing undefined gives risk: 'unknown'.

UploadEstimate:

FieldTypeDescription
uploadCount, uploadBytesnumberFrom work
largestUploadBytesnumberThe largest of work.uploadSizes; without them, uploadBytes when there are uploads, else 0
uploadSecondsnumberSeconds to send uploadBytes at uplinkBitsPerSecond, rounded up
uplinkBitsPerSecondnumberThe speed used
memory.peakBytesnumber7 × uploadBytes
memory.deviceMemoryBytesnumber | undefinedThe device memory used, in bytes
memory.risk'low' | 'high' | 'unknown''high' when peakBytes is over a quarter of device memory, 'unknown' without a figure
maxUploadBytes, confirmUploadAboveBytesnumberThe limits applied
tooLargebooleanuploadBytes is over maxUploadBytes
requiresConfirmbooleanuploadBytes is over confirmUploadAboveBytes, or memory.risk is 'high'

The payment option

PublishConfig.payment states who pays. Each field is optional; leave one out and the lower-level options decide, as Costs and who pays describes for each setup.

type PublishPaymentConfig = {
  gas?: 'sponsored' | 'user'
  storage?: 'operator' | 'user'
  userStorage?: PublishUserStorageConfig
}

type PublishUserStorageConfig = {
  provider?: 'turbo' // the only provider
  uploadUrl?: string // default DEFAULT_TURBO_UPLOAD_URL, https://upload.ardrive.io
  paymentUrl?: string // default DEFAULT_TURBO_PAYMENT_URL, https://payment.ardrive.io
  paidBy?: string[] // addresses whose shared Turbo credits pay first (x-paid-by)
}
SettingEffectinitPublish throws PublishPaymentConfigError when
gas: 'sponsored'Nothing changes; it states the intent.The sender is a plain wallet (no useModularExecutor, no 'eip7702' sender), or useModularExecutor with thirdweb.sponsorGas: false
gas: 'user'thirdweb.sponsorGas defaults to false; the 'eip7702' sender sends without a paymaster.useModularExecutor with thirdweb.sponsorGas: true, or the 'eip7702' sender with paymasterUrl set
storage: 'operator' (default)The upload API's bundler pays.
storage: 'user'Data items go to Turbo (User-paid storage through Turbo).
userStorageTurbo endpoints and paidBy.storage isn't 'user'

initPublish also throws PublishPaymentConfigError for useArweaveBundler: false, whatever payment says.

ConnectButton's default setup counts as a plain wallet here, so gas: 'sponsored' throws with it although its ManagedAccount asks thirdweb to sponsor. The recommended config sets useModularExecutor and doesn't have this problem.

resolvePublishPayment(config) returns what a config does, the same way a publish and estimatePublishCost work it out. It doesn't check the config; initPublish does.

type ResolvedPublishPayment = {
  sender: 'modular' | 'eip7702' | 'wallet'
  gas: { payer: 'sponsored' | 'user'; sponsor?: 'thirdweb' | 'paymaster' }
  storage: {
    payer: 'operator' | 'user'
    path: 'bundler' // always
    uploadTo: 'upload-api' | 'turbo'
    turbo?: { uploadUrl: string; paymentUrl: string; paidBy: string[] } // trailing slash removed
  }
}

resolvePublishSender(config) returns sender alone: 'modular' with useModularExecutor, 'eip7702' with that account mode and a bundlerUrl, else 'wallet'.

User-paid storage through Turbo

With payment: { storage: 'user' }, the publish signs data items as it does for the upload API and POSTs each one to Turbo, ArDrive's upload service, instead of the upload API. Turbo charges the signer's address in Turbo credits (winc, priced like AR winston), which users top up through Turbo with a card or crypto. Uploads at or under Turbo's free size limit cost nothing. Uploads and ANS-104 data items lists the requests.

The address that needs credits is the data item signer's: the dataItemSigner's address, or else the one recovered from the first signed data item. With the recommended config that's the user's in-app wallet, not the ManagedAccount. When the signer has no address and the data items aren't Ethereum-signed, the publish fails with "User-paid storage needs Ethereum-signed data items (dataItemSigner)."

Before uploading, the publish reads Turbo's free limit, prices the items over it, and checks the signer's balance. If the balance can't cover them, the publish fails at uploadingViaBundler with InsufficientStorageCreditsError before anything is uploaded; its address, requiredWinc and balanceWinc (both bigint) say by how much. If Turbo doesn't answer with a free limit, every item counts as paid. A 402 from Turbo during the upload raises the same error, with requiredWinc and balanceWinc undefined.

With userStorage.paidBy, Turbo charges those addresses' shared credits first, so an app wallet can pay for its users. The publish skips the balance check, because it can't see shared credits. authorizePublish tokens aren't sent to Turbo.

getTurboBalanceWinc(endpoints, address) and getTurboPriceWinc(endpoints, bytes) read a balance and a price in winc, for showing them before a publish. A balance lookup for an address Turbo has never seen returns 0n; any other answer than 200 throws "Turbo balance lookup failed: HTTP …" or "Turbo price lookup failed: HTTP …". getTurboPriceWinc prices the byte count you pass and doesn't apply the free limit.

import {
  getPublishConfig,
  getTurboBalanceWinc,
  getTurboPriceWinc,
  resolvePublishPayment,
} from '@seedprotocol/publish'

export async function canPayForStorage(signerAddress: string, bytes: number) {
  const { storage } = resolvePublishPayment(getPublishConfig())
  if (!storage.turbo) return true // the upload API pays
  const [balance, price] = await Promise.all([
    getTurboBalanceWinc(storage.turbo, signerAddress),
    getTurboPriceWinc(storage.turbo, bytes),
  ])
  return balance >= price
}

Lower-level exports

  • combinePublishCostEstimate(work, rates, flags?) prices a summary without fetching anything. flags is { evmUserPays?, arweaveUserPays?, arweavePath?, uploadTo?, freeBytes?, uploadTags?, testnet?, uploadLimits? }; arweavePath is ignored, freeBytes (default 102,400) applies only with uploadTo: 'turbo', uploadTags are the extra tags each data item is sized with, and uploadLimits is the UploadLimits for upload. Left out, the user pays gas, the operator pays storage, uploads go to the upload API and the chain isn't a testnet.
  • repricePublishCostEstimate(estimate, rates) prices an earlier estimate at new rates, keeping its gas, charged bytes, upload and flags. It's the built-in re-price of watchPublishCost.
  • heuristicPublishGas(work) and PUBLISH_GAS_HEURISTICS are the gas heuristic; work.revocationCount is optional.
  • getCachedMarketRates(), getCachedTokenPrices(), resetMarketRateCache() and resetTokenPriceCache() read and reset the 60-second price caches. getCachedMarketRates({ forceRefresh: true }) fetches even when the cache is fresh.

On this page