Skip to content

Commit

Permalink
Merge pull request #6 from metadock/tests/pay-invoice
Browse files Browse the repository at this point in the history
Implement `payInvoice` integration tests
  • Loading branch information
gabrielstoica authored Jul 18, 2024
2 parents 5e8b5fe + f151583 commit d33df80
Show file tree
Hide file tree
Showing 15 changed files with 689 additions and 137 deletions.
73 changes: 65 additions & 8 deletions src/modules/invoice-module/InvoiceModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -98,21 +98,26 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
}
}

// Gets the number of payments for the invoice based on the payment method, interval and recurrence type
// Validates the invoice interval (endTime - startTime) and returns the number of payments of the invoice
// based on the payment method, interval and recurrence type
//
// Notes:
// - The number of payments is taken into account only for transfer-based invoices
// - There should be only one payment when dealing with a one-off transfer-based invoice
// - When dealing with a recurring transfer or tranched stream, the number of payments must be calculated based
// - When dealing with a recurring transfer, the number of payments must be calculated based
// on the payment interval (endTime - startTime) and recurrence type
uint40 numberOfPayments;
if (invoice.payment.method == Types.Method.Transfer && invoice.payment.recurrence == Types.Recurrence.OneOff) {
numberOfPayments = 1;
} else if (invoice.payment.method != Types.Method.LinearStream) {
numberOfPayments = _checkAndComputeNumberOfPayments({
numberOfPayments = _checkIntervalPayments({
recurrence: invoice.payment.recurrence,
startTime: invoice.startTime,
endTime: invoice.endTime
});

// Set the number of payments to zero if dealing with a tranched-based invoice
if (invoice.payment.method == Types.Method.TranchedStream) numberOfPayments = 0;
}

// Checks: the asset is different than the native token if dealing with either a linear or tranched stream-based invoice
Expand Down Expand Up @@ -166,6 +171,11 @@ 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();
}

// Checks: the invoice is not already paid or canceled
if (invoice.status == Types.Status.Paid) {
revert Errors.InvoiceAlreadyPaid();
Expand All @@ -178,14 +188,55 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
_payByTransfer(id, invoice);
} else {
uint256 streamId;
// Check to see wether to pay by creating a linear or tranched stream
// 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);
} else streamId = _payByTranchedStream(invoice);

// Effects: update the status of the invoice and stream ID
_invoices[id].status = Types.Status.Paid;
_invoices[id].payment.streamId = streamId;
}

// Log the payment transaction
emit InvoicePaid({ id: id, payer: msg.sender, status: invoice.status, payment: invoice.payment });
emit InvoicePaid({ id: id, payer: msg.sender, status: _invoices[id].status, payment: _invoices[id].payment });
}

/// @inheritdoc IInvoiceModule
function cancelInvoice(uint256 id) external {
// Load the invoice from storage
Types.Invoice memory invoice = _invoices[id];

// Checks: the invoice is paid or already canceled
if (invoice.status == Types.Status.Paid) {
revert Errors.CannotCancelPaidInvoice();
} else if (invoice.status == Types.Status.Canceled) {
revert Errors.CannotCancelCanceledInvoice();
}

// Checks: the `msg.sender` is the creator if dealing with a transfer-based invoice
//
// Notes:
// - for a linear or tranched stream-based invoice, the `msg.sender` is checked in the
// {SablierV2Lockup} `cancel` method
if (invoice.payment.method == Types.Method.Transfer) {
if (invoice.recipient != msg.sender) {
revert Errors.InvoiceOwnerUnauthorized();
}
}

// Effects: cancel the stream accordingly depending on its type
if (invoice.payment.method == Types.Method.LinearStream) {
cancelLinearStream({ streamId: invoice.payment.streamId });
} else if (invoice.payment.method == Types.Method.TranchedStream) {
cancelTranchedStream({ streamId: invoice.payment.streamId });
}

// Effects: mark the invoice as canceled
_invoices[id].status = Types.Status.Canceled;

// Log the invoice cancelation
emit InvoiceCanceled(id);
}

/*//////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -219,7 +270,8 @@ contract InvoiceModule is IInvoiceModule, StreamManager {
if (!success) revert Errors.NativeTokenPaymentFailed();
} else {
// Interactions: pay the recipient with the ERC-20 token
IERC20(invoice.payment.asset).safeTransfer({
IERC20(invoice.payment.asset).safeTransferFrom({
from: msg.sender,
to: address(invoice.recipient),
value: invoice.payment.amount
});
Expand All @@ -239,19 +291,24 @@ contract InvoiceModule is IInvoiceModule, StreamManager {

/// @dev Create the tranched stream payment
function _payByTranchedStream(Types.Invoice memory invoice) 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,
numberOfTranches: invoice.payment.paymentsLeft,
numberOfTranches: numberOfTranches,
recurrence: invoice.payment.recurrence
});
}

/// @notice Calculates the number of payments to be made for a recurring transfer and tranched stream-based invoice
/// @dev Reverts if the number of payments is zero, indicating that either the interval or recurrence type was set incorrectly
function _checkAndComputeNumberOfPayments(
function _checkIntervalPayments(
Types.Recurrence recurrence,
uint40 startTime,
uint40 endTime
Expand Down
21 changes: 19 additions & 2 deletions src/modules/invoice-module/interfaces/IInvoiceModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface IInvoiceModule {
EVENTS
//////////////////////////////////////////////////////////////////////////*/

/// @notice Emitted when a regular or recurring invoice is created
/// @notice Emitted when an invoice is created
/// @param id The ID of the invoice
/// @param recipient The address receiving the payment
/// @param status The status of the invoice
Expand All @@ -26,13 +26,17 @@ interface IInvoiceModule {
Types.Payment payment
);

/// @notice Emitted when a regular or recurring invoice is paid
/// @notice Emitted when an invoice is paid
/// @param id The ID of the invoice
/// @param payer The address of the payer
/// @param status The status of the invoice
/// @param payment Struct representing the payment details associated with the invoice
event InvoicePaid(uint256 indexed id, address indexed payer, Types.Status status, Types.Payment payment);

/// @notice Emitted when an invoice is canceled
/// @param id The ID of the invoice
event InvoiceCanceled(uint256 indexed id);

/*//////////////////////////////////////////////////////////////////////////
CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -64,4 +68,17 @@ interface IInvoiceModule {
///
/// @param id The ID of the invoice to pay
function payInvoice(uint256 id) external payable;

/// @notice Cancels the `id` invoice
///
/// Notes:
/// - if the invoice has a linear or tranched stream payment method, the streaming flow will be
/// stopped and the remaining funds will be refunded to the stream payer
///
/// Important:
/// - if the invoice has a linear or tranched stream payment method, the portion that has already
/// been streamed is NOT automatically transferred
///
/// @param id The ID of the invoice
function cancelInvoice(uint256 id) external;
}
12 changes: 12 additions & 0 deletions src/modules/invoice-module/libraries/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,25 @@ library Errors {
/// @notice Thrown when a payer attempts to pay a canceled invoice
error InvoiceCanceled();

/// @notice Thrown when the invoice ID references a null invoice
error InvoiceNull();

/// @notice Thrown when `msg.sender` is not the creator (recipient) of the invoice
error InvoiceOwnerUnauthorized();

/// @notice Thrown when the payment interval (endTime - startTime) is too short for the selected recurrence
/// i.e. recurrence is set to weekly but interval is shorter than 1 week
error PaymentIntervalTooShortForSelectedRecurrence();

/// @notice Thrown when a tranched stream has a one-off recurrence type
error TranchedStreamInvalidOneOffRecurence();

/// @notice Thrown when an attempt is made to cancel an already paid invoice
error CannotCancelPaidInvoice();

/// @notice Thrown when an attempt is made to cancel an already canceled invoice
error CannotCancelCanceledInvoice();

/*//////////////////////////////////////////////////////////////////////////
STREAM-MANAGER
//////////////////////////////////////////////////////////////////////////*/
Expand Down
80 changes: 34 additions & 46 deletions src/modules/invoice-module/sablier-v2/StreamManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ISablierV2Lockup } from "@sablier/v2-core/src/interfaces/ISablierV2Lock
import { LockupLinear, LockupTranched } from "@sablier/v2-core/src/types/DataTypes.sol";
import { Broker, LockupLinear } from "@sablier/v2-core/src/types/DataTypes.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { ud60x18, UD60x18 } from "@prb/math/src/UD60x18.sol";

import { IStreamManager } from "./interfaces/IStreamManager.sol";
Expand All @@ -16,6 +17,8 @@ import { Types } from "./../libraries/Types.sol";
/// @title StreamManager
/// @dev See the documentation in {IStreamManager}
contract StreamManager is IStreamManager {
using SafeERC20 for IERC20;

/*//////////////////////////////////////////////////////////////////////////
PUBLIC STORAGE
//////////////////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -71,7 +74,7 @@ contract StreamManager is IStreamManager {
address recipient
) public returns (uint256 streamId) {
// Transfer the provided amount of ERC-20 tokens to this contract and approve the Sablier contract to spend it
_transferFromAndApprove({ asset: asset, spender: address(LOCKUP_LINEAR), amount: totalAmount });
_transferFromAndApprove({ asset: asset, amount: totalAmount, spender: address(LOCKUP_LINEAR) });

// Create the Lockup Linear stream
streamId = _createLinearStream(asset, totalAmount, startTime, endTime, recipient);
Expand All @@ -87,14 +90,14 @@ contract StreamManager is IStreamManager {
Types.Recurrence recurrence
) public returns (uint256 streamId) {
// Transfer the provided amount of ERC-20 tokens to this contract and approve the Sablier contract to spend it
_transferFromAndApprove({ asset: asset, spender: address(LOCKUP_TRANCHED), amount: totalAmount });
_transferFromAndApprove({ asset: asset, amount: totalAmount, spender: address(LOCKUP_TRANCHED) });

// Create the Lockup Linear stream
streamId = _createTranchedStream(asset, totalAmount, startTime, recipient, numberOfTranches, recurrence);
}

/// @inheritdoc IStreamManager
function updateBrokerFee(UD60x18 newBrokerFee) public onlyBrokerAdmin {
function updateStreamBrokerFee(UD60x18 newBrokerFee) public onlyBrokerAdmin {
// Log the broker fee update
emit BrokerFeeUpdated({ oldFee: brokerFee, newFee: newBrokerFee });

Expand All @@ -107,70 +110,41 @@ contract StreamManager is IStreamManager {
//////////////////////////////////////////////////////////////////////////*/

/// @inheritdoc IStreamManager
function withdraw(ISablierV2Lockup sablier, uint256 streamId, address to, uint128 amount) external {
sablier.withdraw(streamId, to, amount);
}

/// @inheritdoc IStreamManager
function withdrawableAmountOf(
ISablierV2Lockup sablier,
uint256 streamId
) external view returns (uint128 withdrawableAmount) {
withdrawableAmount = sablier.withdrawableAmountOf(streamId);
function withdrawLinearStream(uint256 streamId, address to, uint128 amount) public {
_withdrawStream({ sablier: LOCKUP_LINEAR, streamId: streamId, to: to, amount: amount });
}

/// @inheritdoc IStreamManager
function withdrawMax(
ISablierV2Lockup sablier,
uint256 streamId,
address to
) external returns (uint128 withdrawnAmount) {
withdrawnAmount = sablier.withdrawMax(streamId, to);
}

/// @inheritdoc IStreamManager
function withdrawMultiple(
ISablierV2Lockup sablier,
uint256[] calldata streamIds,
uint128[] calldata amounts
) external {
sablier.withdrawMultiple(streamIds, amounts);
function withdrawTranchedStream(uint256 streamId, address to, uint128 amount) public {
_withdrawStream({ sablier: LOCKUP_TRANCHED, streamId: streamId, to: to, amount: amount });
}

/*//////////////////////////////////////////////////////////////////////////
CANCEL FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/

/// @inheritdoc IStreamManager
function cancel(ISablierV2Lockup sablier, uint256 streamId) external {
sablier.cancel(streamId);
function cancelLinearStream(uint256 streamId) public {
_cancelStream({ sablier: LOCKUP_LINEAR, streamId: streamId });
}

/// @inheritdoc IStreamManager
function cancelMultiple(ISablierV2Lockup sablier, uint256[] calldata streamIds) external {
sablier.cancelMultiple(streamIds);
function cancelTranchedStream(uint256 streamId) public {
_cancelStream({ sablier: LOCKUP_TRANCHED, streamId: streamId });
}

/*//////////////////////////////////////////////////////////////////////////
RENOUNCE FUNCTIONS
CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/

/// @inheritdoc IStreamManager
function renounce(ISablierV2Lockup sablier, uint256 streamId) external {
sablier.renounce(streamId);
function getLinearStream(uint256 streamId) public view returns (LockupLinear.StreamLL memory stream) {
stream = LOCKUP_LINEAR.getStream(streamId);
}

/*//////////////////////////////////////////////////////////////////////////
TRANSFER FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/

/// @inheritdoc IStreamManager
function withdrawMaxAndTransfer(
ISablierV2Lockup sablier,
uint256 streamId,
address newRecipient
) external returns (uint128 withdrawnAmount) {
withdrawnAmount = sablier.withdrawMaxAndTransfer(streamId, newRecipient);
function getTranchedStream(uint256 streamId) public view returns (LockupTranched.StreamLT memory stream) {
stream = LOCKUP_TRANCHED.getStream(streamId);
}

/*//////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -225,6 +199,7 @@ contract StreamManager is IStreamManager {
params.asset = asset; // The streaming asset
params.cancelable = true; // Whether the stream will be cancelable or not
params.transferable = true; // Whether the stream will be transferable or not
params.startTime = startTime; // The timestamp when to start streaming

// Calculate the duration of each tranche based on the payment recurrence
uint40 durationPerTranche = _computeDurationPerTrache(recurrence);
Expand All @@ -251,14 +226,27 @@ contract StreamManager is IStreamManager {
streamId = LOCKUP_TRANCHED.createWithTimestamps(params);
}

/// @dev Withdraws from either a linear or tranched stream
function _withdrawStream(ISablierV2Lockup sablier, uint256 streamId, address to, uint128 amount) internal {
sablier.withdraw(streamId, to, amount);
}

/// @dev Cancels the `streamId` stream
function _cancelStream(ISablierV2Lockup sablier, uint256 streamId) internal {
sablier.cancel(streamId);
}

/// @dev Transfers the `amount` of `asset` tokens to this address (or the contract inherting from)
/// and approves either the `SablierV2LockupLinear` or `SablierV2LockupTranched` to spend the amount
function _transferFromAndApprove(IERC20 asset, uint128 amount, address spender) internal {
// Transfer the provided amount of ERC-20 tokens to this contract
asset.transferFrom(msg.sender, address(this), amount);
IERC20(asset).safeTransferFrom(msg.sender, address(this), amount);

// Approve the Sablier contract to spend the ERC-20 tokens
asset.approve(spender, amount);
}

/// @dev Calculates the duration of each tranches from a tranched stream based on a recurrence
function _computeDurationPerTrache(Types.Recurrence recurrence) internal pure returns (uint40 duration) {
if (recurrence == Types.Recurrence.Weekly) duration = 1 weeks;
else if (recurrence == Types.Recurrence.Monthly) duration = 4 weeks;
Expand Down
Loading

0 comments on commit d33df80

Please sign in to comment.