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

Add Vesting #425

Merged
merged 18 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from 16 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
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');
}
ericglau marked this conversation as resolved.
Show resolved Hide resolved
}
}
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', '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));
}
}
ericglau marked this conversation as resolved.
Show resolved Hide resolved
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases where a happy match also has match.length < 2?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, since later on we access the 1st and the 2nd items of the match array in unsafe manner. Either way it'll result in a runtime exception, but it's better to have a straightforward error than index-out-of-bounds one

image

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
Loading