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 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
6 changes: 4 additions & 2 deletions script/DeployDeterministicInvoiceModule.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ contract DeployDeterministicInvoiceModule is BaseScript {
string memory create2Salt,
ISablierV2LockupLinear sablierLockupLinear,
ISablierV2LockupTranched sablierLockupTranched,
address brokerAdmin
address brokerAdmin,
string memory baseURI
) public virtual broadcast returns (InvoiceModule invoiceModule) {
bytes32 salt = bytes32(abi.encodePacked(create2Salt));

// Deterministically deploy the {InvoiceModule} contracts
invoiceModule = new InvoiceModule{ salt: salt }(sablierLockupLinear, sablierLockupTranched, brokerAdmin);
invoiceModule =
new InvoiceModule{ salt: salt }(sablierLockupLinear, sablierLockupTranched, brokerAdmin, baseURI);
}
}
128 changes: 91 additions & 37 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,23 @@ 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;

/// @dev Base URI used to get the ERC-721 `tokenURI` metadata JSON schema
string private _collectionURI;

/*//////////////////////////////////////////////////////////////////////////
CONSTRUCTOR
//////////////////////////////////////////////////////////////////////////*/
Expand All @@ -39,9 +42,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 +86,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 +143,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 +163,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,10 +185,9 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
// Load the invoice from storage
Types.Invoice memory invoice = _invoices[id];

// Checks: the invoice is not null
if (invoice.recipient == address(0)) {
revert Errors.InvoiceNull();
}
// Retrieve the recipient of the invoice
// This will also check if the invoice is minted or not burned
address recipient = ownerOf(id);

// Checks: the invoice is not already paid or canceled
if (invoice.status == Types.Status.Paid) {
Expand All @@ -190,14 +199,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,25 +231,27 @@ 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
// - A linear or tranched stream MUST be canceled by calling the `cancel` method on the according
// {ISablierV2Lockup} contract
else if (invoice.status == Types.Status.Ongoing) {
cancelStream({ streamType: invoice.payment.method, streamId: invoice.payment.streamId });
_cancelStream({ streamType: invoice.payment.method, streamId: invoice.payment.streamId });
}

// Effects: mark the invoice as canceled
Expand All @@ -251,10 +262,13 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
}

/// @inheritdoc IInvoiceModule
function withdrawInvoiceStream(uint256 id) external {
function withdrawInvoiceStream(uint256 id) public returns (uint128 withdrawnAmount) {
// 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 +277,47 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
}

// Check, Effects, Interactions: withdraw from the stream
withdrawStream({ streamType: invoice.payment.method, streamId: invoice.payment.streamId, to: invoice.recipient });
return
_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 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
_withdrawMaxAndTransferStream({
streamType: invoice.payment.method,
streamId: invoice.payment.streamId,
newRecipient: to
});
}

// Checks, Effects and Interactions: 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 +339,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 +402,9 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
revert Errors.PaymentIntervalTooShortForSelectedRecurrence();
}
}

/// @inheritdoc ERC721
function _baseURI() internal view override returns (string memory) {
return _collectionURI;
}
}
2 changes: 1 addition & 1 deletion src/modules/invoice-module/interfaces/IInvoiceModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,5 @@ interface IInvoiceModule {
/// - reverts if the payment method of the `id` invoice is not linear or tranched stream based
///
/// @param id The ID of the invoice
function withdrawInvoiceStream(uint256 id) external;
function withdrawInvoiceStream(uint256 id) external returns (uint128 withdrawnAmount);
}
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
Loading