SIP-187: Fix Partial Synth Updates In Debt Cache

Author
StatusDraft
TypeGovernance
ImplementorTBD
ReleaseTBD
Discussions-ToSynthetix Discord
Created2021-09-30

Simple Summary

Fix an issue with partial updates of synths in the debt cache which is causing the cached debt value to not update after minting and burning sUSD.

Abstract

This SIP proposes to upgrade the debt cache contract with a fix to the partial synth updates so that the cached debt is updated after minting and burning sUSD. It will remove the subtraction of the excluded debt (that is synths issued by non-SNX collateral) in the calculation of the new synth amounts as this caused them to zero out instead of updating the delta in the synth's amount and how much the cached debt has increased / decreased.

Motivation

In the first iteration of the debt cache, minting and burning sUSD would change the cached debt value by the amount of sUSD minted or burned to reflect the change in sUSD supply. It is important that the debt pool is updated immediately when sUSD is minted or burned to reflect the increase or decrease of the debt pool size before a snapshot happens. With the addition of the ETH wrapper, the debt cache was updated to exclude the non-SNX collateral synths that gets issued against the wrapper ETH and loans. The DebtCache._updateCachedSynthDebtsWithRates() function is used for updating individual synths and the cached debt when minting / burning sUSD and exchanges. But it currently isn't able to update the cached debt because the new cachedSum and currentSum is miscalculated by subtracing the excludedDebtSum from them and the new cached debt doesn't have any change.

        cachedSum = cachedSum.floorsub(_cachedSynthDebt[EXCLUDED_DEBT_KEY]);
        currentSum = currentSum.floorsub(excludedDebtSum);
        _cachedSynthDebt[EXCLUDED_DEBT_KEY] = excludedDebtSum;

        // Compute the difference and apply it to the snapshot
        if (cachedSum != currentSum) {
            ...
        }

Specification

Overview

The DebtCache._updateCachedSynthDebtsWithRates() will be updated to not re-calculate the excludedDebt as that is not calculated in the function and it will only update the _cachedSynthDebt[synth] for the synth to update. The delta for the debt cache update will be calculated as the difference between currentSum - cachedSum which measures the change in updated synths compared to the last cached amount.

This will ignore any of the changes in the supply of those synths that has been issued by non-SNX collateral such as ETH wrapper and multi-collateral loans for partial synth updates. The excludedDebt value would be updated when the debt snapshot is taken and updates the portion of the debt cache that is excluded.

Rationale

  • Tracks the delta between the current cached synth amounts and current synth amounts.
  • The updateCachedSynthDebtsWithRates currently doesn't update the excluded debt amount and should be removed.
  • The excluded debt amount is updated during the debt snapshot which keeps the debt within 1% deviations and any synths issued by non-SNX collateral will be recalculated.
  • Allows accurate tracking of the debt after stakers mint and burn their sUSD.

Technical Specification

Update the DebtCache contract interface and the _updateCachedSynthDebtsWithRates function which is used internally when there is an exchange of synths and partial updates of synths from the snapshot keeper.

_updateCachedSynthDebtsWithRates (Before)

function _updateCachedSynthDebtsWithRates(
        bytes32[] memory currencyKeys,
        uint[] memory currentRates,
        bool anyRateIsInvalid,
        bool recomputeExcludedDebt
    ) internal {
        uint numKeys = currencyKeys.length;
        require(numKeys == currentRates.length, "Input array lengths differ");

        // Update the cached values for each synth, saving the sums as we go.
        uint cachedSum;
        uint currentSum;
        uint excludedDebtSum = _cachedSynthDebt[EXCLUDED_DEBT_KEY];
        uint[] memory currentValues = _issuedSynthValues(currencyKeys, currentRates);

        for (uint i = 0; i < numKeys; i++) {
            bytes32 key = currencyKeys[i];
            uint currentSynthDebt = currentValues[i];
            cachedSum = cachedSum.add(_cachedSynthDebt[key]);
            currentSum = currentSum.add(currentSynthDebt);
            _cachedSynthDebt[key] = currentSynthDebt;
        }

        // Always update the cached value of the excluded debt -- it's computed anyway.
        if (recomputeExcludedDebt) {
            (uint excludedDebt, bool anyNonSnxDebtRateIsInvalid) = _totalNonSnxBackedDebt();
            anyRateIsInvalid = anyRateIsInvalid || anyNonSnxDebtRateIsInvalid;
            excludedDebtSum = excludedDebt;
        }

        cachedSum = cachedSum.floorsub(_cachedSynthDebt[EXCLUDED_DEBT_KEY]);
        currentSum = currentSum.floorsub(excludedDebtSum);
        _cachedSynthDebt[EXCLUDED_DEBT_KEY] = excludedDebtSum;

        // Compute the difference and apply it to the snapshot
        if (cachedSum != currentSum) {
            uint debt = _cachedDebt;
            // This requirement should never fail, as the total debt snapshot is the sum of the individual synth
            // debt snapshots.
            require(cachedSum <= debt, "Cached synth sum exceeds total debt");
            debt = debt.sub(cachedSum).add(currentSum);
            _cachedDebt = debt;
            emit DebtCacheUpdated(debt);
        }

        // A partial update can invalidate the debt cache, but a full snapshot must be performed in order
        // to re-validate it.
        if (anyRateIsInvalid) {
            _updateDebtCacheValidity(anyRateIsInvalid);
        }
    }

_updateCachedSynthDebtsWithRates (After)

    function _updateCachedSynthDebtsWithRates(
        bytes32[] memory currencyKeys,
        uint[] memory currentRates,
        bool anyRateIsInvalid,
    ) internal {
        uint numKeys = currencyKeys.length;
        require(numKeys == currentRates.length, "Input array lengths differ");

        // Update the cached values for each synth, saving the sums as we go.
        uint cachedSum;
        uint currentSum;
        uint[] memory currentValues = _issuedSynthValues(currencyKeys, currentRates);

        for (uint i = 0; i < numKeys; i++) {
            bytes32 key = currencyKeys[i];
            uint currentSynthDebt = currentValues[i];
            cachedSum = cachedSum.add(_cachedSynthDebt[key]);
            currentSum = currentSum.add(currentSynthDebt);
            _cachedSynthDebt[key] = currentSynthDebt;
        }

        // Compute the difference and apply it to the snapshot
        if (cachedSum != currentSum) {
            uint debt = _cachedDebt;
            // This requirement should never fail, as the total debt snapshot is the sum of the individual synth
            // debt snapshots.
            require(cachedSum <= debt, "Cached synth sum exceeds total debt");
            debt = debt.sub(cachedSum).add(currentSum);
            _cachedDebt = debt;
            emit DebtCacheUpdated(debt);
        }

        // A partial update can invalidate the debt cache, but a full snapshot must be performed in order
        // to re-validate it.
        if (anyRateIsInvalid) {
            _updateDebtCacheValidity(anyRateIsInvalid);
        }
    }

Updating debt cache for minting and burning sUSD

When minting and burning sUSD, the cachedSum[sUSD] and _cachedDebt values that tracks the amount of sUSD should be updated to reflect the increase or decrease in the sUSD supply and the cached debt also updated to track the amount of sUSD that is minted / burned. It would be more accruate to update the amount of sUSD issued or burned, instead of updating the cached value of sUSD by reading the sUSD total supply as this could include non-SNX collateral issued sUSD such as Multi-collateral loans and shorts between snapshots. The excluded debts will be updated during the snapshot instead.

DebtCache Contract

contract DebtCache is BaseDebtCache {
    function increaseCachedsUSDDebt(
        uint amount
    ) external onlyIssuer {
        _cachedSynthDebt[sUSD] = _cachedSynthDebt[sUSD].add(amount);
        _cachedDebt = _cachedDebt.add(amount);
    }

    function decreaseCachedsUSDDebt(
        uint amount
    ) external onlyIssuer {
        _cachedSynthDebt[sUSD] = _cachedSynthDebt[sUSD].sub(amount);
        _cachedDebt = _cachedDebt.add(amount);
    }
}

Issuer Contract

Contract Issuer {
    function _issueSynths(
        address from,
        uint amount,
        bool issueMax
    ) internal {
        (uint maxIssuable, uint existingDebt, uint totalSystemDebt, bool anyRateIsInvalid) = _remainingIssuableSynths(from);
        _requireRatesNotInvalid(anyRateIsInvalid);

        if (!issueMax) {
            require(amount <= maxIssuable, "Amount too large");
        } else {
            amount = maxIssuable;
        }

        // Keep track of the debt they're about to create
        _addToDebtRegister(from, amount, existingDebt, totalSystemDebt);

        // record issue timestamp
        _setLastIssueEvent(from);

        // Create their synths
        synths[sUSD].issue(from, amount);

        // Account for the issued debt in the cache
        debtCache().increaseCachedsUSDDebt(amount);

        // Store their locked SNX amount to determine their fee % for the period
        _appendAccountIssuanceRecord(from);
    }

     function _burnSynths(
        address debtAccount,
        address burnAccount,
        uint amount,
        uint existingDebt,
        uint totalDebtIssued
    ) internal returns (uint amountBurnt) {
        // liquidation requires sUSD to be already settled / not in waiting period

        // If they're trying to burn more debt than they actually owe, rather than fail the transaction, let's just
        // clear their debt and leave them be.
        amountBurnt = existingDebt < amount ? existingDebt : amount;

        // Remove liquidated debt from the ledger
        _removeFromDebtRegister(debtAccount, amountBurnt, existingDebt, totalDebtIssued);

        // synth.burn does a safe subtraction on balance (so it will revert if there are not enough synths).
        synths[sUSD].burn(burnAccount, amountBurnt);

        // Account for the burnt debt in the cache.
        debtCache().decreaseCachedsUSDDebt(amountBurnt);

        // Store their debtRatio against a fee period to determine their fee/rewards % for the period
        _appendAccountIssuanceRecord(debtAccount);
    }

}

Test Cases

  • Minting 1000 sUSD, should increase the cached debt by 1000 sUSD.

  • Minting 1000 sUSD, should increase the cached sUSD synths by 1000 sUSD.

  • Burning 1000 sUSD, should decrease the cached debt by 1000 sUSD.

  • Burning 1000 sUSD, should decrease the cached sUSD synths by 1000 sUSD.

Configurable Values (Via SCCP)

Copyright and related rights waived via CC0.