RobinFlowStreams

0x3068bf0522d721e674ab9a252fdc50bb614b3cae

Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
7,436 bytes
ABI entries
45
11 read · 9 write
License
none

Contract information

Address
0x3068bf0522d721e674ab9a252fdc50bb614b3cae
Chain
Robinhood Chain (4663)
Compiler
v0.8.24+commit.e11b9ed9
Optimization
Enabled
Creation tx
0x915fe0d3c6…8964ac7fe1

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (11)

MAX_FLAT_FEE()uint256
collectedFees()uint256
flatFee()uint256
getStream(uint256)tuple
incomingStreams(address)uint256[]
nextStreamId()uint256
outgoingStreams(address)uint256[]
owner()address
streams(uint256)address, address, address, uint128, uint128, uint128, uint40, uint40, uint40, uint32, bool, bool, bool, string
vestedAmount(uint256, uint40)uint256
withdrawable(uint256)uint256

Events (8)

CanceledFeePaidFeesWithdrawnFlatFeeUpdatedOwnershipTransferredRecipientTransferredStreamCreatedWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner_",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "flatFee_",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AlreadyCanceled",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "FeeTooHigh",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InsufficientFee",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidParams",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NoFees",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotCancelable",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotRecipient",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotSender",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotTransferable",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingToWithdraw",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TransferFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "recipientAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "senderAmount",
        "type": "uint256"
      }
    ],
    "name": "Canceled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "payer",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "FeePaid",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "FeesWithdrawn",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "flatFee",
        "type": "uint256"
      }
    ],
    "name": "FlatFeeUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "oldRecipient",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newRecipient",
        "type": "address"
      }
    ],
    "name": "RecipientTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "sender",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint128",
        "name": "cliffAmount",
        "type": "uint128"
      },
      {
        "indexed": false,
        "internalType": "uint40",
        "name": "start",
        "type": "uint40"
      },
      {
        "indexed": false,
        "internalType": "uint40",
        "name": "cliff",
        "type": "uint40"
      },
      {
        "indexed": false,
        "internalType": "uint40",
        "name": "end",
        "type": "uint40"
      },
      {
        "indexed": false,
        "internalType": "uint32",
        "name": "period",
        "type": "uint32"
      },
      {
        "indexed": false,
        "internalType": "string",
        "name": "title",
        "type": "string"
      }
    ],
    "name": "StreamCreated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Withdrawn",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "MAX_FLAT_FEE",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      }
    ],
    "name": "cancel",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "collectedFees",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "uint128",
        "name": "amount",
        "type": "uint128"
      },
      {
        "internalType": "uint128",
        "name": "cliffAmount",
        "type": "uint128"
      },
      {
        "internalType": "uint40",
        "name": "start",
        "type": "uint40"
      },
      {
        "internalType": "uint40",
        "name": "cliff",
        "type": "uint40"
      },
      {
        "internalType": "uint40",
        "name": "end",
        "type": "uint40"
      },
      {
        "internalType": "uint32",
        "name": "period",
        "type": "uint32"
      },
      {
        "internalType": "bool",
        "name": "cancelable",
        "type": "bool"
      },
      {
        "internalType": "bool",
        "name": "transferable",
        "type": "bool"
      },
      {
        "internalType": "string",
        "name": "title",
        "type": "string"
      }
    ],
    "name": "createStream",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "address",
            "name": "recipient",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "token",
            "type": "address"
          },
          {
            "internalType": "uint128",
            "name": "amount",
            "type": "uint128"
          },
          {
            "internalType": "uint128",
            "name": "cliffAmount",
            "type": "uint128"
          },
          {
            "internalType": "uint40",
            "name": "start",
            "type": "uint40"
          },
          {
            "internalType": "uint40",
            "name": "cliff",
            "type": "uint40"
          },
          {
            "internalType": "uint40",
            "name": "end",
            "type": "uint40"
          },
          {
            "internalType": "uint32",
            "name": "period",
            "type": "uint32"
          },
          {
            "internalType": "bool",
            "name": "cancelable",
            "type": "bool"
          },
          {
            "internalType": "bool",
            "name": "transferable",
            "type": "bool"
          },
          {
            "internalType": "string",
            "name": "title",
            "type": "string"
          }
        ],
        "internalType": "struct RobinFlowStreams.StreamParams[]",
        "name": "items",
        "type": "tuple[]"
      }
    ],
    "name": "createStreamsBatch",
    "outputs": [
      {
        "internalType": "uint256[]",
        "name": "ids",
        "type": "uint256[]"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "flatFee",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      }
    ],
    "name": "getStream",
    "outputs": [
      {
        "components": [
          {
            "internalType": "address",
            "name": "sender",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "recipient",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "token",
            "type": "address"
          },
          {
            "internalType": "uint128",
            "name": "deposited",
            "type": "uint128"
          },
          {
            "internalType": "uint128",
            "name": "withdrawn",
            "type": "uint128"
          },
          {
            "internalType": "uint128",
            "name": "cliffAmount",
            "type": "uint128"
          },
          {
            "internalType": "uint40",
            "name": "start",
            "type": "uint40"
          },
          {
            "internalType": "uint40",
            "name": "cliff",
            "type": "uint40"
          },
          {
            "internalType": "uint40",
            "name": "end",
            "type": "uint40"
          },
          {
            "internalType": "uint32",
            "name": "period",
            "type": "uint32"
          },
          {
            "internalType": "bool",
            "name": "cancelable",
            "type": "bool"
          },
          {
            "internalType": "bool",
            "name": "transferable",
            "type": "bool"
          },
          {
            "internalType": "bool",
            "name": "canceled",
            "type": "bool"
          },
          {
            "internalType": "string",
            "name": "title",
            "type": "string"
          }
        ],
        "internalType": "struct RobinFlowStreams.Stream",
        "name": "",
        "type": "tuple"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      }
    ],
    "name": "incomingStreams",
    "outputs": [
      {
        "internalType": "uint256[]",
        "name": "",
        "type": "uint256[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "nextStreamId",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "sender",
        "type": "address"
      }
    ],
    "name": "outgoingStreams",
    "outputs": [
      {
        "internalType": "uint256[]",
        "name": "",
        "type": "uint256[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "newFee",
        "type": "uint256"
      }
    ],
    "name": "setFlatFee",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "streams",
    "outputs": [
      {
        "internalType": "address",
        "name": "sender",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "uint128",
        "name": "deposited",
        "type": "uint128"
      },
      {
        "internalType": "uint128",
        "name": "withdrawn",
        "type": "uint128"
      },
      {
        "internalType": "uint128",
        "name": "cliffAmount",
        "type": "uint128"
      },
      {
        "internalType": "uint40",
        "name": "start",
        "type": "uint40"
      },
      {
        "internalType": "uint40",
        "name": "cliff",
        "type": "uint40"
      },
      {
        "internalType": "uint40",
        "name": "end",
        "type": "uint40"
      },
      {
        "internalType": "uint32",
        "name": "period",
        "type": "uint32"
      },
      {
        "internalType": "bool",
        "name": "cancelable",
        "type": "bool"
      },
      {
        "internalType": "bool",
        "name": "transferable",
        "type": "bool"
      },
      {
        "internalType": "bool",
        "name": "canceled",
        "type": "bool"
      },
      {
        "internalType": "string",
        "name": "title",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "newRecipient",
        "type": "address"
      }
    ],
    "name": "transferRecipient",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      },
      {
        "internalType": "uint40",
        "name": "t",
        "type": "uint40"
      }
    ],
    "name": "vestedAmount",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      }
    ],
    "name": "withdraw",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "withdrawFees",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "streamId",
        "type": "uint256"
      }
    ],
    "name": "withdrawable",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {RobinFlowFeeCollector} from "./RobinFlowFeeCollector.sol";

/// @title RobinFlowStreams
/// @notice Token vesting / streaming with competitor-grade schedule options:
///          - optional cliff (time) with an optional initial unlock amount
///            released at the cliff (`cliffAmount`),
///          - linear OR periodic/tranched unlock of the remainder (`period`),
///          - cancelable and recipient-transferable flags,
///          - single or batch creation for many recipients at once.
///         A "token lock" is a stream to yourself with cliff == end.
/// @dev    Fee-on-transfer tokens are handled: the escrowed amount is the
///         balance actually received, not the requested amount.
contract RobinFlowStreams is ReentrancyGuard, RobinFlowFeeCollector {
    using SafeERC20 for IERC20;

    constructor(address owner_, uint256 flatFee_) RobinFlowFeeCollector(owner_, flatFee_) {}

    struct Stream {
        address sender;
        address recipient;
        address token;
        uint128 deposited;
        uint128 withdrawn;
        uint128 cliffAmount; // released in full at the cliff
        uint40 start;
        uint40 cliff;
        uint40 end;
        uint32 period; // 0/1 = per-second; >1 = unlock every `period` seconds
        bool cancelable;
        bool transferable;
        bool canceled;
        string title;
    }

    /// @notice Parameters for creating a stream (used by single and batch create).
    struct StreamParams {
        address recipient;
        address token;
        uint128 amount;
        uint128 cliffAmount;
        uint40 start;
        uint40 cliff;
        uint40 end;
        uint32 period;
        bool cancelable;
        bool transferable;
        string title;
    }

    uint256 public nextStreamId = 1;
    mapping(uint256 => Stream) public streams;
    mapping(address => uint256[]) private _outgoing;
    mapping(address => uint256[]) private _incoming;

    event StreamCreated(
        uint256 indexed streamId,
        address indexed sender,
        address indexed recipient,
        address token,
        uint256 amount,
        uint128 cliffAmount,
        uint40 start,
        uint40 cliff,
        uint40 end,
        uint32 period,
        string title
    );
    event Withdrawn(uint256 indexed streamId, address indexed recipient, uint256 amount);
    event Canceled(uint256 indexed streamId, uint256 recipientAmount, uint256 senderAmount);
    event RecipientTransferred(uint256 indexed streamId, address indexed oldRecipient, address indexed newRecipient);

    error InvalidParams();
    error NotRecipient();
    error NotSender();
    error NotCancelable();
    error NotTransferable();
    error AlreadyCanceled();
    error NothingToWithdraw();

    function createStream(
        address recipient,
        address token,
        uint128 amount,
        uint128 cliffAmount,
        uint40 start,
        uint40 cliff,
        uint40 end,
        uint32 period,
        bool cancelable,
        bool transferable,
        string calldata title
    ) external payable nonReentrant returns (uint256 streamId) {
        _collectFee();
        streamId = _createStream(
            StreamParams(recipient, token, amount, cliffAmount, start, cliff, end, period, cancelable, transferable, title)
        );
    }

    /// @notice Create many streams in one transaction (batch distribution).
    ///         Requires a fee of `flatFee` per stream.
    function createStreamsBatch(StreamParams[] calldata items)
        external
        payable
        nonReentrant
        returns (uint256[] memory ids)
    {
        if (items.length == 0) revert InvalidParams();
        _collectFeeFor(items.length);
        ids = new uint256[](items.length);
        for (uint256 i = 0; i < items.length; i++) {
            ids[i] = _createStream(items[i]);
        }
    }

    function _createStream(StreamParams memory p) private returns (uint256 streamId) {
        if (
            p.recipient == address(0) ||
            p.token == address(0) ||
            p.amount == 0 ||
            p.end <= p.start ||
            p.cliff < p.start ||
            p.cliff > p.end ||
            p.cliffAmount > p.amount
        ) revert InvalidParams();

        // Measure the amount actually received so fee-on-transfer / rebasing
        // tokens are escrowed for exactly what arrived (never over-recorded).
        uint256 balBefore = IERC20(p.token).balanceOf(address(this));
        IERC20(p.token).safeTransferFrom(msg.sender, address(this), p.amount);
        uint256 received = IERC20(p.token).balanceOf(address(this)) - balBefore;
        if (received == 0) revert InvalidParams();
        uint128 deposited = received > type(uint128).max ? type(uint128).max : uint128(received);
        uint128 cliffAmount = p.cliffAmount > deposited ? deposited : p.cliffAmount;

        streamId = nextStreamId++;
        streams[streamId] = Stream({
            sender: msg.sender,
            recipient: p.recipient,
            token: p.token,
            deposited: deposited,
            withdrawn: 0,
            cliffAmount: cliffAmount,
            start: p.start,
            cliff: p.cliff,
            end: p.end,
            period: p.period,
            cancelable: p.cancelable,
            transferable: p.transferable,
            canceled: false,
            title: p.title
        });
        _outgoing[msg.sender].push(streamId);
        _incoming[p.recipient].push(streamId);

        emit StreamCreated(
            streamId, msg.sender, p.recipient, p.token, deposited, cliffAmount, p.start, p.cliff, p.end, p.period, p.title
        );
    }

    /// @notice Amount vested at timestamp `t` (ignores withdrawals/cancel).
    ///         cliffAmount unlocks in full at the cliff; the remainder vests
    ///         from `start` to `end`, stepped to `period` boundaries if set.
    function vestedAmount(uint256 streamId, uint40 t) public view returns (uint256) {
        Stream storage s = streams[streamId];
        if (t < s.cliff) return 0;
        if (t >= s.end) return s.deposited;
        uint256 linearPortion = uint256(s.deposited) - s.cliffAmount;
        uint256 elapsed = uint256(t) - s.start;
        uint32 per = s.period;
        if (per > 1) elapsed = (elapsed / per) * per; // step down to tranche boundary
        uint256 linear = (linearPortion * elapsed) / (uint256(s.end) - s.start);
        return uint256(s.cliffAmount) + linear;
    }

    function withdrawable(uint256 streamId) public view returns (uint256) {
        Stream storage s = streams[streamId];
        if (s.canceled) return 0;
        uint256 vested = vestedAmount(streamId, uint40(block.timestamp));
        return vested - s.withdrawn;
    }

    function withdraw(uint256 streamId) external nonReentrant {
        Stream storage s = streams[streamId];
        if (msg.sender != s.recipient) revert NotRecipient();
        uint256 amount = withdrawable(streamId);
        if (amount == 0) revert NothingToWithdraw();
        s.withdrawn += uint128(amount);
        IERC20(s.token).safeTransfer(s.recipient, amount);
        emit Withdrawn(streamId, s.recipient, amount);
    }

    /// @notice Cancel a stream: vested-but-unwithdrawn goes to the recipient,
    ///         the unvested remainder returns to the sender.
    function cancel(uint256 streamId) external nonReentrant {
        Stream storage s = streams[streamId];
        if (msg.sender != s.sender) revert NotSender();
        if (!s.cancelable) revert NotCancelable();
        if (s.canceled) revert AlreadyCanceled();

        uint256 vested = vestedAmount(streamId, uint40(block.timestamp));
        uint256 recipientAmount = vested - s.withdrawn;
        uint256 senderAmount = s.deposited - vested;

        s.canceled = true;
        s.withdrawn = uint128(vested);

        if (recipientAmount > 0) IERC20(s.token).safeTransfer(s.recipient, recipientAmount);
        if (senderAmount > 0) IERC20(s.token).safeTransfer(s.sender, senderAmount);
        emit Canceled(streamId, recipientAmount, senderAmount);
    }

    function transferRecipient(uint256 streamId, address newRecipient) external {
        Stream storage s = streams[streamId];
        if (msg.sender != s.recipient) revert NotRecipient();
        if (!s.transferable) revert NotTransferable();
        if (newRecipient == address(0)) revert InvalidParams();
        address old = s.recipient;
        s.recipient = newRecipient;
        _incoming[newRecipient].push(streamId);
        emit RecipientTransferred(streamId, old, newRecipient);
    }

    function outgoingStreams(address sender) external view returns (uint256[] memory) {
        return _outgoing[sender];
    }

    function incomingStreams(address recipient) external view returns (uint256[] memory) {
        return _incoming[recipient];
    }

    function getStream(uint256 streamId) external view returns (Stream memory) {
        return streams[streamId];
    }
}
Chain explorer996msChain node76ms