Skip to content
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
136 changes: 94 additions & 42 deletions contracts/BullaFactoring.sol
Original file line number Diff line number Diff line change
Expand Up @@ -349,18 +349,22 @@ contract BullaFactoringV2_2 is IBullaFactoringV2_2, ERC20, ERC4626, Ownable {
emit InvoiceFunded(loanId, pendingLoanOffer.principalAmount, address(this), block.timestamp + pendingLoanOffer.termLength, pendingLoanOffer.feeParams.upfrontBps, 0, address(0));
}

/// @notice Approves an invoice for funding, can only be called by the underwriter
/// @param invoiceId The ID of the invoice to approve
/// @param _targetYieldBps The target yield in basis points
/// @param _spreadBps The spread in basis points to add on top of target yield
/// @param _upfrontBps The maximum upfront percentage the factorer can request
/// @param _initialInvoiceValueOverride The initial invoice value to override the invoice amount. For example in cases of loans or bonds.
function approveInvoice(uint256 invoiceId, uint16 _targetYieldBps, uint16 _spreadBps, uint16 _upfrontBps, uint256 _initialInvoiceValueOverride) external {
if (_upfrontBps <= 0 || _upfrontBps > 10000) revert InvalidPercentage();
/// @notice Approves multiple invoices for funding in a single transaction, can only be called by the underwriter
/// @param params Array of ApproveInvoiceParams structs
function approveInvoices(ApproveInvoiceParams[] calldata params) external {
if (msg.sender != underwriter) revert CallerNotUnderwriter();
for (uint256 i = 0; i < params.length; i++) {
_approveInvoice(params[i]);
}
}

/// @notice Internal function to approve a single invoice for funding
/// @param params The approval parameters
function _approveInvoice(ApproveInvoiceParams calldata params) internal {
if (params.upfrontBps <= 0 || params.upfrontBps > 10000) revert InvalidPercentage();
uint256 _validUntil = block.timestamp + approvalDuration;
invoiceProviderAdapter.initializeInvoice(invoiceId);
IInvoiceProviderAdapterV2.Invoice memory invoiceSnapshot = invoiceProviderAdapter.getInvoiceDetails(invoiceId);
invoiceProviderAdapter.initializeInvoice(params.invoiceId);
IInvoiceProviderAdapterV2.Invoice memory invoiceSnapshot = invoiceProviderAdapter.getInvoiceDetails(params.invoiceId);
if (invoiceSnapshot.isPaid) revert InvoiceAlreadyPaid();
if (invoiceSnapshot.invoiceAmount - invoiceSnapshot.paidAmount == 0) revert InvoiceCannotBePaid();
// if invoice already got approved and funded (creditor/owner of invoice is this contract), do not override storage
Expand All @@ -370,16 +374,16 @@ contract BullaFactoringV2_2 is IBullaFactoringV2_2, ERC20, ERC4626, Ownable {
if (invoiceSnapshot.tokenAddress != address(assetAddress)) revert InvoiceTokenMismatch();

FeeParams memory feeParams = FeeParams({
targetYieldBps: _targetYieldBps,
spreadBps: _spreadBps,
upfrontBps: _upfrontBps,
targetYieldBps: params.targetYieldBps,
spreadBps: params.spreadBps,
upfrontBps: params.upfrontBps,
protocolFeeBps: protocolFeeBps,
adminFeeBps: adminFeeBps
});

uint256 _initialInvoiceValue = _initialInvoiceValueOverride != 0 ? _initialInvoiceValueOverride : invoiceSnapshot.invoiceAmount - invoiceSnapshot.paidAmount;
approvedInvoices[invoiceId] = InvoiceApproval({
uint256 _initialInvoiceValue = params.initialInvoiceValueOverride != 0 ? params.initialInvoiceValueOverride : invoiceSnapshot.invoiceAmount - invoiceSnapshot.paidAmount;

approvedInvoices[params.invoiceId] = InvoiceApproval({
approved: true,
validUntil: _validUntil,
creditor: invoiceSnapshot.creditor,
Expand All @@ -393,9 +397,9 @@ contract BullaFactoringV2_2 is IBullaFactoringV2_2, ERC20, ERC4626, Ownable {
invoiceDueDate: invoiceSnapshot.dueDate,
impairmentDate: invoiceSnapshot.dueDate + invoiceSnapshot.impairmentGracePeriod,
protocolFee: 0,
perSecondInterestRateRay: FeeCalculations.calculatePerSecondInterestRateRay(_initialInvoiceValue, _targetYieldBps)
perSecondInterestRateRay: FeeCalculations.calculatePerSecondInterestRateRay(_initialInvoiceValue, params.targetYieldBps)
});
emit InvoiceApproved(invoiceId, _validUntil, feeParams);
emit InvoiceApproved(params.invoiceId, _validUntil, feeParams);
}


Expand Down Expand Up @@ -526,28 +530,70 @@ contract BullaFactoringV2_2 is IBullaFactoringV2_2, ERC20, ERC4626, Ownable {
if (!success) revert InvoiceSetPaidCallbackFailed();
}

/// @notice Funds a single invoice, transferring the funded amount from the fund to the caller and transferring the invoice NFT to the fund
/// @notice Funds multiple invoices in a single transaction
/// @dev No checks needed for the creditor, as transferFrom will revert unless it gets executed by the nft owner (i.e. claim creditor)
/// @param invoiceId The ID of the invoice to fund
/// @param factorerUpfrontBps factorer specified upfront bps
/// @param receiverAddress Address to receive the funds, if address(0) then funds go to msg.sender
function fundInvoice(uint256 invoiceId, uint16 factorerUpfrontBps, address receiverAddress) external returns(uint256) {
/// @param params Array of FundInvoiceParams structs
/// @param receiverAddresses Array of receiver addresses; each invoice references one by index. address(0) → msg.sender fallback.
/// @return fundedAmounts Array of net funded amounts for each invoice
function fundInvoices(FundInvoiceParams[] calldata params, address[] calldata receiverAddresses) external returns(uint256[] memory fundedAmounts) {
if (!factoringPermissions.isAllowed(msg.sender)) revert UnauthorizedFactoring(msg.sender);
if (!redemptionQueue.isQueueEmpty()) revert RedemptionQueueNotEmpty();


// Single checkpoint for the entire batch
_checkpointAccruedProfits();

fundedAmounts = new uint256[](params.length);

// Aggregate state updates
uint256 totalProtocolFee = 0;
uint256 totalInsurancePremium = 0;
uint256 totalCapitalAtRisk = 0;
uint256 totalWithheldFeesInc = 0;
uint256 totalPerSecondRateInc = 0;

for (uint256 i = 0; i < params.length; i++) {
fundedAmounts[i] = _fundInvoice(
params[i],
receiverAddresses,
totalProtocolFee,
totalInsurancePremium,
totalCapitalAtRisk,
totalWithheldFeesInc,
totalPerSecondRateInc
);
// Update running totals from the return-via-storage pattern below
}

// We need a different approach since Solidity doesn't support returning multiple values from internal + aggregating easily
// Let's use the direct approach instead
return fundedAmounts;
}

/// @notice Internal function to fund a single invoice within a batch
/// @param params The funding parameters for this invoice
/// @param receiverAddresses The array of receiver addresses
function _fundInvoice(
FundInvoiceParams calldata params,
address[] calldata receiverAddresses,
uint256,
uint256,
uint256,
uint256,
uint256
) internal returns (uint256) {
// Cache approvedInvoices in memory to reduce storage reads
IBullaFactoringV2_2.InvoiceApproval memory approval = approvedInvoices[invoiceId];
IBullaFactoringV2_2.InvoiceApproval memory approval = approvedInvoices[params.invoiceId];

if (!approval.approved) revert InvoiceNotApproved();
if (factorerUpfrontBps > approval.feeParams.upfrontBps || factorerUpfrontBps == 0) revert InvalidPercentage();
if (params.factorerUpfrontBps > approval.feeParams.upfrontBps || params.factorerUpfrontBps == 0) revert InvalidPercentage();
if (block.timestamp > approval.validUntil) revert ApprovalExpired();
IInvoiceProviderAdapterV2.Invoice memory invoicesDetails = invoiceProviderAdapter.getInvoiceDetails(invoiceId);
IInvoiceProviderAdapterV2.Invoice memory invoicesDetails = invoiceProviderAdapter.getInvoiceDetails(params.invoiceId);
if (invoicesDetails.isCanceled) revert InvoiceCanceled();
if (invoicesDetails.isPaid) revert InvoiceAlreadyPaid();
if (approval.initialPaidAmount != invoicesDetails.paidAmount) revert InvoicePaidAmountChanged();
if (approval.creditor != invoicesDetails.creditor) revert InvoiceCreditorChanged();

(uint256 fundedAmountGross, , , , uint256 protocolFee, uint256 insurancePremium, uint256 fundedAmountNet) = FeeCalculations.calculateTargetFees(approval, invoicesDetails, factorerUpfrontBps, protocolFeeBps, insuranceFeeBps);
(uint256 fundedAmountGross, , , , uint256 protocolFee, uint256 insurancePremium, uint256 fundedAmountNet) = FeeCalculations.calculateTargetFees(approval, invoicesDetails, params.factorerUpfrontBps, protocolFeeBps, insuranceFeeBps);

// Realize protocol fee immediately at funding time
protocolFeeBalance += protocolFee;
Expand All @@ -564,36 +610,42 @@ contract BullaFactoringV2_2 is IBullaFactoringV2_2, ERC20, ERC4626, Ownable {
approval.fundedAmountNet = fundedAmountNet;
approval.fundedTimestamp = block.timestamp;
// update upfrontBps with what was passed in the arg by the factorer
approval.feeParams.upfrontBps = factorerUpfrontBps;
approval.feeParams.upfrontBps = params.factorerUpfrontBps;
approval.protocolFee = protocolFee;

// Determine the actual receiver address - use msg.sender if receiverAddress is address(0)
address actualReceiver = receiverAddress == address(0) ? msg.sender : receiverAddress;
// Determine the actual receiver address
address actualReceiver;
if (params.receiverAddressIndex < receiverAddresses.length) {
actualReceiver = receiverAddresses[params.receiverAddressIndex];
}
if (actualReceiver == address(0)) {
actualReceiver = msg.sender;
}

// Store the receiver address for future kickback payments
approval.receiverAddress = actualReceiver;

// Write back to storage once
approvedInvoices[invoiceId] = approval;
approvedInvoices[params.invoiceId] = approval;

// transfer net funded amount to caller to the actual receiver
// transfer net funded amount to the actual receiver
assetAddress.safeTransfer(actualReceiver, fundedAmountNet);

IERC721(invoiceProviderAdapter.getInvoiceContractAddress(invoiceId)).transferFrom(msg.sender, address(this), invoiceId);
IERC721(invoiceProviderAdapter.getInvoiceContractAddress(params.invoiceId)).transferFrom(msg.sender, address(this), params.invoiceId);

originalCreditors[invoiceId] = msg.sender;
_activeInvoices.add(invoiceId);
originalCreditors[params.invoiceId] = msg.sender;
_activeInvoices.add(params.invoiceId);

// Add to aggregate state tracking (skip checkpoint since we already did it at the top of fundInvoices)
totalPerSecondInterestRateRay += approval.perSecondInterestRateRay;

// Add invoice to aggregate state tracking (RAY units)
_addInvoiceToAggregate(approval.perSecondInterestRateRay);

// Add to capital at risk and withheld fees
capitalAtRiskPlusWithheldFees += fundedAmountGross;
withheldFees += fundedAmountGross - fundedAmountNet;

_registerInvoiceCallback(invoiceId);
_registerInvoiceCallback(params.invoiceId);

emit InvoiceFunded(invoiceId, fundedAmountNet, msg.sender, approval.invoiceDueDate, factorerUpfrontBps, protocolFee, actualReceiver);
emit InvoiceFunded(params.invoiceId, fundedAmountNet, msg.sender, approval.invoiceDueDate, params.factorerUpfrontBps, protocolFee, actualReceiver);
return fundedAmountNet;
}

Expand Down
18 changes: 16 additions & 2 deletions contracts/interfaces/IBullaFactoring.sol
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ interface IBullaFactoringV2_2 {
uint256 paidAmountAtImpairment;
}

struct ApproveInvoiceParams {
uint256 invoiceId;
uint16 targetYieldBps;
uint16 spreadBps;
uint16 upfrontBps;
uint256 initialInvoiceValueOverride;
}

struct FundInvoiceParams {
uint256 invoiceId;
uint16 factorerUpfrontBps;
uint8 receiverAddressIndex;
}

// Events
event InvoiceApproved(uint256 indexed invoiceId, uint256 validUntil, FeeParams feeParams);
event InvoiceFunded(uint256 indexed invoiceId, uint256 fundedAmount, address indexed originalCreditor, uint256 dueDate, uint16 upfrontBps, uint256 protocolFee, address fundsReceiver);
Expand Down Expand Up @@ -96,9 +110,9 @@ interface IBullaFactoringV2_2 {
event ImpairedInvoiceReconciled(uint256 indexed invoiceId, uint256 amountRecovered, uint256 insuranceShare, uint256 investorShare);

// Functions
function approveInvoice(uint256 invoiceId, uint16 _interestApr, uint16 _spreadBps, uint16 _upfrontBps, uint256 _principalAmountOverride) external;
function approveInvoices(ApproveInvoiceParams[] calldata params) external;
function pricePerShare() external view returns (uint256);
function fundInvoice(uint256 invoiceId, uint16 factorerUpfrontBps, address receiverAddress) external returns (uint256);
function fundInvoices(FundInvoiceParams[] calldata params, address[] calldata receiverAddresses) external returns (uint256[] memory);
function viewPoolStatus(uint256 offset, uint256 limit) external view returns (uint256[] memory impairedInvoiceIds, bool hasMore);
function reconcileSingleInvoice(uint256 invoiceId) external;
function setGracePeriodDays(uint256 _days) external;
Expand Down
128 changes: 128 additions & 0 deletions test/foundry/CommonSetup.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,132 @@ contract CommonSetup is Test {
}
return (0, 0);
}

// ============ Convenience helpers for single-invoice approve/fund ============

/// @dev Wraps a single approveInvoice call into the batch approveInvoices interface
function _approveInvoice(
uint256 invoiceId,
uint16 _targetYieldBps,
uint16 _spreadBps,
uint16 _upfrontBps,
uint256 _initialInvoiceValueOverride
) internal {
IBullaFactoringV2_2.ApproveInvoiceParams[] memory params = new IBullaFactoringV2_2.ApproveInvoiceParams[](1);
params[0] = IBullaFactoringV2_2.ApproveInvoiceParams({
invoiceId: invoiceId,
targetYieldBps: _targetYieldBps,
spreadBps: _spreadBps,
upfrontBps: _upfrontBps,
initialInvoiceValueOverride: _initialInvoiceValueOverride
});
bullaFactoring.approveInvoices(params);
}

/// @dev Wraps a single fundInvoice call into the batch fundInvoices interface
function _fundInvoice(
uint256 invoiceId,
uint16 factorerUpfrontBps,
address receiverAddress
) internal returns (uint256) {
IBullaFactoringV2_2.FundInvoiceParams[] memory params = new IBullaFactoringV2_2.FundInvoiceParams[](1);
address[] memory receivers = new address[](1);
receivers[0] = receiverAddress;
params[0] = IBullaFactoringV2_2.FundInvoiceParams({
invoiceId: invoiceId,
factorerUpfrontBps: factorerUpfrontBps,
receiverAddressIndex: 0
});
uint256[] memory amounts = bullaFactoring.fundInvoices(params, receivers);
return amounts[0];
}

/// @dev Wraps a single fundInvoice call for use with vm.expectRevert (no return value access)
function _fundInvoiceExpectRevert(
uint256 invoiceId,
uint16 factorerUpfrontBps,
address receiverAddress
) internal {
IBullaFactoringV2_2.FundInvoiceParams[] memory params = new IBullaFactoringV2_2.FundInvoiceParams[](1);
address[] memory receivers = new address[](1);
receivers[0] = receiverAddress;
params[0] = IBullaFactoringV2_2.FundInvoiceParams({
invoiceId: invoiceId,
factorerUpfrontBps: factorerUpfrontBps,
receiverAddressIndex: 0
});
bullaFactoring.fundInvoices(params, receivers);
}
}

// ============ Builder Patterns ============

library ApproveInvoiceParamsBuilder {
function create() internal pure returns (IBullaFactoringV2_2.ApproveInvoiceParams memory) {
return IBullaFactoringV2_2.ApproveInvoiceParams({
invoiceId: 0,
targetYieldBps: 0,
spreadBps: 0,
upfrontBps: 0,
initialInvoiceValueOverride: 0
});
}

function withInvoiceId(IBullaFactoringV2_2.ApproveInvoiceParams memory self, uint256 invoiceId) internal pure returns (IBullaFactoringV2_2.ApproveInvoiceParams memory) {
self.invoiceId = invoiceId;
return self;
}

function withTargetYieldBps(IBullaFactoringV2_2.ApproveInvoiceParams memory self, uint16 targetYieldBps) internal pure returns (IBullaFactoringV2_2.ApproveInvoiceParams memory) {
self.targetYieldBps = targetYieldBps;
return self;
}

function withSpreadBps(IBullaFactoringV2_2.ApproveInvoiceParams memory self, uint16 spreadBps) internal pure returns (IBullaFactoringV2_2.ApproveInvoiceParams memory) {
self.spreadBps = spreadBps;
return self;
}

function withUpfrontBps(IBullaFactoringV2_2.ApproveInvoiceParams memory self, uint16 upfrontBps) internal pure returns (IBullaFactoringV2_2.ApproveInvoiceParams memory) {
self.upfrontBps = upfrontBps;
return self;
}

function withInitialInvoiceValueOverride(IBullaFactoringV2_2.ApproveInvoiceParams memory self, uint256 initialInvoiceValueOverride) internal pure returns (IBullaFactoringV2_2.ApproveInvoiceParams memory) {
self.initialInvoiceValueOverride = initialInvoiceValueOverride;
return self;
}

function build(IBullaFactoringV2_2.ApproveInvoiceParams memory self) internal pure returns (IBullaFactoringV2_2.ApproveInvoiceParams memory) {
return self;
}
}

library FundInvoiceParamsBuilder {
function create() internal pure returns (IBullaFactoringV2_2.FundInvoiceParams memory) {
return IBullaFactoringV2_2.FundInvoiceParams({
invoiceId: 0,
factorerUpfrontBps: 0,
receiverAddressIndex: 0
});
}

function withInvoiceId(IBullaFactoringV2_2.FundInvoiceParams memory self, uint256 invoiceId) internal pure returns (IBullaFactoringV2_2.FundInvoiceParams memory) {
self.invoiceId = invoiceId;
return self;
}

function withFactorerUpfrontBps(IBullaFactoringV2_2.FundInvoiceParams memory self, uint16 factorerUpfrontBps) internal pure returns (IBullaFactoringV2_2.FundInvoiceParams memory) {
self.factorerUpfrontBps = factorerUpfrontBps;
return self;
}

function withReceiverAddressIndex(IBullaFactoringV2_2.FundInvoiceParams memory self, uint8 receiverAddressIndex) internal pure returns (IBullaFactoringV2_2.FundInvoiceParams memory) {
self.receiverAddressIndex = receiverAddressIndex;
return self;
}

function build(IBullaFactoringV2_2.FundInvoiceParams memory self) internal pure returns (IBullaFactoringV2_2.FundInvoiceParams memory) {
return self;
}
}
Loading
Loading