Skip to content

Commit

Permalink
Add Vesting (#425)
Browse files Browse the repository at this point in the history
Co-authored-by: Eric Lau <ericglau@outlook.com>
  • Loading branch information
immrsd and ericglau authored Jan 13, 2025
1 parent f0a4438 commit e08725a
Show file tree
Hide file tree
Showing 20 changed files with 888 additions and 22 deletions.
3 changes: 2 additions & 1 deletion packages/core-cairo/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Changelog

## Unreleased
## 0.21.0 (2025-01-09)

- Add Vesting tab. ([#425](https://github.com/OpenZeppelin/contracts-wizard/pull/425))
- Update Contracts Wizard license to AGPLv3. ([#424](https://github.com/OpenZeppelin/contracts-wizard/pull/424))

## 0.20.1 (2024-12-17)
Expand Down
2 changes: 1 addition & 1 deletion packages/core-cairo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openzeppelin/wizard-cairo",
"version": "0.20.1",
"version": "0.21.0",
"description": "A boilerplate generator to get started with OpenZeppelin Contracts for Cairo",
"license": "AGPL-3.0-only",
"repository": "https://github.com/OpenZeppelin/contracts-wizard",
Expand Down
7 changes: 7 additions & 0 deletions packages/core-cairo/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { printERC1155, defaults as erc1155defaults, isAccessControlRequired as e
import { printAccount, defaults as accountDefaults, AccountOptions } from './account';
import { printGovernor, defaults as governorDefaults, isAccessControlRequired as governorIsAccessControlRequired, GovernorOptions } from './governor';
import { printCustom, defaults as customDefaults, isAccessControlRequired as customIsAccessControlRequired, CustomOptions } from './custom';
import { printVesting, defaults as vestingDefaults, isAccessControlRequired as vestingIsAccessControlRequired, VestingOptions } from './vesting';

export interface WizardAccountAPI<Options extends CommonOptions>{
/**
Expand Down Expand Up @@ -41,6 +42,7 @@ export type ERC721 = WizardContractAPI<ERC721Options>;
export type ERC1155 = WizardContractAPI<ERC1155Options>;
export type Account = WizardAccountAPI<AccountOptions>;
export type Governor = WizardContractAPI<GovernorOptions>;
export type Vesting = WizardContractAPI<VestingOptions>;
export type Custom = WizardContractAPI<CustomOptions>;

export const erc20: ERC20 = {
Expand All @@ -67,6 +69,11 @@ export const governor: Governor = {
defaults: governorDefaults,
isAccessControlRequired: governorIsAccessControlRequired
}
export const vesting: Vesting = {
print: printVesting,
defaults: vestingDefaults,
isAccessControlRequired: vestingIsAccessControlRequired
}
export const custom: Custom = {
print: printCustom,
defaults: customDefaults,
Expand Down
6 changes: 6 additions & 0 deletions packages/core-cairo/src/build-generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { ERC1155Options, buildERC1155 } from './erc1155';
import { CustomOptions, buildCustom } from './custom';
import { AccountOptions, buildAccount } from './account';
import { GovernorOptions, buildGovernor } from './governor';
import { VestingOptions, buildVesting } from './vesting';

export interface KindedOptions {
ERC20: { kind: 'ERC20' } & ERC20Options;
ERC721: { kind: 'ERC721' } & ERC721Options;
ERC1155: { kind: 'ERC1155' } & ERC1155Options;
Account: { kind: 'Account' } & AccountOptions;
Governor: { kind: 'Governor' } & GovernorOptions;
Vesting: { kind: 'Vesting' } & VestingOptions;
Custom: { kind: 'Custom' } & CustomOptions;
}

Expand All @@ -32,6 +35,9 @@ export function buildGeneric(opts: GenericOptions) {
case 'Governor':
return buildGovernor(opts);

case 'Vesting':
return buildVesting(opts);

case 'Custom':
return buildCustom(opts);

Expand Down
29 changes: 27 additions & 2 deletions packages/core-cairo/src/generate/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateERC1155Options } from './erc1155';
import { generateAccountOptions } from './account';
import { generateCustomOptions } from './custom';
import { generateGovernorOptions } from './governor';
import { generateVestingOptions } from './vesting';
import { buildGeneric, GenericOptions, KindedOptions } from '../build-generic';
import { printContract } from '../print';
import { OptionsError } from '../error';
Expand Down Expand Up @@ -54,6 +55,12 @@ export function* generateOptions(kind?: Kind): Generator<GenericOptions> {
yield { kind: 'Governor', ...kindOpts };
}
}

if (!kind || kind === 'Vesting') {
for (const kindOpts of generateVestingOptions()) {
yield { kind: 'Vesting', ...kindOpts };
}
}
}

interface GeneratedContract {
Expand Down Expand Up @@ -92,9 +99,27 @@ function generateContractSubset(subset: Subset, kind?: Kind): GeneratedContract[
return contracts;
} else {
const getParents = (c: GeneratedContract) => c.contract.components.map(p => p.path);
function filterByUpgradeableSetTo(isUpgradeable: boolean) {
return (c: GeneratedContract) => {
switch (c.options.kind) {
case 'Vesting':
return isUpgradeable === false;
case 'Account':
case 'ERC20':
case 'ERC721':
case 'ERC1155':
case 'Governor':
case 'Custom':
return c.options.upgradeable === isUpgradeable;
default:
const _: never = c.options;
throw new Error('Unknown kind');
}
}
}
return [
...findCover(contracts.filter(c => c.options.upgradeable), getParents),
...findCover(contracts.filter(c => !c.options.upgradeable), getParents),
...findCover(contracts.filter(filterByUpgradeableSetTo(true)), getParents),
...findCover(contracts.filter(filterByUpgradeableSetTo(false)), getParents),
];
}
}
Expand Down
16 changes: 16 additions & 0 deletions packages/core-cairo/src/generate/vesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { infoOptions } from '../set-info';
import type { VestingOptions } from '../vesting';
import { generateAlternatives } from './alternatives';

const blueprint = {
name: ['MyVesting'],
startDate: ['2024-12-31T23:59'],
duration: ['90 days', '1 year'],
cliffDuration: ['0 seconds', '30 day'],
schedule: ['linear', 'custom'] as const,
info: infoOptions
};

export function* generateVestingOptions(): Generator<Required<VestingOptions>> {
yield* generateAlternatives(blueprint);
}
5 changes: 3 additions & 2 deletions packages/core-cairo/src/governor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ testAPIEquivalence('API erc721 votes + timelock', {
});

testAPIEquivalence('API custom name', {
name: 'CustomGovernor',
delay: '1 day',
period: '1 week',
name: 'CustomGovernor',
});

testAPIEquivalence('API custom settings', {
Expand All @@ -146,7 +146,8 @@ testAPIEquivalence('API quorum mode absolute', {
quorumAbsolute: '200',
});

testAPIEquivalence('API quorum mode percent', { name: NAME,
testAPIEquivalence('API quorum mode percent', {
name: NAME,
delay: '1 day',
period: '1 week',
quorumMode: 'percent',
Expand Down
2 changes: 1 addition & 1 deletion packages/core-cairo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ export { sanitizeKind } from './kind';

export { contractsVersion, contractsVersionTag, compatibleContractsSemver } from './utils/version';

export { erc20, erc721, erc1155, account, governor, custom } from './api';
export { erc20, erc721, erc1155, account, governor, vesting, custom } from './api';
1 change: 1 addition & 0 deletions packages/core-cairo/src/kind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function isKind<T>(value: Kind | T): value is Kind {
case 'ERC1155':
case 'Account':
case 'Governor':
case 'Vesting':
case 'Custom':
return true;

Expand Down
2 changes: 1 addition & 1 deletion packages/core-cairo/src/set-upgradeable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type Upgradeable = typeof upgradeableOptions[number];

function setUpgradeableBase(c: ContractBuilder, upgradeable: Upgradeable): BaseImplementedTrait | undefined {
if (upgradeable === false) {
return;
return undefined;
}

c.upgradeable = true;
Expand Down
30 changes: 22 additions & 8 deletions packages/core-cairo/src/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { generateSources, writeGeneratedSources } from './generate/sources';
import type { GenericOptions, KindedOptions } from './build-generic';
import { custom, erc20, erc721, erc1155 } from './api';


interface Context {
generatedSourcesPath: string
}
Expand Down Expand Up @@ -63,12 +62,27 @@ test('is access control required', async t => {
for (const contract of generateSources('all')) {
const regexOwnable = /(use openzeppelin::access::ownable::OwnableComponent)/gm;

if (contract.options.kind !== 'Account' && contract.options.kind !== 'Governor' && !contract.options.access) {
if (isAccessControlRequired(contract.options)) {
t.regex(contract.source, regexOwnable, JSON.stringify(contract.options));
} else {
t.notRegex(contract.source, regexOwnable, JSON.stringify(contract.options));
}
switch (contract.options.kind) {
case 'Account':
case 'Governor':
case 'Vesting':
// These contracts have no access control option
break;
case 'ERC20':
case 'ERC721':
case 'ERC1155':
case 'Custom':
if (!contract.options.access) {
if (isAccessControlRequired(contract.options)) {
t.regex(contract.source, regexOwnable, JSON.stringify(contract.options));
} else {
t.notRegex(contract.source, regexOwnable, JSON.stringify(contract.options));
}
}
break;
default:
const _: never = contract.options;
throw new Error('Unknown kind');
}
}
});
});
16 changes: 12 additions & 4 deletions packages/core-cairo/src/utils/convert-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,28 @@ const UINT_MAX_VALUES = {
export type UintType = keyof typeof UINT_MAX_VALUES;

/**
* Validates a string value to be a valid uint and converts it to bigint
* Checks that a string/number value is a valid `uint` value and converts it to bigint
*/
export function toUint(str: string, field: string, type: UintType): bigint {
const isValidNumber = /^\d+$/.test(str);
export function toUint(value: number | string, field: string, type: UintType): bigint {
const valueAsStr = value.toString();
const isValidNumber = /^\d+$/.test(valueAsStr);
if (!isValidNumber) {
throw new OptionsError({
[field]: 'Not a valid number'
});
}
const numValue = BigInt(str);
const numValue = BigInt(valueAsStr);
if (numValue > UINT_MAX_VALUES[type]) {
throw new OptionsError({
[field]: `Value is greater than ${type} max value`
});
}
return numValue;
}

/**
* Checks that a string/number value is a valid `uint` value
*/
export function validateUint(value: number | string, field: string, type: UintType): void {
const _ = toUint(value, field, type);
}
2 changes: 1 addition & 1 deletion packages/core-cairo/src/utils/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const secondsForUnit = { second, minute, hour, day, week, month, year };
export function durationToTimestamp(duration: string): number {
const match = duration.trim().match(durationPattern);

if (!match) {
if (!match || match.length < 2) {
throw new Error('Bad duration format');
}

Expand Down
123 changes: 123 additions & 0 deletions packages/core-cairo/src/vesting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import test from 'ava';
import { OptionsError, vesting } from '.';
import { buildVesting, VestingOptions } from './vesting';
import { printContract } from './print';

const defaults: VestingOptions = {
name: 'MyVesting',
startDate: '',
duration: '0 day',
cliffDuration: '0 day',
schedule: 'linear'
};

const CUSTOM_NAME = 'CustomVesting';
const CUSTOM_DATE = '2024-12-31T23:59';
const CUSTOM_DURATION = '36 months';
const CUSTOM_CLIFF = '90 days';

//
// Test helpers
//

function testVesting(title: string, opts: Partial<VestingOptions>) {
test(title, t => {
const c = buildVesting({
...defaults,
...opts
});
t.snapshot(printContract(c));
});
}

function testAPIEquivalence(title: string, opts?: VestingOptions) {
test(title, t => {
t.is(vesting.print(opts), printContract(buildVesting({
...defaults,
...opts
})));
});
}

//
// Snapshot tests
//

testVesting('custom name', {
name: CUSTOM_NAME,
});

testVesting('custom start date', {
startDate: CUSTOM_DATE
});

testVesting('custom duration', {
duration: CUSTOM_DURATION
});

testVesting('custom cliff', {
duration: CUSTOM_DURATION,
cliffDuration: CUSTOM_CLIFF
});

testVesting('custom schedule', {
schedule: 'custom'
});

testVesting('all custom settings', {
startDate: CUSTOM_DATE,
duration: CUSTOM_DURATION,
cliffDuration: CUSTOM_CLIFF,
schedule: 'custom'
});

//
// API tests
//

testAPIEquivalence('API custom name', {
...defaults,
name: CUSTOM_NAME
});

testAPIEquivalence('API custom start date', {
...defaults,
startDate: CUSTOM_DATE
});

testAPIEquivalence('API custom duration', {
...defaults,
duration: CUSTOM_DURATION
});

testAPIEquivalence('API custom cliff', {
...defaults,
duration: CUSTOM_DURATION,
cliffDuration: CUSTOM_CLIFF
});

testAPIEquivalence('API custom schedule', {
...defaults,
schedule: 'custom'
});

testAPIEquivalence('API all custom settings', {
...defaults,
startDate: CUSTOM_DATE,
duration: CUSTOM_DURATION,
cliffDuration: CUSTOM_CLIFF,
schedule: 'custom'
});

test('Vesting API isAccessControlRequired', async t => {
t.is(vesting.isAccessControlRequired({}), true);
});

test('cliff too high', async t => {
const error = t.throws(() => buildVesting({
...defaults,
duration: '20 days',
cliffDuration: '21 days'
}));
t.is((error as OptionsError).messages.cliffDuration, 'Cliff duration must be less than or equal to the total duration');
});
Loading

0 comments on commit e08725a

Please sign in to comment.