Archive anchors
Write archive anchors with @seedprotocol/publish: anchorArchive uploads a reproducible bag of a published item and attests it on the Version, estimateArchiveAnchorCost prices that, and ensureArchiveAnchorSchema registers the anchor schema on a new chain.
An archive anchor is an attestation on a Version that points at a reproducible bag of the item on Arweave: the bag's path manifest and the SHA-256 of its tagmanifest-sha256.txt. It puts the bag's hash on chain, so a reader can check a bag without rebuilding it. Anchoring is separate from publishing: a publish never waits for it, and a Version can go without an anchor. Bags, path manifests, and reading and verifying anchors are on @seedprotocol/archive.
import {
anchorArchive,
estimateArchiveAnchorCost,
ensureArchiveAnchorSchema,
encodeArchiveAnchorData,
ArchiveAnchorInterruptedError,
ArchiveAnchorSchemaNotRegisteredError,
} from '@seedprotocol/publish'anchorArchive and ensureArchiveAnchorSchema need initPublish to have run. anchorArchive and estimateArchiveAnchorCost build the bag from EAS and Arweave, so they also need the SDK client initialized with the item's schema loaded. In Node, load @seedprotocol/publish with the loader hook.
anchorArchive
Builds a reproducible bag of a published item, uploads what Arweave doesn't hold yet, and attests the anchor on the Version.
function anchorArchive(
item: ExportableItem | ArchiveSeedRef,
options?: AnchorArchiveOptions,
): Promise<AnchorArchiveResult>item is an Item or { seedUid, modelName, schemaName? }, which works on a device that holds no rows for the seed. In order, anchorArchive:
- Builds the bag with
exportArchive(item, { reproducible: true, asOf, expectedAttestations }). The attester is the Version's, from the bag. - Looks on EAS for an anchor from that attester on the Version with the same
covered_untiland tagmanifest hash. If there is one, it returns it withalreadyAnchored: trueand spends nothing. - Checks that the anchor schema is registered, and throws
ArchiveAnchorSchemaNotRegisteredErrorif it isn't. - Uploads the bag's files that no published transaction holds, then the path manifest, as signed data items. They go where a publish's uploads go: the upload API, or Turbo with
payment: { storage: 'user' }. After each upload it checks that the upload API (or Turbo) reports the transactions, and throws if one isn't available yet. - Attests the anchor (
refUIDis the Version, no recipient, no expiration, revocable) by the route a publish from the Version's attester takes: straight to EAS for an EOA, with the same refusals as a publish, otherwise amultiPublishthat makes only the anchor attestation, through the executor module for a session key. A session key needs no permission beyond what publishing gives it.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
item | ExportableItem | ArchiveSeedRef | required | The published item, or its Seed UID, model name and schema name |
options.asOf | number | the latest state | The time the bag covers, in whole Unix seconds. It becomes the anchor's covered_until. See @seedprotocol/archive for what the bag holds at that time |
options.expectedAttestations | readonly string[] | Attestation UIDs EAS has to report before the bag is built, such as the attestationUids a publish passes to onPublished. Pass them when you anchor soon after a publish; otherwise a bag built while the indexer is part way through is valid and incomplete | |
options.tagPolicy | 'discoverable' | 'hash-only' | 'discoverable' | Tags on the bag's uploads. 'discoverable' adds Archive-Part (index, asset, tagfile or manifest), Seed-Version-Uid and the config's arweaveUploadTags to Content-SHA-256 and Content-Type. 'hash-only' keeps those two. Tags on Arweave can't be removed later. The policy covers the bag's uploads only, not a publish's |
options.wallet | PublishWallet | the publish wallet | Sends the attestation. It has to attest as the Version's attester |
options.dataItemSigner | ethers.Wallet | SeedSigner | config.dataItemSigner | Signs the uploads. See the callout below |
options.signDataItems | (uploads: PublishUploadData[]) => Promise<ArweaveDataItemInfoResult[]> | config.signDataItems | Signs and uploads the data items yourself; used before dataItemSigner when both are set |
options.publishAuthorization | PublishAuthorization | { token } sent to the upload API with the uploads. anchorArchive doesn't run authorizePublish, so pass a token here if your upload API needs one | |
options.resumeFrom | ArchiveAnchorResume | error.resume from an ArchiveAnchorInterruptedError, so a retry doesn't pay for those uploads again |
dataItemSigner falls back to the config's only. A publish also falls back to the publish wallet's data item signer, which ConnectButton registers; anchorArchive doesn't. In a browser with ConnectButton and no dataItemSigner in the config, pass dataItemSigner: getPublishWallet()?.dataItemSigner, or the uploads fail with "@seedprotocol/publish: uploading data items needs signDataItems or a dataItemSigner …".
Returns
AnchorArchiveResult:
| Field | Type | Description |
|---|---|---|
uid | string | The anchor attestation's UID |
coveredUntil | number | The bag's time, Unix seconds |
manifestTx | string | The path manifest's Arweave transaction ID |
tagmanifestSha256 | string | SHA-256 of the bag's tagmanifest-sha256.txt, 64 hex characters without 0x |
alreadyAnchored | boolean | true when an anchor for this bag already existed, so nothing was uploaded or sent |
Errors
ArchiveExportErrorfrom@seedprotocol/archive, before anything is uploaded.not_syncedmeans EAS doesn't report the seed, its Version or everything inexpectedAttestationsyet; retry once the indexer catches up.incompletemeans a gateway didn't serve a file or the bag can't be built at all, such as for a Json storage seed, so cap those retries.chain_unavailablemeans the EAS indexer couldn't be read, andinvalid_optionsanasOfthat isn't whole seconds or a model that isn't loaded.ArchiveAnchorSchemaNotRegisteredError(code: 'ARCHIVE_ANCHOR_SCHEMA_NOT_REGISTERED'), before anything is uploaded: "The archive anchor schema is not registered on this chain. Call ensureArchiveAnchorSchema with the owner wallet." See ensureArchiveAnchorSchema.ArchiveAnchorInterruptedError(code: 'ARCHIVE_ANCHOR_INTERRUPTED') for any failure after an upload, including the attestation's:anchorArchive stopped after uploading: <cause>, with the original error ascause. Itsresumeis{ tagmanifestSha256, transactions, manifestTx? }, wheretransactionsmaps bag paths to transaction IDs. It's plain data, so a job can store it. A failure before any upload rethrows the original error.This wallet attests as <address>; the Version's attester is <address>, and readers ignore anyone else's anchorwhen the wallet on themultiPublishroute sends as another account.Transaction <hash> made no anchor attestationwhen the receipt has none from the configured EAS contract.
A retry with resumeFrom reuses each upload in resume that belongs to the same bag and that the upload API (or Turbo) reports, and uploads the rest. A resume from another bag is ignored. When it uploads any file again, it uploads a new path manifest too. Uploads from a batch that failed part way aren't in resume, because which of its items landed isn't known, so those are paid for again.
The anchor send isn't journaled the way a publish's attestations are: it has no attempt row and no pinned nonce. A repeat is safe because step 2 finds the existing anchor on EAS, but a repeat before the indexer reports a landed anchor, such as after a lost receipt, attests a second, identical one. Readers treat the two as the same anchor.
Example
A server job that anchors a post's bag, keeps resume between tries, and retries later while the indexer catches up:
import { ArchiveExportError } from '@seedprotocol/archive'
import {
anchorArchive,
ArchiveAnchorInterruptedError,
type ArchiveAnchorResume,
type PublishWallet,
} from '@seedprotocol/publish'
type AnchorJob = { seedUid: string; asOf: number; publishedUids: string[]; resume?: ArchiveAnchorResume }
export async function runAnchorJob(job: AnchorJob, wallet: PublishWallet) {
try {
const { uid, alreadyAnchored } = await anchorArchive(
{ seedUid: job.seedUid, modelName: 'Post', schemaName: 'blog' },
{ asOf: job.asOf, expectedAttestations: job.publishedUids, wallet, resumeFrom: job.resume },
)
return { status: 'done', uid, alreadyAnchored } as const
} catch (error) {
if (error instanceof ArchiveAnchorInterruptedError) {
return { status: 'retry', job: { ...job, resume: error.resume } } as const
}
if (error instanceof ArchiveExportError && error.code === 'not_synced') {
return { status: 'retry', job } as const
}
throw error
}
}wallet attests as the Version's attester: the publisher's own key, or a session key on its ManagedAccount from publish automation. The job's uploads are signed by dataItemSigner in the publish config.
estimateArchiveAnchorCost
Prices an anchor before you run it: the bag's files that need an upload, the path manifest, and one attestation.
function estimateArchiveAnchorCost(
item: ExportableItem | ArchiveSeedRef,
options?: ArchivePackageOptions & Pick<EstimatePublishCostOptions, 'rates'>,
): Promise<PublishCostEstimate>It builds the same reproducible bag anchorArchive would, so the item has to be published and on EAS, and it throws the same ArchiveExportErrors. It sizes the path manifest with placeholder transaction IDs, which are the same length as real ones. Then it prices the result with estimatePublishCost, which fetches prices unless you pass rates and works out who pays from the publish config. options takes asOf and expectedAttestations as anchorArchive does; it accepts tagPolicy and ignores it. With rates it needs no publish config.
In the result, work has attestationCount: 1 and no Seeds or Versions, so evm.gas is the heuristic's 80,000 plus 55,000. arweave and upload cover the bag's files and the manifest. It doesn't look for an existing anchor, so it prices a bag that's already anchored as if it weren't.
The estimate counts every upload as one bundle. anchorArchive sends the bag's files and the path manifest as two batches, so through the upload API, which charges each bundle for at least 256 KiB, the estimate can be low.
ensureArchiveAnchorSchema
Registers the anchor schema when the chain doesn't have it, with no resolver and revocable, then names it seedprotocol.archiveAnchor with a second transaction, waiting for each receipt. It does nothing when the schema is registered. It returns the schema UID.
function ensureArchiveAnchorSchema(account: PublishWallet | SeedTxSender): Promise<`0x${string}`>On OP Sepolia the schema is registered and named, so you don't need to call it there; Base schemas has the status. On another chain, call it once with a wallet that sends its own transactions, such as an EOA's. A session key can't: its permissions cover only the Seed executor module. seed:ensure-schemas in the contracts repo registers it too. Anything other than a PublishWallet or SeedTxSender throws @seedprotocol/publish: ensureArchiveAnchorSchema requires a PublishWallet or SeedTxSender.
The anchor schema
uint64 covered_until,bytes32 manifest_tx,bytes32 tagmanifest_sha256covered_until is the bag's time in Unix seconds, manifest_tx is the path manifest's Arweave transaction ID as 32 bytes, and tagmanifest_sha256 is the SHA-256 of tagmanifest-sha256.txt. The schema has no resolver and is revocable, so its UID, ARCHIVE_ANCHOR_SCHEMA_UID (0xf7f6e2f226f29542fa8bd1763d042d04f47a0a9c3647753f68cc113e26f1739e), is the same on every chain. A reader counts an anchor only from the Version's attester, since anyone can attest with any refUID. An anchor isn't a property: property reads and sync leave it out, and it changes no bag. Its definition can't change without a new UID.
encodeArchiveAnchorData({ coveredUntil, manifestTx, tagmanifestSha256 }) ABI-encodes an anchor's fields, for code that sends the attestation itself. manifestTx is a 43-character Arweave transaction ID and tagmanifestSha256 is 64 hex characters without 0x.
import { encodeArchiveAnchorData } from '@seedprotocol/publish'
const data = encodeArchiveAnchorData({
coveredUntil: 1793799143,
manifestTx: 'Ue_olZbCci9kAv-ur0_U69EBpkqq5vX9E96AkRQLYvw',
tagmanifestSha256: '895c97d7458ab6dc7190f32676df1b80ab618b43b3c2a7291f747e8b882759b9',
})Constants, re-exported from @seedprotocol/eas: ARCHIVE_ANCHOR_SCHEMA_DEF, ARCHIVE_ANCHOR_SCHEMA_NAME and ARCHIVE_ANCHOR_SCHEMA_UID. Types: AnchorArchiveOptions, AnchorArchiveResult, ArchivePackageOptions (asOf, tagPolicy, expectedAttestations), ArchiveTagPolicy and ArchiveAnchorResume.
Related
@seedprotocol/archive: reproducible bags, path manifests, and reading and verifying anchors@seedprotocol/eas: reading anchors from EAS- Cost estimates and payment:
PublishCostEstimateand who pays for uploads - Base schemas: the anchor schema's registration status
Tool attestations
Write and revoke PublishedBy and PublishAuthorization attestations with @seedprotocol/publish: attestPublishedBy and the onPublished batch, attestPublishAuthorization, assessPublishAuthorizationLive, the schema helpers, and the revoke functions for every tool attestation.
@seedprotocol/archive
Reference for @seedprotocol/archive: export a published item as a BagIt bag, build reproducible bags, verify a bag offline or against its archive anchor on EAS, and lay a bag out on Arweave with a path manifest.