// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; import {SealedPolicyRegistry} from "./SealedPolicyRegistry.sol"; interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address a) external view returns (uint256); } /// @title ServiceCredit — non-transferable ERC-20 service credits, one per product line (GH-W1-001 §2.10, Phase A). /// @notice transfer/transferFrom/approve always revert; only the exchange mints (on payment) and burns (on redemption). /// Witness fence (§0.3): this is JFD's own non-transferable service credit; no other token is held by any JFD seat. contract ServiceCredit { string public name; string public symbol; uint8 public constant decimals = 18; uint256 public totalSupply; address public immutable exchange; mapping(address => uint256) public balanceOf; event Transfer(address indexed from, address indexed to, uint256 value); event Redeemed(address indexed holder, uint256 amount, bytes32 serviceRef); constructor(string memory _name, string memory _symbol, address _exchange) { name = _name; symbol = _symbol; exchange = _exchange; } modifier onlyExchange() { require(msg.sender == exchange, "CREDIT: only exchange"); _; } function mint(address to, uint256 amount) external onlyExchange { totalSupply += amount; balanceOf[to] += amount; emit Transfer(address(0), to, amount); } function burn(address from, uint256 amount, bytes32 serviceRef) external onlyExchange { require(balanceOf[from] >= amount, "CREDIT: insufficient"); balanceOf[from] -= amount; totalSupply -= amount; emit Transfer(from, address(0), amount); emit Redeemed(from, amount, serviceRef); } function transfer(address, uint256) external pure returns (bool) { revert("CREDIT: non-transferable"); } function transferFrom(address, address, uint256) external pure returns (bool) { revert("CREDIT: non-transferable"); } function approve(address, uint256) external pure returns (bool) { revert("CREDIT: non-transferable"); } function allowance(address, address) external pure returns (uint256) { return 0; } } /// @title CreditEcosystemTreasury — the only place the 20 % goes; a contract, never an EOA. contract CreditEcosystemTreasury { event Received(address indexed token, uint256 amount); receive() external payable { emit Received(address(0), msg.value); } function noteToken(address token, uint256 amount) external { emit Received(token, amount); } } /// @title CreditExchange — deterministic payment exchange (GH-W1-001 §2.10). /// @notice Accepts USDG and ETH only; mints credits at a posted rate; forwards exactly 20 % of every payment to the /// CreditEcosystemTreasury contract; forwards the remaining 80 % to the product vault (a contract); never routes /// anything to an EOA. Pricing is OPA-gated: a new rate must be a policy hash registered in the sealed registry. contract CreditExchange { SealedPolicyRegistry public immutable registry; bytes32 public constant PRICING_POLICY = keccak256("glass-hull/credit-pricing/v1"); IERC20 public immutable usdg; // 6 decimals on 4663 address payable public immutable treasury; // CreditEcosystemTreasury (contract) address payable public immutable vault; // product vault (contract) uint256 public rateUsdgPerCredit; // USDG (6 dp) per 1e18 credit uint256 public rateWeiPerCredit; // wei per 1e18 credit bytes32 public rateHash; mapping(bytes32 => ServiceCredit) public credits; // product line id -> token event RateApplied(uint256 usdgPerCredit, uint256 weiPerCredit, bytes32 policyHash); event RateRefused(uint256 usdgPerCredit, uint256 weiPerCredit, bytes32 policyHash, string reason); event Purchased(address indexed buyer, bytes32 indexed line, address token, uint256 paid, uint256 credits, uint256 toTreasury, uint256 toVault); event LineCreated(bytes32 indexed line, address token); constructor(address _registry, address _usdg, address payable _treasury, address payable _vault) { require(_treasury.code.length > 0 && _vault.code.length > 0, "EXCHANGE: recipients must be contracts"); registry = SealedPolicyRegistry(_registry); usdg = IERC20(_usdg); treasury = _treasury; vault = _vault; } function createLine(bytes32 line, string calldata name_, string calldata symbol_) external returns (address) { require(address(credits[line]) == address(0), "EXCHANGE: line exists"); ServiceCredit t = new ServiceCredit(name_, symbol_, address(this)); credits[line] = t; emit LineCreated(line, address(t)); return address(t); } function hashOfRate(uint256 usdgPerCredit, uint256 weiPerCredit) public pure returns (bytes32) { return sha256(abi.encodePacked('{"policy":"credit-pricing/v1","usdg_per_credit":', _u(usdgPerCredit), ',"wei_per_credit":', _u(weiPerCredit), '}')); } /// @notice OPA-gated pricing: PASS only if the rate's policy hash is sealed in the registry; REFUSE otherwise. function setRate(uint256 usdgPerCredit, uint256 weiPerCredit) external { bytes32 h = hashOfRate(usdgPerCredit, weiPerCredit); if (!registry.isRegistered(PRICING_POLICY, h)) { emit RateRefused(usdgPerCredit, weiPerCredit, h, "policy-hash-not-sealed"); revert("EXCHANGE: rate not in sealed policy"); } rateUsdgPerCredit = usdgPerCredit; rateWeiPerCredit = weiPerCredit; rateHash = h; emit RateApplied(usdgPerCredit, weiPerCredit, h); } function buyWithUsdg(bytes32 line, uint256 amount) external { require(rateHash != bytes32(0) && rateUsdgPerCredit > 0, "EXCHANGE: no sealed rate"); ServiceCredit t = credits[line]; require(address(t) != address(0), "EXCHANGE: unknown line"); require(usdg.transferFrom(msg.sender, address(this), amount), "EXCHANGE: usdg pull failed"); uint256 toTreasury = amount * 2000 / 10000; uint256 toVault = amount - toTreasury; require(usdg.transfer(treasury, toTreasury) && usdg.transfer(vault, toVault), "EXCHANGE: forward failed"); CreditEcosystemTreasury(treasury).noteToken(address(usdg), toTreasury); uint256 minted = amount * 1e18 / rateUsdgPerCredit; t.mint(msg.sender, minted); emit Purchased(msg.sender, line, address(usdg), amount, minted, toTreasury, toVault); } function buyWithEth(bytes32 line) external payable { require(rateHash != bytes32(0) && rateWeiPerCredit > 0, "EXCHANGE: no sealed rate"); ServiceCredit t = credits[line]; require(address(t) != address(0), "EXCHANGE: unknown line"); uint256 toTreasury = msg.value * 2000 / 10000; uint256 toVault = msg.value - toTreasury; (bool a, ) = treasury.call{value: toTreasury}(""); (bool b, ) = vault.call{value: toVault}(""); require(a && b, "EXCHANGE: forward failed"); uint256 minted = msg.value * 1e18 / rateWeiPerCredit; t.mint(msg.sender, minted); emit Purchased(msg.sender, line, address(0), msg.value, minted, toTreasury, toVault); } /// @notice Any other token is refused at the door: there is no path that accepts it. function redeem(bytes32 line, uint256 amount, bytes32 serviceRef) external { credits[line].burn(msg.sender, amount, serviceRef); } function _u(uint256 v) internal pure returns (bytes memory) { if (v == 0) return "0"; bytes memory b = new bytes(78); uint256 i = 78; uint256 x = v; while (x > 0) { i--; b[i] = bytes1(uint8(48 + x % 10)); x /= 10; } bytes memory o = new bytes(78 - i); for (uint256 j = 0; j < o.length; j++) o[j] = b[i + j]; return o; } } /// @title ProductVault — receives the 80 %; a contract, never an EOA (Phase A placeholder). contract ProductVault { event Received(address indexed token, uint256 amount); receive() external payable { emit Received(address(0), msg.value); } }