Skip to content

Instantly share code, notes, and snippets.

@z0r0z
Created March 12, 2022 00:26
Show Gist options
  • Save z0r0z/d970fa0ba5efc6c186d79b1f715d6b22 to your computer and use it in GitHub Desktop.
Save z0r0z/d970fa0ba5efc6c186d79b1f715d6b22 to your computer and use it in GitHub Desktop.
A simple token streaming manager represented by NFTs
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.10;
import 'https://github.com/Rari-Capital/solmate/blob/main/src/tokens/ERC20.sol';
import 'https://github.com/Rari-Capital/solmate/blob/main/src/tokens/ERC721.sol';
/// @title lil superfluid nft
/// @author Miguel Piedrafita, Ross Campbell
/// modified from (https://github.com/m1guelpf/lil-web3/blob/main/src/LilSuperfluid.sol)
/// @notice A simple token streaming manager represented by NFTs
contract LilSuperfluidNFT is ERC721("LilSuperfluid", "LILS") {
/// ERRORS ///
/// @notice Thrown trying to withdraw/refuel a function without being part of the stream
error Unauthorized();
/// @notice Thrown when attempting to access a non-existant or deleted stream
error StreamNotFound();
/// @notice Thrown when trying to withdraw excess funds while the stream hasn't ended
error StreamStillActive();
/// EVENTS ///
/// @notice Emitted when creating a new steam
/// @param stream The newly-created stream
event StreamCreated(Stream stream);
/// @notice Emitted when increasing the accessible balance of a stream
/// @param streamId The ID of the stream receiving the funds
/// @param amount The ERC20 token balance that is being added
event StreamRefueled(uint256 indexed streamId, uint256 amount);
/// @notice Emitted when the receiver withdraws the received funds
/// @param streamId The ID of the stream having its funds withdrawn
/// @param amount The ERC20 token balance being withdrawn
event FundsWithdrawn(uint256 indexed streamId, uint256 amount);
/// @notice Emitted when the sender withdraws excess funds
/// @param streamId The ID of the stream having its excess funds withdrawn
/// @param amount The ERC20 token balance being withdrawn
event ExcessWithdrawn(uint256 indexed streamId, uint256 amount);
/// @notice Emitted when the configuration of a stream is updated
/// @param streamId The ID of the stream that was updated
/// @param paymentPerBlock The new payment rate for this stream
/// @param timeframe The new interval this stream will be active for
event StreamDetailsUpdated(uint256 indexed streamId, uint256 paymentPerBlock, Timeframe timeframe);
/// @dev Parameters for streams
/// @param sender The address of the creator of the stream
/// @param token The ERC20 token that is getting streamed
/// @param balance The ERC20 balance locked in the contract for this stream
/// @param withdrawnBalance The ERC20 balance the recipient has already withdrawn to their wallet
/// @param paymentPerBlock The amount of tokens to stream for each new block
/// @param timeframe The starting and ending block numbers for this stream
struct Stream {
address sender;
ERC20 token;
uint256 balance;
uint256 withdrawnBalance;
uint256 paymentPerBlock;
Timeframe timeframe;
}
/// @dev A block interval definition
/// @param startBlock The first block where the token stream will be active
/// @param stopBlock The last block where the token stream will be active
struct Timeframe {
uint256 startBlock;
uint256 stopBlock;
}
/// @dev Components of an Ethereum signature
struct Signature {
uint8 v;
bytes32 r;
bytes32 s;
}
/// @notice Used as a counter for the next stream index
/// @dev Initialised at 1 because it makes the first transaction slightly cheaper
uint256 internal streamId = 1;
/// @notice Signature nonce, incremented with each successful execution or state change
/// @dev This is used to prevent signature reuse
/// @dev Initialised at 1 because it makes the first transaction slightly cheaper
uint256 public nonce = 1;
/// @dev The EIP-712 domain separator
bytes32 public immutable domainSeparator;
/// @dev EIP-712 types for a signature that updates stream details
bytes32 public constant UPDATE_DETAILS_HASH =
keccak256(
'UpdateStreamDetails(uint256 streamId,uint256 paymentPerBlock,uint256 startBlock,uint256 stopBlock,uint256 nonce)'
);
/// @notice An indexed list of streams
/// @dev This automatically generates a getter for us!
mapping(uint256 => Stream) public getStream;
/// @notice Deploy a new LilSuperfluid instance
constructor() payable {
domainSeparator = keccak256(
abi.encode(
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
keccak256(bytes('LilSuperfluid')),
bytes('1'),
block.chainid,
address(this)
)
);
}
/// @notice Create a stream that continously delivers tokens to `recipient`
/// @param recipient The address that will receive the streamed tokens
/// @param token The ERC20 token that will get streamed
/// @param initialBalance How many ERC20 tokens to lock on the contract. Note that only the locked amount is guaranteed to be delivered to `recipient`
/// @param timeframe An interval of time, defined in block numbers, during which the stream will be active
/// @param paymentPerBlock How many tokens to deliver for each block the stream is active
/// @return id The ID of the created stream/NFT
/// @dev Remember to call approve(<address of this contract>, <initialBalance or greater>) on the ERC20's contract before calling this function
function streamTo(
address recipient,
ERC20 token,
uint256 initialBalance,
Timeframe calldata timeframe,
uint256 paymentPerBlock
) external payable returns (uint256 id) {
Stream memory stream = Stream({
token: token,
sender: msg.sender,
withdrawnBalance: 0,
timeframe: timeframe,
balance: initialBalance,
paymentPerBlock: paymentPerBlock
});
emit StreamCreated(stream);
id = streamId++;
getStream[id] = stream;
token.transferFrom(msg.sender, address(this), initialBalance);
_mint(recipient, id);
}
/// @notice Increase the amount of locked tokens for a certain token stream
/// @param id The ID for the stream that you are locking the tokens for
/// @param amount The amount of tokens to lock
/// @dev Remember to call approve(<address of this contract>, <amount or greater>) on the ERC20's contract before calling this function
function refuel(uint256 id, uint256 amount) public payable {
if (getStream[id].sender != msg.sender) revert Unauthorized();
unchecked {
getStream[id].balance += amount;
}
emit StreamRefueled(id, amount);
getStream[id].token.transferFrom(msg.sender, address(this), amount);
}
/// @notice Receive some of the streamed tokens, only available to the receiver of the stream (holder of NFT)
/// @param id The ID for the stream you are withdrawing the tokens from
function withdraw(uint256 id) public payable {
if (ownerOf[id] != msg.sender) revert Unauthorized();
uint256 balance = balanceOfStream(id, msg.sender);
unchecked {
getStream[id].withdrawnBalance += balance;
}
emit FundsWithdrawn(id, balance);
getStream[id].token.transfer(msg.sender, balance);
}
/// @notice Withdraw any excess in the locked balance, only available to the creator of the stream after it's no longer active
/// @param id The ID for the stream you are receiving the excess for
function refund(uint256 id) public payable {
if (getStream[id].sender != msg.sender) revert Unauthorized();
if (getStream[id].timeframe.stopBlock > block.number) revert StreamStillActive();
uint256 balance = balanceOfStream(id, msg.sender);
getStream[id].balance -= balance;
emit ExcessWithdrawn(id, balance);
getStream[id].token.transfer(msg.sender, balance);
}
/// @dev A function used internally to calculate how many blocks the stream has been active for so far
/// @param timeframe The time interval the stream is supposed to be active for
/// @param delta The amount of blocks the stream has been active for so far
function calculateBlockDelta(Timeframe memory timeframe) internal view returns (uint256 delta) {
if (block.number <= timeframe.startBlock) return 0;
if (block.number < timeframe.stopBlock) return block.number - timeframe.startBlock;
return timeframe.stopBlock - timeframe.startBlock;
}
/// @notice Check the balance of any of the involved parties on a stream
/// @param id The ID of the stream you're looking up
/// @param who The address of the party you want to know the balance of
/// @return The ERC20 balance of the specified party
/// @dev This function will always return 0 for any address not involved in the stream
function balanceOfStream(uint256 id, address who) public view returns (uint256) {
Stream memory stream = getStream[id];
if (stream.sender == address(0)) revert StreamNotFound();
uint256 blockDelta = calculateBlockDelta(stream.timeframe);
uint256 recipientBalance = blockDelta * stream.paymentPerBlock;
if (who == ownerOf[id]) return recipientBalance - stream.withdrawnBalance;
if (who == stream.sender) return stream.balance - recipientBalance;
return 0;
}
/// @notice Update the rate at which tokens get streamed, or the interval the stream is active for. Requires both parties to authorise the change
/// @param id The ID for the stream which is getting its configuration updated
/// @param paymentPerBlock The new rate at which tokens will get streamed
/// @param timeframe The new interval, defined in blocks, the stream will be active for
/// @param sig The signature of the other affected party for this change, certifying they approve of it
function updateDetails(
uint256 id,
uint256 paymentPerBlock,
Timeframe calldata timeframe,
Signature calldata sig
) public payable {
Stream memory stream = getStream[id];
if (stream.sender == address(0)) revert StreamNotFound();
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
domainSeparator,
keccak256(
abi.encode(
UPDATE_DETAILS_HASH,
id,
paymentPerBlock,
timeframe.startBlock,
timeframe.stopBlock,
++nonce
)
)
)
);
address sigAddress = ecrecover(digest, sig.v, sig.r, sig.s);
address owner = ownerOf[id];
if (
!(stream.sender == msg.sender && owner == sigAddress) &&
!(stream.sender == sigAddress && owner == msg.sender)
) revert Unauthorized();
emit StreamDetailsUpdated(id, paymentPerBlock, timeframe);
getStream[id].paymentPerBlock = paymentPerBlock;
getStream[id].timeframe = timeframe;
}
/// METADATA LOGIC ///
function tokenURI(uint256) public pure override returns (string memory) {
return "PLACEHOLDER";
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment