// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; /*══════════════════════════════════════════════════════════════════════════ Uniscribe UNI-20 Inscriptions on Uniswap v4 ══════════════════════════════════════════════════════════════════════════ Website : https://uniscribe.fun Twitter : https://x.com/uniscribe_eth ══════════════════════════════════════════════════════════════════════════*/ /* Uniscribe — UNI-20 Inscriptions for Uniswap v4 Hooks ----------------------------------------------------- A minimal, gas-conscious inscription protocol that piggybacks on Uniswap v4 swap hookData. Operators are encoded as compact JSON and parsed inside a v4 BaseHook. The protocol keeps balances on-chain while still emitting rich `Inscription` events for off-chain indexers. Supported ops: - deploy : register a new UNI-20 ticker with max supply + per-mint limit - mint : mint up to `lim` tokens of an existing ticker to msg.sender - transfer : move tokens of a ticker between addresses hookData JSON examples: {"p":"uni-20","op":"deploy","tick":"UNIS","max":"21000000","lim":"1000"} {"p":"uni-20","op":"mint","tick":"UNIS","amt":"1000"} {"p":"uni-20","op":"transfer","tick":"UNIS","amt":"500","to":"0x..."} NOTE: Interfaces below are slim shims of the Uniswap v4-core types so this file compiles standalone. In production import from: @uniswap/v4-core/src/interfaces/IPoolManager.sol @uniswap/v4-periphery/src/utils/BaseHook.sol */ // ───────────────────────────────────────────────────────────────────────────── // Real Uniswap v4-core imports // ───────────────────────────────────────────────────────────────────────────── import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/src/types/PoolKey.sol"; import {Currency} from "v4-core/src/types/Currency.sol"; import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; import {BeforeSwapDelta} from "v4-core/src/types/BeforeSwapDelta.sol"; /// @dev Minimal interface to query the treasury's current owner. interface IUniscribeTreasury { function owner() external view returns (address); } // ───────────────────────────────────────────────────────────────────────────── // Uniscribe // ───────────────────────────────────────────────────────────────────────────── contract Uniscribe is IHooks { // -------- Constants -------------------------------------------------- bytes4 internal constant BEFORE_SWAP_SELECTOR = IHooks.beforeSwap.selector; bytes4 internal constant AFTER_SWAP_SELECTOR = IHooks.afterSwap.selector; string public constant PROTOCOL = "uni-20"; string public constant VERSION = "1.0.0"; // Calibrated so fully minting all 21M UNIS costs ~20 ETH in protocol fees. // 21,000 mint ops × MINT_FEE ≈ 20 ETH (off by 8 microether due to integer div). uint256 public constant MINT_FEE = 952_380_952_380_952; // 20 ether / 21000 (floor), ~0.000952 ETH uint256 public constant DEPLOY_FEE = MINT_FEE * 10; // ~0.00952 ETH per ticker deploy // -------- Storage ---------------------------------------------------- IPoolManager public immutable poolManager; address public immutable treasury; struct Ticker { bool exists; address deployer; uint256 max; // hard cap uint256 lim; // per-mint cap uint256 minted; // running total uint64 deployedAt; // block timestamp uint64 inscriptionNo; } // tick (uppercased, max 8 chars) => Ticker mapping(bytes32 => Ticker) public tickers; // tick => holder => balance mapping(bytes32 => mapping(address => uint256)) public balanceOf; // tick => owner => spender => amount mapping(bytes32 => mapping(address => mapping(address => uint256))) public allowance; // global running inscription counter (monotonic) uint64 public totalInscriptions; // Privileged mint surface: the UniscribeMintPool can mint without // msg.value because the protocol fee is paid via v4 swap settlement. // Set once by treasury via setMintPool; immutable thereafter. address public mintPool; // ── Reentrancy guard (saves a slot vs OZ ReentrancyGuard) ──── uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2; uint256 private _reentryStatus = _NOT_ENTERED; modifier nonReentrant() { if (_reentryStatus == _ENTERED) revert Reentrancy(); _reentryStatus = _ENTERED; _; _reentryStatus = _NOT_ENTERED; } // -------- Events ----------------------------------------------------- event Inscription( uint64 indexed inscriptionNo, address indexed from, bytes32 indexed tick, string op, uint256 amount, address to, bytes raw ); event TickerDeployed(bytes32 indexed tick, address indexed deployer, uint256 max, uint256 lim); event Mint (bytes32 indexed tick, address indexed to, uint256 amount); event Transfer (bytes32 indexed tick, address indexed from, address indexed to, uint256 amount); event Approval (bytes32 indexed tick, address indexed owner, address indexed spender, uint256 amount); event MintPoolUpdated(address indexed previous, address indexed current); // -------- Errors ----------------------------------------------------- error InvalidProtocol(); error UnknownOp(); error BadTicker(); error TickerExists(); error TickerNotFound(); error ZeroAmount(); error MintLimitExceeded(); error MaxSupplyExceeded(); error InsufficientBalance(); error InsufficientAllowance(); error InsufficientFee(); error NotPoolManager(); error UnexpectedValue(); error Reentrancy(); error SwapPathOnlyTransfer(); error TreasuryTransferFailed(); error RefundFailed(); error NotMintPool(); error NotTreasury(); // -------- Genesis constants ----------------------------------------- /// @notice The canonical protocol ticker. Inscribed atomically at construction. string public constant GENESIS_TICKER = "UNIS"; uint256 public constant GENESIS_MAX = 21_000_000; uint256 public constant GENESIS_LIM = 1_000; // -------- Constructor ------------------------------------------------ /// @dev On construction we atomically inscribe the canonical `UNIS` ticker /// with Bitcoin-style fair-launch parameters (21M max, 1k per mint, /// no premine). This makes the genesis ticker unfrontrunnable — no /// one can squat `UNIS` in the next block after deployment. /// /// Skips the deploy fee (no msg.value at construction time) and /// records the treasury as the protocol-owned deployer. constructor(IPoolManager _poolManager, address _treasury) { poolManager = _poolManager; treasury = _treasury; // ── Genesis inscription #1: deploy UNIS ────────────────────── bytes32 tick = _packTicker("UNIS"); uint64 no = ++totalInscriptions; tickers[tick] = Ticker({ exists: true, deployer: _treasury, max: GENESIS_MAX, lim: GENESIS_LIM, minted: 0, deployedAt: uint64(block.timestamp), inscriptionNo: no }); bytes memory raw = bytes( '{"p":"uni-20","op":"deploy","tick":"UNIS","max":"21000000","lim":"1000"}' ); emit TickerDeployed(tick, _treasury, GENESIS_MAX, GENESIS_LIM); emit Inscription(no, _treasury, tick, "deploy", GENESIS_MAX, address(0), raw); } // -------- v4 Hook permissions --------------------------------------- // Real BaseHook would override getHookPermissions(); we expose this // helper so the deployer can verify before mining a hook address. function getHookPermissions() external pure returns ( bool beforeInitialize, bool afterInitialize, bool beforeAddLiquidity, bool afterAddLiquidity, bool beforeRemoveLiquidity, bool afterRemoveLiquidity, bool beforeSwapPerm, bool afterSwapPerm, bool beforeDonate, bool afterDonate ) { beforeSwapPerm = true; afterSwapPerm = true; } modifier onlyPoolManager() { if (msg.sender != address(poolManager)) revert NotPoolManager(); _; } // ─── Hook entrypoints ──────────────────────────────────────────────── function beforeSwap( address sender, PoolKey calldata, IPoolManager.SwapParams calldata, bytes calldata hookData ) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) { if (hookData.length > 0) { // PoolManager doesn't forward msg.value to hook callbacks. // Paid ops (deploy/mint) can never satisfy their fee here, so we // restrict the swap-path inscription to free `transfer` ops only. Op memory parsed = UniscribeJSON.parse(hookData); if (!_eq(parsed.p, "uni-20")) revert InvalidProtocol(); if (!_eq(parsed.op, "transfer")) revert SwapPathOnlyTransfer(); bytes32 tick = _packTicker(parsed.tick); _transfer(sender, parsed.to, tick, parsed.amt, hookData); } return (BEFORE_SWAP_SELECTOR, BeforeSwapDelta.wrap(0), 0); } function afterSwap( address, PoolKey calldata, SwapParams calldata, BalanceDelta, bytes calldata ) external override onlyPoolManager returns (bytes4, int128) { return (AFTER_SWAP_SELECTOR, 0); } // Convenience: inscribe without a swap (still emits an event and // mutates state). Useful for tests + direct integrations. function inscribe(bytes calldata payload) external payable nonReentrant { _processInscription(msg.sender, payload); } // ─── ERC20-style approvals (per-ticker) ────────────────────────────── /// @notice Approve `spender` to move up to `amount` of `ticker` from msg.sender. /// @dev Used by UNI-20 ERC20 wrappers and routers. function approve(string calldata ticker, address spender, uint256 amount) external returns (bool) { bytes32 tick = _packTicker(ticker); allowance[tick][msg.sender][spender] = amount; emit Approval(tick, msg.sender, spender, amount); return true; } /// @notice Move `amount` of `ticker` from `from` to `to`, consuming allowance. /// @dev Emits an `Inscription` event with empty raw bytes (off-chain triggered). function transferFromTicker( string calldata ticker, address from, address to, uint256 amount ) external returns (bool) { bytes32 tick = _packTicker(ticker); if (!tickers[tick].exists) revert TickerNotFound(); if (amount == 0) revert ZeroAmount(); if (to == address(0)) revert BadTicker(); if (balanceOf[tick][from] < amount) revert InsufficientBalance(); uint256 allowed = allowance[tick][from][msg.sender]; if (allowed != type(uint256).max) { if (allowed < amount) revert InsufficientAllowance(); unchecked { allowance[tick][from][msg.sender] = allowed - amount; } } unchecked { balanceOf[tick][from] -= amount; balanceOf[tick][to] += amount; } uint64 no = ++totalInscriptions; emit Transfer(tick, from, to, amount); emit Inscription(no, from, tick, "transfer", amount, to, ""); return true; } // ─── Privileged mint path for the v4 mint pool ────────────────────── /// @notice One-shot configuration: the UniscribeTreasury wires up the /// official UniscribeMintPool address, after which it's immutable. /// @notice Updatable by the treasury owner. Used to wire in (or later /// swap) the v4 mint pool contract. The treasury owner is already /// trusted with fee withdrawal so granting them update rights /// here adds no new attack surface vs. the one-shot pattern. function setMintPool(address _mintPool) external { if (msg.sender != IUniscribeTreasury(treasury).owner()) revert NotTreasury(); address old = mintPool; mintPool = _mintPool; emit MintPoolUpdated(old, _mintPool); } /// @notice Mint up to `lim` UNIS to `recipient` without paying msg.value. /// @dev Only callable by the configured mint pool. The protocol fee is /// paid via the v4 swap's settlement flow inside the same tx, /// not via msg.value. Maintains all the standard UNI-20 checks /// and emits the standard Mint + Inscription events so indexers /// see a single uniform mint stream regardless of source. function mintFromPool(address recipient, uint256 amt) external nonReentrant { if (msg.sender != mintPool) revert NotMintPool(); bytes32 tick = _packTicker("UNIS"); Ticker storage t = tickers[tick]; if (amt == 0) revert ZeroAmount(); if (amt > t.lim) revert MintLimitExceeded(); if (t.minted + amt > t.max) revert MaxSupplyExceeded(); t.minted += amt; balanceOf[tick][recipient] += amt; uint64 no = ++totalInscriptions; emit Mint(tick, recipient, amt); emit Inscription(no, recipient, tick, "mint", amt, recipient, ""); } // ─── Core router ───────────────────────────────────────────────────── function _processInscription(address from, bytes calldata payload) internal { Op memory op = UniscribeJSON.parse(payload); if (!_eq(op.p, "uni-20")) revert InvalidProtocol(); bytes32 tick = _packTicker(op.tick); if (_eq(op.op, "deploy")) { _deploy(from, tick, op.max, op.lim, payload); } else if (_eq(op.op, "mint")) { _mint(from, tick, op.amt, payload); } else if (_eq(op.op, "transfer")) { _transfer(from, op.to, tick, op.amt, payload); } else { revert UnknownOp(); } } // ─── Op implementations ────────────────────────────────────────────── function _deploy( address from, bytes32 tick, uint256 max, uint256 lim, bytes calldata raw ) internal { if (tick == bytes32(0)) revert BadTicker(); if (tickers[tick].exists) revert TickerExists(); if (max == 0 || lim == 0 || lim > max) revert BadTicker(); if (msg.value < DEPLOY_FEE) revert InsufficientFee(); uint64 no = ++totalInscriptions; tickers[tick] = Ticker({ exists: true, deployer: from, max: max, lim: lim, minted: 0, deployedAt: uint64(block.timestamp), inscriptionNo: no }); emit TickerDeployed(tick, from, max, lim); emit Inscription(no, from, tick, "deploy", max, address(0), raw); _payTreasury(DEPLOY_FEE); } function _mint( address from, bytes32 tick, uint256 amt, bytes calldata raw ) internal { Ticker storage t = tickers[tick]; if (!t.exists) revert TickerNotFound(); if (amt == 0) revert ZeroAmount(); if (amt > t.lim) revert MintLimitExceeded(); if (t.minted + amt > t.max) revert MaxSupplyExceeded(); if (msg.value < MINT_FEE) revert InsufficientFee(); t.minted += amt; balanceOf[tick][from] += amt; uint64 no = ++totalInscriptions; emit Mint(tick, from, amt); emit Inscription(no, from, tick, "mint", amt, from, raw); _payTreasury(MINT_FEE); } function _transfer( address from, address to, bytes32 tick, uint256 amt, bytes calldata raw ) internal { if (msg.value != 0) revert UnexpectedValue(); if (!tickers[tick].exists) revert TickerNotFound(); if (amt == 0) revert ZeroAmount(); if (to == address(0)) revert BadTicker(); if (balanceOf[tick][from] < amt) revert InsufficientBalance(); unchecked { balanceOf[tick][from] -= amt; balanceOf[tick][to] += amt; } uint64 no = ++totalInscriptions; emit Transfer(tick, from, to, amt); emit Inscription(no, from, tick, "transfer", amt, to, raw); } // ─── Views ─────────────────────────────────────────────────────────── function tickerInfo(string calldata ticker) external view returns (Ticker memory) { return tickers[_packTicker(ticker)]; } function balanceOfTicker(string calldata ticker, address holder) external view returns (uint256) { return balanceOf[_packTicker(ticker)][holder]; } // ─── Helpers ───────────────────────────────────────────────────────── function _payTreasury(uint256 amount) internal { (bool ok, ) = treasury.call{value: amount}(""); if (!ok) revert TreasuryTransferFailed(); if (msg.value > amount) { (bool refundOk, ) = msg.sender.call{value: msg.value - amount}(""); if (!refundOk) revert RefundFailed(); } } function _packTicker(string memory ticker) internal pure returns (bytes32 out) { bytes memory b = bytes(ticker); if (b.length == 0 || b.length > 8) revert BadTicker(); for (uint256 i = 0; i < b.length; ++i) { uint8 c = uint8(b[i]); // upper-case ASCII letters if (c >= 0x61 && c <= 0x7A) c -= 32; // allow A-Z, 0-9 bool ok = (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5A); if (!ok) revert BadTicker(); out |= bytes32(uint256(c) << (8 * (31 - i))); } } function _eq(string memory a, string memory b) internal pure returns (bool) { return keccak256(bytes(a)) == keccak256(bytes(b)); } } // ───────────────────────────────────────────────────────────────────────────── // Op struct + tiny JSON parser // This is deliberately strict: it expects flat JSON objects whose values are // either ASCII strings (quoted) or unsigned integers. No nested objects, no // escape sequences, no whitespace inside strings. Designed for hookData. // ───────────────────────────────────────────────────────────────────────────── struct Op { string p; string op; string tick; uint256 max; uint256 lim; uint256 amt; address to; } library UniscribeJSON { error MalformedJSON(); function parse(bytes calldata data) internal pure returns (Op memory op) { uint256 i = 0; uint256 n = data.length; i = _skipWs(data, i, n); if (i >= n || data[i] != "{") revert MalformedJSON(); i++; while (i < n) { i = _skipWs(data, i, n); if (i < n && data[i] == "}") { i++; break; } // key (string memory key, uint256 j) = _readString(data, i, n); i = _skipWs(data, j, n); if (i >= n || data[i] != ":") revert MalformedJSON(); i++; i = _skipWs(data, i, n); bytes32 k = keccak256(bytes(key)); if (k == keccak256("to")) { (string memory v, uint256 j2) = _readString(data, i, n); op.to = _parseAddress(v); i = j2; } else if (k == keccak256("max") || k == keccak256("lim") || k == keccak256("amt")) { (uint256 v, uint256 j2) = _readUintMaybeQuoted(data, i, n); if (k == keccak256("max")) op.max = v; else if (k == keccak256("lim")) op.lim = v; else op.amt = v; i = j2; } else { (string memory v, uint256 j2) = _readString(data, i, n); if (k == keccak256("p")) op.p = v; else if (k == keccak256("op")) op.op = v; else if (k == keccak256("tick")) op.tick = v; // unknown keys are silently ignored for forward-compat i = j2; } i = _skipWs(data, i, n); if (i < n && data[i] == ",") { i++; continue; } if (i < n && data[i] == "}") { i++; return op; } // anything else (including premature EOF) is malformed revert MalformedJSON(); } // ran out of input without finding "}" revert MalformedJSON(); } // ── primitives ────────────────────────────────────────────────────── function _skipWs(bytes calldata d, uint256 i, uint256 n) private pure returns (uint256) { while (i < n) { bytes1 c = d[i]; if (c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D) { i++; continue; } break; } return i; } function _readString(bytes calldata d, uint256 i, uint256 n) private pure returns (string memory, uint256) { if (i >= n || d[i] != '"') revert MalformedJSON(); i++; uint256 start = i; while (i < n && d[i] != '"') { i++; } if (i >= n) revert MalformedJSON(); string memory s = string(d[start:i]); return (s, i + 1); } function _readUintMaybeQuoted(bytes calldata d, uint256 i, uint256 n) private pure returns (uint256, uint256) { bool quoted = (i < n && d[i] == '"'); if (quoted) i++; uint256 v = 0; uint256 start = i; while (i < n) { uint8 c = uint8(d[i]); if (c < 0x30 || c > 0x39) break; v = v * 10 + (c - 0x30); i++; } if (i == start) revert MalformedJSON(); if (quoted) { if (i >= n || d[i] != '"') revert MalformedJSON(); i++; } return (v, i); } function _parseAddress(string memory s) private pure returns (address) { bytes memory b = bytes(s); if (b.length != 42 || b[0] != "0" || b[1] != "x") revert MalformedJSON(); uint160 out = 0; for (uint256 i = 2; i < 42; i++) { out = out * 16 + uint160(_hex(uint8(b[i]))); } return address(out); } function _hex(uint8 c) private pure returns (uint8) { if (c >= 0x30 && c <= 0x39) return c - 0x30; if (c >= 0x41 && c <= 0x46) return c - 0x37; if (c >= 0x61 && c <= 0x66) return c - 0x57; revert MalformedJSON(); } }