Skip to content

Smart Contract Design: Recommendations and Security

The design of a smart contract lays the foundation for its security, extensibility, and maintainability. It shapes the components the system contains and the relationships between them. Time invested in a secure, robust design prevents a range of security issues and sustains the system over the long term. An insecure or poorly planned design produces vulnerabilities that are incredibly hard to mitigate, leaves the system difficult to manage and maintain, and ends in significant financial losses.

Composable Design

Composability is a fundamental aspect of software development and applies directly to smart contracts. It arranges and integrates distinct software components into new systems or outputs, and the resulting modular structure benefits developers starting from scratch and those refactoring an existing codebase alike. Such a structure is easier to understand and maintain, and it contains errors or vulnerabilities within individual modules, which drastically reduces the risk of a large-scale system compromise.

Implemented correctly, each smart contract becomes a foundational building block for other projects. A smart contract exists in its own source unit as a deployed contract, or comprises one or more abstract contracts separating business logic that the compiler eventually merges into a single source unit. Developers therefore reuse existing contracts rather than reinventing the wheel, and building atop well-known libraries such as OpenZeppelin supplies a degree of modularity from the start.

Three central principles underpin composability: modularity, autonomy, and discoverability. Modularity is the capacity of individual components to perform specific tasks, and the separation into modules follows the separation of concerns in the business logic domain. Autonomy means each composable component, each Ethereum smart contract, operates independently. A smart contract is an isolated system free of external factors unless specifically designed to integrate with one, which speeds up development of localized features and improves testability.

Discoverability ensures the source code of components is publicly available. For deployed components, users and developers compile the published source and match the bytecode hash against what sits on-chain to verify integrity, a check that blockchain explorers like Etherscan offer as a service. It also lets developers fork specific functionality or entire systems for use cases modularity alone does not cover.

As an umbrella over modularity, autonomy, and discoverability, composability speeds up future development while keeping the code understandable to third-party readers such as users and security auditors. As a side effect, it fosters community trust and inspires other developers to innovate on top of the code.

Existing Code Bases

A codebase created for a proof-of-concept or an MVP is rarely modularized appropriately, and the transition from a monolithic system to a modular architecture seems daunting. An incremental approach removes most of that difficulty.

Begin by identifying the distinct functionalities within the system and mapping out the existing logic and data flows. Detach those functionalities from the main body of the code one at a time. Implement thorough testing for each newly isolated module, confirming its behavior is consistent before and after extraction, which surfaces points of failure early and keeps more impactful issues from appearing later. Patience and careful planning matter here, since a rushed transition introduces new errors and vulnerabilities.

New Projects

A blank canvas is the best opportunity to build a modular architecture, and planning is the key. Define the functionalities the system requires and work out how they break down into distinct, independent modules. Draw a map of the proposed system that visualizes how those modules interact and communicate. The planning stage identifies problems and vulnerabilities before a single line of code exists, which saves significant time and resources.

When defining modules, encapsulate specific functionalities while minimizing dependencies. Each module holds only the permissions it needs to perform its role and nothing more, following the Principle of Least Privilege, which limits the damage an attacker inflicts after gaining control of a single module. The result is a robust and flexible system with fewer opportunities for error and an easier path to future modifications.

For established and new projects alike, the essence of modular smart contract design is careful planning and execution. A well-architected system with a clear separation of concerns and trust zones, adhering to the Principle of Least Privilege, is a formidable defense against many common vulnerabilities.

Architecture Diagrams

System Architecture Diagrams (SAD) illustrate the smart contracts within a system, their interconnections, and their interactions with external systems or actors. Shapes or colors distinguish contract types such as upgradeable, proxy, or library contracts, and arrows signify information or control flow.

Tools like draw.io, Lucidchart, or Microsoft Visio create these diagrams. For smart contracts specifically, Surya generates a graphical overview of a contract's inheritance tree, which then feeds into the architecture diagram.

Architecture diagrams serve two purposes. First, they simplify complex systems by converting detailed information into an easily understandable format, so that a viewer observes how things interact and understands the cascading effects. Second, they improve collaboration and communication. Consistency is a significant concern in software engineering, and miscommunications or discrepancies arise among developers and project teams. Accuracy, standardization, and detail in the diagrams is what keeps collaboration working. The following partial system architecture diagram comes from an audit for EigenLayer:

EigenLayer System Architecture Diagram

Whatever its purpose, an architectural diagram is challenging to build efficiently, because it requires a careful balance between automatically generated and manually created content to minimize workload, address different concerns, and cover multiple abstraction levels. Keeping diagrams updated as the design evolves demands additional effort, and modern architectures introduce extra complexities that reflect into the charts. Five guidelines apply:

  • The number of diagrams significantly affects the comprehensibility of the architecture. The decision rests on the nature and complexity of the architecture, the skills and experience of the software architect, available time, the maintenance work required, and what addresses stakeholder concerns. It is a balancing act: too few diagrams leave portions of the architecture undocumented, and too many inflate the effort of keeping them consistent.
  • Consistency, both structural and semantic, across diagrams is critical for interpreting the architecture accurately. Structural consistency keeps the aesthetics uniform across boxes, shapes, borders, lines, and colors. Semantic consistency periodically synchronizes all diagrams with the latest code changes and with each other, so that a change in one diagram is reflected in the rest.
  • Fragmentation occurs where two or more diagrams illustrate the same quality attribute and each remains incomplete on its own, which makes the architecture harder to understand and maintain. Remove diagrams that do not reflect quality attributes tied to architecturally significant requirements, or consolidate them.
  • Traceability is vital for version control and historical referencing. Use tools that expose the history, compare diagram versions, and revert swiftly, which tracks architectural changes over time and eases recovery from a mishap or a design alteration.
  • Legends matter most where a standard architectural description language such as UML is not in use. A legend details every piece of the diagram, including boxes, shapes, borders, lines, colors, and acronyms, so that any viewer reads it correctly regardless of familiarity with the system. Where a standard language is used, naming it in the legend suffices.

Keeping diagrams updated is the key operational concern, and three approaches address it.

The first is automatically generating diagrams from source code, which is straightforward and keeps the diagrams consistent with the code. The limitations of existing tools make completely accurate and meaningful diagrams generated purely from source unattainable, so some manual intervention remains necessary. Len Bass describes the ideal development environment as one where "the documentation is available for essentially free with the push of a button," an aspiration pointing toward auto-generated diagrams that current tooling does not reach.

The second is generating source code from diagrams. Diagrams are designed first in a dedicated tool and then serve as the basis for generated source code skeletons, such as components and packages with boundaries and APIs, which developers elaborate on. Every change to the architectural setup starts from the diagram and regenerates or updates the relevant skeleton, which keeps diagrams and code aligned at the cost of considerable discipline in managing changes.

The third is manual updates, where diagrams are updated by hand whenever a new feature affects the architectural design. Making diagram updates part of the definition of done keeps code changes reflected in the diagrams. The caveat is significant: neglected updates produce outdated or inconsistent diagrams, which makes this the least desirable of the three.

Given the tools available, the recommended approach blends automatic generation with manual creation. Auto-generate the diagrams that tools render reasonably from source without excessive clutter, which covers context, reference architecture, package, class, and entity diagrams, along with anything prone to frequent change. Where the source code does not make it clear how the system satisfies a specific quality attribute such as availability, scalability, or performance, automatic creation falls short and manually modeled diagrams take over. Sequence, state, concurrency, deployment, and operational diagrams fall into that category.

Sequence Diagrams

System Sequence Diagrams (SSDs) showcase process interactions chronologically, detailing the procedures, objects, and communication sequences required to fulfill a particular functionality. They align with use case realizations in the 4+1 architectural view model and are occasionally called event diagrams or scenarios.

An SSD maps out the external events actors generate, their sequence, and the inter-system events within a use case context. Every system is treated as a black box, which focuses the diagram squarely on events crossing the boundary from actors to systems. SSDs matter most for the primary success scenario of a use case and for regularly occurring or intricate alternative procedures.

The diagram visualizes parallel processes or objects, the lifelines, that exist concurrently, along with the messages exchanged between them as horizontal arrows in the order they occur. That produces a graphical definition of simple runtime scenarios.

A comprehensive SSD encapsulates the following:

  • External actors
  • Methods invoked by these actors
  • Return values tied to the preceding methods
  • Indication of loops or iteration areas

SSDs exhibit how specific tasks, simple or complex, are executed between users and the system, and the goal is a visual layout of the use case. Reading one requires familiarity with the Unified Modeling Language (UML). An SSD starts at the top with the actors or systems, and the actions performed are lines extending between the lifelines. Messages appear at the top or bottom to illustrate the action in detail, and each subsequent action or response sits under the previous one.

sequenceDiagram
    autonumber
    actor Alice
    Alice->>Bank: deposit funds
    loop
    Bank->>Bank: assign funds
    Bank->>Bookkeeper: update ledger
    Bookkeeper->>Bank: confirm
    end
    Note right of Bank: Open positions also absorb a partial amount of the deposit
    Bank-->>Alice: mint and transfer representation tokens

A lifeline corresponding to an object signifies a role, and a blank instance name indicates an anonymous, unnamed instance. Messages are horizontal arrows with the message name written above. Solid arrow heads represent synchronous calls, open arrow heads represent asynchronous messages, and dashed lines depict reply messages. A caller sending a synchronous message waits until the message is done, while a caller sending an asynchronous message continues processing without waiting for a response.

Sequence diagrams display how the parts of a system interact over time, which makes them valuable for illustrating how smart contracts interact with each other and with external systems, and for revealing vulnerabilities or design flaws in the process. Tools such as WebSequenceDiagrams or Draw.io create them through user-friendly interfaces with numerous customization options.

Data Separation Patterns

Smart contract design balances simplicity against security and efficiency. Data separation initially looks attractive as a way to protect data integrity and enable easy upgrades, and the Diamond pattern (ERC-2535) is the clear example, promoting upgradability by delegating function calls to facets while keeping logic and data separate. Tempting as that appears for a system expected to evolve, the trade-offs are substantial.

Rather than strict separation into data contracts and logic contracts, data ideally stays integrated wherever possible. The art of smart contract architecture lies not in separating storage but in creating concise, well-defined data structures that avoid straying across component boundaries, which removes the need for pervasive separation.

The Eternal Storage pattern is the counterpoint, offering persistent data storage that survives changes to a contract's logic by separating data into its own contract. Storing and retrieving data points through generic getters and setters keyed by an ABI-encoded key carries a high gas cost and a risk of key collisions, particularly for dynamic data types. That state separation introduces challenges and complexities weighing against its benefits.

An elegant architecture strikes the right balance of domain separation, which produces a design that is modular and maintains a localized state at the same time, and executes complex business logic without friction. Patterns that completely detach data storage from business logic are hard to comprehend, audit, and test, and those complexities, common to the Diamond and Eternal Storage patterns, routinely outweigh the advantages of separation.

The pitfalls of data decoupling also contribute to heightened security risks. Business logic tightly coupled with its associated data is inherently easier to understand, test, and secure, which produces fewer security vulnerabilities.

State separation patterns compound complexity during development and impede debugging, because data and logic are scattered. Inspecting such contracts on-chain is equally difficult, since the broad distribution of contracts obstructs a cohesive view of the system. That matters most during incident response after a security breach, where rapid understanding of system state is vital and the added opacity delays remediation and worsens the fallout.

Resist the reflexive urge to implement state-separating patterns and design well-encapsulated, localized state management with clear domain boundaries instead. Combined with thorough testing and auditing, that produces a more readable, testable, and secure architecture.

The goal of smart contract development is a system that functions correctly and remains easy to understand, maintain, and upgrade. Simplicity and clarity rank alongside security and efficiency, and the temptation to reach for the Diamond or Eternal Storage pattern is weighed against the complications and security risks they introduce.

Diamond Pattern

Storage-separating design patterns such as the diamond pattern appeal through their modular design and potential upgradeability, and they present a complex terrain that breeds security vulnerabilities. Approach them cautiously, or avoid them where possible. Where the diamond pattern is indispensable, specific security guidelines safeguard contract execution and data integrity.

In proxies and upgradeable solutions, providers like OpenZeppelin have delivered reliable and comprehensively documented tools for UUPS, transparent, and beacon upgradeable proxies. They have consciously omitted EIP-2535 from their library offering, so third-party libraries covering it demand heightened diligence.

The critical consideration when building the business logic is how the logic fragments into distinct facets. That fragmentation increases modularity and makes the codebase easier to audit, test, and maintain, so developers work on particular facets without confronting a monolithic codebase. Changes to one facet leave the business logic of other facets untouched.

The diamond pattern relies on proxy contracts. Its initialization during deployment includes a valid DiamondCut facet contract address. The diamondCut() function supplies the logic for adding, removing, and replacing facets and functions, and without it the diamond proxy becomes inoperable.

The position of new state variables introduced into a storage struct is critical. Append them at the end of the struct, which prevents overwrites and referencing errors. Under the AppStorage pattern, declare and use state variables only inside the single shared struct across all facets, and where a design requires multiple structs, call diamondStorage() explicitly.

Avoid nested structs unless the inner structs will never gain further state variables. Direct nesting blocks the introduction of new state variables during upgrades without overwriting storage slots of variables declared after the struct. New state variables belong in structs stored in mappings instead, since the slot calculation for variables in mappings is different and discontinuous.

Do not add new state variables to structs used in arrays either. Any alteration to the size and layout of the struct affects the size of the array and produces indexing issues and other anomalies.

As in other proxy patterns, every variable needs a unique storage slot, which prevents overwrites where two different structs occupy the same location. The protection of the initialize() function is the other critical measure. It commonly assigns privileged roles and is a security risk if left unprotected at deployment, so enforce proper access control on it, or call it during deployment and block further calls.

Finally, treat any facet capable of calling selfdestruct with caution. Triggering it removes the entire contract and takes funds or data with it, a risk amplified in the diamond pattern where multiple facets reach the storage and data of the proxy contract.