Calling reconcile before token distribution from unbonding leads to funds stuck in the contract
Description
Sei chains, by default, have a limit of seven undelegations at a time per validator-delegator pair. In order to support unbonding requests from many users, the contract bundles unbonding requests together and submits them in batches. The contract submits a batch every three days, such that there are at most seven undelegations at a time with each validator. This three-day interval is defined by the epoch_period
parameter.
During the three-day period, the contract accepts unbonding requests from users and stores them in an IndexedMap
data structure under the unbond_requests
key and the aggregated properties of the pending batch under the pending_batch
key. Each user's share in the batch is proportional to the amount of iSEI tokens the user requests to burn.
At the end of the three-day period, anyone can invoke the ExecuteMsg::SubmitUnbond
function to submit the pending batch to be unbonded. The contract calculates the amount of Sei to unbond based on the Sei/iSEI exchange rate at the time, burns the iSEI tokens, and initiates undelegations with the validators.
At the end of the following 21-day unbonding period, anyone can call ExecuteMsg::Reconcile
, which, depending on the current balance of the contract, directly marks the batches as reconciled if the current balance of Sei is greater than what is expected or deducts the difference between the actual balance of Sei and expected Sei from the batches.
The tokens are also distributed in the first block after the end of the unbonding period (21 days). These tokens are distributed in the endblocker. Here is the relevant code:
func EndBlocker(ctx sdk.Context, k keeper.Keeper) []abci.ValidatorUpdate {
defer telemetry.ModuleMeasureSince(types.ModuleName, time.Now(), telemetry.MetricKeyEndBlocker)
return k.BlockValidatorUpdates(ctx)
}
func (k Keeper) BlockValidatorUpdates(ctx sdk.Context) []abci.ValidatorUpdate {
//...
balances, err := k.CompleteUnbonding(ctx, delegatorAddress, addr)
func (k Keeper) CompleteUnbonding(ctx sdk.Context, delAddr sdk.AccAddress, valAddr sdk.ValAddress) (sdk.Coins, error) {
//...
// loop through all the entries and complete unbonding mature entries
for i := 0; i < len(ubd.Entries); i++ {
entry := ubd.Entries[i]
if entry.IsMature(ctxTime) {
ubd.RemoveEntry(int64(i))
i--
// track undelegation only when remaining or truncated shares are non-zero
if !entry.Balance.IsZero() {
amt := sdk.NewCoin(bondDenom, entry.Balance)
if err := k.bankKeeper.UndelegateCoinsFromModuleToAccount(
func (e UnbondingDelegationEntry) IsMature(currentTime time.Time) bool {
return !e.CompletionTime.After(currentTime)
}
As shown above, the tokens are distributed to the contract using the call k.bankKeeper.UndelegateCoinsFromModuleToAccount
in the endblocker. If the reconcile
function is called in the same block as the token distribution, it would be executed before the token distribution and utoken_actual
would be less than utoken_expected
, and thus the utoken_to_deduct
amount would be deducted from the batches to be reconciled.
let unlocked_coins = state.unlocked_coins.load(deps.storage)?;
let utoken_expected_unlocked = Coins(unlocked_coins).find(&stake.utoken).amount;
let utoken_expected = utoken_expected_received + utoken_expected_unlocked;
let utoken_actual = deps.querier.query_balance(&env.contract.address, stake.utoken)?.amount;
if utoken_actual >= utoken_expected {
mark_reconciled_batches(&mut batches);
// println!("here");
for batch in &batches {
state.previous_batches.save(deps.storage, batch.id, batch)?;
}
let ids = batches.iter().map(|b| b.id.to_string()).collect::<Vec<_>>().join(",");
let event = Event::new("silohub/reconciled")
.add_attribute("ids", ids)
.add_attribute("utoken_deducted", "0");
return Ok(Response::new().add_event(event).add_attribute("action", "silohub/reconcile"));
}
let utoken_to_deduct = utoken_expected - utoken_actual;
let reconcile_info = reconcile_batches(&mut batches, utoken_to_deduct);
However, there is a time check in the reconcile
function, which filters out the batches with current_time > b.est_unbond_end_time
to be reconciled:
let mut batches = all_batches
.into_iter()
.filter(|b| current_time > b.est_unbond_end_time)
.collect::<Vec<_>>();
This means that if est_unbond_end_time
is t1, the reconcile could only be called at time t1+1, but token distribution would happen at block with timestamp t1. However, there might be a few cases where reconcile and token distribution happen in the same block. In the CosmWasm code, the time is measured in seconds, while in the Sei blockchain, the time is measured in nanoseconds. The time stored in the Rust code truncates the nanoseconds part as shown in the below code:
/// Returns seconds since epoch (truncate nanoseconds)
#[inline]
pub fn seconds(&self) -> u64 {
self.0.u64() / 1_000_000_000
}
Assuming that a batch is submitted at epoch timestamp 1000.8, the value of est_unbond_end_time
stored in the CosmWasm contract would be 1000 + 21days(1814400) = 1815400, and reconcile could be called a second after that, which is 1815401. But as per the Go code, the exact unbonding time to distribute the tokens will be 1815400.8.
Now, let us assume two blocks, B1 at time 1815400.7 and the next block B2 at time 1815401.1 (as the approximate block time of the Sei blockchain is 0.4 seconds). At block B1, neither reconcile could be called as the value of current_time
will be 1815400 and est_unbond_end_time
is 1815400, nor will the token distribution happen as 1815400.7 is less than 1815400.8.
At the next block B2, the time will be 1815401.1; at this time, reconcile could be called as current_time > b.est_unbond_end_time
would be true and token distribution will happen in the endblocker. In this reconcile call, as the tokens have not been distributed yet, the contract would assume that this is due to slashing and thus deduct those Sei from the batches.
Impact
The tokens would be stuck in the contract, and users would not be able to withdraw them unless the contract is migrated and the admin rescues these tokens and distributes them to the users.
Recommendations
We recommend adding a few seconds of delay in the reconcile call so it could not be called in the same block as the token distribution.
Remediation
This issue has been acknowledged by Silo, and a fix was implemented in commit c86bb206↗.