EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8214: Buy Now Pay Later (BNPL)

An interface for on-chain BNPL installment orders with EIP-712 consent and pluggable payment authorization

Authors
Created2026-03-31
Discussion Linkhttps://ethereum-magicians.org/t/erc-8214-buy-now-pay-later-bnpl/28116
Requires

Markdown

https://raw.githubusercontent.com/asghaier76/ERCs/...

EIP-GPT summary

Contents
AbstractMotivationUse CasesSpecificationPart I — Authorization InfrastructureIPaymentAuthIOperatorManagerPart II — Reference Authorization StrategiesStrategy 1 — AllowanceAuthStrategy 2 — PermitAuthStrategy 3 — Permit2AuthStrategy 4 — MerkleScheduleAuthStrategy 5 — EIP3009AuthPart III — Core BNPL InterfaceOrderAuthorization — EIP-712 Typed StructureIBNPLPart IV — Supporting SpecificationsOrder ID GenerationParameter ConstraintsInstallment Amount and DustAuthorization Strategy CombinationsRationaleFully self-contained standardMerchant calls createOrder with customer EIP-712 signatureEIP-712 over a simple hashERC-1271 supportSplit authorization strategiesIPaymentAuth abstractionERC-165 validation on strategy addressesLate fees apply between nextPaymentDue and nextPaymentDue + gracePeriodDust on the final installmentgetSchedule for wallet and frontend displaySafeERC20 for non-standard token compatibilityRefunded state and processRefundOff-chain default recoverycancelOrder refund behavior is RECOMMENDED not REQUIREDBackwards CompatibilityReference ImplementationSecurity ConsiderationsCustomer consent is the primary attack surfaceERC-165 strategy validationReentrancyOperator privilege boundarymarkDefaulted griefing resistanceNonce replay preventionAuthorization deadlineCross-chain replayAllowanceAuth standing approval riskMerkleScheduleAuth leaf replay and data availabilityMerkleScheduleAuth amount validation and composabilityEIP3009Auth — receiveWithAuthorization and msg.sender constraintEIP3009Auth nonce managementEIP3009Auth validity windowprocessRefund — partial refund schedule consistencyprocessRefund — merchant approval requirementInteger overflow in amount calculationCopyright

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 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 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 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 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.

// 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.

// 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.

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.

// 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.

// 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.

// 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:

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:

// 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.

IndexAmount (uint256)Due date (uint256)
05000001740000000
17500001742592000
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.

// 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.

// 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.

// 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:

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:

// 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 (smart contract wallets):

// 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

// 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
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:

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:

ParameterMinimumMaximum
installments212
downPaymentBps1000 (10%)5000 (50%)
lateFeeBps01500 (15%)
gracePeriod02,592,000 seconds (30 days)
totalAmount1unbounded
deadlineblock.timestamp + 1unbounded

Installment Amount and Dust

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 paymentInstallmentsNotes
Permit2AuthAllowanceAuthRecommended for most deployments. Any ERC-20, gasless checkout, auto collect. Requires one-time Permit2 approval.
EIP3009AuthAllowanceAuthRecommended for USDC. No allowance at any stage, front-running protection. USDC/EURC only.
AllowanceAuthAllowanceAuthSimplest. One ERC-20 approval covers everything. No signature at checkout.
PermitAuthAllowanceAuthERC-2612 tokens only (DAI, UNI, newer tokens). No Permit2 dependency.
Permit2AuthPermit2AuthMaximum security. No standing approval at any stage. Pre-sign all installments at checkout.
EIP3009AuthEIP3009AuthMaximum security for USDC. No allowance at any stage. Pre-sign all installments at checkout.
Permit2AuthMerkleScheduleAuthVariable-amount installments with gasless checkout for any ERC-20.
AllowanceAuthMerkleScheduleAuthVariable-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 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.

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

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 and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth