Skip to content

Multi-Device Postage Batches

This page is the definitive, implementation-level specification of how Swarm ID lets several devices that share one account write to a single mutable postage batch at the same time without corrupting each other’s data. It is written so the scheme can be re-implemented on another platform (native mobile, desktop, a different language) and interoperate with the reference TypeScript implementation in @snaha/swarm-id.

Everything here is derived purely from Swarm primitives (chunks, single-owner chunks, epoch feeds) and a handful of deterministic key derivations. There is no server, no coordinator, and no shared mutable state other than what lives on Swarm itself.

The account shares one batch across devices. Two distinct problems arise, solved by two independent mechanisms:

  1. Partition lease — prevents two devices from stamping the same (bucket, slot) of the batch (which would make one chunk silently overwrite the other on the network). Each active device leases a partition of every bucket’s slot space and only ever stamps slots in its own lane.
  2. Account-state sync — keeps the shared account snapshot (identities, connected apps, postage stamps, the device list) eventually-consistent across devices. It is a single epoch feed plus a merge function, with an optimistic verify-retry to survive concurrent writers.

They are orthogonal: account-state sync would be needed even with one batch per device, and the partition lease would be needed even if account state never changed. A device that holds a partition lease is also the device permitted to publish account state (the “partition gate”, below).

All cross-device addressing derives from the account’s derivation key (derivationKey), a 32-byte secret every device reconstructs after authentication. Two devices interoperate iff they derive the same keys from the same derivation key.

The primitive is deriveSecret(key, context):

deriveSecret(key, context) = hex( HMAC-SHA256( bytes_from_hex(key), utf8(context) ) )

Note the asymmetry: key is interpreted as a hex string decoded to bytes, context is encoded as UTF-8. From this:

swarmEncryptionKey = deriveSecret(derivationKey, "swarm-encryption") // 32 bytes
backupKey = deriveSecret(swarmEncryptionKey, "backup-key") // 32 bytes
  • backupSigner = the secp256k1 private key whose scalar is backupKey. Its Ethereum address (backupSigner.publicKey().address()) is the owner of every feed and every lock SOC in this document. Because the owner is per-account, the same identifier/topic across two different accounts still produces different chunk addresses — domain separation comes from the owner, not from baking the account ID into hashes.
  • swarmEncryptionKey encrypts the lock-SOC payloads and the device-claim payloads, and seeds the per-utilization-chunk key derivation.

Per-device identity:

  • deviceId — a per-install UUID (crypto.randomUUID()), stored under the localStorage key swarm-id-device-id. Stable for the life of the install; not derived from the account, so each device has a distinct one.
  • tiebreaker = hex( keccak256(utf8(deviceId))[0:8] ) — the first 8 bytes of the keccak256 of the device ID, as hex. Used to deterministically order two writers that share a millisecond timestamp. Two distinct device IDs collide with probability 2⁻⁶⁴.

A Swarm postage batch of depth D has:

  • NUM_BUCKETS = 65 536 buckets (2^16, BUCKET_DEPTH = 16).
  • 2^(D-16) slots per bucket (calculateMaxSlotsPerBucket).

A chunk’s bucket is the first two bytes of its 32-byte address, big-endian:

bucket(addr) = (addr[0] << 8) | addr[1] // 0 .. 65535

The slot space of every bucket is divided into PARTITION_COUNT = 2 interleaved lanes. The lowest PARTITION_COUNT slots are reserved (not data):

  • Slot index p (for p in [0, PARTITION_COUNT)) is reserved for partition p’s lock SOC and counter (utilization) chunks.
  • Data slots begin at DATA_COUNTER_START = PARTITION_COUNT.

The physical slot for partition p’s j-th data chunk in a bucket (dataSlot(p, j, K), with K = PARTITION_COUNT):

slot = K + p + K · j // j = 0, 1, 2, … (per-bucket, per-partition data counter)

So with K = 2: partition 0 writes data to slots {2, 4, 6, …}, partition 1 to {3, 5, 7, …}, and slots {0, 1} are the two partitions’ reserved slots. The lanes never collide, so two devices on different partitions can stamp the same bucket concurrently.

Per-partition per-bucket capacity (partitionCapacity):

capacity = floor( 2^(D-16) / K ) − 1 // −1 for the reserved slot
Depth D slots/bucket per-partition data slots/bucket (K=2)
20 16 7
22 64 31
24 256 127
28 4 096 2 047

Legacy single-device accounts use partitionCount = 1 (reserved slot 0, data from slot 1 onward) and skip the lease entirely.

Each device tracks, per bucket, how many data chunks its partition has written — the j counter above. This is held in memory as a Uint32Array(65536) (dataCounters), always uint32 in memory regardless of how it is serialized.

The counter array is serialized into fixed CHUNK_SIZE = 4096-byte chunks. The per-bucket counter is uint16 when it always fits (D ≤ 31, max 2^15), else uint32 (D ≥ 32). This sets the chunk layout (getChunkLayout):

Codec Applies when bucketsPerChunk numUtilizationChunks
uint16 D ≤ 31 2 048 32
uint32 D ≥ 32 1 024 64

Counters are little-endian in the serialized chunk. Chunk i covers buckets [i·bucketsPerChunk, (i+1)·bucketsPerChunk).

Utilization chunks are uploaded as encrypted content-addressed chunks. The key for chunk i is deterministic so re-saves dedup:

chunkKey = HMAC-SHA256(
swarmEncryptionKey,
utf8("swarm-id-util-chunk-v1") ‖ batchId ‖ uint32_be(chunkIndex) ‖ uint32_be(nonce)
)

nonce starts at 0 and is incremented only when the resulting encrypted address would land in the same bucket as another utilization chunk in the same save (collisions are vanishingly rare — ≈0.008 bumps per save with 32 chunks over 65 536 buckets). The nonce that produced a chunk is cached so an unchanged plaintext reuses the same key and the upload is skipped.

The reference implementation caches counter chunks in an IndexedDB store (UtilizationStoreDB, DB name swarm-utilization-store), keyed by (batchId hex, chunkIndex), storing { data, contentHash, nonce, lastAccess }. A port may use any local KV store; the cache is an optimization, not part of the wire protocol. The cross-device source of truth for a partition’s counter is the partition-state feed (below), not the cache.

A single-owner chunk (SOC) per partition is the cross-device authority for “who holds this partition right now.” All of an account’s devices share the backupSigner, so any of them can write any partition’s lock SOC; concurrent writers are ordered by a fencing token.

identifier = keccak256( utf8("swarm-id-partition-lock-v1:" + partition) )
owner = backupSigner address
SOC address = keccak256( identifier ‖ owner ) // 32 bytes
encryption = swarmEncryptionKey

Payload (JSON, ~150 bytes, encrypted):

{
"holderDeviceId": "uuid-or-empty-string",
"generation": { "timestampMs": 1730000000000, "tiebreaker": "a1b2c3d4e5f6a7b8" },
"acquiredAt": 1730000000000,
"leasedUntil": 1730000030000
}
  • holderDeviceId — the current holder’s device ID, or the sentinel NO_HOLDER_DEVICE_ID = "" (empty string) after an explicit release.
  • generation — the fencing token. compareGenerations(a, b) orders by timestampMs first, then by tiebreaker lexicographically. The higher generation always wins.

A release is an action on a specific claim, not on the partition: the sentinel carries the generation of the claim it releases — never a fresh one — so any later claim (a successor on the same device, or a peer) is logically newer than the release. Before writing, the releaser re-reads the lock and skips the sentinel when the lock no longer carries the claim being released:

  • an existing sentinel (any generation) — already released; rewriting would mint a fresh postage stamp that could clobber a claim landing in between;
  • a foreign claim, live or expired — never clobber a peer (expired already reads as takeable);
  • our own claim with a newer generation — a successor re-acquired while the release was publishing.

Our own claim at a non-newer generation writes (<=, not ==): a stale read returning an older refresh of the same holdership must not suppress the release. This matters because Bee replaces a same-address SOC whenever the new chunk’s postage stamp timestamp is newer — without the fence, a detached release outliving a same-device re-acquire would deterministically clobber the successor’s claim and peers would read the partition as free (turning one writer into two). In the reference implementation the sign-out release is additionally serialized under the same origin-wide write lock the acquire path uses, so its sentinel’s stamp is always minted before a successor’s claim stamp. A holder that nevertheless sights a sentinel on its own partition re-asserts its claim at the next upload or refresh tick.

Residual (accepted): a releaser whose node serves a stale view (e.g. a caching gateway that never re-fetches) can still write its fenced sentinel over a peer claim it cannot see; recovery is the displaced peer’s refresh re-assert, bounded by LEASE_REFRESH_MS.

A SOC has no compare-and-swap; ordering comes from the generation fence plus a guard window PARTITION_LOCK_GUARD_MS = 2000 ms:

  1. Read the lock SOC.
  2. If a live foreign holder exists (holderDeviceId is neither us nor empty, and leasedUntil > now) → return blocked, write nothing.
  3. Otherwise write our claim (holderDeviceId = us, fresh generation, leasedUntil = now + LEASE_TTL_MS).
  4. Wait guardMs, then re-read.
  5. If our generation is still the latest visible → acquired. If a higher generation appeared during the guard → lost-race (a peer beat us; fall back to read-only).
  6. If the verify-read fails to return anything (transient Bee error / propagation lag) → optimistically acquired — we observed no live foreign holder before writing, so a failed read is not proof of a race; the periodic refresh reconciles and only demotes on a confirmed live foreign holder.

The lock SOC’s chunk lands in some bucket (bucket(SOC address)). The stamper routes the overstamp to that bucket’s reserved slot = the partition index, so the tight heartbeat cadence never consumes data-slot budget and the SOC always occupies the same slot. The counter (utilization) chunks for a partition are routed the same way.

Each (batchId, partition) has its own epoch feed carrying the holder’s per-bucket data counter. The current holder publishes it on release; the next holder reads it on acquire and resumes from exactly that counter, so an orderly hand-off never re-uses a slot.

topic = keccak256( utf8("swarm-id-partition-state-v1") ‖ batchId ‖ uint32_be(partition) )
owner = backupSigner address

The counter is not a single blob. It is split into the same numUtilizationChunks counter chunks, each uploaded with a random encryption key to the partition’s reserved slot, yielding a 64-byte encrypted reference (address ‖ key). A single reference chunk concatenates those references (N · 64 ≤ 4096 bytes, one chunk even at N = 64), and the feed points at the reference chunk. A taking-over device follows feed → reference chunk → counter chunks, decrypting each from its embedded key.

The format is binary and is versioned by the topic domain (swarm-id-partition-state-v1) — there is no in-blob version byte. A future v2 publishes under a v2 topic and never collides with v1 readers.

Reference chunk — exactly N × 64 bytes, where N = numUtilizationChunks(batchDepth):

Offset Size Content
i · 64 32 Swarm address of counter chunk i
i · 64 + 32 32 Decryption key of counter chunk i (random, generated per publish)

Counter chunk i — exactly 4096 bytes (CHUNK_SIZE): bucketsPerChunk consecutive per-bucket counters, little-endian, covering buckets [i · bucketsPerChunk, (i + 1) · bucketsPerChunk). The counter width depends on the batch depth (getChunkLayout):

Batch depth Counter width bucketsPerChunk N (chunks)
≤ 31 (UINT16_COUNTER_MAX_DEPTH) uint16 LE 2048 32
≥ 32 uint32 LE 1024 64

The decoded result is always a Uint32Array(65536) (one counter per bucket) regardless of the on-wire width; narrowing happens only at the serialize boundary. The decoded state is described by the exported PartitionStateSchemaV1 ({ counters: Uint32Array(NUM_BUCKETS) } in lib/src/sync/partition-state.ts): writePartitionState validates its input against it, and readPartitionState validates the reassembled counter — plus the reference-chunk length (N × 64) and each counter chunk’s length — before returning.

After reconstructing the counter, the reader applies one compensation: +1 in the bucket of the feed entry’s own SOC address. The publisher extracts the snapshot first and writes the feed SOC last, so the feed SOC consumed one data slot — slot(snapshot[bucket]) of its own bucket — that the snapshot does not record. Without the +1, the next holder’s first chunk in that bucket would overstamp the feed entry and evict it from the reserve (Bee deterministically replaces the older chunk at a colliding stamp index), breaking later feed walks. The bump applies only to the entry actually read (earlier entries were compensated by the readers that consumed them) and never on the cached short-circuit path below (the local counter already includes it).

Reads short-circuit on a cached “synced reference”: if the feed still points at the reference a device last synced with, it skips the downloads and reuses its local counter.

Failure semantics are fail-safe: when the feed has an entry but the reference chunk or any counter chunk cannot be read (or fails the wire-format validation above), the read reports failure — without caching the bad reference as synced — and the device falls back to read-only without claiming the partition (retried on the next acquire). It must not seed a zero counter, since stamping from zero would re-issue every used slot and evict the partition’s data chunk by chunk. A zero counter is seeded only when the feed provably has no entry (the legitimate fresh-partition seed in Cases A/B).

Both sides also protect the published chunks from later overstamps: the publisher avoids the buckets of the partition’s lock SOC and of its live utilization chunks, and every device records the published state chunks’ buckets (returned by the write, re-derived from the reference chunk on read) so its utilization saves avoid placing chunks there — all these chunks share the partition’s reserved slot, and a collision would evict the resume point a later takeover depends on.

The reference orchestrator is PartitionLease. Constants:

Constant Value Meaning
LEASE_TTL_MS 30 000 ms A lease is valid this long; a crashed holder is reclaimable after.
LEASE_REFRESH_MS 10 000 ms Holder re-writes the lock SOC this often (3 refreshes per TTL).
IDLE_YIELD_MS 30 000 ms Holder voluntarily releases after this long with no upload.
PARTITION_LOCK_GUARD_MS 2 000 ms Guard window between claim-write and verify-read.
SLOT_WAIT_TIMEOUT_MS 30 000 ms A device with no free partition polls this long for one to free up.

acquire(partitionCount):

  1. If partitionCount ≤ 1 → legacy single-device, no lease.
  2. refreshFromSwarm — read every partition’s lock SOC, record the live (unexpired, non-released) holders.
  3. Choose the partition we already hold, else the lowest free/expired one (pickFreeOrExpired). If every partition has a live foreign holder → read-only.
  4. claimPartition — read the partition-state feed to seed the counter, then run the lock acquire/verify protocol. On acquired, bind the counter to the stamper; on blocked/lost-race, fall back to read-only.

refresh() re-runs the lock protocol on the held partition to bump leasedUntil; returns false (lease lost) on blocked/lost-race/aborted (a refresh that overlaps an in-flight release() aborts before writing, so it cannot mint a ghost claim the fenced release would refuse to clear). release(localCounter) publishes the final counter to the partition-state feed, then writes the generation-fenced release sentinel (see “Release protocol” above) — skipped entirely when the lock no longer carries the claim being released. adoptIfLive()/hydrate()/serialize() let a reload re-adopt a still-valid lease from a local cache without a Swarm round-trip (re-validated on the next refresh).

  • Case A — first device, fresh batch. No lock SOC, no partition-state. Take partition 0, seed a zero counter.
  • Case B — second device. Partition 0 is live-held; take partition 1, seed its counter from its partition-state feed (zero if never published).
  • Case C — third+ device, turn-taking. All PARTITION_COUNT partitions are held. The new device waits (SLOT_WAIT_TIMEOUT_MS, polling every LEASE_REFRESH_MS) for a holder to go idle (IDLE_YIELD_MS → it releases) or for a lease to lapse, then claims the freed partition resuming from the published counter.
  • Case D — crash recovery. A holder dies without releasing. Its leasedUntil expires after LEASE_TTL_MS; a peer then reclaims the partition. Resume is from the last published counter (plus the +1 feed-entry compensation above) — an unclean crash that stamped past its last publish loses only data that was never durably persisted anyway. If the published counter exists but cannot be read, the taker stays read-only and retries rather than resuming from zero.

Account state is one epoch feed per account:

topic = Topic.fromString("swarm-id-backup-v1:account:" + accountId)
owner = backupSigner address

The feed points at an encrypted snapshot (AccountStateSnapshot, schema version 1):

{ version: 1, timestamp, accountId,
metadata: { accountName, defaultPostageStampBatchID, createdAt, lastModified,
devices[], partitionCount },
identities[], connectedApps[], postageStamps[] }

ConnectedApp carries two fields that make cross-device merge correct:

  • updatedAt — a last-writer-wins clock, set on any change (falls back to lastConnectedAt for records written before the field existed).
  • revokedAt — a tombstone marker. A revoked app is kept in the snapshot (so the removal propagates) but hidden from the UI and invalid for auth.

A shared epoch feed has no compare-and-swap — two devices publishing in the same epoch slot write the same SOC address and the later signer silently wins, orphaning the other’s snapshot. Publishing therefore:

  1. Fetches the latest remote snapshot, merges the local state onto it (so we don’t stomp a peer’s additions), uploads the merged snapshot, and updates the feed.
  2. Re-reads the feed (verifyWon): if it points at a different reference, a peer won.
  3. Re-merges from the freshest remote and republishes, up to MAX_PUBLISH_RETRIES = 3 times, backing off PUBLISH_RETRY_BACKOFF_MS = 200 ms plus up to PUBLISH_RETRY_JITTER_MS = 600 ms of jitter so two devices decorrelate. A missing/unreadable re-read counts as “won” (never spuriously republish).

The merge always folds the original local state onto the freshest remote, so retries converge. Publishing is serialized within an origin by a cross-tab Web Lock (swarm-write-<batchId>) so a proxy iframe and the UI never collide locally.

mergeSnapshotWithRemote(local, remote):

  • devices — union by deviceId; larger lastSignedInAt wins, ties to local.
  • identities / postageStamps — union by key (id / batchID); local wins on overlap.
  • connectedAppslast-writer-wins by recency = updatedAt ?? lastConnectedAt ?? 0, keyed by (identityId, appUrl). Process remote then local so a tie favours local. This is what lets both updates and revocations (tombstones) propagate while distinct apps still survive as a union.
  • scalars (accountName, defaultPostageStampBatchID, partitionCount) — local wins.
  • timestamp / lastModified — refreshed to now.
  • Restore (first sign-in on a device): pull the snapshot and apply it wholesale into local stores.
  • Refresh (already-known account): re-pull and merge the full snapshot into the stores via no-sync “applyRefreshed” paths (so the refresh itself doesn’t re-publish).

A device may publish account state only if it holds a partition (or can claim a free/expired one), because publishing writes chunks to the shared batch. The lock SOC is the single source of truth for this — there is no separate “active devices” mirror. If every partition is held by a live foreign device, the publish is skipped and a peer publishes instead.

To interoperate with the reference implementation, an external client must:

  1. Reconstruct the same derivationKey after auth, then derive swarmEncryptionKey and backupKey via deriveSecret exactly as above (HMAC-SHA256, key-as-hex-bytes, context-as-UTF-8). The backupSigner address is the owner of everything.
  2. Generate a stable per-install deviceId (UUID) and the tiebreaker (keccak256(deviceId)[0:8]).
  3. Implement the lock-SOC protocol: identifier hash, SOC address, encrypted JSON payload, the read → guard (2 s) → verify acquire flow, generation fencing, and the generation-fenced "" release sentinel — the sentinel carries the released claim’s generation and is skipped when the lock no longer carries that claim. A port that mints fresh sentinel generations or releases unconditionally re-introduces the stale-release claim-clobbering race (#349).
  4. Implement the slot formula slot = K + p + K·j and route lock-SOC/counter chunks to reserved slot p. Honour PARTITION_COUNT = 2, DATA_COUNTER_START = 2.
  5. Implement the partition-state feed: topic hash, the reference-chunk → counter-chunks structure, publish-on-release / read-on-acquire — including the +1 feed-entry compensation on read and the fail-safe (read-only, never zero-seed) handling of an unreadable published counter. Skipping either re-introduces silent slot-reuse data loss.
  6. Track per-bucket counters with the uint16/uint32 codec and the swarm-id-util-chunk-v1 key derivation (only needed to publish counters that the reference client can read).
  7. Implement account-state sync: the swarm-id-backup-v1:account:<id> epoch feed, the snapshot schema (including updatedAt/revokedAt), the verify-retry publish, and the merge rules — especially connectedApps LWW-with-tombstones.
  8. Respect the partition gate: never publish account state without holding a partition.

All hashes are keccak256; all multi-byte integers in identifiers/topics are big-endian uint32; serialized counters are little-endian; feeds are epoch feeds owned by the backup signer.

Tracked under the multi-device umbrella (#217):

  • BatchWriteCoordinator refactor + proxy-side publish. Extract the partition-lease / stamp / write-lock orchestration the proxy and the sync path currently duplicate into one shared unit, then have the proxy iframe (the only guaranteed-persistent context on a dApp page) publish account state — including announcing a device when it first acquires its lease. Also folds in clearer contention logging (distinguish “all partitions held” from stamp/SOC errors). Tracked in #336.
  • Restore / refresh coverage. Give agent (seed-phrase) accounts a restore-from-Swarm path (today they start fresh on a new device), and broaden refresh triggers to app load / account switch instead of only the Devices view. Tracked in #338.
  • Merge consolidation + deletion tombstones. Make the snapshot-merge rules a single shared implementation (lib vs the UI refresh path), and extend the connectedApps tombstone pattern to identities and stamps so deletions propagate across devices. Tracked in #337.