diff --git a/relic_market/README.md b/relic_market/README.md new file mode 100644 index 000000000..84f025d99 --- /dev/null +++ b/relic_market/README.md @@ -0,0 +1,249 @@ +# Rent-a-Relic Market — RustChain Bounty #2312 + +**Build a wRTC-powered reservation system so AI agents can book authenticated time on named vintage machines through MCP and Beacon, then receive a provenance receipt for what they created.** + +Most ecosystems sell generic compute. RustChain can sell compute with **ancestry, quirks, and romance**. + +--- + +## Overview + +The Rent-a-Relic Market enables AI agents to: +1. **Discover** vintage machines (POWER8, Mac G5, UltraSPARC, VAX, Cray…) with full specs and attestation history +2. **Reserve** time slots via a clean REST API (1h / 4h / 24h durations) +3. **Lock RTC** in escrow during reservation — released only on successful completion +4. **Access** the machine (SSH/API) during the reserved window +5. **Receive** a cryptographically-signed provenance receipt proving the work ran on that hardware + +--- + +## Project Structure + +``` +relic_market/ +├── RentMarket.sol # Solidity contract: ERC-721 machine NFTs + rental state machine +├── machine_registry.py # SQLite-backed machine registry + Python API +├── reservation_server.py # Flask REST API server +├── provenance_receipt.py # Ed25519-signed provenance receipt generation +├── marketplace_ui.py # CLI tool for browsing and booking +├── escrow.py # RTC escrow manager +├── __init__.py # Package init +└── README.md # This file +``` + +--- + +## Quick Start + +### 1. Install Dependencies + +```bash +pip install flask nacl ed25519 +``` + +### 2. Seed Demo Machines + +```bash +cd relic_market +python machine_registry.py +``` + +### 3. Start the API Server + +```bash +python reservation_server.py +# Server runs on http://localhost:5001 +``` + +### 4. Browse Machines + +```bash +python marketplace_ui.py list +python marketplace_ui.py available --hours 4 +``` + +### 5. Book a Machine + +```bash +python marketplace_ui.py book \ + --machine 0 \ + --hours 1 \ + --renter C4c7r9WPsnEe6CUfegMU9M7ReHD1pWg8qeSfTBoRcLbg +``` + +### 6. Complete a Rental & Get Receipt + +```bash +python marketplace_ui.py complete \ + --rental \ + --output $(echo -n "my_computation_output" | sha256sum | cut -d' ' -f1) \ + --attestation "cpu_cycles=12345678" + +python marketplace_ui.py receipt --receipt +``` + +--- + +## API Reference + +### `GET /relic/available` +List available machines with upcoming time slots. + +**Query params:** +- `slot_hours` (int, default 1): Slot duration (1, 4, or 24) +- `machine_token_id` (optional): Filter to a specific machine + +**Response:** +```json +{ + "machines": [ + { + "token_id": 0, + "name": "Old Ironsides", + "model": "IBM POWER8 8247-21L", + "hourly_rate_rtc": 50.0, + "specs": { "CPU": "POWER8 12-core 3.02 GHz", "RAM": "512 GB DDR3 ECC", ... }, + "next_available_slots": [ + { "start": 1742611200, "end": 1742614800, "start_iso": "2025-03-22T00:00:00Z", ... } + ] + } + ] +} +``` + +### `POST /relic/reserve` +Reserve a time slot on a machine. RTC is locked in escrow. + +**Body:** +```json +{ + "machine_token_id": 0, + "slot_hours": 1, + "start_time": 1742611200, + "renter": "C4c7r9WPsnEe6CUfegMU9M7ReHD1pWg8qeSfTBoRcLbg" +} +``` + +**Response:** +```json +{ + "rental_id": "rental_abc123xyz", + "escrow_id": "a1b2c3d4e5f6", + "rtc_locked": 50.0, + "start_time_iso": "2025-03-22T00:00:00Z", + "state": "pending" +} +``` + +### `POST /relic/complete` +Complete a rental and generate the provenance receipt. + +**Body:** +```json +{ + "rental_id": "rental_abc123xyz", + "output_hash": "sha256_of_computation_output", + "attestation_proof": "cpu_cycles=12345678,instruction_count=..." +} +``` + +**Response:** +```json +{ + "rental_id": "rental_abc123xyz", + "state": "completed", + "receipt": { + "receipt_id": "receipt_xyz789", + "machine_passport_id": "Old Ironsides", + "output_hash": "...", + "signature": "ed25519_signature_hex", + "verified": true + } +} +``` + +### `GET /relic/receipt/` +Fetch and verify a provenance receipt. + +### `GET /relic/rentals?renter=
` +List all rentals for a wallet address. + +### `GET /relic/escrow/summary` +Escrow state overview (locked, released, refunded). + +### `GET /health` +Server health check. + +--- + +## Demo Machines + +| Token | Name | Model | Rate | Arch | +|-------|------|-------|------|------| +| 0 | Old Ironsides | IBM POWER8 8247-21L | 50 RTC/hr | ppc64le | +| 1 | Amber Ghost | Apple Mac G5 Quad | 30 RTC/hr | ppc64 | +| 2 | Solaris Sparrow | Sun UltraSPARC T2 | 25 RTC/hr | sparc64 | +| 3 | Vax Phantom | DEC VAX 11/780 (Sim.) | 15 RTC/hr | vax | +| 4 | Cray Shade | Cray X1E (Simulated) | 80 RTC/hr | cray | + +--- + +## Solidity Contract (RentMarket.sol) + +- Each machine is an **ERC-721 NFT** owned by the contract +- `registerMachine()` — register a new vintage machine +- `createRental()` — lock RTC in escrow, create reservation +- `completeRental()` — mark done, record output hash + attestation +- `mcpReserve()` — MCP tool bridge for AI agent integration + +--- + +## Provenance Receipts + +Each receipt includes: +- **Machine passport ID** (human-readable name) +- **Session duration** (start/end timestamps + seconds) +- **Output hash** (SHA-256 of what was computed) +- **Attestation proof** (hardware measurements from the session) +- **Ed25519 signature** — signed by the machine's Ed25519 key + +Receipts are self-verifying: + +```python +receipt = receipt_mgr.get_receipt(receipt_id) +print(receipt.verify()) # True if signature valid +``` + +--- + +## Leaderboard + +```bash +python marketplace_ui.py leaderboard +``` + +Shows most-rented machines ranked by rental count. + +--- + +## BoTTube Integration (Bonus) + +Videos rendered on relic hardware can receive a special badge by: +1. Completing the rental with a `bottube_render_id` in the attestation field +2. The receipt's `machine_passport_id` + `ed25519_pubkey` serves as the badge verification key + +--- + +## Technical Notes + +- **Flask server** runs on port `5001` by default (`RELIC_API_BASE` env var to override) +- **SQLite** databases: `registry.db`, `escrow.db`, `reservations.db`, `receipts/`, `keys/` +- **Ed25519** keys per-machine stored in `keys/.key` +- **Escrow** is simulated locally; in production, integrate with Solana wRTC payment channels +- **Attestation** is passed by the agent/renter — in production, hardware TEE (SEV, TrustZone) provides cryptographic attestation + +--- + +## License + +MIT — RustChain Contributors diff --git a/relic_market/RentMarket.sol b/relic_market/RentMarket.sol new file mode 100644 index 000000000..62ee14699 --- /dev/null +++ b/relic_market/RentMarket.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @title RentMarket - ERC-721 based vintage machine rental marketplace +/// @notice Each machine is an ERC-721 NFT. Rentals lock RTC in escrow and grant time-limited access. +contract RentMarket is ERC721, ERC721URIStorage, Ownable, ReentrancyGuard { + // Machine NFT counter + uint256 private _machineTokenIdCounter; + + // Machine data struct + struct Machine { + string name; + string model; // e.g., "POWER8", "Mac G5", "Sun UltraSPARC" + string specs; // JSON string: CPU, RAM, storage, etc. + string photoCID; // IPFS CID for machine photo + uint256 hourlyRateRTC; // RTC per hour + uint256 totalUptimeSeconds; + uint256 totalRentals; + bool isActive; + address ed25519PubKey; // On-chain reference to machine's Ed25519 verification key + } + + // Rental state + struct Rental { + uint256 machineTokenId; + address renter; + uint64 startTime; + uint64 endTime; // Unix timestamp + uint256 slotHours; // 1, 4, or 24 + uint256 rtcLocked; + bool active; + bytes32 outputHash; // Hash of computed output (set after session) + string attestationProof; + } + + // Machine tokenId -> Machine + mapping(uint256 => Machine) public machines; + + // Rental ID -> Rental + mapping(bytes32 => Rental) public rentals; + + // Machine tokenId -> array of rental IDs (for history) + mapping(uint256 => bytes32[]) public machineRentalHistory; + + // Events + event MachineRegistered(uint256 indexed tokenId, string name, string model, uint256 hourlyRate); + event MachineUpdated(uint256 indexed tokenId, string photoCID, bool isActive); + event RentalCreated(bytes32 indexed rentalId, uint256 indexed machineTokenId, address renter, uint64 startTime, uint64 endTime, uint256 rtcLocked); + event RentalStarted(bytes32 indexed rentalId); + event RentalCompleted(bytes32 indexed rentalId, bytes32 outputHash, string attestationProof); + event RentalCancelled(bytes32 indexed rentalId, uint256 rtcRefunded); + event UptimeReported(uint256 indexed tokenId, uint256 addedSeconds); + + constructor() ERC721("RustChain Relic", "RRELIC") Ownable(msg.sender) {} + + // ─── Machine Management ──────────────────────────────────────────────────── + + /// @notice Register a new vintage machine as an ERC-721 NFT + function registerMachine( + string calldata name, + string calldata model, + string calldata specs, + string calldata photoCID, + uint256 hourlyRateRTC, + address ed25519PubKey + ) external onlyOwner returns (uint256 tokenId) { + tokenId = _machineTokenIdCounter++; + _safeMint(address(this), tokenId); + + machines[tokenId] = Machine({ + name: name, + model: model, + specs: specs, + photoCID: photoCID, + hourlyRateRTC: hourlyRateRTC, + totalUptimeSeconds: 0, + totalRentals: 0, + isActive: true, + ed25519PubKey: ed25519PubKey + }); + + emit MachineRegistered(tokenId, name, model, hourlyRateRTC); + } + + /// @notice Update machine metadata + function updateMachine(uint256 tokenId, string calldata photoCID, bool isActive) external onlyOwner { + require(ownerOf(tokenId) == address(this), "Not the machine owner"); + machines[tokenId].photoCID = photoCID; + machines[tokenId].isActive = isActive; + emit MachineUpdated(tokenId, photoCID, isActive); + } + + /// @notice Report uptime after a completed rental + function reportUptime(uint256 tokenId, uint256 addedSeconds) external onlyOwner { + machines[tokenId].totalUptimeSeconds += addedSeconds; + emit UptimeReported(tokenId, addedSeconds); + } + + // ─── Rental Lifecycle ────────────────────────────────────────────────────── + + /// @notice Create a rental reservation. RTC is locked in escrow. + /// @param machineTokenId The machine to rent + /// @param slotHours Duration: 1, 4, or 24 hours + /// @param startTime Unix timestamp for when rental begins + /// @return rentalId unique rental identifier + function createRental( + uint256 machineTokenId, + uint256 slotHours, + uint64 startTime + ) external nonReentrant returns (bytes32 rentalId) { + require(machines[machineTokenId].isActive, "Machine not available"); + require(slotHours == 1 || slotHours == 4 || slotHours == 24, "Invalid slot duration"); + require(startTime >= block.timestamp, "Start time must be in future"); + + uint64 endTime = startTime + uint64(slotHours) * 3600; + uint256 rtcLocked = machines[machineTokenId].hourlyRateRTC * slotHours; + + rentalId = keccak256(abi.encode(machineTokenId, msg.sender, block.timestamp, slotHours)); + + rentals[rentalId] = Rental({ + machineTokenId: machineTokenId, + renter: msg.sender, + startTime: startTime, + endTime: endTime, + slotHours: slotHours, + rtcLocked: rtcLocked, + active: true, + outputHash: bytes32(0), + attestationProof: "" + }); + + machineRentalHistory[machineTokenId].push(rentalId); + machines[machineTokenId].totalRentals++; + + emit RentalCreated(rentalId, machineTokenId, msg.sender, startTime, endTime, rtcLocked); + } + + /// @notice Mark rental as started (called by renter or agent) + function startRental(bytes32 rentalId) external { + require(rentals[rentalId].renter == msg.sender, "Not the renter"); + require(rentals[rentalId].active, "Rental not active"); + emit RentalStarted(rentalId); + } + + /// @notice Complete a rental and record output hash + attestation proof + function completeRental( + bytes32 rentalId, + bytes32 outputHash, + string calldata attestationProof + ) external onlyOwner { + require(rentals[rentalId].active, "Rental not active"); + + rentals[rentalId].outputHash = outputHash; + rentals[rentalId].attestationProof = attestationProof; + rentals[rentalId].active = false; + + // Release escrow to machine owner (this contract holds it) + // In production, integrate with wRTC payment channel here + + emit RentalCompleted(rentalId, outputHash, attestationProof); + } + + /// @notice Cancel a rental and refund RTC + function cancelRental(bytes32 rentalId) external { + require(rentals[rentalId].renter == msg.sender, "Not the renter"); + require(rentals[rentalId].active, "Rental not active or already completed"); + require(block.timestamp < rentals[rentalId].startTime, "Rental already started"); + + uint256 refund = rentals[rentalId].rtcLocked; + rentals[rentalId].active = false; + + emit RentalCancelled(rentalId, refund); + } + + // ─── Views ──────────────────────────────────────────────────────────────── + + /// @notice List all machines + function getAllMachines() external view returns (Machine[] memory) { + Machine[] memory result = new Machine[](_machineTokenIdCounter); + for (uint256 i = 0; i < _machineTokenIdCounter; i++) { + result[i] = machines[i]; + } + return result; + } + + /// @notice Get rental history for a machine + function getMachineHistory(uint256 tokenId) external view returns (bytes32[] memory) { + return machineRentalHistory[tokenId]; + } + + /// @notice Get rental details + function getRental(bytes32 rentalId) external view returns (Rental memory) { + return rentals[rentalId]; + } + + // ─── ERC-721 Overrides ──────────────────────────────────────────────────── + function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory) { + return machines[tokenId].photoCID; + } + + function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC721URIStorage) returns (bool) { + return super.supportsInterface(interfaceId); + } + + // ─── MCP Tool Bridge ────────────────────────────────────────────────────── + + /// @notice Simplified reserve endpoint — called by MCP tool or Beacon + function mcpReserve( + uint256 machineTokenId, + uint256 slotHours, + uint64 startTime, + address renter + ) external onlyOwner returns (bytes32 rentalId) { + return createRental(machineTokenId, slotHours, startTime); + } + + /// @notice Get available time slots for a machine (stub — full impl in Python layer) + function getAvailableSlots(uint256 machineTokenId) external view returns (uint64[] memory starts, uint64[] memory ends) { + // Placeholder: returns empty arrays. Real availability computed in Python layer. + return (new uint64[](0), new uint64[](0)); + } +} diff --git a/relic_market/__init__.py b/relic_market/__init__.py new file mode 100644 index 000000000..8cc262f2e --- /dev/null +++ b/relic_market/__init__.py @@ -0,0 +1,19 @@ +""" +Rent-a-Relic Market — Python Package +AI agents can book authenticated time on named vintage machines. +""" +__version__ = "0.1.0" +__author__ = "RustChain" + +from .machine_registry import MachineRegistry, Machine +from .escrow import EscrowManager +from .provenance_receipt import ProvenanceReceipt +from .reservation_server import app + +__all__ = [ + "MachineRegistry", + "Machine", + "EscrowManager", + "ProvenanceReceipt", + "app", +] diff --git a/relic_market/escrow.py b/relic_market/escrow.py new file mode 100644 index 000000000..577d19089 --- /dev/null +++ b/relic_market/escrow.py @@ -0,0 +1,191 @@ +""" +Escrow — RTC payment escrow management for relic rentals. +Handles locking RTC during reservation, releasing on completion, refunding on cancellation. +""" +import sqlite3 +import time +import hashlib +from pathlib import Path +from dataclasses import dataclass, field +from typing import Optional, List, Dict +from enum import IntEnum + + +class EscrowState(IntEnum): + PENDING = 0 + LOCKED = 1 + RELEASED = 2 + REFUNDED = 3 + + +@dataclass +class EscrowEntry: + """An escrow entry tracking locked RTC for a rental.""" + escrow_id: str + rental_id: str + machine_token_id: int + renter: str + amount_rtc: float + state: EscrowState + created_at: float = field(default_factory=time.time) + released_at: Optional[float] = None + tx_hash: Optional[str] = None + + +class EscrowManager: + """ + Manages RTC escrow for rental reservations. + + In production, integrates with Solana wRTC or a payment channel. + For this implementation, we track escrow state locally with SQLite + and simulate the on-chain locking via a hash commitment scheme. + """ + + DB_PATH = Path(__file__).parent / "escrow.db" + + def __init__(self, db_path: Optional[str] = None): + self.db_path = Path(db_path) if db_path else self.DB_PATH + self._init_db() + + def _init_db(self): + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS escrow ( + escrow_id TEXT PRIMARY KEY, + rental_id TEXT NOT NULL, + machine_token_id INTEGER, + renter TEXT NOT NULL, + amount_rtc REAL NOT NULL, + state INTEGER DEFAULT 0, + created_at REAL, + released_at REAL, + tx_hash TEXT, + commitment_hash TEXT + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_rental ON escrow(rental_id) + """) + conn.commit() + + def _make_commitment(self, rental_id: str, amount: float, renter: str) -> str: + """Create a deterministic commitment hash for the escrow.""" + raw = f"{rental_id}:{amount}:{renter}:{time.time()}" + return hashlib.sha256(raw.encode()).hexdigest() + + def lock(self, rental_id: str, machine_token_id: int, renter: str, amount_rtc: float) -> EscrowEntry: + """ + Lock RTC for a rental. In production, this would initiate a payment channel + or submit a hash-lock transaction on Solana. + Returns an EscrowEntry with the commitment hash. + """ + escrow_id = hashlib.sha256(f"{rental_id}:{renter}".encode()).hexdigest()[:16] + commitment = self._make_commitment(rental_id, amount_rtc, renter) + + entry = EscrowEntry( + escrow_id=escrow_id, + rental_id=rental_id, + machine_token_id=machine_token_id, + renter=renter, + amount_rtc=amount_rtc, + state=EscrowState.LOCKED, + ) + + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT OR REPLACE INTO escrow + (escrow_id, rental_id, machine_token_id, renter, amount_rtc, state, created_at, commitment_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (entry.escrow_id, entry.rental_id, entry.machine_token_id, + entry.renter, entry.amount_rtc, int(entry.state), + entry.created_at, commitment)) + conn.commit() + + return entry + + def release(self, escrow_id: str, tx_hash: str) -> bool: + """Release locked RTC to machine owner (called after successful session).""" + with sqlite3.connect(self.db_path) as conn: + affected = conn.execute(""" + UPDATE escrow SET state = ?, released_at = ?, tx_hash = ? + WHERE escrow_id = ? AND state = ? + """, (int(EscrowState.RELEASED), time.time(), tx_hash, + escrow_id, int(EscrowState.LOCKED))).rowcount + conn.commit() + return affected > 0 + + def refund(self, escrow_id: str) -> bool: + """Refund locked RTC to renter (called on cancellation).""" + with sqlite3.connect(self.db_path) as conn: + affected = conn.execute(""" + UPDATE escrow SET state = ?, released_at = ? + WHERE escrow_id = ? AND state = ? + """, (int(EscrowState.REFUNDED), time.time(), + escrow_id, int(EscrowState.LOCKED))).rowcount + conn.commit() + return affected > 0 + + def get_entry(self, escrow_id: str) -> Optional[EscrowEntry]: + """Fetch an escrow entry by ID.""" + with sqlite3.connect(self.db_path) as conn: + row = conn.execute( + "SELECT * FROM escrow WHERE escrow_id = ?", (escrow_id,) + ).fetchone() + if not row: + return None + return EscrowEntry( + escrow_id=row[0], rental_id=row[1], machine_token_id=row[2], + renter=row[3], amount_rtc=row[4], state=EscrowState(row[5]), + created_at=row[6], released_at=row[7], tx_hash=row[8] + ) + + def get_entries_by_renter(self, renter: str) -> List[EscrowEntry]: + """Get all escrow entries for a renter.""" + with sqlite3.connect(self.db_path) as conn: + rows = conn.execute( + "SELECT * FROM escrow WHERE renter = ? ORDER BY created_at DESC", (renter,) + ).fetchall() + return [ + EscrowEntry(escrow_id=r[0], rental_id=r[1], machine_token_id=r[2], + renter=r[3], amount_rtc=r[4], state=EscrowState(r[5]), + created_at=r[6], released_at=r[7], tx_hash=r[8]) + for r in rows + ] + + def total_locked(self) -> float: + """Total RTC currently locked in escrow.""" + with sqlite3.connect(self.db_path) as conn: + row = conn.execute( + "SELECT COALESCE(SUM(amount_rtc), 0) FROM escrow WHERE state = ?", + (int(EscrowState.LOCKED),) + ).fetchone() + return row[0] if row else 0.0 + + def summary(self) -> Dict: + """Return a summary of escrow state.""" + with sqlite3.connect(self.db_path) as conn: + locked = conn.execute( + "SELECT COUNT(*) FROM escrow WHERE state = ?", (int(EscrowState.LOCKED),) + ).fetchone()[0] + released = conn.execute( + "SELECT COUNT(*) FROM escrow WHERE state = ?", (int(EscrowState.RELEASED),) + ).fetchone()[0] + refunded = conn.execute( + "SELECT COUNT(*) FROM escrow WHERE state = ?", (int(EscrowState.REFUNDED),) + ).fetchone()[0] + return { + "total_locked_rtc": self.total_locked(), + "active_escrows": locked, + "released_count": released, + "refunded_count": refunded, + } + + +if __name__ == "__main__": + escrow = EscrowManager() + # Demo: lock some RTC + entry = escrow.lock("rental_abc123", 0, "C4c7r9WPsnEe6CUfegMU9M7ReHD1pWg8qeSfTBoRcLbg", 50.0) + print(f"Locked {entry.amount_rtc} RTC — escrow_id={entry.escrow_id}") + print(f"Summary: {escrow.summary()}") + print(f"Released: {escrow.release(entry.escrow_id, 'sol_tx_abc123')}") + print(f"Final summary: {escrow.summary()}") diff --git a/relic_market/machine_registry.py b/relic_market/machine_registry.py new file mode 100644 index 000000000..e07960681 --- /dev/null +++ b/relic_market/machine_registry.py @@ -0,0 +1,278 @@ +""" +Machine Registry — manages the catalog of vintage machines available for rent. +""" +import sqlite3 +import uuid +import time +from dataclasses import dataclass, field, asdict +from typing import Optional, List, Dict +from pathlib import Path + + +@dataclass +class Machine: + """Represents a vintage machine in the registry.""" + token_id: int + name: str + model: str # e.g., "POWER8", "Mac G5", "Sun UltraSPARC" + specs: Dict[str, str] # CPU, RAM, storage, GPU, etc. + photo_url: str + hourly_rate_rtc: float + total_uptime_seconds: int = 0 + total_rentals: int = 0 + is_active: bool = True + ed25519_pubkey_hex: str = "" + attestation_history: List[Dict] = field(default_factory=list) + created_at: float = field(default_factory=time.time) + + def to_dict(self) -> Dict: + return { + "token_id": self.token_id, + "name": self.name, + "model": self.model, + "specs": self.specs, + "photo_url": self.photo_url, + "hourly_rate_rtc": self.hourly_rate_rtc, + "total_uptime_seconds": self.uptime_formatted, + "total_rentals": self.total_rentals, + "is_active": self.is_active, + "ed25519_pubkey": self.ed25519_pubkey_hex, + "created_at": self.created_at, + } + + @property + def uptime_formatted(self) -> str: + h, rem = divmod(self.total_uptime_seconds, 3600) + m = rem // 60 + return f"{h}h {m}m" + + def add_attestation(self, session_id: str, proof: str): + self.attestation_history.append({ + "session_id": session_id, + "proof": proof, + "timestamp": time.time() + }) + + +class MachineRegistry: + """ + SQLite-backed registry of vintage machines. + Used both as a local cache and for MCP tool responses. + """ + + DB_PATH = Path(__file__).parent / "registry.db" + + def __init__(self, db_path: Optional[str] = None): + self.db_path = Path(db_path) if db_path else self.DB_PATH + self._init_db() + + def _init_db(self): + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS machines ( + token_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + model TEXT NOT NULL, + specs TEXT NOT NULL, -- JSON + photo_url TEXT, + hourly_rate_rtc REAL NOT NULL, + total_uptime_seconds INTEGER DEFAULT 0, + total_rentals INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + ed25519_pubkey TEXT, + created_at REAL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS attestation_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_id INTEGER, + session_id TEXT, + proof TEXT, + timestamp REAL, + FOREIGN KEY (token_id) REFERENCES machines(token_id) + ) + """) + conn.commit() + + def register_machine(self, machine: Machine) -> int: + """Add a machine to the registry.""" + import json + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT INTO machines (token_id, name, model, specs, photo_url, + hourly_rate_rtc, total_uptime_seconds, + total_rentals, is_active, ed25519_pubkey, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + machine.token_id, machine.name, machine.model, + json.dumps(machine.specs), machine.photo_url, + machine.hourly_rate_rtc, machine.total_uptime_seconds, + machine.total_rentals, int(machine.is_active), + machine.ed25519_pubkey_hex, machine.created_at + )) + conn.commit() + return machine.token_id + + def get_machine(self, token_id: int) -> Optional[Machine]: + """Fetch a single machine by token ID.""" + import json + with sqlite3.connect(self.db_path) as conn: + row = conn.execute( + "SELECT * FROM machines WHERE token_id = ?", (token_id,) + ).fetchone() + if not row: + return None + return Machine( + token_id=row[0], name=row[1], model=row[2], + specs=json.loads(row[3]), photo_url=row[4], + hourly_rate_rtc=row[5], total_uptime_seconds=row[6], + total_rentals=row[7], is_active=bool(row[8]), + ed25519_pubkey_hex=row[9] or "", created_at=row[10] or time.time() + ) + + def list_machines(self, active_only: bool = True) -> List[Machine]: + """List all machines, optionally filtering to active only.""" + with sqlite3.connect(self.db_path) as conn: + query = "SELECT * FROM machines" + if active_only: + query += " WHERE is_active = 1" + rows = conn.execute(query).fetchall() + + import json + machines = [] + for row in rows: + machines.append(Machine( + token_id=row[0], name=row[1], model=row[2], + specs=json.loads(row[3]), photo_url=row[4], + hourly_rate_rtc=row[5], total_uptime_seconds=row[6], + total_rentals=row[7], is_active=bool(row[8]), + ed25519_pubkey_hex=row[9] or "", created_at=row[10] or time.time() + )) + return machines + + def update_uptime(self, token_id: int, added_seconds: int): + """Increment machine uptime after a rental session.""" + with sqlite3.connect(self.db_path) as conn: + conn.execute( + "UPDATE machines SET total_uptime_seconds = total_uptime_seconds + ? WHERE token_id = ?", + (added_seconds, token_id) + ) + conn.commit() + + def add_attestation(self, token_id: int, session_id: str, proof: str): + """Record an attestation event in history.""" + with sqlite3.connect(self.db_path) as conn: + conn.execute( + "INSERT INTO attestation_history (token_id, session_id, proof, timestamp) VALUES (?, ?, ?, ?)", + (token_id, session_id, proof, time.time()) + ) + conn.commit() + + def get_attestations(self, token_id: int) -> List[Dict]: + """Get attestation history for a machine.""" + with sqlite3.connect(self.db_path) as conn: + rows = conn.execute( + "SELECT session_id, proof, timestamp FROM attestation_history WHERE token_id = ? ORDER BY timestamp DESC", + (token_id,) + ).fetchall() + return [{"session_id": r[0], "proof": r[1], "timestamp": r[2]} for r in rows] + + def seed_demo_machines(self): + """Populate registry with demo vintage machines.""" + demos = [ + Machine( + token_id=0, + name="Old Ironsides", + model="IBM POWER8 8247-21L", + specs={ + "CPU": "POWER8 (3.02 GHz, 12 cores)", + "RAM": "512 GB DDR3 ECC", + "Storage": "4× 600 GB SAS 15k RPM", + "Network": "10GbE", + "OS": "AIX 7.2 / Ubuntu 20.04 (ppc64le)", + "Architecture": "ppc64le", + }, + photo_url="ipfs://QmOldIron001/photo.jpg", + hourly_rate_rtc=50.0, + ed25519_pubkey_hex="a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890", + ), + Machine( + token_id=1, + name="Amber Ghost", + model="Apple Mac G5 Quad 2005", + specs={ + "CPU": "PowerPC 970MP (2.5 GHz, 4 cores)", + "RAM": "256 GB DDR2", + "Storage": "2× 500 GB SATA + 512 GB SSD", + "GPU": "ATI X850 XT", + "OS": "Mac OS X 10.4 / Linux (ppc64)", + "Architecture": "ppc64", + }, + photo_url="ipfs://QmAmberGh002/photo.jpg", + hourly_rate_rtc=30.0, + ed25519_pubkey_hex="b2c3d4e5f6789012bcdef123456789012bcdef123456789012bcdef12345678", + ), + Machine( + token_id=2, + name="Solaris Sparrow", + model="Sun UltraSPARC T2", + specs={ + "CPU": "UltraSPARC T2 (1.4 GHz, 64 threads)", + "RAM": "128 GB DDR2", + "Storage": "4× 300 GB 10k RPM", + "Network": "4× 1GbE", + "OS": "Solaris 10 / OpenIndiana", + "Architecture": "sparc64", + }, + photo_url="ipfs://QmSolarSp003/photo.jpg", + hourly_rate_rtc=25.0, + ed25519_pubkey_hex="c3d4e5f67890123cdef1234567890123cdef1234567890123cdef12345678901", + ), + Machine( + token_id=3, + name="Vax Phantom", + model="DEC VAX 11/780 (Simulated)", + specs={ + "CPU": "Simulated VAX (KA780)", + "RAM": "16 MB", + "Storage": "Simulated RK07", + "OS": "VMS 7.3 / Ultrix", + "Architecture": "vax", + "Note": "Software emulated via SIMH", + }, + photo_url="ipfs://QmVaxPhan004/photo.jpg", + hourly_rate_rtc=15.0, + ed25519_pubkey_hex="d4e5f678901234de12345678901234de12345678901234de12345678901234", + ), + Machine( + token_id=4, + name="Cray Shade", + model="Cray X1E (Simulated)", + specs={ + "CPU": "Cray X1E MSP (10 GHz, 4 cores)", + "RAM": "64 GB", + "Storage": "18.5 GB/s bandwidth", + "OS": "Unicos/mp", + "Architecture": "cray", + "Note": "Software emulated", + }, + photo_url="ipfs://QmCrayShd005/photo.jpg", + hourly_rate_rtc=80.0, + ed25519_pubkey_hex="e5f6789012345ef123456789012345f123456789012345f1234567890123456", + ), + ] + for m in demos: + try: + self.register_machine(m) + print(f"Registered: {m.name} ({m.model})") + except sqlite3.IntegrityError: + print(f"Already registered: {m.name}") + + +if __name__ == "__main__": + registry = MachineRegistry() + registry.seed_demo_machines() + print("\nRegistered machines:") + for m in registry.list_machines(active_only=False): + print(f" [{m.token_id}] {m.name} — {m.model} — {m.hourly_rate_rtc} RTC/hr — active={m.is_active}") diff --git a/relic_market/marketplace_ui.py b/relic_market/marketplace_ui.py new file mode 100644 index 000000000..7585ef6cc --- /dev/null +++ b/relic_market/marketplace_ui.py @@ -0,0 +1,294 @@ +""" +marketplace_ui — CLI tool for browsing and renting vintage machines. +Usage: + python marketplace_ui.py list + python marketplace_ui.py available --hours 4 + python marketplace_ui.py book --machine 0 --hours 1 --start --renter
+ python marketplace_ui.py status --rental + python marketplace_ui.py receipt --receipt + python marketplace_ui.py complete --rental --output + python marketplace_ui.py leaderboard +""" +import argparse +import json +import sys +import time +import os +from pathlib import Path + +# Add parent dir to path so we can import relic_market +sys.path.insert(0, str(Path(__file__).parent)) + +from machine_registry import MachineRegistry +from escrow import EscrowManager +from provenance_receipt import ProvenanceReceiptManager +from reservation_server import RESERVATIONS_DB, registry, escrow_mgr, receipt_mgr, _compute_available_slots, Reservation +import sqlite3 + + +API_BASE = os.environ.get("RELIC_API_BASE", "http://localhost:5001") + + +def cmd_list(args): + """List all machines in the registry.""" + machines = registry.list_machines(active_only=False) + print(f"\n{'─'*70}") + print(f" {'TOKEN':<6} {'NAME':<20} {'MODEL':<25} {'RATE':<10} {'ACTIVE'}") + print(f"{'─'*70}") + for m in machines: + print(f" {m.token_id:<6} {m.name:<20} {m.model:<25} " + f"{m.hourly_rate_rtc:<10.1f} {'✓' if m.is_active else '✗'}") + print(f"{'─'*70}") + print(f"Total machines: {len(machines)}") + + +def cmd_available(args): + """Show available time slots for machines.""" + slot_hours = args.hours or 1 + machines = registry.list_machines(active_only=True) + print(f"\nAvailable machines — {slot_hours}h slots") + print(f"{'─'*70}") + + for m in machines: + slots = _compute_available_slots(m.token_id, slot_hours) + print(f"\n[{m.token_id}] {m.name} — {m.model}") + print(f" Rate: {m.hourly_rate_rtc} RTC/hr | Uptime: {m.uptime_formatted} | Rentals: {m.total_rentals}") + print(f" Specs: {json.dumps(m.specs)}") + if slots: + print(f" Next slots ({len(slots)} shown of total):") + for s in slots[:5]: + print(f" {s['start_iso']} → {s['end_iso']}") + else: + print(" No slots available in next 7 days") + print(f"{'─'*70}") + + +def cmd_book(args): + """Book a machine via direct API call.""" + import urllib.request, urllib.parse + + if not args.renter: + print("Error: --renter address required") + return + + payload = json.dumps({ + "machine_token_id": args.machine, + "slot_hours": args.hours or 1, + "start_time": args.start or time.time() + 300, # 5 min from now if not specified + "renter": args.renter, + }).encode() + + req = urllib.request.Request( + f"{API_BASE}/relic/reserve", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + print(f"\n✓ Reservation created!") + print(f" Rental ID: {data['rental_id']}") + print(f" Escrow ID: {data['escrow_id']}") + print(f" Machine: {data['machine_name']}") + print(f" Start: {data['start_time_iso']}") + print(f" End: {data['end_time_iso']}") + print(f" RTC Locked: {data['rtc_locked']}") + print(f" State: {data['state']}") + except urllib.error.HTTPError as e: + body = e.read().decode() + print(f"Error {e.code}: {body}") + except Exception as e: + print(f"Connection error (is server running?): {e}") + print("Falling back to direct DB simulation...") + # Fallback: simulate locally + _simulate_booking(args) + + +def _simulate_booking(args): + """Fallback booking when server is not running.""" + machine = registry.get_machine(args.machine) + if not machine: + print(f"Machine {args.machine} not found") + return + slot_hours = args.hours or 1 + start_time = args.start or time.time() + 300 + end_time = start_time + slot_hours * 3600 + rtc_locked = machine.hourly_rate_rtc * slot_hours + rental_id = f"rental_simu{time.time():.0f}" + print(f"\n[SIMULATED] Booking: {machine.name} for {slot_hours}h at {start_time}") + print(f" RTC to lock: {rtc_locked}") + + +def cmd_status(args): + """Check status of a reservation.""" + if not args.rental: + print("Error: --rental required") + return + with sqlite3.connect(RESERVATIONS_DB) as conn: + row = conn.execute( + "SELECT * FROM reservations WHERE rental_id = ?", (args.rental,) + ).fetchone() + if not row: + print("Rental not found") + return + res = Reservation(*row) + machine = registry.get_machine(res.machine_token_id) + print(f"\nRental: {res.rental_id}") + print(f" Machine: {machine.name if machine else res.machine_token_id}") + print(f" Renter: {res.renter}") + print(f" Slot: {res.slot_hours}h") + print(f" Start: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(res.start_time))}") + print(f" End: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(res.end_time))}") + print(f" RTC Locked: {res.rtc_locked}") + print(f" Escrow ID: {res.escrow_id}") + print(f" State: {res.state}") + + +def cmd_complete(args): + """Complete a rental and generate provenance receipt.""" + if not args.rental: + print("Error: --rental required") + return + + with sqlite3.connect(RESERVATIONS_DB) as conn: + row = conn.execute( + "SELECT * FROM reservations WHERE rental_id = ?", (args.rental,) + ).fetchone() + if not row: + print("Rental not found") + return + res = Reservation(*row) + + machine = registry.get_machine(res.machine_token_id) + output_hash = args.output or f"output_hash_{res.rental_id}" + attestation = args.attestation or "cpu_cycles=12345678,instruction_count=99999999,mem_access=5000000" + + receipt = receipt_mgr.create_receipt( + machine_passport_id=machine.name if machine else str(res.machine_token_id), + machine_model=machine.model if machine else "Unknown", + session_id=res.rental_id, + renter=res.renter, + slot_hours=res.slot_hours, + start_time=res.start_time, + end_time=res.end_time, + output_hash=output_hash, + attestation_proof=attestation, + ) + + escrow_mgr.release(res.escrow_id, f"tx_{res.rental_id}") + with sqlite3.connect(RESERVATIONS_DB) as conn: + conn.execute("UPDATE reservations SET state = ? WHERE rental_id = ?", + ("completed", res.rental_id)) + conn.commit() + + print(f"\n✓ Session completed!") + print(f" Receipt ID: {receipt.receipt_id}") + print(f" Machine: {receipt.machine_passport_id}") + print(f" Output hash: {receipt.output_hash}") + print(f" Signed at: {receipt.signed_at_iso}") + print(f" Signature: {receipt.signature[:32]}...") + print(f" Verified: {receipt.verify()}") + + +def cmd_receipt(args): + """Fetch and display a receipt.""" + if not args.receipt: + print("Error: --receipt required") + return + receipt = receipt_mgr.get_receipt(args.receipt) + if not receipt: + print("Receipt not found") + return + print(f"\n{'═'*60}") + print(f" PROVENANCE RECEIPT — {receipt.machine_passport_id}") + print(f"{'═'*60}") + print(f" Receipt ID: {receipt.receipt_id}") + print(f" Session ID: {receipt.session_id}") + print(f" Machine Model: {receipt.machine_model}") + print(f" Renter: {receipt.renter}") + print(f" Slot: {receipt.slot_hours}h") + print(f" Duration: {receipt.duration_seconds}s") + print(f" Start: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(receipt.start_time))}") + print(f" End: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(receipt.end_time))}") + print(f" Output Hash: {receipt.output_hash}") + print(f" Attestation: {receipt.attestation_proof}") + print(f" Ed25519 Pubkey: {receipt.ed25519_pubkey[:32]}...") + print(f" Signature: {receipt.signature[:32]}...") + print(f" Signed At: {receipt.signed_at_iso}") + print(f" Verified: {'✓ PASS' if receipt.verify() else '✗ FAIL'}") + print(f"{'═'*60}") + + +def cmd_leaderboard(args): + """Show most-rented machines leaderboard.""" + machines = registry.list_machines(active_only=False) + ranked = sorted(machines, key=lambda m: m.total_rentals, reverse=True) + print(f"\n{'─'*60}") + print(f" {'RANK':<6} {'NAME':<20} {'MODEL':<20} {'RENTALS':<10} {'UPTIME'}") + print(f"{'─'*60}") + for i, m in enumerate(ranked, 1): + print(f" {i:<6} {m.name:<20} {m.model:<20} {m.total_rentals:<10} {m.uptime_formatted}") + print(f"{'─'*60}") + + +def cmd_escrow_summary(args): + """Show escrow state.""" + summary = escrow_mgr.summary() + print(f"\nEscrow Summary") + print(f" Total locked RTC: {summary['total_locked_rtc']}") + print(f" Active escrows: {summary['active_escrows']}") + print(f" Released: {summary['released_count']}") + print(f" Refunded: {summary['refunded_count']}") + + +def main(): + parser = argparse.ArgumentParser(description="RustChain Relic Market CLI") + sub = parser.add_subparsers(dest="cmd") + + sub.add_parser("list", help="List all machines") + sub.add_parser("available", help="Show available slots") + sub.add_parser("leaderboard", help="Most-rented machines") + sub.add_parser("escrow", help="Escrow summary") + + p_book = sub.add_parser("book", help="Book a machine") + p_book.add_argument("--machine", type=int, required=True) + p_book.add_argument("--hours", type=int, default=1) + p_book.add_argument("--start", type=float, help="Unix timestamp for start") + p_book.add_argument("--renter", type=str, required=True) + + p_status = sub.add_parser("status", help="Check reservation status") + p_status.add_argument("--rental", type=str, required=True) + + p_complete = sub.add_parser("complete", help="Complete a rental") + p_complete.add_argument("--rental", type=str, required=True) + p_complete.add_argument("--output", type=str, help="Output hash") + p_complete.add_argument("--attestation", type=str, help="Attestation proof") + + p_receipt = sub.add_parser("receipt", help="Show a receipt") + p_receipt.add_argument("--receipt", type=str, required=True) + + args = parser.parse_args() + + if args.cmd == "list": + cmd_list(args) + elif args.cmd == "available": + cmd_available(args) + elif args.cmd == "book": + cmd_book(args) + elif args.cmd == "status": + cmd_status(args) + elif args.cmd == "complete": + cmd_complete(args) + elif args.cmd == "receipt": + cmd_receipt(args) + elif args.cmd == "leaderboard": + cmd_leaderboard(args) + elif args.cmd == "escrow": + cmd_escrow_summary(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/relic_market/provenance_receipt.py b/relic_market/provenance_receipt.py new file mode 100644 index 000000000..8316d6ebc --- /dev/null +++ b/relic_market/provenance_receipt.py @@ -0,0 +1,252 @@ +""" +Provenance Receipt — generates cryptographically signed receipts for completed sessions. +Uses Ed25519 signing so anyone can verify a receipt was genuinely produced by the machine. +""" +import json +import time +import hashlib +import base64 +import struct +from dataclasses import dataclass, asdict, field +from typing import Optional, Dict, List +from pathlib import Path + +# Ed25519 support — try libsodium / pynacl first, fall back to ed25519 package +try: + from nacl.signing import SigningKey + from nacl.encoding import RawEncoder + HAS_NACL = True +except ImportError: + HAS_NACL = False + +try: + import ed25519 + HAS_ED25519 = True +except ImportError: + HAS_ED25519 = False + + +def _generate_keypair() -> tuple: + """Generate a new Ed25519 keypair. Returns (signing_key_hex, verify_key_hex).""" + if HAS_NACL: + sk = SigningKey.generate() + vk = sk.verify_key + return base64.b16encode(sk.encode()).decode().lower(), base64.b16encode(vk.encode()).decode().lower() + elif HAS_ED25519: + sk = ed25519.SigningKey.generate() + vk = sk.get_verifying_key() + return sk.to_bytes().hex(), vk.to_bytes().hex() + else: + # Fallback: deterministic keys from hash (NOT secure — demo only) + import secrets + seed = secrets.token_bytes(32) + return seed.hex(), hashlib.sha256(seed).hexdigest() + + +@dataclass +class ProvenanceReceipt: + """ + A signed provenance receipt for a completed relic rental session. + Contains machine passport, session details, output hash, and Ed25519 signature. + """ + version: str = "1.0" + receipt_id: str # Unique receipt identifier + machine_passport_id: str # Machine NFT token ID / name + machine_model: str + session_id: str + renter: str + slot_hours: int + start_time: float + end_time: float + duration_seconds: int + output_hash: str # SHA-256 of the computed output + attestation_proof: str # Hardware attestation data + ed25519_pubkey: str # Machine's Ed25519 public key + signature: str # Ed25519 signature over the receipt payload + signed_at: float = field(default_factory=time.time) + + def to_dict(self) -> Dict: + """Return a dict representation suitable for JSON serialization.""" + d = asdict(self) + d["start_time_iso"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.start_time)) + d["end_time_iso"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.end_time)) + d["signed_at_iso"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.signed_at)) + return d + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=2) + + def verify(self) -> bool: + """ + Verify the Ed25519 signature on this receipt. + The signature must be over the canonical JSON of the receipt (excluding signature field). + """ + if not self.signature: + return False + payload = self._canonical_json() + return self._ed25519_verify(payload, self.signature, self.ed25519_pubkey) + + def _canonical_json(self) -> bytes: + """Canonical JSON representation (excludes signature, signed_at).""" + d = self.to_dict() + d.pop("signature", None) + d.pop("signed_at", None) + d.pop("signed_at_iso", None) + canonical = json.dumps(d, separators=(",", ":"), sort_keys=True) + return canonical.encode("utf-8") + + @staticmethod + def _ed25519_verify(message: bytes, signature_hex: str, pubkey_hex: str) -> bool: + """Verify an Ed25519 signature.""" + try: + sig_bytes = bytes.fromhex(signature_hex) + vk_bytes = bytes.fromhex(pubkey_hex) + except Exception: + return False + + if HAS_NACL: + try: + vk = nacl.signing.VerifyKey(vk_bytes, encoder=RawEncoder) + vk.verify(message, sig_bytes) + return True + except Exception: + return False + elif HAS_ED25519: + try: + vk = ed25519.VerifyingKey(vk_bytes) + vk.verify(sig_bytes, message) + return True + except Exception: + return False + else: + # Fallback verification (demo only — accepts any well-formed sig) + return len(sig_bytes) == 64 + + +class ProvenanceReceiptManager: + """ + Creates and stores provenance receipts locally. + Each machine has its own Ed25519 keypair (stored in keys/.key). + """ + + KEY_DIR = Path(__file__).parent / "keys" + RECEIPTS_DIR = Path(__file__).parent / "receipts" + + def __init__(self): + self.KEY_DIR.mkdir(exist_ok=True) + self.RECEIPTS_DIR.mkdir(exist_ok=True) + + def get_or_create_machine_keys(self, machine_passport_id: str) -> tuple: + """ + Get existing Ed25519 keypair for a machine, or generate a new one. + Returns (signing_key_hex, verify_key_hex). + """ + key_file = self.KEY_DIR / f"{machine_passport_id}.key" + if key_file.exists(): + data = json.loads(key_file.read_text()) + return data["sk"], data["vk"] + + sk_hex, vk_hex = _generate_keypair() + key_file.write_text(json.dumps({"sk": sk_hex, "vk": vk_hex}, indent=2)) + return sk_hex, vk_hex + + def sign_payload(self, message: bytes, signing_key_hex: str) -> str: + """Sign a message with an Ed25519 signing key.""" + try: + sk_bytes = bytes.fromhex(signing_key_hex) + vk_bytes = bytes.fromhex(signing_key_hex) # derive vk from sk + except Exception: + raise ValueError("Invalid signing key hex") + + if HAS_NACL: + sk = SigningKey(sk_bytes, encoder=RawEncoder) + signed = sk.sign(message, encoder=RawEncoder) + return signed.signature.hex() + elif HAS_ED25519: + sk = ed25519.SigningKey(sk_bytes) + sig = sk.sign(message) + return sig.hex() + else: + raise RuntimeError("No Ed25519 library available. Install pynacl or ed25519.") + + def create_receipt( + self, + machine_passport_id: str, + machine_model: str, + session_id: str, + renter: str, + slot_hours: int, + start_time: float, + end_time: float, + output_hash: str, + attestation_proof: str, + ) -> ProvenanceReceipt: + """Create and sign a new provenance receipt.""" + import uuid + receipt_id = f"receipt_{uuid.uuid4().hex[:16]}" + + sk_hex, vk_hex = self.get_or_create_machine_keys(machine_passport_id) + + # Build receipt (unsigned) + receipt = ProvenanceReceipt( + receipt_id=receipt_id, + machine_passport_id=machine_passport_id, + machine_model=machine_model, + session_id=session_id, + renter=renter, + slot_hours=slot_hours, + start_time=start_time, + end_time=end_time, + duration_seconds=int(end_time - start_time), + output_hash=output_hash, + attestation_proof=attestation_proof, + ed25519_pubkey=vk_hex, + signature="", # Will be filled below + ) + + # Sign the canonical payload + canonical = receipt._canonical_json() + sig_hex = self.sign_payload(canonical, sk_hex) + receipt.signature = sig_hex + + # Persist + self._save_receipt(receipt) + return receipt + + def _save_receipt(self, receipt: ProvenanceReceipt): + path = self.RECEIPTS_DIR / f"{receipt.receipt_id}.json" + path.write_text(receipt.to_json()) + + def get_receipt(self, receipt_id: str) -> Optional[ProvenanceReceipt]: + path = self.RECEIPTS_DIR / f"{receipt_id}.json" + if not path.exists(): + return None + d = json.loads(path.read_text()) + return ProvenanceReceipt(**d) + + def list_receipts(self, renter: Optional[str] = None) -> List[ProvenanceReceipt]: + receipts = [] + for path in self.RECEIPTS_DIR.glob("*.json"): + d = json.loads(path.read_text()) + if renter is None or d.get("renter") == renter: + receipts.append(ProvenanceReceipt(**d)) + return sorted(receipts, key=lambda r: r.signed_at, reverse=True) + + +if __name__ == "__main__": + manager = ProvenanceReceiptManager() + receipt = manager.create_receipt( + machine_passport_id="old_ironsides", + machine_model="IBM POWER8 8247-21L", + session_id="sess_abc123xyz", + renter="C4c7r9WPsnEe6CUfegMU9M7ReHD1pWg8qeSfTBoRcLbg", + slot_hours=1, + start_time=time.time(), + end_time=time.time() + 3600, + output_hash=hashlib.sha256(b"simulation_output_data").hexdigest(), + attestation_proof="cpu_measurement_cycles=12345678,instruction_count=999999", + ) + print(f"Receipt created: {receipt.receipt_id}") + print(f"Signature: {receipt.signature[:32]}...") + print(f"Verified: {receipt.verify()}") + print(receipt.to_json()) diff --git a/relic_market/reservation_server.py b/relic_market/reservation_server.py new file mode 100644 index 000000000..666916622 --- /dev/null +++ b/relic_market/reservation_server.py @@ -0,0 +1,387 @@ +""" +reservation_server — Flask API server for the Rent-a-Relic Market. +Implements: + POST /relic/reserve — Reserve time on a machine + GET /relic/available — List available machines + GET /relic/receipt/ — Get provenance receipt for a session + GET /relic/machines — Full machine details + POST /relic/complete — Complete a rental and generate receipt + GET /relic/rentals — List rentals for an address + GET /relic/escrow/summary — Escrow state +""" +import json +import time +import uuid +import sqlite3 +from pathlib import Path +from dataclasses import dataclass, asdict +from typing import Optional, List, Dict +from flask import Flask, jsonify, request + +from .machine_registry import MachineRegistry, Machine +from .escrow import EscrowManager, EscrowState +from .provenance_receipt import ProvenanceReceiptManager + +app = Flask(__name__) + +# ─── Database Setup ────────────────────────────────────────────────────────── + +RESERVATIONS_DB = Path(__file__).parent / "reservations.db" + + +def _init_reservation_db(): + with sqlite3.connect(RESERVATIONS_DB) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS reservations ( + rental_id TEXT PRIMARY KEY, + machine_token_id INTEGER, + renter TEXT, + slot_hours INTEGER, + start_time REAL, + end_time REAL, + rtc_locked REAL, + escrow_id TEXT, + state TEXT DEFAULT 'pending', + created_at REAL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_renter ON reservations(renter)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_machine ON reservations(machine_token_id)") + conn.commit() + + +_init_reservation_db() + +# ─── Managers ──────────────────────────────────────────────────────────────── + +registry = MachineRegistry() +escrow_mgr = EscrowManager() +receipt_mgr = ProvenanceReceiptManager() + + +# ─── Helpers ──────────────────────────────────────────────────────────────── + +@dataclass +class Reservation: + rental_id: str + machine_token_id: int + renter: str + slot_hours: int + start_time: float + end_time: float + rtc_locked: float + escrow_id: str + state: str + created_at: float + + def to_dict(self) -> Dict: + return asdict(self) + + @classmethod + def from_row(cls, row) -> "Reservation": + return cls(*row) + + +def _get_active_reservations(machine_token_id: int) -> List[Reservation]: + """Return all active (non-completed, non-cancelled) reservations for a machine.""" + with sqlite3.connect(RESERVATIONS_DB) as conn: + rows = conn.execute( + "SELECT * FROM reservations WHERE machine_token_id = ? AND state NOT IN ('completed','cancelled')", + (machine_token_id,) + ).fetchall() + return [Reservation.from_row(r) for r in rows] + + +def _is_slot_available(machine_token_id: int, start_time: float, end_time: float) -> bool: + """Check if a time slot conflicts with any existing active reservation.""" + active = _get_active_reservations(machine_token_id) + for r in active: + # Conflict if: existing starts before new ends AND existing ends after new starts + if r.start_time < end_time and r.end_time > start_time: + return False + return True + + +def _compute_available_slots(machine_token_id: int, slot_hours: int) -> List[Dict]: + """ + Compute available 1-hour slots for the next 7 days. + Returns list of {start, end} for each available slot. + """ + now = time.time() + slots = [] + day_seconds = 24 * 3600 + slot_seconds = slot_hours * 3600 + + for day_offset in range(7): + day_start = now + day_offset * day_seconds + # Align to next hour boundary + aligned = int((day_start + 3599) / 3600) * 3600 + for hour_offset in range(24 // slot_hours): + start = aligned + hour_offset * slot_seconds + end = start + slot_seconds + if start >= now and _is_slot_available(machine_token_id, start, end): + slots.append({ + "start": start, + "end": end, + "start_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(start)), + "end_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(end)), + }) + return slots + + +# ─── Routes ───────────────────────────────────────────────────────────────── + +@app.route("/relic/available", methods=["GET"]) +def get_available(): + """ + GET /relic/available + Query params: slot_hours (int, default 1), machine_token_id (optional filter) + Returns available machines with their upcoming slots. + """ + slot_hours = int(request.args.get("slot_hours", 1)) + filter_token = request.args.get("machine_token_id") + + machines = registry.list_machines(active_only=True) + result = [] + + for m in machines: + if filter_token is not None and str(m.token_id) != str(filter_token): + continue + slots = _compute_available_slots(m.token_id, slot_hours) + # Show only next 10 slots to keep response lean + result.append({ + "token_id": m.token_id, + "name": m.name, + "model": m.model, + "specs": m.specs, + "photo_url": m.photo_url, + "hourly_rate_rtc": m.hourly_rate_rtc, + "total_uptime": m.uptime_formatted, + "total_rentals": m.total_rentals, + "next_available_slots": slots[:10], + }) + + return jsonify({"machines": result, "query_slot_hours": slot_hours}) + + +@app.route("/relic/machines", methods=["GET"]) +def get_machines(): + """GET /relic/machines — Full machine registry.""" + machines = registry.list_machines(active_only=False) + return jsonify({ + "machines": [m.to_dict() for m in machines], + "total": len(machines), + }) + + +@app.route("/relic/reserve", methods=["POST"]) +def reserve(): + """ + POST /relic/reserve + Body: { machine_token_id, slot_hours, start_time (unix ts), renter } + Returns: { rental_id, escrow_id, rtc_locked, receipt_pending } + """ + body = request.get_json() + if not body: + return jsonify({"error": "Missing JSON body"}), 400 + + machine_token_id = int(body.get("machine_token_id", 0)) + slot_hours = int(body.get("slot_hours", 1)) + start_time = float(body.get("start_time", time.time())) + renter = str(body.get("renter", "")) + + if not renter: + return jsonify({"error": "renter address required"}), 400 + if slot_hours not in (1, 4, 24): + return jsonify({"error": "slot_hours must be 1, 4, or 24"}), 400 + if start_time < time.time(): + return jsonify({"error": "start_time must be in the future"}), 400 + + machine = registry.get_machine(machine_token_id) + if not machine: + return jsonify({"error": "Machine not found"}), 404 + if not machine.is_active: + return jsonify({"error": "Machine not active"}), 400 + + end_time = start_time + slot_hours * 3600 + if not _is_slot_available(machine_token_id, start_time, end_time): + return jsonify({"error": "Time slot not available"}), 409 + + rtc_locked = machine.hourly_rate_rtc * slot_hours + rental_id = f"rental_{uuid.uuid4().hex[:16]}" + escrow_entry = escrow_mgr.lock(rental_id, machine_token_id, renter, rtc_locked) + + with sqlite3.connect(RESERVATIONS_DB) as conn: + conn.execute(""" + INSERT INTO reservations + (rental_id, machine_token_id, renter, slot_hours, start_time, end_time, + rtc_locked, escrow_id, state, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (rental_id, machine_token_id, renter, slot_hours, start_time, + end_time, rtc_locked, escrow_entry.escrow_id, "pending", time.time())) + conn.commit() + + return jsonify({ + "rental_id": rental_id, + "escrow_id": escrow_entry.escrow_id, + "machine_token_id": machine_token_id, + "machine_name": machine.name, + "slot_hours": slot_hours, + "start_time": start_time, + "start_time_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(start_time)), + "end_time_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(end_time)), + "rtc_locked": rtc_locked, + "state": "pending", + "access_provisioned": False, # Would integrate with SSH/API provisioning here + "message": "Reservation created. Access will be provisioned at start_time.", + }), 201 + + +@app.route("/relic/complete", methods=["POST"]) +def complete_rental(): + """ + POST /relic/complete + Body: { rental_id, output_hash, attestation_proof } + Generates provenance receipt and releases escrow. + """ + body = request.get_json() + if not body: + return jsonify({"error": "Missing JSON body"}), 400 + + rental_id = body.get("rental_id", "") + output_hash = str(body.get("output_hash", "")) + attestation_proof = str(body.get("attestation_proof", "")) + + with sqlite3.connect(RESERVATIONS_DB) as conn: + row = conn.execute( + "SELECT * FROM reservations WHERE rental_id = ?", (rental_id,) + ).fetchone() + if not row: + return jsonify({"error": "Rental not found"}), 404 + + res = Reservation.from_row(row) + machine = registry.get_machine(res.machine_token_id) + if not machine: + return jsonify({"error": "Machine not found"}), 500 + + # Update machine uptime + actual_duration = res.end_time - res.start_time + registry.update_uptime(res.machine_token_id, int(actual_duration)) + + # Generate provenance receipt + receipt = receipt_mgr.create_receipt( + machine_passport_id=machine.name, + machine_model=machine.model, + session_id=res.rental_id, + renter=res.renter, + slot_hours=res.slot_hours, + start_time=res.start_time, + end_time=res.end_time, + output_hash=output_hash or "demo_output_hash", + attestation_proof=attestation_proof or "hardware_attestation_v1", + ) + + # Release escrow + escrow_mgr.release(res.escrow_id, f"simulated_tx_{rental_id}") + + # Update reservation state + with sqlite3.connect(RESERVATIONS_DB) as conn: + conn.execute( + "UPDATE reservations SET state = ? WHERE rental_id = ?", + ("completed", rental_id) + ) + conn.commit() + + return jsonify({ + "rental_id": rental_id, + "state": "completed", + "receipt": receipt.to_dict(), + "receipt_url": f"/relic/receipt/{receipt.receipt_id}", + }) + + +@app.route("/relic/receipt/", methods=["GET"]) +def get_receipt(receipt_id): + """GET /relic/receipt/ — Fetch and verify a provenance receipt.""" + receipt = receipt_mgr.get_receipt(receipt_id) + if not receipt: + return jsonify({"error": "Receipt not found"}), 404 + + result = receipt.to_dict() + result["verified"] = receipt.verify() + return jsonify(result) + + +@app.route("/relic/rentals", methods=["GET"]) +def list_rentals(): + """GET /relic/rentals?renter=
— List rentals for an address.""" + renter = request.args.get("renter") + if not renter: + return jsonify({"error": "renter address required"}), 400 + + with sqlite3.connect(RESERVATIONS_DB) as conn: + rows = conn.execute( + "SELECT * FROM reservations WHERE renter = ? ORDER BY created_at DESC", + (renter,) + ).fetchall() + + rentals = [] + for row in rows: + r = Reservation.from_row(row) + machine = registry.get_machine(r.machine_token_id) + rentals.append({ + **r.to_dict(), + "machine_name": machine.name if machine else "Unknown", + "machine_model": machine.model if machine else "Unknown", + }) + return jsonify({"rentals": rentals, "total": len(rentals)}) + + +@app.route("/relic/escrow/summary", methods=["GET"]) +def escrow_summary(): + """GET /relic/escrow/summary — Escrow state overview.""" + return jsonify(escrow_mgr.summary()) + + +@app.route("/relic/cancel", methods=["POST"]) +def cancel_reservation(): + """POST /relic/cancel — Cancel a pending reservation and refund.""" + body = request.get_json() + rental_id = body.get("rental_id", "") if body else "" + renter = body.get("renter", "") if body else "" + + with sqlite3.connect(RESERVATIONS_DB) as conn: + row = conn.execute( + "SELECT * FROM reservations WHERE rental_id = ? AND renter = ?", + (rental_id, renter) + ).fetchone() + if not row: + return jsonify({"error": "Reservation not found or not yours"}), 404 + + res = Reservation.from_row(row) + if res.state != "pending": + return jsonify({"error": f"Cannot cancel: state is {res.state}"}), 400 + + refunded = escrow_mgr.refund(res.escrow_id) + with sqlite3.connect(RESERVATIONS_DB) as conn: + conn.execute( + "UPDATE reservations SET state = ? WHERE rental_id = ?", + ("cancelled", rental_id) + ) + conn.commit() + + return jsonify({ + "rental_id": rental_id, + "state": "cancelled", + "refunded": refunded, + "rtc_refunded": res.rtc_locked, + }) + + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok", "time": time.time()}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5001, debug=True)