Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:

env:
FOUNDRY_PROFILE: ci
BASE_RPC_URL: "https://mainnet.base.org"
BASE_RPC_URL: "https://base.publicnode.com"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something feels off about this. Are we getting rate limited on our own node for our CI? Can we confirm security is okay with this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah we were


jobs:
check:
Expand Down
80 changes: 42 additions & 38 deletions docs/Fees.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# Fee System

The Commerce Payments Protocol implements a fee system that provides flexibility while maintaining security through pre-defined constraints. Fees are specified in the initial `PaymentInfo` struct and validated during `charge()` and `capture()` operations.
The Commerce Payments Protocol implements a fee system that provides flexibility while maintaining security through pre-defined constraints. Fee bounds are specified in the initial `PaymentInfo` struct (via `minFeeBps`/`maxFeeBps`), while the concrete absolute `feeAmount` is supplied and validated during `charge()` and `capture()` operations.

## Overview

Fees in the protocol are calculated as **basis points** (bps), where 10,000 basis points = 100%. For example:
- 250 bps = 2.5%
- 1000 bps = 10.0%
The operator supplies the fee at capture/charge time as an **absolute `feeAmount` denominated in raw token units** — it is no longer expressed in basis points. The `PaymentInfo` struct still bounds the allowable fee using basis points (`minFeeBps`/`maxFeeBps`, where 10,000 basis points = 100%), and the protocol validates that the provided `feeAmount` falls within the bounds those rates imply for the given capture/charge `amount`:

The fee amount is calculated as: `feeAmount = totalAmount * feeBps / 10000`
```
minFee = amount * minFeeBps / 10000
maxFee = amount * maxFeeBps / 10000
require(minFee <= feeAmount <= maxFee)
```

For example, capturing `1000e6` USDC with `minFeeBps = maxFeeBps = 250` (2.5%) requires `feeAmount == 25e6` (25 USDC).

## Fee Parameters in `PaymentInfo`

Expand All @@ -26,11 +30,11 @@ struct PaymentInfo {

### Fee Rate Range (`minFeeBps` and `maxFeeBps`)

These parameters establish the allowed fee range for the payment:
These parameters bound the allowed absolute fee for the payment (applied to the capture/charge `amount`):

- **Fixed Rate**: When `minFeeBps == maxFeeBps`, the operator must use exactly that fee rate
- **Variable Rate**: When `minFeeBps < maxFeeBps`, the operator can choose any rate within the range
- **Zero Fees**: When both are 0, no fees can be charged
- **Fixed Rate**: When `minFeeBps == maxFeeBps`, the operator must supply exactly `amount * feeBps / 10000` token units
- **Variable Rate**: When `minFeeBps < maxFeeBps`, the operator can supply any `feeAmount` between the two implied bounds
- **Zero Fees**: When both are 0, the only valid `feeAmount` is 0

### Fee Receiver (`feeReceiver`)

Expand All @@ -43,17 +47,17 @@ Controls who can receive the fee portion:

During `charge()` and `capture()` operations, the protocol validates:

1. **Rate Range**: `minFeeBps ≤ feeBps ≤ maxFeeBps`
1. **Fee Bounds**: `amount * minFeeBps / 10000 ≤ feeAmountamount * maxFeeBps / 10000`
2. **Maximum Limit**: `maxFeeBps ≤ 10,000` (cannot exceed 100%)
3. **Range Validity**: `minFeeBps ≤ maxFeeBps`
4. **Zero Fee Receiver**: If `feeBps > 0`, then `feeReceiver` cannot be `address(0)`
4. **Zero Fee Receiver**: If `feeAmount > 0`, then `feeReceiver` cannot be `address(0)`
5. **Fixed Recipient**: If `PaymentInfo.feeReceiver != address(0)`, the provided `feeReceiver` must match exactly

## Fee Distribution

When fees are applied:

1. **Fee Calculation**: `feeAmount = amount * feeBps / 10000`
1. **Fee Amount**: The operator-supplied absolute `feeAmount` (already validated against the bounds above)
2. **Fee Transfer**: If `feeAmount > 0`, transfer to `feeReceiver`
3. **Remaining Transfer**: Transfer `amount - feeAmount` to the merchant (`receiver`)

Expand All @@ -70,10 +74,10 @@ PaymentInfo memory payment = PaymentInfo({
});
```

**Operator Options at Capture/Charge:**
- ✅ `feeBps: 250, feeReceiver: 0x123...456`
- ❌ `feeBps: 300, feeReceiver: 0x123...456` (exceeds max rate)
- ❌ `feeBps: 250, feeReceiver: 0x789...abc` (wrong recipient)
**Operator Options at Capture/Charge (for a `1000e6` capture ⇒ bounds are exactly `25e6`):**
- ✅ `feeAmount: 25e6, feeReceiver: 0x123...456`
- ❌ `feeAmount: 30e6, feeReceiver: 0x123...456` (exceeds max)
- ❌ `feeAmount: 25e6, feeReceiver: 0x789...abc` (wrong recipient)


### Example 2: Variable Fee Rate with Flexible Recipient
Expand All @@ -87,13 +91,13 @@ PaymentInfo memory payment = PaymentInfo({
});
```

**Operator Options at Capture/Charge:**
- ✅ `feeBps: 100, feeReceiver: 0x123...456` (minimum rate)
- ✅ `feeBps: 350, feeReceiver: 0x789...abc` (mid-range rate)
- ✅ `feeBps: 500, feeReceiver: 0xdef...123` (maximum rate)
- ❌ `feeBps: 50, feeReceiver: 0x123...456` (below minimum)
- ❌ `feeBps: 600, feeReceiver: 0x123...456` (exceeds maximum)
- ❌ `feeBps: 300, feeReceiver: address(0)` (zero fee receiver with non-zero fee)
**Operator Options at Capture/Charge (for a `1000e6` capture ⇒ bounds are `10e6`–`50e6`):**
- ✅ `feeAmount: 10e6, feeReceiver: 0x123...456` (minimum)
- ✅ `feeAmount: 35e6, feeReceiver: 0x789...abc` (mid-range)
- ✅ `feeAmount: 50e6, feeReceiver: 0xdef...123` (maximum)
- ❌ `feeAmount: 5e6, feeReceiver: 0x123...456` (below minimum)
- ❌ `feeAmount: 60e6, feeReceiver: 0x123...456` (exceeds maximum)
- ❌ `feeAmount: 30e6, feeReceiver: address(0)` (zero fee receiver with non-zero fee)

**Use Case**: Marketplace with tiered fee structure based on merchant volume

Expand All @@ -109,9 +113,9 @@ PaymentInfo memory payment = PaymentInfo({
```

**Operator Options at Capture/Charge:**
- ✅ `feeBps: 0, feeReceiver: address(0)`
- ✅ `feeBps: 0, feeReceiver: 0x123...456` (fee receiver ignored when fee is 0)
- ❌ `feeBps: 1, feeReceiver: 0x123...456` (any non-zero fee rejected)
- ✅ `feeAmount: 0, feeReceiver: address(0)`
- ✅ `feeAmount: 0, feeReceiver: 0x123...456` (fee receiver ignored when fee is 0)
- ❌ `feeAmount: 1, feeReceiver: 0x123...456` (any non-zero fee rejected)


### Example 4: Flexible Rate with Fixed Recipient
Expand All @@ -125,27 +129,27 @@ PaymentInfo memory payment = PaymentInfo({
});
```

**Operator Options at Capture/Charge:**
- ✅ `feeBps: 0, feeReceiver: address(0)` (no fee, receiver ignored)
- ✅ `feeBps: 250, feeReceiver: 0x123...456` (2.5% to fixed recipient)
- ✅ `feeBps: 1000, feeReceiver: 0x123...456` (maximum fee)
- ❌ `feeBps: 250, feeReceiver: 0x789...abc` (wrong recipient)
**Operator Options at Capture/Charge (for a `1000e6` capture ⇒ bounds are `0`–`100e6`):**
- ✅ `feeAmount: 0, feeReceiver: address(0)` (no fee, receiver ignored)
- ✅ `feeAmount: 25e6, feeReceiver: 0x123...456` (2.5% to fixed recipient)
- ✅ `feeAmount: 100e6, feeReceiver: 0x123...456` (maximum fee)
- ❌ `feeAmount: 25e6, feeReceiver: 0x789...abc` (wrong recipient)


## Multiple Captures with Different Fees

For partial captures, operators can use different fee rates within the allowed range:
For partial captures, operators can supply different fee amounts, each validated against the bounds implied by that capture's `amount`:

```solidity
// Initial authorization: 1000 USDC
// PaymentInfo: minFeeBps=200, maxFeeBps=400, feeReceiver=address(0)

// First capture: 600 USDC at 2% fee
capture(paymentInfo, 600e6, 200, feeRecipient1);
// First capture: 600 USDC, 12 USDC fee (2% of 600, within 12e6-24e6 bounds)
capture(paymentInfo, 600e6, 12e6, feeRecipient1);
// Fee: 12 USDC to feeRecipient1, 588 USDC to merchant

// Second capture: 400 USDC at 4% fee
capture(paymentInfo, 400e6, 400, feeRecipient2);
// Second capture: 400 USDC, 16 USDC fee (4% of 400, within 8e6-16e6 bounds)
capture(paymentInfo, 400e6, 16e6, feeRecipient2);
// Fee: 16 USDC to feeRecipient2, 384 USDC to merchant
```

Expand All @@ -157,8 +161,8 @@ The protocol will revert with specific errors for invalid fee configurations:
|-------|-----------|---------|
| `FeeBpsOverflow` | `maxFeeBps > 10000` | Setting 150% fee rate |
| `InvalidFeeBpsRange` | `minFeeBps > maxFeeBps` | min=500, max=200 |
| `FeeBpsOutOfRange` | Fee outside allowed range | 300 bps when range is 500-1000 |
| `ZeroFeeReceiver` | Non-zero fee with zero recipient | 250 bps fee, address(0) recipient |
| `FeeAmountOutOfRange` | `feeAmount` outside bounds implied by min/max fee bps | `30e6` fee when bounds are `50e6`–`100e6` |
| `ZeroFeeReceiver` | Non-zero fee with zero recipient | `25e6` fee, address(0) recipient |
| `InvalidFeeReceiver` | Wrong recipient for fixed fee | Different address than PaymentInfo.feeReceiver |

The protocol uses integer division which truncates decimals, slightly favoring the merchant in rounding scenarios.
12 changes: 6 additions & 6 deletions docs/operations/Capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Capture finalizes the payment by:
function capture(
PaymentInfo calldata paymentInfo,
uint256 amount,
uint16 feeBps,
uint256 feeAmount,
address feeReceiver
) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount)
```
Expand All @@ -32,14 +32,14 @@ function capture(
2. **Timing Check**: Verifies capture occurs before authorization expiry
3. **Availability Check**: Confirms sufficient authorized funds are available
4. **State Update**: Reduces `capturableAmount` and increases `refundableAmount` by captured `amount`
5. **Fee Calculation**: Calculates fee amount based on basis points
5. **Fee Bounds Check**: Confirms the provided absolute `feeAmount` falls within the bounds derived from `minFeeBps`/`maxFeeBps`
6. **Token Distribution**: Transfers fee to recipient and remaining amount to merchant
7. **Event Emission**: Emits `PaymentCaptured` for tracking

### Key Validations
- Must be called before `paymentInfo.authorizationExpiry`
- `amount` cannot exceed available `capturableAmount`
- `feeBps` must be within `[paymentInfo.minFeeBps, paymentInfo.maxFeeBps]` range
- `feeAmount` must be within `[amount * minFeeBps / 10000, amount * maxFeeBps / 10000]`
- `feeReceiver` must match `paymentInfo.feeReceiver` if specified

## Parameters
Expand All @@ -48,7 +48,7 @@ function capture(
|-----------|------|-------------|
| `paymentInfo` | `PaymentInfo` | Original payment configuration |
| `amount` | `uint256` | Amount to capture from escrow |
| `feeBps` | `uint16` | Fee percentage in basis points (0-10000) |
| `feeAmount` | `uint256` | Absolute fee in raw token units (must fall within the payer-approved bounds derived from `minFeeBps`/`maxFeeBps`) |
| `feeReceiver` | `address` | Address to receive fee portion |

## Access Control
Expand Down Expand Up @@ -83,7 +83,7 @@ PaymentState {
event PaymentCaptured(
bytes32 indexed paymentInfoHash,
uint256 amount,
uint16 feeBps,
uint256 feeAmount,
address feeReceiver
);
```
Expand All @@ -97,7 +97,7 @@ Track captures to monitor payment settlement and fee distribution.
| `InvalidSender` | Caller is not the designated operator |
| `ZeroAmount` | Attempting to capture zero amount |
| `AmountOverflow` | Amount exceeds uint120 maximum |
| `FeeBpsOutOfRange` | Fee outside min/max range |
| `FeeAmountOutOfRange` | `feeAmount` outside the bounds derived from min/max fee bps |
| `ZeroFeeReceiver` | Fee recipient is zero address with non-zero fee |
| `InvalidFeeReceiver` | Fee recipient doesn't match payment configuration |
| `AfterAuthorizationExpiry` | Called after authorization expired |
Expand Down
10 changes: 5 additions & 5 deletions docs/operations/Charge.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function charge(
uint256 amount,
address tokenCollector,
bytes calldata collectorData,
uint16 feeBps,
uint256 feeAmount,
address feeReceiver
) external nonReentrant onlySender(paymentInfo.operator) validAmount(amount)
```
Expand All @@ -46,7 +46,7 @@ function charge(
| `amount` | `uint256` | Amount to charge (must be ≤ maxAmount) |
| `tokenCollector` | `address` | Contract that will pull tokens from payer |
| `collectorData` | `bytes` | Data passed to token collector |
| `feeBps` | `uint16` | Fee percentage in basis points |
| `feeAmount` | `uint256` | Absolute fee in raw token units (must fall within the payer-approved bounds derived from `minFeeBps`/`maxFeeBps`) |
| `feeReceiver` | `address` | Address to receive fee portion |

## Access Control
Expand Down Expand Up @@ -83,7 +83,7 @@ event PaymentCharged(
PaymentInfo paymentInfo,
uint256 amount,
address tokenCollector,
uint16 feeBps,
uint256 feeAmount,
address feeReceiver
);
```
Expand All @@ -100,9 +100,9 @@ The `PaymentCharged` event includes more details than `PaymentCaptured` since it
| `ExceedsMaxAmount` | Amount exceeds paymentInfo.maxAmount |
| `AfterPreApprovalExpiry` | Called after signature expiry |
| `InvalidExpiries` | Expiry timestamps are improperly ordered |
| `FeeBpsOverFlow` | maxFeeBps exceeds maximum value |
| `FeeBpsOverflow` | maxFeeBps exceeds maximum value |
| `InvalidFeeBpsRange` | minFeeBps exceeds maxFeeBps |
| `FeeBpsOutOfRange` | Fee outside min/max range |
| `FeeAmountOutOfRange` | `feeAmount` outside the bounds derived from min/max fee bps |
| `ZeroFeeReceiver` | Fee recipient is zero address with non-zero fee |
| `InvalidFeeReceiver` | Fee recipient doesn't match payment configuration |
| `PaymentAlreadyCollected` | Payment already authorized or charged |
Expand Down
6 changes: 6 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ out = "out"
libs = ["lib"]
evm_version = "cancun"

[profile.ci]
src = "src"
out = "out"
libs = ["lib"]
evm_version = "cancun"

[profile.deploy]
src = "src"
out = "out"
Expand Down
Loading
Loading