--- eip: 8214 title: Buy Now Pay Later (BNPL) description: An interface for on-chain BNPL installment orders with EIP-712 consent and pluggable payment authorization author: Ahmad Sghaier Omar (@asghaier76), Emanuel Ortiz (@emaortiz) discussions-to: https://ethereum-magicians.org/t/erc-8214-buy-now-pay-later-bnpl/28116 status: Draft type: Standards Track category: ERC created: 2026-03-31 requires: 20, 165, 712, 1271 --- ## Abstract This ERC defines a standard interface for Buy Now Pay Later (BNPL) installment orders on EVM-compatible blockchains. The standard specifies: 1. **Payment authorization abstraction** (`IPaymentAuth`): A pluggable interface decoupling order scheduling from token pull mechanics, with five normative reference implementations covering the full range of [ERC-20](./eip-20.md) approval models — from simple allowances to cryptographically committed variable payment schedules 2. **Operator management** (`IOperatorManager`): Trusted addresses that may trigger installment collection on behalf of merchants without receiving funds 3. **Order lifecycle** (`IBNPL`): A state machine covering order creation, installment collection, early payoff, default, and cancellation 4. **Customer consent** (`OrderAuthorization`): An [EIP-712](./eip-712.md) typed structure the customer signs at checkout, cryptographically binding them to specific order terms before any tokens move 5. **Split authorization strategies**: Independent `IPaymentAuth` implementations for the down payment and installment phases, enabling a gasless signature-based checkout combined with automatic installment collection Default recovery is intentionally out of scope. When an order is marked `Defaulted`, the `OrderDefaulted` event carries the outstanding balance and recovery is handled by the implementing platform through off-chain processes, legal mechanisms, or platform-specific systems outside this standard. ## Motivation E-commerce BNPL is a mainstream consumer payment method. The global BNPL market processes hundreds of billions of dollars annually through centralized providers (Klarna, Afterpay, Affirm) that are opaque, jurisdiction-specific, and require merchants to integrate with proprietary APIs. On-chain BNPL offers transparent payment schedules, composability with DeFi settlement infrastructure, and reduced operational costs for merchants who self-finance installment credit. No existing ERC addresses the specific requirements of BNPL installment commerce: **Atomic down payment with cryptographic consent.** At checkout, the customer must simultaneously authorize the order terms and transfer the down payment. Separating these creates a window where a merchant can create an order the customer did not consent to, or where the customer consents but never pays. Existing token approval standards (ERC-20 `approve`, [ERC-2612](./eip-2612.md) `permit`, Permit2) handle payment but carry no order-specific consent. This ERC defines an `OrderAuthorization` EIP-712 struct that binds consent and payment in a single merchant-submitted transaction. **Split authorization for down payment vs. installments.** The optimal UX for e-commerce BNPL requires two different authorization models in the same order. At checkout, a gasless signature-based authorization is preferred — the customer signs once, the merchant submits atomically, no prior token approval transaction is required. For subsequent installments, automatic collection is preferred — the customer should not need to interact with the contract again after checkout. No existing standard models this two-phase auth split within a single order. **Authorization model agnosticism.** Different deployments operate under different token ecosystems and UX constraints. Many of the most widely used payment tokens — including USDT on Ethereum mainnet and WBTC — do not implement ERC-2612 and cannot use native permit. `Permit2Auth` handles any ERC-20 token regardless of ERC-2612 support and is the recommended signature-based strategy for most deployments. `EIP3009Auth` uses [ERC-3009](./eip-3009.md) `receiveWithAuthorization`, which is natively implemented by USDC and provides atomic token transfer with front-running protection — no allowance created at any stage. `PermitAuth` is a lighter-weight alternative for deployments where the token implements ERC-2612 natively and avoiding a dependency on the Permit2 contract is a priority. `AllowanceAuth` covers the remaining cases. The `IPaymentAuth` abstraction defined here accommodates all of these without requiring token modifications. **Operator-managed collection.** Merchants need to delegate installment collection to automated keepers or backend infrastructure without granting those operators custody of funds. Collected tokens must flow directly from customer to merchant regardless of who triggers collection. **Defined state machine with off-chain default recovery.** The order lifecycle must be precisely specified so that off-chain backoffice systems can reliably act on state transitions. Default recovery is platform-specific and is intentionally delegated to the implementing application. ### Use Cases **E-commerce BNPL.** The primary target. A customer purchases goods or services and pays in installments: a down payment at checkout and the remainder over weeks or months. The merchant self-finances the credit and accepts default risk. `Permit2Auth` + `AllowanceAuth` is the recommended strategy combination — gasless signature-based checkout with automatic subsequent collection. **DeFi loan repayment.** A lending protocol issues a fixed-term loan and structures repayment as discrete installments rather than a lump sum. The borrower is the `customer`, the lending contract is the `merchant`. Collateral and liquidation are handled by the lending protocol outside this standard — this ERC covers only the repayment pull mechanics. `MerkleScheduleAuth` is appropriate when repayment amounts vary across periods (e.g. amortizing loans with declining interest components). **NFT installment purchases.** High-value NFT acquisitions split into a down payment and trailing installments. The seller or marketplace contract is the `merchant`. The NFT is held in escrow outside this ERC until `OrderCompleted` triggers release. `MerkleScheduleAuth` accommodates non-uniform payment schedules common in negotiated NFT sales. **DAO contributor payments.** A DAO treasury paying a contributor, auditor, or service provider in phased installments tied to milestone delivery. The DAO treasury is the `customer` and the contributor is the `merchant`. Milestone gating is enforced off-chain by the DAO's governance process — this ERC handles the token pull on each approved installment. `AllowanceAuth` with the DAO multisig as operator is the natural fit. **Protocol fee payment plans.** DeFi protocols offering institutional or enterprise clients the ability to pay access fees, listing fees, or one-time charges in installments rather than upfront. Reduces friction for high-value protocol integrations where a large upfront payment is a barrier. **Token vesting tranches.** Discrete token distributions to investors, team members, or ecosystem participants where amounts differ per tranche (cliff + linear schedule, milestone-based, or custom). `MerkleScheduleAuth` handles non-uniform tranche sizes committed upfront. The recipient is the `merchant`, the project treasury is the `customer`. **Out of scope.** This ERC does not define default recovery mechanisms, consumer credit compliance (APR disclosure, cooling-off periods, credit reporting) is the responsibility of the implementing platform and recurring and subscription billing. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119. --- ### Part I — Authorization Infrastructure #### IPaymentAuth `IPaymentAuth` is the token authorization abstraction. All token movement in this ERC MUST go through an `IPaymentAuth` implementation. Implementations of `IBNPL` MUST NOT call `IERC20.transferFrom` directly — all pulls are delegated to the configured strategy. The `authData` parameter is opaque to callers. Its encoding is strategy-defined and MUST be documented by each implementation. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; /** * @title IPaymentAuth * @notice Pluggable interface for pulling ERC-20 tokens from a payer. * @dev Each implementation encapsulates one authorization model. * The authData parameter carries strategy-specific data * (signatures, proofs, empty bytes) and is opaque to IBNPL callers. * * ERC-165 interface identifier (excludes supportsInterface per ERC-165 convention): * pullPayment(address,address,address,uint256,bytes) = 0xc5ee2754 * authType() = 0x05915147 * IPaymentAuth interfaceId = 0xc07f7613 */ interface IPaymentAuth is IERC165 { /** * @notice Pull `amount` of `token` from `payer` to `recipient`. * @param payer Address whose tokens are pulled. * @param recipient Address that receives the tokens. * @param token ERC-20 token address. * @param amount Exact amount to pull in token base units. * @param authData Strategy-specific authorization data. * @return success True if tokens were transferred successfully. */ function pullPayment( address payer, address recipient, address token, uint256 amount, bytes calldata authData ) external returns (bool success); /** * @notice Returns a unique identifier for this authorization strategy type. * @dev Allows callers to identify strategy type without inspecting authData. * SHOULD return bytes4(keccak256("StrategyName")). */ function authType() external pure returns (bytes4); } ``` #### IOperatorManager Operators are addresses authorized by a merchant to trigger collection on their behalf. Operators MUST NOT receive collected funds — all tokens flow directly from customer to merchant. Authorization is scoped per merchant: an address authorized by merchant A has no authority over merchant B's orders. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; /** * @title IOperatorManager * @notice Manages addresses authorized to trigger payment collection * on behalf of a merchant. * * ERC-165 interface identifier (excludes supportsInterface per ERC-165 convention): * authorizeOperator(address) = 0x959b8c3f * revokeOperator(address) = 0xfad8b32a * isOperator(address,address) = 0xb6363cf2 * IOperatorManager interfaceId = 0xd97503e7 */ interface IOperatorManager is IERC165 { event OperatorAuthorized( address indexed operator, address indexed merchant ); event OperatorRevoked( address indexed operator, address indexed merchant ); /** * @notice Authorize an operator to collect on behalf of the caller. * @dev MUST revert if operator is the zero address. * MUST revert if operator is already authorized. * Caller becomes the merchant for this authorization. */ function authorizeOperator(address operator) external; /** * @notice Revoke a previously authorized operator. * @dev MUST revert if operator is not currently authorized for the caller. */ function revokeOperator(address operator) external; /** * @notice Check whether an address is an authorized operator for a merchant. * @param operator The address to check. * @param merchant The merchant whose operator set is checked. * @return True if operator is authorized for merchant. */ function isOperator( address operator, address merchant ) external view returns (bool); } ``` --- ### Part II — Reference Authorization Strategies This section defines five normative `IPaymentAuth` implementations. Deployments SHOULD use one or more of these strategies and MUST clearly document which strategies are supported. Custom strategies are permitted but MUST implement `IPaymentAuth` and support [ERC-165](./eip-165.md). #### Strategy 1 — AllowanceAuth **Model**: Standard ERC-20 `approve` / `transferFrom`. The customer grants a standing allowance to the BNPL contract. **Security profile**: Persistent allowance. The customer SHOULD set an allowance equal to the remaining installment balance (`totalAmount - downPayment`), not `type(uint256).max`. The collecting contract enforces per-installment amounts through its own logic. **Token compatibility**: Any ERC-20 token. **`authData` encoding**: Empty (`0x`). No additional data is required or read. **Typical use**: `installmentAuthStrategy` — automatic installment collection after a signature-based checkout. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {IPaymentAuth} from "./IPaymentAuth.sol"; /** * @title AllowanceAuth * @notice IPaymentAuth strategy using standard ERC-20 allowance. * @dev The payer must call token.approve(bnplContract, amount) before * this strategy can pull. authData is unused and MUST be empty. * Uses SafeERC20.safeTransferFrom to handle tokens that do not * return a bool from transferFrom (e.g. USDT on Ethereum mainnet). */ contract AllowanceAuth is IPaymentAuth, ReentrancyGuard { using SafeERC20 for IERC20; bytes4 public constant AUTH_TYPE_ID = bytes4(keccak256("AllowanceAuth")); function authType() external pure override returns (bytes4) { return AUTH_TYPE_ID; } function pullPayment( address payer, address recipient, address token, uint256 amount, bytes calldata /* authData */ ) external override nonReentrant returns (bool) { IERC20(token).safeTransferFrom(payer, recipient, amount); return true; } function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { return interfaceId == type(IPaymentAuth).interfaceId || interfaceId == type(IERC165).interfaceId; } } ``` #### Strategy 2 — PermitAuth **Model**: ERC-2612 `permit` + `transferFrom`. The customer provides a permit signature for each payment. No standing allowance is required. **Security profile**: No persistent approval. Each pull requires a fresh valid signature with a deadline. The customer must produce a new signature before or at collection time, or pre-sign all permits at checkout. **Token compatibility**: Tokens implementing ERC-2612 only. `pullPayment` will revert if the token does not implement `permit`. **`authData` encoding**: `abi.encode(uint256 deadline, uint8 v, bytes32 r, bytes32 s)`. **Typical use**: `downPaymentAuthStrategy` when the token supports ERC-2612 and the customer has no standing allowance. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {IPaymentAuth} from "./IPaymentAuth.sol"; /** * @title PermitAuth * @notice IPaymentAuth strategy using ERC-2612 permit signatures. * @dev authData MUST be abi.encode(uint256 deadline, uint8 v, bytes32 r, bytes32 s). * The token MUST implement IERC20Permit. If permit() reverts (e.g. unsupported * token, expired deadline, invalid signature), pullPayment reverts. * Uses SafeERC20.safeTransferFrom to handle non-bool-returning tokens. */ contract PermitAuth is IPaymentAuth, ReentrancyGuard { using SafeERC20 for IERC20; bytes4 public constant AUTH_TYPE_ID = bytes4(keccak256("PermitAuth")); function authType() external pure override returns (bytes4) { return AUTH_TYPE_ID; } function pullPayment( address payer, address recipient, address token, uint256 amount, bytes calldata authData ) external override nonReentrant returns (bool) { (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = abi.decode(authData, (uint256, uint8, bytes32, bytes32)); IERC20Permit(token).permit( payer, address(this), amount, deadline, v, r, s ); IERC20(token).safeTransferFrom(payer, recipient, amount); return true; } function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { return interfaceId == type(IPaymentAuth).interfaceId || interfaceId == type(IERC165).interfaceId; } } ``` #### Strategy 3 — Permit2Auth **Model**: Uniswap Permit2 `SignatureTransfer`. Leverages the canonical Permit2 deployment to support signature-based transfers for any ERC-20 token, including tokens that do not implement ERC-2612. Each signature is single-use and replay-proof by construction. **Security profile**: No persistent approval beyond the one-time Permit2 contract approval. Each pull consumes a nonce stored in Permit2's bitmap, making replay impossible. The payer must have approved the canonical Permit2 contract for the relevant token (a one-time operation, shared across all Permit2-integrated protocols). **Token compatibility**: Any ERC-20 token, after a one-time `token.approve(PERMIT2_ADDRESS, type(uint256).max)`. **`authData` encoding**: `abi.encode(uint256 nonce, uint256 deadline, bytes signature)` where `signature` is the EIP-712 signature over the Permit2 `PermitTransferFrom` struct. **Canonical Permit2 address**: `0x000000000022D473030F116dDEE9F6B43aC78BA3` (same across Ethereum mainnet, Arbitrum, Optimism, Base, Polygon, and other major networks). **Typical use**: `downPaymentAuthStrategy` — clean signature-based checkout for any ERC-20 token. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; import {ISignatureTransfer} from "permit2/src/interfaces/ISignatureTransfer.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {IPaymentAuth} from "./IPaymentAuth.sol"; /** * @title Permit2Auth * @notice IPaymentAuth strategy using Uniswap Permit2 SignatureTransfer. * @dev authData MUST be abi.encode(uint256 nonce, uint256 deadline, bytes signature). * The payer must have approved the canonical Permit2 contract on the token. * Each signature is single-use; nonce is consumed on first use. */ contract Permit2Auth is IPaymentAuth, ReentrancyGuard { bytes4 public constant AUTH_TYPE_ID = bytes4(keccak256("Permit2Auth")); /// @notice Canonical Permit2 deployment address. address public constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; function authType() external pure override returns (bytes4) { return AUTH_TYPE_ID; } function pullPayment( address payer, address recipient, address token, uint256 amount, bytes calldata authData ) external override nonReentrant returns (bool) { (uint256 nonce, uint256 deadline, bytes memory signature) = abi.decode(authData, (uint256, uint256, bytes)); ISignatureTransfer(PERMIT2).permitTransferFrom( ISignatureTransfer.PermitTransferFrom({ permitted: ISignatureTransfer.TokenPermissions({ token: token, amount: amount }), nonce: nonce, deadline: deadline }), ISignatureTransfer.SignatureTransferDetails({ to: recipient, requestedAmount: amount }), payer, signature ); return true; } function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { return interfaceId == type(IPaymentAuth).interfaceId || interfaceId == type(IERC165).interfaceId; } } ``` #### Strategy 4 — MerkleScheduleAuth **Model**: Cryptographic commitment to a non-uniform installment schedule via a merkle root. The merchant commits to the full `(installmentIndex, amount, dueDate)` schedule at order creation time by registering a merkle root. Each collection proves a specific leaf against that root. This strategy is appropriate when installment amounts vary across the schedule — promotional structures, amount-weighted splits, or any schedule not derivable from uniform division. **When to use**: Only when installment amounts genuinely differ across payments. For uniform schedules (same amount, fixed interval), `AllowanceAuth` or `Permit2Auth` are simpler and correct. Choosing `MerkleScheduleAuth` for a uniform schedule adds complexity with no benefit. **Security profile**: The merkle root is immutable after registration. Each leaf is consumed exactly once via an on-chain `consumed` mapping keyed by `(orderId, leafHash)`. Replay within or across orders is impossible by construction. The merchant MUST publish the full leaf set to a content-addressed store (IPFS or equivalent) and record the CID on-chain at registration time — this ensures the schedule can always be reconstructed from public data even if the merchant's backend fails. **Token compatibility**: Any ERC-20 token. `MerkleScheduleAuth` calls `IERC20.transferFrom` directly (the payer must hold a standing allowance on the BNPL contract for the full remaining installment balance, as with `AllowanceAuth`). **`authData` encoding for `pullPayment`**: `abi.encode(uint256 installmentIndex, uint256 amount, uint256 dueDate, bytes32[] proof)`. **Amount validation**: `MerkleScheduleAuth.pullPayment` requires that the `amount` parameter passed by the caller exactly equals the leaf amount encoded in `authData`. This is consistent with every other `IPaymentAuth` strategy — the caller's `amount` is authoritative and the strategy enforces it. Callers MUST decode the leaf amount from `authData` before calling `pullPayment` and pass it as `amount`. Passing any other value causes a revert. **Schedule registration**: Because `IPaymentAuth` has no `createOrder` awareness, schedule registration happens via a separate `registerSchedule` call that the merchant makes in the same transaction as `createOrder` using a multicall. The `orderId` is deterministic and can be pre-computed before submitting `createOrder`. Registration MUST be called before or atomically with `createOrder` — `pullPayment` MUST revert if no schedule is registered for the given `orderId`. **Leaf construction**: Leaves MUST be constructed as: ```solidity bytes32 leaf = keccak256(abi.encode( uint256 installmentIndex, // 0-indexed; makes every leaf unique even if amount and dueDate repeat uint256 amount, // token base units uint256 dueDate // unix timestamp; collectInstallment enforces block.timestamp >= dueDate )); ``` `abi.encode` (not `abi.encodePacked`) MUST be used. `installmentIndex` MUST be included to prevent leaf hash collisions when two installments share the same amount and due date. **Tree construction**: The merkle tree MUST be a standard binary tree where sibling nodes are sorted before hashing to prevent second-preimage attacks: ```solidity // Internal node hashing (sort children before hashing): bytes32 node = keccak256(abi.encode( left < right ? left : right, left < right ? right : left )); ``` Implementations SHOULD use OpenZeppelin's `MerkleProof` library which follows this convention. **Test vectors**: For interoperability, the following 2-leaf schedule MUST produce the stated root. All implementations MUST include a test that reproduces this value. | Index | Amount (uint256) | Due date (uint256) | |-------|------------------|--------------------| | 0 | 500000 | 1740000000 | | 1 | 750000 | 1742592000 | ``` Leaf0 = keccak256(abi.encode(uint256(0), uint256(500000), uint256(1740000000))) Leaf1 = keccak256(abi.encode(uint256(1), uint256(750000), uint256(1742592000))) Root = keccak256(abi.encode(min(Leaf0,Leaf1), max(Leaf0,Leaf1))) ``` **Typical use**: `installmentAuthStrategy` for variable-amount BNPL schedules. Not recommended as `downPaymentAuthStrategy` — the down payment amount is always known at order creation and does not benefit from merkle commitment. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {IPaymentAuth} from "./IPaymentAuth.sol"; /** * @title MerkleScheduleAuth * @notice IPaymentAuth strategy for variable-amount installment schedules * committed via a merkle root at order creation time. * * @dev Usage pattern: * 1. Merchant pre-computes orderId (deterministic from createOrder inputs). * 2. Merchant calls registerSchedule(orderId, merkleRoot, dataCID) to commit * the schedule. This MUST happen in the same transaction as createOrder * (use a multicall wrapper). * 3. For each installment, the caller provides authData encoding * (installmentIndex, amount, dueDate, proof[]). * 4. pullPayment verifies the proof, enforces dueDate, marks the leaf * consumed, and pulls the exact amount from the leaf. * * authData encoding: abi.encode( * uint256 installmentIndex, * uint256 amount, * uint256 dueDate, * bytes32[] proof * ) * * Callers MUST pass the exact leaf amount as the `amount` parameter to pullPayment. * The strategy validates that amount == leafAmount from authData, reverting if they * differ. This is consistent with all other IPaymentAuth strategies where the * caller's amount parameter is authoritative. * * Uses SafeERC20.safeTransferFrom to handle non-bool-returning tokens. */ contract MerkleScheduleAuth is IPaymentAuth, ReentrancyGuard { using SafeERC20 for IERC20; bytes4 public constant AUTH_TYPE_ID = bytes4(keccak256("MerkleScheduleAuth")); // ============ Storage ============ /// @notice Merkle root committed for each order. /// @dev orderId => merkleRoot. Zero value means no schedule registered. mapping(bytes32 => bytes32) public scheduleRoots; /// @notice Content-addressed reference to the full leaf set for each order. /// @dev orderId => CID bytes (e.g. IPFS CIDv1 in bytes). Data availability commitment. mapping(bytes32 => bytes) public scheduleDataCIDs; /// @notice Tracks consumed leaves to prevent replay. /// @dev orderId => leafHash => consumed. mapping(bytes32 => mapping(bytes32 => bool)) public consumed; // ============ Events ============ /** * @notice Emitted when a merchant registers a payment schedule for an order. * @param orderId The BNPL order this schedule applies to. * @param merkleRoot Root of the merkle tree of (index, amount, dueDate) leaves. * @param dataCID Content-addressed reference to the full leaf set. */ event ScheduleRegistered( bytes32 indexed orderId, bytes32 merkleRoot, bytes dataCID ); /** * @notice Emitted when a leaf is consumed (installment collected). * @param orderId The order. * @param installmentIndex The 0-indexed installment. * @param leafHash The consumed leaf hash. */ event LeafConsumed( bytes32 indexed orderId, uint256 installmentIndex, bytes32 leafHash ); // ============ Registration ============ /** * @notice Register a variable payment schedule for an order. * * @dev MUST be called by the merchant in the same transaction as * IBNPL.createOrder (via multicall). The orderId is deterministic * and can be pre-computed before createOrder is submitted. * * MUST revert if a schedule is already registered for this orderId * (prevents a merchant from replacing a committed schedule after * the customer has signed the OrderAuthorization). * * MUST revert if merkleRoot is bytes32(0). * MUST revert if dataCID is empty. * * @param orderId The IBNPL order identifier (pre-computable). * @param merkleRoot Root of the (installmentIndex, amount, dueDate) merkle tree. * @param dataCID IPFS CID or other content-addressed reference to the full * leaf set. MUST be non-empty. Publishing the leaf set to the * referenced location is the merchant's responsibility. */ function registerSchedule( bytes32 orderId, bytes32 merkleRoot, bytes calldata dataCID ) external { require( scheduleRoots[orderId] == bytes32(0), "MerkleScheduleAuth: schedule already registered" ); require(merkleRoot != bytes32(0), "MerkleScheduleAuth: empty root"); require(dataCID.length > 0, "MerkleScheduleAuth: empty dataCID"); scheduleRoots[orderId] = merkleRoot; scheduleDataCIDs[orderId] = dataCID; emit ScheduleRegistered(orderId, merkleRoot, dataCID); } // ============ IPaymentAuth ============ function authType() external pure override returns (bytes4) { return AUTH_TYPE_ID; } /** * @notice Pull an installment amount verified against the committed schedule. * * @dev The `amount` parameter supplied by the caller is validated against * the leaf amount. Callers MUST pass the exact leaf amount as `amount`; * the strategy validates amount == leafAmount and reverts if they differ. * * authData MUST be abi.encode( * uint256 installmentIndex, * uint256 leafAmount, * uint256 dueDate, * bytes32[] proof * ) * * The orderId is recovered from the payer and recipient context via * msg.sender (the IBNPL contract), which MUST be the registered caller. * The IBNPL contract MUST pass the orderId in a wrapper or the strategy * MUST be called with orderId in the authData. This implementation * includes orderId in authData for self-contained verification. * * Full authData: abi.encode( * bytes32 orderId, * uint256 installmentIndex, * uint256 leafAmount, * uint256 dueDate, * bytes32[] proof * ) * * @param payer Customer address (tokens pulled from here). * @param recipient Merchant address (tokens delivered here). * @param token ERC-20 token address. * @param amount MUST equal the leaf amount decoded from authData. Reverts otherwise. * @param authData abi.encode(orderId, installmentIndex, leafAmount, dueDate, proof[]). * @return success True if the pull succeeded. */ function pullPayment( address payer, address recipient, address token, uint256 amount, bytes calldata authData ) external override nonReentrant returns (bool) { ( bytes32 orderId, uint256 installmentIndex, uint256 leafAmount, uint256 dueDate, bytes32[] memory proof ) = abi.decode(authData, (bytes32, uint256, uint256, uint256, bytes32[])); // Caller must pass the exact leaf amount — consistent with all other strategies require( amount == leafAmount, "MerkleScheduleAuth: amount must equal leaf amount" ); // Schedule must be registered for this order bytes32 root = scheduleRoots[orderId]; require(root != bytes32(0), "MerkleScheduleAuth: no schedule registered"); // Reconstruct and verify the leaf bytes32 leaf = keccak256(abi.encode(installmentIndex, leafAmount, dueDate)); require( !consumed[orderId][leaf], "MerkleScheduleAuth: leaf already consumed" ); require( MerkleProof.verify(proof, root, leaf), "MerkleScheduleAuth: invalid proof" ); // Enforce due date require( block.timestamp >= dueDate, "MerkleScheduleAuth: payment not yet due" ); // Mark consumed before transfer (checks-effects-interactions) consumed[orderId][leaf] = true; emit LeafConsumed(orderId, installmentIndex, leaf); // Pull tokens — SafeERC20 handles non-bool-returning tokens (e.g. USDT) IERC20(token).safeTransferFrom(payer, recipient, leafAmount); return true; } // ============ View Functions ============ /** * @notice Check whether a specific leaf has been consumed. * @param orderId The order identifier. * @param installmentIndex 0-indexed installment position. * @param leafAmount Installment amount in token base units. * @param dueDate Installment due date as unix timestamp. * @return True if this leaf has already been collected. */ function isLeafConsumed( bytes32 orderId, uint256 installmentIndex, uint256 leafAmount, uint256 dueDate ) external view returns (bool) { bytes32 leaf = keccak256(abi.encode(installmentIndex, leafAmount, dueDate)); return consumed[orderId][leaf]; } /** * @notice Verify a proof without consuming the leaf. * @param orderId The order identifier. * @param installmentIndex 0-indexed installment position. * @param leafAmount Installment amount. * @param dueDate Installment due date. * @param proof Merkle proof. * @return valid True if the proof is valid against the registered root. */ function verifyInstallment( bytes32 orderId, uint256 installmentIndex, uint256 leafAmount, uint256 dueDate, bytes32[] calldata proof ) external view returns (bool valid) { bytes32 root = scheduleRoots[orderId]; if (root == bytes32(0)) return false; bytes32 leaf = keccak256(abi.encode(installmentIndex, leafAmount, dueDate)); return MerkleProof.verify(proof, root, leaf); } function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { return interfaceId == type(IPaymentAuth).interfaceId || interfaceId == type(IERC165).interfaceId; } } ``` #### Strategy 5 — `EIP3009Auth` **Model**: ERC-3009 `transferWithAuthorization` / `receiveWithAuthorization`. The customer signs an off-chain authorization that transfers tokens directly and atomically — no allowance is created and no separate `transferFrom` call is required. The authorization specifies `validAfter` and `validBefore` timestamps and a random 32-byte nonce. **Security profile**: No persistent approval of any kind. The authorization is consumed in the same transaction it is submitted. Random nonces (as opposed to ERC-2612's sequential nonces) allow multiple authorizations to be created and submitted in any order without collision. This strategy uses `receiveWithAuthorization` rather than `transferWithAuthorization` to prevent front-running: `receiveWithAuthorization` enforces `msg.sender == to`, meaning only the designated recipient contract can submit the authorization. An attacker observing the transaction in the mempool cannot redirect the transfer to a different recipient. **Token compatibility**: Tokens that implement ERC-3009 natively. The primary token is USDC (v2 and above) across Ethereum mainnet, Base, Arbitrum, Optimism, Polygon, and other major networks. EURC also implements ERC-3009. USDT, WETH, and most other tokens do NOT implement ERC-3009 — use `Permit2Auth` for those. **ERC-3009 status note**: ERC-3009 remains in Draft status as of this writing. This strategy is included because of USDC's first-class implementation and the strong security properties of `receiveWithAuthorization` for payment use cases. Implementors SHOULD monitor ERC-3009's progression and update their implementations if the spec changes before Final status is assigned. **`authData` encoding**: `abi.encode(uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes signature)` where `signature` is the EIP-712 signature over the `ReceiveWithAuthorization` struct. **Typical use**: `downPaymentAuthStrategy` for USDC deployments — the safest gasless checkout for USDC with no allowance creation and front-running protection. Recommended by Circle as the preferred authorization pattern for USDC payment integrations. **Comparison with `Permit2Auth`**: Both `EIP3009Auth` and `Permit2Auth` avoid standing approvals and are replay-proof. `Permit2Auth` works with any ERC-20 token and requires a one-time Permit2 contract approval. `EIP3009Auth` works only with ERC-3009 tokens (primarily USDC) but requires no prior setup, creates no allowance at any stage, and provides front-running protection through `receiveWithAuthorization`. For USDC deployments, `EIP3009Auth` is preferred. For any other token, use `Permit2Auth`. ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {IPaymentAuth} from "./IPaymentAuth.sol"; /** * @title IEIP3009 (minimal subset) * @dev Interface for tokens implementing ERC-3009 transferWithAuthorization. * Primary implementation: USDC v2+. */ interface IEIP3009 { /** * @notice Execute a transfer with a signed authorization. * @dev receiveWithAuthorization enforces msg.sender == to, preventing * front-running attacks where an attacker extracts the authorization * from the mempool and redirects the transfer. * Use this function (not transferWithAuthorization) when calling * from a smart contract. */ function receiveWithAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes calldata signature ) external; /** * @notice Returns the state of an authorization nonce. * @param authorizer The authorizer's address. * @param nonce The nonce to check. * @return True if the nonce has been used. */ function authorizationState( address authorizer, bytes32 nonce ) external view returns (bool); } /** * @title EIP3009Auth * @notice IPaymentAuth strategy using ERC-3009 receiveWithAuthorization. * * @dev Uses receiveWithAuthorization (not transferWithAuthorization) to * prevent front-running. msg.sender must equal the `to` address in the * authorization — this contract's address must be the recipient, so the * BNPL contract must forward tokens via this strategy as the caller. * * No allowance is created at any point. The token transfer is atomic * with the authorization submission. * * Token support: USDC v2+, EURC, and other ERC-3009 tokens. * Not compatible with USDT, WETH, or tokens lacking transferWithAuthorization. * * ERC-3009 remains in Draft status. Monitor for spec changes. * * authData: abi.encode( * uint256 validAfter, // timestamp after which auth is valid (0 = immediately) * uint256 validBefore, // timestamp before which auth is valid (use deadline) * bytes32 nonce, // random 32-byte nonce, unique per authorization * bytes signature // EIP-712 sig over ReceiveWithAuthorization struct * ) * * ReceiveWithAuthorization EIP-712 type string: * "ReceiveWithAuthorization(address from,address to,uint256 value, * uint256 validAfter,uint256 validBefore,bytes32 nonce)" * * Domain separator uses the token's own domain (name, version, chainId, * verifyingContract = token address) — NOT this contract's domain. * The customer must sign using the USDC token's EIP-712 domain. */ contract EIP3009Auth is IPaymentAuth, ReentrancyGuard { bytes4 public constant AUTH_TYPE_ID = bytes4(keccak256("EIP3009Auth")); // ============ IPaymentAuth ============ function authType() external pure override returns (bytes4) { return AUTH_TYPE_ID; } /** * @notice Pull tokens using ERC-3009 receiveWithAuthorization. * * @dev The `to` field in the signed authorization MUST equal `recipient` * (the merchant address). The authorization is verified and consumed * atomically by the token contract. No transferFrom is called. * * authData MUST be abi.encode( * uint256 validAfter, * uint256 validBefore, * bytes32 nonce, * bytes signature * ) * * @param payer Address that signed the authorization (the `from` field). * @param recipient Address receiving tokens (the `to` field in the authorization). * @param token ERC-3009 token address (e.g. USDC). * @param amount Exact transfer amount — must match the `value` in the authorization. * @param authData ABI-encoded (validAfter, validBefore, nonce, signature). */ function pullPayment( address payer, address recipient, address token, uint256 amount, bytes calldata authData ) external override nonReentrant returns (bool) { ( uint256 validAfter, uint256 validBefore, bytes32 nonce, bytes memory signature ) = abi.decode(authData, (uint256, uint256, bytes32, bytes)); // receiveWithAuthorization requires msg.sender == to. // This contract calls the token; msg.sender from the token's // perspective is this contract. The `to` in the authorization // must equal `recipient` (the merchant). The BNPL contract must // ensure this contract is called such that recipient == to in sig. IEIP3009(token).receiveWithAuthorization( payer, recipient, amount, validAfter, validBefore, nonce, signature ); return true; } // ============ View Functions ============ /** * @notice Check whether an authorization nonce has been consumed. * @dev Delegates to the token contract's authorizationState. * Use this to verify a nonce is still valid before attempting collection. */ function isNonceUsed( address token, address authorizer, bytes32 nonce ) external view returns (bool) { return IEIP3009(token).authorizationState(authorizer, nonce); } // ============ ERC-165 ============ function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { return interfaceId == type(IPaymentAuth).interfaceId || interfaceId == type(IERC165).interfaceId; } } ``` **`receiveWithAuthorization` and `msg.sender` constraint.** ERC-3009's `receiveWithAuthorization` requires `msg.sender == to` at the token contract level. Since `EIP3009Auth.pullPayment` calls the token directly, `msg.sender` from the token's perspective is the `EIP3009Auth` contract address. This means `recipient` in the authorization — the merchant address — MUST NOT be passed directly as `to`. Instead, the BNPL contract must be architected so that `EIP3009Auth` is the initial `to`, and the contract forwards tokens to the merchant. Alternatively, the merchant deploys `EIP3009Auth` as a contract they own, making `msg.sender == merchant` from the token's perspective. The simplest compliant deployment pattern: the merchant calls `createOrder` from their own contract, which calls `EIP3009Auth.pullPayment`. In this pattern `msg.sender` at the token level is the merchant's contract, satisfying `msg.sender == to`. Implementations MUST verify this constraint is satisfied in their deployment before going live. --- ### Part III — Core BNPL Interface #### OrderAuthorization — EIP-712 Typed Structure `OrderAuthorization` is the customer's cryptographic consent to a specific BNPL order. The merchant MUST present this structure to the customer's wallet at checkout using EIP-712 typed data signing. The customer's signature over this struct MUST be verified in `createOrder` before any tokens are moved. ```solidity // EIP-712 type string (single line, no whitespace): // "OrderAuthorization(address merchant,address token,uint256 totalAmount, // uint8 installments,uint256 downPaymentBps,uint256 installmentPeriod, // uint256 gracePeriod,uint256 lateFeeBps,address downPaymentAuthStrategy, // address installmentAuthStrategy,uint256 nonce,uint256 deadline)" struct OrderAuthorization { address merchant; // The merchant receiving payments address token; // ERC-20 token used for all payments uint256 totalAmount; // Total order value in token base units uint8 installments; // Post-down-payment installment count (2–12) uint256 downPaymentBps; // Down payment as bps of totalAmount (1000–5000) uint256 installmentPeriod; // Seconds between installments uint256 gracePeriod; // Seconds after due date before default is possible uint256 lateFeeBps; // Late fee as bps of installment amount (0–1500) address downPaymentAuthStrategy; // IPaymentAuth for the down payment address installmentAuthStrategy; // IPaymentAuth for installments uint256 nonce; // Per-customer nonce; prevents authorization replay uint256 deadline; // Unix timestamp after which this authorization expires } ``` The EIP-712 domain separator MUST be constructed as: ```solidity bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode( keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ), keccak256(bytes("BNPL")), keccak256(bytes("1")), block.chainid, address(this) )); ``` The signed digest MUST be: ```solidity // Precomputed: keccak256("OrderAuthorization(address merchant,address token,uint256 totalAmount,uint8 installments,uint256 downPaymentBps,uint256 installmentPeriod,uint256 gracePeriod,uint256 lateFeeBps,address downPaymentAuthStrategy,address installmentAuthStrategy,uint256 nonce,uint256 deadline)") bytes32 constant ORDER_AUTHORIZATION_TYPEHASH = 0x68c5443e5040df5f1ed96a455ea7294876fb94e43e188f9c3a81b67c21a9be41; bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, keccak256(abi.encode( ORDER_AUTHORIZATION_TYPEHASH, authorization.merchant, authorization.token, authorization.totalAmount, authorization.installments, authorization.downPaymentBps, authorization.installmentPeriod, authorization.gracePeriod, authorization.lateFeeBps, authorization.downPaymentAuthStrategy, authorization.installmentAuthStrategy, authorization.nonce, authorization.deadline )) )); ``` **Signature verification.** Implementations MUST support both ECDSA (EOA wallets) and [ERC-1271](./eip-1271.md) (smart contract wallets): ```solidity // For EOA wallets: address recovered = ECDSA.recover(digest, customerSig); require(recovered == customer, "BNPL: invalid signature"); // For smart contract wallets (ERC-1271): if (customer.code.length > 0) { bytes4 result = IERC1271(customer).isValidSignature(digest, customerSig); require(result == 0x1626ba7e, "BNPL: invalid contract signature"); } ``` **Nonce management.** The contract MUST maintain a per-customer nonce counter initialized to 0. Each successful `createOrder` MUST increment `authorizationNonce[customer]` by exactly 1. The nonce MUST be incremented atomically with order creation — if `createOrder` reverts for any reason after the nonce check (including a failed `pullPayment`), the nonce MUST NOT be incremented. The current nonce MUST be queryable via `authorizationNonce(address customer)`. --- #### IBNPL ```solidity // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.28; /** * @title IBNPL * @notice Standard interface for Buy Now Pay Later BNPL installment orders. * * @dev Flow overview: * * 1. At checkout, the merchant presents an OrderAuthorization EIP-712 * struct to the customer's wallet. The customer signs it. * * 2. The merchant calls createOrder(), providing the order parameters, * the customer's signature (customerSig), and authData for the down * payment (downPaymentAuthData). The contract verifies the signature, * consumes the customer's nonce, and collects the down payment atomically. * * 3. Subsequent installments are collected by calling collectInstallment(). * This may be called by the merchant, customer, or any address the * merchant has authorized via IOperatorManager. Collected tokens always * flow customer → merchant; operators never receive funds. * * 4. When all installments are paid, status transitions to Completed. * The customer may also pay off early via payOffEarly() at any time. * * 5. If a payment is not made within gracePeriod seconds of nextPaymentDue, * the merchant or an operator may call markDefaulted(). The OrderDefaulted * event carries the outstanding balance for off-chain recovery. */ interface IBNPL { // ============ Enums ============ enum OrderStatus { Active, // Order is open; installments are being collected Completed, // All installments have been paid Defaulted, // Customer failed to pay within the grace period Cancelled, // Order was cancelled before completion Refunded // Order was terminated by a merchant-issued refund. // Distinct from Cancelled: signals the order ended due to // a refund, not voluntary termination. paidAmount reflects // the net amount retained by the merchant after the refund. } // ============ Structs ============ /** * @notice Represents a BNPL installment order. * * @param customer The customer address (payer). * @param merchant The merchant address (payee). * @param token ERC-20 token used for all payments. * @param downPaymentAuthStrategy IPaymentAuth used at createOrder for the down payment. * @param installmentAuthStrategy IPaymentAuth used at each collectInstallment. * @param totalAmount Current order value in token base units. Equal to the * original order value at creation; reduced by processRefund * when a partial refund is issued against an active order. * @param paidAmount Total collected so far including down payment, net of * any refunds issued. For a Refunded order, paidAmount * reflects the net amount retained by the merchant. * @param refundedAmount Cumulative amount refunded to the customer across all * processRefund calls. Zero until a refund is issued. * @param downPaymentBps Down payment as basis points of the original totalAmount. * @param installments Total installment count excluding down payment. * @param paidInstallments Installments successfully collected so far. * @param installmentPeriod Seconds between successive installments. * @param nextPaymentDue Timestamp when the next installment is collectible. * @param gracePeriod Seconds after nextPaymentDue before default is possible. * @param lateFeeBps Late fee basis points applied to the overdue installment * if collected after nextPaymentDue but on or before * nextPaymentDue + gracePeriod. * @param status Current order lifecycle state. */ struct Order { address customer; address merchant; address token; address downPaymentAuthStrategy; address installmentAuthStrategy; uint256 totalAmount; uint256 paidAmount; uint256 refundedAmount; uint256 downPaymentBps; uint8 installments; uint8 paidInstallments; uint256 installmentPeriod; uint256 nextPaymentDue; uint256 gracePeriod; uint256 lateFeeBps; OrderStatus status; } // ============ Events ============ /** * @notice Emitted when a new order is created and down payment collected. */ event OrderCreated( bytes32 indexed orderId, address indexed customer, address indexed merchant, address token, uint256 totalAmount, uint8 installments, uint256 downPayment, address downPaymentAuthStrategy, address installmentAuthStrategy ); /** * @notice Emitted when an installment is successfully collected. * @param installmentNumber 1-indexed. First installment after down payment = 1. * @param principal Base installment amount collected. * @param lateFee Late fee collected (0 if paid on or before nextPaymentDue). * @param collector Address that called collectInstallment. */ event InstallmentCollected( bytes32 indexed orderId, uint8 indexed installmentNumber, uint256 principal, uint256 lateFee, address collector ); /** * @notice Emitted when all installments have been paid. */ event OrderCompleted(bytes32 indexed orderId); /** * @notice Emitted when an order is marked defaulted. * @param outstandingAmount totalAmount - paidAmount at time of default. */ event OrderDefaulted( bytes32 indexed orderId, uint256 outstandingAmount ); /** * @notice Emitted when an order is cancelled. * @param cancelledBy Address that initiated cancellation. * @param refundAmount Amount refunded to the customer in the same transaction. * MUST be 0 if no refund is made. MUST NOT be non-zero * unless the refund transfer has already executed. */ event OrderCancelled( bytes32 indexed orderId, address cancelledBy, uint256 refundAmount ); /** * @notice Emitted when a refund is processed against an active order. * @param orderId The order identifier. * @param refundAmount Amount transferred to the customer in this refund. * @param newTotalAmount Updated totalAmount after adjustment. Zero if fully refunded. * @param fullyRefunded True if refundAmount >= remaining balance, transitioning * the order to Refunded status. False for partial refunds * where the order remains Active with a reduced totalAmount. */ event OrderRefunded( bytes32 indexed orderId, uint256 refundAmount, uint256 newTotalAmount, bool fullyRefunded ); /** * @notice Emitted when a customer's authorization nonce is consumed. */ event AuthorizationNonceConsumed( address indexed customer, uint256 nonce ); // ============ Core Functions ============ /** * @notice Create a new BNPL order and collect the down payment atomically. * * @dev Called by the merchant. Execution order MUST be: * * Step 1 — Validate parameters: * MUST revert if installments < 2 or installments > 12 * MUST revert if downPaymentBps < 1000 or downPaymentBps > 5000 * MUST revert if lateFeeBps > 1500 * MUST revert if gracePeriod > 2592000 * MUST revert if totalAmount == 0 * MUST revert if block.timestamp > deadline * * Step 2 — Validate auth strategy addresses via ERC-165: * MUST revert if downPaymentAuthStrategy does not implement IPaymentAuth * MUST revert if installmentAuthStrategy does not implement IPaymentAuth * * Step 3 — Verify customer consent: * MUST reconstruct the OrderAuthorization digest from parameters * MUST verify authorization.nonce == authorizationNonce(customer) * MUST verify customerSig against digest using ECDSA (EOA) or ERC-1271 (contract wallet) * MUST revert if any verification fails * * Step 4 — Effects (update state before any external calls): * Increment authorizationNonce[customer] * Generate and store orderId * Store Order struct with status = Active, paidInstallments = 0 * Set nextPaymentDue = block.timestamp + installmentPeriod * Emit AuthorizationNonceConsumed * * Step 5 — Collect down payment: * downPayment = totalAmount * downPaymentBps / 10000 * Call IPaymentAuth(downPaymentAuthStrategy).pullPayment( * customer, merchant, token, downPayment, downPaymentAuthData) * MUST revert if pullPayment returns false * Set paidAmount = downPayment * * Step 6 — Emit OrderCreated * * @param customer The customer address. * @param token ERC-20 token address. * @param totalAmount Total order value in token base units. * @param installments Number of post-down-payment installments (2–12). * @param downPaymentBps Down payment basis points (1000–5000). * @param installmentPeriod Seconds between installments. * @param gracePeriod Grace period in seconds (max 2592000). * @param lateFeeBps Late fee basis points (0–1500). * @param downPaymentAuthStrategy IPaymentAuth for the down payment. * @param installmentAuthStrategy IPaymentAuth for installment collection. * @param deadline Authorization expiry timestamp. * @param customerSig Customer's EIP-712 signature over OrderAuthorization. * @param downPaymentAuthData Forwarded to downPaymentAuthStrategy.pullPayment. * @return orderId Unique identifier for the created order. */ function createOrder( address customer, address token, uint256 totalAmount, uint8 installments, uint256 downPaymentBps, uint256 installmentPeriod, uint256 gracePeriod, uint256 lateFeeBps, address downPaymentAuthStrategy, address installmentAuthStrategy, uint256 deadline, bytes calldata customerSig, bytes calldata downPaymentAuthData ) external returns (bytes32 orderId); /** * @notice Collect the next due installment for an order. * * @dev Caller MUST be the customer, merchant, or an authorized operator * for the merchant (as determined by IOperatorManager). MUST revert * for any other caller. * * Execution order MUST be: * * Step 1 — Validate: * MUST revert if order.status != Active * MUST revert if block.timestamp < order.nextPaymentDue * MUST revert if caller is not customer, merchant, or authorized operator * * Step 2 — Calculate amounts: * remaining = totalAmount - (totalAmount * downPaymentBps / 10000) * installmentAmt = remaining / installments * dust = remaining % installments * If paidInstallments == installments - 1 (final installment): * installmentAmt += dust * lateFee = 0 * If block.timestamp > nextPaymentDue AND * block.timestamp <= nextPaymentDue + gracePeriod: * lateFee = installmentAmt * lateFeeBps / 10000 * totalDue = installmentAmt + lateFee * * Step 3 — Effects: * paidInstallments += 1 * paidAmount += totalDue * nextPaymentDue += installmentPeriod * If paidInstallments == installments: status = Completed * * Step 4 — Collect: * Call IPaymentAuth(installmentAuthStrategy).pullPayment( * customer, merchant, token, totalDue, installmentAuthData) * MUST revert if pullPayment returns false * * Step 5 — Emit: * Emit InstallmentCollected * If status == Completed: emit OrderCompleted * * @param orderId The order to collect an installment for. * @param installmentAuthData Forwarded to installmentAuthStrategy.pullPayment. */ function collectInstallment( bytes32 orderId, bytes calldata installmentAuthData ) external; /** * @notice Pay off all remaining installments early with no late fee. * * @dev MUST only be callable by the customer. * MUST revert if order.status != Active. * No late fee applies regardless of current timestamp. * remaining = totalAmount - paidAmount * Calls installmentAuthStrategy.pullPayment for remaining balance. * Sets status = Completed. Emits OrderCompleted. * * @param orderId The order to pay off. * @param authData Forwarded to installmentAuthStrategy.pullPayment. */ function payOffEarly( bytes32 orderId, bytes calldata authData ) external; /** * @notice Mark an active order as defaulted after the grace period has elapsed. * * @dev MUST only be callable by the merchant or an authorized operator. * MUST revert if order.status != Active. * MUST revert if block.timestamp <= order.nextPaymentDue + order.gracePeriod. * Sets status = Defaulted. * Emits OrderDefaulted(orderId, totalAmount - paidAmount). * * @param orderId The order to mark as defaulted. */ function markDefaulted(bytes32 orderId) external; /** * @notice Cancel an active order. * * @dev MUST only be callable by the merchant or customer. * MUST revert if order.status != Active. * Sets status = Cancelled. * Emits OrderCancelled(orderId, cancelledBy, refundAmount). * * RECOMMENDED refund behavior (implementations MAY deviate but MUST * document their actual refund policy): * Customer-initiated: down payment is forfeit; any installments * collected beyond the down payment are refunded to the customer. * Merchant-initiated: all collected amounts including down payment * are refunded to the customer. * * REQUIRED invariant: if OrderCancelled emits refundAmount > 0, the * refund transfer MUST execute in the same transaction before the event * is emitted. Emitting a non-zero refundAmount without executing the * transfer is a violation of this specification. * * @param orderId The order to cancel. */ function cancelOrder(bytes32 orderId) external; /** * @notice Process a merchant-issued refund against an active order. * * @dev MUST only be callable by the merchant. * MUST revert if order.status != Active. * MUST revert if refundAmount == 0. * MUST revert if refundAmount > paidAmount (cannot refund more than collected). * * The merchant MUST hold sufficient token balance and allowance to * execute the refund transfer to the customer. Implementations MUST * use SafeERC20.safeTransferFrom(merchant, customer, refundAmount). * * Execution order MUST be: * * Step 1 — Validate: * MUST revert if order.status != Active * MUST revert if refundAmount == 0 * MUST revert if refundAmount > paidAmount * * Step 2 — Determine refund type: * remainingBalance = totalAmount - paidAmount * isFullRefund = refundAmount >= paidAmount * (full refund: customer gets back everything collected so far; * order has nothing left to collect) * * Step 3 — Effects: * refundedAmount += refundAmount * paidAmount -= refundAmount * If isFullRefund: * totalAmount = 0 * status = Refunded * Else (partial refund): * totalAmount -= refundAmount * status remains Active * Recalculate remaining installments from new totalAmount * (implementations MUST document how partial refunds affect * the installment schedule — RECOMMENDED: reduce remaining * installments proportionally by reducing totalAmount only, * keeping installmentPeriod and installments count unchanged, * which results in smaller remaining installment amounts) * * Step 4 — Transfer: * SafeERC20.safeTransferFrom(merchant, customer, refundAmount) * * Step 5 — Emit: * Emit OrderRefunded(orderId, refundAmount, newTotalAmount, isFullRefund) * * @param orderId The order to refund. * @param refundAmount Amount to refund to the customer in token base units. */ function processRefund( bytes32 orderId, uint256 refundAmount ) external; // ============ View Functions ============ /** * @notice Retrieve full order data. * @dev MUST revert if orderId does not exist. */ function getOrder(bytes32 orderId) external view returns (Order memory); /** * @notice Calculate amounts due for the next installment at current timestamp. * * @dev Uses the same calculation as collectInstallment (including dust on final * installment) so that displayed amounts always match collected amounts. * * lateFee is 0 if block.timestamp <= nextPaymentDue. * lateFee is installmentAmt * lateFeeBps / 10000 if * block.timestamp > nextPaymentDue AND * block.timestamp <= nextPaymentDue + gracePeriod. * If block.timestamp > nextPaymentDue + gracePeriod, returns the * installment amount and full late fee — order is past default threshold * but has not yet been marked defaulted. * * MUST revert if order.status != Active. * * @return principal Base installment amount (including dust on final installment). * @return lateFee Late fee at current timestamp (0 if on time). */ function getNextInstallment(bytes32 orderId) external view returns (uint256 principal, uint256 lateFee); /** * @notice Returns the remaining unpaid balance: totalAmount - paidAmount. * @dev Returns 0 for Completed and Refunded orders. */ function getRemainingBalance(bytes32 orderId) external view returns (uint256); /** * @notice Check due/grace status at current timestamp. * @return pastDue True if block.timestamp > order.nextPaymentDue. * @return pastGrace True if block.timestamp > order.nextPaymentDue + order.gracePeriod. */ function isOrderPastDue(bytes32 orderId) external view returns (bool pastDue, bool pastGrace); /** * @notice Returns the full installment schedule for an order as parallel arrays. * * @dev For uniform-schedule orders (all strategies except MerkleScheduleAuth), * the contract derives amounts from the stored Order fields and returns * complete arrays. The final installment includes dust. * * For MerkleScheduleAuth installment orders, this function MUST return * empty arrays (amounts.length == 0, dueDates.length == 0) because the * schedule is committed off-chain. Callers SHOULD retrieve the schedule * from the content-addressed store referenced by * MerkleScheduleAuth.scheduleDataCIDs(orderId) in that case. * * Index 0 is the first post-down-payment installment. * Already-collected installments MUST still appear in the arrays * so that the full schedule is always visible regardless of progress. * Callers MAY use paidInstallments from getOrder() to determine * which entries have already been collected. * * MUST revert if orderId does not exist. * Does NOT revert if order.status != Active — schedule is always readable. * * @return amounts Token amounts due for each installment in order. * amounts[installments-1] includes dust. * @return dueDates Unix timestamps when each installment becomes collectible. * dueDates[i] = order creation timestamp * + installmentPeriod * (i + 1) * Returns empty array for MerkleScheduleAuth orders. */ function getSchedule(bytes32 orderId) external view returns (uint256[] memory amounts, uint256[] memory dueDates); /** * @notice Returns the current authorization nonce for a customer. * @dev This value MUST be included in the OrderAuthorization the customer signs. * Increments by 1 after each successful createOrder. */ function authorizationNonce(address customer) external view returns (uint256); } ``` **ERC-165 interface identifier.** The `IBNPL` interface identifier for ERC-165 introspection is the XOR of all function selectors defined in `IBNPL`. Individual selectors and the computed value are listed below for independent verification: ``` createOrder(address,address,uint256,uint8,uint256,uint256,uint256,uint256,address,address,uint256,bytes,bytes) = 0x636122cc collectInstallment(bytes32,bytes) = 0x1278b53e payOffEarly(bytes32,bytes) = 0x8adb8505 markDefaulted(bytes32) = 0xecb8bab3 cancelOrder(bytes32) = 0x7489ec23 processRefund(bytes32,uint256) = 0xbe0e01c1 getOrder(bytes32) = 0x5778472a getNextInstallment(bytes32) = 0x67391c52 getSchedule(bytes32) = 0x3adc277a getRemainingBalance(bytes32) = 0x765c328b isOrderPastDue(bytes32) = 0x32089582 authorizationNonce(address) = 0x06a82705 IBNPL interfaceId = 0x959cb9a8 ``` ```solidity bytes4 public constant IBNPL_INTERFACE_ID = 0x959cb9a8; ``` Implementations MUST return `true` for `0x959cb9a8` when `supportsInterface` is called. This constant MUST be recomputed if the `IBNPL` interface is extended in a future version of this ERC. --- ### Part IV — Supporting Specifications #### Order ID Generation Order identifiers MUST be generated as: ```solidity bytes32 orderId = keccak256(abi.encode( customer, merchant, token, totalAmount, installments, downPaymentBps, installmentPeriod, block.timestamp, authorizationNonce[customer] // value before increment )); ``` Including all order-defining parameters in the derivation ensures the `orderId` is a complete fingerprint of the order terms. Two orders that differ only in installment count, down payment percentage, or period produce distinct identifiers, which is important for off-chain indexing and dispute resolution. The nonce still guarantees uniqueness even if all other parameters are identical. #### Parameter Constraints Implementations MUST enforce these bounds in `createOrder` and MUST revert if any constraint is violated: | Parameter | Minimum | Maximum | |------------------|----------------------|----------------------------| | `installments` | 2 | 12 | | `downPaymentBps` | 1000 (10%) | 5000 (50%) | | `lateFeeBps` | 0 | 1500 (15%) | | `gracePeriod` | 0 | 2,592,000 seconds (30 days)| | `totalAmount` | 1 | unbounded | | `deadline` | block.timestamp + 1 | unbounded | #### Installment Amount and Dust ```solidity uint256 downPayment = totalAmount * downPaymentBps / 10000; uint256 remaining = totalAmount - downPayment; uint256 installmentAmt = remaining / installments; uint256 dust = remaining % installments; // dust is added to the final installment only uint256 lastInstallment = installmentAmt + dust; ``` `getNextInstallment` and `collectInstallment` MUST share a single internal calculation function to guarantee consistency. #### Authorization Strategy Combinations | Down payment | Installments | Notes | |--------------------|-------------------------|------------------------------------------------------------------------------| | `Permit2Auth` | `AllowanceAuth` | Recommended for most deployments. Any ERC-20, gasless checkout, auto collect. Requires one-time Permit2 approval. | | `EIP3009Auth` | `AllowanceAuth` | Recommended for USDC. No allowance at any stage, front-running protection. USDC/EURC only. | | `AllowanceAuth` | `AllowanceAuth` | Simplest. One ERC-20 approval covers everything. No signature at checkout. | | `PermitAuth` | `AllowanceAuth` | ERC-2612 tokens only (DAI, UNI, newer tokens). No Permit2 dependency. | | `Permit2Auth` | `Permit2Auth` | Maximum security. No standing approval at any stage. Pre-sign all installments at checkout. | | `EIP3009Auth` | `EIP3009Auth` | Maximum security for USDC. No allowance at any stage. Pre-sign all installments at checkout. | | `Permit2Auth` | `MerkleScheduleAuth` | Variable-amount installments with gasless checkout for any ERC-20. | | `AllowanceAuth` | `MerkleScheduleAuth` | Variable-amount installments with standing approval. | Implementations MUST accept `downPaymentAuthStrategy == installmentAuthStrategy`. `MerkleScheduleAuth` MUST NOT be used as `downPaymentAuthStrategy`. The down payment amount is always known at order creation and does not benefit from merkle commitment. Using it as a down payment strategy adds unnecessary complexity and proof verification cost at the most latency-sensitive point of the checkout flow. `EIP3009Auth` is restricted to tokens that implement ERC-3009 natively. Using it with a non-ERC-3009 token will revert at the `receiveWithAuthorization` call. Implementations MUST document which token they deploy `EIP3009Auth` for and MUST NOT present it as a universal option to merchants. --- ## Rationale ### Fully self-contained standard All authorization infrastructure (`IPaymentAuth`, `IOperatorManager`, auth strategy contracts) is defined within this ERC rather than depending on an unpublished companion standard. This ensures the ERC can reach Final status independently and implementors have a single document to reference. ### Merchant calls `createOrder` with customer EIP-712 signature E-commerce checkout flows are merchant-orchestrated. The merchant's backend constructs the order, presents it to the customer for signing, and submits the transaction. Requiring the customer to call `createOrder` directly (Option A) is incompatible with standard checkout UX. A two-step merchant-create / customer-confirm flow (Option C) requires a second on-chain transaction and forces the customer to return to the interface — also incompatible. Option B — merchant submits, customer consent proven via EIP-712 — is the correct model. The customer signs a human-readable summary of exact order terms. The merchant submits. Consent and down payment are verified and collected atomically. The customer never interacts with the contract again unless they initiate early payoff or cancellation. ### EIP-712 over a simple hash Full EIP-712 typed data signing rather than a simple `keccak256` of packed parameters is required because: wallets render typed data in human-readable form enabling informed consent; domain separators prevent cross-chain and cross-contract replay; the type string is fixed and auditable. A raw hash provides no UI-layer consent signal. ### ERC-1271 support [ERC-4337](./eip-4337.md) smart contract wallets (Coinbase Smart Wallet, Safe, Argent) are an increasingly common consumer wallet type. ECDSA-only signature verification would exclude these customers. ERC-1271 support is mandatory so the ERC works with the full range of modern wallet types from day one. ### Split authorization strategies A single strategy for both phases forces a tradeoff: either signature-based (requiring customer action per installment, poor post-checkout UX) or allowance-based (requiring a standing approval at checkout, weaker security). Splitting `downPaymentAuthStrategy` and `installmentAuthStrategy` resolves this — the recommended combination (`Permit2Auth` + `AllowanceAuth`) delivers a clean signature checkout with fully automatic collection. Allowing the same strategy for both (`downPaymentAuthStrategy == installmentAuthStrategy`) preserves simplicity for deployments that don't need the split. ### `IPaymentAuth` abstraction Hardcoding token movement (`transferFrom`, `permit`, Permit2, ERC-3009) into the core interface would force every deployment to use the same approval model regardless of token ecosystem or UX requirements. `IPaymentAuth` keeps the order state machine stable while allowing authorization mechanics to evolve. `Permit2Auth` is the recommended default for most e-commerce BNPL deployments — it works with any ERC-20 token, including USDT and other tokens that predate ERC-2612, and is already deployed as canonical infrastructure across all major EVM networks. `EIP3009Auth` is the preferred strategy for USDC deployments specifically: it uses ERC-3009 `receiveWithAuthorization` which creates no allowance at any stage, is atomic, and prevents front-running through the `msg.sender == to` enforcement in the token contract itself. `PermitAuth` is a lighter alternative for deployments that control the token contract and want no external dependency. `AllowanceAuth` covers simple or internal deployments. `MerkleScheduleAuth` covers variable-amount installment schedules. The ERC accommodates all of these without mandating a specific strategy. ### ERC-165 validation on strategy addresses `createOrder` validates both strategy addresses against ERC-165 before storing them. An invalid strategy address stored in an order creates a permanently broken order — the down payment is collected but no further installments can ever be pulled. ERC-165 validation at creation time costs one external call and prevents this irreversible mistake. ### Late fees apply between `nextPaymentDue` and `nextPaymentDue + gracePeriod` The grace period is a default-prevention buffer, not a free extension of the payment date. A customer who pays one day late incurs the same fee as one who pays 29 days late (within a 30-day grace). This incentivizes timely payment throughout the grace window and matches the common real-world model where late fees accrue from the due date. Implementations MUST NOT apply late fees to payments made on or before `nextPaymentDue`. ### Dust on the final installment Assigning integer division remainder to the final installment means the first N-1 installments have identical amounts. This simplifies operator/keeper logic — uniform installment amounts are the expected case, and the slightly-larger final installment is a known, documented exception. This matches the approach used by most real-world installment lending products. ### `getSchedule` for wallet and frontend display Wallets and checkout UIs need to display the full payment schedule to the customer — not just the next installment. Without `getSchedule`, frontends must reconstruct the schedule off-chain from `Order` struct fields, which requires duplicating the dust calculation and is error-prone. A standard on-chain derivation guarantees consistency with what `collectInstallment` will actually collect. For `MerkleScheduleAuth` orders, returning empty arrays is the correct behavior rather than reverting or attempting to reconstruct from the root — the leaf set is off-chain by design and the `dataCID` is the correct pointer for wallets to use. This asymmetry is explicitly documented in the function natspec so implementations cannot silently get it wrong. ### `SafeERC20` for non-standard token compatibility `IERC20.transferFrom` is specified to return `bool`, but several widely-used tokens — most notably USDT on Ethereum mainnet — do not return any value from `transferFrom`. Calling `bool ok = token.transferFrom(...)` on these tokens causes an ABI decoding revert because Solidity attempts to decode the empty return data as `bool`. `SafeERC20.safeTransferFrom` handles this correctly by checking return data length before attempting to decode. All strategies that call `transferFrom` directly (`AllowanceAuth`, `PermitAuth`, `MerkleScheduleAuth`) MUST use `SafeERC20`. `Permit2Auth` is exempt because Permit2 handles the transfer internally using its own safe transfer logic. Standard e-commerce BNPL uses uniform installments derivable from `(totalAmount, downPaymentBps, installments)`. No merkle tree is needed for this case and implementations SHOULD use simpler strategies. `MerkleScheduleAuth` is appropriate for the subset of BNPL products where amounts genuinely vary — promotional front-loaded discounts, weighted splits, or schedule structures not expressible by division. The merkle approach is chosen over storing the full schedule on-chain because: a 12-installment schedule stored as 12 `(amount, dueDate)` pairs costs significant storage gas at order creation; the merkle root is a single `bytes32` commitment regardless of schedule length; and individual installment verification is O(log n) in proof size. `installmentIndex` is included in each leaf to guarantee uniqueness even when two installments share the same amount and due date. Without the index, identical `(amount, dueDate)` pairs produce the same leaf hash and the second cannot be distinguished from the first — a subtle but real correctness failure for promotional schedules with matching installment amounts. Schedule registration is separated from `createOrder` (via `registerSchedule` in a multicall) rather than embedded in `createOrder` parameters to preserve the `IBNPL` interface without modification. The `orderId` is deterministic and pre-computable, making atomic multicall composition straightforward. The mandatory `dataCID` field forces merchants to commit to a content-addressed copy of the full leaf set on-chain. Without this, a merchant who loses their off-chain leaf data creates a permanently stuck order — the root is committed but no proofs can be constructed. The CID ensures the schedule can always be reconstructed from public data. ### `Refunded` state and `processRefund` E-commerce BNPL orders are frequently subject to partial or full refunds after goods are returned. The existing `cancelOrder` function terminates an order and issues a refund, but it provides no way to handle a partial return — a customer returning some items mid-order without cancelling the remaining installments. `processRefund` fills this gap. The distinction between `Cancelled` and `Refunded` is semantically meaningful for off-chain systems. `Cancelled` signals voluntary termination — either party chose to end the order. `Refunded` signals that the order ended because a refund was issued that covered all remaining collected amounts. A backoffice system, analytics pipeline, or customer service tool can distinguish "customer abandoned their order" from "merchant processed a return" without parsing event logs. For **partial refunds** (the more common e-commerce case), `processRefund` reduces `totalAmount` without terminating the order. The order remains `Active` and the remaining installments are collected against the reduced total. This avoids the operational friction of cancelling and recreating an order when a customer returns one of several items. The `refundedAmount` field in the `Order` struct gives a cumulative view of all refunds issued against an order, which is useful for merchant accounting. The `processRefund` function is merchant-only. Customers cannot initiate a refund — they can only cancel an order (terminating it entirely). This mirrors real-world BNPL operations where refunds are merchant-initiated after return processing. The refund transfer uses `safeTransferFrom(merchant, customer, refundAmount)` — the merchant must hold the token balance and have approved the contract. This is intentional: refunds should require active merchant participation, not be executable against a merchant who has already withdrawn their collected funds. ### Off-chain default recovery BNPL inherently accepts credit risk. Recovery options (debt collection, service termination, credit reporting) depend on legal jurisdiction, customer agreement terms, and platform capabilities — none of which can be standardized at the contract interface level. The `OrderDefaulted` event with `outstandingAmount` gives off-chain systems everything they need: a specific order ID, the customer address (queryable via `getOrder`), and the balance owed. ### `cancelOrder` refund behavior is RECOMMENDED not REQUIRED Refund policy is a commercial decision that varies by merchant and purchase category. The RECOMMENDED behavior (down payment forfeit on customer cancellation, full refund on merchant cancellation) represents the most common consumer-fair default. The one hard invariant is that a non-zero `refundAmount` in `OrderCancelled` MUST be accompanied by an actual transfer in the same transaction — emitting a refund promise without executing it is misleading to event observers. --- ## Backwards Compatibility This ERC introduces new interfaces and does not modify any existing standard. - **ERC-20**: All auth strategies call token transfer functions. `AllowanceAuth`, `PermitAuth`, and `MerkleScheduleAuth` use `SafeERC20.safeTransferFrom` to support tokens that do not return `bool` (e.g. USDT). No token contract modifications are required. - **ERC-165**: Implementations MUST support ERC-165 for `IBNPL` and all `IPaymentAuth` implementations. - **EIP-712**: `OrderAuthorization` follows the EIP-712 specification precisely. No wallet infrastructure changes required. - **ERC-1271**: `createOrder` MUST implement ERC-1271 for smart contract wallet compatibility. - **ERC-2612**: `PermitAuth` is compatible with any ERC-2612 token. - **ERC-3009**: `EIP3009Auth` is compatible with tokens implementing ERC-3009 `receiveWithAuthorization`, primarily USDC v2+ and EURC. ERC-3009 remains in Draft status; implementations SHOULD monitor for spec changes. - **Permit2**: `Permit2Auth` is compatible with any ERC-20 token after a one-time Permit2 approval. The canonical Permit2 address `0x000000000022D473030F116dDEE9F6B43aC78BA3` is used. - **OpenZeppelin MerkleProof**: `MerkleScheduleAuth` uses OpenZeppelin's `MerkleProof.verify` which implements sorted-sibling binary merkle verification. Any off-chain proof generation library MUST produce proofs compatible with this convention. --- ## Reference Implementation A reference implementation is available at: `https://github.com/asghaier76/BNPL` The repository includes: - `interfaces/IPaymentAuth.sol` — authorization abstraction interface - `interfaces/IOperatorManager.sol` — operator management interface - `interfaces/IBNPL.sol` — core BNPL interface - `strategies/AllowanceAuth.sol` — ERC-20 allowance strategy - `strategies/PermitAuth.sol` — ERC-2612 permit strategy - `strategies/Permit2Auth.sol` — Uniswap Permit2 strategy - `strategies/EIP3009Auth.sol` — ERC-3009 receiveWithAuthorization strategy (USDC/EURC) - `strategies/MerkleScheduleAuth.sol` — variable-schedule merkle strategy - `BNPLManager.sol` — full `IBNPL` implementation with EIP-712 and ERC-1271 - `test/BNPLManager.t.sol` — Foundry test suite covering all state transitions, dust handling, all strategy combinations, ERC-1271 wallets, and griefing scenarios - `test/MerkleScheduleAuth.t.sol` — merkle test vectors, leaf consumption, replay prevention, and proof verification - `test/EIP3009Auth.t.sol` — ERC-3009 authorization flow, nonce consumption, front-running protection, and validity window tests - `scripts/OrderAuthorization.ts` — TypeScript utilities for constructing and signing `OrderAuthorization` at checkout - `scripts/MerkleSchedule.ts` — TypeScript utilities for building merkle trees, generating proofs, and encoding `installmentAuthData` for `MerkleScheduleAuth` - `scripts/EIP3009Authorization.ts` — TypeScript utilities for constructing and signing `ReceiveWithAuthorization` messages for USDC/EURC - Deployment scripts for Ethereum mainnet, Arbitrum, Base, and Optimism --- ## Security Considerations ### Customer consent is the primary attack surface The `OrderAuthorization` signature check is the mechanism preventing merchants from creating orders the customer did not agree to. Implementations MUST verify all four conditions before any token movement: 1. Recovered signer equals `customer` (ECDSA) or ERC-1271 magic value returned (contract wallet) 2. `block.timestamp <= deadline` — authorization not expired 3. `nonce == authorizationNonce[customer]` — authorization not replayed 4. Digest is reconstructed from the exact parameters passed to `createOrder` — no parameter substitution possible Checks-effects-interactions ordering in `createOrder` (Steps 1–4 = checks and effects, Step 5 = interaction) means a failed `pullPayment` reverts the entire transaction including the nonce increment, maintaining consistency. ### ERC-165 strategy validation Both `downPaymentAuthStrategy` and `installmentAuthStrategy` MUST be validated via ERC-165 in `createOrder`. An invalid address stored in an order is a permanent, unrecoverable mistake — the down payment is collected but subsequent collection is impossible. The one-time gas cost of ERC-165 calls at order creation is always worth it. ### Reentrancy All state-modifying functions call external contracts (`pullPayment`, optional `IPaymentReceiver` callbacks). Implementations MUST: - Apply checks-effects-interactions: all storage updates MUST complete before any external call - Use a reentrancy guard (`ReentrancyGuard`) on `createOrder`, `collectInstallment`, `payOffEarly`, and `cancelOrder` ### Operator privilege boundary Operators may call `collectInstallment` and `markDefaulted` only. They MUST NOT be able to call `cancelOrder` or `payOffEarly`, and they MUST NOT receive collected funds. All token flows are customer → merchant regardless of who triggers collection. A compromised operator key can at most collect an installment early (the customer owes the money anyway) or trigger default after grace (the merchant has the commercial relationship to resolve disputes). ### `markDefaulted` griefing resistance `markDefaulted` restricted to merchant and authorized operators prevents third-party griefing: an arbitrary address marking a legitimately current order as defaulted one second past the grace window disrupts the customer's access to services before the merchant has intervened. The merchant-only restriction ensures the entity with the commercial relationship controls when default consequences are invoked. ### Nonce replay prevention The per-customer nonce ensures a signed `OrderAuthorization` cannot be replayed to create a second order. Nonces are strictly incrementing — there is no mechanism to skip, reset, or reuse a nonce. The nonce is included in the EIP-712 digest, so a signature over nonce N cannot be used when `authorizationNonce[customer]` is N+1 or higher. ### Authorization deadline Implementations SHOULD recommend merchants set `deadline` to no more than 30 minutes from checkout presentation. A long deadline increases the replay window for a stolen or abandoned authorization. A short deadline limits exposure if the customer abandons the checkout flow. ### Cross-chain replay The EIP-712 domain separator includes `block.chainid` and `address(this)`. A signature for a BNPL contract on Arbitrum cannot be replayed against the same contract on Ethereum mainnet or against a different BNPL contract on the same chain. Implementations MUST NOT cache the domain separator — it must be recomputed on each verification to handle chain forks correctly. ### AllowanceAuth standing approval risk When `installmentAuthStrategy` is `AllowanceAuth`, the customer grants a standing ERC-20 allowance to the BNPL contract. Implementations SHOULD: 1. Document this clearly to customers at checkout 2. Recommend allowance = `totalAmount - downPayment` rather than `type(uint256).max` 3. Consider automating allowance revocation in `cancelOrder` and upon `OrderCompleted` if the token supports zero-approval resets ### `MerkleScheduleAuth` leaf replay and data availability `MerkleScheduleAuth` maintains a `consumed[orderId][leafHash]` mapping. A leaf hash is marked consumed before the `transferFrom` call (checks-effects-interactions). This prevents two attacks: replay of the same proof within the same transaction via reentrancy, and replay of the same proof in a later transaction. The mapping is keyed by `orderId` so the same `(installmentIndex, amount, dueDate)` tuple in two different orders does not cause cross-order leaf collision. If the merchant fails to publish the full leaf set to the registered `dataCID` location, the customer has no way to independently verify the schedule they signed. Implementations SHOULD surface the `dataCID` in checkout UI so customers can verify the schedule before signing the `OrderAuthorization`. The `ScheduleRegistered` event makes the CID permanently observable on-chain. The `registerSchedule` function MUST revert if called a second time for the same `orderId`. This prevents a merchant from silently replacing a committed schedule after the customer has signed — a committed root is immutable for the lifetime of the order. ### `MerkleScheduleAuth` amount validation and composability `MerkleScheduleAuth.pullPayment` requires `amount == leafAmount` — the caller-supplied `amount` must exactly match the leaf amount decoded from `authData`. This is consistent with every other `IPaymentAuth` strategy and is required for safe composability: any contract that wraps or builds on top of `IBNPL` expects the `amount` it passes to `pullPayment` to equal the amount actually transferred. A strategy that silently overrides the caller's `amount` with a different value would cause accounting errors in composing systems. The practical consequence: any keeper, operator, or external contract calling `collectInstallment` on a `MerkleScheduleAuth` order MUST decode `leafAmount` from the `installmentAuthData` bytes and pass it as the `amount` argument. This is not additional work — any system constructing `installmentAuthData` already knows all the leaf fields. The TypeScript SDK utilities in `scripts/MerkleSchedule.ts` handle this correctly in `encodeMerkleAuthData`. ### `EIP3009Auth` — `receiveWithAuthorization` and `msg.sender` constraint `EIP3009Auth` uses `receiveWithAuthorization` rather than `transferWithAuthorization`. The critical difference: `receiveWithAuthorization` enforces `msg.sender == to` at the token contract level. This prevents front-running — an attacker who extracts the signed authorization from the mempool cannot redirect the transfer, because any transaction they submit would have `msg.sender != to` unless they are the designated recipient. The implication for deployment: since `EIP3009Auth.pullPayment` is the direct caller of the token contract, `msg.sender` from the token's perspective is the `EIP3009Auth` contract address. This means the `to` field in the customer's signed `ReceiveWithAuthorization` message MUST equal the `EIP3009Auth` contract address — not the merchant address. The BNPL contract or merchant system must account for this by having `EIP3009Auth` forward the tokens onward, or by structuring the call chain so the merchant contract is the direct caller. Implementations MUST document their exact call chain and verify it satisfies `msg.sender == to` before deploying to production. ### `EIP3009Auth` nonce management ERC-3009 uses random 32-byte nonces (as opposed to ERC-2612's incrementing integer nonces). This means multiple authorizations can coexist and be submitted in any order without collision — a customer can pre-sign all installment authorizations at checkout with different random nonces, and the merchant can submit them in any order. However, nonces are permanent once consumed — the token contract records used nonces in a mapping and they cannot be reused. Implementations SHOULD use a cryptographically random 32-byte value for each nonce and SHOULD verify via `authorizationState` that the nonce has not already been consumed before attempting collection. ### `EIP3009Auth` validity window The `validAfter` and `validBefore` fields in a `ReceiveWithAuthorization` authorization define a time window during which the authorization is valid. Merchants SHOULD set `validAfter = 0` (immediately valid) and `validBefore` to a reasonable deadline — 30 minutes for down payments, or the expected collection timestamp plus a buffer for installments. A `validBefore` in the far future increases the exposure window if the authorization is leaked. Pre-signing all installment authorizations at checkout requires setting appropriate `validBefore` for each one — not a single shared deadline. Several widely deployed ERC-20 tokens — most notably USDT on Ethereum mainnet — deviate from the ERC-20 specification by not returning a `bool` value from `transferFrom`. Calling `bool ok = IERC20(token).transferFrom(...)` on these tokens reverts during ABI decoding of the empty return data, silently breaking collection for any deployment that uses these tokens. All strategies that call `transferFrom` directly MUST use `SafeERC20.safeTransferFrom` from OpenZeppelin. `Permit2Auth` is exempt because Permit2 handles the transfer internally with its own safe transfer logic. Custom `IPaymentAuth` strategies MUST also use `SafeERC20` or equivalent safe transfer handling. Implementations that call `IERC20.transferFrom` directly are non-compliant with this ERC. ### `processRefund` — partial refund schedule consistency When `processRefund` issues a partial refund and reduces `totalAmount`, the remaining installment amounts must be recalculated consistently. Implementations MUST use the same internal calculation function as `collectInstallment` and `getNextInstallment` — applying it to the new `totalAmount`. If the calculation function is not shared, a partial refund can cause `getNextInstallment` to return a different amount than `collectInstallment` will collect, breaking wallet display. A partial refund where `refundAmount > remainingBalance` but `refundAmount <= paidAmount` is valid — the merchant is refunding some already-collected amount while the order remains active. The effect is that `totalAmount` decreases and future installments shrink proportionally. Implementations MUST handle this case without reverting. `processRefund` MUST NOT be callable on `Completed`, `Cancelled`, `Defaulted`, or `Refunded` orders. A completed order has been fully paid — there is nothing to adjust. For any terminated order, the merchant should issue the refund through their own systems rather than through the contract. ### `processRefund` — merchant approval requirement `processRefund` calls `safeTransferFrom(merchant, customer, refundAmount)`. This requires the merchant to have an active ERC-20 approval on the BNPL contract for at least `refundAmount` of the order token. Merchants who immediately withdraw collected funds after each installment will need to re-approve before issuing a refund. Implementations SHOULD document this requirement clearly in their merchant-facing SDK and dashboard. A common pattern is to maintain a merchant deposit equal to the maximum potential refund liability, approved to the contract in advance. ### Integer overflow in amount calculation All amount calculations MUST use Solidity 0.8.x checked arithmetic or equivalent. In particular: - `totalAmount * downPaymentBps` can overflow for large orders with large basis points — implementations SHOULD use `mulDiv` from a safe math library (e.g. OpenZeppelin `Math.mulDiv`) for basis point calculations - `installmentAmt * lateFeeBps` is similarly at risk for large installment amounts --- ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).