Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow voiding solvent streams #290

Merged
merged 3 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 28 additions & 28 deletions DIAGRAMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@

There are two types of streams: `STREAMING`, when debt is actively accruing, and `PAUSED`, when debt is not accruing:

| Type | Status | Description |
| ----------- | --------------------- | ----------------------------------------------------------------------- |
| `STREAMING` | `STREAMING_SOLVENT` | Streaming stream when there is no uncovered debt. |
| `STREAMING` | `STREAMING_INSOLVENT` | Streaming stream when there is uncovered debt. |
| `PAUSED` | `PAUSED_SOLVENT` | Paused stream when there is no uncovered debt. |
| `PAUSED` | `PAUSED_INSOLVENT` | Paused stream when there is uncovered debt. |
| `PAUSED` | `VOIDED` | Paused stream with forfeited uncovered debt and it cannot be restarted. |
| Type | Status | Description |
| ----------- | --------------------- | --------------------------------------------------------------------------------------- |
| `STREAMING` | `STREAMING_SOLVENT` | Streaming stream when there is no uncovered debt. |
| `STREAMING` | `STREAMING_INSOLVENT` | Streaming stream when there is uncovered debt. |
| `PAUSED` | `PAUSED_SOLVENT` | Paused stream when there is no uncovered debt. |
| `PAUSED` | `PAUSED_INSOLVENT` | Paused stream when there is uncovered debt. |
| `PAUSED` | `VOIDED` | Paused stream that cannot be restarted. Sets uncovered debt to 0 for insolvent streams. |

### Statuses diagram

Expand All @@ -25,43 +25,42 @@ stateDiagram-v2
direction LR

state Streaming {
direction LR
STREAMING_SOLVENT
STREAMING_INSOLVENT --> STREAMING_SOLVENT : deposit
STREAMING_SOLVENT --> STREAMING_INSOLVENT : time
}

state Paused {
# direction BT
direction RL
PAUSED_SOLVENT
PAUSED_INSOLVENT
PAUSED_INSOLVENT --> PAUSED_SOLVENT : deposit
PAUSED_INSOLVENT --> VOIDED : void
VOIDED
PAUSED_INSOLVENT
}

STREAMING_SOLVENT --> PAUSED_SOLVENT : pause
STREAMING_INSOLVENT --> PAUSED_INSOLVENT : pause
PAUSED_SOLVENT --> STREAMING_SOLVENT : restart
STREAMING_INSOLVENT --> VOIDED : void
PAUSED_INSOLVENT --> STREAMING_INSOLVENT : restart
Streaming --> Paused : pause
Paused --> Streaming : restart
Paused --> VOIDED : void
Streaming --> VOIDED : void

NULL --> STREAMING_SOLVENT : create (rps > 0)
NULL --> PAUSED_SOLVENT : create (rps = 0)
NULL --> Streaming : create (rps > 0)
NULL --> Paused : create (rps = 0)

NULL:::grey
Streaming:::lightGreen
Paused:::lightYellow
STREAMING_SOLVENT:::intenseGreen
STREAMING_INSOLVENT:::intenseGreen
PAUSED_INSOLVENT:::intenseYellow
PAUSED_SOLVENT:::intenseYellow
VOIDED:::intenseYellow
Streaming:::lightGreen
STREAMING_INSOLVENT:::intenseGreen
STREAMING_SOLVENT:::intenseGreen
VOIDED:::red

classDef grey fill:#b0b0b0,stroke:#333,stroke-width:2px,color:#000,font-weight:bold;
classDef lightGreen fill:#98FB98,color:#000,font-weight:bold;
classDef intenseGreen fill:#32cd32,stroke:#333,stroke-width:2px,color:#000,font-weight:bold;
classDef lightYellow fill:#ffff99,color:#000,font-weight:bold;
classDef intenseYellow fill:#ffd700,color:#000,font-weight:bold;
classDef lightGreen fill:#98FB98,color:#000,font-weight:bold;
classDef lightYellow fill:#ffff99,color:#000,font-weight:bold;
classDef red fill:#ff4e4e,stroke:#333,stroke-width:2px;
```

### Function calls
Expand All @@ -86,10 +85,10 @@ flowchart LR
CR([CREATE])
ADJRPS([ADJUST_RPS])
DP([DEPOSIT])
RFD([REFUND])
PS([PAUSE])
RST([RESTART])
VD([VOID])
RFD([REFUND])
WTD([WITHDRAW])
end

Expand All @@ -109,19 +108,20 @@ flowchart LR
DP -- "update bal (+)" --> BOTH

RFD -- "update bal (-)" --> BOTH
RFD -- "update bal (-)" --> VOID

PS -- "update sd (+od)<br/>update rps (0)<br/>update st" --> STR

BOTH --> STR & PSED

RST -- "update rps<br/>update st" --> PSED

VD -- "update sd (bal)<br/>update rps (0)<br/>update st" --> BOTH
VD -- "update sd (bal || +od)<br/>update rps (0)<br/>update st" --> BOTH

WTD -- "update sd (-)<br/>update st<br/>update bal (-)" --> BOTH
WTD -- "update sd (-)" --> VOID

linkStyle 2,3,9,10 stroke:#ff0000,stroke-width:2px
linkStyle 2,3,4,10,11 stroke:#ff0000,stroke-width:2px
```

## Access Control
Expand Down Expand Up @@ -211,7 +211,7 @@ classDef green1 fill:#32cd32,stroke:#333,stroke-width:2px;
flowchart TD
di0{ }:::red1
sd([Uncovered Debt - ud]):::red0
res_sd(["td- bal"]):::red1
res_sd(["td - bal"]):::red1
res_zero([0]):::red1

sd --> di0
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ premiums, loans interest, token ESOPs etc. If you are looking for vesting and ai
3. **Pause:** A stream can be paused by the sender and can later be restarted without losing track of previously accrued
debt.
4. **Refund:** Unstreamed amount can be refunded back to the sender at any time.
5. **Void:** Voiding a stream forfeits the uncovered debt and, thus, cannot be restarted anymore. Only streams with
non-zero uncovered debt can be voided by any part (either the sender or the recipient).
5. **Void:** Voiding a stream implies it cannot be restarted anymore. Voiding an insolvent stream forfeits the uncovered
debt. Either party can void a stream at any time.
6. **Withdraw:** it is publicly callable as long as `to` is set to the recipient. However, a stream’s recipient is
allowed to withdraw funds to any address.

Expand Down
2 changes: 1 addition & 1 deletion TECHNICAL-DOC.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ can only withdraw the available balance.

15. if $isPaused = true \implies rps = 0$

16. if $isVoided = true \implies isPaused = true$, $ra = 0$ and $ud = 0$
16. if $isVoided = true \implies isPaused = true$ and $ud = 0$

17. if $isVoided = false \implies \text{amount streamed with delay} = td + \text{amount withdrawn}$.

Expand Down
11 changes: 7 additions & 4 deletions benchmark/Flow.Gas.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@ contract Flow_Gas_Test is Integration_Test {
// {flow.restart}
computeGas("restart", abi.encodeCall(flow.restart, (streamId, RATE_PER_SECOND)));

// Warp time to accrue uncovered debt for the next call.
vm.warp(flow.depletionTimeOf(streamId) + 2 days);
// {flow.void} (on a solvent stream)
computeGas("void (solvent stream)", abi.encodeCall(flow.void, (streamId)));

// {flow.void}
computeGas("void", abi.encodeCall(flow.void, (streamId)));
// Warp time to accrue uncovered debt for the next call on an incremented stream ID..
vm.warp(flow.depletionTimeOf(++streamId) + 2 days);

// {flow.void} (on an insolvent stream)
computeGas("void (insolvent stream)", abi.encodeCall(flow.void, (streamId)));

// {flow.withdraw} (on an insolvent stream) on an incremented stream ID.
computeGas(
Expand Down
7 changes: 4 additions & 3 deletions benchmark/results/SablierFlow.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
| `deposit` | 30152 |
| `depositViaBroker` | 22142 |
| `pause` | 9340 |
| `refund` | 11866 |
| `refund` | 11671 |
| `restart` | 7106 |
| `void` | 8314 |
| `void (solvent stream)` | 10389 |
| `void (insolvent stream)` | 36795 |
| `withdraw (insolvent stream)` | 57634 |
| `withdraw (solvent stream)` | 40046 |
| `withdraw (solvent stream)` | 40047 |
| `withdrawMax` | 52200 |
2 changes: 1 addition & 1 deletion precompiles/Precompiles.sol

Large diffs are not rendered by default.

29 changes: 16 additions & 13 deletions src/SablierFlow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,6 @@ contract SablierFlow is
override
noDelegateCall
notNull(streamId)
notVoided(streamId)
onlySender(streamId)
updateMetadata(streamId)
{
Expand Down Expand Up @@ -703,24 +702,28 @@ contract SablierFlow is
emit ISablierFlow.RestartFlowStream(streamId, msg.sender, ratePerSecond);
}

/// @dev Voids a stream that has uncovered debt.
/// @dev See the documentation for the user-facing functions that call this internal function.
function _void(uint256 streamId) internal {
uint128 debtToWriteOff = _uncoveredDebtOf(streamId);

// Check: the stream has debt.
if (debtToWriteOff == 0) {
revert Errors.SablierFlow_UncoveredDebtZero(streamId);
}

// Check: `msg.sender` is either the stream's sender, recipient or an approved third party.
if (msg.sender != _streams[streamId].sender && !_isCallerStreamRecipientOrApproved(streamId)) {
revert Errors.SablierFlow_Unauthorized({ streamId: streamId, caller: msg.sender });
}

uint128 balance = _streams[streamId].balance;
uint128 debtToWriteOff = _uncoveredDebtOf(streamId);

// Effect: update the total debt by setting snapshot debt to the stream balance.
_streams[streamId].snapshotDebt = balance;
// If the stream is solvent, update the total debt normally.
if (debtToWriteOff == 0) {
uint128 ongoingDebt = _ongoingDebtOf(streamId);
if (ongoingDebt > 0) {
andreivladbrg marked this conversation as resolved.
Show resolved Hide resolved
// Effect: Update the snapshot debt by adding the ongoing debt.
_streams[streamId].snapshotDebt += ongoingDebt;
}
}
// If the stream is insolvent, write off the uncovered debt.
else {
// Effect: update the total debt by setting snapshot debt to the stream balance.
_streams[streamId].snapshotDebt = _streams[streamId].balance;
}

// Effect: update the snapshot time.
_streams[streamId].snapshotTime = uint40(block.timestamp);
Expand All @@ -737,7 +740,7 @@ contract SablierFlow is
sender: _streams[streamId].sender,
recipient: _ownerOf(streamId),
caller: msg.sender,
newTotalDebt: balance,
newTotalDebt: _streams[streamId].snapshotDebt,
writtenOffDebt: debtToWriteOff
});
}
Expand Down
23 changes: 12 additions & 11 deletions src/interfaces/ISablierFlow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ interface ISablierFlow is
EVENTS
//////////////////////////////////////////////////////////////////////////*/

/// @notice Emitted when the payment rate per second is updated by the sender.
/// @notice Emitted when the rate per second is updated by the sender.
/// @param streamId The ID of the stream.
/// @param totalDebt The total debt at the time of the update, denoted in token's decimals.
/// @param oldRatePerSecond The old payment rate per second, denoted as a fixed-point number where 1e18 is 1 token
/// @param oldRatePerSecond The old rate per second, denoted as a fixed-point number where 1e18 is 1 token
/// per second.
/// @param newRatePerSecond The new payment rate per second, denoted as a fixed-point number where 1e18 is 1 token
/// @param newRatePerSecond The new rate per second, denoted as a fixed-point number where 1e18 is 1 token
/// per second.
event AdjustFlowStream(
uint256 indexed streamId, uint128 totalDebt, UD21x18 oldRatePerSecond, UD21x18 newRatePerSecond
Expand Down Expand Up @@ -157,7 +157,7 @@ interface ISablierFlow is
NON-CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/

/// @notice Changes the stream's payment rate per second.
/// @notice Changes the stream's rate per second.
///
/// @dev Emits an {AdjustFlowStream} and {MetadataUpdate} event.
///
Expand All @@ -171,7 +171,7 @@ interface ISablierFlow is
/// - `newRatePerSecond` must not equal to the current rate per second.
///
/// @param streamId The ID of the stream to adjust.
/// @param newRatePerSecond The new payment rate per second, denoted as a fixed-point number where 1e18 is 1 token
/// @param newRatePerSecond The new rate per second, denoted as a fixed-point number where 1e18 is 1 token
/// per second.
function adjustRatePerSecond(uint256 streamId, UD21x18 newRatePerSecond) external;

Expand Down Expand Up @@ -306,7 +306,7 @@ interface ISablierFlow is
///
/// Requirements:
/// - Must not be delegate called.
/// - `streamId` must not reference a null or a voided stream.
/// - `streamId` must not reference a null stream.
/// - `msg.sender` must be the sender.
/// - `amount` must be greater than zero and must not exceed the refundable amount.
///
Expand Down Expand Up @@ -364,21 +364,22 @@ interface ISablierFlow is
/// @param amount The deposit amount, denoted in token's decimals.
function restartAndDeposit(uint256 streamId, UD21x18 ratePerSecond, uint128 amount) external;

/// @notice Voids the uncovered debt, and ends the stream.
/// @notice Voids a stream.
///
/// @dev Emits a {VoidFlowStream} event.
///
/// Notes:
/// - It sets the snapshot debt to the stream's balance so that the uncovered debt becomes zero.
/// - It sets the payment rate per second to zero.
/// - A paused stream can be voided only if its uncovered debt is not zero.
/// - It sets snapshot time to the `block.timestamp`
/// - Voiding an insolvent stream sets the snapshot debt to the stream's balance making the uncovered debt to become
/// zero.
/// - Voiding a solvent stream updates the snapshot debt by adding up ongoing debt.
/// - It sets the rate per second to zero.
/// - A voided stream cannot be restarted.
///
/// Requirements:
/// - Must not be delegate called.
/// - `streamId` must not reference a null or a voided stream.
/// - `msg.sender` must either be the stream's sender, recipient or an approved third party.
/// - The uncovered debt must be greater than zero.
///
/// @param streamId The ID of the stream to void.
function void(uint256 streamId) external;
Expand Down
3 changes: 0 additions & 3 deletions src/libraries/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,6 @@ library Errors {
/// @notice Thrown when `msg.sender` lacks authorization to perform an action.
error SablierFlow_Unauthorized(uint256 streamId, address caller);

/// @notice Thrown when voiding a stream with zero uncovered debt.
error SablierFlow_UncoveredDebtZero(uint256 streamId);

/// @notice Thrown when trying to withdraw to an address other than the recipient's.
error SablierFlow_WithdrawalAddressNotRecipient(uint256 streamId, address caller, address to);

Expand Down
4 changes: 2 additions & 2 deletions src/types/DataTypes.sol
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ library Flow {
/// @param snapshotTime The Unix timestamp used for the ongoing debt calculation.
/// @param isStream Boolean indicating if the struct entity exists.
/// @param isTransferable Boolean indicating if the stream NFT is transferable.
/// @param isVoided Boolean indicating if the stream is voided. Voiding a stream is a non reversible step. When a
/// stream is voided, its uncovered debt is set to zero and it can not be restarted.
/// @param isVoided Boolean indicating if the stream is voided. Voiding any stream is non-reversible and it cannot
/// be restarted. Voiding an insolvent stream sets its uncovered debt to zero.
/// @param token The contract address of the ERC-20 token to stream.
/// @param tokenDecimals The decimals of the ERC-20 token to stream.
/// @param snapshotDebt The amount of tokens that the sender owed to the recipient at snapshot time, denoted in
Expand Down
29 changes: 7 additions & 22 deletions tests/fork/Flow.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -528,39 +528,24 @@ contract Flow_Fork_Test is Fork_Test {
address sender = flow.getSender(streamId);
address recipient = flow.getRecipient(streamId);
uint128 uncoveredDebt = flow.uncoveredDebtOf(streamId);
uint128 expectedTotalDebt;

resetPrank({ msgSender: sender });

if (uncoveredDebt == 0) {
if (flow.isPaused(streamId)) {
flow.restart(streamId, RATE_PER_SECOND);
}

// In case of a big depletion time, refund and withdraw all the funds, and then warp for one second. Warping
// too much in the future would affect the other tests.
uint128 refundableAmount = flow.refundableAmountOf(streamId);
if (refundableAmount > 0) {
// Refund and withdraw all the funds.
flow.refund(streamId, refundableAmount);
}
if (flow.coveredDebtOf(streamId) > 0) {
flow.withdrawMax(streamId, recipient);
}

vm.warp({ newTimestamp: getBlockTimestamp() + 100 seconds });
uncoveredDebt = flow.uncoveredDebtOf(streamId);
if (uncoveredDebt > 0) {
expectedTotalDebt = flow.getBalance(streamId);
} else {
expectedTotalDebt = flow.totalDebtOf(streamId);
}

uint128 beforeVoidBalance = flow.getBalance(streamId);

// It should emit 1 {VoidFlowStream}, 1 {MetadataUpdate} events.
vm.expectEmit({ emitter: address(flow) });
emit ISablierFlow.VoidFlowStream({
streamId: streamId,
recipient: recipient,
sender: sender,
caller: sender,
newTotalDebt: beforeVoidBalance,
newTotalDebt: expectedTotalDebt,
writtenOffDebt: uncoveredDebt
});

Expand All @@ -576,7 +561,7 @@ contract Flow_Fork_Test is Fork_Test {
assertTrue(flow.isPaused(streamId), "Void: paused");

// It should set the total debt to stream balance.
assertEq(flow.totalDebtOf(streamId), beforeVoidBalance, "Void: total debt");
assertEq(flow.totalDebtOf(streamId), expectedTotalDebt, "Void: total debt");
}

/*//////////////////////////////////////////////////////////////////////////
Expand Down
Loading
Loading