// SPDX-License-Identifier: LGPL-3.0-or-later pragma solidity 0.8.30; import {Delta} from "./ZycordDelta.sol"; /// @title HashlockEscrow -- non-custodial OTC escrow for ZCD, canary phase. /// /// An OTC buyer and seller agree a price off-chain. The seller locks ZCD here /// against a hashlock the parties already share; the claimant takes the funds /// by revealing the preimage; after the refund height anyone may return the /// remainder to the opener. No key of the operator's can touch a deal, and /// every state transition is a plain on-chain read. /// /// # The flow this contract serves /// /// 1. buyer and seller agree amount, price and payment channel off-chain; /// 2. the seller makes a 32-byte secret s and hands only H = sha256(s) to /// the buyer, together with the deal terms; /// 3. the seller calls `open` -- the buyer verifies the deal cells on-chain /// BEFORE paying anything: the ZCD exists, it is committed to the buyer's /// claim, and it cannot move to anyone else; /// 4. the buyer pays the external leg; /// 5. the seller sends s to the buyer; the buyer (or anyone holding s) /// calls `claim` and is paid; /// 6. if the deal dies, anyone calls `refund` after the height and the /// opener is made whole. /// /// # What this contract does NOT solve, said where a user will read it /// /// The external leg has no on-chain representation, so no contract can force /// it. A seller who is paid and then withholds s still refunds to itself in /// the end: the buyer's protection is that the lock is verifiable before /// paying, the caps below bound every loss, and the preimage route can never /// be blocked, only unused. That is the honest limit of a hashlock over one /// chain, and it is why the caps are small. /// /// # Time: the epoch dialect, written for the caller /// /// `block.number` here is the height at which the current epoch BEGAN (2880 /// blocks, about a day): it is constant for a whole epoch and moves once per /// epoch, and it is therefore already up to a day in the past when a deal /// opens. `refundHeight` is written against that clock, and the window check /// demands it clear the NEXT boundary, so the unlock lands on the second /// boundary ahead: every deal is locked for a full 24 h at minimum and 48 h /// at most. Thirty boundaries out is about a month. Deal parties read the /// same clock before they agree terms, so both sides price the window /// knowingly. /// /// # Discipline (the stdlib's, followed exactly) /// /// Terms are WRITE-ONCE: `open` refuses an id whose terms cell is non-zero, /// and the id is `escrowId(opener, salt)`, derived from the opener exactly /// the way the standard Escrow derives its own, so a copied certificate lands /// under the copier's id and nothing can be squatted ahead of its opener. A /// cell only its own creation writes cannot be made stale by a stranger, so /// the reads of the terms in `claim` and `refund` are not poisonable. /// /// The amount lives on a DELTA cell under `subGuarded`, and the global /// locked counter and the per-opener outstanding counter are delta cells /// too: no entry point exact-reads either, so no stranger can stale a /// certificate through them. The per-deal cap is a plain `require` on /// `msg.value` -- an input, which nobody can poison. The global and /// per-opener caps are GUARDS (`assertLE`) recorded after the add: the fold /// rejects any certificate whose folded total would exceed them, concurrent /// opens included, and the skip fee is the submitter's own cost. /// /// Block data never decides settlement. The one height read gates `refund` /// alone, against the epoch clock, and no hash of any block enters anything. contract HashlockEscrow { /// Canary-phase caps, in drops (1 ZCD = 1e8 drops). Raising them is a /// redeployment, not a parameter: they exist to bound the loss a defect /// in this contract can cause before it has had an external review. uint256 public constant MAX_PER_DEAL = 20 * 1e8; // 20 ZCD uint256 public constant MAX_TOTAL_LOCKED = 200 * 1e8; // 200 ZCD /// Per-opener concurrent outstanding. Without it, one party could fill the /// whole global cap with self-deals and freeze the facility for a window; /// with it, squatting the cap needs many distinct funded wallets. uint256 public constant MAX_PER_OPENER = 60 * 1e8; // 60 ZCD /// The epoch length in blocks (2880 x 30s = 24 h). block.number is the /// CURRENT epoch's start height, so it is already up to a day in the /// past: a refundHeight merely above block.number can fall before the /// NEXT boundary and unlock in as little as one block. The window check /// below therefore demands the height clear the next whole boundary, /// which pushes the unlock to the SECOND boundary ahead: a guaranteed /// 24 h (and at most 48 h) of lock. The far cap is 30 boundaries (~1 month). uint256 public constant EPOCH = 2880; uint256 public constant MAX_WINDOW_EPOCHS = 30; event Opened( bytes32 indexed id, address indexed opener, address indexed claimant, uint256 amount, bytes32 hashlock, uint64 refundHeight, bytes32 salt ); event Claimed(bytes32 indexed id, address indexed claimant, uint256 amount, bytes32 preimage); event Refunded(bytes32 indexed id, address indexed opener, uint256 amount); /// Terms, in two cells per id: `claimant` and `refundHeight` share the /// first, `opener` the second. Set by `open`, never again. struct Terms { address claimant; uint64 refundHeight; address opener; } /// A cell only the delta wrapper touches (the stdlib's Amount). struct Amount { uint256 value; } /// A cell only the delta wrapper touches (the stdlib's Amount shape; a /// one-word struct is what makes its slot reachable through `.slot`). struct Locked { uint256 value; } Locked private _locked; mapping(bytes32 => Terms) private _terms; mapping(bytes32 => Amount) private _amount; mapping(bytes32 => bytes32) private _hashlock; mapping(address => Amount) private _outstanding; /// The id `opener` gets for `salt` -- the stdlib derivation, unchanged. function escrowId(address opener, bytes32 salt) public pure returns (bytes32) { return keccak256(abi.encode(opener, salt)); } function amountSlot(bytes32 id) public view returns (uint256 slot) { Amount storage a = _amount[id]; assembly { slot := a.slot } } function lockedSlot() public view returns (uint256 slot) { Locked storage l = _locked; assembly { slot := l.slot } } /// The opener's outstanding-escrow cell (a delta, guarded like the rest). function openerSlot(address opener) public view returns (uint256 slot) { Amount storage a = _outstanding[opener]; assembly { slot := a.slot } } /// Write the terms once, and credit the value. function open(bytes32 salt, address claimant, bytes32 hashlock, uint64 refundHeight) external payable returns (bytes32 id) { require(claimant != address(0), "claimant"); require(msg.value > 0 && msg.value <= MAX_PER_DEAL, "cap"); require(refundHeight > block.number + EPOCH, "window"); require(refundHeight <= block.number + MAX_WINDOW_EPOCHS * EPOCH, "far"); require(hashlock != bytes32(0), "hashlock"); require(hashlock != bytes32(0x66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925), "hashlock"); id = escrowId(msg.sender, salt); Terms storage t = _terms[id]; require(t.claimant == address(0), "open"); t.claimant = claimant; t.refundHeight = refundHeight; t.opener = msg.sender; _hashlock[id] = hashlock; Delta.add(amountSlot(id), msg.value); Delta.add(lockedSlot(), msg.value); Delta.assertLE(lockedSlot(), MAX_TOTAL_LOCKED); Delta.add(openerSlot(msg.sender), msg.value); Delta.assertLE(openerSlot(msg.sender), MAX_PER_OPENER); emit Opened(id, msg.sender, claimant, msg.value, hashlock, refundHeight, salt); } /// Pay the deal out to its claimant. The preimage is the right: whoever /// holds it may submit, the funds go to the stored claimant either way. /// No height gate -- a late reveal still beats a refund that has not /// landed, and the guarded deltas decide the race drop for drop. function claim(bytes32 id, uint256 amount, bytes32 preimage) external { Terms storage t = _terms[id]; address claimant = t.claimant; require(claimant != address(0), "id"); require(sha256(abi.encodePacked(preimage)) == _hashlock[id], "secret"); Delta.subGuarded(amountSlot(id), amount); Delta.subGuarded(lockedSlot(), amount); Delta.subGuarded(openerSlot(t.opener), amount); emit Claimed(id, claimant, amount, preimage); (bool ok,) = payable(claimant).call{value: amount}(""); require(ok, "pay"); } /// Return the funds to the opener once the height has arrived. Anyone /// may submit: the opener does not have to be the one online. function refund(bytes32 id, uint256 amount) external { Terms storage t = _terms[id]; require(t.claimant != address(0), "id"); require(block.number >= uint256(t.refundHeight), "early"); Delta.subGuarded(amountSlot(id), amount); Delta.subGuarded(lockedSlot(), amount); Delta.subGuarded(openerSlot(t.opener), amount); emit Refunded(id, t.opener, amount); (bool ok,) = payable(t.opener).call{value: amount}(""); require(ok, "pay"); } }