oakoak

Code.

Everything that runs oak. The contract below is the exact source that is deployed.

Deployed at 0xe4FFED90e5EABFB12B068Da673607514F9C6f469 on Robinhood Chain.

Repository layout

contracts/src/OakLoans.solThe protocol. One contract, ~330 lines.
contracts/test/Foundry tests: offers, takes, repays, claims, expiry, minimums, pause, paging, and a mainnet fork test moving real USDG and TSLA.
contracts/script/Deploy.s.solDeploys, allowlists every Robinhood equity, writes deployments/<chainId>.json.
web/server/Indexer (contract logs to Postgres), price poller (DexScreener, deepest USDG pool per stock), read API.
web/lib/chain.tsChain, addresses, ABI. web/lib/oak.ts holds reads and writes; web/lib/wallet.tsx the wallet chooser.
web/app/Landing, the app (/app), the demo (/dev), these pages.

OakLoans.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";

/// @title OakLoans: fixed-term, peer-to-peer loans against tokenized stocks. No oracle, no liquidations.
///
/// A lender posts an offer: stablecoin they will lend, which stock token they accept, how much of it
/// they want per stablecoin unit (their own price, their own LTV), the term, and the APR. A borrower
/// takes part of an offer: the stock goes into escrow here, the stablecoin goes to the borrower, and
/// the interest is fixed in stablecoin units at that moment. Before the due date the borrower repays
/// principal plus interest and gets the stock back. After the due date the lender can claim the stock.
/// Prices never move a loan; only the date matters.
contract OakLoans is Ownable2Step, ReentrancyGuard {
    using SafeERC20 for IERC20;

    uint256 public constant BPS = 10_000;
    uint256 public constant MAX_FEE_BPS = 2_000; // 20% of interest, hard cap
    uint256 public constant MIN_TERM = 1 days;
    uint256 public constant MAX_TERM = 365 days;

    struct Offer {
        address lender;
        address collateral; // stock token accepted
        uint128 available; // stablecoin still lendable
        uint128 minTake; // smallest principal a borrower may take
        uint64 term; // seconds
        uint32 aprBps; // simple interest, annualised
        uint64 expiry; // offer cannot be taken after this timestamp
        uint256 collateralPerUnit; // collateral wei per 1 stablecoin unit (10**stableDecimals)
        bool cancelled;
    }

    enum LoanState {
        None,
        Active,
        Repaid,
        Claimed
    }

    struct Loan {
        address borrower;
        address lender;
        address collateral;
        uint128 principal;
        uint128 interest;
        uint256 collateralAmount;
        uint64 startedAt;
        uint64 dueAt;
        uint256 offerId;
        LoanState state;
    }

    IERC20 public immutable stable;
    uint256 public immutable stableUnit; // 10**decimals of the stablecoin
    address public treasury;
    uint256 public feeBps; // share of interest taken on repayment
    bool public offersPaused; // pauses new offers and takes only; repay and claim always work

    mapping(address => bool) public collateralAllowed;
    address[] public collateralList;

    Offer[] public offers;
    Loan[] public loans;
    mapping(address => uint256[]) private _offersOf;
    mapping(address => uint256[]) private _loansOf;

    event CollateralSet(address indexed token, bool allowed);
    event FeeSet(uint256 feeBps, address treasury);
    event OffersPaused(bool paused);
    event OfferCreated(uint256 indexed offerId, address indexed lender, address indexed collateral, uint256 amount, uint256 minTake, uint64 term, uint32 aprBps, uint256 collateralPerUnit, uint64 expiry);
    event OfferCancelled(uint256 indexed offerId, uint256 returned);
    event OfferTaken(uint256 indexed offerId, uint256 indexed loanId, address indexed borrower, uint256 principal, uint256 interest, uint256 collateralAmount, uint64 dueAt);
    event LoanRepaid(uint256 indexed loanId, uint256 principal, uint256 interest, uint256 fee);
    event LoanClaimed(uint256 indexed loanId, uint256 collateralAmount);

    error NotAllowed();
    error BadInput();
    error Paused();
    error NotLender();
    error NotBorrower();
    error OfferClosed();
    error TooSmall();
    error NotActive();
    error TooEarly();
    error TooLate();

    constructor(IERC20 stable_, uint8 stableDecimals, address owner_, address treasury_, uint256 feeBps_) Ownable(owner_) {
        if (address(stable_) == address(0) || treasury_ == address(0) || feeBps_ > MAX_FEE_BPS) revert BadInput();
        stable = stable_;
        stableUnit = 10 ** stableDecimals;
        treasury = treasury_;
        feeBps = feeBps_;
    }

    // ---------------------------------------------------------------- admin

    function setCollateral(address token, bool allowed) external onlyOwner {
        if (token == address(0) || token == address(stable)) revert BadInput();
        if (allowed && !collateralAllowed[token]) collateralList.push(token);
        collateralAllowed[token] = allowed;
        emit CollateralSet(token, allowed);
    }

    function setFee(uint256 feeBps_, address treasury_) external onlyOwner {
        if (feeBps_ > MAX_FEE_BPS || treasury_ == address(0)) revert BadInput();
        feeBps = feeBps_;
        treasury = treasury_;
        emit FeeSet(feeBps_, treasury_);
    }

    function setOffersPaused(bool paused) external onlyOwner {
        offersPaused = paused;
        emit OffersPaused(paused);
    }

    // ---------------------------------------------------------------- lender

    /// @notice Post stablecoin to lend against one stock token at a price you set.
    /// @param collateralPerUnit stock token wei required per one whole stablecoin (e.g. per 1 USDG).
    function createOffer(address collateral, uint128 amount, uint128 minTake, uint64 term, uint32 aprBps, uint256 collateralPerUnit, uint64 expiry)
        external
        nonReentrant
        returns (uint256 offerId)
    {
        if (offersPaused) revert Paused();
        if (!collateralAllowed[collateral]) revert NotAllowed();
        if (amount == 0 || minTake == 0 || minTake > amount || collateralPerUnit == 0) revert BadInput();
        if (term < MIN_TERM || term > MAX_TERM || aprBps > BPS * 5) revert BadInput();
        if (expiry <= block.timestamp) revert BadInput();

        stable.safeTransferFrom(msg.sender, address(this), amount);
        offerId = offers.length;
        offers.push(
            Offer({
                lender: msg.sender,
                collateral: collateral,
                available: amount,
                minTake: minTake,
                term: term,
                aprBps: aprBps,
                expiry: expiry,
                collateralPerUnit: collateralPerUnit,
                cancelled: false
            })
        );
        _offersOf[msg.sender].push(offerId);
        emit OfferCreated(offerId, msg.sender, collateral, amount, minTake, term, aprBps, collateralPerUnit, expiry);
    }

    /// @notice Withdraw whatever is still unlent. Loans already taken from the offer are untouched.
    function cancelOffer(uint256 offerId) external nonReentrant {
        Offer storage o = offers[offerId];
        if (o.lender != msg.sender) revert NotLender();
        if (o.cancelled) revert OfferClosed();
        o.cancelled = true;
        uint256 back = o.available;
        o.available = 0;
        if (back > 0) stable.safeTransfer(msg.sender, back);
        emit OfferCancelled(offerId, back);
    }

    /// @notice After the due date, the lender takes the escrowed stock. The loan is closed.
    function claim(uint256 loanId) external nonReentrant {
        Loan storage l = loans[loanId];
        if (l.state != LoanState.Active) revert NotActive();
        if (l.lender != msg.sender) revert NotLender();
        if (block.timestamp <= l.dueAt) revert TooEarly();
        l.state = LoanState.Claimed;
        IERC20(l.collateral).safeTransfer(l.lender, l.collateralAmount);
        emit LoanClaimed(loanId, l.collateralAmount);
    }

    // ---------------------------------------------------------------- borrower

    /// @notice Borrow `principal` stablecoin from an offer. Collateral is pulled from you; know the numbers first:
    ///         collateral = ceil(principal * collateralPerUnit / stableUnit), interest = principal * aprBps * term / (365d * BPS).
    function take(uint256 offerId, uint128 principal) external nonReentrant returns (uint256 loanId) {
        if (offersPaused) revert Paused();
        Offer storage o = offers[offerId];
        if (o.cancelled || o.available == 0 || block.timestamp > o.expiry) revert OfferClosed();
        if (principal == 0 || principal > o.available) revert BadInput();
        // the remainder must stay takeable, or the whole thing must go
        if (principal < o.minTake && principal != o.available) revert TooSmall();
        if (o.available - principal != 0 && o.available - principal < o.minTake) revert TooSmall();
        if (!collateralAllowed[o.collateral]) revert NotAllowed();

        uint256 collateralAmount = quoteCollateral(offerId, principal);
        uint128 interest = uint128(quoteInterest(offerId, principal));
        o.available -= principal;

        IERC20(o.collateral).safeTransferFrom(msg.sender, address(this), collateralAmount);
        stable.safeTransfer(msg.sender, principal);

        uint64 dueAt = uint64(block.timestamp) + o.term;
        loanId = loans.length;
        loans.push(
            Loan({
                borrower: msg.sender,
                lender: o.lender,
                collateral: o.collateral,
                principal: principal,
                interest: interest,
                collateralAmount: collateralAmount,
                startedAt: uint64(block.timestamp),
                dueAt: dueAt,
                offerId: offerId,
                state: LoanState.Active
            })
        );
        _loansOf[msg.sender].push(loanId);
        _loansOf[o.lender].push(loanId);
        emit OfferTaken(offerId, loanId, msg.sender, principal, interest, collateralAmount, dueAt);
    }

    /// @notice Repay principal plus the fixed interest any time up to and including the due date. Collateral comes back.
    function repay(uint256 loanId) external nonReentrant {
        Loan storage l = loans[loanId];
        if (l.state != LoanState.Active) revert NotActive();
        if (block.timestamp > l.dueAt) revert TooLate();
        l.state = LoanState.Repaid;

        uint256 fee = (uint256(l.interest) * feeBps) / BPS;
        uint256 toLender = uint256(l.principal) + l.interest - fee;
        // anyone may repay on the borrower's behalf; the collateral always returns to the borrower
        stable.safeTransferFrom(msg.sender, address(this), uint256(l.principal) + l.interest);
        stable.safeTransfer(l.lender, toLender);
        if (fee > 0) stable.safeTransfer(treasury, fee);
        IERC20(l.collateral).safeTransfer(l.borrower, l.collateralAmount);
        emit LoanRepaid(loanId, l.principal, l.interest, fee);
    }

    // ---------------------------------------------------------------- views

    function quoteCollateral(uint256 offerId, uint256 principal) public view returns (uint256) {
        Offer storage o = offers[offerId];
        return (principal * o.collateralPerUnit + stableUnit - 1) / stableUnit;
    }

    function quoteInterest(uint256 offerId, uint256 principal) public view returns (uint256) {
        Offer storage o = offers[offerId];
        return (principal * o.aprBps * o.term) / (365 days * BPS);
    }

    function offersCount() external view returns (uint256) {
        return offers.length;
    }

    function loansCount() external view returns (uint256) {
        return loans.length;
    }

    function collateralCount() external view returns (uint256) {
        return collateralList.length;
    }

    function offersOf(address lender) external view returns (uint256[] memory) {
        return _offersOf[lender];
    }

    function loansOf(address user) external view returns (uint256[] memory) {
        return _loansOf[user];
    }

    function getOffers(uint256 from, uint256 count) external view returns (Offer[] memory out) {
        uint256 n = offers.length;
        if (from >= n) return out;
        uint256 to = from + count > n ? n : from + count;
        out = new Offer[](to - from);
        for (uint256 i = from; i < to; i++) out[i - from] = offers[i];
    }

    function getLoans(uint256 from, uint256 count) external view returns (Loan[] memory out) {
        uint256 n = loans.length;
        if (from >= n) return out;
        uint256 to = from + count > n ? n : from + count;
        out = new Loan[](to - from);
        for (uint256 i = from; i < to; i++) out[i - from] = loans[i];
    }
}

ABI you need

createOffer(address collateral, uint128 amount, uint128 minTake, uint64 term, uint32 aprBps, uint256 collateralPerUnit, uint64 expiry) returns (uint256)
cancelOffer(uint256 offerId)
take(uint256 offerId, uint128 principal) returns (uint256 loanId)
repay(uint256 loanId)
claim(uint256 loanId)
quoteCollateral(uint256 offerId, uint256 principal) view returns (uint256)
quoteInterest(uint256 offerId, uint256 principal) view returns (uint256)
getOffers(uint256 from, uint256 count) view returns (Offer[])
getLoans(uint256 from, uint256 count) view returns (Loan[])
offersOf(address) view returns (uint256[])   loansOf(address) view returns (uint256[])