SIP-156: Debt Pool Oracle

Author
StatusRejected
TypeGovernance
NetworkEthereum & Optimism
ImplementorTBD
ReleaseTBD
Created2021-07-05

Simple Summary

This SIP proposes to replace the existing debt cache mechanism with a debt pool oracle operated by Chainlink.

Abstract

This SIP will deprecate the existing debt cache mechanism described in SIPs 83 and 91 in favour of an oracle that reads the composition of the debt pool, then calculates the total debt size off-chain and pushes it on-chain via a Chainlink aggregation contract.

The current debt cache mechanism has the benefit of being entirely on-chain, however, it introduces some complexity to the protocol. By replacing it with a Chainlink oracle we will simplify several functions and reduce gas costs, as well as introducing more scalability to the number of Synths the protocol can support, and unifying the debt pool between chains, which is addressed in SIP 165.

Motivation

The current debt cache, while an extremely elegant solution to the problem of calculating the size of the debt pool for use by the minting and burning functions has a number of limitations. The primary limitation driving this proposed change is the upcoming need to unify the debt pools across L1 and L2. This requirement would mean that cross chain messaging would need to be enabled and would introduce further complexity to the implementation. Moving this functionality off-chain will allow for a more scalable network as the number of Synths that can comprise the debt pool will no longer be limited by on-chain computational resources.

Specification

Overview

  1. Implement a new debt pool contract interface to allow the oracle to read and calculate the skew of each Synth.
  2. Replace the function to read the debt cache with a new function to read the latest debt oracle value.
  3. Add a function to minting and burning that tracks the incremental debt since the last debt oracle update.

Rationale

The existing debt system requires snapshots to be performed by an off-chain keeper. Although this is trustless, in that all the logic required for keeping the debt cache number exists on chain, it is also relatively expensive. This calculation is only becoming moreso as new synths and debt-relevant mechanisms such as loans, the ether wrapper, and futures are added. A debt oracle would need to perform only a single state update and no extra calculations on-chain, and should therefore be substantially more efficient to operate. The upshot of the move from an \(O(n)\) to an \(O(1)\) on-chain component means that the number of synths the system can support is no longer bottlenecked by the debt snapshot, but by oracle computations, and so this limit should become substantially higher, if it is not effectively removed.

At the same time, we were contemplating extending the current debt cache mechanism to support cross network messaging as well as making a number of other iterative improvements to the calculations. However, after reviewing the implementation effort compared to performing these calculations off-chain we concluded that an oracle was the superior solution.

While an oracle increases the reliance on off-chain data aggregation, the system this SIP proposes is designed to ensure this procedure is as simple as possible, and processes only easily-accessible on-chain data. We believe this solution has relatively few failure modes, and therefore a poses a relatively low risk to the system as a whole.

The modularity of this design means that it is easily possible to modify the contract-level structure of the debt pool calculation that feeds up to the oracle, so that new protocol functionality can be supported without any modifications to the oracle logic.

Technical Specification

As the interface between L1 and L2 is to be unified, DebtCache and BaseDebtCache should be merged back into a single contract with a substantially modified interface:

contract DebtCache {
    function availableDebtComponents() external view returns (bytes32[] memory components);
    function debtComponent(bytes32 key) external view returns (int component, bool invalid);
    function totalDebt() external view returns (int debt, bool invalid, bool stale);
    function totalDebtOnL1() external view returns (int debt, bool invalid, bool stale);
    function totalDebtOnL2() external view returns (int debt, bool invalid, bool stale);
    function issuanceCorrection() external view returns (int correction);
    function updateIssuanceCorrection(int amount, bytes32 synth) external;
    function isInvalidOrStale() external view returns (bool invalid, bool stale);
    function debtOracleStaleTime() external view returns (uint staleTime);
    event DebtUpdated(uint debt);
}

The debtOracleStaleTime function and DebtUpdated event should be preserved from the respective debtCacheStaleTime and DebtCacheUpdated members from the previous implementation. The isInvalidOrStale is simply the merger of the previous isInvalid and isStale functions. The other contract functions are described below.

Debt Components

Currently, the maximum number of components possible in the system is constrained by the execution budget of the currentDebt function. To alleviate this, the debt system should be able to break down the debt cache into each of its component parts, representing the net skew for each synth, including both circulating synths (long) and outstanding non-SNX collateral debt (short). The overall debt pool value will simply be the sum of all these components.

In this way, Chainlink oracle nodes will just need to perform an off-chain sum over all available synth debt components. The debt pool contract will require new functions to support this:

  • function availableDebtComponents() external view returns (bytes32[] memory): Returns the list of all available debt component currency keys (sUSD, sETH, et cetera)
  • function debtComponent(bytes32 key) external view returns (int component, bool invalid): Returns the dollar value of a given component of the debt pool. This value will be positive if the debt component is long-skewed, negative if it is short-skewed.

Each synth's debt component will be calculated as follows:

function debtComponent(bytes32 key) external view returns (int component, bool invalid) {
    (uint rate, bool invalid) = ExchangeRates.getRateAndInvalid(key);

    // The circulating supply is the long part of the debt component
    uint component = synth(key).totalSupply();

    // Any non-SNX backed debt is the short part of the debt component
    // Multi-collateral loans
    component -= collateralManager.long(key) + collateralManager.long(short);
    // wrapper debt
    component -= wrapper(key);

    return (component * rate, invalid);
}

Note that we have omitted the EtherCollateral and EtherCollateralsUSD contributions present in the current implementation, as these are shortly to be deprecated.

Oracle Logic

The Chainlink oracle should report the sum of the debt components in a manner equivalent to the following function:

// Implemented off-chain
function currentDebt() {
  debt = 0
  invalid = false

  for (key of DebtPool.availableDebtComponents()) {
    component, (componentInvalid = DebtPool.debtComponent(key))
    debt += component
    invalid |= componentInvalid
  }

  return debt, invalid
}

In this way, Synthetix will be able to update the composition of its debt pool calculation by altering the available debt components and the behaviour of the debtComponent function, without Chainlink nodes needing to update their internal logic. Each debt component will at first correspond to a particular synth, but in the future may be extended to different objects.

The oracle should simply report the current value returned by this currentDebt() function. Whenever there is a deviation of 1% or more (SCCP configurable) between this result, and the result returned by DebtCache.totalDebt()< then a new update should be pushed on chain. The oracle should also operate at a regular heartbeat.

Total Debt and Issuance Corrections

The updated debt cache contract's interface includes a single new function, which will report the debt as received from the oracle.

  • function totalDebt() external view returns (uint debt, bool isInvalid)

This function will replace Issuer._totalIssuedSynths, which currently contains a call to DebtCache.cacheInfo. As its name implies, totalDebt should report the latest result from the oracle, and its validity. There is a caveat here, which is that in between oracle updates, it must still accurately reflect the fluctuations in the debt pool induced by the minting and burning of synths. it must reflect the resulting fluctuations in the debt pool. Without this, the issue identified in SIP-150 will remain in place. To accomplish the desired result, the debt pool contract must track two additional variables:

  • int256 issuanceCorrection: the net movement in the sUSD value of the debt pool since the last oracle update
  • uint256 lastTimestamp: the Chainlink debt aggregator update time last known to the cache

With this in mind, the totalDebt function should approximately implement the following pseudo-code:

function totalDebt() external view returns (uint, bool) {
    // Fetch the latest debt numbers
    (debt, timestamp, isInvalid) = ExchangeRates.debtAndTimestampAndInvalid();

    // An update has occurred, but the new timestamp has not yet been recorded.
    // Do not apply the issuance correction in this case.
    if (lastTimestamp < timestamp) {
        return (debt, isInvalid);
    }

    // Otherwise, account for all mint/burn events
    return (debt + issuanceCorrection, isInvalid, _isStale(timestamp));
}

In addition, whenever new synths are minted or burnt, whether against SNX or otherwise, the sUSD value of those synths should be recorded against the issuanceCorrection variable:

function updateIssuanceCorrection(int amount, bytes32 synth) external onlyIssuers {
    // Fail if the oracle's debt value is invalid or stale.
    (, timestamp, isInvalid) = ExchangeRates.debtAndTimestampAndInvalid();
    require(!(isInvalid || _isStale(timestamp));

    // If a new update has come through from the oracle, reset the issuance correction
    if (lastTimestamp < timestamp) {
        lastTimestamp = timestamp;
        issuanceCorrection = 0;
    }

    issuanceCorrection += amount * ExchangeRates.getRateAndEnsureValid(synth);
    emit DebtUpdated(totalDebt());
}

This function should be invoked whenever synths are created or destroyed, but not when exchanged or transformed into another form, such as by a deposit into a futures margin account. It must be callable only by synth-issuing contracts including the issuer, wrapper, and multi-collateral contracts. It must fail if any relevant oracle feeds are invalid.

Phase 2 - Total Debt on L1 and L2

The updated debt cache contract's interface includes a two new functions, reporting the totalDebtOnL1 and totalDebtOnL2. based on the total debt on mainnet and L2.

The total debt would be aggregated by Chainlink oracles and reported for both L1 and L2, which will be accessible from the ExchangeRates contract on both layers.

  • function totalDebtOnL1() external view returns (int debt, bool invalid, bool stale)
  • function totalDebtOnL2() external view returns (int debt, bool invalid, bool stale)

These functions will allow the total combined debt of L1 and L2 debt pools to be calculated within the debt cache contract when debt is issued or burned on both chains.

Test Cases

TBD

Configurable Values (Via SCCP)

Parameter Description Initial Value
Max Debt Oracle Deviation If the difference between the true system debt and the number reported by the oracle exceeds this value, the oracle should push an updated value on-chain. 1%

Copyright and related rights waived via CC0.