Skip to content
Merged
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
72 changes: 70 additions & 2 deletions docs/Fees.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,13 @@ PaymentInfo memory payment = PaymentInfo({
```

**Operator Options at Capture/Charge (for a `1000e6` capture ⇒ bounds are `0`–`100e6`):**
- ✅ `feeAmount: 0, feeReceiver: address(0)` (no fee, receiver ignored)
- ✅ `feeAmount: 0, feeReceiver: 0x123...456` (no fee, but the configured recipient must still be supplied)
- ✅ `feeAmount: 25e6, feeReceiver: 0x123...456` (2.5% to fixed recipient)
- ✅ `feeAmount: 100e6, feeReceiver: 0x123...456` (maximum fee)
- ❌ `feeAmount: 25e6, feeReceiver: 0x789...abc` (wrong recipient)
- ❌ `feeAmount: 0, feeReceiver: address(0)` (fixed recipient enforced even when the fee is 0)

> **Note.** When `PaymentInfo.feeReceiver` is a non-zero fixed address, `_validateFee` requires the caller-supplied `feeReceiver` to match it **unconditionally** — including when `feeAmount == 0`. Passing `address(0)` alongside a zero fee against a fixed-recipient payment reverts with `InvalidFeeReceiver`. Receiver matching is only skipped when `PaymentInfo.feeReceiver == address(0)` (flexible recipient), as in Example 3.


## Multiple Captures with Different Fees
Expand Down Expand Up @@ -165,4 +168,69 @@ The protocol will revert with specific errors for invalid fee configurations:
| `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.
## Per-Capture Semantics and Rounding

`minFeeBps` and `maxFeeBps` are **per-capture** rate bounds — they are evaluated independently against each `capture()` (or `charge()`) call's `amount`. They are not aggregate, payment-level guarantees over the full authorized amount.

Because the bounds are computed with integer division:

```
minFee = amount * minFeeBps / 10_000
maxFee = amount * maxFeeBps / 10_000
```

both `minFee` and `maxFee` round toward zero. When `amount * minFeeBps < 10_000`, `minFee` rounds to `0` and the operator may supply `feeAmount = 0` for that capture even if `minFeeBps > 0`. Rounding favors the merchant/payer at `maxFee` and favors the operator at `minFee`.

### Fragmentation implication

Because the minimum is per-capture, an operator with capture-amount discretion can, in principle, fragment a large payment into many small captures whose individual `minFee` values each round to zero, reducing the total fee paid below what a single equivalently-sized capture would require. Example: a zero-decimal token authorization of `100` units with `minFeeBps = maxFeeBps = 100` (1%) yields:

- One capture of `100` → `minFee = maxFee = 1` → operator must supply `feeAmount = 1`.
- One hundred captures of `1` each → `minFee = maxFee = 0` → operator may supply `feeAmount = 0` on every one.

This behavior is **pre-existing** — it is a property of per-capture bps-based bounds and integer division, not something introduced by moving the concrete fee amount off-chain. Absolute-fee validation preserves the same per-capture rounding semantics as the previous `feeBps` API.

### Accepted risk

The protocol treats this as an accepted risk under the current operator-trust model:

- Operators are trusted counterparties in the payment flow and, in current deployments, are typically also the fee receiver — fragmenting fees away from themselves has no benefit.
- Per-capture gas costs make fragmenting economically pointless except for low-decimal, high-unit-value tokens.
- Integrators that need aggregate-level fee guarantees should either (a) issue authorizations whose minimum meaningful capture size makes rounding irrelevant, (b) use tokens with sufficient decimals (e.g. USDC's 6 decimals push the rounding gap below one cent for typical bps rates), or (c) constrain operator behavior off-chain.

If a future deployment weakens the operator-trust assumption (for example by allowing arbitrary fee receivers distinct from the operator), integrators should re-evaluate this trade-off. Tightening the guarantee on-chain would require either per-payment aggregate fee accounting or a minimum-capture-size rule, both of which change the two-phase escrow's flexibility and would be handled as separate design work.

The protocol uses integer division which truncates decimals, slightly favoring the merchant in `maxFee` rounding and the operator in `minFee` rounding.

## Event Migration: `topic0` Changes for `PaymentCharged` and `PaymentCaptured`

The absolute-fee migration changed the type of the fee field in both events from `uint16 feeBps` to `uint256 feeAmount`. Solidity derives an event's log `topic0` from `keccak256("EventName(param1,param2,...)")`, so both signature changes produced brand-new `topic0` values. Off-chain indexers filtering by the previous `topic0` will silently observe zero matching logs from any deployment that carries the new signature — no error is raised.

The current mainnet/Sepolia deployment (`0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff`) is immutable and continues to emit the **old** signatures. Any future redeployment of `AuthCaptureEscrow` at a different address will emit the **new** signatures. Consumers migrating across the redeployment boundary must add filters for the new `topic0` values keyed on the new contract address, while continuing to accept the old `topic0` from the existing address for as long as authorizations against it remain in flight.

Additionally, the third `data` field (immediately after `amount`) changes meaning: previously `uint16 feeBps` (right-padded to 32 bytes as a rate in basis points), now `uint256 feeAmount` (an absolute token-unit fee). ABIs from before the migration will decode without error against the new logs but will misinterpret the value (for example, reading a `25e6` USDC fee as `25,000,000 bps`). Integrators must upgrade ABIs alongside the contract address, not lazily.

### Canonical signatures

| Event | Version | Full signature (used to derive `topic0`) |
|---|---|---|
| `PaymentCharged` | old (deployed at `0xBdEA...cff`) | `PaymentCharged(bytes32,(address,address,address,address,uint120,uint48,uint48,uint48,uint16,uint16,address,uint256),uint256,address,uint16,address)` |
| `PaymentCharged` | new (post-#90) | `PaymentCharged(bytes32,(address,address,address,address,uint120,uint48,uint48,uint48,uint16,uint16,address,uint256),uint256,address,uint256,address)` |
| `PaymentCaptured` | old (deployed at `0xBdEA...cff`) | `PaymentCaptured(bytes32,uint256,uint16,address)` |
| `PaymentCaptured` | new (post-#90) | `PaymentCaptured(bytes32,uint256,uint256,address)` |

### `topic0` values

| Event | Version | `topic0` |
|---|---|---|
| `PaymentCharged` | old | `0x943ae4341dd799d7aeedc501f616cd26b134639e0bc2ec059581ba3ebbf1e7d0` |
| `PaymentCharged` | new | `0x137b0e73e4453f43c2e1ad2552980a0e0c7e988764619974ca26d37a7159940c` |
| `PaymentCaptured` | old | `0xe749f7bbd01b49bb05abf26ca492cb4dfdea6bedeada8a40fdccd478c73a74e2` |
| `PaymentCaptured` | new | `0xac1e0db1957daedbc6944bcb4c8dc947ff8101d710671ac2ec26309f7f38d58e` |

### Migration checklist for integrators

1. Update ABI JSON alongside the redeployment; do not mix an old ABI with the new contract address.
2. Subscribe to both `topic0` values during any overlap period where authorizations against `0xBdEA...cff` may still capture or refund.
3. Route logs by `(address, topic0)` rather than `topic0` alone; the two signatures will coexist across the two deployed addresses.
4. Confirm all downstream reconciliation, monitoring, and dashboarding pipelines have been redeployed with the new ABI before the new address begins receiving production traffic.
Loading