From a90a224df614b45e88d9f3778d35cc01b49ba081 Mon Sep 17 00:00:00 2001 From: jishumi Date: Sat, 6 Jun 2026 21:23:17 +0300 Subject: [PATCH] feat(escrow): DEV-36 paymentToken parameterization Per-escrow payment-token allow-list. Each escrow binds its payment token at creation, gated by an owner-controlled allow-list (new AllowedTokens extension), instead of a single global engine token. Wired into Escrow and ConfidentialEscrow; IEscrow gains addAllowedToken/removeAllowedToken/ isAllowedToken and per-escrow paymentToken accessors. Migrated as-is from protocol-private #40 (feat/escrow-per-escrow-payment-token), squashed (intermediate tokens-wrapper add/revert dropped). AllowedTokens storage namespace rebranded privara.storage -> reineira.storage (slot recomputed) to match scrubbed public main. --- .../contracts/core/ConfidentialEscrow.sol | 189 +++++++++++------- packages/escrow/contracts/core/Escrow.sol | 162 ++++++++++----- .../contracts/extensions/AllowedTokens.sol | 54 +++++ .../ICCTPV2ConfidentialEscrowReceiver.sol | 6 + .../CCTPV2ConfidentialEscrowReceiver.sol | 5 + .../integration/EscrowSettlementFHE.t.sol | 28 +++ .../test/unit/ConfidentialEscrowFHE.t.sol | 118 +++++++++++ packages/escrow/test/unit/Escrow.t.sol | 162 +++++++++++++++ .../src/abis/ConfidentialEscrow.json | 36 ++++ .../contracts/interfaces/core/IEscrow.sol | 21 ++ .../interfaces/core/IEscrowEvents.sol | 8 + .../shared/contracts/libraries/EscrowLib.sol | 1 + 12 files changed, 668 insertions(+), 122 deletions(-) create mode 100644 packages/escrow/contracts/extensions/AllowedTokens.sol diff --git a/packages/escrow/contracts/core/ConfidentialEscrow.sol b/packages/escrow/contracts/core/ConfidentialEscrow.sol index 9c6c8c9..af9f372 100644 --- a/packages/escrow/contracts/core/ConfidentialEscrow.sol +++ b/packages/escrow/contracts/core/ConfidentialEscrow.sol @@ -10,11 +10,12 @@ import {EscrowLib} from "@reineira-os/shared/contracts/libraries/EscrowLib.sol"; import {FeeLib} from "@reineira-os/shared/contracts/libraries/FeeLib.sol"; import {IEscrow} from "@reineira-os/shared/contracts/interfaces/core/IEscrow.sol"; import {IConfidentialEscrow} from "../interfaces/core/IConfidentialEscrow.sol"; +import {AllowedTokens} from "../extensions/AllowedTokens.sol"; import {EscrowCondition} from "../extensions/EscrowCondition.sol"; import {FHEMeta} from "@reineira-os/shared/contracts/common/FHEMeta.sol"; import {IConditionResolver} from "@reineira-os/shared/contracts/interfaces/plugins/IConditionResolver.sol"; -contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, TestnetCoreBase { +contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, AllowedTokens, EscrowCondition, TestnetCoreBase { uint256 public constant MAX_BATCH_SIZE = 20; IFHERC20 private _paymentToken; @@ -56,9 +57,28 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te if (paymentToken_ == address(0)) revert ZeroAddress(); __TestnetCoreBase_init(owner_); _paymentToken = IFHERC20(paymentToken_); + _seedAllowedToken(paymentToken_); emit CoreInitialized(owner_); } + function addAllowedToken(address token) external override onlyOwner { + if (token == address(0)) revert ZeroAddress(); + _addAllowedToken(token); + } + + function removeAllowedToken(address token) external override onlyOwner { + _removeAllowedToken(token); + } + + function isAllowedToken(address token) external view override returns (bool) { + return _isAllowedToken(token); + } + + function paymentTokenOf(uint256 escrowId) external view override returns (address) { + EscrowLib.validateExists(escrowId < _nextId, escrowId); + return _paymentTokenOfRaw(escrowId); + } + function create( InEaddress calldata encryptedOwner, InEuint64 calldata encryptedAmount, @@ -71,6 +91,7 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te eaddress callerEncrypted = FHE.asEaddress(sender); escrowId = _nextId++; + _resolvePaymentToken(escrowId, address(0)); euint64 zeroPaid = FHE.asEuint64(0); ebool notRedeemed = FHE.asEbool(false); @@ -118,15 +139,16 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te address sender = _msgSender(); Escrow storage escrow = _escrows[escrowId]; + IFHERC20 token = IFHERC20(_paymentTokenOfRaw(escrowId)); euint64 paymentAmount = FHEMeta.asEuint64(encryptedPayment, sender); - euint64 balanceBefore = _paymentToken.confidentialBalanceOf(address(this)); + euint64 balanceBefore = token.confidentialBalanceOf(address(this)); - FHE.allowTransient(paymentAmount, address(_paymentToken)); + FHE.allowTransient(paymentAmount, address(token)); - _paymentToken.confidentialTransferFrom(sender, address(this), paymentAmount); + token.confidentialTransferFrom(sender, address(this), paymentAmount); - euint64 balanceAfter = _paymentToken.confidentialBalanceOf(address(this)); + euint64 balanceAfter = token.confidentialBalanceOf(address(this)); euint64 actualPayment = FHE.sub(balanceAfter, balanceBefore); euint64 newPaidAmount = FHE.add(escrow.paidAmount, actualPayment); @@ -143,12 +165,13 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te address sender = _msgSender(); Escrow storage escrow = _escrows[escrowId]; - euint64 balanceBefore = _paymentToken.confidentialBalanceOf(address(this)); + IFHERC20 token = IFHERC20(_paymentTokenOfRaw(escrowId)); + euint64 balanceBefore = token.confidentialBalanceOf(address(this)); - FHE.allowTransient(amount, address(_paymentToken)); - _paymentToken.confidentialTransferFrom(sender, address(this), amount); + FHE.allowTransient(amount, address(token)); + token.confidentialTransferFrom(sender, address(this), amount); - euint64 balanceAfter = _paymentToken.confidentialBalanceOf(address(this)); + euint64 balanceAfter = token.confidentialBalanceOf(address(this)); euint64 actualPayment = FHE.sub(balanceAfter, balanceBefore); euint64 newPaidAmount = FHE.add(escrow.paidAmount, actualPayment); @@ -164,25 +187,10 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te _checkCondition(escrowId); address sender = _msgSender(); - Escrow storage escrow = _escrows[escrowId]; - - eaddress callerEncrypted = FHE.asEaddress(sender); - - ebool canRedeem = FHE.and( - FHE.and(FHE.eq(escrow.owner, callerEncrypted), FHE.gte(escrow.paidAmount, escrow.amount)), - FHE.not(escrow.isRedeemed) - ); + (IFHERC20 token, euint64 net) = _settleOne(escrowId, FHE.asEaddress(sender)); - euint64 zero = FHE.asEuint64(0); - euint64 redemptionAmount = FHE.select(canRedeem, escrow.paidAmount, zero); - - escrow.isRedeemed = FHE.or(escrow.isRedeemed, canRedeem); - FHE.allowThis(escrow.isRedeemed); - - euint64 net = _distributeFees(escrowId, redemptionAmount, canRedeem); - - FHE.allowTransient(net, address(_paymentToken)); - _paymentToken.confidentialTransfer(sender, net); + FHE.allowTransient(net, address(token)); + token.confidentialTransfer(sender, net); emit EscrowRedeemed(escrowId); } @@ -193,38 +201,27 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te EscrowLib.validateBatchSize(length, MAX_BATCH_SIZE); address sender = _msgSender(); - - euint64 totalNet = FHE.asEuint64(0); - FHE.allowThis(totalNet); - eaddress callerEncrypted = FHE.asEaddress(sender); + address[] memory tokens = new address[](length); + euint64[] memory nets = new euint64[](length); + uint256 uniq = 0; + for (uint256 i = 0; i < length; i++) { uint256 escrowId = escrowIds[i]; if (escrowId >= _nextId) continue; if (!_isConditionMet(escrowId)) continue; - Escrow storage escrow = _escrows[escrowId]; - - ebool canRedeem = FHE.and( - FHE.and(FHE.eq(escrow.owner, callerEncrypted), FHE.gte(escrow.paidAmount, escrow.amount)), - FHE.not(escrow.isRedeemed) - ); - - euint64 zero = FHE.asEuint64(0); - euint64 redemptionAmount = FHE.select(canRedeem, escrow.paidAmount, zero); - - escrow.isRedeemed = FHE.or(escrow.isRedeemed, canRedeem); - FHE.allowThis(escrow.isRedeemed); - - euint64 net = _distributeFees(escrowId, redemptionAmount, canRedeem); - totalNet = FHE.add(totalNet, net); + (IFHERC20 token, euint64 net) = _settleOne(escrowId, callerEncrypted); + uniq = _accumulate(tokens, nets, uniq, address(token), net); } - FHE.allowThis(totalNet); - FHE.allowTransient(totalNet, address(_paymentToken)); - _paymentToken.confidentialTransfer(sender, totalNet); + for (uint256 j = 0; j < uniq; j++) { + FHE.allowThis(nets[j]); + FHE.allowTransient(nets[j], tokens[j]); + IFHERC20(tokens[j]).confidentialTransfer(sender, nets[j]); + } emit EscrowBatchRedeemed(escrowIds); } @@ -330,9 +327,9 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te address resolver, bytes calldata resolverData ) external override nonReentrant returns (uint256 escrowId) { - (InEaddress memory encryptedOwner, InEuint64 memory encryptedAmount) = abi.decode( + (InEaddress memory encryptedOwner, InEuint64 memory encryptedAmount, address token_) = abi.decode( initData, - (InEaddress, InEuint64) + (InEaddress, InEuint64, address) ); address sender = _msgSender(); @@ -341,6 +338,7 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te eaddress callerEncrypted = FHE.asEaddress(sender); escrowId = _nextId++; + _resolvePaymentToken(escrowId, token_); euint64 zeroPaid = FHE.asEuint64(0); ebool notRedeemed = FHE.asEbool(false); @@ -390,15 +388,16 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te address sender = _msgSender(); Escrow storage escrow = _escrows[escrowId]; + IFHERC20 token = IFHERC20(_paymentTokenOfRaw(escrowId)); euint64 paymentAmount = FHEMeta.asEuint64(encryptedPayment, sender); - euint64 balanceBefore = _paymentToken.confidentialBalanceOf(address(this)); + euint64 balanceBefore = token.confidentialBalanceOf(address(this)); - FHE.allowTransient(paymentAmount, address(_paymentToken)); + FHE.allowTransient(paymentAmount, address(token)); - _paymentToken.confidentialTransferFrom(sender, address(this), paymentAmount); + token.confidentialTransferFrom(sender, address(this), paymentAmount); - euint64 balanceAfter = _paymentToken.confidentialBalanceOf(address(this)); + euint64 balanceAfter = token.confidentialBalanceOf(address(this)); euint64 actualPayment = FHE.sub(balanceAfter, balanceBefore); euint64 newPaidAmount = FHE.add(escrow.paidAmount, actualPayment); @@ -423,26 +422,10 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te EscrowLib.validateExists(escrowId < _nextId, escrowId); _checkCondition(escrowId); - address sender = _msgSender(); - Escrow storage escrow = _escrows[escrowId]; + (IFHERC20 token, euint64 net) = _settleOne(escrowId, FHE.asEaddress(_msgSender())); - eaddress callerEncrypted = FHE.asEaddress(sender); - - ebool canRedeem = FHE.and( - FHE.and(FHE.eq(escrow.owner, callerEncrypted), FHE.gte(escrow.paidAmount, escrow.amount)), - FHE.not(escrow.isRedeemed) - ); - - euint64 zero = FHE.asEuint64(0); - euint64 redemptionAmount = FHE.select(canRedeem, escrow.paidAmount, zero); - - escrow.isRedeemed = FHE.or(escrow.isRedeemed, canRedeem); - FHE.allowThis(escrow.isRedeemed); - - euint64 net = _distributeFees(escrowId, redemptionAmount, canRedeem); - - FHE.allowTransient(net, address(_paymentToken)); - _paymentToken.confidentialTransfer(recipient, net); + FHE.allowTransient(net, address(token)); + token.confidentialTransfer(recipient, net); emit EscrowRedeemed(escrowId); } @@ -490,7 +473,49 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te emit FeeStamped(escrowId, kind, 0, recipient); } - function _distributeFees(uint256 escrowId, euint64 paidAmount, ebool canRedeem) private returns (euint64 net) { + function _settleOne(uint256 escrowId, eaddress callerEncrypted) private returns (IFHERC20 token, euint64 net) { + Escrow storage escrow = _escrows[escrowId]; + + ebool canRedeem = FHE.and( + FHE.and(FHE.eq(escrow.owner, callerEncrypted), FHE.gte(escrow.paidAmount, escrow.amount)), + FHE.not(escrow.isRedeemed) + ); + + euint64 redemptionAmount = FHE.select(canRedeem, escrow.paidAmount, FHE.asEuint64(0)); + + escrow.isRedeemed = FHE.or(escrow.isRedeemed, canRedeem); + FHE.allowThis(escrow.isRedeemed); + + token = IFHERC20(_paymentTokenOfRaw(escrowId)); + net = _distributeFees(escrowId, redemptionAmount, canRedeem, token); + } + + function _accumulate( + address[] memory tokens, + euint64[] memory nets, + uint256 uniq, + address token, + euint64 net + ) private returns (uint256) { + for (uint256 j = 0; j < uniq; j++) { + if (tokens[j] == token) { + nets[j] = FHE.add(nets[j], net); + FHE.allowThis(nets[j]); + return uniq; + } + } + tokens[uniq] = token; + nets[uniq] = net; + FHE.allowThis(nets[uniq]); + return uniq + 1; + } + + function _distributeFees( + uint256 escrowId, + euint64 paidAmount, + ebool canRedeem, + IFHERC20 token + ) private returns (euint64 net) { net = paidAmount; euint64 maxBpsEnc = FHE.asEuint64(uint64(FeeLib.MAX_TOTAL_BPS)); for (uint8 k = 0; k < FeeLib.MAX_FEE_KIND; k++) { @@ -503,8 +528,8 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te euint64 cappedFee = FHE.select(FHE.lte(conditionalFee, net), conditionalFee, net); net = FHE.sub(net, cappedFee); - FHE.allowTransient(cappedFee, address(_paymentToken)); - _paymentToken.confidentialTransfer(f.recipient, cappedFee); + FHE.allowTransient(cappedFee, address(token)); + token.confidentialTransfer(f.recipient, cappedFee); emit FeeDistributed(escrowId, k, 0, f.recipient); } @@ -525,4 +550,14 @@ contract ConfidentialEscrow is IEscrow, IConfidentialEscrow, EscrowCondition, Te } FHE.allowThis(net); } + + function _resolvePaymentToken(uint256 escrowId, address token) private returns (IFHERC20 resolved) { + if (token == address(0)) { + resolved = _paymentToken; + } else { + _requireAllowedToken(token); + resolved = IFHERC20(token); + } + _setPaymentTokenOf(escrowId, address(resolved)); + } } diff --git a/packages/escrow/contracts/core/Escrow.sol b/packages/escrow/contracts/core/Escrow.sol index 32ca7ae..8445089 100644 --- a/packages/escrow/contracts/core/Escrow.sol +++ b/packages/escrow/contracts/core/Escrow.sol @@ -10,9 +10,10 @@ import {EscrowLib} from "@reineira-os/shared/contracts/libraries/EscrowLib.sol"; import {FeeLib} from "@reineira-os/shared/contracts/libraries/FeeLib.sol"; import {IEscrow} from "@reineira-os/shared/contracts/interfaces/core/IEscrow.sol"; import {IConditionResolver} from "@reineira-os/shared/contracts/interfaces/plugins/IConditionResolver.sol"; +import {AllowedTokens} from "../extensions/AllowedTokens.sol"; import {EscrowCondition} from "../extensions/EscrowCondition.sol"; -contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { +contract Escrow is IEscrow, AllowedTokens, EscrowCondition, TestnetCoreBase { using SafeERC20 for IERC20; uint256 public constant MAX_BATCH_SIZE = 20; @@ -37,6 +38,13 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { bool set; } + enum Redeemability { + Ok, + NotOwnerErr, + NotFullyPaidErr, + AlreadyRedeemedErr + } + mapping(uint256 => EscrowData) private _escrows; mapping(uint256 => Fee[3]) private _fees; mapping(uint256 => Fee[]) private _underwriterFees; @@ -56,9 +64,28 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { if (paymentToken_ == address(0)) revert ZeroAddress(); __TestnetCoreBase_init(owner_); paymentToken = IERC20(paymentToken_); + _seedAllowedToken(paymentToken_); emit CoreInitialized(owner_); } + function addAllowedToken(address token) external onlyOwner { + if (token == address(0)) revert ZeroAddress(); + _addAllowedToken(token); + } + + function removeAllowedToken(address token) external onlyOwner { + _removeAllowedToken(token); + } + + function isAllowedToken(address token) external view returns (bool) { + return _isAllowedToken(token); + } + + function paymentTokenOf(uint256 escrowId) external view returns (address) { + EscrowLib.validateExists(escrowId < _nextId, escrowId); + return _paymentTokenOfRaw(escrowId); + } + function create( address owner_, uint256 amount_, @@ -77,6 +104,7 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { paidAmount: 0, isRedeemed: false }); + _resolvePaymentToken(escrowId, address(0)); if (resolver != address(0)) { _setCondition(escrowId, resolver, resolverData); @@ -92,9 +120,10 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { address sender = _msgSender(); EscrowData storage e = _escrows[escrowId]; - uint256 balanceBefore = paymentToken.balanceOf(address(this)); - paymentToken.safeTransferFrom(sender, address(this), amount); - uint256 actualPayment = paymentToken.balanceOf(address(this)) - balanceBefore; + IERC20 token = IERC20(_paymentTokenOfRaw(escrowId)); + uint256 balanceBefore = token.balanceOf(address(this)); + token.safeTransferFrom(sender, address(this), amount); + uint256 actualPayment = token.balanceOf(address(this)) - balanceBefore; e.paidAmount += actualPayment; @@ -106,47 +135,38 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { _checkCondition(escrowId); address sender = _msgSender(); - EscrowData storage e = _escrows[escrowId]; - - if (e.owner != sender) revert NotOwner(); - if (e.paidAmount < e.amount) revert NotFullyPaid(); - if (e.isRedeemed) revert AlreadyRedeemed(); + _requireRedeemable(escrowId, sender); - e.isRedeemed = true; - - uint256 net = _distributeFees(escrowId, e.paidAmount); - if (net > 0) paymentToken.safeTransfer(sender, net); + (IERC20 token, uint256 net) = _markAndDistribute(escrowId); + if (net > 0) token.safeTransfer(sender, net); emit EscrowRedeemed(escrowId); } function redeemMultiple(uint256[] calldata escrowIds) external nonReentrant { - EscrowLib.validateNonEmpty(escrowIds.length); - EscrowLib.validateBatchSize(escrowIds.length, MAX_BATCH_SIZE); + uint256 length = escrowIds.length; + EscrowLib.validateNonEmpty(length); + EscrowLib.validateBatchSize(length, MAX_BATCH_SIZE); address sender = _msgSender(); - uint256 totalNet = 0; - for (uint256 i = 0; i < escrowIds.length; i++) { + address[] memory tokens = new address[](length); + uint256[] memory nets = new uint256[](length); + uint256 uniq = 0; + + for (uint256 i = 0; i < length; i++) { uint256 escrowId = escrowIds[i]; if (escrowId >= _nextId) continue; if (!_isConditionMet(escrowId)) continue; + if (_redeemable(escrowId, sender) != Redeemability.Ok) continue; - EscrowData storage e = _escrows[escrowId]; - - if (e.owner != sender) continue; - if (e.paidAmount < e.amount) continue; - if (e.isRedeemed) continue; - - e.isRedeemed = true; - - uint256 net = _distributeFees(escrowId, e.paidAmount); - totalNet += net; + (IERC20 token, uint256 net) = _markAndDistribute(escrowId); + uniq = _accumulate(tokens, nets, uniq, address(token), net); } - if (totalNet > 0) { - paymentToken.safeTransfer(sender, totalNet); + for (uint256 j = 0; j < uniq; j++) { + if (nets[j] > 0) IERC20(tokens[j]).safeTransfer(sender, nets[j]); } emit EscrowBatchRedeemed(escrowIds); @@ -234,7 +254,14 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { address resolver, bytes calldata resolverData ) external nonReentrant returns (uint256 escrowId) { - (address owner_, uint256 amount_) = abi.decode(initData, (address, uint256)); + address owner_; + uint256 amount_; + address token_; + if (initData.length == 96) { + (owner_, amount_, token_) = abi.decode(initData, (address, uint256, address)); + } else { + (owner_, amount_) = abi.decode(initData, (address, uint256)); + } if (owner_ == address(0)) revert ZeroAddress(); @@ -248,6 +275,7 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { paidAmount: 0, isRedeemed: false }); + _resolvePaymentToken(escrowId, token_); if (resolver != address(0)) { _setCondition(escrowId, resolver, resolverData); @@ -265,9 +293,10 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { address sender = _msgSender(); EscrowData storage e = _escrows[escrowId]; - uint256 balanceBefore = paymentToken.balanceOf(address(this)); - paymentToken.safeTransferFrom(sender, address(this), amount); - uint256 actualPayment = paymentToken.balanceOf(address(this)) - balanceBefore; + IERC20 token = IERC20(_paymentTokenOfRaw(escrowId)); + uint256 balanceBefore = token.balanceOf(address(this)); + token.safeTransferFrom(sender, address(this), amount); + uint256 actualPayment = token.balanceOf(address(this)) - balanceBefore; e.paidAmount += actualPayment; @@ -289,17 +318,10 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { EscrowLib.validateExists(escrowId < _nextId, escrowId); _checkCondition(escrowId); - address sender = _msgSender(); - EscrowData storage e = _escrows[escrowId]; - - if (e.owner != sender) revert NotOwner(); - if (e.paidAmount < e.amount) revert NotFullyPaid(); - if (e.isRedeemed) revert AlreadyRedeemed(); - - e.isRedeemed = true; + _requireRedeemable(escrowId, _msgSender()); - uint256 net = _distributeFees(escrowId, e.paidAmount); - if (net > 0) paymentToken.safeTransfer(recipient, net); + (IERC20 token, uint256 net) = _markAndDistribute(escrowId); + if (net > 0) token.safeTransfer(recipient, net); emit EscrowRedeemed(escrowId); } @@ -347,7 +369,47 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { emit FeeStamped(escrowId, kind, bps, recipient); } - function _distributeFees(uint256 escrowId, uint256 paidAmount) private returns (uint256 net) { + function _redeemable(uint256 escrowId, address sender) private view returns (Redeemability) { + EscrowData storage e = _escrows[escrowId]; + if (e.owner != sender) return Redeemability.NotOwnerErr; + if (e.paidAmount < e.amount) return Redeemability.NotFullyPaidErr; + if (e.isRedeemed) return Redeemability.AlreadyRedeemedErr; + return Redeemability.Ok; + } + + function _requireRedeemable(uint256 escrowId, address sender) private view { + Redeemability r = _redeemable(escrowId, sender); + if (r == Redeemability.NotOwnerErr) revert NotOwner(); + if (r == Redeemability.NotFullyPaidErr) revert NotFullyPaid(); + if (r == Redeemability.AlreadyRedeemedErr) revert AlreadyRedeemed(); + } + + function _markAndDistribute(uint256 escrowId) private returns (IERC20 token, uint256 net) { + EscrowData storage e = _escrows[escrowId]; + e.isRedeemed = true; + token = IERC20(_paymentTokenOfRaw(escrowId)); + net = _distributeFees(escrowId, e.paidAmount, token); + } + + function _accumulate( + address[] memory tokens, + uint256[] memory nets, + uint256 uniq, + address token, + uint256 net + ) private pure returns (uint256) { + for (uint256 j = 0; j < uniq; j++) { + if (tokens[j] == token) { + nets[j] += net; + return uniq; + } + } + tokens[uniq] = token; + nets[uniq] = net; + return uniq + 1; + } + + function _distributeFees(uint256 escrowId, uint256 paidAmount, IERC20 token) private returns (uint256 net) { net = paidAmount; for (uint8 k = 0; k < FeeLib.MAX_FEE_KIND; k++) { if (k == uint8(FeeLib.FeeKind.Underwriter)) continue; @@ -357,7 +419,7 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { if (feeAmount == 0) continue; if (feeAmount > net) feeAmount = net; net -= feeAmount; - paymentToken.safeTransfer(f.recipient, feeAmount); + token.safeTransfer(f.recipient, feeAmount); emit FeeDistributed(escrowId, k, feeAmount, f.recipient); } Fee[] storage uwFees = _underwriterFees[escrowId]; @@ -372,4 +434,14 @@ contract Escrow is IEscrow, EscrowCondition, TestnetCoreBase { emit FeeDistributed(escrowId, uint8(FeeLib.FeeKind.Underwriter), feeAmount, f.recipient); } } + + function _resolvePaymentToken(uint256 escrowId, address token) private returns (IERC20 resolved) { + if (token == address(0)) { + resolved = paymentToken; + } else { + _requireAllowedToken(token); + resolved = IERC20(token); + } + _setPaymentTokenOf(escrowId, address(resolved)); + } } diff --git a/packages/escrow/contracts/extensions/AllowedTokens.sol b/packages/escrow/contracts/extensions/AllowedTokens.sol new file mode 100644 index 0000000..4dbfd2e --- /dev/null +++ b/packages/escrow/contracts/extensions/AllowedTokens.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: FSL-1.1-ALv2 +// Copyright (c) 2026 Reineira Labs Limited All rights reserved. +pragma solidity ^0.8.25; + +import {EscrowLib} from "@reineira-os/shared/contracts/libraries/EscrowLib.sol"; +import {IEscrowEvents} from "@reineira-os/shared/contracts/interfaces/core/IEscrowEvents.sol"; + +abstract contract AllowedTokens is IEscrowEvents { + /// @custom:storage-location erc7201:reineira.storage.AllowedTokens + struct AllowedTokensStorage { + mapping(address => bool) allowed; + mapping(uint256 => address) paymentTokenOf; + } + + // keccak256(abi.encode(uint256(keccak256("reineira.storage.AllowedTokens")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ALLOWED_TOKENS_STORAGE_LOCATION = + 0xa34ff427081f28d50fd66eacfbab47ac3001df068b02df5a6fd55fbc551ca700; + + function _getAllowedTokensStorage() private pure returns (AllowedTokensStorage storage $) { + assembly { + $.slot := ALLOWED_TOKENS_STORAGE_LOCATION + } + } + + function _isAllowedToken(address token) internal view returns (bool) { + return _getAllowedTokensStorage().allowed[token]; + } + + function _paymentTokenOfRaw(uint256 escrowId) internal view returns (address) { + return _getAllowedTokensStorage().paymentTokenOf[escrowId]; + } + + function _addAllowedToken(address token) internal { + _getAllowedTokensStorage().allowed[token] = true; + emit TokenAllowed(token); + } + + function _removeAllowedToken(address token) internal { + _getAllowedTokensStorage().allowed[token] = false; + emit TokenRemoved(token); + } + + function _seedAllowedToken(address token) internal { + _getAllowedTokensStorage().allowed[token] = true; + } + + function _requireAllowedToken(address token) internal view { + if (!_getAllowedTokensStorage().allowed[token]) revert EscrowLib.TokenNotAllowed(token); + } + + function _setPaymentTokenOf(uint256 escrowId, address token) internal { + _getAllowedTokensStorage().paymentTokenOf[escrowId] = token; + } +} diff --git a/packages/escrow/contracts/interfaces/receivers/ICCTPV2ConfidentialEscrowReceiver.sol b/packages/escrow/contracts/interfaces/receivers/ICCTPV2ConfidentialEscrowReceiver.sol index 9842682..7cff7b7 100644 --- a/packages/escrow/contracts/interfaces/receivers/ICCTPV2ConfidentialEscrowReceiver.sol +++ b/packages/escrow/contracts/interfaces/receivers/ICCTPV2ConfidentialEscrowReceiver.sol @@ -21,6 +21,12 @@ interface ICCTPV2ConfidentialEscrowReceiver { /// @notice Thrown when hook data cannot be decoded error MalformedHookData(); + /// @notice Thrown when the target escrow's payment token is not the wrapped USDC bridged by CCTP + /// @param escrowId The escrow identifier being settled + /// @param expected The token CCTP can settle (the configured confidential USDC) + /// @param actual The payment token the escrow was created with + error EscrowTokenMismatch(uint256 escrowId, address expected, address actual); + /// @notice Emitted when an escrow is settled via a cross-chain USDC transfer /// @param escrowId The settled escrow identifier /// @param relayer The address that relayed the settlement diff --git a/packages/escrow/contracts/receivers/CCTPV2ConfidentialEscrowReceiver.sol b/packages/escrow/contracts/receivers/CCTPV2ConfidentialEscrowReceiver.sol index 6a91e11..505117c 100644 --- a/packages/escrow/contracts/receivers/CCTPV2ConfidentialEscrowReceiver.sol +++ b/packages/escrow/contracts/receivers/CCTPV2ConfidentialEscrowReceiver.sol @@ -72,6 +72,11 @@ contract CCTPV2ConfidentialEscrowReceiver is ICCTPV2ConfidentialEscrowReceiver, revert EscrowNotFound(escrowId); } + address escrowToken = escrow.paymentTokenOf(escrowId); + if (escrowToken != address(confidentialUsdc)) { + revert EscrowTokenMismatch(escrowId, address(confidentialUsdc), escrowToken); + } + uint256 rate = confidentialUsdc.rate(); uint256 amountToWrap = usdcReceived - (usdcReceived % rate); if (amountToWrap == 0) revert ZeroAmount(); diff --git a/packages/escrow/test/integration/EscrowSettlementFHE.t.sol b/packages/escrow/test/integration/EscrowSettlementFHE.t.sol index 4d1ce57..8dd5101 100644 --- a/packages/escrow/test/integration/EscrowSettlementFHE.t.sol +++ b/packages/escrow/test/integration/EscrowSettlementFHE.t.sol @@ -275,6 +275,34 @@ contract EscrowSettlementFHETest is FHETestBase { escrow.redeem(0); } + function test_settle_revertsWhenEscrowTokenMismatch() public { + vm.prank(owner); + MockConfidentialToken otherToken = new MockConfidentialToken(); + vm.prank(owner); + escrow.addAllowedToken(address(otherToken)); + + InEaddress memory encOwner = createInEaddress(escrowOwner, owner); + InEuint64 memory encAmount = createInEuint64(1000000, owner); + bytes memory initData = abi.encode(encOwner, encAmount, address(otherToken)); + vm.prank(owner); + uint256 escrowId = escrow.create(initData, address(0), ""); + + transmitter.setAmountToMint(1000000); + usdc.mint(address(transmitter), 1000000); + bytes memory message = buildMockCCTPV2Message(escrowId); + + vm.prank(relayer); + vm.expectRevert( + abi.encodeWithSelector( + ICCTPV2ConfidentialEscrowReceiver.EscrowTokenMismatch.selector, + escrowId, + address(token), + address(otherToken) + ) + ); + receiver.settle(message, ""); + } + function test_accessControl_anyAddressCanRelaySettlement() public { _createEscrow(escrowOwner, 1000000); diff --git a/packages/escrow/test/unit/ConfidentialEscrowFHE.t.sol b/packages/escrow/test/unit/ConfidentialEscrowFHE.t.sol index a318d4d..aee769f 100644 --- a/packages/escrow/test/unit/ConfidentialEscrowFHE.t.sol +++ b/packages/escrow/test/unit/ConfidentialEscrowFHE.t.sol @@ -402,4 +402,122 @@ contract ConfidentialEscrowFHETest is FHETestBase { emit IEscrowEvents.EscrowBatchRedeemed(ids); escrow.redeemMultiple(ids); } + + function _createEscrowWithToken(address token_) internal returns (uint256) { + InEaddress memory encOwner = createInEaddress(escrowOwner, owner); + InEuint64 memory encAmount = createInEuint64(ESCROW_AMOUNT, owner); + bytes memory initData = abi.encode(encOwner, encAmount, token_); + vm.prank(owner); + return escrow.create(initData, address(0), ""); + } + + function _fundEscrowWith(uint256 escrowId, MockConfidentialToken token_) internal { + vm.prank(owner); + token_.mintPlain(payer, PAYMENT_AMOUNT); + vm.prank(payer); + token_.setOperator(address(escrow), uint48(block.timestamp + 86400)); + InEuint64 memory encPayment = createInEuint64(PAYMENT_AMOUNT, payer); + vm.prank(payer); + escrow.fund(escrowId, encPayment); + } + + function test_initialize_allowsDefaultToken() public view { + assertTrue(escrow.isAllowedToken(address(token))); + } + + function test_create_typed_usesDefaultToken() public { + _createEscrow(); + assertEq(escrow.paymentTokenOf(0), address(token)); + } + + function test_create_bytes_zeroToken_usesDefaultToken() public { + _createEscrowWithToken(address(0)); + assertEq(escrow.paymentTokenOf(0), address(token)); + } + + function test_create_bytes_allowedToken_setsPaymentTokenOf() public { + vm.prank(owner); + MockConfidentialToken token2 = new MockConfidentialToken(); + vm.prank(owner); + escrow.addAllowedToken(address(token2)); + + _createEscrowWithToken(address(token2)); + assertEq(escrow.paymentTokenOf(0), address(token2)); + } + + function test_create_bytes_notAllowedToken_reverts() public { + vm.prank(owner); + MockConfidentialToken token2 = new MockConfidentialToken(); + + InEaddress memory encOwner = createInEaddress(escrowOwner, owner); + InEuint64 memory encAmount = createInEuint64(ESCROW_AMOUNT, owner); + bytes memory initData = abi.encode(encOwner, encAmount, address(token2)); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(EscrowLib.TokenNotAllowed.selector, address(token2))); + escrow.create(initData, address(0), ""); + } + + function test_addAllowedToken_revertsForNonOwner() public { + vm.prank(attacker); + vm.expectRevert(); + escrow.addAllowedToken(attacker); + } + + function test_addAllowedToken_emitsTokenAllowed() public { + vm.prank(owner); + MockConfidentialToken token2 = new MockConfidentialToken(); + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit IEscrowEvents.TokenAllowed(address(token2)); + escrow.addAllowedToken(address(token2)); + assertTrue(escrow.isAllowedToken(address(token2))); + } + + function test_removeAllowedToken_emitsTokenRemoved() public { + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit IEscrowEvents.TokenRemoved(address(token)); + escrow.removeAllowedToken(address(token)); + assertFalse(escrow.isAllowedToken(address(token))); + } + + function test_fundAndRedeem_useSelectedToken() public { + vm.prank(owner); + MockConfidentialToken token2 = new MockConfidentialToken(); + vm.prank(owner); + escrow.addAllowedToken(address(token2)); + + uint256 id = _createEscrowWithToken(address(token2)); + _fundEscrowWith(id, token2); + + vm.prank(escrowOwner); + vm.expectEmit(true, false, false, false); + emit IEscrowEvents.EscrowRedeemed(0); + escrow.redeem(0); + } + + function test_redeemMultiple_acrossDifferentTokens() public { + vm.prank(owner); + MockConfidentialToken token2 = new MockConfidentialToken(); + vm.prank(owner); + escrow.addAllowedToken(address(token2)); + + uint256 id0 = _createEscrow(); + _fundEscrow(id0); + uint256 id1 = _createEscrowWithToken(address(token2)); + _fundEscrowWith(id1, token2); + + assertEq(escrow.paymentTokenOf(id0), address(token)); + assertEq(escrow.paymentTokenOf(id1), address(token2)); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + + vm.prank(escrowOwner); + vm.expectEmit(false, false, false, true); + emit IEscrowEvents.EscrowBatchRedeemed(ids); + escrow.redeemMultiple(ids); + } } diff --git a/packages/escrow/test/unit/Escrow.t.sol b/packages/escrow/test/unit/Escrow.t.sol index 85a2d44..72dd33c 100644 --- a/packages/escrow/test/unit/Escrow.t.sol +++ b/packages/escrow/test/unit/Escrow.t.sol @@ -335,4 +335,166 @@ contract EscrowTest is Test { assertFalse(escrow.exists(0)); assertFalse(escrow.exists(999)); } + + function test_initialize_allowsDefaultToken() public view { + assertTrue(escrow.isAllowedToken(address(usdc))); + } + + function test_addAllowedToken_onlyOwner() public { + MockUSDC token = new MockUSDC(); + vm.prank(user1); + vm.expectRevert(); + escrow.addAllowedToken(address(token)); + } + + function test_addAllowedToken_setsAllowedAndEmits() public { + MockUSDC token = new MockUSDC(); + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit IEscrowEvents.TokenAllowed(address(token)); + escrow.addAllowedToken(address(token)); + assertTrue(escrow.isAllowedToken(address(token))); + } + + function test_addAllowedToken_revertsOnZeroAddress() public { + vm.prank(owner); + vm.expectRevert(); + escrow.addAllowedToken(address(0)); + } + + function test_removeAllowedToken_onlyOwner() public { + vm.prank(user1); + vm.expectRevert(); + escrow.removeAllowedToken(address(usdc)); + } + + function test_removeAllowedToken_clearsAllowedAndEmits() public { + vm.prank(owner); + vm.expectEmit(true, false, false, false); + emit IEscrowEvents.TokenRemoved(address(usdc)); + escrow.removeAllowedToken(address(usdc)); + assertFalse(escrow.isAllowedToken(address(usdc))); + } + + function test_removeAllowedToken_existingEscrowStillRedeems() public { + MockUSDC token = new MockUSDC(); + vm.prank(owner); + escrow.addAllowedToken(address(token)); + + vm.prank(user1); + escrow.create(abi.encode(user2, ESCROW_AMOUNT, address(token)), address(0), ""); + + vm.prank(owner); + escrow.removeAllowedToken(address(token)); + + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(EscrowLib.TokenNotAllowed.selector, address(token))); + escrow.create(abi.encode(user2, ESCROW_AMOUNT, address(token)), address(0), ""); + + token.mint(user1, ESCROW_AMOUNT); + vm.startPrank(user1); + token.approve(address(escrow), ESCROW_AMOUNT); + escrow.fund(0, ESCROW_AMOUNT); + vm.stopPrank(); + + vm.prank(user2); + escrow.redeem(0); + assertEq(token.balanceOf(user2), ESCROW_AMOUNT); + } + + function test_create_typed_usesDefaultToken() public { + vm.prank(user1); + escrow.create(user2, ESCROW_AMOUNT, address(0), ""); + assertEq(escrow.paymentTokenOf(0), address(usdc)); + } + + function test_create_bytes2Field_usesDefaultToken() public { + vm.prank(user1); + escrow.create(abi.encode(user2, ESCROW_AMOUNT), address(0), ""); + assertEq(escrow.paymentTokenOf(0), address(usdc)); + } + + function test_create_bytes3Field_zeroToken_usesDefaultToken() public { + vm.prank(user1); + escrow.create(abi.encode(user2, ESCROW_AMOUNT, address(0)), address(0), ""); + assertEq(escrow.paymentTokenOf(0), address(usdc)); + } + + function test_create_bytes3Field_allowedToken_setsPaymentTokenOf() public { + MockUSDC token = new MockUSDC(); + vm.prank(owner); + escrow.addAllowedToken(address(token)); + + vm.prank(user1); + escrow.create(abi.encode(user2, ESCROW_AMOUNT, address(token)), address(0), ""); + assertEq(escrow.paymentTokenOf(0), address(token)); + } + + function test_create_bytes3Field_notAllowedToken_reverts() public { + MockUSDC token = new MockUSDC(); + vm.prank(user1); + vm.expectRevert(abi.encodeWithSelector(EscrowLib.TokenNotAllowed.selector, address(token))); + escrow.create(abi.encode(user2, ESCROW_AMOUNT, address(token)), address(0), ""); + } + + function test_paymentTokenOf_revertsForNonExistent() public { + vm.expectRevert(abi.encodeWithSelector(EscrowLib.EscrowDoesNotExist.selector, uint256(0))); + escrow.paymentTokenOf(0); + } + + function test_fundAndRedeem_useSelectedToken() public { + MockUSDC token = new MockUSDC(); + vm.prank(owner); + escrow.addAllowedToken(address(token)); + + vm.prank(user1); + escrow.create(abi.encode(user2, ESCROW_AMOUNT, address(token)), address(0), ""); + + token.mint(user1, ESCROW_AMOUNT); + vm.startPrank(user1); + token.approve(address(escrow), ESCROW_AMOUNT); + escrow.fund(0, ESCROW_AMOUNT); + vm.stopPrank(); + + assertEq(token.balanceOf(address(escrow)), ESCROW_AMOUNT); + assertEq(usdc.balanceOf(address(escrow)), 0); + + vm.prank(user2); + escrow.redeem(0); + + assertEq(token.balanceOf(user2), ESCROW_AMOUNT); + assertEq(usdc.balanceOf(user2), 0); + } + + function test_redeemMultiple_paysEachTokenSeparately() public { + MockUSDC token = new MockUSDC(); + vm.prank(owner); + escrow.addAllowedToken(address(token)); + + vm.startPrank(user1); + escrow.create(user1, ESCROW_AMOUNT, address(0), ""); + escrow.create(abi.encode(user1, ESCROW_AMOUNT, address(token)), address(0), ""); + vm.stopPrank(); + + usdc.mint(user1, ESCROW_AMOUNT); + token.mint(user1, ESCROW_AMOUNT); + vm.startPrank(user1); + usdc.approve(address(escrow), ESCROW_AMOUNT); + token.approve(address(escrow), ESCROW_AMOUNT); + escrow.fund(0, ESCROW_AMOUNT); + escrow.fund(1, ESCROW_AMOUNT); + vm.stopPrank(); + + uint256[] memory ids = new uint256[](2); + ids[0] = 0; + ids[1] = 1; + + vm.prank(user1); + escrow.redeemMultiple(ids); + + assertEq(usdc.balanceOf(user1), ESCROW_AMOUNT); + assertEq(token.balanceOf(user1), ESCROW_AMOUNT); + assertTrue(escrow.getRedeemedStatus(0)); + assertTrue(escrow.getRedeemedStatus(1)); + } } diff --git a/packages/offchain/packages/operator-cli/src/abis/ConfidentialEscrow.json b/packages/offchain/packages/operator-cli/src/abis/ConfidentialEscrow.json index 886ed51..557b55e 100644 --- a/packages/offchain/packages/operator-cli/src/abis/ConfidentialEscrow.json +++ b/packages/offchain/packages/operator-cli/src/abis/ConfidentialEscrow.json @@ -371,6 +371,42 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getUnderwriterFees", + "inputs": [ + { + "name": "escrowId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "tuple[]", + "internalType": "struct ConfidentialEscrow.Fee[]", + "components": [ + { + "name": "bps", + "type": "bytes32", + "internalType": "euint64" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "set", + "type": "bool", + "internalType": "bool" + } + ] + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "initialize", diff --git a/packages/shared/contracts/interfaces/core/IEscrow.sol b/packages/shared/contracts/interfaces/core/IEscrow.sol index db80810..1d13fb1 100644 --- a/packages/shared/contracts/interfaces/core/IEscrow.sol +++ b/packages/shared/contracts/interfaces/core/IEscrow.sol @@ -126,4 +126,25 @@ interface IEscrow is IERC165, ICore, IEscrowEvents { /// @notice Assigns the coverage manager authorized to call `setUnderwriterFee` /// @param coverageManager The new coverage manager address function setCoverageManager(address coverageManager) external; + + /// @notice Returns the payment token bound to a specific escrow at creation + /// @dev Escrows created without an explicit token fall back to the engine default. + /// @param escrowId The escrow identifier + /// @return The ERC20/FHERC20 token address used to fund and redeem this escrow + function paymentTokenOf(uint256 escrowId) external view returns (address); + + /// @notice Adds a token to the per-escrow payment-token allow-list + /// @dev Owner-only. Allowed tokens may be selected as the payment token at create. + /// @param token The token address to allow (must be non-zero) + function addAllowedToken(address token) external; + + /// @notice Removes a token from the per-escrow payment-token allow-list + /// @dev Owner-only. Does not affect escrows already created with this token. + /// @param token The token address to disallow + function removeAllowedToken(address token) external; + + /// @notice Checks whether a token is on the per-escrow payment-token allow-list + /// @param token The token address to query + /// @return True if the token may be selected as a payment token at create + function isAllowedToken(address token) external view returns (bool); } diff --git a/packages/shared/contracts/interfaces/core/IEscrowEvents.sol b/packages/shared/contracts/interfaces/core/IEscrowEvents.sol index cdc2654..f2a733c 100644 --- a/packages/shared/contracts/interfaces/core/IEscrowEvents.sol +++ b/packages/shared/contracts/interfaces/core/IEscrowEvents.sol @@ -27,6 +27,14 @@ interface IEscrowEvents { /// @param coverageManager The newly assigned coverage manager event CoverageManagerSet(address indexed coverageManager); + /// @notice Emitted when a payment token is added to the per-escrow allow-list + /// @param token The token address that is now allowed at create + event TokenAllowed(address indexed token); + + /// @notice Emitted when a payment token is removed from the per-escrow allow-list + /// @param token The token address that is no longer allowed at create + event TokenRemoved(address indexed token); + /// @notice Emitted when a fee is stamped onto an escrow /// @dev For the confidential branch, `bps` is emitted as 0 (encrypted state cannot be in events). /// @param escrowId The escrow identifier diff --git a/packages/shared/contracts/libraries/EscrowLib.sol b/packages/shared/contracts/libraries/EscrowLib.sol index eb0fe68..d9ed2ca 100644 --- a/packages/shared/contracts/libraries/EscrowLib.sol +++ b/packages/shared/contracts/libraries/EscrowLib.sol @@ -9,6 +9,7 @@ library EscrowLib { error NotCoverageManager(); error InvalidFeeKind(uint8 kind); error FeeBudgetExceeded(uint16 currentSumBps, uint16 requestedBps, uint16 maxBps); + error TokenNotAllowed(address token); function validateExists(bool exists_, uint256 escrowId) internal pure { if (!exists_) revert EscrowDoesNotExist(escrowId);