Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.peaq.xyz/llms.txt

Use this file to discover all available pages before exploring further.

Omni-chain V1 ships with Scale. Lite contracts and the Signer Daemon are landing across supported chains this week. Addresses get published in the launch manifest. Treat anything here as the canonical shape that goes live.
peaq stays canonical. Every other chain runs a thin, read-only mirror of peaq state, kept in sync by a Signer Daemon that watches finalized peaq events, packages them into EIP-712 batches, signs them with a per-chain push key, and pushes them to satellite Lite contracts. This means a contract or app on Base, Ethereum, Polygon, or any other supported chain can resolve a machine’s peaqID, identity, MCR, and bond status without an RPC hop to peaq, and without trusting an off-chain oracle on top.

What the mesh looks like

                 peaq home chain
                 ┌────────────────────────┐
                 │ IdentityRegistry       │
                 │ peaq DID precompile    │
                 │ IdentityStaking        │
                 │ MCR pipeline           │
                 └──────────┬─────────────┘
                            │ finalized events

                 ┌────────────────────────┐
                 │ Signer Daemon          │
                 │ (off-chain, peaq-side) │
                 │ batches + EIP-712 sign │
                 └─┬────────┬─────────────┘
                   │        │
        ┌──────────┘        └──────────┐
        ▼                              ▼
  ┌───────────────┐              ┌───────────────┐
  │ Base satellite│              │ other chains  │
  │ IdentityLite  │              │ IdentityLite  │
  │ DIDLite       │              │ DIDLite       │
  │ StakingLite   │              │ StakingLite   │
  └───────────────┘              └───────────────┘
Domain separator: name = "PeaqosLite", version = "1.0.0". Each Lite is identified by (chainId, verifyingContract) per EIP-712.

DIDLite

DIDLite mirrors the peaq DID precompile’s per-attribute records onto every satellite chain. Consumers on the satellite resolve any peaq DID account’s attributes (including the locked "peaqID" attribute) without an extra cross-chain hop. Records are written exclusively by signed batches from the Signer Daemon; consumers only read.

Public consumer views

function readAttribute(address didAccount, bytes calldata attrName)
    external view
    returns (
        bytes memory value,
        uint32 validity,
        uint64 lastUpdatedHomeBlock,
        uint64 lastBatchAcceptedAtTs
    );

function readAttributeRaw(address didAccount, bytes calldata attrName)
    external view
    returns (DIDAttributeRecord memory record, uint64 lastBatchAcceptedAtTs);
    // admin/debug only; ignores the pause gate; returns soft-deleted records as-is

function lastHomeBlockApplied() external view returns (uint64);

struct DIDAttributeRecord {
    bytes  value;                  // raw bytes from peaq; empty after soft-delete
    uint32 validity;               // peaq DID validity field
    uint64 lastUpdatedHomeBlock;
    bool   removed;                // soft-delete sentinel
}
readAttribute reverts LitePaused while the Lite is paused. readAttributeRaw is an admin/debug carve-out that ignores the gate. lastHomeBlockApplied() is intentionally not pause-gated so consumers can still read the global watermark when reads are paused. To resolve a peaqID, compose: readAttribute(didAccount, "peaqID"). The orchestrator does not ship a dedicated peaqIDOf view; clients decode the 32-byte value themselves.

Consumer view errors

  • AttributeNotFound(address didAccount, bytes attrName)
  • AttributeRemoved(address didAccount, bytes attrName, uint64 removedAtHomeBlock)
  • LitePaused()

IdentityLite

IdentityLite mirrors the peaq IdentityRegistry per-machineId record. Consumers gate writes that depend on having seen a specific peaq approval by reading lastCursorPacked().

Public consumer views

function getIdentity(uint256 machineId)
    external view
    returns (IdentityRecord memory record, uint64 lastBatchAcceptedAt);
    // pause-gated; lastBatchAcceptedAt == 0 is the cold-start sentinel

function getIdentityRaw(uint256 machineId)
    external view
    returns (IdentityRecord memory record, uint64 lastBatchAcceptedAt);
    // admin/debug only; ignores pause gate

function lastCursor()
    external view
    returns (uint64 blockNumber, uint32 txIndex, uint32 logIndex);
    // unpacked; NOT pause-gated (cross-Lite read invariant)

function lastCursorPacked() external view returns (uint128);
    // packed = (block << 64) | (txIndex << 32) | logIndex
    // NOT pause-gated

function lastUpdatedHomeBlock(uint256 machineId) external view returns (uint64);
    // per-record cursor; pause-gated

// Home-style accessors (additive to getIdentity)
function getOwnerIfExists(uint256 machineId)
    external view
    returns (address owner_, bool exists_);

function operatorOf(uint256 machineId) external view returns (address);

function getMachineStatus(uint256 machineId) external view returns (MachineStatus);

function getMachineURI(uint256 machineId) external view returns (string memory);

struct IdentityRecord {
    address owner;        // immutable post-Registered (soulbound on peaq)
    address operator;     // mutable; address(0) clears
    string  machineURI;   // immutable post-Registered
    MachineStatus status;
    uint64  lastHomeBlock;
}

enum MachineStatus { None, Pending, Verified, Rejected, Deactivated }
// Ordinals match peaq IdentityRegistry. Do not reorder.

Consumer view errors

  • MachineNotFound(uint256 machineId)
  • LiteUninitialized(address lite) — not raised by the Lite itself. Read (record, lastAt) and require(lastAt > 0, LiteUninitialized(address(this))).
  • LitePaused()

Cold-start pattern

The Lite returns (record, 0) when it has never accepted a batch, rather than reverting, so callers choose the policy:
(IdentityRecord memory rec, uint64 lastAt) = identityLite.getIdentity(machineId);
require(lastAt > 0, LiteUninitialized(address(identityLite)));
require(rec.status == MachineStatus.Verified, "not verified");

StakingLite

StakingLite mirrors the peaq IdentityStaking per-machine stake record. Consumers gate authorisation or service eligibility on stake state without crossing chains.

Public consumer views

function getStake(uint256 machineId)
    external view
    returns (StakeRecord memory record, uint64 lastBatchAcceptedAt_);
    // pause-gated; lastBatchAcceptedAt_ == 0 is the cold-start sentinel

function getStakeRaw(uint256 machineId)
    external view
    returns (StakeRecord memory record, uint64 lastBatchAcceptedAt_);
    // admin/debug only; ignores pause gate

function isStaked(uint256 machineId) external view returns (bool);
    // pause-gated

function isAuthorized(address wallet)
    external view
    returns (bool isAuth, uint64 lastBatchAcceptedAt_);
    // pause-gated; cold-start sentinel applies

function totalStaked() external view returns (uint256);
    // pause-gated; aggregate of applied Staked.amount events
Same cold-start pattern as IdentityLite — require(lastBatchAcceptedAt_ > 0, LiteUninitialized(address(this))) before trusting reads. StakingLite has its own pause flag and its own EIP-712 schema (StakingEvent), but shares the PeaqosLite domain and the cross-language daemon parity gate.

EIP-712 schemas

7-field IdentityEvent

IdentityEvent(uint8 kind,uint256 machineId,address subject,string machineURI,uint64 homeBlockNumber,uint32 txIndex,uint32 logIndex)
txIndex and logIndex were added vs the earlier 5-field shape so the on-chain cursor can be sub-block-precise. The daemon-side encoder is bit-identical to Solidity (parity-gated by the EIP-712 fixture suite).

DIDEvent

DIDEvent(uint8 kind,address didAccount,bytes attrName,bytes attrValue,uint32 validity,uint64 homeBlockNumber)
Both bytes fields are pre-hashed with keccak256(bytes(...)) per EIP-712 dynamic-bytes rule. kind ordinal: 0 = Add, 1 = Update, 2 = Remove. Removes must carry value.length == 0 and validity == 0.

Batch envelope (shared)

Batch(uint64 nonce,uint64 deadline,uint8 schemaVersion,bytes32 eventsRoot,uint64 cursorLo_block,uint32 cursorLo_txIndex,uint32 cursorLo_logIndex,uint64 cursorHi_block,uint32 cursorHi_txIndex,uint32 cursorHi_logIndex)
Schema version is per-Lite. A typehash bump requires lockstep upgrade of both the Lite and the daemon.

Signer Daemon

Off-chain. Python package, one instance per (cross-chain pair, Lite) tuple. V1 launches with a 6-daemon fleet: three for peaq mainnet (home) → Agung (satellite) and three for Agung (home) → Base Sepolia (satellite). Each daemon binds to one LITE_NAME (IdentityLite | DIDLite | StakingLite), one push key (currentSigner on the Lite), one HEALTH_PORT, and one CURSOR_FILE_PATH. Six unique push keys total — reuse triggers nonce races.
PropertyBehaviour
FinalityGRANDPA-finalized only. No latest-N fallback. Daemon freezes if GRANDPA stalls rather than serve unfinalized state.
BatchingCritical events flush immediately; non-critical aggregate up to flush_after_sec=30 or max_batch_size=50. MAX_BATCH_SIZE env override bounded [1, 50]; required =10 for DIDLite (byte-payload events exceed Agung block gas at 50).
Nonce orderingPer-chain EVM nonce lock + per-Lite monotonic batch nonce. AlreadyApplied → skip-advance. NonceOutOfOrder → backfill missing nonce(s).
Restart catchupOn boot, resume_cursor = max(localCursor, liteCursor). Prevents replay after a crash between Lite acceptance and local commit.
Pause retriesWritesPaused from applyBatch triggers exponential backoff (1, 5, 15, 60, 300)s rather than fatal exit, so admin pauses do not crash the fleet.
Poison events (DIDLite)Orphan Update/Remove events whose Add predates the deploy block trigger AttributeDoesNotExist or AttributeAlreadySoftDeleted. The daemon decodes the revert, isolates the offending event, pushes the rest of the batch, and increments poison_event_skipped_per_lite[lite] on /health. Under partial-history replay the satellite is best-effort cache, not authoritative.
Cross-language parityEIP-712 typehashes are constants in Solidity (EVENT_TYPEHASH_V1, BATCH_TYPEHASH_V1) and in Python. A Hardhat task dumps canonical fixtures; Python recomputes identical hashes (17 fixtures, all 65-byte signatures bit-identical).
Hot-wallet floorHOT_WALLET_MIN_BALANCE_WEI (default 0.02 native). Below the floor the daemon stops signing and /health.hot_wallet_low[chain_id] = true.

Health endpoint

Each daemon exposes a loopback-only /health (default port unique per instance, conventionally 8080–8085):
{
  signer_key_id,
  last_push_at,
  hot_wallet_low: { [chain_id]: bool },
  queue_depth,
  poison_event_skipped_per_lite: { [lite_name]: int }
}
Operator runbooks (env vars, deploy steps, monitoring, troubleshooting) for the Signer Daemon will live in the Operate section: signer-daemon-deploy, signer-daemon-monitoring, and signer-daemon-troubleshooting (coming soon).

Pause and emergency model

Every Lite has two independent pause flags and one emergency flag, all external onlyOwner:
pauseLite(string reason)         // pause user-facing reads
unpauseLite()                     // resume reads (blocked while in emergency)
pauseApplyBatch(string reason)    // pause inbound writes
unpauseApplyBatch()               // resume writes (allowed even in emergency)
setSigner(address newSigner)      // routine PUSH_KEY rotation
emergencyRotatePushKey(address)   // incident-response rotation
exitEmergencyMode()                // requires currentSigner != snapshot
adminReplayEvents(...)             // owner-driven replay/rewind
Routine setSigner rotation opens a GRACE_BLOCKS = 600 (~1h) window where the previous PUSH_KEY remains valid so in-flight signed batches do not fail mid-flight. Emergency rotation does not keep the previous key valid.

EmergencyMode

EmergencyMode is two booleans plus a snapshot address, not an enum:
bool    inEmergencyMode;
address emergencyEnteredBySigner;   // snapshot at first incident pause
bool    litePaused;                 // read pause
bool    applyBatchPaused;           // write pause
A first pauseLite or pauseApplyBatch sets inEmergencyMode = true and snapshots emergencyEnteredBySigner = currentSigner. To exit, the owner calls emergencyRotatePushKey(newKey) (rotation must actually change the signer) then exitEmergencyMode(). Pause flags are not auto-cleared.

Hot/cold key collapse defense

The cold key is owner() (admin and upgrade authority). The hot key is currentSigner (the PUSH_KEY on the daemon server). A single transaction must never collapse them onto the same address. Enforcement points:
  • initialize(owner_, pushKey_) reverts if pushKey_ == owner_.
  • setSigner(newSigner) reverts if newSigner ∈ {owner(), pendingOwner()}.
  • emergencyRotatePushKey(newPushKey) reverts on the same membership check.
  • transferOwnership / _transferOwnership reject newOwner ∈ {currentSigner, previousSigner}.
  • renounceOwnership is permanently disabled.
A stolen daemon key cannot also seize upgrade authority.

Staleness policy

The Lite does not staleness-revert. SDK and ops layers apply staleness gates. Consumers should compare lastBatchAcceptedAt to block.timestamp and reject reads older than their own SLO. The Lite only blocks cold-start via the consumer-side sentinel pattern.

Soft delete (DIDLite)

Removed DID attributes stay in storage with removed = true and value = "". readAttribute reverts AttributeRemoved(...) for these. readAttributeRaw returns them as-is for admin/debug.

What an integrator does

  1. Resolve a peaqID on a satellite chain. Call readAttribute(didAccount, "peaqID") and decode the returned bytes as a bytes32. Always pair with require(lastBatchAcceptedAtTs > 0) to handle cold start.
  2. Read identity status. getIdentity(machineId) returns the full record. Same cold-start check. Or use the granular home-style getters: getOwnerIfExists, operatorOf, getMachineStatus, getMachineURI.
  3. Read stake state. StakingLite.getStake(machineId), isStaked(machineId), isAuthorized(wallet), or totalStaked().
  4. Gate on a specific peaq approval. Read IdentityLite.lastCursorPacked() >= myExpectedCursor before letting a satellite action depend on it. Use the unpaused lastCursor() view if you need the unpacked form.
  5. Apply your own staleness SLO. Compare lastBatchAcceptedAt to block.timestamp. The Lite intentionally does not enforce one.

Addresses

Per-chain proxy addresses are pasted into operator .env files generated from templates in signer-daemon/deploy/ and surfaced through the SDK satellite registry. There is no single deploy-manifest JSON in the repo. Track the launch announcement for the canonical Agung and Base Sepolia address list, or pull them from peaqos/concepts/contracts once published.