One of the best parts of competing on Code4rena is reading code from many different projects and seeing different styles, designs, and techniques in the wild. This is the first post in an occasional series about interesting patterns I've seen on C4.
Seen in Astaria.
A contract stores several configuration values, all of which should be settable only by an authorized caller.
One straightforward solution is to write a separate setter with an auth modifier for each storage variable:
address public token;
uint256 public min;
uint256 public max;
bytes32 public hash;
function setToken(address _token) external onlyAdmin {
token = _token;
}
function setMin(uint256 _min) external onlyAdmin {
min = _min;
}
function setMax(uint256 _max) external onlyAdmin {
max = _max;
}
function setHash(bytes32 _hash) external onlyAdmin {
hash = _hash;
}
The above is a good start, but in addition to setting the new value, any time a privileged caller changes a parameter, the contract should emit an event.
This is a good practice that enables off chain monitoring for unauthorized interactions, helps to debug in the event of accidental changes, and allows anyone to reconstruct a full history of configuration state.
Let's add events:
address public token;
uint256 public min;
uint256 public max;
bytes32 public hash;
event SetToken(address oldToken, address newToken);
event SetMin(uint256 oldMin, uint256 newMin);
event SetMax(uint256 oldMax, uint256 newMax);
event SetHash(bytes32 oldToken, bytes32 newHash);
function setToken(address _token) external onlyAdmin {
emit SetToken(token, _token);
token = _token;
}
function setMin(uint256 _min) external onlyAdmin {
emit SetMin(min, _min);
min = _min;
}
function setMax(uint256 _max) external onlyAdmin {
emit SetMax(max, _max);
max = _max;
}
function setHash(bytes32 _hash) external onlyAdmin {
emit SetHash(hash, _hash);
hash = _hash;
}
This is perfectly functional, but it's a bit repetitive and verbose. Imagine if our contract had ten more parameters: we'd have ten more events, and ten more setters!
Enter the file
pattern: a single function that dispatches multiple setters:
address public token;
uint256 public min;
uint256 public max;
bytes32 public hash;
event File(bytes32 what, bytes value);
error InvalidParameter(bytes32 what);
function file(bytes32 what, bytes calldata value) external onlyAdmin {
if (what == "token") token = abi.decode(value, (address));
else if (what == "min") min = abi.decode(value, (uint256));
else if (what == "max") max = abi.decode(value, (uint256));
else if (what == "hash") hash = abi.decode(value, (bytes32));
else revert InvalidParameter(what);
emit File(what, value);
}
"Dispatching setters" may sound complicated, but it's really just a big if
statement. We check for each supported what
key to determine which value to set, and dynamically ABI-decode the value
bytes into an address
, uint256
, or bytes32
when we find a match.
We can call file
with a key-value-ish syntax and ABI-encoded data to set specific values:
file("token", abi.encode(address(0x6B175474E89094C44Da98b954EedeAC495271d0F)));
file("min", abi.encode(uint256(10)));
file("max", abi.encode(uint256(100)));
file("hash", abi.encode(keccak256("all my apes gone!")));
In addition to being more concise, file
has a few other advantages:
The onlyAdmin
modifier is applied just once, to a single authenticated function. It's easy to miss or delete one of these modifiers when creating multiple setter functions.
It's possible to monitor the File
event for all configuration changes, and reconstruct the configuration history of the contract from one event rather than collating multiple different events.
Alternatively, at the cost of adding a few more functions, we can use function overloading to define file
separately for each type we need to set. Rather than ABI-decoding the value
, we can instead ABI-encode it at the very end in the File
event we emit from each function:
address public token;
uint256 public min;
uint256 public max;
bytes32 public hash;
event File(bytes32 what, bytes value);
error InvalidParameter(bytes32 what);
function file(bytes32 what, address value) external onlyAdmin {
if (what == "token") token = value;
else revert InvalidParameter(what);
emit File(what, abi.encode(value));
}
function file(bytes32 what, uint256 value) external onlyAdmin {
if (what == "min") min = value;
else if (what == "max") max = value;
else revert InvalidParameter(what);
emit File(what, abi.encode(value));
}
function file(bytes32 what, bytes32 value) external onlyAdmin {
if (what == "hash") hash = value;
else revert InvalidParameter(what);
emit File(what, abi.encode(value));
}
Our code is a little longer, but a lot cleaner. And calling these functions is nicer, because there's no need to encode the values:
file("token", address(0x6B175474E89094C44Da98b954EedeAC495271d0F));
file("min", 10);
file("max", 100);
file("hash", keccak256("all my apes gone!"));
Here's one example of the pattern from Astaria's LienToken.sol
:
enum FileType {
NotSupported,
CollateralToken,
AstariaRouter
}
struct File {
FileType what;
bytes data;
}
event FileUpdated(FileType what, bytes data);
function file(File calldata incoming) external requiresAuth {
FileType what = incoming.what;
bytes memory data = incoming.data;
LienStorage storage s = _loadLienStorageSlot();
if (what == FileType.CollateralToken) {
s.COLLATERAL_TOKEN = ICollateralToken(abi.decode(data, (address)));
} else if (what == FileType.AstariaRouter) {
s.ASTARIA_ROUTER = IAstariaRouter(abi.decode(data, (address)));
} else {
revert UnsupportedFile();
}
emit FileUpdated(what, data);
}
(See also the much more complex file
function here in AstariaRouter.sol
, which does more complex decoding and uses unstructured storage).
The first place I saw this pattern in the wild was in Maker's central accounting contract, the Vat. (And as far as I know, they were the first to call this file
). Here's the original file
function from Vat.sol
:
// --- Data ---
struct Ilk {
uint256 Art; // Total Normalised Debt [wad]
uint256 rate; // Accumulated Rates [ray]
uint256 spot; // Price with Safety Margin [ray]
uint256 line; // Debt Ceiling [rad]
uint256 dust; // Urn Debt Floor [rad]
}
mapping (bytes32 => Ilk) public ilks;
uint256 public Line; // Total Debt Ceiling [rad]
// --- Administration ---
function file(bytes32 what, uint data) external note auth {
require(live == 1, "Vat/not-live");
if (what == "Line") Line = data;
else revert("Vat/file-unrecognized-param");
}
function file(bytes32 ilk, bytes32 what, uint data) external note auth {
require(live == 1, "Vat/not-live");
if (what == "spot") ilks[ilk].spot = data;
else if (what == "line") ilks[ilk].line = data;
else if (what == "dust") ilks[ilk].dust = data;
else revert("Vat/file-unrecognized-param");
}
In Maker's case, the note
modifier handles emitting the call arguments as a ds-note anonymous event, leaving just two concise functions to handle all configuration setters.
Using a file
function to replace multiple setters is a clean, concise way to manage contracts with many configuration parameters. But it can also hide complexity in your code. Before adding a file
function to a contract with many configuration variables, consider whether they are all necessary, and whether your contract might have too many responsibilities at once. But when some complex configuration is necessary, they are a nice little technique.