diff --git a/packages/nouns-contracts/contracts/StreamEscrow.sol b/packages/nouns-contracts/contracts/StreamEscrow.sol index 5acd8a077..d174841d2 100644 --- a/packages/nouns-contracts/contracts/StreamEscrow.sol +++ b/packages/nouns-contracts/contracts/StreamEscrow.sol @@ -245,6 +245,22 @@ contract StreamEscrow is IStreamEscrow { return !streams[nounId].canceled && streams[nounId].lastTick > currentTick; } + /** + * @notice Returns the amount of ETH that was not yet streamed for a specific Noun token. + * Returns zero for inactive streams. + * @param nounId The ID of the Noun token to check the stream for. + */ + function unstreamedETHForNoun(uint256 nounId) public view returns (uint256) { + Stream memory stream = streams[nounId]; + uint32 currentTick_ = currentTick; + if (!isStreamActive(stream, currentTick_)) { + return 0; + } + + uint256 ticksLeft = stream.lastTick - currentTick_; + return ticksLeft * stream.ethPerTick; + } + function isStreamActive(Stream memory stream, uint32 tick) internal pure returns (bool) { return !stream.canceled && stream.lastTick > tick; } diff --git a/packages/nouns-contracts/test/foundry/StreamEscrow.t.sol b/packages/nouns-contracts/test/foundry/StreamEscrow.t.sol index 99d7e1666..1bc3e8d00 100644 --- a/packages/nouns-contracts/test/foundry/StreamEscrow.t.sol +++ b/packages/nouns-contracts/test/foundry/StreamEscrow.t.sol @@ -717,6 +717,65 @@ contract RescueTokensTest is BaseStreamEscrowTest { } } +contract UnstreamedETHTest is BaseStreamEscrowTest { + function test_unstreamedETHForNoun() public { + vm.prank(streamCreator); + escrow.forwardAllAndCreateStream{ value: 1 ether }({ nounId: 1, streamLengthInTicks: 20 }); + + // 1 ether / 20 = 0.05 eth per tick + assertEq(escrow.unstreamedETHForNoun(1), 1 ether); + + // forward 5 ticks + for (uint i; i < 5; i++) { + forwardOneDay(); + } + // check unstreamed eth + assertEq(escrow.unstreamedETHForNoun(1), 0.75 ether); + + // forward 15 more ticks + for (uint i; i < 15; i++) { + forwardOneDay(); + } + // check unstreamed eth + assertEq(escrow.unstreamedETHForNoun(1), 0 ether); + } + + function test_unstreamedETHForNoun_canceledStream() public { + vm.prank(streamCreator); + escrow.forwardAllAndCreateStream{ value: 1 ether }({ nounId: 1, streamLengthInTicks: 20 }); + + // 1 ether / 20 = 0.05 eth per tick + assertEq(escrow.unstreamedETHForNoun(1), 1 ether); + + // forward 5 ticks + for (uint i; i < 5; i++) { + forwardOneDay(); + } + // check unstreamed eth + assertEq(escrow.unstreamedETHForNoun(1), 0.75 ether); + + // cancel stream + vm.prank(streamCreator); + nounsToken.approve(address(escrow), 1); + vm.prank(streamCreator); + escrow.cancelStream(1); + + // check unstreamed eth is zero + assertEq(escrow.unstreamedETHForNoun(1), 0 ether); + } + + function test_unstreamedETHForNoun_returnsZeroForNonExistentStream() public { + assertEq(escrow.unstreamedETHForNoun(1), 0 ether); + + // forward 5 ticks + for (uint i; i < 5; i++) { + forwardOneDay(); + } + + assertEq(escrow.unstreamedETHForNoun(3), 0 ether); + } +} + contract StreamEscrowGasTest is BaseStreamEscrowTest { function setUp() public virtual override { super.setUp();