Abstract
This ERC defines a standard interface for Buy Now Pay Later (BNPL) installment orders on EVM-compatible blockchains. The standard specifies:
- 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 - Operator management (
IOperatorManager): Trusted addresses that may trigger installment collection on behalf of merchants without receiving funds - Order lifecycle (
IBNPL): A state machine covering order creation, installment collection, early payoff, default, and cancellation - Customer consent (
OrderAuthorization): An EIP-712 typed structure the customer signs at checkout, cryptographically binding them to specific order terms before any tokens move - Split authorization strategies: Independent
IPaymentAuthimplementations 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.
| Index | Amount (uint256) | Due date (uint256) |
|---|---|---|
| 0 | 500000 | 1740000000 |
| 1 | 750000 | 1742592000 |
Leaf0 = keccak256(abi.encode(uint256(0), uint256(500000), uint256(1740000000)))
Leaf1 = keccak256(abi.encode(uint256(1), uint256(750000), uint256(1742592000)))
Root = keccak256(abi.encode(min(Leaf0,Leaf1), max(Leaf0,Leaf1)))Typical use: installmentAuthStrategy for variable-amount BNPL schedules. Not recommended as downPaymentAuthStrategy — the down payment amount is always known at order creation and does not benefit from merkle commitment.
// 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 = 0x959cb9a8bytes4 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:
| Parameter | Minimum | Maximum |
|---|---|---|
installments | 2 | 12 |
downPaymentBps | 1000 (10%) | 5000 (50%) |
lateFeeBps | 0 | 1500 (15%) |
gracePeriod | 0 | 2,592,000 seconds (30 days) |
totalAmount | 1 | unbounded |
deadline | block.timestamp + 1 | unbounded |
Installment Amount and Dust
uint256 downPayment = totalAmount * downPaymentBps / 10000;
uint256 remaining = totalAmount - downPayment;
uint256 installmentAmt = remaining / installments;
uint256 dust = remaining % installments;
// dust is added to the final installment only
uint256 lastInstallment = installmentAmt + dust;getNextInstallment and collectInstallment MUST share a single internal calculation function to guarantee consistency.
Authorization Strategy Combinations
| Down payment | Installments | Notes |
|---|---|---|
Permit2Auth | AllowanceAuth | Recommended for most deployments. Any ERC-20, gasless checkout, auto collect. Requires one-time Permit2 approval. |
EIP3009Auth | AllowanceAuth | Recommended for USDC. No allowance at any stage, front-running protection. USDC/EURC only. |
AllowanceAuth | AllowanceAuth | Simplest. One ERC-20 approval covers everything. No signature at checkout. |
PermitAuth | AllowanceAuth | ERC-2612 tokens only (DAI, UNI, newer tokens). No Permit2 dependency. |
Permit2Auth | Permit2Auth | Maximum security. No standing approval at any stage. Pre-sign all installments at checkout. |
EIP3009Auth | EIP3009Auth | Maximum security for USDC. No allowance at any stage. Pre-sign all installments at checkout. |
Permit2Auth | MerkleScheduleAuth | Variable-amount installments with gasless checkout for any ERC-20. |
AllowanceAuth | MerkleScheduleAuth | Variable-amount installments with standing approval. |
Implementations MUST accept downPaymentAuthStrategy == installmentAuthStrategy.
MerkleScheduleAuth MUST NOT be used as downPaymentAuthStrategy. The down payment amount is always known at order creation and does not benefit from merkle commitment. Using it as a down payment strategy adds unnecessary complexity and proof verification cost at the most latency-sensitive point of the checkout flow.
EIP3009Auth is restricted to tokens that implement ERC-3009 natively. Using it with a non-ERC-3009 token will revert at the receiveWithAuthorization call. Implementations MUST document which token they deploy EIP3009Auth for and MUST NOT present it as a universal option to merchants.
Rationale
Fully self-contained standard
All authorization infrastructure (IPaymentAuth, IOperatorManager, auth strategy contracts) is defined within this ERC rather than depending on an unpublished companion standard. This ensures the ERC can reach Final status independently and implementors have a single document to reference.
Merchant calls createOrder with customer EIP-712 signature
E-commerce checkout flows are merchant-orchestrated. The merchant's backend constructs the order, presents it to the customer for signing, and submits the transaction. Requiring the customer to call createOrder directly (Option A) is incompatible with standard checkout UX. A two-step merchant-create / customer-confirm flow (Option C) requires a second on-chain transaction and forces the customer to return to the interface — also incompatible.
Option B — merchant submits, customer consent proven via EIP-712 — is the correct model. The customer signs a human-readable summary of exact order terms. The merchant submits. Consent and down payment are verified and collected atomically. The customer never interacts with the contract again unless they initiate early payoff or cancellation.
EIP-712 over a simple hash
Full EIP-712 typed data signing rather than a simple keccak256 of packed parameters is required because: wallets render typed data in human-readable form enabling informed consent; domain separators prevent cross-chain and cross-contract replay; the type string is fixed and auditable. A raw hash provides no UI-layer consent signal.
ERC-1271 support
ERC-4337 smart contract wallets (Coinbase Smart Wallet, Safe, Argent) are an increasingly common consumer wallet type. ECDSA-only signature verification would exclude these customers. ERC-1271 support is mandatory so the ERC works with the full range of modern wallet types from day one.
Split authorization strategies
A single strategy for both phases forces a tradeoff: either signature-based (requiring customer action per installment, poor post-checkout UX) or allowance-based (requiring a standing approval at checkout, weaker security). Splitting downPaymentAuthStrategy and installmentAuthStrategy resolves this — the recommended combination (Permit2Auth + AllowanceAuth) delivers a clean signature checkout with fully automatic collection.
Allowing the same strategy for both (downPaymentAuthStrategy == installmentAuthStrategy) preserves simplicity for deployments that don't need the split.
IPaymentAuth abstraction
Hardcoding token movement (transferFrom, permit, Permit2, ERC-3009) into the core interface would force every deployment to use the same approval model regardless of token ecosystem or UX requirements. IPaymentAuth keeps the order state machine stable while allowing authorization mechanics to evolve.
Permit2Auth is the recommended default for most e-commerce BNPL deployments — it works with any ERC-20 token, including USDT and other tokens that predate ERC-2612, and is already deployed as canonical infrastructure across all major EVM networks. EIP3009Auth is the preferred strategy for USDC deployments specifically: it uses ERC-3009 receiveWithAuthorization which creates no allowance at any stage, is atomic, and prevents front-running through the msg.sender == to enforcement in the token contract itself. PermitAuth is a lighter alternative for deployments that control the token contract and want no external dependency. AllowanceAuth covers simple or internal deployments. MerkleScheduleAuth covers variable-amount installment schedules. The ERC accommodates all of these without mandating a specific strategy.
ERC-165 validation on strategy addresses
createOrder validates both strategy addresses against ERC-165 before storing them. An invalid strategy address stored in an order creates a permanently broken order — the down payment is collected but no further installments can ever be pulled. ERC-165 validation at creation time costs one external call and prevents this irreversible mistake.
Late fees apply between nextPaymentDue and nextPaymentDue + gracePeriod
The grace period is a default-prevention buffer, not a free extension of the payment date. A customer who pays one day late incurs the same fee as one who pays 29 days late (within a 30-day grace). This incentivizes timely payment throughout the grace window and matches the common real-world model where late fees accrue from the due date.
Implementations MUST NOT apply late fees to payments made on or before nextPaymentDue.
Dust on the final installment
Assigning integer division remainder to the final installment means the first N-1 installments have identical amounts. This simplifies operator/keeper logic — uniform installment amounts are the expected case, and the slightly-larger final installment is a known, documented exception. This matches the approach used by most real-world installment lending products.
getSchedule for wallet and frontend display
Wallets and checkout UIs need to display the full payment schedule to the customer — not just the next installment. Without getSchedule, frontends must reconstruct the schedule off-chain from Order struct fields, which requires duplicating the dust calculation and is error-prone. A standard on-chain derivation guarantees consistency with what collectInstallment will actually collect.
For MerkleScheduleAuth orders, returning empty arrays is the correct behavior rather than reverting or attempting to reconstruct from the root — the leaf set is off-chain by design and the dataCID is the correct pointer for wallets to use. This asymmetry is explicitly documented in the function natspec so implementations cannot silently get it wrong.
SafeERC20 for non-standard token compatibility
IERC20.transferFrom is specified to return bool, but several widely-used tokens — most notably USDT on Ethereum mainnet — do not return any value from transferFrom. Calling bool ok = token.transferFrom(...) on these tokens causes an ABI decoding revert because Solidity attempts to decode the empty return data as bool. SafeERC20.safeTransferFrom handles this correctly by checking return data length before attempting to decode. All strategies that call transferFrom directly (AllowanceAuth, PermitAuth, MerkleScheduleAuth) MUST use SafeERC20. Permit2Auth is exempt because Permit2 handles the transfer internally using its own safe transfer logic.
Standard e-commerce BNPL uses uniform installments derivable from (totalAmount, downPaymentBps, installments). No merkle tree is needed for this case and implementations SHOULD use simpler strategies. MerkleScheduleAuth is appropriate for the subset of BNPL products where amounts genuinely vary — promotional front-loaded discounts, weighted splits, or schedule structures not expressible by division.
The merkle approach is chosen over storing the full schedule on-chain because: a 12-installment schedule stored as 12 (amount, dueDate) pairs costs significant storage gas at order creation; the merkle root is a single bytes32 commitment regardless of schedule length; and individual installment verification is O(log n) in proof size.
installmentIndex is included in each leaf to guarantee uniqueness even when two installments share the same amount and due date. Without the index, identical (amount, dueDate) pairs produce the same leaf hash and the second cannot be distinguished from the first — a subtle but real correctness failure for promotional schedules with matching installment amounts.
Schedule registration is separated from createOrder (via registerSchedule in a multicall) rather than embedded in createOrder parameters to preserve the IBNPL interface without modification. The orderId is deterministic and pre-computable, making atomic multicall composition straightforward.
The mandatory dataCID field forces merchants to commit to a content-addressed copy of the full leaf set on-chain. Without this, a merchant who loses their off-chain leaf data creates a permanently stuck order — the root is committed but no proofs can be constructed. The CID ensures the schedule can always be reconstructed from public data.
Refunded state and processRefund
E-commerce BNPL orders are frequently subject to partial or full refunds after goods are returned. The existing cancelOrder function terminates an order and issues a refund, but it provides no way to handle a partial return — a customer returning some items mid-order without cancelling the remaining installments. processRefund fills this gap.
The distinction between Cancelled and Refunded is semantically meaningful for off-chain systems. Cancelled signals voluntary termination — either party chose to end the order. Refunded signals that the order ended because a refund was issued that covered all remaining collected amounts. A backoffice system, analytics pipeline, or customer service tool can distinguish "customer abandoned their order" from "merchant processed a return" without parsing event logs.
For partial refunds (the more common e-commerce case), processRefund reduces totalAmount without terminating the order. The order remains Active and the remaining installments are collected against the reduced total. This avoids the operational friction of cancelling and recreating an order when a customer returns one of several items. The refundedAmount field in the Order struct gives a cumulative view of all refunds issued against an order, which is useful for merchant accounting.
The processRefund function is merchant-only. Customers cannot initiate a refund — they can only cancel an order (terminating it entirely). This mirrors real-world BNPL operations where refunds are merchant-initiated after return processing.
The refund transfer uses safeTransferFrom(merchant, customer, refundAmount) — the merchant must hold the token balance and have approved the contract. This is intentional: refunds should require active merchant participation, not be executable against a merchant who has already withdrawn their collected funds.
Off-chain default recovery
BNPL inherently accepts credit risk. Recovery options (debt collection, service termination, credit reporting) depend on legal jurisdiction, customer agreement terms, and platform capabilities — none of which can be standardized at the contract interface level. The OrderDefaulted event with outstandingAmount gives off-chain systems everything they need: a specific order ID, the customer address (queryable via getOrder), and the balance owed.
cancelOrder refund behavior is RECOMMENDED not REQUIRED
Refund policy is a commercial decision that varies by merchant and purchase category. The RECOMMENDED behavior (down payment forfeit on customer cancellation, full refund on merchant cancellation) represents the most common consumer-fair default. The one hard invariant is that a non-zero refundAmount in OrderCancelled MUST be accompanied by an actual transfer in the same transaction — emitting a refund promise without executing it is misleading to event observers.
Backwards Compatibility
This ERC introduces new interfaces and does not modify any existing standard.
- ERC-20: All auth strategies call token transfer functions.
AllowanceAuth,PermitAuth, andMerkleScheduleAuthuseSafeERC20.safeTransferFromto support tokens that do not returnbool(e.g. USDT). No token contract modifications are required. - ERC-165: Implementations MUST support ERC-165 for
IBNPLand allIPaymentAuthimplementations. - EIP-712:
OrderAuthorizationfollows the EIP-712 specification precisely. No wallet infrastructure changes required. - ERC-1271:
createOrderMUST implement ERC-1271 for smart contract wallet compatibility. - ERC-2612:
PermitAuthis compatible with any ERC-2612 token. - ERC-3009:
EIP3009Authis compatible with tokens implementing ERC-3009receiveWithAuthorization, primarily USDC v2+ and EURC. ERC-3009 remains in Draft status; implementations SHOULD monitor for spec changes. - Permit2:
Permit2Authis compatible with any ERC-20 token after a one-time Permit2 approval. The canonical Permit2 address0x000000000022D473030F116dDEE9F6B43aC78BA3is used. - OpenZeppelin MerkleProof:
MerkleScheduleAuthuses OpenZeppelin'sMerkleProof.verifywhich 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 interfaceinterfaces/IOperatorManager.sol— operator management interfaceinterfaces/IBNPL.sol— core BNPL interfacestrategies/AllowanceAuth.sol— ERC-20 allowance strategystrategies/PermitAuth.sol— ERC-2612 permit strategystrategies/Permit2Auth.sol— Uniswap Permit2 strategystrategies/EIP3009Auth.sol— ERC-3009 receiveWithAuthorization strategy (USDC/EURC)strategies/MerkleScheduleAuth.sol— variable-schedule merkle strategyBNPLManager.sol— fullIBNPLimplementation with EIP-712 and ERC-1271test/BNPLManager.t.sol— Foundry test suite covering all state transitions, dust handling, all strategy combinations, ERC-1271 wallets, and griefing scenariostest/MerkleScheduleAuth.t.sol— merkle test vectors, leaf consumption, replay prevention, and proof verificationtest/EIP3009Auth.t.sol— ERC-3009 authorization flow, nonce consumption, front-running protection, and validity window testsscripts/OrderAuthorization.ts— TypeScript utilities for constructing and signingOrderAuthorizationat checkoutscripts/MerkleSchedule.ts— TypeScript utilities for building merkle trees, generating proofs, and encodinginstallmentAuthDataforMerkleScheduleAuthscripts/EIP3009Authorization.ts— TypeScript utilities for constructing and signingReceiveWithAuthorizationmessages for USDC/EURC- Deployment scripts for Ethereum mainnet, Arbitrum, Base, and Optimism
Security Considerations
Customer consent is the primary attack surface
The OrderAuthorization signature check is the mechanism preventing merchants from creating orders the customer did not agree to. Implementations MUST verify all four conditions before any token movement:
- Recovered signer equals
customer(ECDSA) or ERC-1271 magic value returned (contract wallet) block.timestamp <= deadline— authorization not expirednonce == authorizationNonce[customer]— authorization not replayed- 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) oncreateOrder,collectInstallment,payOffEarly, andcancelOrder
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:
- Document this clearly to customers at checkout
- Recommend allowance =
totalAmount - downPaymentrather thantype(uint256).max - Consider automating allowance revocation in
cancelOrderand uponOrderCompletedif 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 * downPaymentBpscan overflow for large orders with large basis points — implementations SHOULD usemulDivfrom a safe math library (e.g. OpenZeppelinMath.mulDiv) for basis point calculationsinstallmentAmt * lateFeeBpsis similarly at risk for large installment amounts
Copyright
Copyright and related rights waived via CC0.
