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
| Name | Type | Default | Description |
|---|---|---|---|
item | IItem<any> | required | The item to estimate. Not read when options.work is set |
options.publishMode | 'patch' | 'new_version' | 'patch' | Which publish to estimate |
options.work | PublishWorkSummary | Price 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:
| Field | Type | Description |
|---|---|---|
totalUsd | number | evm.usd + arweave.usd, rounded to cents, whoever pays each part |
evm.gas | bigint | Heuristic 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.eth | string | gas × gasPrice, six significant digits |
evm.usd | number | 0 on a testnet, else evm.eth × ethUsd |
evm.testnet | boolean | The publish chain is a testnet (viem's chain.testnet), so its ETH has no value |
evm.userPays | boolean | false when thirdweb (useModularExecutor with sponsorGas) or a paymaster ('eip7702' sender) is asked to sponsor; follows payment.gas |
arweave.bytes | number | Bytes the publish would upload |
arweave.chargedBytes | number | Bytes that are paid for, measured on the signed data items; see below |
arweave.uploadCount | number | Uploads the publish would send |
arweave.uploadTo | 'upload-api' | 'turbo' | Where the uploads go: 'turbo' with payment: { storage: 'user' } |
arweave.freeBytes | number | The largest data item Turbo stores for free, as read for this estimate; 0 for the upload API |
arweave.ar | string | chargedBytes times the per-byte price from the 1,000,000-byte quote, six significant digits |
arweave.usd | number | arweave.ar × arUsd |
arweave.path | 'bundler' | Always 'bundler': uploads are signed data items |
arweave.userPays | boolean | true only with payment: { storage: 'user' }; otherwise the upload API's operator pays |
prices | { ethUsd; arUsd; fetchedAt } | The prices used |
upload | UploadEstimate | Size, 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. |
work | PublishWorkSummary | { 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 |
estimatedAt | number | Milliseconds 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.userPaysistruealthough the ManagedAccount's UserOps ask thirdweb to sponsor. The payment resolution counts onlyuseModularExecutorand the'eip7702'sender as sponsored, and treats the default setup as a plain wallet. With the recommended config it's right.userPays: falsemeans 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
isStaleand re-estimates once the item has been idle foritemIdleMs(default 15,000;0re-estimates on every change). In the browser a change is any write to the item'sseeds,versionsormetadatarows, 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;0turns it off), re-prices the last estimate with freshly fetched prices throughrepricePublishCostEstimate, without walking the item again: gas, charged bytes, payers,uploadTo,freeBytes,testnetanduploadstay 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:
| Name | Type | Description |
|---|---|---|
estimate | (item: IItem<any>) => Promise<PublishCostEstimate> | Replaces the built-in estimate. |
reprice | (previous: PublishCostEstimate) => Promise<PublishCostEstimate> | Replaces the built-in re-price. |
previousEstimate | PublishCostEstimate | null | Shown 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 withUploadTooLargeError(code: 'UPLOAD_TOO_LARGE'), withuploadBytes,maxUploadBytesandestimate. - Over
confirmUploadAboveBytes(default 25 MiB), or whenmemory.riskis'high', it fails withUploadNeedsConfirmError(code: 'UPLOAD_NEEDS_CONFIRM'), withestimate, unless the publish hasconfirmUpload: 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:
| Field | Type | Description |
|---|---|---|
uploadCount, uploadBytes | number | From work |
largestUploadBytes | number | The largest of work.uploadSizes; without them, uploadBytes when there are uploads, else 0 |
uploadSeconds | number | Seconds to send uploadBytes at uplinkBitsPerSecond, rounded up |
uplinkBitsPerSecond | number | The speed used |
memory.peakBytes | number | 7 × uploadBytes |
memory.deviceMemoryBytes | number | undefined | The 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, confirmUploadAboveBytes | number | The limits applied |
tooLarge | boolean | uploadBytes is over maxUploadBytes |
requiresConfirm | boolean | uploadBytes 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)
}| Setting | Effect | initPublish 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). | |
userStorage | Turbo 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.flagsis{ evmUserPays?, arweaveUserPays?, arweavePath?, uploadTo?, freeBytes?, uploadTags?, testnet?, uploadLimits? };arweavePathis ignored,freeBytes(default 102,400) applies only withuploadTo: 'turbo',uploadTagsare the extra tags each data item is sized with, anduploadLimitsis theUploadLimitsforupload. 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,uploadand flags. It's the built-in re-price ofwatchPublishCost.heuristicPublishGas(work)andPUBLISH_GAS_HEURISTICSare the gas heuristic;work.revocationCountis optional.getCachedMarketRates(),getCachedTokenPrices(),resetMarketRateCache()andresetTokenPriceCache()read and reset the 60-second price caches.getCachedMarketRates({ forceRefresh: true })fetches even when the cache is fresh.
Related
- Costs and who pays: who pays in each wallet setup, what to fund, and Arweave prices
- Publishing and revoking items: estimates in the blog app
- Overview and initPublish:
sponsorGas,accountModeandpaymasterUrl - Bundler and paymaster and Uploads and ANS-104 data items
Components and hooks
The React components and hooks in @seedprotocol/publish: PublishProvider, ConnectButton, PublishModeButtons, useSeedWallet, useItemPublishStatus, the publish history hooks and helpers.
Revocation
How item.unpublish() revokes an item's attestations through @seedprotocol/publish: the checks it runs, which wallet sends the revokes, their order, errors, retrying, and what it leaves live.