Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(registry): Add registry subscription model #1636

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/registry/contracts/CannonRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import {EfficientStorage} from "./EfficientStorage.sol";
import {ERC2771Context} from "./ERC2771Context.sol";
import {IOptimismL1Sender} from "./IOptimismL1Sender.sol";
import {IOptimismL2Receiver} from "./IOptimismL2Receiver.sol";
import {CannonSubscription} from "./CannonSubscription.sol";

/**
* @title An on-chain record of contract deployments with Cannon
* See https://usecannon.com
*/
contract CannonRegistry is EfficientStorage, OwnedUpgradableUpdated {
contract CannonRegistry is EfficientStorage, OwnedUpgradableUpdated, CannonSubscription {
using SetUtil for SetUtil.Bytes32Set;

/**
Expand Down Expand Up @@ -219,6 +220,16 @@ contract CannonRegistry is EfficientStorage, OwnedUpgradableUpdated {
}
}

function publishWithSubscription(
bytes32 _packageName,
bytes32 _variant,
bytes32[] memory _packageTags,
string memory _packageDeployUrl,
string memory _packageMetaUrl
) external payable {
// TODO: implement same publish logic, but taking into account membership logic
}

/**
* @notice Removes a package from the registry. This can be useful if a package on ethereum or optimism is taking undesired precedence, or
* if the owner simply wants to clean up the display of their protocol on the website
Expand Down
48 changes: 48 additions & 0 deletions packages/registry/contracts/CannonSubscription.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol";
import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol";
import {ERC2771Context} from "./ERC2771Context.sol";
import {Subscription} from "./storage/Subscription.sol";

/**
* @title Management of Subscriptions to Cannon Registry
*/
contract CannonSubscription {
using Subscription for Subscription.Data;

function getPlan(uint16 _planId) external view returns (Subscription.Plan memory) {
return Subscription.load().getPlan(_planId);
}

function getDefaultPlan() external view returns (Subscription.Plan memory) {
return Subscription.load().getDefaultPlan();
}

function registerDefaultPlan(uint32 _termDuration, uint32 _price, uint32 _publishQuota) external returns (uint16) {
OwnableStorage.onlyOwner();
return Subscription.load().registerDefaultPlan(_termDuration, _price, _publishQuota);
}

function getMembership(address _user) external view returns (Subscription.Membership memory) {
return Subscription.load().getMembership(_user);
}

function purchaseMembership(address _user, uint32 _numberOfTerms) external {
address sender = ERC2771Context.msgSender();

// TODO: check that the user has enough USDC to purchase the membership

Subscription.load().purchaseMembership(_user, _numberOfTerms);

// TODO: give back the USDC to the user that was not used
}

function cancelMembership(uint32 _numberOfTerms) external {
address sender = ERC2771Context.msgSender();

// TODO: check that the user has enough terms left to cancel
// TODO: give back the USDC from the not started terms
}
}
161 changes: 161 additions & 0 deletions packages/registry/contracts/storage/Subscription.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
* @title Subscription
*/
library Subscription {
error PlanNotFound(uint16 planId);
error PlanExpired(uint16 planId);
error InvalidNumberOfTerms(uint32 numberOfTerms);

bytes32 private constant _SLOT =
keccak256(abi.encode("usecannon.cannon.registry.subscription"));

struct Plan {
/**
* @notice The ID of the plan
*/
uint16 id;
/**
* @notice The duration of a subscription term in seconds
*/
uint32 duration;
/**
* @notice The amount of publishes allowed per subscription term
*/
uint32 publishQuota;
/**
* @notice The amount of the configured ERC20 token required to subscribe to
* the registry for a single term.
*/
uint256 price;
}

struct Membership {
/**
* @notice The ID of the plan the user has purchased
*/
uint16 planId;
/**
* @notice The start time of the last active term
*/
uint32 activatedAt;
/**
* @notice The number of publishes the user has made in the current term
*/
uint32 publishCount;
/**
* @notice The amount of terms the user has left to use, including the current term
*/
uint32 termsLeft;
}

struct Data {
/**
* @notice The default amount of ETH required to publish a package when the
* user does not have an active membership
*/
uint256 defaultPublishFee;
/**
* @notice The default amount of ETH required to register a new package when
* the user does not have an active membership
*/
uint256 defaultRegisterFee;
/**
* @notice The default plan for new user memberships. This is the only plan
* that can be purchased, if the user already has an active
* membership they need to finish all the terms or cancel the
* membership first
*/
uint16 defaultPlanId;
/**
* @notice All the plans ever created
*/
mapping(uint16 planId => Plan plan) plans;
/**
* @notice All the user memberships
*/
mapping(address => Membership) memberships;
}

function load() internal pure returns (Data storage store) {
bytes32 s = _SLOT;
assembly {
store.slot := s
}
}

function getPlan(Data storage _self, uint16 _planId) internal view returns (Plan storage) {
Plan storage plan = _self.plans[_planId];
if (plan.id == 0) revert PlanNotFound(_planId);
return plan;
}

function getDefaultPlan(Data storage _self) internal view returns (Plan storage) {
return getPlan(_self, _self.defaultPlanId);
}

function registerDefaultPlan(Data storage _self, uint32 _termDuration, uint32 _price, uint32 _publishQuota) internal returns (uint16) {
_self.defaultPlanId++;
_self.plans[_self.defaultPlanId] = Plan({
id: _self.defaultPlanId,
duration: _termDuration,
price: _price,
publishQuota: _publishQuota
});
}

function getMembership(Data storage _self, address _user) internal view returns (Membership storage) {
return _self.memberships[_user];
}

function isMembershipActive(Plan storage _plan, Membership storage _membership) internal view returns (bool) {
// membership never created
if (_membership.activatedAt == 0) return false;
// is active and on the current term
if ((_membership.activatedAt + _plan.duration) > block.timestamp) return true;
// is expired and has no terms left
if (_membership.termsLeft == 0) return false;
// membership is active for the coming term
return (_membership.activatedAt + (_plan.duration * _membership.termsLeft)) > block.timestamp;
}

function resetMembership(Data storage _self, Membership storage _membership, uint32 _numberOfTerms) internal {
_membership.planId = _self.defaultPlanId;
_membership.activatedAt = uint32(block.timestamp);
_membership.publishCount = 0;
_membership.termsLeft = _numberOfTerms;
}

function purchaseMembership(Data storage _self, address _user, uint32 _numberOfTerms) internal {
if (_numberOfTerms == 0) {
revert InvalidNumberOfTerms(_numberOfTerms);
}

Membership storage _membership = getMembership(_self, _user);

// first time getting a membership
if (_membership.activatedAt == 0) {
resetMembership(_self, _membership, _numberOfTerms);
} else {
Plan storage _plan = getPlan(_self, _membership.planId);

// check if the current membership is active
if (isMembershipActive(_plan, _membership)) {
// The user is not allowed to upgrade to a different running plan, they
// should cancel the current membership first
if (_membership.planId != _self.defaultPlanId) {
revert PlanExpired(_membership.planId);
}

// add the new terms to the membership
_membership.termsLeft += _numberOfTerms;
} else {
resetMembership(_self, _membership, _numberOfTerms);
}
}
}
}
3 changes: 3 additions & 0 deletions packages/registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@
"typechain": "8.1.0",
"typescript": "^5.3.3",
"viem": "^2.21.15"
},
"dependencies": {
"@openzeppelin/contracts": "^5.1.0"
}
}
72 changes: 18 additions & 54 deletions packages/registry/test/contracts/CannonRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,43 @@
import { deepEqual, equal, ok } from 'assert/strict';
import { deepEqual, equal, ok } from 'node:assert/strict';
import { BigNumber, ContractTransaction, Signer } from 'ethers';
import { ethers } from 'hardhat';
import { stringToHex } from 'viem';
import { CannonRegistry as TCannonRegistry } from '../../typechain-types/contracts/CannonRegistry';
import { MockOptimismBridge as TMockOptimismBridge } from '../../typechain-types/contracts/MockOptimismBridge';
import assertRevert from '../helpers/assert-revert';
import { assertRevert } from '../helpers/assert-revert';
import { bootstrap, deployCannonRegistry } from '../helpers/bootstrap';

const toBytes32 = (str: string) => stringToHex(str, { size: 32 });
const parseEther = ethers.utils.parseEther;

describe('CannonRegistry', function () {
let l1ChainId: number;
let CannonRegistry: TCannonRegistry;
let MockOPSendBridge: TMockOptimismBridge;
let MockOPRecvBridge: TMockOptimismBridge;
let owner: Signer, user2: Signer, user3: Signer;
let owner: Signer;
let ownerAddress: string;
let user2: Signer;
let user3: Signer;
let fee: BigNumber;

before('identify signers', async function () {
before('load context', async function () {
const ctx = await bootstrap();
[owner, user2, user3] = await ethers.getSigners();
ownerAddress = await owner.getAddress();
});

before('deploy contract', async function () {
l1ChainId = (await ethers.provider.getNetwork()).chainId;
const MockOptimismBridge = await ethers.getContractFactory('MockOptimismBridge');
const MockOPBridgeImpl = await MockOptimismBridge.deploy();
await MockOPBridgeImpl.deployed();
const MockOPBridgeImplCode = await ethers.provider.send('eth_getCode', [MockOPBridgeImpl.address]);

await ethers.provider.send('hardhat_setCode', ['0x4200000000000000000000000000000000000007', MockOPBridgeImplCode]);
await ethers.provider.send('hardhat_setCode', ['0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1', MockOPBridgeImplCode]);

MockOPSendBridge = (await ethers.getContractAt(
'MockOptimismBridge',
'0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1'
)) as TMockOptimismBridge;
MockOPRecvBridge = (await ethers.getContractAt(
'MockOptimismBridge',
'0x4200000000000000000000000000000000000007'
)) as TMockOptimismBridge;

const CannonRegistryFactory = await ethers.getContractFactory('CannonRegistry');
const Implementation = await CannonRegistryFactory.deploy(MockOPSendBridge.address, MockOPRecvBridge.address, l1ChainId);
await Implementation.deployed();

const ProxyFactory = await ethers.getContractFactory('Proxy');
const Proxy = await ProxyFactory.deploy(Implementation.address, ownerAddress);
await Proxy.deployed();

CannonRegistry = (await ethers.getContractAt('CannonRegistry', Proxy.address)) as TCannonRegistry;

({ CannonRegistry, MockOPSendBridge, MockOPRecvBridge } = ctx);
fee = await CannonRegistry.publishFee();
});

before('register', async () => {
await CannonRegistry.setPackageOwnership(toBytes32('some-module'), await owner.getAddress());
await CannonRegistry.setPackageOwnership(toBytes32('some-module'), ownerAddress);
});

describe('Upgradedability', function () {
let newImplementation: TCannonRegistry;

before('deploy new implementation', async function () {
const CannonRegistry = await ethers.getContractFactory('CannonRegistry');
newImplementation = (await CannonRegistry.deploy(
MockOPSendBridge.address,
MockOPRecvBridge.address,
l1ChainId
)) as TCannonRegistry;
await newImplementation.deployed();
});

it('upgrades to a new implementation', async function () {
const { address } = newImplementation;
await CannonRegistry.upgradeTo(address).then((tx) => tx.wait());

const { chainId } = await ethers.provider.getNetwork();
const newImplementation = await deployCannonRegistry(MockOPSendBridge.address, MockOPRecvBridge.address, chainId);
const tx = await CannonRegistry.upgradeTo(newImplementation.address);
await tx.wait();
equal(await CannonRegistry.getImplementation(), newImplementation.address);
});
});
Expand Down Expand Up @@ -461,9 +423,11 @@ describe('CannonRegistry', function () {

it('sends cross chain message', async function () {
const functionName = 'setAdditionalPublishers';
const expectedArgs = [toBytes32('some-module'), [], [await user3.getAddress()]];
const expectedArgs = [toBytes32('some-module'), [], [await user3.getAddress()]] as const;
const functionSelector = CannonRegistry.interface.getSighash(functionName);
const encodedParameters = CannonRegistry.interface.encodeFunctionData(functionName, expectedArgs).slice(10); // Remove the first 10 characters (0x + selector)
const encodedParameters = CannonRegistry.interface
.encodeFunctionData(functionName as any, expectedArgs as any)
.slice(10);

const data = functionSelector + encodedParameters;

Expand Down
Loading
Loading