// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // ╔══════════════════════════════════════════════════════════════════════════════╗ // ║ NubeLending.sol v2 · Protocolo de Préstamos NUBE ║ // ║ ───────────────────────────────────────────────────────────────────────── ║ // ║ Préstamos sin interés, sin liquidaciones automáticas, respaldados en BTCB ║ // ║ sobre Binance Smart Chain. ║ // ║ ║ // ║ Cambios respecto a v1: ║ // ║ · NubeToken usa AccessControl en lugar de Ownable. ║ // ║ El paso de activación ahora es grantRole, no transferOwnership. ║ // ║ · Parámetro _minNubeOut en requestLoan() — anti front-running. ║ // ║ · ReentrancyGuard de OpenZeppelin (reemplaza implementación custom). ║ // ║ · Ownable2Step de OpenZeppelin para la titularidad: la transferencia ║ // ║ del administrador es en dos pasos (transferOwnership + acceptOwnership) ║ // ║ para un traspaso deliberado y confirmado por el destinatario. Las ║ // ║ operaciones (setFee, pausa, préstamos) siguen siendo instantáneas. ║ // ║ · Solidity 0.8.20. ║ // ║ ║ // ║ Administrador (owner): el Gnosis Safe institucional. Ver Cap. 4.6. ║ // ║ ║ // ║ PASO DE ACTIVACIÓN (desde el Safe institucional, post-despliegue): ║ // ║ nubeToken.grantRole(MINTER_ROLE, address(NubeLending)) ║ // ║ nubeToken.grantRole(BURNER_ROLE, address(NubeLending)) ║ // ║ ║ // ║ Dirección BTCB (BSC Mainnet): 0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c ║ // ╚══════════════════════════════════════════════════════════════════════════════╝ import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; // ─── Interfaces ─────────────────────────────────────────────────────────────── /** * @dev Interfaz mínima del token NUBE v2. * NubeLending necesita MINTER_ROLE y BURNER_ROLE sobre este contrato. */ 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 NubeLending * @author Proyecto NUBE — Universidad Nacional de Avellaneda * @notice Protocolo de préstamos sin interés sobre BSC. * No utiliza oráculos de precio en USD. * No ejecuta liquidaciones automáticas por precio fiat. * El colateral (BTCB) se contabiliza y devuelve en sus propios términos. * * @dev Activación: tras el despliegue, el owner (Safe) debe llamar: * nubeToken.grantRole(MINTER_ROLE, address(this)) * nubeToken.grantRole(BURNER_ROLE, address(this)) * El contrato NO necesita ser el owner del token (a diferencia de v1). * El revoke de roles es el mecanismo de emergencia equivalente a la * función transferTokenOwnership de v1. * * La titularidad se hereda de Ownable2Step: el owner inicial es el * deployer, que debe transferirla al Safe institucional mediante * transferOwnership(Safe) + acceptOwnership() (dos pasos). Ver Cap. 4.6.3 * y Anexo G para el procedimiento de traspaso y su verificación. */ contract NubeLending is ReentrancyGuard, Ownable2Step { // ── Variables de estado ──────────────────────────────────────────────────── INubeToken public immutable nubeToken; IERC20 public immutable btcb; address public feeCollector; /// @notice Cargo administrativo en puntos básicos (100 = 1%, máx. 1000 = 10%). uint256 public feeBPS; bool public paused; // ── Estructura de posición ───────────────────────────────────────────────── struct Position { uint256 collateralBTCB; uint256 nubeIssued; uint256 openedAt; } mapping(address => Position) public positions; // ── Estadísticas ────────────────────────────────────────────────────────── uint256 public totalCollateral; uint256 public totalLoansOpened; uint256 public totalFeesCollected; // ── Eventos ─────────────────────────────────────────────────────────────── event LoanOpened( address indexed borrower, uint256 btcbDeposited, uint256 feeCharged, uint256 nubeIssued, uint256 timestamp ); event LoanRepaid( address indexed borrower, uint256 nubeReturned, uint256 btcbReleased, uint256 timestamp ); event FeeUpdated(uint256 oldBPS, uint256 newBPS); event FeeCollectorUpdated(address indexed oldCollector, address indexed newCollector); event ProtocolPaused(address indexed by); event ProtocolUnpaused(address indexed by); // ── Modificadores ───────────────────────────────────────────────────────── // El control de acceso administrativo usa onlyOwner de Ownable2Step. // (La transferencia del owner es en dos pasos; ver transferOwnership / // acceptOwnership heredadas de Ownable2Step.) modifier whenNotPaused() { require(!paused, "NUBE: protocol paused"); _; } // ── Constructor ─────────────────────────────────────────────────────────── /** * @param _nubeToken Dirección del contrato NubeToken v2 (OFT + AccessControl). * @param _btcb Dirección del contrato BTCB en BSC. * @param _feeCollector Wallet o contrato que recibe los cargos administrativos. * @param _feeBPS Cargo inicial en puntos básicos (ej: 100 = 1%). * @dev El owner inicial es el deployer (Ownable). Transferir al Safe con * transferOwnership(Safe) + acceptOwnership() tras el despliegue. */ constructor( address _nubeToken, address _btcb, address _feeCollector, uint256 _feeBPS ) { require(_nubeToken != address(0), "NUBE: zero address token"); require(_btcb != address(0), "NUBE: zero address btcb"); require(_feeCollector != address(0), "NUBE: zero address collector"); require(_feeBPS <= 1000, "NUBE: fee exceeds 10%"); nubeToken = INubeToken(_nubeToken); btcb = IERC20(_btcb); feeCollector = _feeCollector; feeBPS = _feeBPS; } // ══════════════════════════════════════════════════════════════════════════ // CORE — Solicitar préstamo // ══════════════════════════════════════════════════════════════════════════ /** * @notice Deposita BTCB como colateral y recibe tokens NUBE. * @dev Requiere aprobación previa: btcb.approve(address(this), _btcbAmount) * * @param _btcbAmount BTCB a depositar (incluye el cargo administrativo). * @param _minNubeOut Mínimo de NUBE aceptable. Protege ante un incremento * del fee entre la estimación off-chain y la confirmación * on-chain (front-running del owner). Pasar 0 para omitir. */ function requestLoan(uint256 _btcbAmount, uint256 _minNubeOut) external nonReentrant whenNotPaused { require(_btcbAmount > 0, "NUBE: amount must be > 0"); require(positions[msg.sender].collateralBTCB == 0, "NUBE: position already open"); require( btcb.transferFrom(msg.sender, address(this), _btcbAmount), "NUBE: BTCB transferFrom failed" ); uint256 fee = (_btcbAmount * feeBPS) / 10_000; uint256 netCollateral = _btcbAmount - fee; require(netCollateral > 0, "NUBE: net collateral is zero"); require(netCollateral >= _minNubeOut, "NUBE: slippage — too few NUBE out"); if (fee > 0) { require(btcb.transfer(feeCollector, fee), "NUBE: fee transfer failed"); totalFeesCollected += fee; } // ── Checks-Effects-Interactions ─────────────────────────────────────── positions[msg.sender] = Position({ collateralBTCB : netCollateral, nubeIssued : netCollateral, openedAt : block.timestamp }); totalCollateral += netCollateral; totalLoansOpened += 1; nubeToken.mint(msg.sender, netCollateral); emit LoanOpened(msg.sender, _btcbAmount, fee, netCollateral, block.timestamp); } // ══════════════════════════════════════════════════════════════════════════ // CORE — Devolver préstamo // ══════════════════════════════════════════════════════════════════════════ /** * @notice Devuelve los tokens NUBE y recupera el BTCB colateralizado. * @dev Requiere aprobación previa: nubeToken.approve(address(this), nubeIssued) * Sin interés. Se devuelve exactamente el colateral neto depositado. * Siempre habilitado, incluso con el protocolo pausado. */ function repayLoan() external nonReentrant { Position memory pos = positions[msg.sender]; require(pos.collateralBTCB > 0, "NUBE: no open position"); uint256 nubeToReturn = pos.nubeIssued; uint256 btcbToRelease = pos.collateralBTCB; // ── Checks-Effects-Interactions ─────────────────────────────────────── delete positions[msg.sender]; totalCollateral -= btcbToRelease; nubeToken.burnFrom(msg.sender, nubeToReturn); require( btcb.transfer(msg.sender, btcbToRelease), "NUBE: BTCB return failed" ); emit LoanRepaid(msg.sender, nubeToReturn, btcbToRelease, block.timestamp); } // ══════════════════════════════════════════════════════════════════════════ // VISTAS // ══════════════════════════════════════════════════════════════════════════ function getPosition(address _borrower) external view returns (uint256 collateral, uint256 nubeIssued, uint256 openedAt, bool hasPosition) { Position memory p = positions[_borrower]; return (p.collateralBTCB, p.nubeIssued, p.openedAt, p.collateralBTCB > 0); } /// @notice Calcula el cargo y el colateral neto. Usar _minNubeOut = netCollateral en requestLoan. function calculateFee(uint256 _btcbAmount) external view returns (uint256 fee, uint256 netCollateral) { fee = (_btcbAmount * feeBPS) / 10_000; netCollateral = _btcbAmount - fee; } function protocolStats() external view returns ( uint256 _totalCollateral, uint256 _totalLoansOpened, uint256 _totalFeesCollected, uint256 _feeBPS, bool _paused ) { return (totalCollateral, totalLoansOpened, totalFeesCollected, feeBPS, paused); } // ══════════════════════════════════════════════════════════════════════════ // ADMINISTRACIÓN (onlyOwner — instantánea; el owner es el Safe) // ══════════════════════════════════════════════════════════════════════════ /** * @notice Actualiza el cargo administrativo. * @dev RECOMENDACIÓN: ejecutar a través de TimelockController (48h) * para dar tiempo a los usuarios a reaccionar. */ function setFee(uint256 _newBPS) external onlyOwner { require(_newBPS <= 1000, "NUBE: fee too high (max 10%)"); emit FeeUpdated(feeBPS, _newBPS); feeBPS = _newBPS; } function setFeeCollector(address _newCollector) external onlyOwner { require(_newCollector != address(0), "NUBE: zero address"); emit FeeCollectorUpdated(feeCollector, _newCollector); feeCollector = _newCollector; } /// @notice Pausa nuevas solicitudes. Las devoluciones permanecen habilitadas. function pauseProtocol() external onlyOwner { paused = true; emit ProtocolPaused(msg.sender); } function unpauseProtocol() external onlyOwner { paused = false; emit ProtocolUnpaused(msg.sender); } /** * @notice El traspaso de la administración usa Ownable2Step (dos pasos): * 1) transferOwnership(nuevoOwner) ← owner actual (Safe) * 2) acceptOwnership() ← nuevoOwner confirma * La transferencia no surte efecto hasta que el destinatario acepta, * lo que evita traspasos a direcciones no controladas y da al nuevo * administrador (p. ej. en una venta) una confirmación on-chain de control. * * @notice Mecanismo de emergencia: revocar MINTER_ROLE y BURNER_ROLE * de este contrato directamente en NubeToken desde el Safe. * nubeToken.revokeRole(MINTER_ROLE, address(this)) * nubeToken.revokeRole(BURNER_ROLE, address(this)) * Equivale al transferTokenOwnership de NubeLending v1. */ }