Closed MaksymZavershynskyi closed 4 years ago
Places that do the checks that verify and discard invalid transactions early on. With the proposed change these checks will also discard invalid staking actions early on:
Unfortunately this doesn't quite work. When you want to decrease your stake, since the balance does not change immediately, we have to record your intention, which means that we have to at least add another field in the account. I would rather that we don't add anything into account that is not strictly necessary because people do get charged for it.
When you want to decrease your stake, since the balance does not change immediately
Are you suggesting that in addition to subtracting the staked amount from Account::amount
we need to record somewhere on the account level what is the amount of stake is currently in-flight with the receipt? What do we need it for?
I would rather that we don't add anything into account that is not strictly necessary because people do get charged for it.
Please consider again the list of the benefits this change offers -- it is a pretty long list.
@bowenwang1996 I just checked Account::locked
it is not used outside the runtime. That seems to contradict what you are saying here:
When you want to decrease your stake, since the balance does not change immediately, we have to record your intention, which means that we have to at least add another field in the account.
We don't touch your account right now if you already staked and want to decrease your stake. Because of that, we need to record your intention in case you send another staking transaction in the same epoch, assuming we don't change how staking is handled from the blockchain side (I would highly recommend that we don't change it as doing so might very well delay our progress by several weeks, if not months).
As for the benefits, I don't completely agree that the transfer model is better. When I stake I am more interested in the total stake that is locked right now rather than thinking about how much I should adjust. Also even if we do this, the logic would still be similar and I don't see how it is as simple as account transfer.
We don't touch your account right now if you already staked and want to decrease your stake. Because of that, we need to record your intention in case you send another staking transaction in the same epoch, assuming we don't change how staking is handled from the blockchain side (I would highly recommend that we don't change it as doing so might very well delay our progress by several weeks, if not months).
The whole point of this change is to avoid having this "declaration of intent" thing. There is no need to declare an intent if we subtracted the tokens that are going to be staked immediately when the transaction is processed.
assuming we don't change how staking is handled from the blockchain side
I will write here again what I wrote above: Account::locked
is not used by the chain at all. So this change should be completely transparent to anything happening outside the Runtime.
I would highly recommend that we don't change it as doing so might very well delay our progress by several weeks, if not months
Could you elaborate on exactly how is it going to delay us by weeks or months?
As for the benefits, I don't completely agree that the transfer model is better. When I stake I am more interested in the total stake that is locked right now rather than thinking about how much I should adjust. Also even if we do this, the logic would still be similar and I don't see how it is as simple as account transfer.
Okay, I see you addressed the first bullet of benefits (the mental model one). Could you please address the remaining 5 bullets describing the benefits of this change?
The whole point of this change is to avoid having this "declaration of intent" thing. There is no need to declare an intent if we subtracted the tokens that are going to be staked immediately when the transaction is processed.
I don't understand how you do it when you already staked and want to decrease your stake.
I will write here again what I wrote above:
Account::locked
is not used by the chain at all. So this change should be completely transparent to anything happening outside the Runtime.
I understand. That's why I said "assuming".
Okay, I see you addressed the first bullet of benefits (the mental model one). Could you please address the remaining 5 bullets describing the benefits of this change?
I also addressed second point. For this
The account information is not up to date while the staking receipt is in flight! Currently if person A issues a staking transaction and concurrently someone calls a smart contract method on A (could be called by a different account or the same account) that creates a transfer action that takes X amount of tokens and sends it to account C, the staking transaction might fail upon the processing of the receipt; I don't quite understand. Staking transaction creates a local receipt that either succeeds or fails. If it succeeds then the account information is updated. If it fails then the account information is not updated. I think I am missing something here.
It is clear from the discussion in #1713 that people agree that we need to change how we do unstaking. The change proposed here removes the need for the separate unstaking action altogether;
How does this work? If you want to unstake you send a transaction with amount = 0 or amount = the total amount staked?
We can benefit from all the checks that filter out invalid transactions early on. These checks currently work only for transfers and balances attached to function calls.
I agree. But we can also add this check for staking.
I don't understand how you do it when you already staked and want to decrease your stake.
Suppose you want to decrease your stake by 10 then you send a transaction with StakeAction::amount=-10
.
I also addressed second point.
The second point states that fn action_stake
is much more complex than fn action_transfer
and this change will fix this. I do not see where you addressed this, could you please paste a quote or rephrase?
How does this work? If you want to unstake you send a transaction with amount = 0 or amount = the total amount staked?
You send the staking action with StakeAction::amount=-<currently locked amount>
.
I agree. But we can also add this check for staking.
Notice, that there are four checks. You can have these checks with the current model, but they will not be able to catch some failures. For instance, after we convert a staking transaction into a staking receipt it can still fail when receipt is processed. With transfers, it cannot happen. With the current staking method, a contract can create an invalid staking receipt that will fail once it is processed. With transfers and the proposed change, it will not be possible. Once we implement this change ActionError::TriesToUnstake
and ActionError::TriesToStake
will be gone.
Please also address the benefits 3-6.
Suppose you want to decrease your stake by 10 then you send a transaction with
StakeAction::amount=-10
.
Sorry for being unclear. I meant I don't understand how it works on the account level.
The second point states that
fn action_stake
is much more complex thanfn action_transfer
and this change will fix this. I do not see where you addressed this, could you please paste a quote or rephrase?
I don't see how with this change the logic will be simpler.
You send the staking action with StakeAction::amount=-
I would say that's not as intuitive as sending a unstaking transaction or even a staking transaction with amount 0 because you first need to look up the locked amount. Another problem is that in case reward is added in the same block, things will be messed up since reward is added before transaction get processed and you can just unintentionally unstake a large chunk of your stake.
I agree with 4. For 3 and 6 I see the point, but I am not sure I understand why transaction failure is better than receipt failure. 5 is addressed above.
Sorry for being unclear. I meant I don't understand how it works on the account level.
The same way it works with transfers. When we process a transaction that contains StakeAction
if StakeAction::amount > 0
we deduct it from Account::amount
if StakeAction::amount < 0
we deduct if from Account::locked
; we then create a receipt. When we process the receipt if StakeAction::amount > 0
we deposit it to Account::locked
, if StakeAction::amount < 0
we deposit it to Account::amount
.
I don't see how with this change the logic will be simpler.
Here is the current code:
pub(crate) fn action_stake(
account: &mut Option<Account>,
result: &mut ActionResult,
account_id: &AccountId,
stake: &StakeAction,
) {
let mut account = account.as_mut().unwrap();
let increment = stake.stake.saturating_sub(account.locked);
if account.amount >= increment {
if account.locked == 0 && stake.stake == 0 {
// if the account hasn't staked, it cannot unstake
result.result = Err(ActionError::TriesToUnstake(account_id.clone()));
return;
}
result.validator_proposals.push(ValidatorStake {
account_id: account_id.clone(),
public_key: stake.public_key.clone(),
amount: stake.stake,
});
if stake.stake > account.locked {
account.amount -= increment;
account.locked = stake.stake;
}
} else {
result.result = Err(ActionError::TriesToStake(
account_id.clone(),
stake.stake,
account.locked,
account.amount,
));
}
}
This is how the code will look like after the change:
pub(crate) fn action_stake(account: &mut Option<Account>, stake: &StakeAction) {
let mut account = account.as_mut().unwrap();
if stake.amount > 0 {
account.locked += stake.amount;
} else {
account.amount += stake.amount;
}
}
Forget that it is much shorter, the proposed code not only cannot throw errors, but it does not need for us to define custom errors specifically for this place in code that we have to process somewhere upstream.
I would say that's not as intuitive as sending a unstaking transaction or even a staking transaction with amount 0 because you first need to look up the locked amount.
You need to do the same action with the transfer. If Alice wants Bob to have 100 tokens she needs to first lookup his account balance and then send the remaining tokens.
Another problem is that in case reward is added in the same block, things will be messed up since reward is added before transaction get processed and you can just unintentionally unstake a large chunk of your stake.
How exactly an accidental unstaking of your own stake is going to happen?
but I am not sure I understand why transaction failure is better than receipt failure
Because:
Do you agree with 3?
5 is addressed above.
I have replied to your argument about 5. Does it clarify how unstaking works?
The same way it works with transfers. When we process a transaction that contains
StakeAction
ifStakeAction::amount > 0
we deduct it fromAccount::amount
ifStakeAction::amount < 0
we deduct if fromAccount::locked
; we then create a receipt. When we process the receipt ifStakeAction::amount > 0
we deposit it toAccount::locked
, ifStakeAction::amount < 0
we deposit it toAccount::amount
.
This unfortunately doesn't work because we cannot reduce your locked amount right away. Otherwise staking invariant will break. That is also why the logic you see for processing staking action is not as simple as token transfer.
This unfortunately doesn't work because we cannot reduce your locked amount right away.
Are you saying we can only increase the stake and unstake entirely but not decrease the stake? What is the reason for that?
Are you saying we can only increase the stake and unstake entirely but not decrease the stake? What is the reason for that?
Yes. The reason is how stake return and rewards are calculated. It is not very easy to explain but basically there is some invariant we maintain to make sure rewards and stake are returned properly and it relies on the assumption that staking cannot decrease your stake in the same epoch. Even though you might think this is very suboptimal, I have to say that this is what we agreed on and it is a collective effort between more than one person who work on chain. I am for sure not saying that a better solution doesn't exist, but we have spent some time on this and have not come up with a better solution. Also, depending on how the solution works, at this point it might be too late to change as it might involve refactoring multiple parts of chain.
StakeAction::amount=-10
is not intuitive to me. I would rather forbid decreasing action to avoid confusion (there are no transfer transactions of a negative amount).
As a note, I have seen questions on the staking transactions like “why can I stake 10N twice?”, so a cumulative amount seem to make sense to people. However, we can easily fix this on the tooling side and I also believe that we can implement both cumulative and absolute amount staking on top of both the current and the proposed design (+ unstaking)
I think the main issue is that account.amount
is "The sum of amount
and staked
is the total value of the account.". The fact that account.amount
mixes liquid and locked tokens makes it harder to reason about and forces to keep in mind about the locked part
each time when user works with. If we separate locked
and amount
things become much easier.
Idempotent APIs are much easier to reason about. "I want to stake 'x' amount of my tokens" vs "I want to change amount of staked tokens by".
account.amount
is only liquid tokens
account.locked
is currently locked tokens. This value can only be decreased by passing ValidatorAccountsUpdate
from epoch manager.
To have incremental updates we need to record the latest desired stake in the account. Something like account.desired
which we considered before, but it was not needed for the final solution.
This creates an issue since desire to unstake some or all doesn't modify locked
or amount
from the account immediately. While positive increment should take the different from the amount
immediately.
To achieve what @nearmax want, we may add an additional field to the Stake
action that indicates the prepaid balance that is taken at the moment when a Stake
is created from the amount
. It wouldn't break the invariance and we can basically compute the stake properly. But we already have a Transfer
option that does exactly the desired action, so there are no need to modify Stake
action to increment current stake.
Now to have a permanent Unstake
is not as simple. We can't get the current locked balance out immediately and have to wait for 3 epochs to get it unlocked back to the amount
. The issue is the there can be conflict of Stake
and Unstake
that can happen in the next receipt. The only way to resolve it now is to rely that the receipts are processed in the same order as they are generated from 1 account. So if we unstaked first and then tried to stake again, the stake call will fail because the contract no longer allows to stake. But if we staked first and then unstaked again, then the receipt of unstake should arrive later and override the stake action.
Should we close it?
This is an expanded continuation of the discussion here: https://github.com/nearprotocol/nearcore/issues/1713
It is not clear to me what is the benefit of not making staking behave in exactly the same way the transfer action behaves. Here is how I think it should work:
StakeAction
to be:pub type BalanceDiff = i128;