-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Description
Problem:
The Treasury doesn't always have enough assets like USDt or USDc to pay for every approved spend. It usually gets more of these assets by periodically swapping its native currency for them.
Since anyone can trigger an approved expense payment as soon as it matures, regardless of the order in which expenses were approved, a problem can arise. If smaller, more recently approved spends are paid out first, they can drain the Treasury's funds. This can leave insufficient funds to pay out a larger expense that was approved earlier.
Details:
Let’s look at the example:
At block 50:
Treasury USDt balance: 150 USDt, acquiring 10 USDt daily.
Spend 1: 200 USDt, approved at block 10, matured for payout since block 15.
Spend 2: 180 USDt, approved at block 20, matured for payout since block 25.
Spend 3: 170 USDt, approved at block 30, matured for payout since block 35.
At block 52:
Treasury USDt balance: 170 USDt, acquiring 10 USDt daily.
Spend 1: not able to be paid out, as the Treasury balance is insufficient.
Spend 2: not able to be paid out, as the Treasury balance is insufficient.
Spend 3: paid out first, despite being approved last.
At block 70:
Treasury USDt balance: 180 USDt, acquiring 10 USDt daily.
Spend 1: not able to be paid out, as the Treasury balance is insufficient.
Spend 2: paid out, despite being approved later than Spend 1.
Spend 3: executed.
Spend 4: 100 USDt, will be able to be paid out earlier than Spend 1.
Proposed Solution:
Solution 1. with fixed expiration
We need to introduce two new storage items:
StorageValue of NextPayout ( SpendIndex, ExpireAt ) - the spend to be paid out next; where the first value is the spend identifier and the second value is when its order expires and can be moved to the end of the list of mature spends.
StorageValue of PayoutQueue ( Vec<SpendIndex> ) - the list of mature spends in the order they can be paid out. This requires MaxQueuedSpends to store a bounded Vec. We also need to keep the vector’s type relatively small to avoid increasing the PoV size.
Spends have a maturity block number that determines when they can be paid out, and this value is derived from the valid_from field. An undefined valid_from value means the spend is immediately payable after approval, whereas a defined value specifies a future date from which it can be paid.
Upon maturity, each spend can be added to the end of the PayoutQueue via the new call (call: 1.1). If the queue is empty, the spend becomes NextPayout. ExpireAt(n) is calculated as now + OrderExpirationPeriod, where OrderExpirationPeriod is a configurable time period.
Every next payout is only possible for the spend from NextPayout storage item. The payout call is permissionless and can be executed by any signed origin.
If the order for the next spend payout is expired (ExpireAt(0) < now), anyone can submit an extrinsic with a call to update the payout order (call: 1.2). The first (n = 0) in the list will be moved to the end. The second (n = 1) spend from the list will be set as NextPayout with the ExpireAt set as now + OrderExpirationPeriod.
The spend that has been successfully paid out is removed from the list and NextPayout with the check_status call.
The described approach uses a fixed order expiration and does not account for cases where the treasury may have insufficient balance for an extended period (> OrderExpirationPeriod).
Solution 1.1. with fixed extendable expiration (extension of Solution 1.)
Optionally, users can be allowed to extend a spend’s order expiration after a failed payout attempt, but this introduces additional complexity. Let’s look at it:
A spend whose payout order is approaching expiration can extend its expiration date if the treasury is still unable to fulfill the payout. The user submits an extrinsic to pay out a specific spend and later calls check_status to update its payment status to FailedOnExpiration, if the spend’s ExpireAt is about to elapse relative to now (within some fraction of OrderExpirationPeriod).
The new call (call: 1.3) to update the order expiration first checks that the payment status of the spend (n = 0) is FailedOnExpiration and then updates the expiration block to now + OrderExpirationPeriod. At the same time, the payment status is set to Pending to ensure the expiration is not updated again for the same payout attempt.
Conclusion:
Both options seem reasonable to me. However, I prefer Solution (1) because it is simpler. I also believe the treasury should learn to manage its funds more efficiently. A single large spend should not indefinitely block other payouts.
Instead, we can focus on choosing better parameters for OrderExpirationPeriod and PayoutPeriod (i.e., defining the spend validity window via ValidFrom). The PayoutPeriod can be significantly longer, as it mainly serves to prevent unclaimed spends from remaining pending indefinitely.
Open questions:
- Solution (1) or Solution (1.2) ?
- (call: 1.1) permissionless or permissioned to the owner of the spend?