Skip to content
This repository has been archived by the owner on Oct 3, 2023. It is now read-only.

Feat: introduce Safebox contracts #1

Open
wants to merge 40 commits into
base: master
Choose a base branch
from

Conversation

amusingaxl
Copy link
Member

@amusingaxl amusingaxl commented Oct 25, 2022

A safebox for digital assets.

  • The owner can request funds to be sent to the recipient.
  • The custodian can deny the request within a predefined time period (24h).
  • After the timelock, anyone can execute the withdrawal.
  • The custodian cooperation is required whenever the owner wants to update the recipient.

Copy link

@dbkbali dbkbali left a comment

Choose a reason for hiding this comment

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

Looks good to me - based on our discussions

README.md Show resolved Hide resolved
src/Safebox.sol Outdated
* @param to The destination address.
* @param amount The amount to be transfered.
*/
function _safeTransfer(address token, address to, uint256 amount) internal {

Choose a reason for hiding this comment

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

I'd reduce the verbosity of the code just using a simple transfer. I don't see Maker interacting with a token that doesn't revert and this contract is behind governance setting it up for a specific purpose. This is only useful for contracts that are open to any token.
At least I'd remove the internal function and just put the code in withdraw (same for deposit) as there is not reuse of it. But honestly I'd just keep it simple with the regular transfer.

Copy link
Member Author

Choose a reason for hiding this comment

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

Makes sense, removed.

Copy link
Member Author

Choose a reason for hiding this comment

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

Another thing I considered was to make token a constructor parameter instead of a parameter for withdraw.

As it is, the Safebox can be seen as multi-token, but there will probably never be a case for it to hold more than 1 asset.

* @param data The new value of the parameter.
*/
function file(bytes32 what, address data) external auth {
if (what == "recipient") {

Choose a reason for hiding this comment

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

This should be blocked when VAT is not live.

Choose a reason for hiding this comment

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

++

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

src/Safebox.sol Outdated

/**
* @notice Approves the change in the recipient.
* @dev Reverts if `previousRecipient` has not been set or if `_recipient` does not match it.

Choose a reason for hiding this comment

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

previousRecipient => pendingRecipient ?

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed.

src/Safebox.sol Outdated
* @title A safebox for digital assets.
* @notice
* - The `owner` is in full control of how much and when it can send assets to `recipient`.
* - If MakerDAO governance ever executes an Emergency Shutdown, anyone can send assets to `recipient`. This prevents assets being stuck in this contract when the governance smart contract is no longer operational.

Choose a reason for hiding this comment

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

This line is really long

Copy link
Member Author

Choose a reason for hiding this comment

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

Visual glitch on my editor. Already fixed.

src/Safebox.sol Outdated
* @param token The token withdrawn.
* @param amount The amount withdrawn.
*/
event Withdraw(address indexed token, uint256 amount);

Choose a reason for hiding this comment

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

This should probably emit the recipient as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

Also added the sender to the Deposit event.

README.md Outdated
There are 3 "roles" in this contract:

1. `owner`: has full control of how much and when it can send assets to `recipient`.
2. `custodian`: approves changes in the `recipient` address made by the `owner`.

Choose a reason for hiding this comment

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

mmm namewise - this entity is not really storing/custodying the tokens, it can only block changing the recipient.

Copy link
Member Author

Choose a reason for hiding this comment

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

It has to be named like this for legal reasons.

src/Safebox.sol Outdated
/// @notice Addresses with owner access on this contract. `wards[usr]`
mapping(address => uint256) public wards;
/// @notice Addresses with custodian access on this contract. `can[usr]`
mapping(address => uint256) public can;

Choose a reason for hiding this comment

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

If custodian is effectively immutable and can only be set in the constructor why is this mapping needed?

Choose a reason for hiding this comment

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

Yeah this is a good observation. Let's just make an immutable variable and remove the mapping.

Copy link
Member Author

Choose a reason for hiding this comment

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

Coinbase requested the ability to be able to rotate their keys, so this will be needed. I'm adding the functionality right now.

src/Safebox.sol Outdated
/**
* @notice Check if an address has `custodian` access on this contract.
* @param usr The user address.
* @return Whether `usr` is a ward or not.

Choose a reason for hiding this comment

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

usr => custodian

src/Safebox.sol Outdated
* @notice Removes a custodian from this contract.
* @param usr The user address.
*/
function removeCustodian(address usr) external override onlyCustodian {

Choose a reason for hiding this comment

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

Note that as with wards behavior, also here if there is more than one custodian this will allow any custodian to remove any other.
So need to make sure this is wanted.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that's the expected feature.

src/Safebox.sol Outdated
* @param _recipient The new recipient being approved.
*/
function approveChangeRecipient(address _recipient) external override {
require(custodians[msg.sender] == 1, "Safebox/not-custodian");

Choose a reason for hiding this comment

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

Should this be replaced with the onlyCustodian modifier?


vat = VatAbstract(_vat);
token = GemAbstract(_token);
recipient = _recipient;

Choose a reason for hiding this comment

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

Consider emitting a RecipientChange event (and maybe changing the event name to RecipientSet or something similar).

Copy link
Member Author

Choose a reason for hiding this comment

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

All other events have "verb-like" structure (i.e.: Rely, Deny, AddCustodian), I'm gonna use SetRecipient for this.

constructor(address _vat, address _token, address _owner, address _custodian, address _recipient) {
require(_recipient != address(0), "Safebox/invalid-recipient");

wards[_owner] = 1;

Choose a reason for hiding this comment

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

Note that common behavior for MakerDao contracts is to only rely msg.sender in the constructor.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, we are aware of that, however, since Coinbase will be handling the deployment, we want to avoid them having to deny themselves afterwards.

Copy link
Member Author

@amusingaxl amusingaxl Nov 10, 2022

Choose a reason for hiding this comment

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

Just to clarify, we are gonna provide them the parameters for deployment and then verify the contract after it's deployed. If there is a mismatch, the verification will fail and we will know.

Also the variables are public, so they could be easily checked after deployment & verification.

src/Safebox.sol Outdated
* @notice Deposits tokens into this contract.
* @param amount The amount of tokens.
*/
function deposit(uint256 amount) external override {

Choose a reason for hiding this comment

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

Do you plan to use this function for depositing from the swap/output conduit?
Seems like current code passes the funds by specifying the recipient of buyGem.

Copy link
Member Author

Choose a reason for hiding this comment

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

You are right, I'm gonna remove that.
The idea was to keep track of Deposit events, but we can do the same by tracking Push from the RwaSwapOutputConduit contract.

@amusingaxl amusingaxl requested review from rockyfour and removed request for gbalabasquer November 10, 2022 15:21
Copy link

@The-Arbiter The-Arbiter left a comment

Choose a reason for hiding this comment

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

Hey guys, here is my brief analysis of SafeBox. I have extensive experience with escrows so I am reviewing it from the perspective of an escrow in "Maker style" :)
Since I am reviewing this in isolation, context may be missing with regards to some comments, however I have made various points as documented below:

TLDR

  • 'Dumb depositing' ERC20 poses risks. I will go into this in the Attack Vectors section at the end.
  • Everything is override and I really don't think it should be (also nothing is virtual in SafeBoxLike.sol)
  • Some of the names could be changed to be more like regular Daiwanese, especially the addCustodian stuff
  • The structure of the 2 step ownership makes checking for address(0) edge cases mandatory however allows for smart contract wallets / recipients. This is a contextual thing, I want to confirm that this is intended.
  • IsOwner and IsCustodian seem redundant unless I'm missing some context here.
  • Comments and NATSPEC could do with trimming down depending on how dss-ish you want the code to look; this resembles how I wrote my own code prior to adapting to the dss style so this is just a general comment.

Documentation

  • This looks good, just needs updates when it's all complete.
  • I would appreciate some notes on why address(0) is checked for (i.e. to allow pendingRecipient to work only if it's initialised)

SafeBox.sol

  • AGPL license OK
  • All interfaces used
  • Not immediately clear to me how the Abstracts are used with the external view return functions in SafeBoxLike.sol but this is probably my lack of knowledge (have never tried to just return an imported interface before). Knowledge gap here so someone else needs to fill this in and make sure it's OK.
  • VatAbstract and GemAbstract OK - do we really need to use dss interfaces for only 3 interfaces? Personally I would use VatLike and GemLike.
  • Mappings OK - fixed spacing
  • Recipient and pending recipient vars OK
  • Auth mapping OK
  • onlyCustodian code OK - modifier name seems a bit out of place since it sits next to auth - not a big issue
  • Constructor OK
    • Rely owner OK
    • Add custodian OK
      I don't like AddCustodian as a name when we have rely already used as a keyword here.
    • Set recipient OK
    • Set vat OK
    • Set gem OK
      I would change the param order to match either when they're used (sequentially) or make it alphabetically ordered, but that's me.
  • withdraw has an attack vector explained below but the code is OK.
  • rely - why is this override?
  • deny - same issue
  • file is OK - I made it public and not override but this is optional as it varies between implementations
    The address(0) check is needed to stop issues with pendingRecipient and this should be noted in documentation.
  • isOwner - why do we need this? Can't they just query wards[usr]? I don't see a need for this (I may be missing context?) but the code is OK.
  • isCustodian - same as isOwner
  • addCustodian OK but should be called relyCustodian
  • removeCustodian OK but should be called denyCustodian
  • approveChangeRecipient - so I have some notes on this:
    Existing implementation seems OK as it is. The benefit of it is that it doesn't rely on a call from the recipient's address. The drawback is that we have to ensure that the recipient isn't address(0) for this to be possible.
    OZ has a 2 step transfer method however that requires that the recipient calls the accept function which works for EOAs but not things like smart contract wallets. In the end this depends on the usecase and I may be missing the context here.
  • No deposit function is present anymore (the previous deposit function looked OK though)

Attack Vectors

Assume that I am a malicious member of governance and decide to steal the funds of the SafeBox. I can use CREATE2crunch or another similar tool to determine the salt for a contract address which resembles an ERC20 token's address, then deploy my own ERC20 there which looks like the real thing, however, my transfer function is malicious.
While a deposit function would only transfer in tokens with address token (meaning that deposit and withdraw keeps a 'closed loop' such that the deposited token is the one being withdrawn), depositing directly to the contract would allow this kind of attack to occur if there was someone malicious responsible for the SafeBox deployment.

src/Safebox.sol Outdated
GemAbstract public immutable override token;

/// @notice Addresses with owner access on this contract. `wards[usr]`
mapping(address => uint256) public override wards;

Choose a reason for hiding this comment

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

Spacing

Suggested change
mapping(address => uint256) public override wards;
mapping (address => uint256) public override wards;

Copy link
Member Author

Choose a reason for hiding this comment

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

This is handled by prettier-plugin-solidity.

src/Safebox.sol Outdated
/// @notice Addresses with owner access on this contract. `wards[usr]`
mapping(address => uint256) public override wards;
/// @notice Addresses with custodian access on this contract. `custodians[usr]`
mapping(address => uint256) public override custodians;

Choose a reason for hiding this comment

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

Spacing

Suggested change
mapping(address => uint256) public override custodians;
mapping (address => uint256) public override custodians;

src/Safebox.sol Outdated
/// @notice Reference to the new recipient when it is to be changed.
address public override pendingRecipient;

modifier auth() {
Copy link

@The-Arbiter The-Arbiter Nov 10, 2022

Choose a reason for hiding this comment

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

Not crazy on the layout of code but it's subjective...

Maybe we should do it like autoline? (https://github.com/makerdao/dss-auto-line/blob/master/src/DssAutoLine.sol) where the rely deny and auth are together in the code? Up to you

Copy link
Member Author

Choose a reason for hiding this comment

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

We're using CES layout for this code.

* @param _custodian The safebox custodian.
* @param _recipient The recipient for tokens in the safebox.
*/
constructor(address _vat, address _token, address _owner, address _custodian, address _recipient) {

Choose a reason for hiding this comment

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

Any reason for the ordering of the params here? Doesn't seem to follow any specific logic - up to you here but I would order it either alphabetically or based on order of use within the constructor (owner custodian recipient vat token). Again up to you

Copy link
Member Author

Choose a reason for hiding this comment

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

We're trying to logically group them:

MCD core modules > General contracts > Roles for this contract.

emit Rely(_owner);

custodians[_custodian] = 1;
emit AddCustodian(_custodian);

Choose a reason for hiding this comment

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

Why not RelyCustodian to keep it in line with existing naming conventions?

Copy link
Member Author

Choose a reason for hiding this comment

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

We're refraining from using Daiwanese for functions that are exclusively accessible to Coinbase.

src/Safebox.sol Outdated
* @notice Grants `usr` owner access to this contract.
* @param usr The user address.
*/
function rely(address usr) external override auth {

Choose a reason for hiding this comment

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

Suggested change
function rely(address usr) external override auth {
function rely(address usr) external auth {

Copy link
Member Author

Choose a reason for hiding this comment

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

override is here to ensure it's implementing the same method declared in the SafeboxLike interface.

src/Safebox.sol Outdated
* @notice Revokes `usr` owner access from this contract.
* @param usr The user address.
*/
function deny(address usr) external override auth {

Choose a reason for hiding this comment

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

Suggested change
function deny(address usr) external override auth {
function deny(address usr) external auth {

src/Safebox.sol Outdated
* @param what The changed parameter name. `"recipient"`
* @param data The new value of the parameter.
*/
function file(bytes32 what, address data) external override auth {

Choose a reason for hiding this comment

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

Make this public and no override?

Suggested change
function file(bytes32 what, address data) external override auth {
function file(bytes32 what, address data) public auth {

Copy link
Member Author

Choose a reason for hiding this comment

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

Why public instead of external?

README.md Outdated
There are 2 possible ways of making a deposit into the `Safebox`:

1. Use the `Safebox` address as the `to` address of an ERC-20 compatible `transfer` transaction.
2. Call the `deposit`<sup>[1]</sup> method from the `Safebox` contract:

Choose a reason for hiding this comment

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

If this is removed we should remove this :)

@rockyfour
Copy link

@amusingaxl can you please point us to the code of the input conduit with the support for post shutdown collateral distribution (basically the intended recipient of SafeBox)?

@amusingaxl
Copy link
Member Author

Thank you for the thorough review @The-Arbiter.

Just providing some context:

We are between a rock and a hard place in a sense that we want to use the regular Daiwanese where it's possible, however there was some push back on that from the Coinbase side.

This contract is going to be operated by both MakerDAO Governance and Coinbase in a collaborative manner, so our strategy is trying to segregate the interfaces that each party will interact with. That's why we kept things like wards, file, etc. for the MakerDAO-facing methods, while the part that will mostly be accessed by Coinbase have "more common" names.

Contrary to most MCD contract out there, a ward is not able to add or remove "custodian" privileges, only another custodian can do so. That was explicitly requested by Coinbase, because they were not comfortable with the idea of MakerDAO Governance being freely able to replace them.

Now answering to some of your comments:

Everything is override and I really don't think it should be (also nothing is virtual in SafeBoxLike.sol)

In previous Solidity version you were required to override functions declared in interfaces, looks like I forgot this behavior changed in Solidity 0.8.8.

Some of the names could be changed to be more like regular Daiwanese, especially the addCustodian stuff

As explained above, we are using Daiwanese only for MakerDAO-facing features, as this was a requirement from Coinbase.

The structure of the 2 step ownership makes checking for address(0) edge cases mandatory however allows for smart contract wallets / recipients. This is a contextual thing, I want to confirm that this is intended.

For this specific deal, the recipient is going to be a smart contract (a RwaSwapInputConduit2 instance to be more precise). I don't think adding a block for EOAs brings much in terms of security, because changing a recipient requires collaboration between both parties. The chances of having this being set to an unintended address is pretty slim, since changes have to go through the due legal process.

IsOwner and IsCustodian seem redundant unless I'm missing some context here.

These are mainly for Coinbase's use. While the custodians mapping has a "common" name, they felt the need of having something closer to the legal language "owner", so we added a isOwner as a wrapper for wards. For consistency, we decided to add isCustodian, but the latter can be removed if PE feels strongly against it.

Comments and NATSPEC could do with trimming down depending on how dss-ish you want the code to look; this resembles how I wrote my own code prior to adapting to the dss style so this is just a general comment.

Our understanding is that this is not a Core MCD contract, so it would be fine to use CES coding convention here. Again, it's not something that would be a blocker for us, however considering the Coinbase requirements, I find it very hard to make it 100% compliant anyways.

I would appreciate some notes on why address(0) is checked for (i.e. to allow pendingRecipient to work only if it's initialised)

Noted! Will update the docs.

Not immediately clear to me how the Abstracts are used with the external view return functions in SafeBoxLike.sol but this is probably my lack of knowledge (have never tried to just return an imported interface before). Knowledge gap here so someone else needs to fill this in and make sure it's OK.

From an ABI perspective, those types are simply translated to address. However, we usually store contract references with their actual type to make them easier to consume. It's out of the scope of this review, but the contract to enable the recovery of funds after Emergency Shutdown will use the SafeboxLike interface.

VatAbstract and GemAbstract OK - do we really need to use dss interfaces for only 3 interfaces? Personally I would use VatLike and GemLike.

Those interfaces are pretty much set in stone at this point. We import the specific files directly, so it wouldn't be a problem of bloating contract source verification for instance. We could make some copy-pasta from dss-interfaces and remove the methods not used though if you think that's the best way.

onlyCustodian code OK - modifier name seems a bit out of place since it sits next to auth - not a big issue

I've been back and forth with the layout of this contract a couple of times TBH 😅.
I'll probably move everything accessible only to coinbase to their designated section.

Existing implementation seems OK as it is. The benefit of it is that it doesn't rely on a call from the recipient's address. The drawback is that we have to ensure that the recipient isn't address(0) for this to be possible.

Yes, this is by design. Recipient is not meant to play an active role here because.


Regarding the possible attack vector:

Assume that I am a malicious member of governance and decide to steal the funds of the SafeBox. I can use CREATE2crunch or another similar tool to determine the salt for a contract address which resembles an ERC20 token's address, then deploy my own ERC20 there which looks like the real thing, however, my transfer function is malicious.
While a deposit function would only transfer in tokens with address token (meaning that deposit and withdraw keeps a 'closed loop' such that the deposited token is the one being withdrawn), depositing directly to the contract would allow this kind of attack to occur if there was someone malicious responsible for the SafeBox deployment.

Wouldn't this be solved by having the diligence of checking the token value matches exactly the expected one after deployment before the onboarding spell?

We can even safeguard against this in the spell itself by having a sanity check like:

require(address(safebox.token()) == chainlog.getAddress("<TOKEN>"), "Invalid safebox token");

Any other asset sent to this contract other than token itself is locked there forever.

@amusingaxl
Copy link
Member Author

@amusingaxl can you please point us to the code of the input conduit with the support for post shutdown collateral distribution (basically the intended recipient of SafeBox)?

I'm finishing the required changes and will post the link to it here as soon as I'm done.

@amusingaxl
Copy link
Member Author

@The-Arbiter @rockyfour here's the input conduit that is going to be used as recipient in this case: https://github.com/makerdao/mip21-toolkit/pull/27/files#diff-42d74a78f57f1a5daa3af319c8bdbec3e7850b836cd393fb7caa0b3ada65a0e2

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants