docs(blockchain): key SDP declarations by zk_id and drop the declaration id - #407
docs(blockchain): key SDP declarations by zk_id and drop the declaration id#407madxor wants to merge 6 commits into
Conversation
…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>
| 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 | ||
| ``` |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
| declaration_id: DeclarationId | ||
| zk_id: ZkPublicKey | ||
| service: ServiceType |
There was a problem hiding this comment.
yeah, lets have zk id unique across all declaration ids.
davidrusu
left a comment
There was a problem hiding this comment.
an observation, we removed something (declaration id) but the spec got bigger!
|
We can make it smaller :) |
…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>
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>
[RFC-PR] SDP Declarations Are Keyed by
zk_idReviewer Orientation
Prerequisite context: read Motivation first — it explains what the derived
declaration_idcost and why removing it is the simplification rather than a rename. The rewarding and quota constructions are assumed and unchanged.declarationsbecomesdict[ZkPublicKey, DeclarationInfo]anddeclaration_idis gone; check that nothing in the protocol still needs a value derived fromservice,provider_idandlocators, and that dropping it does not weaken any binding those fields hadSDPActive/SDPWithdrawDeclarationId→ZkId, both 32 bytes.SDPActivekeeps its size while its first field means something different, so a stale decoder misreads rather than failing;SDPWithdrawalso dropsLockedNoteId, 72 → 40 bytesprovider_idchange 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 peerswithdraw_atis the withdrawal epoche, the implementation's is the removal epoche+2, so the condition is writtenn < withdraw_at + 2here andwithdraw_at > nthere. Same boundary, and an easy off-by-two to get wrongLockedNoteremovedlocked_notesbecomesdict[NoteId, ZkPublicKey]. Confirm nothing outside SDP relied on a note carrying a set of declarations;Ledger.locked_notesand the spendability check are the two call sitesSDP_DECLAREprovider_iduniqueness assertion is a scan overdeclarations; flag if an index is expected insteaddeclaration_id-preimage rationale but is kept and widened to every serialization of the listzk_id; Key Types records one NQK and NSK per declaration; three latent defects fixed in passingStatus tracker
Change log
ServiceTypefield on the active and withdraw messages, and theLockedNotestructure are all removedprovider_idis unique across the registry rather than within a serviceWithdrawMessagedrops the redundant locked note; the genesis block's initial declarations are aligned with the declaration protocolcreatedwith itactiveare specified, closing two rules that existed only in the reference implementationMotivation
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_idandzk_id, because distinctdeclaration_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 thelocatorslist, 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_idis 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_idbecomes the identifier of a declaration, and the deriveddeclaration_idis removed.declarationsmaps aZkPublicKeyto oneDeclarationInfo, 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_idbecause it is the map key, thelocked_note_idbecause a note backs one declaration, and theprovider_idbecause it is one peer identity. Nothing is derived, so nothing has to be kept collision-free by construction.Because a
zk_iddetermines 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:
zk_idwas an enforced rule that had to be checked because the hash could not guarantee it. Under this proposal thezk_idis the map key, so no two entries can share one — there is nothing left to enforce, and no derivation whose preimage could be gamed.locatorslist 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
locatorsfed the hash, a validator that changed an address changed the identity of its declaration. Under the new structurelocatorsis 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_thresholdon 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.
LockedNoteand itsdeclarationsset are deleted;locked_notesmaps a note directly to thezk_idit backs. Rejecting an already-locked note is the uniqueness rule, and unlocking on withdrawal needs no reference count.Why
provider_idis unique across the registryScoping the
provider_idto 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 aLocatoris completed by appending theprovider_idto form a usable libp2p address. Every locator list therefore belongs to exactly one peer identity. Binding oneprovider_idto 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_idup in the set ofprovider_ids; it breaks duplicate connections by comparingprovider_ids as libp2p peer ids; and it already requires that any reuse of aprovider_idinvalidates 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_idand locked note it already needs. In exchangeGetDeclarationInfo(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:
ServiceParameterscarries onlyinactivity_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
eremains in the active set throughe+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
ServiceParametersand an anchor recording when the declaration began.With no rule anchored on it,
createdis 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:
SDPActiveandSDPWithdrawchange meaning.ZkIdandDeclarationIdare both 32 bytes, soSDPActivekeeps 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.SDPWithdrawadditionally loses a field and shrinks from 72 to 40 bytes, which an old decoder does reject.SDPDeclareis unchanged.declarationsis re-keyed, andlocked_noteschanges 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_idis updated rather than supported alongside the new form.Details
The
zk_ididentifies the declaration (bedrock-service-declaration-protocol.md)DeclarationInfoloses only itszk_idfield, which becomes the key it is held under. The derivation, its serialization caveat, and thedeclarationslist 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: NonceBoth messages address the declaration by
zk_id, and neither gains a field, because thezk_iddetermines the declaration and the declaration determines its service:The note to release is no longer carried. A declaration locks exactly one, so the
zk_iddetermines it; carrying it as well meant Mantle had to assert the two agreed, and the reference implementation carries anInvalidLockedNoteerror variant whose only purpose is that mismatch.DeclarationMessageis unchanged. The event index keyszk_idinstead ofdeclaration_id. The active and withdraw actions are otherwise unchanged: withdrawal setswithdraw_at = e, and the declaration is removed and its note unlocked ate+2, immediately after its epoch-ereward 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 ofdeclarations, so no derivation can produce two entries carrying the same one. ADeclarationMessagewhosezk_idis 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 onelocatorslist, for the reasons given in Discussion.Declare validation gains the locked-note criterion and scopes the
provider_idcriterion to the service:GetDeclarationInfo(declaration_id)becomesGetDeclarationInfo(zk_id).GetDeclarationInfo(provider_id)keeps its signature and becomes unambiguous, which it was not before: aprovider_idunique only within a service had one answer per service.SDP operations are addressed by
ZkId(mantle-transaction-encoding.md)DeclarationIdhas no remaining use and is removed from the grammar.SDPDeclareis untouched, andLockedNoteIdstays defined for it.SDPActivekeeps its size;SDPWithdrawdrops 32 bytes, from 72 to 40.The Mantle SDP operations follow (
bedrock-v1.1-mantle-specification.md)declarationsbecomesdict[ZkPublicKey, DeclarationInfo], and theLockedNotestructure is deleted in favour of a direct binding:SDP_DECLAREvalidation replaces the existence check on the derived id with the three uniqueness checks, the third of which is what enforces one note per declaration:Execution locks the note with
locked_notes[declaration.locked_note_id] = declaration.zk_idand stores the declaration under itszk_id.SDP_ACTIVEandSDP_WITHDRAWaddressdeclarations[msg.zk_id], and withdraw additionally asserts the note is bound to that declaration.SDP Epoch Finalizationkeeps its single loop and drops the reference count, deleting the note's entry outright when the declaration is removed.Ledger.locked_notesand 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 whereactive + inactivity_period >= nand wherewithdraw_atisNoneorn < withdraw_at + 2, both evaluated againstnrather than against the epoch the snapshot was taken in.This is what aligns membership with collateral. A declaration withdrawn in epoch
ehas its note unlocked ate+2, and the second condition drops it from the active set ate+2as 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.activealso becomes non-optional. It was specified asEpochNumber | None, defaulting toNone, 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
Locatorlist 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 thezk_id-keyed registry.bedrock-genesis-block.md: the initial service declarations namedServiceType.BLENDwhere the enum definesBN, wrapped the message in aDeclarationtype no specification defines, omitted thelocked_note_idthe message carries, and usedip://1.1.1.1:3000where aLocatormust be a multiaddr. They are nowSDP_DECLAREOperations carrying a fullDeclarationMessage, and the section records that each genesis declaration needs a distinctzk_id,provider_idand locked note.bedrock-v1.1-mantle-specification.md: the withdraw proof check verified theZkSignatureagainstdeclare_info.zk_id, which this branch turns into the map key rather than a field. It now verifies against the message'szk_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 byzk_id. Its existing rule that reuse of aprovider_idinvalidates the message is unchanged, and is now also enforced at declaration time.key-types-and-generation.md: the NQK was described as "thezk_idfield in theDeclarationInfo"; 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 inSDP_DECLAREbuilt a list ofDeclarationInfoobjects and then tested aServiceTypefor 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: theSDP_DECLAREexecution snippet mixed:and=in its constructor call and carried a straydeclaration,argument; it is now valid Python.bedrock-service-declaration-protocol.md: the Declare validation list required a monotonically increasingnonce, butDeclarationMessagecarries no nonce. The criterion is dropped and the initialisation to 0 stated where the declaration is stored.Implementation
declaration_idtozk_idand remove the derivation and every call site that recomputed itLockedNotestructure with a directNoteId → ZkPublicKeybinding and drop the reference counting on unlockprovider_idcheck with an index rather than a scan if the registry size warrants itSDPActiveandSDPWithdrawencoders and decoders; becauseSDPActivekeeps its size, add a test that a payload built against the old grammar is not silently accepted as validzk_id, a locked note, or aprovider_idis rejected, including across different services; withdrawal removes the declaration and releases its note after the final rewardwithdraw_atconventions, and that a declaration leaves the set in the same epoch its note is unlockedAffected Specifications
zk_id; one note per declaration;declaration_idremovedLockedNoteremovedSDPActive/SDPWithdrawaddressed byZkId;DeclarationIdremovedzk_idzk_id, so it needs no edit and this change makes its assumption structuralzk_ids; unaffected, but it is the second consumer relying on the uniqueness this PR makes structural