TOWARD WEB3

Making Your Solidity Smart Contract Upgradeable

And the used ChatGPT Prompt

TechExplorer
Published in
7 min readJul 7, 2023

--

In the blockchain world, smart contracts are the bedrock for any decentralized application. Like any software, smart contracts also need regular updates and bug fixes. But given the immutable nature of the blockchain, achieving this isn’t straightforward. This guide will walk you through how to make your smart contracts upgradeable, ensuring a smoother, future-proof development cycle.

This article is in a series of articles where we try to develop a “crypto bank”. You can freely check the first article that advertises the idea here. In this part, we will first present the original smart contract used. Then we will show how we made it upgradable using a specific prompt given to ChatGPT. And finally an example of adding a functionality to the smart contract.

The Original Smart Contract

Our starting point is a smart contract that operates like a check system where users can deposit and withdraw funds and creates “checks” for others to claim. Here’s the original contract, written in Solidity:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract CheckClaim {
using ECDSA for bytes32;

mapping(address => uint256) public balances;
mapping(address => mapping(uint256 => bool)) public usedNonces;

function deposit() public payable {
balances[msg.sender] += msg.value;
}

function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}

function depositCheck(address recipient, bytes memory signature, uint256 amount, uint256 nonce) public {
bytes32 message = prefixed(keccak256(abi.encodePacked(recipient, nonce, amount)));
address signer = recoverSigner(message, signature);

// Set the recipient to the sender's address if it's set to address(0)
// This allows anyone to claim the check!
if (recipient == address(0)) {
recipient = msg.sender;
}

require(!usedNonces[signer][nonce], "The check has already been used");
require(signer != address(0), "Invalid signature");
require(balances[signer] >= amount, "Insufficient balance");

balances[signer] -= amount;
balances[recipient] += amount;
usedNonces[signer][nonce] = true;
}

function claimCheckAndWithdraw(address recipient, bytes memory signature, uint256 amount, uint256 nonce) public {
// First, claim the check using the depositCheck function
depositCheck(recipient, signature, amount, nonce);

// Then, withdraw the claimed amount to the user's actual balance using the existing withdraw function
withdraw(amount);
}

function prefixed(bytes32 hash) internal pure returns (bytes32) {
return hash.toEthSignedMessageHash();
}

function recoverSigner(bytes32 message, bytes memory signature) internal pure returns (address) {
return message.recover(signature);
}
}

In detail, the contract’s key functions are:

  • deposit(): This function allows a user to deposit ethers into the contract. The ethers are added to the user's balance in the contract.
  • withdraw(uint256 amount): This function allows a user to withdraw ethers from their balance in the contract.
  • depositCheck(address recipient, bytes memory signature, uint256 amount, uint256 nonce): This function allows a user to deposit a check that can be claimed by a recipient. It verifies the signature of the check and adds the check amount to the recipient's balance in the contract.
  • claimCheckAndWithdraw(address recipient, bytes memory signature, uint256 amount, uint256 nonce): This function is a combination of depositCheck and withdraw. It allows a recipient to claim a check and withdraw the check amount from their balance in the contract.

The contract also includes two helper functions: recoverSigner(bytes32 message, bytes memory signature) and prefixed(bytes32 hash). These functions are used to verify the check's signature in the depositCheck function.

Making the Contract Upgradeable

To make this contract upgradeable, we’ll use the Proxy pattern along with the OpenZeppelin’s TransparentUpgradeableProxy, Initializable, and TimelockController libraries. The new contract will be controlled by a single admin who can initiate upgrades that take effect after a minimum delay of 72 hours.

Here’s the modified contract in three parts. First, the updated contract:

// The updated original contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract CheckClaimV1 is Initializable {
using ECDSA for bytes32;

mapping(address => uint256) public balances;
mapping(address => mapping(uint256 => bool)) public usedNonces;

function initialize() public initializer {
}

// Other functions of the original contract should go here
// deposit, withdraw, ...
}

The Proxy:

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

import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

contract YourProxy is TransparentUpgradeableProxy {
constructor(address _logic, address _admin, bytes memory _data)
TransparentUpgradeableProxy(_logic, _admin, _data) {
}
}

The TimeLockController:

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

import "@openzeppelin/contracts/governance/TimelockController.sol";

contract YourTimelockController is TimelockController {
constructor(uint256 minDelay, address[] memory proposers, address[] memory executors)
TimelockController(minDelay, proposers, executors) {
renounceRole(DEFAULT_ADMIN_ROLE, msg.sender);
}
}

This was created by issuing the following prompt to ChatGPT:

Design an upgradeable Solidity smart contract that's compatible with any EVM-based blockchain (such as Ethereum, Polygon, etc.) by utilizing the Proxy pattern. Make use of OpenZeppelin's TransparentUpgradeableProxy, Initializable, and TimelockController libraries.

The updated contract should include:

- A minimum delay of 72 hours from the time an upgrade is initiated to when it takes effect, enforced by the TimelockController.
- An event that is fired at the start of the upgrade process, containing the address of the new implementation contract for user verification. This delay is intended to allow users to validate the changes before they are implemented.
- A structure that allows for easy addition and modification of functions in future versions, while preserving the structure of existing functions. It should also enable the addition of new state variables without necessitating data migration.
- A naming convention for the implementation contract, which should be suffixed with V1. This contract should be unaware of the proxy.

Requirements:

- The code should compile without placeholders, except for the functions of the original contract that are unmodified.
- Keep the solution as simple as possible.
- Each contract should be written in its own code block. We expect three code block one for each contract.
- Provide a Hardhat-compatible deployment script. Upon deployment, the admin role of the TimeLockController should be renounced.
- Both the proxy and the TimeLockController inherited contracts should be kept basic (just the contructor), as all the necessary functionalities (upgradeTo, schedule, execute) are already included in the base contract. Most of the work will be done in the deployment script and the upgrade script.
- The latest prototype of the TimeLockController constructor has a fourth additional argument `TimelockController(uint256 minDelay, address[] memory proposers, address[] memory executors, address admin);`. The last argument is the admin. For simplicity put the admin as the deployer (msg.sender). Also renounce to the admin role by calling renounceRole(DEFAULT_ADMIN_ROLE, msg.sender); within the constructor.
Here is the smart contract:
<original smart contract code>

A diagram explaining the interactions between the different parts:

Diagram explaining the interaction between the different contracts. The “72 hours” is our chosen time, you can choose other durations.

Deploying the contract on Remix

You can either deploy the contract using the command line or an online IDE such as Remix. If you are using the command line you can have the deployment script auto-generated by ChatGPT. In the prompt described in the previous section, we asked ChatGPT to generate it for hardhat. Feel free to modify the corresponding line of the prompt to ask for other types of deployment scripts. Also, check the Annex section for the obtained result.

It is important to watch the order in which we deploy the contract. We need to deploy both the implementation of the contract and the TimelockController before deploying the Proxy contract. This is necessary since the two arguments of the proxy contract are deployment addresses. Here is a diagram representing the order.

Deployment Steps

Finally, one should make sure that the renounceRole() function of the TimeLockController. This is important as it avoid any single party from having full control over the contract and making it decentralized. In our case, ChatGPT has put the renounceRole() in the constructor itself so no need to call it at deployment time.

Note: For Remix, when deploying the proxy the _DATA field of the constructor should be put to “0x” for an empty string of bytes.

Upgrading the Smart Contract

Upgrading a contract involves creating a new version that introduces additional variables and functions, without eliminating existing ones. This ensures operational continuity and prevents disruptions. It allows for enhancements without compromising compatibility or necessitating data migration.

Once the new contract has been written you will need first to propose it by using the schedule() function; then after the configured delay (configured in the deployment phase) has expired you can call the execute() function to actually deploy the new contract. Both schedule() and execute() are part of the TimeLockContract.

Here also you can ask chatGPT to generate the necessary script to execute and upgrade (see the Extra section for the scripts I obtained). I personally did not go as far as to upgrade the current contract as I did not have any new features to add. Will update this article in case it happens.

Conclusion

And there you have it! Making your Solidity smart contracts upgradeable is an invaluable step towards ensuring your projects can stand the test of time. Whether you’re a seasoned blockchain developer or a newcomer to the space, I hope this guide proves useful in your future development endeavors. Happy coding!

Annex

The deployment script I used:

const hre = require("hardhat");

async function main() {
const [deployer, admin] = await hre.ethers.getSigners();
console.log("Deployed by:", deployer.address, "admin", admin.address);

const minDelay = 72 * 60 * 60; // 72 hours in seconds
const proposers = [admin.address];
const executors = [admin.address];

const CheckClaimV1 = await ethers.getContractFactory("CheckClaimV1");
const checkClaimV1 = await CheckClaimV1.deploy();
await checkClaimV1.deployed();
console.log("CheckClaimV1 deployed at:", checkClaimV1.address);

const CheckClaimTimelock = await ethers.getContractFactory("CheckClaimTimelock");
const timelock = await CheckClaimTimelock.deploy(minDelay, proposers, executors, admin.address);
await timelock.deployed();
console.log("CheckClaimTimelock deployed at:", timelock.address);

const CheckClaimProxy = await ethers.getContractFactory("CheckClaimProxy");
const proxy = await CheckClaimProxy.deploy(checkClaimV1.address, timelock.address, '0x');
await proxy.deployed();
console.log("CheckClaimProxy deployed at:", proxy.address);
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
}); async function main() { const [deployer, admin] = await hre.ethers.getSigners(); console.log("Deployed by:", deployer.address, "admin", admin.address); const minDelay = 72 * 60 * 60; // 72 hours in seconds const proposers = [ admin.address ]; const executors = [ admin.address ]; const CheckClaimV1 = await ethers.getContractFactory("CheckClaimV1"); const checkClaimV1 = await CheckClaimV1.deploy(); await checkClaimV1.deployed(); console.log("CheckClaimV1 deployed at:", checkClaimV1.address); const CheckClaimTimelock = await ethers.getContractFactory("CheckClaimTimelock"); const timelock = await CheckClaimTimelock.deploy(minDelay, proposers, executors, admin.address); await timelock.deployed(); console.log("CheckClaimTimelock deployed at:", timelock.address); const CheckClaimProxy = await ethers.getContractFactory("CheckClaimProxy"); const proxy = await CheckClaimProxy.deploy(checkClaimV1.address, timelock.address, '0x'); await proxy.deployed(); console.log("CheckClaimProxy deployed at:", proxy.address); } main().then(() => process.exit(0)).catch((error) => { console.error(error); process.exit(1); });

--

--

TechExplorer

Tech enthusiast with an interest in software and a love for cryptocurrencies. Check out my crypto donation page at: https://rb.gy/mqd43h