Trimming the fat: how we shaved 4kb off our oversized loan contract

Goldfinch Foundation
goldfinch_fi
4 min readJul 6, 2023

--

By Dalton Sweeney, Software Engineer

There comes a time when every smart contract developer encounters this fateful warning: Warning: Contract code size exceeds 24576 bytes (a limit introduced in Spurious Dragon). This contract may not be deployable on mainnet. Consider enabling the optimizer (with a low “runs” value!), turning off revert strings, or using libraries.

Here at Warbler labs we encountered the issue in one of Goldfinch Protocol’s core contracts, CallableLoan. We experimented with several strategies and implemented the most effective ones to get the contract’s size from ~26kb to ~21.5kb. In this post we’ll cover those strategies in order of most impactful to least impactful.

Make libraries external

An unintuitive fact of using libraries in Solidity is that they must have external functions in order for them to actually reduce contract size. A library with an external function is deployed as a separate contract, and your contract delegate calls into the library. If your contract calls an internal library function then the compiler will inline the function in your contract, and hence make it bigger, not smaller.

By converting the functions in CallableLoan’s libraries from internal to external we were able to decrease the contract’s size by ~3.9 kb [1]. This was the most impactful strategy we tried by a large margin.

Further optimization — selectively making functions internal and external

Note that a library can have a mix of external and internal functions. If a function is sufficiently small then the bytecode overhead of delegate calling into it can exceed the bytecode for inlining it in the calling contract (you can see an example of this by running solc --ir Incrementer.sol on this toy contract and examining the outputted Yul). You could play around with which library functions you make internal vs. external and see how it affects the bytecode size of your calling contract. Consider making the largest library functions external (delegate called) while keeping the smallest ones internal (inlined).

Remove convenience functions

CallableLoan’s size forced us to think about which functions were absolutely necessary. This isn’t only a contract size consideration, but also a security one. Every additional state modifying function increases a contract’s surface area for potential vulnerabilities — for example, the seemingly innocuous donateToReserves function had devastating consequences for the Euler protocol.

By removing two convenience functions withdrawMax and withdrawMultiple we were able to reduce CallableLoan’s size by 0.6 kb. These functions were variants of our withdraw function but could be recreated exactly through other means so we dropped them.

Shorten and remove error strings

Turning off revert strings is one of the compiler’s official recommendations, but it’s all or nothing. If you use the revertStrings compiler option to disable revert strings, then all revert strings are disabled.

We wanted to keep most of our revert strings for debugging reasons and thus searched for the biggest strings to remove first. The major culprits were OpenZeppelin’s AccessControl and Initializable contracts. They have long error strings and we use those contracts heavily as base contracts. Replacing their revert strings with two-character revert strings shrank CallableLoan by almost 1kb.

A short error string convention

Keeping your error strings short across the protocol can save you a lot of bytecode, but the primary tradeoff is losing information in your error messages. Our convention is to make our error strings two character abbreviations of a full error message, and we put the full error message in a the NatSpec so it shows up in documentation. This keeps our error strings short yet informative. Here’s an example

/**
* @inheritdoc ILoan
* @dev ZA: Zero amount
* @dev TL: invalid amount
* @dev TL: tranche locked
*/
function withdraw(
uint256 tokenId,
uint256 amount
) public override nonReentrant whenNotPaused returns (uint256, uint256) {
...
require(amount > 0, "ZA");
...
}

This advice only applies if you’re restricted to a compiler version before v0.8.4. If you’re on v0.8.4 or higher then it’s best practice to use custom errors.

Make AccessControl roles and other constants internal

The Goldfinch Protocol uses OpenZeppelin’s AccessControl for its access control needs. It’s common for us to declare roles as public constants in our contracts:

contract CallableLoan {
bytes32 public constant LOCKER_ROLE = keccak256("LOCKER_ROLE");
bytes32 public constant OWNER_ROLE = keccak256("OWNER_ROLE");
...
}

But for every role that’s a public constant we’re effectively creating a public function. That’s a lot of bytecode! By making the roles internal were able to reduce CallableLoan by over 0.2 kb.

contract CallableLoan {
bytes32 private constant LOCKER_ROLE = keccak256("LOCKER_ROLE");
bytes32 private constant OWNER_ROLE = keccak256("OWNER_ROLE");
...
}

Conclusion

The external library strategy was our biggest win but having the other strategies in our toolkit will be useful if we push up against the size limit again. There is another strategy that completely eliminates this problem, but would require a lot of refactoring: the Diamond Proxy. It’s something we may consider for future protocol contracts.

Footnotes

[1] In refactoring CallableLoan to make its libraries external, we uncovered a bug in forge’s contract linking. Two of CallableLoan’s libraries called each other, creating a circular dependency and triggering an infinite loop in forge’s source code. The problem didn’t surface when the libraries were internal because they were part of the same contract.

--

--

Goldfinch Foundation
goldfinch_fi

Goldfinch is a decentralized credit protocol that allows anyone to be a lender, not just banks. https://goldfinch.finance/