From 9402317eb2957f71fb00b7531f757c76badebb97 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Wed, 8 Jul 2026 10:02:12 -0700 Subject: [PATCH] feat(atomic): add x402 intent bridge adapters Co-authored-by: Cursor --- .../bridge/atomic/AtomicBridgeCoordinator.sol | 106 ++++---- .../bridge/atomic/AtomicX402SourceAdapter.sol | 119 ++++++++ .../CWMultiTokenBridgeSettlementAdapter.sol | 107 ++++++++ contracts/x402/AtomicCwUsdcUsdcPurchaseV2.sol | 150 +++++++++++ .../ConfigureAtomicX402PolygonIntent.s.sol | 73 +++++ .../atomic/AtomicCwUsdcUsdcPurchaseV2.t.sol | 84 ++++++ test/bridge/atomic/AtomicX402Intent.t.sol | 255 ++++++++++++++++++ 7 files changed, 848 insertions(+), 46 deletions(-) create mode 100644 contracts/bridge/atomic/AtomicX402SourceAdapter.sol create mode 100644 contracts/bridge/atomic/CWMultiTokenBridgeSettlementAdapter.sol create mode 100644 contracts/x402/AtomicCwUsdcUsdcPurchaseV2.sol create mode 100644 script/bridge/atomic/ConfigureAtomicX402PolygonIntent.s.sol create mode 100644 test/bridge/atomic/AtomicCwUsdcUsdcPurchaseV2.t.sol create mode 100644 test/bridge/atomic/AtomicX402Intent.t.sol diff --git a/contracts/bridge/atomic/AtomicBridgeCoordinator.sol b/contracts/bridge/atomic/AtomicBridgeCoordinator.sol index c417963..f2d736a 100644 --- a/contracts/bridge/atomic/AtomicBridgeCoordinator.sol +++ b/contracts/bridge/atomic/AtomicBridgeCoordinator.sol @@ -15,6 +15,7 @@ import {AtomicSlashingManager} from "./AtomicSlashingManager.sol"; contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridgeCoordinator { bytes32 public constant CORRIDOR_MANAGER_ROLE = keccak256("CORRIDOR_MANAGER_ROLE"); bytes32 public constant SETTLEMENT_MANAGER_ROLE = keccak256("SETTLEMENT_MANAGER_ROLE"); + bytes32 public constant INTENT_ADAPTER_ROLE = keccak256("INTENT_ADAPTER_ROLE"); IAtomicLiquidityVault public immutable liquidityVault; IAtomicFulfillerRegistry public immutable fulfillerRegistry; @@ -41,11 +42,16 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg mapping(bytes32 => AtomicTypes.AtomicIntent) private _intents; mapping(bytes32 => AtomicTypes.AtomicCommitment) private _commitments; mapping(bytes32 => AtomicTypes.AtomicObligation) private _obligations; + mapping(bytes32 => address) private _refundRecipients; event CorridorConfigured(bytes32 indexed corridorId, address indexed assetIn, address indexed assetOut); - event IntentCreated(bytes32 indexed obligationId, bytes32 indexed corridorId, bytes32 indexed intentId, address sender); + event IntentCreated( + bytes32 indexed obligationId, bytes32 indexed corridorId, bytes32 indexed intentId, address sender + ); event CommitmentAccepted(bytes32 indexed obligationId, address indexed fulfiller, uint256 bondAmount); - event SettlementInitiated(bytes32 indexed obligationId, bytes32 indexed settlementId, bytes32 indexed settlementMode); + event SettlementInitiated( + bytes32 indexed obligationId, bytes32 indexed settlementId, bytes32 indexed settlementMode + ); event SettlementConfirmed(bytes32 indexed obligationId, uint256 replenishAmount); event IntentRefunded(bytes32 indexed obligationId, uint256 refundedAmount); event CorridorDegraded(bytes32 indexed corridorId, bytes32 indexed obligationId, bytes32 reason); @@ -61,6 +67,8 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg error DeadlineNotReached(); error SettlementTimeoutNotReached(); error MinimumReplenishNotMet(); + error ZeroPayer(); + error ZeroRefundRecipient(); constructor( address liquidityVault_, @@ -84,12 +92,11 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg _grantRole(SETTLEMENT_MANAGER_ROLE, admin); } - function getCorridorId( - uint64 sourceChain, - uint64 destinationChain, - address assetIn, - address assetOut - ) public pure returns (bytes32) { + function getCorridorId(uint64 sourceChain, uint64 destinationChain, address assetIn, address assetOut) + public + pure + returns (bytes32) + { return keccak256(abi.encode(sourceChain, destinationChain, assetIn, assetOut)); } @@ -104,15 +111,36 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg } function createIntent(CreateIntentParams calldata p) external nonReentrant returns (bytes32 obligationId) { + obligationId = _createIntent(p, msg.sender, msg.sender); + } + + function createIntentFor(CreateIntentParams calldata p, address payer, address refundTo) + external + nonReentrant + onlyRole(INTENT_ADAPTER_ROLE) + returns (bytes32 obligationId) + { + if (payer == address(0)) revert ZeroPayer(); + if (refundTo == address(0)) revert ZeroRefundRecipient(); + obligationId = _createIntent(p, payer, refundTo); + } + + function _createIntent(CreateIntentParams calldata p, address payer, address refundTo) + internal + returns (bytes32 obligationId) + { bytes32 corridorId = getCorridorId(p.sourceChain, p.destinationChain, p.assetIn, p.assetOut); AtomicTypes.CorridorConfig memory cfg = _corridors[corridorId]; if (!cfg.enabled) revert CorridorDisabled(); if (cfg.degraded) revert CorridorDegradedError(); if (cfg.assetIn != p.assetIn || cfg.assetOut != p.assetOut) revert InvalidCorridor(); - if (p.deadline <= block.timestamp || p.deadline > block.timestamp + cfg.fulfilmentTimeout) revert InvalidDeadline(); + if (p.deadline <= block.timestamp || p.deadline > block.timestamp + cfg.fulfilmentTimeout) { + revert InvalidDeadline(); + } if (p.amountIn > cfg.maxNotional) revert MaxNotionalExceeded(); - AtomicTypes.CorridorLiquidityState memory state = liquidityVault.getCorridorLiquidityState(corridorId, p.assetOut); + AtomicTypes.CorridorLiquidityState memory state = + liquidityVault.getCorridorLiquidityState(corridorId, p.assetOut); if (p.minAmountOut > state.freeLiquidity) revert ReservedLiquidityLimitExceeded(); if (state.totalLiquidity > 0) { uint256 nextReserved = state.reservedLiquidity + p.minAmountOut; @@ -124,17 +152,10 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg bytes32 intentId = keccak256( abi.encode( - block.chainid, - msg.sender, - ++intentNonce, - corridorId, - p.amountIn, - p.minAmountOut, - p.deadline, - p.routeId + block.chainid, payer, ++intentNonce, corridorId, p.amountIn, p.minAmountOut, p.deadline, p.routeId ) ); - obligationId = keccak256(abi.encode(intentId, p.recipient)); + obligationId = keccak256(abi.encode(intentId, p.recipient, refundTo)); _intents[obligationId] = AtomicTypes.AtomicIntent({ sourceChain: p.sourceChain, @@ -158,10 +179,11 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg settlementInitiatedAt: 0 }); - obligationEscrow.escrowFunds(obligationId, p.assetIn, msg.sender, p.amountIn); + _refundRecipients[obligationId] = refundTo; + obligationEscrow.escrowFunds(obligationId, p.assetIn, payer, p.amountIn); liquidityVault.reserveLiquidity(corridorId, p.assetOut, obligationId, p.minAmountOut); - emit IntentCreated(obligationId, corridorId, intentId, msg.sender); + emit IntentCreated(obligationId, corridorId, intentId, payer); } function submitCommitment(bytes32 obligationId, bytes32 settlementMode) external nonReentrant { @@ -203,19 +225,10 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg if (obligation.status != AtomicTypes.ObligationStatus.Fulfilled) revert InvalidStatus(); bytes32 corridorId = getCorridorId(intent.sourceChain, intent.destinationChain, intent.assetIn, intent.assetOut); - uint256 settlementAmount = _releaseEscrowForSettlement( - obligationId, - corridorId, - intent.amountIn, - commitment.fulfiller - ); + uint256 settlementAmount = + _releaseEscrowForSettlement(obligationId, corridorId, intent.amountIn, commitment.fulfiller); settlementId = _executeSettlement( - obligationId, - commitment.settlementMode, - intent.assetIn, - settlementAmount, - intent.recipient, - settlementData + obligationId, commitment.settlementMode, intent.assetIn, settlementAmount, intent.recipient, settlementData ); obligation.status = AtomicTypes.ObligationStatus.SettlementPending; @@ -223,12 +236,10 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg emit SettlementInitiated(obligationId, settlementId, commitment.settlementMode); } - function _releaseEscrowForSettlement( - bytes32 obligationId, - bytes32 corridorId, - uint256 amountIn, - address fulfiller - ) internal returns (uint256 settlementAmount) { + function _releaseEscrowForSettlement(bytes32 obligationId, bytes32 corridorId, uint256 amountIn, address fulfiller) + internal + returns (uint256 settlementAmount) + { (uint256 fulfillerFee, uint256 protocolFee) = feePolicy.quoteFees(corridorId, amountIn); settlementAmount = amountIn - fulfillerFee - protocolFee; @@ -250,12 +261,7 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg bytes calldata settlementData ) internal returns (bytes32 settlementId) { settlementId = settlementRouter.executeSettlement{value: msg.value}( - obligationId, - settlementMode, - assetIn, - settlementAmount, - recipient, - settlementData + obligationId, settlementMode, assetIn, settlementAmount, recipient, settlementData ); } @@ -284,8 +290,12 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg bytes32 corridorId = getCorridorId(intent.sourceChain, intent.destinationChain, intent.assetIn, intent.assetOut); (, address payer,,,) = obligationEscrow.escrows(obligationId); + address refundTo = _refundRecipients[obligationId]; + if (refundTo == address(0)) { + refundTo = payer; + } liquidityVault.releaseReservation(obligationId); - uint256 refunded = obligationEscrow.refundRemaining(obligationId, payer); + uint256 refunded = obligationEscrow.refundRemaining(obligationId, refundTo); _corridors[corridorId].degraded = true; obligation.status = AtomicTypes.ObligationStatus.Refunded; emit IntentRefunded(obligationId, refunded); @@ -321,4 +331,8 @@ contract AtomicBridgeCoordinator is AccessControl, ReentrancyGuard, IAtomicBridg function getObligation(bytes32 obligationId) external view returns (AtomicTypes.AtomicObligation memory) { return _obligations[obligationId]; } + + function getRefundRecipient(bytes32 obligationId) external view returns (address) { + return _refundRecipients[obligationId]; + } } diff --git a/contracts/bridge/atomic/AtomicX402SourceAdapter.sol b/contracts/bridge/atomic/AtomicX402SourceAdapter.sol new file mode 100644 index 0000000..0cb450b --- /dev/null +++ b/contracts/bridge/atomic/AtomicX402SourceAdapter.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {AtomicBridgeCoordinator} from "./AtomicBridgeCoordinator.sol"; + +interface ILegacyBurnableERC20 is IERC20 { + function burn(uint256 amount) external; +} + +interface IMintableERC20 is IERC20 { + function mint(address to, uint256 amount) external; +} + +interface IAtomicObligationEscrowAddress { + function obligationEscrow() external view returns (address); +} + +/** + * @title AtomicX402SourceAdapter + * @notice Normalizes Chain 138 cUSDC/cUSDC_V2 input into cUSDC_V2 before creating + * an atomic x402 obligation. Legacy cUSDC is burned by this adapter after + * transfer-in; cUSDC_V2 is escrowed as the coordinator assetIn. + */ +contract AtomicX402SourceAdapter { + using SafeERC20 for IERC20; + + uint64 public immutable sourceChain; + uint64 public immutable destinationChainSelector; + ILegacyBurnableERC20 public immutable legacyCUSDC; + IMintableERC20 public immutable cUSDCV2; + IERC20 public immutable cWUSDCV2; + AtomicBridgeCoordinator public immutable coordinator; + address public immutable escrow; + + event SourceIntentCreated( + bytes32 indexed obligationId, + address indexed payer, + address indexed sourceToken, + address recipient, + uint256 amountIn, + uint256 minAmountOut + ); + + error UnsupportedSourceToken(address token); + error ZeroAddress(); + error ZeroAmount(); + + constructor( + address legacyCUSDC_, + address cUSDCV2_, + address cWUSDCV2_, + address coordinator_, + uint64 sourceChain_, + uint64 destinationChainSelector_ + ) { + if ( + legacyCUSDC_ == address(0) || cUSDCV2_ == address(0) || cWUSDCV2_ == address(0) + || coordinator_ == address(0) + ) { + revert ZeroAddress(); + } + legacyCUSDC = ILegacyBurnableERC20(legacyCUSDC_); + cUSDCV2 = IMintableERC20(cUSDCV2_); + cWUSDCV2 = IERC20(cWUSDCV2_); + coordinator = AtomicBridgeCoordinator(coordinator_); + sourceChain = sourceChain_; + destinationChainSelector = destinationChainSelector_; + escrow = IAtomicObligationEscrowAddress(coordinator_).obligationEscrow(); + IERC20(cUSDCV2_).forceApprove(escrow, type(uint256).max); + } + + function createIntent( + address sourceToken, + uint256 amountIn, + uint256 minAmountOut, + address recipient, + uint256 deadline, + bytes32 routeId + ) external returns (bytes32 obligationId) { + if (amountIn == 0 || minAmountOut == 0) revert ZeroAmount(); + if (recipient == address(0)) revert ZeroAddress(); + + _normalizeToV2(sourceToken, amountIn); + + obligationId = coordinator.createIntentFor( + AtomicBridgeCoordinator.CreateIntentParams({ + sourceChain: sourceChain, + destinationChain: destinationChainSelector, + assetIn: address(cUSDCV2), + assetOut: address(cWUSDCV2), + amountIn: amountIn, + minAmountOut: minAmountOut, + recipient: recipient, + deadline: deadline, + routeId: routeId + }), + address(this), + msg.sender + ); + + emit SourceIntentCreated(obligationId, msg.sender, sourceToken, recipient, amountIn, minAmountOut); + } + + function _normalizeToV2(address sourceToken, uint256 amountIn) internal { + if (sourceToken == address(legacyCUSDC)) { + IERC20(sourceToken).safeTransferFrom(msg.sender, address(this), amountIn); + legacyCUSDC.burn(amountIn); + cUSDCV2.mint(address(this), amountIn); + return; + } + if (sourceToken == address(cUSDCV2)) { + IERC20(sourceToken).safeTransferFrom(msg.sender, address(this), amountIn); + return; + } + revert UnsupportedSourceToken(sourceToken); + } +} diff --git a/contracts/bridge/atomic/CWMultiTokenBridgeSettlementAdapter.sol b/contracts/bridge/atomic/CWMultiTokenBridgeSettlementAdapter.sol new file mode 100644 index 0000000..9dfc8d9 --- /dev/null +++ b/contracts/bridge/atomic/CWMultiTokenBridgeSettlementAdapter.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAtomicSettlementAdapter} from "./interfaces/IAtomicSettlementAdapter.sol"; + +interface ICWMultiTokenBridgeL1Settlement { + function calculateFee(address canonicalToken, uint64 destinationChainSelector, address recipient, uint256 amount) + external + view + returns (uint256); + + function feeToken() external view returns (address); + + function lockAndSend(address canonicalToken, uint64 destinationChainSelector, address recipient, uint256 amount) + external + payable + returns (bytes32 messageId); +} + +/** + * @title CWMultiTokenBridgeSettlementAdapter + * @notice Replenishes a fulfiller/vault by forwarding coordinator settlement + * funds into CWMultiTokenBridgeL1.lockAndSend. + */ +contract CWMultiTokenBridgeSettlementAdapter is IAtomicSettlementAdapter { + using SafeERC20 for IERC20; + + ICWMultiTokenBridgeL1Settlement public immutable bridge; + uint64 public immutable defaultDestinationSelector; + + event CwSettlementSent( + bytes32 indexed obligationId, + bytes32 indexed messageId, + address indexed token, + address recipient, + uint64 destinationSelector, + uint256 amount, + uint256 fee + ); + + error ZeroAddress(); + error FeeTooLow(uint256 requiredFee, uint256 providedFee); + error InvalidSettlementData(); + + constructor(address bridge_, uint64 defaultDestinationSelector_) { + if (bridge_ == address(0)) revert ZeroAddress(); + bridge = ICWMultiTokenBridgeL1Settlement(bridge_); + defaultDestinationSelector = defaultDestinationSelector_; + } + + function executeSettlement( + bytes32 obligationId, + address token, + uint256 amount, + address recipient, + bytes calldata data + ) external payable returns (bytes32 settlementId) { + (address settlementRecipient, uint64 destinationSelector, uint256 maxFee) = + _decodeSettlementData(recipient, data); + uint256 fee = bridge.calculateFee(token, destinationSelector, settlementRecipient, amount); + if (maxFee != 0 && fee > maxFee) revert FeeTooLow(fee, maxFee); + + IERC20(token).forceApprove(address(bridge), amount); + address feeToken = bridge.feeToken(); + if (feeToken == address(0)) { + if (msg.value < fee) revert FeeTooLow(fee, msg.value); + settlementId = bridge.lockAndSend{value: fee}(token, destinationSelector, settlementRecipient, amount); + _refundNativeExcess(fee); + } else { + if (IERC20(feeToken).balanceOf(address(this)) < fee) { + revert FeeTooLow(fee, IERC20(feeToken).balanceOf(address(this))); + } + IERC20(feeToken).forceApprove(address(bridge), fee); + settlementId = bridge.lockAndSend(token, destinationSelector, settlementRecipient, amount); + } + + emit CwSettlementSent(obligationId, settlementId, token, settlementRecipient, destinationSelector, amount, fee); + } + + function _decodeSettlementData(address fallbackRecipient, bytes calldata data) + internal + view + returns (address settlementRecipient, uint64 destinationSelector, uint256 maxFee) + { + if (data.length == 0) { + settlementRecipient = fallbackRecipient; + destinationSelector = defaultDestinationSelector; + } else { + if (data.length != 96) revert InvalidSettlementData(); + (settlementRecipient, destinationSelector, maxFee) = abi.decode(data, (address, uint64, uint256)); + if (destinationSelector == 0) { + destinationSelector = defaultDestinationSelector; + } + } + if (settlementRecipient == address(0)) revert ZeroAddress(); + } + + function _refundNativeExcess(uint256 feeUsed) internal { + if (msg.value <= feeUsed) { + return; + } + (bool ok,) = payable(msg.sender).call{value: msg.value - feeUsed}(""); + require(ok, "CWMultiTokenBridgeSettlementAdapter: refund failed"); + } +} diff --git a/contracts/x402/AtomicCwUsdcUsdcPurchaseV2.sol b/contracts/x402/AtomicCwUsdcUsdcPurchaseV2.sol new file mode 100644 index 0000000..0a94db8 --- /dev/null +++ b/contracts/x402/AtomicCwUsdcUsdcPurchaseV2.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IERC3009TransferWithAuthorizationV2 { + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external; +} + +/** + * @title AtomicCwUsdcUsdcPurchaseV2 + * @notice Polygon x402 atomic purchase executor with an explorer-friendly `atomicX402Purchase` method name. + * @dev The buyer signs ERC-3009 for cWUSDC_V2 to `treasury`. This contract relays the authorization + * and transfers matching USDC inventory to the buyer in the same transaction. + */ +contract AtomicCwUsdcUsdcPurchaseV2 is Ownable, Pausable, ReentrancyGuard { + using SafeERC20 for IERC20; + + IERC3009TransferWithAuthorizationV2 public immutable cWUSDC; + IERC20 public immutable usdc; + address public treasury; + + event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); + event AtomicX402Purchase( + address indexed payer, + address indexed recipient, + address indexed treasury, + uint256 amount, + bytes32 authorizationNonce + ); + event AtomicX402InvoicePurchase( + bytes32 indexed obligationId, + bytes32 indexed invoiceHash, + address indexed payer, + address recipient, + uint256 amount, + bytes32 authorizationNonce + ); + + error ZeroAddress(); + error ZeroAmount(); + + constructor(address cWUSDC_, address usdc_, address treasury_, address owner_) Ownable(owner_) { + if (cWUSDC_ == address(0) || usdc_ == address(0) || treasury_ == address(0) || owner_ == address(0)) { + revert ZeroAddress(); + } + cWUSDC = IERC3009TransferWithAuthorizationV2(cWUSDC_); + usdc = IERC20(usdc_); + treasury = treasury_; + } + + function setTreasury(address newTreasury) external onlyOwner { + if (newTreasury == address(0)) revert ZeroAddress(); + emit TreasuryUpdated(treasury, newTreasury); + treasury = newTreasury; + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function atomicX402Purchase( + address payer, + address recipient, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant whenNotPaused { + _atomicX402Purchase(payer, recipient, amount, validAfter, validBefore, nonce, v, r, s); + } + + function purchaseWithAuthorization( + address payer, + address recipient, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant whenNotPaused { + _atomicX402Purchase(payer, recipient, amount, validAfter, validBefore, nonce, v, r, s); + } + + function atomicX402InvoicePurchase( + bytes32 obligationId, + bytes32 invoiceHash, + address payer, + address recipient, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) external nonReentrant whenNotPaused { + _atomicX402Purchase(payer, recipient, amount, validAfter, validBefore, nonce, v, r, s); + emit AtomicX402InvoicePurchase(obligationId, invoiceHash, payer, recipient, amount, nonce); + } + + function withdrawToken(address token, address to, uint256 amount) external onlyOwner { + if (token == address(0) || to == address(0)) revert ZeroAddress(); + IERC20(token).safeTransfer(to, amount); + } + + function _atomicX402Purchase( + address payer, + address recipient, + uint256 amount, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8 v, + bytes32 r, + bytes32 s + ) internal { + if (payer == address(0) || recipient == address(0)) revert ZeroAddress(); + if (amount == 0) revert ZeroAmount(); + + address currentTreasury = treasury; + cWUSDC.transferWithAuthorization(payer, currentTreasury, amount, validAfter, validBefore, nonce, v, r, s); + usdc.safeTransfer(recipient, amount); + + emit AtomicX402Purchase(payer, recipient, currentTreasury, amount, nonce); + } +} diff --git a/script/bridge/atomic/ConfigureAtomicX402PolygonIntent.s.sol b/script/bridge/atomic/ConfigureAtomicX402PolygonIntent.s.sol new file mode 100644 index 0000000..b1b14da --- /dev/null +++ b/script/bridge/atomic/ConfigureAtomicX402PolygonIntent.s.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console} from "forge-std/Script.sol"; +import "../../../contracts/bridge/atomic/AtomicBridgeCoordinator.sol"; +import "../../../contracts/bridge/atomic/AtomicFeePolicy.sol"; +import "../../../contracts/bridge/atomic/AtomicSettlementRouter.sol"; +import "../../../contracts/bridge/atomic/AtomicTypes.sol"; + +contract ConfigureAtomicX402PolygonIntent is Script { + uint64 internal constant SOURCE_CHAIN_138 = 138; + uint64 internal constant POLYGON_SELECTOR = 4051577828743386545; + address internal constant CUSDC_V2_CHAIN138 = 0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d; + address internal constant CWUSDC_V2_POLYGON = 0x7476a64B37732698b9f729fdB373a7B65e9677e9; + bytes32 internal constant SETTLEMENT_MODE = keccak256("CW_POLYGON_CUSDCV2_X402"); + + function run() external { + uint256 pk = vm.envUint("PRIVATE_KEY"); + AtomicBridgeCoordinator coordinator = AtomicBridgeCoordinator(vm.envAddress("ATOMIC_BRIDGE_COORDINATOR")); + AtomicFeePolicy feePolicy = AtomicFeePolicy(vm.envAddress("ATOMIC_FEE_POLICY")); + AtomicSettlementRouter settlementRouter = AtomicSettlementRouter(vm.envAddress("ATOMIC_SETTLEMENT_ROUTER")); + + address sourceAdapter = vm.envAddress("ATOMIC_X402_SOURCE_ADAPTER"); + address settlementAdapter = vm.envAddress("ATOMIC_CW_SETTLEMENT_ADAPTER"); + + uint256 maxNotional = vm.envOr("ATOMIC_X402_MAX_NOTIONAL", uint256(110_000e6)); + uint16 maxReservedBps = uint16(vm.envOr("ATOMIC_X402_MAX_RESERVED_BPS", uint256(8_000))); + uint256 targetBuffer = vm.envOr("ATOMIC_X402_TARGET_BUFFER", uint256(10_000e6)); + uint256 maxSettlementBacklog = vm.envOr("ATOMIC_X402_MAX_SETTLEMENT_BACKLOG", uint256(250_000e6)); + uint16 maxOracleDriftBps = uint16(vm.envOr("ATOMIC_X402_MAX_ORACLE_DRIFT_BPS", uint256(100))); + uint256 fulfilmentTimeout = vm.envOr("ATOMIC_X402_FULFILMENT_TIMEOUT", uint256(30 minutes)); + uint256 settlementTimeout = vm.envOr("ATOMIC_X402_SETTLEMENT_TIMEOUT", uint256(2 days)); + + uint16 fulfillerFeeBps = uint16(vm.envOr("ATOMIC_X402_FILLER_FEE_BPS", uint256(100))); + uint16 protocolFeeBps = uint16(vm.envOr("ATOMIC_X402_PROTOCOL_FEE_BPS", uint256(50))); + uint16 bondBps = uint16(vm.envOr("ATOMIC_X402_BOND_BPS", uint256(12_000))); + uint16 slashPenaltyBps = uint16(vm.envOr("ATOMIC_X402_SLASH_PENALTY_BPS", uint256(1_000))); + + bytes32 corridorId = + coordinator.getCorridorId(SOURCE_CHAIN_138, POLYGON_SELECTOR, CUSDC_V2_CHAIN138, CWUSDC_V2_POLYGON); + + vm.startBroadcast(pk); + coordinator.configureCorridor( + AtomicTypes.CorridorConfig({ + enabled: true, + degraded: false, + sourceChain: SOURCE_CHAIN_138, + destinationChain: POLYGON_SELECTOR, + assetIn: CUSDC_V2_CHAIN138, + assetOut: CWUSDC_V2_POLYGON, + maxNotional: maxNotional, + maxReservedBps: maxReservedBps, + targetBuffer: targetBuffer, + maxSettlementBacklog: maxSettlementBacklog, + maxOracleDriftBps: maxOracleDriftBps, + fulfilmentTimeout: fulfilmentTimeout, + settlementTimeout: settlementTimeout, + defaultSettlementMode: SETTLEMENT_MODE + }) + ); + coordinator.grantRole(coordinator.INTENT_ADAPTER_ROLE(), sourceAdapter); + settlementRouter.setAdapter(SETTLEMENT_MODE, settlementAdapter); + feePolicy.setCorridorPolicy( + corridorId, fulfillerFeeBps, protocolFeeBps, bondBps, slashPenaltyBps, fulfilmentTimeout, settlementTimeout + ); + vm.stopBroadcast(); + + console.log("corridorId"); + console.logBytes32(corridorId); + console.log("settlementMode"); + console.logBytes32(SETTLEMENT_MODE); + } +} diff --git a/test/bridge/atomic/AtomicCwUsdcUsdcPurchaseV2.t.sol b/test/bridge/atomic/AtomicCwUsdcUsdcPurchaseV2.t.sol new file mode 100644 index 0000000..da88fa2 --- /dev/null +++ b/test/bridge/atomic/AtomicCwUsdcUsdcPurchaseV2.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {AtomicCwUsdcUsdcPurchaseV2} from "../../../contracts/x402/AtomicCwUsdcUsdcPurchaseV2.sol"; +import "../../dbis/MockMintableToken.sol"; + +contract MockERC3009Token is ERC20 { + mapping(address => mapping(bytes32 => bool)) public authorizationState; + + constructor() ERC20("USD Coin (Compliant Wrapped V2)", "cWUSDC_V2") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transferWithAuthorization( + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint8, + bytes32, + bytes32 + ) external { + require(block.timestamp > validAfter, "not yet valid"); + require(block.timestamp < validBefore, "expired"); + require(!authorizationState[from][nonce], "used"); + authorizationState[from][nonce] = true; + _transfer(from, to, value); + } + + function decimals() public pure override returns (uint8) { + return 6; + } +} + +contract AtomicCwUsdcUsdcPurchaseV2Test is Test { + address internal payer; + address internal treasury = address(0xCAFE); + address internal invoiceRecipient = address(0xBEEF); + address internal owner = address(0x1234); + + MockERC3009Token internal cWUSDCV2; + MockMintableToken internal usdc; + AtomicCwUsdcUsdcPurchaseV2 internal purchase; + + function setUp() public { + payer = address(0xA11CE); + cWUSDCV2 = new MockERC3009Token(); + cWUSDCV2.mint(payer, 1_000e6); + usdc = new MockMintableToken("Polygon USDC", "USDC", 6, address(this)); + purchase = new AtomicCwUsdcUsdcPurchaseV2(address(cWUSDCV2), address(usdc), treasury, owner); + usdc.mint(address(purchase), 1_000e6); + } + + function testInvoicePurchaseTransfersCwToTreasuryAndUsdcToRecipient() public { + uint256 amount = 110_000; + uint256 validAfter = block.timestamp - 1; + uint256 validBefore = block.timestamp + 1 days; + bytes32 nonce = keccak256("invoice-auth"); + + purchase.atomicX402InvoicePurchase( + keccak256("obligation"), + keccak256("invoice"), + payer, + invoiceRecipient, + amount, + validAfter, + validBefore, + nonce, + 0, + bytes32(0), + bytes32(0) + ); + + assertEq(cWUSDCV2.balanceOf(treasury), amount); + assertEq(usdc.balanceOf(invoiceRecipient), amount); + assertTrue(cWUSDCV2.authorizationState(payer, nonce)); + } +} diff --git a/test/bridge/atomic/AtomicX402Intent.t.sol b/test/bridge/atomic/AtomicX402Intent.t.sol new file mode 100644 index 0000000..46b31ab --- /dev/null +++ b/test/bridge/atomic/AtomicX402Intent.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import "../../../contracts/bridge/atomic/AtomicBridgeCoordinator.sol"; +import "../../../contracts/bridge/atomic/AtomicFeePolicy.sol"; +import "../../../contracts/bridge/atomic/AtomicFulfillerRegistry.sol"; +import "../../../contracts/bridge/atomic/AtomicLiquidityVault.sol"; +import "../../../contracts/bridge/atomic/AtomicObligationEscrow.sol"; +import "../../../contracts/bridge/atomic/AtomicSettlementRouter.sol"; +import "../../../contracts/bridge/atomic/AtomicSlashingManager.sol"; +import "../../../contracts/bridge/atomic/AtomicTypes.sol"; +import "../../../contracts/bridge/atomic/AtomicX402SourceAdapter.sol"; +import "../../../contracts/bridge/atomic/CWMultiTokenBridgeSettlementAdapter.sol"; +import "../../dbis/MockMintableToken.sol"; + +contract MockBurnMintToken is ERC20 { + uint8 private immutable _decimals; + + constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) { + _decimals = decimals_; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } + + function decimals() public view override returns (uint8) { + return _decimals; + } +} + +contract MockCWMultiTokenBridgeL1 { + uint256 public fee; + address public feeToken; + bytes32 public nextMessageId = keccak256("cw-message"); + address public lastToken; + uint64 public lastSelector; + address public lastRecipient; + uint256 public lastAmount; + + function setFee(uint256 fee_) external { + fee = fee_; + } + + function setFeeToken(address feeToken_) external { + feeToken = feeToken_; + } + + function calculateFee(address, uint64, address, uint256) external view returns (uint256) { + return fee; + } + + function lockAndSend(address token, uint64 selector, address recipient, uint256 amount) + external + payable + returns (bytes32) + { + if (feeToken == address(0)) { + require(msg.value >= fee, "native fee"); + } else { + IERC20(feeToken).transferFrom(msg.sender, address(this), fee); + } + IERC20(token).transferFrom(msg.sender, address(this), amount); + lastToken = token; + lastSelector = selector; + lastRecipient = recipient; + lastAmount = amount; + return nextMessageId; + } +} + +contract AtomicX402IntentTest is Test { + uint64 internal constant SOURCE_CHAIN = 138; + uint64 internal constant POLYGON_SELECTOR = 4051577828743386545; + bytes32 internal constant SETTLEMENT_MODE = keccak256("CW_POLYGON_SETTLEMENT"); + + MockBurnMintToken internal legacyCUSDC; + MockBurnMintToken internal cUSDCV2; + MockMintableToken internal cWUSDCV2; + MockMintableToken internal bondToken; + + AtomicLiquidityVault internal vault; + AtomicFulfillerRegistry internal registry; + AtomicFeePolicy internal feePolicy; + AtomicObligationEscrow internal escrow; + AtomicSettlementRouter internal router; + AtomicSlashingManager internal slashingManager; + AtomicBridgeCoordinator internal coordinator; + AtomicX402SourceAdapter internal sourceAdapter; + + address internal user = address(0x1111); + address internal fulfiller = address(0x2222); + address internal protocolTreasury = address(0x3333); + bytes32 internal corridorId; + + function setUp() public { + legacyCUSDC = new MockBurnMintToken("Legacy cUSDC", "cUSDC", 6); + cUSDCV2 = new MockBurnMintToken("V2 cUSDC", "cUSDC_V2", 6); + cWUSDCV2 = new MockMintableToken("Polygon cWUSDC V2", "cWUSDC_V2", 6, address(this)); + bondToken = new MockMintableToken("Bond", "BOND", 6, address(this)); + + vault = new AtomicLiquidityVault(address(this)); + registry = new AtomicFulfillerRegistry(address(bondToken), address(this)); + feePolicy = new AtomicFeePolicy(address(this)); + escrow = new AtomicObligationEscrow(address(this)); + router = new AtomicSettlementRouter(address(this)); + slashingManager = new AtomicSlashingManager(address(registry), address(this)); + coordinator = new AtomicBridgeCoordinator( + address(vault), + address(registry), + address(escrow), + address(router), + address(feePolicy), + address(slashingManager), + protocolTreasury, + address(this) + ); + + vault.grantRole(vault.COORDINATOR_ROLE(), address(coordinator)); + vault.grantRole(vault.RECONCILER_ROLE(), address(coordinator)); + registry.grantRole(registry.COORDINATOR_ROLE(), address(coordinator)); + registry.grantRole(registry.SLASHER_ROLE(), address(slashingManager)); + escrow.grantRole(escrow.COORDINATOR_ROLE(), address(coordinator)); + router.grantRole(router.COORDINATOR_ROLE(), address(coordinator)); + slashingManager.grantRole(slashingManager.COORDINATOR_ROLE(), address(coordinator)); + + corridorId = coordinator.getCorridorId(SOURCE_CHAIN, POLYGON_SELECTOR, address(cUSDCV2), address(cWUSDCV2)); + coordinator.configureCorridor( + AtomicTypes.CorridorConfig({ + enabled: true, + degraded: false, + sourceChain: SOURCE_CHAIN, + destinationChain: POLYGON_SELECTOR, + assetIn: address(cUSDCV2), + assetOut: address(cWUSDCV2), + maxNotional: 500_000e6, + maxReservedBps: 8_000, + targetBuffer: 10_000e6, + maxSettlementBacklog: 250_000e6, + maxOracleDriftBps: 500, + fulfilmentTimeout: 1 days, + settlementTimeout: 2 days, + defaultSettlementMode: SETTLEMENT_MODE + }) + ); + feePolicy.setCorridorPolicy(corridorId, 100, 50, 12_000, 1_000, 1 days, 2 days); + vault.setTargetBuffer(corridorId, address(cWUSDCV2), 10_000e6); + + registry.setFulfillerActive(fulfiller, true); + registry.setCorridorAuthorization(fulfiller, corridorId, true); + + cWUSDCV2.mint(address(this), 100_000e6); + cWUSDCV2.approve(address(vault), type(uint256).max); + vault.fundCorridor(corridorId, address(cWUSDCV2), 100_000e6); + + bondToken.mint(fulfiller, 100_000e6); + vm.startPrank(fulfiller); + bondToken.approve(address(registry), type(uint256).max); + registry.depositBond(50_000e6); + vm.stopPrank(); + + sourceAdapter = new AtomicX402SourceAdapter( + address(legacyCUSDC), + address(cUSDCV2), + address(cWUSDCV2), + address(coordinator), + SOURCE_CHAIN, + POLYGON_SELECTOR + ); + coordinator.grantRole(coordinator.INTENT_ADAPTER_ROLE(), address(sourceAdapter)); + } + + function testLegacyCUSDCNormalizesToV2AndCreatesRefundableIntent() public { + legacyCUSDC.mint(user, 1_000e6); + + vm.startPrank(user); + legacyCUSDC.approve(address(sourceAdapter), 1_000e6); + bytes32 obligationId = sourceAdapter.createIntent( + address(legacyCUSDC), 1_000e6, 990e6, user, block.timestamp + 1 hours, corridorId + ); + vm.stopPrank(); + + assertEq(legacyCUSDC.balanceOf(user), 0); + assertEq(legacyCUSDC.totalSupply(), 0); + assertEq(cUSDCV2.balanceOf(address(escrow)), 1_000e6); + assertEq(cUSDCV2.balanceOf(address(sourceAdapter)), 0); + assertEq(coordinator.getRefundRecipient(obligationId), user); + + AtomicTypes.AtomicIntent memory intent = coordinator.getIntent(obligationId); + assertEq(intent.assetIn, address(cUSDCV2)); + assertEq(intent.assetOut, address(cWUSDCV2)); + assertEq(intent.destinationChain, POLYGON_SELECTOR); + } + + function testDirectCUSDCV2InputCreatesIntentWithoutLegacyBurn() public { + cUSDCV2.mint(user, 1_000e6); + legacyCUSDC.mint(user, 1_000e6); + + vm.startPrank(user); + cUSDCV2.approve(address(sourceAdapter), 1_000e6); + sourceAdapter.createIntent(address(cUSDCV2), 1_000e6, 990e6, user, block.timestamp + 1 hours, corridorId); + vm.stopPrank(); + + assertEq(cUSDCV2.balanceOf(address(escrow)), 1_000e6); + assertEq(legacyCUSDC.balanceOf(user), 1_000e6); + } + + function testExpiredAdapterIntentRefundsUserWithNormalizedV2() public { + legacyCUSDC.mint(user, 1_000e6); + + vm.startPrank(user); + legacyCUSDC.approve(address(sourceAdapter), 1_000e6); + bytes32 obligationId = sourceAdapter.createIntent( + address(legacyCUSDC), 1_000e6, 990e6, user, block.timestamp + 30 minutes, corridorId + ); + vm.stopPrank(); + + vm.warp(block.timestamp + 31 minutes); + coordinator.refundExpiredIntent(obligationId); + + assertEq(cUSDCV2.balanceOf(user), 1_000e6); + assertEq(cUSDCV2.balanceOf(address(sourceAdapter)), 0); + } + + function testSettlementAdapterCallsCwBridgeLockAndSend() public { + MockCWMultiTokenBridgeL1 bridge = new MockCWMultiTokenBridgeL1(); + bridge.setFee(0.01 ether); + CWMultiTokenBridgeSettlementAdapter adapter = + new CWMultiTokenBridgeSettlementAdapter(address(bridge), POLYGON_SELECTOR); + + cUSDCV2.mint(address(adapter), 1_000e6); + bytes32 settlementId = adapter.executeSettlement{value: 0.01 ether}( + keccak256("obligation"), + address(cUSDCV2), + 1_000e6, + fulfiller, + abi.encode(fulfiller, POLYGON_SELECTOR, uint256(0.01 ether)) + ); + + assertEq(settlementId, bridge.nextMessageId()); + assertEq(bridge.lastToken(), address(cUSDCV2)); + assertEq(bridge.lastSelector(), POLYGON_SELECTOR); + assertEq(bridge.lastRecipient(), fulfiller); + assertEq(bridge.lastAmount(), 1_000e6); + assertEq(cUSDCV2.balanceOf(address(bridge)), 1_000e6); + } +}