Skip to content

🧾 Chainlink Proof of Reserve#1176

Merged
yuchenlintt merged 31 commits into
mainfrom
proof-of-reserve2
Aug 8, 2022
Merged

🧾 Chainlink Proof of Reserve#1176
yuchenlintt merged 31 commits into
mainfrom
proof-of-reserve2

Conversation

@pkuchtatt

Copy link
Copy Markdown
Contributor

No description provided.

@pkuchtatt
pkuchtatt requested a review from tt-krzysztof May 17, 2022 11:25
await token.setChainReserveFeed(mockV3AggregatorWith6Decimals.address)
expect(await token.chainReserveFeed()).to.equal(mockV3AggregatorWith6Decimals.address)
await token.setChainReserveHeartbeat(ONE_DAY_SECONDS)
expect(await token.chainReserveHeartbeat()).to.equal(ONE_DAY_SECONDS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We don't need to verify every time that setting the heartbeat works. It's enough to verify it in one test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed redundant checks.

})

it('should revert if TrueCurrency supply > proof-of-reserves', async () => {
// Re-deploy aggregator with fewer TUSD in reserves

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We'r not re-deploying here, just updating the oracle's answer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the comment.

Comment thread test/true-currencies/TrueCurrencyWithPoR.test.ts Outdated
Comment thread contracts/true-currencies/TrueCurrencyWithPoR.sol Outdated
Comment thread contracts/true-currencies/TrueCurrencyWithPoR.sol Outdated
require(
chainReserveHeartbeat == 0 ||
(newHeartbeat != chainReserveHeartbeat && !(newHeartbeat == 0 && chainReserveHeartbeat == MAX_AGE)),
"TrueCurrency: new chainReserveHeartbeat must be different to current heartbeat"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Add test cases covering this condition.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need this require? It shouldn't hurt to set chainReserveHeartbeat to the same value as before, and if we want an explicit MIN_AGE == 1 then we should add that as a constant -- although personally I don't see much difference between 1 second of heartbeat and 0 seconds for Chainlink's update frequency (at most once per block).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added tests covering the condition.

@yuchenlintt That's a good question. The code was originally written by a Chainlink eng. It seems for some reason he thought that not allowing to set chainReserveHeartbeat to the same value was important. I can see this would lead to emission of NewChainReserveHeartbeat(<n>, <n>) events, which would be a little silly, but we could as well skip emitting instead of reverting in this case. What do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think it's fine to emit an event that says nothing changed. Anyone who consumes the event should be able to handle this without any issues.

In smart contracts I'd much prefer to reason about simple code than a bunch of edge cases. And in this example I don't see any failure modes that would force us to add this require (which might instead backfire and prevent us from newHeartbeat == 0 if for some reason we need to, for instance).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Committed suggestion.

Comment thread contracts/true-currencies/TrueCurrencyWithPoR.sol
@yuchenlintt

Copy link
Copy Markdown
Collaborator

🚨 I haven't had a chance to review most of this code yet, but a critical first step is that we need to first merge #1170 into main, followed by merging #1171 into main.

Comment thread contracts/common/interface/IChainlinkAggregatorV3.sol Outdated
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.6.10;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can we update to the latest Solidity version?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unfortunately probably not, since the existing contract has a lot of legacy code that we would need to inspect carefully for upgrade compatibility.

* @dev Admin function to set a new feed
* @param newFeed Address of the new feed
*/
function setChainReserveFeed(address newFeed) external override onlyOwner returns (uint256) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does this function need a zero address check?

@yuchenlintt yuchenlintt May 19, 2022

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would recommend against a zero address check, since address(0) in this current implementation acts as a sentinel value that indicates proof of reserve is disabled. I would be more amenable to adding a zero address check if there were no special case logic for chainReserveFeed == address(0).

Setting to 0 might be necessary to call in case, for instance, the Chainlink aggregator goes down for a period that is longer than MAX_AGE.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We need to be able to set chainReserveFeed to 0 to disable temporarily the PoR check in case Armanino API or Chainlink is down for an extended period of time.

Comment thread contracts/true-currencies/common/ProxyStorage.sol
abstract contract TrueCurrencyWithPoR is TrueCurrency, IPoRToken {
using SafeMath for uint256;

uint256 public constant MAX_AGE = 7 days;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Will MAX_AGE never need to change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not sure. To be honest I'm not sure having MAX_AGE is needed at all, maybe it would be better to allow contract owner to set chainReserveHeartbeat to whatever he wants. What do you think? CC @yuchenlintt , @tt-krzysztof

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agree, I don't think it's necessary to have a MAX_AGE set. The setter is only callable by owner anyway, which for TUSD is the same as the proxyOwner that can override MAX_AGE via upgrade.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworked the code so chainReserveHeartbeat is initialised in the constructor to 7 days but can be later reset to any value.

uint256 public constant MAX_AGE = 7 days;

constructor() public {
chainReserveHeartbeat = MAX_AGE;

@yuchenlintt yuchenlintt May 19, 2022

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If we're doing an upgrade of TUSD, then this constructor won't get called. We'll have to tell Techteryx to queue setChainReserveHeartbeat(7 days) from the multisig after the upgrade call.

Edit: the order things are called is important! If Techteryx executes setChainReserveFeed() before setChainReserveHeartbeat(), then all mints will be rejected until heartbeat is high enough, since updatedAt will need to be at or above block.timestamp

@yuchenlintt yuchenlintt May 19, 2022

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If this isn't intended to change very often, another possibility would be to hardcode both CHAIN_RESERVE_HEARTBEAT and CHAIN_RESERVE_FEED as compiled constants. This would let us skip the setter logic and just deploy an upgrade whenever it's necessary to reset these.

Given communication delays with Techteryx, I think it's easier to ask them to queue a single upgrade than to request an ordered sequence of function calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As mentioned earlier, I think MAX_AGE is an arbitrary constant, I don't think anybody knows for sure what a really useful/practical MAX_AGE is at the moment. I think it's probably best not to constrain the possible values of chainReserveHeartbeat in the code and remove MAX_AGE altogether. Please do let me know if you think otherwise.

We need to be able to disable temporarily PoR if Armanino, Chainlink or one of our banking partners' API (most likely) is down for an extended period of time, this will be done be setting chainReserveFeed to 0. We should be able to do it quickly, e.g. in case of a VIP mint, and I think calling setChainReserveFeed should be quicker and cheaper than upgrading the contract. Do you agree?

Also, we plan to deploy it on Ethereum and Avalanche, which, I assume, will have different feed addresses, so I think hardcoding CHAIN_RESERVE_FEED in the code is probably not a good idea.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Having the option to set chainReserveFeed to 0 sounds to me like enough reason to keep this as a storage variable! And yeah, I'd overlooked that Ethereum mainnet and Avalanche will have different addresses. Definitely shouldn't hardcode in that case.

Comment on lines +44 to +45
uint256 oldestAllowed = block.timestamp.sub(chainReserveHeartbeat, "TrueCurrency: Invalid timestamp from PoR feed");
require(updatedAt >= oldestAllowed, "TrueCurrency: PoR answer too old");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unless MAX_AGE > 53 years or something seriously wrong happened to block.timestamp, I wouldn't bother to add an error message for uint underflow on a Unix timestamp.

Suggested change
uint256 oldestAllowed = block.timestamp.sub(chainReserveHeartbeat, "TrueCurrency: Invalid timestamp from PoR feed");
require(updatedAt >= oldestAllowed, "TrueCurrency: PoR answer too old");
require(updatedAt >= block.timestamp.sub(chainReserveHeartbeat), "TrueCurrency: PoR answer too old");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point! It made me realise that we do have to constrain values of chainReserveHeartbeat. Added MAX_CHAIN_RESERVE_HEARTBEAT and set it to 30 days, it's hopefully more than we will never need but still not cause the overflow here. Note, INITIAL_CHAIN_RESERVE_HEARTBEAT is set to 7 days now.

* @param newFeed Address of the new feed
*/
function setChainReserveFeed(address newFeed) external override onlyOwner returns (uint256) {
require(newFeed != chainReserveFeed, "TrueCurrency: new chainReserveFeed must be different to current feed");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need this require? I don't think it hurts to set chainReserveFeed to the same value.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I would consider skiping the storage write and event emission if it's set to the same value:

if (newFeed != chainReserveFeed) {
  ...
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed require and added ifs as suggested to avoid writing to storage and emitting events when no change.

@yuchenlintt yuchenlintt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(Possibly in a follow-up PR) it's a good idea to add a simulation test that forks the mainnet contract address 0x0000000000085d4780B73119b644AE5ecd22b376, performs the upgrade, and checks that a user is still able to mint, redeem, etc.

@pkuchtatt

Copy link
Copy Markdown
Contributor Author

🚨 I haven't had a chance to review most of this code yet, but a critical first step is that we need to first merge #1170 into main, followed by merging #1171 into main.

@yuchenlintt Thank you for the info! Please let us know when this is ready. Speaking about the repo, I noticed CI always fails on the verify task (certora). Do you have an idea why? If not, do you think we can remove the task from CI?

@yuchenlintt

Copy link
Copy Markdown
Collaborator

@yuchenlintt Thank you for the info! Please let us know when this is ready.

These PRs have been ready to merge since February, but no one's approved them :)

I noticed CI always fails on the verify task (certora). Do you have an idea why? If not, do you think we can remove the task from CI?

My guess is our dev environment is probably out of date? Calls to Certora go to an external service, so if we haven't updated our client version then there might be some errors. Feel free to remove this check from CI -- we'd only gotten around to verifying code that has since become obsolete.

Comment thread contracts/true-currencies/TrueCurrencyWithPoR.sol

// Get latest proof-of-reserves from the feed
(, int256 signedReserves, , uint256 updatedAt, ) = IChainlinkAggregatorV3(chainReserveFeed).latestRoundData();
require(signedReserves > 0, "TrueCurrency: Invalid answer from PoR feed");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Just a small suggestion to save gas but maybe we can shorten the require messages

@bryanjowers

Copy link
Copy Markdown

Thanks @yosriady and @cds95 for taking a quick look over this PR. @pkuchtatt Glad we could provide a few helpful bits of feedback, but want to call out this shouldn't be considered a complete review or audit. excited about this project!

abstract contract TrueCurrencyWithPoR is TrueCurrency, IPoRToken {
using SafeMath for uint256;

uint256 public constant MAX_CHAIN_RESERVE_HEARTBEAT = 30 days;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we had agreed it would be cleaner to remove MAX_AGE, which I believe has been renamed to MAX_CHAIN_RESERVE_HEARTBEAT:
#1176 (comment)

I might have missed the conversation where we decided to keep it, though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So, I realised here #1176 (comment) that we should constrain the values of chainReserveHeartbeat because it can potentially cause an underflow in this code:

require(updatedAt >= block.timestamp.sub(chainReserveHeartbeat), "TrueCurrency: PoR answer too old");

I think it's better to revert with a clear error message that will help in debugging rather than potentially allow the code to fail with an underflow. What do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ah, thanks for finding the context! Sorry I missed your reply from May.

I would instead rephrase the require statement like this:

require(block.timestamp.sub(updatedAt) <= chainReserveHeartbeat, "blah reasons")

It should be nearly impossible to underflow on this require since updatedAt presumably gets set to block.timestamp from a previous tx, and the chain enforces monotonicity of timestamps.

There's also a benefit that you can set extreme values for chainReserveHeartbeat like MAX_UINT256, and this code won't unexpectedly revert.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great idea! updatedAt is an answer from chainlink aggregator contract, I imagine they may be scenarios where we get a wrong value here, e.g. when owner sets chainReserveFeed to a wrong/malicious contract, and it's causing an underflow, however we can and probably should protect ourselves from this with something like

require(block.timestamp >= updatedAt)

Will implement this shortly, sorry about the delay, was setting up a new workstation and also found that an integration test is failing on the main branch, here is a PR with a fix: #1180 Can you please review and merge?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As discussed, reworked the require statement and removed MAX_CHAIN_RESERVE_HEARTBEAT .

Comment thread contracts/true-currencies/TrueCurrencyWithPoR.sol Outdated
Comment on lines +83 to +84
require(newHeartbeat <= MAX_CHAIN_RESERVE_HEARTBEAT, "TrueCurrency: PoR heartbeat too long");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Delete? This function is onlyOwner, and the owner can make any arbitrary upgrade already anyway, so this require doesn't actually prevent them from doing anything.

Suggested change
require(newHeartbeat <= MAX_CHAIN_RESERVE_HEARTBEAT, "TrueCurrency: PoR heartbeat too long");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As explained above, too big maxChainReserveHeartbeat can cause an underflow in one of the requires.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Here's my reply: #1176 (comment)

In brief: the only possible way for my proposal to underflow would be if block.timestamp < updatedAt, which should not happen assuming block.timestamp is monotonic increasing. If this change means we can cut out extra code, I'd be happy to accept a slightly less legible error in that situation.

Comment thread contracts/true-currencies/TrueCurrencyWithPoR.sol Outdated

@yuchenlintt yuchenlintt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, thanks for carrying this through!

For the sake of a cleaner commit history, I suggest we revert the irrelevant changes and handle them in a separate PR. I'll commit these as suggested changes and then approve this PR.

Comment thread test/integration/TrueFiPool2.test.ts
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
@yuchenlintt

Copy link
Copy Markdown
Collaborator

#1170 and #1171 have been merged, so this should be ready as well now!

@yuchenlintt
yuchenlintt merged commit 6db38fc into main Aug 8, 2022
@yuchenlintt
yuchenlintt deleted the proof-of-reserve2 branch August 8, 2022 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants