Zum Inhalt springen

Upgradable Smart Contracts verstehen und schreiben

Erfahren Sie, warum upgradbare Smart Contracts in dezentralen Anwendungen wichtig sind. Lernen Sie, wie sie Updates, Fehlerbehebungen und mehr Sicherheit durch das Proxy-Muster ermöglichen.

8 Min. Lesezeit
Upgradable Smart Contracts verstehen und schreiben

Smart Contracts haben die Art und Weise, wie wir über dezentrale Anwendungen denken, verändert, indem sie selbstausführende Programme eingeführt haben, die Intermediäre überflüssig machen. Sie werden transparent auf dezentralen Blockchain-Netzwerken ausgeführt und bieten Vorteile wie Trustlessness und erhöhte Sicherheit. Ihre Unveränderlichkeit, die einst als Stärke galt, stellt jedoch eine erhebliche Herausforderung dar: Sobald ein Smart Contract deployed ist, kann er nicht mehr verändert werden. Diese Starrheit erschwert die Implementierung notwendiger Updates oder die Behebung von Bugs.

In diesem Beitrag werden wir das Konzept der upgradefähigen Smart Contracts erläutern, erklären, warum sie unverzichtbar sind, und aufzeigen, wie sie funktionieren – mit besonderem Fokus auf ihre Implementierung auf Linea zkEVM.

Was sind upgradefähige Smart Contracts?

Upgradefähige Smart Contracts lösen die Einschränkungen traditioneller Smart Contracts, indem sie Änderungen und Verbesserungen nach dem Deployment ermöglichen. Während traditionelle Smart Contracts für ihre Unveränderlichkeit bekannt sind, verbinden upgradefähige Contracts die Sicherheit der Unveränderlichkeit mit der Flexibilität zur Anpassung. Dies ermöglicht es Entwicklern, Updates zu implementieren, ohne Nutzer zu beeinträchtigen oder sie zur Migration zu einem neuen Contract zu zwingen.

Bei traditionellen Contracts sind Bugs oder Sicherheitslücken nach dem Deployment dauerhaft. Upgradefähige Contracts begegnen diesem Problem, indem sie Updates nach dem Deployment ermöglichen und dabei folgende zentrale Herausforderungen lösen:

1. Bug-Fixes und Sicherheitslücken: Entwickler können Schwachstellen nach dem Deployment beheben und so Nutzer-Assets schützen.

2. Anpassungsfähigkeit: Upgradefähige Contracts ermöglichen eine nahtlose Integration mit neuen Protokollen und halten Anwendungen mit dem technologischen Fortschritt auf dem neuesten Stand.

3. Sicherheitsverbesserungen: Angesichts sich weiterentwickelnder Cyber-Bedrohungen ermöglichen diese Contracts die einfache Implementierung neuer Sicherheitsmaßnahmen, ohne den gesamten Contract neu zu deployen.

4. Governance-Flexibilität: Dezentrale Autonome Organisationen (DAOs) können Community-Abstimmungen nutzen, um den Upgrade-Prozess zu verwalten und so Transparenz und Stakeholder-Beteiligung sicherzustellen.

5. Vereinfachte Updates: Im Gegensatz zu traditionellen Contracts, die ein komplexes Redeployment und eine Datenmigration erfordern, vereinfachen upgradefähige Contracts Updates für eine effizientere und nutzerfreundlichere Erfahrung.

Wie funktionieren upgradefähige Smart Contracts?

Um einen upgradefähigen Smart Contract zu erstellen, ist das Proxy-Pattern die gängigste Methode. Dieser Ansatz trennt die Logik des Contracts von seinen Daten, sodass Entwickler die Logik aktualisieren können, während der gleiche Datenspeicher erhalten bleibt.

So funktioniert es:

  1. Proxy Contract: Dieser Contract enthält den Storage (State Variables) und dient als Schnittstelle für Nutzer.

  2. Implementation Contract: Dieser Contract enthält den ausführbaren Code (Business Logic).

  3. Delegation: Der Proxy Contract delegiert Funktionsaufrufe mithilfe von Ethereums delegatecall an den Implementation Contract. Dadurch kann der Proxy den Code der Implementation ausführen und dabei denselben Storage beibehalten.

Wenn ein Upgrade erforderlich ist, wird der Proxy Contract auf einen neuen Implementation Contract verwiesen, was Logic-Updates ermöglicht, ohne den Storage des Contracts zu verändern oder Nutzer zu beeinträchtigen. Hier ist ein Beispiel mit einem DAO-Proxy-Contract zur Veranschaulichung dieses Prozesses:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DAOProxy {
    // Storage slot for the address of the implementation contract
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    // Storage slot for the admin address
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

    // Modifier to restrict access to admin
    modifier onlyAdmin() {
        require(msg.sender == _admin(), "Caller is not admin");
        _;
    }

    // Constructor to set the initial implementation and admin
    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    // Fallback function to delegate calls to the implementation
    fallback() external payable {
        _delegate(implementation());
    }

    // Receive function to handle plain Ether transfers
    receive() external payable {
        _delegate(implementation());
    }

    // Internal function to delegate the call to the implementation
    function _delegate(address _implementation) internal {
        assembly {
            // Copy msg.data
            calldatacopy(0, 0, calldatasize())
            // Call the implementation
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            // Copy the returned data
            returndatacopy(0, 0, returndatasize())
            // Handle result
            switch result
            case 0 {
                // Revert if the call failed
                revert(0, returndatasize())
            }
            default {
                // Return data if the call succeeded
                return(0, returndatasize())
            }
        }
    }

    // Getter for the implementation address
    function implementation() internal view returns (address impl) {
        bytes32 slot = implementationSlot;
        assembly {
            impl := sload(slot)
        }
    }

    // Getter for the admin address
    function _admin() internal view returns (address adm) {
        bytes32 slot = adminSlot;
        assembly {
            adm := sload(slot)
        }
    }

    // Function to upgrade the implementation contract
    function upgradeTo(address newImplementation) external onlyAdmin {
        _setImplementation(newImplementation);
    }

    // Internal function to set the implementation address
    function _setImplementation(address newImplementation) internal {
        bytes32 slot = implementationSlot;
        assembly {
            sstore(slot, newImplementation)
        }
    }

    // Function to change admin
    function changeAdmin(address newAdmin) external onlyAdmin {
        _setAdmin(newAdmin);
    }

    // Internal function to set the admin address
    function _setAdmin(address newAdmin) internal {
        bytes32 slot = adminSlot;
        assembly {
            sstore(slot, newAdmin)
        }
    }
}
Kopieren
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");
Kopieren

Zwei wichtige Aspekte sind der Storage Slot für die Adresse des Implementation Contracts und der Storage Slot für die Admin-Adresse. Dies sind bewusst gewählte, eindeutige Bezeichner, die zur Berechnung eines Storage Slots für die Implementation-Adresse innerhalb des Proxy Contracts verwendet werden.

Bei der Verwendung des Proxy-Patterns mit delegatecall (wir werden uns diese Funktion bald genauer ansehen) arbeitet der Implementation Contract im Kontext des Proxy-Storages. Das bedeutet:

  • Gemeinsamer Speicherbereich: Beide Contracts teilen dasselbe Storage-Layout.

  • Überschreibungsrisiko: Ohne sorgfältiges Management könnten Variablen im Proxy Contract durch den Implementation Contract überschrieben werden.

Durch die Verwendung eines gehashten eindeutigen Bezeichners:

  • Isolation: Es wird sichergestellt, dass die vom Proxy verwendeten Storage Slots mit keinen Variablen im Implementation Contract in Konflikt geraten.

  • Konsistenz: Derselbe Storage Slot wird durchgehend im gesamten Contract verwendet, wo immer die Implementation-Adresse benötigt wird.

constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }
Kopieren

Ein weiterer wichtiger Aspekt ist der Constructor des Proxy Contracts. Er enthält einen _implementation-Parameter, was bedeutet, dass der Implementation Contract zuerst deployed werden muss und seine Adresse beim Deployment des Proxy Contracts als Argument übergeben wird.

 fallback() external payable {
        _delegate(implementation());
    }

    receive() external payable {
        _delegate(implementation());
    }
Kopieren

Nun haben wir die Fallback- und Receive-Funktionen – das sind Solidity-Funktionen, die eine entscheidende Rolle bei der Weiterleitung von Aufrufen und Ether-Transfers an den Implementation Contract spielen. Sie behandeln Situationen, in denen ein Contract Aufrufe oder Mittel erhält, die keiner seiner explizit definierten Funktionen entsprechen. Hier ist eine ausführliche Erklärung, was diese Funktionen tun:

1. Die Fallback-Funktion

  • Fallback-Mechanismus: Die Fallback-Funktion in Solidity ist eine spezielle Funktion, die unter zwei Hauptbedingungen ausgeführt wird:- Wenn eine Funktion aufgerufen wird, die im Contract nicht existiert.

  • Wenn Daten an den Contract gesendet werden, ohne einer Funktionssignatur zu entsprechen.

  • Delegation: Im Kontext des DAOProxy-Contracts delegiert die Fallback-Funktion alle solchen Aufrufe an den aktuellen Implementation Contract.

Wie es in DAOProxy funktioniert:

  • Behandlung von Funktionsaufrufen:- Wenn ein Nutzer eine Funktion aufruft, die nicht im Proxy Contract definiert ist, wird die Fallback-Funktion ausgelöst.

  • Dies ist üblich, da der Proxy Contract hauptsächlich administrative Funktionen wie upgradeTo und changeAdmin enthält, anstatt die Business-Logic-Funktionen des Implementation Contracts.

  • Delegation an die Implementation:- Die Fallback-Funktion ruft _delegate(implementation()) auf und leitet den Aufruf an den Implementation Contract weiter.

  • _delegate ist eine interne Funktion, die delegatecall verwendet, um den Code der Implementation auszuführen und dabei im Storage-Kontext des Proxys zu operieren.

  • Ausführungskontext:- Alle State-Änderungen erfolgen im Storage des Proxys.

  • msg.sender und msg.value bleiben unverändert und bewahren den Kontext des ursprünglichen Aufrufers.

2. Die Receive-Funktion

  • Ether-Empfang: Die Receive-Funktion, eingeführt in Solidity 0.6.0, wird ausgeführt, wenn ein Contract Ether ohne begleitende Daten empfängt (d. h. wenn msg.data leer ist).

  • Delegation von Ether-Transfers: Im DAOProxy-Contract werden einfache Ether-Transfers an den Implementation Contract delegiert.

Wie es in DAOProxy funktioniert:

  • Behandlung von Ether-Transfers:- Wenn jemand Ether an die Adresse des Proxy Contracts ohne Daten sendet, wird die Receive-Funktion ausgelöst.

  • Dies geschieht durch eine Transaktion, die nur die to-Adresse und den value (Ether-Betrag) angibt, ohne eine bestimmte Funktion aufzurufen.

  • Delegation an die Implementation:- Wie die Fallback-Funktion ruft receive _delegate(implementation()) auf und leitet den Ether-Transfer an den Implementation Contract weiter.

  • Der Implementation Contract definiert dann, wie mit dem empfangenen Ether umgegangen wird (z. B. Aktualisierung von Salden, Ausgabe von Events).

 function _delegate(address _implementation) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
Kopieren

Nun haben wir die _delegate-Funktion, die es dem Proxy ermöglicht, Funktionsaufrufe an den Implementation Contract weiterzuleiten und dabei den Kontext des ursprünglichen Aufrufs beizubehalten. Dies ermöglicht es, die Contract-Logik im Laufe der Zeit zu upgraden, ohne die Adresse des Contracts zu ändern oder seinen State zu beeinträchtigen.

Die anderen Funktionen sind Getter und Setter, die uns bei der Verwaltung der State Variables unseres Proxy Contracts helfen. Um dies zu erreichen, verwenden wir das Schlüsselwort assembly, das es Entwicklern ermöglicht, Low-Level-Assembly-Code direkt in ihre Contracts einzubinden. Diese Funktion bietet direkten Zugriff auf Ethereum Virtual Machine (EVM)-Instruktionen und ermöglicht eine präzisere Kontrolle über das Contract-Verhalten, die Optimierung des Gas-Verbrauchs und die Ausführung von Operationen, die in Soliditys High-Level-Code entweder nicht direkt verfügbar oder weniger effizient sind.

Nun stellt sich die große Frage: Wie interagieren wir mit unserem Implementation Smart Contract über diesen Proxy Contract?

Dazu benötigen wir das ABI des Implementation Smart Contracts. Da der Proxy die Aufrufe an den Implementation Contract delegiert, benötigen wir sein ABI, um zu wissen, welche Funktionen wir aufrufen können. Außerdem verwenden wir bei der Interaktion mit der Blockchain die Adresse des Proxy Contracts, nicht die des Implementation Contracts. Der Proxy hält den State und delegiert Aufrufe an die Implementation, daher sollten alle Interaktionen über den Proxy erfolgen.

Stellen wir uns vor, wir haben ein JavaScript-Projekt und verwenden Ethers.js zur Interaktion mit der Blockchain. So würde es aussehen:

const proxyAddress = "0xProxyContractAddress";
const implementationABI = [ /* ABI array of the implementation contract */ ];

const proxyContract = new ethers.Contract(proxyAddress, implementationABI, provider);

// For a function 'setValue(uint256 newValue)'
await proxyContract.connect(signer).setValue(42);

// For a function 'getValue()'
const value = await proxyContract.getValue();
console.log("Value:", value);
Kopieren

Auf diese Weise rufen wir die imaginäre Funktion setValue in unserem Implementation Contract über unseren Proxy Contract auf.

Da wir nun über upgradefähige Contracts sprechen, stellt sich eine weitere wichtige Frage: Wie upgrade ich meine Contracts?

Zunächst kann nur der Admin (die Person, die den Proxy Contract deployed hat) den Implementation Contract upgraden. Dazu muss der Admin zunächst eine neue Version des Implementation Contracts auf der Blockchain deployen. Danach sollte er die Funktion upgradeTo im Proxy Contract aufrufen und die neue Contract-Adresse als Parameter übergeben. Es würde so aussehen:

await proxyContract.connect(adminSigner).upgradeTo(newImplementationAddress);

Die Zukunft der Smart Contracts annehmen

Upgradefähige Smart Contracts sind in der Blockchain-Entwicklung wichtig, da sie Änderungen und Verbesserungen nach dem Deployment ermöglichen. Durch die Nutzung des Proxy-Patterns können Entwickler nahtlos neue Features einführen, Bugs beheben und auf sich ändernde Anforderungen reagieren, ohne die Nutzererfahrung zu beeinträchtigen oder Assets zu migrieren.

Mit großer Macht geht jedoch große Verantwortung einher. Es ist entscheidend, upgradefähige Contracts mit einem gründlichen Verständnis ihrer Mechanismen, Sicherheitsimplikationen und Best Practices anzugehen. Die Sicherstellung der Konsistenz des Storage-Layouts, die Implementierung robuster Zugriffskontrollen und das gründliche Testen von Upgrades sind wesentliche Schritte zum Schutz vor potenziellen Risiken.

Wenn Sie sich an den Aufbau upgradefähiger Contracts wagen, sollten Sie etablierte Tools wie das OpenZeppelin Upgrades Plugin in Betracht ziehen, um den Prozess zu vereinfachen und die Sicherheit zu erhöhen. Viel Spaß beim Coden!

KI-übersetzt. Kann Fehler enthalten. Bitte überprüfen Sie die Informationen stets.

Bewerten Sie die Übersetzung

Dieser Artikel wurde verfasst von: