Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewards/store: Fix claimed rewards' disappearance #1678

Merged
merged 2 commits into from
Nov 27, 2019

Conversation

e18r
Copy link
Contributor

@e18r e18r commented Nov 20, 2019

This PR is the product of some awesome team work. @chadoh helped me try to reproduce the issue locally; @stellarmagnet taught me how to inspect the state of a DAO on Rinkeby; @ottodevs generously spent many of his sleeping hours going with me through the steps to publish my
solution to Rinkeby and make it work there; and @topocount debugged an issue with claiming rewards. Thank you all so much!

On Rinkeby, when a reward is claimed it suddenly disappears from My Rewards, and reappears after switching tabs. Since I was unable to reproduce this on my local environment, I investigated the root cause of this problem and found the following facts:

F1. The recently-claimed reward disappears because it gets filtered out from the myRewards object by the app state reducer.
Screenshot from 2019-11-20 13-46-36

F2. The app state reducer filters out the reward because its userRewardAmount property suddenly becomes zero.
Screenshot from 2019-11-19 10-26-05

F3. The reward with userRewardAmount set to zero comes from the store's onRewardClaimed function, which gets the reward's properties from getRewardById.

F4. The getRewardById function gets the userRewardAmount property from the rewardAmount return value of the contract's getReward function.
Screenshot from 2019-11-19 16-52-40

F5. The rewardAmount return value of the contract's getReward function is calculated dynamically by the contract's _calculateRewardAmount function.
Screenshot from 2019-11-19 16-54-50

F6. rewardAmount is the reward's amount multiplied by the user's balance at disbursement date, divided by the reference token's supply at disbursement date.
Screenshot from 2019-11-19 17-03-16
Screenshot from 2019-11-19 17-03-02

F7. In order for rewardAmount to be zero, either the reward's amount, the user's balance or the token's supply have to be zero.

After determining this, I formulated the following hypothesis:

H1. rewardAmount is zero because the user's balance is zero, because _calculateRewardAmount is receiving the wrong user; a user who doesn't possess any of that token. This happens when the store's getRewardById function doesn't receive a userAddress param.

My hypothesis H1 is supported by the following facts:

F8. On the UI, the reward reappears whenever tabs are switched. What happens underneath is that the store's onRefreshRewards function gets called. This function calls getRewardById just like onRewardClaimed (see F3), but unlike it, it specifies a user address.

F9. Another store function, onRewardAdded, also calls getRewardById without specifying a user address, and the same behaviour on the UI can be observed as in onRewardClaimed: the
reward's rewardAmount field is set to zero. This is less noticeable, but can be observed by creating a reward while on the My Rewards tab.

F10. The userRewardAmount property of the recently-claimed reward is not the only property that becomes zero. The same happens to timeClaimed. On the contract, timeClaimed is a map from addresses to integers. The contract's getReward function uses the calling user's address to get the value. If the user's address didn't exist on the map, that could explain why it ends up being zero.
Screenshot from 2019-11-19 10-26-43
Screenshot from 2019-11-19 19-45-07

F11. @topocount solved a similar bug a while ago. The description he gave me of the bug matched perfectly H1.

In order to test my hypothesis, @ottodevs and I published my locally modified version of the Rewards app to Rinkeby, created a Rinkeby DAO and installed it there. The bug went away. You can test it here.
Peek 2019-11-20 12-17

So I can say with confidence that this PR fixes #1518 :)

The only remaining mistery is why can't this be reproduced locally. I don't know enough about the differences between Ganache and Rinkeby (or between the local environment and other blockchain
environments in general) in order to answer that question. But I do believe it's a relevant question that needs further inquiry in order to determine if the best path forward with similar issues is to
somehow make the local environment more closely resemble the blockchain, or to acknowledge the need to sometimes deploy custom Rinkeby DAOs, just like in this case.

Finally, it's important to note that this PR modifies slightly the audited contract code. That's because this is the best way to solve the bug at hand. But if modifying the contract code is not a possibility right now, I can think of some inelegant workarounds.

@e18r e18r requested a review from topocount November 20, 2019 19:03
@e18r e18r requested a review from a team as a code owner November 20, 2019 19:03
@ghost ghost requested review from ottodevs and removed request for a team November 20, 2019 19:03
*This PR is the product of some awesome team work. @chadoh helped me
try to reproduce the issue locally; @stellarmagnet taught me how to
inspect the state of a DAO on Rinkeby; @OttoG generously spent many
of his sleeping hours going with me through the steps to publish my
solution to Rinkeby and make it work there; and @topocount debugged
an issue with claiming rewards. Thank you all so much!*

On Rinkeby, when a reward is claimed it suddenly disappears from My
Rewards, and reappears after switching tabs. Since I was unable to
reproduce this on my local environment, I investigated the root cause
of this problem and found the following facts:

F1. The recently-claimed reward disappears because it gets filtered out
from the `myRewards` object by the app state reducer.

F2. The app state reducer filters out the reward because its
`userRewardAmount` property suddenly becomes zero.

F3. The reward with `userRewardAmount` set to zero comes from the
store's `onRewardClaimed` function, which gets the reward's properties
from `getRewardById`.

F4. The `getRewardById` function gets the `userRewardAmount` property
from the `rewardAmount` return value of the contract's `getReward` function.

F5. The `rewardAmount` return value of the contract's `getReward`
function is calculated dynamically by the contract's
`_calculateRewardAmount` function.

F6. `rewardAmount` is the reward's amount multiplied by the user's
balance at disbursement date, divided by the reference token's supply
at disbursement date.

F7. In order for `rewardAmount` to be zero, either the reward's amount,
the user's balance or the token's supply have to be zero.

After determining this, I formulated the following hypothesis:

H1. `rewardAmount` is zero because the user's balance is zero, because
`_calculateRewardAmount` is receiving the wrong user; a user who
doesn't possess any of that token. This happens when the store's
`getRewardById` function doesn't receive a `userAddress` param.

My hypothesis H1 is supported by the following facts:

F8. On the UI, the reward reappears whenever tabs are switched. What
happens underneath is that the store's `onRefreshRewards` function
gets called. This function calls `getRewardById` just like
`onRewardClaimed` (see F3), but unlike it, it specifies a user
address.

F9. Another store function, `onRewardAdded`, also calls
`getRewardById` without specifying a user address, and the same
behaviour on the UI can be observed as in `onRewardClaimed`: the
reward's `rewardAmount` field is set to zero. This is less noticeable,
but can be observed by creating a reward while on the My Rewards tab.

F10. The `userRewardAmount` property of the recently-claimed reward is
not the only property that becomes zero. The same happens to
`timeClaimed`. On the contract, `timeClaimed` is a map from addresses
to integers. The contract's `getReward` function uses the calling user's
address to get the value. If the user's address didn't exist on the
map, that could explain why it ends up being zero.

F11. @topocount [solved a similar
bug](11c9b3f?diff=split#diff-44e6c6b68470709dfa38518ab1f9c198R53)
a while ago. The description he gave me of the bug matched perfectly H1.

In order to test my hypothesis, @OttoG and I published my locally
modified version of the Rewards app to Rinkeby, created a Rinkeby DAO
and installed it there. The bug went away. You can test it
[here](https://rinkeby.aragon.org/#/rewards45/0x632b710170835d55946cc717a50586267c2b6832/).

So I can say with confidence that this PR fixes #1518 :)

The only remaining mistery is why can't this be reproduced locally. I
don't know enough about the differences between Ganache and
Rinkeby (or between the local environment and other blockchain
environments in general) in order to answer that question. But I do
believe it's a relevant question that needs further inquiry in order
to determine if the best path forward with similar issues is to
somehow make the local environment more closely resemble the
blockchain, or to acknowledge the need to sometimes deploy custom
Rinkeby DAOs, just like in this case.
@e18r e18r force-pushed the rewards-fix-claimed-rewards-disappearance branch from 34cffbf to e1cff47 Compare November 20, 2019 19:15
Copy link
Member

@ottodevs ottodevs left a comment

Choose a reason for hiding this comment

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

Just some checks about using tx.origin once we confirm its use does not represent any security concern, then I will fine approving this :)

Did you experience issues with msg.sender because the proxy?

Amazing work and explanation about the process @e18r !

@@ -62,8 +62,8 @@ contract Rewards is AragonApp {
Vault public vault;

/// Events
event RewardAdded(uint256 rewardId); /// Emitted when a new reward is created
event RewardClaimed(uint256 rewardId); /// Emitted when a reward is claimed
event RewardAdded(uint256 rewardId, address adder);
Copy link
Member

Choose a reason for hiding this comment

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

I don't think keeping the comments would hurt, but on the other hand the events names seem quite intuitive, so I am "ok" with the change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, a bit redundant and unnecessary. But if they're part of some convention or standard practice, it might make sense to leave them.

@@ -97,7 +97,7 @@ contract Rewards is AragonApp {

_transferReward(reward, rewardAmount);

emit RewardClaimed(_rewardID);
emit RewardClaimed(_rewardID, tx.origin);
Copy link
Member

Choose a reason for hiding this comment

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

I am not really sure if this should be msg.sender instead... I invoke @Quazia for this or try to read this to ensure tx.origin is what we need: https://ethereum.stackexchange.com/questions/76929/tx-origin-alternative

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ottodevs thank you for pointing this out. I decided to use tx.origin because in some cases the proxy address was showing up instead of the user's. But I'd need to test this more. Also, I'm gonna read about the security implications of tx.origin, which I was totally unaware of: ethereum/solidity#683

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. After reading about it, tx.origin is insecure and not future-proof. I'm going to test if msg.sender works as expected and let you know.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, neither one of these will behave as we want.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Quazia after testing msg.sender it does work as expected for the claim transaction event. Or am I missing something?

@@ -216,7 +216,7 @@ contract Rewards is AragonApp {
reward.delay = _delay;
reward.blockStart = _startBlock;
reward.creator = msg.sender;
emit RewardAdded(rewardId);
emit RewardAdded(rewardId, tx.origin);
Copy link
Member

Choose a reason for hiding this comment

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

Same here: I am not really sure if this should be msg.sender instead... I invoke @Quazia for this or try to read this to ensure tx.origin is what we need: https://ethereum.stackexchange.com/questions/76929/tx-origin-alternative

@ottodevs ottodevs self-assigned this Nov 20, 2019
Changed `tx.origin` to `msg.sender` for the case of claiming
rewards. Reverted the changes for the case of adding rewards because
`msg.sender` doesn't work there and it's not a very relevant change.
@e18r
Copy link
Contributor Author

e18r commented Nov 21, 2019

@ottodevs I changed tx.origin to msg.sender for the case of claiming rewards, and I reverted the changes for the case of creating rewards, because msg.sender in that case issues the proxy address.

This means that the bug will still be present for the case of creating rewards, but in practice it's barely noticeable at all. It will only manifest itself if all of the following is true:

a) A user creates a reward while on the "My Rewards" tab
b) The created reward can be claimed by the user
c) The user can create rewards directly without going through a vote

This is a pretty rare situation and it isn't covered on the issue this PR fixes, so I consider it safe to leave it for an eventual future PR.

@e18r e18r requested a review from ottodevs November 21, 2019 14:38
Copy link
Collaborator

@chadoh chadoh left a comment

Choose a reason for hiding this comment

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

👏 👏 👏

Amazing investigative and communicative work! The commit messages and PR description are works of art.

I'm still flummoxed that we're seeing different behavior in Rinkeby than what we see locally, and I am curious if @topocount or @Quazia have any ideas why that might be. I'd like us to understand that, before we move on, given that this mismatch implies that we may have similar bugs lurking around that we can't catch during development.

Also, I want to note that the root cause is a difference between the arguments a function expects to receive and what callers of that function actually passed, and I want to further note that this whole class of error goes away with a fully-implemented type system!

Copy link
Member

@ottodevs ottodevs left a comment

Choose a reason for hiding this comment

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

LGTM

@ottodevs ottodevs merged commit 12e15b9 into dev Nov 27, 2019
@ottodevs ottodevs deleted the rewards-fix-claimed-rewards-disappearance branch December 15, 2019 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Improve state management after a reward is claimed
4 participants