Skip to content

Initial work for workflow registry v2: limits #40

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

Open
wants to merge 1 commit into
base: develop
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
27 changes: 27 additions & 0 deletions contracts/gas-snapshots/workflow.gas-snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ WorkflowRegistry_registerWorkflow:test_WhenTheWorkflowInputsAreAllValid() (gas:
WorkflowRegistry_requestForceUpdateSecrets:test_WhenTheCallerIsAnAuthorizedAddress_AndTheWorkflowIsInAnAllowedDON() (gas: 936092)
WorkflowRegistry_requestForceUpdateSecrets:test_WhenTheCallerIsAnAuthorizedAddress_AndTheWorkflowIsNotInAnAllowedDON() (gas: 510822)
WorkflowRegistry_requestForceUpdateSecrets:test_WhenTheCallerIsNotAnAuthorizedAddress() (gas: 509176)
WorkflowRegistry_setDONOverride:test_WhenCallerIsNOTTheOwner() (gas: 120)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

not sure if we should set up a new profile for this and possibly also use pragma 0.8.26 if we do, especially as there will be duplicate functions when it comes to things like register

Copy link
Contributor

Choose a reason for hiding this comment

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

For VRF, we set up a different profile for each version to benefit from having a newer Solidity version, so it's certainly possible. In this case, the Foundry profile could be called "workflow_v2".

WorkflowRegistry_setDONOverride:test_WhenCallingTheFunctionMultipleTimes() (gas: 208)
WorkflowRegistry_setDONOverride:test_WhenLimitIsSetTo0() (gas: 186)
WorkflowRegistry_setDONOverride:test_WhenLimitIsSetToANormalPositiveValue() (gas: 230)
WorkflowRegistry_setDONOverride:test_WhenLimitIsSetToUint32Max() (gas: 249)
WorkflowRegistry_setDONOverride:test_WhenThereAreMultipleDONs() (gas: 164)
WorkflowRegistry_setDONOverride:test_WhenThereAreNoOverridesYet() (gas: 142)
WorkflowRegistry_setDefaults:test_WhenAllValuesAreAtUint32Max() (gas: 208)
WorkflowRegistry_setDefaults:test_WhenAllValuesAreZero() (gas: 120)
WorkflowRegistry_setDefaults:test_WhenCalledMultipleTimesInSequence() (gas: 164)
WorkflowRegistry_setDefaults:test_WhenItIsCalledWithATypicalValidUpdate() (gas: 142)
WorkflowRegistry_setDefaults:test_WhenTheCallerIsNOTTheOwner() (gas: 227)
WorkflowRegistry_setDefaults:test_WhenThereAreNoCallsMadeYet() (gas: 186)
WorkflowRegistry_setUserDONOverride:test_WhenCallerIsNOTTheOwner() (gas: 142)
WorkflowRegistry_setUserDONOverride:test_WhenItIsCalledMultipleTimes() (gas: 208)
WorkflowRegistry_setUserDONOverride:test_WhenLimitIsSetTo0() (gas: 186)
WorkflowRegistry_setUserDONOverride:test_WhenLimitIsSetToANormalPositiveValue() (gas: 230)
WorkflowRegistry_setUserDONOverride:test_WhenLimitIsUint32Max4294967295() (gas: 120)
WorkflowRegistry_setUserDONOverride:test_WhenThereAreMultipleUsersAndMultipleDONs() (gas: 249)
WorkflowRegistry_setUserDONOverride:test_WhenThereAreNoOverridesYet() (gas: 164)
WorkflowRegistry_setUserOverride:test_WhenCallerIsNOTTheOwner() (gas: 120)
WorkflowRegistry_setUserOverride:test_WhenLimitIsSetTo0() (gas: 186)
WorkflowRegistry_setUserOverride:test_WhenLimitIsSetToANormalPositiveValue() (gas: 208)
WorkflowRegistry_setUserOverride:test_WhenLimitIsSetToUint32Max4294967295() (gas: 249)
WorkflowRegistry_setUserOverride:test_WhenTheFunctionIsCalledMultipleTimes() (gas: 230)
WorkflowRegistry_setUserOverride:test_WhenThereAreMultipleUsers() (gas: 142)
WorkflowRegistry_setUserOverride:test_WhenThereAreNoOverridesYet() (gas: 164)
WorkflowRegistry_unlockRegistry:test_WhenTheCallerIsTheContractOwner() (gas: 30325)
WorkflowRegistry_updateAllowedDONs:test_WhenTheBoolInputIsFalse() (gas: 29739)
WorkflowRegistry_updateAllowedDONs:test_WhenTheBoolInputIsTrue() (gas: 170256)
Expand Down
172 changes: 172 additions & 0 deletions contracts/src/v0.8/workflow/dev/WorkflowRegistry.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: BUSL 1.1
pragma solidity 0.8.26;

import {ITypeAndVersion} from "../../shared/interfaces/ITypeAndVersion.sol";

import {Ownable2StepMsgSender} from "../../shared/access/Ownable2StepMsgSender.sol";

contract WorkflowRegistry is Ownable2StepMsgSender, ITypeAndVersion {
string public constant override typeAndVersion = "WorkflowRegistry 2.0.0-dev";

enum WorkflowStatus {
ACTIVE,
PAUSED
}

struct WorkflowMetadata {
bytes32 workflowID; // Unique identifier from hash of owner address, WASM binary content, config content and secrets URL.
bytes32 donLabel; // Label for the DON that is used when distributing the workflow across DONs.
address owner; // ─────────╮ Workflow owner.
uint64 created_at; // │ block.timestamp when the workflow was first registered.
WorkflowStatus status; // ─╯ Current status of the workflow (active, paused).
string workflowName; // Human readable string capped at 64 characters length.
string binaryURL; // URL to the WASM binary.
string configURL; // URL to the config.
string secretsURL; // URL to the encrypted secrets. Workflow DON applies a default refresh period (e.g. daily).
}

constructor() {
// Intialize with default limits for Config.
s_cfg.defaultsPacked = _packDefaults(200, 500, 200);
}

// ================================================================
// | Limits Config |
// ================================================================

/// @dev Instead of a big struct with mappings, we store
/// defaults in a single 32-byte slot, and use nested mappings
/// for overrides.
struct Config {
// Pack three uint32 defaults into a single 96-bit field:
// Layout in bits: [maxUser(0..31) | maxDON(32..63) | maxUserDON(64..95)]
uint96 defaultsPacked;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why pack it like this instead of specifying three separate uint32 fields that will also fit into a single storage slot (uint32 = 4 bytes, storage slot size uint256 = 32 bytes, total of 12 bytes in the slot, so you still have some space left)?

Copy link
Contributor Author

@eutopian eutopian Apr 29, 2025

Choose a reason for hiding this comment

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

its one less SSTORE when you write. we need to check it often so it saves a little. Even though storage slot is packed together accessing them as separate fields is still separate...

maybe it's not worth it :D

Copy link
Contributor Author

@eutopian eutopian Apr 29, 2025

Choose a reason for hiding this comment

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

although... if we have them as separate, we might need separate setters too, otherwise, there's not much gained, and I don't know if this is worth having different setters. otherwise, it's also not worth doing
config.user = a
config.don = b
config.userdon = c each time as that's 3 writes

// Struct to distinguish between unset and explicitly set zero values
struct Value {
uint32 value;
bool enabled;
}
// 1) user-specific overrides: address -> limit
mapping(address => Value) userOverride;
// 2) DON-specific overrides: donLabel -> limit
mapping(bytes32 => Value) donOverride;
// 3) user+don override: user => (donLabel => limit)
mapping(address => mapping(bytes32 => Value)) userDONOverride;
}

// Our single config instance
Config private s_cfg;

// ─────────────────────────────────────────────────────────────────────────
// Limits Config - External Setters: Owner can set defaults and individual overrides
// ─────────────────────────────────────────────────────────────────────────

/// @notice Update the three default limits in a single call.
function setDefaults(uint32 maxPerUser, uint32 maxPerDON, uint32 maxPerUserDON) external onlyOwner {
s_cfg.defaultsPacked = _packDefaults(maxPerUser, maxPerDON, maxPerUserDON);
}

/// @notice Override the maximum # of workflows a specific user can register.
function setUserOverride(address user, uint32 limit, bool enabled) external onlyOwner {
if (enabled) {
s_cfg.userOverride[user] = Config.Value(limit, true);
} else {
delete s_cfg.userOverride[user];
Copy link
Contributor

Choose a reason for hiding this comment

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

Not 100% sure on this, but generally, deleting elements in mappings/arrays is avoided due to gas costs, so resetting values is more desirable. Since you're already using this isSet field value, maybe you can just set it false? In that case, this code is as simple as:

s_cfg.userOverride[user] = Config.Value(limit, isSet);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

delete is only costly for large structures, but this is one slot size, so delete versus setting this to false cost the same storage write but has the slight benefit of the cleared slot refund. the only benefit potentially in allowing it is record I think.

}
}

/// @notice Override the maximum # of workflows allowed for a specific DON label.
/// @dev donLabel is a bytes32 value of the string, which should not exceed 32 ASCII characters.
function setDONOverride(bytes32 donLabel, uint32 limit, bool enabled) external onlyOwner {
if (enabled) {
s_cfg.donOverride[donLabel] = Config.Value(limit, true);
} else {
delete s_cfg.donOverride[donLabel];
}
}

/// @notice Override the max # of workflows for a specific (user, DON) pair.
function setUserDONOverride(address user, bytes32 donLabel, uint32 limit, bool enabled) external onlyOwner {
if (enabled) {
s_cfg.userDONOverride[user][donLabel] = Config.Value(limit, true);
} else {
delete s_cfg.userDONOverride[user][donLabel];
}
}

// ─────────────────────────────────────────────────────────────────────────
// Limits Config - Public Getters that return the *effective* limit
// (override if set, else default)
// ─────────────────────────────────────────────────────────────────────────

/// @notice Effective max # of workflows for a particular user.
function getMaxWorkflowsPerUser(
address user
) public view returns (uint32) {
Config.Value memory override = s_cfg.userOverride[user];
if (override.enabled) {
return override.value;
}
// fallback to the default
(uint32 defUser,,) = _unpackDefaults(s_cfg.defaultsPacked);
return defUser;
}

/// @notice Effective max # of workflows for a particular DON.
function getMaxWorkflowsPerDON(
bytes32 donLabel
) public view returns (uint32) {
Config.Value memory override = s_cfg.donOverride[donLabel];
if (override.enabled) {
return override.value;
}
// fallback to the default
(, uint32 defDON,) = _unpackDefaults(s_cfg.defaultsPacked);
return defDON;
}

/// @notice Effective max # of workflows for a (user, DON) combo.
function getMaxWorkflowsPerUserDON(address user, bytes32 donLabel) public view returns (uint32) {
Config.Value memory override = s_cfg.userDONOverride[user][donLabel];
if (override.enabled) {
return override.value;
}
// fallback to the default
(,, uint32 defUserDON) = _unpackDefaults(s_cfg.defaultsPacked);
return defUserDON;
}

/// @notice Returns the three default limits:
/// (maxWorkflowsPerUser, maxWorkflowsPerDon, maxWorkflowsPerUserDon)
function getDefaults() external view returns (uint32 maxPerUser, uint32 maxPerDon, uint32 maxPerUserDon) {
(maxPerUser, maxPerDon, maxPerUserDon) = _unpackDefaults(s_cfg.defaultsPacked);
return (maxPerUser, maxPerDon, maxPerUserDon);
}

// ─────────────────────────────────────────────────────────────────────────
// Limits Config - Internal Helpers: set/read defaults (packed into one 96-bit variable)
// ─────────────────────────────────────────────────────────────────────────

/// @dev Store 3 uint32 values in a single 96-bit field.
function _packDefaults(
uint32 maxPerUser,
uint32 maxPerDON,
uint32 maxPerUserDON
) internal pure returns (uint96 packed) {
// lower 32 bits: maxPerUser
// middle 32 bits: maxPerDON
// top 32 bits: maxPerUserDON
packed = uint96(maxPerUser) | (uint96(maxPerDON) << 32) | (uint96(maxPerUserDON) << 64);
return packed;
}

/// @dev Extract the 3 defaults from the packed value.
function _unpackDefaults(
uint96 packed
) internal pure returns (uint32 maxPerUser, uint32 maxPerDON, uint32 maxPerUserDON) {
maxPerUser = uint32(packed);
maxPerDON = uint32(packed >> 32);
maxPerUserDON = uint32(packed >> 64);
return (maxPerUser, maxPerDON, maxPerUserDON);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: BUSL 1.1
pragma solidity 0.8.24;

contract WorkflowRegistry_setDONOverride {
function test_WhenCallerIsNOTTheOwner() external {
// it should revert with OnlyCallableByOwner
}

modifier whenCallerISTheOwner() {
_;
}

function test_WhenThereAreNoOverridesYet() external whenCallerISTheOwner {
// it is the default value
// call getMaxWorkflowsPerDon(donLabelA) returns the default (e.g. 500)
}

function test_WhenLimitIsSetTo0() external whenCallerISTheOwner {
// it correctly sets the limit to 0
// call setDONOverride(donLabelA, 0)
// getMaxWorkflowsPerDon(donLabelA) returns default (override cleared)
}

function test_WhenLimitIsSetToANormalPositiveValue() external whenCallerISTheOwner {
// it correctly sets the limit
// call setDONOverride(donLabelA, 123)
// getMaxWorkflowsPerDon(donLabelA) returns 123
}

function test_WhenLimitIsSetToUint32Max() external whenCallerISTheOwner {
// it correctly sets the limit
// call setDONOverride(donLabelA, 4294967295)
// getMaxWorkflowsPerDon(donLabelA) returns 4294967295
}

function test_WhenCallingTheFunctionMultipleTimes() external whenCallerISTheOwner {
// it correctly sets the latest value
// call setDONOverride(donLabelA, 10)
// getMaxWorkflowsPerDon(donLabelA) returns 10
// call setDONOverride(donLabelA, 20)
// getMaxWorkflowsPerDon(donLabelA) returns 20 (updated)
}

function test_WhenThereAreMultipleDONs() external whenCallerISTheOwner {
// it should set the correct value for each DON
// setDONOverride(donLabelA, 77)
// getMaxWorkflowsPerDon(donLabelA) returns 77
// getMaxWorkflowsPerDon(donLabelB) returns default (500)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
WorkflowRegistry.setDONOverride
├── when caller is NOT the owner
│ └── it should revert with OnlyCallableByOwner
└── when caller IS the owner
├── when there are no overrides yet for a DON label
│ └── it is the default value
│ └── call getMaxWorkflowsPerDon(donLabelA) returns the default (e.g. 500)
├── when limit is set to 0
│ └── it correctly sets the limit to 0
│ ├── call setDONOverride(donLabelA, 0)
│ └── getMaxWorkflowsPerDon(donLabelA) returns default (override cleared)
├── when limit is set to a normal positive value
│ └── it correctly sets the limit
│ ├── call setDONOverride(donLabelA, 123)
│ └── getMaxWorkflowsPerDon(donLabelA) returns 123
├── when limit is set to uint32 max
│ └── it correctly sets the limit
│ ├── call setDONOverride(donLabelA, 4294967295)
│ └── getMaxWorkflowsPerDon(donLabelA) returns 4294967295
├── when calling the function multiple times
│ └── it correctly sets the latest value
│ ├── call setDONOverride(donLabelA, 10)
│ ├── getMaxWorkflowsPerDon(donLabelA) returns 10
│ ├── call setDONOverride(donLabelA, 20)
│ └── getMaxWorkflowsPerDon(donLabelA) returns 20 (updated)
└── when there are multiple DONs
└── it should set the correct value for each DON
├── setDONOverride(donLabelA, 77)
├── getMaxWorkflowsPerDon(donLabelA) returns 77
└── getMaxWorkflowsPerDon(donLabelB) returns default (500)
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: BUSL 1.1
pragma solidity 0.8.24;

contract WorkflowRegistry_setDefaults {
function test_WhenTheCallerIsNOTTheOwner() external {
// it should revert with OnlyCallableByOwner
}

modifier whenTheCallerISTheOwner() {
_;
}

function test_WhenThereAreNoCallsMadeYet() external whenTheCallerISTheOwner {
// it should be the constructor defaults (200, 500, 200)
}

function test_WhenItIsCalledWithATypicalValidUpdate() external whenTheCallerISTheOwner {
// it should correctly set the updated values
// call setDefaults(100, 200, 50)
// getMaxWorkflowsPerUser(...) returns 100
// getMaxWorkflowsPerDon(...) returns 200
// getMaxWorkflowsPerUserDon(...) returns 50
// other mappings/overrides remain untouched
}

function test_WhenAllValuesAreZero() external whenTheCallerISTheOwner {
// it should set 0 for all three values
// call setDefaults(0, 0, 0)
// getMaxWorkflowsPerUser(...) returns 0
// getMaxWorkflowsPerDon(...) returns 0
// getMaxWorkflowsPerUserDon(...) returns 0
}

function test_WhenAllValuesAreAtUint32Max() external whenTheCallerISTheOwner {
// it should set uint32 max for all three values
// call setDefaults(4294967295, 4294967295, 4294967295)
// getMaxWorkflowsPerUser(...) returns 4294967295
// getMaxWorkflowsPerDon(...) returns 4294967295
// getMaxWorkflowsPerUserDon(...) returns 4294967295
}

function test_WhenCalledMultipleTimesInSequence() external whenTheCallerISTheOwner {
// it should set to the most recent values
// call setDefaults(A, B, C)
// getters reflect (A, B, C)
// call setDefaults(D, E, F)
// getters now reflect (D, E, F) (overwriting previous)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
WorkflowRegistry.setDefaults
├── when the caller is NOT the owner
│ └── it should revert with OnlyCallableByOwner
└── when the caller IS the owner
├── when there are no calls made yet
│ └── it should be the constructor defaults (200, 500, 200)
├── when it is called with a typical valid update
│ └── it should correctly set the updated values
│ ├── call setDefaults(100, 200, 50)
│ │ ├── getMaxWorkflowsPerUser(...) returns 100
│ │ ├── getMaxWorkflowsPerDon(...) returns 200
│ │ └── getMaxWorkflowsPerUserDon(...) returns 50
│ └── other mappings/overrides remain untouched
├── when all values are zero
│ └── it should set 0 for all three values
│ └── call setDefaults(0, 0, 0)
│ ├── getMaxWorkflowsPerUser(...) returns 0
│ ├── getMaxWorkflowsPerDon(...) returns 0
│ └── getMaxWorkflowsPerUserDon(...) returns 0
├── when all values are at uint32 max
│ └── it should set uint32 max for all three values
│ └── call setDefaults(4294967295, 4294967295, 4294967295)
│ ├── getMaxWorkflowsPerUser(...) returns 4294967295
│ ├── getMaxWorkflowsPerDon(...) returns 4294967295
│ └── getMaxWorkflowsPerUserDon(...) returns 4294967295
└── when called multiple times in sequence
└── it should set to the most recent values
├── call setDefaults(A, B, C)
│ └── getters reflect (A, B, C)
└── call setDefaults(D, E, F)
└── getters now reflect (D, E, F) (overwriting previous)
Loading
Loading