diff --git a/solidity/contracts/test/TBTCVaultThrottleExemptStub.sol b/solidity/contracts/test/TBTCVaultThrottleExemptStub.sol new file mode 100644 index 000000000..69e84a10d --- /dev/null +++ b/solidity/contracts/test/TBTCVaultThrottleExemptStub.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity 0.8.17; + +import "../vault/TBTCVault.sol"; +import "../bank/Bank.sol"; +import "../token/TBTC.sol"; +import "../bridge/Bridge.sol"; + +/// @dev Test stub exercising the optimistic minting throttle exemption seam +/// of `TBTCOptimisticMinting`. Allows marking arbitrary requesters as +/// exempt from the optimistic minting rate limits. +contract TBTCVaultThrottleExemptStub is TBTCVault { + mapping(address => bool) public throttleExempt; + + constructor( + Bank _bank, + TBTC _tbtcToken, + Bridge _bridge + ) TBTCVault(_bank, _tbtcToken, _bridge) {} + + function setThrottleExempt(address requester, bool exempt) external { + throttleExempt[requester] = exempt; + } + + function _isOptimisticMintingThrottleExempt(address requester) + internal + view + override + returns (bool) + { + return throttleExempt[requester]; + } +} diff --git a/solidity/contracts/vault/TBTCOptimisticMinting.sol b/solidity/contracts/vault/TBTCOptimisticMinting.sol index f578b7ec3..0571586b2 100644 --- a/solidity/contracts/vault/TBTCOptimisticMinting.sol +++ b/solidity/contracts/vault/TBTCOptimisticMinting.sol @@ -27,7 +27,12 @@ import "../GovernanceUtils.sol"; /// Any single Minter can perform this action. There is an /// `optimisticMintingDelay` between the time of the request from /// a Minter to the time TBTC is minted. During the time of the delay, -/// any Guardian can cancel the minting. +/// any Guardian can cancel the minting. The total outstanding +/// optimistic minting exposure — TBTC minted optimistically but not +/// yet backed by swept deposits, plus the value of in-flight +/// requests — is capped, the value a single Minter can request +/// within a rolling 24-hour window is rate-limited, and the size of +/// a single deposit eligible for optimistic minting is bounded. /// @dev This functionality is a part of `TBTCVault`. It is implemented in /// a separate abstract contract to achieve better separation of concerns /// and easier-to-follow code. @@ -42,6 +47,26 @@ abstract contract TBTCOptimisticMinting is Ownable { uint64 finalizedAt; } + // Tracks the remaining optimistic minting allowance of a Minter's + // rate-limiting buckets. Each dimension refills continuously over time, + // at a rate of the full cap per 24 hours, up to the cap. A dimension is + // tracked only while its limit is enabled; a dimension with a zero + // timestamp has never been tracked and is considered full. + struct OptimisticMintingAllowance { + // Remaining value of deposits (in satoshi) that can be a subject of + // an optimistic minting request before the bucket exhausts. + uint64 valueRemaining; + // UNIX timestamp at which value tokens were last credited. Zero if + // the value dimension has never been tracked. + uint64 valueRefilledAt; + // Remaining number of optimistic minting requests that can be + // submitted before the bucket exhausts. + uint32 requestsRemaining; + // UNIX timestamp at which request tokens were last credited. Zero if + // the request count dimension has never been tracked. + uint64 requestsRefilledAt; + } + /// @notice The time delay that needs to pass between initializing and /// finalizing the upgrade of governable parameters. uint256 public constant GOVERNANCE_DELAY = 24 hours; @@ -75,6 +100,52 @@ abstract contract TBTCOptimisticMinting is Ownable { /// finalized with minting TBTC. uint32 public optimisticMintingDelay = 3 hours; + /// @notice The maximum total optimistic minting exposure (in satoshi) + /// that can be outstanding at any moment: the sum of the + /// optimistic minting debt not yet repaid by swept deposits + /// (`optimisticMintingDebtTotal`) and the value of in-flight, not + /// yet finalized requests (`optimisticMintingPendingTotal`). + /// A request is rejected if it would push the exposure above the + /// cap. Capacity is recycled as deposits get swept and the debt + /// is repaid. Debt of deposits that never get swept consumes the + /// cap until governance resolves the situation, e.g. by raising + /// the cap; this acts as an automatic circuit breaker when + /// optimistically minted deposits do not settle. Zero value means + /// no limit. + /// The default is a deliberately conservative initial value; + /// governance is expected to raise it based on observed cap + /// utilization. Hitting the cap costs only latency — deposits + /// fall back to the standard sweep flow — while raising it takes + /// the 24-hour governance delay. + uint64 public optimisticMintingDebtCap = 1_000_000_000; // 10 BTC + + /// @notice The maximum total value of deposits (in satoshi) that can be + /// a subject of optimistic minting requests of a single Minter + /// within a rolling 24-hour window. The limit is enforced with + /// a token bucket that refills continuously at a rate of the full + /// cap per 24 hours. Zero value means no limit. + /// Per-Minter caps are meant to overlap: the sum of all Minters' + /// caps may exceed `optimisticMintingDebtCap`, which remains the + /// binding total limit. + uint64 public optimisticMintingCapPerMinter = 1_000_000_000; // 10 BTC + + /// @notice The maximum size of a single deposit (in satoshi) that can be + /// a subject of an optimistic minting request. Deposits above + /// this size follow the standard flow and are minted when swept. + /// Zero value means no limit. Note the effective bound on + /// a single optimistically minted deposit is the smallest of this + /// limit, the per-Minter cap, and the debt cap, out of those that + /// are enabled. + uint64 public optimisticMintingMaxDepositSize = 500_000_000; // 5 BTC + + /// @notice The maximum number of optimistic minting requests a single + /// Minter can submit within a rolling 24-hour window. The limit + /// is enforced with a token bucket that refills continuously at + /// a rate of the full limit per 24 hours. Zero value means no + /// limit. Independently of the value caps, this limit bounds the + /// number of requests Guardians may need to validate and cancel. + uint32 public optimisticMintingRequestLimitPerMinter = 100; + /// @notice Indicates if the given address is a Minter. Only Minters can /// request optimistic minting. mapping(address => bool) public isMinter; @@ -119,6 +190,43 @@ abstract contract TBTCOptimisticMinting is Ownable { /// delay started. Zero if update is not in progress. uint256 public optimisticMintingDelayUpdateInitiatedTimestamp; + /// @notice Total value of deposits (in satoshi) with a pending — not yet + /// finalized and not cancelled — optimistic minting request. + /// Counted against `optimisticMintingDebtCap` together with the + /// outstanding `optimisticMintingDebtTotal`. + uint64 public optimisticMintingPendingTotal; + + /// @notice The total outstanding optimistic minting debt across all + /// depositors, in 1e18 Ethereum precision. Increased when an + /// optimistic mint is finalized and decreased when the debt is + /// repaid by a swept deposit. Always equals the sum of all + /// `optimisticMintingDebt` values. + uint256 public optimisticMintingDebtTotal; + + /// @notice Rate-limiting buckets tracking the remaining optimistic + /// minting allowance of individual Minters. + /// @dev Raw bucket state; dimensions whose limits are disabled hold + /// stale values. Use `getOptimisticMintingAllowance` for + /// interpreted values. + mapping(address => OptimisticMintingAllowance) public minterAllowances; + + /// @notice New optimistic minting debt cap value. Set only when the + /// parameter update process is pending. Once the update gets + /// finalized, this will be the value of the cap. + uint64 public newOptimisticMintingDebtCap; + /// @notice New per-Minter optimistic minting cap value. Set only when the + /// parameter update process is pending. + uint64 public newOptimisticMintingCapPerMinter; + /// @notice New maximum size of an optimistically minted deposit. Set only + /// when the parameter update process is pending. + uint64 public newOptimisticMintingMaxDepositSize; + /// @notice New per-Minter optimistic minting request limit. Set only when + /// the parameter update process is pending. + uint32 public newOptimisticMintingRequestLimitPerMinter; + /// @notice The timestamp at which the update of the optimistic minting + /// rate limits started. Zero if update is not in progress. + uint256 public optimisticMintingCapsUpdateInitiatedTimestamp; + event OptimisticMintingRequested( address indexed minter, uint256 indexed depositKey, @@ -156,6 +264,26 @@ abstract contract TBTCOptimisticMinting is Ownable { event OptimisticMintingDelayUpdateStarted(uint32 newOptimisticMintingDelay); event OptimisticMintingDelayUpdated(uint32 newOptimisticMintingDelay); + event OptimisticMintingCapsUpdateStarted( + uint64 newOptimisticMintingDebtCap, + uint64 newOptimisticMintingCapPerMinter, + uint64 newOptimisticMintingMaxDepositSize, + uint32 newOptimisticMintingRequestLimitPerMinter + ); + event OptimisticMintingCapsUpdated( + uint64 optimisticMintingDebtCap, + uint64 optimisticMintingCapPerMinter, + uint64 optimisticMintingMaxDepositSize, + uint32 optimisticMintingRequestLimitPerMinter + ); + + event OptimisticMintingAllowanceConsumed( + address indexed minter, + uint64 amount, // amount in satoshi + uint64 minterValueRemaining, // type(uint64).max if no per-Minter cap + uint64 globalHeadroomRemaining // satoshi; type(uint64).max if no debt cap + ); + modifier onlyMinter() { require(isMinter[msg.sender], "Caller is not a minter"); _; @@ -214,6 +342,12 @@ abstract contract TBTCOptimisticMinting is Ownable { /// - The deposit has not been swept yet. /// - The deposit is targeted into the TBTCVault. /// - The optimistic minting is not paused. + /// - The deposit size does not exceed + /// `optimisticMintingMaxDepositSize`. + /// - The Minter's rate-limiting bucket has enough allowance for + /// the deposit value; the request consumes it. + /// - The total outstanding and in-flight optimistic minting + /// exposure stays under `optimisticMintingDebtCap`. /// After calling this function, the Minter has to wait for /// `optimisticMintingDelay` before finalizing the mint with a call /// to finalizeOptimisticMint. @@ -257,6 +391,16 @@ abstract contract TBTCOptimisticMinting is Ownable { require(deposit.sweptAt == 0, "The deposit is already swept"); require(deposit.vault == address(this), "Unexpected vault address"); + if (!_isOptimisticMintingThrottleExempt(msg.sender)) { + _consumeOptimisticMintingAllowance(deposit.amount); + } + + // The in-flight requested value is tracked for all requests, + // including the ones of throttle-exempt requesters, so that + // `optimisticMintingPendingTotal` always measures the actual + // in-flight exposure. + optimisticMintingPendingTotal += deposit.amount; + /* solhint-disable-next-line not-rely-on-time */ request.requestedAt = uint64(block.timestamp); @@ -339,6 +483,10 @@ abstract contract TBTCOptimisticMinting is Ownable { ? (amountToMint / optimisticMintingFeeDivisor) : 0; + // The request is no longer in-flight: its value moves from the + // pending total to the outstanding debt total. + optimisticMintingPendingTotal -= deposit.amount; + // Both the optimistic minting fee and the share that goes to the // depositor are optimistically minted. All TBTC that is optimistically // minted should be added to the optimistic minting debt. When the @@ -347,6 +495,7 @@ abstract contract TBTCOptimisticMinting is Ownable { uint256 newDebt = optimisticMintingDebt[deposit.depositor] + amountToMint; optimisticMintingDebt[deposit.depositor] = newDebt; + optimisticMintingDebtTotal += amountToMint; _mint(deposit.depositor, amountToMint - optimisticMintFee); if (optimisticMintFee > 0) { @@ -371,6 +520,15 @@ abstract contract TBTCOptimisticMinting is Ownable { /// been finalized yet. /// Optimistic minting request is removed. It is possible to request /// optimistic minting again for the same deposit later. + /// Cancelling releases the deposit value from the in-flight + /// requested total counted against `optimisticMintingDebtCap` but + /// does not restore the per-Minter allowance consumed by the + /// request. This is deliberate: repeated request-cancel cycles + /// keep consuming the requesting Minter's allowance and are + /// naturally bounded by the rate limits. Guardians should also + /// cancel requests that can no longer be finalized (e.g. for + /// deposits swept before finalization) to release their in-flight + /// value. /// @dev Guardians must validate the following conditions for every deposit /// for which the optimistic minting was requested: /// - The deposit happened on Bitcoin side and it has enough @@ -400,6 +558,12 @@ abstract contract TBTCOptimisticMinting is Ownable { "Optimistic minting already finalized for the deposit" ); + // Release the deposit value from the in-flight requested total. No + // TBTC was minted so there is nothing at risk for this request + // anymore. The per-Minter allowance consumed by the request is not + // restored. + optimisticMintingPendingTotal -= bridge.deposits(depositKey).amount; + // Delete it. It allows to request optimistic minting for the given // deposit again. Useful in case of an errant Guardian. delete optimisticMintingRequests[depositKey]; @@ -407,6 +571,58 @@ abstract contract TBTCOptimisticMinting is Ownable { emit OptimisticMintingCancelled(msg.sender, depositKey); } + /// @notice Returns the current optimistic minting allowance of the given + /// Minter, including the continuous refill accrued up to the + /// current block timestamp, and the remaining global debt cap + /// headroom. + /// @dev Fields corresponding to disabled limits are returned as maximum + /// values of their types. Intended for off-chain Minter clients and + /// monitoring. + /// @param minter The Minter to return the allowance for. + /// @return minterValueRemaining Remaining value (in satoshi) the Minter + /// can request before their bucket exhausts. + /// @return minterRequestsRemaining Remaining number of requests the + /// Minter can submit before their bucket exhausts. + /// @return globalHeadroomRemaining Remaining value (in satoshi) that can + /// be requested across all Minters before the total outstanding + /// and in-flight exposure reaches `optimisticMintingDebtCap`. + function getOptimisticMintingAllowance(address minter) + external + view + returns ( + uint64 minterValueRemaining, + uint32 minterRequestsRemaining, + uint64 globalHeadroomRemaining + ) + { + uint64 capPerMinter = optimisticMintingCapPerMinter; + uint32 requestLimit = optimisticMintingRequestLimitPerMinter; + OptimisticMintingAllowance memory allowance = _refillAllowance( + minterAllowances[minter], + capPerMinter, + requestLimit + ); + minterValueRemaining = capPerMinter != 0 + ? allowance.valueRemaining + : type(uint64).max; + minterRequestsRemaining = requestLimit != 0 + ? allowance.requestsRemaining + : type(uint32).max; + + uint64 debtCap = optimisticMintingDebtCap; + if (debtCap != 0) { + uint256 exposure = uint256(optimisticMintingPendingTotal) * + SATOSHI_MULTIPLIER + + optimisticMintingDebtTotal; + uint256 cap = uint256(debtCap) * SATOSHI_MULTIPLIER; + globalHeadroomRemaining = exposure >= cap + ? 0 + : uint64((cap - exposure) / SATOSHI_MULTIPLIER); + } else { + globalHeadroomRemaining = type(uint64).max; + } + } + /// @notice Adds the address to the Minter list. function addMinter(address minter) external onlyOwner { require(!isMinter[minter], "This address is already a minter"); @@ -518,6 +734,68 @@ abstract contract TBTCOptimisticMinting is Ownable { optimisticMintingDelayUpdateInitiatedTimestamp = 0; } + /// @notice Begins the process of updating the optimistic minting limits: + /// the debt cap, the per-Minter cap, the maximum size of an + /// optimistically minted deposit, and the per-Minter request + /// limit. The limits are updated together as they form a single + /// exposure-limiting policy. Zero value disables the given limit. + /// @dev See the documentation of `optimisticMintingDebtCap`, + /// `optimisticMintingCapPerMinter`, `optimisticMintingMaxDepositSize` + /// and `optimisticMintingRequestLimitPerMinter`. + /// @param _optimisticMintingDebtCap The new debt cap, in satoshi. + /// @param _optimisticMintingCapPerMinter The new per-Minter cap, + /// in satoshi. + /// @param _optimisticMintingMaxDepositSize The new maximum size of an + /// optimistically minted deposit, in satoshi. + /// @param _optimisticMintingRequestLimitPerMinter The new per-Minter + /// request limit. + function beginOptimisticMintingCapsUpdate( + uint64 _optimisticMintingDebtCap, + uint64 _optimisticMintingCapPerMinter, + uint64 _optimisticMintingMaxDepositSize, + uint32 _optimisticMintingRequestLimitPerMinter + ) external onlyOwner { + /* solhint-disable-next-line not-rely-on-time */ + optimisticMintingCapsUpdateInitiatedTimestamp = block.timestamp; + newOptimisticMintingDebtCap = _optimisticMintingDebtCap; + newOptimisticMintingCapPerMinter = _optimisticMintingCapPerMinter; + newOptimisticMintingMaxDepositSize = _optimisticMintingMaxDepositSize; + // solhint-disable-next-line max-line-length + newOptimisticMintingRequestLimitPerMinter = _optimisticMintingRequestLimitPerMinter; + emit OptimisticMintingCapsUpdateStarted( + _optimisticMintingDebtCap, + _optimisticMintingCapPerMinter, + _optimisticMintingMaxDepositSize, + _optimisticMintingRequestLimitPerMinter + ); + } + + /// @notice Finalizes the update process of the optimistic minting + /// limits. + function finalizeOptimisticMintingCapsUpdate() + external + onlyOwner + onlyAfterGovernanceDelay(optimisticMintingCapsUpdateInitiatedTimestamp) + { + optimisticMintingDebtCap = newOptimisticMintingDebtCap; + optimisticMintingCapPerMinter = newOptimisticMintingCapPerMinter; + optimisticMintingMaxDepositSize = newOptimisticMintingMaxDepositSize; + // solhint-disable-next-line max-line-length + optimisticMintingRequestLimitPerMinter = newOptimisticMintingRequestLimitPerMinter; + emit OptimisticMintingCapsUpdated( + newOptimisticMintingDebtCap, + newOptimisticMintingCapPerMinter, + newOptimisticMintingMaxDepositSize, + newOptimisticMintingRequestLimitPerMinter + ); + + newOptimisticMintingDebtCap = 0; + newOptimisticMintingCapPerMinter = 0; + newOptimisticMintingMaxDepositSize = 0; + newOptimisticMintingRequestLimitPerMinter = 0; + optimisticMintingCapsUpdateInitiatedTimestamp = 0; + } + /// @notice Calculates deposit key the same way as the Bridge contract. /// The deposit key is computed as /// `keccak256(fundingTxHash | fundingOutputIndex)`. @@ -554,12 +832,207 @@ abstract contract TBTCOptimisticMinting is Ownable { if (amount > debt) { optimisticMintingDebt[depositor] = 0; + // slither-disable-next-line costly-loop + optimisticMintingDebtTotal -= debt; emit OptimisticMintingDebtRepaid(depositor, 0); return amount - debt; } else { optimisticMintingDebt[depositor] = debt - amount; + // slither-disable-next-line costly-loop + optimisticMintingDebtTotal -= amount; emit OptimisticMintingDebtRepaid(depositor, debt - amount); return 0; } } + + /// @notice Enforces the optimistic minting rate limits for a request of + /// the given deposit amount submitted by `msg.sender` and + /// consumes the corresponding allowance from the Minter's and the + /// global rate-limiting buckets. + /// @dev Consumed allowance is not restored when the request gets + /// cancelled by a Guardian. See `cancelOptimisticMint`. + /// @param amount The deposit amount in satoshi. + function _consumeOptimisticMintingAllowance(uint64 amount) internal { + if (optimisticMintingMaxDepositSize != 0) { + require( + amount <= optimisticMintingMaxDepositSize, + "Deposit exceeds optimistic minting size cap" + ); + } + + uint64 minterValueRemaining = type(uint64).max; + uint64 globalHeadroomRemaining = type(uint64).max; + + uint64 capPerMinter = optimisticMintingCapPerMinter; + uint32 requestLimit = optimisticMintingRequestLimitPerMinter; + if (capPerMinter != 0 || requestLimit != 0) { + OptimisticMintingAllowance memory allowance = _refillAllowance( + minterAllowances[msg.sender], + capPerMinter, + requestLimit + ); + if (capPerMinter != 0) { + require( + allowance.valueRemaining >= amount, + "Optimistic minting minter cap exceeded" + ); + allowance.valueRemaining -= amount; + minterValueRemaining = allowance.valueRemaining; + } + if (requestLimit != 0) { + require( + allowance.requestsRemaining >= 1, + "Optimistic minting request limit exceeded" + ); + allowance.requestsRemaining -= 1; + } + minterAllowances[msg.sender] = allowance; + } + + uint64 debtCap = optimisticMintingDebtCap; + if (debtCap != 0) { + // The exposure the bridge would carry if this request and all + // other in-flight requests got finalized, on top of the debt + // already outstanding. `optimisticMintingPendingTotal` does not + // include this request's amount yet; the caller adds it after + // all checks pass. + uint256 exposure = (uint256(optimisticMintingPendingTotal) + + amount) * + SATOSHI_MULTIPLIER + + optimisticMintingDebtTotal; + uint256 cap = uint256(debtCap) * SATOSHI_MULTIPLIER; + require(exposure <= cap, "Optimistic minting debt cap exceeded"); + globalHeadroomRemaining = uint64( + (cap - exposure) / SATOSHI_MULTIPLIER + ); + } + + emit OptimisticMintingAllowanceConsumed( + msg.sender, + amount, + minterValueRemaining, + globalHeadroomRemaining + ); + } + + /// @notice Indicates whether the given optimistic minting requester is + /// exempt from the optimistic minting limits. Always false in + /// this contract. + /// @dev Derived contracts may override this function to exempt classes of + /// requesters whose issuance is bounded by separate, dedicated + /// exposure limits. Exempt requesters bypass the debt cap, value, + /// request count, and deposit size checks and do not consume any + /// per-Minter allowance. Their requests still count toward the + /// `optimisticMintingPendingTotal` and `optimisticMintingDebtTotal` + /// measurements, reducing the headroom available to non-exempt + /// Minters; overriding contracts must account for this overlap. + function _isOptimisticMintingThrottleExempt(address) + internal + view + virtual + returns (bool) + { + return false; + } + + /// @notice Computes the refilled state of a Minter's rate-limiting + /// buckets without modifying storage. Each dimension refills + /// continuously at a rate of the full cap per 24 hours, up to + /// the cap. A dimension that has never been tracked, or whose + /// limit was enabled after a period of being disabled, is + /// considered full. + /// @param allowance The current state of the buckets. + /// @param valueCap The value cap, in satoshi. Zero if disabled. + /// @param requestLimit The request count limit. Zero if disabled. + /// @return The refilled state of the buckets. + function _refillAllowance( + OptimisticMintingAllowance memory allowance, + uint64 valueCap, + uint32 requestLimit + ) private view returns (OptimisticMintingAllowance memory) { + // Each dimension is tracked independently and only while its limit + // is enabled. A dimension that was disabled keeps its old timestamp, + // so when governance re-enables the limit — which takes at least the + // 24-hour governance delay — the accrued refill covers the full cap + // and every Minter starts from a full bucket. Timestamps advance + // only when tokens are credited so that fractional accrual between + // frequent touches is never lost. + if (valueCap != 0) { + // slither-disable-next-line incorrect-equality + if (allowance.valueRefilledAt == 0) { + allowance.valueRemaining = valueCap; + /* solhint-disable-next-line not-rely-on-time */ + allowance.valueRefilledAt = uint64(block.timestamp); + } else { + /* solhint-disable-next-line not-rely-on-time */ + uint256 elapsed = block.timestamp - allowance.valueRefilledAt; + uint256 credit = (uint256(valueCap) * elapsed) / 24 hours; + if (credit != 0) { + uint256 value = uint256(allowance.valueRemaining) + credit; + if (value >= valueCap) { + allowance.valueRemaining = valueCap; + /* solhint-disable-next-line not-rely-on-time */ + allowance.valueRefilledAt = uint64(block.timestamp); + } else { + allowance.valueRemaining = uint64(value); + allowance.valueRefilledAt += _refillTimeForCredit( + credit, + valueCap + ); + } + } + } + // Clamp in case the cap was lowered since the last touch. + if (allowance.valueRemaining > valueCap) { + allowance.valueRemaining = valueCap; + } + } + + if (requestLimit != 0) { + // slither-disable-next-line incorrect-equality + if (allowance.requestsRefilledAt == 0) { + allowance.requestsRemaining = requestLimit; + /* solhint-disable-next-line not-rely-on-time */ + allowance.requestsRefilledAt = uint64(block.timestamp); + } else { + /* solhint-disable-next-line not-rely-on-time */ + uint256 elapsed = block.timestamp - + allowance.requestsRefilledAt; + uint256 credit = (uint256(requestLimit) * elapsed) / 24 hours; + if (credit != 0) { + uint256 requests = uint256(allowance.requestsRemaining) + + credit; + if (requests >= requestLimit) { + allowance.requestsRemaining = requestLimit; + /* solhint-disable-next-line not-rely-on-time */ + allowance.requestsRefilledAt = uint64(block.timestamp); + } else { + allowance.requestsRemaining = uint32(requests); + allowance.requestsRefilledAt += _refillTimeForCredit( + credit, + requestLimit + ); + } + } + } + // Clamp in case the limit was lowered since the last touch. + if (allowance.requestsRemaining > requestLimit) { + allowance.requestsRemaining = requestLimit; + } + } + + return allowance; + } + + /// @dev Returns the whole seconds represented by an integer refill credit. + /// Rounding up preserves every whole second of fractional accrual + /// without making the next token available early for limits that do + /// not divide 24 hours evenly. + function _refillTimeForCredit(uint256 credit, uint256 limit) + private + pure + returns (uint64) + { + return uint64((credit * 24 hours + limit - 1) / limit); + } } diff --git a/solidity/test/vault/TBTCVault.OptimisticMintingCaps.test.ts b/solidity/test/vault/TBTCVault.OptimisticMintingCaps.test.ts new file mode 100644 index 000000000..0cc5dec42 --- /dev/null +++ b/solidity/test/vault/TBTCVault.OptimisticMintingCaps.test.ts @@ -0,0 +1,1066 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" +import { ethers, helpers } from "hardhat" +import { expect } from "chai" +import { BigNumber, BigNumberish } from "ethers" + +import { + Bank, + Bridge, + TBTC, + TBTCVault, + TBTCVaultThrottleExemptStub, +} from "../../typechain" +import { createMock } from "../helpers/mock" +import type { Mock } from "../helpers/mock" + +const { createSnapshot, restoreSnapshot } = helpers.snapshot +const { increaseTime } = helpers.time +const { impersonateAccount } = helpers.account + +describe("TBTCVault - OptimisticMintingCaps", () => { + // 1 BTC in satoshi. + const BTC = 100_000_000 + const DAY = 86400 + + // Multiplier converting satoshi to 1e18 TBTC precision. + const SATOSHI_MULTIPLIER = BigNumber.from(10).pow(10) + + const MAX_UINT64 = BigNumber.from(2).pow(64).sub(1) + const MAX_UINT32 = BigNumber.from(2).pow(32).sub(1) + + // Contract defaults. + const DEFAULT_DEBT_CAP = 10 * BTC + const DEFAULT_CAP_PER_MINTER = 10 * BTC + const DEFAULT_MAX_DEPOSIT_SIZE = 5 * BTC + const DEFAULT_REQUEST_LIMIT = 100 + + let governance: SignerWithAddress + let minter: SignerWithAddress + let minterTwo: SignerWithAddress + let guardian: SignerWithAddress + let depositorSigner: SignerWithAddress + let treasury: SignerWithAddress + let thirdParty: SignerWithAddress + + let bridge: Mock + let bank: Mock + let tbtc: TBTC + let tbtcVault: TBTCVault + + // Ensures unique funding transaction hashes across all fabricated deposits. + let depositNonce = 0 + + // Fabricates a revealed deposit of the given amount in the fake Bridge, + // targeted at the given vault. Returns the funding transaction coordinates + // to be used with requestOptimisticMint. + async function fabricateDeposit( + amountSat: BigNumberish, + vaultAddress: string + ): Promise<{ fundingTxHash: string; fundingOutputIndex: number }> { + depositNonce += 1 + const fundingTxHash = ethers.utils.hexZeroPad( + BigNumber.from(depositNonce).toHexString(), + 32 + ) + const fundingOutputIndex = 0 + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [fundingTxHash, fundingOutputIndex] + ) + await bridge.deposits.whenCalledWith(BigNumber.from(depositKey)).returns({ + depositor: depositorSigner.address, + amount: amountSat, + revealedAt: 1, + vault: vaultAddress, + treasuryFee: 0, + sweptAt: 0, + extraData: ethers.constants.HashZero, + }) + return { fundingTxHash, fundingOutputIndex } + } + + // Updates the optimistic minting limits, waiting out the governance delay. + async function updateCaps( + debtCap: BigNumberish, + capPerMinter: BigNumberish, + maxDepositSize: BigNumberish, + requestLimit: BigNumberish + ) { + await tbtcVault + .connect(governance) + .beginOptimisticMintingCapsUpdate( + debtCap, + capPerMinter, + maxDepositSize, + requestLimit + ) + await increaseTime(DAY) + await tbtcVault.connect(governance).finalizeOptimisticMintingCapsUpdate() + } + + before(async () => { + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;[ + governance, + minter, + minterTwo, + guardian, + depositorSigner, + treasury, + thirdParty, + ] = await ethers.getSigners() + + bridge = await createMock("Bridge") + bank = await createMock("Bank") + await bridge.treasury.returns(treasury.address) + + const TBTCFactory = await ethers.getContractFactory("TBTC") + tbtc = await TBTCFactory.connect(governance).deploy() + + const TBTCVaultFactory = await ethers.getContractFactory("TBTCVault") + tbtcVault = await TBTCVaultFactory.connect(governance).deploy( + bank.address, + tbtc.address, + bridge.address + ) + + await tbtc.connect(governance).transferOwnership(tbtcVault.address) + + await tbtcVault.connect(governance).addMinter(minter.address) + await tbtcVault.connect(governance).addMinter(minterTwo.address) + await tbtcVault.connect(governance).addGuardian(guardian.address) + }) + + describe("default parameters", () => { + it("should set the expected limit defaults", async () => { + expect(await tbtcVault.optimisticMintingDebtCap()).to.equal( + DEFAULT_DEBT_CAP + ) + expect(await tbtcVault.optimisticMintingCapPerMinter()).to.equal( + DEFAULT_CAP_PER_MINTER + ) + expect(await tbtcVault.optimisticMintingMaxDepositSize()).to.equal( + DEFAULT_MAX_DEPOSIT_SIZE + ) + expect(await tbtcVault.optimisticMintingRequestLimitPerMinter()).to.equal( + DEFAULT_REQUEST_LIMIT + ) + }) + + it("should report full allowances for an unused minter", async () => { + const allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterValueRemaining).to.equal(DEFAULT_CAP_PER_MINTER) + expect(allowance.minterRequestsRemaining).to.equal(DEFAULT_REQUEST_LIMIT) + expect(allowance.globalHeadroomRemaining).to.equal(DEFAULT_DEBT_CAP) + }) + }) + + describe("beginOptimisticMintingCapsUpdate", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when called by a third party", () => { + it("should revert", async () => { + await expect( + tbtcVault + .connect(thirdParty) + .beginOptimisticMintingCapsUpdate(1, 2, 3, 4) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when called by the governance", () => { + it("should set pending values and emit an event", async () => { + const tx = await tbtcVault + .connect(governance) + .beginOptimisticMintingCapsUpdate(60 * BTC, 20 * BTC, 5 * BTC, 40) + + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingCapsUpdateStarted") + .withArgs(60 * BTC, 20 * BTC, 5 * BTC, 40) + + expect(await tbtcVault.newOptimisticMintingDebtCap()).to.equal(60 * BTC) + expect(await tbtcVault.newOptimisticMintingCapPerMinter()).to.equal( + 20 * BTC + ) + expect(await tbtcVault.newOptimisticMintingMaxDepositSize()).to.equal( + 5 * BTC + ) + expect( + await tbtcVault.newOptimisticMintingRequestLimitPerMinter() + ).to.equal(40) + expect( + await tbtcVault.optimisticMintingCapsUpdateInitiatedTimestamp() + ).to.be.gt(0) + + // Current values are not changed yet. + expect(await tbtcVault.optimisticMintingDebtCap()).to.equal( + DEFAULT_DEBT_CAP + ) + }) + }) + }) + + describe("finalizeOptimisticMintingCapsUpdate", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when the update has not been initiated", () => { + it("should revert", async () => { + await expect( + tbtcVault.connect(governance).finalizeOptimisticMintingCapsUpdate() + ).to.be.revertedWith("Change not initiated") + }) + }) + + context("when the governance delay has not elapsed", () => { + it("should revert", async () => { + await tbtcVault + .connect(governance) + .beginOptimisticMintingCapsUpdate(60 * BTC, 20 * BTC, 5 * BTC, 40) + await increaseTime(DAY - 3600) // 23 hours + await expect( + tbtcVault.connect(governance).finalizeOptimisticMintingCapsUpdate() + ).to.be.revertedWith("Governance delay has not elapsed") + }) + }) + + context("when the governance delay has elapsed", () => { + it("should update the values, emit an event and reset the state", async () => { + await increaseTime(3600) // 24 hours in total + + const tx = await tbtcVault + .connect(governance) + .finalizeOptimisticMintingCapsUpdate() + + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingCapsUpdated") + .withArgs(60 * BTC, 20 * BTC, 5 * BTC, 40) + + expect(await tbtcVault.optimisticMintingDebtCap()).to.equal(60 * BTC) + expect(await tbtcVault.optimisticMintingCapPerMinter()).to.equal( + 20 * BTC + ) + expect(await tbtcVault.optimisticMintingMaxDepositSize()).to.equal( + 5 * BTC + ) + expect( + await tbtcVault.optimisticMintingRequestLimitPerMinter() + ).to.equal(40) + + expect(await tbtcVault.newOptimisticMintingDebtCap()).to.equal(0) + expect(await tbtcVault.newOptimisticMintingCapPerMinter()).to.equal(0) + expect(await tbtcVault.newOptimisticMintingMaxDepositSize()).to.equal(0) + expect( + await tbtcVault.newOptimisticMintingRequestLimitPerMinter() + ).to.equal(0) + expect( + await tbtcVault.optimisticMintingCapsUpdateInitiatedTimestamp() + ).to.equal(0) + }) + }) + }) + + describe("deposit size cap", () => { + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should reject a deposit exceeding the maximum size", async () => { + const deposit = await fabricateDeposit( + DEFAULT_MAX_DEPOSIT_SIZE + 1, + tbtcVault.address + ) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.be.revertedWith("Deposit exceeds optimistic minting size cap") + }) + + it("should accept a deposit of exactly the maximum size", async () => { + const deposit = await fabricateDeposit( + DEFAULT_MAX_DEPOSIT_SIZE, + tbtcVault.address + ) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + }) + }) + + describe("per-minter cap", () => { + before(async () => { + await createSnapshot() + // Only the per-minter value cap is active. + await updateCaps(0, 5 * BTC, 0, 0) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should consume the minter allowance and emit an event", async () => { + const deposit = await fabricateDeposit(3 * BTC, tbtcVault.address) + const tx = await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + + // The first request starts from a full bucket so the remaining values + // are exact. The global cap is disabled so its remaining value is + // reported as the uint64 sentinel. + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingAllowanceConsumed") + .withArgs(minter.address, 3 * BTC, 2 * BTC, MAX_UINT64) + }) + + it("should reject a request exceeding the remaining allowance", async () => { + const deposit = await fabricateDeposit(3 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting minter cap exceeded") + }) + + it("should track other minters' allowances independently", async () => { + const deposit = await fabricateDeposit(3 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minterTwo) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + }) + }) + + describe("debt cap", () => { + before(async () => { + await createSnapshot() + await updateCaps(6 * BTC, 5 * BTC, 0, 0) + }) + + after(async () => { + await restoreSnapshot() + }) + + let first: { fundingTxHash: string; fundingOutputIndex: number } + + it("should bind across minters", async () => { + first = await fabricateDeposit(4 * BTC, tbtcVault.address) + const tx = await tbtcVault + .connect(minter) + .requestOptimisticMint(first.fundingTxHash, first.fundingOutputIndex) + + // Headroom values are exact: no time dependence in the debt cap. + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingAllowanceConsumed") + .withArgs(minter.address, 4 * BTC, 1 * BTC, 2 * BTC) + + // The second minter's own bucket has room (4 < 5 BTC) but only + // 2 BTC of debt cap headroom is left. + const second = await fabricateDeposit(4 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minterTwo) + .requestOptimisticMint( + second.fundingTxHash, + second.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting debt cap exceeded") + }) + + it("should release the headroom on cancellation", async () => { + expect(await tbtcVault.optimisticMintingPendingTotal()).to.equal(4 * BTC) + + await tbtcVault + .connect(guardian) + .cancelOptimisticMint(first.fundingTxHash, first.fundingOutputIndex) + + expect(await tbtcVault.optimisticMintingPendingTotal()).to.equal(0) + + // The full 6 BTC headroom is available again; the second minter can + // request now. + const second = await fabricateDeposit(4 * BTC, tbtcVault.address) + const tx = await tbtcVault + .connect(minterTwo) + .requestOptimisticMint(second.fundingTxHash, second.fundingOutputIndex) + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingAllowanceConsumed") + .withArgs(minterTwo.address, 4 * BTC, 1 * BTC, 2 * BTC) + + // The first minter's own bucket allowance was not restored by the + // cancellation: ~1 BTC left of the 5 BTC per-minter cap. + const third = await fabricateDeposit(2 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint(third.fundingTxHash, third.fundingOutputIndex) + ).to.be.revertedWith("Optimistic minting minter cap exceeded") + }) + }) + + describe("debt settlement", () => { + before(async () => { + await createSnapshot() + await updateCaps(6 * BTC, 0, 0, 0) + }) + + after(async () => { + await restoreSnapshot() + }) + + let deposit: { fundingTxHash: string; fundingOutputIndex: number } + + it("should count in-flight requests against the cap", async () => { + deposit = await fabricateDeposit(4 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + expect(await tbtcVault.optimisticMintingPendingTotal()).to.equal(4 * BTC) + + const blocked = await fabricateDeposit(3 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + blocked.fundingTxHash, + blocked.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting debt cap exceeded") + }) + + it("should move the pending value to the debt on finalization", async () => { + const delay = await tbtcVault.optimisticMintingDelay() + await increaseTime(delay + 1) + + await tbtcVault + .connect(minter) + .finalizeOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + + expect(await tbtcVault.optimisticMintingPendingTotal()).to.equal(0) + expect(await tbtcVault.optimisticMintingDebtTotal()).to.equal( + SATOSHI_MULTIPLIER.mul(4 * BTC) + ) + + // The outstanding debt still consumes the cap. + const blocked = await fabricateDeposit(3 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + blocked.fundingTxHash, + blocked.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting debt cap exceeded") + }) + + it("should recycle the capacity when the debt is repaid", async () => { + // The Bank notifies the vault about the swept deposit which repays + // the optimistic minting debt. + const bankSigner = await impersonateAccount(bank.address, { + from: governance, + value: 10, + }) + await tbtcVault + .connect(bankSigner) + .receiveBalanceIncrease([depositorSigner.address], [4 * BTC]) + + expect(await tbtcVault.optimisticMintingDebtTotal()).to.equal(0) + + const unblocked = await fabricateDeposit(3 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + unblocked.fundingTxHash, + unblocked.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + }) + }) + + describe("cap lowered below the outstanding exposure", () => { + before(async () => { + await createSnapshot() + await updateCaps(6 * BTC, 0, 0, 0) + + const deposit = await fabricateDeposit(5 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + + await updateCaps(3 * BTC, 0, 0, 0) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should report zero headroom", async () => { + const allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.globalHeadroomRemaining).to.equal(0) + }) + + it("should reject new requests", async () => { + const deposit = await fabricateDeposit(1 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting debt cap exceeded") + }) + }) + + describe("allowance refill", () => { + before(async () => { + await createSnapshot() + await updateCaps(0, 4 * BTC, 0, 0) + + // Exhaust the minter's bucket. + const deposit = await fabricateDeposit(4 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should reject a request right after the bucket exhausts", async () => { + const deposit = await fabricateDeposit(1 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting minter cap exceeded") + }) + + it("should refill the bucket continuously", async () => { + await increaseTime(DAY / 2) + + // After 12 hours, roughly half of the 4 BTC cap has refilled. + const allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterValueRemaining).to.be.closeTo( + BigNumber.from(2 * BTC), + 0.1 * BTC + ) + + const deposit = await fabricateDeposit(1.5 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + }) + + it("should clamp the refill at the cap", async () => { + await increaseTime(2 * DAY) + + const allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterValueRemaining).to.equal(4 * BTC) + }) + }) + + describe("request count limit", () => { + before(async () => { + await createSnapshot() + await updateCaps(0, 0, 0, 2) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should bound the number of requests in the window", async () => { + const first = await fabricateDeposit(0.1 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint(first.fundingTxHash, first.fundingOutputIndex) + + const second = await fabricateDeposit(0.1 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint(second.fundingTxHash, second.fundingOutputIndex) + + const third = await fabricateDeposit(0.1 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint(third.fundingTxHash, third.fundingOutputIndex) + ).to.be.revertedWith("Optimistic minting request limit exceeded") + }) + + it("should accrue the fractional refill without loss", async () => { + // Half a window refills exactly limit/2 = 1 request token. + await increaseTime(DAY / 2) + + const first = await fabricateDeposit(0.1 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint(first.fundingTxHash, first.fundingOutputIndex) + + const second = await fabricateDeposit(0.1 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + second.fundingTxHash, + second.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting request limit exceeded") + }) + + it("should allow requests again after the window refills", async () => { + await increaseTime(DAY) + + const deposit = await fabricateDeposit(0.1 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + }) + }) + + describe("fractional allowance refill", () => { + describe("when a whole token leaves a fractional remainder", () => { + before(async () => { + await createSnapshot() + await updateCaps(0, 2, 0, 2) + + // Exhaust both two-token buckets. + for (let i = 0; i < 2; i++) { + // Mock setup and requests must be mined sequentially. + // eslint-disable-next-line no-await-in-loop + const deposit = await fabricateDeposit(1, tbtcVault.address) + // eslint-disable-next-line no-await-in-loop + await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + } + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should preserve the remainder for value and request tokens", async () => { + const rawAllowance = await tbtcVault.minterAllowances(minter.address) + const checkpoint = rawAllowance.requestsRefilledAt.toNumber() + expect(rawAllowance.valueRefilledAt).to.equal(checkpoint) + + const first = await fabricateDeposit(1, tbtcVault.address) + // At a rate of two tokens per day, 23 hours accrues one token and + // leaves 11 hours of fractional refill time. + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(checkpoint + (23 * DAY) / 24).toHexString(), + ]) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + first.fundingTxHash, + first.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + + const second = await fabricateDeposit(1, tbtcVault.address) + // The retained remainder plus one more hour completes the next token. + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(checkpoint + DAY).toHexString(), + ]) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + second.fundingTxHash, + second.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + }) + }) + + describe("when the daily limit does not divide a day", () => { + before(async () => { + await createSnapshot() + await updateCaps(0, 0, 0, 7) + + for (let i = 0; i < 7; i++) { + // Mock setup and requests must be mined sequentially. + // eslint-disable-next-line no-await-in-loop + const deposit = await fabricateDeposit(1, tbtcVault.address) + // eslint-disable-next-line no-await-in-loop + await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + } + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should not credit the next request token early", async () => { + const rawAllowance = await tbtcVault.minterAllowances(minter.address) + const checkpoint = rawAllowance.requestsRefilledAt.toNumber() + const firstTokenAt = checkpoint + Math.ceil(DAY / 7) + const secondTokenAt = checkpoint + Math.ceil((2 * DAY) / 7) + + const deposit = await fabricateDeposit(1, tbtcVault.address) + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(firstTokenAt).toHexString(), + ]) + await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(secondTokenAt - 1).toHexString(), + ]) + await ethers.provider.send("evm_mine", []) + let allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterRequestsRemaining).to.equal(0) + + await ethers.provider.send("evm_setNextBlockTimestamp", [ + BigNumber.from(secondTokenAt).toHexString(), + ]) + await ethers.provider.send("evm_mine", []) + allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterRequestsRemaining).to.equal(1) + }) + }) + }) + + describe("enabling a previously-disabled limit", () => { + before(async () => { + await createSnapshot() + // Count-only throttling: the value dimension is disabled and must not + // be poisoned by activity happening while it is off. + await updateCaps(0, 0, 0, 5) + + const first = await fabricateDeposit(3 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint(first.fundingTxHash, first.fundingOutputIndex) + const second = await fabricateDeposit(3 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint(second.fundingTxHash, second.fundingOutputIndex) + + // Enable the per-minter value cap. + await updateCaps(0, 5 * BTC, 0, 5) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should give active minters a full bucket for the newly-enabled limit", async () => { + // The minter was active while the value cap was disabled; the newly + // enabled 5 BTC bucket starts full, so an exact-boundary request + // consuming the whole bucket succeeds. + const deposit = await fabricateDeposit(5 * BTC, tbtcVault.address) + const tx = await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingAllowanceConsumed") + .withArgs(minter.address, 5 * BTC, 0, MAX_UINT64) + + // The bucket is exhausted by the exact-boundary request. + const blocked = await fabricateDeposit(1 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + blocked.fundingTxHash, + blocked.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting minter cap exceeded") + }) + }) + + describe("cancellation", () => { + before(async () => { + await createSnapshot() + await updateCaps(0, 5 * BTC, 0, 0) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should not restore the consumed allowance", async () => { + const deposit = await fabricateDeposit(4 * BTC, tbtcVault.address) + await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + + await tbtcVault + .connect(guardian) + .cancelOptimisticMint(deposit.fundingTxHash, deposit.fundingOutputIndex) + + // Requesting the same deposit again is allowed but the bucket still + // remembers the first request: only ~1 BTC of allowance is left. + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.be.revertedWith("Optimistic minting minter cap exceeded") + }) + + it("should allow requesting the cancelled deposit after a refill", async () => { + await increaseTime(DAY) + + const deposit = await fabricateDeposit(4 * BTC, tbtcVault.address) + await expect( + tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.emit(tbtcVault, "OptimisticMintingRequested") + }) + }) + + describe("disabled limits", () => { + before(async () => { + await createSnapshot() + await updateCaps(0, 0, 0, 0) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should not limit requests", async () => { + const deposit = await fabricateDeposit(200 * BTC, tbtcVault.address) + const tx = await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingAllowanceConsumed") + .withArgs(minter.address, 200 * BTC, MAX_UINT64, MAX_UINT64) + + // The in-flight exposure is still measured even with the limits + // disabled. + expect(await tbtcVault.optimisticMintingPendingTotal()).to.equal( + 200 * BTC + ) + }) + + it("should report maximum allowances", async () => { + const allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterValueRemaining).to.equal(MAX_UINT64) + expect(allowance.minterRequestsRemaining).to.equal(MAX_UINT32) + expect(allowance.globalHeadroomRemaining).to.equal(MAX_UINT64) + }) + }) + + describe("finalization", () => { + before(async () => { + await createSnapshot() + await updateCaps(10 * BTC, 10 * BTC, 0, 0) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should not consume any additional allowance", async () => { + const deposit = await fabricateDeposit(2 * BTC, tbtcVault.address) + const tx = await tbtcVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + await expect(tx) + .to.emit(tbtcVault, "OptimisticMintingAllowanceConsumed") + .withArgs(minter.address, 2 * BTC, 8 * BTC, 8 * BTC) + + const delay = await tbtcVault.optimisticMintingDelay() + await increaseTime(delay + 1) + + await tbtcVault + .connect(minter) + .finalizeOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + + // TBTC has been minted to the depositor... + expect(await tbtc.balanceOf(depositorSigner.address)).to.be.gt(0) + + // ...but no additional allowance has been consumed. The minter bucket + // can only have refilled since the request and the debt cap headroom + // is exactly the cap minus the 2 BTC of outstanding debt. + const allowance = await tbtcVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterValueRemaining).to.be.gte(8 * BTC) + expect(allowance.globalHeadroomRemaining).to.equal(8 * BTC) + }) + }) + + describe("throttle exemption", () => { + let stubVault: TBTCVaultThrottleExemptStub + + before(async () => { + await createSnapshot() + + const StubFactory = await ethers.getContractFactory( + "TBTCVaultThrottleExemptStub" + ) + stubVault = await StubFactory.connect(governance).deploy( + bank.address, + tbtc.address, + bridge.address + ) + await stubVault.connect(governance).addMinter(minter.address) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should enforce the limits for non-exempt requesters", async () => { + const deposit = await fabricateDeposit(20 * BTC, stubVault.address) + await expect( + stubVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.be.revertedWith("Deposit exceeds optimistic minting size cap") + }) + + it("should bypass the limits for exempt requesters", async () => { + await stubVault.setThrottleExempt(minter.address, true) + + const deposit = await fabricateDeposit(20 * BTC, stubVault.address) + await expect( + stubVault + .connect(minter) + .requestOptimisticMint( + deposit.fundingTxHash, + deposit.fundingOutputIndex + ) + ).to.emit(stubVault, "OptimisticMintingRequested") + }) + + it("should not consume the per-minter allowance for exempt requesters", async () => { + const allowance = await stubVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.minterValueRemaining).to.equal(DEFAULT_CAP_PER_MINTER) + expect(allowance.minterRequestsRemaining).to.equal(DEFAULT_REQUEST_LIMIT) + }) + + it("should still measure the exempt in-flight exposure", async () => { + // The 20 BTC exempt request counts toward the pending total and + // reduces the headroom available to non-exempt requesters. As it + // exceeds the whole 10 BTC debt cap, the reported headroom clamps + // to zero. + expect(await stubVault.optimisticMintingPendingTotal()).to.equal(20 * BTC) + const allowance = await stubVault.getOptimisticMintingAllowance( + minter.address + ) + expect(allowance.globalHeadroomRemaining).to.equal(0) + }) + }) +})