From c5fab40535c9345e5c72749db01a83ba9f95b274 Mon Sep 17 00:00:00 2001 From: Mark Spanbroek Date: Tue, 15 Feb 2022 17:01:54 +0100 Subject: [PATCH] Account locking --- contracts/AccountLocks.sol | 65 +++++++++++++ contracts/TestAccountLocks.sol | 23 +++++ test/AccountLocks.js | 165 +++++++++++++++++++++++++++++++++ test/examples.js | 10 +- test/time.js | 6 ++ 5 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 contracts/AccountLocks.sol create mode 100644 contracts/TestAccountLocks.sol create mode 100644 test/AccountLocks.js create mode 100644 test/time.js diff --git a/contracts/AccountLocks.sol b/contracts/AccountLocks.sol new file mode 100644 index 0000000..9417fad --- /dev/null +++ b/contracts/AccountLocks.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract AccountLocks { + uint256 public constant MAX_LOCKS_PER_ACCOUNT = 128; + + mapping(bytes32 => Lock) private locks; + mapping(address => Account) private accounts; + + function _createLock(bytes32 id, uint256 expiry) internal { + require(locks[id].owner == address(0), "Lock already exists"); + locks[id] = Lock(msg.sender, expiry, false); + } + + function _lock(address account, bytes32 lockId) internal { + require(locks[lockId].owner != address(0), "Lock does not exist"); + bytes32[] storage accountLocks = accounts[account].locks; + removeInactiveLocks(accountLocks); + require(accountLocks.length < MAX_LOCKS_PER_ACCOUNT, "Max locks reached"); + accountLocks.push(lockId); + } + + function _unlock(bytes32 lockId) internal { + Lock storage lock = locks[lockId]; + require(lock.owner != address(0), "Lock does not exist"); + require(lock.owner == msg.sender, "Only lock creator can unlock"); + lock.unlocked = true; + } + + function _unlockAccount() internal { + bytes32[] storage accountLocks = accounts[msg.sender].locks; + removeInactiveLocks(accountLocks); + require(accountLocks.length == 0, "Account locked"); + } + + function removeInactiveLocks(bytes32[] storage lockIds) private { + uint256 index = 0; + while (true) { + if (index >= lockIds.length) { + return; + } + if (isInactive(locks[lockIds[index]])) { + lockIds[index] = lockIds[lockIds.length - 1]; + lockIds.pop(); + } else { + index++; + } + } + } + + function isInactive(Lock storage lock) private view returns (bool) { + // solhint-disable-next-line not-rely-on-time + return lock.unlocked || lock.expiry <= block.timestamp; + } + + struct Lock { + address owner; + uint256 expiry; + bool unlocked; + } + + struct Account { + bytes32[] locks; + } +} diff --git a/contracts/TestAccountLocks.sol b/contracts/TestAccountLocks.sol new file mode 100644 index 0000000..cf54020 --- /dev/null +++ b/contracts/TestAccountLocks.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./AccountLocks.sol"; + +// exposes internal functions for testing +contract TestAccountLocks is AccountLocks { + function createLock(bytes32 id, uint256 expiry) public { + _createLock(id, expiry); + } + + function lock(address account, bytes32 id) public { + _lock(account, id); + } + + function unlock(bytes32 id) public { + _unlock(id); + } + + function unlockAccount() public { + _unlockAccount(); + } +} diff --git a/test/AccountLocks.js b/test/AccountLocks.js new file mode 100644 index 0000000..2cff736 --- /dev/null +++ b/test/AccountLocks.js @@ -0,0 +1,165 @@ +const { ethers } = require("hardhat") +const { expect } = require("chai") +const { exampleLock } = require("./examples") +const { now } = require("./time") + +describe("Account Locks", function () { + let locks + + beforeEach(async function () { + let AccountLocks = await ethers.getContractFactory("TestAccountLocks") + locks = await AccountLocks.deploy() + }) + + describe("creating a lock", function () { + it("allows creation of a lock with an expiry time", async function () { + let { id, expiry } = exampleLock() + await locks.createLock(id, expiry) + }) + + it("fails to create a lock with an existing id", async function () { + let { id, expiry } = exampleLock() + await locks.createLock(id, expiry) + await expect(locks.createLock(id, expiry + 1)).to.be.revertedWith( + "Lock already exists" + ) + }) + }) + + describe("locking an account", function () { + let lock + + beforeEach(async function () { + lock = exampleLock() + await locks.createLock(lock.id, lock.expiry) + }) + + it("locks an account", async function () { + let [account] = await ethers.getSigners() + await locks.lock(account.address, lock.id) + }) + + it("fails to lock account when lock does not exist", async function () { + let [account] = await ethers.getSigners() + let nonexistent = exampleLock().id + await expect(locks.lock(account.address, nonexistent)).to.be.revertedWith( + "Lock does not exist" + ) + }) + }) + + describe("unlocking a lock", function () { + let lock + + beforeEach(async function () { + lock = exampleLock() + await locks.createLock(lock.id, lock.expiry) + }) + + it("unlocks a lock", async function () { + await locks.unlock(lock.id) + }) + + it("fails to unlock a lock that does not exist", async function () { + let nonexistent = exampleLock().id + await expect(locks.unlock(nonexistent)).to.be.revertedWith( + "Lock does not exist" + ) + }) + + it("fails to unlock by someone other than the creator", async function () { + let [_, other] = await ethers.getSigners() + await expect(locks.connect(other).unlock(lock.id)).to.be.revertedWith( + "Only lock creator can unlock" + ) + }) + }) + + describe("unlocking an account", function () { + it("unlocks an account that has not been locked", async function () { + await locks.unlockAccount() + }) + + it("unlocks an account whose locks have been unlocked", async function () { + let [account] = await ethers.getSigners() + let lock = exampleLock() + await locks.createLock(lock.id, lock.expiry) + await locks.lock(account.address, lock.id) + await locks.unlock(lock.id) + await locks.unlockAccount() + }) + + it("unlocks an account whose locks have expired", async function () { + let [account] = await ethers.getSigners() + let lock = { ...exampleLock(), expiry: now() } + await locks.createLock(lock.id, lock.expiry) + await locks.lock(account.address, lock.id) + await locks.unlockAccount() + }) + + it("unlocks multiple accounts tied to the same lock", async function () { + let [account0, account1] = await ethers.getSigners() + let lock = exampleLock() + await locks.createLock(lock.id, lock.expiry) + await locks.lock(account0.address, lock.id) + await locks.lock(account1.address, lock.id) + await locks.unlock(lock.id) + await locks.connect(account0).unlockAccount() + await locks.connect(account1).unlockAccount() + }) + + it("fails to unlock when some locks are still locked", async function () { + let [account] = await ethers.getSigners() + let [lock1, lock2] = [exampleLock(), exampleLock()] + await locks.createLock(lock1.id, lock1.expiry) + await locks.createLock(lock2.id, lock2.expiry) + await locks.lock(account.address, lock1.id) + await locks.lock(account.address, lock2.id) + await locks.unlock(lock1.id) + await expect(locks.unlockAccount()).to.be.revertedWith("Account locked") + }) + }) + + describe("limits", function () { + let maxlocks + let account + + beforeEach(async function () { + maxlocks = await locks.MAX_LOCKS_PER_ACCOUNT() + ;[account] = await ethers.getSigners() + }) + + async function addLock() { + let { id, expiry } = exampleLock() + await locks.createLock(id, expiry) + await locks.lock(account.address, id) + return id + } + + it("supports a limited amount of locks per account", async function () { + for (let i = 0; i < maxlocks; i++) { + await addLock() + } + await expect(addLock()).to.be.revertedWith("Max locks reached") + }) + + it("doesn't count unlocked locks towards the limit", async function () { + for (let i = 0; i < maxlocks; i++) { + let id = await addLock() + await locks.unlock(id) + } + await expect(addLock()).not.to.be.reverted + }) + + it("handles maximum amount of locks within gas limit", async function () { + let ids = [] + for (let i = 0; i < maxlocks; i++) { + ids.push(await addLock()) + } + for (let id of ids) { + await locks.unlock(id) + } + await locks.unlockAccount() + }) + }) +}) diff --git a/test/examples.js b/test/examples.js index cf1a8ba..9ec4da6 100644 --- a/test/examples.js +++ b/test/examples.js @@ -1,4 +1,5 @@ const { ethers } = require("hardhat") +const { now, hours } = require("./time") const exampleRequest = () => ({ duration: 150, // 150 blocks ≈ half an hour @@ -11,7 +12,12 @@ const exampleRequest = () => ({ const exampleBid = () => ({ price: 42, - bidExpiry: Math.round(Date.now() / 1000) + 60 * 60, // 1 hour from now + bidExpiry: now() + hours(1), }) -module.exports = { exampleRequest, exampleBid } +const exampleLock = () => ({ + id: ethers.utils.randomBytes(32), + expiry: now() + hours(1), +}) + +module.exports = { exampleRequest, exampleBid, exampleLock } diff --git a/test/time.js b/test/time.js new file mode 100644 index 0000000..6144f12 --- /dev/null +++ b/test/time.js @@ -0,0 +1,6 @@ +const now = () => Math.round(Date.now() / 1000) +const hours = (amount) => amount * minutes(60) +const minutes = (amount) => amount * seconds(60) +const seconds = (amount) => amount + +module.exports = { now, hours, minutes, seconds }