// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ NubeLender.sol v1 · Protocolo de Prestadores NUBE ║ // ║ ───────────────────────────────────────────────────────────────────────── ║ // ║ Permite a usuarios actuar como prestadores: depositan USDT y reciben ║ // ║ tokens NUBE equivalentes al tipo de cambio administrativo vigente. ║ // ║ ║ // ║ Rol económico: ║ // ║ Los prestadores proveen liquidez de NUBE al mercado secundario. ║ // ║ Los prestatarios necesitan comprar NUBE para devolver sus préstamos ║ // ║ de BTCB, generando presión de compra que beneficia a los prestadores. ║ // ║ ║ // ║ Flujo del prestador: ║ // ║ 1. Aprobar USDT: usdt.approve(address(NubeLender), monto) ║ // ║ 2. Depositar: NubeLender.deposit(usdtAmount) ║ // ║ → se transfiere USDT al contrato ║ // ║ → se mintean NUBE equivalentes al tipo de cambio vigente ║ // ║ 3. Para retirar: aprobar NUBE y llamar redeem(nubeAmount) ║ // ║ → se queman los NUBE ║ // ║ → se devuelve USDT al tipo de cambio vigente ║ // ║ ║ // ║ Estabilización de mercado (solo owner): ║ // ║ mintDirect(to, amount) → emitir NUBE para vender y bajar precio alto ║ // ║ burnDirect(from, amount) → quemar NUBE comprados para subir precio bajo ║ // ║ ║ // ║ Titularidad: Ownable2Step de OpenZeppelin. El owner (Gnosis Safe ║ // ║ institucional) se transfiere en dos pasos (transferOwnership + ║ // ║ acceptOwnership); las operaciones administrativas son instantáneas. ║ // ║ ║ // ║ PASO DE ACTIVACIÓN (desde el Safe institucional, post-despliegue): ║ // ║ nubeToken.grantRole(MINTER_ROLE, address(NubeLender)) ║ // ║ nubeToken.grantRole(BURNER_ROLE, address(NubeLender)) ║ // ║ ║ // ║ Dirección USDT (BSC Mainnet): 0x55d398326f99059fF775485246999027B3197955 ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; // ─── Interfaces ─────────────────────────────────────────────────────────────── interface INubeToken { function mint(address to, uint256 amount) external; function burnFrom(address account, uint256 amount) external; function balanceOf(address account) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256); } 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 account) external view returns (uint256); } // ─── Contrato principal ──────────────────────────────────────────────────────── /** * @title NubeLender * @author Proyecto NUBE — Universidad Nacional de Avellaneda * @notice Protocolo que permite convertir USDT en tokens NUBE (prestador) * y viceversa, a un tipo de cambio administrado. * * @dev Tipo de cambio: * nubePerUsdt es el número de tokens NUBE por 1 USDT, * expresado con 18 decimales de precisión. * Ejemplo: 1 USDT = 10 NUBE → nubePerUsdt = 10 * 1e18 * * Fórmula de conversión: * nubeAmount = usdtAmount * nubePerUsdt / 1e18 * usdtAmount = nubeAmount * 1e18 / nubePerUsdt * * El tipo de cambio lo define el owner (Safe) según el precio de mercado * de NUBE en PancakeSwap, para que no exista arbitraje estructural * entre el mercado secundario y este contrato. * * Titularidad: Ownable2Step. El owner inicial es el deployer; debe * transferirse al Safe con transferOwnership(Safe) + acceptOwnership() * (dos pasos). Ver Cap. 4.6.3 y Anexo G. * * NOTA: USDT en BSC Mainnet tiene 18 decimales (a diferencia de * USDT en Ethereum, que tiene 6). Este contrato asume 18 decimales. */ contract NubeLender is ReentrancyGuard, Ownable2Step { // ── Variables de estado ──────────────────────────────────────────────────── INubeToken public immutable nubeToken; IERC20 public immutable usdt; /// @notice Tokens NUBE por 1 USDT, con 18 decimales de precisión. /// Ejemplo: 10 * 1e18 = 10 NUBE por cada 1 USDT. uint256 public nubePerUsdt; bool public paused; // ── Estadísticas ────────────────────────────────────────────────────────── uint256 public totalUsdtDeposited; // USDT acumulado en el contrato uint256 public totalNubeMinted; // NUBE emitidos a prestadores uint256 public totalNubeBurned; // NUBE quemados en retiros // ── Eventos ─────────────────────────────────────────────────────────────── event LenderDeposited( address indexed lender, uint256 usdtAmount, uint256 nubeIssued, uint256 rate, uint256 timestamp ); event LenderRedeemed( address indexed lender, uint256 nubeAmount, uint256 usdtReturned, uint256 rate, uint256 timestamp ); event RateUpdated(uint256 oldRate, uint256 newRate); event ProtocolPaused(address indexed by); event ProtocolUnpaused(address indexed by); // Eventos de estabilización de mercado event MarketMintExecuted(address indexed to, uint256 amount, uint256 timestamp); event MarketBurnExecuted(address indexed from, uint256 amount, uint256 timestamp); // ── Modificadores ───────────────────────────────────────────────────────── // El control de acceso administrativo usa onlyOwner de Ownable2Step. modifier whenNotPaused() { require(!paused, "NUBE: protocol paused"); _; } // ── Constructor ─────────────────────────────────────────────────────────── /** * @param _nubeToken Dirección del contrato NubeToken v2. * @param _usdt Dirección del contrato USDT en BSC. * @param _nubePerUsdt Tipo de cambio inicial: NUBE por 1 USDT (escala 1e18). * Ejemplo: 10 NUBE por USDT → pasar 10 * 1e18. * @dev El owner inicial es el deployer (Ownable). Transferir al Safe con * transferOwnership(Safe) + acceptOwnership() tras el despliegue. */ constructor( address _nubeToken, address _usdt, uint256 _nubePerUsdt ) { require(_nubeToken != address(0), "NUBE: zero address token"); require(_usdt != address(0), "NUBE: zero address usdt"); require(_nubePerUsdt > 0, "NUBE: rate must be > 0"); nubeToken = INubeToken(_nubeToken); usdt = IERC20(_usdt); nubePerUsdt = _nubePerUsdt; } // ══════════════════════════════════════════════════════════════════════════ // CORE — Depositante (prestador) // ══════════════════════════════════════════════════════════════════════════ /** * @notice Deposita USDT y recibe tokens NUBE al tipo de cambio vigente. * @dev Requiere aprobación previa: usdt.approve(address(this), _usdtAmount) * * @param _usdtAmount Cantidad de USDT a depositar (18 decimales en BSC). * @param _minNubeOut Mínimo de NUBE aceptable. Protege ante cambios de tipo * de cambio entre estimación y confirmación. Pasar 0 para omitir. */ function deposit(uint256 _usdtAmount, uint256 _minNubeOut) external nonReentrant whenNotPaused { require(_usdtAmount > 0, "NUBE: amount must be > 0"); uint256 nubeAmount = (_usdtAmount * nubePerUsdt) / 1e18; require(nubeAmount > 0, "NUBE: NUBE amount is zero"); require(nubeAmount >= _minNubeOut, "NUBE: slippage — too few NUBE out"); require( usdt.transferFrom(msg.sender, address(this), _usdtAmount), "NUBE: USDT transferFrom failed" ); // ── Checks-Effects-Interactions ─────────────────────────────────────── totalUsdtDeposited += _usdtAmount; totalNubeMinted += nubeAmount; nubeToken.mint(msg.sender, nubeAmount); emit LenderDeposited(msg.sender, _usdtAmount, nubeAmount, nubePerUsdt, block.timestamp); } /** * @notice Devuelve tokens NUBE y recupera USDT al tipo de cambio vigente. * @dev Requiere aprobación previa: nubeToken.approve(address(this), _nubeAmount) * El tipo de cambio puede diferir del que se usó al depositar. * Si el precio de NUBE subió en el mercado, el prestador recibe * más USDT de los que depositó originalmente. * * @param _nubeAmount Cantidad de NUBE a quemar. * @param _minUsdtOut Mínimo de USDT aceptable. Protege ante cambios de rate. */ function redeem(uint256 _nubeAmount, uint256 _minUsdtOut) external nonReentrant { require(_nubeAmount > 0, "NUBE: amount must be > 0"); uint256 usdtAmount = (_nubeAmount * 1e18) / nubePerUsdt; require(usdtAmount > 0, "NUBE: USDT amount is zero"); require(usdtAmount >= _minUsdtOut, "NUBE: slippage — too few USDT out"); require( usdt.balanceOf(address(this)) >= usdtAmount, "NUBE: insufficient USDT reserve" ); // ── Checks-Effects-Interactions ─────────────────────────────────────── totalNubeBurned += _nubeAmount; if (totalUsdtDeposited >= usdtAmount) { totalUsdtDeposited -= usdtAmount; } else { totalUsdtDeposited = 0; } nubeToken.burnFrom(msg.sender, _nubeAmount); require( usdt.transfer(msg.sender, usdtAmount), "NUBE: USDT transfer failed" ); emit LenderRedeemed(msg.sender, _nubeAmount, usdtAmount, nubePerUsdt, block.timestamp); } // ══════════════════════════════════════════════════════════════════════════ // CORE — Estabilización de mercado (solo owner) // ══════════════════════════════════════════════════════════════════════════ /** * @notice Emite NUBE directamente para la wallet del owner. * @dev Caso de uso: precio de NUBE en PancakeSwap está sobrevaluado. * El owner mintea NUBE y los vende en el mercado para bajar el precio * y capturar la diferencia en la tesorería institucional. * Este contrato debe tener MINTER_ROLE en NubeToken. * * @param _to Destinatario (normalmente la wallet del owner/tesorería). * @param _amount Cantidad de NUBE a emitir. */ function mintDirect(address _to, uint256 _amount) external onlyOwner { require(_to != address(0), "NUBE: zero address"); require(_amount > 0, "NUBE: amount must be > 0"); nubeToken.mint(_to, _amount); emit MarketMintExecuted(_to, _amount, block.timestamp); } /** * @notice Quema NUBE de una cuenta con aprobación previa. * @dev Caso de uso: precio de NUBE en PancakeSwap está subvaluado. * El owner compra NUBE en el mercado y los quema para reducir el supply * y subir el precio, protegiendo el valor intrínseco del token. * Este contrato debe tener BURNER_ROLE en NubeToken. * Requiere que _from haya aprobado a este contrato mediante: * nubeToken.approve(address(NubeLender), _amount) * * @param _from Cuenta de la que se queman tokens (normalmente la wallet owner). * @param _amount Cantidad de NUBE a quemar. */ function burnDirect(address _from, uint256 _amount) external onlyOwner { require(_from != address(0), "NUBE: zero address"); require(_amount > 0, "NUBE: amount must be > 0"); nubeToken.burnFrom(_from, _amount); emit MarketBurnExecuted(_from, _amount, block.timestamp); } // ══════════════════════════════════════════════════════════════════════════ // VISTAS // ══════════════════════════════════════════════════════════════════════════ /** * @notice Calcula cuántos NUBE recibiría un prestador por un monto de USDT. */ function quoteDeposit(uint256 _usdtAmount) external view returns (uint256 nubeAmount) { nubeAmount = (_usdtAmount * nubePerUsdt) / 1e18; } /** * @notice Calcula cuántos USDT recibiría un prestador al quemar NUBE. */ function quoteRedeem(uint256 _nubeAmount) external view returns (uint256 usdtAmount) { usdtAmount = (_nubeAmount * 1e18) / nubePerUsdt; } /** * @notice Estadísticas globales del protocolo de prestadores. */ function protocolStats() external view returns ( uint256 _totalUsdtDeposited, uint256 _totalNubeMinted, uint256 _totalNubeBurned, uint256 _nubePerUsdt, uint256 _usdtReserve, bool _paused ) { return ( totalUsdtDeposited, totalNubeMinted, totalNubeBurned, nubePerUsdt, usdt.balanceOf(address(this)), paused ); } // ══════════════════════════════════════════════════════════════════════════ // ADMINISTRACIÓN (onlyOwner — instantánea; el owner es el Safe) // ══════════════════════════════════════════════════════════════════════════ /** * @notice Actualiza el tipo de cambio NUBE/USDT. * @dev RECOMENDACIÓN: usar TimelockController (48h) para dar aviso previo. * Un cambio brusco de rate puede perjudicar a prestadores con posiciones * abiertas. El parámetro _minUsdtOut en redeem() protege al usuario. * * @param _newRate Nuevo tipo de cambio (NUBE por 1 USDT, escala 1e18). */ function setRate(uint256 _newRate) external onlyOwner { require(_newRate > 0, "NUBE: rate must be > 0"); emit RateUpdated(nubePerUsdt, _newRate); nubePerUsdt = _newRate; } /** * @notice Retira USDT de la reserva a una dirección indicada. * @dev Solo para emergencias o rebalanceo de tesorería. * El retiro excesivo podría dejar sin respaldo a los prestadores. */ function withdrawUsdt(address _to, uint256 _amount) external onlyOwner { require(_to != address(0), "NUBE: zero address"); require(_amount > 0, "NUBE: amount must be > 0"); require( usdt.balanceOf(address(this)) >= _amount, "NUBE: insufficient reserve" ); require(usdt.transfer(_to, _amount), "NUBE: transfer failed"); } /** * @notice El traspaso de la administración usa Ownable2Step (dos pasos): * 1) transferOwnership(nuevoOwner) ← owner actual (Safe) * 2) acceptOwnership() ← nuevoOwner confirma * No surte efecto hasta que el destinatario acepta, dando al nuevo * administrador (p. ej. en una venta) una confirmación on-chain de control. */ function pauseProtocol() external onlyOwner { paused = true; emit ProtocolPaused(msg.sender); } function unpauseProtocol() external onlyOwner { paused = false; emit ProtocolUnpaused(msg.sender); } }