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

Represent invoices as ERC721 tokens #25

Merged
merged 11 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
128 changes: 96 additions & 32 deletions src/modules/invoice-module/InvoiceModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ pragma solidity ^0.8.26;

import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol";
import { ISablierV2LockupLinear } from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol";
import { ISablierV2LockupTranched } from "@sablier/v2-core/src/interfaces/ISablierV2LockupTranched.sol";

Expand All @@ -15,22 +17,22 @@ import { Helpers } from "./libraries/Helpers.sol";

/// @title InvoiceModule
/// @notice See the documentation in {IInvoiceModule}
contract InvoiceModule is IInvoiceModule, StreamManager {
contract InvoiceModule is IInvoiceModule, StreamManager, ERC721 {
using SafeERC20 for IERC20;
using Strings for uint256;

/*//////////////////////////////////////////////////////////////////////////
PRIVATE STORAGE
//////////////////////////////////////////////////////////////////////////*/

/// @dev Array with invoice IDs created through the `container` container contract
mapping(address container => uint256[]) private _invoicesOf;

/// @dev Invoice details mapped by the `id` invoice ID
mapping(uint256 id => Types.Invoice) private _invoices;

/// @dev Counter to keep track of the next ID used to create a new invoice
uint256 private _nextInvoiceId;

string private _collectionURI;

/*//////////////////////////////////////////////////////////////////////////
CONSTRUCTOR
//////////////////////////////////////////////////////////////////////////*/
Expand All @@ -39,9 +41,17 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
constructor(
ISablierV2LockupLinear _sablierLockupLinear,
ISablierV2LockupTranched _sablierLockupTranched,
address _brokerAdmin
) StreamManager(_sablierLockupLinear, _sablierLockupTranched, _brokerAdmin) {
address _brokerAdmin,
string memory _URI
)
StreamManager(_sablierLockupLinear, _sablierLockupTranched, _brokerAdmin)
ERC721("Metadock Invoice NFT", "MD-INVOICES")
{
// Start the invoice IDs from 1
_nextInvoiceId = 1;

// Set the ERC721 baseURI
_collectionURI = _URI;
}

/*//////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -75,7 +85,7 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
//////////////////////////////////////////////////////////////////////////*/

/// @inheritdoc IInvoiceModule
function createInvoice(Types.Invoice calldata invoice) external onlyContainer returns (uint256 id) {
function createInvoice(Types.Invoice calldata invoice) external onlyContainer returns (uint256 invoiceId) {
// Checks: the amount is non-zero
if (invoice.payment.amount == 0) {
revert Errors.ZeroPaymentAmount();
Expand Down Expand Up @@ -132,11 +142,10 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
}

// Get the next invoice ID
id = _nextInvoiceId;
invoiceId = _nextInvoiceId;

// Effects: create the invoice
_invoices[id] = Types.Invoice({
recipient: invoice.recipient,
_invoices[invoiceId] = Types.Invoice({
status: Types.Status.Pending,
startTime: invoice.startTime,
endTime: invoice.endTime,
Expand All @@ -153,16 +162,16 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
// Effects: increment the next invoice id
// Use unchecked because the invoice id cannot realistically overflow
unchecked {
_nextInvoiceId = id + 1;
++_nextInvoiceId;
}

// Effects: add the invoice on the list of invoices generated by the container
_invoicesOf[invoice.recipient].push(id);
// Effects: mint the invoice NFT to the recipient container
_mint({ to: msg.sender, tokenId: invoiceId });

// Log the invoice creation
emit InvoiceCreated({
id: id,
recipient: invoice.recipient,
id: invoiceId,
recipient: msg.sender,
status: Types.Status.Pending,
startTime: invoice.startTime,
endTime: invoice.endTime,
Expand All @@ -175,8 +184,11 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
// Load the invoice from storage
Types.Invoice memory invoice = _invoices[id];

// Retrieve the recipient of the invoice
address recipient = ownerOf(id);

// Checks: the invoice is not null
if (invoice.recipient == address(0)) {
if (recipient == address(0)) {
revert Errors.InvoiceNull();
}

Expand All @@ -190,14 +202,14 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
// Handle the payment workflow depending on the payment method type
if (invoice.payment.method == Types.Method.Transfer) {
// Effects: pay the invoice and update its status to `Paid` or `Ongoing` depending on the payment type
_payByTransfer(id, invoice);
_payByTransfer(id, invoice, recipient);
} else {
uint256 streamId;
// Check to see whether the invoice must be paid through a linear or tranched stream
if (invoice.payment.method == Types.Method.LinearStream) {
streamId = _payByLinearStream(invoice);
streamId = _payByLinearStream(invoice, recipient);
} else {
streamId = _payByTranchedStream(invoice);
streamId = _payByTranchedStream(invoice, recipient);
}

// Effects: update the status of the invoice to `Ongoing` and the stream ID
Expand All @@ -222,18 +234,20 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
revert Errors.InvoiceAlreadyCanceled();
}

// Checks: `msg.sender` is the recipient if dealing with a transfer-based invoice
// or a linear/tranched stream-based invoice which was not paid yet (not streaming)
// Checks: `msg.sender` is the recipient if invoice status is pending
//
// Notes:
// - Once a linear or tranched stream is created, the `msg.sender` is checked in the
// {SablierV2Lockup} `cancel` method
if (invoice.payment.method == Types.Method.Transfer || invoice.status == Types.Status.Pending) {
if (invoice.recipient != msg.sender) {
if (invoice.status == Types.Status.Pending) {
// Retrieve the recipient of the invoice
address recipient = ownerOf(id);

if (recipient != msg.sender) {
revert Errors.OnlyInvoiceRecipient();
}
}
// Effects: cancel the stream accordingly depending on its type
// Checks, Effects, Interactions: cancel the stream if status is ongoing
//
// Notes:
// - A transfer-based invoice can be canceled directly
Expand All @@ -255,6 +269,9 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
// Load the invoice from storage
Types.Invoice memory invoice = _invoices[id];

// Retrieve the recipient of the invoice
address recipient = ownerOf(id);

// Effects: update the invoice status to `Paid` once the full payment amount has been successfully streamed
uint128 streamedAmount =
streamedAmountOf({ streamType: invoice.payment.method, streamId: invoice.payment.streamId });
Expand All @@ -263,15 +280,54 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
}

// Check, Effects, Interactions: withdraw from the stream
withdrawStream({ streamType: invoice.payment.method, streamId: invoice.payment.streamId, to: invoice.recipient });
withdrawStream({ streamType: invoice.payment.method, streamId: invoice.payment.streamId, to: recipient });
}

/// @inheritdoc ERC721
function tokenURI(uint256 tokenId) public view override returns (string memory) {
// Checks: the `tokenId` was minted or is not burned
_requireOwned(tokenId);

// Create the `tokenURI` by concatenating the `baseURI`, `tokenId` and metadata extension (.json)
string memory baseURI = _baseURI();
return string.concat(baseURI, tokenId.toString(), ".json");
}

/// @inheritdoc ERC721
function transferFrom(address from, address to, uint256 tokenId) public override {
// Retrieve the current recipient of the invoice
address currentRecipient = ownerOf(tokenId);

// Checks: the invoice is not null
if (currentRecipient == address(0)) {
revert Errors.InvoiceNull();
}

// Retrieve the invoice details
Types.Invoice memory invoice = _invoices[tokenId];

// Checks: the payment request has been accepted and a stream has already been
// created if dealing with a stream-based payment
if (invoice.payment.streamId != 0) {
// Checks and Effects: withdraw the maximum withdrawable amount to the current stream recipient
// and transfer the stream NFT to the new recipient
withdrawMaxAndTransfer({
streamType: invoice.payment.method,
streamId: invoice.payment.streamId,
newRecipient: to
});
}

// Checks and Effects: transfer the invoice NFT
super.transferFrom(from, to, tokenId);
}

/*//////////////////////////////////////////////////////////////////////////
INTERNAL-METHODS
//////////////////////////////////////////////////////////////////////////*/

/// @dev Pays the `id` invoice by transfer
function _payByTransfer(uint256 id, Types.Invoice memory invoice) internal {
function _payByTransfer(uint256 id, Types.Invoice memory invoice, address recipient) internal {
// Effects: update the invoice status to `Paid` if the required number of payments has been made
// Using unchecked because the number of payments left cannot underflow as the invoice status
// will be updated to `Paid` once `paymentLeft` is zero
Expand All @@ -293,39 +349,42 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
}

// Interactions: pay the recipient with native token (ETH)
(bool success,) = payable(invoice.recipient).call{ value: invoice.payment.amount }("");
(bool success,) = payable(recipient).call{ value: invoice.payment.amount }("");
if (!success) revert Errors.NativeTokenPaymentFailed();
} else {
// Interactions: pay the recipient with the ERC-20 token
IERC20(invoice.payment.asset).safeTransferFrom({
from: msg.sender,
to: address(invoice.recipient),
to: recipient,
value: invoice.payment.amount
});
}
}

/// @dev Create the linear stream payment
function _payByLinearStream(Types.Invoice memory invoice) internal returns (uint256 streamId) {
function _payByLinearStream(Types.Invoice memory invoice, address recipient) internal returns (uint256 streamId) {
streamId = StreamManager.createLinearStream({
asset: IERC20(invoice.payment.asset),
totalAmount: invoice.payment.amount,
startTime: invoice.startTime,
endTime: invoice.endTime,
recipient: invoice.recipient
recipient: recipient
});
}

/// @dev Create the tranched stream payment
function _payByTranchedStream(Types.Invoice memory invoice) internal returns (uint256 streamId) {
function _payByTranchedStream(
Types.Invoice memory invoice,
address recipient
) internal returns (uint256 streamId) {
uint40 numberOfTranches =
Helpers.computeNumberOfPayments(invoice.payment.recurrence, invoice.endTime - invoice.startTime);

streamId = StreamManager.createTranchedStream({
asset: IERC20(invoice.payment.asset),
totalAmount: invoice.payment.amount,
startTime: invoice.startTime,
recipient: invoice.recipient,
recipient: recipient,
numberOfTranches: numberOfTranches,
recurrence: invoice.payment.recurrence
});
Expand Down Expand Up @@ -353,4 +412,9 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
revert Errors.PaymentIntervalTooShortForSelectedRecurrence();
}
}

/// @inheritdoc ERC721
function _baseURI() internal view override returns (string memory) {
return _collectionURI;
}
}
1 change: 0 additions & 1 deletion src/modules/invoice-module/libraries/Types.sol
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ library Types {
/// @param payment The payment struct describing the invoice payment
struct Invoice {
// slot 0
address recipient;
Status status;
uint40 startTime;
uint40 endTime;
Expand Down
26 changes: 24 additions & 2 deletions src/modules/invoice-module/sablier-v2/StreamManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,20 @@ abstract contract StreamManager is IStreamManager {
ISablierV2Lockup sablier = _getISablierV2Lockup(streamType);

// Withdraw the maximum withdrawable amount
withdrawnAmount = _withdrawStream(sablier, streamId, to);
withdrawnAmount = _withdraw(sablier, streamId, to);
}

/// @inheritdoc IStreamManager
function withdrawMaxAndTransfer(
Types.Method streamType,
uint256 streamId,
address newRecipient
) public returns (uint128 withdrawnAmount) {
// Set the according {ISablierV2Lockup} based on the stream type
ISablierV2Lockup sablier = _getISablierV2Lockup(streamType);

// Withdraw the maximum withdrawable amount and transfer the stream to the `to` address
withdrawnAmount = _withdrawMaxAndTransfer(sablier, streamId, newRecipient);
}

/// @inheritdoc IStreamManager
Expand Down Expand Up @@ -268,14 +281,23 @@ abstract contract StreamManager is IStreamManager {
}

/// @dev Withdraws the maximum withdrawable amount from either a linear or tranched stream
function _withdrawStream(
function _withdraw(
ISablierV2Lockup sablier,
uint256 streamId,
address to
) internal returns (uint128 withdrawnAmount) {
return sablier.withdrawMax(streamId, to);
}

/// @dev Withdraws the maximum withdrawable amount and transfers the stream to the `newRecipient` address
function _withdrawMaxAndTransfer(
ISablierV2Lockup sablier,
uint256 streamId,
address newRecipient
) internal returns (uint128 withdrawnAmount) {
return sablier.withdrawMaxAndTransfer(streamId, newRecipient);
}

/// @dev Cancels the `streamId` stream
function _cancelStream(ISablierV2Lockup sablier, uint256 streamId) internal {
// Checks: the `msg.sender` is the initial stream creator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ interface IStreamManager {
address to
) external returns (uint128 withdrawnAmount);

/// @notice See the documentation in {ISablierV2Lockup-withdrawMaxAndTransfer}
/// Notes:
/// - `streamType` parameter has been added to withdraw from the according {ISablierV2Lockup} contract
function withdrawMaxAndTransfer(
Types.Method streamType,
uint256 streamId,
address to
) external returns (uint128 withdrawnAmount);

/// @notice See the documentation in {ISablierV2Lockup-withdrawableAmountOf}
/// Notes:
/// - `streamType` parameter has been added to retrieve from the according {ISablierV2Lockup} contract
Expand Down
Loading