Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
223 lines
11 KiB
Solidity
223 lines
11 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.19;
|
|
|
|
/**
|
|
* @title AereCardEscrow
|
|
* @notice Pre-auth + capture rail for 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; // program ids 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
|
|
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);
|
|
}
|
|
}
|