Cross-references (publishIndex)
How one multiPublish batch writes a Seed UID it creates into a property or list element of any request in the batch, the rules the contracts enforce, how the SDK builds a batch, and how direct EAS differs.
A multiPublish batch can create several Seeds at once: a post and the new author it points to, say. The post's author property has to hold the author's Seed UID, but that UID doesn't exist until the author's Seed attestation is made, partway through the same transaction. A cross-reference solves this. It tells the contract to take the Seed UID it created for one request and write it into a property attestation of any request in the batch, before any property is attested.
The cross-reference field
Each request in multiPublish(PublishRequestData[] requests) carries a propertiesToUpdate array:
struct PropertyToUpdateWithSeed {
uint256 publishIndex; // position in `requests` of the request to write into
bytes32 propertySchemaUid; // schema of the property attestation to fill
bool listElement; // false: a single bytes32. true: one element of a bytes32[]
uint256 elementIndex; // the element to fill; must be 0 for a single relation
}The entry sits on the request whose Seed is being referenced (the author), and publishIndex points at the request that refers to it (the post). localId, the string each request also carries, is for the client only: the contract never reads it.
The contract works through the whole batch in three phases:
- For every request, it creates the Seed if
seedUidis zero and the Version ifversionUidis zero. - For every request, it applies each
propertiesToUpdateentry, writing that request's Seed UID into requestpublishIndex. - For every request, it attests the properties, pointing each at the request's Version.
Every Seed exists before the first reference is applied, and no property is attested until the last one is, so publishIndex can name any request in the batch, the request's own position and earlier ones included. Items that refer to each other resolve in one call: each carries an entry naming the other.
The write replaces one 32-byte word in place. With listElement false, the attestation's data must be exactly 32 bytes, which is what a Relation property attests as. With listElement true, it must be exactly abi.encode(bytes32[]), which is what a List of relations attests as, and elementIndex names the element.
Rules and errors
The checks live in SeedPublishLib, which the Seed Protocol extension and the executor share. A failed check reverts the whole multiPublish, so nothing in the batch is attested.
| Error | Reverts when |
|---|---|
PublishIndexOutOfBounds(targetIndex, length) | publishIndex is not a position in requests |
EmptyAttestationData(targetIndex, propertySchemaUid) | the target has an attestation group with that schema but no data entries to write into |
PropertyToUpdateNotFound(requestIndex, targetIndex, propertySchemaUid) | the target has no attestation with that schema, so the UID would be written nowhere |
AmbiguousPropertyToUpdate(targetIndex, propertySchemaUid) | the target has more than one group with that schema, or one group with more than one data entry, so a write could overwrite other data |
InvalidRelationEncoding(targetIndex, propertySchemaUid) | a reference with listElement false targets data that isn't exactly 32 bytes |
ElementIndexOnSingleRelation(targetIndex, propertySchemaUid) | a reference with listElement false has a non-zero elementIndex |
InvalidListEncoding(targetIndex, propertySchemaUid) | a reference with listElement true targets data that isn't exactly abi.encode(bytes32[]) |
ListElementOutOfBounds(targetIndex, propertySchemaUid, elementIndex, length) | elementIndex is past the end of the list |
PlaceholderMismatch(requestIndex, targetIndex, propertySchemaUid, found) | the slot doesn't hold this request's placeholder, so the reference names the wrong request or property, or the slot was already written or holds zero |
In practice the rules come down to:
- Mark each slot with its source. Before the write, a slot must hold
bytes32(sourceIndex + 1), wheresourceIndexis the position of the request whose Seed UID goes there. So a reference lands only on a slot marked for its source, each slot is written once, and a zero (cleared) relation can't be filled.encodeMultiPublishwrites the placeholder for you; if you encode the call yourself, you write it. - Give the target one entry. The post's request needs exactly one attestation group for the property, with exactly one data entry.
- Size a list up front. A list slot needs a canonical
bytes32[]of its final length. Real UIDs and placeholders can sit side by side in it, and each placeholder takes its own reference. - Compute
publishIndexand placeholders from the array you send. If you reorder, filter or merge requests afterwards, recompute both.
How the SDK builds a batch
When you publish an item, the SDK's getPublishPayload builds one request for the item and one for every draft item it reaches through Relation, List and Image/File/Html/Json properties, each with its full set of properties. Already-published targets aren't included: the referring property attests their current UID. A target that's been unpublished (revoked) stops the publish with RelatedItemUnpublishedError before anything is uploaded or sent. A back reference, a property pointing at an item the publish is still creating, is attested in the same transaction on a contract publish, which passes backReferences: 'link'. Direct EAS leaves it for the item's next publish, as does item.getPublishPayload when you call it without that option; Publish related items together has the details.
Each related draft's request gets a cross-reference to the item that refers to it, and a reference into a list gets one entry per position that holds the draft. The SDK names the target by local ID ({ publishLocalId, propertySchemaUid }) while it builds the batch, then:
- Sorts the requests by dependency. A request that carries a cross-reference goes before its target, and a request whose attestations hold another request's local ID goes after that request. Ties keep their original order, and requests in a cycle, which can't be ordered, keep theirs at the end.
- Encodes.
encodeMultiPublishturns eachpublishLocalIdinto the target's index in the final array, writes the placeholder into each slot a reference fills, and sets the call'svalueto the sum of the attestations'valuefields.
encodeMultiPublish throws instead of guessing when two requests share a localId, when a cross-reference has no publishLocalId, when the target isn't in the batch, or on what the contract would revert on: a target with no attestation or more than one for the property, an entry of the wrong shape, an element past the end of its list, or two references to one slot. It also throws when a property still holds a request's local ID, or when a bytes32 or bytes32[] property holds a placeholder no reference fills, since the contract would attest either as data. The placeholder check needs the attestation's _easType, which the SDK sets and a hand-built request may leave out. On a read, a relation holding a non-zero bytes32 below 2^32 counts as an unfilled reference and shows as no related item.
Before sending, the publish package simulates the call from the publishing account and decodes any revert into the error names above. A batch that would revert is never sent: the publish fails with PUBLISH_PREFLIGHT_FAILED, or AUTOMATION_PREFLIGHT_FAILED for a session key.
encodeMultiPublish is exported from @seedprotocol/publish if you build batches yourself:
import { encodeMultiPublish } from '@seedprotocol/publish'
import type { SeedTxRequest } from '@seedprotocol/publish'
type Hex = `0x${string}`
const ZERO: Hex = `0x${'0'.repeat(64)}`
// Schema UIDs for your models and the relation property, from the SchemaRegistry.
declare const authorModelUid: Hex
declare const postModelUid: Hex
declare const authorRelationUid: Hex // "bytes32 author"
declare const versionSchemaUid: Hex // the base "bytes32 version" schema
export function buildBatch(to: Hex): SeedTxRequest {
const placeholder = {
recipient: '0x0000000000000000000000000000000000000000' as Hex,
expirationTime: 0n,
revocable: true,
refUID: ZERO,
data: ZERO, // encodeMultiPublish writes the placeholder here
value: 0n,
}
return encodeMultiPublish(to, [
{
localId: 'author1',
seedUid: ZERO,
seedSchemaUid: authorModelUid,
versionUid: ZERO,
versionSchemaUid,
seedIsRevocable: true,
listOfAttestations: [],
// Write this request's new Seed UID into post1's "bytes32 author" attestation.
propertiesToUpdate: [{ publishLocalId: 'post1', propertySchemaUid: authorRelationUid }],
},
{
localId: 'post1',
seedUid: ZERO,
seedSchemaUid: postModelUid,
versionUid: ZERO,
versionSchemaUid,
seedIsRevocable: true,
listOfAttestations: [{ schema: authorRelationUid, data: [placeholder] }],
propertiesToUpdate: [],
},
])
}encodeMultiPublish(to, requests, gas?) returns a transaction request. Pass the account's address as to and send it from the account (the extension path), or pass the executor's address and send it through the executor. The result has value only when the sum isn't zero, and gas only when you pass it. seedIsRevocable and each entry's revocable are ignored: the contracts make everything revocable.
To fill a list element, give the entry listElement and elementIndex, as in { publishLocalId: 'post1', propertySchemaUid: authorIdsUid, listElement: true, elementIndex: 1 }. The target's data is then abi.encode(bytes32[]), and the element's current value doesn't matter.
One transaction for the whole publish
A contract publish sends the item and its related drafts in one multiPublish, as one transaction or UserOp. If it fails, nothing is attested. Every request records its Seed, Version and property UIDs from the receipt's Attested events, which are the publishing account's on the configured EAS. A graph too big for one block fails with PUBLISH_TOO_LARGE before anything is sent, and the message says to publish some related drafts on their own first.
A publish that attests straight to EAS doesn't use multiPublish at all, and it isn't atomic. It sends an attest for each new Seed and each new Version and one multiAttest for each item's properties, in order, and resolves references on the client. It records each Seed locally as soon as it's attested, so a retry after a later failure doesn't attest that Seed again. Because each request is attested before the next, a reference can only reach its own request or a later one: one aimed at an earlier request fails with PUBLISH_PREFLIGHT_FAILED before anything is sent. getPublishPayload doesn't build such a link on this route; the check guards against a reordered batch. Publish overview says when a publish takes this route.
Related
- Seed Protocol extension and Executor and router extension: the
multiPublishstruct and the other errors - Modeling relations: Relation and List properties in your schema
- Publishing and revoking: publishing items with related drafts
- Working with EAS
- Base schemas: the Version schema every request uses
Working with EAS
How Seed Protocol data maps onto EAS attestations: Seed, Version and property attestations, refUID chains, schema naming, reading attestations on easscan, and what revoking does.
Security and session keys
Who can publish and revoke through the Seed contracts, why everything is revocable, how session keys are scoped, what a publishAuthorization attestation records, and what a leaked session key can and can't do.