🧾 Chainlink Proof of Reserve#1176
Conversation
| 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) |
There was a problem hiding this comment.
We don't need to verify every time that setting the heartbeat works. It's enough to verify it in one test.
There was a problem hiding this comment.
Removed redundant checks.
| }) | ||
|
|
||
| it('should revert if TrueCurrency supply > proof-of-reserves', async () => { | ||
| // Re-deploy aggregator with fewer TUSD in reserves |
There was a problem hiding this comment.
We'r not re-deploying here, just updating the oracle's answer.
There was a problem hiding this comment.
Removed the comment.
| require( | ||
| chainReserveHeartbeat == 0 || | ||
| (newHeartbeat != chainReserveHeartbeat && !(newHeartbeat == 0 && chainReserveHeartbeat == MAX_AGE)), | ||
| "TrueCurrency: new chainReserveHeartbeat must be different to current heartbeat" |
There was a problem hiding this comment.
Add test cases covering this condition.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Committed suggestion.
|
🚨 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
|
| @@ -0,0 +1,20 @@ | |||
| // SPDX-License-Identifier: MIT | |||
| pragma solidity 0.6.10; | |||
There was a problem hiding this comment.
Can we update to the latest Solidity version?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Does this function need a zero address check?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| abstract contract TrueCurrencyWithPoR is TrueCurrency, IPoRToken { | ||
| using SafeMath for uint256; | ||
|
|
||
| uint256 public constant MAX_AGE = 7 days; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| uint256 oldestAllowed = block.timestamp.sub(chainReserveHeartbeat, "TrueCurrency: Invalid timestamp from PoR feed"); | ||
| require(updatedAt >= oldestAllowed, "TrueCurrency: PoR answer too old"); |
There was a problem hiding this comment.
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.
| 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"); |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
Do we need this require? I don't think it hurts to set chainReserveFeed to the same value.
There was a problem hiding this comment.
I would consider skiping the storage write and event emission if it's set to the same value:
if (newFeed != chainReserveFeed) {
...
}
There was a problem hiding this comment.
Removed require and added ifs as suggested to avoid writing to storage and emitting events when no change.
yuchenlintt
left a comment
There was a problem hiding this comment.
(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.
@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 |
These PRs have been ready to merge since February, but no one's approved them :)
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. |
|
|
||
| // Get latest proof-of-reserves from the feed | ||
| (, int256 signedReserves, , uint256 updatedAt, ) = IChainlinkAggregatorV3(chainReserveFeed).latestRoundData(); | ||
| require(signedReserves > 0, "TrueCurrency: Invalid answer from PoR feed"); |
There was a problem hiding this comment.
Just a small suggestion to save gas but maybe we can shorten the require messages
|
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
As discussed, reworked the require statement and removed MAX_CHAIN_RESERVE_HEARTBEAT .
| require(newHeartbeat <= MAX_CHAIN_RESERVE_HEARTBEAT, "TrueCurrency: PoR heartbeat too long"); | ||
|
|
There was a problem hiding this comment.
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.
| require(newHeartbeat <= MAX_CHAIN_RESERVE_HEARTBEAT, "TrueCurrency: PoR heartbeat too long"); |
There was a problem hiding this comment.
As explained above, too big maxChainReserveHeartbeat can cause an underflow in one of the requires.
There was a problem hiding this comment.
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.
Co-authored-by: yuchenlintt <80530881+yuchenlintt@users.noreply.github.com>
Co-authored-by: yuchenlintt <80530881+yuchenlintt@users.noreply.github.com>
…underflow for high values
yuchenlintt
left a comment
There was a problem hiding this comment.
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.
No description provided.