Skip to content

docs(blockchain): key SDP declarations by zk_id and drop the declaration id - #407

Open
madxor wants to merge 6 commits into
masterfrom
Bedrock-RFC-SDP-declarations-revision
Open

docs(blockchain): key SDP declarations by zk_id and drop the declaration id#407
madxor wants to merge 6 commits into
masterfrom
Bedrock-RFC-SDP-declarations-revision

Conversation

@madxor

@madxor madxor commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

[RFC-PR] SDP Declarations Are Keyed by zk_id

Reviewer Orientation

Prerequisite context: read Motivation first — it explains what the derived declaration_id cost and why removing it is the simplification rather than a rename. The rewarding and quota constructions are assumed and unchanged.

# Priority Document / Change What to look for
1 Critical Start hereService Declaration Protocol → Declaration Storage declarations becomes dict[ZkPublicKey, DeclarationInfo] and declaration_id is gone; check that nothing in the protocol still needs a value derived from service, provider_id and locators, and that dropping it does not weaken any binding those fields had
2 Critical Start hereService Declaration Protocol → the locked note rule requiring a distinct locked note per declaration means a validator providing two services must lock two notes, each meeting the threshold. Under the current spec one note could back several declarations across services. This is an economics change, not only a structural one — confirm it is intended
3 Critical Transaction EncodingSDPActive / SDPWithdraw DeclarationIdZkId, both 32 bytes. SDPActive keeps its size while its first field means something different, so a stale decoder misreads rather than failing; SDPWithdraw also drops LockedNoteId, 72 → 40 bytes
4 High Service Declaration Protocol → Identifier Uniqueness all three identifiers become unique registry-wide. The provider_id change is the one to scrutinise: it means a validator presents a distinct libp2p identity per service. The networking argument is in Discussion — check it against how Blend resolves peers
5 Critical Service Declaration Protocol → Active Set a new section stating how membership is derived from a snapshot. Check the boundary: the specification's withdraw_at is the withdrawal epoch e, the implementation's is the removal epoch e+2, so the condition is written n < withdraw_at + 2 here and withdraw_at > n there. Same boundary, and an easy off-by-two to get wrong
5b High MantleLockedNote removed locked_notes becomes dict[NoteId, ZkPublicKey]. Confirm nothing outside SDP relied on a note carrying a set of declarations; Ledger.locked_notes and the spendability check are the two call sites
6 Medium MantleSDP_DECLARE the provider_id uniqueness assertion is a scan over declarations; flag if an index is expected instead
7 Low Service Declaration Protocol → Locators the length-prefix requirement loses its declaration_id-preimage rationale but is kept and widened to every serialization of the list
8 Low Blend Protocol, Key Types the Blend active message now points at a declaration by zk_id; Key Types records one NQK and NSK per declaration; three latent defects fixed in passing

Status tracker

  • 🚧 Raw (make sure that all below is completed)
    • Template applied
    • Authors filled in
    • Authors agree on the RFC content
  • 📘 Draft (make sure that all below is completed)
    • All dependent specifications added (Notion backlinks checked)
    • Specifications to deprecate added, if applicable
    • Specifications to retire added, if applicable
    • Research Lead assigned, or Project Lead assigned if the Research Lead is an author
    • Relevant Research Domain Experts assigned (cannot be authors)
  • ⚙️ Verified (make sure that all below is completed)
    • Researchers’ comments addressed
    • All logical changes documented
    • All Research reviewers approve the latest version
    • Engineering Lead assigned
    • Relevant Engineering Domain Experts assigned
  • 🔀 Merged (make sure that all below is completed)
    • Engineers’ comments addressed
    • Every change added to the change log
    • All Engineering reviewers approve the latest version
    • Specification version numbers assigned
    • Implementation reviewed and merged
    • Branch updated to master and all conflicts resolved
    • PR merged

Change log

Revision Description Date
v1 Initial PR description 2026-08-19
v2 Recorded that the change is pre-genesis, so no state migration is required 2026-08-20
v3 Simplified to one declaration per service, each backed by its own locked note; the per-service state map, the ServiceType field on the active and withdraw messages, and the LockedNote structure are all removed 2026-08-20
v4 The provider_id is unique across the registry rather than within a service 2026-08-20
v5 WithdrawMessage drops the redundant locked note; the genesis block's initial declarations are aligned with the declaration protocol 2026-08-20
v6 The unspecified locking period is removed in favour of the existing withdrawal delay, and created with it 2026-08-20
v7 The active set and the initial value of active are specified, closing two rules that existed only in the reference implementation 2026-08-20

Motivation

A declaration is currently identified by declaration_id = Hash(service || provider_id || zk_id || locators). The identifier is derived, so it must be recomputed from four fields to address a declaration, and it commits to fields that were never meant to be immutable — changing a locator changes the identity of the declaration.

Keeping that derivation correct has already cost two revisions of this specification. Revision 1.2.0 added per-service uniqueness of provider_id and zk_id, because distinct declaration_ids did not imply distinct providers — two declarations differing only in a locator collide on neither the identifier nor any other check. Revision 1.3.0 had to length-prefix the locators list, because the multiaddr byte form is self-describing and the preimage did not otherwise bind the list it committed to. Both are corrections to hazards that exist only because the identifier is derived.

The zk_id is already the value the rest of the system uses to name a provider. Rewards are keyed by it and their notes ordered by it, and the Proof of Quota core Merkle tree is built over it, one value per leaf. Making it the identifier of the declaration removes the derivation and the class of defect that comes with it, and gives those downstream constructions the guarantee they assume rather than one that has to be separately enforced.

Proposal

The zk_id becomes the identifier of a declaration, and the derived declaration_id is removed. declarations maps a ZkPublicKey to one DeclarationInfo, whose fields are otherwise unchanged.

A declaration remains a single-service unit, and each is backed by its own locked note: a note that already collateralizes a declaration cannot be offered again. A validator providing two services therefore holds two independent declarations, under two zk_ids and two locked notes, each declared, activated, and withdrawn on its own.

That keeps the protocol's shape and makes uniqueness cheap to state. All three identifiers are unique across the registry: the zk_id because it is the map key, the locked_note_id because a note backs one declaration, and the provider_id because it is one peer identity. Nothing is derived, so nothing has to be kept collision-free by construction.

Because a zk_id determines the declaration and the declaration determines its service, the active and withdraw messages carry no service of their own, and the note bookkeeping that existed to let one note back several declarations is removed. The net effect on the three specifications is a reduction in text.

Discussion

What removing the derived identifier buys

The two corrections cited in Motivation both disappear rather than being carried forward:

  • Per-service uniqueness of zk_id was an enforced rule that had to be checked because the hash could not guarantee it. Under this proposal the zk_id is the map key, so no two entries can share one — there is nothing left to enforce, and no derivation whose preimage could be gamed.
  • Length-prefixing the locators list was required so the preimage bound the list. There is no preimage now. The requirement is kept and widened, but as a serialization rule rather than a hashing one.

The change also removes a silent identity coupling: because locators fed the hash, a validator that changed an address changed the identity of its declaration. Under the new structure locators is ordinary mutable data.

One note per declaration is an economics change

This is the part of the proposal that is not merely structural, and it deserves an explicit decision.

Today a locked note may back several declarations, provided they are for different services; the validation only rejects a second declaration in the same service against the same note. The threshold is then checked against the same note value each time, so a single locked amount can satisfy the minimum stake for every service a validator provides.

Requiring a distinct note per declaration ends that. A validator providing two services must lock two notes, each meeting stake_threshold on its own, so the collateral it puts up scales with the number of services rather than being shared across them. That is the stricter and more defensible reading — collateral that can be slashed or withheld for one service is not simultaneously securing another — but it raises the cost of multi-service participation, and with one service defined today the change has no present effect to observe.

In exchange, the note bookkeeping collapses. LockedNote and its declarations set are deleted; locked_notes maps a note directly to the zk_id it backs. Rejecting an already-locked note is the uniqueness rule, and unlocking on withdrawal needs no reference count.

Why provider_id is unique across the registry

Scoping the provider_id to a service — so one validator could present one network identity across several — was considered and rejected, because the networking model does not support it.

Each declaration carries its own locators, and a Locator is completed by appending the provider_id to form a usable libp2p address. Every locator list therefore belongs to exactly one peer identity. Binding one provider_id to two declarations would advertise two different address sets for that peer, and a node resolving it would have no basis for choosing between them.

The Blend protocol depends on the same one-to-one reading in three places: it decides whether a neighbour is a core node by looking the connection's peer_id up in the set of provider_ids; it breaks duplicate connections by comparing provider_ids as libp2p peer ids; and it already requires that any reuse of a provider_id invalidates an Active Message.

There is a key-hygiene argument as well. The Non-ephemeral Encryption Key is derived from the provider_id, so sharing one across two services would reuse a single static key between them — one compromise would cover both, and the two participations would be linkable to each other on the ledger. A distinct identity per declaration keeps them separate.

The cost is that a validator providing several services generates one NSK per service, alongside the distinct zk_id and locked note it already needs. In exchange GetDeclarationInfo(provider_id) has a single answer and does not need the service as a second argument.

The withdrawal delay is the locking period

The Overview promised that a node may withdraw "after the service-specific locking period", and the gas analysis priced withdrawal as verifying "that the note has exceeded its lock period". No such period was defined anywhere: ServiceParameters carries only inactivity_period, the Withdraw action listed no criterion for it, and the reference implementation checks the declaration, the note binding, the signature and the nonce and nothing else. Both statements described a mechanism that does not exist.

The existing withdrawal delay already does the job a locking period would. A node that withdraws in epoch e remains in the active set through e+2, because the registry is read from a two-epoch-old snapshot, and its note stays locked to exactly that point — so the collateral is held for as long as the node can still serve, and a further period would hold it for a window in which the node cannot act. There is no slashing in these specifications or in the implementation, so no penalty needs a longer window, and the minimum stake resists Sybils by what is locked simultaneously rather than for how long: a note backs one declaration, so N providers cost N thresholds at once however briefly each is held.

This PR therefore states the withdrawal that way and corrects the gas analysis, rather than specifying a period. Should slashing be introduced later, or a service want a minimum commitment as a stability guarantee, the rule would need a parameter in ServiceParameters and an anchor recording when the declaration began.

With no rule anchored on it, created is dead — written when the declaration is stored and read by nothing, in the specifications or the implementation — and is removed.

Backwards compatibility

This is a breaking change along two dimensions:

  • Serialization. SDPActive and SDPWithdraw change meaning. ZkId and DeclarationId are both 32 bytes, so SDPActive keeps exactly its current size — the leading 32 bytes simply denote a different thing, and a decoder written against the old grammar parses the new form successfully and misinterprets it rather than failing. SDPWithdraw additionally loses a field and shrinks from 72 to 40 bytes, which an old decoder does reject. SDPDeclare is unchanged.
  • Ledger state. declarations is re-keyed, and locked_notes changes value type. State written under the old layout cannot be read under the new one.

Both are pre-genesis changes. No chain state exists under the old layout and no operation has been encoded under the old grammar, so there is nothing to migrate and no coordinated upgrade to schedule. The incompatibility is recorded because it constrains when this can land, not because it implies a migration path: it must merge before genesis, and any implementation or test vector already written against declaration_id is updated rather than supported alongside the new form.

Details

The zk_id identifies the declaration (bedrock-service-declaration-protocol.md)

DeclarationInfo loses only its zk_id field, which becomes the key it is held under. The derivation, its serialization caveat, and the declarations list are removed.

 class DeclarationInfo:
     service: ServiceType
     provider_id: Ed25519PublicKey
-    locked_note_id: NoteId
-    zk_id: ZkPublicKey
     locators: list[Locator]
+    locked_note_id: NoteId
     created: EpochNumber
     active: EpochNumber | None
     withdraw_at: EpochNumber | None
     nonce: Nonce
-declaration_id = Hash(service||provider_id||zk_id||locators)
-declarations: list[declaration_id]
+declarations: dict[ZkPublicKey, DeclarationInfo]

Both messages address the declaration by zk_id, and neither gains a field, because the zk_id determines the declaration and the declaration determines its service:

 class ActiveMessage:
-    declaration_id: DeclarationId
+    zk_id: ZkPublicKey
     nonce: Nonce
     metadata: Metadata

 class WithdrawMessage:
-    declaration_id: DeclarationId
-    locked_note_id: NoteId
+    zk_id: ZkPublicKey
     nonce: Nonce

The note to release is no longer carried. A declaration locks exactly one, so the zk_id determines it; carrying it as well meant Mantle had to assert the two agreed, and the reference implementation carries an InvalidLockedNote error variant whose only purpose is that mismatch.

DeclarationMessage is unchanged. The event index keys zk_id instead of declaration_id. The active and withdraw actions are otherwise unchanged: withdrawal sets withdraw_at = e, and the declaration is removed and its note unlocked at e+2, immediately after its epoch-e reward is paid.

One declaration per service, one note per declaration (bedrock-service-declaration-protocol.md)

The Identifier Uniqueness section is restated around three rules:

  • zk_id — unique registry-wide, and structurally so: it is the key of declarations, so no derivation can produce two entries carrying the same one. A DeclarationMessage whose zk_id is already registered is rejected, whichever service it names.
  • locked_note_id — unique registry-wide. A note that already collateralizes a declaration must not be offered as collateral again, so the minimum stake is met independently for every service a validator provides.
  • provider_id — unique registry-wide. It is one libp2p peer identity, owning exactly one locators list, for the reasons given in Discussion.

Declare validation gains the locked-note criterion and scopes the provider_id criterion to the service:

 - The sender meets the stake requirements and its `locked_note_id` is valid.
-- The `declaration_id` is unique.
-- The `provider_id` and the `zk_id` are each unique in the context of the `service`.
+- The `zk_id` is not already registered in `declarations`.
+- The `locked_note_id` does not already back another declaration.
+- The `provider_id` is not already bound to another declaration.
 - The sender knows the secret behind the `provider_id` identifier.
-- The length of the `locators` list must not be longer than 8.
+- The `locators` list is non-empty and not longer than 8 entries.

GetDeclarationInfo(declaration_id) becomes GetDeclarationInfo(zk_id). GetDeclarationInfo(provider_id) keeps its signature and becomes unambiguous, which it was not before: a provider_id unique only within a service had one answer per service.

SDP operations are addressed by ZkId (mantle-transaction-encoding.md)

-SDPWithdraw   = DeclarationId Nonce LockedNoteId
-DeclarationId = Hash32
+SDPWithdraw   = ZkId Nonce
 Nonce         = UINT64

-SDPActive     = DeclarationId Nonce Metadata
+SDPActive     = ZkId Nonce Metadata
 Metadata      = UINT32 *BYTE  ; Service-specific node activeness metadata

DeclarationId has no remaining use and is removed from the grammar. SDPDeclare is untouched, and LockedNoteId stays defined for it. SDPActive keeps its size; SDPWithdraw drops 32 bytes, from 72 to 40.

The Mantle SDP operations follow (bedrock-v1.1-mantle-specification.md)

declarations becomes dict[ZkPublicKey, DeclarationInfo], and the LockedNote structure is deleted in favour of a direct binding:

-locked_notes: dict[NoteID, LockedNote]
-declarations: dict[DeclarationID, DeclarationInfo]
-
-class LockedNote:
-    declarations: set[DeclarationID]
+locked_notes: dict[NoteId, ZkPublicKey]
+declarations: dict[ZkPublicKey, DeclarationInfo]

SDP_DECLARE validation replaces the existence check on the derived id with the three uniqueness checks, the third of which is what enforces one note per declaration:

-assert declaration_id(declaration) not in declarations
+assert declaration.zk_id not in declarations
+assert declaration.locked_note_id not in locked_notes
+assert all(d.provider_id != declaration.provider_id for d in declarations.values())

Execution locks the note with locked_notes[declaration.locked_note_id] = declaration.zk_id and stores the declaration under its zk_id. SDP_ACTIVE and SDP_WITHDRAW address declarations[msg.zk_id], and withdraw additionally asserts the note is bound to that declaration. SDP Epoch Finalization keeps its single loop and drops the reference count, deleting the note's entry outright when the declaration is removed. Ledger.locked_notes and the Locked notes prose follow the new value type.

The active set is defined (bedrock-service-declaration-protocol.md)

The specification described a snapshot of the registry and stopped there, which reads as though membership is whatever the snapshot block contained. A new Active Set section states the derivation: for an epoch n, keep the declarations where active + inactivity_period >= n and where withdraw_at is None or n < withdraw_at + 2, both evaluated against n rather than against the epoch the snapshot was taken in.

This is what aligns membership with collateral. A declaration withdrawn in epoch e has its note unlocked at e+2, and the second condition drops it from the active set at e+2 as well — even though the snapshot it came from was read from an earlier block in which it was still stored and not yet withdrawn. Read literally, the previous wording would have left a withdrawn validator in the set for the length of the snapshot lag with its note already released: able to provide the service, and to prove membership in it through the Proof of Quota core root, with no stake at risk. The reference implementation has always filtered this way (is_active); the rule simply was not written down.

active also becomes non-optional. It was specified as EpochNumber | None, defaulting to None, with no statement of when a declaration that has never reported activity first goes inactive. It is initialised to the epoch of the block that contained the declaration plus two — the first epoch the declaration can appear in a snapshot — which gives a new declaration the same inactivity grace as one that has just reported activity.

Locator list serialization is re-anchored (bedrock-service-declaration-protocol.md)

The requirement that a Locator list be serialized with an element count and per-element byte lengths is kept, but stated as a property of every serialization of the list rather than of the (now absent) hash preimage. The reasoning for it — that concatenated multiaddr bytes are indistinguishable from a single longer multiaddr — is unchanged.

Chores

  • analysis-gas-cost-determination.md: the SDP Withdraw cost still described removing a declaration from a locked note's set and unlocking the note once no declaration referenced it — the reference counting this PR removes — and the SDP Activation cost referred to looking up a declaration ID. Both now match the zk_id-keyed registry.
  • bedrock-genesis-block.md: the initial service declarations named ServiceType.BLEND where the enum defines BN, wrapped the message in a Declaration type no specification defines, omitted the locked_note_id the message carries, and used ip://1.1.1.1:3000 where a Locator must be a multiaddr. They are now SDP_DECLARE Operations carrying a full DeclarationMessage, and the section records that each genesis declaration needs a distinct zk_id, provider_id and locked note.
  • bedrock-v1.1-mantle-specification.md: the withdraw proof check verified the ZkSignature against declare_info.zk_id, which this branch turns into the map key rather than a field. It now verifies against the message's zk_id.
  • blend-protocol.md: the reward flow required a node to "point to a single declaration (declaration_id)" when constructing its Active Message; it now points by zk_id. Its existing rule that reuse of a provider_id invalidates the message is unchanged, and is now also enforced at declaration time.
  • key-types-and-generation.md: the NQK was described as "the zk_id field in the DeclarationInfo"; it is now the key the entry is held under. Both the NQK and NSK sections record that one key is generated per declaration.
  • bedrock-v1.1-mantle-specification.md: the locked-note check in SDP_DECLARE built a list of DeclarationInfo objects and then tested a ServiceType for membership in it, so it could never fail. The check is removed along with the multi-declaration note structure it queried, and the one-note-per-declaration assertion takes its place.
  • bedrock-v1.1-mantle-specification.md: the SDP_DECLARE execution snippet mixed : and = in its constructor call and carried a stray declaration, argument; it is now valid Python.
  • bedrock-service-declaration-protocol.md: the Declare validation list required a monotonically increasing nonce, but DeclarationMessage carries no nonce. The criterion is dropped and the initialisation to 0 stated where the declaration is stored.

Implementation

  • Re-key the declaration registry from declaration_id to zk_id and remove the derivation and every call site that recomputed it
  • Replace the LockedNote structure with a direct NoteId → ZkPublicKey binding and drop the reference counting on unlock
  • Enforce the three registry-wide uniqueness rules at declaration validation, backing the provider_id check with an index rather than a scan if the registry size warrants it
  • Update the SDPActive and SDPWithdraw encoders and decoders; because SDPActive keeps its size, add a test that a payload built against the old grammar is not silently accepted as valid
  • Drop the locked note from the withdraw operation and derive it from the declaration, removing the mismatch error it needed
  • Add or extend tests / test vectors: encoding round-trips for the two changed operations; a second declaration reusing a zk_id, a locked note, or a provider_id is rejected, including across different services; withdrawal removes the declaration and releases its note after the final reward
  • Confirm the active-set filter matches the specification at the boundary epoch, in both withdraw_at conventions, and that a declaration leaves the set in the same epoch its note is unlocked
  • Verify the implementation matches this specification

Affected Specifications

Specification Status Note
Service Declaration Protocol Modified rev 1.4.0 — declarations keyed by zk_id; one note per declaration; declaration_id removed
Mantle Modified rev 1.11.0 — SDP operations and state follow; LockedNote removed
Mantle Transaction Encoding Modified rev 1.6.0 — SDPActive / SDPWithdraw addressed by ZkId; DeclarationId removed
Key Types and Generation Modified rev 1.0.2 — chore; the NQK is the declaration's key, and one NQK and NSK are generated per declaration
Blend Protocol Modified rev 1.1.2 — chore; the Active Message points to a declaration by zk_id
Analysis: Gas Cost Determination Modified rev 1.5.2 — chore; SDP costs no longer price a lock-period check or per-note declaration sets
Genesis Block Modified rev 1.1.4 — initial declarations aligned with the declaration protocol; see Chores
Service Reward Distribution Protocol Unchanged — flagged already keyed by zk_id, so it needs no edit and this change makes its assumption structural
Proof of Quota Unchanged — flagged builds the core Merkle tree over the declared zk_ids; unaffected, but it is the second consumer relying on the uniqueness this PR makes structural
Analysis: Static Minimum Stake Estimation Unchanged — flagged checked against the one-note-per-declaration rule: it derives the stake required per service provider and nowhere assumes collateral is shared across services, so its estimate carries over unchanged

…ion id

A declaration was identified by declaration_id = Hash(service || provider_id
|| zk_id || locators), a derived value that had to be recomputed to address a
declaration, bound fields that were never meant to be immutable, and produced
one registry entry per service. Keeping it correct had already cost two
revisions: one to make the identifiers unique per service, because distinct
declaration_ids did not imply distinct providers, and one to length-prefix the
locators so the preimage bound the list it committed to.

Make the zk_id the identifier instead. A validator has one declaration, held
under its zk_id, carrying the provider_id, locators and locked note it shares
across services, plus a services map holding the per-service active and
withdraw_at. A service is enabled for the declaration exactly when it is a key
of that map. Nothing is derived, so nothing has to be kept collision-free by
construction: zk_id uniqueness is structural, and provider_id uniqueness is a
single registry-wide check.

Withdrawal becomes per-service. The named service is disabled at epoch e+2,
after its final reward is paid; the declaration and its stake are released only
once no enabled service remains. Because zk_id alone does not say which service
a message concerns, the active and withdraw messages now carry a ServiceType.

Update the Mantle SDP operations and epoch finalization to match, and address
SDPActive and SDPWithdraw by ZkId and ServiceType in the transaction encoding.
ZkId and DeclarationId are both 32 bytes, so the added ServiceType costs one
byte per operation.

Enabling a further service on an existing declaration, and updating provider_id
or locators, need a message that is not defined here. Only BN exists today, so
a declaration is created with its single service enabled and its shared fields
fixed for its lifetime; the requirement is recorded for whenever a second
service type is added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines 178 to 189
class DeclarationInfo:
service: ServiceType
provider_id: Ed25519PublicKey
locked_note_id: NoteId
zk_id: ZkPublicKey
locators: list[Locator]
locked_note_id: NoteId
created: EpochNumber
nonce: Nonce
services: dict[ServiceType, ServiceState]

class ServiceState:
active: EpochNumber | None
withdraw_at: EpochNumber | None
nonce: Nonce
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a strong reason to do this grouping per service? my feeling is that we should keep things simple and require a unique declaration per service you wish to participate in.

that means, globally unique zk id across all declarations (independent of service)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only reason is to reduce the redundancy. With unique zk_id per service we duplicate provider_id, locked_note_id, locators, etc. So this complexity is to reduce the footprint of handling multiple services per node. Also, there is a question of how to handle locked_note_id in that case, do we allow reusing it for multiple services?

To really simplify things, we would need to accept higher on-chain footprint and require locking notes per-service. I'm fine with that.

Comment on lines +236 to +235
declaration_id: DeclarationId
zk_id: ZkPublicKey
service: ServiceType

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, lets have zk id unique across all declaration ids.

@davidrusu davidrusu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an observation, we removed something (declaration id) but the spec got bigger!

@madxor

madxor commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

We can make it smaller :)

madxor and others added 5 commits August 20, 2026 18:59
…ed note

The previous revision let one declaration span several services through a
services map, which pulled per-service state into every part of the protocol:
active and withdraw messages had to name a service, withdrawal disabled one
entry of the map, epoch finalization ran a disable pass before a removal pass,
and a locked note carried a set of the declarations it backed so the stake could
be released only once the last of them was gone.

Require instead a single declaration per service, each backed by its own locked
note. A DeclarationInfo carries one service, one activity value, one withdrawal
epoch and one note, and a validator that provides two services holds two
independent declarations under two zk_ids.

The per-service machinery goes with it. The active and withdraw messages carry
no ServiceType, since the zk_id determines the declaration and the declaration
determines the service, which also restores their encodings to their original
size. Withdrawal removes the declaration and releases its note outright, with no
reference count to check. The LockedNote structure is gone: locked_notes maps a
note directly to the zk_id it backs, and rejecting an already-locked note is
what enforces one note per declaration.

Requiring a distinct note per declaration means the minimum stake is met
independently for each service rather than one locked value counting towards
several. Uniqueness now splits by identifier: the zk_id is unique registry-wide
because it is the key, the locked note because a note backs one declaration, and
the provider_id per service, because a validator serving two services is one
peer that may present the same network identity in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The provider_id was unique only within a service, on the reasoning that a
validator providing two services is one peer that may present one network
identity in both. The networking model does not support that.

Each declaration carries its own locators, and a Locator is completed by
appending the provider_id to form a usable libp2p address, so every locator list
belongs to exactly one peer identity. Binding one provider_id to two
declarations would advertise two different address sets for that peer, leaving a
node resolving it with no basis to choose. Blend depends on the same one-to-one
reading: it decides whether a neighbour is a core node by looking the peer_id up
in the set of provider_ids, breaks duplicate connections by comparing
provider_ids as libp2p peer ids, and already requires that any reuse of a
provider_id invalidates an Active Message. The Non-ephemeral Encryption Key is
derived from the provider_id as well, so sharing one across services would reuse
a static key between them and link the two participations on the ledger.

Make the provider_id unique across the registry. All three identifiers of a
declaration are now registry-wide, and the declaration is a fully independent
unit: one service, one zk_id, one provider_id, one locked note, sharing nothing
with a validator's other declarations. GetDeclarationInfo(provider_id) has a
single answer again and no longer takes the service.

Record in Key Types that one NQK and one NSK are generated per declaration, and
point the Blend active message at a declaration by zk_id rather than by the
declaration_id this branch removes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…genesis

A withdraw message carried the locked note to release alongside the zk_id, but
a declaration locks exactly one note, so the zk_id already determines it. The
duplication only created a second value to reconcile: the Mantle validation
asserted the two matched, and the reference implementation carries an
InvalidLockedNote error variant whose sole purpose is that mismatch. Withdrawal
now derives the note from the declaration, and SDPWithdraw drops LockedNoteId,
taking it from 72 to 40 bytes.

Fix a reference left dangling by this branch: the withdraw proof check verified
the ZkSignature against declare_info.zk_id, which stopped being a field when the
zk_id became the key. It verifies against the message's zk_id.

Bring the genesis block into line with the declaration protocol. Its initial
declarations named ServiceType.BLEND where the enum defines BN, wrapped the
message in a Declaration type that no specification defines, omitted the
locked_note_id the message carries, and used ip://1.1.1.1:3000 where a Locator
must be a multiaddr. They are now SDP_DECLARE Operations carrying a full
DeclarationMessage, and the section states that each genesis declaration needs a
distinct zk_id, provider_id and locked note.

Trim the provider_id uniqueness rationale to the address-resolution argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… epoch

The Overview promised that a node may withdraw "after the service-specific
locking period", and the gas analysis priced withdrawal as verifying "that the
note has exceeded its lock period". No such period is defined anywhere:
ServiceParameters carries only inactivity_period, the Withdraw action lists no
criterion for it, and the reference implementation checks the declaration, the
note binding, the signature and the nonce and nothing else.

The withdrawal delay already does the job a locking period would. A node that
withdraws in epoch e stays in the active set through e+2 because the registry is
read from a two-epoch-old snapshot, and its note stays locked to exactly that
point, so the collateral is held for as long as the node can still serve. A
further period would hold it for a window in which the node cannot act. There is
no slashing in these specifications or in the implementation, so there is no
penalty needing a longer window, and the minimum stake resists Sybils by what is
locked simultaneously rather than for how long.

State the withdrawal that way in the Overview and correct the gas analysis, which
also still described removing a declaration from a locked note's set and
unlocking the note once no declaration referenced it -- the reference counting
this branch removes.

With no rule anchored on it, created is dead: written when the declaration is
stored and read by nothing. Remove it from the declaration structure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rules that keep the protocol safe existed only in the reference
implementation.

The first is how the active set is derived. The specification described a
snapshot of the registry and left it there, which reads as though membership is
whatever the snapshot block contained. It is not: the implementation filters the
snapshot with is_active, re-evaluating the activity and withdrawal conditions
against the epoch the set is being derived for. That filter is what aligns
membership with collateral. A declaration withdrawn in epoch e has its note
unlocked at e+2, and the filter drops it from the active set at e+2 as well,
even though the snapshot it came from was read from an earlier block in which it
was still stored. Taken literally, the previous wording would have left a
withdrawn validator in the set for the length of the snapshot lag with its note
already released — able to serve, and to prove membership, with no stake at
risk. Define the set explicitly.

Note the two documents mean different things by withdraw_at: the specification
records the withdrawal epoch e, the implementation the removal epoch e+2. The
condition is written here in the specification's own convention, as
n < withdraw_at + 2, which is the same boundary.

The second is the initial value of active. The specification made it optional
and set it to None, and never said when a declaration that has never reported
activity first goes inactive. The implementation initialises it to the
declaration epoch plus two — the first epoch the declaration can appear in a
snapshot — which gives a new declaration the same inactivity grace as one that
has just reported activity. Make it non-optional and state that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
madxor added a commit that referenced this pull request Aug 27, 2026
DeclarationInfo.active stored an epoch number without saying which epoch it was,
and three places in the specifications answered differently. Declaration Storage
called it "the latest epoch number for which the active message was sent", which
reads as the epoch the message was included in. The Active action said it is
"set to the epoch number indicated by metadata", and the Blend metadata carries
the epoch the proof attests to. The initial value proposed in #407 is the first
epoch the declaration can appear in a snapshot. Those three readings are up to
three epochs apart, and nothing on master reads the field, so the disagreement
has been latent.

Define active as the latest epoch the declaration was active for: the epoch an
accepted active message attests to. That is the value every consumer wants. The
reward layer attributes the epoch-N reward to a message submitted during N+1,
and a service activity policy needs to know which epoch it is judging. Neither
alternative records it, so both would leave every consumer re-deriving it.

The inactivity floor follows. "At least 2 epochs long due to finalization
reasons" counts the two-epoch snapshot delay and omits the epoch that separates
an epoch from the report attesting to it. At epoch n the freshest attested epoch
any snapshot can show is n-3, so once the active set rule from #407 lands —
active + inactivity_period >= n — an inactivity_period of 2 excludes every
declaration, including a perfectly honest one. State the floor as 3 + k for k
tolerated missed reports, and move the Blend default to 4 so that one active
message missing inclusion does not drop a node out of the service.

Three smaller gaps sat on the same seam. The SDP set active from metadata it
defines as opaque service-specific bytes; instead the activity logic returns the
attested epoch, which must exceed the recorded one, giving the Blend rule of one
active message per node per epoch a generic enforcement point rather than a
service-local one. SDP_ACTIVE asserted on the nonce without saying what becomes
of it, alone among the SDP operations; it is advanced whether or not the
activity logic approves the report. And an active message must stay valid after
a withdrawal is recorded, since a node reports its last rewardable epoch one
epoch after it withdraws, while the declaration is still stored.

The rest is wording. The event index is keyed by the epoch of the including
block, which for an active event is not the epoch it attests to. "Expired",
which appeared once and was defined nowhere, is replaced by what actually
happens to an inactive declaration. And "active message" becomes the single name
for the message, against five uses of "activity message" and one of "activation
message".

Unrelated to the above, four inline-math comparisons in blend-protocol.md use a
literal > and are escaped here. validate_rendering.py only checks the files a
change touches, so they were dormant on master until this change touched that
file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants