// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; /** * @title AereCardEscrow * @notice Pre-auth + capture rail for Bank28 (and future card programs) on AERE. * Settlement node calls preAuth() to lock user funds via EIP-2612 permit * (~3s, 1 block on AERE QBFT). When the card auth resolves, the node * calls capture() with the final amount, which transfers to the * issuer settlement address minus protocol/program fees, and refunds * the residual hold to the user. Expired auths are released by anyone. * * This is the "slower path" alternative to AereLightningChannels for * programs that don't run a long-lived channel counterparty. */ interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); } interface IERC2612 { function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external; } interface IAereIdentity { function hasClaim(address identity, bytes32 claimType) external view returns (bool); } contract AereCardEscrow { // ── Auth state ───────────────────────────────────────────────── enum Status { None, Held, Captured, Released } struct Auth { address user; address asset; uint128 amount; // amount on hold (residual after partial captures) uint64 expiresAt; uint64 programId; // 1 = Bank28, future programs allocated by owner Status status; } mapping(bytes32 => Auth) public auths; // authId => Auth // ── Programs ─────────────────────────────────────────────────── struct Program { address settlementNode; // authorized to call preAuth/capture for this program address feeRecipient; // program's own fee bucket (Bank28 treasury) uint16 programFeeBps; // program-charged bps on capture (max 1000) bool active; } mapping(uint64 => Program) public programs; uint64 public nextProgramId = 1; // ── Globals ──────────────────────────────────────────────────── address public owner; address public protocolTreasury; uint16 public protocolFeeBps = 30; // 30 bps = 0.30% to AereTreasury uint16 public constant MAX_PROGRAM_FEE_BPS = 1000; uint16 public constant MAX_PROTOCOL_FEE_BPS = 100; IAereIdentity public identity; bytes32 public requiredClaim; // e.g. keccak256("KYC_TIER_2") // ── Events ───────────────────────────────────────────────────── event ProgramRegistered(uint64 indexed programId, address settlementNode, address feeRecipient, uint16 feeBps); event ProgramUpdated(uint64 indexed programId, address settlementNode, address feeRecipient, uint16 feeBps, bool active); event PreAuth(bytes32 indexed authId, uint64 indexed programId, address indexed user, address asset, uint128 amount, uint64 expiresAt); event Captured(bytes32 indexed authId, uint128 captured, address recipient, uint128 protocolFee, uint128 programFee, uint128 refunded); event Released(bytes32 indexed authId, uint128 refunded); event ProtocolFeeUpdated(uint16 bps); event RequiredClaimUpdated(bytes32 claim); modifier onlyOwner() { require(msg.sender == owner, "owner"); _; } constructor(address _treasury, address _identity, bytes32 _requiredClaim) { require(_treasury != address(0), "treasury=0"); owner = msg.sender; protocolTreasury = _treasury; identity = IAereIdentity(_identity); requiredClaim = _requiredClaim; } // ── Admin ────────────────────────────────────────────────────── function registerProgram(address settlementNode, address feeRecipient, uint16 feeBps) external onlyOwner returns (uint64 id) { require(settlementNode != address(0) && feeRecipient != address(0), "zero"); require(feeBps <= MAX_PROGRAM_FEE_BPS, "feeBps"); id = nextProgramId++; programs[id] = Program(settlementNode, feeRecipient, feeBps, true); emit ProgramRegistered(id, settlementNode, feeRecipient, feeBps); } function updateProgram(uint64 id, address settlementNode, address feeRecipient, uint16 feeBps, bool active) external onlyOwner { require(programs[id].settlementNode != address(0), "no program"); require(feeBps <= MAX_PROGRAM_FEE_BPS, "feeBps"); programs[id] = Program(settlementNode, feeRecipient, feeBps, active); emit ProgramUpdated(id, settlementNode, feeRecipient, feeBps, active); } function setProtocolFeeBps(uint16 bps) external onlyOwner { require(bps <= MAX_PROTOCOL_FEE_BPS, "max"); protocolFeeBps = bps; emit ProtocolFeeUpdated(bps); } function setProtocolTreasury(address t) external onlyOwner { require(t != address(0)); protocolTreasury = t; } function setIdentity(address a, bytes32 claim) external onlyOwner { identity = IAereIdentity(a); requiredClaim = claim; emit RequiredClaimUpdated(claim); } function transferOwnership(address n) external onlyOwner { require(n != address(0)); owner = n; } // ── Pre-auth (settlement node only) ──────────────────────────── /** * @notice Lock user funds for an upcoming card auth. * @dev Uses EIP-2612 permit so the user signs off-chain and the settlement * node submits the on-chain tx (sponsored by the program's paymaster). * authId is supplied by the caller — typically keccak256(programId, externalAuthRef) * so the off-chain card system controls the namespace. */ function preAuthWithPermit( bytes32 authId, uint64 programId, address user, address asset, uint128 amount, uint64 expiresAt, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s ) external { Program storage p = programs[programId]; require(p.active && msg.sender == p.settlementNode, "not node"); require(auths[authId].status == Status.None, "authId used"); require(amount > 0 && expiresAt > block.timestamp, "params"); if (address(identity) != address(0) && requiredClaim != bytes32(0)) { require(identity.hasClaim(user, requiredClaim), "not eligible"); } // Pull user funds via permit. Permit failure (e.g. already used) is OK // if allowance is already sufficient. try IERC2612(asset).permit(user, address(this), amount, permitDeadline, v, r, s) {} catch {} require(IERC20(asset).transferFrom(user, address(this), amount), "transferFrom"); auths[authId] = Auth(user, asset, amount, expiresAt, programId, Status.Held); emit PreAuth(authId, programId, user, asset, amount, expiresAt); } /// @notice Lock funds when the user has already approved this contract (no permit). function preAuth( bytes32 authId, uint64 programId, address user, address asset, uint128 amount, uint64 expiresAt ) external { Program storage p = programs[programId]; require(p.active && msg.sender == p.settlementNode, "not node"); require(auths[authId].status == Status.None, "authId used"); require(amount > 0 && expiresAt > block.timestamp, "params"); if (address(identity) != address(0) && requiredClaim != bytes32(0)) { require(identity.hasClaim(user, requiredClaim), "not eligible"); } require(IERC20(asset).transferFrom(user, address(this), amount), "transferFrom"); auths[authId] = Auth(user, asset, amount, expiresAt, programId, Status.Held); emit PreAuth(authId, programId, user, asset, amount, expiresAt); } // ── Capture (settlement node only) ───────────────────────────── /** * @notice Settle a held auth. Splits funds: protocolFeeBps to AereTreasury, * programFeeBps to program.feeRecipient, captureAmount-fees to * issuerRecipient, residual to the user. Closes the auth. */ function capture(bytes32 authId, uint128 captureAmount, address issuerRecipient) external { Auth storage a = auths[authId]; require(a.status == Status.Held, "not held"); Program storage p = programs[a.programId]; require(msg.sender == p.settlementNode, "not node"); require(captureAmount <= a.amount, "over"); require(issuerRecipient != address(0), "recipient=0"); uint128 protoFee = uint128(uint256(captureAmount) * protocolFeeBps / 10_000); uint128 progFee = uint128(uint256(captureAmount) * p.programFeeBps / 10_000); require(uint256(protoFee) + progFee < captureAmount, "fees>=capture"); uint128 toIssuer = captureAmount - protoFee - progFee; uint128 toRefund = a.amount - captureAmount; a.status = Status.Captured; a.amount = 0; if (protoFee > 0) require(IERC20(a.asset).transfer(protocolTreasury, protoFee), "t1"); if (progFee > 0) require(IERC20(a.asset).transfer(p.feeRecipient, progFee), "t2"); if (toIssuer > 0) require(IERC20(a.asset).transfer(issuerRecipient, toIssuer), "t3"); if (toRefund > 0) require(IERC20(a.asset).transfer(a.user, toRefund), "t4"); emit Captured(authId, captureAmount, issuerRecipient, protoFee, progFee, toRefund); } // ── Release (anyone, after expiry) ───────────────────────────── /// @notice Refund a held auth that wasn't captured before expiry. Permissionless. function release(bytes32 authId) external { Auth storage a = auths[authId]; require(a.status == Status.Held, "not held"); require(block.timestamp >= a.expiresAt, "not expired"); uint128 amt = a.amount; a.status = Status.Released; a.amount = 0; require(IERC20(a.asset).transfer(a.user, amt), "refund"); emit Released(authId, amt); } /// @notice Voluntary release by the program before expiry (e.g. cardholder voided). function voidAuth(bytes32 authId) external { Auth storage a = auths[authId]; require(a.status == Status.Held, "not held"); Program storage p = programs[a.programId]; require(msg.sender == p.settlementNode, "not node"); uint128 amt = a.amount; a.status = Status.Released; a.amount = 0; require(IERC20(a.asset).transfer(a.user, amt), "refund"); emit Released(authId, amt); } }