Seen on C4: The File Pattern

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.

The problem

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!

The pattern

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!"));

Seen on C4

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.

Subscribe to horsefacts
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.