Introduction
We express our gratitude to the Europeum team for the collaborative engagement that enabled the execution of this Smart Contract Security Assessment.
Issuers Registry is an on-chain registry that records which decentralised identifiers (DIDs) are trusted to issue verifiable credentials. It maintains an append-only accreditation hierarchy (RootTAO → TAO → TI) and per-issuer proxy records, with authorization delegated to the external Policies Registry and DID Registry.
Document | |
|---|---|
| Name | Smart Contract Code Review and Security Analysis Report for Europeum |
| Audited By | Khrystyna Tkachuk |
| Approved By | Ivan Bondar |
| Website | https://europeum.eu/→ |
| Changelog | 03/08/2026 - Preliminary Report |
| 10/08/2026 - Final Report | |
| 12/08/2026 - Updated Final Report | |
| Platform | Private Chain |
| Language | Solidity |
| Tags | Upgradable, Decentralized Identity (DID) |
| Methodology | https://docs.hacken.io/methodologies/smart-contracts→ |
Document
- Name
- Smart Contract Code Review and Security Analysis Report for Europeum
- Audited By
- Khrystyna Tkachuk
- Approved By
- Ivan Bondar
- Website
- https://europeum.eu/→
- Changelog
- 03/08/2026 - Preliminary Report
- 10/08/2026 - Final Report
- 12/08/2026 - Updated Final Report
- Platform
- Private Chain
- Language
- Solidity
- Tags
- Upgradable, Decentralized Identity (DID)
Review Scope | |
|---|---|
| Repository | https://gitlab.com/europeum/public/core-services/→ |
| Commit | db871ad |
| Final Commit | 82c4740 |
| Updated Final Commit | d1535ba |
Review Scope
- Commit
- db871ad
- Final Commit
- 82c4740
- Updated Final Commit
- d1535ba
Audit Summary
The system users should acknowledge all the risks summed up in the risks section of the report
Documentation quality
Functional requirements are partially missed.
Technical description is provided.
NatSpec is sufficient.
Code quality
The code leverages OpenZeppelin 5.6.1 and shared in-house primitives, and follows the established beacon-proxy upgradeable pattern with a reserved storage gap.
The codebase is well-structured and clearly organized.
The development environment is configured.
Test coverage
Code coverage of the project is 79.86% (statement coverage).
Deployment and basic user interactions are covered with 44 passing tests.
Negative cases coverage is present.
System Overview
The system consists of a single stateful contract, IssuersRegistry, supported by the internal Pagination library. It is deployed behind a versioned beacon proxy (@ebsiint-sc/beacon-proxy), initialized once via initialize, Authorization depends entirely on two addresses fixed at initialization: the Policies Registry (IPolicyRegistry.checkPolicy) and the DID Registry (IDidRegistry.checkController). The internal checkEligibility helper is the sole gate on accreditation, short-circuiting when the caller holds the TPR policy, otherwise requiring the caller to control a taoDid whose latest revision carries the RootTAO or TAO issuer type and to be within the target attribute's existing trust chain.
Issuer state is keyed by DID string. Each issuer holds a list of attributes; each attribute is identified by the hash of its first revision and accumulates further revisions in an append-only chain. Revision metadata stores issuerType, accrediting taoDid, and rootTaoDid. Accreditation writes go through setAttributeMetadata (metadata / trust-chain updates); the accredited issuer publishes the attribute payload through setAttributeData. Revocation is expressed as an additional revision with IssuerType.Revoked, not as deletion. Independently, each issuer may store proxy records (e.g. status-list endpoints) keyed by sha256(proxyData).
List reads are paginated (page size 1–50) via Pagination. Attribute payloads and proxy data are opaque on-chain bytes/strings; semantic validation is left to off-chain verifiers.
Files in Scope
IssuersRegistry.sol — Upgradeable registry implementation: stores issuers, attribute revision chains, and proxy records; enforces eligibility via the Policies and DID registries; exposes accreditation, data-publication, and proxy lifecycle writers plus paginated readers.
Pagination.sol — Internal library for cursor and page arithmetic used by all paginated view functions.
Privileged roles
IssuersRegistry.sol:
TAO / RootTAO DID controller (via DID Registry + latest revision type): Controller of a
taoDidwhose latest revision isRootTAOorTAO, and which belongs to the target attribute’s trust chain (waived for new attributes). May accredit subordinates asTAOorTI, revise metadata within its chain, and revoke subordinates. Cannot createRootTAOentries without the policy.Issuer DID controller (via DID Registry): Controller of the issuer DID. May call
setAttributeDatato publish attribute payloads (and clearnoAttributesAccepted), and may add, update, or remove that DID’s proxy records.TIR:updateIssuerpolicy holder (via Policies Registry): May add, update, or remove proxy records for any registered issuer DID, without controlling that DID.TIR:setAttributeMetadatapolicy holder (via Policies Registry): Bypasses trust-chain eligibility. May create any issuer type includingRootTAO, write or revise attribute metadata for any DID, reassigntaoDid/rootTaoDid, and revoke any attribute.
Potential Risks
Append-only registry arrays enumerated against a hard page ceiling of 50: setAttributeMetadata pushes to issuers.didStore and to iss.attributes, and addRevision pushes to iss.revisionHashes[attributeId], with no code path anywhere in the contract that pops or deletes from these arrays. Every enumerating view, getIssuers, getIssuerAttributes, getIssuerAttributeRevisions, and getIssuerProxies, rejects any pageSize above 50, so retrieving the complete issuer set or a full revision history requires a linearly growing number of calls and no on-chain consumer can read either in a single call.
Deprecated reader materializes full payloads per page: getIssuerAttributeRevisions__deprecated loops over up to 50 revision hashes and, for each, copies an AttributeMetadata record and the associated iss.revisions[hashId] payload into a returned Attribute struct, whereas the current getIssuerAttributeRevisions returns bare bytes32 hashes. Response cost on the deprecated path therefore scales with the size of the stored accreditation blobs rather than with the page size alone, and those blob sizes are chosen by the issuers themselves through setAttributeData.
Policy holders bypass the entire trust-chain model: checkEligibility evaluates getPolicyRegistry().checkPolicy(policy, msg.sender) first and returns immediately when it is true, skipping the controller check, the TAO/RootTAO type check, and the chain-membership comparison against lastAttrMetadata.taoDid and lastAttrMetadata.rootTaoDid. Any address granted TIR:setAttributeMetadata can therefore write attribute metadata at any position in the hierarchy, and any address granted TIR:updateIssuer satisfies the first branch of the || guard in addIssuerProxy, updateIssuerProxy, and removeIssuerProxy for every registered issuer. Compromise of a single policy-holding key is equivalent to full write control over the trust registry.
Two coarse policy strings govern all privileged behaviour: The contract recognizes exactly two policy names, the literal "TIR:setAttributeMetadata" passed from setAttributeMetadata into checkEligibility and the literal "TIR:updateIssuer" checked in addIssuerProxy, updateIssuerProxy, and removeIssuerProxy. Creating a RootTAO, accrediting a TAO, issuing a TI attribute, and revoking an existing issuer all collapse into the single TIR:setAttributeMetadata grant, and adding, updating, and deleting proxy records collapse into TIR:updateIssuer. Granting an operator the minimum permission for one routine task necessarily confers every other privileged action of the same family across every registered issuer.
Absence of time-lock mechanisms for critical operations: Without time-locks on critical operations, there is no buffer to review or revert potentially harmful actions, increasing the risk of rapid exploitation and irreversible changes. InsertUserAttributes and insertScopedUserAttributes grant TIR:setAttributeMetadata or TIR:updateIssuer with immediate effect, addRevision writes a Revoked revision that verifiers observe through getLatestRevisionAttributeId in the same block, and addVersion followed by upgradeProxyToVersion can move the registry proxy onto new logic in consecutive transactions. Relying parties and affected issuers have no window in which to detect a pending change to the trust hierarchy or the registry logic before it becomes authoritative.
Registry entries are created for DIDs without their consent: setAttributeMetadata pushes a caller-supplied did string into issuers.didStore and appends an attribute whenever iss.attributes.length 0, requiring no signature or approval from the controller of that DID; authorization is checked only against the caller's policy grant or their control of taoDid. The only trace of non-consent is the noAttributesAccepted flag, initialized to true at creation and cleared wholesale by setAttributeData for the entire entity rather than per attribute, so an entity can appear in the public issuer list with an unrequested issuerType and trust chain, and once any single attribute is accepted the flag no longer distinguishes accepted attributes from unsolicited ones.
Cross-hierarchy accreditation of any DID via the new-attribute waiver: checkEligibility skips the trust-chain membership check whenever the target attribute does not yet exist, so any live TAO can attach a TAO or TI credential to an arbitrary DID — including one outside its hierarchy and with no DID document — without the target's consent. The forged record is enumerated by getIssuers as a live accreditation under the attacker's root; attributes are never deleted, and revocation requires the attribute's recorded taoDid or rootTaoDid, which are the attacker's strings, so the target and its own RootTAO cannot clear it.
Trust roots are minted exclusively by policy holders: In checkEligibility, when issuerType IssuerType.RootTAO and the caller lacks the policy, execution reverts with PolicyAttributeMissing, so RootTAO records can only originate from an address holding TIR:setAttributeMetadata. setAttributeMetadata then forces taoDid = did and _rootTaoDid = did for that entry, producing a self-rooted hierarchy. The set of accounts holding this policy in the external PolicyRegistry is therefore a single point of failure for the legitimacy of every trust chain the registry records.
Registry logic is replaceable behind a versioned beacon: IssuersRegistry is an Initializable implementation with _disableInitializers in its constructor and a trailing uint256[50] private ______gap, deployed behind VersionedBeaconProxy and resolved through VersionedUpgradeableBeacon, whose implementation returns the version pinned for the calling proxy and falls back to _latestVersion for unpinned callers. getImplementationInitSelector returns this.initialize.selector, tying the deployment convention to this specific initializer signature. A future implementation registered through addVersion can redefine any function, including checkEligibility and checkController, while retaining all accumulated issuer, attribute, revision, and proxy state.
Registry writes halt when a policy name is undefined or deactivated: PolicyRegistry._checkPolicy reverts with PolicyInactiveOrNotDefined whenever the resolved policy has status false, which includes a name never inserted, since checkPolicy resolves an unknown name through tryGet to policy id 0. The two policy names are embedded as string literals at their call sites in setAttributeMetadata, addIssuerProxy, updateIssuerProxy, and removeIssuerProxy rather than as named constants, and the registry never verifies that they exist, so correct operation depends on an operator having previously called insertPolicy with byte-identical names on the configured instance. Because checkEligibility calls checkPolicy as its first statement and the proxy functions place it on the left of their || guard, a typo or a single deactivatePolicy call renders those write paths inoperable for every caller, including legitimate DID controllers, with no override inside IssuersRegistry.
Meaning of stored payloads is established entirely off-chain: The registry stores attributeData as opaque bytes in iss.revisions and proxyData as an opaque string in iss.proxiesStore, and never parses, schema-checks, or signature-verifies either. Accreditation semantics, status-list endpoint resolution, and credential validity are determined solely by off-chain EBSI verifier software reading these blobs through getLatestRevisionAttribute and getIssuerProxyById, so divergence between verifier implementations or unavailability of the endpoints named inside proxyData breaks credential verification without any observable on-chain fault.
Trust chain snapshots are frozen at accreditation time: In setAttributeMetadata, _rootTaoDid is read once from issuers.attributeMetadataStore[lastRevisionIdTao].rootTaoDid and written verbatim into the new record by addRevision, and setAttributeData likewise copies lastAttrMetadata.taoDid and lastAttrMetadata.rootTaoDid forward unchanged. No function re-evaluates a downstream issuer's chain when its accrediting TAO is later revoked or re-parented, and none iterates descendants. Revoking a TAO by appending a Revoked revision therefore leaves every issuer it previously accredited with an unchanged issuerType and a stale taoDid and rootTaoDid, so verifiers must independently walk and re-validate each level of the hierarchy.
Any chain ancestor can re-parent an attribute without a distinguishable signal: checkEligibility accepts the caller when the supplied taoDid matches either lastAttrMetadata.taoDid or lastAttrMetadata.rootTaoDid, and in both cases the caller's taoDid is written into the new revision. A RootTAO controller can thus append a revision that replaces the intermediate TAO recorded on an existing issuer attribute, while the only trace is the generic AddAttributeRevision event carrying did, attributeId, revisionId, and issuerType, so relying parties tracking events alone cannot observe that the accrediting parent changed.
No on-chain distinction between sanctioned and self-declared trust roots: When issuerType IssuerType.RootTAO, setAttributeMetadata overwrites the caller's taoDid argument with did and sets _rootTaoDid = did, so a RootTAO vouches only for itself. The registry maintains no allowlist of sanctioned roots and getIssuers returns all DIDs in didStore without differentiating them by type, so consumers cannot determine from registry data which of several RootTAO entries represents the governing authority and must carry that judgment in off-chain configuration.
Revision identifiers are content hashes in a registry-wide namespace: setAttributeData derives newRevisionId = sha256(attributeData) from the payload alone, and addRevision rejects the write with RevisionAlreadyStored whenever issuers.attributeMetadataStore[newRevisionId].did is already populated in that globally keyed mapping. Two different issuers consequently cannot store byte-identical attribute payloads, and the first party to register a given payload permanently occupies that identifier registry-wide, so standardized or templated accreditation bodies that are byte-identical across issuers are storable only by the first submitter.
New attribute identifiers are taken directly from caller input: For a first-time attribute, setAttributeMetadata uses the caller-supplied revisionId verbatim as attributeId and pushes it into iss.attributes without deriving it from content or verifying its provenance, while only subsequent revisions are derived through sha256(abi.encode(block.timestamp, did, lastRevisionId)). The AttributeAlreadyStored guard then rejects any later attempt to reuse that bytes32 under a different DID, making attribute identifiers a first-come-first-served global namespace populated with unvalidated caller input.
Revision history carries no on-chain ordering metadata: Ordering is expressed solely by push order into iss.revisionHashes[attributeId], and AttributeMetadata stores no timestamp or sequence number even though block.timestamp enters the derivation of metadata revision ids. Consumers reading getIssuerAttributeRevisions can therefore establish only the relative position of a revision, not when it was recorded, and must reconstruct timing from event logs held off-chain.
Accreditation data is permanently public and cannot be erased: issuers.didStore holds plaintext DID strings enumerable through getIssuers, iss.revisions holds raw accreditation payloads readable through getRevisionAttribute and getLatestRevisionAttribute, and iss.proxiesStore holds proxy and status-list payloads readable through getIssuerProxyById, with all of it additionally recoverable from the AddAttributeRevision, AddIssuerProxy, and UpdateIssuerProxy events. No function removes an entry from didStore, iss.attributes, or iss.revisionHashes, and addRevision only appends; removeIssuerProxy is the sole removal primitive and merely blanks iss.proxiesStore[proxyId] while swapping the id out of proxies, leaving the original payload in chain history. Any personal or organizational data placed into attributeData or proxyData becomes irrevocably public, and revocation expressed as a further Revoked revision leaves the full prior accreditation record readable indefinitely.
DID strings are compared byte-for-byte with no normalization: compareStrings reduces to keccak256(abi.encodePacked(str1)) keccak256(abi.encodePacked(str2)), getLatestRevisionAttributeId and getRevisionAttribute compare with keccak256(bytes(...)), and issuers.issuerStore is keyed on the raw string supplied by the caller. No trimming, case folding, or DID-syntax validation is performed anywhere in the contract, and checkController forwards bytes(did) to the DID registry unaltered, so two encodings of the same logical DID create two independent issuer records with separate attribute sets and trust chains, with no on-chain means of detecting the split.
Mixed hashing conventions across the contract: The registry uses sha256 for revisionId and proxyId derivation, keccak256(abi.encodePacked(...)) inside compareStrings, and keccak256(bytes(...)) for the direct DID comparisons in getIssuerAttributeRevisions, getLatestRevisionAttributeId, and getRevisionAttribute. Off-chain clients constructing revisionId, attributeIdTao, or proxyId values must reproduce the exact function used at each call site, and a client applying the wrong algorithm receives AttributeNotFound or ProxyNotFound rather than an explicit encoding error.
Deprecated read functions remain part of the live ABI: getIssuerAttributeRevisions__deprecated and getIssuerAttributeByHash__deprecated are still declared in IIssuersRegistry and implemented in IssuersRegistry, returning fully materialized Attribute structs and tuple data respectively, alongside the current getIssuerAttributeRevisions which returns bare revision hashes. getIssuerAttributeByHash__deprecated performs no issuer-existence check, validating only that the resolved did is non-empty, unlike its counterparts which revert with IssuerDoesNotExist. Integrators on the deprecated entry points therefore receive a different data shape and weaker input validation, and removal in a future beacon version would break those consumers.
Findings
Code ― | Title | Status | Severity | |
|---|---|---|---|---|
| F-2026-1873 | Unconstrained attributeIdTao in setAttributeMetadata Silently Re-Anchors an Existing Attribute to a Foreign RootTAO | fixed | Medium | |
| F-2026-1873 | Unsalted Revision Identifiers in a Global attributeMetadataStore Permit Permanent Exact-Value Denial | fixed | Medium | |
| F-2026-1876 | Missing Live-Ancestry Validation in checkEligibility Lets a TAO Clone Its Authority and Keep Accrediting After Revocation | fixed | Medium | |
| F-2026-1876 | An Empty did Defeats the attributeMetadataStore Emptiness Sentinel | fixed | Low | |
| F-2026-1876 | Empty Payloads in the Proxy Functions Permanently Desynchronise proxies, proxyIndex and proxiesStore | fixed | Low | |
| F-2026-1876 | A Reverting checkPolicy Short-Circuits Every DID-Controller Fallback and Blocks Four of Five Write Functions | fixed | Low | |
| F-2026-1874 | Revoked Is Not Terminal and No View or NatSpec Statement Conveys Effective Trust-Chain Status | fixed | Observation | |
| F-2026-1876 | getPaginationParameters Computes an Unused Cursor on the Out-of-Range Page Path | fixed | Observation | |
| F-2026-1876 | updateIssuerProxy Breaks the Content Commitment of proxyId and Blocks Re-Insertion of the Original Payload | fixed | Observation | |
| F-2026-1876 | getRevisionAttribute Does Not Bind revisionId to the Requested Attribute and Silently Returns the Wrong Attribute's State | fixed | Observation |
Appendix 1. Definitions
Severities
When auditing smart contracts, Hacken is using a risk-based approach that considers Likelihood, Impact, Exploitability and Complexity metrics to evaluate findings and score severities.
Reference on how risk scoring is done is available through the repository in our Github organization:
Severity | Description |
|---|---|
Critical | Critical vulnerabilities are usually straightforward to exploit and can lead to the loss of user funds or contract state manipulation. |
High | High vulnerabilities are usually harder to exploit, requiring specific conditions, or have a more limited scope, but can still lead to the loss of user funds or contract state manipulation. |
Medium | Medium vulnerabilities are usually limited to state manipulations and, in most cases, cannot lead to asset loss. Contradictions and requirements violations. Major deviations from best practices are also in this category. |
Low | Major deviations from best practices or major Gas inefficiency. These issues will not have a significant impact on code execution. |
Severity
- Critical
Description
- Critical vulnerabilities are usually straightforward to exploit and can lead to the loss of user funds or contract state manipulation.
Severity
- High
Description
- High vulnerabilities are usually harder to exploit, requiring specific conditions, or have a more limited scope, but can still lead to the loss of user funds or contract state manipulation.
Severity
- Medium
Description
- Medium vulnerabilities are usually limited to state manipulations and, in most cases, cannot lead to asset loss. Contradictions and requirements violations. Major deviations from best practices are also in this category.
Severity
- Low
Description
- Major deviations from best practices or major Gas inefficiency. These issues will not have a significant impact on code execution.
Potential Risks
The "Potential Risks" section identifies issues that are not direct security vulnerabilities but could still affect the project’s performance, reliability, or user trust. These risks arise from design choices, architectural decisions, or operational practices that, while not immediately exploitable, may lead to problems under certain conditions. Additionally, potential risks can impact the quality of the audit itself, as they may involve external factors or components beyond the scope of the audit, leading to incomplete assessments or oversight of key areas. This section aims to provide a broader perspective on factors that could affect the project's long-term security, functionality, and the comprehensiveness of the audit findings.
Appendix 2. Scope
The scope of the project includes the following smart contracts from the provided repository:
Scope Details | |
|---|---|
| Repository | https://gitlab.com/europeum/public/core-services/→ |
| Commit | db871ad07ff4dd62e89dceeb08cacc4bbb50103f |
| Final Commit | 82c47401904b6dba2aa6319f868107706f7f9f79 |
| Updated Final Commit | d1535bacb0ee03fc987339c58324b34c3db14687 |
| Whitepaper | - |
| Requirements | README.md; NatSpec |
| Technical Requirements | README.md: NatSpec |
Scope Details
- Commit
- db871ad07ff4dd62e89dceeb08cacc4bbb50103f
- Final Commit
- 82c47401904b6dba2aa6319f868107706f7f9f79
- Updated Final Commit
- d1535bacb0ee03fc987339c58324b34c3db14687
- Whitepaper
- -
- Requirements
- README.md; NatSpec
- Technical Requirements
- README.md: NatSpec
Assets in Scope
Appendix 3. Additional Valuables
Additional Recommendations
The smart contracts in the scope of this audit could benefit from the introduction of automatic emergency actions for critical activities, such as unauthorized operations like ownership changes or proxy upgrades, as well as unexpected fund manipulations, including large withdrawals or minting events. Adding such mechanisms would enable the protocol to react automatically to unusual activity, ensuring that the contract remains secure and functions as intended.
To improve functionality, these emergency actions could be designed to trigger under specific conditions, such as:
Detecting changes to ownership or critical permissions.
Monitoring large or unexpected transactions and minting events.
Pausing operations when irregularities are identified.
These enhancements would provide an added layer of security, making the contract more robust and better equipped to handle unexpected situations while maintaining smooth operations.
Frameworks and Methodologies
This security assessment was conducted in alignment with recognised penetration testing standards, methodologies and guidelines, including the NIST SP 800-115 – Technical Guide to Information Security Testing and Assessment →, and the Penetration Testing Execution Standard (PTES) →, These assets provide a structured foundation for planning, executing, and documenting technical evaluations such as vulnerability assessments, exploitation activities, and security code reviews. Hacken’s internal penetration testing methodology extends these principles to Web2 and Web3 environments to ensure consistency, repeatability, and verifiable outcomes.