diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0272b34 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + linux: + name: linux + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: System deps + run: | + sudo apt-get update + sudo apt-get install -y ninja-build pkg-config libgl1-mesa-dev \ + libboost-all-dev libluajit-5.1-dev + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.*' + modules: 'qtmultimedia' + cache: true + - name: Configure + run: > + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + -DCWB_BUILD_CES_TOOLS=OFF + - name: Build + run: cmake --build build + - name: Test + run: ctest --test-dir build --output-on-failure + + macos: + name: macos + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: System deps + # Homebrew Qt: built against the runner's SDK (the aqt binaries still + # reference the AGL framework, removed from modern Xcode SDKs). + run: brew install ninja pkg-config boost luajit qt + - name: Configure + run: > + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + -DCWB_BUILD_CES_TOOLS=OFF + -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" + -DCMAKE_EXE_LINKER_FLAGS="-L$(brew --prefix icu4c)/lib" + - name: Build + run: cmake --build build + - name: Test + run: ctest --test-dir build --output-on-failure + + windows: + name: windows + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - uses: actions/checkout@v4 + - name: MSYS2 / MinGW64 toolchain + deps + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: true + install: >- + git + make + mingw-w64-x86_64-gcc + mingw-w64-x86_64-cmake + mingw-w64-x86_64-ninja + mingw-w64-x86_64-pkgconf + mingw-w64-x86_64-boost + mingw-w64-x86_64-luajit + mingw-w64-x86_64-qt6-base + mingw-w64-x86_64-qt6-svg + mingw-w64-x86_64-qt6-multimedia + - name: Configure + run: > + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + -DCWB_BUILD_CES_TOOLS=OFF + - name: Build + run: cmake --build build + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d8a918..2e43f79 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,15 +23,26 @@ set(LITEHTML_ENABLE_LINT OFF CACHE BOOL "" FORCE) set(LITEHTML_BUILD_TESTING OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(litehtml) -# Pinned CES: fetch the client engine (ceslib) from the ces remote at a fixed -# commit -- never the local working tree. MakeAvailable also exposes the ces -# server and cesluajitd as build targets, so the local demo runs on the same pin. +# Vendored CES snapshot (vendor/ces): the client engine cwb links. Only ceslib +# and its deps build (EXCLUDE_FROM_ALL); the ces tools are opt-in below. A +# developer can still point at a live checkout with +# -DFETCHCONTENT_SOURCE_DIR_CES= (build.sh CES_SRC). FetchContent_Declare(ces - GIT_REPOSITORY https://github.com/fcecin/ces - GIT_TAG 1b8bf36) + SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/ces + EXCLUDE_FROM_ALL) set(CES_BUILD_TESTS OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(ces) +# The local demo loop wants the ces server + cesh on the side; CI does not. +option(CWB_BUILD_CES_TOOLS "Also build the ces server and cesh" ON) +if(CWB_BUILD_CES_TOOLS) + foreach(t ces cesh cesluajitd) + if(TARGET ${t}) + set_target_properties(${t} PROPERTIES EXCLUDE_FROM_ALL FALSE) + endif() + endforeach() +endif() + # md4c: a small, fast, MIT-licensed Markdown parser (GFM tables/tasklists). Its # md4c-html target renders Markdown straight to an HTML fragment that litehtml # then styles + lays out. Forced static so the app needs no extra runtime .so. diff --git a/src/cesdial.cpp b/src/cesdial.cpp index ded4b8e..f2c1719 100644 --- a/src/cesdial.cpp +++ b/src/cesdial.cpp @@ -160,7 +160,7 @@ class Dialer { if (!minx_) return; if (tickTimer_) { boost::system::error_code ec; - tickTimer_->cancel(ec); + tickTimer_->cancel(); } minx_->closeSocket(false); if (netGuard_) netGuard_->reset(); @@ -1167,7 +1167,11 @@ uint8_t cesAccountFetch(const std::string& host, uint16_t port, char when[40] = ""; const std::time_t t = static_cast(xt); std::tm tmv{}; +#ifdef _WIN32 + gmtime_s(&tmv, &t); +#else gmtime_r(&t, &tmv); +#endif std::strftime(when, sizeof when, "%Y-%m-%d %H:%M:%S UTC", &tmv); b << "at" << when << ""; } diff --git a/src/widgets/write.cpp b/src/widgets/write.cpp index 83be88e..eec51e4 100644 --- a/src/widgets/write.cpp +++ b/src/widgets/write.cpp @@ -77,7 +77,11 @@ QString todayUtc() { char date[20] = ""; const std::time_t t = std::time(nullptr); std::tm tmv{}; +#ifdef _WIN32 + gmtime_s(&tmv, &t); +#else gmtime_r(&t, &tmv); +#endif std::strftime(date, sizeof date, "%Y-%m-%d", &tmv); return QLatin1String(date); } diff --git a/vendor/ces/CMakeLists.txt b/vendor/ces/CMakeLists.txt new file mode 100644 index 0000000..eb4bd85 --- /dev/null +++ b/vendor/ces/CMakeLists.txt @@ -0,0 +1,360 @@ +cmake_minimum_required(VERSION 3.28) + +project(ces) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED True) + +# ----------------------------------------------------------------------------- +# 1. EXPLICIT COMPILER CONFIGURATION +# ----------------------------------------------------------------------------- + +# Apply hardware optimizations globally to all C and C++ targets (x86 only; +# e.g. Apple Silicon rejects x86-64-v3). +set(CES_MARCH "x86-64-v3" CACHE STRING "Target -march; 'native' = local-only") +if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64") + add_compile_options(-march=${CES_MARCH}) +endif() + +# Force -O3 for pure Release builds (Overrides secp256k1's -O2 fallback) +add_compile_options("$<$:-O3>") +add_compile_options("$<$:-DNDEBUG>") + +# For profiling builds, cleanly enforce -O3 and keep the frame pointer +add_compile_options("$<$:-O3>") +add_compile_options("$<$:-DNDEBUG>") +add_compile_options("$<$:-g>") +add_compile_options("$<$:-fno-omit-frame-pointer>") + +include(FetchContent) + +# If no network connection, just use cached FetchContent deps +set(FETCHCONTENT_UPDATES_DISCONNECTED ON) + +# ----------------------------------------------------------------------------- +# 2. SYSTEM DEPENDENCIES +# ----------------------------------------------------------------------------- + +find_package(Threads) + +if(POLICY CMP0167) + cmake_policy(SET CMP0167 NEW) +endif() +set(Boost_USE_STATIC_LIBS ON) +# Boost.System is header-only since 1.69 and its component config vanished in +# 1.90, so never request it. The addr2line stacktrace backend (plus +# libbacktrace below) is a Linux nicety; other platforms get a plain handler. +set(CES_BOOST_COMPONENTS log log_setup) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + list(APPEND CES_BOOST_COMPONENTS stacktrace_addr2line) +endif() +find_package(Boost 1.83.0 REQUIRED COMPONENTS ${CES_BOOST_COMPONENTS}) + +# ----------------------------------------------------------------------------- +# 3. FETCH CONTENT DEPENDENCIES +# ----------------------------------------------------------------------------- + +FetchContent_Declare( + cryptopp-cmake + GIT_REPOSITORY https://github.com/abdes/cryptopp-cmake.git + GIT_TAG master +) +set(CRYPTOPP_BUILD_TESTING OFF CACHE BOOL "Disable building Crypto++ tests" FORCE) +FetchContent_MakeAvailable(cryptopp-cmake) + +FetchContent_Declare( + secp256k1 + GIT_REPOSITORY https://github.com/bitcoin-core/secp256k1.git + GIT_TAG v0.4.1 # Stable release with solid CMake support +) +set(SECP256K1_BUILD_TESTS OFF CACHE BOOL "Disable secp256k1 tests" FORCE) +set(SECP256K1_BUILD_EXHAUSTIVE_TESTS OFF CACHE BOOL "Disable secp256k1 exhaustive tests" FORCE) +set(SECP256K1_BUILD_BENCHMARK OFF CACHE BOOL "Disable secp256k1 benchmarks" FORCE) +set(SECP256K1_BUILD_EXAMPLES OFF CACHE BOOL "Disable secp256k1 examples" FORCE) +set(SECP256K1_DISABLE_SHARED ON CACHE BOOL "Only build static library" FORCE) +set(SECP256K1_ENABLE_MODULE_RECOVERY ON CACHE BOOL "Enable recovery module" FORCE) + +FetchContent_MakeAvailable(secp256k1) + +FetchContent_Declare( + cli11_proj + GIT_REPOSITORY https://github.com/CLIUtils/CLI11.git + GIT_TAG v2.6.1 +) +FetchContent_MakeAvailable(cli11_proj) + +FetchContent_Declare( + minx + GIT_REPOSITORY https://github.com/fcecin/minx.git + GIT_TAG main + PATCH_COMMAND ${CMAKE_COMMAND} -P + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-minx-boost187.cmake +) +set(MINX_BUILD_EXAMPLES OFF CACHE BOOL "Disable building MINX examples" FORCE) +FetchContent_MakeAvailable(minx) + +FetchContent_Declare( + tomlplusplus + GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git + GIT_TAG v3.4.0 +) +FetchContent_MakeAvailable(tomlplusplus) + +# mene: the admin-panel UI library. Its build exports mene::assets, a +# dependency-free static lib carrying the embedded Lua library and browser +# renderer (see ), so the Lua encoder and the JS renderer in +# one server binary cannot skew. Bump GIT_TAG to take a new mene. Local mene +# checkout for development: cmake -DFETCHCONTENT_SOURCE_DIR_MENE=/path/to/mene +FetchContent_Declare( + mene + GIT_REPOSITORY https://github.com/fcecin/mene.git + GIT_TAG 1ad758ddd51587602fe145653a45c9d76d36b11a + SOURCE_SUBDIR cpp +) +FetchContent_MakeAvailable(mene) + +# ----------------------------------------------------------------------------- +# 4. EXTERNAL PROJECT DEPENDENCIES +# ----------------------------------------------------------------------------- + +include(ExternalProject) + +# Linux-only (see the Boost components note above). BYPRODUCTS is required +# for Ninja to know the imported .a comes from the external project. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(LIBBACKTRACE_PREFIX ${CMAKE_CURRENT_BINARY_DIR}/libbacktrace_install) + set(LIBBACKTRACE_INCLUDE_DIR ${LIBBACKTRACE_PREFIX}/include) + set(LIBBACKTRACE_LIB ${LIBBACKTRACE_PREFIX}/lib/libbacktrace.a) + + ExternalProject_Add(libbacktrace_ext + GIT_REPOSITORY https://github.com/ianlancetaylor/libbacktrace.git + GIT_TAG master + PREFIX ${LIBBACKTRACE_PREFIX} + CONFIGURE_COMMAND /configure --prefix=${LIBBACKTRACE_PREFIX} --enable-shared=OFF --enable-static=ON --with-pic + BUILD_COMMAND make + INSTALL_COMMAND make install + UPDATE_COMMAND "" + BUILD_BYPRODUCTS ${LIBBACKTRACE_LIB} + LOG_DOWNLOAD ON LOG_CONFIGURE ON LOG_BUILD ON + ) + + file(MAKE_DIRECTORY ${LIBBACKTRACE_INCLUDE_DIR}) + + add_library(libbacktrace STATIC IMPORTED GLOBAL) + add_dependencies(libbacktrace libbacktrace_ext) + set_target_properties(libbacktrace PROPERTIES + IMPORTED_LOCATION "${LIBBACKTRACE_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${LIBBACKTRACE_INCLUDE_DIR}" + ) + set(CES_STACKTRACE_LIBS Boost::stacktrace_addr2line libbacktrace) +else() + set(CES_STACKTRACE_LIBS "") +endif() + +# ----------------------------------------------------------------------------- +# 5. TARGETS +# ----------------------------------------------------------------------------- + +# Recursive so ceslib's source tree mirrors the include tree: every +# include/ces//.h has its src/ceslib//.cpp. +file(GLOB_RECURSE CESLIB_SOURCES "src/ceslib/*.cpp") + +# The portable client subset: keys/protocol/client, CesPlexClient, the L2 +# file/compute clients, and utils. The server half (ledger tables, VM, L2 +# handlers, CesPlex bus, webadmin) is POSIX and stays off non-POSIX hosts. +# A pure client (cwb) links the same subset everywhere. +# Default: full stack on Linux (the server's home), client subset elsewhere. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(CES_CLIENT_ONLY_DEFAULT OFF) +else() + set(CES_CLIENT_ONLY_DEFAULT ON) +endif() +option(CES_CLIENT_ONLY "ceslib = client-side engine only" + ${CES_CLIENT_ONLY_DEFAULT}) +if(CES_CLIENT_ONLY) + list(FILTER CESLIB_SOURCES EXCLUDE REGEX + "src/ceslib/(server|webadmin|extension_manager|cesco|clientasync|ramfilestore|cesvm|accounts|aliases|assets|keynames)\\.cpp$") + list(FILTER CESLIB_SOURCES EXCLUDE REGEX + "src/ceslib/l2/.*(handler|site)\\.cpp$") + list(FILTER CESLIB_SOURCES EXCLUDE REGEX + "src/ceslib/cesplex/(mux|meter|endpoint)\\.cpp$") + list(FILTER CESLIB_SOURCES EXCLUDE REGEX + "src/ceslib/(lang/.*|util/(smtp|vmprogram|kvcount))\\.cpp$") +else() + list(FILTER CESLIB_SOURCES EXCLUDE REGEX + "src/ceslib/client_only_compat\\.cpp$") +endif() +add_library(ceslib STATIC ${CESLIB_SOURCES}) + +target_compile_definitions(ceslib PUBLIC BOOST_ASIO_HEADER_ONLY) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_compile_definitions(ceslib PUBLIC BOOST_STACKTRACE_USE_BACKTRACE + CES_HAVE_STACKTRACE) +endif() + +target_compile_options(ceslib PRIVATE -Wall -Wextra -Wpedantic) + +target_include_directories(ceslib +PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${Boost_INCLUDE_DIRS} + ${logkv_SOURCE_DIR}/include +) + +target_link_libraries(ceslib +PUBLIC + ${Boost_LIBRARIES} + ${CES_STACKTRACE_LIBS} + cryptopp::cryptopp + secp256k1 + minxlib + mene::assets + Threads::Threads + ${CMAKE_DL_LIBS} + tomlplusplus::tomlplusplus +) + +execute_process( + COMMAND git rev-parse HEAD + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_COMMIT_HASH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) +execute_process( + COMMAND bash -c "date -u -d \"$(git log -1 --format=%ci)\" '+%Y-%m-%d %H:%M:%S UTC'" + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_COMMIT_DATE + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) +if(NOT GIT_COMMIT_HASH) + set(GIT_COMMIT_HASH "unknown") +endif() +if(NOT GIT_COMMIT_DATE) + set(GIT_COMMIT_DATE "unknown") +endif() + +# Every executable needs the full ceslib; a client-only build ships only the +# library for a downstream (e.g. cwb) to link. +if(NOT CES_CLIENT_ONLY) +file(GLOB CES_CPP "src/ces/*.cpp") +add_executable(ces ${CES_CPP}) +target_link_libraries(ces PRIVATE ceslib CLI11::CLI11) +target_link_options(ces PRIVATE "-Wl,-z,noexecstack") +target_compile_definitions(ces PRIVATE CES_GIT_HASH="${GIT_COMMIT_HASH}") +set_target_properties(ces PROPERTIES OUTPUT_NAME "ces") + +file(GLOB CESH_CPP "src/cesh/*.cpp") +add_executable(cesh ${CESH_CPP}) +target_link_libraries(cesh PRIVATE ceslib CLI11::CLI11) +target_link_options(cesh PRIVATE "-Wl,-z,noexecstack") +target_compile_definitions(cesh PRIVATE CES_GIT_HASH="${GIT_COMMIT_HASH}") +set_target_properties(cesh PROPERTIES OUTPUT_NAME "cesh") + +file(GLOB CESBENCH_CPP "src/cesbench/*.cpp") +add_executable(cesbench ${CESBENCH_CPP}) +target_link_libraries(cesbench PRIVATE ceslib CLI11::CLI11) +target_link_options(cesbench PRIVATE "-Wl,-z,noexecstack") +set_target_properties(cesbench PROPERTIES OUTPUT_NAME "cesbench") + +file(GLOB CESVMBENCH_CPP "src/cesvmbench/*.cpp") +add_executable(cesvmbench ${CESVMBENCH_CPP}) +target_link_libraries(cesvmbench PRIVATE ceslib) +target_link_options(cesvmbench PRIVATE "-Wl,-z,noexecstack") +set_target_properties(cesvmbench PROPERTIES OUTPUT_NAME "cesvmbench") + +add_executable(cesproxy src/cesproxy/main.cpp) +target_link_libraries(cesproxy PRIVATE ceslib CLI11::CLI11) +target_link_options(cesproxy PRIVATE "-Wl,-z,noexecstack") +set_target_properties(cesproxy PROPERTIES OUTPUT_NAME "cesproxy") + +add_executable(cesc src/cesc/main.cpp) +target_link_libraries(cesc PRIVATE ceslib CLI11::CLI11) +target_link_options(cesc PRIVATE "-Wl,-z,noexecstack") +set_target_properties(cesc PROPERTIES OUTPUT_NAME "cesc") + +add_executable(cescompmockd src/cescompmockd/main.cpp) +target_link_options(cescompmockd PRIVATE "-Wl,-z,noexecstack") +set_target_properties(cescompmockd PROPERTIES OUTPUT_NAME "cescompmockd") + +# The compute child; also the only LuaJIT consumer, so the pkg-config +# requirement lives with it. +find_package(PkgConfig REQUIRED) +pkg_check_modules(LUAJIT REQUIRED luajit) +add_executable(cesluajitd + src/cesluajitd/main.cpp + src/cesluajitd/sha256.cpp +) +target_include_directories(cesluajitd PRIVATE ${LUAJIT_INCLUDE_DIRS}) +target_link_libraries(cesluajitd PRIVATE ${LUAJIT_LIBRARIES} ceslib) +target_link_directories(cesluajitd PRIVATE ${LUAJIT_LIBRARY_DIRS}) +target_link_options(cesluajitd PRIVATE "-Wl,-z,noexecstack") +set_target_properties(cesluajitd PROPERTIES OUTPUT_NAME "cesluajitd") +endif() # NOT CES_CLIENT_ONLY: tools, cesluajitd, and LuaJIT all skipped + +# ----------------------------------------------------------------------------- +# 6. OPTIONAL: the hyle consensus engine (core + services) for cesluajitd +# ----------------------------------------------------------------------------- +# Enabled by build.sh --hyle (-DCES_HYLE=ON). Fetches hyle from GitHub like every +# other dep (no local paths). hyle transitively pulls malachite-cpp (its Rust core, +# via corrosion) and builds hyle (core) + hyle_services (the node facilities: kv + +# the a/e economy + mempool + the consensus-drive runtime; embedded, it builds the +# libs only, not morphe's CLI or tests). cesluajitd links hyle_services (which +# PUBLIC-links core), so the C++ side can drive a blockchain node over its own +# transport. Wired now; unused (no #include) until cesluajitd starts using it. +option(CES_HYLE "Fetch + link the hyle consensus engine (core + services) into cesluajitd" OFF) +if(CES_HYLE AND NOT CES_CLIENT_ONLY) + FetchContent_Declare( + hyle + GIT_REPOSITORY https://github.com/fcecin/hyle.git + GIT_TAG f3092352f17f9702a7a56f12b90d3ebf0de34e92 + ) + FetchContent_MakeAvailable(hyle) + target_link_libraries(cesluajitd PRIVATE hyle_services) + target_compile_definitions(cesluajitd PRIVATE CES_HYLE=1) + # cesh links it too, for the client-side hyle verbs (build + sign ops, speak a + # chain's line protocol). Linked to the cesh target only, never ceslib: the CES + # client engine stays hyle-free; all hyle knowledge rides in the shell. + target_link_libraries(cesh PRIVATE hyle_services) + target_compile_definitions(cesh PRIVATE CES_HYLE=1) + message(STATUS "CES_HYLE ON: cesluajitd + cesh link hyle_services (+ core) from github.com/fcecin/hyle") +endif() + +# Outbound mail: builtin:mail + the SMTP/TLS client. Off by default; build.sh +# --mail. Needs system OpenSSL. +option(CES_MAIL "Enable outbound mail (builtin:mail + SMTP with TLS)" OFF) +if(CES_MAIL AND NOT CES_CLIENT_ONLY) + find_package(OpenSSL REQUIRED) + target_link_libraries(ceslib PUBLIC OpenSSL::SSL OpenSSL::Crypto) + target_compile_definitions(ceslib PUBLIC CES_MAIL=1) + target_compile_definitions(cesluajitd PRIVATE CES_MAIL=1) + message(STATUS "CES_MAIL ON: builtin:mail + SMTP/TLS (OpenSSL)") +endif() + +include(${CMAKE_CURRENT_LIST_DIR}/cmake/QtDiscovery.cmake) +find_package(Qt6 QUIET COMPONENTS Widgets Network) +if(Qt6_FOUND AND NOT CES_CLIENT_ONLY) + set(CMAKE_AUTOMOC ON) + add_executable(cesqt + src/cesqt/main.cpp + src/cesqt/rpcserver.cpp + src/cesqt/rpcserver.h + src/cesqt/console.h + src/cesqt/about.h + src/cesqt/appmodel.h) + target_link_libraries(cesqt PRIVATE ceslib Qt6::Widgets Qt6::Network) + target_compile_definitions(cesqt PRIVATE + CES_GIT_HASH="${GIT_COMMIT_HASH}" + CES_GIT_DATE="${GIT_COMMIT_DATE}" + CES_BUILD_TYPE="${CMAKE_BUILD_TYPE}" + CES_COMPILER="${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}") + set_target_properties(cesqt PROPERTIES OUTPUT_NAME "cesqt") + message(STATUS "Qt6 found — cesqt will be built") +else() + message(STATUS "Qt6 not found — cesqt will not be built") +endif() + +option(CES_BUILD_TESTS "Build unit tests" ON) +if(CES_BUILD_TESTS AND NOT CES_CLIENT_ONLY) + enable_testing() + add_subdirectory(tests) +endif() \ No newline at end of file diff --git a/vendor/ces/LICENSE b/vendor/ces/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/vendor/ces/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/vendor/ces/cmake/QtDiscovery.cmake b/vendor/ces/cmake/QtDiscovery.cmake new file mode 100644 index 0000000..04be1ce --- /dev/null +++ b/vendor/ces/cmake/QtDiscovery.cmake @@ -0,0 +1,100 @@ +# QtDiscovery.cmake — do everything we reasonably can to locate a Qt6 +# install before the main CMakeLists.txt calls `find_package(Qt6 ...)`. +# +# Precedence (highest wins, each step is a no-op if the previous found a hit): +# +# 1. User override via -DQt6_DIR=... or -DCMAKE_PREFIX_PATH=... +# (respected implicitly — we check Qt6_DIR first and bail out if set). +# 2. $ENV{Qt6_DIR} — honored by CMake natively, listed for completeness. +# 3. $ENV{QTDIR} / $ENV{QT_DIR} — common user convention (Qt4/5 legacy +# but still widely respected in the wild). +# 4. `qmake6` / `qmake-qt6` / `qmake` on $PATH. Queried via +# `qmake -query QT_INSTALL_PREFIX`, same technique KDE uses. Catches +# system packages and locally-built Qt that the user has on $PATH. +# 5. Glob well-known Qt online-installer roots (`~/Qt///`, +# `/opt/Qt///`). For each match we run the bundled +# qmake and trust its reported prefix. We pick the highest-versioned +# match so upgrades are picked up automatically. +# +# Only the final `find_package(Qt6 ...)` call in the main CMakeLists.txt +# decides whether we actually succeeded. This file is purely about stuffing +# good candidates into CMAKE_PREFIX_PATH so that call has a chance. +# +# All internal variables are namespaced `_qt_disc_*` and unset on exit so +# this file is safe to `include()` from the top-level scope. + +if(DEFINED Qt6_DIR AND NOT Qt6_DIR STREQUAL "Qt6_DIR-NOTFOUND") + message(STATUS "QtDiscovery: Qt6_DIR already set (${Qt6_DIR}); skipping") + return() +endif() + +# --- helper: given a qmake binary path, prepend its QT_INSTALL_PREFIX ------- +function(_qt_disc_try_qmake qmake_path label) + if(NOT EXISTS "${qmake_path}") + return() + endif() + execute_process( + COMMAND "${qmake_path}" -query QT_INSTALL_PREFIX + OUTPUT_VARIABLE _prefix + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _rc + ERROR_QUIET) + if(_rc EQUAL 0 AND _prefix AND EXISTS "${_prefix}") + message(STATUS "QtDiscovery: ${label} -> ${_prefix}") + list(PREPEND CMAKE_PREFIX_PATH "${_prefix}") + set(CMAKE_PREFIX_PATH "${CMAKE_PREFIX_PATH}" PARENT_SCOPE) + endif() +endfunction() + +# --- 2 & 3: environment variables ------------------------------------------- +foreach(_var Qt6_DIR QTDIR QT_DIR) + if(DEFINED ENV{${_var}} AND NOT "$ENV{${_var}}" STREQUAL "") + set(_path "$ENV{${_var}}") + message(STATUS "QtDiscovery: env ${_var}=${_path}") + list(PREPEND CMAKE_PREFIX_PATH "${_path}") + endif() +endforeach() + +# --- 4: qmake on $PATH ------------------------------------------------------ +find_program(_qt_disc_qmake_path + NAMES qmake6 qmake-qt6 qmake + DOC "qmake binary used to locate Qt install prefix") +if(_qt_disc_qmake_path) + _qt_disc_try_qmake("${_qt_disc_qmake_path}" "qmake on PATH") +endif() + +# --- 5: glob well-known online-installer roots ------------------------------ +# `~/Qt///bin/qmake6` and friends. Version dirs sort +# lexically as MAJOR.MINOR.PATCH, which is monotonic enough for our purposes +# (6.10.2 > 6.9.5 > 6.6.0). Reverse the sort to prefer the newest. +set(_qt_disc_roots + "$ENV{HOME}/Qt" + "/opt/Qt" +) +set(_qt_disc_candidates) +foreach(_root IN LISTS _qt_disc_roots) + if(EXISTS "${_root}") + # Two-level glob: ///bin/qmake* + # Platform examples: gcc_64, macos, msvc2022_64, mingw_64. + file(GLOB _matches + "${_root}/*/*/bin/qmake6" + "${_root}/*/*/bin/qmake") + list(APPEND _qt_disc_candidates ${_matches}) + endif() +endforeach() +if(_qt_disc_candidates) + list(REMOVE_DUPLICATES _qt_disc_candidates) + # Sort lexically; reverse so the highest-versioned path comes first. + list(SORT _qt_disc_candidates) + list(REVERSE _qt_disc_candidates) + list(GET _qt_disc_candidates 0 _qt_disc_best) + _qt_disc_try_qmake("${_qt_disc_best}" "Qt online installer") +endif() + +# --- cleanup ---------------------------------------------------------------- +unset(_qt_disc_qmake_path CACHE) +unset(_qt_disc_roots) +unset(_qt_disc_candidates) +unset(_qt_disc_best) +unset(_path) +unset(_matches) diff --git a/vendor/ces/cmake/patch-minx-boost187.cmake b/vendor/ces/cmake/patch-minx-boost187.cmake new file mode 100644 index 0000000..649f9e3 --- /dev/null +++ b/vendor/ces/cmake/patch-minx-boost187.cmake @@ -0,0 +1,24 @@ +# Applied to the fetched MINX source (PATCH_COMMAND, CWD = minx source dir): +# Boost >= 1.87 removed deprecated asio APIs, and >= 1.90 dropped the +# boost_system component config. string(REPLACE) is idempotent -- an absent +# pattern changes nothing, so re-population is safe. +macro(subst f old new) + file(READ ${f} _c) + string(REPLACE "${old}" "${new}" _c "${_c}") + file(WRITE ${f} "${_c}") +endmacro() + +subst(CMakeLists.txt + "find_package(Boost 1.83.0 REQUIRED COMPONENTS system log log_setup)" + "find_package(Boost 1.83.0 REQUIRED COMPONENTS log log_setup)") + +subst(include/minx/filter.h + "addr = addr.to_v6().to_v4();" + "addr = boost::asio::ip::make_address_v4(boost::asio::ip::v4_mapped, addr.to_v6());") + +subst(src/minx.cpp + "netIORetryTimer_->cancel(ec);" + "(void)ec; netIORetryTimer_->cancel();") + +subst(src/minxrunner.cpp "netIO_.reset();" "netIO_.restart();") +subst(src/minxrunner.cpp "taskIO_.reset();" "taskIO_.restart();") diff --git a/vendor/ces/include/ces/account.h b/vendor/ces/include/ces/account.h new file mode 100644 index 0000000..a33dfad --- /dev/null +++ b/vendor/ces/include/ces/account.h @@ -0,0 +1,313 @@ +#pragma once + +#include +#include +#include + +namespace ces { + +/** + * Ledger account, stored in logkv::Store. The map entry is pair: an 8-byte key and + * this 56-byte value, 64 bytes total, one cache line. Fields are ordered for + * zero padding; the layout is pinned in tests/test_account_layout.cpp. + * + * keyTail combines with the 8-byte map key to form the full 32-byte account + * key. balance and nonce are the core state; lastXferDest/Amount/Time hold the + * last outgoing single-transfer receipt. balance and lastXferAmount are stored + * in 48 bits (Int48/UInt48, capping an account at ~1.4M credits) to free the + * 32-bit aliasId without growing the row. + */ +struct Account { + + Account() + : keyTail_{}, balance_(0), lastXferAmount_(0), lastXferDest_{}, + lastXferTime_(0), nonce_(0), aliasId_(0) {} + + Account(const HashTail& keyTail, int64_t balance, uint32_t nonce) + : keyTail_(keyTail), balance_(balance), lastXferAmount_(0), + lastXferDest_{}, lastXferTime_(0), nonce_(nonce), aliasId_(0) {} + + Account(const Hash& key, int64_t balance, uint32_t nonce) + : balance_(balance), lastXferAmount_(0), lastXferDest_{}, + lastXferTime_(0), nonce_(nonce), aliasId_(0) { + setKeyTail(key); + } + + Account(const Account&) = default; + Account(Account&&) = default; + Account& operator=(const Account&) = default; + Account& operator=(Account&&) = default; + + auto operator<=>(const Account&) const = default; + + uint32_t getNonce() const { return nonce_; } + void setNonce(uint32_t nonce) { nonce_ = nonce; } + + int64_t getBalance() const { return balance_.get(); } + void setBalance(int64_t balance) { balance_.set(balance); } + + HashTail& getKeyTail() { return keyTail_; } + const HashTail& getKeyTail() const { return keyTail_; } + void setKeyTail(const HashTail& keyTail) { keyTail_ = keyTail; } + void setKeyTail(const Hash& fullKey) { keyTail_ = getHashTail(fullKey); } + + Hash getKey(const HashPrefix& mapKey) const { + return getHash(mapKey, keyTail_); + } + + static HashPrefix getMapKey(const Hash& fullKey) { + return getHashPrefix(fullKey); + } + + HashPrefix getLastXferDest() const { return lastXferDest_; } + void setLastXferDest(const HashPrefix& dest) { lastXferDest_ = dest; } + + uint64_t getLastXferAmount() const { return lastXferAmount_.get(); } + void setLastXferAmount(uint64_t amount) { lastXferAmount_.set(amount); } + + uint32_t getLastXferTime() const { return lastXferTime_; } + void setLastXferTime(uint32_t time) { lastXferTime_ = time; } + + uint32_t getAliasId() const { return aliasId_; } + void setAliasId(uint32_t aliasId) { aliasId_ = aliasId; } + + const HashTail* keyTailPtr() const { return &keyTail_; } + const Int48* balancePtr() const { return &balance_; } + const uint32_t* noncePtr() const { return &nonce_; } + const HashPrefix* lastXferDestPtr() const { return &lastXferDest_; } + const UInt48* lastXferAmountPtr() const { return &lastXferAmount_; } + const uint32_t* lastXferTimePtr() const { return &lastXferTime_; } + const uint32_t* aliasIdPtr() const { return &aliasId_; } + + enum class SerMode : uint8_t { + Full = 0x00, // all fields (account creation, snapshots) + BalanceNonce = 0x01, // balance + nonce (PoW, credits, errors, bulk xfer) + None = 0x02, // erased object + Transfer = 0x03 // balance + nonce + lastXferDest + lastXferAmount + lastXferTime + }; + + CES_PERSISTED_BOILERPLATE(SerMode::BalanceNonce) + +private: + HashTail keyTail_; + Int48 balance_; + UInt48 lastXferAmount_; + HashPrefix lastXferDest_; + uint32_t lastXferTime_; + uint32_t nonce_; + uint32_t aliasId_; +}; + +} // namespace ces + +// --- Custom logkv serializer for ces::Account --- + +namespace logkv { + +template <> +struct serializer { + + using SerMode = ces::Account::SerMode; + + // Field group sizes for serialization + static constexpr size_t SZ_KEY_TAIL = sizeof(ces::HashTail); + static constexpr size_t SZ_BALANCE = 6; // 48-bit, low32 + high16 + static constexpr size_t SZ_NONCE = sizeof(uint32_t); + static constexpr size_t SZ_XFER_DEST = sizeof(ces::HashPrefix); + static constexpr size_t SZ_XFER_AMOUNT = 6; // 48-bit, low32 + high16 + static constexpr size_t SZ_XFER_TIME = sizeof(uint32_t); + static constexpr size_t SZ_ALIAS_ID = sizeof(uint32_t); + + static constexpr size_t SZ_HEADER = 1; + static constexpr size_t SZ_BALANCE_NONCE = SZ_BALANCE + SZ_NONCE; + static constexpr size_t SZ_XFER = SZ_XFER_DEST + SZ_XFER_AMOUNT + SZ_XFER_TIME; + static constexpr size_t SZ_ALL = + SZ_KEY_TAIL + SZ_BALANCE_NONCE + SZ_XFER + SZ_ALIAS_ID; + + // 48-bit codec: low 32 bits then high 16 bits, in the Writer's byte order. + static void write48(Writer& w, uint64_t v) { + w.write(static_cast(v)); + w.write(static_cast(v >> 32)); + } + static uint64_t read48(Reader& r) { + uint32_t lo; uint16_t hi; + r.read(lo); + r.read(hi); + return static_cast(lo) | (static_cast(hi) << 32); + } + + static bool is_empty(const ces::Account& obj) { + return serializer::is_empty(obj.getKeyTail()) && + obj.getBalance() == 0 && obj.getNonce() == 0; + } + + static size_t get_size(const ces::Account& obj) { + if (ces::Account::_logkvStoreSnapshot()) + return SZ_ALL; + + SerMode mode = ces::Account::_getSerMode(); + + if (is_empty(obj)) + return SZ_HEADER; + + switch (mode) { + case SerMode::Full: + return SZ_HEADER + SZ_ALL; + case SerMode::BalanceNonce: + return SZ_HEADER + SZ_BALANCE_NONCE; + case SerMode::Transfer: + return SZ_HEADER + SZ_BALANCE_NONCE + SZ_XFER; + case SerMode::None: + return SZ_HEADER; + } + return SZ_HEADER; + } + + static size_t write(char* dest, size_t size, const ces::Account& obj) { + Writer writer(dest, size); + bool isSnapshot = ces::Account::_logkvStoreSnapshot(); + + try { + if (isSnapshot) { + // Snapshot: write all fields, no header + writer.write(obj.getKeyTail()); + write48(writer, static_cast(obj.getBalance())); + writer.write(obj.getNonce()); + writer.write(obj.getLastXferDest()); + write48(writer, obj.getLastXferAmount()); + writer.write(obj.getLastXferTime()); + writer.write(obj.getAliasId()); + return writer.bytes_processed(); + } + + bool objectIsEmpty = is_empty(obj); + SerMode mode = ces::Account::_getSerMode(); + + uint8_t header; + if (objectIsEmpty) { + header = static_cast(SerMode::None); + } else { + header = static_cast(mode); + } + writer.write(header); + + if (!objectIsEmpty) { + switch (mode) { + case SerMode::Full: + writer.write(obj.getKeyTail()); + write48(writer, static_cast(obj.getBalance())); + writer.write(obj.getNonce()); + writer.write(obj.getLastXferDest()); + write48(writer, obj.getLastXferAmount()); + writer.write(obj.getLastXferTime()); + writer.write(obj.getAliasId()); + break; + case SerMode::BalanceNonce: + write48(writer, static_cast(obj.getBalance())); + writer.write(obj.getNonce()); + break; + case SerMode::Transfer: + write48(writer, static_cast(obj.getBalance())); + writer.write(obj.getNonce()); + writer.write(obj.getLastXferDest()); + write48(writer, obj.getLastXferAmount()); + writer.write(obj.getLastXferTime()); + break; + case SerMode::None: + break; + } + } + } catch (const insufficient_buffer& e) { + return writer.bytes_processed() + e.get_required_bytes(); + } + return writer.bytes_processed(); + } + + static size_t read(const char* src, size_t size, ces::Account& obj) { + Reader reader(src, size); + bool isSnapshot = ces::Account::_logkvStoreSnapshot(); + + try { + if (isSnapshot) { + ces::HashTail kt; + ces::HashPrefix xferDest; + uint32_t nonce, xferTime, aliasId; + reader.read(kt); + int64_t bal = static_cast(read48(reader) << 16) >> 16; + reader.read(nonce); + reader.read(xferDest); + uint64_t xferAmount = read48(reader); + reader.read(xferTime); + reader.read(aliasId); + obj.getKeyTail() = kt; + obj.setBalance(bal); + obj.setNonce(nonce); + obj.setLastXferDest(xferDest); + obj.setLastXferAmount(xferAmount); + obj.setLastXferTime(xferTime); + obj.setAliasId(aliasId); + return reader.bytes_processed(); + } + + uint8_t header; + reader.read(header); + + switch (static_cast(header)) { + case SerMode::None: + obj = ces::Account(); + break; + case SerMode::Full: { + ces::HashTail kt; + ces::HashPrefix xferDest; + uint32_t nonce, xferTime, aliasId; + reader.read(kt); + int64_t bal = static_cast(read48(reader) << 16) >> 16; + reader.read(nonce); + reader.read(xferDest); + uint64_t xferAmount = read48(reader); + reader.read(xferTime); + reader.read(aliasId); + obj.getKeyTail() = kt; + obj.setBalance(bal); + obj.setNonce(nonce); + obj.setLastXferDest(xferDest); + obj.setLastXferAmount(xferAmount); + obj.setLastXferTime(xferTime); + obj.setAliasId(aliasId); + break; + } + case SerMode::BalanceNonce: { + int64_t bal = static_cast(read48(reader) << 16) >> 16; + uint32_t nonce; + reader.read(nonce); + obj.setBalance(bal); + obj.setNonce(nonce); + break; + } + case SerMode::Transfer: { + ces::HashPrefix xferDest; + uint32_t nonce, xferTime; + int64_t bal = static_cast(read48(reader) << 16) >> 16; + reader.read(nonce); + reader.read(xferDest); + uint64_t xferAmount = read48(reader); + reader.read(xferTime); + obj.setBalance(bal); + obj.setNonce(nonce); + obj.setLastXferDest(xferDest); + obj.setLastXferAmount(xferAmount); + obj.setLastXferTime(xferTime); + break; + } + default: + throw std::runtime_error("Invalid Account serialization header"); + } + } catch (const insufficient_buffer& e) { + return reader.bytes_processed() + e.get_required_bytes(); + } + return reader.bytes_processed(); + } +}; + +} // namespace logkv diff --git a/vendor/ces/include/ces/accounts.h b/vendor/ces/include/ces/accounts.h new file mode 100644 index 0000000..450712f --- /dev/null +++ b/vendor/ces/include/ces/accounts.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace ces { + +class Accounts { +public: + using AccountStore = + logkv::Store; + + struct ActiveAccount { + Accounts& parent; + HashPrefix id; + AccountStore::iterator it; + + bool exists() const; + Account& data(); + const Account& data() const; + int64_t balance() const; + uint32_t nonce() const; + uint8_t validateSpend(uint64_t amount, uint64_t fee, uint32_t reqNonce, + int64_t errFee); + void debit(uint64_t totalAmount); + void debitTransfer(uint64_t totalAmount, const HashPrefix& destId, + uint64_t xferAmount); + void credit(uint64_t amount); + void settlePayment(uint64_t amount); + void chargeError(int64_t errFee); + }; + + Accounts(const std::string& dataDir, uint64_t minAcc, uint64_t flushValue, + size_t bufferSize = 1 << 19); + AccountStore& getStore() { return store_; } + AccountStore* operator->() { return &store_; } + const AccountStore* operator->() const { return &store_; } + AccountStore& operator*() { return store_; } + const AccountStore& operator*() const { return store_; } + + ActiveAccount get(const HashPrefix& id); + ActiveAccount get(const minx::Hash& key); + ActiveAccount getFirst(); + void createAccount(const HashPrefix& id, const Account& acc); + static bool checkAddOverflow(uint64_t a, uint64_t b, uint64_t& res); + void checkFlush(uint64_t amount); + + int64_t getTotalCredits() const { return totalCredits_; } + void adjustTotalCredits(int64_t delta) { totalCredits_ += delta; } + +private: + friend struct ActiveAccount; + + AccountStore store_; + uint64_t flushValue_; + uint64_t flushAccumulator_ = 0; + int64_t totalCredits_ = 0; +}; + +} // namespace ces \ No newline at end of file diff --git a/vendor/ces/include/ces/alias.h b/vendor/ces/include/ces/alias.h new file mode 100644 index 0000000..309e8aa --- /dev/null +++ b/vendor/ces/include/ces/alias.h @@ -0,0 +1,266 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace ces { + +// Alias layout, single source of truth. The flat-map entry is a 4-byte id key +// plus the value image; the image is owner | editor | op | content with zero +// padding, so every offset derives from the field sizes. Patch writes and +// windowed reads address the value image by these offsets. The op field is +// host-endian in the image (little-endian on all supported targets). +constexpr size_t ALIAS_ENTRY_BYTES = 1024; // id key + value image +constexpr size_t ALIAS_ID_BYTES = sizeof(uint32_t); +constexpr size_t ALIAS_VALUE_BYTES = ALIAS_ENTRY_BYTES - ALIAS_ID_BYTES; + +constexpr size_t ALIAS_OFF_OWNER = 0; +constexpr size_t ALIAS_OFF_EDITOR = ALIAS_OFF_OWNER + sizeof(HashPrefix); +constexpr size_t ALIAS_OFF_OP = ALIAS_OFF_EDITOR + sizeof(HashPrefix); +constexpr size_t ALIAS_OFF_CONTENT = ALIAS_OFF_OP + sizeof(uint16_t); +constexpr size_t ALIAS_CONTENT_BYTES = ALIAS_VALUE_BYTES - ALIAS_OFF_CONTENT; + +// Patch floors: the owner may write everything but the server-set owner field; +// the editor may write content only, never the header. +constexpr size_t ALIAS_PATCH_MIN_OWNER = ALIAS_OFF_EDITOR; +constexpr size_t ALIAS_PATCH_MIN_EDITOR = ALIAS_OFF_CONTENT; + +// Inline program layout: for the ALIAS_OP_INLINE_* ops the content is split +// into a fixed-size code area (always loaded whole, zero-padded, so +// SYS_LOAD_CODE bases stay link-time constants) and an 8-byte trailer +// carrying the hook refill ceiling (u64 LE; meaningful for INLINE_HOOK_WATCH, +// forced 0 for gates like the pointer form). +constexpr size_t ALIAS_INLINE_CEILING_BYTES = sizeof(uint64_t); +constexpr size_t ALIAS_INLINE_CODE_BYTES = + ALIAS_CONTENT_BYTES - ALIAS_INLINE_CEILING_BYTES; +constexpr size_t ALIAS_OFF_INLINE_CEILING = + ALIAS_OFF_CONTENT + ALIAS_INLINE_CODE_BYTES; + +using AliasData = std::array; + +// Alias op enum: the uint16 in the value, a hardcoded system operation code. +// 16 bits (65536 codes); the vocabulary grows as features land. +constexpr uint16_t ALIAS_OP_NONE = 0x0000; // raw uninterpreted bytes (default); render as hex +constexpr uint16_t ALIAS_OP_STRING = 0x0001; // UTF-8 text; render as text +// Account hooks (CESVM triggers). The op selects when the trigger runs and its +// failure mode; the content's first 32 bytes are the trigger program's asset +// key (see local/account_hooks_design.md). Set-time: the server enforces the +// target asset is IMMUTABLE or owned by the setter (CES_ERROR_HOOK_TARGET). +constexpr uint16_t ALIAS_OP_HOOK_GATE = 0x0010; // runs before the credit; may + // reject (fail-closed). v1. +constexpr uint16_t ALIAS_OP_HOOK_WATCH = 0x0011; // runs after the credit; + // observe-only (fail-open). v2. +// Inline programs: the content IS the bytecode (ALIAS_INLINE_CODE_BYTES at +// base 0 + the ceiling trailer), no trigger asset, no set-time target check. +// Code in the owner's own cell is consented code: the cell's owner is the +// run's programOwner (gates stay principal-less for purity). +constexpr uint16_t ALIAS_OP_INLINE_HOOK_GATE = 0x0012; // inline fail-closed gate +constexpr uint16_t ALIAS_OP_INLINE_HOOK_WATCH = 0x0013; // inline fail-open watch +constexpr uint16_t ALIAS_OP_INLINE_PROGRAM = 0x0014; // publicly invocable + // (CES_RUN_ALIAS / + // SYS_SCHEDULE_ALIAS); + // never deposit-fired +constexpr uint16_t ALIAS_OP_SYSTEM = 0xFFFF; // reserved: the id-generator cell (id 0) + +// True if an alias op is a POINTER account-hook (content[0..32) = trigger +// asset key; the set-time immutable-or-owned target check applies). +inline bool aliasOpIsHook(uint16_t op) { + return op == ALIAS_OP_HOOK_GATE || op == ALIAS_OP_HOOK_WATCH; +} +// True if the content is inline bytecode (any ALIAS_OP_INLINE_* op). +inline bool aliasOpIsInline(uint16_t op) { + return op == ALIAS_OP_INLINE_HOOK_GATE || op == ALIAS_OP_INLINE_HOOK_WATCH || + op == ALIAS_OP_INLINE_PROGRAM; +} +// Hook class matchers across pointer and inline forms. +inline bool aliasOpIsGate(uint16_t op) { + return op == ALIAS_OP_HOOK_GATE || op == ALIAS_OP_INLINE_HOOK_GATE; +} +inline bool aliasOpIsWatch(uint16_t op) { + return op == ALIAS_OP_HOOK_WATCH || op == ALIAS_OP_INLINE_HOOK_WATCH; +} + +/** + * Alias: a server-allocated, ID-keyed sidecar bound to one account. + * The boost flat-map entry is pair: a 4-byte id key and + * a 1020-byte value, 1024 bytes total with zero padding anywhere. The layout + * is pinned in tests/test_alias_layout.cpp. + * + * - owner: HashPrefix, the owning account (server-set at create, not + * forgeable; may never be patched). + * - editor: HashPrefix, an account granted content-only write access + * (all-zero = no grant). Owner-set via patch. + * - op: uint16_t, the system operation enum (hardcoded). Owner-set. + * - content: ALIAS_CONTENT_BYTES of payload whose meaning is defined by op. + * + * No per-cell balance: an alias is funded by its owner account (daily rent at + * the feeAlias rate). One alias per account; the account holds its id in + * aliasId. + */ +struct Alias { + + Alias() : owner_{}, editor_{}, op_(0), content_{} {} + + Alias(HashPrefix owner, HashPrefix editor, uint16_t op, + const AliasData& content) + : owner_(owner), editor_(editor), op_(op), content_(content) {} + + void setOwner(HashPrefix owner) { owner_ = owner; } + void setOwner(const Hash& ownerFullKey) { + owner_ = getHashPrefix(ownerFullKey); + } + HashPrefix getOwner() const { return owner_; } + + void setEditor(HashPrefix editor) { editor_ = editor; } + HashPrefix getEditor() const { return editor_; } + + void setOp(uint16_t op) { op_ = op; } + uint16_t getOp() const { return op_; } + + void setContent(const AliasData& content) { content_ = content; } + const AliasData& getContent() const { return content_; } + AliasData& accessContent() { return content_; } + + // Raw value image (owner|editor|op|content) for patch writes and windowed + // reads. Valid because the layout is standard, trivially copyable, and + // padding-free (static_asserts below; pinned in tests/test_alias_layout.cpp). + uint8_t* imageData() { return reinterpret_cast(this); } + const uint8_t* imageData() const { + return reinterpret_cast(this); + } + + const HashPrefix* ownerPtr() const { return &owner_; } + const HashPrefix* editorPtr() const { return &editor_; } + const uint16_t* opPtr() const { return &op_; } + const AliasData* contentPtr() const { return &content_; } + + enum class SerMode : uint8_t { + Full = 0x00, // owner + editor + op + content (set, snapshots) + None = 0x01 // erased object + }; + + CES_PERSISTED_BOILERPLATE(SerMode::Full) + +private: + HashPrefix owner_; + HashPrefix editor_; + uint16_t op_; + AliasData content_; +}; + +static_assert(sizeof(Alias) == ALIAS_VALUE_BYTES); +static_assert(std::is_standard_layout_v); +static_assert(std::is_trivially_copyable_v); + +} // namespace ces + +// --- Custom logkv serializer for ces::Alias --- + +namespace logkv { + +template <> +struct serializer { + + using SerMode = ces::Alias::SerMode; + + static constexpr size_t SZ_OWNER = sizeof(ces::HashPrefix); + static constexpr size_t SZ_EDITOR = sizeof(ces::HashPrefix); + static constexpr size_t SZ_OP = sizeof(uint16_t); + static constexpr size_t SZ_CONTENT = sizeof(ces::AliasData); + + static constexpr size_t SZ_HEADER = 1; + static constexpr size_t SZ_ALL = SZ_OWNER + SZ_EDITOR + SZ_OP + SZ_CONTENT; + + // An alias is empty (erased) only when owner and op are both zero. Entry 0 + // (the id generator) carries a non-zero system op, so it is never empty. + static bool is_empty(const ces::Alias& obj) { + return obj.getOwner() == ces::HashPrefix{} && obj.getOp() == 0; + } + + static size_t get_size(const ces::Alias& obj) { + if (ces::Alias::_logkvStoreSnapshot()) + return SZ_ALL; + if (is_empty(obj)) + return SZ_HEADER; + return SZ_HEADER + SZ_ALL; + } + + static size_t write(char* dest, size_t size, const ces::Alias& obj) { + Writer writer(dest, size); + try { + if (ces::Alias::_logkvStoreSnapshot()) { + writer.write(obj.getOwner()); + writer.write(obj.getEditor()); + writer.write(obj.getOp()); + writer.write(obj.getContent()); + return writer.bytes_processed(); + } + + bool objectIsEmpty = is_empty(obj); + uint8_t header = objectIsEmpty + ? static_cast(SerMode::None) + : static_cast(SerMode::Full); + writer.write(header); + + if (!objectIsEmpty) { + writer.write(obj.getOwner()); + writer.write(obj.getEditor()); + writer.write(obj.getOp()); + writer.write(obj.getContent()); + } + } catch (const insufficient_buffer& e) { + return writer.bytes_processed() + e.get_required_bytes(); + } + return writer.bytes_processed(); + } + + static size_t read(const char* src, size_t size, ces::Alias& obj) { + Reader reader(src, size); + try { + if (ces::Alias::_logkvStoreSnapshot()) { + ces::HashPrefix owner; + ces::HashPrefix editor; + uint16_t op; + ces::AliasData content; + reader.read(owner); + reader.read(editor); + reader.read(op); + reader.read(content); + obj = ces::Alias(owner, editor, op, content); + return reader.bytes_processed(); + } + + uint8_t header; + reader.read(header); + + switch (static_cast(header)) { + case SerMode::None: + obj = ces::Alias(); + break; + case SerMode::Full: { + ces::HashPrefix owner; + ces::HashPrefix editor; + uint16_t op; + ces::AliasData content; + reader.read(owner); + reader.read(editor); + reader.read(op); + reader.read(content); + obj = ces::Alias(owner, editor, op, content); + break; + } + default: + throw std::runtime_error("Invalid Alias serialization header"); + } + } catch (const insufficient_buffer& e) { + return reader.bytes_processed() + e.get_required_bytes(); + } + return reader.bytes_processed(); + } +}; + +} // namespace logkv diff --git a/vendor/ces/include/ces/aliases.h b/vendor/ces/include/ces/aliases.h new file mode 100644 index 0000000..c71b5e9 --- /dev/null +++ b/vendor/ces/include/ces/aliases.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace ces { + +class Aliases { +public: + using AliasStore = logkv::Store; + + struct ActiveAlias { + Aliases& parent; + uint32_t id; + AliasStore::iterator it; + + bool exists() const; + Alias& data(); + const Alias& data() const; + + HashPrefix getOwner() const; + HashPrefix getEditor() const; + uint16_t getOp() const; + const AliasData& getContent() const; + + void setOwner(const HashPrefix& owner); + void setOp(uint16_t op); + void setContent(const AliasData& content); + void updateValue(const Alias& value); + + private: + template + void persistWithMode(Alias::SerMode mode, Mutator&& mutate) { + if (!exists()) + return; + mutate(data()); + Alias::SerModeGuard guard(mode); + parent.store_.persist(it); + } + }; + + Aliases(const std::string& dataDir, uint64_t minAlias, uint64_t flushValue, + size_t bufferSize = 1 << 19); + + AliasStore& getStore() { return store_; } + AliasStore* operator->() { return &store_; } + const AliasStore* operator->() const { return &store_; } + AliasStore& operator*() { return store_; } + const AliasStore& operator*() const { return store_; } + + ActiveAlias get(uint32_t id); + ActiveAlias getFirst(); + + void checkFlush(uint64_t amount); + +private: + friend struct ActiveAlias; + + AliasStore store_; + uint64_t flushValue_; + uint64_t flushAccumulator_ = 0; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/asset.h b/vendor/ces/include/ces/asset.h new file mode 100644 index 0000000..5c1bcb4 --- /dev/null +++ b/vendor/ces/include/ces/asset.h @@ -0,0 +1,236 @@ +#pragma once + +#include +#include +#include + +namespace ces { + +using AssetData = std::array; + +/** + * Asset: a 256-byte memory cell for rent, indexed by an application-defined + * 32-byte key. The 256 bytes are the boost flat-map entry pair: a 32-byte key and this 224-byte value, packed with zero + * padding. content (210 bytes) fills the row after the key and the 14 bytes of + * metadata. The layout is pinned in tests/test_asset_layout.cpp. + * + * Metadata fields: + * - HashPrefix owner + * - uint16_t balance (bit-packed prepaid-days counter plus flag bits; + * see assetDays() / isAsset* in types.h) + * - uint32_t price (whole credits; multiply by PRICE_UNIT for internal units) + * + * content carries the AssetData payload. + */ +struct Asset { + + Asset() : ownerId_{}, content_{}, balance_(0), price_(0) {} + + Asset(HashPrefix ownerId, const AssetData& content, uint16_t balance, + uint32_t price) + : ownerId_(ownerId), content_(content), balance_(balance), price_(price) {} + + void setOwnerId(HashPrefix ownerId) { ownerId_ = ownerId; } + void setOwnerId(const Hash& ownerFullKey) { + ownerId_ = getHashPrefix(ownerFullKey); + } + HashPrefix getOwnerId() const { return ownerId_; } + + void setPrice(uint32_t price) { price_ = price; } + uint32_t getPrice() const { return price_; } + + void setBalance(uint16_t balance) { balance_ = balance; } + uint16_t getBalance() const { return balance_; } + + void setContent(const AssetData& content) { content_ = content; } + const AssetData& getContent() const { return content_; } + AssetData& accessContent() { return content_; } + + const HashPrefix* ownerIdPtr() const { return &ownerId_; } + const AssetData* contentPtr() const { return &content_; } + const uint16_t* balancePtr() const { return &balance_; } + const uint32_t* pricePtr() const { return &price_; } + + enum class SerMode : uint8_t { + Full = 0x00, // all fields (create, full update, snapshots) + Content = 0x01, // content only (NOTE: intentionally unused) + Meta = 0x02, // ownerId + price (updateAssetMeta, buy, give) + Balance = 0x03, // balance only (fund, daily maintenance) + None = 0x04 // erased object + }; + + CES_PERSISTED_BOILERPLATE(SerMode::Full) + +private: + HashPrefix ownerId_; + AssetData content_; + uint16_t balance_; + uint32_t price_; +}; + +} // namespace ces + +// --- Custom logkv serializer for ces::Asset --- + +namespace logkv { + +template <> +struct serializer { + + using SerMode = ces::Asset::SerMode; + + static constexpr size_t SZ_OWNER = sizeof(ces::HashPrefix); + static constexpr size_t SZ_CONTENT = sizeof(ces::AssetData); + static constexpr size_t SZ_BALANCE = sizeof(uint16_t); + static constexpr size_t SZ_PRICE = sizeof(uint32_t); + + static constexpr size_t SZ_HEADER = 1; + static constexpr size_t SZ_META = SZ_OWNER + SZ_PRICE; + static constexpr size_t SZ_ALL = SZ_OWNER + SZ_CONTENT + SZ_BALANCE + SZ_PRICE; + + static bool is_empty(const ces::Asset& obj) { + return ces::assetDays(obj.getBalance()) == 0 && obj.getPrice() == 0 && + obj.getOwnerId() == ces::HashPrefix{}; + } + + static size_t get_size(const ces::Asset& obj) { + if (ces::Asset::_logkvStoreSnapshot()) + return SZ_ALL; + + SerMode mode = ces::Asset::_getSerMode(); + + if (is_empty(obj)) + return SZ_HEADER; + + switch (mode) { + case SerMode::Full: + return SZ_HEADER + SZ_ALL; + case SerMode::Content: + return SZ_HEADER + SZ_CONTENT; + case SerMode::Meta: + return SZ_HEADER + SZ_META; + case SerMode::Balance: + return SZ_HEADER + SZ_BALANCE; + case SerMode::None: + return SZ_HEADER; + } + return SZ_HEADER; + } + + static size_t write(char* dest, size_t size, const ces::Asset& obj) { + Writer writer(dest, size); + + try { + if (ces::Asset::_logkvStoreSnapshot()) { + writer.write(obj.getOwnerId()); + writer.write(obj.getBalance()); + writer.write(obj.getPrice()); + writer.write(obj.getContent()); + return writer.bytes_processed(); + } + + bool objectIsEmpty = is_empty(obj); + SerMode mode = ces::Asset::_getSerMode(); + + uint8_t header = objectIsEmpty + ? static_cast(SerMode::None) + : static_cast(mode); + writer.write(header); + + if (!objectIsEmpty) { + switch (mode) { + case SerMode::Full: + writer.write(obj.getOwnerId()); + writer.write(obj.getBalance()); + writer.write(obj.getPrice()); + writer.write(obj.getContent()); + break; + case SerMode::Content: + writer.write(obj.getContent()); + break; + case SerMode::Meta: + writer.write(obj.getOwnerId()); + writer.write(obj.getPrice()); + break; + case SerMode::Balance: + writer.write(obj.getBalance()); + break; + case SerMode::None: + break; + } + } + } catch (const insufficient_buffer& e) { + return writer.bytes_processed() + e.get_required_bytes(); + } + return writer.bytes_processed(); + } + + static size_t read(const char* src, size_t size, ces::Asset& obj) { + Reader reader(src, size); + + try { + if (ces::Asset::_logkvStoreSnapshot()) { + ces::HashPrefix owner; + uint16_t balance; + uint32_t price; + ces::AssetData content; + reader.read(owner); + reader.read(balance); + reader.read(price); + reader.read(content); + obj = ces::Asset(owner, content, balance, price); + return reader.bytes_processed(); + } + + uint8_t header; + reader.read(header); + + switch (static_cast(header)) { + case SerMode::None: + obj = ces::Asset(); + break; + case SerMode::Full: { + ces::HashPrefix owner; + uint16_t balance; + uint32_t price; + ces::AssetData content; + reader.read(owner); + reader.read(balance); + reader.read(price); + reader.read(content); + obj = ces::Asset(owner, content, balance, price); + break; + } + case SerMode::Content: { + ces::AssetData content; + reader.read(content); + obj.setContent(content); + break; + } + case SerMode::Meta: { + ces::HashPrefix owner; + uint32_t price; + reader.read(owner); + reader.read(price); + obj.setOwnerId(owner); + obj.setPrice(price); + break; + } + case SerMode::Balance: { + uint16_t balance; + reader.read(balance); + obj.setBalance(balance); + break; + } + default: + throw std::runtime_error("Invalid Asset serialization header"); + } + } catch (const insufficient_buffer& e) { + return reader.bytes_processed() + e.get_required_bytes(); + } + return reader.bytes_processed(); + } +}; + +} // namespace logkv diff --git a/vendor/ces/include/ces/assets.h b/vendor/ces/include/ces/assets.h new file mode 100644 index 0000000..2ccd1bf --- /dev/null +++ b/vendor/ces/include/ces/assets.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace ces { + +class Assets { +public: + using AssetStore = logkv::Store; + + struct ActiveAsset { + Assets& parent; + minx::Hash id; + AssetStore::iterator it; + + bool exists() const; + Asset& data(); + const Asset& data() const; + + HashPrefix getOwnerId() const; + uint32_t getPrice() const; + uint16_t getBalance() const; + const AssetData& getContent() const; + + void setContent(const AssetData& content); + void setPrice(uint32_t price); + void setOwner(const HashPrefix& newOwner); + void setBalance(uint16_t newBalance); + + void updateFull(const HashPrefix& newOwner, const AssetData& content, + uint32_t price); + void transferOwnership(const HashPrefix& newOwner); + + private: + template + void persistWithMode(Asset::SerMode mode, Mutator&& mutate) { + if (!exists()) + return; + mutate(data()); + Asset::SerModeGuard guard(mode); + parent.store_.persist(it); + } + }; + + Assets(const std::string& dataDir, uint64_t minAsset, uint64_t flushValue, + size_t bufferSize = 1 << 19); + + AssetStore& getStore() { return store_; } + AssetStore* operator->() { return &store_; } + const AssetStore* operator->() const { return &store_; } + AssetStore& operator*() { return store_; } + const AssetStore& operator*() const { return store_; } + + ActiveAsset get(const minx::Hash& assetId); + ActiveAsset getFirst(); + + void checkFlush(uint64_t amount); + +private: + friend struct ActiveAsset; + + AssetStore store_; + uint64_t flushValue_; + uint64_t flushAccumulator_ = 0; +}; + +} // namespace ces \ No newline at end of file diff --git a/vendor/ces/include/ces/autoexec.h b/vendor/ces/include/ces/autoexec.h new file mode 100644 index 0000000..41d733d --- /dev/null +++ b/vendor/ces/include/ces/autoexec.h @@ -0,0 +1,67 @@ +#pragma once + +/** + * Autoexec — utilities for creating boot-time runAsset assets. + * + * An autoexec asset has a recognizable key pattern and contains a + * signed CesRunAsset packet. The server scans for these on boot + * and executes them. + * + * Key layout: [8 zero bytes][8 AUTOEXEC_KEY_MAGIC BE][8 account prefix][8 random] + * Content: [2 byte BE packet length][signed CesRunAsset packet bytes] + */ + +#include +#include +#include +#include +#include + +#include +#include + +namespace ces { + +/// Build an autoexec asset key for the given account. +inline minx::Hash buildAutoexecKey(const HashPrefix& accountPrefix) { + minx::Hash key{}; + // bytes 0-7: zeros (already) + ces::Buffer::poke(&key[8], CesConfig::AUTOEXEC_KEY_MAGIC); + std::memcpy(&key[16], accountPrefix.data(), 8); + // bytes 24-31: random (LE — read from key purely as opaque bytes) + std::mt19937_64 rng(std::random_device{}()); + ces::Buffer::pokeLE(&key[24], rng()); + return key; +} + +/// Build autoexec asset content from a program asset key and budget. +/// Signs a nonceless CesRunAsset packet with the given key pair. Returns +/// nullopt when the signed packet does not fit the asset content slot. +inline std::optional buildAutoexecContent( + const minx::Hash& programAssetId, uint64_t budget, const ces::Bytes& input, + KeyPair& keyPair, const HashPrefix& serverId) { + CesRunAsset req; + req.originId = keyPair.getPublicKeyAsHash(); + req.serverId = serverId; + req.reqNonce = CES_NONCELESS; + req.assetId = programAssetId; + req.budget = budget; + req.time = getMicrosSinceEpoch(); + req.input = input; + minx::Bytes packetBytes; + try { + packetBytes = req.toBytes(keyPair); + } catch (const std::exception&) { + return std::nullopt; // input too large to fit a signed packet + } + + AssetData content{}; + if (packetBytes.size() > content.size() - 2) + return std::nullopt; + ces::Buffer::poke(content.data(), + static_cast(packetBytes.size())); + std::memcpy(&content[2], packetBytes.data(), packetBytes.size()); + return content; +} + +} // namespace ces diff --git a/vendor/ces/include/ces/buffer.h b/vendor/ces/include/ces/buffer.h new file mode 100644 index 0000000..80920c5 --- /dev/null +++ b/vendor/ces/include/ces/buffer.h @@ -0,0 +1,373 @@ +#pragma once + +/** + * Vector-owning byte buffer for CES wire data. Auto-grows on put; + * tracks separate read and write cursors so the same Buffer can be a + * builder (sequence of puts) or a parser (sequence of gets). + * + * BE serialization delegates to logkv::serializer; the LE family + * uses boost::endian directly (CES wire format is BE except for VM + * bytecode operands and a few proxy framing fields). + * + * The static helpers (Buffer::put, Buffer::peek, ...) operate on raw + * pointers or external Bytes/minx::Bytes for sites that don't own a + * Buffer instance. + */ + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ces { + +class Buffer { +public: + // ---- Construction ---- + + Buffer() = default; + + // Pre-allocate `n` bytes (sized but uninitialized data is fine — + // every put/poke writes before reading). + explicit Buffer(size_t n) : v_(n) {} + + // Take ownership of an existing Bytes. Cursors start at 0 / 0; + // useful for parsing (then call get) or for chaining-build. + explicit Buffer(Bytes v) : v_(std::move(v)) {} + + // ---- Storage access ---- + + Bytes& vec() noexcept { return v_; } + const Bytes& vec() const noexcept { return v_; } + + uint8_t* data() noexcept { return v_.data(); } + const uint8_t* data() const noexcept { return v_.data(); } + size_t size() const noexcept { return v_.size(); } + bool empty() const noexcept { return v_.empty(); } + + // Move the underlying Bytes out. Use for + // `return std::move(buf).take();` at end-of-build sites. + Bytes take() && noexcept { + return std::move(v_); + } + + // ---- Cursors ---- + + size_t writePos() const noexcept { return wPos_; } + size_t readPos() const noexcept { return rPos_; } + void setWritePos(size_t p) noexcept { wPos_ = p; } + void setReadPos (size_t p) noexcept { rPos_ = p; } + + // Bytes left between the read cursor and end-of-data. + size_t remaining() const noexcept { + return v_.size() > rPos_ ? v_.size() - rPos_ : 0; + } + + // ---- Resize / clear ---- + + void resize(size_t n) { v_.resize(n); } + void reserve(size_t n) { v_.reserve(n); } + void clear() noexcept { v_.clear(); wPos_ = 0; rPos_ = 0; } + + // ---- BE put — auto-grow ---- + + // Append a typed value at the write cursor; advance cursor by the + // serialized size. Grows the underlying vector if needed. + // Integers go BE; std::array / std::span + // / minx::Hash / ces::PublicKey / ces::Signature go raw via their + // logkv::serializer specializations. + template + Buffer& put(const T& val) { + const size_t needed = logkv::serializer::get_size(val); + if (wPos_ + needed > v_.size()) v_.resize(wPos_ + needed); + logkv::serializer::write( + reinterpret_cast(v_.data() + wPos_), needed, val); + wPos_ += needed; + return *this; + } + + // Convenience: append raw bytes (equivalent to put(span) + // but doesn't require the explicit span construction). + Buffer& putBytes(std::span bytes) { + if (wPos_ + bytes.size() > v_.size()) v_.resize(wPos_ + bytes.size()); + if (!bytes.empty()) { + std::memcpy(v_.data() + wPos_, bytes.data(), bytes.size()); + } + wPos_ += bytes.size(); + return *this; + } + Buffer& putBytes(std::string_view s) { + return putBytes(std::span( + reinterpret_cast(s.data()), s.size())); + } + + // ---- BE get — advance read cursor; throws on short read ---- + + template + T get() { + T out{}; + const size_t available = (v_.size() > rPos_) ? (v_.size() - rPos_) : 0; + const size_t consumed = logkv::serializer::read( + reinterpret_cast(v_.data() + rPos_), + available, out); + if (consumed > available) + throw std::out_of_range("ces::Buffer::get: short read"); + rPos_ += consumed; + return out; + } + + template + Buffer& get(T& out) { + const size_t available = (v_.size() > rPos_) ? (v_.size() - rPos_) : 0; + const size_t consumed = logkv::serializer::read( + reinterpret_cast(v_.data() + rPos_), + available, out); + if (consumed > available) + throw std::out_of_range("ces::Buffer::get: short read"); + rPos_ += consumed; + return *this; + } + + // ---- get raw byte ranges — advance read cursor ---- + // + // The read counterpart to putBytes. getBytesSpan is zero-copy (the span + // points into this Buffer — consume before any further mutation); + // getBytes copies into a fresh R. Both throw on short read, so parsers + // guard with remaining() first. Mirrors minx::Buffer::getBytesSpan/getBytes. + + std::span getBytesSpan(size_t n) { + if (n > remaining()) + throw std::out_of_range("ces::Buffer::getBytesSpan: short read"); + std::span result(v_.data() + rPos_, n); + rPos_ += n; + return result; + } + + template + R getBytes(size_t n) { + if (n > remaining()) + throw std::out_of_range("ces::Buffer::getBytes: short read"); + R result; + result.resize(n); + if (n > 0) { + std::memcpy(result.data(), v_.data() + rPos_, n); + rPos_ += n; + } + return result; + } + + // ---- BE peek / poke (no cursor change) ---- + + // Read at a specific offset without advancing the cursor. + // Caller is responsible for offset + sizeof(T) being in bounds. + template + T peek(size_t offset) const { + T out{}; + const size_t available = (v_.size() > offset) ? (v_.size() - offset) : 0; + logkv::serializer::read( + reinterpret_cast(v_.data() + offset), + available, out); + return out; + } + + // Write at a specific offset without disturbing the write cursor. + // Useful for patching header fields after the body length is known. + template + Buffer& poke(size_t offset, const T& val) { + if (offset + sizeof(T) > v_.size()) v_.resize(offset + sizeof(T)); + logkv::serializer::write( + reinterpret_cast(v_.data() + offset), sizeof(T), val); + return *this; + } + + // ---- LE family ---- + + // CES wire format is BE except for VM bytecode operands and a few + // proxy framing fields, which are little-endian. logkv::serializer + // is BE-only; the LE methods here use boost::endian directly. + + template + Buffer& putLE(T val) { + if (wPos_ + sizeof(T) > v_.size()) v_.resize(wPos_ + sizeof(T)); + pokeLE(v_.data() + wPos_, val); + wPos_ += sizeof(T); + return *this; + } + + template + T peekLE(size_t offset) const { + return peekLE(v_.data() + offset); + } + + template + Buffer& pokeLE(size_t offset, T val) { + if (offset + sizeof(T) > v_.size()) v_.resize(offset + sizeof(T)); + pokeLE(v_.data() + offset, val); + return *this; + } + + // ---- Static raw-pointer helpers ---- + // + // For sites that operate on an already-allocated byte region (e.g. + // the result of a fixed-offset memcpy, an existing std::array, + // or an external vector that ces::Buffer wouldn't own). Read at a + // pointer / write at a pointer / append into an external vector — + // all delegating to the same logkv::serializer backend that the + // instance methods use. No bounds checks; caller guarantees space. + + template + static T peek(const uint8_t* ptr) { + T out{}; + logkv::serializer::read(reinterpret_cast(ptr), + sizeof(T), out); + return out; + } + + template + static T peek(std::span data, size_t offset) { + T out{}; + const size_t available = (data.size() > offset) ? (data.size() - offset) : 0; + logkv::serializer::read( + reinterpret_cast(data.data() + offset), + available, out); + return out; + } + + template + static void poke(uint8_t* ptr, const T& val) { + logkv::serializer::write(reinterpret_cast(ptr), + sizeof(T), val); + } + + template + static T peekLE(const uint8_t* ptr) { + static_assert(std::is_integral_v, + "peekLE is for integers only"); + T le{}; + std::memcpy(&le, ptr, sizeof(T)); + return boost::endian::little_to_native(le); + } + + template + static T peekLE(std::span data, size_t offset) { + return peekLE(data.data() + offset); + } + + template + static void pokeLE(uint8_t* ptr, T val) { + static_assert(std::is_integral_v, + "pokeLE is for integers only"); + T le = boost::endian::native_to_little(val); + std::memcpy(ptr, &le, sizeof(T)); + } + + // Append a typed value at the end of an EXTERNAL ces::Bytes + // (i.e. when the caller doesn't want to own a ces::Buffer at the + // call site — typical for incremental builders working over their + // own pre-existing vector). Same byte order rules as the instance + // put: BE for ints, raw for arrays/spans/PublicKey/Hash/etc. + template + static void put(ces::Bytes& v, const T& val) { + const size_t before = v.size(); + const size_t needed = logkv::serializer::get_size(val); + v.resize(before + needed); + logkv::serializer::write( + reinterpret_cast(v.data() + before), needed, val); + } + + template + static void putLE(ces::Bytes& v, T val) { + static_assert(std::is_integral_v, + "putLE is for integers only"); + const size_t before = v.size(); + v.resize(before + sizeof(T)); + T le = boost::endian::native_to_little(val); + std::memcpy(v.data() + before, &le, sizeof(T)); + } + + // Runtime-width LE: append exactly `byteCount` low bytes of `val`, + // 1..sizeof(T). For VM bytecode operand emission where the byte + // count is decided by the value's magnitude, not by sizeof(T). + template + static void putLE(ces::Bytes& v, T val, uint8_t byteCount) { + static_assert(std::is_integral_v, + "putLE is for integers only"); + if (byteCount < 1 || byteCount > sizeof(T)) + throw std::invalid_argument( + "ces::Buffer::putLE: byteCount out of range [1, sizeof(T)]"); + const size_t before = v.size(); + v.resize(before + byteCount); + T le = boost::endian::native_to_little(val); + std::memcpy(v.data() + before, &le, byteCount); + } + + static void putBytes(ces::Bytes& v, + std::span bytes) { + v.insert(v.end(), bytes.begin(), bytes.end()); + } + static void putBytes(ces::Bytes& v, const std::string& s) { + v.insert(v.end(), + reinterpret_cast(s.data()), + reinterpret_cast(s.data() + s.size())); + } + + // ---- minx::Bytes overloads ---- + // Same statics, but for minx::Bytes (boost::container::static_vector + // ). Lets put/putLE/putBytes work uniformly across + // the heap and the MTU-bounded byte containers. + + template + static void put(minx::Bytes& v, const T& val) { + const size_t before = v.size(); + const size_t needed = logkv::serializer::get_size(val); + v.resize(before + needed); + logkv::serializer::write(v.data() + before, needed, val); + } + + template + static void putLE(minx::Bytes& v, T val) { + static_assert(std::is_integral_v, + "putLE is for integers only"); + const size_t before = v.size(); + v.resize(before + sizeof(T)); + T le = boost::endian::native_to_little(val); + std::memcpy(v.data() + before, &le, sizeof(T)); + } + + static void putBytes(minx::Bytes& v, std::span bytes) { + v.insert(v.end(), + reinterpret_cast(bytes.data()), + reinterpret_cast(bytes.data() + bytes.size())); + } + static void putBytes(minx::Bytes& v, const std::string& s) { + v.insert(v.end(), s.data(), s.data() + s.size()); + } + + // ---- Asio integration ---- + + boost::asio::const_buffer asioConstBuffer() const noexcept { + return boost::asio::buffer(v_); + } + boost::asio::mutable_buffer asioMutableBuffer() noexcept { + return boost::asio::buffer(v_); + } + +private: + Bytes v_; + size_t wPos_ = 0; + size_t rPos_ = 0; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/cesco.h b/vendor/ces/include/ces/cesco.h new file mode 100644 index 0000000..4e659fa --- /dev/null +++ b/vendor/ces/include/ces/cesco.h @@ -0,0 +1,86 @@ +#pragma once + +/** + * Cesco — CES Console + * + * A Unix domain socket server that provides a bidirectional byte stream + * per connection with a replaceable interpreter. The default interpreter + * is a line-based REPL with built-in commands. + * + * Enable in server config: + * admin_socket = "./admin.sock" + * + * Connect: + * rlwrap socat - UNIX-CONNECT:./admin.sock + * socat READLINE UNIX-CONNECT:./admin.sock (if socat has readline support) + * + * Architecture: + * - The socket layer reads/writes raw bytes. + * - Each connection has its own Interpreter. + * - The default interpreter line-buffers input and dispatches commands. + * - A future interpreter (e.g. Lua) can replace the default and take + * full control of the byte stream, including terminal escape sequences. + */ + +#include +#include +#include +#include +#include +#include + +namespace ces { + +class CesServer; + +class CescoSession : public std::enable_shared_from_this { +public: + using Socket = boost::asio::local::stream_protocol::socket; + using SendFn = std::function; + + // Interpreter: processes raw input bytes. Default is the builtin REPL. + // Can be replaced at runtime (e.g. by a "load lua" command). + using Interpreter = std::function; + + CescoSession(Socket socket, CesServer& server); + void start(); + void send(const std::string& data); + +private: + void doRead(); + void doWrite(); + void enqueue(const std::string& data); + void builtinInterpreter(const uint8_t* data, size_t len); + std::string dispatchCommand(const std::string& line); + + Socket socket_; + CesServer& server_; + Interpreter interpreter_; + std::string lineBuffer_; + std::array readBuf_; + std::deque writeQueue_; + bool writing_ = false; + bool closing_ = false; +}; + +class Cesco { +public: + Cesco(boost::asio::io_context& io, CesServer& server); + ~Cesco(); + + // Start listening. Returns true on success. + bool listen(const std::string& socketPath); + + // Stop accepting and close all sessions. + void stop(); + +private: + void doAccept(); + + boost::asio::io_context& io_; + CesServer& server_; + std::unique_ptr acceptor_; + std::string socketPath_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/cesplex/endpoint.h b/vendor/ces/include/ces/cesplex/endpoint.h new file mode 100644 index 0000000..ab638e8 --- /dev/null +++ b/vendor/ces/include/ces/cesplex/endpoint.h @@ -0,0 +1,125 @@ +// endpoint.h — CesPlexEndpoint: a self-contained CesPlex host on a +// dedicated UDP port. +// +// Owns its own Minx + Rudp + two io threads + a Rudp tick + a ChannelMeter +// + a CesPlex, and serves the given protocol mounts. Construct to start +// listening; destroy to tear down (in the safe order: CesPlex → meter → +// Rudp → Minx, after both threads have joined). +// +// Use this when a process wants to host a CesPlex on a port of its OWN — +// e.g. the cesluajitd compute child hosting /ces/luarpc/1. CesServer does +// NOT use this: its rpc port is a shared, multi-tenant strand that also +// carries SYS_RPC outbound, with its own bespoke bring-up. +// +// Threading: all CesPlex / Rudp / handler work runs on the endpoint's +// single task strand (an internal io_context with one thread); a separate +// net thread runs the UDP socket I/O. The caller must keep `host` alive +// for the endpoint's whole lifetime. + +#pragma once + +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { + +class CesPlexEndpoint { +public: + // `port` — UDP port to bind (0 = OS-assigned; read back via boundPort()). + // `host` — signs bind replies + sinks measured usage; its signing key + // also becomes the Minx server key. Must outlive the endpoint. + // `mounts` — protocol-name → handler object, mounted into the CesPlex at + // construction. Each handler must outlive the endpoint. + // `minxCfg` / `rudpCfg` — fully specify the MINX + RUDP posture (the caller + // owns those decisions: anti-spam, pacing, buffer sizes). The + // server key is set internally from `host`. + // `meterTick` — ChannelMeter cadence. + CesPlexEndpoint(uint16_t port, + CesPlexHost* host, + std::map mounts, + minx::MinxConfig minxCfg, + minx::RudpConfig rudpCfg, + std::chrono::seconds meterTick = std::chrono::seconds(60)); + ~CesPlexEndpoint(); + + CesPlexEndpoint(const CesPlexEndpoint&) = delete; + CesPlexEndpoint& operator=(const CesPlexEndpoint&) = delete; + + // The actually-bound port (the requested port, or the OS pick if 0). 0 + // means the socket failed to open and the endpoint is inert. + uint16_t boundPort() const { return boundPort_; } + bool listening() const { return boundPort_ != 0; } + + // True if any mount resolved to a registered handler. If false, every + // inbound bind NACKs (nothing to accept) — the endpoint still listens. + bool hasAnyBinding() const; + + // The endpoint's task strand. The owner (e.g. the lua host) posts onto it + // to drive outbound work — conn writes/closes, dialing out — on the same + // single thread the CesPlex + Rudp already run on, so no extra locking is + // needed around per-channel state. + boost::asio::io_context& io() { return taskIO_; } + + // The endpoint's Rudp, for opening OUTBOUND channels on the same socket + // (e.g. dialing /ces/luarpc/1 from a lua program). Null if the socket + // failed to open. Touch only on the io() strand. + minx::Rudp* rudp() { return rudp_.get(); } + + // The endpoint's ChannelMeter, for registering OUTBOUND channels so they are + // metered like the inbound ones (which are auto-tracked on bind). Null if the + // endpoint failed to start. Touch only on the io() strand. + ChannelMeter* meter() { return meter_.get(); } + +private: + // No-op MinxListener: the endpoint's Minx carries only the Rudp + // extension lane, never CES signed-op traffic. + struct MinxNoopListener : minx::MinxListener {}; + + // Rudp::Listener: onSend → minx->sendExtension, onAccept → cesplex. + class RudpListener : public minx::Rudp::Listener { + public: + explicit RudpListener(CesPlexEndpoint* ep) : ep_(ep) {} + void onSend(const minx::SockAddr& peer, const minx::Bytes& bytes) override; + std::shared_ptr onAccept( + const minx::SockAddr& peer, uint32_t channelId) override; + private: + CesPlexEndpoint* ep_; + }; + + void scheduleTick(); + + CesPlexHost* host_; + MinxNoopListener minxListener_; + RudpListener rudpListener_{this}; + + // Declared before the owned objects so it outlives them; the destructor + // body does the real teardown in the correct order regardless. + boost::asio::io_context netIO_; + boost::asio::io_context taskIO_; + std::thread netThread_; + std::thread taskThread_; + + std::unique_ptr minx_; + std::unique_ptr rudp_; + std::unique_ptr meter_; + std::unique_ptr cesplex_; + std::shared_ptr tickTimer_; + std::atomic running_{true}; + uint16_t boundPort_ = 0; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/cesplex/meter.h b/vendor/ces/include/ces/cesplex/meter.h new file mode 100644 index 0000000..6b89e50 --- /dev/null +++ b/vendor/ces/include/ces/cesplex/meter.h @@ -0,0 +1,140 @@ +// meter.h — per-channel RUDP resource meter (ChannelMeter) +// +// A 60s tick walks tracked channels and measures each one's resource +// deltas, then reports them to the host (CesPlexHost::cesplexReportUsage). +// The meter does NOT price, charge, or evict — the host prices the usage +// in its own units, charges the payer, and closes the channel if it can't +// cover the tick. A channel the host closes simply vanishes from +// metricsFor() on the next tick and is dropped here. +// +// Measured dimensions (raw, no credits): +// - bytes sent / received (wire bandwidth) +// - memory-byte-seconds (RUDP buffer residency in RAM) +// - channel age in seconds (the "channel is open" duration) +// +// Known coverage gap: a channel that opens and closes within one tick +// window (<= 60s) never participates in a tick, so its age usage is +// never reported. Sub-tick channels are effectively a free tier; closing +// this would require flushing a partial tick on close. +// +// All public methods (track / snapshot / _runTick) post onto the +// io_context they were constructed with — typically rpcTaskIO_, the +// same strand the Rudp itself runs on. snapshot() blocks the caller +// until the post completes. + +#pragma once + +#include +#include +#include // CesPlexHost (the host interface) +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { + +class ChannelMeter { +public: + // Each tick the meter measures every tracked channel's resource deltas + // and reports them to `host` via host->cesplexReportUsage(); the host + // prices them, charges, and closes. Pass a null host for + // observability-only mode (delta tracking, nothing reported) — used by + // tests and by a host that wants the bus without metering. + ChannelMeter(minx::Rudp& rudp, + boost::asio::io_context& io, + CesPlexHost* host = nullptr, + std::chrono::seconds tickInterval = std::chrono::seconds(60)); + ~ChannelMeter(); + + ChannelMeter(const ChannelMeter&) = delete; + ChannelMeter& operator=(const ChannelMeter&) = delete; + ChannelMeter(ChannelMeter&&) = delete; + ChannelMeter& operator=(ChannelMeter&&) = delete; + + // Begin tracking (peer, channelId). The framework calls track() + // exactly once per channel after a successful bind, with the + // payerPfx from the bound principal. Posts onto the construction + // io_context; safe to call from any thread. + // + // payerPfx default = HashPrefix{} is for tests / observability paths + // where there's no bound payer (no host, or the operator wants delta + // tracking without reporting usage). + // + // Idempotent: a second call with the same (peer, channelId) updates + // tag/payer in place; counters and deltas keep accruing. + void track(const minx::SockAddr& peer, uint32_t channelId, + std::string tag, HashPrefix payerPfx = {}); + + // Per-channel snapshot, by value. Posts onto the construction + // io_context and waits for the post to run, so this BLOCKS the + // caller. Used by cesco `netbill` and tests; not for hot paths. + struct ChannelSnapshot { + minx::SockAddr peer; + uint32_t channelId = 0; + std::string tag; + HashPrefix payerPfx{}; + minx::Rudp::ChannelMetrics metrics{}; + // Last per-tick resource delta (zero before the first tick has run). + // Credits are not here — the meter measures resources; the host prices + // them. + uint64_t deltaBytesSent = 0; + uint64_t deltaBytesReceived = 0; + uint64_t deltaMemByteSeconds = 0; + uint64_t deltaAgeSec = 0; + }; + std::vector snapshot() const; + + // Test hook: run one tick synchronously, blocking until done. + // Real tick cadence is 60 s; tests use this to fast-forward delta + // computation + the usage report without sleeping a minute. + void _runTick(); + + // Test hook: force a tracked channel's last-seen counter baselines (the + // values the NEXT tick computes its deltas against). Used to simulate a + // reused (peer, channelId) whose stale baseline exceeds a fresh channel's + // smaller counters — the regression that would unsigned-underflow the delta. + // Blocks until applied. No-op if the channel isn't tracked. + void _testSetBaseline(const minx::SockAddr& peer, uint32_t channelId, + uint64_t lastBytesSent, uint64_t lastBytesReceived, + uint64_t lastMemByteSeconds); + +private: + struct MeteredChannel { + std::string tag; + HashPrefix payerPfx{}; + // Last-seen counter values (for delta computation). + uint64_t lastBytesSent = 0; + uint64_t lastBytesReceived = 0; + uint64_t lastMemByteSeconds = 0; + uint64_t lastMeteredAtUs = 0; + // Last per-tick resource delta. + uint64_t deltaBytesSent = 0; + uint64_t deltaBytesReceived = 0; + uint64_t deltaMemByteSeconds = 0; + uint64_t deltaAgeSec = 0; + }; + + using ChannelKey = std::pair; + + void scheduleTick(); + void doTick(); + + minx::Rudp& rudp_; + boost::asio::io_context& io_; + CesPlexHost* host_; + std::chrono::seconds tickInterval_; + // Touched only on io_'s strand. No mutex needed if all access posts + // through io_, which is the contract. + std::map channels_; + std::shared_ptr timer_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/cesplex/mux.h b/vendor/ces/include/ces/cesplex/mux.h new file mode 100644 index 0000000..6e3775e --- /dev/null +++ b/vendor/ces/include/ces/cesplex/mux.h @@ -0,0 +1,303 @@ +// mux.h — CesPlex, the CES-protocol connection controller / multiplexer +// +// CesPlex is a Layer-1, general-purpose mechanism: it latches onto a MINX +// engine that speaks CES and multiplexes it. It knows the CES protocol and +// nothing about who hosts it (see CesPlexHost — the only host interface: +// sign + rate disclosure + a sink for measured resource usage). CesServer uses +// one on its secondary port; a ledgerless host (the cesluajitd compute +// child) can use one too. The L2 protocols +// (file / compute / lua) are handlers that ride this bus — users of it, +// not part of it. +// +// Every inbound RUDP channel speaks a SIGNED protocol-select handshake +// (the bind contract — see cesplex/wire.h for the wire format) before any +// protocol-specific bytes flow. On bind OK, the handler registered under +// the requested name takes over the channel for its life and the channel +// is metered against its bound payer. On NACK, the channel closes. +// +// Handler shape: statically-linked C++ handler, registered at program +// load via a Meyer-style static init into a process-wide registry. +// CES core ships handlers for "file", "compute", and "lua"; downstream +// binaries can register their own (e.g., a content-server linking +// ceslib to add "rpc"). The protocol name "/ces/rpc/1" is reserved +// but unhandled by CES core itself. +// +// Threading: a CesPlex instance lives on its host's single bus strand +// (the CesServer's rpcTaskIO_). All of acceptInbound / per-channel +// session callbacks / handler.serve invocations run on that strand. A +// handler that wants to do anything long-running should hop off to +// another executor; the framework keeps its own bookkeeping on the strand. +// +// Extensibility: a CesPlex serves only handler objects its host mounts +// via mount(). The host owns the name->class choice (CesServer does it in +// resolveBuiltin). No global registry, no dynamic loading, no plugin API. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace ces { +class ChannelMeter; +} + +namespace ces { + +// ----------------------------------------------------------------------- +// Per-channel resource usage — what CesPlex measures, what the host prices +// ----------------------------------------------------------------------- +// +// CesPlex measures; it does not price. A tick reports each channel's raw +// resource deltas to the host, which decides what they cost in its own +// units. No credits here — just bytes and byte-time. +struct CesPlexUsage { + uint64_t bytesSent = 0; // wire bytes sent this tick + uint64_t bytesReceived = 0; // wire bytes received this tick + uint64_t memByteSeconds = 0; // RUDP buffer residency this tick (byte·s) + uint64_t ageSeconds = 0; // wall seconds the channel lived this tick +}; + +// ----------------------------------------------------------------------- +// CesPlexHost — the host interface +// ----------------------------------------------------------------------- +// +// CesPlex is a general-purpose connection controller / multiplexer that +// latches onto a MINX engine speaking CES. It is Layer 1: it knows the CES +// protocol and measures its own memory + net resources, and nothing else. +// It does NOT know credits, does NOT price usage, and does NOT decide when +// a connection has "run out" — the host does all accounting and all +// closing. The host supplies an identity (to sign) and a sink for measured +// per-channel usage. CesServer implements this against its ledger; a +// ledgerless host (e.g. the cesluajitd compute child) forwards usage to its +// parent. No CesServer, no ledger, no L2 in here. +struct CesPlexHost { + virtual ~CesPlexHost() = default; + // Signs the bind reply and every per-op response on this bus. + virtual const KeyPair& cesplexSigningKey() const = 0; + // Report one channel's measured resource usage for this tick. The host + // does ALL the accounting: it prices the usage in its own units, charges + // `payer`, and — if the payer can't cover it — closes (peer, channelId). + // Fire-and-forget from CesPlex's side: CesPlex never learns the cost and + // never closes a channel for non-payment; it only stops tracking a + // channel once the host has closed it (its metrics vanish). + virtual void cesplexReportUsage(const HashPrefix& payer, + const minx::SockAddr& peer, + uint32_t channelId, + const CesPlexUsage& usage) = 0; + + // Whether a bound channel for this protocol participates in metering. + // Default true; a server-to-server protocol (the peer mesh) opts out, + // since both ends pay from their own bottomless accounts. (Access control + // is the handler's job, decided in serve(), not a bind-time gate here.) + virtual bool cesplexChannelMetered(const std::string& /*proto*/) { + return true; + } +}; + +// Graceful-close timeout for server-initiated channel teardown across +// every CesPlex handler. Plumbed into RudpStream::shutdown(timeout) at +// each handler's "we're done with this channel" site: the in-flight +// reply finishes draining into Rudp's sendBuf, then HS_CLOSE fires +// (or after this deadline elapses, whichever comes first). 0 here +// would equal closeChannel() — i.e. RST — which loses any final +// bytes the user hasn't ACKed yet. 3s is generous for a healthy wire +// and reclaims fast on a dead one. +inline constexpr auto kRudpStreamCloseTimeout = std::chrono::seconds(3); + +// ----------------------------------------------------------------------- +// Handler base class +// ----------------------------------------------------------------------- +// +// A CesPlexHandler owns an inbound RUDP channel once the select handshake +// has completed successfully with its registered name. The single +// virtual is `serve`; the handler implements whatever protocol it +// speaks by reading/writing on the stream and closing when done. +// +// Handlers are long-lived SINGLETONS (one instance per protocol, +// registered once at static-init time). `serve` may be called for +// many channels — interleaved on the rpcTaskIO_ strand, not in +// parallel across threads — so handler state must use per-channel +// state captured in its own async continuations. No mutexes are +// needed for the strand-shared bookkeeping; do need them for state +// shared with handler-internal threads (if the handler spawns any). +// +// LIFETIME CONTRACT — every handler MUST obey this: +// +// The handler is the SOLE strong owner of the stream after serve() +// returns. CesPlex holds only a weak reference, used for routing +// inbound bytes while the handler is active. When the handler is +// done (successful completion, error, or peer disconnect), it must +// drop every shared_ptr it holds to the stream. That destructs the +// stream, which closes the RUDP channel, and CesPlex's weak_ptr +// expires. CesPlex detects expiry lazily (on the next inbound +// receive for that channel) and erases its session bookkeeping. +// +// Consequence: if a handler stashes a shared_ptr into +// a long-lived context and forgets to release it, the channel +// leaks. The framework does not detect or alarm on this; handlers +// that spawn async op chains should have their outermost +// continuation release the stream on both success AND failure paths. + +// ----------------------------------------------------------------------- +// SYS_L2_CALL — VM-to-L2 paid call routing +// ----------------------------------------------------------------------- +// +// CesVM's SYS_L2_CALL routes a paid, reliable call into an in-CES (L2) +// built-in by an 8-byte discriminator (sha256 of the built-in mount name, +// first 8 bytes, matched as a flat array). Settlement is delivery-based and +// host-owned: the syscall burns `value` from the caller; on Delivered the +// host mints it to the payee, on a failure it refunds the caller. The +// built-in owns the fine addressing (the opaque `blob`) and the attempt. + +// Delivery outcome. Delivered => mint payee; NoHandler / Timeout => refund +// payer. NoHandler is a permanent, immediate refuse (no registered target); +// Timeout is give-up after the built-in could not hand off to a live target. +enum class L2CallOutcome { Delivered, NoHandler, Timeout }; + +// A routed L2 call handed to a built-in. The discriminator already selected +// the handler; `blob` is the provider-ABI payload, opaque to core. +struct L2CallRequest { + uint64_t callId = 0; // host-owned, unique; the idempotency / dedup key + minx::Hash payer; // the burned account (full key); the refund target + uint64_t value = 0; // credits burned; minted to payee on Delivered + Bytes blob; // provider-ABI payload (target + inner request) +}; + +// The built-in reports the terminal outcome, possibly later and from another +// thread. On Delivered it names the payee (full 32-byte key, so the host can +// credit or create the account) to mint, and passes the program's reply bytes +// (empty on NoHandler / Timeout, or when on_l2call returned nothing). The host +// truncates the reply to the sink's ceiling. Idempotent by callId. +using L2CallReport = + std::function; + +class CesPlexHandler { +public: + virtual ~CesPlexHandler() = default; + // Called once per inbound channel after the signed select handshake + // succeeded with this handler's name. The stream is pre-constructed + // and ready for async_read/async_write. The bound context carries + // the channel's principal identity (pubkey + payerPfx) and + // anchoring state (sessionToken + bound rate schedule + bind time) + // — all set once at handoff, immutable for the channel's lifetime. + // The handler stores the BoundChannelContext on its per-channel + // state struct and uses it for every per-op verify (verifyPerOp). + // + // The handler owns the stream lifetime from here; CesPlex has + // dropped its strong reference. See the LIFETIME CONTRACT above. + virtual void serve(std::shared_ptr stream, + BoundChannelContext bound) = 0; + + // SYS_L2_CALL delivery. The discriminator already selected this handler. + // Return CES_OK to accept (the handler owns the call and MUST invoke + // `report` exactly once, possibly later and from any thread) or an + // immediate error to refuse synchronously (the host refunds the caller). + // The default refuses: a built-in without an L2-call capability is skipped. + virtual uint8_t cesplexL2Call(const L2CallRequest& /*req*/, + L2CallReport /*report*/) + { return CES_ERROR_UNSUPPORTED; } +}; + +// ----------------------------------------------------------------------- +// CesPlex — the multiplexer +// ----------------------------------------------------------------------- +// +// One CesPlex instance per host bus. Constructed when the host's CES +// port comes up; destroyed when it goes down. All methods are expected +// to run on the host's single bus strand. + +class CesPlex { +public: + // Constructed with no bindings; the host mounts handler objects after + // construction via mount(). There is no global registry: a CesPlex serves + // only what its host explicitly mounts. + // + // `rudp` is the Rudp instance on the secondary port. CesPlex + // installs channel-opened + receive callbacks on it that route + // inbound channels through the bind handshake. `io` is the + // io_context those callbacks run on (rpcTaskIO_). + // + // `meter` is optional (may be null). When non-null, every + // inbound channel is registered with it post-bind so the tick can + // measure its resource deltas and report them to the host. + // + // `host` supplies the bind-reply ingredients: cesplexSigningKey() to + // sign the reply. May be null only for tests that don't exercise the + // bind handshake. + CesPlex(minx::Rudp& rudp, + boost::asio::io_context& io, + CesPlexHost* host, + ChannelMeter* meter = nullptr); + + ~CesPlex(); + + CesPlex(const CesPlex&) = delete; + CesPlex& operator=(const CesPlex&) = delete; + + // Called by the host's Rudp::Listener::onAccept hook for + // every fresh inbound HS_OPEN. Constructs a per-channel + // RudpStream + Session, kicks off the bind-handshake read, and + // returns the stream as the channel handler for Rudp to wire. + // + // Returns null (silent rejection) if no bindings were resolved at + // ctor time, or if a session for these (peer, channelId) coords + // already exists (duplicate accept). Otherwise always succeeds. + // + // After this returns, RUDP routes per-channel events (bytes, + // close) directly to the returned stream — CesPlex no longer + // intercepts on the receive path. + std::shared_ptr acceptInbound( + const minx::SockAddr& peer, uint32_t channelId); + + // True if any bindings resolved at ctor time. If false, the select + // handshake on every inbound channel will NACK — fine, but the + // caller may want to skip constructing us entirely. + bool hasAnyBinding() const { return !bindings_.empty(); } + + // Bind a per-host handler object to a protocol name. This is the ONLY way + // handlers get bound: the host owns the handler's lifetime and the + // name->object choice. A null handler unbinds the protocol. Call before the + // socket opens (host construction), or post onto the bus strand if the + // endpoint is already live. + void mount(const std::string& proto, CesPlexHandler* handler); + + // Introspection — test-only. Number of live sessions awaiting bind. + size_t _pendingSessionCount() const { return sessions_.size(); } + +private: + // Per-channel session state: opens, reads the select header, either + // hands off to a handler (OK) or closes (NACK). + struct Session; + using SessionKey = std::pair; + + minx::Rudp& rudp_; + boost::asio::io_context& io_; + CesPlexHost* host_; // signs bind replies + supplies bind rates + ChannelMeter* channelMeter_; // optional, may be null + + // Protocol-name → mounted handler object (host-owned). Filled by mount(). + std::map bindings_; + + // In-flight inbound select handshakes. Keyed by (peer, channelId). + // Entries are removed once a handler takes over or the channel + // closes. + std::map> sessions_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/cesplex/session.h b/vendor/ces/include/ces/cesplex/session.h new file mode 100644 index 0000000..05ef501 --- /dev/null +++ b/vendor/ces/include/ces/cesplex/session.h @@ -0,0 +1,261 @@ +// session.h — the per-op CesPlex layer for handlers and clients. +// +// The pieces both sides of a bound CesPlex channel build on, layered over +// cesplex/wire.h (low-level wire format) and cesplex/mux.h (the bus: bind +// handshake + channel routing + handler registration + metering): +// +// * Server side — the signed-request loop. A handler's serve() calls +// cesPlexServe(); the framework reads [verb][envelope] ops, verifies +// each against the bound pubkey, and hands them to the handler's +// dispatch. CesPlexRequest::respond/error/respondAndClose emit the +// server-signed reply and loop/close. builtin:file and +// builtin:compute share this verbatim. +// +// * Client side — CesPlexClient. One MINX socket + Rudp + io threads + +// the signed bind handshake + the blocking verb driver. CesFileClient +// and CesComputeClient are just verb methods over it. + +#pragma once + +#include +#include // CesPlexHost, BoundChannelContext +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +// Forward declarations for CesPlexChannel's injected-transport ctor — the +// concrete types are only needed in session.cpp. +namespace boost { namespace asio { class io_context; } } +namespace minx { class Rudp; } + +namespace ces { + +// =========================================================================== +// Server side — the signed-request loop +// =========================================================================== +// +// builtin:file and builtin:compute speak the same post-bind shape: a +// stream of per-op envelopes +// [u8 verb][u32 BE preamble_len][preamble][65 sig] +// where the sig is verifyPerOp(bound, verb, preamble) and the preamble's +// first 4 bytes are reqNonce. cesPlexServe() drives that loop — read a +// verb, read + verify the envelope, peel reqNonce — and hands each +// verified op to the handler's CesPlexProtocol::dispatch with the request +// plus the rest of the preamble. The handler owns only its verb set and +// per-verb logic; it finishes each op via the CesPlexRequest helpers, +// which emit the server-signed response and loop (or close) the channel. +// +// (Handlers whose protocol isn't verb/envelope-shaped — builtin:lua's +// one-shot ATTACH — drive the stream directly in serve() instead.) + +struct CesPlexRequest; + +// Handler-supplied protocol descriptor for cesPlexServe. +struct CesPlexProtocol { + // True if `verb` is one this handler serves right now. A verb outside + // the set ends the channel without reading an envelope — so handlers + // fold their "am I still bound?" check in here too (return false when + // unbound), which is what stops in-flight channels on teardown. + std::function accepts; + // Handle one verified op. `req` carries verb / reqNonce / reqSigHash / + // bound; `preamble` is the preamble after the 4-byte reqNonce. Finish + // via req->respond() / error() / respondAndClose(). + std::function req, + ces::Bytes preamble)> dispatch; +}; + +// Per-op request handed to a handler's dispatch. Framework-built: the +// handler reads identity from `bound`, the op's nonce/sig from here, and +// finishes the op via the helpers below. Field names match the per-handler +// ReqCtx structs they replace, so dispatchers need no edits. +struct CesPlexRequest : std::enable_shared_from_this { + std::shared_ptr stream; + CesPlexHost* host = nullptr; // signs responses + BoundChannelContext bound; + uint8_t verb = 0; + std::array sig{}; + uint64_t reqSigHash = 0; + uint32_t reqNonce = 0; + std::shared_ptr proto; // for looping to the next verb + + // Server-signed response for this op, then loop to the next verb. + // `extraBody` streams after the envelope (e.g. a READ payload). + void respond(uint8_t status, ces::Bytes preamble, ces::Bytes extraBody = {}); + // Server-signed error (empty preamble), then loop. Clean-stream + // rejections — the envelope was consumed, nothing in flight. + void error(uint8_t status) { respond(status, {}); } + // Response (default empty preamble), then close the channel. For + // body-bearing verbs rejected before their trailing body was read — + // looping would desync the stream. + void respondAndClose(uint8_t status, ces::Bytes preamble = {}); + void errorAndClose(uint8_t status) { respondAndClose(status); } +}; + +// Begin the signed-request loop on `stream`. Call from serve(); loops +// until the channel closes. Everything runs on the rpcTaskIO_ strand. +void cesPlexServe(std::shared_ptr stream, + BoundChannelContext bound, + CesPlexHost* host, + CesPlexProtocol proto); + +// =========================================================================== +// CesPlexChannel — the per-channel client protocol, over an injected transport +// =========================================================================== +// +// The CesPlex client protocol — the signed bind handshake, per-op envelope +// signing, and the blocking verb-drive loop — with NO owned mechanics. It +// borrows a task io_context (where its async stream ops run) and a Rudp (where +// it opens its channel), so the SAME client codec composes onto any transport: +// +// * CesPlexClient owns a Minx/Rudp/threads and hands this its taskIO + Rudp. +// * a process already running a Rudp (the cesluajitd compute child's CesPlex +// endpoint) hands this that same Rudp. +// +// CesFileClient / CesComputeClient are verb wrappers over a CesPlexChannel, so +// they have one implementation regardless of who owns the socket. +// +// Threading: the borrowed task io_context must be run by another thread; each +// verb posts its I/O there and blocks the CALLING thread on a future (caller +// thread must differ from the task io_context's thread). + +class CesPlexChannel { +public: + CesPlexChannel(boost::asio::io_context& taskIO, minx::Rudp* rudp); + ~CesPlexChannel(); + CesPlexChannel(const CesPlexChannel&) = delete; + CesPlexChannel& operator=(const CesPlexChannel&) = delete; + + // Open a fresh channel to `peer` and run the signed bind for `protocol`, + // signed by `signerKey` (the per-op signer + billed principal). + uint8_t select(const minx::SockAddr& peer, const std::string& protocol, + const KeyPair& signerKey); + + // Drop the bound stream (teardown). + void reset(); + + // Provide the server pubkey so response signatures verify (else each + // response logs one LOGERROR and is treated as unverifiable). + void setServerPubkey(const minx::Hash& pk); + + // See CesPlexClient::buildEnvelope. + minx::Bytes buildEnvelope(uint8_t verb, std::span preamble); + + // See CesPlexClient::driveVerb (both overloads). + uint8_t driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + const std::function& respBodyLen, + const ces::Bytes& extraBodyToSend, + ces::Bytes& outPreamble, + ces::Bytes& outBody); + + uint8_t driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + ces::Bytes& outPreamble); + + bool readExact(ces::Bytes& out, size_t n); + + uint64_t boundSessionToken() const; + + class Impl; + +private: + std::unique_ptr impl_; +}; + +// =========================================================================== +// Client side — the shared blocking client +// =========================================================================== +// +// Owns one MINX socket + Rudp state machine + two io_contexts (net + task) +// with a thread each, drives Rudp ticks, runs the signed bind handshake +// for a protocol name, and exposes a blocking verb driver. A concrete +// client (CesFileClient, CesComputeClient) holds one of these and is just +// its per-verb preamble building + response parsing. +// +// Verb wire (per op): client writes [u8 verb][envelope][optional body], +// reads [u8 status], then on OK a fixed + optional variable preamble, then +// the server-signed trailer, then an optional response body. The bound +// channel survives across verbs; a wire error marks it dirty and the next +// verb reselects a fresh channel. + +class CesPlexClient { +public: + CesPlexClient(); + ~CesPlexClient(); + CesPlexClient(const CesPlexClient&) = delete; + CesPlexClient& operator=(const CesPlexClient&) = delete; + + // Open a local UDP socket, wire up Rudp, and run the signed bind + // handshake for `protocol` (e.g. "/ces/file/1") against the server. + // `signerKey` becomes the channel principal. + uint8_t connect(const std::string& host, uint16_t rpcPort, + const std::string& protocol, const KeyPair& signerKey); + + // Tear down the channel + I/O threads. Safe to call more than once. + void disconnect(); + + // Provide the server pubkey so response signatures verify (else each + // response logs one LOGERROR and is treated as unverifiable). + void setServerPubkey(const minx::Hash& pk); + + // The bound channel (valid after connect()), so a verb client can ride + // this client's transport instead of opening its own. + CesPlexChannel* channel(); + + // Build a signed per-op envelope [u32 len][salt+preamble][65 sig] for + // this channel. An 8-byte per-op salt is prepended so identical + // (verb, preamble) ops still get distinct sigs (the server strips it); + // the sig is over verb || salt || preamble || sessionToken. + minx::Bytes buildEnvelope(uint8_t verb, + std::span preamble); + + // Drive one verb to completion. ensureClean() → write + // [verb][envelope][extraBodyToSend?] → read status → on OK read the + // fixed preamble (respFixedPreambleLen) + run readVariablePreamble → + // verify the server-signed trailer → on OK read a response body whose + // length respBodyLen() derives from the preamble. Returns the status + // byte (or CES_ERROR_INTERNAL on a wire failure). Unused hooks: pass + // nullptr / {} / 0. + uint8_t driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + const std::function& respBodyLen, + const ces::Bytes& extraBodyToSend, + ces::Bytes& outPreamble, + ces::Bytes& outBody); + + // Convenience for verbs with no body to send and no response body. + uint8_t driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + ces::Bytes& outPreamble); + + // Read n more bytes off the channel — for readVariablePreamble hooks + // that pull trailing variable fields. + bool readExact(ces::Bytes& out, size_t n); + + class Impl; + +private: + std::unique_ptr impl_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/cesplex/wire.h b/vendor/ces/include/ces/cesplex/wire.h new file mode 100644 index 0000000..0179967 --- /dev/null +++ b/vendor/ces/include/ces/cesplex/wire.h @@ -0,0 +1,336 @@ +// wire.h — wire format helpers for the CesPlex bind contract +// + per-op envelope. +// +// 1. The select handshake is a SIGNED bind contract. Both client and +// server commit cryptographic artifacts to the binding event: +// +// Client → Server (signed by client): +// [u16 BE name_len][name] +// [u64 BE client_time_us][32 client_pubkey] +// [32 sha256(everything above)] +// [65 sig] +// +// Server → Client (signed by server): +// [u8 status] (CES_PLEX_OK=0x01 / CES_PLEX_NACK=0x00) +// [u64 BE server_time_us][32 server_pubkey] +// [8 channel_session_token] (from RUDP, anchors per-op sigs; +// guaranteed non-zero in practice) +// [u32 BE server_proto_version] +// [32 sha256(status || client_sha256 || all-of-above)] +// [65 sig] +// +// 2. Once bound, every per-op verb on the channel uses a minimal +// envelope: +// +// [u8 verb][u32 BE preamble_len][preamble bytes] +// [65 sig over sha256(verb || preamble || sessionToken)] +// [optional body: bytes whose sha256 is committed inside the +// preamble for verbs that carry executable code (WRITE/APPEND). +// READ has the symmetric thing on the response side.] +// +// No per-op pubkey on the wire — the bound pubkey is implicit. +// No per-op timestamp — the application's reqNonce in the +// preamble + the channel's sessionToken give natural uniqueness +// per channel-incarnation. Dedup key = first 8 bytes of sig as +// big-endian u64. Sig space is per-channel-incarnation so +// cross-channel collisions can't happen; within a channel the +// birthday horizon is ~2^32 ops, well beyond any practical +// channel lifetime. +// +// 3. The bound state (pubkey + sessionToken + bind time) lives on a +// BoundChannelContext that the CesPlex Session passes to the +// application handler at handoff (CesPlexHandler::serve). +// +// All multi-byte ints are big-endian on the wire. +// +// Status byte convention (OK=0x01, NACK=0x00) is opposite of the usual +// "0 = success" idiom: NACK is the safer default for an uninitialized +// ParsedBindReply, so a parse failure or zeroed buffer reads as +// "rejected" not "approved". +// +// The signing is anti-MITM only. Server sigs prove the bind reply came from +// the intended server; client sigs prove each op came from the bound +// principal. The bus carries no prices — it measures bytes and time and +// reports the counts to its host, which does all pricing and accounting. + +#pragma once + +#include +#include +#include + + +#include + +#include +#include +#include +#include +#include + +namespace ces { + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Status byte values in the bind reply. NACK=0x00 is intentional — +// see the header comment. +constexpr uint8_t CES_PLEX_OK = 0x01; +constexpr uint8_t CES_PLEX_NACK = 0x00; + +// Initial server protocol version. Iterated freely inside the bound +// contract without re-bumping the protocol name (`/ces/file/N`). +// The protocol-name version reserves itself for handshake-shape +// breaks (decade-scale events). +constexpr uint32_t CES_PLEX_PROTO_VERSION_V1 = 1; + +// Maximum protocol name length accepted in the bind preamble. Names +// longer than this are NACKed before the server reads them. +constexpr uint16_t CES_PLEX_MAX_NAME_LEN = 256; + +// Wire layout sizes. PublicKey and Hash in CES are 32 bytes on the +// wire (the in-memory PublicKey class wraps a larger buffer to also +// hold the algorithm decorator and per-algo state, so its sizeof is +// not 32; the wire form is always serialized via PublicKey::toBytes). +// Signature on the wire is 65 bytes (1 algo decorator + 64-byte sig). +constexpr size_t CES_PLEX_SHA256_SIZE = 32; +constexpr size_t CES_PLEX_PUBKEY_SIZE = 32; +constexpr size_t CES_PLEX_SIG_SIZE = 65; + +static_assert(sizeof(minx::Hash) == CES_PLEX_SHA256_SIZE, + "wire/type mismatch: minx::Hash vs CES_PLEX_SHA256_SIZE"); +static_assert(sizeof(Signature) == CES_PLEX_SIG_SIZE, + "wire/type mismatch: Signature vs CES_PLEX_SIG_SIZE"); + +// Bind reply fixed-shape size (the bytes between the status byte +// and the trailing sha256 + sig). Locked to its decomposition: +// 8 server_time_us +// + 32 server_pubkey (= CES_PLEX_PUBKEY_SIZE) +// + 8 channel_session_token +// + 4 server_proto_version +// = 52 bytes +constexpr size_t CES_PLEX_BIND_REPLY_BODY_SIZE = + 8 + CES_PLEX_PUBKEY_SIZE + 8 + 4; +static_assert(CES_PLEX_BIND_REPLY_BODY_SIZE == 52, + "bind reply body size diverged from documented layout"); + +// Total bind reply size = status + body + sha256 + sig. +constexpr size_t CES_PLEX_BIND_REPLY_TOTAL_SIZE = + 1 + CES_PLEX_BIND_REPLY_BODY_SIZE + CES_PLEX_SHA256_SIZE + CES_PLEX_SIG_SIZE; +static_assert(CES_PLEX_BIND_REPLY_TOTAL_SIZE == 150, + "bind reply total size diverged"); + +// Per-op request envelope header: [u8 verb][u32 BE preamble_len]. +constexpr size_t CES_PLEX_VERB_SIZE = sizeof(uint8_t); +constexpr size_t CES_PLEX_PREAMBLE_LEN_SIZE = sizeof(uint32_t); + +// Server-signed per-op response envelope: +// [u8 status][preamble][u64 time_us][u64 req_sig_hash][sha256][sig] +constexpr size_t CES_PLEX_STATUS_SIZE = sizeof(uint8_t); +constexpr size_t CES_PLEX_TIME_US_SIZE = sizeof(uint64_t); +constexpr size_t CES_PLEX_REQ_SIG_HASH_SIZE = sizeof(uint64_t); + +// Fixed bytes trailing the response preamble: time_us + req_sig_hash + +// digest + sig. The status byte and preamble precede it. +constexpr size_t CES_PLEX_RESP_TRAILER_SIZE = + CES_PLEX_TIME_US_SIZE + CES_PLEX_REQ_SIG_HASH_SIZE + + CES_PLEX_SHA256_SIZE + CES_PLEX_SIG_SIZE; +static_assert(CES_PLEX_RESP_TRAILER_SIZE == 113, + "response trailer size diverged from documented layout"); + +// Bind request wire: [u16 name_len][name][u64 client_time_us] +// [client_pubkey][client_sha256][sig]. The tail is everything after +// the name (time + pubkey + digest + sig). +constexpr size_t CES_PLEX_NAME_LEN_SIZE = sizeof(uint16_t); +constexpr size_t CES_PLEX_BIND_REQ_TAIL_SIZE = + sizeof(uint64_t) + CES_PLEX_PUBKEY_SIZE + + CES_PLEX_SHA256_SIZE + CES_PLEX_SIG_SIZE; +static_assert(CES_PLEX_BIND_REQ_TAIL_SIZE == 137, + "bind request tail size diverged from documented layout"); + +// Bind-request freshness window. The signed client_time_us is rejected if more +// than CES_PLEX_BIND_MAX_AGE_US in the past or CES_PLEX_BIND_FUTURE_DRIFT_US in +// the future. Without it a captured bind replays indefinitely on fresh channels, +// each re-binding as the victim and accruing ChannelMeter against them. 5 min +// each way absorbs clock skew while bounding replay. +constexpr uint64_t CES_PLEX_BIND_MAX_AGE_US = 300ULL * 1000000; +constexpr uint64_t CES_PLEX_BIND_FUTURE_DRIFT_US = 300ULL * 1000000; + +// --------------------------------------------------------------------------- +// BoundChannelContext — what CesPlex passes to a handler at handoff +// --------------------------------------------------------------------------- +// +// Constructed by CesPlex Session after the signed select succeeds. +// Passed by value to CesPlexHandler::serve. Handlers store it on +// their per-channel state struct and use it for every per-op verify. + +// What CesPlex passes to a server-side handler at bind handoff. No rate +// or price fields anywhere — the bus measures resource usage and reports +// counts to its host; the host does all pricing and accounting. +struct BoundChannelContext { + // Identity + PublicKey boundPubkey; // for sig verify + HashPrefix payerPfx{}; // first 8 bytes of pubkey, account map key + + // Channel anchor (from RUDP). Always non-zero for a successful bind; + // a 0 sessionToken indicates the context wasn't populated. + uint64_t sessionToken = 0; // per-channel-incarnation salt for sigs + + // Server's wall-clock at bind (informational; useful for handler + // logging and freshness checks). + uint64_t serverBoundAtUs = 0; +}; + +// --------------------------------------------------------------------------- +// Bind preamble — client side +// --------------------------------------------------------------------------- + +// Build the signed bind request the client sends as the very first +// bytes on a freshly-opened RUDP channel. `name` is the protocol name +// (e.g. "/ces/file/1"). `clientKey` is the client's keypair; the +// resulting bind binds this channel to clientKey.getPublicKeyAsHash() as +// the principal identity on the server side. +// +// Output bytes are ready to async_write onto the RudpStream as a +// single buffer. +minx::Bytes buildBindRequest(const std::string& name, + uint64_t clientTimeUs, + const KeyPair& clientKey); + +// Same wire as buildBindRequest, but with the signature supplied from outside +// instead of signing with a local KeyPair. For tunnelers (cesweb) where the +// private key never reaches this process: the caller signs the bind digest +// elsewhere (browser / node) and passes the 65-byte sig + the 32-byte pubkey. +// The digest is recomputed here from (name, time, pubkey) so it always matches +// what the server verifies; the caller's sig must be over that same digest. +minx::Bytes buildBindRequestSigned(const std::string& name, + uint64_t clientTimeUs, + std::span clientPubkey, + const Signature& sig); + +// Compute the digest the client signs over (and the server recomputes +// to verify): sha256(name_len || name || clientTimeUs || clientPubkey). +std::array +computeBindRequestDigest(std::span name, + uint64_t clientTimeUs, + std::span clientPubkey); + +// --------------------------------------------------------------------------- +// Bind reply — server side +// --------------------------------------------------------------------------- + +// Inputs the server gathers when building the reply. Identity and +// anchoring only — the bind contract carries no prices. +struct BindReplyFields { + uint8_t status = CES_PLEX_OK; + uint64_t serverTimeUs = 0; + uint64_t channelSessionToken = 0; + uint32_t serverProtoVersion = CES_PLEX_PROTO_VERSION_V1; +}; + +// Build the signed bind reply. `clientSha256` is the digest the +// client already signed (returned by computeBindRequestDigest at parse +// time); the reply binds to it so the client can confirm the server +// is responding to *this* bind. +minx::Bytes buildBindReply(const BindReplyFields& fields, + std::span clientSha256, + const KeyPair& serverKey); + +// Parsed view of an inbound bind reply. The client populates this by +// reading the wire, then runs the four mandatory checks. +struct ParsedBindReply { + uint8_t status = CES_PLEX_NACK; + uint64_t serverTimeUs = 0; + std::array serverPubkey{}; + uint64_t channelSessionToken = 0; + uint32_t serverProtoVersion = 0; + std::array sha256{}; + std::array sig{}; +}; + +// Compute the digest the server signs over and the client recomputes: +// sha256(status || clientSha256 || serverTimeUs || serverPubkey || +// channelSessionToken || serverProtoVersion). +// +// Reads only the input fields of `reply` (status / time / pubkey / +// token / version). Ignores reply.sha256 and reply.sig (those are the +// digest's *output*, populated by the signer). The type is shared with +// parseBindReply for caller convenience; the digest computation itself +// is symmetric on both sides. +std::array +computeBindReplyDigest(const ParsedBindReply& reply, + std::span clientSha256); + +// Decode CES_PLEX_BIND_REPLY_TOTAL_SIZE bytes off the wire into a +// ParsedBindReply. Pure parser — no validation. Caller checks status +// and runs verifyBindReply for digest + sig integrity. +ParsedBindReply parseBindReply( + std::span buf); + +// Recompute the reply digest, compare against the in-reply digest, +// then verify the server's sig against the in-reply pubkey. Returns +// true iff both checks pass. Deliberately doesn't distinguish +// digest-mismatch from sig-mismatch — both are "rejected", and +// leaking which check failed would help an attacker probe. +// +// Caller decides what to do with a true result (TOFU-capture the +// pubkey, hard-check against an expected one, etc.). +bool verifyBindReply(const ParsedBindReply& reply, + std::span clientSha256); + +// --------------------------------------------------------------------------- +// Per-op envelope — minimal artifact, just a sig +// --------------------------------------------------------------------------- +// +// On the wire: [u8 verb][u32 BE preamble_len][preamble][65 sig] +// +// The sig is over sha256(verb || preamble || sessionToken). No pubkey, +// no timestamp — both implicit in the bound channel. For verbs with +// a body (WRITE/APPEND/READ-response), the preamble carries +// body_length + body_sha256; the body bytes follow the sig and are +// hashed incrementally on receive, compared to the authenticated +// digest in the preamble. + +// Compute the digest the client signs over and the server recomputes +// to verify: sha256(verb || preamble || sessionToken). +std::array +computePerOpDigest(uint8_t verb, + std::span preamble, + uint64_t sessionToken); + +// Build the 65-byte sig for a per-op envelope. Caller emits +// [u8 verb][u32 preamble_len][preamble] and appends this sig. +Signature signPerOp(const KeyPair& signer, + uint8_t verb, + std::span preamble, + uint64_t sessionToken); + +// Verify a per-op sig against the bound pubkey. Returns true on +// success; false on any mismatch (bad sig, wrong pubkey, etc.). +bool verifyPerOp(const BoundChannelContext& bound, + uint8_t verb, + std::span preamble, + const Signature& sig); + +// Convenience: extract the dedup hash. Skips the 1-byte algorithm +// decorator at sig[0] and reads sig[1..8] as a big-endian u64. Sig +// space is per-channel-incarnation, so cross-channel collisions +// can't happen; within one channel the 64-bit prefix gives a +// birthday horizon of ~2^32 ops, well past any practical channel +// lifetime. +uint64_t sigDedupHash(const Signature& sig); + +// Build a server-signed per-op response envelope: +// [u8 status][preamble][u64 time_us][u64 req_sig_hash][sha256][sig] +// The signature covers sha256(status || verb || preamble || time_us || +// req_sig_hash) — verb is bound into the digest but not emitted (the +// client already knows which verb it sent). Shared by every CesPlex +// handler that answers a per-op verb. +ces::Bytes buildPerOpResponse(const KeyPair& serverKey, + uint8_t verb, + uint8_t status, + std::span preamble, + uint64_t reqSigHash); + +} // namespace ces diff --git a/vendor/ces/include/ces/cesproxy.h b/vendor/ces/include/ces/cesproxy.h new file mode 100644 index 0000000..775373d --- /dev/null +++ b/vendor/ces/include/ces/cesproxy.h @@ -0,0 +1,149 @@ +#pragma once + +/** + * CesProxy — MinxProxy subclass with CES-level message validation. + * + * - Signed messages: verify signature before forwarding + * - Unsigned messages: validate opcode and structure + * - Unknown opcodes: drop + close + * - PoW: verify RandomX hash locally before forwarding + * + * Only the general-purpose server request/response lane (MINX_MESSAGE) is + * proxied. The APPLICATION push lane (CES_APP_COMPUTE_MSG) is intentionally + * NOT proxied: compute programs are reachable only over a direct UDP + * connection. + */ + +#include +#include +#include +#include + +class CesProxy : public minx::MinxProxy { +public: + using MinxProxy::MinxProxy; // inherit constructors + +protected: + bool filterMessage(const minx::TcpSessionPtr& session, + const minx::MinxMessage& msg) override { + if (msg.data.empty()) + return false; + + uint8_t opCode = msg.data[0]; + + switch (opCode) { + + // --- Signed messages: extract key, verify signature --- + + case ces::CES_TRANSFER: { + ces::CesTransfer req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_OPEN_TRANSFER: { + ces::CesOpenTransfer req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_CREATE_PAYMENT: { + ces::CesCreatePayment req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_CROSS_TRANSFER: { + ces::CesCrossTransfer req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_RUN_ASSET: { + ces::CesRunAsset req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_BULK_TRANSFER: { + ces::CesBulkTransfer req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_QUERY_ACCOUNT: { + ces::CesQueryAccount req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_CREATE_ASSET: { + ces::CesCreateAsset req; + return verifySigned(msg.data, req, req.ownerId); + } + case ces::CES_UPDATE_ASSET: { + ces::CesUpdateAsset req; + return verifySigned(msg.data, req, req.ownerId); + } + case ces::CES_UPDATE_ASSET_META: { + ces::CesUpdateAssetMeta req; + return verifySigned(msg.data, req, req.ownerId); + } + case ces::CES_UPDATE_ASSET_FAST: { + ces::CesUpdateAssetFast req; + return verifySigned(msg.data, req, req.ownerId); + } + case ces::CES_FUND_ASSET: { + ces::CesFundAsset req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_BUY_ASSET: { + ces::CesBuyAsset req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_GIVE_ASSET: { + ces::CesGiveAsset req; + return verifySigned(msg.data, req, req.ownerId); + } + case ces::CES_QUERY_ASSET: { + ces::CesQueryAsset req; + return verifySigned(msg.data, req, req.originId); + } + case ces::CES_QUERY_SERVER_INFO: { + ces::CesQueryServerInfo req; + return verifySigned(msg.data, req, req.originId); + } + + // --- Unsigned messages: validate structure only --- + + case ces::CES_UNSIGNED_QUERY_ACCOUNT: { + ces::CesUnsignedQueryAccount req; + return verifyUnsigned(msg.data, req); + } + case ces::CES_UNSIGNED_QUERY_SOLUTION: { + ces::CesUnsignedQuerySolution req; + return verifyUnsigned(msg.data, req); + } + case ces::CES_UNSIGNED_QUERY_ASSET: { + ces::CesUnsignedQueryAsset req; + return verifyUnsigned(msg.data, req); + } + + default: + return false; // unknown opcode → drop + } + } + + bool filterProveWork(const minx::TcpSessionPtr& session, + const minx::MinxProveWork& msg) override { + return verifyProveWork(msg); + } + +private: + template + bool verifySigned(const minx::Bytes& data, Req& req, + const minx::Hash& keyField) { + try { + req.fromBytes(data); + return req.verifySignature(data, ces::PublicKey(keyField)); + } catch (...) { + return false; + } + } + + template + bool verifyUnsigned(const minx::Bytes& data, Req& req) { + try { + req.fromBytes(data); + return true; + } catch (...) { + return false; + } + } +}; diff --git a/vendor/ces/include/ces/cesvm.h b/vendor/ces/include/ces/cesvm.h new file mode 100644 index 0000000..61b76d0 --- /dev/null +++ b/vendor/ces/include/ces/cesvm.h @@ -0,0 +1,1135 @@ +#pragma once + +/** + * CesVM — Server-side bytecode VM for CES. + * + * Based on GVM (github.com/FluxBP/gvm). Executes bytecode stored in + * asset cells. The HOST instruction dispatches CES I/O operations + * (read/write accounts, assets, send UDP) based on a syscall number + * in register io[3]. + * + * Programs are invoked via CES_RUN_ASSET. The caller provides: + * - Asset key (which program to run) + * - Credit budget (max credits to burn) + * - Input data (arbitrary bytes, available to the program) + * + * The program runs on the logic strand, serialized with all other + * CES operations. It can read any account/asset, write assets owned + * by the caller, transfer credits from the caller, and send UDP + * packets. All mutations are charged to the caller's budget. + * + * Architecture: + * - Harvard: code (read-only, up to 8KB) and data (io[], 8KB) are separate + * - Code starts as the 210-byte asset content, grows via SYS_LOAD_CODE + * - 59 opcodes (35 GVM core + RND, TIME, MOV, LDB, STB, CMP, FIL, + * HOSTX, ABORT, JMPR, CALLR, HOSTV, HOSTXV, SAR, LNOT, SLT, SGT, + * SGE, SLE, ADDX, SUBX, MULX, ASSERT, DUP) + * - 16 registers: PC, R, S, SYSCALL, ARG0-3, GPR0-7 + * - CALL/RET save/restore all 16 registers (128-byte frame) + * - Write syscalls execute real mutations via host callbacks + * - Atomicity (rollback on error) is the server's responsibility via undo log + * + * Syscall convention (HOST/HOSTX instruction): + * io[3] = syscall number + * io[4..8] = arguments (syscall-specific) + * R (io[1]) = data return value (syscall-specific, unchanged if no data) + * S (io[2]) = CES error code (0 = CES_OK, nonzero = failure) + * HOST: executes syscall, program checks S to decide what to do + * HOSTX: executes syscall, aborts VM (CESVM_ABORT) if S != 0 + * + * Syscalls (dense range 0..22; see CesVMSyscall enum below for the + * authoritative IDs and per-call ABI doc): + * 0 NOP S=ok + * 1 READ_ACCOUNT io[4]=prefix_ptr → R=balance, io[5]=nonce, io[6]=aliasId (0 = none), S=ok + * 2 TRANSFER io[4]=dest_key_ptr, io[5]=amount → S=error_code + * 3 READ_ASSET io[4]=key_ptr, io[5]=owner_out, io[6]=content_out → io[7]=balance (raw u16: bits 0..12 days, bit 13 immut, bit 14 aowned, bit 15 priv), io[8]=price, S=ok/ASSET_NOT_FOUND + * 4 CREATE_ASSET_RANDOM io[4]=content_ptr, io[5]=days, io[6]=key_out_ptr → S=ok + * 5 UPDATE_ASSET io[4]=key_ptr, io[5]=content_ptr → S=error_code + * 6 FUND_ASSET io[4]=key_ptr, io[5]=days → S=error_code + * 7 BUY_ASSET io[4]=key_ptr, io[5]=max_price → S=error_code + * 8 GIVE_ASSET io[4]=key_ptr, io[5]=new_owner_ptr → S=error_code + * 9 SEND_UDP DISABLED (returns S=DISABLED). Use SEND_CLIENT instead. + * 10 HASH io[4]=data_ptr, io[5]=len, io[6]=out_ptr → S=ok + * 11 VERIFY_SIG io[4]=data_ptr, io[5]=data_len, io[6]=sig_ptr, io[7]=pubkey_ptr → R=1/0, S=ok + * 12 CROSS_TRANSFER io[4]=dest_key_ptr, io[5]=amount, io[6]=server_ptr → S=ok (buffered) + * 13 LOAD_CODE io[4]=key_ptr → R=code_offset, S=ok/ASSET_NOT_FOUND + * 14 CREATE_ASSET io[4]=key_ptr, io[5]=content_ptr, io[6]=days → S=ok/ASSET_EXISTS + * 15 SEND_CLIENT io[4]=account_prefix_ptr, io[5]=data_ptr, io[6]=data_len → R=sent(1/0), S=ok + * 16 SCHEDULE io[4]=asset_key_ptr, io[5]=budget, io[6]=child_allowance, io[7]=input_ptr, io[8]=input_len, io[9]=time_us → S=ok/QUEUE_FULL/ALLOWANCE_EXCEEDED (parent's allowance decremented by child_allowance on success) + * 17 CREATE_ASSET_MANAGED io[4]=key_ptr, io[5]=content_ptr, io[6]=days → S=ok/ASSET_EXISTS + * 18 RPC io[4..10] = host cell, host len, port, file head, followup program, followup budget, followup tag → S=ok/queue code (see SYS_RPC enum doc) + * 19 OWNER_TRANSFER io[4]=dest_key_ptr, io[5]=amount → S=error_code (drains programOwner, not caller) + * 20 DEPOSIT io[4]=amount → S=error_code (caller → programOwner) + * 21 WITHDRAW io[4]=amount → S=error_code (programOwner → caller) + * 22 UPDATE_ASSET_META io[4]=key_ptr, io[5]=new_owner_ptr, io[6]=new_price → S=error_code (owner/price only, content untouched) + * 25 READ_ALIAS io[4]=alias_id, io[5]=offset, io[6]=len, io[7]=dest_ptr → R=len, S=ok/ALIAS_NOT_FOUND/BAD_INPUT (public windowed read of the value image) + * 26 WRITE_ALIAS io[4]=alias_id, io[5]=offset, io[6]=len, io[7]=src_ptr → S=error_code (patch as programOwner: owner floor 8, editor floor 18; principal-less runs always NOT_OWNER) + * 27 LOAD_CODE_ALIAS io[4]=alias_id → R=code_offset, S=ok/ALIAS_NOT_FOUND (appends the whole ALIAS_INLINE_CODE_BYTES area) + * 28 SCHEDULE_ALIAS io[4]=alias_id, io[5]=budget, io[6]=child_allowance, io[7]=input_ptr, io[8]=input_len, io[9]=time_us → S=ok/QUEUE_FULL/ALLOWANCE_EXCEEDED/ALIAS_NOT_FOUND (target must be ALIAS_OP_INLINE_PROGRAM) + * + * Preloaded io locations (read-only context, set before execution): + * io[752] = input length (bytes) + * io[754] = initial budget (credits) + * io[755] = start time (microseconds since epoch) + * io[756..759] = caller public key (32 bytes) + * io[760..763] = self asset key (32 bytes; all-zero = alias program: + * no boot asset, asset-custody syscalls disabled) + * io[892..1019] = input data (up to 1024 bytes) + * io[1023] = programOwner account prefix (8 bytes; all-zero = no + * principal: allowance-exempt syscalls no-op, alias + * writes rejected) + * + * Program-writable io locations: + * io[753] = output length (bytes, set by program) + * io[764..891] = output data (up to 1024 bytes) + * + * Memory layout (io[0..1023], 8KB): + * [0..15] Registers (PC, R, S, SYSCALL, ARG0-3, GPR0-7) + * [16..751] Program memory (736 cells = 5888 bytes) + * [752..763] Preloaded context (see above) + * [764..891] Output data (128 cells = 1024 bytes) + * [892..1019] Input data (128 cells = 1024 bytes) + * [1020..1023] Reserved + * + * Pointers in syscalls refer to io memory offsets where byte data + * is packed into uint64_t cells (8 bytes per cell, little-endian). + */ + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// Interpreter optimization switches +// ============================================================================ +// Each is independently toggleable at compile time (-DCESVM_OPT_X=0) and must +// be set identically for every TU in a build (they change the CesVM class +// layout). Defaults: everything on; THREADED auto-detects GNU C. +// +// CESVM_OPT_PREDECODE Memoized operand pre-decode (the fast core). Each +// byte offset's instruction is decoded once and +// cached in a side table (epoch-validated per +// execute(), extended on SYS_LOAD_CODE growth). +// Anything the fast core cannot statically decode — +// HOSTV/HOSTXV variable arg lists, operands that +// dereference cell 0 (the mid-instruction PC), +// truncated or invalid encodings — replays through +// the reference interpreter (stepSlow), so +// observable semantics are identical by +// construction. 0 = always run the reference core. +// CESVM_OPT_THREADED Computed-goto dispatch for the fast core (GNU C +// label-address extension; GCC/Clang only, +// auto-off elsewhere). No effect when +// CESVM_OPT_PREDECODE=0. +// CESVM_OPT_BILL_HOIST Precompute COST_PER_OP * gasMult once per +// execute() instead of overflow-guarding the +// multiply on every op. Pure C++. +// CESVM_OPT_FIXED_STACKS Data stack and CALL frame stack as fixed member +// arrays (their depth caps are hard constants) +// instead of std::vectors. Pure C++. +// +// The runtime hook CesVM::_setLegacyCore(true) forces the reference core on +// one instance (used by the differential test and by cesvmbench to measure +// the fast core against the reference in a single binary). + +#ifndef CESVM_OPT_PREDECODE +#define CESVM_OPT_PREDECODE 1 +#endif + +#ifndef CESVM_OPT_THREADED +#if defined(__GNUC__) || defined(__clang__) +#define CESVM_OPT_THREADED 1 +#else +#define CESVM_OPT_THREADED 0 +#endif +#endif +#if CESVM_OPT_THREADED && !(defined(__GNUC__) || defined(__clang__)) +#undef CESVM_OPT_THREADED +#define CESVM_OPT_THREADED 0 +#endif + +#ifndef CESVM_OPT_BILL_HOIST +#define CESVM_OPT_BILL_HOIST 1 +#endif + +// The one switch whose "off" position has a real use case rather than +// being a fallback. On: the data stack and CALL frame stack live inline +// in the object, growing sizeof(CesVM) by ~40 KB (8 KB stack + 32 KB +// frames) on top of the 8 KB io_ array that is inline in every +// configuration; worth ~10-20% on arith/memory workloads. Fine for the +// current usage (one stack-local CesVM per run on logicStrand_). Off: +// both revert to vectors and the object is ~8.5 KB again -- flip this +// for any embedding that holds many CesVM objects alive at once. +#ifndef CESVM_OPT_FIXED_STACKS +#define CESVM_OPT_FIXED_STACKS 1 +#endif + +namespace ces { + +// GVM error codes +enum CesVMError : uint64_t { + CESVM_OK = 0, + CESVM_OPCODE = 1, + CESVM_CODESIZE = 2, + CESVM_DIVZERO = 3, + CESVM_OPLIMIT = 4, + CESVM_UNDERFLOW = 5, + CESVM_RET = 6, + CESVM_SEGFAULT = 7, + CESVM_NEGNUM = 8, // tombstoned; ADD/SUB/MUL all wrap silently now + // (programs check via CMP before the op) + // CES-specific + CESVM_BUDGET = 9, // credit budget exhausted + CESVM_SYSCALL = 10, // invalid syscall number + CESVM_AUTH = 11, // not authorized (e.g. write to non-owned asset) + CESVM_CODEFULL = 12, // code space exhausted (SYS_LOAD_CODE) + CESVM_ABORT = 13, // program aborted (OP_HOSTX on S!=0, OP_ABORT, or OP_ASSERT) + CESVM_HOST = 14, // host callback or VM infrastructure threw + CESVM_OVERFLOW = 15, // checked arithmetic (ADDX/SUBX/MULX) wrapped +}; + +// Syscall numbers, dense range 0..22. +enum CesVMSyscall : uint64_t { + SYS_NOP = 0, + SYS_READ_ACCOUNT = 1, + SYS_TRANSFER = 2, + SYS_READ_ASSET = 3, + SYS_CREATE_ASSET_RANDOM = 4, + SYS_UPDATE_ASSET = 5, + SYS_FUND_ASSET = 6, + SYS_BUY_ASSET = 7, + SYS_GIVE_ASSET = 8, + SYS_SEND_UDP = 9, // tombstoned: returns CES_ERROR_DISABLED + SYS_HASH = 10, + SYS_VERIFY_SIG = 11, + SYS_CROSS_TRANSFER = 12, + SYS_LOAD_CODE = 13, + SYS_CREATE_ASSET = 14, + SYS_SEND_CLIENT = 15, + SYS_SCHEDULE = 16, + SYS_CREATE_ASSET_MANAGED = 17, // caller pays, boot asset owns + // SYS_RPC — MINX/RUDP stream call to an external service. + // The caller pre-writes the request body into a cesh file (typically + // pre-allocated with extra capacity to receive the response), passes + // its head key to SYS_RPC, and the dispatcher: + // - Reads header.fileSize bytes from the file chain as the request. + // - Builds a signed envelope: [u32 BE body_len][body][u64 BE time_us] + // [32 sender_key][32 sha256(body||time||key)][65 signature] + // - Opens an outbound Rudp channel on the server's dedicated rpcMinx_, + // writes the envelope via asio::async_write on a RudpStream, reads + // a [u32 BE body_len][body] response. + // - Writes the response bytes back into the SAME file chain, updating + // header.fileSize to the response length (bounded by chain capacity; + // excess response bytes are silently truncated). + // - Schedules a followup VM program via scheduleRun with a 48-byte + // input: [u32 tag][u32 status][u32 wire_body_len][u32 bytes_written] + // [32 file_head_key]. + // + // io layout: + // io[4] = host cell ptr (cell index of ASCII host/IP bytes) + // io[5] = host length (bytes, max 255) + // io[6] = port (u16) + // io[7] = file_head cell (cell index of a 32-byte cesh file head) + // io[8] = followup cell (cell index of a 32-byte followup VM asset) + // io[9] = followup budget (u64) + // io[10] = followup tag (u32) + // + // Returns CES_OK in S() on successful queue; CES_ERROR_DISABLED if + // the server was started with rpcPort == 0; error codes from the + // file auth / materialization checks otherwise. + SYS_RPC = 18, + // SYS_OWNER_TRANSFER — same shape as SYS_TRANSFER (io[4]=dest cell ptr, + // io[5]=amount), but the source account is programOwner, not the + // caller. The caller still pays the protocol fee (feeTx) because they + // invoked the syscall — only the value-bearing transfer is debited + // from programOwner. NO allowance check applies: this is an unbounded + // spend of programOwner, gated only by the bytecode reaching this + // instruction. See the programOwner field doc (this file) for the + // capability/security model and the /b/dice gating pattern. Returns + // CES_ERROR_ORIGIN_NOT_FOUND if programOwner has no account (e.g. an + // asset-owned chain, or a run with programOwner deliberately empty), + // or CES_ERROR_INSUFFICIENT_BALANCE if the owner can't cover it. + SYS_OWNER_TRANSFER = 19, + // SYS_DEPOSIT — caller -> programOwner. Convenience for the common + // "user funds the asset's owner" pattern (deposits, bets, payments + // for an asset's service). io[4] = amount; both endpoints are + // implicit, so no dest pubkey is needed in io memory or input. + // Allowance-bound (caller is spending their own credits). Returns + // CES_ERROR_ORIGIN_NOT_FOUND if the owner has no account. + SYS_DEPOSIT = 20, + // SYS_WITHDRAW — programOwner -> caller. Convenience for the + // "asset pays its caller" pattern (refunds, payouts, faucets). + // io[4] = amount. NOT allowance-bound: an unbounded spend of + // programOwner, so the bytecode alone stands between it and a drain. + // The bytecode MUST reach this only on a path it authorized (in a + // faucet: only after the service was actually rendered / paid for). + // /b/dice pays 2*bet here but only after a hostx SYS_DEPOSIT proved + // the bet cleared; a plain (non-aborting) collect would let an + // underfunded caller win house money for free. See the programOwner + // field doc for the full model. Returns CES_ERROR_ORIGIN_NOT_FOUND or + // CES_ERROR_INSUFFICIENT_BALANCE per host.withdraw. + SYS_WITHDRAW = 21, + // SYS_UPDATE_ASSET_META — set owner+price on an existing asset + // without touching content. io[4] = key_ptr, io[5] = new_owner_ptr + // (8-byte HashPrefix), io[6] = new_price (uint32). Auth via + // checkAssetWriteAuth (caller, programOwner, or self-asset-key for + // asset-owned chains). Bills feeTx, not feeAsset — matches the wire + // CES_UPDATE_ASSET_META fee tier (cheaper than full content update). + SYS_UPDATE_ASSET_META = 22, + // SYS_REFILL — top up this run's gas budget past the free grant, funded by + // the caller account (used by account hooks: the trigger spends its own + // account's money to do work bigger than the free grant, e.g. write + // history). io[4] = requested gas credits. Returns the amount actually + // granted in R (0 if refill is disabled or the sidecar ceiling / balance is + // exhausted); never aborts. The caller is charged post-run for gas consumed + // past the free grant, outside the undo log so a crash still pays for the + // strand time it used. No-op (grants 0) outside a run whose host set a + // refill ceiling. See local/account_hooks_design.md. + SYS_REFILL = 23, + // SYS_CREATE_ASSET_RANGE — atomically create N account-owned assets at a + // fresh random 24-byte prefix, keyed prefix||0 .. prefix||(N-1) (last 8 bytes + // a native index). Cell 0's content carries uint32_t N at bytes 0..3; all + // cells are otherwise zero. The prefix is opaque entropy, no type tag, and + // the whole batch is collision-checked: any pre-existing target key abandons + // the batch (creating nothing) and retries a fresh prefix. io[4]=N, io[5]=days, + // io[6]=cell index where the 32-byte handle (cell-0 key) is written. Bills N + // asset creations. This is the bare array primitive; sequence membership is + // metadata held by the owner (all cells share one owner), not read from keys. + SYS_CREATE_ASSET_RANGE = 24, + // SYS_READ_ALIAS — public windowed read of an alias's value image + // (owner|editor|op|content; see alias.h layout constants). io[4]=alias id, + // io[5]=offset, io[6]=len, io[7]=dest cell ptr. R=len, S=ok / + // ALIAS_NOT_FOUND (also for an out-of-bounds window) / BAD_INPUT. + // Bills feeQuery. + SYS_READ_ALIAS = 25, + // SYS_WRITE_ALIAS — patch bytes into an alias's value image, acting as the + // run's programOwner principal (the owner of the cell or asset that + // carries this code, when that owner consented to it; see docs/aliases.md + // section 5). Same + // floors as the wire op: owner from ALIAS_PATCH_MIN_OWNER, editor from + // ALIAS_PATCH_MIN_EDITOR. A principal-less run (empty programOwner: any + // gate, a foreign-code hook, bare CES_RUN_ASSET) always gets NOT_OWNER — + // no consenting principal, no write authority; the same emptiness keeps + // gates pure. io[4]=alias id, + // io[5]=offset, io[6]=len, io[7]=src cell ptr. S=error_code. Bills + // feeAlias (one upfront day, wire parity). Undo-log atomic. + SYS_WRITE_ALIAS = 26, + // SYS_LOAD_CODE_ALIAS — append an alias's whole inline code area + // (ALIAS_INLINE_CODE_BYTES, zero-padded so load bases are link-time + // constants) to the code buffer. io[4]=alias id. R=code offset, S=ok / + // ALIAS_NOT_FOUND. Bills feeQuery. The loaded code runs under the current + // run's identities (linking, not calling). + SYS_LOAD_CODE_ALIAS = 27, + // SYS_SCHEDULE_ALIAS — enqueue a future run of an alias's inline program + // (the async alias-to-alias call). Same ABI as SYS_SCHEDULE with io[4] = + // alias id instead of an asset key ptr. The target must be + // ALIAS_OP_INLINE_PROGRAM at queue AND fire time; the fired run gets + // self = 0 and programOwner = the cell's owner (consented code). + SYS_SCHEDULE_ALIAS = 28, + // SYS_L2_CALL — paid, reliable call into an in-CES (L2) service, routed by + // an 8-byte discriminator: the first 8 bytes of sha256(built-in mount name), + // treated as a flat byte array (endian-independent, never a native int). + // io[4] = discriminator cell (the 8 bytes = one io cell) + // io[5] = value (u64, burned from caller on accept) + // io[6] = blob cell (provider-ABI payload, opaque to core) + // io[7] = blob len (bytes) + // io[8] = followup cell (32-byte followup VM asset key; 0 = fire-and-forget) + // io[9] = followup budget (u64) + // io[10]= followup tag (u32) + // The syscall OWNS the burn: a synchronous reject (unmounted discriminator, + // queue full) returns a code and burns nothing; CES_OK atomically burns + // `value` and enqueues a persistent call. Settlement is delivery-based and + // core-owned: delivered -> mint payee; not-delivered (no-handler / timeout) + // -> refund payer. Resolution schedules a followup run (invokeKind = + // INVOKE_L2_RETURN, input [tag][outcome]) unless followup cell is zero. + SYS_L2_CALL = 29, +}; + +// Invocation kind — which entry path started this run. Preloaded into +// io[CESVM_IO_INVOKE_KIND] from host.invokeKind so a program can dispatch on +// how it was invoked. This is the universal VM invocation-context ABI: every +// entry path stamps its value here; hooks are the first extension of it. +// +// APPEND-ONLY. Never renumber or reuse a value: a program built against an +// older ABI treats an unrecognized kind as INVOKE_DIRECT semantics (read +// io[INPUT] as opaque) or, for a gate, default-rejects. INVOKE_DIRECT = 0 so +// zero-initialized io already means "direct call" with no migration. +// +// The hook subrange (16+) carries an event descriptor in io[INPUT] (which is +// free on a hook, there being no user-supplied input): counterparty pubkey, +// amount, resulting balance. Reserved values below are real fill-sites (a named +// event, a fire-site, a descriptor), not placeholders: wiring one later is +// "stamp the value + fill the descriptor", never an ABI break. Full model: +// local/account_hooks_design.md. +enum CesVMInvoke : uint64_t { + INVOKE_DIRECT = 0, // CES_RUN_ASSET (also the zero-init default). + INVOKE_SCHEDULED = 1, // SYS_SCHEDULE fire. + INVOKE_AUTOEXEC = 2, // boot autoexec. + INVOKE_DIRECT_ALIAS = 3, // CES_RUN_ALIAS (public inline-program run). + INVOKE_SCHEDULED_ALIAS = 4, // SYS_SCHEDULE_ALIAS fire. + INVOKE_L2_RETURN = 5, // SYS_L2_CALL resolution (delivered / refunded). + + // Account-hook subrange. + INVOKE_HOOK_XFER_IN = 16, // a local transfer credited this account. + INVOKE_HOOK_XFER_OUT = 17, // this account sent a local transfer. + INVOKE_HOOK_XFER_VM = 18, // a program's SYS_TRANSFER credited this account + // (deferred, never nested). + + // Reserved: real events, not yet wired. See the design doc. + INVOKE_HOOK_MINT = 32, // PoW mint credited this account. + INVOKE_HOOK_ASSET_BUY = 33, // CES_BUY_ASSET paid this account (asset owner). + // Cross-transfer settlement events, for complete transaction histories. + // Both WATCH-ONLY: a landed cross already committed on the origin so it + // cannot be gated (SETTLE_IN), and outbound gating is a porous soft + // guardrail not worth the settlement path (SETTLE_OUT). Fire points are on + // the settlement path (the dest credit / the crossTransfer origin debit), + // NOT the exempt transfer(Open) path. + INVOKE_HOOK_SETTLE_IN = 34, // a cross transfer landed on this account. + INVOKE_HOOK_SETTLE_OUT = 35, // this account sent a cross transfer. +}; + +// Opcode numbers (one byte each). Exposed here as the single source of +// truth: cesvm.cpp's parser, the VmProgram builder (include/ces/vmprogram.h), +// and every test file that checks bytecode bytes directly all pull from +// this header instead of maintaining their own local copies. When a new +// opcode lands it goes here and nowhere else needs to change. +// +// The high bit (0x80) of an opcode byte is the STACK modifier: OR it +// with any opcode that has a stack variant (ADD, SUB, MUL, DIV, MOD, OR, +// AND, ANDL, XOR, NOT, LNOT, SHL, SHR, SAR, EQ, NE, GT, LT, GE, LE, NEG, +// ORL, JF, JT, LDB, STB, RND, TIME, CALL, SLT, SGT, SGE, SLE, ADDX, +// SUBX, MULX, ASSERT) to pop operands from the stack instead of reading +// them from the instruction stream. +enum CesVMOpcode : uint8_t { + OP_NOP = 0, OP_TERM = 1, OP_SET = 2, OP_JMP = 3, + OP_ADD = 4, OP_SUB = 5, OP_MUL = 6, OP_DIV = 7, + OP_MOD = 8, OP_OR = 9, OP_ANDL = 10, OP_XOR = 11, + OP_NOT = 12, OP_SHL = 13, OP_SHR = 14, OP_INC = 15, + OP_DEC = 16, OP_PUSH = 17, OP_POP = 18, OP_AND = 19, + OP_HOST = 20, OP_VPUSH = 21, OP_VPOP = 22, OP_CALL = 23, + OP_RET = 24, OP_JF = 25, OP_JT = 26, OP_EQ = 27, + OP_NE = 28, OP_GT = 29, OP_LT = 30, OP_GE = 31, + OP_LE = 32, OP_NEG = 33, OP_ORL = 34, OP_RND = 35, + OP_TIME = 36, OP_MOV = 37, OP_LDB = 38, OP_STB = 39, + OP_CMP = 40, OP_FIL = 41, OP_HOSTX = 42, OP_ABORT = 43, + // Indirect variants of JMP / CALL. The original OP_JMP and OP_CALL + // read their target as a 2-byte literal via read(true), which means + // the destination is baked into the code at build time. These + // variants read a regular operand (via read()), so the target can + // be a cell index that gets dereferenced at runtime. Needed for + // SYS_LOAD_CODE + call-into-loaded-block patterns where the loaded + // code's offset isn't known until runtime. + OP_JMPR = 44, OP_CALLR = 45, + // Variadic syscall dispatch. Reads (syscall_num, arg_count, arg0..) + // inline and populates io[3] + io[4..4+arg_count-1] in one opcode, + // then calls hostCall. OP_HOSTXV additionally promotes a nonzero S + // on return to CESVM_ABORT, matching OP_HOSTX's semantics. + OP_HOSTV = 46, OP_HOSTXV = 47, + // Arithmetic shift right: sign-extends (preserves the high bit), unlike + // logical SHR (zero-fill). Use for signed values such as int64_t balances. + OP_SAR = 48, + // Logical NOT (!x). Distinct from OP_NOT (bitwise ~x) and OP_NEG (arithmetic). + OP_LNOT = 49, + // Signed comparisons: operands reinterpreted as two's-complement + // int64. The GT/LT/GE/LE family is unsigned and reads the sign bit + // as magnitude; these are correct for signed values such as account + // balances. + OP_SLT = 50, OP_SGT = 51, OP_SGE = 52, OP_SLE = 53, + // Checked unsigned arithmetic: identical to ADD/SUB/MUL except a + // wrap (add/mul overflow, sub borrow) halts with CESVM_OVERFLOW + // instead of producing a wrapped value. Intended as the default for + // money math; the wrapping forms remain for intentional mod-2^64 use. + OP_ADDX = 54, OP_SUBX = 55, OP_MULX = 56, + // Abort (CESVM_ABORT) if the operand is falsy (0). One-opcode form + // of the jt-over-abort guard idiom; complements OP_HOSTX, which is + // the same contract for syscall status. + OP_ASSERT = 57, + // Duplicate the top of the data stack. Stack-only by nature (no + // register form); used when a stack-scheduled value feeds two + // consumers, since stack-mode ops pop their operands. + OP_DUP = 58, +}; + +// Named cell indices for the 16 registers. Useful anywhere a cell +// index is expected — e.g. `set(Imm(R_CELL), Imm(42))` writes 42 to R, +// or `set(Imm(destCell), Ref(R_CELL))` stores the current R value +// into destCell. The register names match the ABI doc comment at +// the top of this file. +static constexpr uint64_t CESVM_CELL_PC = 0; +static constexpr uint64_t CESVM_CELL_R = 1; +static constexpr uint64_t CESVM_CELL_S = 2; +static constexpr uint64_t CESVM_CELL_SYSCALL = 3; +static constexpr uint64_t CESVM_CELL_ARG0 = 4; +static constexpr uint64_t CESVM_CELL_ARG1 = 5; +static constexpr uint64_t CESVM_CELL_ARG2 = 6; +static constexpr uint64_t CESVM_CELL_ARG3 = 7; +static constexpr uint64_t CESVM_CELL_GPR0 = 8; +static constexpr uint64_t CESVM_CELL_GPR1 = 9; +static constexpr uint64_t CESVM_CELL_GPR2 = 10; +static constexpr uint64_t CESVM_CELL_GPR3 = 11; +static constexpr uint64_t CESVM_CELL_GPR4 = 12; +static constexpr uint64_t CESVM_CELL_GPR5 = 13; +static constexpr uint64_t CESVM_CELL_GPR6 = 14; +static constexpr uint64_t CESVM_CELL_GPR7 = 15; + +// Operand control-byte encoding bits (see cesvm.cpp's read() function). +// SHORT_VAL means "the low 6 bits of the control byte ARE the value"; +// absent, the low 6 bits are the number of LE payload bytes that follow. +// REG_PTR wraps either form with "the resulting value is a cell index — +// dereference it to get the real value." +static constexpr uint8_t CESVM_OP_STACK = 0x80; +static constexpr uint8_t CESVM_REG_PTR = 0x80; +static constexpr uint8_t CESVM_SHORT_VAL = 0x40; +static constexpr uint8_t CESVM_MAX_SHORT_VAL = 0x3F; // 6 bits = 0..63 + +static constexpr uint64_t CESVM_IO_SIZE = 1024; +static constexpr uint64_t CESVM_REG_SIZE = 16; +static constexpr uint64_t CESVM_MAX_CODE = 8192; +static constexpr uint64_t CESVM_CODE_BLOCK = 210; + +// Caps on the data stack (OP_PUSH) and call/context stack (OP_CALL register +// frames). The GVM core leaves both unbounded; without a cap a push loop +// exhausts server memory faster than the gas budget halts it. Overflow is +// promoted to CESVM_SEGFAULT, triggering the undo-log rollback. +static constexpr uint64_t CESVM_MAX_STACK_DEPTH = 1024; +static constexpr uint64_t CESVM_MAX_CALL_DEPTH = 256; + +// Upper bound on OP_HOSTV / OP_HOSTXV argument count; a larger count promotes +// to CESVM_SEGFAULT before any io slot is touched. 16 fills io[4..19]. +static constexpr uint64_t CESVM_MAX_HOSTV_ARGS = 16; + +// ============================================================================ +// Memory layout +// ============================================================================ +// +// io[0..1023] is a flat uint64_t cell array with a byte-overlay view via +// OP_LDB / OP_STB. Cells are 8-byte aligned; stored multi-byte integers are +// little-endian. Cells are untyped: a hash and a balance are indistinguishable +// at the ISA level. +// +// The address space is divided into five bands. The short-val operand +// encoding (control byte SHORT_VAL | 6-bit value) reaches io[0..63] in a +// single byte; io[64] and up need a wide operand (2+ bytes), so the low 64 +// cells are the scarce resource. +// +// io[0..15] ── REGISTERS (16 cells, short-encoded) +// PC=io[0], R=io[1], S=io[2], SYSCALL=io[3], +// ARG0..3=io[4..7], GPR0..7=io[8..15]. +// Syscall arguments flow through ARG0..3 and GPR0..3; +// bigger syscalls spill into GPR4..7. +// +// io[16..63] ── LOW REGION / SCRATCH (48 cells, short-encoded) +// The program's working memory for values it references +// often. CONVENTIONS (not enforced by the VM): +// +// io[16..23] ── Hash slots (two 4-cell = 32-byte +// slots). Typical use: io[16..19] for +// a "destination key" argument, io[20..23] +// for a "source key" argument. +// io[24..31] ── Syscall argument staging. When a +// program needs to compose a multi-arg +// syscall, build the args here so each +// OP_SET into the io[4..10] slot region +// uses short-encoded sources. +// io[32..47] ── General scratch. Loop counters, +// temporaries, computed addresses. +// io[48..63] ── Free. Reserved for future conventions. +// +// Programs that copy their inputs from io[CESVM_IO_INPUT] +// (the high region) down into this low band can then +// address them at short-encoded (1-byte) operand cost. +// +// io[64..751] ── WIDE PROGRAM MEMORY (688 cells = 5504 bytes) +// Readable and writable by the program, but every +// reference costs a wide operand. Use for large scratch +// buffers, content blocks, and anything too big for +// the low region. +// +// io[752..891] ── PRELOADED CONTEXT (140 cells, wide-encoded) +// Filled by execute() before the program runs. +// Programs typically copy the parts they need down +// into the low region and work from there rather than +// touching this band directly. Documented slots: +// +// io[752] = input length (bytes, preloaded) +// io[753] = output length (bytes, program writes this) +// io[754] = initial budget (credits, preloaded) +// io[755] = start time (microseconds since epoch, preloaded) +// io[756..759] = caller public key (32 bytes = 4 cells, preloaded) +// io[760..763] = self asset key (32 bytes = 4 cells, preloaded) +// io[764..891] = output data buffer (128 cells, program writes) +// +// io[892..1023] ── INPUT + RESERVED (132 cells) +// io[892..1019] = input data (128 cells, preloaded) +// io[1020] = remaining caller-debit allowance (syscall-synced) +// io[1021] = remaining gas budget (op-synced — see bill()) +// io[1022..1023] = reserved +// +// ---------------------------------------------------------------------------- +// Preloaded context lives in the high region, not the low one +// ---------------------------------------------------------------------------- +// +// The ~140 preloaded cells (caller/self key, input and output buffers) would +// fill io[0..63] and leave no short-encoded scratch for the program. Instead +// they sit high; a program OP_MOVs the few cells it needs into the low region +// at startup and works against the short-encoded copies. The VmProgram +// builder's copyFromInput() / copyCallerKeyTo() / copySelfKeyTo() wrap this. +// +// ---------------------------------------------------------------------------- +// Invariant: execute() never writes io[16..63] +// ---------------------------------------------------------------------------- +// +// execute() writes only io[0..15] (registers) and the preloaded context in +// io[752..1023]. The low scratch region io[16..63] belongs to the program. +// New preloaded values go in the high region or the io[1023] reserve (io[1022] +// is now the invocation kind), never the low region, which would clobber +// programs relying on it as scratch. +// ============================================================================ +static constexpr uint64_t CESVM_IO_INPUT_LEN = 752; +static constexpr uint64_t CESVM_IO_OUTPUT_LEN = 753; +static constexpr uint64_t CESVM_IO_BUDGET = 754; +static constexpr uint64_t CESVM_IO_START_TIME = 755; +static constexpr uint64_t CESVM_IO_CALLER_KEY = 756; +static constexpr uint64_t CESVM_IO_SELF_KEY = 760; +static constexpr uint64_t CESVM_IO_OUTPUT = 764; +static constexpr uint64_t CESVM_IO_INPUT = 892; +static constexpr uint64_t CESVM_IO_ALLOWANCE = 1020; +// Gas budget remaining — programs can read this to bail gracefully +// before running out of budget mid-operation. Mirrored by bill() after +// every op, so the value is always current as of the last instruction +// that successfully billed. Symmetric with CESVM_IO_ALLOWANCE but +// updated at op granularity instead of syscall granularity because +// budget is consumed by every op, not just syscalls. +static constexpr uint64_t CESVM_IO_BUDGET_REMAINING = 1021; +// Invocation kind: which entry path started this run (a CesVMInvoke value). +// execute() preloads it from host.invokeKind. Zero-init means INVOKE_DIRECT, +// so a program that never reads this cell behaves exactly as before. Programs +// that do read it dispatch on how they were invoked (direct call, scheduled, +// autoexec, account hook). See CesVMInvoke. +static constexpr uint64_t CESVM_IO_INVOKE_KIND = 1022; +// The run's programOwner principal (8-byte account prefix; all-zero = no +// principal). Informational copy for the program; authorization always reads +// the host-side field, never this cell. +static constexpr uint64_t CESVM_IO_PROGRAM_OWNER = 1023; +static constexpr uint64_t CESVM_MAX_INPUT = 1024; +static constexpr uint64_t CESVM_MAX_OUTPUT = 1024; + +// SYS_CREATE_ASSET_RANGE: max cells per atomic range, and collision retries. +// The 24-byte prefix carries 2^192 entropy, so one attempt effectively always +// wins; retries only guard the astronomically rare collision. +static constexpr uint64_t CESVM_MAX_ASSET_RANGE = 256; +static constexpr uint64_t CESVM_ASSET_RANGE_RETRIES = 4; + +// --- Gas cost constants --- +// Anchor: 1 gas unit = 0.1 ns of logic-strand time, measured on a release +// build with cesvmbench (methodology and full tables: docs/cesvm-perf.md). +// Every constant below is its measured wall cost expressed in that unit, +// so gas is uniform across opcodes, syscall dispatch, bulk memory, hashing, +// and EC verify: burning N units always buys ~N/10 ns of strand time. +// Ledger mutations inside syscalls are NOT priced here; they bill the same +// protocol fees as the wire ops (feeTx, feeQuery, ...) via billCredits. +// +// Cost per VM instruction in gas units (~10 ns: wide-operand dispatch; +// short-val code runs faster and is deliberately not discounted) +static constexpr uint64_t CESVM_COST_PER_OP = 100; +// Cost per syscall (dispatch + host vtable, on top of per-op cost; ~15 ns) +static constexpr uint64_t CESVM_COST_PER_SYSCALL = 150; +// Cost for compute-only syscalls (HASH init, no ledger I/O; ~50 ns) +static constexpr uint64_t CESVM_COST_PER_MEMOP = 500; +// Per-cell cost for variable-length memory opcodes (MOV, CMP, FIL; ~0.24 ns) +static constexpr uint64_t CESVM_COST_PER_CELL = 3; +// Per-byte cost for variable-length data processing (HASH, VERIFY_SIG; +// SHA256 measures ~0.46 ns/byte) +static constexpr uint64_t CESVM_COST_PER_BYTE = 5; +// EC signature verification cost (ED25519 verify measures ~30 us) +static constexpr uint64_t CESVM_COST_VERIFY_EC = 300000; +// Fixed penalty on VM crash (deducted from refund, not from budget) +static constexpr uint64_t CESVM_CRASH_FEE = 1000000; +// Cost to schedule a delayed runAsset (base + per second of hosting) +static constexpr uint64_t CESVM_SCHEDULE_BASE_COST = CESVM_COST_PER_OP * 100; // 10000 +static constexpr uint64_t CESVM_SCHEDULE_PER_SEC = CESVM_COST_PER_OP; // 100 + +// Account-hook free grant = MINIMUM COMPUTE: the fixed gas budget every trigger +// run starts with, sized as N instructions + M asset reads (not as a fraction +// of any fee), converted to a credit budget at the live gasMult so a screen +// always fits regardless of load. Too small to compute with (a screen, not a +// program), so it is non-launderable via self-transfer no matter the nominal +// credits. At stock fees this lands ~1.1x feeTx (screening an event costs about +// a transaction). See local/account_hooks_design.md. +// grant = OPS*COST_PER_OP*gasMult + READS*(COST_PER_SYSCALL*gasMult + feeQuery) +static constexpr uint64_t CESVM_HOOK_GRANT_OPS = 50; +static constexpr uint64_t CESVM_HOOK_GRANT_READS = 4; + +struct CesVMResult { + uint64_t error = CESVM_OK; + uint64_t opsExecuted = 0; + uint64_t budgetUsed = 0; + ces::Bytes output; +}; + +// The execution environment provided by the CES server. Virtual interface; +// the VM dispatches every syscall through these methods. Production: see +// CesServerVmHost in src/ceslib/server.cpp — overrides every method with +// real ledger mutations. Default implementations throw std::logic_error so +// any uncovered method becomes loud rather than silent. Tests that only +// need data members (allowance, callerKey, ...) can default-construct; +// tests that need behavior subclass and override. +// +// Atomicity is the server's job, via an undo log outside the VM. +class CesVMHost { +public: + virtual ~CesVMHost() = default; + + // Throws std::logic_error("CesVMHost: not implemented"). Every + // default virtual delegates here; production override or per-test + // subclass replaces them. + [[noreturn]] static void notImpl(const char* name) { + throw std::logic_error(std::string("CesVMHost: ") + name + " not implemented"); + } + + // ---- Reads --------------------------------------------------------------- + virtual int64_t readAccountBalance(const HashPrefix&) + { notImpl("readAccountBalance"); } + virtual uint32_t readAccountNonce (const HashPrefix&) + { notImpl("readAccountNonce"); } + virtual uint32_t readAccountAliasId(const HashPrefix&) + { notImpl("readAccountAliasId"); } + virtual bool readAsset(const minx::Hash&, HashPrefix&, AssetData&, + uint16_t&, uint32_t&) + { notImpl("readAsset"); } + // Windowed read of an alias's value image into `dest` (len bytes at + // offset). Returns false for an unknown id or an out-of-bounds window. + // Backs SYS_READ_ALIAS and SYS_LOAD_CODE_ALIAS. + virtual bool readAlias(uint32_t /*id*/, uint32_t /*offset*/, + uint32_t /*len*/, uint8_t* /*dest*/) + { notImpl("readAlias"); } + + // ---- Writes — return CES_OK on success, error code otherwise ------------ + virtual uint8_t transfer (const minx::Hash&, uint64_t) + { notImpl("transfer"); } + // Same credit path as `transfer`, but the source is programOwner, not + // the caller. NO allowance check (unbounded spend of programOwner); + // bytecode-gated. See the programOwner field doc. Backs SYS_OWNER_TRANSFER. + virtual uint8_t ownerTransfer (const minx::Hash&, uint64_t) + { notImpl("ownerTransfer"); } + // caller -> programOwner. Allowance-bound. Backs SYS_DEPOSIT. + virtual uint8_t deposit (uint64_t) { notImpl("deposit"); } + // programOwner -> caller. NO allowance check (unbounded spend of + // programOwner); bytecode-gated. See the programOwner field doc. + // Backs SYS_WITHDRAW. + virtual uint8_t withdraw (uint64_t) { notImpl("withdraw"); } + // Grant up to `requested` gas credits, funded by the caller account, bounded + // by the refill ceiling and the account balance. Returns the amount granted; + // the VM adds it to its budget. Default 0 (refill disabled). Backs + // SYS_REFILL. The host tracks the running total in `refilledTotal` so + // executeVmRun can charge for gas consumed past the free grant. + virtual uint64_t refillGas (uint64_t /*requested*/) { return 0; } + virtual uint8_t createAsset (const minx::Hash&, const AssetData&, uint16_t) + { notImpl("createAsset"); } + // Caller pays, boot asset owns. + virtual uint8_t createAssetManaged(const minx::Hash&, const AssetData&, uint16_t) + { notImpl("createAssetManaged"); } + // Atomically create `n` account-owned assets keyed by the given first key + // with its last 8 bytes overwritten 0..n-1 (LE). Returns CES_ERROR_ASSET_EXISTS + // creating nothing if any target key exists (caller retries a fresh prefix); + // CES_OK on success. Cell 0's content carries uint32_t n at bytes 0..3. + virtual uint8_t createAssetRange(const minx::Hash&, uint32_t, uint16_t) + { notImpl("createAssetRange"); } + virtual uint8_t updateAsset (const minx::Hash&, const AssetData&) + { notImpl("updateAsset"); } + // Owner+price-only update; content untouched. Backs SYS_UPDATE_ASSET_META. + virtual uint8_t updateAssetMeta(const minx::Hash&, const HashPrefix&, uint32_t) + { notImpl("updateAssetMeta"); } + virtual uint8_t fundAsset (const minx::Hash&, uint16_t) + { notImpl("fundAsset"); } + virtual uint8_t buyAsset (const minx::Hash&, uint64_t) + { notImpl("buyAsset"); } + virtual uint8_t giveAsset (const minx::Hash&, const HashPrefix&) + { notImpl("giveAsset"); } + // Patch an alias's value image as the run's programOwner principal (see + // SYS_WRITE_ALIAS). Returns a CES error code. + virtual uint8_t writeAlias (uint32_t /*id*/, uint32_t /*offset*/, + const uint8_t*, uint32_t /*len*/) + { notImpl("writeAlias"); } + // Schedule a future runAsset. `allowance` is the per-run caller-debit cap + // the future run will see — the syscall handler snapshots `host.allowance` + // at queue time so the child inherits the parent's remaining headroom. + virtual uint8_t schedule (const minx::Hash&, uint64_t /*budget*/, + uint64_t /*allowance*/, + const uint8_t*, size_t, uint64_t /*time_us*/) + { notImpl("schedule"); } + // Schedule a future run of an alias's inline program (SYS_SCHEDULE_ALIAS). + virtual uint8_t scheduleAlias (uint32_t /*aliasId*/, uint64_t /*budget*/, + uint64_t /*allowance*/, + const uint8_t*, size_t, uint64_t /*time_us*/) + { notImpl("scheduleAlias"); } + // SYS_RPC — fire-and-forget MINX/RUDP stream call. The dispatcher reads + // the request body from the cesh file at `fileHeadKey`, signs a footer + // envelope (see the SYS_RPC enum comment), ships it to (host, port) on + // the server's dedicated rpcMinx_, reads the response, writes it back + // into the same file, and schedules a followup VM run with the outcome. + // Returns a CES error code for the queue result (CES_OK = queued; the + // actual call outcome arrives later via the followup). + virtual uint8_t rpc (const std::string&, uint16_t, + const minx::Hash&, const minx::Hash&, + uint64_t, uint32_t) + { notImpl("rpc"); } + + // SYS_L2_CALL — route to an in-CES (L2) built-in by 8-byte discriminator, + // burn `value` from the caller on accept, enqueue a persistent call, and + // settle on delivery (delivered -> mint payee; no-handler / timeout -> + // refund payer). Zero followupKey = fire-and-forget. Returns the queue + // result (CES_OK = accepted + burned; reject codes burn nothing). + virtual uint8_t l2call (const uint8_t* /*discriminator8*/, + uint64_t /*value*/, + const uint8_t*, size_t /*blob*/, + const minx::Hash& /*followupKey*/, + uint64_t /*followupBudget*/, + uint32_t /*followupTag*/) + { notImpl("l2call"); } + + // ---- Caller debit chokepoint -------------------------------------------- + // For *spending* (transfer amounts, asset purchase prices, cross-transfer + // amounts). Allowance-bound: the user signed an `allowance` value when + // invoking CES_RUN_ASSET, and these debits collectively cannot exceed it. + // Returns: + // CES_OK on success + // CES_ERROR_ORIGIN_NOT_FOUND caller account is gone + // CES_ERROR_INSUFFICIENT_BALANCE caller exists but lacks the credits + // CES_ERROR_ALLOWANCE_EXCEEDED debit would exceed `allowance` + // Used internally by `transfer`, `crossTransfer`, `buyAsset`. Undo-log + // tracked under CES_RUN_ASSET. *Protocol fees* (feeTx, feeQuery, + // feeAsset rent, etc.) do NOT go through this entry — they're billed + // against the run's `budget` (pre-paid at CES_RUN_ASSET time); see + // CesVM::billCredits. + virtual uint8_t debitCaller (uint64_t) { notImpl("debitCaller"); } + + // ---- Deferred side-effects ---------------------------------------------- + // Server buffers in CES_RUN_ASSET path; fires immediately in the + // scheduled-run path. + virtual void sendUdp (const std::string&, uint16_t, + const uint8_t*, size_t) + { notImpl("sendUdp"); } + // Returns CES_OK on successful queue-and-debit, or a specific error: + // CES_ERROR_UNKNOWN_PEER (no reachable peer for `server`), + // CES_ERROR_QUEUE_FULL (settlement client backpressure), + // or whatever debitCaller surfaced (INSUFFICIENT_BALANCE, ALLOWANCE_EXCEEDED, + // ORIGIN_NOT_FOUND). Programs branch on the result; SYS_CROSS_TRANSFER + // mirrors it into S. + virtual uint8_t crossTransfer (const minx::Hash&, uint64_t, + const std::string&) + { notImpl("crossTransfer"); } + + // Push to connected client (APPLICATION message via presence cache). + // Returns true if client was found and message sent. + virtual bool sendClient (const HashPrefix&, const uint8_t*, size_t) + { notImpl("sendClient"); } + + // ---- Crypto ------------------------------------------------------------- + virtual bool verifySig (const uint8_t*, size_t, + const uint8_t*, const uint8_t*) + { notImpl("verifySig"); } + + // ============================================================================ + // Per-run context (data members; populated before execute()). + // ============================================================================ + + // Per-operation protocol fees, populated from CesConfig at construction. + // Syscall handlers bill these so the gas-billed compute cost stays separate + // from the ledger-mutation fee that the VM and the UDP path must agree on. + uint64_t feeQuery = 0; + uint64_t feeTx = 0; + uint64_t feeAsset = 0; + uint64_t feeAccount = 0; + uint64_t feeAlias = 0; // per-patch upfront day (SYS_WRITE_ALIAS) + uint64_t feeSendClient = 0; // no UDP equivalent — see CesServerVmHost ctor + + // Inputs for the per-day attenuated prepay-cost math (CREATE/FUND asset). + // feeAssetRaw is the undiscounted feeAsset; assetRentMultBp is the current + // AssetRent multiplier in basis points 0..10000. SYS_FUND_ASSET / + // SYS_CREATE_ASSET* compute total cost inline using these plus + // kPrepaidDiscountWindowDays — no extra hooks needed. + uint64_t feeAssetRaw = 0; + uint16_t assetRentMultBp = 10000; + + // Per-run cap on total caller-account debit through `debitCaller`. Initial + // value is mirrored into io[CESVM_IO_ALLOWANCE] at execute() entry and is + // decremented (and re-synced into io memory after every syscall) as the run + // progresses, so VM programs can branch on remaining allowance. The default + // (UINT64_MAX) is the "no enforcement" sentinel — set explicitly by callers + // who want to cap how much a gateway program can spend on their behalf. + // Gas budget is *not* counted here; it has its own cap (`budget`). + uint64_t allowance = std::numeric_limits::max(); + + // Refill (SYS_REFILL). `refillCeiling` caps how much gas the run may draw + // from the caller account past the free grant (0 = refill disabled). + // `refilledTotal` is the running granted total, read by executeVmRun to + // charge the caller for gas consumed past the free grant. + uint64_t refillCeiling = 0; + uint64_t refilledTotal = 0; + + // Which entry path started this run (a CesVMInvoke value). execute() + // preloads it into io[CESVM_IO_INVOKE_KIND]. Default INVOKE_DIRECT so the + // wire CES_RUN_ASSET path needs no change; scheduled/autoexec/hook paths set + // it explicitly. + uint64_t invokeKind = INVOKE_DIRECT; + + // Context + minx::Hash callerKey; + minx::Hash selfAssetKey; // the asset being executed (boot cell) + // The account this run may spend from WITHOUT an allowance check: + // SYS_OWNER_TRANSFER and SYS_WITHDRAW debit it, SYS_DEPOSIT credits + // it, and checkAssetWriteAuth lets the run rewrite its assets. It is a + // CAPABILITY (a consenting principal), not merely a provenance record, + // even though today it happens to equal the boot asset's current owner. + // + // SECURITY: this is a loaded gun. Any code path that reaches an + // allowance-exempt syscall spends this account, and the caller may be + // a stranger. The safety model is that the bytecode gates those + // syscalls itself; the field is only sound when the party it names has + // genuinely consented to this exact bytecode. In CES_RUN_ASSET that + // holds because deploying the asset IS the consent, and the deployer + // wrote the gate. /b/dice (buildDiceVmProgram in server.cpp) is the + // reference: it collects the bet with a hostx (abort-on-failure) + // SYS_DEPOSIT before it can ever reach the SYS_WITHDRAW payout, so an + // underfunded caller aborts the run instead of winning house money. + // + // A future caller that runs bytecode the named account did NOT consent + // to (e.g. an account-attached hook pointing at a stranger's program) + // MUST leave this empty; a zero prefix finds no account, so every + // allowance-exempt syscall no-ops and the run can only spend the + // caller via the allowance-bounded path. + HashPrefix programOwner{}; + ces::Bytes input; +}; + +class CesVM { +public: + CesVM(); + + // Execute bytecode with the given host environment and credit budget. + // gasMult: server-configured multiplier applied to all gas costs. + CesVMResult execute(const ces::Bytes& code, + CesVMHost& host, uint64_t budget, + uint64_t gasMult = 1); + + // Test/bench hook: force the reference (non-predecoded) interpreter core + // on this instance. The reference core defines the semantics; the fast + // core must match it observably. No effect when CESVM_OPT_PREDECODE=0 + // (the reference core is all there is). + void _setLegacyCore(bool legacy) { legacyCore_ = legacy; } + +private: + // GVM core + uint64_t io_[CESVM_IO_SIZE]; + +#if CESVM_OPT_FIXED_STACKS + // Both stacks have hard depth caps, so fixed member arrays beat vectors + // (no capacity checks or heap traffic on the hot push/pop path). Contents + // above the length watermark are never read. + uint64_t stackBuf_[CESVM_MAX_STACK_DEPTH]; + uint32_t stackLen_ = 0; + std::array ctxBuf_[CESVM_MAX_CALL_DEPTH]; + uint32_t ctxLen_ = 0; +#else + std::vector stack_; + std::vector> context_; +#endif + + // Named register refs + uint64_t& PC() { return io_[0]; } + uint64_t& R() { return io_[1]; } + uint64_t& S() { return io_[2]; } + + // Operand decoding (from GVM) + uint64_t read(bool jumpSkipControl = false); + + uint64_t& get(uint64_t index) { + if (index < CESVM_IO_SIZE) { + return io_[index]; + } + term_ = CESVM_SEGFAULT; + return R(); + } + + // Hard cap on the data stack to close the "push in a tight loop and + // burn server memory faster than the gas budget can stop it" DoS + // vector. Overflow is promoted to CESVM_SEGFAULT, which the server's + // undo log rolls back the same as any other VM crash. + void push(uint64_t v) { +#if CESVM_OPT_FIXED_STACKS + if (stackLen_ >= CESVM_MAX_STACK_DEPTH) { + term_ = CESVM_SEGFAULT; + return; + } + stackBuf_[stackLen_++] = v; +#else + if (stack_.size() >= CESVM_MAX_STACK_DEPTH) { + term_ = CESVM_SEGFAULT; + return; + } + stack_.push_back(v); +#endif + } + + uint64_t pop() { +#if CESVM_OPT_FIXED_STACKS + if (stackLen_ == 0) { + term_ = CESVM_UNDERFLOW; + return 0; + } + return stackBuf_[--stackLen_]; +#else + if (stack_.empty()) { + term_ = CESVM_UNDERFLOW; + return 0; + } + uint64_t v = stack_.back(); + stack_.pop_back(); + return v; +#endif + } + + void stackClear() { +#if CESVM_OPT_FIXED_STACKS + stackLen_ = 0; +#else + stack_.clear(); +#endif + } + + // CALL frame stack. ctxPush snapshots the 16 registers (including the + // already-advanced PC = return address); false means the call-depth cap + // was hit and CESVM_SEGFAULT is set. ctxRestorePop restores the caller's + // registers and drops the frame. + bool ctxPush() { +#if CESVM_OPT_FIXED_STACKS + if (ctxLen_ >= CESVM_MAX_CALL_DEPTH) { + term_ = CESVM_SEGFAULT; + return false; + } + std::memcpy(ctxBuf_[ctxLen_++].data(), &io_[0], + sizeof(uint64_t) * CESVM_REG_SIZE); +#else + if (context_.size() >= CESVM_MAX_CALL_DEPTH) { + term_ = CESVM_SEGFAULT; + return false; + } + std::array regs; + std::memcpy(regs.data(), &io_[0], sizeof(uint64_t) * CESVM_REG_SIZE); + context_.push_back(regs); +#endif + return true; + } + + bool ctxEmpty() const { +#if CESVM_OPT_FIXED_STACKS + return ctxLen_ == 0; +#else + return context_.empty(); +#endif + } + + void ctxRestorePop() { +#if CESVM_OPT_FIXED_STACKS + std::memcpy(&io_[0], ctxBuf_[--ctxLen_].data(), + sizeof(uint64_t) * CESVM_REG_SIZE); +#else + std::memcpy(&io_[0], context_.back().data(), + sizeof(uint64_t) * CESVM_REG_SIZE); + context_.pop_back(); +#endif + } + + void ctxClear() { +#if CESVM_OPT_FIXED_STACKS + ctxLen_ = 0; +#else + context_.clear(); +#endif + } + + // CES syscall dispatch + void hostCall(CesVMHost& host); + + // Interpreter cores. stepSlow executes exactly one instruction at PC + // (base per-op cost already billed by the caller) and is the reference + // semantics; runLegacy loops it. runFast is the predecoded core, which + // dispatches anything it can't fast-path back through stepSlow. + void stepSlow(CesVMHost& host); + void runLegacy(CesVMHost& host, CesVMResult& result); +#if CESVM_OPT_PREDECODE + struct Decoded { + uint32_t epoch = 0; // valid iff == epoch_ + uint16_t h = 0; // fast-handler index (0 = replay via stepSlow) + uint8_t regptr = 0; // bit i: operand i is a cell dereference + uint8_t nops = 0; + uint16_t nextPc = 0; // byte offset after the full instruction + uint16_t target = 0; // JMP/CALL/JF/JT literal jump target + uint64_t val[3] = {}; // operand immediates / static cell indices + }; + void decodeAt(uint64_t pc, Decoded& d); + void runFast(CesVMHost& host, CesVMResult& result); + std::vector dtab_; // indexed by byte offset; lazily decoded + uint32_t epoch_ = 0; // bumped per execute(); validates dtab_ entries +#endif + bool legacyCore_ = false; + + // Billing: deduct cost from budget, set CESVM_BUDGET on insufficient funds. + // Returns true if budget was sufficient, false if execution should stop. + bool bill(uint64_t cost); + // Overflow-safe bill(a * b): rejects if the multiplication would wrap. + bool billMul(uint64_t a, uint64_t b); + // Bill raw credits against the budget without applying gasMult — used + // for protocol fees (feeTx, feeQuery, etc.) which are denominated in + // credits already, not in gas units. Same halt semantics as bill(). + bool billCredits(uint64_t raw); + + // The per-op base cost, billed once per instruction by both cores. + // With CESVM_OPT_BILL_HOIST the COST_PER_OP * gasMult product (and its + // overflow guard, which halts as budget-exhausted, matching bill()) is + // computed once per execute() instead of per op. + bool billOp() { +#if CESVM_OPT_BILL_HOIST + if (opCostOvf_ || opCost_ > budget_ - budgetUsed_) { + term_ = CESVM_BUDGET; + io_[CESVM_IO_BUDGET_REMAINING] = 0; + return false; + } + budgetUsed_ += opCost_; + io_[CESVM_IO_BUDGET_REMAINING] = budget_ - budgetUsed_; + return true; +#else + return bill(CESVM_COST_PER_OP); +#endif + } + + // Helper: read bytes from io memory at offset into a buffer + void readIoBytes(uint64_t ioOffset, uint8_t* out, size_t len); + // Helper: write bytes to io memory at offset + void writeIoBytes(uint64_t ioOffset, const uint8_t* data, size_t len); + + uint64_t term_ = 0; + uint64_t budget_ = 0; + uint64_t budgetUsed_ = 0; + uint64_t gasMult_ = 1; + uint64_t opCost_ = CESVM_COST_PER_OP; + bool opCostOvf_ = false; + ces::Bytes code_; // mutable code buffer (grows via SYS_LOAD_CODE) + std::mt19937_64 rng_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/client.h b/vendor/ces/include/ces/client.h new file mode 100644 index 0000000..84329ec --- /dev/null +++ b/vendor/ces/include/ces/client.h @@ -0,0 +1,533 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { + +class CesClient : public minx::MinxListener { +public: + /** Construct in UDP mode (direct connection to server). The default Minx + * config is server-sized (a ~32MB recv ring); a memory-constrained or + * one-shot caller can pass a lighter config (e.g. a small recvBuffersSize). */ + CesClient(const boost::asio::ip::udp::endpoint& serverEndpoint, + bool useDataset = true, + const minx::MinxConfig& config = minx::MinxConfig{"cl"}); + + /** Construct in TCP proxy mode (connection through a MinxProxy). */ + CesClient(const boost::asio::ip::tcp::endpoint& proxyEndpoint, + bool useDataset = true); + + virtual ~CesClient(); + + void setKey(const KeyPair& keyPair); + void setTries(int tries) { tries_ = tries > 0 ? tries : 1; } + int getTries() const { return tries_; } + // Per-attempt reply timeout, which doubles as the sleep between connect + // retries (ms). Default 3000; a fast-fail caller (e.g. a crawler that must + // not block 3s on a dead host) lowers it. Clamped to >= 1. + void setRetryIntervalMs(int ms) { retryIntervalMs_ = ms > 0 ? ms : 1; } + int getRetryIntervalMs() const { return retryIntervalMs_; } + bool start(uint16_t localPort = 0); + bool stop(); + bool connect(); + bool disconnect(); + + // Move this (started) client to a different remote, reusing the transport. + // The udp overload moves to a new server; the tcp overload reconnects to a + // new proxy. Both disconnect() to drop per-peer session state; the udp + // overload also moves the inbound fence (currentTarget_). Returns false in + // the wrong mode or, for tcp, on reconnect failure. Call connect() after. + bool setRemoteEndpoint(const boost::asio::ip::udp::endpoint& serverEndpoint); + bool setRemoteEndpoint(const boost::asio::ip::tcp::endpoint& proxyEndpoint); + bool getInfo(); + + int proveWork(const minx::MinxProveWork& msg, minx::Hash& beneficiary, + uint64_t& creditAmount, uint64_t& time); + + /** + * Query the state of a given account. + * @return CES_OK, CES_ERROR_NOT_FOUND, or CES_ERROR_TIMEOUT/INTERNAL + */ + uint8_t queryAccount(const ces::HashPrefix& accountMapKey, int64_t& balance, + uint32_t& nonce, + ces::HashPrefix& lastXferDest, + uint64_t& lastXferAmount, + uint32_t& lastXferTime); + uint8_t queryAccount(const ces::HashPrefix& accountMapKey, int64_t& balance, + uint32_t& nonce); + + /** + * Signed query for account details. + * @return CES_OK, protocol error, or CES_ERROR_TIMEOUT/INTERNAL + */ + uint8_t queryAccountSigned(const ces::HashPrefix& accountMapKey, + uint8_t items, + std::vector& accounts); + + /** + * Safe transfer — fails if destination account doesn't exist. + * @return CES_OK on success, CES_ERROR_TARGET_NOT_FOUND, or other error. + */ + uint8_t transfer(const ces::Hash& dest, uint64_t amount, + int64_t& newOriginBal); + + /** + * Open transfer — auto-creates destination account if it doesn't exist. + * @return CES_OK on success, otherwise specific error code. + */ + uint8_t openTransfer(const ces::Hash& dest, uint64_t amount, + int64_t& newOriginBal); + + /** + * Create payment account — creates dest with negative balance (amount owed). + * Fails if destination already exists. + * @return CES_OK on success, CES_ERROR_INVALID_TARGET_ACCOUNT if exists. + */ + uint8_t createPayment(const ces::Hash& dest, uint64_t amount, + uint8_t days, int64_t& newOriginBal); + + /** + * Cross-server transfer — send credits to a key on a peer server. + * The local server handles the settlement. + * @return CES_OK, CES_ERROR_UNKNOWN_PEER, CES_ERROR_QUEUE_FULL, + * or standard error. + */ + uint8_t crossTransfer(const ces::Hash& dest, uint64_t amount, + const std::string& destServer, + int64_t& newOriginBal); + + // successfulCount sentinel for "outcome unknown" (a TIMEOUT: request sent, + // reply lost). Distinct from 0 (= none applied). Real counts are 0..MAX_ITEMS. + static constexpr uint8_t BULK_COUNT_UNKNOWN = 0xFF; + + // successfulCount: on CES_OK the true number applied (partial possible); on + // CES_ERROR_TIMEOUT BULK_COUNT_UNKNOWN; on other errors 0. + uint8_t bulkTransfer(const std::vector& transfers, + int64_t& newOriginBal, uint8_t& successfulCount); + + /** + * Mine a PoW solution. + * @param extraDifficulty Added to server's minDiff. + * @param appData Optional application data (map). + * Serialized into the PoW data field; hdata = SHA256 of that payload. + * An empty map hashes the empty payload (a fixed, non-zero digest). + */ + std::optional mine( + const uint8_t extraDifficulty, + const std::map& appData = {}, + int numThreads = 1, + uint64_t startNonce = 0, + uint64_t maxIters = 0); + + // --- Asset Operations (Return uint8_t status code) --- + + uint8_t createAsset(const Hash& assetId, const AssetData& content, + uint16_t days, bool private_ = false, + bool immutable = false, bool ownerPays = false); + // Atomically create `count` account-owned cells at firstKey||0..count-1 + // (firstKey is prefix||0). Success means all cells exist. + uint8_t createAssetRange(const Hash& firstKey, uint32_t count, uint16_t days); + uint8_t setAssetOwnerPays(const Hash& assetId, bool ownerPays); + + uint8_t updateAsset(const Hash& assetId, const HashPrefix& newOwner, + const AssetData& content, uint32_t price); + + uint8_t updateAssetMeta(const Hash& assetId, const HashPrefix& newOwner, + uint32_t price); + + uint8_t updateAssetFast(const Hash& assetId, const AssetData& content); + + uint8_t fundAsset(const Hash& assetId, uint16_t days); + + uint8_t buyAsset(const Hash& assetId, uint64_t amount); + + uint8_t giveAsset(const Hash& assetId, const HashPrefix& newOwner); + + /** + * Execute VM bytecode on an asset. + * @param allowance Per-run cap on caller-account debits inside the VM + * (transfers, asset purchases, protocol fees). UINT64_MAX = no + * enforcement. Use a smaller value when running an untrusted gateway + * program that should not be able to drain more than a budgeted amount + * from the caller's account beyond the gas budget itself. + * @return CES_OK on VM success, CES_ERROR_VM_FAILED on VM error, or protocol error. + * On success, outVmError=CESVM_OK. On VM failure, outVmError has the VM error code. + */ + uint8_t runAsset(const Hash& assetId, uint64_t budget, + const ces::Bytes& input, + uint64_t& outVmError, uint64_t& outBudgetUsed, + ces::Bytes& outOutput, + bool nonceless = false, + uint64_t allowance = + std::numeric_limits::max()); + + // Allowance consumed by the most recent runAsset() reply. Always + // 0 when the call used the unlimited-allowance sentinel. + uint64_t getLastRunAssetAllowanceUsed() const { + return runAssetResultAllowanceUsed_; + } + + // Execute an alias's inline program (ALIAS_OP_INLINE_PROGRAM cell). Same + // contract as runAsset with an alias-id target: caller pays gas, allowance + // caps caller-side spend; the run acts as the cell's owner for alias + // writes and the allowance-exempt syscalls. + uint8_t runAlias(uint32_t aliasId, uint64_t budget, + const ces::Bytes& input, + uint64_t& outVmError, uint64_t& outBudgetUsed, + ces::Bytes& outOutput, + bool nonceless = false, + uint64_t allowance = + std::numeric_limits::max()); + + // Alias id linked to the account in the most recent queryAccount() reply + // (0 = none). + uint32_t getLastQueryAccountAliasId() const { return accQueryAliasId_; } + + // Originate a gossip from this client. The home server floods the message + // across the peer mesh and charges this client's account the first-hop relay + // fee (a burn -- a client has no ledger to receive the conserved leg). dest + // all-zero = broadcast, otherwise a targeted route. Returns the home server's + // rcode (CES_OK = accepted and injected). + uint8_t gossip(const ces::Bytes& msg, uint64_t budget, const Hash& dest); + + /** + * Query asset. + * @return CES_OK, CES_ERROR_ASSET_NOT_FOUND, etc. + */ + uint8_t queryAssetSigned(const Hash& assetId, uint8_t items, + std::vector& assets); + + /** + * Query asset (Unsigned/Public). + * @param outBalance raw 16-bit balance — bits 0..12 = days remaining + * (max 8191); bit 13 = immutable; bit 14 = asset-owned; bit 15 = + * private. Use `assetDays(b)` for the day count and the + * `isAsset{Private,Owned,Immutable}(b)` predicates for flags. + * @return CES_OK, CES_ERROR_NOT_FOUND, or CES_ERROR_TIMEOUT + */ + uint8_t queryAsset(const Hash& assetId, HashPrefix& outOwner, + AssetData& outContent, uint16_t& outBalance, + uint32_t& outPrice); + + // --- Alias Operations --- + // writeAlias patches bytes into an alias's value image at offset (aliasId 0 + // = my own alias, created on first use). readAlias is the unsigned windowed + // read of the image; outFound is false for an unknown id or an + // out-of-bounds window. + uint8_t writeAlias(uint32_t aliasId, uint16_t offset, const ces::Bytes& bytes, + uint32_t& outAliasId); + uint8_t deleteAlias(); + uint8_t readAlias(uint32_t aliasId, uint16_t offset, uint16_t length, + ces::Bytes& outBytes, bool& outFound); + + // key_names: bind THIS client's key to `name` (the signer is the owner); + // clear it; and unsigned lookups both ways. `name` is <= 32 bytes. + uint8_t registerKeyName(const ces::Bytes& name); + uint8_t clearKeyName(); + uint8_t queryKeyName(const Hash& key, ces::Bytes& outName, bool& outFound); + uint8_t queryKeyNameByName(const ces::Bytes& name, Hash& outKey, + bool& outFound); + + /** + * Query a peer-table slot (Unsigned/Public) for discovery. + * @return CES_OK (outFound says whether the slot held a peer) or + * CES_ERROR_TIMEOUT. + */ + uint8_t queryPeerInfo(uint16_t index, uint16_t& outCount, bool& outFound, + Hash& outPubkey, std::string& outAddress); + + + /** + * Query extended server info (Signed/Paid). + * Returns self-describing key-value pairs. + * @return CES_OK, protocol error, or CES_ERROR_TIMEOUT/INTERNAL + */ + uint8_t queryServerInfo(std::vector& outEntries); + + // Info obtained from the MINX handshake (available after connect) + uint8_t getMinDifficulty() const { return serverMinDiff_; } + uint8_t getMinSecsPoW() const { return serverMinSecsPoW_; } + uint16_t getPendingPoWs() const { return serverPendingPoWs_; } + uint16_t getTps() const { return serverTps_; } + uint16_t getServerRpcPort() const { return serverRpcPort_; } + const minx::Hash& getServerKey() const { return serverKey_; } + HashPrefix getServerId() const { return Account::getMapKey(serverKey_); } + + void incomingInfo(const minx::SockAddr& addr, + const minx::MinxInfo& msg) override; + void incomingMessage(const minx::SockAddr& addr, + const minx::MinxMessage& msg) override; + void incomingApplication(const minx::SockAddr& addr, + const uint8_t code, + const minx::Bytes& data) override; + + // Set a callback for incoming APPLICATION messages (server push). + // Called on the network thread — keep it fast. + using ApplicationCallback = std::function; + void onApplicationMessage(ApplicationCallback cb) { appCallback_ = std::move(cb); } + bool ensureServerTicket(); + +private: + uint8_t getMyNonce(uint32_t& outNextNonce); + + // True iff `addr` is the server currently pointed at; always true in TCP + // proxy mode. The incoming* handlers call it to drop a reply from a previous + // server, which the re-pointable open socket can still deliver. + bool isCurrentPeer(const minx::SockAddr& addr) const; + + // Send a signed request and wait for a matching response, with retries. + // The caller is responsible for filling `req` completely (including + // originId/ownerId, serverId, reqNonce, and op-specific fields) before + // calling. `gen` is the generation counter that the incoming dispatcher + // increments when the matching response arrives. `resultCode` is the + // member where the dispatcher stores the response's rcode. `matchFn` + // returns true when a newly-arrived response matches this request's + // identity fields (typically checking reqNonce and originId/ownerId). + // + // Returns `resultCode` on success, CES_ERROR_INTERNAL on interrupt or + // handshake failure, CES_ERROR_TIMEOUT after all retries exhausted. + template + uint8_t sendSigned(Req& req, std::atomic& gen, + const uint8_t& resultCode, MatchFn&& matchFn) { + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + + minx::MinxMessage msg{0, transport_->generatePassword(), serverTicket_, + req.toBytes(keyPair_)}; + uint64_t g = gen.load(); + + for (int i = 0; i < tries_; ++i) { + transport_->sendMessage(msg); + auto res = ces::waitFor(retryIntervalMs_, [&]() { + return g < gen.load() && matchFn(); + }); + if (res == ces::WaitResult::Success) return resultCode; + if (res == ces::WaitResult::Interrupted) return CES_ERROR_INTERNAL; + } + return CES_ERROR_TIMEOUT; + } + + std::unique_ptr transport_; + KeyPair keyPair_; + + // The server currently pointed at, normalized to v4-mapped IPv6 to match + // inbound sender addresses on the dual-stack v6 socket. Written by the ctor + // and setRemoteEndpoint on the caller thread, read by the incoming* handlers + // on the IO thread; the mutex guards that. + minx::SockAddr currentTarget_; + mutable std::mutex targetMutex_; + int tries_ = 3; + int retryIntervalMs_ = 3000; + ApplicationCallback appCallback_; + bool connected_ = false; + minx::Hash serverKey_; + uint8_t serverMinDiff_ = 0; + uint64_t serverMinPoWTimestamp_ = 0; + uint64_t serverTicket_ = 0; + uint64_t serverTicketLastTime_ = 0; + uint8_t serverMinSecsPoW_ = 0; + uint16_t serverPendingPoWs_ = 0; + uint16_t serverTps_ = 0; + uint16_t serverRpcPort_ = 0; + std::atomic serverInfoGen_ = 0; + + // Tracking vars + HashPrefix accQueryId_; + int64_t accQueryBal_ = 0; + uint32_t accQueryNonce_ = 0; + HashPrefix accQueryLastXferDest_{}; + uint64_t accQueryLastXferAmount_ = 0; + uint32_t accQueryLastXferTime_ = 0; + uint32_t accQueryAliasId_ = 0; + std::atomic accQueryGen_ = 0; + + minx::Hash solQueryHash_; + uint8_t solQueryCode_ = 0; + std::atomic solQueryGen_ = 0; + + minx::Hash proveWorkSolHash_; + minx::Hash proveWorkBeneficiary_; + uint64_t proveWorkCreditAmount_ = 0; + uint64_t proveWorkServerTimeSecsEpoch_ = 0; + std::atomic proveWorkGen_ = 0; + + uint8_t transferResultCode_ = 0; + uint32_t transferResultNonce_ = 0; + int64_t transferNewOriginBal_ = 0; + std::atomic transferGen_ = 0; + + uint8_t openTransferResultCode_ = 0; + uint32_t openTransferResultNonce_ = 0; + int64_t openTransferNewOriginBal_ = 0; + std::atomic openTransferGen_ = 0; + + uint8_t createPaymentResultCode_ = 0; + uint32_t createPaymentResultNonce_ = 0; + int64_t createPaymentNewOriginBal_ = 0; + std::atomic createPaymentGen_ = 0; + + uint8_t crossTransferResultCode_ = 0; + uint32_t crossTransferResultNonce_ = 0; + int64_t crossTransferNewOriginBal_ = 0; + std::atomic crossTransferGen_ = 0; + + uint8_t bulkTransferResultCode_ = 0; + uint8_t bulkTransferSuccessfulCount_ = 0; + uint32_t bulkTransferResultNonce_ = 0; + int64_t bulkTransferNewOriginBal_ = 0; + std::atomic bulkTransferGen_{0}; + + HashPrefix accSignedQueryOriginId_; + uint32_t accSignedQueryReqNonce_ = 0; + std::vector accSignedQueryAccounts_; + uint8_t accSignedQueryResultCode_ = 0; + std::atomic accSignedQueryGen_ = 0; + + // Alias Results + HashPrefix setAliasResultOriginId_{}; + uint32_t setAliasResultNonce_ = 0; + uint32_t setAliasResultId_ = 0; + uint8_t setAliasResultCode_ = 0; + std::atomic setAliasGen_ = 0; + + HashPrefix deleteAliasResultOriginId_{}; + uint32_t deleteAliasResultNonce_ = 0; + uint8_t deleteAliasResultCode_ = 0; + std::atomic deleteAliasGen_ = 0; + + uint32_t queryAliasResultId_ = 0; + uint16_t queryAliasResultOffset_ = 0; + ces::Bytes queryAliasResultBytes_; + uint8_t queryAliasResultFound_ = 0; + std::atomic queryAliasGen_ = 0; + + // key_name results + HashPrefix registerKeyNameResultOriginId_{}; + uint32_t registerKeyNameResultNonce_ = 0; + uint8_t registerKeyNameResultCode_ = 0; + std::atomic registerKeyNameGen_ = 0; + HashPrefix clearKeyNameResultOriginId_{}; + uint32_t clearKeyNameResultNonce_ = 0; + uint8_t clearKeyNameResultCode_ = 0; + std::atomic clearKeyNameGen_ = 0; + Hash queryKeyNameResultKey_{}; + ces::Bytes queryKeyNameResultName_; + uint8_t queryKeyNameResultFound_ = 0; + std::atomic queryKeyNameGen_ = 0; + ces::Bytes qknByNameResultName_; // echoed query name (stale-reply guard) + uint8_t qknByNameResultFound_ = 0; + Hash qknByNameResultKey_{}; + std::atomic qknByNameGen_ = 0; + + // Asset Results + HashPrefix createAssetResultOriginId_; + uint32_t createAssetResultNonce_ = 0; + uint8_t createAssetResultCode_ = 0; + std::atomic createAssetGen_ = 0; + + HashPrefix createAssetRangeResultOriginId_; + uint32_t createAssetRangeResultNonce_ = 0; + uint8_t createAssetRangeResultCode_ = 0; + std::atomic createAssetRangeGen_ = 0; + + HashPrefix updateAssetResultOwnerId_; + uint32_t updateAssetResultNonce_ = 0; + uint8_t updateAssetResultCode_ = 0; + std::atomic updateAssetGen_ = 0; + + HashPrefix updateAssetFastResultOwnerId_; + uint32_t updateAssetFastResultNonce_ = 0; + uint8_t updateAssetFastResultCode_ = 0; + std::atomic updateAssetFastGen_ = 0; + + HashPrefix updateAssetMetaResultOwnerId_; + uint32_t updateAssetMetaResultNonce_ = 0; + uint8_t updateAssetMetaResultCode_ = 0; + std::atomic updateAssetMetaGen_ = 0; + + HashPrefix setAssetOwnerPaysResultOwnerId_; + uint32_t setAssetOwnerPaysResultNonce_ = 0; + uint8_t setAssetOwnerPaysResultCode_ = 0; + std::atomic setAssetOwnerPaysGen_ = 0; + + HashPrefix fundAssetResultOriginId_; + uint32_t fundAssetResultNonce_ = 0; + uint8_t fundAssetResultCode_ = 0; + std::atomic fundAssetGen_ = 0; + + HashPrefix buyAssetResultOriginId_; + uint32_t buyAssetResultNonce_ = 0; + uint8_t buyAssetResultCode_ = 0; + std::atomic buyAssetGen_ = 0; + + HashPrefix giveAssetResultOwnerId_; + uint32_t giveAssetResultNonce_ = 0; + uint8_t giveAssetResultCode_ = 0; + std::atomic giveAssetGen_ = 0; + + HashPrefix runAssetResultOriginId_; + uint32_t runAssetResultNonce_ = 0; + uint8_t runAssetResultCode_ = 0; + uint64_t runAssetResultVmError_ = 0; + uint64_t runAssetResultBudgetUsed_ = 0; + uint64_t runAssetResultAllowanceUsed_ = 0; + ces::Bytes runAssetResultOutput_; + + HashPrefix runAliasResultOriginId_; + uint32_t runAliasResultNonce_ = 0; + uint8_t runAliasResultCode_ = 0; + uint64_t runAliasResultVmError_ = 0; + uint64_t runAliasResultBudgetUsed_ = 0; + uint64_t runAliasResultAllowanceUsed_ = 0; + ces::Bytes runAliasResultOutput_; + std::atomic runAliasGen_ = 0; + + HashPrefix gossipResultOriginId_; + uint8_t gossipResultCode_ = 0; + std::atomic gossipGen_ = 0; + std::atomic runAssetGen_ = 0; + + uint32_t assetQueryReqNonce_ = 0; + uint8_t assetQueryResultCode_ = 0; + std::vector assetQueryAssets_; + std::atomic assetQueryGen_ = 0; + + Hash assetUnsignedQueryId_; + HashPrefix assetUnsignedQueryOwner_; + AssetData assetUnsignedQueryContent_; + uint16_t assetUnsignedQueryBalance_ = 0; + uint32_t assetUnsignedQueryPrice_ = 0; + uint8_t assetUnsignedQueryResultCode_ = 0; + std::atomic assetUnsignedQueryGen_ = 0; + + uint16_t peerQueryIndex_ = 0; + uint16_t peerQueryCount_ = 0; + uint8_t peerQueryFound_ = 0; + Hash peerQueryPubkey_{}; + PeerAddr peerQueryAddress_{}; + std::atomic peerQueryGen_ = 0; + + + HashPrefix serverInfoExtOriginId_; + uint32_t serverInfoExtReqNonce_ = 0; + uint8_t serverInfoExtResultCode_ = 0; + std::vector serverInfoExtEntries_; + std::atomic serverInfoExtGen_ = 0; +}; + +} // namespace ces \ No newline at end of file diff --git a/vendor/ces/include/ces/clientasync.h b/vendor/ces/include/ces/clientasync.h new file mode 100644 index 0000000..e94a0d2 --- /dev/null +++ b/vendor/ces/include/ces/clientasync.h @@ -0,0 +1,165 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace ces { + +/** + * Async CES client for server-to-server settlement. + * + * Runs entirely on an externally-provided io_context (no threads of its own). + * Maintains N independent ticket channels to one peer server. Each channel + * handles one operation at a time with proper MINX ticket chaining. + * Operations are queued and dispatched to idle channels. + */ +class CesClientAsync { +public: + using Callback = std::function; + // Gossip result carries the amount the peer drained for the hop (leg 2); the + // local server credits the peer's reserve on itself by it (leg 1). rc != OK + // (e.g. terminal timeout) arrives with paid = 0 -> no credit -> a burn. + using GossipCallback = std::function; + + CesClientAsync(boost::asio::io_context& io, + const boost::asio::ip::udp::endpoint& serverEndpoint, + const KeyPair& keyPair, + const Hash& peerServerKey, + size_t numChannels = DEFAULT_CHANNELS, + int maxRetries = DEFAULT_MAX_RETRIES); + + ~CesClientAsync(); + + void openTransfer(const Hash& destKey, uint64_t amount, Callback cb); + + // Queue a CES_GOSSIP op to this peer. Re-signs as the local server (originId); + // authorId/msgId are carried unchanged for provenance and dedup, budget is + // per-hop. Reuses the same ticketed-channel machinery as openTransfer. + void gossip(const Hash& authorId, const Hash& msgId, const Hash& dest, + uint64_t budget, const ces::Bytes& msg, GossipCallback cb); + + // Queue fill percentage [0..100]. The server uses this to backpressure: + // callers bounce with CES_ERROR_QUEUE_FULL around 95%. + static constexpr int LOAD_PERCENT_SCALE = 100; + int load() const { + size_t n = pendingCount_.load(std::memory_order_relaxed); + return n >= MAX_QUEUE ? LOAD_PERCENT_SCALE + : static_cast(n * LOAD_PERCENT_SCALE / MAX_QUEUE); + } + + void close(); + + static constexpr size_t DEFAULT_CHANNELS = 16; + static constexpr int DEFAULT_MAX_RETRIES = 7; + +private: + enum class OpKind { OpenTransfer, Gossip }; + + // Queued operation (not yet assigned to a channel) + struct QueuedOp { + OpKind kind = OpKind::OpenTransfer; + Hash destKey; + uint64_t amount; + Callback cb; // OpenTransfer result (rc) + GossipCallback gcb; // Gossip result (rc, paid) + minx::Bytes signedPayload; // cached, stable across retries + std::chrono::steady_clock::time_point deadline{}; // gossip give-up time + }; + + // Fire whichever callback the op carries, with a uniform (rc, paid) shape. + // Gossip uses paid; OpenTransfer ignores it. Used by the result path and by + // every failure path (timeout / close) with paid = 0 so a failed gossip hop + // burns rather than mints. + static void fireOpCallback(QueuedOp& op, uint8_t rc, uint64_t paid) { + if (op.kind == OpKind::Gossip) { if (op.gcb) op.gcb(rc, paid); } + else { if (op.cb) op.cb(rc); } + } + + // Per-channel state + enum class ChState { Idle, Handshaking, Ready, Busy }; + + struct Channel { + ChState state = ChState::Idle; + uint64_t ticket = 0; // server's gpassword to spend next + uint64_t sentGPass = 0; // our gpassword on the in-flight packet + minx::Hash serverKey{}; // server pubkey from INFO + QueuedOp currentOp; // the op we're working on (valid when Busy) + int retries = 0; + std::chrono::steady_clock::time_point sentAt; + }; + + void dispatch(); + void handshake(Channel& ch); + void sendGetInfo(Channel& ch); + void sendOp(Channel& ch); + + void startReceive(); + void onReceive(const boost::system::error_code& ec, size_t bytes); + void handleInfo(const uint8_t* data, size_t len); + void handleMessage(const uint8_t* data, size_t len); + + void sweep(); + void startSweepTimer(); + void failAll(uint8_t rc); + + size_t chIdx(const Channel& ch) const { + return static_cast(&ch - channels_.data()); + } + + uint64_t genPassword(); + + template + void sendBuf(minx::ArrayBuffer& buf); + + boost::asio::io_context& io_; + boost::asio::ip::udp::socket socket_; + boost::asio::ip::udp::endpoint serverEp_; + boost::asio::ip::udp::endpoint recvEp_; + boost::asio::steady_timer sweepTimer_; + + KeyPair keyPair_; + Hash peerServerKey_; + + std::vector channels_; + std::deque queue_; + std::atomic pendingCount_{0}; + + std::array recvBuf_; + + std::mt19937_64 rng_{std::random_device{}()}; + std::uniform_int_distribution rngDist_{1, + std::numeric_limits::max()}; + + bool closed_ = false; + + int maxRetries_; + static constexpr int RETRY_MS = 3000; + static constexpr int HANDSHAKE_RETRY_MS = 2000; + static constexpr size_t MAX_QUEUE = 50000; + // Gossip is best-effort and timely: it retries fast and gives up after a short + // window (paid = 0, a burn), instead of inheriting settlement's retry. + static constexpr int GOSSIP_RETRY_MS = 1000; + static constexpr int GOSSIP_DEADLINE_MS = 5000; + // Gossip rides a peer's settlement client only while its queue has no backlog + // (nothing waiting for a channel). In-flight settlement ops do not bar it. + // Settlement gives up after the receiver's nonceless dedup window: past it a + // retry is stale-rejected anyway, and this also bounds a half-up peer that + // answers handshakes but never replies to the op. Bound to the same window + // (CES_NONCELESS_DEDUP_WINDOW_US) so the two cannot drift. Give-up is + // conserved (vostro stands). + static constexpr int SETTLEMENT_DEADLINE_MS = + static_cast(CES_NONCELESS_DEDUP_WINDOW_US / 1000); +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/extension_manager.h b/vendor/ces/include/ces/extension_manager.h new file mode 100644 index 0000000..b0cc433 --- /dev/null +++ b/vendor/ces/include/ces/extension_manager.h @@ -0,0 +1,82 @@ +// extension_manager.h - the /s/ extension lifecycle, driven by the webadmin +// Extensions page. Coordinates three subsystems it does not own: the file store +// (copy a catalog .lua into /s/, delete it, read/write /s/.conf), the +// compute handler (launch / kill), and the ces.extension_admin contract IPC +// (status / command / config) exposed by compute_handler. +// +// Definitions: +// available - a single .lua present in the read-only catalog (extensions_dir) +// installed - a .lua present in /s/ +// enabled - a running compute instance of /s/.lua +// +// Names are bare basenames ([A-Za-z0-9._-], no path separators); anything else +// is rejected so a name can never escape the catalog or /s/. + +#pragma once + +#include +#include +#include +#include + +namespace ces { + +class CesServer; + +struct ExtensionItem { + std::string name; // basename, no .lua — identity for all path ops + std::string displayName; // ces.manifest `name` (live); "" if not running/declared + bool available = false; // present in the catalog + bool installed = false; // present in /s/ + bool enabled = false; // a running instance exists + uint64_t pid = 0; // running instance (if enabled) + // From the running instance's ces.extension_admin{} registration (if enabled): + bool isExtension = false; // it called ces.extension_admin{} (else N/A everywhere) + uint8_t caps = 0; // kComputeExtCap* bits + std::string version, description; + std::vector> commands; // {id, label} +}; + +// Catalog union installed, with live state folded in for the enabled ones. +std::vector extensionList(CesServer* server); + +bool extensionInstall(CesServer* server, const std::string& name); // catalog -> /s/ +bool extensionUninstall(CesServer* server, const std::string& name); // kill + delete /s/ copy +bool extensionEnable(CesServer* server, const std::string& name, std::string& errOut); // launch +bool extensionDisable(CesServer* server, const std::string& name); // kill + +// Live status k/v of a running extension. false if not enabled / not an +// extension / no status callback (the page renders that as N/A). +bool extensionStatus(CesServer* server, const std::string& name, + std::vector>& kv); + +// Run a declared/freeform command on a running extension; `out` = its result. +bool extensionCommand(CesServer* server, const std::string& name, + const std::string& id, const std::string& arg, + std::string& out); + +// The mene admin panel of a running extension (kComputeExtCapPanel). +// `frame` = a complete wire frame JSON: {"type":"render","tree":...} or a +// {"type":"toast",...} carrying a panel-side Lua error. false if not enabled / +// no panel registered / IPC timeout. +bool extensionPanel(CesServer* server, const std::string& name, + std::string& frame); +// Dispatch a browser event ({"on":...,"value":...} JSON) to the panel's +// update(); `frame` = the post-update render (or toast), as above. +bool extensionPanelEvent(CesServer* server, const std::string& name, + const std::string& eventJson, std::string& frame); + +// Tell the extension whether any dashboard client is watching its panel +// (drives the child's change-detect push tick). One-way, idempotent — +// re-send periodically so a relaunched child re-arms. +void extensionPanelWatch(CesServer* server, const std::string& name, bool on); + +// /s/.conf text ("" if none). +std::string extensionConfigGet(CesServer* server, const std::string& name); +// Persist text to /s/.conf and push it live to the running instance. +bool extensionConfigSet(CesServer* server, const std::string& name, + const std::string& text); +// Reset config to the running extension's declared defaults (requires enabled). +bool extensionConfigReset(CesServer* server, const std::string& name); + +} // namespace ces diff --git a/vendor/ces/include/ces/feemult.h b/vendor/ces/include/ces/feemult.h new file mode 100644 index 0000000..8d0e314 --- /dev/null +++ b/vendor/ces/include/ces/feemult.h @@ -0,0 +1,80 @@ +#pragma once + +// Per-fee discount multiplier (basis points, 0..10000). +// +// Each named fee constant in CesConfig is associated with exactly one +// FeeKind. The metrics pulse refreshes feeMult_[k] from the gauge bp +// it's mapped to (l1cpu, l2cpu, l1memac, l1memas, l2mem, net). Debit +// sites then bill `raw * feeMult_[k] / 10000` instead of `raw`. +// +// Idle bp ≈ 0 → fees subsidized; saturation bp = 10000 → full price. +// +// Fees that prepay future days (CES_FUND_ASSET, CES_CREATE_ASSET initial +// days) attenuate the discount linearly to zero over kPrepaidDiscountWindowDays +// so users can't lock in cheap rates by funding years during idle periods. +// +// CesConfig::feeDiscountEnabled = false pins every multiplier to 10000. +// Default true in production; tests flip it off to keep static-fee assertions. + +#include +#include + +namespace ces { + +enum class FeeKind : uint8_t { + Tx = 0, // feeTx — protocol op CPU cost + Query, // feeQuery — signed query CPU cost + AccountRent, // feeAccount daily — account slot pressure + AssetRent, // feeAsset daily / fund — asset slot pressure (attenuated) + VMMult, // feeVMMult — VM gas + ComputeSlot, // feeComputeSlotSec — compute supervisor slot rent + ComputeCpu, // feeComputeCpuSec — compute child CPU cost + ComputeRss, // feeComputeRssByteDay — compute child RSS rent + BucketByteSec, // feeBucketByteSec — Lua bucket capacity rent + Net, // feeNet* — ChannelMeter per-channel RUDP rates + Count_ // sentinel for sizing +}; + +constexpr std::size_t kFeeKindCount = + static_cast(FeeKind::Count_); + +// Attenuation window for prepaid-days fees: any day at distance D from +// today ramps the discount linearly toward 0 (full price) at D = window. +constexpr int kPrepaidDiscountWindowDays = 90; + +// Sum cost of prepaying `daysAdded` days at `feePerDay`, with the +// discount attenuated linearly to full price across kPrepaidDiscountWindowDays. +// `bp` is the AssetRent / AccountRent multiplier in basis points (0..10000); +// at saturation (bp=10000) cost collapses to flat. Distant days pay full +// price so funding deep into the future can't lock in a low rate. +inline uint64_t computePrepayCost(uint64_t feePerDay, uint16_t bp, + uint32_t daysAdded, uint32_t daysHeld) { + if (daysAdded == 0 || feePerDay == 0) + return 0; + if (bp >= 10000) + return static_cast(daysAdded) * feePerDay; + constexpr uint32_t W = static_cast(kPrepaidDiscountWindowDays); + // Any day at distance D >= W pays full price (effBp = 10000). D = daysHeld + i + // grows past W for every i > W, so only the first min(daysAdded, W) days can be + // discounted; the rest is a flat tail. Bounding the loop to W keeps this O(W) + // regardless of daysAdded. + const uint32_t inWindow = daysAdded < W ? daysAdded : W; + uint64_t total = 0; + for (uint32_t i = 1; i <= inWindow; ++i) { + uint32_t D = daysHeld + i; + uint64_t effBp; + if (D >= W) { + effBp = 10000; + } else { + // eff_bp = 10000 - (10000 - bp) * (W - D) / W + uint64_t deficit = static_cast(10000 - bp); + uint64_t windowGap = static_cast(W - D); + effBp = 10000 - (deficit * windowGap) / W; + } + total += feePerDay * effBp / 10000; + } + total += static_cast(daysAdded - inWindow) * feePerDay; + return total; +} + +} // namespace ces diff --git a/vendor/ces/include/ces/keyname.h b/vendor/ces/include/ces/keyname.h new file mode 100644 index 0000000..8fc8ffb --- /dev/null +++ b/vendor/ces/include/ces/keyname.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include + +// A key_name entry: the ledger's local, crypto-owned name registry. +// +// Map key = a 32-byte PUBLIC KEY. The key IS the owner -- there is no owner +// field, so only the holder of that key can register/update/delete its entry +// (the register op's signer must equal the map key). Value = the NAME, 32 +// bytes UTF-8. So one entry is 32 (key) + 32 (name) = 64 bytes on the wire. +// +// Rent is NOT a per-entry balance: like an alias, the entry is funded by its +// key's ACCOUNT (getMapKey of the key) via the daily maintenance pass at the +// key_name rent rate (derived from feeAccount at the byte ratio, x2); an +// account that cannot pay loses its key_name(s). +// +// Uniqueness is bidirectional: one key -> at most one name (map-key unique, +// free), and one name -> at most one key (a reverse index the KeyNames wrapper +// maintains, checked under logicStrand_ at register time). The reverse index +// is DERIVED from this store, never a second authoritative copy, so snapshot +// and WAL replay reconstruct the same state. +namespace ces { + +constexpr size_t KEYNAME_NAME_BYTES = 32; +using KeyNameData = std::array; + +struct KeyName { + KeyName() : name_{} {} + explicit KeyName(const KeyNameData& name) : name_(name) {} + + void setName(const KeyNameData& name) { name_ = name; } + const KeyNameData& getName() const { return name_; } + KeyNameData& accessName() { return name_; } + + enum class SerMode : uint8_t { + Full = 0x00, // name (set, snapshots) + None = 0x01 // erased object + }; + + CES_PERSISTED_BOILERPLATE(SerMode::Full) + + private: + KeyNameData name_; +}; + +static_assert(sizeof(KeyName) == KEYNAME_NAME_BYTES); +static_assert(std::is_standard_layout_v); +static_assert(std::is_trivially_copyable_v); + +} // namespace ces + +// --- Custom logkv serializer for ces::KeyName --- + +namespace logkv { + +template <> +struct serializer { + using SerMode = ces::KeyName::SerMode; + + static constexpr size_t SZ_NAME = sizeof(ces::KeyNameData); + static constexpr size_t SZ_HEADER = 1; + + // An empty (erased) entry has an all-zero name (a valid name is never empty). + static bool is_empty(const ces::KeyName& obj) { + return obj.getName() == ces::KeyNameData{}; + } + + static size_t get_size(const ces::KeyName& obj) { + if (ces::KeyName::_logkvStoreSnapshot()) + return SZ_NAME; + if (is_empty(obj)) + return SZ_HEADER; + return SZ_HEADER + SZ_NAME; + } + + static size_t write(char* dest, size_t size, const ces::KeyName& obj) { + Writer writer(dest, size); + try { + if (ces::KeyName::_logkvStoreSnapshot()) { + writer.write(obj.getName()); + return writer.bytes_processed(); + } + const bool empty = is_empty(obj); + writer.write(static_cast(empty ? SerMode::None : SerMode::Full)); + if (!empty) + writer.write(obj.getName()); + } catch (const insufficient_buffer& e) { + return writer.bytes_processed() + e.get_required_bytes(); + } + return writer.bytes_processed(); + } + + static size_t read(const char* src, size_t size, ces::KeyName& obj) { + Reader reader(src, size); + try { + if (ces::KeyName::_logkvStoreSnapshot()) { + ces::KeyNameData name; + reader.read(name); + obj = ces::KeyName(name); + return reader.bytes_processed(); + } + uint8_t header; + reader.read(header); + if (static_cast(header) == SerMode::None) { + obj = ces::KeyName(); + return reader.bytes_processed(); + } + ces::KeyNameData name; + reader.read(name); + obj = ces::KeyName(name); + } catch (const insufficient_buffer&) { + return 0; + } + return reader.bytes_processed(); + } +}; + +} // namespace logkv diff --git a/vendor/ces/include/ces/keynames.h b/vendor/ces/include/ces/keynames.h new file mode 100644 index 0000000..3f4b74c --- /dev/null +++ b/vendor/ces/include/ces/keynames.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace ces { + +// The key_names table wrapper (see keyname.h). Owns the forward store +// (keyHash -> name) and a DERIVED reverse index (normalized-name -> keyHash) +// rebuilt from the forward store on load, so snapshot and WAL replay produce +// the same state. All mutations go through this class so both stay consistent. +// +// The username/name unification: a name's path form replaces spaces with +// underscores. Uniqueness is enforced on that normalized form, so "Ada +// Lovelace" and "Ada_Lovelace" cannot both exist (they would collide as /f/ +// segments). +class KeyNames { + public: + using KeyNameStore = logkv::Store; + + enum class RegisterResult { Ok, NameTaken, CapacityFull, BadName }; + + KeyNames(const std::string& dataDir, uint64_t minKeyName, uint64_t flushValue, + size_t bufferSize = 1 << 19); + + KeyNameStore& getStore() { return store_; } + KeyNameStore* operator->() { return &store_; } + const KeyNameStore* operator->() const { return &store_; } + KeyNameStore& operator*() { return store_; } + const KeyNameStore& operator*() const { return store_; } + + // Register/update the caller's own name. `key` is the signer's full pubkey + // (the entry's owner, by crypto). Enforces name uniqueness on the normalized + // form and the capacity cap. Re-registering the same key with a new name + // moves it (frees the old reverse entry). + RegisterResult registerName(const Hash& key, const KeyNameData& name, + uint64_t maxKeyName); + + // Erase the caller's own entry (both maps). No-op if absent. + bool clearName(const Hash& key); + + // key -> name ("" via outFound=false if absent). + bool nameForKey(const Hash& key, KeyNameData& outName) const; + // normalized name -> key (the reverse index). `name` is normalized here. + bool keyForName(const KeyNameData& name, Hash& outKey) const; + + // The canonical form: spaces and underscores both map to '_', with leading + // and trailing underscores stripped (a name cannot begin or end with one). + // Deterministic, no locale. This is what is STORED and what /f// uses; + // registerName runs it on the input, so "Ada Lovelace" and "Ada_Lovelace" + // are the same name. + static KeyNameData normalize(const KeyNameData& name); + // A valid (already-normalized) name: 1..32 bytes, no NUL-hole, no control + // chars, no path breakers, no leading '.'/'-'. (UTF-8 validity is the + // client's concern; the ledger only guards path/pathmap safety.) + static bool validName(const KeyNameData& name); + + void checkFlush(uint64_t amount); + const boost::unordered_flat_map& reverse() const { + return byName_; + } + // Rebuild the reverse index from the forward store. Called on load, and by + // the server after the daily rent sweep erases entries directly on the store + // (which bypasses this wrapper's dual-map bookkeeping). + void rebuildReverse(); + + private: + + KeyNameStore store_; + boost::unordered_flat_map byName_; // normalized -> key + uint64_t flushValue_; + uint64_t flushAccumulator_ = 0; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/keys.h b/vendor/ces/include/ces/keys.h new file mode 100644 index 0000000..f2c1281 --- /dev/null +++ b/vendor/ces/include/ces/keys.h @@ -0,0 +1,338 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace ces { + +static constexpr size_t KEY_SIZE = 32; +static constexpr size_t KEY_HEX_STRING_SIZE = KEY_SIZE * 2; +static constexpr size_t SIG_SIZE = 65; + +using Signature = std::array; + +enum class SigType : uint8_t { + ED25519 = 0x00, + SECP256K1_EVEN = 0x01, + SECP256K1_ODD = 0x02 +}; + +enum class KeyAlgo { + ED25519, + SECP256K1 +}; + +inline CryptoPP::AutoSeededRandomPool& getThreadLocalPRNG() { + thread_local CryptoPP::AutoSeededRandomPool prng; + return prng; +} + +// Prepare a 32-byte digest for signing/verification. +// If data is already 32 bytes, just copy it. Otherwise, SHA256 it. +inline std::array prepareDigest(std::span data) { + std::array out; + if (data.size() == 32) { + std::memcpy(out.data(), data.data(), 32); + } else { + CryptoPP::SHA256().CalculateDigest(out.data(), data.data(), data.size()); + } + return out; +} + +// Thread-local secp256k1 context (one per thread, no locking). +inline secp256k1_context* getThreadLocalSecpContext() { + thread_local struct ContextWrapper { + secp256k1_context* ctx; + ContextWrapper() { + ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN | + SECP256K1_CONTEXT_VERIFY); + if (!ctx) + throw std::runtime_error("secp256k1_context_create returned null"); + } + ~ContextWrapper() { + if (ctx) + secp256k1_context_destroy(ctx); + } + } wrapper; + return wrapper.ctx; +} + +class PublicKey { +public: + PublicKey() = default; + + explicit PublicKey(const Hash& publicKey) : key_(publicKey) {} + + explicit PublicKey(const std::string& hexStr) { + if (hexStr.size() != KEY_HEX_STRING_SIZE) { + throw std::runtime_error("Invalid public key hex string length"); + } + minx::stringToHash(key_, hexStr); + } + + bool verifySignature(std::span data, + const Signature& signature) const { + SigType sigType = static_cast(signature[0]); + const uint8_t* rawSigData = signature.data() + 1; + + if (sigType == SigType::ED25519) { + CryptoPP::ed25519::Verifier verifier( + reinterpret_cast(key_.data())); + return verifier.VerifyMessage( + reinterpret_cast(data.data()), data.size(), + reinterpret_cast(rawSigData), 64); + } else if (sigType == SigType::SECP256K1_EVEN || + sigType == SigType::SECP256K1_ODD) { + auto* ctx = getThreadLocalSecpContext(); + + // 1. Reconstruct the 33-byte compressed public key + std::array compressed; + compressed[0] = (sigType == SigType::SECP256K1_EVEN) ? 0x02 : 0x03; + std::memcpy(compressed.data() + 1, key_.data(), 32); + + // 2. Parse the public key into secp internal format + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_parse(ctx, &pubkey, compressed.data(), 33)) { + return false; + } + + // 3. Parse the 64-byte signature (r, s) + secp256k1_ecdsa_signature sig; + if (!secp256k1_ecdsa_signature_parse_compact(ctx, &sig, rawSigData)) { + return false; + } + + // Reject non-canonical (high-S) signatures: sign() always emits low-S, so + // a high-S copy is a malleated form and must not verify (one wire form). + if (secp256k1_ecdsa_signature_normalize(ctx, nullptr, &sig)) { + return false; + } + + // 4. Hash if not already 32 bytes + auto msgHash = prepareDigest(data); + + // 5. Verify + return secp256k1_ecdsa_verify(ctx, &sig, msgHash.data(), &pubkey) == 1; + } + + return false; + } + + bool verifySignature(const char* data, size_t size, + const Signature& signature) const { + return verifySignature( + std::span(reinterpret_cast(data), size), + signature); + } + + bool verifySignature(const uint8_t* data, size_t size, + const Signature& signature) const { + return verifySignature(std::span(data, size), signature); + } + + const Hash& getHash() const { return key_; } + + // Hex form is computed on first call and cached. Verify-only call + // paths (the hot path for inbound packets) never touch this and pay + // nothing; display/logging/wallet-export paths pay one encode per + // PublicKey instance lifetime. + const std::string& getHexStr() const { + if (hexStr_.empty()) { + hexStr_.resize(KEY_HEX_STRING_SIZE); + logkv::encodeHex(hexStr_.data(), hexStr_.size(), + reinterpret_cast(key_.data()), + key_.size(), false); + } + return hexStr_; + } + +private: + Hash key_{}; + mutable std::string hexStr_; +}; + +class KeyPair { +public: + KeyPair(KeyAlgo algo = KeyAlgo::ED25519) : algo_(algo) { + generateKeyPair({}); + } + + explicit KeyPair(const Hash& privateKey, KeyAlgo algo = KeyAlgo::ED25519) + : algo_(algo) { + generateKeyPair(privateKey); + } + + explicit KeyPair(const std::string& hexStr, KeyAlgo algo = KeyAlgo::ED25519) + : algo_(algo) { + minx::Hash privateKey; + minx::stringToHash(privateKey, hexStr); + generateKeyPair(privateKey); + } + + static KeyPair generate(KeyAlgo algo = KeyAlgo::ED25519) { + return KeyPair(algo); + } + + Signature signData(std::span data) const { + Signature signature; + signature[0] = static_cast(decoratorByte_); + + if (algo_ == KeyAlgo::ED25519) { + CryptoPP::ed25519::Signer edSigner( + reinterpret_cast(privateKey_.data())); + edSigner.SignMessage( + getThreadLocalPRNG(), + reinterpret_cast(data.data()), data.size(), + reinterpret_cast(signature.data() + 1)); + } else { + auto* ctx = getThreadLocalSecpContext(); + + auto msgHash = prepareDigest(data); + + secp256k1_ecdsa_signature sig; + if (secp256k1_ecdsa_sign(ctx, &sig, msgHash.data(), privateKey_.data(), + nullptr, nullptr) != 1) { + throw std::runtime_error("secp256k1_ecdsa_sign failed"); + } + + secp256k1_ecdsa_signature_serialize_compact(ctx, signature.data() + 1, + &sig); + } + + return signature; + } + + Signature signData(const char* data, size_t size) const { + return signData( + std::span(reinterpret_cast(data), size)); + } + + bool verifySignature(std::span data, + const Signature& signature) const { + return pubKey_.verifySignature(data, signature); + } + + bool verifySignature(const char* data, size_t size, + const Signature& signature) const { + return pubKey_.verifySignature(data, size, signature); + } + + const PublicKey& getPublicKey() const { return pubKey_; } + const minx::Hash& getPublicKeyAsHash() const { return pubKey_.getHash(); } + const std::string& getPublicKeyHexStr() const { return pubKey_.getHexStr(); } + const minx::Hash& getPrivateKey() const { return privateKey_; } + const std::string& getPrivateKeyHexStr() const { return privateKeyHexStr_; } + KeyAlgo getAlgorithm() const { return algo_; } + SigType getDecoratorType() const { return decoratorByte_; } + +private: + void generateKeyPair(const Hash& privateKey) { + bool isNewKey = std::all_of(privateKey.begin(), privateKey.end(), + [](uint8_t byte) { return byte == 0; }); + + Hash pubHash; + + if (algo_ == KeyAlgo::ED25519) { + decoratorByte_ = SigType::ED25519; + if (isNewKey) { + CryptoPP::ed25519::Signer edSigner(getThreadLocalPRNG()); + const auto& specificPriv = + dynamic_cast( + edSigner.GetPrivateKey()); + std::memcpy(privateKey_.data(), specificPriv.GetPrivateKeyBytePtr(), + KEY_SIZE); + } else { + privateKey_ = privateKey; + } + + CryptoPP::ed25519::Signer edSigner( + reinterpret_cast(privateKey_.data())); + CryptoPP::ed25519::Verifier tempVerifier(edSigner); + const auto& specificPub = dynamic_cast( + tempVerifier.GetPublicKey()); + std::memcpy(pubHash.data(), specificPub.GetPublicKeyBytePtr(), KEY_SIZE); + } else { + auto* ctx = getThreadLocalSecpContext(); + if (isNewKey) { + do { + getThreadLocalPRNG().GenerateBlock(privateKey_.data(), KEY_SIZE); + } while (!secp256k1_ec_seckey_verify(ctx, privateKey_.data())); + } else { + privateKey_ = privateKey; + } + + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_create(ctx, &pubkey, privateKey_.data())) { + throw std::runtime_error("secp256k1_ec_pubkey_create failed"); + } + + std::array compressed; + size_t clen = 33; + secp256k1_ec_pubkey_serialize(ctx, compressed.data(), &clen, &pubkey, + SECP256K1_EC_COMPRESSED); + + std::memcpy(pubHash.data(), compressed.data() + 1, 32); + decoratorByte_ = (compressed[0] == 0x02) ? SigType::SECP256K1_EVEN + : SigType::SECP256K1_ODD; + } + + privateKeyHexStr_.resize(KEY_HEX_STRING_SIZE); + logkv::encodeHex(privateKeyHexStr_.data(), privateKeyHexStr_.size(), + reinterpret_cast(privateKey_.data()), + privateKey_.size(), false); + + pubKey_ = PublicKey(pubHash); + } + + KeyAlgo algo_; + SigType decoratorByte_; + PublicKey pubKey_; + minx::Hash privateKey_; + std::string privateKeyHexStr_; +}; + +} // namespace ces + +// --------------------------------------------------------------------------- +// logkv::serializer — wire form is the 32-byte pubkey hash. +// --------------------------------------------------------------------------- +// +// Thin delegation to serializer: a PublicKey is a Hash (plus a +// lazily-cached hex string), so the wire form is the same 32 raw bytes. The +// specialization lets `buf.put(pk)` / `buf.get()` work on the +// PublicKey type directly instead of unwrapping to/from Hash at each call. + +namespace logkv { + +template <> struct serializer { + static size_t get_size(const ces::PublicKey& pk) { + return serializer::get_size(pk.getHash()); + } + static bool is_empty(const ces::PublicKey& pk) { + return serializer::is_empty(pk.getHash()); + } + static size_t write(char* dest, size_t size, const ces::PublicKey& pk) { + return serializer::write(dest, size, pk.getHash()); + } + static size_t read(const char* src, size_t size, ces::PublicKey& pk) { + ces::Hash h; + const size_t n = serializer::read(src, size, h); + if (n <= size) pk = ces::PublicKey(h); + return n; + } +}; + +} // namespace logkv \ No newline at end of file diff --git a/vendor/ces/include/ces/l2/builtin_site.h b/vendor/ces/include/ces/l2/builtin_site.h new file mode 100644 index 0000000..e4d170f --- /dev/null +++ b/vendor/ces/include/ces/l2/builtin_site.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace ces { + +// A static file shipped INSIDE the CES binary and published into the file +// store's /s/ zone at boot, whenever the file feature is on. There is no switch: +// the demo site comes with the file feature and is overwritten on every boot, so +// it always matches the running binary (operators publish their own content +// elsewhere in /s/). +// +// relPath is the path under /s/ — e.g. "welcome/index.html" → CES name +// "/s/welcome/index.html". +struct BuiltinSiteFile { + std::string relPath; + std::string content; +}; + +// The bundled site (currently /s/welcome: a small "what am I looking at" page a +// gateway like cesweb serves out of the box). +std::vector builtinSiteFiles(); + +// True for a CES name that belongs to the bundled site. The file handler treats +// these as static server content (server-owned, no program account — like the +// generated /s/index.html catalog), so reconcile leaves them alone. +bool isBuiltinSitePath(const std::string& cesName); + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/compute_client.h b/vendor/ces/include/ces/l2/compute_client.h new file mode 100644 index 0000000..347d676 --- /dev/null +++ b/vendor/ces/include/ces/l2/compute_client.h @@ -0,0 +1,137 @@ +// compute_client.h - blocking client for the L2 compute protocol. +// +// Same shape as CesFileClient: one instance, one RUDP channel, one +// server. Each verb method is blocking. Response signatures are +// verified if setServerPubkey() has been called; unverified responses +// log one LOGERROR and return the bytes. +// +// Verbs: +// launch(signer, name) → pid + started_at_us (always mints +// a new id; multiple instances per source +// coexist up to compute_max_instances) +// kill(signer, id) → ok +// list(signer) → owner-scoped [pid, name, +// started_at_us, file_balance, cpu, rss, +// ports]* +// stat(id) → started_at_us, file_balance, cpu, rss, +// ports, name (public — any signer) +// instances(path) → public [pid, started_at_us, cpu, +// rss, ports]* for `path` (no owner check; +// enables discovery AND dialing of services) +// +// LAUNCH / KILL are owner-gated (they mutate); LIST is scoped to the +// signer's own instances. STAT and INSTANCES are public to any signer — +// they expose a live instance's leased ports so anyone can discover a +// running service and dial it. No unsigned verbs in the compute protocol +// (every verb rides a signed bind and pays feeQuery). + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ces { + +class CesPlexChannel; + +class CesComputeClient { +public: + struct InstanceInfo { + uint64_t pid = 0; + std::string sourceName; + uint64_t startedAtUs = 0; + uint64_t fileBalance = 0; + // Last supervisor-tick sample. cpuBasisPoints is in units of + // one core: 10000 = 100% of a single CPU (Lua children are + // single-threaded so 10000 is the practical ceiling). + uint32_t cpuBasisPoints = 0; + uint64_t rssBytes = 0; + // The instance's statically-leased UDP ports (0 = no lease): + // clientPort = outbound CES-client source port; rpcPort = the + // instance's own inbound /ces/luarpc/1 host port (directly dialable). + uint16_t clientPort = 0; + uint16_t rpcPort = 0; + // The instance's program-keypair pubkey (0 = none). A peer needs it to + // address this instance's /ces/luarpc/1 endpoint via ces.conn.connect. + std::array programPubkey{}; + }; + + CesComputeClient(); + ~CesComputeClient(); + + CesComputeClient(const CesComputeClient&) = delete; + CesComputeClient& operator=(const CesComputeClient&) = delete; + + // Open a UDP socket, wire up Rudp, and perform the signed CesPlex + // bind handshake for "/ces/compute/1" against the target server. + // `signerKey` becomes the channel principal — every verb on this + // connection is signed by + bills against `signerKey.getPublicKeyAsHash()`. + uint8_t connect(const std::string& host, uint16_t rpcPort, + const KeyPair& signerKey); + + // Drive verbs over a CesPlexChannel the caller owns and has already + // bound — e.g. the compute child driving /ces/compute/1 over its own + // CesPlex endpoint. Mutually exclusive with connect(). + void attach(CesPlexChannel& channel); + + // Tear down the channel and I/O threads. Safe to call more than once. + void disconnect(); + + // Provide the server's 32-byte public key so response signatures + // can be verified. Leaving this unset is permitted — responses are + // then treated as unverifiable; one LOGERROR is emitted per response. + void setServerPubkey(const minx::Hash& pk); + + // ---- Verbs ---- + // + // The signing key is fixed at connect() time. Each verb is signed + // by + bills against the bound key. + + uint8_t launch(const std::string& name, + uint64_t& outInstanceId, uint64_t& outStartedAtUs); + + uint8_t kill(uint64_t pid); + + uint8_t list(std::vector& out); + + uint8_t stat(uint64_t pid, InstanceInfo& out); + + // Public enumeration of live instances for a given source path — + // discovery + endpoints in one call. Each InstanceInfo carries the + // instance id, uptime, last cpu/rss sample, and leased ports (sourceName + // is set to `path`; fileBalance is left 0 — use stat(id) for that). + // Returns CES_OK with an empty `out` if nothing is running under `path`. + // No owner check; signer just pays the per-op fee. + uint8_t instances(const std::string& path, std::vector& out); + + // Paid call into a running instance: escrows `value` credits from the signer, + // delivers `memo` (up to CES_L2_CALL_MAX_MEMO) to its on_l2call handler, and + // returns the handler's reply bytes in `reply` (digest-verified). value may + // be 0; the per-op fee is charged regardless and never refunded. The escrow + // is refunded only if the call is never delivered, one code per cause: + // CES_ERROR_UNSUPPORTED = the program defines no on_l2call; + // CES_ERROR_TIMEOUT = no delivery ack before the deadline; + // CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND = `pid` is not live (checked before + // any charge, and also if the instance dies mid-flight). Delivery is final: + // a delivered call that never replies keeps the payment and returns CES_OK + // with an empty reply -- the payment lands in the program account only + // after on_l2call returns. + uint8_t call(uint64_t pid, uint64_t value, const ces::Bytes& memo, + ces::Bytes& reply); + + // Implementation detail; public only so the .cpp-local helpers can + // take Impl& without forward-declaring everything inside the .cpp. + class Impl; + +private: + std::unique_ptr impl_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/compute_handler.h b/vendor/ces/include/ces/l2/compute_handler.h new file mode 100644 index 0000000..4697d32 --- /dev/null +++ b/vendor/ces/include/ces/l2/compute_handler.h @@ -0,0 +1,277 @@ +// compute_handler.h - builtin:compute: per-server program-hosting handler. +// +// Mounts /ces/compute/1 on the plex (rpc) port. Each LAUNCH spawns +// `cesComputeChildBinary` (default `cesluajitd`, a sandboxed LuaJIT VM per +// process) as a separate OS process, holds a Unix domain socket to it, and +// charges slot/cpu/rss/bucket fees to the source file's `file_balance` while +// the instance runs. When the balance can't cover a tick the instance is +// SIGKILLed; likewise when the source file is deleted (file handler delete / +// rent-exhaust / GC). +// +// One ComputeHandler OBJECT per CesServer (owned by the server, holds a back +// pointer to it). No process-global state: N servers coexist in one process. +// +// Lifecycle: the server constructs a ComputeHandler(this) and mounts it into +// its CesPlex when /ces/compute/1 is wired in [cesplex_mounts], then calls +// start() (which self-gates on its prereqs: computeMaxInstances > 0, builtin: +// file mounted, work dir); on shutdown it calls fundingDrain() then stop() and +// drops the object (before CesPlex / +// rpcRudp_ go away). + +#pragma once + +#include +#include +#include // CesPlexHandler, BoundChannelContext + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ces { + +class CesServer; + +// Per-instance supervisor state, defined in compute_handler.cpp (the only TU +// that touches it). Held here only through shared_ptr, so the header stays +// free of its definition. +struct Instance; +// In-flight EXT_REQ correlation state, also defined in the .cpp. +struct ExtPending; +// The CesPlex per-op request (cesplex/session.h); held by shared_ptr for the +// deferred reply of a client CALL verb. +struct CesPlexRequest; + +// Per-instance monitoring snapshot, surfaced to the web dashboard's +// Compute tab. CPU is in basis points of one core (10000 = a full core); +// rssBytes + cpuBasisPoints are the supervisor's last per-tick sample. +struct ComputeInstanceStat { + uint64_t pid = 0; + std::string source; // source file path (e.g. /s/dice.lua) + uint32_t cpuBasisPoints = 0; // 0..10000, of one core + uint64_t rssBytes = 0; + uint64_t uptimeSecs = 0; + uint16_t clientPort = 0; // outbound CES-client port (0 = none) + uint16_t rpcPort = 0; // inbound /ces/luarpc/1 host port (0 = none) +}; + +// ---- Extension contract (ces.extension_admin{}). Read/drive a running /s/ +// extension through the management surface the ExtensionManager exposes to the +// webadmin. Metadata (name/version/description) is NOT here — it comes from the +// file's static manifest header; this contract is caps/commands/config only. ---- +constexpr uint8_t kComputeExtCapStatus = 0x01; +constexpr uint8_t kComputeExtCapCommands = 0x02; +constexpr uint8_t kComputeExtCapConfigDefaults = 0x04; +constexpr uint8_t kComputeExtCapOnConfig = 0x08; +constexpr uint8_t kComputeExtCapPanel = 0x10; // mene UI panel registered +constexpr uint8_t kComputeExtReqStatus = 0x00; +constexpr uint8_t kComputeExtReqCommand = 0x01; +constexpr uint8_t kComputeExtReqPanelRender = 0x02; // reply = render/toast frame JSON +constexpr uint8_t kComputeExtReqPanelEvent = 0x03; // body = event JSON; reply as RENDER + +struct ComputeExtInfo { + // Identity, reported live via ces.manifest{} (may be set even with no contract). + std::string name, version, description; + // Admin contract, from ces.extension_admin{}. + bool isExtension = false; + uint8_t caps = 0; // kComputeExtCap* bits the program registered + std::vector> commands; // {id, label} + std::string configDefaults; +}; + +class ComputeHandler : public CesPlexHandler { +public: + explicit ComputeHandler(CesServer* server); + ~ComputeHandler(); + + ComputeHandler(const ComputeHandler&) = delete; + ComputeHandler& operator=(const ComputeHandler&) = delete; + + // CesPlexHandler: a freshly-bound /ces/compute/1 channel. Runs the signed + // per-op verb loop (cesPlexServe). + void serve(std::shared_ptr stream, + BoundChannelContext bound) override; + + // Bring the handler up: validate prereqs (computeMaxInstances > 0, + // builtin:file up, work-dir + child binary resolvable), create the + // work-dir, register the file-deletion interlock, and start the + // supervisor tick. Returns CES_OK or a CES_ERROR_COMPUTE_* code. + uint8_t start(); + + // SIGKILL every running instance, cancel the supervisor tick, and stop + // accepting verbs. Call on shutdown before CesPlex / rpcRudp_ go away. + void stop(); + + // Bounded wait for in-flight ces.request_funds remote transfers to finish, + // so the CesServer isn't destroyed under them. Call before stop(). + void fundingDrain(); + + // Boot-time launch path for /s/ extensions. Bypasses the + // wire-auth/dedup/upfront-fee sequence used by the LAUNCH verb -- the caller + // is the server itself. The source file at `name` must already exist on disk + // (deploy via fileHandler), be in /s/, and be owned by the server's pubkey. + // Must be called from rpcTaskIO_'s strand. CES_OK means "validated + spawn + // started," not "child connected." + uint8_t launchInternal(const std::string& name); + + // Rebuild the public /s/instances.html catalog from the live instance + // table (the pre-computed page portless luarpc:// readers land on). Fires + // automatically at every /s/ pid-set change (launch commit, kill/death); + // this entry point covers boot so the file exists even with zero + // extensions. Must be called from rpcTaskIO_'s strand (launchExtensions is). + void regenerateInstanceCatalogNow(); + + // Enable an extension by source path: idempotent, singleton, thread-safe. Marshals + // onto rpcTaskIO_ (blocking post+wait) so the launch is serialized with the IPC + // readers/kills, and refuses a duplicate if the source already has a live instance + // OR a spawn in flight. Returns true if it is now (or was already) running; on + // failure sets errOut to the extension's own last log line (crash diagnostic) if any. + bool enableExtension(const std::string& source, std::string& errOut); + + // Deliver an inbound CES_APP_COMPUTE_MSG-shaped packet. Called from + // CesServer::incomingApplication after the hop onto rpcTaskIO_. `senderPfx` + // is the 8-byte prefix of the sending client's pubkey (reply-to target for + // ces.client_send); all-zero means the sender wasn't in presence. + void onApplicationMsg(const uint8_t* data, std::size_t len, + const std::array& senderPfx); + + // Snapshot every running instance for monitoring. Safe to call from any + // thread; runs on the CesPlex strand internally (blocking post+wait). + std::vector snapshot(); + + // What a running instance reported: identity (ces.manifest) + the contract + // (ces.extension_admin). false if pid unknown. Synchronous (CesPlex strand). + bool extInfo(uint64_t pid, ComputeExtInfo& out); + + // Round-trip a status/command request to a running extension; `out` is the + // reply payload after the status byte. false on timeout / unknown / non-ext / + // child error. + bool extRequest(uint64_t pid, uint8_t kind, const ces::Bytes& in, + ces::Bytes& out, int timeoutMs); + + // Push a config blob (text) to a running extension's on_config. Best-effort. + void extConfig(uint64_t pid, const std::string& cfg); + // Panel watch on/off (drives the child's change-detect push tick). One-way, + // idempotent; the caller re-sends periodically to survive child relaunches. + void extPanelWatch(uint64_t pid, bool on); + + // Kill every running instance of `sourceName` (e.g. "/s/discovery.lua"). + // Async (hops onto the CesPlex strand). The ExtensionManager's Disable. + void killBySource(const std::string& sourceName); + + // Deliver a flooded gossip message to EVERY local compute instance (each + // child calls its program's on_gossip handler if defined). Must run on the + // compute supervisor thread (rpcTaskIO_). + void deliverGossip(const minx::Hash& author, const minx::Hash& sender, + const minx::Hash& msgId, const minx::Hash& dest, + const uint8_t* msg, std::size_t len); + + // SYS_L2_CALL delivery: route the paid call into the target live instance + // (blob = [u64 pid BE][payload]) via TAG_L2_CALL_IN; the child settles it + // with a TAG_L2_CALL_RESULT (delivered / no-handler), else the timeout + // sweep refunds. Runs on the CesPlex strand (like every handler entry). + uint8_t cesplexL2Call(const L2CallRequest& req, L2CallReport report) override; + // The child's TAG_L2_CALL_RESULT: delivered marks the pending call (settle + // waits for the reply); no-handler reports it (refund) at once. + void l2Result(uint64_t callId, bool delivered); + // The child's TAG_L2_CALL_REPLY: on_l2call's return bytes. Reports the call + // as Delivered, carrying the reply to the sink (channel respond / followup). + void l2Reply(uint64_t callId, const uint8_t* data, size_t len); + // Resolve L2 calls whose deadline passed: a delivered-but-silent call keeps + // the payment (empty reply); one that never delivered is refunded. + void l2SweepTimeouts(uint64_t nowUs); + + // ---- /ces/lua/1 + /ces/peer/1 cross-handler primitives. Used by the lua / + // peer handlers; all run on rpcTaskIO_'s strand. ---- + + // True iff `pid` is currently registered in the supervisor. + bool instanceExists(uint64_t pid); + // True iff the instance has its accept gate open (ces.conn.set_listener). + bool instanceAcceptsConnections(uint64_t pid); + // The instance's declared greeting (empty if none / request-driven). + std::vector instanceHello(uint64_t pid); + // Allocate a fresh server-side conn_id and send TAG_CONN_OPENED to the child + // (with the user's pubkey). Returns the new conn_id, or 0 if the instance + // is gone before the allocation lands. + uint64_t openConnection(uint64_t pid, + const std::array& userPubkey); + // Send TAG_CONN_DATA_IN (bytes user -> program). No-op if instance is gone. + void sendConnDataIn(uint64_t pid, uint64_t connId, + const uint8_t* data, std::size_t len); + // Send TAG_CONN_CLOSED (channel ended). No-op if instance is gone. The lua + // handler cleans up its own (pid, connId) routing entry separately. + void sendConnClosed(uint64_t pid, uint64_t connId, uint8_t reason); + // Route an inbound /ces/peer/1 mesh message to the local instance that + // registered `service` via ces.peer.listen. No-op if none did. + void routePeerMsg(const std::string& service, const minx::Hash& fromKey, + const uint8_t* data, std::size_t len); + + // ---- Test hooks (see comments at the definitions). ---- + void testForceTick(); + std::size_t testFloodDeliver(uint64_t pid, std::size_t count); + uint16_t testInstanceClientPort(uint64_t pid); + uint16_t testInstanceRpcPort(uint64_t pid); + + // Count of child instances that terminated by a signal (a crash, e.g. SIGSEGV + // on a bad shutdown). Surfaced in the supervisor WARNING; lets a test assert + // an instance shut down cleanly. + std::size_t crashedCount() const { return crashedInstances_.load(); } + + // Per-server supervisor state. Public so this translation unit's free + // helpers reach it via server->computeHandler(); not a stable API. + CesServer* server_ = nullptr; + std::map> instances_; + // In-flight L2 calls awaiting the child's reply (or timeout). Keyed by the + // host-owned callId, one map for both callers (VM syscall and client CALL + // verb) -- they differ only in the report's sink, which the host owns. + // Touched only on the CesPlex strand. RAM-ONLY: a hard crash between accept + // and the reply loses the refund. See the DURABILITY GAP note on + // CesServer::PendingL2Call (server.h) for the full analysis and the fix. + struct PendingL2 { + L2CallReport report; + minx::Hash payee; // instance program pubkey, minted on delivery + bool delivered = false; // RESULT(delivered) seen -> keep money on timeout + uint64_t deadlineUs = 0; + }; + std::map pendingL2_; + + std::map, std::set> byPrefix_; + std::map> byName_; + // Source path -> expiry (us) of a spawn in flight. Bridges the async connect-back + // window so a rapid second enable does not double-launch a singleton extension; + // byName_ takes over once the child registers. Short TTL -> never a stuck entry. + std::map launchingUntil_; + // Source path -> {when(us), diagnostic} of the most recent instance death, so a + // failed enable can surface WHY it died (the extension's own last log line). + std::map> lastExtDeath_; + std::map serviceTags_; + uint64_t nextPid_ = 1; + std::map> extPending_; + uint16_t extCorr_ = 1; + uint64_t pendingLaunches_ = 0; + std::set usedComputePorts_; + std::shared_ptr tickTimer_; + std::atomic tickRunning_{false}; + std::atomic fundingInFlight_{0}; + std::atomic stopped_{false}; + std::atomic crashedInstances_{0}; +}; + +// Test hook: reads CPU ticks (utime + stime from /proc//stat) and +// resident-set-size bytes for the given pid. Stateless; not tied to a handler. +bool _computeTestReadProcSample(int pid, + uint64_t& outTicks, + uint64_t& outRssBytes); + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/compute_lua_handler.h b/vendor/ces/include/ces/l2/compute_lua_handler.h new file mode 100644 index 0000000..ee6244c --- /dev/null +++ b/vendor/ces/include/ces/l2/compute_lua_handler.h @@ -0,0 +1,68 @@ +// compute_lua_handler.h - builtin:lua: per-server channel-routing handler. +// +// Mounts /ces/lua/1. Routes a user's signed-bound RUDP channel to a running +// cesluajitd instance via a one-shot ATTACH verb, then shovels raw bytes both +// ways through the compute supervisor's IPC. +// +// One LuaHandler OBJECT per CesServer (owned by the server). No process-global +// state. Lifecycle: the server constructs a LuaHandler(this), mounts it into +// its CesPlex, and drops it (after stop()) on shutdown. + +#pragma once + +#include // CesPlexHandler, BoundChannelContext +#include + +#include +#include +#include +#include +#include + +namespace ces { + +class CesServer; +struct LuaConnCtx; // per-connection state, defined in the .cpp + +class LuaHandler : public CesPlexHandler { +public: + explicit LuaHandler(CesServer* server); + ~LuaHandler(); + + LuaHandler(const LuaHandler&) = delete; + LuaHandler& operator=(const LuaHandler&) = delete; + + // CesPlexHandler: a freshly-bound /ces/lua/1 channel. Reads the one-shot + // ATTACH verb, then enters raw-byte DATA mode into the target instance. + void serve(std::shared_ptr stream, + BoundChannelContext bound) override; + + // Tear down all routed connections. Call on shutdown before CesPlex / + // rpcRudp_ go away. + void stop(); + + // Cross-handler dispatchers, called by the compute supervisor when a child + // writes bytes back / closes a conn from the program side, or an instance + // dies. Reached via server->luaHandler(). + void handleConnDataOut(uint64_t pid, uint64_t connId, + const uint8_t* data, std::size_t len); + void handleConnClose(uint64_t pid, uint64_t connId); + void onInstanceDying(uint64_t pid); + +private: + using ConnKey = std::pair; // (pid, conn_id) + + void teardownConn(std::shared_ptr ctx, uint8_t reason, + bool notifyChild); + void kickConnWrite(std::shared_ptr ctx); + void sendAttachReply(std::shared_ptr ctx, uint8_t status, + uint64_t reqSigHash, + const std::vector& hello = {}); + void readAttachVerb(std::shared_ptr ctx); + void dataReadLoop(std::shared_ptr ctx); + + CesServer* server_; + std::map> conns_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/file_client.h b/vendor/ces/include/ces/l2/file_client.h new file mode 100644 index 0000000..b31418d --- /dev/null +++ b/vendor/ces/include/ces/l2/file_client.h @@ -0,0 +1,141 @@ +// file_client.h - blocking client for the L2 file-store protocol. +// +// Wraps the full stack needed to drive `builtin:file` verbs from outside +// the server: MINX socket, RUDP, CesPlex select handshake, and per-verb +// preamble-first encoding with server-signed response parsing. +// +// The class is blocking: each verb posts to internal threads, waits for +// completion, returns. One instance talks to exactly one server over +// exactly one RUDP channel (opened at connect, closed at disconnect / +// destructor). Callers that need parallelism use multiple instances. +// +// Response signature verification: call setServerPubkey() with the +// operator's pubkey (fetched via `server-info` or provided by the +// user) before running any verb. Each response's server signature is +// then verified; on mismatch, a LOGERROR is emitted but the op's +// return value is unchanged — the caller decides whether to trust +// the bytes. +// +// Error model: every verb returns a `uint8_t` CES error code +// (CES_OK on success). Out-parameters are written only on CES_OK. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace ces { + +class CesPlexChannel; + +class CesFileClient { +public: + // Data returned by STAT. + struct StatInfo { + std::array ownerPubkey{}; + uint64_t fileBalance = 0; + uint64_t pricePerKb = 0; + uint64_t size = 0; + uint64_t createdUs = 0; + uint64_t modifiedUs = 0; + }; + + CesFileClient(); + ~CesFileClient(); + + CesFileClient(const CesFileClient&) = delete; + CesFileClient& operator=(const CesFileClient&) = delete; + + // Open a UDP socket, wire up Rudp, and perform the signed CesPlex + // bind handshake for "/ces/file/1" against the target server. + // `signerKey` is the principal identity for the channel — every + // verb on this connection bills + acts as `signerKey.getPublicKeyAsHash()`. + // To act as a different principal, open a new connection. + // + // Returns CES_OK on success; CES_ERROR_INTERNAL on transport + // failure or sig-verify mismatch; CES_ERROR_PROTO_REJECTED on a + // signed NACK reply. + uint8_t connect(const std::string& host, uint16_t rpcPort, + const KeyPair& signerKey); + + // Drive verbs over a CesPlexChannel the caller owns and has already + // bound (select()ed) — e.g. the compute child driving /ces/file/1 over + // its own CesPlex endpoint, instead of opening a fresh socket. Mutually + // exclusive with connect(); the caller owns the channel's lifetime. + void attach(CesPlexChannel& channel); + + // Tear down the channel and I/O threads. Safe to call more than once. + void disconnect(); + + // Provide the server's 32-byte public key so response signatures can + // be verified. Leaving this unset is permitted — responses are then + // treated as unverifiable; one LOGERROR is emitted per response. + void setServerPubkey(const minx::Hash& pk); + + // ---- Verbs ---- + // + // The signing key is fixed at connect() time; every verb on this + // connection is signed by + bills against that key. No per-verb + // signer parameter — the bind contract makes the channel principal + // immutable for the channel's lifetime. + + uint8_t create(const std::string& name, + uint64_t size, uint64_t pricePerKb, + uint64_t initialDeposit, + uint64_t& outFileBalance, uint64_t& outCostDebited); + + // `content.size()` must be ≤ 1 MB (server cap). For bigger payloads + // the caller chunks and calls `write` multiple times. + uint8_t write(const std::string& name, + uint64_t offset, const ces::Bytes& content, + uint64_t& outFileBalance); + + // `length` must be ≤ 1 MB. + uint8_t read(const std::string& name, + uint64_t offset, uint32_t length, + ces::Bytes& outContent, + minx::Hash& outRangeHash); + + uint8_t deposit(const std::string& name, + uint64_t amount, uint64_t& outFileBalance); + + // Fund a single key in a kv-store ("anyone funds any entry there"): adds + // `amount` to that key's rent balance. Any signer, no owner check. The key + // must already exist. Returns the key's new cell balance. + uint8_t kvDeposit(const std::string& name, const ces::Bytes& key, + uint64_t amount, uint64_t& outCellBalance); + + uint8_t withdraw(const std::string& name, + uint64_t amount, uint64_t& outFileBalance); + + uint8_t setPrice(const std::string& name, + uint64_t newPrice, uint64_t& outPrice); + + uint8_t deleteFile(const std::string& name, + uint64_t& outRefunded); + + uint8_t append(const std::string& name, + const ces::Bytes& content, + uint64_t& outFileBalance, uint64_t& outNewSize); + + uint8_t resize(const std::string& name, + uint64_t newSize, uint64_t& outNewSize); + + uint8_t stat(const std::string& name, StatInfo& outInfo); + + // Declared public only so .cpp-local helpers can take Impl& as a + // parameter. The full definition lives in the .cpp; callers have + // no way to instantiate or inspect it. + class Impl; + +private: + std::unique_ptr impl_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/file_handler.h b/vendor/ces/include/ces/l2/file_handler.h new file mode 100644 index 0000000..63c070b --- /dev/null +++ b/vendor/ces/include/ces/l2/file_handler.h @@ -0,0 +1,212 @@ +// file_handler.h - builtin:file: per-server disk file storage handler. +// +// Mounts /ces/file/1 on the plex (rpc) port. Disk-backed file storage with +// four-zone naming, per-byte/day rent, and a kv-store file type. +// +// One FileHandler OBJECT per CesServer (owned by the server, holds a back +// pointer to it). No process-global state: N servers coexist in one process. +// +// Lifecycle: the server constructs a FileHandler(this) and mounts it into its +// CesPlex when /ces/file/1 is wired in [cesplex_mounts], then calls +// startupReconcile(); on shutdown it calls stop() and drops the object (before +// CesPlex / rpcRudp_ go away). cesFileStoreMaxBytes is only the metered-zone +// cap (/s/ is unmetered and served whenever mounted), not the on/off switch. +// +// Rent is collected lazily - on every non-DEPOSIT op touching a file, and on +// JIT GC during CREATE when the store is at capacity. No periodic "rent pass" +// runs; dead files stay on disk until the next CREATE (or op touching them) +// reclaims them. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include // ces::Bytes +#include // CesPlexHandler, BoundChannelContext +#include + +namespace ces { + +class CesServer; + +// The single /s/ ("server zone") predicate. /s/ paths are operator-deployed: +// unmetered, outside the store cap, server-key-write-only; a compute program +// sourced from /s/ runs privileged (may use operator-only API such as ces.log). +// Every /s/ and privilege decision routes through here -- do not re-derive it. +inline bool isServerZone(const std::string& name) { + return name.size() >= 3 && name[0] == '/' && + name[1] == 's' && name[2] == '/'; +} + +// --------------------------------------------------------------------------- +// In-process verb execution for L2 cross-handler use +// --------------------------------------------------------------------------- +// +// Parallel to the wire verbs, but takes an already-authorized owner +// pubkey (no wire signature verify, no nonce, no dedup). Intended +// for builtin:compute: a Lua program running under an owner's +// authority invokes these to touch the file store 1:1 as the owner +// would. +// +// Fees mirror the wire path exactly (feeQuery per op, feeFileWrite +// on WRITE/APPEND, feeFileRead + price_per_kb on non-owner READ, +// upfront rent on CREATE/APPEND/RESIZE-grow, initial_deposit on +// CREATE, etc.). The PAYER for every credit that would normally hit +// the wire signer's account is `sourceName` - the file_balance of +// the program's source file ("the program's wallet"). Refunds that +// would normally go to the wire signer's account (WITHDRAW amount, +// DELETE refund) also land in sourceName's file_balance. +// +// Zone-ownership gate (/h/, /f/, /p/, /s/) still applies. +// +// Returns via callback on `cbExecutor`. Thread-safe: internal calls +// hop to logicStrand_ as needed (/f/ zone check). + +struct FileExecReq { + std::array ownerPubkey{}; + std::string sourceName; // program's source file - pays all fees + uint8_t verb = 0; // 0x01..0x0a (matches wire verb codes) + std::string name; + uint64_t offset = 0; // WRITE, READ + uint32_t length = 0; // READ + uint64_t size = 0; // CREATE, RESIZE + uint64_t pricePerKb = 0; // CREATE, SET_PRICE + uint64_t initialDeposit = 0; // CREATE + uint64_t amount = 0; // DEPOSIT, WITHDRAW, KV_DEPOSIT, KV_RANGE byte budget + ces::Bytes body; // WRITE, APPEND + ces::Bytes key; // KV_PUT, KV_GET, KV_ERASE, KV_DEPOSIT + ces::Bytes value; // KV_PUT + ces::Bytes rangeLo; // KV_RANGE (inclusive lower bound; empty = start of store) + ces::Bytes rangeHi; // KV_RANGE (exclusive upper bound; empty = end of store) +}; + +struct FileExecResp { + uint8_t status = 0xFF; // CES_OK on success, else error_code_t + // Verb-specific outputs. Zero-valued on verbs that don't set them. + uint64_t fileBalance = 0; // CREATE, WRITE, DEPOSIT, WITHDRAW, APPEND, RESIZE, STAT + uint64_t size = 0; // STAT, APPEND (new size), RESIZE (new size) + uint64_t pricePerKb = 0; // STAT, SET_PRICE + uint64_t createdUs = 0; // STAT + uint64_t modifiedUs = 0; // STAT + uint64_t refunded = 0; // DELETE + std::array ownerPubkey{}; // STAT + ces::Bytes data; // READ + ces::Bytes value; // KV_GET + bool found = false; // KV_GET + std::vector keys; // KV_ITER, KV_RANGE (sorted; parallel to values) + std::vector values; // KV_RANGE (parallel to keys) + ces::Bytes rangeEnd; // KV_RANGE: == requested hi if complete, else + // the next undelivered key (resume point) +}; + +// Per-server kv-store cache (mutex + open-store map). Defined in the .cpp, +// where the logkv store type is visible; the handler holds it by pointer so +// this header stays free of logkv. +struct FileKvCache; + +class FileHandler : public CesPlexHandler { +public: + explicit FileHandler(CesServer* server); + ~FileHandler(); + + FileHandler(const FileHandler&) = delete; + FileHandler& operator=(const FileHandler&) = delete; + + // CesPlexHandler: a freshly-bound /ces/file/1 channel. Runs the signed + // per-op verb loop (cesPlexServe). + void serve(std::shared_ptr stream, + BoundChannelContext bound) override; + + // Release kv handles and stop accepting verbs. Call on shutdown before + // CesPlex / rpcRudp_ go away. + void stop(); + + // One-time startup walk of the file store directory: publish the bundled + // /s/ site, reconcile /s/ sidecars, regenerate the /s/ index, and recompute + // total_files/total_bytes into .store.toml. Safe with no files present. + void startupReconcile(); + + // Daily per-extension local-budget sweep: top every /s/ program account up + // to the server's extLocalBudget (deficit only). Called from dailyTaskTick. + // No-op if the file feature is off or the budget is 0. + void sweepExtensionBudget(); + + // Daily per-key rent sweep for kv-stores: charge each cell its rent, evict + // cells whose balance hits 0, and burn the collected rent from the store's + // program account. MUST run OFF logicStrand_ (takes the kv mutex then hops + // to logicStrand for the burn). + void sweepKvRent(); + + // /s/ file read/write/remove for L2 cross-handler use (the extension + // manager). Read returns "" and write/remove false if not /s/. + std::string readServerFile(const std::string& name); + bool writeServerFile(const std::string& name, const std::string& content); + bool removeServerFile(const std::string& name); + + // Read store-level stats from .store.toml (under the store mutex) for + // monitoring. Fills the totals (0/0 when empty). Any-thread safe. + bool storeStats(uint64_t& outTotalFiles, uint64_t& outTotalBytes); + + // ------------------------------------------------------------------------- + // Cross-handler primitives (builtin:compute). In-process, unsigned; no + // feeQuery / nonce / dedup. See the per-method comments in file_handler.cpp. + // ------------------------------------------------------------------------- + + // Roll rent forward and return the file's owner pubkey + program-account + // balance. False if missing/unreadable or rent drove it into deletion. + bool readOwnerAndBalance(const std::string& name, + std::array& outOwnerPubkey, + uint64_t& outFileBalance); + // Read the file's program-account ed25519 public key from its sidecar. + bool readProgramPubkey(const std::string& name, + std::array& outProgramPubkey); + // Read the file's program-account ed25519 private key from its sidecar. + bool readProgramPrivkey(const std::string& name, + std::array& outProgramPrivkey); + // Read a file's size in bytes (for mail-attachment cost estimation, before + // any read work). Returns false if the file doesn't exist. + bool attachmentSize(const std::string& name, uint64_t& outSize); + // Read a file's content for a mail attachment, bounded by maxBytes. Returns + // CES_OK, CES_ERROR_FILE_NOT_FOUND, or CES_ERROR_BAD_INPUT (over maxBytes). + uint8_t readAttachment(const std::string& name, uint64_t maxBytes, + ces::Bytes& outContent); + // Debit `amount` from the file's program account (rolls rent first). The + // file is DELETED if the post-roll balance cannot cover it; returns false. + bool debitBalance(const std::string& name, uint64_t amount); + // Credit `amount` into the file's program account (rolls rent first). + bool creditBalance(const std::string& name, uint64_t amount); + // sha256(file_content || file_path), cached in the sidecar until the file + // is content-mutated. Used by builtin:compute for authentic asset minting. + bool getProgramHash(const std::string& name, + std::array& outHash); + // Register a callback invoked right after a file is deleted by any internal + // path. Fires in registration order on the deleting thread. No deregister. + void registerDeletionCallback(std::function cb); + // In-process verb execution. See FileExecReq / FileExecResp above. + void exec(const FileExecReq& req, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // Per-server state. Public so this translation unit's free helpers + // (file_handler.cpp) reach it via server->fileHandler(); not a stable API + // for other code. + CesServer* server_ = nullptr; + std::mutex storeMetaMutex_; + std::mutex deletionCallbacksMutex_; + std::vector> deletionCallbacks_; + std::unique_ptr kv_; + +private: + std::atomic stopped_{false}; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/ledger_txn.h b/vendor/ces/include/ces/l2/ledger_txn.h new file mode 100644 index 0000000..d64d701 --- /dev/null +++ b/vendor/ces/include/ces/l2/ledger_txn.h @@ -0,0 +1,39 @@ +#pragma once +// The single atomic ledger transaction available to an L2 verb. A verb does its +// disk and validation work off the logic strand, then makes at most one +// CesServer::_l2Transact(fn) call; inside fn it touches accounts and assets +// atomically through this interface. CesServer implements it over its stores. + +#include // minx::Hash, HashPrefix + +#include + +namespace ces { + +struct LedgerTxn { + // Network billing: validateSpend then debit on success. Burns the amount. + // Returns the CES rc. + virtual uint8_t signerSpend(const minx::Hash& signer, uint64_t amount, + uint32_t reqNonce, int64_t errFee) = 0; + + // Program-account billing: balance check then debit. false on missing + // account or insufficient balance. + virtual bool debitAccount(const minx::Hash& pubkey, uint64_t amount) = 0; + + // Adds to balance, creating the account if missing. + virtual void credit(const minx::Hash& pubkey, int64_t amount) = 0; + + // 0 if the account does not exist. + virtual int64_t balance(const minx::Hash& pubkey) = 0; + + // NONCELESS dedup, keyed on the op sig-hash. + virtual bool isReplay(uint64_t sigHash) = 0; + virtual void recordDedup(uint64_t sigHash) = 0; + + // Asset ownership read, for /f/ zone authz inside the txn. + virtual bool assetOwnedBy(const minx::Hash& assetId, const minx::Hash& who) = 0; + + virtual ~LedgerTxn() = default; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/mail_client.h b/vendor/ces/include/ces/l2/mail_client.h new file mode 100644 index 0000000..9610f0f --- /dev/null +++ b/vendor/ces/include/ces/l2/mail_client.h @@ -0,0 +1,66 @@ +// mail_client.h - blocking client for the L2 mail protocol (/ces/mail/1). +// +// One verb (SEND). A thin wrapper over a CesPlexChannel, the same shape as +// CesFileClient / CesComputeClient: one instance, one RUDP channel, one server. +// Pure wire -- it never touches SMTP, so it is built regardless of CES_MAIL and +// works against any server that mounts builtin:mail. The server burns a per-MB +// anti-spam fee from the bound signer and relays the message. + +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace ces { + +class CesPlexChannel; + +class CesMailClient { +public: + CesMailClient(); + ~CesMailClient(); + + CesMailClient(const CesMailClient&) = delete; + CesMailClient& operator=(const CesMailClient&) = delete; + + // Open a UDP socket, wire up Rudp, and perform the signed CesPlex bind + // handshake for "/ces/mail/1". `signerKey` becomes the channel principal: + // the SEND is signed by and billed (fee burned) against its pubkey. + uint8_t connect(const std::string& host, uint16_t rpcPort, + const KeyPair& signerKey); + + // Drive the verb over a CesPlexChannel the caller owns and has already + // bound. Mutually exclusive with connect(). + void attach(CesPlexChannel& channel); + + // Tear down the channel and I/O threads. Safe to call more than once. + void disconnect(); + + // Provide the server's 32-byte public key so the response signature can be + // verified. Unset = responses treated as unverifiable (one LOGERROR each). + void setServerPubkey(const minx::Hash& pk); + + // SEND: relay one message. `text` is the plain-text body (binary-safe + // bytes); it streams as the request body (hash-committed in the preamble), + // so it is bounded by the server's mailMaxEncodedBytes, not the envelope. + // to/subject/attachmentPath ride the envelope (<= 1024 bytes combined). + // `attachmentPath` is an optional store path to one file attachment ("" = + // none; a private /m// path must be owned by the signer). Returns the + // server's CES status (CES_OK = charged + queued for delivery). + uint8_t send(const std::string& to, const std::string& subject, + const ces::Bytes& text, const std::string& attachmentPath); + + // Implementation detail; public only so the .cpp-local helpers can take + // Impl& without forward-declaring everything inside the .cpp. + class Impl; + +private: + std::unique_ptr impl_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/l2/mail_handler.h b/vendor/ces/include/ces/l2/mail_handler.h new file mode 100644 index 0000000..2ec5237 --- /dev/null +++ b/vendor/ces/include/ces/l2/mail_handler.h @@ -0,0 +1,47 @@ +// mail_handler.h - builtin:mail, the outbound email RPC service. +// +// A CesPlex handler with one verb (SEND). The server estimates the encoded +// (on-the-wire) message size, burns a per-MB fee from the signer (anti-spam; +// no payee, the fee is destroyed), then relays via the SMTP client. One +// optional file attachment, referenced by store path; a private /m/ attachment +// is readable only here, owner-gated. Two front doors share mailSubmit(): the +// SEND verb (users) and ces.mail.send (programs, charged from their account). + +#pragma once + +#ifdef CES_MAIL + +#include +#include + +#include +#include +#include +#include + +namespace ces { + +class CesServer; + +class MailHandler : public CesPlexHandler { + public: + explicit MailHandler(CesServer* server) : server_(server) {} + + void serve(std::shared_ptr stream, + BoundChannelContext bound) override; + void stop() { stopped_ = true; } + + // estimate -> max-size gate -> burn `payer` -> read attachment -> send. + // `payer` is the account charged (burned). Returns a CES status. + uint8_t mailSubmit(const minx::Hash& payer, const std::string& to, + const std::string& subject, const std::string& text, + const std::string& attachmentPath); + + private: + CesServer* server_ = nullptr; + std::atomic stopped_{false}; +}; + +} // namespace ces + +#endif // CES_MAIL diff --git a/vendor/ces/include/ces/l2/peer_handler.h b/vendor/ces/include/ces/l2/peer_handler.h new file mode 100644 index 0000000..0394d9b --- /dev/null +++ b/vendor/ces/include/ces/l2/peer_handler.h @@ -0,0 +1,133 @@ +// peer_handler.h - builtin:peer: the per-server server-to-server mesh handler. +// +// Mounts /ces/peer/1 on the plex (rpc) port. A persistent, unmetered, +// bidirectional RUDP channel between two CES servers that are mutual entries +// in each other's peer tables, plus a service-tagged message bus that +// extensions ride (ces.peer.send / ces.peer.listen). +// +// One PeerHandler OBJECT per CesServer (owned by the server, holds a back +// pointer to it). No process-global state: N servers coexist in one process. +// All link bookkeeping runs on the server's rpcTaskIO_ strand. +// +// Lifecycle: the server constructs a PeerHandler(this), mounts it into its +// CesPlex (cesplex->mount(CES_PEER_PROTO, h)), and calls start(); on shutdown +// it calls stop() and drops the object (before CesPlex / rpcRudp_ go away). + +#pragma once + +#include // CesPlexHandler, BoundChannelContext +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ces { + +class CesServer; + +// Protocol name for the server-to-server mesh. Mounted whenever the plex +// port is up (the master switch is rpc_port). +inline constexpr const char* CES_PEER_PROTO = "/ces/peer/1"; + +// A candidate peer for the mesh, flattened from the peer table for the +// reconcile pass. dialable = we know where to reach its plex port. +struct PeerLinkTarget { + minx::Hash ckey{}; // peer identity (account / peer-table key) + minx::SockAddr endpoint; // peer's plex (rpc) endpoint, valid iff dialable + bool dialable = false; +}; + +// Pure reconcile decision (no I/O, no state): from the current peer set, our +// identity, and the ckeys we already hold links to, decide which to dial and +// which to drop. Dial only peers we are the lower-pubkey side of (one channel +// per pair) that are dialable and unlinked; drop links whose peer is gone. +// Separately unit-tested. +struct PeerLinkActions { + std::vector toDial; + std::vector toDrop; +}; +PeerLinkActions computePeerLinkActions( + const std::vector& peers, + const minx::Hash& ourKey, + const std::set& currentLinks); + +// Per-link state, defined in the .cpp. +struct PeerLink; + +class PeerHandler : public CesPlexHandler { +public: + explicit PeerHandler(CesServer* server); + ~PeerHandler(); + + PeerHandler(const PeerHandler&) = delete; + PeerHandler& operator=(const PeerHandler&) = delete; + + // CesPlexHandler: an inbound /ces/peer/1 channel handed off after bind. + void serve(std::shared_ptr stream, + BoundChannelContext bound) override; + + // Begin the reconcile pass (dial / drop / hold the mesh). Idempotent-safe + // to call once at server start, after the rpc threads exist. + void start(); + // Cancel the reconcile pass and tear down every link. Call before CesPlex / + // rpcRudp_ are destroyed. + void stop(); + + // Send a service-tagged message to peer `destKey` over its /ces/peer/1 link. + // Framed [u16 service_len][service][payload]; the far side routes it by + // service to the extension that registered. No-op if no established link. + // Runs on rpcTaskIO_ (the compute handler relays a child's ces.peer.send). + // + // A link keeps two RUDP channels up: small frames ride the control channel, + // large ones the bulk channel, so a long download never head-of-line-blocks + // consensus. If only one channel is up (the other died or never opened) + // everything rides it -- a latency/QoS regression until reconcile regenerates + // the missing channel, never a loss of the link. The size split and the + // two-channel bookkeeping are internal here; a caller just sends a message. + void sendMessage(const minx::Hash& destKey, const std::string& service, + const uint8_t* data, std::size_t len); + + // True if an established link to destKey exists right now. isLinked is + // thread-safe (mutex-guarded mirror, for the web dashboard / tests); + // hasLink is rpcTaskIO_-only (the message path). + bool isLinked(const minx::Hash& ckey); + bool hasLink(const minx::Hash& destKey); + + // Test hook: run one reconcile pass synchronously (blocks until dispatched + // on rpcTaskIO_); dials it kicks off finish async, poll isLinked. + void reconcileNow(); + +private: + // `bulk` selects which of a link's two channels a per-channel op acts on. + void teardownLink(std::shared_ptr link); + void closeChannel(std::shared_ptr link, bool bulk); + void establishChannel(std::shared_ptr link, bool bulk); + void channelReadLoop(std::shared_ptr link, bool bulk); + void kickWrite(std::shared_ptr link, bool bulk); + void readDialBindReply(std::shared_ptr link, bool bulk); + void dialChannel(std::shared_ptr link, bool bulk); + void dialPeer(const minx::Hash& ckey, const minx::SockAddr& endpoint); + void reconcileOnce(); + void scheduleReconcile(); + + CesServer* server_; + std::atomic running_{false}; + std::shared_ptr reconcileTimer_; + // rpcTaskIO_-only: the live links keyed by peer pubkey. + std::map> links_; + // Cross-thread mirror of established link keys (web dashboard / tests). + std::mutex linkMutex_; + std::set established_; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/lang/bundle.h b/vendor/ces/include/ces/lang/bundle.h new file mode 100644 index 0000000..18ef35b --- /dev/null +++ b/vendor/ces/include/ces/lang/bundle.h @@ -0,0 +1,62 @@ +#pragma once + +/** + * bundle.h — multi-asset packaging for CesVM programs larger than one + * 210-byte boot block ("the bundler"). + * + * Runtime layout: the boot asset holds a loader stub; the program body + * is compiled with codeBase 210 (casmAssemble / ceslCompile) and split + * into 210-byte chunk assets that SYS_LOAD_CODE reassembles contiguously + * at 210, 420, ... — so instructions may straddle chunk boundaries and + * all label targets are link-time constants. Chunk keys live in a chain + * of key-table assets: each table holds 5 chunk keys plus a next-table + * key (zeros = end), so one root key in the loader covers the full 8 KB + * code space. The loader walks the chain (SYS_READ_ASSET per table, + * SYS_LOAD_CODE per chunk), zeroes the scratch cells it used, and jumps + * to 210. + * + * Keys are deterministic — sha256 over a domain tag, the salt, the + * index, and the body bytes — so the whole bundle is computed offline + * with no create-then-patch step. On a key collision with an existing + * asset, rebundle with a different salt. + * + * Deployment: create every chunk and table asset at its listed key + * (CES_CREATE_ASSET), then the boot asset (any key) last. Every run of + * the boot program pays feeQuery per table read and per chunk load on + * top of gas. + */ + +#include +#include + +#include + +#include +#include +#include +#include + +namespace ces { + +class CesBundleError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +struct CesBundle { + AssetData boot{}; // deploy last, at any key + std::vector chunkKeys; // create chunks[i] at chunkKeys[i] + std::vector chunks; + std::vector tableKeys; // tables[0] is the root (baked into boot) + std::vector tables; +}; + +// Package program bytecode into a deployable bundle. A body that fits +// one block (<= 210 bytes, compiled with codeBase 0) becomes a boot-only +// bundle; anything larger must have been compiled with codeBase 210 (the +// compiled length is identical for any base, so the size check is stable +// across the recompile). Throws CesBundleError if the body exceeds the +// 8 KB code space minus the boot block. +CesBundle bundleProgram(const ces::Bytes& body, std::string_view salt = {}); + +} // namespace ces diff --git a/vendor/ces/include/ces/lang/casm.h b/vendor/ces/include/ces/lang/casm.h new file mode 100644 index 0000000..5f29647 --- /dev/null +++ b/vendor/ces/include/ces/lang/casm.h @@ -0,0 +1,73 @@ +#pragma once + +/** + * casm.h — textual assembler for CesVM bytecode. + * + * Maps 1:1 onto the CesVM ISA via the VmProgram builder: every mnemonic + * is one opcode, every operand is one Imm/Ref control-byte operand. + * Input is line-oriented text; output is deployable bytecode. + * + * Line shape (one instruction, label, or directive per line): + * + * ; comment # comment + * name: label definition (targets jmp/call/jf/jt) + * .equ NAME value named constant + * .alloc NAME count allocate `count` scratch cells (bump + * allocator, cells 16..751); NAME = first cell + * .at NAME cell bind NAME to a fixed cell index + * .string NAME "text" emit SETs packing the bytes into cells + * at NAME (little-endian, 8 per cell) + * + * Operands: + * + * 42, 0x2A, -5 immediate value (Imm); -5 wraps to u64 + * name, name+3 symbol (register alias, .equ/.alloc/.at), + * optionally displaced by a constant + * [x] dereference (Ref): the operand value is + * read from cell x at runtime; x is any of + * the immediate forms above + * + * Register aliases (cells 0..15): pc r s sys a0 a1 a2 a3 g0..g7. + * Protocol cells: input_len output_len budget start_time caller_key + * self_key output input allowance gas_left. + * + * Mnemonics are the CesVMOpcode names in lowercase (set, add, mov, jf, + * hostxv, require, dup, ...). A `.s` suffix emits the stack-mode variant + * (operands popped from the data stack): add.s, slt.s, assert.s, jf.s, + * jt.s, call.s. `hostv NAME, args...` / `hostxv NAME, args...` dispatch a + * syscall by enum name (TRANSFER, READ_ACCOUNT, ...) or number. + * + * Example: + * + * .alloc dest 4 + * mov dest, caller_key, 4 ; dest = caller pubkey + * hostxv TRANSFER, dest, 100 ; send 100 credits back to caller + * set output_len, 8 + * set output, [r] + * term + */ + +#include + +#include +#include +#include + +namespace ces { + +// Errors carry "line N: " messages. +class CasmError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +// Assemble CesVM assembly text into bytecode. Throws CasmError on any +// lexical, syntactic, or semantic error. The result is the flexible +// byte-vector shape (VmProgram::buildBytes); callers deploying a boot +// block enforce the 210-byte limit themselves. codeBase relocates all +// label targets for code that runs at a nonzero address (a bundle body +// entered at 210; see lang/bundle.h); the byte length is identical for +// any base. +ces::Bytes casmAssemble(std::string_view source, uint64_t codeBase = 0); + +} // namespace ces diff --git a/vendor/ces/include/ces/lang/cesl.h b/vendor/ces/include/ces/lang/cesl.h new file mode 100644 index 0000000..e70db4f --- /dev/null +++ b/vendor/ces/include/ces/lang/cesl.h @@ -0,0 +1,104 @@ +#pragma once + +/** + * cesl.h — compiler for cesl, the CesVM transaction-script language. + * + * cesl is a small, single-type (u64) imperative language that compiles + * to CesVM bytecode through the VmProgram builder. It targets the + * machine honestly: variables are statically allocated io cells, + * functions are non-reentrant (recursion is a compile error), and + * arithmetic is checked by default (a wrap halts the VM with + * CESVM_OVERFLOW; wrapping operators exist for intentional mod-2^64 + * math). Expressions are evaluated on the VM data stack via the + * stack-mode opcodes. + * + * Program shape: top-level statements are the program (run from PC 0, + * implicit final `term`); `fn` definitions may appear anywhere at top + * level and are emitted after it. + * + * const FEE = 2 * PRICE_UNIT; // compile-time constant (folded) + * let counter = 0; // global cell + * let key[4]; // 4-cell region (32 bytes) + * + * fn clamp(v, hi) { + * if (v > hi) { return hi; } + * return v; + * } + * + * copy(key, caller_key, 4); + * require(deposit(FEE) == 0); + * return clamp(input_len, 64); // top level: writes output, terms + * + * Statements: let, assignment (`x = e;`, `region[i] = e;`), if/else, + * while, break, continue, return, require(e), abort(), expression + * statements, and the memory builtins poke(addr, v), setb(byteoff, v), + * copy(dst, src, n), fill(dst, v, n), emit_string(region, "text"). + * + * Expressions: u64 literals (decimal, 0x hex), variables, region names + * (the value is the region's first cell index), region[i], function + * calls, and operators by C precedence: + * unary ! ~ - + * multiplicative * / % *% + * additive + - +% -% + * shift << >> + * relational < > <= >= (unsigned) + * equality == != + * bitwise & ^ | + * logical && || (short-circuit, yield 0/1) + * `+ - *` are checked; `+% -% *%` wrap. Signed helpers are builtins: + * slt/sgt/sge/sle(a, b) and sar(a, b). + * + * Value builtins: peek(addr), getb(byteoff), memcmp(a, b, n), + * random(), now(). + * + * Syscall builtins mirror the CesVM syscall ABI (cesvm.h) one to one; + * pointer-typed parameters take a region (or any expression yielding a + * cell index). Each dispatches via hostxv (failure aborts the program) + * and yields the syscall's R value: + * read_account(pfx) transfer(dest, amt) deposit(amt) withdraw(amt) + * owner_transfer(dest, amt) read_asset(key, owner_out, content_out) + * create_asset(key, content, days) create_asset_random(content, days, + * key_out) create_asset_range(count, days, key_out) + * create_asset_managed(key, content, days) + * update_asset(key, content) update_asset_meta(key, owner, price) + * fund_asset(key, days) buy_asset(key, max_price) + * give_asset(key, owner) hash(ptr, len, out) + * verify_sig(ptr, len, sig, pubkey) cross_transfer(dest, amt, server) + * load_code(key) send_client(id, ptr, len) + * schedule(key, budget, allowance, in_ptr, in_len, time_us) + * rpc(host, hostlen, port, filehead, followup, budget, tag) + * Each also has a try_ variant (try_transfer, ...) that dispatches via + * hostv and yields S (the CES error code) instead of aborting. + * + * Predeclared names: + * regions input[128] output[128] caller_key[4] self_key[4] + * read-only r s arg0 arg1 arg2 arg3 input_len budget_start + * start_time allowance_left gas_left + * writable output_len + * const PRICE_UNIT + */ + +#include + +#include +#include +#include + +namespace ces { + +// Errors carry "line N: " messages. +class CeslError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +// Compile cesl source into CesVM bytecode. Throws CeslError on any +// lexical, syntactic, or semantic error (including recursion and +// constant-expression overflow). The result is the flexible byte-vector +// shape (VmProgram::buildBytes); boot-block deployment enforces the +// 210-byte limit at the call site. codeBase relocates all label targets +// for code that runs at a nonzero address (a bundle body entered at +// 210; see lang/bundle.h); the byte length is identical for any base. +ces::Bytes ceslCompile(std::string_view source, uint64_t codeBase = 0); + +} // namespace ces diff --git a/vendor/ces/include/ces/persisted.h b/vendor/ces/include/ces/persisted.h new file mode 100644 index 0000000..5cd0733 --- /dev/null +++ b/vendor/ces/include/ces/persisted.h @@ -0,0 +1,21 @@ +#pragma once + +#define CES_PERSISTED_BOILERPLATE(DEFAULT_MODE) \ + static void _setSerMode(SerMode m) { serMode_ = m; } \ + static SerMode _getSerMode() { return serMode_; } \ + static void _logkvStoreSnapshot(bool s) { snapshotFlag_ = s; } \ + static bool _logkvStoreSnapshot() { return snapshotFlag_; } \ + \ + struct SerModeGuard { \ + SerMode prev; \ + explicit SerModeGuard(SerMode m) : prev(_getSerMode()) { _setSerMode(m); } \ + ~SerModeGuard() { _setSerMode(prev); } \ + SerModeGuard(const SerModeGuard&) = delete; \ + SerModeGuard& operator=(const SerModeGuard&) = delete; \ + }; \ + \ +private: \ + inline static thread_local SerMode serMode_ = DEFAULT_MODE; \ + inline static thread_local bool snapshotFlag_ = false; \ + \ +public: diff --git a/vendor/ces/include/ces/protocol.h b/vendor/ces/include/ces/protocol.h new file mode 100644 index 0000000..e4851f7 --- /dev/null +++ b/vendor/ces/include/ces/protocol.h @@ -0,0 +1,1357 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { + +// ============================================================================ + +#define CES_FIELD_DECL(type, name) type name; +#define CES_FIELD_SIZE(type, name) +sizeof(type) +#define CES_FIELD_PUT(type, name) buf.put(name); +#define CES_FIELD_GET(type, name) name = buf.get(); + +#define CES_DECLARE_FIELDS(MACRO) MACRO(CES_FIELD_DECL) +#define CES_CALC_SIZE(MACRO) (0 MACRO(CES_FIELD_SIZE)) +#define CES_WRITE_FIELDS(buf, MACRO) MACRO(CES_FIELD_PUT) +#define CES_READ_FIELDS(buf, MACRO) MACRO(CES_FIELD_GET) + +// ============================================================================ + +#define CES_INJECT_FIXED_SIGNED_PAYLOAD(MACRO) \ + static constexpr size_t SIZE = 1 + CES_CALC_SIZE(MACRO) + sizeof(Signature); \ + size_t getPayloadSize() const { return CES_CALC_SIZE(MACRO); } \ + void writePayload(minx::Buffer& buf) { \ + (void)buf; CES_WRITE_FIELDS(buf, MACRO) } \ + void readPayload(minx::ConstBuffer& buf) { \ + (void)buf; CES_READ_FIELDS(buf, MACRO) } + +#define CES_INJECT_FIXED_UNSIGNED_PAYLOAD(MACRO) \ + static constexpr size_t SIZE = 1 + CES_CALC_SIZE(MACRO); \ + size_t getPayloadSize() const { return CES_CALC_SIZE(MACRO); } \ + void writePayload(minx::Buffer& buf) const { \ + (void)buf; CES_WRITE_FIELDS(buf, MACRO) } \ + void readPayload(minx::ConstBuffer& buf) { \ + (void)buf; CES_READ_FIELDS(buf, MACRO) } + +// ============================================================================ + +#define CES_INJECT_SIGNED_METHODS(OpCode) \ + minx::Bytes toBytes(KeyPair& k) { \ + size_t payloadSz = this->getPayloadSize(); \ + minx::Bytes bytes(1 + payloadSz + sizeof(sig)); \ + minx::Buffer buf(bytes); \ + buf.put(OpCode); \ + this->writePayload(buf); \ + sig = k.signData(bytes.data(), 1 + payloadSz); \ + buf.put(sig); \ + return bytes; \ + } \ + void fromBytes(const minx::Bytes& bytes) { \ + minx::ConstBuffer buf(bytes); \ + if (buf.get() != OpCode) \ + throw std::runtime_error("wrong code"); \ + this->readPayload(buf); \ + sig = buf.get(); \ + } \ + bool verifySignature(const minx::Bytes& originalBytes, const PublicKey& pk) \ + const { \ + size_t expectedSz = 1 + this->getPayloadSize() + sizeof(sig); \ + if (originalBytes.size() != expectedSz) \ + return false; \ + return pk.verifySignature(originalBytes.data(), expectedSz - sizeof(sig), \ + sig); \ + } \ + bool fromBytes(const minx::Bytes& bytes, const PublicKey& pk) { \ + this->fromBytes(bytes); \ + return this->verifySignature(bytes, pk); \ + } + +#define CES_INJECT_UNSIGNED_METHODS(OpCode) \ + minx::Bytes toBytes() const { \ + minx::Bytes bytes(1 + this->getPayloadSize()); \ + minx::Buffer buf(bytes); \ + buf.put(OpCode); \ + this->writePayload(buf); \ + return bytes; \ + } \ + void fromBytes(const minx::Bytes& bytes) { \ + minx::ConstBuffer buf(bytes); \ + if (buf.get() != OpCode) \ + throw std::runtime_error("wrong code"); \ + this->readPayload(buf); \ + } + +// ============================================================================ + +// --- CES_TRANSFER (safe: fail if dest not found) --- +#define CES_TRANSFER_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, destKey) X(uint64_t, amount) + +struct CesTransfer { + CES_DECLARE_FIELDS(CES_TRANSFER_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_TRANSFER_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_TRANSFER) +}; + +// --- CES_TRANSFER_RESULT --- +#define CES_TRANSFER_RESULT_FIELDS(X) \ + X(HashPrefix, originId) \ + X(uint32_t, reqNonce) X(HashPrefix, destId) X(uint64_t, amount) \ + X(int64_t, originNewBalance) X(uint8_t, rcode) + +struct CesTransferResult { + CES_DECLARE_FIELDS(CES_TRANSFER_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_TRANSFER_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_TRANSFER_RESULT) +}; + +// --- CES_OPEN_TRANSFER (auto-create dest if not found) --- +#define CES_OPEN_TRANSFER_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, destKey) X(uint64_t, amount) \ + X(uint64_t, time) + +struct CesOpenTransfer { + CES_DECLARE_FIELDS(CES_OPEN_TRANSFER_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_OPEN_TRANSFER_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_OPEN_TRANSFER) +}; + +#define CES_OPEN_TRANSFER_RESULT_FIELDS(X) \ + X(HashPrefix, originId) \ + X(uint32_t, reqNonce) X(HashPrefix, destId) X(uint64_t, amount) \ + X(int64_t, originNewBalance) X(uint8_t, rcode) + +struct CesOpenTransferResult { + CES_DECLARE_FIELDS(CES_OPEN_TRANSFER_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_OPEN_TRANSFER_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_OPEN_TRANSFER_RESULT) +}; + +// --- CES_CREATE_PAYMENT (create payment account with negative balance) --- +#define CES_CREATE_PAYMENT_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, destKey) X(uint64_t, amount) \ + X(uint8_t, days) + +struct CesCreatePayment { + CES_DECLARE_FIELDS(CES_CREATE_PAYMENT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CREATE_PAYMENT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CREATE_PAYMENT) +}; + +#define CES_CREATE_PAYMENT_RESULT_FIELDS(X) \ + X(HashPrefix, originId) \ + X(uint32_t, reqNonce) X(HashPrefix, destId) X(uint64_t, amount) \ + X(int64_t, originNewBalance) X(uint8_t, rcode) + +struct CesCreatePaymentResult { + CES_DECLARE_FIELDS(CES_CREATE_PAYMENT_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CREATE_PAYMENT_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CREATE_PAYMENT_RESULT) +}; + +// --- CES_CROSS_TRANSFER (inter-server transfer via peer) --- +struct CesCrossTransfer { + Hash originId; + HashPrefix serverId{}; + uint32_t reqNonce; + Hash destKey; + uint64_t amount; + std::string destServer; // peer server address (host:port) + Signature sig{}; + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(serverId) + sizeof(reqNonce) + + sizeof(destKey) + sizeof(amount) + 1 + destServer.size(); + } + + void writePayload(minx::Buffer& buf) { + if (destServer.size() > 255) + throw std::runtime_error("destServer too long"); + buf.put(originId); + buf.put(serverId); + buf.put(reqNonce); + buf.put(destKey); + buf.put(amount); + buf.put(static_cast(destServer.size())); + buf.putBytes(std::span( + reinterpret_cast(destServer.data()), destServer.size())); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + serverId = buf.get(); + reqNonce = buf.get(); + destKey = buf.get(); + amount = buf.get(); + uint8_t len = buf.get(); + destServer = buf.getBytes(len); + } + CES_INJECT_SIGNED_METHODS(CES_CROSS_TRANSFER) +}; + +#define CES_CROSS_TRANSFER_RESULT_FIELDS(X) \ + X(HashPrefix, originId) \ + X(uint32_t, reqNonce) X(uint64_t, amount) \ + X(int64_t, originNewBalance) X(uint8_t, rcode) + +struct CesCrossTransferResult { + CES_DECLARE_FIELDS(CES_CROSS_TRANSFER_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CROSS_TRANSFER_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CROSS_TRANSFER_RESULT) +}; + +// --- CES_GOSSIP (flood/route a message across the server mesh) --- +// Re-signed each hop: originId is the immediate sender (the client at hop 0, +// the relaying server after), serverId binds this copy to its next-hop +// recipient. authorId and msgId are the original broadcaster and a dedup id, +// carried unchanged across hops; budget decrements per hop. dest all-zero = +// broadcast to everyone, otherwise a targeted route. +struct CesGossip { + Hash originId; + HashPrefix serverId{}; + uint32_t reqNonce; + Hash authorId; + Hash msgId; + Hash dest; + uint64_t budget; + uint64_t time = 0; // UTC epoch microseconds (staleness) + ces::Bytes msg; // up to 1024 bytes + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(serverId) + sizeof(reqNonce) + + sizeof(authorId) + sizeof(msgId) + sizeof(dest) + sizeof(budget) + + sizeof(time) + 2 + msg.size(); + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(serverId); + buf.put(reqNonce); + buf.put(authorId); + buf.put(msgId); + buf.put(dest); + buf.put(budget); + buf.put(time); + buf.put(static_cast(msg.size())); + buf.putBytes(msg); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + serverId = buf.get(); + reqNonce = buf.get(); + authorId = buf.get(); + msgId = buf.get(); + dest = buf.get(); + budget = buf.get(); + time = buf.get(); + uint16_t len = buf.get(); + if (len > 1024) len = 1024; + msg = buf.getBytes(len); + } + + Signature sig{}; + CES_INJECT_SIGNED_METHODS(CES_GOSSIP) +}; + +struct CesGossipResult { + HashPrefix originId{}; + uint8_t rcode = 0; + // Amount the receiver actually drained from the sender's reserve for this hop + // (leg 2). The sender credits this same amount to the receiver's reserve on + // itself (leg 1) when the ack lands -- so the per-hop value move is conserved, + // and a lost/timed-out ack leaves paid=0 on the sender side: a burn, never a + // mint. Zero on a free hop, an unpaid drop, or a deduped cycle copy. + uint64_t paid = 0; + Signature sig{}; + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(rcode) + sizeof(paid); + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(rcode); + buf.put(paid); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + rcode = buf.get(); + paid = buf.get(); + } + + CES_INJECT_SIGNED_METHODS(CES_GOSSIP_RESULT) +}; + +// --- CES_RUN_ASSET --- +struct CesRunAsset { + Hash originId; + HashPrefix serverId{}; + uint32_t reqNonce; + Hash assetId; + uint64_t budget; + // Per-run cap on caller-account debits (transfers, asset purchases, protocol + // fees) inside the VM. UINT64_MAX = no enforcement (the default). + uint64_t allowance = std::numeric_limits::max(); + uint64_t time = 0; // UTC epoch microseconds (required for CES_NONCELESS dedup) + ces::Bytes input; // up to 1024 bytes + Signature sig{}; + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(serverId) + sizeof(reqNonce) + + sizeof(assetId) + sizeof(budget) + sizeof(allowance) + + sizeof(time) + 2 + input.size(); + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(serverId); + buf.put(reqNonce); + buf.put(assetId); + buf.put(budget); + buf.put(allowance); + buf.put(time); + buf.put(static_cast(input.size())); + buf.putBytes(input); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + serverId = buf.get(); + reqNonce = buf.get(); + assetId = buf.get(); + budget = buf.get(); + allowance = buf.get(); + time = buf.get(); + uint16_t len = buf.get(); + if (len > 1024) len = 1024; + input = buf.getBytes(len); + } + CES_INJECT_SIGNED_METHODS(CES_RUN_ASSET) +}; + +struct CesRunAssetResult { + HashPrefix originId; + uint32_t reqNonce; + uint8_t rcode; + uint64_t vmError; + uint64_t budgetUsed; + // Allowance consumed by caller-side spending (transfer amounts, + // purchase prices). Always 0 when allowance was set to the + // unlimited sentinel. Distinct from budgetUsed, which covers gas + // and protocol fees. + uint64_t allowanceUsed; + ces::Bytes output; // up to 1024 bytes + Signature sig{}; + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(reqNonce) + sizeof(rcode) + + sizeof(vmError) + sizeof(budgetUsed) + sizeof(allowanceUsed) + + 2 + output.size(); + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(reqNonce); + buf.put(rcode); + buf.put(vmError); + buf.put(budgetUsed); + buf.put(allowanceUsed); + buf.put(static_cast(output.size())); + buf.putBytes(output); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + reqNonce = buf.get(); + rcode = buf.get(); + vmError = buf.get(); + budgetUsed = buf.get(); + allowanceUsed = buf.get(); + uint16_t len = buf.get(); + if (len > 1024) len = 1024; + output = buf.getBytes(len); + } + CES_INJECT_SIGNED_METHODS(CES_RUN_ASSET_RESULT) +}; + +// --- CES_BULK_TRANSFER --- +struct BulkTransferItem { + Hash destKey; + uint64_t amount; + static constexpr size_t SIZE = + sizeof(destKey) + sizeof(amount); +}; + +struct CesBulkTransfer { + Hash originId; + HashPrefix serverId{}; + uint32_t reqNonce; + uint8_t count; + std::vector transfers; + Signature sig{}; + + static constexpr size_t MAX_ITEMS = 20; + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(serverId) + sizeof(reqNonce) + + sizeof(count) + transfers.size() * BulkTransferItem::SIZE; + } + + void writePayload(minx::Buffer& buf) { + if (transfers.size() > MAX_ITEMS) + throw std::runtime_error("too many items"); + count = static_cast(transfers.size()); + buf.put(originId); + buf.put(serverId); + buf.put(reqNonce); + buf.put(count); + for (const auto& item : transfers) { + buf.put(item.destKey); + buf.put(item.amount); + } + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + serverId = buf.get(); + reqNonce = buf.get(); + count = buf.get(); + if (count > MAX_ITEMS) + throw std::runtime_error("too many items"); + transfers.clear(); + transfers.reserve(count); + for (size_t i = 0; i < count; ++i) { + BulkTransferItem item; + item.destKey = buf.get(); + item.amount = buf.get(); + transfers.push_back(item); + } + } + CES_INJECT_SIGNED_METHODS(CES_BULK_TRANSFER) +}; + +#define CES_BULK_TRANSFER_RESULT_FIELDS(X) \ + X(HashPrefix, originId) \ + X(uint32_t, reqNonce) X(uint8_t, rcode) X(uint8_t, successfulCount) \ + X(int64_t, originNewBalance) + +struct CesBulkTransferResult { + CES_DECLARE_FIELDS(CES_BULK_TRANSFER_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_BULK_TRANSFER_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_BULK_TRANSFER_RESULT) +}; + +// --- CES_QUERY_ACCOUNT (Custom Validation) --- +#define CES_QUERY_ACCOUNT_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(HashPrefix, queryId) X(uint8_t, items) + +struct CesQueryAccount { + CES_DECLARE_FIELDS(CES_QUERY_ACCOUNT_FIELDS) + Signature sig{}; + static constexpr size_t MAX_ITEMS = 24; + static constexpr size_t SIZE = + 1 + CES_CALC_SIZE(CES_QUERY_ACCOUNT_FIELDS) + sizeof(Signature); + + size_t getPayloadSize() const { + return CES_CALC_SIZE(CES_QUERY_ACCOUNT_FIELDS); + } + void writePayload(minx::Buffer& buf) { + if (items >= MAX_ITEMS) + throw std::runtime_error("too many items"); + CES_WRITE_FIELDS(buf, CES_QUERY_ACCOUNT_FIELDS) + } + void readPayload(minx::ConstBuffer& buf){CES_READ_FIELDS( + buf, CES_QUERY_ACCOUNT_FIELDS)} CES_INJECT_SIGNED_METHODS(CES_QUERY_ACCOUNT) +}; + +// --- ACCOUNT ENTRY & RESULT (Dynamic Size) --- +struct AccountEntry { + Hash key; + int64_t balance; + uint32_t nonce; + HashPrefix lastXferDest; + uint64_t lastXferAmount; + uint32_t lastXferTime; + static constexpr size_t SIZE = sizeof(key) + sizeof(balance) + sizeof(nonce) + + sizeof(lastXferDest) + sizeof(lastXferAmount) + + sizeof(lastXferTime); +}; + +struct CesQueryAccountResult { + HashPrefix originId; + uint32_t reqNonce; + HashPrefix queryId; + uint8_t items; + uint8_t rcode; + std::vector accounts; + Signature sig{}; + + static constexpr size_t HEADER_SIZE = + 1 + sizeof(originId) + sizeof(reqNonce) + sizeof(queryId) + sizeof(items) + + sizeof(rcode) + sizeof(sig); + + size_t getPayloadSize() const { + size_t sz = sizeof(originId) + sizeof(reqNonce) + sizeof(queryId) + + sizeof(items) + sizeof(rcode); + if (rcode == CES_OK) + sz += accounts.size() * AccountEntry::SIZE; + return sz; + } + void writePayload(minx::Buffer& buf) { + if (rcode == CES_OK) { + if (accounts.empty()) + throw std::runtime_error("CES_OK response has no accounts"); + items = accounts.size() - 1; + } + buf.put(originId); + buf.put(reqNonce); + buf.put(queryId); + buf.put(items); + buf.put(rcode); + if (rcode == CES_OK) { + for (const auto& acc : accounts) { + buf.put(acc.key); + buf.put(acc.balance); + buf.put(acc.nonce); + buf.put(acc.lastXferDest); + buf.put(acc.lastXferAmount); + buf.put(acc.lastXferTime); + } + } + } + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + reqNonce = buf.get(); + queryId = buf.get(); + items = buf.get(); + rcode = buf.get(); + accounts.clear(); + if (rcode == CES_OK) { + size_t count = static_cast(items) + 1; + accounts.reserve(count); + for (size_t i = 0; i < count; ++i) { + AccountEntry acc; + acc.key = buf.get(); + acc.balance = buf.get(); + acc.nonce = buf.get(); + acc.lastXferDest = buf.get(); + acc.lastXferAmount = buf.get(); + acc.lastXferTime = buf.get(); + accounts.push_back(acc); + } + } + } + CES_INJECT_SIGNED_METHODS(CES_QUERY_ACCOUNT_RESULT) +}; + +// --- UNSIGNED QUERIES --- +#define CES_UNSIGNED_QUERY_ACCOUNT_FIELDS(X) X(HashPrefix, accountMapKey) +struct CesUnsignedQueryAccount { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_ACCOUNT_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_ACCOUNT_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_UNSIGNED_QUERY_ACCOUNT) +}; + +#define CES_UNSIGNED_QUERY_ACCOUNT_RESULT_FIELDS(X) \ + X(HashPrefix, queryId) X(int64_t, bal) X(uint32_t, nonce) \ + X(HashPrefix, lastXferDest) X(uint64_t, lastXferAmount) \ + X(uint32_t, lastXferTime) X(uint32_t, aliasId) +struct CesUnsignedQueryAccountResult { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_ACCOUNT_RESULT_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_ACCOUNT_RESULT_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_UNSIGNED_QUERY_ACCOUNT_RESULT) +}; + +#define CES_UNSIGNED_QUERY_SOLUTION_FIELDS(X) \ + X(uint64_t, time) X(Hash, solution) +struct CesUnsignedQuerySolution { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_SOLUTION_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_SOLUTION_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_UNSIGNED_QUERY_SOLUTION) +}; + +#define CES_UNSIGNED_QUERY_SOLUTION_RESULT_FIELDS(X) \ + X(Hash, querySolution) X(uint8_t, queryResult) +struct CesUnsignedQuerySolutionResult { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_SOLUTION_RESULT_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_SOLUTION_RESULT_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_UNSIGNED_QUERY_SOLUTION_RESULT) +}; + +// --- CES_PROVE_WORK_RESULT --- +#define CES_PROVE_WORK_RESULT_FIELDS(X) \ + X(Hash, solution) \ + X(Hash, beneficiary) X(uint64_t, creditAmount) X(uint64_t, serverTime) +struct CesProveWorkResult { + CES_DECLARE_FIELDS(CES_PROVE_WORK_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_PROVE_WORK_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_PROVE_WORK_RESULT) +}; + +// --- ASSET CREATION & UPDATES --- +#define CES_CREATE_ASSET_FIELDS(X) \ + X(Hash, ownerId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(uint16_t, amount) \ + X(uint32_t, price) X(AssetData, content) +struct CesCreateAsset { + CES_DECLARE_FIELDS(CES_CREATE_ASSET_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CREATE_ASSET_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CREATE_ASSET) +}; + +#define CES_CREATE_ASSET_RESULT_FIELDS(X) \ + X(HashPrefix, ownerId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(uint16_t, amount) \ + X(uint32_t, price) X(uint8_t, rcode) +struct CesCreateAssetResult { + CES_DECLARE_FIELDS(CES_CREATE_ASSET_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CREATE_ASSET_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CREATE_ASSET_RESULT) +}; + +// Atomic multi-cell allocation: create `count` account-owned cells at a +// caller-chosen prefix, keyed firstKey||0 .. firstKey||(count-1) (firstKey is +// prefix||0). Success means all cells exist; the caller already knows the +// handle (it picked the prefix), so nothing is echoed but a status. +#define CES_CREATE_ASSET_RANGE_FIELDS(X) \ + X(Hash, ownerId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, firstKey) X(uint32_t, count) \ + X(uint16_t, days) +struct CesCreateAssetRange { + CES_DECLARE_FIELDS(CES_CREATE_ASSET_RANGE_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CREATE_ASSET_RANGE_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CREATE_ASSET_RANGE) +}; + +#define CES_CREATE_ASSET_RANGE_RESULT_FIELDS(X) \ + X(HashPrefix, ownerId) \ + X(uint32_t, reqNonce) X(Hash, firstKey) X(uint8_t, rcode) +struct CesCreateAssetRangeResult { + CES_DECLARE_FIELDS(CES_CREATE_ASSET_RANGE_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CREATE_ASSET_RANGE_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CREATE_ASSET_RANGE_RESULT) +}; + +#define CES_UPDATE_ASSET_FIELDS(X) \ + X(Hash, ownerId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(HashPrefix, newOwnerId) \ + X(uint32_t, price) X(AssetData, content) +struct CesUpdateAsset { + CES_DECLARE_FIELDS(CES_UPDATE_ASSET_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_UPDATE_ASSET_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_UPDATE_ASSET) +}; + +#define CES_UPDATE_ASSET_RESULT_FIELDS(X) \ + X(HashPrefix, ownerId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(HashPrefix, newOwnerId) \ + X(uint32_t, price) X(uint8_t, rcode) +struct CesUpdateAssetResult { + CES_DECLARE_FIELDS(CES_UPDATE_ASSET_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_UPDATE_ASSET_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_UPDATE_ASSET_RESULT) +}; + +#define CES_UPDATE_ASSET_META_FIELDS(X) \ + X(Hash, ownerId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(HashPrefix, newOwnerId) \ + X(uint32_t, price) +struct CesUpdateAssetMeta { + CES_DECLARE_FIELDS(CES_UPDATE_ASSET_META_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_UPDATE_ASSET_META_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_UPDATE_ASSET_META) +}; + +#define CES_UPDATE_ASSET_META_RESULT_FIELDS(X) \ + X(HashPrefix, ownerId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(HashPrefix, newOwnerId) \ + X(uint32_t, price) X(uint8_t, rcode) +struct CesUpdateAssetMetaResult { + CES_DECLARE_FIELDS(CES_UPDATE_ASSET_META_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_UPDATE_ASSET_META_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_UPDATE_ASSET_META_RESULT) +}; + +// --- CES_SET_ASSET_OWNER_PAYS (owner toggles the auto-fund bit) --- +#define CES_SET_ASSET_OWNER_PAYS_FIELDS(X) \ + X(Hash, ownerId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(uint8_t, ownerPays) +struct CesSetAssetOwnerPays { + CES_DECLARE_FIELDS(CES_SET_ASSET_OWNER_PAYS_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_SET_ASSET_OWNER_PAYS_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_SET_ASSET_OWNER_PAYS) +}; + +#define CES_SET_ASSET_OWNER_PAYS_RESULT_FIELDS(X) \ + X(HashPrefix, ownerId) X(uint32_t, reqNonce) X(Hash, assetId) \ + X(uint8_t, rcode) +struct CesSetAssetOwnerPaysResult { + CES_DECLARE_FIELDS(CES_SET_ASSET_OWNER_PAYS_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_SET_ASSET_OWNER_PAYS_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_SET_ASSET_OWNER_PAYS_RESULT) +}; + +#define CES_UPDATE_ASSET_FAST_FIELDS(X) \ + X(Hash, ownerId) X(HashPrefix, serverId) X(uint32_t, reqNonce) X(Hash, assetId) X(AssetData, content) +struct CesUpdateAssetFast { + CES_DECLARE_FIELDS(CES_UPDATE_ASSET_FAST_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_UPDATE_ASSET_FAST_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_UPDATE_ASSET_FAST) +}; + +#define CES_UPDATE_ASSET_FAST_RESULT_FIELDS(X) \ + X(HashPrefix, ownerId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(uint8_t, rcode) +struct CesUpdateAssetFastResult { + CES_DECLARE_FIELDS(CES_UPDATE_ASSET_FAST_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_UPDATE_ASSET_FAST_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_UPDATE_ASSET_FAST_RESULT) +}; + +// --- ASSET FUNDING, BUYING, GIVING --- +#define CES_FUND_ASSET_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) X(uint32_t, reqNonce) X(Hash, assetId) X(uint16_t, amount) +struct CesFundAsset { + CES_DECLARE_FIELDS(CES_FUND_ASSET_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_FUND_ASSET_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_FUND_ASSET) +}; + +#define CES_FUND_ASSET_RESULT_FIELDS(X) \ + X(HashPrefix, originId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(uint16_t, amount) X(uint8_t, rcode) +struct CesFundAssetResult { + CES_DECLARE_FIELDS(CES_FUND_ASSET_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_FUND_ASSET_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_FUND_ASSET_RESULT) +}; + +#define CES_BUY_ASSET_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(uint64_t, priceLimit) +struct CesBuyAsset { + CES_DECLARE_FIELDS(CES_BUY_ASSET_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_BUY_ASSET_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_BUY_ASSET) +}; + +#define CES_BUY_ASSET_RESULT_FIELDS(X) \ + X(HashPrefix, originId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(uint64_t, priceLimit) \ + X(uint8_t, rcode) +struct CesBuyAssetResult { + CES_DECLARE_FIELDS(CES_BUY_ASSET_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_BUY_ASSET_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_BUY_ASSET_RESULT) +}; + +#define CES_GIVE_ASSET_FIELDS(X) \ + X(Hash, ownerId) X(HashPrefix, serverId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(HashPrefix, newOwnerId) +struct CesGiveAsset { + CES_DECLARE_FIELDS(CES_GIVE_ASSET_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_GIVE_ASSET_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_GIVE_ASSET) +}; + +#define CES_GIVE_ASSET_RESULT_FIELDS(X) \ + X(HashPrefix, ownerId) \ + X(uint32_t, reqNonce) X(Hash, assetId) X(HashPrefix, newOwnerId) \ + X(uint8_t, rcode) +struct CesGiveAssetResult { + CES_DECLARE_FIELDS(CES_GIVE_ASSET_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_GIVE_ASSET_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_GIVE_ASSET_RESULT) +}; + +// --- CES_QUERY_ASSET (Custom Validation) --- +#define CES_QUERY_ASSET_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) X(uint32_t, reqNonce) X(Hash, assetId) X(uint8_t, items) + +struct CesQueryAsset { + CES_DECLARE_FIELDS(CES_QUERY_ASSET_FIELDS) + Signature sig{}; + static constexpr size_t MAX_ITEMS = 4; + static constexpr size_t SIZE = + 1 + CES_CALC_SIZE(CES_QUERY_ASSET_FIELDS) + sizeof(Signature); + + size_t getPayloadSize() const { + return CES_CALC_SIZE(CES_QUERY_ASSET_FIELDS); + } + void writePayload(minx::Buffer& buf) { + if (items >= MAX_ITEMS) + throw std::runtime_error("too many items"); + CES_WRITE_FIELDS(buf, CES_QUERY_ASSET_FIELDS) + } + void readPayload(minx::ConstBuffer& buf){CES_READ_FIELDS( + buf, CES_QUERY_ASSET_FIELDS)} CES_INJECT_SIGNED_METHODS(CES_QUERY_ASSET) +}; + +// --- ASSET ENTRY & RESULT (Dynamic Size) --- +struct AssetEntry { + HashPrefix ownerId; + AssetData content; + uint16_t balance; + uint32_t price; + static constexpr size_t SIZE = + sizeof(ownerId) + sizeof(content) + sizeof(balance) + sizeof(price); +}; + +struct CesQueryAssetResult { + HashPrefix originId; + uint32_t reqNonce; + uint8_t items; + uint8_t rcode; + std::vector assets; + Signature sig{}; + + static constexpr size_t HEADER_SIZE = 1 + sizeof(originId) + + sizeof(reqNonce) + sizeof(items) + + sizeof(rcode) + sizeof(sig); + + size_t getPayloadSize() const { + size_t sz = + sizeof(originId) + sizeof(reqNonce) + sizeof(items) + sizeof(rcode); + if (rcode == CES_OK) + sz += assets.size() * AssetEntry::SIZE; + return sz; + } + void writePayload(minx::Buffer& buf) { + if (rcode == CES_OK) { + if (assets.empty()) + throw std::runtime_error("CES_OK response has no assets"); + items = assets.size() - 1; + } + buf.put(originId); + buf.put(reqNonce); + buf.put(items); + buf.put(rcode); + if (rcode == CES_OK) { + for (const auto& a : assets) { + buf.put(a.ownerId); + buf.put(a.content); + buf.put(a.balance); + buf.put(a.price); + } + } + } + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + reqNonce = buf.get(); + items = buf.get(); + rcode = buf.get(); + assets.clear(); + if (rcode == CES_OK) { + size_t count = static_cast(items) + 1; + assets.reserve(count); + for (size_t i = 0; i < count; ++i) { + AssetEntry a; + a.ownerId = buf.get(); + a.content = buf.get(); + a.balance = buf.get(); + a.price = buf.get(); + assets.push_back(a); + } + } + } + CES_INJECT_SIGNED_METHODS(CES_QUERY_ASSET_RESULT) +}; + +// --- CES_SET_ALIAS (patch bytes into an alias's value image) --- +// The write is a patch: offset + bytes address the ALIAS_VALUE_BYTES image +// (owner | editor | op | content). aliasId 0 targets the signer's own alias +// (created zeroed on first use; the id is stable across patches, delete to +// drop it); nonzero targets any alias the signer owns or edits. The server +// enforces the patch floors (ALIAS_PATCH_MIN_*) and bounds; an illegal write +// rejects whole. +struct CesSetAlias { + Hash originId; + HashPrefix serverId{}; + uint32_t reqNonce; + uint32_t aliasId; + uint16_t offset; + ces::Bytes bytes; // patch payload; its size is the write length + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(serverId) + sizeof(reqNonce) + + sizeof(aliasId) + sizeof(offset) + sizeof(uint16_t) + bytes.size(); + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(serverId); + buf.put(reqNonce); + buf.put(aliasId); + buf.put(offset); + buf.put(static_cast(bytes.size())); + buf.putBytes(bytes); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + serverId = buf.get(); + reqNonce = buf.get(); + aliasId = buf.get(); + offset = buf.get(); + uint16_t len = buf.get(); + if (len > ALIAS_VALUE_BYTES) len = ALIAS_VALUE_BYTES; + bytes = buf.getBytes(len); + } + + Signature sig{}; + CES_INJECT_SIGNED_METHODS(CES_SET_ALIAS) +}; + +#define CES_SET_ALIAS_RESULT_FIELDS(X) \ + X(HashPrefix, originId) X(uint32_t, reqNonce) X(uint32_t, aliasId) \ + X(uint8_t, rcode) +struct CesSetAliasResult { + CES_DECLARE_FIELDS(CES_SET_ALIAS_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_SET_ALIAS_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_SET_ALIAS_RESULT) +}; + +// --- CES_DELETE_ALIAS (erase this account's alias) --- +#define CES_DELETE_ALIAS_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) X(uint32_t, reqNonce) +struct CesDeleteAlias { + CES_DECLARE_FIELDS(CES_DELETE_ALIAS_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_DELETE_ALIAS_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_DELETE_ALIAS) +}; + +#define CES_DELETE_ALIAS_RESULT_FIELDS(X) \ + X(HashPrefix, originId) X(uint32_t, reqNonce) X(uint8_t, rcode) +struct CesDeleteAliasResult { + CES_DECLARE_FIELDS(CES_DELETE_ALIAS_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_DELETE_ALIAS_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_DELETE_ALIAS_RESULT) +}; + +// --- CES_QUERY_ALIAS (unsigned: windowed read of an alias's value image) --- +// offset + length address the ALIAS_VALUE_BYTES image; an out-of-bounds +// window returns found = 0. The whole image is public. +#define CES_QUERY_ALIAS_FIELDS(X) \ + X(uint32_t, aliasId) X(uint16_t, offset) X(uint16_t, length) +struct CesQueryAlias { + CES_DECLARE_FIELDS(CES_QUERY_ALIAS_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_QUERY_ALIAS_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_ALIAS) +}; + +struct CesQueryAliasResult { + uint32_t aliasId = 0; + uint16_t offset = 0; + ces::Bytes bytes; // the requested window; empty unless found + uint8_t found = 0; + + size_t getPayloadSize() const { + return sizeof(aliasId) + sizeof(offset) + sizeof(uint16_t) + bytes.size() + + sizeof(found); + } + + void writePayload(minx::Buffer& buf) const { + buf.put(aliasId); + buf.put(offset); + buf.put(static_cast(bytes.size())); + buf.putBytes(bytes); + buf.put(found); + } + + void readPayload(minx::ConstBuffer& buf) { + aliasId = buf.get(); + offset = buf.get(); + uint16_t len = buf.get(); + if (len > ALIAS_VALUE_BYTES) len = ALIAS_VALUE_BYTES; + bytes = buf.getBytes(len); + found = buf.get(); + } + + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_ALIAS_RESULT) +}; + +// --- CES_RUN_ALIAS (execute an alias's inline program) --- +// The alias-id twin of CES_RUN_ASSET: the target must be +// ALIAS_OP_INLINE_PROGRAM; the run gets self = 0 and programOwner = the +// cell's owner. Caller pays gas; allowance caps caller-side spend. +struct CesRunAlias { + Hash originId; + HashPrefix serverId{}; + uint32_t reqNonce; + uint32_t aliasId; + uint64_t budget; + uint64_t allowance = std::numeric_limits::max(); + uint64_t time = 0; // UTC epoch microseconds (required for CES_NONCELESS dedup) + ces::Bytes input; // up to 1024 bytes + Signature sig{}; + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(serverId) + sizeof(reqNonce) + + sizeof(aliasId) + sizeof(budget) + sizeof(allowance) + + sizeof(time) + 2 + input.size(); + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(serverId); + buf.put(reqNonce); + buf.put(aliasId); + buf.put(budget); + buf.put(allowance); + buf.put(time); + buf.put(static_cast(input.size())); + buf.putBytes(input); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + serverId = buf.get(); + reqNonce = buf.get(); + aliasId = buf.get(); + budget = buf.get(); + allowance = buf.get(); + time = buf.get(); + uint16_t len = buf.get(); + if (len > 1024) len = 1024; + input = buf.getBytes(len); + } + CES_INJECT_SIGNED_METHODS(CES_RUN_ALIAS) +}; + +struct CesRunAliasResult { + HashPrefix originId; + uint32_t reqNonce; + uint8_t rcode; + uint64_t vmError; + uint64_t budgetUsed; + uint64_t allowanceUsed; + ces::Bytes output; // up to 1024 bytes + Signature sig{}; + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(reqNonce) + sizeof(rcode) + + sizeof(vmError) + sizeof(budgetUsed) + sizeof(allowanceUsed) + + 2 + output.size(); + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(reqNonce); + buf.put(rcode); + buf.put(vmError); + buf.put(budgetUsed); + buf.put(allowanceUsed); + buf.put(static_cast(output.size())); + buf.putBytes(output); + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + reqNonce = buf.get(); + rcode = buf.get(); + vmError = buf.get(); + budgetUsed = buf.get(); + allowanceUsed = buf.get(); + uint16_t len = buf.get(); + if (len > 1024) len = 1024; + output = buf.getBytes(len); + } + CES_INJECT_SIGNED_METHODS(CES_RUN_ALIAS_RESULT) +}; + +// --- CES_REGISTER_KEYNAME (bind the signer's key to a name) --- +// The signer IS the entry owner: originId is the 32-byte public key, and the +// signature proves control of it, so no one can bind a name to a key they do +// not hold. `name` is 32 bytes (UTF-8, zero-padded); the server enforces name +// uniqueness (on the spaces->underscore normalized form) and validity. +struct CesRegisterKeyName { + Hash originId; + HashPrefix serverId{}; + uint32_t reqNonce = 0; + ces::Bytes name; // the name bytes; <= 32 + + size_t getPayloadSize() const { + return sizeof(originId) + sizeof(serverId) + sizeof(reqNonce) + + sizeof(uint16_t) + name.size(); + } + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(serverId); + buf.put(reqNonce); + buf.put(static_cast(name.size())); + buf.putBytes(name); + } + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + serverId = buf.get(); + reqNonce = buf.get(); + uint16_t len = buf.get(); + if (len > 32) len = 32; + name = buf.getBytes(len); + } + + Signature sig{}; + CES_INJECT_SIGNED_METHODS(CES_REGISTER_KEYNAME) +}; + +#define CES_REGISTER_KEYNAME_RESULT_FIELDS(X) \ + X(HashPrefix, originId) X(uint32_t, reqNonce) X(uint8_t, rcode) +struct CesRegisterKeyNameResult { + CES_DECLARE_FIELDS(CES_REGISTER_KEYNAME_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_REGISTER_KEYNAME_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_REGISTER_KEYNAME_RESULT) +}; + +// --- CES_CLEAR_KEYNAME (erase the signer's own key_name) --- +#define CES_CLEAR_KEYNAME_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) X(uint32_t, reqNonce) +struct CesClearKeyName { + CES_DECLARE_FIELDS(CES_CLEAR_KEYNAME_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CLEAR_KEYNAME_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CLEAR_KEYNAME) +}; + +#define CES_CLEAR_KEYNAME_RESULT_FIELDS(X) \ + X(HashPrefix, originId) X(uint32_t, reqNonce) X(uint8_t, rcode) +struct CesClearKeyNameResult { + CES_DECLARE_FIELDS(CES_CLEAR_KEYNAME_RESULT_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_CLEAR_KEYNAME_RESULT_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_CLEAR_KEYNAME_RESULT) +}; + +// --- CES_QUERY_KEYNAME (unsigned: pubkey -> name) --- +#define CES_QUERY_KEYNAME_FIELDS(X) X(Hash, key) +struct CesQueryKeyName { + CES_DECLARE_FIELDS(CES_QUERY_KEYNAME_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_QUERY_KEYNAME_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_KEYNAME) +}; + +struct CesQueryKeyNameResult { + Hash key{}; + uint8_t found = 0; + ces::Bytes name; // the name bytes if found + + size_t getPayloadSize() const { + return sizeof(key) + sizeof(found) + sizeof(uint16_t) + name.size(); + } + void writePayload(minx::Buffer& buf) const { + buf.put(key); + buf.put(found); + buf.put(static_cast(name.size())); + buf.putBytes(name); + } + void readPayload(minx::ConstBuffer& buf) { + key = buf.get(); + found = buf.get(); + uint16_t len = buf.get(); + if (len > 32) len = 32; + name = buf.getBytes(len); + } + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_KEYNAME_RESULT) +}; + +// --- CES_QUERY_KEYNAME_BY_NAME (unsigned: name -> pubkey) --- +struct CesQueryKeyNameByName { + ces::Bytes name; // the name to look up (<= 32) + + size_t getPayloadSize() const { return sizeof(uint16_t) + name.size(); } + void writePayload(minx::Buffer& buf) const { + buf.put(static_cast(name.size())); + buf.putBytes(name); + } + void readPayload(minx::ConstBuffer& buf) { + uint16_t len = buf.get(); + if (len > 32) len = 32; + name = buf.getBytes(len); + } + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_KEYNAME_BY_NAME) +}; + +struct CesQueryKeyNameByNameResult { + ces::Bytes name; // echo of the queried name: lets the client drop a stale/ + // reordered reply from a prior lookup (<= 32) + uint8_t found = 0; + Hash key{}; + + size_t getPayloadSize() const { + return sizeof(uint16_t) + name.size() + sizeof(found) + sizeof(key); + } + void writePayload(minx::Buffer& buf) const { + buf.put(static_cast(name.size())); + buf.putBytes(name); + buf.put(found); + buf.put(key); + } + void readPayload(minx::ConstBuffer& buf) { + uint16_t len = buf.get(); + if (len > 32) len = 32; + name = buf.getBytes(len); + found = buf.get(); + key = buf.get(); + } + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_KEYNAME_BY_NAME_RESULT) +}; + +// --- UNSIGNED ASSET QUERIES & SERVER INFO --- +#define CES_UNSIGNED_QUERY_ASSET_FIELDS(X) X(Hash, assetId) +struct CesUnsignedQueryAsset { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_ASSET_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_ASSET_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_UNSIGNED_QUERY_ASSET) +}; + +#define CES_UNSIGNED_QUERY_ASSET_RESULT_FIELDS(X) \ + X(Hash, assetId) \ + X(HashPrefix, ownerId) X(AssetData, content) X(uint16_t, balance) \ + X(uint32_t, price) +struct CesUnsignedQueryAssetResult { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_ASSET_RESULT_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_ASSET_RESULT_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_UNSIGNED_QUERY_ASSET_RESULT) +}; + +// --- CES_QUERY_PEER_INFO (Unsigned/Public): peer-table slot lookup for discovery --- +#define CES_UNSIGNED_QUERY_PEER_INFO_FIELDS(X) X(uint16_t, index) +struct CesUnsignedQueryPeerInfo { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_PEER_INFO_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_PEER_INFO_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_PEER_INFO) +}; + +#define CES_UNSIGNED_QUERY_PEER_INFO_RESULT_FIELDS(X) \ + X(uint16_t, index) X(uint16_t, peerCount) X(uint8_t, found) \ + X(Hash, pubkey) X(PeerAddr, address) +struct CesUnsignedQueryPeerInfoResult { + CES_DECLARE_FIELDS(CES_UNSIGNED_QUERY_PEER_INFO_RESULT_FIELDS) + CES_INJECT_FIXED_UNSIGNED_PAYLOAD(CES_UNSIGNED_QUERY_PEER_INFO_RESULT_FIELDS) + CES_INJECT_UNSIGNED_METHODS(CES_QUERY_PEER_INFO_RESULT) +}; + +// --- CES_QUERY_SERVER_INFO (Signed, self-describing KV response) --- +#define CES_QUERY_SERVER_INFO_FIELDS(X) \ + X(Hash, originId) X(HashPrefix, serverId) X(uint32_t, reqNonce) + +struct CesQueryServerInfo { + CES_DECLARE_FIELDS(CES_QUERY_SERVER_INFO_FIELDS) + Signature sig{}; + CES_INJECT_FIXED_SIGNED_PAYLOAD(CES_QUERY_SERVER_INFO_FIELDS) + CES_INJECT_SIGNED_METHODS(CES_QUERY_SERVER_INFO) +}; + +struct ServerInfoEntry { + std::string key; + std::string value; +}; + +struct CesQueryServerInfoResult { + HashPrefix originId; + uint32_t reqNonce; + uint8_t rcode; + std::vector entries; + Signature sig{}; + + size_t getPayloadSize() const { + size_t sz = sizeof(originId) + sizeof(reqNonce) + sizeof(rcode); + if (rcode == CES_OK) { + // Convert to map for logkv serialization + std::map m; + for (auto& e : entries) m[e.key] = e.value; + sz += logkv::serializer>::get_size(m); + } + return sz; + } + + void writePayload(minx::Buffer& buf) { + buf.put(originId); + buf.put(reqNonce); + buf.put(rcode); + if (rcode == CES_OK) { + std::map m; + for (auto& e : entries) m[e.key] = e.value; + size_t sz = logkv::serializer>::get_size(m); + std::vector tmp(sz); + logkv::serializer>::write( + tmp.data(), sz, m); + buf.putBytes(std::span( + reinterpret_cast(tmp.data()), tmp.size())); + } + } + + void readPayload(minx::ConstBuffer& buf) { + originId = buf.get(); + reqNonce = buf.get(); + rcode = buf.get(); + entries.clear(); + if (rcode == CES_OK) { + size_t avail = buf.getRemainingBytesCount(); + if (avail < sizeof(Signature)) + throw std::runtime_error("truncated server-info result"); + auto mapData = buf.getBytes>(avail - sizeof(Signature)); + std::map m; + logkv::serializer>::read( + mapData.data(), mapData.size(), m); + for (auto& [k, v] : m) + entries.push_back({k, v}); + } + } + CES_INJECT_SIGNED_METHODS(CES_QUERY_SERVER_INFO_RESULT) +}; + +} // namespace ces \ No newline at end of file diff --git a/vendor/ces/include/ces/ramfilestore.h b/vendor/ces/include/ces/ramfilestore.h new file mode 100644 index 0000000..e61c136 --- /dev/null +++ b/vendor/ces/include/ces/ramfilestore.h @@ -0,0 +1,234 @@ +#pragma once + +/** + * Ramfile — chunked file storage over CES assets. + * + * File format: + * Head asset (210 bytes): + * Byte 0: magic 0x46 ('F') + * Bytes 1-8: file size (uint64 BE) + * Bytes 9-40: SHA256 of complete file content + * Bytes 41-48: created time (uint64 BE, microseconds since epoch UTC) + * Bytes 49-56: modified time (uint64 BE, microseconds since epoch UTC) + * Bytes 57-177: metadata (121 bytes, app-defined, zero-padded) + * Bytes 178-209: first chunk key (32 bytes, zeros if empty file) + * + * Data chunk (210 bytes): + * Bytes 0-177: payload (178 bytes) + * Bytes 178-209: next chunk key (32 bytes, zeros = last chunk) + * + * Upload order: last chunk first (backward), so each chunk is created + * with its correct next pointer. No patching needed. + * + * Head asset key is user-chosen (the "filename"). + * Chunk keys are random (CES_CREATE_ASSET with random ID). + */ + +#include +#include + +#include +#include +#include +#include +#include + +namespace ces { + +// File format constants +static constexpr uint8_t RAMFILE_MAGIC = 0x46; // 'F' +static constexpr size_t RAMFILE_HEAD_CTIME_OFFSET = 41; +static constexpr size_t RAMFILE_HEAD_MTIME_OFFSET = 49; +static constexpr size_t RAMFILE_HEAD_META_OFFSET = 57; +static constexpr size_t RAMFILE_HEAD_META_SIZE = 121; +static constexpr size_t RAMFILE_NEXT_OFFSET = 178; +static constexpr size_t RAMFILE_NEXT_SIZE = 32; +static constexpr size_t RAMFILE_CHUNK_DATA_SIZE = 178; + +// Layout must exactly fill one asset cell — the head metadata section +// followed by the next-chunk pointer has to sum to the asset content size. +// `sizeof(AssetData)` works here because AssetData is a std::array of uint8_t. +static_assert(RAMFILE_HEAD_META_OFFSET + RAMFILE_HEAD_META_SIZE == RAMFILE_NEXT_OFFSET, + "Ramfile head layout: META region must end at RAMFILE_NEXT_OFFSET"); +static_assert(RAMFILE_NEXT_OFFSET + RAMFILE_NEXT_SIZE == sizeof(AssetData), + "Ramfile head layout: must exactly fill AssetData"); +static_assert(RAMFILE_CHUNK_DATA_SIZE + RAMFILE_NEXT_SIZE == sizeof(AssetData), + "Ramfile chunk layout: must exactly fill AssetData"); + +// Parsed file header +struct RamfileHeader { + uint64_t fileSize = 0; + minx::Hash contentHash{}; + uint64_t createdTime = 0; // microseconds since epoch UTC + uint64_t modifiedTime = 0; // microseconds since epoch UTC + std::array metadata{}; + minx::Hash firstChunk{}; + bool valid = false; +}; + +// Parse a head asset's content into a RamfileHeader +RamfileHeader parseRamfileHeader(const AssetData& headContent); + +// Build a head asset content from components +AssetData buildRamfileHeader(uint64_t fileSize, const minx::Hash& contentHash, + uint64_t createdTime, uint64_t modifiedTime, + const uint8_t* metadata, size_t metaLen, + const minx::Hash& firstChunk); + +// Build a data chunk content +AssetData buildRamfileChunk(const uint8_t* data, size_t dataLen, + const minx::Hash& nextKey); + +// Compute SHA256 of raw data +minx::Hash sha256(const uint8_t* data, size_t len); + +/** + * Upload a file as a chain of assets. + * @param client Connected CesClient + * @param headKey The key for the head asset (the "filename") + * @param data File content bytes + * @param days Days to fund each asset + * @param metadata Optional metadata (up to 121 bytes, zero-padded) + * @param metaLen Length of metadata + * @param progress Optional callback(chunksCreated, totalChunks) + * @return CES_OK on success, error code on failure + */ +uint8_t ramfilePut(CesClient& client, const minx::Hash& headKey, + const uint8_t* data, size_t dataLen, + uint16_t days, + const uint8_t* metadata = nullptr, size_t metaLen = 0, + std::function progress = nullptr); + +/** + * Download a file from a chain of assets. + * @param client Connected CesClient + * @param headKey The key of the head asset + * @param outData Output: file content bytes + * @param outHeader Output: parsed header (optional) + * @param hashMismatch Output: true if SHA256 doesn't match (optional) + * @return CES_OK on success, error code on failure + */ +uint8_t ramfileGet(CesClient& client, const minx::Hash& headKey, + ces::Bytes& outData, + RamfileHeader* outHeader = nullptr, + bool* hashMismatch = nullptr); + +/** + * Fund all assets in a file chain. + * @param client Connected CesClient + * @param headKey The key of the head asset + * @param days Days to add to each asset + * @return CES_OK on success, error code on first failure + */ +uint8_t ramfileFund(CesClient& client, const minx::Hash& headKey, uint16_t days); + +/** + * Scan a file chain and return all asset keys (head + chunks) in order. + * @param client Connected CesClient + * @param headKey The key of the head asset + * @param outKeys Output: head key followed by chunk keys in order + * @return CES_OK on success, error code on failure + */ +uint8_t ramfileScan(CesClient& client, const minx::Hash& headKey, + std::vector& outKeys); + +/** + * Fund all assets listed in a scan file (no chain walking needed). + * @param client Connected CesClient + * @param keys Vector of asset keys (head + chunks) + * @param days Days to add to each asset + * @return CES_OK on success, error code on first failure + */ +uint8_t ramfileFundFromScan(CesClient& client, + const std::vector& keys, uint16_t days); + +/** + * Build scan filename: @.scan + * If headKey is valid null-terminated UTF-8 (printable, <=31 chars), + * use that as name. Otherwise use 64-char hex. + */ +std::string buildRamfileScanFilename(const minx::Hash& headKey, + const std::string& serverAddr); + +/** + * Write scan file (one hex key per line). + */ +void writeRamfileScan(const std::string& path, + const std::vector& keys); + +/** + * Read scan file (one hex key per line). Returns empty on failure. + */ +std::vector readRamfileScan(const std::string& path); + +/** + * Read bytes from a file at an offset (requires scan file keys). + * @param client Connected CesClient + * @param keys Scan file keys (head + chunks) + * @param offset Byte offset into file + * @param length Number of bytes to read + * @param outData Output bytes + * @return CES_OK on success + */ +uint8_t ramfileRead(CesClient& client, const std::vector& keys, + uint64_t offset, uint64_t length, + ces::Bytes& outData); + +/** + * Write bytes to a file at an offset (requires scan file keys). + * + * Zeroes the head SHA256 (marks file as dirty) and bumps mtime. The + * write is bounded by the file's pre-allocated chain CAPACITY + * (keys.size() - 1) * RAMFILE_CHUNK_DATA_SIZE, NOT by the file's + * declared size. If the write extends past `header.fileSize`, the + * declared size is extended to cover the written range. If the write + * exceeds the chain's capacity, the function writes what fits and + * returns an error code; the caller can re-read the head to see how + * much landed. + * + * Writes stop on the first chunk-level failure (query or update). On + * failure, the head is still touched: the declared size reflects how + * many bytes were successfully written (if the write extended past + * the old size). Writes that were entirely within the pre-existing + * `header.fileSize` region do not update the declared size, so on a + * partial failure the caller cannot tell from `fileSize` alone how + * much data was written — treat the region as indeterminate. + * + * To grow a file's chain (allocate more chunks), use `ramfileResize` — + * which grows the declared size, allocating chunks as needed, and + * leaves the chain intact on a subsequent shrink so the capacity + * remains available to a future grow or write. + * + * @param client Connected CesClient + * @param keys Scan file keys (head + chunks) + * @param offset Byte offset into file. Must be ≤ capacity. + * @param data Bytes to write + * @param dataLen Length of data + * @return CES_OK if every byte was written, error code otherwise + */ +uint8_t ramfileWrite(CesClient& client, const std::vector& keys, + uint64_t offset, const uint8_t* data, size_t dataLen); + +/** + * Rehash a file: download all data, recompute SHA256, update head. + * @param client Connected CesClient + * @param headKey The key of the head asset + * @return CES_OK on success + */ +uint8_t ramfileRehash(CesClient& client, const minx::Hash& headKey); + +/** + * Resize a file. Truncate if shorter, zero-extend if longer, nop if same. + * Requires scan file keys. Updates keys vector (may grow on extend; never + * shrinks — truncated chunks stay linked as reusable capacity). + * Zeroes head hash (dirty). + * @param client Connected CesClient + * @param keys Scan file keys — may be modified + * @param newSize Target file size in bytes + * @param days Days for any new chunks (only used if extending) + * @return CES_OK on success + */ +uint8_t ramfileResize(CesClient& client, std::vector& keys, + uint64_t newSize, uint16_t days); + +} // namespace ces diff --git a/vendor/ces/include/ces/server.h b/vendor/ces/include/ces/server.h new file mode 100644 index 0000000..46bdaee --- /dev/null +++ b/vendor/ces/include/ces/server.h @@ -0,0 +1,2333 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // CesPlexHost (CesServer implements it) +#include +#include +#include + +using namespace minx; + +namespace ces { + +// Forward declaration — defined in ces/cesplex/mux.h. CesServer holds +// a unique_ptr to one; the full type is only needed in server.cpp. +class CesPlex; + +// Forward declaration — defined in ces/cesplex/session.h. A PendingL2Call +// bound to a client CALL verb holds one as its reply sink; the full type is +// only needed in server.cpp. +struct CesPlexRequest; + +// L1 RAM is one uniform good: every ledger row's daily rent is its byte +// footprint times this one price. Fixed, not load-discounted -- rent tracks +// occupancy, which the throughput gauge can't see. Disk, compute RSS, and +// net-mem all derive from feeAsset, so they scale with it. +constexpr uint64_t MEMORY_PRICE = 10'000; // raw credits per byte-day of RAM + +// Row footprint (value + key + overhead), in bytes. Account 64 : Asset 256 +// holds the 4x ratio. Alias is the resizable memory area, pinned at the most +// a fixed CES_* UDP op can carry (a whole-value CES_SET_ALIAS patch rides +// inside MINX's 1280-byte payload ceiling, like the gossip msg cap). Growing +// past it needs chunked RUDP. +constexpr uint64_t ACCOUNT_BYTES = 64; +constexpr uint64_t ASSET_BYTES = 256; +constexpr uint64_t ALIAS_BYTES = ALIAS_ENTRY_BYTES; + +constexpr uint64_t BASE_FEE_ACCOUNT = ACCOUNT_BYTES * MEMORY_PRICE; // 640,000 +constexpr uint64_t BASE_FEE_ASSET = ASSET_BYTES * MEMORY_PRICE; // 2,560,000 +// Alias and key_name rent are DERIVED from feeAccount at their byte ratios +// (ALIAS_BYTES/ACCOUNT_BYTES = 16x, KEYNAME_BYTES/ACCOUNT_BYTES = 2x); neither +// is an independent price, so neither has a BASE_FEE_* constant or config knob. +// 32-byte key + 32-byte name = 64 in the forward store, DOUBLED by the derived +// reverse index (normalized-name -> key), so the real footprint is 128 bytes. +// The key_name rent is DERIVED from the account fee at this byte ratio +// (feeAccount x KEYNAME_BYTES / ACCOUNT_BYTES = feeAccount x 2); it has no +// config knob of its own -- it is not an independent price. +constexpr uint64_t KEYNAME_BYTES = 128; +constexpr uint64_t BASE_FEE_TRANSACTION = 32'000; +// Query fee covers dedup + state write. The network share is billed +// separately (feeNetKiB*) and the bind contract removed the per-op +// envelope verify, leaving this well below feeTx. +constexpr uint64_t BASE_FEE_QUERY = 2'000; +// Gas-to-credits policy multiplier for CES_RUN_ASSET. The CESVM_COST_* +// constants (cesvm.h) price VM work uniformly at 1 gas unit = 0.1 ns of +// logic-strand time, so a run at multiplier m burns ~10*m credits per +// strand-ns. The anchor is the strand's opportunity cost — what the +// server earns settling instead: feeTx x measured transfer capacity +// (cesbench: ~80k TPS, ~12 us/transfer) ≈ 2.6 credits per strand-ns. +// m = 5 prices VM compute at ~19x that: enough margin that a +// strand-hogging program is a well-paying customer rather than a denial +// of service, while leaving the load discount (FeeKind::VMMult, which +// scales gasMult toward 1 on an idle server) a real 5:1 dynamic range. +// Ledger-touching syscalls bill their protocol fees separately +// (billCredits); this multiplier prices pure compute only. +constexpr uint64_t BASE_FEE_VM_MULT = 5; + +// --------------------------------------------------------------------------- +// Default config values. Single source of truth: the CesConfig member +// initializers below, main.cpp's CLI option defaults, and the `ces --config` +// dumped template all read these constants, so the three cannot drift apart. +// 0/-1 sentinels ("off" / "derive at startup") are left as literals at their +// field; only real default values live here. +// --------------------------------------------------------------------------- +constexpr uint64_t DEFAULT_MIN_ACC = 131072; +constexpr uint64_t DEFAULT_MAX_ACC = 16777216; +constexpr uint64_t DEFAULT_MIN_ASSET = 131072; +constexpr uint64_t DEFAULT_MAX_ASSET = 16777216; +constexpr uint64_t DEFAULT_MIN_ALIAS = 131072; +constexpr uint64_t DEFAULT_MAX_ALIAS = 16777216; +constexpr uint64_t DEFAULT_MIN_KEYNAME = 131072; +constexpr uint64_t DEFAULT_MAX_KEYNAME = 16777216; +constexpr uint8_t DEFAULT_MIN_DIFF = 10; +constexpr uint64_t DEFAULT_POW_DELAY = 0; +constexpr uint64_t DEFAULT_SPEND_SLOT_SIZE = 3600; +constexpr uint64_t DEFAULT_FLUSH_VALUE = 0; // 0 = flush every change +constexpr uint64_t DEFAULT_MAX_LOG_SIZE_GB = 100; +constexpr uint64_t DEFAULT_PEER_TARGET = 500000000; // 5 full credits; 0 = no peering +constexpr uint64_t DEFAULT_PEER_POW_INBOUND_RECIPROCATION_BPS = 0; +constexpr int DEFAULT_PEER_MINER_INTERVAL_SECS = 60; +constexpr uint32_t DEFAULT_PEER_GRIEF_BAN_THRESHOLD = 20; // grief points to ban +constexpr uint64_t DEFAULT_PEER_BAN_SECS = 604800; // 1 week. Post-decision + // only (does not affect the + // detector); a threshold- + // crossing peer is excluded + // regardless of intent, and + // removal-on-expiry + a large + // peer population make a long + // ban free, so set it long. +constexpr uint64_t PEER_GRIEF_DECAY_SECS = 86400; // 1 day to shed 1 grief + // point (forgiveness; never + // applied to a banned peer). + // Relaxed to the L2 timescale: + // a coalition griefs a + // withholder on the order of + // once an hour, so the decay + // must be slower than that for + // sustained grief to ban. +constexpr size_t DEFAULT_MAX_PEERS = 100; // peer-table size: peers + // persisted/exposed. In-mem + // hard cap is 3x for ban- + // tombstone headroom. Small + // values bound the candidate + // view (useful for testing). +constexpr uint64_t DEFAULT_MAX_PEER_RESERVE_DISTURBANCE = 100'000; +constexpr uint32_t DEFAULT_GOSSIP_FANOUT_DEGREE = 6; +constexpr size_t DEFAULT_PRESENCE_CACHE_SIZE = 250'000; +constexpr const char* DEFAULT_WEB_BIND = "127.0.0.1"; +constexpr uint32_t DEFAULT_RPC_MAX_PENDING = 1000; +constexpr size_t DEFAULT_RPC_MAX_REQUEST_BYTES = 64 * 1024; +constexpr size_t DEFAULT_RPC_MAX_RESPONSE_BYTES = 64 * 1024; +constexpr uint32_t DEFAULT_RPC_RESPONSE_TIMEOUT_MS = 30000; +constexpr uint32_t DEFAULT_RPC_RUDP_BYTES_PER_SECOND = 0xFFFFFFFFu; +constexpr uint32_t DEFAULT_RPC_RUDP_BURST_BYTES = 0xFFFFFFFFu; +// builtin:peer keeps two channels per peer link (control + bulk); a reconnect +// briefly overlaps old and new channels, so allow headroom above 2. +constexpr size_t DEFAULT_RPC_RUDP_MAX_CHANNELS_PER_PEER = 8; +constexpr int64_t DEFAULT_RPC_RUDP_MAX_REORDER_BYTES = -1; // -1 = library default +constexpr int64_t DEFAULT_RPC_RUDP_MAX_REORDER_MSGS = -1; // -1 = library default +constexpr uint32_t DEFAULT_RPC_RUDP_CHANNEL_IDLE_SECS = 60; +constexpr uint64_t DEFAULT_EXT_FUNDING_PER_DAY = 500'000'000; // 5 credits +constexpr uint64_t DEFAULT_EXT_LOCAL_BUDGET = 100'000'000'000; // 1000 credits +constexpr uint64_t DEFAULT_COMPUTE_PROCESS_MEM_MAX = 268435456; // 256 MB +constexpr uint32_t DEFAULT_COMPUTE_CLIENT_POOL_SIZE = 4; +constexpr const char* DEFAULT_COMPUTE_USER = "cesluad"; + +struct CesConfig { + + boost::filesystem::path dataDir; + minx::Hash serverPrivKey; + KeyAlgo serverKeyAlgo = KeyAlgo::ED25519; + std::string version; + + uint64_t minAcc = DEFAULT_MIN_ACC; + uint64_t maxAcc = DEFAULT_MAX_ACC; + uint8_t minDiff = DEFAULT_MIN_DIFF; + uint64_t spendSlotSize = DEFAULT_SPEND_SLOT_SIZE; + uint64_t minProveWorkTimestamp = 0; + + uint64_t minAsset = DEFAULT_MIN_ASSET; + uint64_t maxAsset = DEFAULT_MAX_ASSET; + uint64_t minAlias = DEFAULT_MIN_ALIAS; + uint64_t maxAlias = DEFAULT_MAX_ALIAS; + uint64_t minKeyName = DEFAULT_MIN_KEYNAME; + uint64_t maxKeyName = DEFAULT_MAX_KEYNAME; + + // Runtime default is hardware_concurrency/2 - 2 (computed in main.cpp); this + // is the floor used by direct construction (tests override it). + int taskThreads = 1; + uint64_t flushValue = DEFAULT_FLUSH_VALUE; + uint64_t maxLogBytes = DEFAULT_MAX_LOG_SIZE_GB * 1024ULL * 1024 * 1024; + size_t accountStoreBufferSize = 1 << 19; + size_t assetStoreBufferSize = 1 << 19; + + uint64_t feeAccount = BASE_FEE_ACCOUNT; + uint64_t feeAsset = BASE_FEE_ASSET; + // (no feeAlias/feeKeyName knobs: alias + key_name rent derive from feeAccount + // at their byte ratios -- see the BASE_FEE_* note above) + uint64_t feeTx = BASE_FEE_TRANSACTION; + uint64_t feeQuery = BASE_FEE_QUERY; + uint64_t feeVmMult = BASE_FEE_VM_MULT; + + // Fee-discount system (see feemult.h). When true, the metrics pulse + // refreshes a per-FeeKind multiplier from the relevant gauge bp; idle + // server → fees subsidized → full free; saturated → full price. When + // false, every multiplier is pinned to 10000 (full price always). + // Tests set this false to keep static-fee assertions. + bool feeDiscountEnabled = true; + + size_t recvBuffersSize = MinxConfig::DEFAULT_RECV_BUFFERS_SIZE; + + uint64_t getFeeError() const { return feeQuery; } + + // Server identity (optional, e.g. "myserver.example.com:53830") + // Used in PoW appData to declare this server's address to peers. + // If empty, falls back to ":port". + std::string serverName; + + // Peering + struct PeerConfig { + std::string pubKeyHex; // 64-char hex + std::string address; // host:port + }; + uint64_t peerTarget = DEFAULT_PEER_TARGET; // credit target on each peer (0 = no peering) + // Inbound PoW reciprocation, basis points: outbound PoW we mine per inbound + // PoW received. 0 = off. 10000 = 1:1. Outbound peers ignore it (use peerTarget). + uint64_t peerPowInboundReciprocationBps = DEFAULT_PEER_POW_INBOUND_RECIPROCATION_BPS; + int peerMinerIntervalSecs = DEFAULT_PEER_MINER_INTERVAL_SECS; + // Grief/ban: an extension raises a peer's grief via ces.grief_peer; at the + // threshold the C++ side bans it for peerBanSecs (hidden from ces.peers(), + // refused at bind, not dialed, not re-added). 0 threshold disables the ban. + uint32_t peerGriefBanThreshold = DEFAULT_PEER_GRIEF_BAN_THRESHOLD; + uint64_t peerBanSecs = DEFAULT_PEER_BAN_SECS; + // Peer-table size: peers persisted to disk and exposed to ces.peers(). The in-memory + // hard cap is 3x this (ban-tombstone headroom). Small values bound each node's + // candidate view, which is what makes larger-scale formation testable. + size_t maxPeers = DEFAULT_MAX_PEERS; + std::vector peers; + int settlementMaxRetries = CesClientAsync::DEFAULT_MAX_RETRIES; + + // Max reserve (raw) one operation may spend at one peer. It caps each gossip + // fan-out leg (each peer at min(our reserve there, this)); the unallocated + // remainder reverts to the originator. Bounds a single op's upstream-reserve + // reach. 0 = uncapped. + uint64_t maxPeerReserveDisturbance = DEFAULT_MAX_PEER_RESERVE_DISTURBANCE; + + // Peers each gossip hop forwards to: a random subset of this size drawn from + // the reachable peers we hold reserve with. 0 = forward to every peer. + uint32_t gossipFanoutDegree = DEFAULT_GOSSIP_FANOUT_DEGREE; + + // Presence cache: max tracked client addresses for push (send()). + size_t presenceCacheSize = DEFAULT_PRESENCE_CACHE_SIZE; + + // Autoexec key pattern: assets whose key matches this pattern + // are parsed as signed CesRunAsset packets and executed on server boot. + // Key layout: [8 zero bytes][8 AUTOEXEC_MAGIC bytes][8 account prefix][8 random] + static constexpr uint64_t AUTOEXEC_KEY_MAGIC = 0xCE5C40E70000ULL; + + // Scheduled runAsset (cron) settings + size_t maxScheduledEntries = 1000000; + static constexpr uint64_t CRON_TICK_INTERVAL_MS = 100; // 10 Hz + static constexpr uint64_t CRON_TICK_DEADLINE_MS = 100; // max realtime per tick + + // Cesco admin console (Unix domain socket). Empty = disabled. + std::string adminSocket; + + // Web dashboard — an HTTP admin UI embedded in the server, bound to + // loopback only. There is NO authentication: reach it by SSH-tunneling + // to the host. 0 = disabled (default). The bind address is loopback by + // design (127.0.0.1); an operator who fronts it with their own auth + // proxy can repoint it, and gets a warning if it isn't a loopback IP. + uint16_t webPort = 0; + std::string webBind = DEFAULT_WEB_BIND; + // The web dashboard has NO authentication (see webadmin.h). Binding it to a + // non-loopback address is refused unless this is explicitly set true -- the + // operator must acknowledge exposing a no-auth credit/debit surface. + bool webAllowPublic = false; + + // Dedicated MINX/RUDP port for the SYS_RPC syscall. This is a SECOND + // Minx instance bound to a separate UDP port, held + // inside CesServer for deployment convenience. It carries no CES + // protocol traffic — only outbound (initially) RUDP streams. + // + // 0 = disabled (default). Minx is not constructed, no socket is + // bound, SYS_RPC returns CES_ERROR_DISABLED. + // + // When nonzero, CesServer::start() binds a second Minx on this UDP + // port with a no-op listener. Inbound RUDP handshakes are rejected + // at Rudp's ChannelAccept predicate (outbound only). Operators can + // open or close this port independently from the CES protocol port + // via firewall rules. + uint16_t rpcPort = 0; + + // Test-only: when true AND rpcPort == 0, the rpc Minx socket is + // bound on an OS-allocated free port instead of being treated as + // "disabled." Production callers leave this false; tests set it + // so they don't have to negotiate port collisions. + bool rpcAutoPort = false; + + // --- SYS_RPC outbound flow control --- + // Backpressure against buggy VM programs and slow peers. rpcMaxPending + // caps concurrent in-flight outbound calls; queueRpc returns + // CES_ERROR_QUEUE_FULL past this. rpcMaxRequestBytes / + // rpcMaxResponseBytes cap the envelope body size in either direction. + // rpcResponseTimeoutMs arms an asio timer per session. + uint32_t rpcMaxPending = DEFAULT_RPC_MAX_PENDING; + size_t rpcMaxRequestBytes = DEFAULT_RPC_MAX_REQUEST_BYTES; + size_t rpcMaxResponseBytes = DEFAULT_RPC_MAX_RESPONSE_BYTES; + uint32_t rpcResponseTimeoutMs = DEFAULT_RPC_RESPONSE_TIMEOUT_MS; + // SYS_L2_CALL: caps concurrent in-flight VM->L2 calls. queueL2Call returns + // CES_ERROR_QUEUE_FULL past this, burning nothing. + uint32_t l2MaxPending = 4096; + + // Per-channel RUDP pacing advertised in the handshake. The effective + // bucket is min(local, peer) per parameter. Defaults mean "unlimited" + // (matches current behavior); operators who run adversarial SYS_RPC + // workloads can clamp. 0xFFFFFFFFu == minx::RudpConfig::PER_CHANNEL_UNLIMITED. + uint32_t rpcRudpBytesPerSecond = DEFAULT_RPC_RUDP_BYTES_PER_SECOND; + uint32_t rpcRudpBurstBytes = DEFAULT_RPC_RUDP_BURST_BYTES; + + // RUDP transport caps on the rpc_port. CES ships an opinion on + // channels (default 2 — lets cesh hold a long-lived stream while + // doing other ops in parallel) and stays passthrough on the two + // reorder buffers (-1 = keep the minx::RudpConfig library default, + // currently 1 MB / 1024 messages). + size_t rpcRudpMaxChannelsPerPeer = DEFAULT_RPC_RUDP_MAX_CHANNELS_PER_PEER; + int64_t rpcRudpMaxReorderBytesPerChannel = DEFAULT_RPC_RUDP_MAX_REORDER_BYTES; + int64_t rpcRudpMaxReorderMsgsPerChannel = DEFAULT_RPC_RUDP_MAX_REORDER_MSGS; + + // rpc_port RUDP channel idle GC, in seconds: a channel with no traffic for + // this long is dropped. 60 suits request/reply, but is too short for a + // long-lived interactive channel (a `cesh dial` terminal where a human + // pauses between commands) — raise it on terminal-serving boxes. + uint32_t rpcRudpChannelIdleSecs = DEFAULT_RPC_RUDP_CHANNEL_IDLE_SECS; + + // --- File storage feature (CesPlex builtin:file, v2) --- + // Master switch + hard capacity cap. The feature is entirely + // disabled when 0 — the FileHandler is not created, startup reconcile + // is skipped, and /ces/file/1 is never mounted (inbound binds NACK). + // A positive value is a hard ceiling + // on store-wide total_bytes: CREATE is rejected with + // CES_ERROR_STORE_FULL when the new file's size would push past + // the cap. WRITE cannot extend files (size fixed at CREATE), so + // CREATE is the single gate the cap enforces at. + uint64_t cesFileStoreMaxBytes = 0; + // Directory where bytes + sidecars live. Empty means + // "/cesplex_files/". Operators can repoint for + // bigger / faster / separately-provisioned storage. + std::string cesFileStoreDir; + // Read-only catalog of installable extensions (single .lua files). The + // Extensions page lists these as available; Install copies one into /s/. + // Empty disables the catalog (already-installed /s/ extensions still show). + std::string cesExtensionsDir; + // Extension funding budget: the global rate (raw credit units per day, over all + // extensions and remotes) the server grants /s/ programs that call + // ces.request_funds to spend at remotes. The discovery extension needs it. + // 0 = off. Enforced here, never in Lua. + uint64_t extFundingPerDay = DEFAULT_EXT_FUNDING_PER_DAY; + // Local extension budget: raw credit units each /s/ program account is topped up + // to (per extension) on boot and at daily maintenance. 0 = off (no auto top-up). + uint64_t extLocalBudget = DEFAULT_EXT_LOCAL_BUDGET; + // Three fee knobs mapping to the three physical costs of file + // storage: + // feeFileRent = retention (byte sitting on disk over time) + // feeFileWrite = networking + SSD write (per-KB, one-shot) + // feeFileRead = networking + SSD read (per-KB, one-shot) + // + // Defaults (resolved in CesServer ctor, zero means "derive"): + // feeFileRent = feeAsset / 100 / 256 (disk 100x cheaper than + // asset-cell RAM; the asset pays for its full + // 256 B cell, so per-byte-day of RAM is + // feeAsset/256 and disk is /100 of that) + // feeFileWrite = 10 * feeFileRent (writing a KB ~= 10 days + // of rent on 1 byte of that + // KB — real I/O) + // feeFileRead = feeFileRent (reading is ~rent-equivalent; + // cheaper than write, no wear) + // + // Write/read charges are ceil(bytes/1024) × fee. At defaults + // feeAsset=25.6M → feeFileRent=1000, feeFileWrite=10000; a 1 MB + // WRITE = 1024 × 10000 ≈ 10.2 M credits. CREATE has no per-byte + // cost — sparse allocation. Rent starts accruing, covered by the + // signer's initial_deposit landing in file_balance. + int64_t feeFileRent = 0; // credits per (byte × day) — retention + int64_t feeFileWrite = 0; // credits per KB — WRITE I/O + int64_t feeFileRead = 0; // credits per KB — READ I/O + + // --- RUDP-tier pricing. The server's own price per resource dimension + // the CesPlex bus measures, applied live each ChannelMeter tick in + // cesplexReportUsage — the bus reports raw counts, the server prices + // them here. The bus never carries these on the wire; pricing is the + // host's private concern, not part of the bind contract. + // + // feeNetChannelSec - per-second "channel is open" rate. + // Pays for the supervisor + memory baseline + // of holding a RUDP channel. + // feeNetMemByteDay - per (byte x day) of RUDP buffer state + // (reorder + send + ack caches). + // feeNetKiBSent per KiB sent server to client. + // feeNetKiBReceived per KiB received client to server. + // Throughput is metered per KiB so the rate can sit below 1 raw/byte. + // + // 0 is a sentinel, not free: the CesServer constructor derives a non-zero + // rate from the ledger anchors (floored to >= 1), so a live server always + // meters and evicts. Setting 0 re-derives; it does not disable metering. An + // explicit non-zero is honored. See the constructor for the anchors. + uint64_t feeNetChannelSec = 0; + uint64_t feeNetMemByteDay = 0; + uint64_t feeNetKiBSent = 0; + uint64_t feeNetKiBReceived = 0; + + // --- Compute feature (CesPlex builtin:compute) --- + // + // Master switch + hard caps for the L2 compute feature. The feature + // is entirely disabled when + // computeMaxInstances == 0 — the compute handler refuses to bind, + // inbound /ces/compute/1 selects NACK, and the cesluad child binary + // is never spawned. + // + // Bind-time prerequisites (all must hold for bind to succeed): + // 1. computeMaxInstances > 0 + // 2. builtin:file is also registered in the CesPlex registry + // (compute uses the file handler's internals for owner- + // authority file ops on behalf of running programs). + // 3. computeUser exists on the host system (a dedicated + // unprivileged uid each cesluad child drops to). + uint32_t computeMaxInstances = 0; // 0 = feature OFF + // computeMaxInstances is the whole admission story. It caps concurrent + // child processes; worst-casing every instance at one saturated core + + // computeProcessMemMax of RAM, it statically bounds both resources — + // RAM = instances × mem-cap, CPU = instances cores. Size it to the host + // (min of cores × factor and RAM ÷ mem-cap). No runtime load sampling: + // measuring CPU load without chasing instant peaks or reinventing a load + // monitor isn't worth it; a static process cap is simpler and safe. + // + // Per-process memory ceiling, enforced merciless + instant by the + // child's RLIMIT_AS: the kernel denies any allocation past it, so a + // runaway or malicious program can never OOM the host (OOM is an + // instant, machine-wide attack vector the 60 s billing tick can't react + // to in time). The program controls its own footprint, and instances × + // this value is the hard global bound on total L2-compute memory. + // + // No per-process CPU cap — CPU is a flow, billed (feeComputeCpuSec) and + // shared by the scheduler; the process cap above is the CPU bound. No + // pids cap — the sandbox exposes no fork/exec, so a child is one process. + uint64_t computeProcessMemMax = DEFAULT_COMPUTE_PROCESS_MEM_MAX; + // Worker threads each Lua child uses for OUTBOUND verb-client calls + // (ces.file_client / ces.compute_client): how many such round-trips can be in + // flight concurrently before further ones queue. Bounds the child's blocking + // pool; the main-port client pool (ces.ping / ces.remote_*) is fixed at 1 + // (one leased outbound port). Passed to the child on spawn; clamped [1, 64]. + uint32_t computeClientPoolSize = DEFAULT_COMPUTE_CLIENT_POOL_SIZE; + // Fee knobs for compute — credits per unit time / per byte. + // Every non-zero on a tick is accumulated against the source file's + // file_balance. 0 = "derive default" at bind time. The four knobs: + // + // feeComputeSlotSec — flat "slot is occupied" overhead, per + // wall-clock second (pays for supervisor + // state + the per-tick sidecar rewrite). + // feeComputeCpuSec — full-core-second cost. Billed as + // cpu_bp × feeComputeCpuSec / 10000 × sec. + // A 100%-of-one-core second pays the full + // amount; 25% pays a quarter, etc. + // feeComputeRssByteDay — RAM residency, credits per byte per day. + // Billed as rss_bytes × rate × sec / 86400. + // feeComputeNetByte — reserved for outbound APPLICATION bytes + // (not yet wired). + // + // Defaults: + // feeComputeSlotSec = feeAsset / 86400, floor 1 + // Tracking a running instance costs far + // more than a single 64 B account — it's + // a child process, a unix-domain socket, + // a task_struct, supervisor bookkeeping. + // "One asset-day of fixed overhead" is + // the conservative minimum. ~296/sec. + // feeComputeRssByteDay = feeAsset / 256, floor 1 + // "RAM is RAM" — same per-byte-day as the + // full asset cell. 100K/byte-day at stock. + // feeComputeCpuSec = 5_000_000, floor 1 + // Full-core-second cost: 0.005 credits per + // ns of a background core. Deliberately far + // below the logic-strand VM lane (see + // BASE_FEE_VM_MULT): compute cores are + // fungible, the strand is the global lock. + // At 10000 bp (full core) × 1 sec = 5M; at + // 500 bp (5% usage) × 1 sec = 250K. + // + // Orientation: a 1 MB idle service at stock fees pays roughly + // ~72M (RAM) + ~18K (slot) per 60 s tick ≈ 104.9B/day + // (≈ 4100 assets/day), dominated by RAM residency. + int64_t feeComputeCpuSec = 0; + int64_t feeComputeRssByteDay = 0; + int64_t feeComputeNetByte = 0; + int64_t feeComputeSlotSec = 0; // 0 = derive at bind + // Per-byte-second rate billed for ces.bucket_new() committed + // capacity (max_entries × max_entry_bytes per bucket, summed across + // all of an instance's buckets). Charged on the same supervisor + // tick that bills slot/cpu/rss, against the source file's + // file_balance. /s/ files are exempt (the file handler's debitBalance is a + // no-op there). 0 = derive at bind from feeComputeRssByteDay / + // 86400 — same per-byte basis as RSS, but on a per-second cadence + // because the buckets are an explicit standing capacity rather + // than a sampled measurement. Use a small positive value if + // feeComputeRssByteDay is too tuned-down to derive a non-zero rate. + int64_t feeBucketByteSec = 0; + // Supervisor tick cadence in milliseconds. One tick does: /proc + // sample (CPU delta + RSS) + accrued slot-fee debit + source-file + // sidecar rewrite. 60 s is the production default — rare enough + // that the sidecar rewrite is nearly free per instance, while + // procfs sampling stays cheap and balance-drift stays bounded. + // Tests override this to a short interval so they don't wait a + // full minute for the first sample. + uint32_t computeTickIntervalMs = 60000; + // Per-instance scratch / IPC socket directory. Empty means + // "/cescompute/". Operators can repoint for faster / + // separately-provisioned storage. + std::string cesComputeWorkDir; + // Unix user that cesluad child processes drop to. Must exist on the + // host. Bind fails if the user can't be resolved. + std::string cesComputeUser = DEFAULT_COMPUTE_USER; + // Path to the compute child binary. The CES CLI (src/ces/main.cpp) + // auto-discovers `cesluajitd` next to /proc/self/exe when this is + // empty, with bare-name PATH fallback — operators with the typical + // sibling-binaries install don't need to set anything. Tests and + // other in-process consumers (which don't go through main.cpp) get + // the literal default `"cesluajitd"` and rely on PATH or override + // explicitly. Absolute path → used directly. The original + // `cescompmockd` stub is still built and kept for regression + // testing of the plumbing itself (LAUNCH / KILL / inbox delivery) + // without pulling Lua into the mix. + std::string cesComputeChildBinary = "cesluajitd"; + +#ifdef CES_MAIL + // Outbound mail relay (ces.mail.send). Empty host => mail is logged + dropped. + // Credentials ride AUTH LOGIN; keep them in an operator secrets file, never + // committed. STARTTLS is used when the relay advertises it (see util/smtp.h). + std::string mailRelayHost; + uint16_t mailRelayPort = 587; + std::string mailFrom; + std::string mailUser; + std::string mailPass; + + // Outbound mail charging (builtin:mail / ces.mail.send). mailFeePerMB is the + // credits burned per megabyte of encoded (on-the-wire) message; 0 = free. + // mailMaxEncodedBytes caps one message's encoded size (default 20 MiB). + uint64_t mailFeePerMB = 0; + uint64_t mailMaxEncodedBytes = 20ull * 1024 * 1024; +#endif // CES_MAIL + + // L2 compute program UDP port range: [computePortBase, computePortBase + // + computePortCount - 1]. Each launched child binds its outbound CES + // client to a port the server allocates statically from this range. The + // child never picks its own (ephemeral) port: a firewalled L2 host opens + // only known ports, and an OS-assigned egress port is neither reachable + // nor configurable. The server owns the whole lifecycle — it tracks + // which ports are free, assigns one at LAUNCH, hands it to the child in + // the bootstrap frame, and frees it when the instance dies. Port + // allocation is best-effort: a spent range leaves the instance with port + // 0 (local-only), but the LAUNCH still succeeds — the instance stays + // reachable via the server's own rpc port (/ces/lua/1 relay). + // + // Base and count are independent of computeMaxInstances on purpose: + // ports and instance slots are orthogonal resources. Size the range to + // your firewall opening; a count below computeMaxInstances simply means + // the instances past the range run local-only (no outbound network). + // + // computePortBase == 0 = no range: instances launch local-only — their + // outbound network verbs (ces.remote_transfer / remote_account_read / + // remote_cross_transfer) fail cleanly with "networking disabled" rather + // than binding an unreachable ephemeral port. When set, open [base, + // base + count - 1]/udp at the firewall to match. + uint16_t computePortBase = 0; + uint16_t computePortCount = 0; + + // --- /s/ extensions --- + // Each name in `extensions` is the basename of a Lua program the + // operator dropped into /s/ (e.g. "dice" → /s/dice.lua). + // /s/ is operator-controlled at the disk level — fileHandler's + // startup reconcile auto-generates sidecars for any files the + // operator placed there. At boot, CesServer::launchExtensions + // calls computeHandlerLaunchInternal("/s/.lua") for each + // entry: source missing → WRN, skip; otherwise one cesluajitd + // instance is launched. + // Requires: rpcPort > 0, cesplex builtin:file mounted with + // cesFileStoreMaxBytes > 0, builtin:compute mounted with + // computeMaxInstances > 0. If prereqs are missing the autolaunch + // is skipped with a warning; the server otherwise runs fine. + std::set extensions; + + // --- CesPlex protocol mounts --- + // The L2 bus (CesPlex) runs on the secondary port (rpcPort). Every + // inbound RUDP channel does a protocol-select handshake; the table + // below says which protocol names are mounted, and which handler + // each name resolves to. + // + // Key: protocol name (e.g. "/ces/file/1"). + // Value: target — "builtin:" to bind to a statically-linked + // handler registered in the CesPlex registry. + // + // Empty map = CesPlex has no bindings; every inbound select is + // NACK'd. This is the right default for a pure ledger deployment. + std::map cesplexMounts; +}; + +// No-op MinxListener for the dedicated RPC Minx (see rpcPort above). +// All MinxListener methods fall through to their empty defaults; this +// subclass exists only so the second Minx has an owner for its +// listener pointer. +class CesRpcListener : public minx::MinxListener {}; + +class CesServer; + +// Defined in ces/l2/peer_handler.h; returned by _peerLinkTargets() for +// the /ces/peer/1 mesh reconcile. +struct PeerLinkTarget; + +// builtin:peer / builtin:lua handlers, per-server objects owned by CesServer +// (defined in ces/l2/*.h, included only in server.cpp). Forward-declared so +// server.h does not pull the L2 handler headers into every consumer. +class PeerHandler; +class LuaHandler; +class FileHandler; +class ComputeHandler; +#ifdef CES_MAIL +class MailHandler; +#endif + +// Rudp::Listener for the rpc port. Owns the back-pointer to CesServer +// for onSend (forward to rpcMinx_) and onAccept (delegate to CesPlex +// for inbound channel handler factory). +class CesRpcRudpListener : public minx::Rudp::Listener { +public: + explicit CesRpcRudpListener(CesServer* owner) : owner_(owner) {} + void onSend(const minx::SockAddr& peer, const minx::Bytes& bytes) override; + std::shared_ptr onAccept( + const minx::SockAddr& peer, uint32_t channelId) override; +private: + CesServer* owner_; +}; + +class CesServer : public minx::MinxListener, public CesPlexHost { + friend class CesRpcRudpListener; + friend struct ServerLedgerTxn; // L2 verb ledger transaction (server.cpp) +public: + using ActiveAccount = Accounts::ActiveAccount; + using ActiveAsset = Assets::ActiveAsset; + + CesServer(const CesConfig& config); + virtual ~CesServer(); + + uint16_t start(uint16_t serverPort = DEFAULT_PORT); + void createPoWEngine(bool fullMem = true); + bool isPoWEngineReady(); + void startPeerMiner(); + void stop(bool flushEvents = true); + + void pause(); + void resume(); + + uint64_t getTxCount(); + + virtual bool isConnected(const SockAddr& addr); + virtual bool delegateProveWork(const SockAddr& addr, + const MinxProveWork& msg); + virtual void incomingInit(const SockAddr& addr, const MinxInit& msg); + virtual void incomingMessage(const SockAddr& addr, const MinxMessage& msg); + virtual void incomingGetInfo(const SockAddr& addr, const MinxGetInfo& msg); + virtual void incomingInfo(const SockAddr& addr, const MinxInfo& msg); + virtual void incomingProveWork(const SockAddr& addr, const MinxProveWork& msg, + const int difficulty); + virtual void incomingApplication(const SockAddr& addr, const uint8_t code, + const minx::Bytes& data); + + // Send an unsolicited APPLICATION message to a connected client. + // Looks up the client's address in the presence cache. + // Returns true if the client was found and the message was sent. + bool send(const HashPrefix& clientId, const minx::Bytes& data); + + // Like send() but with an explicit APPLICATION opcode byte. + // Used by builtin:compute to push CES_APP_COMPUTE_MSG (0x81) to + // program clients — the default overload sends as + // MINX_APPLICATION_DEFAULT, which wouldn't route back to the + // compute inbox on the receiving side. + bool send(const HashPrefix& clientId, uint8_t code, const minx::Bytes& data); + + // Schedule a delayed runAsset. time_us=0 or past means next tick. + // Cost is deducted from caller upfront. Returns CES_ERROR_QUEUE_FULL if + // the scheduled-run queue is at capacity, CES_OK otherwise. + // `allowance` is the per-run caller-debit cap the future run will see; + // UINT64_MAX = no enforcement (the autoexec / cron-from-API default). + // `aliasId` nonzero schedules an alias's inline program instead (assetId + // ignored; the fired run boots the cell's code area with self = 0 and + // programOwner = the cell's owner). + uint8_t scheduleRun(const HashPrefix& callerPrefix, const minx::Hash& assetId, + uint64_t budget, uint64_t allowance, + const ces::Bytes& input, + uint64_t time_us, bool prepaid = false, + uint32_t aliasId = 0, + uint64_t invokeKind = INVOKE_SCHEDULED); + + // Fee-free credit move used to escrow an L2 call: the payer's `value` burn + // into the bottomless self-account (Sync, returns success, blocks on the + // logic strand). Settlement mints self -> payee or refunds self -> payer. + bool l2TransferSync(const minx::Hash& from, const minx::Hash& to, + uint64_t amount); + + // Client CALL-verb entry into the same L2-call machinery. Called on + // rpcTaskIO_ by a handler's verb dispatcher: applies backpressure, burns + // payer -> self (escrow), mints the callId, and posts drainL2Call. The reply + // sink is the held request. Returns CES_OK once escrowed (response is then + // deferred to completeL2Call), or an error to answer synchronously (nothing + // burned). + uint8_t enqueueChannelL2Call(CesPlexHandler* handler, const minx::Hash& payer, + uint64_t value, ces::Bytes blob, + std::shared_ptr replyCtx); + + uint8_t crossTransfer(const minx::Hash& originKey, + const minx::Hash& destKey, uint64_t amount, + const std::string& destServer, + uint32_t providedNonce, + int64_t& outOriginBalance, + int64_t txFee = -1, int64_t errFee = -1); + + enum class TransferMode : uint8_t { + Safe = 0, // fail if dest not found + Open = 1, // auto-create dest if not found + Payment = 2 // create payment account (negative balance) + }; + + uint8_t transfer(const minx::Hash& originKey, const minx::Hash& destKey, + uint64_t amount, TransferMode mode, + uint8_t paymentDays, + uint32_t providedNonce, int64_t& outOriginBalance, + int64_t txFee = -1, int64_t rentFee = -1, + int64_t errFee = -1); + + uint8_t bulkTransfer(const minx::Hash& originKey, + const std::vector& items, + uint32_t providedNonce, int64_t& outOriginBalance, + uint8_t& outSuccessfulCount, int64_t txFee = -1, + int64_t rentFee = -1, int64_t errFee = -1); + + uint8_t queryAccount(const minx::Hash& originKey, const HashPrefix& queryId, + uint8_t items, uint32_t providedNonce, + int64_t& outOriginBalance, + std::vector& outResults, + int64_t queryFee = -1, int64_t errFee = -1); + + void unsignedQueryAccount(const HashPrefix& queryId, int64_t& outBalance, + uint32_t& outNonce, HashPrefix& outLastXferDest, + uint64_t& outLastXferAmount, + uint32_t& outLastXferTime, uint32_t& outAliasId); + void unsignedQueryAccount(const HashPrefix& queryId, int64_t& outBalance, + uint32_t& outNonce, HashPrefix& outLastXferDest, + uint64_t& outLastXferAmount, + uint32_t& outLastXferTime) { + uint32_t aliasId = 0; + unsignedQueryAccount(queryId, outBalance, outNonce, outLastXferDest, + outLastXferAmount, outLastXferTime, aliasId); + } + + uint8_t createAsset(const minx::Hash& originKey, const HashPrefix& ownerId, + const minx::Hash& assetId, const AssetData& content, + uint16_t balance, uint32_t providedNonce, + int64_t rentFee = -1, int64_t errFee = -1); + + // Atomically create `count` account-owned cells at firstKey||0..count-1 + // (firstKey is prefix||0). Rejects the whole batch if any key exists. + uint8_t createAssetRange(const minx::Hash& originKey, const HashPrefix& ownerId, + const minx::Hash& firstKey, uint32_t count, + uint16_t days, uint32_t providedNonce, + int64_t rentFee = -1, int64_t errFee = -1); + + uint8_t updateAsset(const minx::Hash& originKey, const minx::Hash& assetId, + const HashPrefix& newOwnerId, const AssetData& content, + uint32_t price, uint32_t providedNonce, + int64_t updateFee = -1, int64_t errFee = -1); + + uint8_t updateAssetMeta(const minx::Hash& originKey, + const minx::Hash& assetId, + const HashPrefix& newOwnerId, uint32_t price, + uint32_t providedNonce, int64_t updateFee = -1, + int64_t errFee = -1); + + // Owner-only toggle of the asset's owner-pays (auto-fund) bit. Preserves days + // and the other flags; charges feeTx. Allowed on immutable assets (the seal + // is content-only). + uint8_t setAssetOwnerPays(const minx::Hash& originKey, + const minx::Hash& assetId, bool ownerPays, + uint32_t providedNonce, int64_t fee = -1, + int64_t errFee = -1); + + uint8_t updateAssetFast(const minx::Hash& originKey, + const minx::Hash& assetId, const AssetData& content, + uint32_t providedNonce, int64_t fastUpdateFee = -1, + int64_t errFee = -1); + + uint8_t fundAsset(const minx::Hash& originKey, const minx::Hash& assetId, + uint16_t balance, uint32_t providedNonce, + int64_t fundFee = -1, int64_t rentFee = -1, + int64_t errFee = -1); + + uint8_t buyAsset(const minx::Hash& originKey, const minx::Hash& assetId, + uint64_t priceLimit, uint32_t providedNonce, + int64_t buyFee = -1, int64_t errFee = -1); + + // Alias ops: a dependable, account-owned 1 KiB sidecar (local/aliases.md). + // setAlias patches bytes into an alias's value image at offset. aliasId 0 + // targets the origin's own alias: with no live alias it allocates a fresh + // id (zeroed image), otherwise it patches in place (the id is stable across + // patches; delete to drop or rotate it). A nonzero aliasId targets any + // alias the origin owns or edits; an editor may write content only + // (ALIAS_PATCH_MIN_EDITOR), an owner everything but the server-set owner + // field (ALIAS_PATCH_MIN_OWNER). Out-of-bounds or under-floor patches + // reject whole. Charges the signer one day at the feeAlias rate. + // deleteAlias erases the origin's own alias and clears the account link; + // queryAlias is a public read. All run on logicStrand_. + uint8_t setAlias(const minx::Hash& originKey, uint32_t aliasId, + uint16_t offset, const ces::Bytes& bytes, + uint32_t providedNonce, uint32_t& outAliasId, + int64_t fee = -1, int64_t errFee = -1); + + uint8_t deleteAlias(const minx::Hash& originKey, uint32_t providedNonce, + int64_t errFee = -1); + + bool queryAlias(uint32_t aliasId, Alias& out); + + // key_names: the signer's own key IS the entry owner (originKey == the key). + uint8_t registerKeyName(const minx::Hash& originKey, const ces::Bytes& name, + uint32_t providedNonce, int64_t errFee = -1); + uint8_t clearKeyName(const minx::Hash& originKey, uint32_t providedNonce, + int64_t errFee = -1); + bool queryKeyName(const minx::Hash& key, KeyNameData& outName); + bool queryKeyNameByName(const ces::Bytes& name, minx::Hash& outKey); + + uint8_t giveAsset(const minx::Hash& originKey, const minx::Hash& assetId, + const HashPrefix& newOwnerId, uint32_t providedNonce, + int64_t giveFee = -1, int64_t errFee = -1); + + uint8_t queryAsset(const minx::Hash& originKey, const minx::Hash& assetId, + uint8_t items, uint32_t providedNonce, + std::vector& outResults, int64_t queryFee = -1, + int64_t errFee = -1); + + uint8_t queryServerInfo(const minx::Hash& originKey, uint32_t providedNonce, + int64_t& outOriginBalance, + std::vector& outEntries, + int64_t queryFee = -1, int64_t errFee = -1); + + void unsignedQueryAsset(const minx::Hash& assetId, HashPrefix& outOwner, + AssetData& outContent, uint16_t& outBalance, + uint32_t& outPrice); + + void liveSnapshot(std::function cb = {}); + + void _brr(const minx::Hash& accountKey, int64_t amount); + void _burn(const minx::Hash& accountKey, int64_t amount); + // Register a key_name for `key` directly (test hook; bypasses the signed op + // and fee). Returns true on success. + bool _registerKeyName(const minx::Hash& key, const std::string& name); + // Operator wallet send: transfer `amount` from the server's own (bottomless) + // account to `destKey`, creating dest if missing. Debits the server exactly + // and credits dest — net totalCredits unchanged. Returns false if the server + // balance can't cover it (a guard against driving its account negative). + bool _walletSend(const minx::Hash& destKey, uint64_t amount); + void _save(); + + // Credits in circulation: the raw account tally minus the server's own + // bottomless account, which is counted like any other account and + // subtracted here so the core ledger class stays unaware of it. + int64_t circulatingCredits() { + return accounts_.getTotalCredits() - + accounts_.get(serverKeyPair_.getPublicKeyAsHash()).balance(); + } + int64_t _getTotalCredits() { return circulatingCredits(); } + + // Test hooks: an account's current balance / nonce (0 if absent). Read + // accounts_ directly like circulatingCredits(); tests call them between ops, + // never concurrently with logicStrand mutations. + int64_t _balanceOf(const minx::Hash& key) { + auto a = accounts_.get(Account::getMapKey(key)); + return a.exists() ? a.balance() : 0; + } + uint32_t _nonceOf(const minx::Hash& key) { + auto a = accounts_.get(Account::getMapKey(key)); + return a.exists() ? a.nonce() : 0; + } + bool _accountExists(const minx::Hash& key) { + return accounts_.get(Account::getMapKey(key)).exists(); + } + uint32_t _aliasIdOf(const minx::Hash& key) { + auto a = accounts_.get(Account::getMapKey(key)); + return a.exists() ? a.data().getAliasId() : 0; + } + // Force the id-generator's next-id (entry 0). Test-only: drives the allocator + // to the wrap boundary or onto an occupied slot without minting 2^32 aliases. + // Call on a quiescent logic strand (drain first). + void _setAliasNextId(uint32_t next); + + void _runDailyMaintenance(); + + // Test hooks for peer table. Production code reaches peer state through + // the peer miner loop (probe + writeback). Tests that only care about + // cross-transfer behavior — not reachability detection itself — use + // these to skip the miner and set up a reachable outbound peer directly. + void _markPeerReachable(const minx::Hash& ckey, const std::string& address); + bool _isPeerReachable(const minx::Hash& ckey); + + // True iff `ckey` is currently in the peer table (membership, not + // reachability). The /ces/peer/1 bind gate uses it. + bool _isPeerByKey(const minx::Hash& ckey); + + // Reachable peers flattened into mesh targets for the /ces/peer/1 + // reconcile. dialable = resolved IP known and plex port advertised. + // Unreachable peers are omitted (the reconcile drops their links). + std::vector _peerLinkTargets(); + + // rpcRudp accessor for the peer-mesh dialer (server-to-server). Null + // when the plex port is down. Used by builtin:peer to open outbound + // channels on the server's own rpc Rudp. + minx::Rudp* _rpcRudp() { return rpcRudp_.get(); } + + // This server's builtin handler instances, or null when the protocol is not + // wired in [cesplex_mounts] (e.g. file is null unless /ces/file/1 is mounted; + // file_store_max_bytes is only the metered cap, not the on/off switch). + // Cross-handler callers reach them through these accessors. + PeerHandler* peerHandler() { return peerHandler_.get(); } + LuaHandler* luaHandler() { return luaHandler_.get(); } + FileHandler* fileHandler() { return fileHandler_.get(); } + ComputeHandler* computeHandler() { return computeHandler_.get(); } +#ifdef CES_MAIL + MailHandler* mailHandler() { return mailHandler_.get(); } +#endif + + // Test hook: add a peer with a pre-resolved plex endpoint (simulates a + // completed probe) without starting the peer miner. Drives the + // /ces/peer/1 reconcile deterministically in tests. + void _testAddPeerWithRpc(const minx::Hash& ckey, + const std::string& declaredAddr, + const boost::asio::ip::address& ip, + uint16_t rpcPort); + + // TEST-ONLY (`_`-prefixed). Marks an existing peer as fully reciprocated + // (reserve held, inbound PoW proved, verified), as a completed mining cycle + // would, so an in-process test drives the discovery/clusterer/coalition stack + // without RandomX. No production path reaches this: real reciprocation comes + // only from the peer miner. + void _testCompletePeering(const minx::Hash& ckey, int64_t reserve, + uint64_t inboundPoW); + + // TEST-ONLY (`_`-prefixed). Mounts an arbitrary CesPlex handler object on + // the live bus, for tests that exercise a non-core handler (e.g. echo). + // This is NOT a production extension point: prod handlers are mounted only + // by resolveBuiltin() in start() (the hardcoded builtin: -> class + // switch). Do not call this outside tests. Posts onto rpcTaskIO_ and blocks + // (race-free vs live accepts); the handler must outlive the server. No-op + // if the plex port is down. + void _mountCesPlexHandler(const std::string& proto, CesPlexHandler* handler); + + // Test hook: post runAutoexec() onto logicStrand_ and block until done. + // Production callers go through the one-shot boot post in start(). + void _runAutoexecSync(); + + // Test hook: barrier on logicStrand_. Posts a no-op and blocks until it runs, + // so every mutation posted earlier (e.g. _brr) has been applied. Deterministic + // replacement for a timed settle. Caller MUST NOT run on logicStrand_. + void _drainLogic(); + + // Test hook: fire a scheduled (cron) VM run synchronously — build a + // ScheduledRun, post it onto logicStrand_, and block until it completes. + // Bypasses the cron timer so crash-recovery tests can drive the cron path + // deterministically. Returns executeScheduledRun's result. + bool _executeScheduledRunSync(const HashPrefix& callerPrefix, + const minx::Hash& assetId, uint64_t budget, + uint64_t allowance, const ces::Bytes& input); + + // Test hook: number of pending scheduled (cron) runs. Used to assert that a + // VM abort rolls back a SYS_SCHEDULE enqueue. + size_t _scheduledRunCount() const { return scheduledRuns_.size(); } + + // Test hook: prime the presence cache (both directions) with a + // (pubkey prefix, addr) pair. Normally presence is populated by + // the server's signed-op dispatch; tests that just want to + // receive unsolicited APPLICATION pushes (e.g. from a running + // compute program) can bypass authentication with this. + void _primePresence(const HashPrefix& prefix, const minx::SockAddr& addr); + + // Test hook: observe the UDP port the SYS_RPC Minx instance ended up + // bound to. Non-zero after start() when cfg_.rpcPort was non-zero. + uint16_t _rpcBoundPort() const { return rpcBoundPort_; } + + // ChannelMeter accessor — used by cesco for the `netbill` + // command and by tests. Returns nullptr when the rpc port is + // disabled (no rpcRudp_, no ChannelMeter). + ChannelMeter* _channelMeter() { return channelMeter_.get(); } + + // --------------------------------------------------------------------------- + // Dashboard / admin surface (web dashboard, cesco) + // --------------------------------------------------------------------------- + // Read-only, lock-free getters (atomics / set-once boot state). + uint16_t _boundPort() const { return boundPort_; } + uint16_t getTps() const { return tpsCurrent_.load(); } + + // Ledger-derived stats. The account/asset stores are logicStrand-only, + // so these hop onto the strand and block the caller on a future. The + // caller MUST NOT be the logic strand (the web/cesco threads aren't). + struct AdminStats { + int64_t circulating = 0; // credits in circulation (server-self excluded) + uint64_t accounts = 0; + uint64_t assets = 0; + uint64_t aliases = 0; + uint64_t txCount = 0; + }; + AdminStats _adminStats(); + + // Read-only account lookup (strand-hopped). `exists` distinguishes a + // missing account from a zero-balance one. + struct AdminAccount { + bool exists = false; + // The 8-byte map-key prefix is occupied, but by a DIFFERENT full key (the + // stored 24-byte keyTail doesn't match the queried one) — a prefix + // collision. The queried account does NOT exist; creating it would clash. + bool prefixTaken = false; + int64_t balance = 0; + uint32_t nonce = 0; + HashPrefix lastXferDest{}; + uint64_t lastXferAmount = 0; + uint32_t lastXferTime = 0; + }; + AdminAccount _adminQueryAccount(const minx::Hash& accountKey); + + // Read-only asset lookup (strand-hopped). Returns the raw 16-bit balance + // word (days + flag bits); the dashboard masks/labels it. + struct AdminAsset { + bool exists = false; + HashPrefix owner{}; + uint16_t balance = 0; + uint32_t price = 0; + AssetData content{}; + }; + AdminAsset _adminQueryAsset(const minx::Hash& assetId); + + // L2 file STAT for the dashboard's file lookup. Public/unsigned (no signer). + // `enabled` is false when the file feature is off; `found` false when the + // path has no file. Rolls rent forward like a real STAT (a rent-dead file + // reports not-found). + struct FileStat { + bool enabled = false; + bool found = false; + std::array ownerPubkey{}; + uint64_t fileBalance = 0; + uint64_t size = 0; + uint64_t pricePerKb = 0; + uint64_t createdUs = 0; + uint64_t modifiedUs = 0; + }; + FileStat _fileStat(const std::string& path); + + // Peer table, flattened for display. `inbound` is derived (the peer has + // submitted PoW to us); `outbound` means we mine/settle to them. + struct PeerInfo { + minx::Hash ckey{}; + std::string declaredAddress; + std::string resolvedIP; + bool outbound = false; + bool inbound = false; + bool reachable = false; + bool verified = false; + int64_t ourBalanceThere = -1; + uint64_t totalInboundPoW = 0; + uint64_t totalOutboundPoW = 0; + uint64_t lastInboundTime = 0; + uint64_t lastCheckTime = 0; + uint32_t pingFailures = 0; + uint16_t rpcPort = 0; // peer's CesPlex rpc port (0 = not yet probed) + uint32_t grief = 0; // L2-reported grief (ces.grief_peer) + uint64_t bannedUntil = 0; // unix seconds; 0 = not banned + }; + // Author and fan out a gossip from this server (no client, no ticket). + // Returns the amount actually fanned out; the caller may refund budget minus + // that (a program refunds it to its file_balance). + uint64_t originateGossip(const ces::Bytes& msg, uint64_t budget, + const Hash& dest); + // Count of gossips received and processed (non-sink). + uint64_t gossipReceivedCount() const { + return gossipRecvCount_.load(std::memory_order_relaxed); + } + // Count of gossip sink events (dest was an entity hosted here). + uint64_t gossipSinkCount() const { + return gossipSinkCount_.load(std::memory_order_relaxed); + } + + // Gossip sink registry: a `dest` matching a pubkey registered here is terminal + // (handleGossip flushes the budget into it, no fan-out). Refcounted. Both run + // on logicStrand_ (post from elsewhere) and the set is read there, so no lock. + void registerSinkTarget(const minx::Hash& pubkey); + void unregisterSinkTarget(const minx::Hash& pubkey); + + // Even split of `budget` over `caps` with redistribution of capped-off excess; + // `pocket` takes the remainder. sum(alloc) + pocket == budget. Static so the + // allocator can be unit-tested directly. + static std::vector splitCapped(uint64_t budget, + const std::vector& caps, + uint64_t& pocket); + + std::vector _peerSnapshot(); + // Vostro balances: for each peer pubkey, the balance of THAT peer's account on + // THIS server (what we owe them) — the other half of the nostro/vostro pair + // (PeerInfo.ourBalanceThere is our reserve on them, what they owe us). Lives in + // the ledger, not the peer table, so it's looked up on logicStrand_. Aligned + // with `keys`; a peer with no local account reads 0. + std::vector _peerVostroBalances(const std::vector& keys); + + // Add (or upgrade an existing entry to) an outbound peer — one we mine to + // peerTarget and can cross-transfer through. Persists immediately so it + // survives restart even if the miner hasn't ticked yet. + void _addOutboundPeer(const minx::Hash& ckey, const std::string& address); + // Remove a peer entirely. Returns true if an entry was erased. + bool _removePeer(const minx::Hash& ckey); + + // Raise a peer's grief by `amount` (called by extensions via ces.grief_peer). + // At peerGriefBanThreshold the peer is banned for peerBanSecs. An expired ban is + // reset here before the new grief is applied. Returns the resulting grief. + uint32_t griefPeer(const minx::Hash& ckey, uint32_t amount); + + // Ban a peer immediately on conclusive evidence (bypasses the accumulating counter). + void banPeer(const minx::Hash& ckey); + + // Sign `data` with the server key for a privileged /s/ extension (ces.serverSign). + // The host applies a reserved domain tag and hashes, so the signed message is always + // SHA256(tag || data): an extension attestation can never collide with another + // server-key op, and a 32-byte payload cannot be a raw pre-image of one. The server + // private key never leaves C++. + Signature serverSign(const uint8_t* data, size_t len); + + // Test hooks: drive the inbound-PoW peer-table path (inboundCredit > 0) and set + // the verified flag, so the address-claim policy (a verified address is sticky + // against an unsigned inbound claim; unverified entries are freely overwritten) + // can be unit-tested without real PoW + a live signed server-info exchange. + void _upsertPeerForTest(const minx::Hash& ckey, const std::string& address, + uint64_t inboundCredit) { + upsertPeer(ckey, address, inboundCredit); + } + bool _setPeerVerifiedForTest(const minx::Hash& ckey, bool v) { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) + if (p.ckey == ckey) { p.verified = v; return true; } + return false; + } + + // Runtime peer-credit target. Reading/writing goes through an atomic the + // miner consults each cycle; setting it >0 starts the miner if it wasn't + // already running (e.g. a server that booted with target 0). Note: this + // is runtime-only — it does not rewrite the TOML, so it resets on restart. + uint64_t _peerTarget() const { return peerTarget_.load(); } + void _setPeerTarget(uint64_t target); + size_t _maxPeers() const { return maxPeers_.load(); } + void _setMaxPeers(size_t n); + bool _peerMinerRunning() const { return peerMinerRunning_.load(); } + // Peer miner heartbeat for the dashboard: unix seconds of the last completed + // cycle (0 = never), and a cumulative cycle count. + uint64_t _peerMinerLastCycle() const { return lastPeerMinerCycle_.load(); } + uint64_t _peerMinerCycles() const { return peerMinerCycles_.load(); } + // What the miner is doing RIGHT NOW: `mining` is true only while actually + // computing a PoW solution (not merely looping/probing). When true, `peer` + // and `difficulty` say where and at what target difficulty. + struct PeerMinerActivity { + bool mining = false; + std::string peer; + uint8_t difficulty = 0; + uint64_t startSecs = 0; // unix secs this solve began (0 = not mining) + double hashRate = 0.0; // smoothed H/s from completed solves (0 = unknown) + uint64_t hashesTried = 0; // hashes tried so far in the current solve (live) + uint64_t expectedHashes = 0; // 2^difficulty: mean hashes for one solution + }; + PeerMinerActivity _peerMinerActivity() const { + std::lock_guard lock(peerMinerActivityMutex_); + return {peerMinerMining_, peerMinerMiningPeer_, peerMinerMiningDiff_, + peerMinerMiningStartSecs_, peerMinerHashRate_, + peerMinerHashesTried_, peerMinerExpectedHashes_}; + } + + // Export the current effective server config (knobs, with the LIVE runtime + // peer target) as a TOML config file written to /ces.toml — the + // resolution to the "config is a boot snapshot but the dashboard mutates it" + // paradox: change values live, then export and feed the file back on the + // next boot. Deliberately excludes the peer table (own peerdata.toml) and + // the hello banner (own hello.txt), which already persist themselves. + // Returns the absolute path written, or empty string on failure. + std::string _exportConfig(std::string* errReason = nullptr); + + // Set a runtime-editable config knob live (mutated on logicStrand_, where it + // is read; the export reads cfg_ so the change persists on the next export). + // Supported keys: fee_account, fee_asset, fee_tx, fee_query, fee_vm_mult, + // fee_discount_enabled (0/1). Returns false for an unknown/non-editable key. + bool _setConfigKnob(const std::string& key, uint64_t value); + + // Inspect a remote server by address. Runs a blocking CesClient handshake + // on the CALLER's thread (never the logic strand) — discovers the peer's + // pubkey + min-difficulty + reachability for free off the MINX handshake. + // If `fetchPaidInfo` and we already hold a balance there, also pulls the + // paid CES_QUERY_SERVER_INFO KV map (empty otherwise). This is how the + // dashboard discovers a server before adding it as a peer. + struct RemoteServerInfo { + bool reachable = false; + minx::Hash serverKey{}; + uint8_t minDifficulty = 0; + std::vector entries; // paid KV info, may be empty + }; + RemoteServerInfo _inspectRemoteServer(const std::string& address, + bool fetchPaidInfo); + + // Mine `count` solutions on a remote server (our key is the beneficiary) — + // the way to bootstrap a reserve balance on a server before peering with + // it. Blocking, on the caller's thread; reuses the same path as the peer + // miner. RandomX makes this slow, so callers run it off the I/O thread. + struct RemoteMineResult { + bool ok = false; + uint64_t credit = 0; + int status = 0; + std::string error; + }; + RemoteMineResult _mineRemoteServer(const std::string& address, int count); + + // Operator "hello" banner — a UTF-8 string served in CES_QUERY_SERVER_INFO + // as the "hello" field. Capped at HELLO_MAX_BYTES of UTF-8, trimmed on a + // codepoint boundary (never mid-sequence). Seeded at boot from + // /hello.txt if present; the only other way it changes is the + // dashboard's save (_setHello, which also rewrites the file). + static constexpr size_t HELLO_MAX_BYTES = 160; + std::string _getHello(); + // Normalize `raw` (strip trailing newlines, cap to HELLO_MAX_BYTES on a + // codepoint boundary), write it to /hello.txt (creating the file + // if absent), set it as the served hello, and return the normalized value. + std::string _setHello(const std::string& raw); + // Read /hello.txt and return its normalized contents (for the + // dashboard's "load" button). Does NOT change the served hello; `existed` + // reports whether the file was present. + std::string _loadHelloFile(bool& existed); + + // ---- L2 handler support ---- + + // ChannelMeter per-tick debit. Looks up the account + // by `payerPfx`; if it exists AND has at least `amount` credits, + // debits and calls cb(true). Otherwise leaves the account alone + // and calls cb(false) — the caller (ChannelMeter) responds by + // closing the channel. + // + // No nonce, no dedup — billing is server-authoritative and the + // tick's idempotency comes from "we already advanced the gauges + // and stored the new lastBilledAt". Posts to logicStrand, callback + // hops to cbExecutor. + void _l2DebitNetworkBill( + const HashPrefix& payerPfx, + int64_t amount, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // Asset-ownership check — looks up the asset at `assetId` and + // returns whether it exists AND its owner prefix matches the + // signer's pubkey prefix. Used by the file handler's /f/ zone + // to gate CREATE. Read-only ledger access. Posts to logicStrand, + // callback hops to cbExecutor. + void _l2CheckAssetOwner( + const minx::Hash& assetId, + const ces::PublicKey& signer, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // The /f// zone owner check: the signer owns the zone iff they own the + // NAME `fname` in key_names (crypto-owned, unsquattable). This is the SOLE + // gate -- no asset gate, so there is never a second, racing source of truth. + void _l2CheckFZoneOwner( + const std::string& fname, + const ces::PublicKey& signer, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // --------------------------------------------------------------------------- + // Program-account primitives + // --------------------------------------------------------------------------- + // + // Files in the L2 file store carry an associated "program account": + // a regular ledger Account in accountStore_, identified by a 32B + // ed25519 public key. This is the unified balance pool that compute + // supervision, Lua-side ces.transfer / ces.authentic_asset_create, and + // file handler fee collection all debit. Inbound transfers (anyone → + // this account) work normally; the program can also sign outbound ops + // with the account's private key (held in the sidecar). + // + // The keypair is allocated once at file CREATE and stored in the + // sidecar (program_pubkey / program_privkey). All running instances of + // the same source file share the same program account. + + // Atomically debit `amount` from the program account at `pubkey` + // on logicStrand_. If the account doesn't exist or balance < + // amount, calls cb(false, currentBalance) and the account is + // unchanged. On success calls cb(true, newBalance). Used by + // every server-mediated outbound flow (compute supervisor, + // Lua transfers, file fee collection). + void _l2DebitProgramAccount( + const minx::Hash& pubkey, + int64_t amount, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // SYNC-BLOCKING variants of program-account ops. + // + // The caller's thread is blocked on a std::future while the work + // runs on logicStrand_. The strand sets the promise and returns; + // the caller wakes up. **Caller MUST NOT be running on + // logicStrand_** (would deadlock). + // + // Sync helper for callers that are not yet async (file handler + // CREDIT/DEBIT, compute supervisor tick). Slated for removal once + // those callers go fully async. + struct ProgramAccountDebitResult { + bool ok; + int64_t newBalance; + }; + ProgramAccountDebitResult _l2DebitProgramAccountSync( + const minx::Hash& pubkey, int64_t amount); + void _l2CreditProgramAccountSync( + const minx::Hash& pubkey, int64_t amount); + // Read-only balance query. Returns the account's balance, or 0 if + // the account doesn't exist (collected by daily maintenance). + int64_t _l2ProgramAccountBalanceSync(const minx::Hash& pubkey); + + // Test hook: register an L2-call handler under `name` (its discriminator = + // first 8 bytes of sha256(name)) so a VM SYS_L2_CALL routes to it. Handlers + // wired via [cesplex_mounts] register automatically in the mount loop; this + // is for unit tests that mount a mock handler directly. + void _testRegisterL2Handler(const std::string& name, CesPlexHandler* handler); + +#ifdef CES_MAIL + // Outbound email (send-only). ces.mail.send from an /s/ extension frames to + // the compute handler, which calls mailDeliver on the CesPlex strand. The + // default logs and drops; a production build wires an SMTP relay here, and + // tests install a recording sink via _testSetMailSink. + struct MailMessage { + std::string to, subject, body; + std::string attachmentName; // empty = no attachment + std::string attachmentData; + }; + void mailDeliver(const MailMessage& m); + void _testSetMailSink(std::function sink); + + // Charge (burn) `price` from `payer` for one outbound mail. Returns false if + // the account can't cover it (the send is denied, nothing burned). Blocking; + // runs the check-and-burn atomically on the logic strand. No payee -- the fee + // is destroyed (anti-spam), never credited. + bool mailChargeSync(const minx::Hash& payer, uint64_t price); +#endif // CES_MAIL + + // The L2 verb primitive: run `fn` as one atomic logicStrand_ task with a + // LedgerTxn over the account/asset stores (dedup + debit + credit + reads). + // Blocks until the task completes. The caller must not be on logicStrand_ + // (would deadlock); a verb makes at most one of these per request. + void _l2Transact(const std::function& fn); + + // Program-initiated transfer. Origin is the program's owner pubkey + // (the program acts as its owner — same model as FileHandler::exec). + // Mode = Open (auto-creates destination), CES_NONCELESS, standard + // tx + rent fees out of origin's balance. Wraps transfer() onto + // logicStrand_, hops back to cbExecutor with the result code and + // origin's post-call balance. + void _l2Transfer( + const minx::Hash& originKey, + const minx::Hash& destKey, + uint64_t amount, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // Program-initiated cross-transfer (home server is the originator). + // Wraps crossTransfer() onto logicStrand_ — debits origin here, credits + // the vostro for `destServer`, settles to destKey on that peer. Hops back + // to cbExecutor with the result code and origin's post-call balance. + void _l2CrossTransfer( + const minx::Hash& originKey, + const minx::Hash& destKey, + uint64_t amount, + const std::string& destServer, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // Off-strand asset creation for in-process callers (e.g. L2 service + // handlers running on rpcTaskIO_). Hops onto logicStrand_, runs the + // generic createAsset (origin pays/authorizes; ownerId controls the + // result; balance carries days + flag bits; NONCELESS), then posts + // the result code to cbExecutor. The caller supplies the full + // 210-byte content — the server attaches no meaning to its bytes. + void createAssetAsync( + const minx::Hash& originKey, + const HashPrefix& ownerId, + const minx::Hash& assetId, + const AssetData& content, + uint16_t balance, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // Unsigned account query. Reads balance + nonce + lastXfer* on + // logicStrand_, hops back to cbExecutor with the fields. Used by + // ces.account_read in cesluajitd; identical semantics to + // unsignedQueryAccount but async-and-thread-hopped. + void _l2QueryAccount( + const minx::Hash& accountKey, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // Resolve a pubkey to its key_name (the stored normalized/underscore form), + // off the logic strand via the executor. Empty string if the key has none. + void _l2QueryKeyName( + const minx::Hash& key, + std::function cb, + boost::asio::any_io_executor cbExecutor); + + // ---- Extension funding rate gate. A token + // bucket refilling at cfg_.extFundingPerDay raw units/day, capped at one day's + // worth. The grant is the only thing bounding what /s/ programs spend at remotes + // (the server account is bottomless), so it lives here, never in Lua. + // extFundingGrant RESERVES up to `requested` (returns what it could reserve); + // the caller does the remote transfer and either keeps it or extFundingRefunds + // on failure. Thread-safe (own mutex) — called off logicStrand_. + uint64_t extFundingGrant(uint64_t requested); + void extFundingRefund(uint64_t amount); + uint64_t extFundingRemaining(); // live available, for the gauge + uint64_t extFundingPerDay() const { // live rate (boot value, or last set) + std::lock_guard lk(extFundingMu_); return extFundingRatePerDay_; + } + void extFundingSetPerDay(uint64_t perDay); // operator sets the rate + + // Local extension budget (raw units): each /s/ program account is topped up to + // this on boot and daily. Live-settable from the dashboard. + uint64_t extLocalBudget() const { + return extLocalBudget_.load(std::memory_order_relaxed); + } + void extLocalBudgetSet(uint64_t v) { + extLocalBudget_.store(v, std::memory_order_relaxed); + } + + // Extension panel push sink (webadmin's WebSocket lane). The compute handler + // calls notifyExtPanelPush from rpcTaskIO with an unsolicited mene frame from + // a /s/ extension; the registered handler must be thread-safe (webadmin posts + // onto its own io_context). Unregistered = pushes dropped (pull still works). + void setExtPanelPushHandler( + std::function h) { + std::lock_guard lk(extPanelPushMu_); + extPanelPush_ = std::move(h); + } + void notifyExtPanelPush(const std::string& name, const std::string& frame) { + std::function h; + { + std::lock_guard lk(extPanelPushMu_); + h = extPanelPush_; + } + if (h) h(name, frame); + } + + // CesConfig accessor — the file handler needs the resolved + // feeFile / fileMaxBytes / cesplexFileDir after start() has + // defaulted them. + const CesConfig& _config() const { return cfg_; } + + // Server keypair accessor — L2 handlers (file handler) sign + // their responses with the server's key so clients can store + // receipts that prove "the server committed this state at this + // time." Cost covered by per-op fees. + const ces::KeyPair& _serverKeyPair() const { return serverKeyPair_; } + + // CesPlexHost — CesServer hosts the L2 bus on its rpc port. It signs + // bind replies / responses with its own key and prices the per-channel + // resource usage the bus measures. + const ces::KeyPair& cesplexSigningKey() const override { + return serverKeyPair_; + } + // The bus measures; the server prices. Charges `payer` for this tick's + // resource usage at the live discounted feeNet* rates and closes the + // channel if the payer can't cover it. Defined in server.cpp. + void cesplexReportUsage(const HashPrefix& payer, + const minx::SockAddr& peer, uint32_t channelId, + const CesPlexUsage& usage) override; + + // Metering opt-out: the /ces/peer/1 mesh is never metered (both ends are + // sovereign servers paying from their bottomless accounts). Every other + // protocol is metered. Defined in server.cpp. (The peer-only access gate + // lives in PeerHandler::serve, not here.) + bool cesplexChannelMetered(const std::string& proto) override; + + // Price a CesPlexUsage tick at the live discounted feeNet* rates -> credits + // (0 = free). Shared by cesplexReportUsage and the compute handler's + // per-instance-endpoint billing. + uint64_t priceNetUsage(const CesPlexUsage& usage) const; + + // Debit a pre-priced net bill to `payerPfx` on logicStrand_, no close callback + // (the per-instance endpoint owns its channel). Used by the compute handler to + // bill a child's INBOUND luarpc usage to the caller. Pair with priceNetUsage. + void debitNetworkBill(const HashPrefix& payerPfx, uint64_t amount); + + // The rpcTaskIO executor — L2 handlers that need to post work onto + // the CesPlex / handler strand (e.g. the compute supervisor tick, + // file-deletion interlocks) fetch the executor through this hook. + // Returns a default-constructed executor if the rpc port isn't up + // (no secondary Minx running); handlers should check with .target() + // or equality-against-default before posting. + boost::asio::any_io_executor _rpcTaskIOExecutor() { + if (!rpcMinx_) return boost::asio::any_io_executor{}; + return rpcTaskIO_.get_executor(); + } + + // Test hook: if set, fires on logicStrand_ at the end of completeRpc + // with the status code that would be handed to the followup VM + // program's input. Lets tests observe async failures (timeout, + // oversized response) without depending on a followup's side effects. + std::function _rpcCompletionObserver; + +private: + bool doSnapshot(const char* reason); // strand-only + void _brrInner(const minx::Hash& accountKey, int64_t amount); + void _burnInner(const minx::Hash& accountKey, int64_t amount); + + // Context describing how an individual VM run wires its host lambdas. + // Two code paths use this: the CES_RUN_ASSET handler (undo-log + deferred + // side effects) and the scheduled run handler (direct mutations, stubs). + // A paid VM->L2 call in flight (SYS_L2_CALL). Built by the VmHost l2 sink, + // buffered in executeVmRun, fired on commit as a drainL2Call post. The burn + // already parked `value` in the self-account (undo-logged); settlement moves + // it self->payee (delivered) or self->payer (refund). + // + // DURABILITY GAP (deliberate, documented, not yet closed): this record is + // RAM-ONLY. It lives as a shared_ptr flowing drainL2Call -> completeL2Call + // (and, once handed to builtin:compute, in ComputeHandler::pendingL2_) and is + // never journaled. The two ledger LEGS it coordinates ARE persisted -- the + // burn (committed atomically with the VM run) and the settle (its own + // logicStrand mutation). What is not persisted is the in-flight INTENT + // between them. + // + // Consequence of a HARD crash in the burn..settle window: on restart the burn + // is recovered (payer debited; the self-account resets to its bottomless + // baseline), but this record is gone, so no settle ever fires. The payer is + // neither refunded nor served -- `value` is silently lost. Conservation still + // holds (totalCredits_ recomputes from the loaded accounts and matches the + // sum; nothing is minted), so this is a durability/fairness gap, NOT a ledger + // integrity bug. It is bounded: only calls in-flight at the instant of a hard + // crash, and a graceful shutdown drains cleanly. + // + // To close it: make the pending set a persisted, transactional + // logKV structure. The burn+enqueue already commit atomically; also durably + // record {payerKey, value, target, deadline, callId}. On boot, recovery walks + // the surviving entries and either re-dispatches them or refunds the payer + // (treat unresolved-at-restart as a timeout) -- exactly-once across a crash. + struct PendingL2Call { + uint64_t callId = 0; + CesPlexHandler* handler = nullptr; // resolved at accept (stable ptr) + minx::Hash payerKey; // burned account; the refund target + uint64_t value = 0; // credits burned; settled on delivery + ces::Bytes blob; // provider-ABI payload + // Return sink: exactly one is set. A VM caller resolves through a followup + // run; a client CALL verb resolves by responding on its held request. + minx::Hash followupProgramKey; // resolution run (0 = fire-and-forget) + uint64_t followupBudget = 0; + uint64_t followupAllowance = + std::numeric_limits::max(); + uint32_t followupTag = 0; + std::shared_ptr replyCtx; // client CALL sink (null = VM) + // Synchronous handler refusal code (0 = none): the concrete reason the + // built-in rejected the call (e.g. instance died mid-flight). The channel + // sink answers with it instead of the generic outcome mapping. + uint8_t syncRefuseRc = 0; + bool resolved = false; // logic-strand-guarded idempotency + }; + + struct VmHostSetup { + HashPrefix callerPrefix; + HashPrefix programOwnerPrefix; + + // Hook called before mutating an account. Empty = no undo tracking. + std::function saveAccountFn; + // Hook called before mutating an asset. Empty = no undo tracking. + std::function saveAssetFn; + // Hook called before mutating an alias. Empty = no undo tracking. + std::function saveAliasFn; + + // Deferred side effects. Empty = side effect is discarded. + std::function sendUdpFn; + // Invoked by VmHost::crossTransfer *after* it has pre-validated the peer, + // checked settlement backpressure, and debited the caller + credited the + // peer vostro via the undo log. executeVmRun buffers the dispatch and + // fires it on commit, so an aborted VM cleanly rolls everything back. Both + // run paths (wire and cron) go through executeVmRun, so both are atomic. + // `peerKey` is the resolved peer public key (the settlement client key). + std::function crossTransferFn; + + // Invoked by VmHost::l2call after it resolves the discriminator, checks + // backpressure, and burns `value` (caller -> self-account) via the undo + // log. executeVmRun buffers the PendingL2Call and posts the drain on + // commit, so an aborted VM rolls the burn back and never dispatches. + // Empty = syscall disabled. + std::function l2CallFn; + + // Invoked by SYS_SCHEDULE. Enqueues a future VM run paid by `callerPrefix` + // and returns the schedule rc (CES_OK / QUEUE_FULL). executeVmRun records + // the enqueue in the undo log so a VM abort rolls it back — an aborted run + // must not leave a live scheduled run behind. Empty = syscall disabled. + std::function scheduleFn; + + // Invoked by SYS_SCHEDULE_ALIAS; same contract as scheduleFn with an + // alias id target. Empty = syscall disabled. + std::function scheduleAliasFn; + + // Invoked by VmHost::creditDest when a program's SYS_TRANSFER credits an + // account: lets executeVmRun record a deferred XFER_VM hook to fire after + // the run commits (a program transfer cannot be gated; the hook is a + // watch). Empty = no hooks recorded. + std::function creditHookFn; + + // true = real signature verification; false = always return false. + bool enableVerifySig = false; + + // Per-run cap on caller-account debits inside the VM (transfers, asset + // purchases, protocol fees). UINT64_MAX = no enforcement. Both run paths + // forward a per-run cap: the wire field on CES_RUN_ASSET, or the scheduled + // run's carried-over allowance. + uint64_t allowance = std::numeric_limits::max(); + }; + + // Production CesVMHost implementation. Defined in server.cpp; both VM + // run paths (CES_RUN_ASSET dispatch and executeScheduledRun) construct + // one inline as `VmHost vmHost(*this, setup);`, then assign callerKey / + // selfAssetKey / programOwner / input before calling vm.execute(). + class VmHost; + + // CES_RUN_ASSET dispatch. Invoked on logicStrand_ after dispatchSigned + // has verified the wire signature. Materializes the program asset, + // opens an undo-log + deferred-effects context, runs the VM, and + // commits or reverts. Factored out of the incomingMessage switch to + // keep that function readable. + void handleRunAsset(const CesRunAsset& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg); + + // CES_RUN_ALIAS dispatch: public invocation of an ALIAS_OP_INLINE_PROGRAM + // cell. Caller pays gas; the run gets self = 0 and programOwner = the + // cell's owner (consented code). Same dedup/scheduling shape as + // handleRunAsset. + void handleRunAlias(const CesRunAlias& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg); + + // CES_GOSSIP: sink-or-dedup, collect the sender's budget (leg 2), skim, fan + // out, ack the charge (leg 1), deliver to local programs. + void handleGossip(const CesGossip& req, const SockAddr& addr, + const MinxMessage& msg); + + // A planned fan-out: one re-signed gossip per leg carrying that leg's + // allocation as its budget. `fanned` = sum of allocations. + struct FanoutLeg { std::string address; minx::Hash key; uint64_t alloc; }; + struct FanoutPlan { std::vector legs; uint64_t fanned = 0; }; + // Plan the fan-out of `budget` over a random gossipFanoutDegree subset of + // reachable peers (!= exclude), each capped by reserve and disturbance. + // Any-thread (peer table mutex). The unallocated remainder is not charged. + FanoutPlan computeGossipFanout(uint64_t budget, const Hash& excludeKey); + // Dispatch a planned fan-out via CesClientAsync, wiring the leg-1 credit + // callbacks. MUST run on logicStrand_ (touches settlementClients_). + void dispatchGossipFanout(const Hash& authorId, const Hash& msgId, + const Hash& dest, const ces::Bytes& msg, + const FanoutPlan& plan); + // Deliver a gossip to local compute programs (on_gossip). Hops onto + // rpcTaskIO_ (the compute supervisor thread). Called for received and + // self-originated gossip, so local programs see this node's own messages. + void deliverGossipLocal(const Hash& author, const Hash& sender, + const Hash& msgId, const Hash& dest, + const ces::Bytes& msg); + + // NONCELESS resolution, shared by the two time-boxed escape-hatch ops + // (CES_OPEN_TRANSFER and CES_RUN_ASSET). Validates the dedup time window + + // replay, then resolves the server-assigned nonce into `outNonce`. A non- + // NONCELESS reqNonce short-circuits to Proceed with outNonce = reqNonce. + // Callers map the verdict to their own reply shape. + // + // Dedup is CHECK-ONLY here: on Proceed it returns the op's sig-hash in + // `outSigHash` (0 for non-NONCELESS), and the caller must `recordDedup` it + // ONLY after the op commits a ledger event. This keys dedup on the committed + // event, not the request — a failed op records nothing and stays retryable. + enum class NoncelessResult { Proceed, Stale, Duplicate }; + NoncelessResult resolveNonceless(uint64_t time, const Signature& sig, + const HashPrefix& originPrefix, + uint32_t reqNonce, uint32_t& outNonce, + uint64_t& outSigHash); + + // The neutral VM-execution transaction core. Both run paths (CES_RUN_ASSET + // dispatch and executeScheduledRun) call this with the gas budget already + // debited from the caller. It owns the undo log, the deferred side effects, + // VM execution, commit-or-revert, the refund of unused budget, and the + // durability flush — so a scheduled run is atomic exactly like a wire run. + // Each caller keeps only what genuinely differs: gas reservation (nonce vs + // prepaid), program-not-found policy, and the after-step (signed reply vs + // schedule followup). + struct VmRunRequest { + HashPrefix callerPrefix; + minx::Hash callerKey; // full pubkey, preloaded into VM io + minx::Hash selfAssetKey; // the program's own asset key + HashPrefix programOwnerPrefix; + ces::Bytes code; // program bytecode + ces::Bytes input; + uint64_t budget = 0; // already debited from the caller + uint64_t allowance = std::numeric_limits::max(); + uint64_t gasMult = 0; // discounted (wire) or raw (cron) + bool enableVerifySig = false; + // Free gas that was NOT debited from anyone (the account-hook grant). + // executeVmRun must then NOT refund the unused remainder to the caller, + // which would mint. Undo-log rollback of the run's own effects is + // unaffected. See runAccountHook. + bool freeBudget = false; + // Preloaded into io[CESVM_IO_INVOKE_KIND] (a CesVMInvoke value): which + // entry path started this run. Default INVOKE_DIRECT. + uint64_t invokeKind = INVOKE_DIRECT; + // SYS_REFILL cap: max gas the run may draw from the caller account past + // `budget` (0 = refill disabled). Used by account hooks. + uint64_t refillCeiling = 0; + }; + struct VmRunResult { + uint8_t rcode = 0; + uint64_t vmError = 0; + uint64_t budgetUsed = 0; + uint64_t allowanceUsed = 0; + ces::Bytes output; + }; + VmRunResult executeVmRun(const VmRunRequest& req); + + // Run an account's CESVM trigger (account hook) on already-resolved code. + // Runs on the free grant (minimum compute, CESVM_HOOK_GRANT_*) with the + // hooked account as caller, allowance 0 (no caller spend), and an event + // descriptor in io[INPUT]. `selfAssetKey` is the trigger asset for pointer + // hooks or all-zero for inline code; `programOwnerPrefix` is the consenting + // principal (the hooked account for inline / owned-trigger WATCHES; empty + // for every GATE — purity — and for foreign immutable code). Returns true = + // accept (clean TERM), false = reject (abort/fault/out-of-gas). Must run on + // logicStrand_; callers must not hold an ActiveAccount/ActiveAsset handle + // across it (the run may rehash the maps). + bool runAccountHook(const minx::Hash& hookedKey, ces::Bytes code, + const minx::Hash& selfAssetKey, + const HashPrefix& programOwnerPrefix, + uint64_t invokeKind, + const minx::Hash& counterpartyKey, uint64_t amount, + int64_t balance, uint64_t refillCeiling); + // Fire `accountKey`'s hook iff it exists, matches `wantOp`'s CLASS (gate or + // watch, pointer or inline form), and the account is a real positive-balance + // match for the key. Returns true = accept / no hook (proceed), false = + // reject (GATE only; callers of a WATCH ignore the result). Read-only peek: + // holds no ActiveAccount/alias handle across the run. Resolves the code + // (trigger asset or inline area), refill ceiling, self, and principal. + bool fireAccountHook(const minx::Hash& accountKey, uint16_t wantOp, + uint64_t invokeKind, const minx::Hash& counterpartyKey, + uint64_t amount); + // The account-hook free grant = MINIMUM COMPUTE at the live rates + // (CESVM_HOOK_GRANT_*). Doubles as the inbound dust floor: a transfer worth + // less than the cost of screening it fires no hook ("thanks for the money"). + // One quantity, so the grant a screen gets and the floor it must clear are + // provably identical. + uint64_t hookFreeGrant(); + // Re-entrancy guard: a hook never fires from inside a hook. logicStrand_-only, + // so a plain bool suffices. + bool inHook_ = false; + + CesConfig cfg_; + + std::unique_ptr minx_; + IOContext netIO_, taskIO_; + std::thread netIOThread_; + std::vector taskIOThreads_; + + // Second Minx instance, bound to a dedicated UDP port for the SYS_RPC + // syscall. Only constructed if cfg_.rpcPort != 0 — otherwise rpcMinx_ + // stays null and the whole RPC path is disabled. The listener is a + // value member (always present, always no-op) so we don't need a + // conditional pointer owning it. IO contexts and threads are likewise + // always present but only `run()` when the second Minx is active. + CesRpcListener rpcListener_; + // Rudp::Listener for rpcRudp_ — value member (always present), bound + // to the Rudp at construction. Forwards onSend to rpcMinx_ and + // onAccept to cesplex_ when the latter is alive. + CesRpcRudpListener rpcRudpListener_{this}; + std::unique_ptr rpcMinx_; + IOContext rpcNetIO_, rpcTaskIO_; + std::thread rpcNetIOThread_; + std::thread rpcTaskIOThread_; + uint16_t rpcBoundPort_ = 0; + + // RUDP transport layered on rpcMinx_'s EXTENSION lane. Drives + // outbound SYS_RPC calls (inbound handshakes are accepted at the + // Rudp level but drop at the session demux when no session is + // registered for the (peer, channel_id) pair). All Rudp access is + // serialized on rpcTaskIO_'s single thread: the extension handler + // (which calls Rudp::onPacket), the tick timer (which calls + // Rudp::tick), and any push() initiated by an RpcSession. Only + // constructed when cfg_.rpcPort != 0 — otherwise stays null. + std::unique_ptr rpcRudp_; + std::shared_ptr rpcTickTimer_; + + // RUDP-tier billing. Constructed alongside rpcRudp_ when rpc_port is + // enabled. Bills per-channel byte/memory/age deltas against the bound + // payer each tick; runs observability-only (delta tracking, no debits + // or evictions) when the feeNet* rates are 0, the default. + std::unique_ptr channelMeter_; + + // CesPlex — the L2 protocol multiplexer. Lives on rpcTaskIO_, + // owns inbound-channel dispatch for the secondary port. Handed the + // mount map from cfg_.cesplexMounts at ctor. Null only when + // cfg_.rpcPort == 0 (no second Minx); otherwise constructed whenever the + // plex port is up, even with an empty mount map (inbound binds then NACK). + // All callbacks on rpcRudp_ that CesPlex installs run on rpcTaskIO_. + // + // Forward-declared: defined in ces/cesplex/mux.h — only included where + // needed (server.cpp) to avoid pulling the rudp headers into every + // consumer of server.h. + std::unique_ptr cesplex_; + + // builtin:peer / lua / file / compute handler instances, owned per-server. + // Each is created only when its protocol is wired in [cesplex_mounts] (so any + // may be null), mounted into cesplex_, destroyed on stop(). Per-server + // objects (migrated off process-globals). + std::unique_ptr peerHandler_; + std::unique_ptr luaHandler_; + std::unique_ptr fileHandler_; + std::unique_ptr computeHandler_; +#ifdef CES_MAIL + std::unique_ptr mailHandler_; +#endif + + // SYS_RPC dispatcher state. queueRpc runs on the logic strand + // (validates the file, materializes request bytes, signs the + // envelope); executeRpc runs on rpcTaskIO_ (allocates a channel, + // constructs an RpcSession, kicks off async_write); completeRpc + // runs on the logic strand (writes response into the same file, + // schedules the followup). + std::atomic rpcPendingCount_{0}; + + // RpcSession holds per-call RudpStream + async state. Defined + // out-of-line as a nested class in server.cpp — the header only + // needs a forward declaration because all uses here are via + // shared_ptr members, and the destructor of this class runs in + // server.cpp where RpcSession is complete. + class RpcSession; + std::map, + std::shared_ptr> rpcSessions_; // rpcTaskIO_ only + + // Host-callback entry point. Runs on the logic strand. Validates + // the destination, the request file's auth + size, materializes + // the request bytes, builds the signed envelope, and posts to + // rpcTaskIO_. Returns CES_OK on successful queue, error code + // otherwise. + struct PendingRpc { + std::string host; // destination host / IP (ASCII) + uint16_t port = 0; + minx::Hash fileHeadKey; // the file — request source AND + // response destination + minx::Hash followupProgramKey; // scheduled on completion + minx::Hash selfAssetKey; // running VM program's boot asset + HashPrefix callerPrefix; // account paying for the followup + HashPrefix programOwnerPrefix{}; // owner of the boot asset + uint64_t followupBudget = 0; + uint64_t followupAllowance = + std::numeric_limits::max(); + uint32_t followupInputTag = 0; + + // Filled in by queueRpc before the call is posted to rpcTaskIO_. + // Just the raw request body: the signed bind handshake happens once + // on the channel, so the body itself is unwrapped (the bound channel + // authenticates the sender — no per-rpc envelope). + ces::Bytes requestBody; + }; + + uint8_t queueRpc(PendingRpc pending); + void executeRpc(std::shared_ptr pending); + void completeRpc(std::shared_ptr pending, + uint8_t errorCode, + ces::Bytes responseBody); + + // SYS_L2_CALL dispatcher trio (mirrors the RPC trio's threading). The + // enqueue is deferred: the VmHost l2 sink buffers a PendingL2Call and + // executeVmRun posts the drain on commit, so an aborted run rolls the burn + // back and never dispatches. drainL2Call runs on rpcTaskIO_ (hand to the + // built-in); completeL2Call on the logic strand (settle once, via a + // self-account transfer). l2Registry_ maps the 8-byte discriminator + // (sha256 of the built-in mount name, first 8 bytes, read big-endian) to + // the mounted handler; built once in the mount loop. PendingL2Call is + // declared above (VmHostSetup's l2 sink carries it). + void drainL2Call(std::shared_ptr pending); + void completeL2Call(std::shared_ptr pending, + L2CallOutcome outcome, const minx::Hash& payee, + const ces::Bytes& reply); + + std::unordered_map l2Registry_; + std::atomic l2PendingCount_{0}; + uint64_t l2NextCallId_ = 1; // logic strand only +#ifdef CES_MAIL + std::function mailSink_; // relay / test override +#endif + + boost::asio::strand logicStrand_; + std::thread verifyPoWThread_; + std::atomic receiving_{false}; + std::atomic running_{false}; + uint16_t boundPort_ = 0; + std::atomic paused_{false}; + std::atomic txCount_{0}; + + KeyPair serverKeyPair_; + + // Extension funding token bucket (see extFundingGrant). ratePerDay_ is the live + // rate (cfg_.extFundingPerDay copied at start, then operator-settable); + // allowance_ in raw credit units; lastUs_ a steady-clock micros stamp. Mutex is + // mutable so the const extFundingPerDay() accessor can lock. + std::atomic extLocalBudget_{0}; // seeded from cfg_.extLocalBudget at boot + mutable std::mutex extFundingMu_; + + // Extension panel push sink (see setExtPanelPushHandler). + std::mutex extPanelPushMu_; + std::function extPanelPush_; + uint64_t extFundingRatePerDay_ = 0; + double extFundingAllowance_ = 0.0; + int64_t extFundingLastUs_ = 0; + void extFundingRefillLocked(); // refill; caller holds the mutex + + // Operator hello banner (see _getHello/_setHello). Guarded by its own + // mutex: read on the logic strand by queryServerInfo, written by the + // dashboard's web thread. + std::mutex helloMutex_; + std::string helloMessage_; + void loadHelloFromFile(); // boot seed from /hello.txt + + std::atomic lastTimePoWQueueSizeUpdated_ = 0; + std::atomic powQueueSize_ = 0; + + // 1Hz metrics pulse. Drives every BucketGauge roll and every PointGauge + // resample on a single shared timer (see metricsTick). + BucketGauge<60> tpsGauge_; + BucketGauge<60> l1cpuGauge_; // strand busy_ns per second + BucketGauge<60> netRxTxGauge_; // /proc/net/dev rx+tx delta per second + std::atomic tpsCurrent_{0}; // cached average for getters + PointGauge l1cpuBp_; + PointGauge l2cpuBp_; + PointGauge l1memacBp_; + PointGauge l1memasBp_; + PointGauge l2memBp_; + PointGauge netBp_; + + // Net sampler state (touched only on taskIO_ from metricsTick). + uint64_t lastNetCumulative_ = 0; + bool lastNetCumulativeValid_ = false; + double netPeakBps_ = 0.0; + + // Per-FeeKind discount multiplier (basis points 0..10000). Refreshed + // by metricsTick from the gauge each FeeKind is mapped to. Reads are + // lock-free from any thread; the writer is the metrics tick. + std::array, kFeeKindCount> feeMult_; + + std::shared_ptr metricsTimer_; + std::shared_ptr dailyTimer_; + + static constexpr uint64_t SNAPSHOT_COOLDOWN_SECS = 30; + uint64_t lastSnapshotTime_ = 0; // epoch seconds, strand-only access + + Accounts accounts_; + Assets assets_; + Aliases aliases_; + KeyNames keyNames_; + + // Scheduled (delayed) runAsset entries — RAM only, not persisted. + struct ScheduledRun { + HashPrefix callerPrefix; // account that pays for gas + minx::Hash assetId; // program to run (aliasId == 0) + uint64_t budget; // gas budget + uint64_t allowance = // per-run caller-debit cap (default = none) + std::numeric_limits::max(); + ces::Bytes input; // input data + // true = budget already debited at submission (future-time wire + // CES_RUN_ASSET); executeScheduledRun must not debit again. + bool prepaid = false; + // Discriminant: 0 = asset run (assetId is live); nonzero = run this + // alias's inline program (assetId ignored; op re-checked at fire time). + uint32_t aliasId = 0; + // Invoke kind the fired run self-describes as (INVOKE_SCHEDULED for cron; + // INVOKE_L2_RETURN for a SYS_L2_CALL resolution followup). + uint64_t invokeKind = INVOKE_SCHEDULED; + }; + // Map key: (timeUs, seq) tuple. `timeUs` sorts entries by their firing + // deadline; `seq` is a monotonic tiebreaker so two runs scheduled for + // the same microsecond preserve insertion order (FIFO within the slot). + struct ScheduleKey { + uint64_t timeUs; + uint64_t seq; + auto operator<=>(const ScheduleKey&) const = default; + }; + std::map scheduledRuns_; + uint64_t scheduledSeq_ = 0; // monotonic sequence for insertion order + std::shared_ptr cronTimer_; + + // scheduleRun's core, plus the inserted ScheduleKey so a VM transaction's + // undo log can erase the enqueue on abort. scheduleRun() is the thin wrapper + // that discards the key (for callers with no rollback context). + uint8_t scheduleRunUndoable(const HashPrefix& callerPrefix, + const minx::Hash& assetId, uint64_t budget, + uint64_t allowance, const ces::Bytes& input, + uint64_t time_us, bool prepaid, + ScheduleKey& outKey, uint32_t aliasId = 0, + uint64_t invokeKind = INVOKE_SCHEDULED); + + // Presence cache: tracks last known address of authenticated clients + // for unsolicited push (send()). Updated on every dispatchSigned. + BucketCache presence_; + + // Gossip dedup record: who we charged for a msgId and how much (leg 2). A + // retried copy from the same sender re-acks the same amount (idempotent + // leg 1); a cycle copy from a different peer (never charged) acks 0. + struct GossipCharge { + HashPrefix sender{}; + uint64_t paid = 0; + }; + // Gossip dedup: seen message-ids (first 8 bytes) -> charge record. Bounded: + // two rotating buckets of this size each (~2x entries live across a flip), + // ~6 MB total at this capacity. Never grows. + static constexpr uint64_t GOSSIP_SEEN_CAPACITY = 65536; + BucketCache gossipSeen_{GOSSIP_SEEN_CAPACITY}; + std::atomic gossipRecvCount_{0}; + std::atomic gossipSinkCount_{0}; + // dest-prefix -> (full pubkey, refcount). logicStrand_-owned (see + // registerSinkTarget). Seeded at construction with this server's own pubkey. + std::unordered_map> localSinkKeys_; + + // Reverse of presence_: addr → HashPrefix. Populated alongside + // presence_.put and used by CES_APP_COMPUTE_MSG dispatch to stamp + // a real sender_pfx on inbound program-bound messages. Guarded + // by its own mutex since incomingApplication fires on taskIO_ + // while the compute handler reads on rpcTaskIO_. Entries may + // outlive the presence cache's rotations (stale addresses never + // evict); lookups that matter are validated by checking + // presence_.get(prefix) == addr before trusting the result. + std::mutex presenceReverseMutex_; + std::map presenceReverse_; + + struct PendingReply { + minx::SockAddr addr; + minx::MinxMessage msg; + std::chrono::steady_clock::time_point triggerTime; + }; + + std::mutex replyMutex_; + std::deque replyQueueFast_; + std::deque replyQueueSlow_; + std::shared_ptr replyTimer_; + + void checkPause(); + + void replyStartTimer(); + void replyTick(const boost::system::error_code& ec); + + void tpsInc(); + void metricsTick(const boost::system::error_code& ec); + void metricsStartTimer(); + // Body of one metrics tick. Always runs on taskIO_ — both the timer + // and runMetricsTickOnce serialize through that executor. + void metricsCompute(); + + // /proc parsers used by metricsTick. Linux-only; plain ASCII files. + double readLoadAvg(); // /proc/loadavg field 1, or 0.0 + uint64_t readMemUsedBp(); // (MemTotal-MemAvailable)*10000/MemTotal + uint64_t readNetCumulative(); // /proc/net/dev sum of rx+tx (skip lo:) + + // Wrap a strand-bound handler with chrono so its on-strand wall time + // accumulates into l1cpuGauge_. This is the canonical way to enqueue + // work on logicStrand_ from anywhere in CesServer; the alternative — + // direct boost::asio::post(logicStrand_, ...) — is uninstrumented and + // should be avoided except for the metrics path itself. + // + // Perf note: the wrapper captures [this, fn], so the composed handler is + // larger than a bare post. Captures already near Boost.Asio's ~32 B + // small-object buffer can tip over and heap-allocate per post (~100 ns). + // Currently negligible (strand handlers do microseconds of real work). If a + // profiler flags it, pool-allocate handler frames via + // boost::asio::associated_allocator rather than adding it now. + // Logs a logic-handler exception (definition in server.cpp, where the log + // macros live). noexcept: it is called from a catch and must not re-throw. + void onLogicHandlerThrew(const char* what) noexcept; + + template + void postLogic(F&& f) { + boost::asio::post(logicStrand_, + [this, fn = std::forward(f)]() mutable { + auto t0 = std::chrono::steady_clock::now(); + // A logic handler must never let an exception escape: it would unwind the + // strand out to runGuardedThread, which re-enters run() with no rollback + // and no context. Catch, log, and drop the op; the strand keeps serving. + // Ledger mutations are validate-then-mutate and the VM/hook path + // self-reverts via its undo log, so a caught throw does not leave the + // committed ledger inconsistent on the paths that mutate after validation. + try { + fn(); + } catch (const std::exception& e) { + onLogicHandlerThrew(e.what()); + } catch (...) { + onLogicHandlerThrew(nullptr); + } + auto dt = std::chrono::steady_clock::now() - t0; + l1cpuGauge_.record(static_cast( + std::chrono::duration_cast(dt).count())); + }); + } + + public: + // Gauge readouts (basis points, 0..10000). Readable from any thread. + uint16_t getL1cpuBp() const { return static_cast(l1cpuBp_.load()); } + uint16_t getL2cpuBp() const { return static_cast(l2cpuBp_.load()); } + uint16_t getL1memacBp() const{ return static_cast(l1memacBp_.load()); } + uint16_t getL1memasBp() const{ return static_cast(l1memasBp_.load()); } + uint16_t getL2memBp() const { return static_cast(l2memBp_.load()); } + uint16_t getNetBp() const { return static_cast(netBp_.load()); } + + // Test hook: raw strand busy-ns sum over the active 60s window. + // bp truncates anything below ~6ms, so unit tests assert on this + // directly to detect that postLogic instrumentation is wired. + uint64_t getL1cpuBusyNs() const { return l1cpuGauge_.sum(); } + + // Test hook: run the metrics readout/refresh once, synchronously. + // Equivalent to one timer fire without rescheduling. Lets tests + // exercise gauge → bp → multiplier wiring without sleeping. + void runMetricsTickOnce(); + + // Look up the discount multiplier (bp 0..10000) for a fee kind. + // Pinned to 10000 when cfg_.feeDiscountEnabled is false. + uint16_t getFeeMult(FeeKind k) const { + return feeMult_[static_cast(k)].load( + std::memory_order_relaxed); + } + + // Apply the discount: raw fee × FeeKind's multiplier / 10000. Use + // this at every named-fee debit site instead of billing raw. + uint64_t discountFee(FeeKind k, uint64_t raw) const { + uint64_t bp = getFeeMult(k); + return (raw == 0) ? 0 : (raw * bp / 10000); + } + + // Convenience: resolveFee + flat discount in one. Prepay-days fees + // use attenuatedFundCost separately; this is for one-shot op fees. + int64_t discountedFlatFee(int64_t passedFee, int64_t defaultFee, + FeeKind k) const { + int64_t f = resolveFee(passedFee, defaultFee); + return static_cast(discountFee(k, static_cast(f))); + } + + // Cost of prepaying `daysAdded` days of a per-day fee, given that + // `daysAlreadyHeld` days are already on the cell. The discount + // attenuates linearly to zero over kPrepaidDiscountWindowDays — + // every day at distance D ≥ window from now is full price, every + // day closer than that pays a blended (bp..10000) rate. Prevents + // funding years of cheap rent during idle bp. + uint64_t attenuatedFundCost(FeeKind k, + uint64_t feePerDay, + uint32_t daysAdded, + uint32_t daysAlreadyHeld) const; + private: + + void dailyTaskTick(const boost::system::error_code& ec); + void dailyTaskStartTimer(); + + void cronTick(const boost::system::error_code& ec); + void cronStartTimer(); + bool executeScheduledRun(ScheduledRun& run); // returns false if account gone/broke + void runAutoexec(); // scan assets for autoexec keys, execute on boot + + // One-shot at boot: force the server's own account to exactly the + // TARGET balance. Deeply bottomless yet far below INT64_MAX, so + // deposits and fee receipts can never overflow signed-int64 addition; + // forcing (not just topping up) also heals a stale balance corrupted + // by an older build. The server account is counted in the raw credit + // tally like any other and subtracted out at the stat + // (circulatingCredits()). + // Strand-only access (called via post(logicStrand_, ...)). + void topUpServerAccount(); + + // One-shot at boot, after the asset store has loaded from disk and + // the server account has been topped up. Unconditionally writes the + // canonical bytecode for each shipped `/b/` program over + // whatever asset (if any) sits at sha256("/b/"). The `/b/` + // prefix is reserved by convention for server-deployed bytecode + // programs — owner = server, content = current build's bytecode, + // days = max, no flag bits, price = 0. Squat-resistant: a user who + // races to register the well-known key before this build first ships + // the program loses on the next boot. Idempotent across reboots. + // Strand-only access. + void deployBuiltinVmPrograms(); + + // One-shot at boot, after CesPlex/file/compute handlers have all + // bound: deploy any [extension] /s/.lua sources to the file + // store (if missing) and launch one cesluajitd instance of each. + // Posted onto rpcTaskIO_ — the file deploy uses the + // fileHandlerEnsureServerFile cross-handler primitive; the launch + // uses computeHandlerLaunchInternal. Skipped silently if any + // prereq is missing (compute disabled, file disabled, etc.). + void launchExtensions(); + + void reply(const SockAddr& addr, const MinxMessage& msg); + + template + void sendSignedReply(const SockAddr& addr, const MinxMessage& msg, ResT res); + + template + void sendUnsignedReply(const SockAddr& addr, const MinxMessage& msg, ResT res); + + template + void dispatchSigned(const SockAddr& addr, const MinxMessage& msg, + ReqT req, const Hash& keyField, Fn&& fn, + bool noncelessOk = false); + + int64_t resolveFee(int64_t passedFee, int64_t defaultFee) const; + void checkAutoSnapshot(); + + // -- Peer table -- + struct PeerEntry { + minx::Hash ckey{}; + std::string declaredAddress; + boost::asio::ip::address resolvedIP; + // Full endpoint the peer miner resolved off-strand, so + // getOrCreateSettlementClient never does a blocking getaddrinfo on the logic + // strand. Runtime-only; not persisted (re-resolved on the next probe). + boost::asio::ip::udp::endpoint resolvedEndpoint; + bool resolvedEndpointValid = false; + uint64_t totalInboundPoW = 0; + uint64_t totalOutboundPoW = 0; + int64_t ourBalanceThere = -1; + uint64_t lastInboundTime = 0; + uint64_t lastCheckTime = 0; + bool reachable = false; + bool verified = false; + bool outbound = false; + uint32_t pingFailures = 0; + uint16_t rpcPort = 0; // peer's CesPlex rpc port, learned at probe time + // Grief: raised by extensions (ces.grief_peer, amount = confidence) when a peer + // misbehaves at L2. The peer maintenance pass decays it toward 0 (PEER_GRIEF_DECAY_SECS + // per point) while the peer is well-behaved, so a one-off does not accumulate; only a + // sustained pattern outruns the decay. At peerGriefBanThreshold the C++ side bans the + // peer until bannedUntil: hidden from ces.peers(), refused at the bind gate, not + // dialed, not re-added. A banned peer's grief never decays; when the ban expires the + // maintenance pass removes the peer outright (it re-enters fresh if it returns). + uint32_t grief = 0; + uint64_t bannedUntil = 0; // unix seconds; 0 = not banned + uint64_t lastDecayTime = 0; // unix seconds; decay clock, reset on each grief increment + }; + + std::mutex peerTableMutex_; + std::vector peerTable_; + void upsertPeer(const minx::Hash& ckey, const std::string& address, + uint64_t inboundCredit); + + // -- Auto-nonce dedup constants -- + static constexpr uint64_t DEDUP_WINDOW_US = CES_NONCELESS_DEDUP_WINDOW_US; + static constexpr uint64_t DEDUP_FUTURE_DRIFT_US = 300ULL * 1000000; + + // Peer-table caps, driven by the runtime-settable maxPeers_ (seeded from cfg_.maxPeers). + // The persisted/exposed cap is maxPeers; the in-mem hard cap is 3x, headroom so + // grief-banned tombstones (held until their ban expires) do not crowd out live peers. + size_t maxPersistedPeers() const { return maxPeers_.load(); } + size_t maxInmemPeers() const { return 3 * maxPeers_.load(); } + static constexpr uint32_t PEER_EVICTION_THRESHOLD = 5000; + + // Peer management + void loadPeerData(); + void savePeerData(); + std::thread peerMinerThread_; + std::atomic peerMinerRunning_{false}; + // Serializes ensurePeerMinerStarted()'s spawn against stop()'s join decision. + std::mutex peerMinerLifecycleMutex_; + void peerMinerLoop(); + + // Runtime peer-credit target. Seeded from cfg_.peerTarget in the ctor and + // read by the miner each cycle; the dashboard can change it live (CesConfig + // is copyable, so its field can't itself be atomic). `peerMinerRunning_` + // doubles as the spawn guard: ensurePeerMinerStarted() compare-exchanges it + // so the miner thread is created exactly once even if peering is turned on + // at runtime from a server that booted with target 0. + std::atomic peerTarget_{0}; + std::atomic maxPeers_{DEFAULT_MAX_PEERS}; + void ensurePeerMinerStarted(); + + // Peer miner heartbeat — unix seconds of the last completed cycle and a + // cumulative cycle count, surfaced to the dashboard so the operator can see + // that the otherwise-opaque peering thread is alive and working. + std::atomic lastPeerMinerCycle_{0}; + std::atomic peerMinerCycles_{0}; + // Live "actively mining" state (vs. just looping/probing) for the dashboard — + // set only around the mineOnce() call. Guarded by its own mutex (the string + // can't be atomic); read off-thread by the web layer via _peerMinerActivity(). + mutable std::mutex peerMinerActivityMutex_; + bool peerMinerMining_ = false; + std::string peerMinerMiningPeer_; + uint8_t peerMinerMiningDiff_ = 0; + uint64_t peerMinerMiningStartSecs_ = 0; // when the current solve began + double peerMinerHashRate_ = 0.0; // EMA H/s from completed solves + uint64_t peerMinerHashesTried_ = 0; // live hashes tried in current solve + uint64_t peerMinerExpectedHashes_ = 0; // 2^difficulty for current solve + + // Auto-nonce dedup table + std::mutex dedupMutex_; + std::unordered_set dedupCurrent_; + std::unordered_set dedupOlder_; + uint64_t dedupBaseTime_ = 0; + // Atomic check+insert — used where seeing the request IS the dedupable + // event (CesPlex per-op bind dedup). + bool checkAndInsertDedup(uint64_t sigHash, uint64_t epochNow = 0); + // Split check / record — used by NONCELESS ops, which must record the + // dedup only after the op commits a ledger event (so a failed op stays + // retryable). See resolveNonceless / recordDedup call sites. + bool isDuplicateDedup(uint64_t sigHash, uint64_t epochNow = 0); + void recordDedup(uint64_t sigHash, uint64_t epochNow = 0); + void rotateDedupLocked(uint64_t epochNow); // caller holds dedupMutex_ + + // Async cross-transfer settlement + IOContext settlementIO_; + std::unique_ptr> + settlementWorkGuard_; + std::thread settlementThread_; + std::unordered_map> + settlementClients_; + CesClientAsync* getOrCreateSettlementClient(const std::string& address, + const minx::Hash& peerKey); + +}; + +} // namespace ces \ No newline at end of file diff --git a/vendor/ces/include/ces/types.h b/vendor/ces/include/ces/types.h new file mode 100644 index 0000000..484091f --- /dev/null +++ b/vendor/ces/include/ces/types.h @@ -0,0 +1,465 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ces { + +/** + * Server incoming message codes. + */ +enum op_code_t : uint8_t { + CES_TRANSFER = 0x00, // safe: fail if dest not found + CES_BULK_TRANSFER = 0x01, // always auto-creates dest + CES_QUERY_ACCOUNT = 0x02, + CES_UNSIGNED_QUERY_ACCOUNT = 0x03, + CES_UNSIGNED_QUERY_SOLUTION = 0x04, + CES_CREATE_ASSET = 0x05, + CES_UPDATE_ASSET = 0x06, + CES_UPDATE_ASSET_META = 0x07, + CES_UPDATE_ASSET_FAST = 0x08, + CES_FUND_ASSET = 0x09, + CES_BUY_ASSET = 0x0a, + CES_GIVE_ASSET = 0x0b, + CES_QUERY_ASSET = 0x0c, + CES_UNSIGNED_QUERY_ASSET = 0x0d, + CES_QUERY_SERVER_INFO = 0x0f, + CES_OPEN_TRANSFER = 0x10, // auto-create dest if not found + CES_CREATE_PAYMENT = 0x11, // create payment account + CES_CROSS_TRANSFER = 0x12, // inter-server transfer via peer + CES_RUN_ASSET = 0x13, // execute asset bytecode (CesVM) + CES_QUERY_PEER_INFO = 0x14, // unsigned: peer-table slot lookup (discovery) + CES_GOSSIP = 0x15, // signed: flood/route a message across the server mesh + CES_SET_ALIAS = 0x16, // signed: set this account's alias (create on first use, edit in place after) + CES_DELETE_ALIAS = 0x17, // signed: erase this account's alias + CES_QUERY_ALIAS = 0x18, // unsigned: read an alias by id + CES_SET_ASSET_OWNER_PAYS = 0x19, // signed: toggle an asset's owner-pays (auto-fund) bit + CES_CREATE_ASSET_RANGE = 0x1a, // signed: atomically create N account-owned cells at a prefix + CES_RUN_ALIAS = 0x1b, // signed: execute an alias's inline program (CesVM) + CES_REGISTER_KEYNAME = 0x1c, // signed: bind the signer's key to a name (key IS the owner) + CES_CLEAR_KEYNAME = 0x1d, // signed: erase the signer's key_name + CES_QUERY_KEYNAME = 0x1e, // unsigned: pubkey -> name + CES_QUERY_KEYNAME_BY_NAME = 0x1f // unsigned: name -> pubkey +}; + +/** + * APPLICATION-lane opcodes. These ride MINX's APPLICATION path (not + * the signed-op path). Clients with an established MINX session may + * push these directly; servers dispatch by opcode in + * CesServer::incomingApplication. Values are in the 0x80+ range to + * stay out of the signed-op op_code_t / result_code_t space. + */ +enum app_code_t : uint8_t { + // Client↔program message (see L2 compute §4.2). Client→server: + // target program file_key prefix + opaque payload. Server→client: + // source program file_key prefix + opaque payload. Payload cap + // 1 KB. Discarded silently if no program instance matches the + // prefix. + CES_APP_COMPUTE_MSG = 0x81 +}; + +/** + * Server outgoing message codes. + */ +enum result_code_t : uint8_t { + CES_TRANSFER_RESULT = 0x00, + CES_BULK_TRANSFER_RESULT = 0x01, + CES_QUERY_ACCOUNT_RESULT = 0x02, + CES_UNSIGNED_QUERY_ACCOUNT_RESULT = 0x03, + CES_UNSIGNED_QUERY_SOLUTION_RESULT = 0x04, + CES_CREATE_ASSET_RESULT = 0x05, + CES_UPDATE_ASSET_RESULT = 0x06, + CES_UPDATE_ASSET_META_RESULT = 0x07, + CES_UPDATE_ASSET_FAST_RESULT = 0x08, + CES_FUND_ASSET_RESULT = 0x09, + CES_BUY_ASSET_RESULT = 0x0a, + CES_GIVE_ASSET_RESULT = 0x0b, + CES_QUERY_ASSET_RESULT = 0x0c, + CES_UNSIGNED_QUERY_ASSET_RESULT = 0x0d, + CES_QUERY_SERVER_INFO_RESULT = 0x0f, + CES_OPEN_TRANSFER_RESULT = 0x10, + CES_CREATE_PAYMENT_RESULT = 0x11, + CES_CROSS_TRANSFER_RESULT = 0x12, + CES_RUN_ASSET_RESULT = 0x13, + CES_QUERY_PEER_INFO_RESULT = 0x14, + CES_GOSSIP_RESULT = 0x15, + CES_SET_ALIAS_RESULT = 0x16, + CES_DELETE_ALIAS_RESULT = 0x17, + CES_QUERY_ALIAS_RESULT = 0x18, + CES_SET_ASSET_OWNER_PAYS_RESULT = 0x19, + CES_CREATE_ASSET_RANGE_RESULT = 0x1a, + CES_RUN_ALIAS_RESULT = 0x1b, + CES_REGISTER_KEYNAME_RESULT = 0x1c, + CES_CLEAR_KEYNAME_RESULT = 0x1d, + CES_QUERY_KEYNAME_RESULT = 0x1e, + CES_QUERY_KEYNAME_BY_NAME_RESULT = 0x1f, + // Request is MINX_PROVE_WORK (no CES opcode for the request side) + CES_PROVE_WORK_RESULT = 0x80 +}; + +/** + * Operation error codes. + */ +enum error_code_t : uint8_t { + CES_OK = 0x00, + CES_ERROR_ORIGIN_NOT_FOUND = 0x01, + CES_ERROR_WRONG_NONCE = 0x02, + CES_ERROR_INSUFFICIENT_BALANCE = 0x03, + CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE = 0x04, + CES_ERROR_INVALID_TARGET_ACCOUNT = 0x05, + CES_ERROR_WRONG_TARGET_ACCOUNT = 0x06, + CES_ERROR_WRONG_PAYMENT_AMOUNT = 0x07, + CES_ERROR_ASSET_EXISTS = 0x08, + CES_ERROR_ASSET_NOT_FOUND = 0x09, + CES_ERROR_NOT_OWNER = 0x0a, + CES_ERROR_NOT_FOR_SALE = 0x0b, + CES_ERROR_INSUFFICIENT_PAYMENT = 0x0c, + CES_ERROR_TIMEOUT = 0x0d, + CES_ERROR_INTERNAL = 0x0e, + CES_ERROR_TARGET_NOT_FOUND = 0x0f, + CES_ERROR_UNKNOWN_PEER = 0x10, + CES_ERROR_QUEUE_FULL = 0x11, + CES_ERROR_VM_FAILED = 0x12, + CES_ERROR_DISABLED = 0x13, + CES_ERROR_ALLOWANCE_EXCEEDED = 0x14, + // CesPlex protocol-select was NACKed by the target. Raised by the + // outbound SYS_RPC path when the remote doesn't mount the rpc + // protocol (e.g. a plain CES server talking to another plain CES + // server — rpc is an outbound-only capability on CES). Distinct + // from ERROR_INTERNAL because it's a clean protocol-level refusal, + // not a wire or I/O failure. + CES_ERROR_PROTO_REJECTED = 0x15, + // CesPlex file handler — GET/WRITE/etc. against a name that doesn't exist. + CES_ERROR_FILE_NOT_FOUND = 0x16, + // CesPlex file handler — CREATE on a name that already exists. + CES_ERROR_FILE_EXISTS = 0x17, + // CesPlex file handler — name fails the §1 validation rules + // (too long, too deep, bad component, non-canonical, etc.). + CES_ERROR_BAD_NAME = 0x18, + // CesPlex file handler — CREATE would collide with an existing + // directory prefix, or a file exists where a directory is needed. + CES_ERROR_PATH_CONFLICT = 0x19, + // File storage — CREATE would push total_bytes past + // cesFileStoreMaxBytes. Feature is on but at capacity. + CES_ERROR_STORE_FULL = 0x1a, + // Compute feature — feature off (compute_max_instances == 0). + CES_ERROR_COMPUTE_DISABLED = 0x1b, + // Compute feature — builtin:file prerequisite not registered at bind time. + CES_ERROR_COMPUTE_NO_FILE_HANDLER = 0x1c, + // Compute feature — source file's file_balance too low to cover the + // 15-min upfront deposit at LAUNCH. + CES_ERROR_COMPUTE_FUND_TOO_LOW = 0x1d, + // Compute feature — KILL/STAT referenced an pid that isn't + // running (already exited, never existed, wrong owner). + CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND = 0x1e, + // Compute feature — LAUNCH would exceed compute_max_instances. + CES_ERROR_COMPUTE_MAX_INSTANCES = 0x1f, + // /ces/lua/1 — ATTACH against an instance whose accept gate is + // closed (the program hasn't called ces.conn.set_listener). + CES_ERROR_NOT_LISTENING = 0x20, + // Asset is marked IMMUTABLE — its content cannot be modified. + // updateAsset / updateAssetFast / RPC writes against an immutable + // asset return this code. Owner, price, and funding are still + // mutable; this only seals the 210-byte content. + CES_ERROR_IMMUTABLE = 0x21, + // Caller-supplied input violates a syscall/handler precondition + // that's *not* captured by a more specific code (e.g. a host + // string longer than the wire field's max length, a CesPlex + // preamble shorter than its declared structure, an out-of-range + // numeric argument). Distinct from CES_ERROR_INTERNAL: the server + // is healthy, the *caller* sent something it shouldn't have. + CES_ERROR_BAD_INPUT = 0x22, + // A credit (transfer/buy/cross) would push the destination past the int48 + // balance cap (~1.4M credits). The op reverts instead of saturating, so + // moved credits are never destroyed. + CES_ERROR_BALANCE_OVERFLOW = 0x23, + // Alias op referenced an id / account-selector that has no live alias. + CES_ERROR_ALIAS_NOT_FOUND = 0x24, + CES_ERROR_HOOK_REJECTED = 0x25, // a destination account's GATE hook + // rejected the incoming transfer + CES_ERROR_HOOK_TARGET = 0x26, // hook sidecar points at an asset that is + // not immutable and not owned by the setter + // SYS_L2_CALL — the 8-byte discriminator names no built-in mounted on this + // server, or the target built-in does not implement the L2-call handler. + CES_ERROR_UNSUPPORTED = 0x27, + CES_ERROR_KEYNAME_TAKEN = 0x28, // the name is already bound to another key + CES_ERROR_KEYNAME_NOT_FOUND = 0x29, // no key_name for this key + CES_ERROR_LAST = CES_ERROR_KEYNAME_NOT_FOUND +}; + +/// reqNonce value meaning "server assigns nonce, use time-based dedup." +static constexpr uint32_t CES_NONCELESS = UINT32_MAX; + +/// Time window for CES_NONCELESS time-based dedup. A request whose timestamp is +/// older than this is rejected as Stale. The settlement give-up deadline binds +/// to it: a retry past this window can no longer be accepted by the receiver. +static constexpr uint64_t CES_NONCELESS_DEDUP_WINDOW_US = 3600ULL * 1000000; + +/// Microseconds since epoch (UTC). Used for dedup time fields. +inline uint64_t getMicrosSinceEpoch() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +// Heap-backed CES wire-bytes type. Defined here (alongside Hash / +// HashPrefix / HashTail) because half the codebase references it as a +// data type — function signatures, struct members — without needing +// the active read/write wrapper. The wrapper (ces::Buffer) lives in +// ces/buffer.h; include that when you want put/get/peek/poke methods. +// +// The MTU-bounded stack-vector wire-payload type is minx::Bytes; use +// that explicitly when you specifically want the 1280-cap +// static_vector for UDP packet construction. +using Bytes = std::vector; +using Hash = minx::Hash; +using HashTail = std::array; +using HashPrefix = std::array; +using PeerAddr = std::array; + +// 48-bit integers stored in 6 bytes, little-endian. Account balances use these +// (see account.h) so the two 64-bit balance fields shed 4 bytes total, making +// room for a 32-bit field while the account row stays one cache line. The +// public accessors keep their int64/uint64 signatures, so call sites are +// unchanged; only the storage narrows. Arithmetic is done in 64 bits: get() +// widens (sign-extending for Int48), set() writes the low 6 bytes. No standard +// or Boost type packs to 6 bytes (they target width, not footprint), so this +// small type is the right tool, same weight as HashTail. +static_assert(std::endian::native == std::endian::little, + "Int48/UInt48 assume a little-endian host"); + +class Int48 { +public: + Int48() : b_{} {} + Int48(int64_t v) { set(v); } + int64_t get() const { + uint64_t raw = 0; + std::memcpy(&raw, b_, 6); + return static_cast(raw << 16) >> 16; // sign-extend bit 47 + } + void set(int64_t v) { + assert(v >= -(int64_t(1) << 47) && v < (int64_t(1) << 47)); + std::memcpy(b_, &v, 6); // low 6 bytes + } + std::strong_ordering operator<=>(const Int48& o) const { return get() <=> o.get(); } + bool operator==(const Int48& o) const { return get() == o.get(); } +private: + uint8_t b_[6]; +}; + +class UInt48 { +public: + UInt48() : b_{} {} + UInt48(uint64_t v) { set(v); } + uint64_t get() const { + uint64_t raw = 0; + std::memcpy(&raw, b_, 6); + return raw; + } + void set(uint64_t v) { + assert(v < (uint64_t(1) << 48)); + std::memcpy(b_, &v, 6); + } + std::strong_ordering operator<=>(const UInt48& o) const { return get() <=> o.get(); } + bool operator==(const UInt48& o) const { return get() == o.get(); } +private: + uint8_t b_[6]; +}; + +// Ledger balance range: account balances are stored in 48 bits, capping an +// account at 2^47-1 raw units (~1.4M whole credits). Credit paths saturate +// here instead of at INT64_MAX so a credit can never overflow the field. +constexpr int64_t BALANCE_MAX = (int64_t(1) << 47) - 1; +constexpr int64_t BALANCE_MIN = -(int64_t(1) << 47); + +// True if crediting `amount` onto a non-negative balance would exceed the +// int48 cap. Value-movement ops (transfer/buy/cross/VM) reject rather than +// saturate, or the moved credits vanish (a conservation break). Payment +// accounts (balance < 0) never take a plain credit, so this returns false. +inline bool creditWouldOverflow(int64_t balance, uint64_t amount) { + return balance >= 0 && amount > static_cast(BALANCE_MAX - balance); +} + +inline HashPrefix getHashPrefix(const Hash& full_hash) { + HashPrefix prefix; + std::copy(full_hash.begin(), full_hash.begin() + 8, prefix.begin()); + return prefix; +} + +inline HashTail getHashTail(const Hash& full_hash) { + HashTail tail; + std::copy(full_hash.begin() + 8, full_hash.end(), tail.begin()); + return tail; +} + +inline Hash getHash(const HashPrefix& prefix, const HashTail& tail) { + Hash full_hash; + std::copy(prefix.begin(), prefix.end(), full_hash.begin()); + std::copy(tail.begin(), tail.end(), full_hash.begin() + 8); + return full_hash; +} + +inline void hashPrefixToString(const HashPrefix& src, std::string& dest, + bool upper = false) { + dest.resize(16); + logkv::encodeHex(dest.data(), dest.size(), + reinterpret_cast(src.data()), src.size(), + upper); +} + +inline std::string hashPrefixToString(const HashPrefix& src, + bool upper = false) { + std::string dest(16, '\0'); + logkv::encodeHex(dest.data(), dest.size(), + reinterpret_cast(src.data()), src.size(), + upper); + return dest; +} + +// ---- Error code to human-readable string ---- + +inline const char* errorString(uint8_t code) { + switch (code) { + case CES_OK: return "CES_OK"; + case CES_ERROR_ORIGIN_NOT_FOUND: return "CES_ERROR_ORIGIN_NOT_FOUND"; + case CES_ERROR_WRONG_NONCE: return "CES_ERROR_WRONG_NONCE"; + case CES_ERROR_INSUFFICIENT_BALANCE: return "CES_ERROR_INSUFFICIENT_BALANCE"; + case CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE: return "CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE"; + case CES_ERROR_INVALID_TARGET_ACCOUNT: return "CES_ERROR_INVALID_TARGET_ACCOUNT"; + case CES_ERROR_WRONG_TARGET_ACCOUNT: return "CES_ERROR_WRONG_TARGET_ACCOUNT"; + case CES_ERROR_WRONG_PAYMENT_AMOUNT: return "CES_ERROR_WRONG_PAYMENT_AMOUNT"; + case CES_ERROR_ASSET_EXISTS: return "CES_ERROR_ASSET_EXISTS"; + case CES_ERROR_ASSET_NOT_FOUND: return "CES_ERROR_ASSET_NOT_FOUND"; + case CES_ERROR_NOT_OWNER: return "CES_ERROR_NOT_OWNER"; + case CES_ERROR_NOT_FOR_SALE: return "CES_ERROR_NOT_FOR_SALE"; + case CES_ERROR_INSUFFICIENT_PAYMENT: return "CES_ERROR_INSUFFICIENT_PAYMENT"; + case CES_ERROR_TIMEOUT: return "CES_ERROR_TIMEOUT"; + case CES_ERROR_INTERNAL: return "CES_ERROR_INTERNAL"; + case CES_ERROR_TARGET_NOT_FOUND: return "CES_ERROR_TARGET_NOT_FOUND"; + case CES_ERROR_UNKNOWN_PEER: return "CES_ERROR_UNKNOWN_PEER"; + case CES_ERROR_QUEUE_FULL: return "CES_ERROR_QUEUE_FULL"; + case CES_ERROR_VM_FAILED: return "CES_ERROR_VM_FAILED"; + case CES_ERROR_DISABLED: return "CES_ERROR_DISABLED"; + case CES_ERROR_ALLOWANCE_EXCEEDED: return "CES_ERROR_ALLOWANCE_EXCEEDED"; + case CES_ERROR_PROTO_REJECTED: return "CES_ERROR_PROTO_REJECTED"; + case CES_ERROR_FILE_NOT_FOUND: return "CES_ERROR_FILE_NOT_FOUND"; + case CES_ERROR_FILE_EXISTS: return "CES_ERROR_FILE_EXISTS"; + case CES_ERROR_BAD_NAME: return "CES_ERROR_BAD_NAME"; + case CES_ERROR_PATH_CONFLICT: return "CES_ERROR_PATH_CONFLICT"; + case CES_ERROR_STORE_FULL: return "CES_ERROR_STORE_FULL"; + case CES_ERROR_COMPUTE_DISABLED: return "CES_ERROR_COMPUTE_DISABLED"; + case CES_ERROR_COMPUTE_NO_FILE_HANDLER: return "CES_ERROR_COMPUTE_NO_FILE_HANDLER"; + case CES_ERROR_COMPUTE_FUND_TOO_LOW: return "CES_ERROR_COMPUTE_FUND_TOO_LOW"; + case CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND: return "CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND"; + case CES_ERROR_COMPUTE_MAX_INSTANCES: return "CES_ERROR_COMPUTE_MAX_INSTANCES"; + case CES_ERROR_NOT_LISTENING: return "CES_ERROR_NOT_LISTENING"; + case CES_ERROR_IMMUTABLE: return "CES_ERROR_IMMUTABLE"; + case CES_ERROR_BAD_INPUT: return "CES_ERROR_BAD_INPUT"; + case CES_ERROR_BALANCE_OVERFLOW: return "CES_ERROR_BALANCE_OVERFLOW"; + case CES_ERROR_ALIAS_NOT_FOUND: return "CES_ERROR_ALIAS_NOT_FOUND"; + case CES_ERROR_KEYNAME_TAKEN: return "CES_ERROR_KEYNAME_TAKEN"; + case CES_ERROR_KEYNAME_NOT_FOUND: return "CES_ERROR_KEYNAME_NOT_FOUND"; + case CES_ERROR_HOOK_REJECTED: return "CES_ERROR_HOOK_REJECTED"; + case CES_ERROR_HOOK_TARGET: return "CES_ERROR_HOOK_TARGET"; + case CES_ERROR_UNSUPPORTED: return "CES_ERROR_UNSUPPORTED"; + default: return "UNKNOWN_ERROR"; + } +} + +// ---- Asset balance (days + flag bits) ---- +// The uint16_t balance field encodes: +// bit 15 = private (hide content from unauthorized queries) +// bit 14 = asset-owned (owner field is an asset key prefix, not an account prefix) +// bit 13 = immutable (content/value cannot be changed; once set, cannot be unset) +// bit 12 = owner-pays (daily rent auto-funds from the owner account once the +// prepaid days run out; a 0-day owner-pays asset stays alive while +// the owner can pay and dies the day it cannot) +// bits 0-11 = days remaining (max 4095 ~= 11 years) +// +// IMMUTABLE seals content only. Owner, price, and rent (days) can still +// be updated, transferred, and funded on an immutable asset -- only the +// 210-byte content array is locked. Programs reading a raw balance must mask +// with 0x0FFF for the day count; the top four bits are flags. +inline bool isAssetPrivate(uint16_t balance) { return balance & 0x8000; } +inline bool isAssetOwned(uint16_t balance) { return balance & 0x4000; } +inline bool isAssetImmutable(uint16_t balance) { return balance & 0x2000; } +inline bool isAssetOwnerPays(uint16_t balance) { return balance & 0x1000; } +inline uint16_t assetDays(uint16_t balance) { return balance & 0x0FFF; } +inline uint16_t assetBalance(uint16_t days, bool priv, + bool assetOwned = false, + bool immutable = false, + bool ownerPays = false) { + return static_cast( + (priv ? 0x8000 : 0) | + (assetOwned ? 0x4000 : 0) | + (immutable ? 0x2000 : 0) | + (ownerPays ? 0x1000 : 0) | + (days & 0x0FFF)); +} + +// ---- Price conversion utilities ---- +// Asset prices are stored as uint32_t; real cost = storedPrice * PRICE_UNIT. +// PRICE_UNIT matches the currency display divisor (1.00000000 = 100,000,000). +// The minimum non-zero price is 1 whole credit (100,000,000 internal units). +// The price in the interface is an integer number of whole credits. + +constexpr uint64_t PRICE_UNIT = 100'000'000ULL; +constexpr uint64_t PRICE_MAX = static_cast(UINT32_MAX) * PRICE_UNIT; + +// Default CES server UDP port. +constexpr uint16_t DEFAULT_PORT = 53830; + +// The conventional CesPlex rpc_port: the main port + 1. Not a compiled-in +// default (rpc_port = 0 stays the master off-switch); this is the number +// clients assume when an address names no port (cwb's file:// compute:// +// lua:// default here), and the one configs should serve on. Both ports sit +// in the IANA dynamic range (49152-65535, RFC 6335), never assigned. +constexpr uint16_t DEFAULT_RPC_PORT = 53831; + +// L2 call (SYS_L2_CALL and the builtin:compute CALL verb): a paid memo +// delivered into a live L2 program, plus its reply. Both memo and reply ride +// CesPlex/RUDP, so neither is packet-bounded; a client can send a large memo +// and receive a large reply. The VM's own send and receive are far smaller: +// it builds its blob in inline io memory and receives the reply truncated into +// its input window (CES_L2_CALL_VM_REPLY). +constexpr uint32_t CES_L2_CALL_MAX_MEMO = 64u * 1024; // request memo ceiling +constexpr uint32_t CES_L2_CALL_MAX_REPLY = 64u * 1024; // reply ceiling +constexpr uint32_t CES_L2_CALL_VM_REPLY = 512; // VM reply truncation + +// Convert a user-facing price (whole credits) to stored form. +// Returns 0 on success, non-zero on validation failure. +// out receives the stored price on success. +inline int validatePrice(uint64_t wholeCredits, uint32_t& out) { + if (wholeCredits == 0) { out = 0; return 0; } + if (wholeCredits > UINT32_MAX) return 2; // too high + out = static_cast(wholeCredits); + return 0; +} + +// Convert stored price back to real credits (internal units). +constexpr uint64_t storedToRealPrice(uint32_t stored) { + return static_cast(stored) * PRICE_UNIT; +} + +} // namespace ces + +namespace std { +template <> struct hash { + std::size_t operator()(const ces::HashPrefix& key) const noexcept { + uint64_t raw_val; + std::memcpy(&raw_val, key.data(), sizeof(uint64_t)); + return std::hash{}(raw_val); + } +}; +} // namespace std diff --git a/vendor/ces/include/ces/util/ctrlc.h b/vendor/ces/include/ces/util/ctrlc.h new file mode 100644 index 0000000..b4a9112 --- /dev/null +++ b/vendor/ces/include/ces/util/ctrlc.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +namespace ces { +namespace internal { +extern volatile std::sig_atomic_t g_interrupted; +} +inline bool interrupted() { return internal::g_interrupted != 0; } +inline bool notInterrupted() { return internal::g_interrupted == 0; } +inline int interruptCount() { return internal::g_interrupted; } + +enum class WaitResult { + Success, + Timeout, + Interrupted +}; + +template +inline WaitResult waitFor(uint64_t millis, Predicate pred) { + auto duration = std::chrono::milliseconds(millis); + auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < duration) { + if (ces::interrupted()) + return WaitResult::Interrupted; + if (pred()) + return WaitResult::Success; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + return pred() ? WaitResult::Success : WaitResult::Timeout; +} + +inline bool sleep(uint64_t millis) { + return waitFor(millis, [] { return false; }) == WaitResult::Timeout; +} + +} // namespace ces diff --git a/vendor/ces/include/ces/util/fileperm.h b/vendor/ces/include/ces/util/fileperm.h new file mode 100644 index 0000000..ab102a9 --- /dev/null +++ b/vendor/ces/include/ces/util/fileperm.h @@ -0,0 +1,51 @@ +#pragma once + +// Filesystem permission helper. + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#pragma comment(lib, "advapi32.lib") +#else +#include +#endif + +namespace ces { + +// Restrict `path` to owner-only read/write. POSIX: 0600. Windows: ACL with +// Built-in Administrators + SYSTEM + Owner full-control. Returns "" on +// success, error string on failure. +inline std::string setSecurePermission(const std::filesystem::path& path) { + try { +#ifdef _WIN32 + const wchar_t* sddl = L"D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;OW)"; + PSECURITY_DESCRIPTOR pSD = nullptr; + if (ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, SDDL_REVISION_1, &pSD, nullptr)) { + if (!SetFileSecurityW(path.c_str(), DACL_SECURITY_INFORMATION, pSD)) { + std::string err = + "Error: " + std::system_category().message(GetLastError()); + LocalFree(pSD); + return err; + } + LocalFree(pSD); + } +#else + std::filesystem::permissions(path, + std::filesystem::perms::owner_read | + std::filesystem::perms::owner_write, + std::filesystem::perm_options::replace); +#endif + } catch (const std::exception& e) { + return std::string("Error: ") + e.what(); + } + return ""; +} + +} // namespace ces diff --git a/vendor/ces/include/ces/util/hash.h b/vendor/ces/include/ces/util/hash.h new file mode 100644 index 0000000..0bd02f8 --- /dev/null +++ b/vendor/ces/include/ces/util/hash.h @@ -0,0 +1,34 @@ +#pragma once + +// Hashing helpers. +// +// Hashing is provider-agnostic at this header: shaUpdate() is templated +// on the hasher type. Anything exposing `.Update(const uint8_t*, size_t)` +// fits (CryptoPP::SHA256 happens to). The crypto provider header is +// pulled in only at the .cpp site that actually constructs the hasher. + +#include +#include +#include + +#include + +namespace ces { + +// Feed an integer value into a streaming hasher, big-endian encoded. +// Avoids the inline `uint8_t buf[N]; ... h.Update(buf, N);` pattern at +// every digest-building site. For byte arrays/spans, call +// h.Update(arr.data(), arr.size()) directly — this helper is for +// integer scalars only. +template +inline void shaUpdate(Hasher& h, const T& val) { + static_assert(std::is_integral_v, + "shaUpdate is for integer scalars; for byte arrays/" + "spans use h.Update(arr.data(), arr.size()) directly"); + std::array tmp; + logkv::serializer::write( + reinterpret_cast(tmp.data()), tmp.size(), val); + h.Update(tmp.data(), tmp.size()); +} + +} // namespace ces diff --git a/vendor/ces/include/ces/util/helpers.h b/vendor/ces/include/ces/util/helpers.h new file mode 100644 index 0000000..0fc92c0 --- /dev/null +++ b/vendor/ces/include/ces/util/helpers.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace ces { + +// Invoke runFn (typically an io_context::run() call, or any worker-thread body) +// so that an exception escaping it is logged at ERROR and runFn is retried — a +// leaf/auxiliary thread must never let an uncaught exception std::terminate the +// whole process. Returns when runFn completes cleanly (e.g. the io_context was +// stopped). Use as the std::thread body: +// std::thread([&]{ runGuardedThread([&]{ io.run(); }, "myIO"); }) +void runGuardedThread(const std::function& runFn, const char* threadName); + +} // namespace ces diff --git a/vendor/ces/include/ces/util/hex.h b/vendor/ces/include/ces/util/hex.h new file mode 100644 index 0000000..2d6e772 --- /dev/null +++ b/vendor/ces/include/ces/util/hex.h @@ -0,0 +1,26 @@ +#pragma once + +// Hex encoding helpers (free functions). + +#include +#include +#include +#include + +#include + +namespace ces { + +// Parse an ASCII hex string into raw bytes. Two characters = one byte, +// interpreted as big-endian-per-byte (standard hex dump layout). +// - An odd trailing character is silently ignored (matches existing +// cesh callsite behavior). +// - Throws std::invalid_argument on non-hex characters. +// - Empty input yields an empty vector. +ces::Bytes parseHex(std::string_view hex); + +// Format raw bytes as a lowercase ASCII hex string (2 chars per byte, +// no separators). Inverse of parseHex on valid inputs. +std::string bytesToHex(std::span bytes); + +} // namespace ces diff --git a/vendor/ces/include/ces/util/kvcount.h b/vendor/ces/include/ces/util/kvcount.h new file mode 100644 index 0000000..a8d42cb --- /dev/null +++ b/vendor/ces/include/ces/util/kvcount.h @@ -0,0 +1,16 @@ +#pragma once + +// Count entries in a canonical length-prefixed KV dump by their key's first byte. + +#include +#include + +namespace ces { + +// Count entries whose key's first byte == prefix. Format is [u32 BE count] then per entry +// [u32 BE keylen][key][u32 BE vallen][val], all big-endian -- hyle State::canonical(). Used to +// split account ('a') vs entry ('e') cells for ces.hyle.solo. A truncated or malformed buffer +// stops the walk and returns the count so far; never reads out of bounds. +uint64_t countCanonicalKvKeyPrefix(std::span canon, uint8_t prefix); + +} // namespace ces diff --git a/vendor/ces/include/ces/util/log.h b/vendor/ces/include/ces/util/log.h new file mode 100644 index 0000000..7affeef --- /dev/null +++ b/vendor/ces/include/ces/util/log.h @@ -0,0 +1,34 @@ +#pragma once + +/** + * Shared log-level parsing for CLI binaries. + * + * Accepts the first letter (trace/debug/info/warning/error/fatal or t/d/i/w/e/f) + * case-insensitively. Throws std::runtime_error on unknown input. + */ + +#include + +#include +#include +#include + +namespace ces { + +inline void setupLogger(const std::string& logLevel) { + if (logLevel.empty()) + throw std::invalid_argument("empty log level"); + + switch (std::tolower(static_cast(logLevel[0]))) { + case 't': blog::set_level(blog::trace); return; + case 'd': blog::set_level(blog::debug); return; + case 'i': blog::set_level(blog::info); return; + case 'w': blog::set_level(blog::warning); return; + case 'e': blog::set_level(blog::error); return; + case 'f': blog::set_level(blog::fatal); return; + } + + throw std::invalid_argument("unsupported log level: " + logLevel); +} + +} // namespace ces diff --git a/vendor/ces/include/ces/util/metrics.h b/vendor/ces/include/ces/util/metrics.h new file mode 100644 index 0000000..50c0180 --- /dev/null +++ b/vendor/ces/include/ces/util/metrics.h @@ -0,0 +1,72 @@ +#pragma once + +// Server-side runtime gauges driven by a 1Hz pulse. +// +// Two shapes: +// - BucketGauge ring of N atomic accumulators rolled by the pulse. +// Workers call record(v); the pulse advances the +// write index every second. sum() returns the total +// across the window. average() is sum()/N. +// - PointGauge single atomic value resampled by the pulse. +// +// All values are unsigned 64-bit. Conversion to 0..10000 basis points +// is the caller's job (each gauge has a different scale). + +#include +#include +#include +#include + +namespace ces { + +template +class BucketGauge { + public: + static constexpr std::size_t BUCKET_COUNT = N; + + BucketGauge() { + for (auto& b : buckets_) b.store(0, std::memory_order_relaxed); + } + + // Add v to the current write bucket. Lock-free; safe from any thread. + void record(uint64_t v) { + std::size_t idx = idx_.load(std::memory_order_relaxed); + buckets_[idx].fetch_add(v, std::memory_order_relaxed); + } + + // Advance to the next bucket and zero it. Called once per pulse tick. + void roll() { + std::size_t cur = idx_.load(std::memory_order_relaxed); + std::size_t next = (cur + 1) % N; + buckets_[next].store(0, std::memory_order_relaxed); + idx_.store(next, std::memory_order_relaxed); + } + + uint64_t sum() const { + uint64_t s = 0; + for (const auto& b : buckets_) s += b.load(std::memory_order_relaxed); + return s; + } + + uint64_t average() const { return sum() / N; } + + private: + std::array, N> buckets_{}; + std::atomic idx_{0}; +}; + +class PointGauge { + public: + void store(uint64_t v) { value_.store(v, std::memory_order_relaxed); } + uint64_t load() const { return value_.load(std::memory_order_relaxed); } + + private: + std::atomic value_{0}; +}; + +// Clamp a uint64_t to the basis-points range [0, 10000]. +inline uint16_t clampBp(uint64_t v) { + return v > 10000 ? static_cast(10000) : static_cast(v); +} + +} // namespace ces diff --git a/vendor/ces/include/ces/util/resolver.h b/vendor/ces/include/ces/util/resolver.h new file mode 100644 index 0000000..cb6a28b --- /dev/null +++ b/vendor/ces/include/ces/util/resolver.h @@ -0,0 +1,78 @@ +#pragma once + +// Resolver — centralized text-to-endpoint conversion. +// +// Every place in CES that turns a string (DNS name, IP, "host:port") into a +// boost::asio endpoint goes through here, so IPv6 / bracketed-host / port-name +// quirks live in one code path. Callers should not use +// boost::asio::ip::{udp,tcp}::resolver or make_address directly. + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace ces { + +class CesClient; + +class Resolver { +public: + // DNS-capable resolution of "host:port" to a UDP endpoint. The split + // happens on the LAST ':' so unbracketed IPv6 addresses don't mis-parse + // as long as the caller uses the accepted convention (see splitHostPort + // in the implementation). Throws on empty resolver result or bad format. + static boost::asio::ip::udp::endpoint resolveUdp(const std::string& hostPort); + + // Separate-args variant. Same semantics minus the split. + static boost::asio::ip::udp::endpoint resolveUdp(const std::string& host, + uint16_t port); + + // TCP counterparts of the above. + static boost::asio::ip::tcp::endpoint resolveTcp(const std::string& hostPort); + static boost::asio::ip::tcp::endpoint resolveTcp(const std::string& host, + uint16_t port); + + // Parse a numeric IP (no DNS). Throws on invalid. + static boost::asio::ip::address parseIp(const std::string& ip); + // Non-throwing variant for hot paths that want to handle the failure. + static boost::asio::ip::address parseIp(const std::string& ip, + boost::system::error_code& ec); + + // Give an advertised peer address a routable host. A node with no serverName + // can't name its own external address, so it advertises only its listen port + // (":"); the routable host then comes from the packet we received from + // it (srcIp) — routable by definition, since it just reached us — while the + // listen port stays the advertised one (the packet's source port is ephemeral + // and useless). If `advertised` already carries a host (an operator-set + // serverName: a DNS name or IP literal), it is the intentional external + // address and is returned unchanged. IPv4-mapped IPv6 sources are unwrapped + // and bare IPv6 hosts are bracketed so the result re-parses as host:port. + static std::string fillHost(const std::string& advertised, + const boost::asio::ip::address& srcIp); + + // Result of probe(): auto-detected TCP-vs-UDP with pre-resolved endpoints. + struct Probe { + bool isTcp = false; + boost::asio::ip::udp::endpoint udpEp; + boost::asio::ip::tcp::endpoint tcpEp; + + // Create a CesClient using the detected transport. Defined inline in + // the .cpp (forward declaration of CesClient at the top of this header + // keeps us from pulling client.h transitively everywhere). + std::unique_ptr makeClient(bool useDataset = false) const; + }; + + // Try TCP connect first with a short timeout; fall back to UDP on + // refusal / timeout. Results are cached for the process lifetime per + // input string. Optional log callback for diagnostics. + static Probe probe(const std::string& hostPort, + std::function log = nullptr); +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/util/smtp.h b/vendor/ces/include/ces/util/smtp.h new file mode 100644 index 0000000..1de78aa --- /dev/null +++ b/vendor/ces/include/ces/util/smtp.h @@ -0,0 +1,53 @@ +// smtp.h - SMTP submission client for the outbound mail relay (--mail only). +// +// Synchronous SMTP: EHLO / opportunistic STARTTLS / optional AUTH LOGIN / MAIL / +// RCPT / DATA. If the relay advertises STARTTLS the connection upgrades to TLS +// (OpenSSL, via boost::asio::ssl) and everything after (AUTH, envelope, body) +// rides the encrypted channel, with a fresh EHLO after the upgrade per RFC 3207. +// A relay that does not advertise STARTTLS is spoken to in plaintext. The peer +// certificate is not verified. +// +// Blocking: call OFF the CesPlex strand. CesServer::mailDeliver runs it on a +// worker thread; tests call it directly against an in-process mock server. + +#pragma once + +#ifdef CES_MAIL + +#include +#include + +namespace ces { + +struct SmtpConfig { + std::string host; + uint16_t port = 587; + std::string from; + std::string user; // empty => skip AUTH + std::string pass; + uint32_t timeout_secs = 60; // per-connection watchdog: a relay that + // accepts then stalls cannot hang the caller + // past this; the send fails instead. +}; + +// One optional file attachment. `data` is the already-read raw bytes (bounded +// by the server's max message size, so it fits in memory). With an attachment +// present the message is sent as MIME multipart/mixed: a text part plus a +// base64 attachment part. +struct MailAttachment { + std::string filename; + std::string contentType = "application/octet-stream"; + std::string data; +}; + +// Submit one message (optionally with one attachment). Returns true on a 2xx +// final reply. On failure returns false and, if `errOut` is non-null, a short +// diagnostic. +bool smtpSend(const SmtpConfig& cfg, const std::string& to, + const std::string& subject, const std::string& body, + const MailAttachment* attachment = nullptr, + std::string* errOut = nullptr); + +} // namespace ces + +#endif // CES_MAIL diff --git a/vendor/ces/include/ces/util/vmprogram.h b/vendor/ces/include/ces/util/vmprogram.h new file mode 100644 index 0000000..72bfe04 --- /dev/null +++ b/vendor/ces/include/ces/util/vmprogram.h @@ -0,0 +1,967 @@ +#pragma once + +/** + * vmprogram.h — C++ builder for CesVM bytecode programs. + * + * A fluent API that emits bytecode for the GVM-based CesVM used by + * CES_RUN_ASSET. Test authors and program writers use this instead of + * hand-packing opcode bytes and operand control-byte encodings, turning + * that mechanical work into compile-checked C++. + * + * The builder: + * + * - Exposes one method per VM opcode, with C++ types instead of raw + * bytes. Misspellings become compile errors. + * + * - Auto-encodes operands in the shortest valid control-byte form. + * Small immediates use the 1-byte SHORT_VAL encoding; larger + * values use the multi-byte wide form (1..8 payload bytes LE). + * + * - Handles labels with forward-reference patching. A label is + * declared, referenced by branch/call instructions as many times + * as needed, and placed at its target by place(label). The build + * step resolves all pending references in one pass. + * + * - Offers structured control flow on top of labels: lambda-bodied + * ifThen / ifThenElse that allocate and place their own labels, + * with merge-jmp elision when the then-body terminates (the + * emitted bytecode matches a hand-written branch). + * + * - Manages scratch-cell layout via a tiny bump allocator. Programs + * ask for typed Regions (allocHash, allocContent, …) instead of + * typing magic cell numbers; two regions in the same program are + * guaranteed non-overlapping. allocAt() is the escape hatch for + * protocol-fixed addresses. + * + * - Provides typed syscall wrappers for the host-dispatch family, + * plus pair-bundle helpers (loadCodeAndCall, readAndUpdateAsset) + * that bake safety contracts and ABI quirks into a single call. + * + * - Provides convenience helpers for common patterns: copying cells + * out of preloaded regions (caller key, self key, input), writing + * byte sequences (e.g. "127.0.0.1") into io memory, byte-level + * ldb/stb addressed by (cell, byteOffset). + * + * - Builds into either a 210-byte AssetData (the boot code block of + * a CES asset) via buildBootBlock(), or a flexible byte vector + * via buildBytes() if the program is larger than one block. The + * CesVM code limit is 8 KB. + * + * Example — a minimal "ext_call rudp" gateway program: + * + * VmProgram pgm; + * Region fileHead = pgm.allocHash(); // 4 cells of scratch + * pgm.copyFromInput(fileHead, 0); // 0 = input cell offset + * pgm.term(); + * AssetData code = pgm.buildBootBlock(); + * + * Threading / lifetime: VmProgram is a plain builder object with no + * external state. It is not thread-safe; construct one per building + * session and throw it away. + */ + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { + +// --------------------------------------------------------------------------- +// Operand helpers +// --------------------------------------------------------------------------- +// +// The CesVM's read() function decodes each operand into a uint64_t by +// combining: +// - A control byte whose bits say "short form vs. wide form" and +// "raw value vs. dereference via io[value]". +// - Optional 1..8 payload bytes (for wide form only). +// +// VmVal wraps the triplet (numeric value, dereference flag) the builder +// needs to emit one operand. Opcodes interpret the final value +// differently — OP_SET's first operand is a cell index to write into, +// OP_ADD's operands are literal values to add, OP_MOV's operands are +// (dst cell, src cell, count) — but the wire encoding is uniform, so +// the VmVal struct is enough to represent every operand. + +struct VmVal { + uint64_t value = 0; + bool deref = false; // if true, emit with the REG_PTR bit set (io[value]) +}; + +// Immediate value. The emitted operand decodes to exactly `v`, whatever +// the surrounding opcode does with it. +inline VmVal Imm(uint64_t v) { return {v, false}; } + +// Dereference through a cell. The emitted operand reads cell `c` at +// runtime and uses that cell's contents as the operand value. +inline VmVal Ref(uint64_t c) { return {c, true}; } + +// --------------------------------------------------------------------------- +// Labels +// --------------------------------------------------------------------------- +// +// A VmLabel is an opaque handle into the builder's internal label table. +// Declare one with VmProgram::label(), place it at its intended code +// offset with VmProgram::place(l), and reference it from jumps and +// calls. References emit a 2-byte placeholder that build-time +// resolution patches with the resolved offset. Forward references are +// fine; unplaced labels at build time are a build error. + +struct VmLabel { + size_t id; + bool operator==(const VmLabel& o) const { return id == o.id; } +}; + +// --------------------------------------------------------------------------- +// Region — a typed handle for a contiguous range of scratch cells +// --------------------------------------------------------------------------- +// +// CesVM's data plane (`io_`) has 1024 cells. Cells 0..15 are named +// registers (PC, R, S, …, GPR0..7); cells 752..1023 are protocol-fixed +// (context, output, input, allowance, budget remaining); cells +// 16..751 are 736 cells of free-form scratch shared across the +// program. Bytecode lives in a separate buffer (`code_` in CesVM), not +// overlaid on io[], so a program can use any subrange of cells 16..751 as +// scratch without overwriting its own bytecode. +// +// VmProgram ships a tiny bump allocator over that 736-cell range so +// programs don't pick magic cell numbers by hand. Each `alloc(N)` (or +// the typed shortcuts `allocHash`, `allocHashPrefix`, `allocContent`, +// `allocCell`) returns a Region with a stable first-cell index and a +// count. Two regions allocated in the same program are guaranteed +// non-overlapping. The allocator has no notion of free/release — it's +// strictly bump-up, matching the program-as-straight-line shape that +// VmProgram targets. Programs that share data with the outside world +// at fixed addresses (or that need backwards-compatibility with +// existing layouts) can use `allocAt(cell, count)` to anchor a +// Region at a specific cell without moving the bump cursor. +// +// Region implicitly converts to VmVal as Imm(cell), so it slots +// directly into existing typed syscall structs whose `*Cell` fields +// are VmVal-typed cell indices. Helpers that take raw cell indices +// (ldbFromCell, stbInCell, the copy* family) gain Region overloads +// for fluency. + +struct Region { + uint64_t cell = 0; // first cell index in the region + uint64_t count = 0; // number of cells + + // Implicit conversion to a VmVal: "the cell index where this region + // starts, as an immediate." Lets a Region pass for any VmVal-typed + // parameter expecting a cell-index pointer. + operator VmVal() const { return Imm(cell); } +}; + +// --------------------------------------------------------------------------- +// VmProgram — the builder +// --------------------------------------------------------------------------- +// +// The builder is register-style: every binary-op method takes two VmVal +// operands and writes the result to R. CesVM's ISA also has stack-mode +// variants (the opcode OR'd with the STACK bit 0x80, popping and pushing +// the data stack). For hand-written programs the register methods are +// the intended surface; stack mode is exposed through stackOp() / +// jfStack() / jtStack() / dup() for code generators, where stack-machine +// evaluation of expression trees emits one operand-free byte per +// interior node. + +class VmProgram { +public: + VmProgram(); + ~VmProgram() = default; + + VmProgram(const VmProgram&) = delete; + VmProgram& operator=(const VmProgram&) = delete; + VmProgram(VmProgram&&) = default; + VmProgram& operator=(VmProgram&&) = default; + + // ========================================================================= + // Low-level opcode methods + // + // One method per GVM / CesVM opcode, named after the opcode in + // lower_snake_case. Operand types match the decoded shape; binary ops + // take two VmVal operands, ternary ops (mov/cmp/fil) take three, and + // so on. The dst/src distinction is documented per method. + // + // These methods can be chained and each returns *this for fluent + // composition. + // ========================================================================= + + // --- Control flow + meta --- + + // No-op (1 gas unit). + VmProgram& nop(); + // Terminate normally. Sets PC to UINT64_MAX, which ends execution. + VmProgram& term(); + // Abort. Ends execution with CESVM_ABORT. + VmProgram& abort(); + // Unconditional jump to `target`. + VmProgram& jmp(VmLabel target); + // Jump to `target` if `cond` is falsy (0). + VmProgram& jf(VmVal cond, VmLabel target); + // Jump to `target` if `cond` is truthy (nonzero). + VmProgram& jt(VmVal cond, VmLabel target); + // Call `target`. Saves all 16 registers (128-byte frame); RET restores. + VmProgram& call(VmLabel target); + // Return from a call. The VM reads `retVal` (in the callee's register + // context), restores the caller's 16 registers from the frame, then + // writes `retVal` into R — so R is the return-value register and every + // other register is callee-saved. For top-level returns (no enclosing + // call), the program ends with CESVM_RET, which the VM treats as a + // crash. + VmProgram& ret(VmVal retVal); + // Abort (CESVM_ABORT) if `cond` is falsy. One opcode replacing the + // jt-over-abort guard idiom; the register-mode counterpart of the + // hostx contract. + VmProgram& require(VmVal cond); + + // Indirect jump and call: the target is a runtime value computed by + // the program, not a compile-time label. Typical use: after + // SYS_LOAD_CODE writes the loaded block's offset into R, jmpr(Ref( + // CESVM_CELL_R)) jumps into the freshly-loaded code. OP_JMPR / + // OP_CALLR take a single operand that gets dereferenced by the + // VM's regular operand decoder (so Imm(offset) jumps to that + // literal address, Ref(cell) jumps to io[cell]). + VmProgram& jmpr(VmVal target); + VmProgram& callr(VmVal target); + + // --- Host dispatch --- + + // Call the host, ignoring S (status). Use host() for "best-effort" + // syscalls where the program will inspect S manually. + VmProgram& host(); + // Call the host and abort the program if S is nonzero on return. + // This is the common case — use hostx() unless you explicitly want + // to handle a failing syscall. + VmProgram& hostx(); + + // --- Data movement --- + + // io[dst] = src. dst is interpreted as a cell index to write into; + // src is interpreted as the value to store (so src = Imm(v) stores + // the literal, src = Ref(c) stores io[c]). dst can itself be a Ref + // (writing through a pointer stored in a cell). + VmProgram& set(VmVal dst, VmVal src); + + // Copy `count` cells from io[src..] to io[dst..]. See also copy() + // below for the common all-literal case. + VmProgram& mov(VmVal dst, VmVal src, VmVal count); + + // Compare: R() = 1 if io[a..a+count-1] == io[b..b+count-1], else 0. + VmProgram& cmp(VmVal a, VmVal b, VmVal count); + + // Fill: io[dst..dst+count-1] = val. + VmProgram& fil(VmVal dst, VmVal val, VmVal count); + + // Load/store byte — io's byte-level view. LDB reads one byte at + // `byteOffset` into R; STB writes the low byte of `val` at + // `byteOffset`. + VmProgram& ldb(VmVal byteOffset); + VmProgram& stb(VmVal byteOffset, VmVal val); + + // Increment / decrement a cell in place. + VmProgram& inc(VmVal cell); + VmProgram& dec(VmVal cell); + + // --- Arithmetic / logic (two-operand, result in R) --- + + VmProgram& add(VmVal a, VmVal b); + VmProgram& sub(VmVal a, VmVal b); + VmProgram& mul(VmVal a, VmVal b); + VmProgram& div(VmVal a, VmVal b); + VmProgram& mod(VmVal a, VmVal b); + VmProgram& or_(VmVal a, VmVal b); + VmProgram& and_(VmVal a, VmVal b); + VmProgram& xor_(VmVal a, VmVal b); + // Bitwise complement (~x). For boolean-flip use lnot(). + VmProgram& not_(VmVal a); + // Logical NOT (!x): 0 → 1, anything else → 0. + VmProgram& lnot(VmVal a); + // Arithmetic two's-complement negate (-x). + VmProgram& neg(VmVal a); + VmProgram& shl(VmVal a, VmVal b); + VmProgram& shr(VmVal a, VmVal b); + // Arithmetic shift right: signed >> (preserves sign bit). Use for + // negative values; SHR is logical/zero-fill. + VmProgram& sar(VmVal a, VmVal b); + VmProgram& andl(VmVal a, VmVal b); // logical AND (!!a && !!b) + VmProgram& orl(VmVal a, VmVal b); // logical OR + VmProgram& eq(VmVal a, VmVal b); + VmProgram& ne(VmVal a, VmVal b); + VmProgram& gt(VmVal a, VmVal b); + VmProgram& lt(VmVal a, VmVal b); + VmProgram& ge(VmVal a, VmVal b); + VmProgram& le(VmVal a, VmVal b); + + // Signed comparisons: operands compared as two's-complement int64. + // gt/lt/ge/le above are unsigned and read the sign bit as magnitude. + VmProgram& slt(VmVal a, VmVal b); + VmProgram& sgt(VmVal a, VmVal b); + VmProgram& sge(VmVal a, VmVal b); + VmProgram& sle(VmVal a, VmVal b); + + // Checked unsigned arithmetic: like add/sub/mul but a wrap halts the + // VM with CESVM_OVERFLOW instead of producing a mod-2^64 result. The + // default choice for money math; the wrapping forms remain for + // intentional modular arithmetic. + VmProgram& addx(VmVal a, VmVal b); + VmProgram& subx(VmVal a, VmVal b); + VmProgram& mulx(VmVal a, VmVal b); + + // --- Stack --- + + VmProgram& push(VmVal v); + VmProgram& pop(VmVal cell); + + // Duplicate the top of the data stack (OP_DUP). Stack-only opcode. + VmProgram& dup(); + + // Emit the stack-mode variant (opcode | 0x80) of `op`: operands are + // popped from the data stack and the result pushed, one byte total. + // Accepts only opcodes whose stack form reads no inline operand bytes + // (arithmetic, logic, comparisons incl. signed and checked, ASSERT, + // NEG/NOT/LNOT, RND/TIME, LDB/STB, CALL); anything else throws + // VmProgramError. JF/JT stack forms carry a label and have dedicated + // emitters below. + VmProgram& stackOp(CesVMOpcode op); + + // Stack-mode conditional jumps: the condition is popped from the data + // stack, the target is a label like jf()/jt(). + VmProgram& jfStack(VmLabel target); + VmProgram& jtStack(VmLabel target); + + // Generic emitter for table-driven code generators (the casm + // assembler): emit `op` followed by `operands`, each in shortest + // form. Performs no per-opcode operand-count validation; the caller's + // table owns that. Rejects opcodes whose wire shape is not + // opcode-then-plain-operands: label-target jumps/calls (2-byte raw + // target, use jmp/call/jf/jt) and the variadic host dispatchers + // (inline count byte, use hostv/hostxv). + VmProgram& rawOp(CesVMOpcode op, std::initializer_list operands); + + // --- Miscellaneous --- + + // Write a 64-bit random number to R. + VmProgram& rnd(); + // Write current time (microseconds since epoch) to R. + VmProgram& time(); + + // ========================================================================= + // Label management + // ========================================================================= + + // Create a fresh label. The label can be referenced immediately (for + // forward jumps) and placed later via place(). + VmLabel label(); + + // Place a label at the current code offset. A label can only be + // placed once; placing twice is a build error. + VmProgram& place(VmLabel l); + + // ========================================================================= + // Structured control flow + // ========================================================================= + // + // Lambda-bodied if-then[-else] that allocates and places its own + // labels. The body callable is invoked with `*this`, so it can emit + // any further instructions (including nested ifs, syscalls, jumps to + // outer labels — closures-by-reference make outer labels visible). + // + // ifThenElse emits the canonical shape: + // jf(cond, elseL); ; jmp(endL); place(elseL); ; place(endL) + // with one optimization: if the then-body's last emitted opcode is + // OP_TERM or OP_ABORT, the merge `jmp(endL)` is elided as dead code + // (execution can never reach it). This keeps the lambda form + // bytecode-equal to a hand-written branch where both arms terminate. + + template + VmProgram& ifThen(VmVal cond, ThenBody&& body) { + VmLabel endL = label(); + jf(cond, endL); + std::forward(body)(*this); + place(endL); + return *this; + } + + template + VmProgram& ifThenElse(VmVal cond, + ThenBody&& thenBody, + ElseBody&& elseBody) { + VmLabel elseL = label(); + VmLabel endL = label(); + jf(cond, elseL); + lastWasTerminator_ = false; // jf emits non-terminator OP_JF + std::forward(thenBody)(*this); + if (!lastWasTerminator_) jmp(endL); + place(elseL); + std::forward(elseBody)(*this); + place(endL); + return *this; + } + + // ========================================================================= + // Scratch region allocator + // ========================================================================= + + // Allocate `count` contiguous cells from the scratch range and + // return a Region. Throws if the bump cursor would overflow the + // scratch range (cells 16..751 by default). + Region alloc(uint64_t count); + + // Typed shortcuts for the most common region sizes. Equivalent to + // alloc(N) with N matching the protocol object's cell footprint. + Region allocHash() { return alloc(4); } // 32-byte hash + Region allocHashPrefix() { return alloc(1); } // 8-byte HashPrefix + Region allocContent() { return alloc(27); } // 210-byte AssetData (round up to 27 cells) + Region allocCell() { return alloc(1); } // single 8-byte cell + + // Anchor a region at a fixed cell index without bumping the cursor. + // Use when the program needs a region at a protocol-fixed location + // (e.g. CESVM_IO_INPUT range), or when bridging to legacy code that + // hard-coded cells. The caller takes responsibility for ensuring + // the anchored range doesn't collide with allocator-managed cells. + Region allocAt(uint64_t cell, uint64_t count) const { + return Region{cell, count}; + } + + // Set the starting cell for the bump allocator. Default is + // CESVM_REG_SIZE (16) — the first cell past the registers. Calling + // this AFTER allocations have happened simply moves the cursor; + // previously returned regions remain valid (and may now overlap + // future allocations, which is the caller's problem). + void setScratchBase(uint64_t cell) { scratchTop_ = cell; } + + // ========================================================================= + // High-level convenience helpers + // ========================================================================= + + // Copy `count` cells from io[srcCell..] to io[dstCell..]. Shortcut + // for mov(Imm(dst), Imm(src), Imm(count)) — the common all-literal + // case that appears in almost every test program. + VmProgram& copy(uint64_t dstCell, uint64_t srcCell, uint64_t count); + + // Preloaded-context copy helpers. Every gateway program starts by + // copying some of its inputs out of the high-region preloaded cells + // (CESVM_IO_INPUT, CESVM_IO_CALLER_KEY, CESVM_IO_SELF_KEY) into the + // low region where short-encoded access is cheap. These wrap the + // copy() call with the right source offset so callers don't have + // to remember the high-region constants. + + // Copy `count` cells from io[CESVM_IO_INPUT + inputCellOffset..] + // to io[dstCell..]. `inputCellOffset` is a count of u64 cells from + // the start of the input region, NOT bytes. Each cell is 8 bytes. + VmProgram& copyFromInput(uint64_t dstCell, uint64_t inputCellOffset, + uint64_t count); + + // Region-typed overload. The destination region's `count` is the + // number of cells copied; `dst.cell` is where they land. + VmProgram& copyFromInput(Region dst, uint64_t inputCellOffset) { + return copyFromInput(dst.cell, inputCellOffset, dst.count); + } + + // Copy the caller public key (4 cells = 32 bytes) from the + // preloaded io[CESVM_IO_CALLER_KEY..] region to io[dstCell..]. + VmProgram& copyCallerKeyTo(uint64_t dstCell); + VmProgram& copyCallerKeyTo(Region dst) { return copyCallerKeyTo(dst.cell); } + + // Copy the self asset key (4 cells = 32 bytes) from the preloaded + // io[CESVM_IO_SELF_KEY..] region to io[dstCell..]. Typically used + // by counter programs that need to read/write their own asset. + VmProgram& copySelfKeyTo(uint64_t dstCell); + VmProgram& copySelfKeyTo(Region dst) { return copySelfKeyTo(dst.cell); } + + // Output-write helpers. The "return a value" pattern at the end of + // a test program is a set of io[CESVM_IO_OUTPUT_LEN] + a write into + // io[CESVM_IO_OUTPUT..]. These helpers collapse the pair into a + // single builder call. + + // Write a single value to io[CESVM_IO_OUTPUT] and set + // io[CESVM_IO_OUTPUT_LEN] to `byteLen`. `value` can be an + // Imm for a literal, Ref for a dereference from another cell. + // Single-cell values only; for multi-cell outputs use copy() + // into io[CESVM_IO_OUTPUT..] and then set the length directly. + VmProgram& setOutput(VmVal value, uint64_t byteLen); + + // Multi-cell output: copy `count` cells from io[srcCell..] into + // io[CESVM_IO_OUTPUT..] and set io[CESVM_IO_OUTPUT_LEN] to + // `byteLen`. Useful when the program assembled a multi-byte result + // in scratch space and wants to return it verbatim. + VmProgram& setOutputBytes(uint64_t srcCell, uint64_t count, + uint64_t byteLen); + + // Byte-level cell addressing. CesVM's LDB/STB operate on byte offsets + // into io memory, computed as `cellIndex * 8 + byteInCell`. These helpers + // compute that offset so call sites don't hand-roll the arithmetic. + + // Returns the byte offset of the `byteOffsetInCell`-th byte inside + // the cell at `cellIndex`. Little-endian byte order within the cell. + // Constexpr so it can appear in constant expressions. + static constexpr uint64_t byteInCell(uint64_t cellIndex, + uint64_t byteOffsetInCell) { + return cellIndex * 8 + byteOffsetInCell; + } + + // Load the `byteOffsetInCell`-th byte of the cell at `cellIndex` + // into R. Equivalent to `ldb(Imm(byteInCell(cellIndex, + // byteOffsetInCell)))` but reads more naturally at call sites. + VmProgram& ldbFromCell(uint64_t cellIndex, uint64_t byteOffsetInCell); + VmProgram& ldbFromCell(Region region, uint64_t byteOffsetInCell) { + return ldbFromCell(region.cell, byteOffsetInCell); + } + + // Store the low byte of `val` at the `byteOffsetInCell`-th byte of + // the cell at `cellIndex`. + VmProgram& stbInCell(uint64_t cellIndex, uint64_t byteOffsetInCell, + VmVal val); + VmProgram& stbInCell(Region region, uint64_t byteOffsetInCell, VmVal val) { + return stbInCell(region.cell, byteOffsetInCell, val); + } + + // Load a library asset's code block and call into it in one + // emission. `keyPtr` is passed through to SYS_LOAD_CODE as + // io[4], so it should be a cell index holding a 32-byte hash (or + // a Ref() to such a cell). After SYS_LOAD_CODE populates R with + // the loaded block's offset, a CALLR through R transfers control + // to the freshly-loaded code. Emitting the two back-to-back guarantees + // R is not clobbered between the load and the call. + // + // The loaded code should end in OP_RET to return to the + // instruction after this emission. Use loadCodeAndJmp() instead + // if the loaded code ends in TERM and execution should not + // return. + VmProgram& loadCodeAndCall(VmVal keyPtr); + + // Same as loadCodeAndCall but emits OP_JMPR instead of OP_CALLR — + // no context save, no return. Used when the loaded code is a + // self-contained program that ends in TERM. + VmProgram& loadCodeAndJmp(VmVal keyPtr); + + // Read-modify-write idiom for an existing asset. Issues + // SYS_READ_ASSET to load content into the caller-supplied `content` + // region, invokes `body` to mutate the buffer, then issues + // SYS_UPDATE_ASSET to write the (potentially modified) content + // back. The owner-out scratch cell is allocated internally from the + // bump allocator (one cell, placed past whatever has already been + // allocated); balance and price land in io[7]/io[8] per the + // SYS_READ_ASSET ABI. + // + // `content` is typically the result of `allocContent()` (27 cells, + // enough for a 210-byte AssetData). `keyPtr` is a VmVal — pass a + // Region (auto-converts to Imm(cell)), an Imm/Ref directly, or + // anything else compatible. + // + // Both syscalls use hostx semantics — failure aborts the program. + // `body` is invoked with `*this`, so it can emit any further bytecode + // (including nested syscalls or control flow); closures-by-reference + // make outer state visible. + template + VmProgram& readAndUpdateAsset(VmVal keyPtr, + Region content, + Body&& body) { + Region ownerOut = allocHashPrefix(); + sysReadAsset({ + .keyPtr = keyPtr, + .ownerOutCell = ownerOut, + .contentOutCell = content, + }); + std::forward(body)(*this); + sysUpdateAsset({.keyPtr = keyPtr, .contentPtr = content}); + return *this; + } + + // Embed raw bytes into consecutive io cells starting at `dstCell`, + // via a sequence of OP_SET instructions. Bytes are packed little- + // endian eight per cell; any trailing bytes in the final cell are + // zero. `bytes` is copied at build time; it does not have to + // outlive this call. + // + // Cost-wise this emits one OP_SET per 8 bytes. A 9-byte string like + // "127.0.0.1" takes two OP_SETs. + VmProgram& writeBytesToIo(uint64_t dstCell, const uint8_t* bytes, size_t len); + + // Same, taking a string view. + VmProgram& writeBytesToIo(uint64_t dstCell, std::string_view s); + + // ========================================================================= + // Typed syscall wrappers + // + // One method per commonly-used syscall. Each wrapper lowers to a + // single OP_HOSTXV (the compact variadic host-dispatch opcode) with + // the syscall number and the args populated into io[4..4+N-1] in the + // order the cesvm.cpp dispatcher reads them. Wrappers always use the + // hostx variant — failure aborts the program. + // + // Callers supply per-argument VmVals so they can pass either + // literals or dereferences as appropriate; Region implicitly + // converts to Imm(cell) so allocator-managed regions slot in + // directly. + // + // For syscalls without a dedicated wrapper below, use the generic + // syscall() method (which emits an OP_SET chain + OP_HOSTX, suitable + // for non-sequential slot patterns), or hostv()/hostxv() to dispatch + // by number, or set up the io slots manually with set() + hostx(). + // ========================================================================= + + // SYS_READ_ACCOUNT — reads balance into R and nonce into io[5]. + // Only one input: a cell pointer to the 8-byte HashPrefix. + struct ReadAccountArgs { + VmVal prefixPtr; // io[4] + }; + VmProgram& sysReadAccount(ReadAccountArgs a); + + // SYS_TRANSFER — transfer credits from caller to dest. + struct TransferArgs { + VmVal destKeyPtr; // io[4] — cell index of a 32-byte hash + VmVal amount; // io[5] — uint64 credits + }; + VmProgram& sysTransfer(TransferArgs a); + + // SYS_OWNER_TRANSFER — same shape as sysTransfer, but the source is + // the program's owner (the asset that holds the bytecode being + // executed), not the caller. Caller still pays the protocol fee. + // Use for "house pays out" / "faucet" / "refund" patterns where the + // program's deployer has consented to disburse from its own account + // by virtue of deploying the bytecode. + using OwnerTransferArgs = TransferArgs; + VmProgram& sysOwnerTransfer(OwnerTransferArgs a); + + // SYS_DEPOSIT — caller -> programOwner. Convenience for the common + // "pay the asset's owner" pattern. Source/dest are both implicit, so + // the only argument is the amount. Allowance-bound. + struct DepositArgs { + VmVal amount; // io[4] + }; + VmProgram& sysDeposit(DepositArgs a); + + // SYS_WITHDRAW — programOwner -> caller. Symmetric convenience for + // "asset pays its caller". Same shape: only amount, no dest. NOT + // allowance-bound (the asset's owner consented to the bytecode). + using WithdrawArgs = DepositArgs; + VmProgram& sysWithdraw(WithdrawArgs a); + + // SYS_REFILL — grow this run's gas budget past the free grant, funded by the + // caller account (account hooks). amount = requested gas credits; the amount + // actually granted lands in R. Non-aborting (grants 0 if refill is off or + // the ceiling/balance is exhausted), so read R to see how much you got. + using RefillArgs = DepositArgs; + VmProgram& sysRefill(RefillArgs a); + + // SYS_READ_ASSET — read asset content and metadata. Three input + // slots at io[4..6] (key, owner-out cell-index, content-out + // cell-index); balance and price are written directly by the + // syscall into io[7] and io[8] respectively. + struct ReadAssetArgs { + VmVal keyPtr; // io[4] — cell index of a 32-byte hash + VmVal ownerOutCell; // io[5] — cell index where owner (HashPrefix) is written + VmVal contentOutCell; // io[6] — cell index where content (AssetData) is written + }; + VmProgram& sysReadAsset(ReadAssetArgs a); + + // SYS_CREATE_ASSET_RANDOM — create a new asset with a fresh random key. + struct CreateAssetRandomArgs { + VmVal contentPtr; // io[4] — cell index of asset content + VmVal days; // io[5] — lifetime in days + VmVal keyOutPtr; // io[6] — cell index where the new key is written + }; + VmProgram& sysCreateAssetRandom(CreateAssetRandomArgs a); + + // SYS_CREATE_ASSET — create an asset with a caller-specified key. + struct CreateAssetArgs { + VmVal keyPtr; // io[4] — cell index of a 32-byte hash + VmVal contentPtr; // io[5] — cell index of asset content + VmVal days; // io[6] — lifetime in days + }; + VmProgram& sysCreateAsset(CreateAssetArgs a); + + // SYS_CREATE_ASSET_MANAGED — like CREATE_ASSET, but the created + // asset is owned by the running program's boot cell (asset-owned), + // not by the runner. Used by gateway programs that want to manage + // their own state. + using CreateAssetManagedArgs = CreateAssetArgs; + VmProgram& sysCreateAssetManaged(CreateAssetManagedArgs a); + + // SYS_CREATE_ASSET_RANGE — atomically allocate `count` account-owned cells at + // a fresh random prefix; cell 0's content holds uint32_t count. + struct CreateAssetRangeArgs { + VmVal count; // io[4] — number of cells + VmVal days; // io[5] — lifetime in days + VmVal keyOutPtr; // io[6] — cell index where the handle (cell-0 key) lands + }; + VmProgram& sysCreateAssetRange(CreateAssetRangeArgs a); + + // SYS_UPDATE_ASSET — overwrite the content of an existing asset. + struct UpdateAssetArgs { + VmVal keyPtr; // io[4] + VmVal contentPtr; // io[5] + }; + VmProgram& sysUpdateAsset(UpdateAssetArgs a); + + // SYS_UPDATE_ASSET_META — set owner+price without touching content. + // Lets a program list its asset for sale, drop the price, or hand off + // ownership in a single op (cheaper than UPDATE_ASSET). + struct UpdateAssetMetaArgs { + VmVal keyPtr; // io[4] + VmVal newOwnerPtr; // io[5] — cell index of an 8-byte HashPrefix + VmVal newPrice; // io[6] — new price (0 = remove from sale) + }; + VmProgram& sysUpdateAssetMeta(UpdateAssetMetaArgs a); + + // SYS_FUND_ASSET — extend the lifetime of an existing asset. + struct FundAssetArgs { + VmVal keyPtr; // io[4] + VmVal days; // io[5] + }; + VmProgram& sysFundAsset(FundAssetArgs a); + + // SYS_BUY_ASSET — purchase an asset from its current owner. + struct BuyAssetArgs { + VmVal keyPtr; // io[4] + VmVal maxPrice; // io[5] + }; + VmProgram& sysBuyAsset(BuyAssetArgs a); + + // SYS_GIVE_ASSET — transfer ownership to a new account. + struct GiveAssetArgs { + VmVal keyPtr; // io[4] + VmVal newOwnerPtr; // io[5] — cell index of an 8-byte HashPrefix + }; + VmProgram& sysGiveAsset(GiveAssetArgs a); + + // SYS_HASH — compute SHA-256 of a byte range. + struct HashArgs { + VmVal dataPtr; // io[4] — cell index of input bytes + VmVal len; // io[5] — length in bytes + VmVal outPtr; // io[6] — cell index for 32-byte output + }; + VmProgram& sysHash(HashArgs a); + + // SYS_VERIFY_SIG — verify a signature. On return, R holds 1 (valid) + // or 0 (invalid); S holds CES_OK regardless. + struct VerifySigArgs { + VmVal dataPtr; + VmVal dataLen; + VmVal sigPtr; + VmVal pubkeyPtr; + }; + VmProgram& sysVerifySig(VerifySigArgs a); + + // SYS_CROSS_TRANSFER — send credits to an account on another peer + // server. Deferred: the actual network send happens at commit time. + struct CrossTransferArgs { + VmVal destKeyPtr; // io[4] + VmVal amount; // io[5] + VmVal serverPtr; // io[6] — cell index of the peer server address string + }; + VmProgram& sysCrossTransfer(CrossTransferArgs a); + + // SYS_LOAD_CODE — append a library asset's code block to the + // running program's code buffer. On return, R holds the offset of + // the loaded block, which can then be targeted by jmpr()/callr() + // via Ref(CESVM_CELL_R). + struct LoadCodeArgs { + VmVal keyPtr; // io[4] + }; + VmProgram& sysLoadCode(LoadCodeArgs a); + + // SYS_SEND_CLIENT — push an application message to a currently- + // connected client. R holds 1 on successful push, 0 otherwise. + struct SendClientArgs { + VmVal clientIdPtr; // io[4] — cell index of 8-byte HashPrefix + VmVal dataPtr; // io[5] — cell index of payload bytes + VmVal dataLen; // io[6] — length in bytes + }; + VmProgram& sysSendClient(SendClientArgs a); + + // SYS_SCHEDULE — queue a future runAsset for a different program. + // The parent passes an explicit `childAllowance` and the syscall + // decrements the parent's remaining allowance by that amount before + // handing it off (returns CES_ERROR_ALLOWANCE_EXCEEDED if the parent + // can't cover it), so a child run cannot exceed its parent's headroom. + struct ScheduleArgs { + VmVal assetKeyPtr; // io[4] + VmVal budget; // io[5] + VmVal childAllowance; // io[6] + VmVal inputPtr; // io[7] + VmVal inputLen; // io[8] + VmVal timeUs; // io[9] — wall clock microseconds (0 = next tick) + }; + VmProgram& sysSchedule(ScheduleArgs a); + + // SYS_RPC — MINX/RUDP stream call to an external service. The + // request body comes from a pre-allocated cesh file (the caller + // must have written the body via cesh's ramfileWrite/ramfileResize + // helpers, typically with extra capacity so the response has + // room to land in the same file). The followup VM program is + // scheduled on completion with a 48-byte input describing the + // result. See SYS_RPC in cesvm.h for the full io layout and + // wire protocol. + struct RpcArgs { + VmVal hostCell; // io[4] — cell index of ASCII host string + VmVal hostLen; // io[5] — host string length in bytes + VmVal port; // io[6] — destination UDP port (u16) + VmVal fileHead; // io[7] — cell holding 32-byte cesh file head key + VmVal followup; // io[8] — cell holding 32-byte followup asset key + VmVal budget; // io[9] — followup VM gas budget + VmVal tag; // io[10] — followup input tag (u32) + }; + VmProgram& sysRpc(RpcArgs args); + + // SYS_READ_ALIAS — public windowed read of an alias's value image + // (owner|editor|op|content; offsets in alias.h). R = bytes read. + struct ReadAliasArgs { + VmVal aliasId; // io[4] — alias id (value, not a pointer) + VmVal offset; // io[5] — image byte offset + VmVal len; // io[6] — bytes to read + VmVal destPtr; // io[7] — cell index where the window is written + }; + VmProgram& sysReadAlias(ReadAliasArgs a); + + // SYS_WRITE_ALIAS — patch bytes into an alias's value image, acting as + // the run's programOwner principal (owner floor 8, editor floor 18; + // principal-less runs always fail). hostx: a rejected write aborts. + struct WriteAliasArgs { + VmVal aliasId; // io[4] + VmVal offset; // io[5] + VmVal len; // io[6] + VmVal srcPtr; // io[7] — cell index of the patch bytes + }; + VmProgram& sysWriteAlias(WriteAliasArgs a); + + // SYS_LOAD_CODE_ALIAS — append an alias's whole inline code area to the + // code buffer. R = offset of the loaded area (a link-time constant when + // the boot size is fixed). + struct LoadCodeAliasArgs { + VmVal aliasId; // io[4] + }; + VmProgram& sysLoadCodeAlias(LoadCodeAliasArgs a); + + // SYS_SCHEDULE_ALIAS — queue a future run of an alias's inline program + // (must be ALIAS_OP_INLINE_PROGRAM). Same allowance-carve contract as + // sysSchedule. + struct ScheduleAliasArgs { + VmVal aliasId; // io[4] + VmVal budget; // io[5] + VmVal childAllowance; // io[6] + VmVal inputPtr; // io[7] + VmVal inputLen; // io[8] + VmVal timeUs; // io[9] — wall clock microseconds (0 = next tick) + }; + VmProgram& sysScheduleAlias(ScheduleAliasArgs a); + + // Generic syscall dispatch. Populates io[3] with `syscallNum`, then + // emits OP_SET for each (slot, value) pair in `slots`, then OP_HOSTX. + // Use this for one-off tests where adding a typed wrapper above is + // overkill. + VmProgram& syscall( + uint64_t syscallNum, + std::initializer_list> slots); + + // Variadic syscall dispatch — emits a single OP_HOSTV / OP_HOSTXV + // opcode that populates io[3] with `syscallNum` and io[4..4+N-1] + // with the args (in the order given). More compact than the equivalent + // OP_SET chain; gas cost is identical. + // + // Use `hostxv()` for the common "abort if the syscall's S register + // is nonzero on return" semantics (matches OP_HOSTX). Use `hostv()` + // if the caller wants to inspect S manually after the dispatch. + // + // The args.size() must be <= CESVM_MAX_HOSTV_ARGS (currently 16); + // builder time enforces this so a program that overflows can't be + // built. The VM also checks at runtime and returns CESVM_SEGFAULT + // if a hand-crafted byte sequence exceeds the cap. + VmProgram& hostv(uint64_t syscallNum, + std::initializer_list args); + VmProgram& hostxv(uint64_t syscallNum, + std::initializer_list args); + + // ========================================================================= + // Build-out + // ========================================================================= + + // Code-base offset for label resolution. A program destined to run at + // a nonzero base address (a bundle body entered at 210 behind the boot + // loader; see lang/bundle.h) sets this before build-out: every label + // target resolves to (offset + base) so jumps land correctly at run + // time. The emitted bytes do not move, only the resolved targets. + // Computed jmpr/callr addresses are the program's own responsibility. + // buildBootBlock() rejects a nonzero base (a boot block runs at 0). + void setBaseOffset(uint64_t base) { baseOffset_ = base; } + + // Current code size in bytes. Grows as opcodes are appended. + size_t size() const { return code_.size(); } + + // Build into a 210-byte AssetData boot block — the deployment shape + // for code that lives in a single asset's content cell. Throws if: + // - any label was referenced but never placed. + // - the code is larger than AssetData::size() (210 bytes). + // The output is zero-padded to the full 210 bytes. + AssetData buildBootBlock(); + + // Build into a byte vector of the minimum needed size. The + // in-memory shape used for direct CesVM execution and for chained + // library code (loaded via SYS_LOAD_CODE). Throws if any label was + // referenced but never placed, or if the code is larger than + // CESVM_MAX_CODE (8192 bytes). + ces::Bytes buildBytes(); + +private: + // Raw bytecode accumulator. + ces::Bytes code_; + + // Labels: index == label id. Value is either an offset into code_ + // (if placed) or UINT64_MAX (if still unplaced). + std::vector labels_; + + // Forward-reference patch sites. Each record says "at `offset` in + // code_, there are two bytes that should be overwritten with the + // little-endian offset of labels_[labelId] when build-out runs." + struct Reloc { + size_t offset; + size_t labelId; + }; + std::vector relocs_; + + // Tracks whether the most recent emitted opcode was OP_TERM or + // OP_ABORT. Used by ifThenElse to elide an unreachable merge jmp + // when the then-body ends in a terminator. Updated only inside + // emitOpcode(), so operand bytes pushed via emitVal/emitLabelRef + // never affect the flag (an operand byte that happens to equal + // OP_TERM by coincidence cannot trigger a false positive). + bool lastWasTerminator_ = false; + + // Bump cursor for the scratch region allocator. Initialized to the + // first cell past the named registers; advances by alloc() each + // time a region is requested. allocAt() does not move it. + uint64_t scratchTop_ = CESVM_REG_SIZE; + + // Added to every resolved label target at build-out. See setBaseOffset. + uint64_t baseOffset_ = 0; + + // --- Internal emit helpers --- + + void emitByte(uint8_t b); + void emitOpcode(uint8_t op); + // Emit one operand in the shortest control-byte form that fits. + void emitVal(VmVal v); + // Emit a 2-byte label reference placeholder and record a reloc. + void emitLabelRef(VmLabel l); + + // Resolve all pending relocs against the labels table, or throw if + // any label is unplaced. + void resolveLabelsOrThrow(); +}; + +// Exception type for builder errors. Catch std::runtime_error to be +// transport-agnostic. +class VmProgramError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +} // namespace ces diff --git a/vendor/ces/include/ces/util/wallet.h b/vendor/ces/include/ces/util/wallet.h new file mode 100644 index 0000000..3493f23 --- /dev/null +++ b/vendor/ces/include/ces/util/wallet.h @@ -0,0 +1,203 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ces { + +namespace fs = std::filesystem; + +// ============================================================================= +// Wallet — key management with file persistence +// ============================================================================= + +class Wallet { +public: + Wallet() = default; + + // ---- Key loading ---- + + // Load a key from its qualified hex string (66 chars = 2-char algo prefix + + // 64-char private key, or 64 chars = raw ED25519 private key). + static KeyPair loadKey(const std::string& hex); + + // Algo prefix string for a KeyAlgo. + static std::string algoPrefix(KeyAlgo algo); + + // Human label for a KeyPair's algorithm. + static const char* algoLabel(const KeyPair& kp); + + // ---- Wallet file I/O ---- + + // Load keys from a wallet file (one qualified hex key per line). + void loadFromFile(const fs::path& path); + + // Load keys from a string of colon-separated qualified hex keys. + void loadFromString(const std::string& colonSeparated); + + // Save all keys to a wallet file (with secure permissions). + void saveToFile(const fs::path& path) const; + + // ---- Key management ---- + + // Generate count new keys of the given algorithm. Returns the index of the + // first new key. + int generate(int count = 1, KeyAlgo algo = KeyAlgo::ED25519, + const std::string& label = ""); + + // Add a key (raw 64-char hex or qualified 66-char hex). Returns true if + // the key was new (not a duplicate). + bool addKey(const std::string& hex, KeyAlgo defaultAlgo = KeyAlgo::ED25519, + const std::string& label = ""); + + // Number of keys in the wallet. + int size() const { return static_cast(keys_.size()); } + + // Check if empty. + bool empty() const { return keys_.empty(); } + + // Get the qualified hex string at index. + const std::string& keyHex(int index) const { return keys_.at(index); } + + // Get a KeyPair at index. + KeyPair keyPair(int index) const { return loadKey(keys_.at(index)); } + + // All qualified hex strings. + const std::vector& keys() const { return keys_; } + + // ---- Labels ---- + + // Get/set label for a key at index. + const std::string& label(int index) const { return labels_.at(index); } + void setLabel(int index, const std::string& label) { labels_.at(index) = label; } + + // Find first key index with the given label, or -1 if not found. + int findByLabel(const std::string& label) const; + + // ---- Key resolution ---- + + // Resolve a key reference: "@0" → public key hex of wallet key 0, + // otherwise returns the input unchanged (assumed to be a public key hex). + std::string resolveKey(const std::string& input) const; + + // Resolve an actor key: if actorArg is given, find it in the wallet and + // return the KeyPair. If empty, return the first key. Throws if no keys. + KeyPair resolveActor(const std::string& actorArg = "") const; + +private: + std::vector keys_; + std::vector labels_; + + void addKeyUnique(const std::string& key, const std::string& label = ""); +}; + +// ============================================================================= +// Parsing helpers — hex, asset keys, asset content +// ============================================================================= + +// Check if a string is exactly 64 hex characters. +bool is32ByteHex(const std::string& s); + +// Parse a string as a 32-byte asset key (hex or UTF-8 padded). +Hash parseAssetKey(const std::string& input); + +// Parse a string as 210-byte asset content (hex or UTF-8 padded). +AssetData parseAssetContent(const std::string& input); + +// Parse a hex string as 210-byte asset content (up to 420 hex chars = 210 bytes). +AssetData parseHexContent(const std::string& hexStr); + +// Convert asset content to a display string (UTF-8 if printable, else hex). +std::string contentToDisplayString(const AssetData& data); + +// ============================================================================= +// Network helpers — resolveServer / resolveTcp / probeServer / ServerProbe +// have moved into ces/utils.h as ces::Resolver::resolveUdp / +// ces::Resolver::resolveTcp / ces::Resolver::probe / ces::Resolver::Probe. +// ============================================================================= + +// ============================================================================= +// ClientSession — RAII wrapper for CesClient connect/disconnect +// ============================================================================= + +class ClientSession { +public: + /** UDP mode: connect directly to server. `tries` is applied BEFORE the + * constructor's connect()/handshake — pass it here, not via a later + * setTries(), or the initial connect uses the default retry count. */ + ClientSession(bool cacheOnly, uint16_t port, + const boost::asio::ip::udp::endpoint& ep, + const KeyPair* kp = nullptr, int tries = 3); + + /** TCP proxy mode: connect through a MinxProxy. */ + ClientSession(bool cacheOnly, + const boost::asio::ip::tcp::endpoint& proxyEp, + const KeyPair* kp = nullptr, int tries = 3); + + /** Auto-detect mode: probe server, use TCP if proxy, UDP otherwise. */ + ClientSession(bool cacheOnly, const std::string& serverStr, + const KeyPair* kp = nullptr, int tries = 3); + + ~ClientSession(); + + // Non-copyable, non-movable. + ClientSession(const ClientSession&) = delete; + ClientSession& operator=(const ClientSession&) = delete; + + CesClient& client() { return *client_; } + const CesClient& client() const { return *client_; } + bool isTcp = false; + +private: + std::optional client_; +}; + +// ============================================================================= +// Mining helper +// ============================================================================= + +// Result of a single mine-and-submit cycle. +struct MineResult { + bool success = false; + uint64_t credit = 0; + int status = 0; // last proveWork return code +}; + +// Mine once: find a solution and submit it. Blocks until complete or +// interrupted (ces::notInterrupted). Calls statusCallback with the +// proveWork return code on each submission attempt (may be called +// from the same thread). +// +// If progressCallback is set and chunkIters > 0, the search runs in +// chunkIters-sized nonce windows and progressCallback(hashesTried) fires after +// each unsolved window, so a long solve can report live progress. With either +// unset the search is a single unbounded call (the default; no progress). +MineResult mineOnce(CesClient& client, int extraDifficulty = 1, + const std::map& appData = {}, + int numThreads = 1, + std::function statusCallback = nullptr, + std::function progressCallback = nullptr, + uint64_t chunkIters = 0); + +} // namespace ces diff --git a/vendor/ces/include/ces/webadmin.h b/vendor/ces/include/ces/webadmin.h new file mode 100644 index 0000000..9bccc3e --- /dev/null +++ b/vendor/ces/include/ces/webadmin.h @@ -0,0 +1,204 @@ +#pragma once + +/** + * WebAdmin — the CES server's localhost web dashboard. + * + * A small, single-connection-at-a-time HTTP/1.1 server embedded in the + * `ces` server binary. It is the operator's "experience center": peering, + * minting, ledger lookups, billing, and a live log tail — the things you'd + * otherwise reach for cesh/cesqt to do while a server is running. + * + * SECURITY MODEL: there is NO authentication. The dashboard binds to a + * loopback address by design; reach it by SSH-tunneling into the host + * (e.g. `ssh -L 8080:127.0.0.1:8080 host`). Never bind it to a public + * interface. + * + * Enable in server config: + * web_port = 8080 # 0 = disabled (default) + * web_bind = "127.0.0.1" # loopback only + * + * Architecture mirrors Cesco: one acceptor + a per-connection session, on + * its own io_context/thread. Fast endpoints answer on that thread; the two + * blocking operations (remote inspect / mine) run on an ephemeral worker + * thread and post their response back, so the UI never freezes. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ces { + +class CesServer; +class WebAdmin; + +// Bounded in-memory ring of recent server log lines. A Boost.Log sink that +// WebAdmin installs on the logging core pushes finished lines here; the web +// thread reads them for the live "Logs" tab. Thread-safe. Each line carries +// a monotonic sequence number and a unix timestamp (seconds). +class LogRing { +public: + struct Line { + uint64_t seq = 0; + uint64_t ts = 0; // unix seconds + std::string text; + }; + + static LogRing& instance(); + + // Append one finished log line (already formatted). Called from logging + // threads under the sink's serialization plus this ring's own mutex. + void push(std::string text); + + // Return the lines with seq > sinceSeq (oldest first) and report the + // current high-water sequence in outHi. A sinceSeq of 0 returns the whole + // retained window. + std::vector since(uint64_t sinceSeq, uint64_t& outHi) const; + +private: + mutable std::mutex mu_; + std::deque lines_; + uint64_t nextSeq_ = 1; + static constexpr size_t kCap = 2000; +}; + +class WebAdminSession : public std::enable_shared_from_this { +public: + using Socket = boost::asio::ip::tcp::socket; + + WebAdminSession(Socket socket, CesServer& server, WebAdmin& admin); + void start(); + + // ---- WebSocket mode (after a GET /ws upgrade) ---- + // Queue one TEXT frame for delivery. io thread only. + void wsSend(const std::string& text); + // Close the socket and unregister (idempotent). io thread only. + void wsClose(); + bool isWs() const { return ws_; } + std::set& wsSubs() { return wsSubs_; } + // Push new log lines (> the client's last seq) if this client subscribed + // with logs-on. Called from WebAdmin's 1s tick; sends only when there is + // something new. io thread only. + void wsPushLogs(); + // View-table subscriptions (peers/billing/compute tabs). A subscribed view's + // payload is pushed when its hash changed for this client. io thread only. + bool wsWatchesView(const std::string& v) const { return wsViews_.count(v) > 0; } + void wsPushView(const std::string& view, const std::string& msg, size_t hash); + +private: + void doRead(); + bool requestComplete(); + void handleRequest(); + void route(const std::string& method, const std::string& path, + const std::string& query, const std::string& body); + void respond(int status, const std::string& contentType, + const std::string& body); + void respondJson(const std::string& json); + // Run blocking work (remote inspect/mine) off the io thread; the result + // string is posted back and sent as the JSON response. + void runAsync(std::function work); + + // ---- WebSocket internals (RFC 6455, TEXT frames) ---- + // Upgrade the parsed GET /ws request; leftover buffered bytes become the + // start of the WS stream. + void wsUpgrade(); + void wsRead(); + // Consume complete frames from wsIn_; false = protocol error (close). + bool wsProcessBuffer(); + void wsHandleMessage(const std::string& text); + void wsWriteNext(); + + Socket socket_; + CesServer& server_; + WebAdmin& admin_; + std::array readChunk_; + std::string request_; + size_t headerEnd_ = 0; // index just past "\r\n\r\n" (0 = not found yet) + size_t contentLength_ = 0; + bool responded_ = false; + + bool ws_ = false; // session upgraded to WebSocket + bool wsClosed_ = false; + std::string wsIn_; // raw bytes not yet framed + std::string wsFrag_; // fragmented-message assembly + uint8_t wsFragOp_ = 1; + std::deque wsOutQ_; + bool wsWriting_ = false; + std::set wsSubs_; // extension panels this client watches + bool wsLogsSub_ = false; // logs-on: push new log lines each tick + uint64_t wsLogsSeq_ = 0; // last log seq delivered to this client + std::set wsViews_; // view-on subscriptions + std::map wsViewHash_; // last pushed payload per view +}; + +class WebAdmin { +public: + WebAdmin(boost::asio::io_context& io, CesServer& server); + ~WebAdmin(); + + // Bind + start accepting. Returns true on success. Installs the log sink + // and registers as the server's extension-panel push sink. A non-loopback + // bindAddr is REFUSED (returns false) unless allowPublic is true: the + // dashboard has no auth, so exposing it must be an explicit operator choice. + bool listen(const std::string& bindAddr, uint16_t port, bool allowPublic = false); + + // Stop accepting, close the acceptor and any WebSocket sessions, remove + // the log sink and the push sink. + void stop(); + + // The TCP port actually bound. Equals the requested port, or the + // OS-assigned one when listen() was called with port 0 (tests). 0 if not + // listening. + uint16_t boundPort() const { return boundPort_; } + + // ---- WebSocket push lane (sessions call these on the io thread) ---- + void wsRegister(const std::shared_ptr& s); + void wsUnregister(WebAdminSession* s); + // hello/bye for one extension's panel; drives per-extension watch counts + // (0 <-> >0 transitions toggle the child's change-detect tick). + void wsSubscribe(WebAdminSession* s, const std::string& ext); + void wsUnsubscribe(WebAdminSession* s, const std::string& ext); + + // Relay an unsolicited panel frame to subscribed clients. Any thread + // (posts onto the io thread). + void panelPush(const std::string& name, const std::string& frame); + + // Payload for a pushed tab view ("peers"/"billing"/"compute"; "" = unknown). + // io thread only (same builders the GET endpoints run on this thread). + std::string viewData(const std::string& view); + +private: + void doAccept(); + // 1s cadence while at least one WS client is connected: push a status + // frame (replaces the browser heartbeat poll), log/view deltas to their + // subscribers, and re-assert panel watches (idempotent; heals a relaunched + // extension child). + void armStatusTimer(); + void statusTick(); + // Build each subscribed-anywhere view once, push to sessions whose hash + // changed. + void viewsTick(); + + boost::asio::io_context& io_; + CesServer& server_; + std::unique_ptr acceptor_; + uint16_t boundPort_ = 0; + bool logSinkInstalled_ = false; + + // WS state. io thread only (sessions live on the one io_context). + std::map> wsSessions_; + std::map wsWatch_; // ext -> subscriber count + std::unique_ptr statusTimer_; + bool stopping_ = false; +}; + +} // namespace ces diff --git a/vendor/ces/src/ces/main.cpp b/vendor/ces/src/ces/main.cpp new file mode 100644 index 0000000..be0c6f3 --- /dev/null +++ b/vendor/ces/src/ces/main.cpp @@ -0,0 +1,1356 @@ +/** + * ces - A Hashcash server built on MINX + * + * Supports TOML config file (--config file.toml) and CLI switches. + * CLI switches override config file values. + * --config without argument dumps default config to stdout and exits. + */ + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +using namespace ces; + +static const std::string DEFAULT_DATA_DIR = "./data"; +static const std::string DEFAULT_PRIV_KEY_HEX_STR = + "3fdade772f129d5b43e36fab610c77db6a4a697e9d0899b24b4254f0968aa7b5"; + +// Config default values are the DEFAULT_* / BASE_FEE_* constants in +// (namespace ces) -- the single source the CesConfig struct, +// the CLI option defaults below, and dumpDefaultConfig() all read. DATA_DIR +// and the dump's sample key are main-only, so they stay here. + +void dumpKeyPair(const KeyPair& keyPair) { + std::cout << "Private Key: " << keyPair.getPrivateKeyHexStr() << std::endl; + std::cout << "Public Key: " << keyPair.getPublicKeyHexStr() << std::endl; + exit(0); +} + +// Dump default config to stdout. +void dumpDefaultConfig() { + int defaultThreads = + static_cast(std::thread::hardware_concurrency()) / 2 - 2; + if (defaultThreads < 1) defaultThreads = 1; + + // Mint a fresh key for this dump so the emitted config is unique and + // runnable as-is, never a shared well-known one. + std::string freshServerKey = KeyPair().getPrivateKeyHexStr(); + + std::cout << R"(# CES Server Configuration +# +# Generated from the binary: this template lists every knob THIS build +# understands, set to its default value. Dump it, edit, then run: +# ces --config > server.toml # dump this template +# ces --config server.toml # run with it +# CLI flags override file values. -1 on a fee means "use the built-in default". +# +# CRUCIAL: the rpc-port L2 protocols (file / lua / compute / peer) are wired in +# the [cesplex_mounts] table further down -- that map is what turns them on. +# It's a TOML table, so it has to live below the top-level keys. + +# Log level: trace, debug, info, warning, error, fatal +log_level = "info" + +# Data directory for account/asset persistence +data_dir = ")" << DEFAULT_DATA_DIR << R"(" + +# Server UDP port +port = )" << DEFAULT_PORT << R"( + +# Server private key (32-byte hex). This value is freshly generated on every +# `ces --config` dump, so this file is unique and ready to run as-is -- it is a +# real, usable key, not a shared placeholder. Replace it only if you already +# have one (e.g. from ces --genkeypair). +server_key = ")" << freshServerKey << R"(" + +# Minimum proof-of-work difficulty: the per-solution hash floor. Higher +# mints scarcer credit and costs more CPU per solution. +min_difficulty = )" << static_cast(DEFAULT_MIN_DIFF) << R"( + +# Delay in seconds before accepting PoW after startup (0 = immediate). A +# delay lets the RandomX dataset finish initializing before solutions count. +pow_delay = )" << DEFAULT_POW_DELAY << R"( + +# Size of the PoW double-spend tracking slots in seconds +spend_slot_size = )" << DEFAULT_SPEND_SLOT_SIZE << R"( + +# Don't create the RandomX verifier (no mining). true = a pure L2/file/ +# compute box that serves no signed main-port ops and accepts no mints +# (instant boot, no RandomX RAM). Leave false for any node that mints. +no_pow_engine = false + +# Use cache-only RandomX: ~256 MB instead of the full ~2.5 GB dataset, at +# slower verification. true only when RAM is tight. +cache_only_pow = false + +# Number of task processing threads (default = hardware_concurrency/2 - 2). +threads = )" << defaultThreads << R"( + +# Account DB capacity (power of 2). min = reserved at boot, max = hard cap. +# RAM is roughly 64 bytes/account plus hash-map load factor (2^24 = 16M +# accounts ~ 1.2 GB). +min_accounts = )" << DEFAULT_MIN_ACC << R"( +max_accounts = )" << DEFAULT_MAX_ACC << R"( + +# Asset DB capacity (power of 2). RAM is ~256 bytes/asset plus load factor. +min_assets = )" << DEFAULT_MIN_ASSET << R"( +max_assets = )" << DEFAULT_MAX_ASSET << R"( + +# Alias DB capacity (power of 2). RAM is ~64 bytes/alias plus load factor. +min_aliases = )" << DEFAULT_MIN_ALIAS << R"( +max_aliases = )" << DEFAULT_MAX_ALIAS << R"( + +# Minimum value delta before flushing to OS buffers. 0 = flush every change +# (max durability); a larger batch cuts write syscalls under load. +flush_value = )" << DEFAULT_FLUSH_VALUE << R"( + +# Max events log (WAL) size in GB before an auto-snapshot compacts it +# (0 = disable). Smaller compacts more often (faster restart-replay) at the +# cost of more snapshot forks. +max_log_size_gb = )" << DEFAULT_MAX_LOG_SIZE_GB << R"( + +# Fees (internal units). -1 = use the value compiled into this binary; the +# inline comment shows what that built-in default currently is. +fee_account = -1 # built-in: )" << ces::BASE_FEE_ACCOUNT << R"( +fee_asset = -1 # built-in: )" << ces::BASE_FEE_ASSET << R"( +fee_tx = -1 # built-in: )" << ces::BASE_FEE_TRANSACTION << R"( +fee_query = -1 # built-in: )" << ces::BASE_FEE_QUERY << R"( +fee_vm_mult = -1 # built-in: )" << ces::BASE_FEE_VM_MULT << R"( + +# Server's public address (optional, for peer discovery) +# If set, included in PoW submissions to peer servers. +# server_name = "myserver.example.com:53830" + +# Peering: target credit balance to maintain on each peer server +peer_target = )" << DEFAULT_PEER_TARGET << R"( + +# Inbound PoW reciprocation (basis points): outbound PoW we mine per unit of +# inbound PoW a peer mines on us. 0 = off (never mine on inbound-only peers). +# 10000 = 1:1, 20000 = 2x, 5000 = half. Outbound peers ignore this. +peer_pow_inbound_reciprocation_bps = )" << DEFAULT_PEER_POW_INBOUND_RECIPROCATION_BPS << R"( + +# Peer-table size: peers persisted to disk and exposed to ces.peers(). The number +# held in RAM is 3x this (headroom so grief-banned tombstones do not crowd out live +# peers). Small values bound each node's candidate view; raise for larger meshes. +max_peers = )" << DEFAULT_MAX_PEERS << R"( + +# Async settlement max retries per operation (1 = no retries, for testing) +settlement_max_retries = )" << CesClientAsync::DEFAULT_MAX_RETRIES << R"( + +# Max reserve (raw) one operation may spend at one peer. Caps each gossip +# fan-out leg; the unallocated remainder reverts to the originator. +# 0 = uncapped (only the reserve on hand bounds a leg). +max_peer_reserve_disturbance = )" << DEFAULT_MAX_PEER_RESERVE_DISTURBANCE << R"( + +# Peers each gossip hop forwards to: a random subset of this size from the +# reachable peers we hold reserve with. 0 = forward to every peer. +gossip_fanout_degree = )" << DEFAULT_GOSSIP_FANOUT_DEGREE << R"( + +# Admin console (Unix domain socket). Empty or omitted = disabled. +# admin_socket = "./admin.sock" + +# Web dashboard (HTTP). Loopback only, NO authentication — reach it by +# SSH-tunneling to the host (e.g. ssh -L 8080:127.0.0.1:8080 host). The +# operator's control panel: peering, minting, lookups, billing, live log +# tail, and the server-info "hello" banner. 0 = disabled (default). +# web_port = 0 +# web_bind = ")" << DEFAULT_WEB_BIND << R"(" + +# Dedicated MINX/RUDP UDP port for CesPlex -- the gateway for the ENTIRE +# L2 stack (file / lua / compute / peer mesh) plus the SYS_RPC syscall. +# 0 = disabled: no second Minx, no L2 binds, no peer mesh, SYS_RPC returns +# CES_ERROR_DISABLED. Nonzero binds a second Minx here; open or close it at +# the firewall independently from the main port. When up, which protocols +# are actually served is decided entirely by [cesplex_mounts] below -- nothing +# auto-mounts. +# The conventional CES rpc port is )" << DEFAULT_RPC_PORT << R"( (the main port + 1, +# DEFAULT_RPC_PORT). Clients assume it when an address names no port (cwb's +# file:// compute:// lua:// default here), so serve on it unless you have a +# reason not to. Both ports sit in the IANA dynamic range (49152-65535, +# RFC 6335), which is never assigned to protocols. +rpc_port = 0 + +# SYS_RPC outbound flow control (only relevant when rpc_port != 0). +# - rpc_max_pending: cap on concurrent outbound calls; queueRpc returns +# CES_ERROR_QUEUE_FULL beyond this. +# - rpc_max_request_bytes / rpc_max_response_bytes: size caps per call. +# - rpc_response_timeout_ms: per-call asio timer firing CES_ERROR_TIMEOUT. +# - rpc_rudp_bytes_per_second / rpc_rudp_burst_bytes: per-channel RUDP +# pacing advertised in the handshake. 4294967295 = unlimited. +rpc_max_pending = )" << DEFAULT_RPC_MAX_PENDING << R"( +rpc_max_request_bytes = )" << DEFAULT_RPC_MAX_REQUEST_BYTES << R"( +rpc_max_response_bytes = )" << DEFAULT_RPC_MAX_RESPONSE_BYTES << R"( +rpc_response_timeout_ms = )" << DEFAULT_RPC_RESPONSE_TIMEOUT_MS << R"( +rpc_rudp_bytes_per_second = )" << DEFAULT_RPC_RUDP_BYTES_PER_SECOND << R"( +rpc_rudp_burst_bytes = )" << DEFAULT_RPC_RUDP_BURST_BYTES << R"( + +# (The [rpc_rudp] sub-table for these transport caps is at the end, with the +# other TOML tables -- all top-level keys must come before any [table] header.) + +# --- File-storage feature (CesPlex builtin:file) --- +# File storage is integral to the system, so its knobs are active below (not +# commented). They still read as defaults; editing them is the common case. +# +# Metered-storage cap (bytes) for the user zones /h/ /f/ /p/. This is NOT the +# on/off switch -- the file handler is on iff /ces/file/1 is wired in +# [cesplex_mounts]. 0 = no metered user storage (CREATE in those zones is +# rejected); the unmetered /s/ zone (extensions, operator content) still works +# whenever file is mounted. >0 = allow metered storage up to this many bytes. +file_store_max_bytes = 0 + +# Storage directory. Empty = "/cesfilestore". +file_store_dir = "" + +# Read-only catalog of installable extensions (single .lua files). The +# Extensions page lists these as available. Empty = no catalog. +extensions_dir = "" + +# Extension funding budget: global rate (raw credit units/day, over all +# extensions and remotes) the server grants /s/ programs that call +# ces.request_funds. The discovery extension needs it. 0 = off. +ext_funding_per_day = )" << DEFAULT_EXT_FUNDING_PER_DAY << R"( + +# Local extension budget: raw credit units each /s/ program account is topped +# up to (per extension) on boot and at daily maintenance. 0 = off. +ext_local_budget = )" << DEFAULT_EXT_LOCAL_BUDGET << R"( + +# File fees. -1 = derive at startup (the math is shown inline per knob). +# CREATE has no per-byte cost; rent accrues from the file's file_balance. +fee_file_rent = -1 # when -1: fee_asset / 100 / 256 (retention, per byte-day) +fee_file_write = -1 # when -1: fee_file_rent * 9 (write, per KB) +fee_file_read = -1 # when -1: fee_file_rent / 8 (read, per KB) + +# --- Compute feature (CesPlex builtin:compute) --- +# +# Mounted iff /ces/compute/1 is wired in [cesplex_mounts]. It also requires +# builtin:file mounted (it uses the file handler for owner-authority file ops) +# and compute_user to exist on the host; if a wired compute is missing those +# it stays inert (logged at boot), it does not crash. compute_max_instances is +# the admission cap: 0 = mounted but admits no instances; >0 caps child +# processes (× mem cap = total RAM bound). +# compute_max_instances = 0 +# +# L2 compute program UDP port range: [compute_port_base, +# compute_port_base + compute_port_count - 1]. Each instance binds its +# outbound CES client to a server-assigned port from this range — the +# child never picks an ephemeral port (a firewalled L2 host opens only +# known ports). Base and count are independent of compute_max_instances; +# open exactly [base, base+count-1] at the firewall to match. base = 0 = +# no range: instances run local-only and their outbound network verbs +# fail with "networking disabled". An exhausted range is NOT a launch +# failure — those instances just run local-only too. +# compute_port_base = 0 +# compute_port_count = 0 +# +# Per-process memory ceiling — RLIMIT_AS in the child (the kernel denies +# allocations past it; OOM is an instant, machine-wide attack vector). +# With compute_max_instances this bounds total compute memory. CPU is +# billed, not capped; the sandbox forbids forking. +# compute_process_mem_max = )" << DEFAULT_COMPUTE_PROCESS_MEM_MAX << R"( # bytes (RLIMIT_AS per process) +# +# Worker threads each Lua child uses for outbound ces.file_client / +# ces.compute_client calls — how many such round-trips can be in flight before +# more queue. The main-port client pool (ces.ping / ces.remote_*) is fixed at 1. +# compute_client_pool_size = )" << DEFAULT_COMPUTE_CLIENT_POOL_SIZE << R"( # clamped 1..64 +# +# Fees — credits per unit time / per byte. -1 = use default. +# fee_compute_cpu_sec — CPU time, per second (unused in stub phase) +# fee_compute_rss_byte_day — RSS, per byte per second (unused in stub phase) +# fee_compute_net_byte — outbound APPLICATION bytes, per byte +# fee_compute_slot_sec — nominal "occupy a slot" fee, per wall-second. +# Only fee actually charged in the stub phase. +# Default: rent on a 1 KB file per second +# (derived from fee_file_rent). +# fee_compute_cpu_sec = -1 # when -1: 5000000 (flat) +# fee_compute_rss_byte_day = -1 # when -1: fee_asset / 256 +# fee_compute_net_byte = -1 # when -1: 0 (reserved, unused) +# fee_compute_slot_sec = -1 # when -1: fee_asset / 86400 +# fee_bucket_byte_sec = -1 # when -1: fee_compute_rss_byte_day / 86400 +# +# ChannelMeter (CesPlex net metering) rates. Throughput is metered per KiB so +# the rate can sit below 1 raw/byte. 0 is a sentinel, not "free": the server +# derives a non-zero default from the ledger anchors at startup, so metering +# stays on. An explicit non-zero overrides (small = cheap, large = dear). +# fee_net_kib_sent = 0 # when 0: fee_file_rent / 2 +# fee_net_kib_received = 0 # when 0: fee_net_kib_sent +# fee_net_channel_sec = 0 # when 0: fee_asset / 86400 +# fee_net_mem_byte_day = 0 # when 0: fee_asset / 256 +# +# Storage dir for per-instance scratch / IPC sockets. +# compute_work_dir = "" # default "/cescompute/" +# +# Unix user cesluad child processes drop to (must exist on host). +# compute_user = ")" << DEFAULT_COMPUTE_USER << R"(" +# +# Compute child binary path. Empty/unset (default) = auto-discover +# `cesluajitd` next to ces's own binary (/proc/self/exe), then fall +# back to bare-name PATH lookup. Set explicitly to use a specific +# binary or pin a path that's not next to ces. +# compute_child_binary = "" +)" +#ifdef CES_MAIL + << R"(# +# Outbound mail relay (ces.mail.send). Empty relay host = mail is logged and +# dropped. Credentials ride AUTH LOGIN; STARTTLS is used when the relay +# advertises it. Secure this file if you set a password. +# mail_relay_host = "" # e.g. "localhost" +# mail_relay_port = 587 +# mail_from = "" # e.g. "no-reply@your-domain.example" +# mail_user = "" # SMTP AUTH user (empty = no AUTH) +# mail_pass = "" # SMTP AUTH password (empty = no AUTH) +# mail_fee_per_mb = 0 # credits burned per MB of outgoing mail +# mail_max_encoded_bytes = 20971520 # 20 MiB cap on one message +)" +#endif + << R"( +# === CesPlex mounts: the crucial L2 wiring (rpc_port only). === +# CesPlex is implementation-agnostic. Each entry wires a protocol name to a +# builtin handler impl, and this map is the ONLY thing that decides what the +# rpc port serves -- nothing auto-mounts. A protocol absent here is not served +# (a badly-wired or empty map just means those features are off). file + peer +# are wired by default. Uncomment lua + compute to enable them; compute also +# needs compute_max_instances > 0, and lua is the dial-in relay for compute +# instances. (This is a TOML table, so it must sit below all top-level keys.) +[cesplex_mounts] +"/ces/file/1" = "builtin:file" +"/ces/peer/1" = "builtin:peer" +# "/ces/lua/1" = "builtin:lua" +# "/ces/compute/1" = "builtin:compute" + +# Transport caps on the rpc_port's RUDP (a TOML sub-table). max_channels_per_peer +# is a CES opinion (default 2 — cesh/cesqt typically want a long-lived stream +# open while issuing other ops). The two reorder caps bound per-channel +# reassembly; -1 = leave the library default (currently 1 MB / 1024 messages). +# channel_idle_secs: a channel idle this long is GC'd; raise it for long-lived +# interactive dial terminals where a human pauses between commands. +[rpc_rudp] +max_channels_per_peer = )" << DEFAULT_RPC_RUDP_MAX_CHANNELS_PER_PEER << R"( +max_reorder_bytes_per_channel = )" << DEFAULT_RPC_RUDP_MAX_REORDER_BYTES << R"( +max_reorder_msgs_per_channel = )" << DEFAULT_RPC_RUDP_MAX_REORDER_MSGS << R"( +channel_idle_secs = )" << DEFAULT_RPC_RUDP_CHANNEL_IDLE_SECS << R"( + +# /s/ extensions — operator-deployed Lua programs that get +# autolaunched at boot. Drop dice.lua / chat.lua / etc. into +# /s/; the server auto-generates the sidecar (owner = +# server pubkey, file_balance = 0, /s/ is unmetered) and, for any +# name listed below as truthy, launches one cesluajitd instance. +# Programs in /s/ run with owner = server, so ces.transfer pulls +# from the server's bottomless auto-topped account. +# +# Names are arbitrary; the value just needs to be truthy. +# Requires: rpc_port > 0, builtin:file with file_store_max_bytes > 0, +# and builtin:compute with compute_max_instances > 0. +# [extension] +# dice = 1 # /s/dice.lua +# discovery = 1 # /s/discovery.lua (network registry crawler) +# chat = 1 # /s/chat.lua, etc. + +# Peer servers +# [[peers]] +# key = "public_key_hex_of_peer_server" +# address = "host:port" +# +# [[peers]] +# key = "another_peer_key" +# address = "host2:port2" +)"; +} + +int main(int argc, char* argv[]) { + blog::enable("minx"); + blog::enable("powengine"); + + // -- CLI Options -- + std::string optLogLevel; + std::string optDataDir; + uint16_t optServerPort; + std::string optServerPrivKey; + uint64_t optMinAcc; + uint64_t optMaxAcc; + uint64_t optMinAsset; + uint64_t optMaxAsset; + uint64_t optMinAlias; + uint64_t optMaxAlias; + uint8_t optMinDiff; + uint64_t optPoWDelay; + uint64_t optSpendSlotSize; + std::string optGeneratePubKey; + uint64_t optFlushValue = DEFAULT_FLUSH_VALUE; + uint64_t optMaxLogSizeGB = DEFAULT_MAX_LOG_SIZE_GB; + int64_t optFeeAccount = -1; + int64_t optFeeAsset = -1; + int64_t optFeeTx = -1; + int64_t optFeeQuery = -1; + int64_t optFeeVmMult = -1; + bool optGenerateKeyPair = false; + bool optNoPowEngine = false; + bool optNoFeeDiscount = false; + bool optCacheOnlyPoWEngine = false; + int defaultOptTaskThreads = + static_cast(std::thread::hardware_concurrency()) / 2 - 2; + int optTaskThreads = defaultOptTaskThreads; + if (optTaskThreads < 1) optTaskThreads = 1; + std::string optConfigFile; + std::string optServerName; + uint64_t optPeerTarget = DEFAULT_PEER_TARGET; + uint64_t optPeerPowInboundReciprocationBps = DEFAULT_PEER_POW_INBOUND_RECIPROCATION_BPS; + int optPeerMinerInterval = DEFAULT_PEER_MINER_INTERVAL_SECS; + uint64_t optMaxPeers = DEFAULT_MAX_PEERS; + int optSettlementMaxRetries = CesClientAsync::DEFAULT_MAX_RETRIES; + uint64_t optMaxPeerReserveDisturbance = DEFAULT_MAX_PEER_RESERVE_DISTURBANCE; + uint32_t optGossipFanoutDegree = DEFAULT_GOSSIP_FANOUT_DEGREE; + std::string optAdminSocket; + uint16_t optWebPort = 0; + std::string optWebBind = DEFAULT_WEB_BIND; + bool optWebAllowPublic = false; + uint16_t optRpcPort = 0; + uint32_t optRpcMaxPending = DEFAULT_RPC_MAX_PENDING; + uint64_t optRpcMaxRequestBytes = DEFAULT_RPC_MAX_REQUEST_BYTES; + uint64_t optRpcMaxResponseBytes = DEFAULT_RPC_MAX_RESPONSE_BYTES; + uint32_t optRpcResponseTimeoutMs = DEFAULT_RPC_RESPONSE_TIMEOUT_MS; + uint32_t optRpcRudpBytesPerSecond = DEFAULT_RPC_RUDP_BYTES_PER_SECOND; + uint32_t optRpcRudpBurstBytes = DEFAULT_RPC_RUDP_BURST_BYTES; + uint64_t optRpcRudpMaxChannelsPerPeer = DEFAULT_RPC_RUDP_MAX_CHANNELS_PER_PEER; + int64_t optRpcRudpMaxReorderBytesPerChannel = -1; + int64_t optRpcRudpMaxReorderMsgsPerChannel = -1; + uint32_t optRpcRudpChannelIdleSecs = DEFAULT_RPC_RUDP_CHANNEL_IDLE_SECS; + // File-storage feature (CesPlex builtin:file, v2). + uint64_t optFileStoreMaxBytes = 0; + std::string optFileStoreDir; + std::string optExtensionsDir; + uint64_t optExtFundingPerDay = DEFAULT_EXT_FUNDING_PER_DAY; + uint64_t optExtLocalBudget = DEFAULT_EXT_LOCAL_BUDGET; + int64_t optFeeFileRent = -1; + int64_t optFeeFileWrite = -1; + int64_t optFeeFileRead = -1; + // Compute feature (CesPlex builtin:compute). + uint32_t optComputeMaxInstances = 0; + uint16_t optComputePortBase = 0; // 0 = no range (network off) + uint16_t optComputePortCount = 0; // ports in the range + uint64_t optComputeProcessMemMax = DEFAULT_COMPUTE_PROCESS_MEM_MAX; + uint32_t optComputeClientPoolSize = DEFAULT_COMPUTE_CLIENT_POOL_SIZE; + int64_t optFeeComputeCpuSec = -1; + int64_t optFeeComputeRssByteSec = -1; + int64_t optFeeComputeNetByte = -1; + int64_t optFeeComputeSlotSec = -1; + int64_t optFeeBucketByteSec = -1; + // net meter (ChannelMeter). 0 is a sentinel: the CesServer ctor derives a + // non-zero rate from the ledger anchors (floored to >= 1), so metering stays + // on. Pass an explicit non-zero to override. + uint64_t optFeeNetKiBSent = 0; + uint64_t optFeeNetKiBReceived = 0; + uint64_t optFeeNetChannelSec = 0; + uint64_t optFeeNetMemByteDay = 0; + std::string optComputeWorkDir; + std::string optComputeUser = DEFAULT_COMPUTE_USER; + // Empty default → auto-discover next to /proc/self/exe at startup + // (typical case: ces and cesluajitd are siblings in the same dir). + // Operators who want PATH lookup or an absolute path set it + // explicitly via TOML or --computechildbinary. + std::string optComputeChildBinary; + // /s/ extensions — operator-deployed Lua programs in + // /s/.lua, autolaunched at boot when enabled. + // Names are arbitrary basenames; CLI flag is repeatable. + std::vector optExtensions; +#ifdef CES_MAIL + // Mail relay (SMTP submission for ces.mail.send / builtin:mail). + std::string optMailRelayHost; + uint16_t optMailRelayPort = 587; + std::string optMailFrom, optMailUser, optMailPass; + uint64_t optMailFeePerMB = 0; + uint64_t optMailMaxEncodedBytes = 20ull * 1024 * 1024; +#endif // CES_MAIL + // CesPlex mounts — `proto=target` pairs. Target is + // "builtin:" (statically linked handler). + std::vector optCesplexMounts; + std::vector optPeers; // key@host:port + std::string optCreditAccount, optDebitAccount; + int64_t optCreditAmount = 0, optDebitAmount = 0; + CLI::App* cmd_credit = nullptr; + CLI::App* cmd_debit = nullptr; + CLI::App* cmd_snapshot = nullptr; + + CLI::App app{"ces"}; + try { +#ifndef CES_GIT_HASH +#define CES_GIT_HASH "unknown" +#endif + app.set_version_flag("--version", std::string(CES_GIT_HASH)); + app.add_option("-l,--loglevel", optLogLevel, + "Log level ([t]race, [d]ebug, [i]nfo, [w]arning, [e]rror, [f]atal)") + ->default_val("info"); + app.add_option("-d,--datadir", optDataDir, "Data directory") + ->default_val(DEFAULT_DATA_DIR); + app.add_option("-p,--port", optServerPort, "Server port") + ->default_val(DEFAULT_PORT); + app.add_option("-k,--serverkey", optServerPrivKey, + "Server key (32-byte hex)") + ->default_val(DEFAULT_PRIV_KEY_HEX_STR); + app.add_option("--minacc", optMinAcc, + "Reserved account DB store capacity") + ->default_val(std::to_string(DEFAULT_MIN_ACC)); + app.add_option("--maxacc", optMaxAcc, + "Maximum account DB size") + ->default_val(std::to_string(DEFAULT_MAX_ACC)); + app.add_option("--minasset", optMinAsset, + "Reserved asset DB store capacity") + ->default_val(std::to_string(DEFAULT_MIN_ASSET)); + app.add_option("--maxasset", optMaxAsset, + "Maximum asset DB size") + ->default_val(std::to_string(DEFAULT_MAX_ASSET)); + app.add_option("--minalias", optMinAlias, + "Reserved alias DB store capacity") + ->default_val(std::to_string(DEFAULT_MIN_ALIAS)); + app.add_option("--maxalias", optMaxAlias, + "Maximum alias DB size") + ->default_val(std::to_string(DEFAULT_MAX_ALIAS)); + app.add_option("--flushvalue", optFlushValue, + "Minimum value delta for flushing") + ->default_val(std::to_string(DEFAULT_FLUSH_VALUE)); + app.add_option("--mindiff", optMinDiff, + "Minimum proof-of-work difficulty") + ->default_val(std::to_string(DEFAULT_MIN_DIFF)); + app.add_option("--powdelay", optPoWDelay, + "Delay in seconds before accepting PoW") + ->default_val(std::to_string(DEFAULT_POW_DELAY)); + app.add_option("--spendslotsize", optSpendSlotSize, + "Size of spend db slots in seconds") + ->default_val(std::to_string(DEFAULT_SPEND_SLOT_SIZE)); + app.add_option("--genpubkey", optGeneratePubKey, + "Generate public key from private key")->default_val(""); + app.add_option("--threads", optTaskThreads, + "Number of task processing threads")->default_val(defaultOptTaskThreads); + app.add_option("--maxlogsize", optMaxLogSizeGB, + "Max events log size in GB (0=disable)") + ->default_val(std::to_string(DEFAULT_MAX_LOG_SIZE_GB)); + app.add_flag("--genkeypair", optGenerateKeyPair, + "Generate a new Ed25519 key pair"); + app.add_flag("--nopowengine,-x", optNoPowEngine, + "Don't create the RandomX verifier"); + app.add_flag("--cacheonlypowengine,-c", optCacheOnlyPoWEngine, + "Cache-only RandomX verifier (slower, less RAM)"); + app.add_flag("--nofeediscount", optNoFeeDiscount, + "Disable the load-based fee discount (pin every fee at full price). For " + "tests/benchmarks that need fees to actually bite."); + app.add_option("--feeaccount", optFeeAccount, "Fee for account rent"); + app.add_option("--feeasset", optFeeAsset, "Fee for asset operations"); + app.add_option("--feetx", optFeeTx, "Fee for transactions"); + app.add_option("--feequery", optFeeQuery, "Fee for queries"); + app.add_option("--feevmmult", optFeeVmMult, "VM gas cost multiplier"); + app.add_option("--config", optConfigFile, + "Load TOML config file (no arg = dump default config)") + ->expected(0, 1); + app.add_option("--servername", optServerName, + "Server's public address (e.g. myserver.com:53830)"); + app.add_option("--peertarget", optPeerTarget, + "Credit target on each peer server") + ->default_val(std::to_string(DEFAULT_PEER_TARGET)); + app.add_option("--peerpowinboundreciprocationbps", + optPeerPowInboundReciprocationBps, + "Inbound PoW reciprocation, basis points (0=off, 10000=1:1)") + ->default_val(std::to_string(DEFAULT_PEER_POW_INBOUND_RECIPROCATION_BPS)); + app.add_option("--peerminerinterval", optPeerMinerInterval, + "Seconds between peer miner cycles (default 60; lower for local/dev)") + ->default_val(std::to_string(DEFAULT_PEER_MINER_INTERVAL_SECS)); + app.add_option("--maxpeers", optMaxPeers, + "Peer-table size (persisted/exposed; RAM holds 3x). Small bounds the view") + ->default_val(std::to_string(DEFAULT_MAX_PEERS)); + app.add_option("--settlementretries", optSettlementMaxRetries, + "Async settlement max retries (1 = no retries)") + ->default_val(std::to_string(CesClientAsync::DEFAULT_MAX_RETRIES)); + app.add_option("--maxpeerreservedisturbance", optMaxPeerReserveDisturbance, + "Max reserve (raw) one op may spend at one peer; caps each gossip " + "fan-out leg, remainder refunds the originator (0 = uncapped)") + ->default_val(std::to_string(DEFAULT_MAX_PEER_RESERVE_DISTURBANCE)); + app.add_option("--gossipfanoutdegree", optGossipFanoutDegree, + "Peers each gossip hop forwards to, a random subset of funded peers " + "(0 = forward to every peer)") + ->default_val(std::to_string(DEFAULT_GOSSIP_FANOUT_DEGREE)); + app.add_option("--adminsocket", optAdminSocket, + "Admin console Unix socket path (empty = disabled)")->default_val(""); + app.add_option("--webport", optWebPort, + "Web dashboard port (0 = disabled). Loopback only, no auth — " + "reach it via SSH tunnel.")->default_val("0"); + app.add_option("--webbind", optWebBind, + "Web dashboard bind address (loopback by design)") + ->default_val(DEFAULT_WEB_BIND); + app.add_flag("--web-allow-public", optWebAllowPublic, + "Permit a non-loopback web_bind despite the dashboard having no auth " + "(exposes a credit/debit surface; off by default)"); + app.add_option("--rpcport", optRpcPort, + "Dedicated MINX/RUDP UDP port for the SYS_RPC syscall " + "(0 = disabled)")->default_val("0"); + app.add_option("--rpcmaxpending", optRpcMaxPending, + "SYS_RPC: max concurrent outbound calls in flight") + ->default_val(std::to_string(DEFAULT_RPC_MAX_PENDING)); + app.add_option("--rpcmaxreqbytes", optRpcMaxRequestBytes, + "SYS_RPC: max request body size in bytes") + ->default_val(std::to_string(DEFAULT_RPC_MAX_REQUEST_BYTES)); + app.add_option("--rpcmaxrespbytes", optRpcMaxResponseBytes, + "SYS_RPC: max response body size in bytes") + ->default_val(std::to_string(DEFAULT_RPC_MAX_RESPONSE_BYTES)); + app.add_option("--rpctimeoutms", optRpcResponseTimeoutMs, + "SYS_RPC: per-call response timeout in milliseconds") + ->default_val(std::to_string(DEFAULT_RPC_RESPONSE_TIMEOUT_MS)); + app.add_option("--rpcrudpbps", optRpcRudpBytesPerSecond, + "SYS_RPC: per-channel RUDP pacing rate in bytes/sec " + "(0xFFFFFFFF = unlimited)") + ->default_val(std::to_string(DEFAULT_RPC_RUDP_BYTES_PER_SECOND)); + app.add_option("--rpcrudpburst", optRpcRudpBurstBytes, + "SYS_RPC: per-channel RUDP burst bytes " + "(0xFFFFFFFF = unlimited)") + ->default_val(std::to_string(DEFAULT_RPC_RUDP_BURST_BYTES)); + app.add_option("--rpcrudpmaxchannels", + optRpcRudpMaxChannelsPerPeer, + "rpc_port RUDP: max RUDP channels per peer " + "(CES default 2 — long-lived dial + side ops)") + ->default_val(std::to_string(DEFAULT_RPC_RUDP_MAX_CHANNELS_PER_PEER)); + app.add_option("--rpcrudpmaxreorderbytes", + optRpcRudpMaxReorderBytesPerChannel, + "rpc_port RUDP: per-channel reorder buffer cap in bytes " + "(-1 = library default)") + ->default_val(std::to_string(DEFAULT_RPC_RUDP_MAX_REORDER_BYTES)); + app.add_option("--rpcrudpmaxreordermsgs", + optRpcRudpMaxReorderMsgsPerChannel, + "rpc_port RUDP: per-channel reorder buffer cap in messages " + "(-1 = library default)") + ->default_val(std::to_string(DEFAULT_RPC_RUDP_MAX_REORDER_MSGS)); + app.add_option("--rpcrudpidlesecs", optRpcRudpChannelIdleSecs, + "rpc_port RUDP channel idle-GC timeout in seconds (default 60). A channel " + "with no traffic for this long is dropped; raise it for long-lived " + "interactive dial terminals where a human pauses between commands.") + ->default_val(std::to_string(DEFAULT_RPC_RUDP_CHANNEL_IDLE_SECS)); + app.add_option("--peer", optPeers, + "Peer server as key@host:port (repeatable)"); + +#ifdef CES_MAIL + // --- Mail relay (ces.mail.send / builtin:mail) --- + app.add_option("--mailrelayhost", optMailRelayHost, + "SMTP submission relay host for outbound mail (empty = log + drop)") + ->default_val(""); + app.add_option("--mailrelayport", optMailRelayPort, + "SMTP submission relay port")->default_val("587"); + app.add_option("--mailfrom", optMailFrom, + "From address for outbound mail")->default_val(""); + app.add_option("--mailuser", optMailUser, + "SMTP AUTH LOGIN user (empty = no AUTH)")->default_val(""); + app.add_option("--mailpass", optMailPass, + "SMTP AUTH LOGIN password (empty = no AUTH)")->default_val(""); + app.add_option("--mailfeepermb", optMailFeePerMB, + "Credits burned per MB of outgoing mail (0 = free)")->default_val("0"); + app.add_option("--mailmaxencodedbytes", optMailMaxEncodedBytes, + "Max encoded size of one email, bytes") + ->default_val(std::to_string(20ull * 1024 * 1024)); +#endif // CES_MAIL + + // --- File-storage feature (CesPlex builtin:file) --- + app.add_option("--filestoremaxbytes", optFileStoreMaxBytes, + "File-storage feature max bytes (0 = feature off, >0 = hard cap " + "on total stored bytes)")->default_val("0"); + app.add_option("--filestoredir", optFileStoreDir, + "File-storage directory (empty = /cesfilestore/)") + ->default_val(""); + app.add_option("--extensionsdir", optExtensionsDir, + "Read-only extension catalog directory (empty = none)") + ->default_val(""); + app.add_option("--extfundingperday", optExtFundingPerDay, + "Extension funding budget: global raw credit units/day the server grants " + "ces.request_funds petitions (0 = off)")->default_val(std::to_string(DEFAULT_EXT_FUNDING_PER_DAY)); + app.add_option("--extlocalbudget", optExtLocalBudget, + "Local extension budget: raw credit units each /s/ program account is topped " + "up to on boot and daily (0 = off)")->default_val(std::to_string(DEFAULT_EXT_LOCAL_BUDGET)); + app.add_option("--feefilerent", optFeeFileRent, + "File-storage rent fee (credits per byte per day, -1 = default)"); + app.add_option("--feefilewrite", optFeeFileWrite, + "File-storage write fee (credits per KB, -1 = default)"); + app.add_option("--feefileread", optFeeFileRead, + "File-storage read fee (credits per KB, -1 = default)"); + app.add_option("--cesplexmount", optCesplexMounts, + "CesPlex mount as proto=target " + "(e.g. /ces/file/1=builtin:file; repeatable)"); + + // --- Compute feature (CesPlex builtin:compute) --- + app.add_option("--computemaxinstances", optComputeMaxInstances, + "Compute feature max concurrent instances " + "(0 = feature off)")->default_val("0"); + app.add_option("--computeportbase", optComputePortBase, + "Base UDP port for the L2 compute program range. 0 = ephemeral " + "(loopback dev only). Open [base, base+count-1] at the firewall " + "to match.")->default_val("0"); + app.add_option("--computeportcount", optComputePortCount, + "Number of UDP ports in the L2 compute program range " + "(independent of computemaxinstances).")->default_val("0"); + app.add_option("--computeprocessmemmax", optComputeProcessMemMax, + "Per-process memory ceiling in bytes (child RLIMIT_AS)") + ->default_val(std::to_string(DEFAULT_COMPUTE_PROCESS_MEM_MAX)); + app.add_option("--computeclientpoolsize", optComputeClientPoolSize, + "Worker threads per Lua child for outbound file_client/compute_client " + "calls (concurrent in-flight verb round-trips; clamped 1..64)") + ->default_val(std::to_string(DEFAULT_COMPUTE_CLIENT_POOL_SIZE)); + app.add_option("--feecomputecpusec", optFeeComputeCpuSec, + "Compute fee for CPU time (credits per second, -1 = default)"); + app.add_option("--feecomputerssbyteday", optFeeComputeRssByteSec, + "Compute fee for RSS (credits per byte per second, -1 = default)"); + app.add_option("--feecomputenetbyte", optFeeComputeNetByte, + "Compute fee for outbound app bytes (credits per byte, -1 = default)"); + app.add_option("--feecomputeslotsec", optFeeComputeSlotSec, + "Compute fee for just existing / occupying a monitoring slot " + "(credits per second, -1 = default)"); + app.add_option("--feebucketbytesec", optFeeBucketByteSec, + "Bucket cache fee per byte per second of declared capacity " + "(credits, -1 = derive from feeComputeRssByteDay)"); + app.add_option("--feenetkibsent", optFeeNetKiBSent, + "Net meter: credits per KiB sent server->client (0 = derive default)"); + app.add_option("--feenetkibreceived", optFeeNetKiBReceived, + "Net meter: credits per KiB received client->server (0 = derive default)"); + app.add_option("--feenetchannelsec", optFeeNetChannelSec, + "Net meter: credits per channel-second open (0 = observe only)"); + app.add_option("--feenetmembyteday", optFeeNetMemByteDay, + "Net meter: credits per RUDP-buffer byte-day (0 = observe only)"); + app.add_option("--computeworkdir", optComputeWorkDir, + "Compute per-instance scratch / IPC socket dir " + "(empty = /cescompute/)")->default_val(""); + app.add_option("--computeuser", optComputeUser, + "Unix user cesluad child processes drop to") + ->default_val(DEFAULT_COMPUTE_USER); + app.add_option("--computechildbinary", optComputeChildBinary, + "Compute child binary path. Empty default = auto-discover " + "`cesluajitd` next to ces's own binary (/proc/self/exe), with " + "bare-name PATH fallback. Set explicitly to bypass discovery."); + + // --- /s/ extensions --- + // Repeatable: --extension dice --extension chat. Each entry + // is the basename of a Lua file the operator deployed to + // /s/.lua; the server autolaunches one + // cesluajitd instance per entry at boot. + app.add_option("--extension", optExtensions, + "Autolaunch /s/.lua at boot (repeatable)"); + + // -- Subcommands (mutually exclusive with running the server) -- + cmd_credit = app.add_subcommand("credit", + "Credit an account and exit (no server run)"); + cmd_credit->add_option("amount", optCreditAmount, "Amount to credit") + ->required(); + cmd_credit->add_option("account", optCreditAccount, + "Full public key hex (64 chars)")->required(); + + cmd_debit = app.add_subcommand("debit", + "Debit an account and exit (no server run)"); + cmd_debit->add_option("amount", optDebitAmount, "Amount to debit") + ->required(); + cmd_debit->add_option("account", optDebitAccount, + "Full public key hex (64 chars)")->required(); + + cmd_snapshot = app.add_subcommand("snapshot", + "Load data, write a snapshot, and exit (no server run)"); + + app.require_subcommand(0, 1); + + app.parse(argc, argv); + } catch (const CLI::ParseError& e) { + return app.exit(e); + } + + // -- --config with no argument: dump default config and exit -- + if (app.count("--config") && optConfigFile.empty()) { + dumpDefaultConfig(); + return 0; + } + + // -- Load config file (if provided), then let CLI switches override -- + if (!optConfigFile.empty()) { + try { + auto tbl = toml::parse_file(optConfigFile); + + // Helper: apply a TOML value into `var` only if the matching CLI + // flag wasn't explicitly set on the command line. Precedence is + // CLI > TOML > compiled default. + auto applyIfDefault = [&](const char* tomlKey, auto& var, + const char* cliFlag) { + if (app[cliFlag]->count() != 0) + return; // CLI wins + auto v = tbl[tomlKey]; + if (!v) + return; // not in config file + using T = std::decay_t; + if constexpr (std::is_same_v || + std::is_same_v) + var = v.value_or(var); + else if constexpr (std::is_same_v) + var = static_cast(v.value_or(static_cast(var))); + else if constexpr (std::is_same_v) + var = static_cast(v.value_or(static_cast(var))); + else if constexpr (std::is_same_v) + var = static_cast(v.value_or(static_cast(var))); + else + var = static_cast(v.value_or(static_cast(var))); + }; + + applyIfDefault("log_level", optLogLevel, "--loglevel"); + applyIfDefault("data_dir", optDataDir, "--datadir"); + applyIfDefault("port", optServerPort, "--port"); + applyIfDefault("server_key", optServerPrivKey, "--serverkey"); + applyIfDefault("min_difficulty", optMinDiff, "--mindiff"); + applyIfDefault("pow_delay", optPoWDelay, "--powdelay"); + applyIfDefault("spend_slot_size", optSpendSlotSize, "--spendslotsize"); + applyIfDefault("no_pow_engine", optNoPowEngine, "--nopowengine"); + applyIfDefault("cache_only_pow", optCacheOnlyPoWEngine, "--cacheonlypowengine"); + applyIfDefault("threads", optTaskThreads, "--threads"); + applyIfDefault("min_accounts", optMinAcc, "--minacc"); + applyIfDefault("max_accounts", optMaxAcc, "--maxacc"); + applyIfDefault("min_assets", optMinAsset, "--minasset"); + applyIfDefault("max_assets", optMaxAsset, "--maxasset"); + applyIfDefault("min_aliases", optMinAlias, "--minalias"); + applyIfDefault("max_aliases", optMaxAlias, "--maxalias"); + applyIfDefault("flush_value", optFlushValue, "--flushvalue"); + applyIfDefault("max_log_size_gb", optMaxLogSizeGB, "--maxlogsize"); + applyIfDefault("server_name", optServerName, "--servername"); + applyIfDefault("peer_target", optPeerTarget, "--peertarget"); + applyIfDefault("peer_pow_inbound_reciprocation_bps", + optPeerPowInboundReciprocationBps, + "--peerpowinboundreciprocationbps"); + applyIfDefault("peer_miner_interval", optPeerMinerInterval, + "--peerminerinterval"); + applyIfDefault("max_peers", optMaxPeers, "--maxpeers"); + applyIfDefault("settlement_max_retries", optSettlementMaxRetries, "--settlementretries"); + applyIfDefault("max_peer_reserve_disturbance", optMaxPeerReserveDisturbance, + "--maxpeerreservedisturbance"); + applyIfDefault("gossip_fanout_degree", optGossipFanoutDegree, + "--gossipfanoutdegree"); + applyIfDefault("admin_socket", optAdminSocket, "--adminsocket"); + applyIfDefault("web_port", optWebPort, "--webport"); + applyIfDefault("web_bind", optWebBind, "--webbind"); + applyIfDefault("web_allow_public", optWebAllowPublic, "--web-allow-public"); + applyIfDefault("rpc_port", optRpcPort, "--rpcport"); + applyIfDefault("rpc_max_pending", optRpcMaxPending, "--rpcmaxpending"); + applyIfDefault("rpc_max_request_bytes", optRpcMaxRequestBytes, "--rpcmaxreqbytes"); + applyIfDefault("rpc_max_response_bytes", optRpcMaxResponseBytes, "--rpcmaxrespbytes"); + applyIfDefault("rpc_response_timeout_ms", optRpcResponseTimeoutMs, "--rpctimeoutms"); + applyIfDefault("rpc_rudp_bytes_per_second", optRpcRudpBytesPerSecond, "--rpcrudpbps"); + applyIfDefault("rpc_rudp_burst_bytes", optRpcRudpBurstBytes, "--rpcrudpburst"); + + // [rpc_rudp] table — transport caps on the rpc_port. Mirror the + // CLI > TOML precedence the rest of the loader uses. + if (auto t = tbl["rpc_rudp"].as_table()) { + if (app["--rpcrudpmaxchannels"]->count() == 0) { + if (auto v = (*t)["max_channels_per_peer"].value(); + v && *v >= 0) + optRpcRudpMaxChannelsPerPeer = static_cast(*v); + } + if (app["--rpcrudpmaxreorderbytes"]->count() == 0) { + if (auto v = (*t)["max_reorder_bytes_per_channel"].value()) + optRpcRudpMaxReorderBytesPerChannel = *v; + } + if (app["--rpcrudpmaxreordermsgs"]->count() == 0) { + if (auto v = (*t)["max_reorder_msgs_per_channel"].value()) + optRpcRudpMaxReorderMsgsPerChannel = *v; + } + if (app["--rpcrudpidlesecs"]->count() == 0) { + if (auto v = (*t)["channel_idle_secs"].value(); v && *v > 0) + optRpcRudpChannelIdleSecs = static_cast(*v); + } + } + + // Fees: config uses the same -1 = unset convention as the CLI. + applyIfDefault("fee_account", optFeeAccount, "--feeaccount"); + applyIfDefault("fee_asset", optFeeAsset, "--feeasset"); + applyIfDefault("fee_tx", optFeeTx, "--feetx"); + applyIfDefault("fee_query", optFeeQuery, "--feequery"); + applyIfDefault("fee_vm_mult", optFeeVmMult, "--feevmmult"); + + // File-storage feature knobs. + applyIfDefault("file_store_max_bytes", optFileStoreMaxBytes, + "--filestoremaxbytes"); + applyIfDefault("file_store_dir", optFileStoreDir, "--filestoredir"); + applyIfDefault("extensions_dir", optExtensionsDir, "--extensionsdir"); + applyIfDefault("ext_funding_per_day", optExtFundingPerDay, + "--extfundingperday"); + applyIfDefault("ext_local_budget", optExtLocalBudget, + "--extlocalbudget"); + applyIfDefault("fee_file_rent", optFeeFileRent, "--feefilerent"); + applyIfDefault("fee_file_write", optFeeFileWrite, "--feefilewrite"); + applyIfDefault("fee_file_read", optFeeFileRead, "--feefileread"); + +#ifdef CES_MAIL + // Mail relay (ces.mail.send / builtin:mail). + applyIfDefault("mail_relay_host", optMailRelayHost, "--mailrelayhost"); + applyIfDefault("mail_relay_port", optMailRelayPort, "--mailrelayport"); + applyIfDefault("mail_from", optMailFrom, "--mailfrom"); + applyIfDefault("mail_user", optMailUser, "--mailuser"); + applyIfDefault("mail_pass", optMailPass, "--mailpass"); + applyIfDefault("mail_fee_per_mb", optMailFeePerMB, "--mailfeepermb"); + applyIfDefault("mail_max_encoded_bytes", optMailMaxEncodedBytes, + "--mailmaxencodedbytes"); +#endif // CES_MAIL + + // Compute feature knobs. + applyIfDefault("compute_max_instances", optComputeMaxInstances, + "--computemaxinstances"); + applyIfDefault("compute_port_base", optComputePortBase, + "--computeportbase"); + applyIfDefault("compute_port_count", optComputePortCount, + "--computeportcount"); + applyIfDefault("compute_process_mem_max", optComputeProcessMemMax, + "--computeprocessmemmax"); + applyIfDefault("compute_client_pool_size", optComputeClientPoolSize, + "--computeclientpoolsize"); + applyIfDefault("fee_compute_cpu_sec", optFeeComputeCpuSec, + "--feecomputecpusec"); + applyIfDefault("fee_compute_rss_byte_day", optFeeComputeRssByteSec, + "--feecomputerssbyteday"); + applyIfDefault("fee_compute_net_byte", optFeeComputeNetByte, + "--feecomputenetbyte"); + applyIfDefault("fee_compute_slot_sec", optFeeComputeSlotSec, + "--feecomputeslotsec"); + applyIfDefault("fee_bucket_byte_sec", optFeeBucketByteSec, + "--feebucketbytesec"); + applyIfDefault("fee_net_kib_sent", optFeeNetKiBSent, + "--feenetkibsent"); + applyIfDefault("fee_net_kib_received", optFeeNetKiBReceived, + "--feenetkibreceived"); + applyIfDefault("fee_net_channel_sec", optFeeNetChannelSec, + "--feenetchannelsec"); + applyIfDefault("fee_net_mem_byte_day", optFeeNetMemByteDay, + "--feenetmembyteday"); + applyIfDefault("compute_work_dir", optComputeWorkDir, + "--computeworkdir"); + applyIfDefault("compute_user", optComputeUser, + "--computeuser"); + applyIfDefault("compute_child_binary", optComputeChildBinary, + "--computechildbinary"); + + // /s/ extensions from config. Names are arbitrary; any + // truthy entry enables autolaunch of /s/.lua. CLI's + // repeatable --extension overrides the TOML list when set. + // [extension] + // dice = 1 + // chat = 1 + if (optExtensions.empty()) { + if (auto t = tbl["extension"].as_table()) { + for (auto& [k, v] : *t) { + bool enabled = false; + if (auto i = v.value()) enabled = (*i != 0); + else if (auto b = v.value()) enabled = *b; + if (enabled) optExtensions.push_back(std::string(k.str())); + } + } + } + + // CesPlex mounts from config (only if no --cesplexmount on CLI). + // TOML shape: + // [cesplex_mounts] + // "/ces/file/1" = "builtin:file" + if (optCesplexMounts.empty()) { + if (auto t = tbl["cesplex_mounts"].as_table()) { + for (auto& [k, v] : *t) { + auto s = v.value(); + if (s && !s->empty()) + optCesplexMounts.push_back( + std::string(k.str()) + "=" + *s); + } + } + } + + // Peers from config (only if no CLI peers specified) + if (optPeers.empty()) { + if (auto peers = tbl["peers"].as_array()) { + for (auto& p : *peers) { + if (auto t = p.as_table()) { + auto key = (*t)["key"].value_or(std::string("")); + auto addr = (*t)["address"].value_or(std::string("")); + if (!key.empty() && !addr.empty()) + optPeers.push_back(key + "@" + addr); + } + } + } + } + + } catch (const toml::parse_error& err) { + std::cerr << "Config parse error: " << err.description() << "\n" + << " " << err.source().path->c_str() << ":" + << err.source().begin.line << "\n"; + return 1; + } + } + + // -- Setup logging -- + try { + ces::setupLogger(optLogLevel); + } catch (const std::exception& e) { + std::cerr << "ERROR: " << e.what() << std::endl; + std::exit(1); + } + + // -- Key generation utilities (exit after) -- + if (optGenerateKeyPair) { + KeyPair keyPair; + dumpKeyPair(keyPair); + } + if (!optGeneratePubKey.empty()) { + KeyPair keyPair(optGeneratePubKey); + dumpKeyPair(keyPair); + } + + // -- Validate peers (key@host:port) -- + for (auto& p : optPeers) { + if (p.find('@') == std::string::npos) { + std::cerr << "ERROR: invalid peer format (expected key@host:port): " << p << "\n"; + return 1; + } + } + + LOGINFO << "ces start" << VAL("version", CES_GIT_HASH); + + minx::Hash serverPrivKey; + if (optServerPrivKey.size() != 64) { + std::cerr << "Error: server_key must be a 64-character hex string.\n" + << "Generate one with: ces --genkeypair\n"; + return 1; + } + try { + minx::stringToHash(serverPrivKey, optServerPrivKey); + } catch (std::exception& e) { + std::cerr << "Error: invalid server_key: " << e.what() << "\n"; + return 1; + } + + if (optServerPrivKey == DEFAULT_PRIV_KEY_HEX_STR) + LOGWARNING << "server_key is the well-known default sample key; generate " + "your own with ces --genkeypair (every ces --config dump " + "mints a fresh one)"; + + // -- Configure server -- + CesConfig config; + config.dataDir = optDataDir; + config.serverPrivKey = serverPrivKey; + config.version = CES_GIT_HASH; + config.minAcc = optMinAcc; + config.maxAcc = optMaxAcc; + config.minAsset = optMinAsset; + config.maxAsset = optMaxAsset; + config.minAlias = optMinAlias; + config.maxAlias = optMaxAlias; + config.minDiff = optMinDiff; + config.spendSlotSize = optSpendSlotSize; + config.taskThreads = optTaskThreads; + config.flushValue = optFlushValue; + config.maxLogBytes = optMaxLogSizeGB * 1024ULL * 1024 * 1024; + + if (optFeeAccount >= 0) config.feeAccount = static_cast(optFeeAccount); + if (optFeeAsset >= 0) config.feeAsset = static_cast(optFeeAsset); + if (optFeeTx >= 0) config.feeTx = static_cast(optFeeTx); + if (optFeeQuery >= 0) config.feeQuery = static_cast(optFeeQuery); + if (optFeeVmMult >= 0) config.feeVmMult = static_cast(optFeeVmMult); + if (optNoFeeDiscount) config.feeDiscountEnabled = false; + + uint64_t now = minx::getSecsSinceEpoch(); + if (optPoWDelay > 0) + config.minProveWorkTimestamp = now + optPoWDelay; + else + config.minProveWorkTimestamp = 0; + + // Trim and normalize server name (empty/whitespace = unset) + { + auto s = optServerName; + s.erase(0, s.find_first_not_of(" \t\r\n")); + s.erase(s.find_last_not_of(" \t\r\n") + 1); + config.serverName = s; + } + config.peerTarget = optPeerTarget; + config.peerPowInboundReciprocationBps = optPeerPowInboundReciprocationBps; + config.peerMinerIntervalSecs = optPeerMinerInterval; + config.maxPeers = static_cast(optMaxPeers < 1 ? 1 : optMaxPeers); + config.settlementMaxRetries = optSettlementMaxRetries; + config.maxPeerReserveDisturbance = optMaxPeerReserveDisturbance; + config.gossipFanoutDegree = optGossipFanoutDegree; + config.adminSocket = optAdminSocket; + config.webPort = optWebPort; + config.webBind = optWebBind; + config.webAllowPublic = optWebAllowPublic; + config.rpcPort = optRpcPort; + config.rpcMaxPending = optRpcMaxPending; + config.rpcMaxRequestBytes = static_cast(optRpcMaxRequestBytes); + config.rpcMaxResponseBytes = static_cast(optRpcMaxResponseBytes); + config.rpcResponseTimeoutMs = optRpcResponseTimeoutMs; + config.rpcRudpBytesPerSecond = optRpcRudpBytesPerSecond; + config.rpcRudpBurstBytes = optRpcRudpBurstBytes; + config.rpcRudpMaxChannelsPerPeer = static_cast(optRpcRudpMaxChannelsPerPeer); + config.rpcRudpMaxReorderBytesPerChannel = optRpcRudpMaxReorderBytesPerChannel; + config.rpcRudpMaxReorderMsgsPerChannel = optRpcRudpMaxReorderMsgsPerChannel; + config.rpcRudpChannelIdleSecs = optRpcRudpChannelIdleSecs; + + // File-storage feature config. + config.cesFileStoreMaxBytes = optFileStoreMaxBytes; + config.cesFileStoreDir = optFileStoreDir; + config.cesExtensionsDir = optExtensionsDir; + +#ifdef CES_MAIL + // Mail relay (ces.mail.send / builtin:mail). Empty relay host => log + drop. + config.mailRelayHost = optMailRelayHost; + config.mailRelayPort = optMailRelayPort; + config.mailFrom = optMailFrom; + config.mailUser = optMailUser; + config.mailPass = optMailPass; + config.mailFeePerMB = optMailFeePerMB; + config.mailMaxEncodedBytes = optMailMaxEncodedBytes; +#endif // CES_MAIL + config.extFundingPerDay = optExtFundingPerDay; + config.extLocalBudget = optExtLocalBudget; + if (optFeeFileRent >= 0) config.feeFileRent = optFeeFileRent; + if (optFeeFileWrite >= 0) config.feeFileWrite = optFeeFileWrite; + if (optFeeFileRead >= 0) config.feeFileRead = optFeeFileRead; + + // Compute feature config. + config.computeMaxInstances = optComputeMaxInstances; + config.computePortBase = optComputePortBase; + config.computePortCount = optComputePortCount; + config.computeProcessMemMax = optComputeProcessMemMax; + config.computeClientPoolSize = optComputeClientPoolSize; + if (optFeeComputeCpuSec >= 0) + config.feeComputeCpuSec = optFeeComputeCpuSec; + if (optFeeComputeRssByteSec >= 0) + config.feeComputeRssByteDay = optFeeComputeRssByteSec; + if (optFeeComputeNetByte >= 0) + config.feeComputeNetByte = optFeeComputeNetByte; + if (optFeeComputeSlotSec >= 0) + config.feeComputeSlotSec = optFeeComputeSlotSec; + if (optFeeBucketByteSec >= 0) + config.feeBucketByteSec = optFeeBucketByteSec; + config.feeNetKiBSent = optFeeNetKiBSent; + config.feeNetKiBReceived = optFeeNetKiBReceived; + config.feeNetChannelSec = optFeeNetChannelSec; + config.feeNetMemByteDay = optFeeNetMemByteDay; + config.cesComputeWorkDir = optComputeWorkDir; + config.cesComputeUser = optComputeUser; + config.cesComputeChildBinary = optComputeChildBinary; + // Auto-discover cesluajitd next to our own binary if the operator + // didn't specify a path. Typical install (build dir or /opt/ces/) + // has ces and cesluajitd as siblings, so no toml line is needed. + // Falls back to bare "cesluajitd" (PATH lookup) if discovery fails. + if (config.cesComputeChildBinary.empty()) { + char exePath[4096]; + ssize_t n = ::readlink("/proc/self/exe", exePath, sizeof(exePath) - 1); + if (n > 0) { + exePath[n] = '\0'; + auto candidate = + std::filesystem::path(exePath).parent_path() / "cesluajitd"; + std::error_code ec; + if (std::filesystem::exists(candidate, ec)) { + config.cesComputeChildBinary = candidate.string(); + LOGINFO << "compute: auto-discovered cesluajitd next to ces" + << SVAR(config.cesComputeChildBinary); + } + } + if (config.cesComputeChildBinary.empty()) { + config.cesComputeChildBinary = "cesluajitd"; // PATH fallback + } + } + + // /s/ extensions. Dedup via std::set semantics. + for (const auto& n : optExtensions) { + if (!n.empty()) config.extensions.insert(n); + } + + // CesPlex mounts: parse "proto=target" entries, skip malformed ones + // with a warning (rather than exit — a typo shouldn't kill the whole + // server if other mounts are fine). + for (const auto& m : optCesplexMounts) { + auto eq = m.find('='); + if (eq == std::string::npos || eq == 0 || eq == m.size() - 1) { + std::cerr << "WARN: ignoring malformed cesplex mount: " << m + << " (expected proto=target)\n"; + continue; + } + config.cesplexMounts[m.substr(0, eq)] = m.substr(eq + 1); + } + + LOGINFO << "ces config" + << VAR(optServerPort) << VAR(config.dataDir) + << VAR(config.minAcc) << VAR(config.maxAcc) + << VAR(config.minDiff) << VAR(config.minProveWorkTimestamp) + << VAR(config.spendSlotSize) << VAR(config.taskThreads) + << VAR(config.feeAccount) << VAR(config.feeAsset) + << VAR(config.feeTx) << VAR(config.feeQuery) << VAR(config.feeVmMult) + << VAR(config.minAsset) << VAR(config.maxAsset) + << VAR(config.flushValue) << VAR(config.maxLogBytes) + << VAR(config.serverName) << VAR(config.peerTarget) + << VAR(config.peerPowInboundReciprocationBps) + << VAR(config.settlementMaxRetries) + << VAR(config.rpcPort) + << VAR(config.cesFileStoreMaxBytes) + << VAR(optNoPowEngine); + for (auto& p : optPeers) { + auto at = p.find('@'); + if (at == std::string::npos) continue; // already validated above + CesConfig::PeerConfig pc; + pc.pubKeyHex = p.substr(0, at); + pc.address = p.substr(at + 1); + config.peers.push_back(pc); + } + + if (!config.peers.empty()) { + LOGINFO << "ces peers configured: " << config.peers.size() + << " target=" << config.peerTarget; + for (size_t i = 0; i < config.peers.size(); ++i) + LOGINFO << " peer " << i << ": " << config.peers[i].address + << " key=" << config.peers[i].pubKeyHex.substr(0, 16) << "..."; + } + + auto server = std::make_unique(config); + + // -- Handle credit/debit commands (no networking needed) -- + if (cmd_credit->parsed() || cmd_debit->parsed()) { + if (cmd_credit->parsed()) { + if (optCreditAmount <= 0) { + std::cerr << "Error: amount must be a positive number.\n"; + return 1; + } + minx::Hash key; + minx::stringToHash(key, optCreditAccount); + server->_brr(key, optCreditAmount); + LOGINFO << "credited " << optCreditAmount << " to " << optCreditAccount; + std::cout << "Credited " << optCreditAmount << " to " + << optCreditAccount << "\n"; + } else { + if (optDebitAmount <= 0) { + std::cerr << "Error: amount must be a positive number.\n"; + return 1; + } + minx::Hash key; + minx::stringToHash(key, optDebitAccount); + server->_burn(key, optDebitAmount); + LOGINFO << "debited " << optDebitAmount << " from " << optDebitAccount; + std::cout << "Debited " << optDebitAmount << " from " + << optDebitAccount << "\n"; + } + + server->_save(); + server.reset(); + return 0; + } + + // -- Handle snapshot command (no networking needed) -- + if (cmd_snapshot->parsed()) { + LOGINFO << "writing snapshot..."; + server->_save(); + LOGINFO << "snapshot complete"; + std::cout << "Snapshot written.\n"; + server.reset(); + return 0; + } + + LOGINFO << "ces starting server"; + + uint16_t boundPort = server->start(optServerPort); + if (boundPort == 0) { + LOGERROR << "ces failed to bind the server port; aborting" + << VAR(optServerPort); + server.reset(); + return 1; + } + + if (optNoPowEngine) { + LOGINFO << "ces will not create the PoW engine"; + } else { + LOGINFO << "ces creating pow engine" << VAR(optCacheOnlyPoWEngine); + server->createPoWEngine(!optCacheOnlyPoWEngine); + } + + if (!optNoPowEngine) { + LOGINFO << "ces waiting for PoW engine to be ready (press Ctrl+C to stop)"; + int c = 0; + while (ces::notInterrupted()) { + ces::sleep(100); + if (server->isPoWEngineReady()) { + LOGINFO << "ces pow engine ready"; + break; + } + if (++c >= 50) { + c = 0; + LOGTRACE << "ces waiting for PoW engine to be ready..."; + } + } + } + + // Start peer miner (if peers configured and target > 0) + server->startPeerMiner(); + + // Start admin console (Cesco) if configured + boost::asio::io_context cescoIO; + std::unique_ptr cesco; + std::thread cescoThread; + if (!config.adminSocket.empty()) { + cesco = std::make_unique(cescoIO, *server); + if (cesco->listen(config.adminSocket)) { + cescoThread = std::thread( + [&cescoIO]() { ces::runGuardedThread([&cescoIO]{ cescoIO.run(); }, "cescoIO"); }); + } else { + cesco.reset(); + } + } + + // Start web dashboard if configured. Loopback only, no auth — reach it + // over an SSH tunnel. Runs on its own io_context, like Cesco. + boost::asio::io_context webIO; + std::unique_ptr webadmin; + std::vector webThreads; + if (config.webPort != 0) { + webadmin = std::make_unique(webIO, *server); + if (webadmin->listen(config.webBind, config.webPort, config.webAllowPublic)) { + // A small pool, not one thread: the dashboard's ledger reads block the + // serving thread on a logicStrand_ hop, and a browser polls several + // endpoints concurrently — a single thread stalls (dashboard "flicker") + // whenever one read waits on a busy strand (e.g. a server being mined + // on). The handlers are thread-safe (strand hops / mutexes / atomics), + // so serving them across a few threads is safe. + for (int i = 0; i < 4; ++i) + webThreads.emplace_back( + [&webIO]() { ces::runGuardedThread([&webIO]{ webIO.run(); }, "webIO"); }); + } else { + webadmin.reset(); + } + } + + LOGINFO << "ces running server" << VAR(boundPort) + << "(press Ctrl+C to stop)"; + + while (ces::notInterrupted()) { + ces::sleep(100); + } + + LOGINFO << "ces stopping server"; + if (webadmin) { + webadmin->stop(); + webIO.stop(); + for (auto& t : webThreads) + if (t.joinable()) + t.join(); + webadmin.reset(); + } + if (cesco) { + cesco->stop(); + cescoIO.stop(); + if (cescoThread.joinable()) + cescoThread.join(); + cesco.reset(); + } + server->stop(); + LOGINFO << "ces destroying server"; + server.reset(); + LOGINFO << "ces exit"; + return 0; +} diff --git a/vendor/ces/src/cesbench/cesbench.cpp b/vendor/ces/src/cesbench/cesbench.cpp new file mode 100644 index 0000000..a99f5ad --- /dev/null +++ b/vendor/ces/src/cesbench/cesbench.cpp @@ -0,0 +1,521 @@ +/** + * cesbench - CES server throughput benchmark + * + * Spins up an in-process CES server, pre-signs N transfer packets, blasts them + * while the server is paused, then resumes and measures processing throughput. + * Finishes with a ledger consistency check and a raw crypto cycle benchmark. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +using namespace ces; +namespace fs = boost::filesystem; + +constexpr size_t HUGE_BUFFER_SIZE = 4ULL * 1024 * 1024 * 1024; +constexpr uint64_t MAX_FLUSH = std::numeric_limits::max(); + +// Wait this many seconds for server replies before giving up on a bench run. +constexpr int BENCH_REPLY_TIMEOUT_SECS = 15; + +class BenchClient : public minx::MinxListener { +public: + BenchClient( + uint16_t port, bool verbose, int taskThreads, + size_t totalTx, size_t poolSize, + const std::unordered_map& accMap) + : port_(port), verbose_(verbose), + taskThreadCount_(taskThreads), poolSize_(poolSize), accMap_(accMap) { + size_t arraySize = totalTx + poolSize + 1000; + seenFlags_.resize(arraySize, 0); + minx_ = std::make_unique( + this, minx::MinxConfig{"bench_cli", 0, 3600, 1, taskThreads, 1000, 0, true, + totalTx * 3 + 16384}); + } + + ~BenchClient() { stop(); } + + void start() { + minx_->openSocket( + minx::SockAddr(boost::asio::ip::address_v6::loopback(), 0), netIO_, + taskIO_); + netThread_ = std::thread([this]() { netIO_.run(); }); + for (int i = 0; i < taskThreadCount_; ++i) { + taskThreads_.emplace_back([this]() { taskIO_.run(); }); + } + if (verbose_) + std::cout << "[Client] Threads: 1 Net, " << taskThreadCount_ + << " Task.\n"; + } + + void stop() { + if (minx_) + minx_->closeSocket(false); + netIO_.stop(); + taskIO_.stop(); + if (netThread_.joinable()) + netThread_.join(); + for (auto& t : taskThreads_) + if (t.joinable()) + t.join(); + taskThreads_.clear(); + } + + bool isConnected(const minx::SockAddr& addr) override { + return addr.address().is_loopback(); + } + + void sendRaw(const minx::Bytes& packet) { + minx::SockAddr serverAddr(boost::asio::ip::address_v6::loopback(), port_); + minx::MinxMessage msg{0, 0, 0, {packet.begin(), packet.end()}}; + minx_->sendMessage(serverAddr, msg); + } + + void incomingInfo(const minx::SockAddr&, const minx::MinxInfo&) override {} + + void incomingMessage(const minx::SockAddr&, + const minx::MinxMessage& msg) override { + rawPackets_++; + + try { + if (msg.data.empty()) + return; + if (static_cast(msg.data[0]) != CES_TRANSFER_RESULT) + return; + + CesTransferResult res; + res.fromBytes(msg.data); + + auto it = accMap_.find(res.originId); + if (it != accMap_.end()) { + size_t flatIdx = + (static_cast(res.reqNonce) - 1) * poolSize_ + it->second; + + if (flatIdx < seenFlags_.size() && seenFlags_[flatIdx] == 0) { + seenFlags_[flatIdx] = 1; + + if (res.rcode == CES_OK) + successCount_++; + else + failCount_++; + } + } + } catch (...) { + // Swallow — unparseable responses are counted via the absence of + // successCount_/failCount_ increments and surface as the gap + // between rawPackets_ and (success + fail) in the results banner. + } + } + + uint64_t getSuccess() const { return successCount_; } + uint64_t getFail() const { return failCount_; } + uint64_t getRawPackets() const { return rawPackets_; } + +private: + uint16_t port_; + bool verbose_; + int taskThreadCount_; + size_t poolSize_; + const std::unordered_map& accMap_; + + ces::Bytes seenFlags_; + + std::unique_ptr minx_; + minx::IOContext netIO_, taskIO_; + std::thread netThread_; + std::vector taskThreads_; + + std::atomic successCount_{0}; + std::atomic failCount_{0}; + std::atomic rawPackets_{0}; +}; + +struct SimAccount { + KeyPair key; + uint32_t nonce; +}; + +int main(int argc, char* argv[]) { + uint64_t optTxCount = 100; + int optTaskThreads = std::thread::hardware_concurrency() / 2; + if (optTaskThreads < 1) + optTaskThreads = 1; + + std::string optDataDir = "./cesbench_data"; + bool optVerbose = false; + bool optSecp = false; + + CLI::App app{"cesbench"}; + app.add_option("-n,--count", optTxCount, "Total transactions"); + app.add_option("-d,--dir", optDataDir, "Data directory"); + app.add_option("-t,--threads", optTaskThreads, "Task threads"); + app.add_flag("-v,--verbose", optVerbose, "Enable verbose logging"); + app.add_flag("--secp", optSecp, "Use secp256k1 keys instead of ed25519"); + CLI11_PARSE(app, argc, argv); + + KeyAlgo algo = optSecp ? KeyAlgo::SECP256K1 : KeyAlgo::ED25519; + + // One sender per transaction: avoids out-of-order nonce failures over UDP. + uint64_t optPoolSize = optTxCount; + + fs::remove_all(optDataDir); + fs::create_directories(optDataDir); + + blog::init(); + if (optVerbose) { + blog::set_level(blog::trace); + blog::enable("minx"); + } else { + blog::set_level(blog::fatal); + } + + std::cout << "========================================================\n"; + std::cout << " cesbench - CES throughput benchmark\n"; + std::cout << "========================================================\n"; + std::cout << " Algorithm : " << (optSecp ? "secp256k1" : "ed25519") << "\n"; + std::cout << " Transactions: " << optTxCount << "\n"; + std::cout << " Senders : " << optPoolSize << " (1 tx each)\n"; + std::cout << " Threads : " << optTaskThreads << "\n"; + std::cout << "========================================================\n"; + + CesConfig cfg; + cfg.dataDir = optDataDir; + cfg.serverPrivKey.fill(0xEE); + cfg.serverKeyAlgo = algo; + cfg.minAcc = optPoolSize + 1000; + cfg.maxAcc = (optPoolSize + 1000) * 5; + cfg.minDiff = 1; + cfg.spendSlotSize = 3600; + cfg.taskThreads = std::thread::hardware_concurrency(); + cfg.flushValue = MAX_FLUSH; + cfg.accountStoreBufferSize = HUGE_BUFFER_SIZE; + cfg.feeTx = 0; + cfg.feeAccount = 0; + cfg.recvBuffersSize = optTxCount + 16384; + + auto server = std::make_unique(cfg); + + server->pause(); + + uint16_t port = server->start(0); + std::cout << "Server listening on port " << port << " (paused)\n"; + + std::cout << "Generating " << optPoolSize << " sender accounts..." + << std::endl; + std::vector pool(optPoolSize); + std::unordered_map accMap; + for (size_t i = 0; i < optPoolSize; ++i) { + pool[i].key = KeyPair(algo); + pool[i].nonce = 0; + accMap[Account::getMapKey(pool[i].key.getPublicKeyAsHash())] = i; + } + + std::cout << "Funding accounts..." << std::endl; + const int64_t INITIAL_BALANCE = 1'000'000'000; + for (const auto& acc : pool) { + server->_brr(acc.key.getPublicKeyAsHash(), INITIAL_BALANCE); + } + + // The transfers run in Safe mode, which refuses to create the + // destination; the receiver account must exist up front. + KeyPair receiver(algo); + minx::Hash receiverPub = receiver.getPublicKeyAsHash(); + server->_brr(receiverPub, 1); + + std::this_thread::sleep_for(std::chrono::milliseconds(5000)); + + std::cout << "Pre-signing " << optTxCount << " transfer packets..." << std::endl; + std::vector packets; + packets.reserve(optTxCount); + + // Signed ops are bound to the destination server (anti-replay); derive + // the server's id from the same private key the config seeds. + HashPrefix serverId = Account::getMapKey( + KeyPair(cfg.serverPrivKey, algo).getPublicKeyAsHash()); + + for (uint64_t i = 0; i < optTxCount; ++i) { + size_t accIdx = i % optPoolSize; + SimAccount& sender = pool[accIdx]; + sender.nonce++; + + CesTransfer tx; + tx.originId = sender.key.getPublicKeyAsHash(); + tx.serverId = serverId; + tx.destKey = receiverPub; + tx.amount = 1; + tx.reqNonce = sender.nonce; + + packets.emplace_back(tx.toBytes(sender.key)); + } + + BenchClient client(port, optVerbose, optTaskThreads, + optTxCount, optPoolSize, accMap); + client.start(); + + std::cout << "Sending " << optTxCount << " packets (server paused)..." + << std::endl; + + for (uint64_t i = 0; i < optTxCount; ++i) { + client.sendRaw(packets[i]); + + if (i % 1000 == 0 || i == optTxCount - 1) { + std::cout << "\rSent: " << std::setw(7) << i + 1 << std::flush; + } + if (i % 10 == 0) + std::this_thread::sleep_for(std::chrono::microseconds(10)); + } + std::cout << "\nAll packets sent. Resuming server..." << std::endl; + + server->resume(); + auto startBench = std::chrono::high_resolution_clock::now(); + auto timeoutStart = std::chrono::high_resolution_clock::now(); + + while (true) { + uint64_t serverProcessedTxs = server->getTxCount(); + if (serverProcessedTxs >= optTxCount) { + std::cout << "\nServer processed " << serverProcessedTxs << " txs.\n"; + break; + } + + uint64_t total = client.getSuccess() + client.getFail(); + if (total >= optTxCount) { + std::cout << "\nAll " << optTxCount << " replies received (OK: " + << client.getSuccess() << ", Fail: " << client.getFail() + << ").\n"; + break; + } + + auto now = std::chrono::high_resolution_clock::now(); + if (std::chrono::duration_cast(now - timeoutStart) + .count() > BENCH_REPLY_TIMEOUT_SECS) { + std::cout << "\nTimed out after " << BENCH_REPLY_TIMEOUT_SECS << "s.\n"; + break; + } + + static auto lastPrint = now; + if (std::chrono::duration_cast(now - lastPrint) + .count() > 200) { + std::cout << "\r Acked: " << client.getSuccess() + << " Processed: " << serverProcessedTxs + << " Total: " << optTxCount + << " Raw: " << client.getRawPackets() + << " Failed: " << client.getFail() << std::flush; + lastPrint = now; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + auto endBench = std::chrono::high_resolution_clock::now(); + + std::cout << "\nWaiting 10s for straggler replies...\n"; + std::this_thread::sleep_for(std::chrono::seconds(10)); + + client.stop(); + server->pause(); + + auto benchMs = + std::chrono::duration_cast(endBench - startBench) + .count(); + double seconds = benchMs / 1000.0; + if (seconds < 0.001) + seconds = 0.001; + + std::cout << "\n========================================================\n"; + std::cout << " LEDGER VERIFICATION\n"; + std::cout << "========================================================\n"; + + uint64_t validAccounts = 0; + uint64_t errorAccounts = 0; + uint64_t totalVolumeExpected = 0; + + std::cout << "Verifying " << optPoolSize << " senders..." << std::flush; + for (size_t i = 0; i < optPoolSize; ++i) { + const auto& sender = pool[i]; + int64_t expectedBalance = + INITIAL_BALANCE - static_cast(sender.nonce); + totalVolumeExpected += sender.nonce; + + int64_t actualBalance = 0; + uint32_t actualNonce = 0; + HashPrefix id = Account::getMapKey(sender.key.getPublicKeyAsHash()); + + HashPrefix xd{}; uint64_t xa = 0; uint32_t xt = 0; uint32_t xal = 0; + server->unsignedQueryAccount(id, actualBalance, actualNonce, xd, xa, xt, + xal); + + bool ok = true; + if (actualBalance != expectedBalance) { + if (errorAccounts < 10) + std::cout << "\n Acc " << i << ": balance " << actualBalance + << " (expected " << expectedBalance << ")"; + ok = false; + } + if (actualNonce != sender.nonce) { + if (errorAccounts < 10) + std::cout << "\n Acc " << i << ": nonce " << actualNonce + << " (expected " << sender.nonce << ")"; + ok = false; + } + + if (ok) + validAccounts++; + else + errorAccounts++; + } + std::cout << " Done.\n"; + + std::cout << "Verifying receiver..." << std::flush; + int64_t recvBal = 0; + uint32_t recvNonce = 0; + { HashPrefix xd{}; uint64_t xa = 0; uint32_t xt = 0; uint32_t xal = 0; + server->unsignedQueryAccount(Account::getMapKey(receiverPub), recvBal, + recvNonce, xd, xa, xt, xal); } + + // +1: the receiver was seeded with 1 credit so the account exists. + bool recvOk = (recvBal == static_cast(totalVolumeExpected) + 1); + std::cout << (recvOk ? " OK" : " FAIL") << std::endl; + if (!recvOk) { + std::cout << " Receiver balance " << recvBal << " (expected " + << (totalVolumeExpected + 1) << ")\n"; + } + + server->stop(false); + + std::cout << "========================================================\n"; + std::cout << " RESULTS\n"; + std::cout << "========================================================\n"; + std::cout << " Time : " << seconds << " s\n"; + std::cout << " Throughput : " << static_cast(client.getSuccess() / seconds) + << " TPS (acked)\n"; + if (client.getSuccess() > 0) + std::cout << " Per transfer: " + << (seconds * 1e6 / static_cast(client.getSuccess())) + << " us wall (sig verify parallel on task threads; when the" + " logic strand is the bottleneck this approximates strand" + " time per transfer)\n"; + std::cout << " Server Txs : " << server->getTxCount() << "\n"; + std::cout << " Sent : " << optTxCount << "\n"; + std::cout << " Verified : " << totalVolumeExpected << "\n"; + std::cout << " Missing : " << (optTxCount - totalVolumeExpected) << "\n"; + std::cout << "--------------------------------------------------------\n"; + std::cout << " Senders : " << validAccounts << " / " << optPoolSize + << " OK\n"; + std::cout << " Errors : " << errorAccounts << "\n"; + std::cout << " Receiver : " << (recvOk ? "PASS" : "FAIL") << "\n"; + std::cout << "========================================================\n"; + + uintmax_t totalEventsSize = 0; + fs::path accDir = fs::path(optDataDir) / "accounts"; + auto sumEvents = [](const fs::path& dir) -> uintmax_t { + uintmax_t sum = 0; + if (fs::exists(dir) && fs::is_directory(dir)) { + for (const auto& entry : fs::directory_iterator(dir)) { + if (entry.path().extension() == ".events") + sum += fs::file_size(entry.path()); + } + } + return sum; + }; + totalEventsSize += sumEvents(accDir); + std::cout << " WAL Size : " << totalEventsSize << " bytes\n"; + + { + const size_t NUM_THREADS = static_cast(optTaskThreads); + + size_t calculatedIter = optTxCount / NUM_THREADS; + if (calculatedIter < 1) + calculatedIter = 1; + const size_t ITERATIONS_PER_THREAD = calculatedIter; + + const size_t PAYLOAD_SIZE = 256; + + std::cout << "\n\n========================================================\n"; + std::cout << " Crypto cycle benchmark (verify + sign)\n"; + std::cout << "========================================================\n"; + std::cout << " Threads : " << NUM_THREADS << "\n"; + std::cout << " Iterations : " << ITERATIONS_PER_THREAD << " per thread\n"; + std::cout << " Total : " << (NUM_THREADS * ITERATIONS_PER_THREAD) + << " ops\n"; + + std::atomic completed_ops{0}; + std::vector threads; + threads.reserve(NUM_THREADS); + + auto start_time = std::chrono::high_resolution_clock::now(); + + for (size_t t = 0; t < NUM_THREADS; ++t) { + threads.emplace_back([&, t]() { + ces::KeyPair serverKey(algo); + ces::KeyPair clientKey(algo); + ces::PublicKey clientPub(clientKey.getPublicKeyAsHash()); + + minx::Bytes clientBytes(PAYLOAD_SIZE); + std::memset(clientBytes.data(), 0xAA, PAYLOAD_SIZE); + ces::Signature clientSig = + clientKey.signData(clientBytes.data(), PAYLOAD_SIZE); + + minx::Bytes serverBytes(PAYLOAD_SIZE); + std::memset(serverBytes.data(), 0xBB, PAYLOAD_SIZE); + + for (size_t i = 0; i < ITERATIONS_PER_THREAD; ++i) { + + bool ok = clientPub.verifySignature(clientBytes.data(), PAYLOAD_SIZE, + clientSig); + if (!ok) + std::terminate(); + + std::memcpy(serverBytes.data(), &i, sizeof(i)); + + ces::Signature sig = + serverKey.signData(serverBytes.data(), PAYLOAD_SIZE); + + if (sig[0] == 0xDE && sig[1] == 0xAD) { + volatile int x = 0; + (void)x; + } + + if (t == 0 && i % 5000 == 0) { + std::cout << "." << std::flush; + } + } + + completed_ops += ITERATIONS_PER_THREAD; + }); + } + + for (auto& th : threads) { + if (th.joinable()) + th.join(); + } + std::cout << std::endl; + + auto end_time = std::chrono::high_resolution_clock::now(); + std::chrono::duration elapsed = end_time - start_time; + size_t total_ops = NUM_THREADS * ITERATIONS_PER_THREAD; + double tps = total_ops / elapsed.count(); + + std::cout << "========================================================\n"; + std::cout << " Threads : " << NUM_THREADS << "\n"; + std::cout << " Cycles : " << total_ops << " (1 verify + 1 sign)\n"; + std::cout << " Time : " << elapsed.count() << " s\n"; + std::cout << " Throughput : " << static_cast(tps) << " ops/s\n"; + std::cout << "========================================================\n"; + } + + return (errorAccounts == 0 && recvOk) ? 0 : 1; +} \ No newline at end of file diff --git a/vendor/ces/src/cesc/main.cpp b/vendor/ces/src/cesc/main.cpp new file mode 100644 index 0000000..b501940 --- /dev/null +++ b/vendor/ces/src/cesc/main.cpp @@ -0,0 +1,211 @@ +/** + * cesc — CesVM toolchain CLI. + * + * Compiles cesl source (.cesl) or assembles casm text (.casm) into + * CesVM bytecode. Language picked by input extension unless forced + * with --lang. --boot enforces the 210-byte boot-block limit and pads + * the output to exactly 210 bytes (the deployment shape for a single + * asset content cell). + */ + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + CLI::App app{"cesc - casm assembler / cesl compiler for CesVM"}; + + std::string inPath; + std::string outPath; + std::string lang; + std::string bundleDir; + std::string salt; + bool boot = false; + bool alias = false; + bool hex = false; + + app.add_option("input", inPath, "source file (.cesl or .casm)") + ->required(); + app.add_option("-o,--output", outPath, + "output file (default: input with .bin extension)"); + app.add_option("--lang", lang, "force input language: cesl or casm") + ->check(CLI::IsMember({"cesl", "casm"})); + app.add_flag("--boot", boot, + "enforce the 210-byte boot-block limit and pad to 210"); + auto* aliasFlag = + app.add_flag("--alias", alias, + "enforce the alias inline-code limit and pad to the inline " + "code area (deploy with `cesh alias write` at the content " + "offset)"); + aliasFlag->excludes("--boot"); + app.add_flag("--hex", hex, "print hex to stdout instead of writing a file"); + app.add_option("--bundle", bundleDir, + "write a multi-asset bundle (boot + chunks + key tables + " + "manifest) to this directory") + ->excludes("--boot") + ->excludes("--alias") + ->excludes("--hex") + ->excludes("-o"); + app.add_option("--salt", salt, + "salt mixed into the bundle's deterministic asset keys"); + + try { + if (argc <= 1) throw CLI::CallForHelp(); + app.parse(argc, argv); + } catch (const CLI::ParseError& e) { + return app.exit(e); + } + + std::ifstream in(inPath, std::ios::binary); + if (!in) { + std::cerr << "cesc: cannot open " << inPath << "\n"; + return 1; + } + std::stringstream ss; + ss << in.rdbuf(); + const std::string source = ss.str(); + + if (lang.empty()) { + const size_t dot = inPath.rfind('.'); + const std::string ext = + dot == std::string::npos ? "" : inPath.substr(dot + 1); + if (ext == "cesl") lang = "cesl"; + else if (ext == "casm") lang = "casm"; + else { + std::cerr << "cesc: cannot infer language from '" << inPath + << "'; pass --lang cesl|casm\n"; + return 1; + } + } + + auto compile = [&](uint64_t codeBase) { + return (lang == "cesl") ? ces::ceslCompile(source, codeBase) + : ces::casmAssemble(source, codeBase); + }; + + ces::Bytes code; + try { + code = compile(0); + } catch (const std::exception& e) { + std::cerr << "cesc: " << inPath << ": " << e.what() << "\n"; + return 1; + } + + if (!bundleDir.empty()) { + namespace fs = std::filesystem; + try { + // The compiled length is base-independent, so the fits-one-block + // decision made on the base-0 build is stable. + if (code.size() > ces::AssetData{}.size()) { + code = compile(210); + } + ces::CesBundle b = ces::bundleProgram(code, salt); + fs::create_directories(bundleDir); + auto writeBlock = [&](const std::string& name, + const ces::AssetData& block) { + std::ofstream f(fs::path(bundleDir) / name, + std::ios::binary | std::ios::trunc); + if (!f) throw std::runtime_error("cannot write " + name); + f.write(reinterpret_cast(block.data()), + static_cast(block.size())); + }; + std::string manifest; + manifest += "# cesc bundle for " + inPath + " (" + + std::to_string(code.size()) + " bytes, " + + std::to_string(b.chunks.size()) + " chunks, " + + std::to_string(b.tables.size()) + " tables)\n"; + manifest += "# create every chunk and table asset at its key " + "(CES_CREATE_ASSET), then the boot asset (any key) last\n"; + manifest += "# one-command deploy: " + "cesh asset deploy-bundle --days N\n"; + char name[32]; + for (size_t i = 0; i < b.chunks.size(); ++i) { + std::snprintf(name, sizeof(name), "chunk_%02zu.bin", i); + writeBlock(name, b.chunks[i]); + manifest += "chunk " + + ces::bytesToHex({b.chunkKeys[i].data(), 32}) + " " + + name + "\n"; + } + for (size_t i = 0; i < b.tables.size(); ++i) { + std::snprintf(name, sizeof(name), "table_%02zu.bin", i); + writeBlock(name, b.tables[i]); + manifest += "table " + + ces::bytesToHex({b.tableKeys[i].data(), 32}) + " " + + name + (i == 0 ? " root\n" : "\n"); + } + writeBlock("boot.bin", b.boot); + manifest += "boot - boot.bin\n"; + std::ofstream mf(fs::path(bundleDir) / "manifest.txt", + std::ios::trunc); + if (!mf) throw std::runtime_error("cannot write manifest.txt"); + mf << manifest; + std::cout << "wrote bundle to " << bundleDir << " (" + << code.size() << " bytes"; + if (b.chunks.empty()) { + std::cout << ", boot block only)\n"; + } else { + std::cout << ", " << b.chunks.size() << " chunks, " + << b.tables.size() << " tables)\n"; + } + return 0; + } catch (const std::exception& e) { + std::cerr << "cesc: " << inPath << ": " << e.what() << "\n"; + return 1; + } + } + + const size_t rawSize = code.size(); + if (boot) { + if (rawSize > ces::AssetData{}.size()) { + std::cerr << "cesc: " << rawSize << " bytes exceeds the " + << ces::AssetData{}.size() << "-byte boot block\n"; + return 1; + } + code.resize(ces::AssetData{}.size(), 0); + } + if (alias) { + if (rawSize > ces::ALIAS_INLINE_CODE_BYTES) { + std::cerr << "cesc: " << rawSize << " bytes exceeds the " + << ces::ALIAS_INLINE_CODE_BYTES + << "-byte alias inline code area\n"; + return 1; + } + code.resize(ces::ALIAS_INLINE_CODE_BYTES, 0); + } + + if (hex) { + std::cout << ces::bytesToHex({code.data(), code.size()}) << "\n"; + return 0; + } + + if (outPath.empty()) { + const size_t dot = inPath.rfind('.'); + outPath = (dot == std::string::npos ? inPath : inPath.substr(0, dot)) + + ".bin"; + } + std::ofstream out(outPath, std::ios::binary | std::ios::trunc); + if (!out) { + std::cerr << "cesc: cannot write " << outPath << "\n"; + return 1; + } + out.write(reinterpret_cast(code.data()), + static_cast(code.size())); + out.close(); + std::cout << "wrote " << outPath << " (" << rawSize << " bytes" + << (boot ? ", padded to 210-byte boot block" + : alias ? ", padded to the alias inline code area" : "") + << ")\n"; + return 0; +} diff --git a/vendor/ces/src/cescompmockd/main.cpp b/vendor/ces/src/cescompmockd/main.cpp new file mode 100644 index 0000000..2ba6516 --- /dev/null +++ b/vendor/ces/src/cescompmockd/main.cpp @@ -0,0 +1,59 @@ +// cescompmockd — stub compute child for the L2 compute feature. +// +// Takes a Unix domain socket path as argv[1]. Connects to it, then +// reads bytes from the socket in a loop and discards them. Exits on +// socket close, read error, or SIGKILL from the supervisor. +// +// A no-Lua regression stub: it lets the compute plumbing (CesPlex +// builtin:compute, IPC framing, supervisor, slot-fee accounting) be +// tested end-to-end without pulling in the LuaJIT runtime (cesluajitd). +// +// Deliberately minimal: no boost, no ceslib, no logging. A running +// instance should occupy a handful of kilobytes of RSS and essentially +// zero CPU. If you find yourself adding anything beyond libc + POSIX +// sockets here, ask whether it belongs in cesluajitd instead. + +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: %s \n", + argc > 0 ? argv[0] : "cescompmockd"); + return 2; + } + const char* sockPath = argv[1]; + + int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) { std::perror("cescompmockd: socket"); return 1; } + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + if (std::strlen(sockPath) >= sizeof(addr.sun_path)) { + std::fprintf(stderr, "cescompmockd: socket path too long\n"); + return 2; + } + std::strncpy(addr.sun_path, sockPath, sizeof(addr.sun_path) - 1); + + if (::connect(fd, reinterpret_cast(&addr), + sizeof(addr)) < 0) { + std::perror("cescompmockd: connect"); + return 1; + } + + char buf[4096]; + for (;;) { + ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n == 0) return 0; // EOF: supervisor closed channel + if (n < 0) { + if (errno == EINTR) continue; + return 1; + } + // Discard. The mock has no program semantics. + } +} diff --git a/vendor/ces/src/cesh/cesh_hyle.inc b/vendor/ces/src/cesh/cesh_hyle.inc new file mode 100644 index 0000000..908592a --- /dev/null +++ b/vendor/ces/src/cesh/cesh_hyle.inc @@ -0,0 +1,450 @@ +// cesh_hyle.inc -- client-side hyle verbs for cesh. Included by main.cpp only when built +// with --hyle (CES_HYLE). All hyle knowledge lives here in the shell; the CES client engine +// (ceslib) never links hyle. cesh builds + signs ops with hyle_services and relays them to a +// chain-hosting instance (hylesolo) over its /ces/lua/1 line protocol -- the same channel +// `cesh dial` opens. One chain, one connection: cross-chain aggregation is POST, not cesh. + +#include "dial.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct HyleCli { + std::string host; + uint16_t rpcPort = 0; + ces::KeyPair actorKey; + std::optional expectedServerPk; + std::string source = "/s/hylesolo.lua"; // `list`: which source's instances to enumerate + std::vector args; // "list", or [operands] + uint64_t fund = 0; // put: initial entry funding + bool wait = false; // write verbs: poll txr until it settles + bool quiet = false; // -q: data-only stdout + std::vector inTokens; // put: --in value tokens (text/hex:/file:), appended + std::string outPath; // get: --out file for the raw value bytes (else stdout) + bool hexOut = false; // get: print the value as a hex string +}; + +std::string hyHex(const uint8_t* p, std::size_t n) { + static const char* d = "0123456789abcdef"; + std::string s; + s.reserve(n * 2); + for (std::size_t i = 0; i < n; i++) { + s.push_back(d[p[i] >> 4]); + s.push_back(d[p[i] & 15]); + } + return s; +} +std::string hyHex(const hyle::PubKey& k) { return hyHex(k.data(), k.size()); } +std::string hyHex(const hyle::wire::Bytes& b) { return hyHex(b.data(), b.size()); } + +hyle::wire::View hySv(const std::string& s) { + return hyle::wire::View(reinterpret_cast(s.data()), s.size()); +} + +// Pull the value of a "key=..." token out of an "ok ..." reply line. +std::string hyField(const std::string& line, const std::string& key) { + const std::string k = " " + key + "="; + const auto p = line.find(k); + if (p == std::string::npos) return ""; + const auto start = p + k.size(); + const auto end = line.find(' ', start); + return line.substr(start, end == std::string::npos ? std::string::npos : end - start); +} + +bool hyHexTo32(const std::string& hex, uint8_t* out) { + if (hex.size() != 64) return false; + auto nyb = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + for (int i = 0; i < 32; i++) { + const int hi = nyb(hex[i * 2]), lo = nyb(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return false; + out[i] = static_cast((hi << 4) | lo); + } + return true; +} + +// Concatenate the --in tokens into one value buffer via the shared appender (text/hex:/file:). +std::string hyComposeValue(const std::vector& tokens) { + ces::Bytes buf; + for (const std::string& t : tokens) appendInToken(buf, t); + return std::string(buf.begin(), buf.end()); +} + +int hyErr(const std::string& msg) { + std::cerr << "Error: " << msg << "\n"; + return 1; +} + +// True if the bytes are valid UTF-8 with no control chars other than tab/newline/CR. +bool hyIsText(const std::string& s) { + std::size_t i = 0, n = s.size(); + while (i < n) { + const unsigned char c = static_cast(s[i]); + std::size_t len; + if (c < 0x80) { + if (c < 0x20 && c != '\t' && c != '\n' && c != '\r') return false; + len = 1; + } else if ((c >> 5) == 0x6) len = 2; + else if ((c >> 4) == 0xE) len = 3; + else if ((c >> 3) == 0x1E) len = 4; + else return false; + if (i + len > n) return false; + for (std::size_t k = 1; k < len; k++) + if ((static_cast(s[i + k]) >> 6) != 0x2) return false; + i += len; + } + return true; +} + +// Human display of a byte blob: the text if it is text, else hex: (N bytes). +// Display only; the exact bytes come from -q / --out / --hex. +std::string hyRenderBlob(const std::string& s) { + if (s.empty()) return "(empty)"; + if (hyIsText(s)) return s; + return "hex:" + hyHex(reinterpret_cast(s.data()), s.size()) + + " (" + std::to_string(s.size()) + " bytes)"; +} + +// Line-protocol name encoding: safe text rides bare; a name with whitespace, a control +// byte, or a leading "hex:" is sent as hex: so it round-trips unambiguously. +std::string hyWireName(const std::string& raw) { + bool safe = !raw.empty() && raw.rfind("hex:", 0) != 0; + for (unsigned char c : raw) if (c <= 0x20 || c == 0x7f) { safe = false; break; } + return safe ? raw : "hex:" + hyHex(reinterpret_cast(raw.data()), raw.size()); +} + +// Resolve a name argument through the shared text:/hex:/file:/bare grammar to raw bytes. +std::string hyResolveName(const std::string& arg) { + ces::Bytes buf; + appendInToken(buf, arg); + return std::string(buf.begin(), buf.end()); +} + +// `cesh hyle list`: enumerate the instances of a source (default /s/hylesolo.lua) with their +// pids, so the operator picks one and names it explicitly on every other command. This tool +// does not guess a pid; a stateful client would cache the mapping. +int hyleList(const HyleCli& c) { + ces::CesComputeClient cc; + if (c.expectedServerPk) cc.setServerPubkey(*c.expectedServerPk); + if (cc.connect(c.host, c.rpcPort, c.actorKey) != CES_OK) { + cc.disconnect(); + return hyErr("connect failed (check --rpc-port)"); + } + std::vector insts; + const uint8_t rc = cc.instances(c.source, insts); + cc.disconnect(); + if (rc != CES_OK) return hyErr("instances query failed rc=" + std::to_string(rc)); + if (insts.empty()) { + if (!c.quiet) std::cout << "no instances of " << c.source << "\n"; + return 0; + } + for (const auto& e : insts) { + if (c.quiet) { + std::cout << e.pid << "\n"; + } else { + std::cout << "pid=" << e.pid << " source=" << e.sourceName + << " client_port=" << e.clientPort << " rpc_port=" << e.rpcPort << "\n"; + } + } + return 0; +} + +// info|config|self|height|account|entry|txr|nodekey ride straight through: build the +// line, print the chain's reply verbatim. `get` is separate (extracts an entry's value as raw +// bytes); the write verbs build + sign ops. (nodekey is owner-gated at the program.) +const std::set& hyRelayVerbs() { + static const std::set v = {"info", "config", "self", "height", + "txr", "nodekey"}; + return v; +} + +// Submit hexOps to the chain, print the tx id, and with --wait poll txr until it settles. +// Shared by the op-building verbs and the raw submit escape hatch. +int hySubmit(ces::DialLineSession& sess, const std::string& hexOps, const HyleCli& c) { + std::string reply; + if (std::string e = sess.request("submit " + hexOps, reply); !e.empty()) return hyErr(e); + if (reply.rfind("ok ", 0) != 0) { std::cerr << reply << "\n"; return 1; } + const auto sp = reply.find(' ', 3); + const std::string txid = reply.substr(3, sp == std::string::npos ? std::string::npos : sp - 3); + if (!c.wait) { + std::cout << (c.quiet ? txid : ("submitted tx " + txid)) << "\n"; + return 0; + } + for (int i = 0; i < 200; i++) { + std::string t; + if (std::string e = sess.request("txr " + txid, t); !e.empty()) return hyErr(e); + if (t.rfind("ok ", 0) == 0) { + const bool applied = hyField(t, "applied") == "1"; + std::cout << (c.quiet ? (applied ? "1" : "0") + : ("tx " + txid + (applied ? " applied" : " rejected"))) << "\n"; + return applied ? 0 : 1; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return hyErr("tx " + txid + " did not settle in time"); +} + +int runHyle(const HyleCli& c) { + if (c.args.empty()) + return hyErr("usage: cesh hyle list | cesh hyle [args]"); + + // `list` is the only verb that does not name an instance; it is how you find the pids. + if (c.args[0] == "list") return hyleList(c); + + uint64_t pid = 0; + try { pid = std::stoull(c.args[0]); } + catch (...) { return hyErr("first argument must be an instance pid, or 'list' (see: cesh hyle list)"); } + if (pid == 0) return hyErr("instance pid required (see: cesh hyle list)"); + if (c.args.size() < 2) + return hyErr("usage: cesh hyle [args] -- verb is one of info|config|self|height|" + "account|entry|get|txr|nodekey|put|del|give|rip|transfer|mint|seize|" + "propose|approve|submit"); + const std::string verb = c.args[1]; + const std::vector op(c.args.begin() + 2, c.args.end()); // the verb's operands + + const bool isWrite = verb == "put" || verb == "del" || verb == "give" || verb == "transfer" || + verb == "rip" || verb == "mint" || verb == "seize" || + verb == "propose" || verb == "approve"; + // Writes act AS this hyle identity, so the key must be ed25519 (a CES ed25519 key IS a hyle + // account at the same 32 bytes; a secp256k1 key has no hyle identity). Reads do not. + if (isWrite && c.actorKey.getAlgorithm() != ces::KeyAlgo::ED25519) + return hyErr("this verb acts as a hyle identity; use an ed25519 key (secp256k1 has none)"); + + ces::DialArgs da; + da.serverHost = c.host; + da.rpcPort = c.rpcPort; + da.pid = pid; + da.signerKey = c.actorKey; + da.expectedServerPk = c.expectedServerPk; + + ces::DialLineSession sess; + if (std::string e = sess.open(da); !e.empty()) return hyErr("dial pid " + std::to_string(pid) + ": " + e); + + auto ask = [&](const std::string& line, std::string& reply) -> std::string { + return sess.request(line, reply); + }; + + // get : fetch an entry and emit its value bytes -- to --out (binary-safe), --hex, else + // raw stdout. `entry` shows the record; `get` gives the content, matching cesh file get. The + // name takes the text:/hex:/file: grammar and rides hyWireName, so binary keys round-trip. + if (verb == "get") { + if (op.empty()) return hyErr("get "); + const std::string name = hyResolveName(op[0]); + std::string reply; + if (std::string e = ask("entry " + hyWireName(name), reply); !e.empty()) return hyErr(e); + if (reply.rfind("ok ", 0) != 0) { std::cerr << reply << "\n"; return 1; } + const ces::Bytes bytes = ces::parseHex(hyField(reply, "payload")); + if (!c.outPath.empty()) { + std::ofstream f(c.outPath, std::ios::binary); + if (!f) return hyErr("cannot open --out " + c.outPath); + f.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if (!c.quiet) std::cout << "wrote " << bytes.size() << " bytes to " << c.outPath << "\n"; + } else if (c.hexOut) { + std::cout << hyHex(bytes) << "\n"; + } else { + std::cout.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + } + return 0; + } + + // submit: relay pre-signed op bytes (the carrier path). The op carries its own signature, so + // this needs no identity. Bytes come from --in; put/del/give/transfer build them for you, + // submit is the escape hatch for any op cesh does not model. + if (verb == "submit") { + if (c.inTokens.empty()) return hyErr("submit needs op bytes: --in "); + const std::string ops = hyComposeValue(c.inTokens); + return hySubmit(sess, hyHex(reinterpret_cast(ops.data()), ops.size()), c); + } + + // account / entry pretty-print the record (with balance) in human mode; -q emits the raw + // "ok k=v" line for piping. entry's name rides hyWireName so binary keys round-trip, and its + // name/value render as text or hex:<...> for the eyes only. + if (verb == "account") { + if (op.empty()) return hyErr("account "); + std::string reply; + if (std::string e = ask("account " + op[0], reply); !e.empty()) return hyErr(e); + if (reply.rfind("ok ", 0) != 0) { std::cerr << reply << "\n"; return 1; } + if (c.quiet) { std::cout << reply << "\n"; return 0; } + print_header("Hyle Account"); + print_field("Pubkey", op[0]); + print_field("Balance", hyField(reply, "balance")); + print_field("Sequence", hyField(reply, "sequence")); + print_field("Exists", hyField(reply, "exists") == "1" ? "yes" : "no"); + std::cout << std::endl; + return 0; + } + if (verb == "entry") { + if (op.empty()) return hyErr("entry "); + const std::string name = hyResolveName(op[0]); + std::string reply; + if (std::string e = ask("entry " + hyWireName(name), reply); !e.empty()) return hyErr(e); + if (reply.rfind("ok ", 0) != 0) { std::cerr << reply << "\n"; return 1; } + if (c.quiet) { std::cout << reply << "\n"; return 0; } + const ces::Bytes pl = ces::parseHex(hyField(reply, "payload")); + print_header("Hyle Entry"); + print_field("Name", hyRenderBlob(name)); + print_field("Owner", hyField(reply, "owner")); + print_field("Balance", hyField(reply, "balance")); + print_field("Created", hyField(reply, "created")); + print_field("Modified", hyField(reply, "modified")); + print_field("Rent", hyField(reply, "rent")); + print_field("Value", hyRenderBlob(std::string(pl.begin(), pl.end()))); + std::cout << std::endl; + return 0; + } + + if (hyRelayVerbs().count(verb)) { + std::string line = verb; + for (const auto& a : op) line += " " + a; + std::string reply; + if (std::string e = ask(line, reply); !e.empty()) return hyErr(e); + if (reply.rfind("err ", 0) == 0) { std::cerr << reply << "\n"; return 1; } + std::cout << reply << "\n"; + return 0; + } + + if (!isWrite) return hyErr("unknown verb: " + verb); + + // Write verbs: build + sign an op, encode it, submit the opaque bytes. Signatures are + // chain-bound, so learn the chain id first; the op's seq is the account's current sequence. + const minx::Hash priv = c.actorKey.getPrivateKey(); + hyle::PrivKey secret{}; + std::memcpy(secret.data(), priv.data(), 32); + const hyle::KeyPair hk = hyle::KeyPair::from_secret(secret); + + std::string info; + if (std::string e = ask("info", info); !e.empty()) return hyErr(e); + if (info.rfind("ok ", 0) != 0) return hyErr("info: " + info); + const std::string chain = hyField(info, "chain"); + if (chain.empty()) return hyErr("could not read chain id from info"); + + std::string acct; + if (std::string e = ask("account " + hyHex(hk.pub), acct); !e.empty()) return hyErr(e); + uint64_t seq = 0; + if (acct.rfind("ok ", 0) == 0) { + const std::string s = hyField(acct, "sequence"); + if (!s.empty()) seq = std::stoull(s); + } + + // put/del/give/rip take an entry key as the first positional; the rest use their own operands. + // The key takes the text:/hex:/file: grammar, so it can be UTF-8 or binary, then rides in the + // signed op as raw bytes. + std::string name; + if (verb == "put" || verb == "del" || verb == "give" || verb == "rip") { + if (op.empty()) return hyErr(verb + " needs a key: "); + name = hyResolveName(op[0]); + } + + hyle::services::Decoded d; + try { + if (verb == "put") { + std::string value; + if (!c.inTokens.empty()) value = hyComposeValue(c.inTokens); + else if (op.size() >= 2) value = op[1]; + else return hyErr("put needs a value: or --in "); + d.entries.push_back(hyle::services::make_entry_put( + hk, hySv(name), seq, c.fund, hySv(value), hySv(chain))); + } else if (verb == "del") { + d.entries.push_back(hyle::services::make_entry_del(hk, hySv(name), seq, hySv(chain))); + } else if (verb == "give") { + if (op.size() < 2) return hyErr("give "); + hyle::PubKey no{}; + if (!hyHexTo32(op[1], no.data())) return hyErr("bad newowner hex (need 64 hex chars)"); + d.entries.push_back(hyle::services::make_entry_give(hk, hySv(name), seq, no, hySv(chain))); + } else if (verb == "transfer") { + if (op.size() < 2) return hyErr("transfer "); + hyle::wire::Bytes to; + const std::string& dest = op[0]; + if (dest.rfind("e:", 0) == 0) { + to.push_back(hyle::services::ENTRY_PREFIX); + const std::string nm = dest.substr(2); + to.insert(to.end(), nm.begin(), nm.end()); + } else { + uint8_t pk[32]; + if (!hyHexTo32(dest, pk)) return hyErr("transfer dest: 64-hex account or e:"); + to.push_back(hyle::services::ACCOUNT_PREFIX); + to.insert(to.end(), pk, pk + 32); + } + d.transfers.push_back(hyle::services::make_transfer( + hk, hyle::wire::View(to.data(), to.size()), std::stoull(op[1]), seq, hySv(chain))); + } else if (verb == "rip") { + // The permissionless cull: unsigned, chain-independent. The culler (this key) takes the + // bounty; anyone may reap a rent-starved entry. + d.entries.push_back(hyle::services::make_entry_rip(hySv(name), hk.pub)); + } else if (verb == "mint" || verb == "seize") { + // Privileged sudo ops the signer must be a validator for (the chain gates it). mint moves + // from the mint sentinel (creates credit); seize moves an account's real funds. The inner + // is an unsigned transfer; the sudo vote authorizes it. On a one-validator chain the + // proposer's own vote is quorum, so it executes at once. + const bool isMint = verb == "mint"; + if (op.size() < (isMint ? 2u : 3u)) + return hyErr(isMint ? "mint " : "seize "); + hyle::services::TransferOp t; + uint8_t to[32]; + if (isMint) { + t.from = hyle::services::MINT_SENTINEL; + if (!hyHexTo32(op[0], to)) return hyErr("mint recipient must be 64 hex chars"); + } else { + if (!hyHexTo32(op[0], t.from.data())) return hyErr("seize from must be 64 hex chars"); + if (!hyHexTo32(op[1], to)) return hyErr("seize to must be 64 hex chars"); + } + t.to.push_back(hyle::services::ACCOUNT_PREFIX); + t.to.insert(t.to.end(), to, to + 32); + t.amount = std::stoull(op[isMint ? 1 : 2]); + hyle::services::Decoded inner; + inner.transfers.push_back(t); + const hyle::wire::Bytes ib = hyle::services::encode_ops(inner); + d.sudos.push_back(hyle::services::make_sudo_propose( + hk, seq, hyle::wire::View(ib.data(), ib.size()), hySv(chain))); + } else if (verb == "propose") { + // General sudo propose: the inner op batch (any privileged act) comes via --in. The caller + // builds it (mint/seize/entry acts); cesh signs the proposal as the validator. + if (c.inTokens.empty()) return hyErr("propose --in "); + const std::string inner = hyComposeValue(c.inTokens); + d.sudos.push_back(hyle::services::make_sudo_propose( + hk, seq, hyle::wire::View(reinterpret_cast(inner.data()), inner.size()), + hySv(chain))); + } else { // approve: add this validator's vote to a proposer's open proposal, bound to the act + if (op.empty() || c.inTokens.empty()) + return hyErr("approve --in "); + hyle::PubKey proposer{}; + if (!hyHexTo32(op[0], proposer.data())) return hyErr("approve proposer must be 64 hex chars"); + const std::string inner = hyComposeValue(c.inTokens); + const hyle::Hash ih = hyle::sha256( + hyle::wire::View(reinterpret_cast(inner.data()), inner.size())); + d.sudos.push_back(hyle::services::make_sudo_approve(hk, seq, proposer, ih, hySv(chain))); + } + } catch (const std::exception& e) { + return hyErr(std::string("bad argument: ") + e.what()); + } + + return hySubmit(sess, hyHex(hyle::services::encode_ops(d)), c); +} + +} // namespace diff --git a/vendor/ces/src/cesh/dial.cpp b/vendor/ces/src/cesh/dial.cpp new file mode 100644 index 0000000..3e6a93e --- /dev/null +++ b/vendor/ces/src/cesh/dial.cpp @@ -0,0 +1,969 @@ +// dial.cpp — implementation of `cesh dial `. +// +// Single self-contained translation unit. We don't reuse +// CesComputeClient because (a) it's hardcoded to /ces/compute/1, (b) it +// has a verb-driven request/response loop while dial flips into raw +// byte-pump mode after ATTACH, and (c) factoring a shared CesPlex +// client base would balloon scope. The ~80 LOC of bind boilerplate +// duplicated below is the price. + +#include "dial.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include // STDIN_FILENO, STDOUT_FILENO, ::write +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +LOG_MODULE("cesh"); + +namespace ces { + +namespace { + +constexpr const char* kLuaProto = "/ces/lua/1"; +constexpr uint8_t kVerbAttach = 0x01; + +// Per-call timeouts. Long enough to absorb LAN+WAN jitter, short +// enough that a hung server gives the user back the prompt. +constexpr auto kBindTimeout = std::chrono::seconds(10); +constexpr auto kAttachTimeout = std::chrono::seconds(10); + +uint64_t nowMicros() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +// --extsign control-channel helpers: line-based on fd 0/1, read byte-by-byte so +// we never consume past the newline into the raw byte pipe that follows. +bool hexToBytes(const std::string& hex, uint8_t* out, size_t n) { + if (hex.size() != n * 2) return false; + auto hv = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + for (size_t i = 0; i < n; i++) { + int hi = hv(hex[2 * i]), lo = hv(hex[2 * i + 1]); + if (hi < 0 || lo < 0) return false; + out[i] = static_cast((hi << 4) | lo); + } + return true; +} + +std::string readControlLine() { + std::string s; + char c; + for (;;) { + ssize_t r = ::read(STDIN_FILENO, &c, 1); + if (r <= 0) return s; // EOF/error: caller treats short line as error + if (c == '\n') break; + if (c != '\r') s.push_back(c); + } + return s; +} + +void writeControlLine(const std::string& s) { + std::string o = s + "\n"; + ssize_t w = ::write(STDOUT_FILENO, o.data(), o.size()); + (void)w; +} + +// All BE serialization goes through ces::Buffer (see ces/buffer.h). + +class NoopListener : public minx::MinxListener {}; + +// Outbound-only Rudp::Listener — forwards onSend to the local Minx, +// no inbound channel acceptance. +class DialRudpListener : public minx::Rudp::Listener { +public: + void setMinx(minx::Minx* m) { minx_ = m; } + void onSend(const minx::SockAddr& peer, + const minx::Bytes& bytes) override { + if (!minx_) return; + try { minx_->sendExtension(peer, bytes); } + catch (const std::exception&) { /* socket closed during teardown */ } + } +private: + minx::Minx* minx_ = nullptr; +}; + +// Resolve host:rpcPort → SockAddr (v6, IPv4-mapped if needed). +bool resolvePeer(const std::string& host, uint16_t port, + minx::SockAddr& out, std::string& err) { + boost::asio::io_context ioc; + boost::asio::ip::udp::resolver res(ioc); + boost::system::error_code ec; + auto results = res.resolve(host, std::to_string(port), ec); + if (ec || results.empty()) { + err = "resolve(" + host + "): " + + (ec ? ec.message() : std::string("no results")); + return false; + } + auto addr = results.begin()->endpoint().address(); + if (addr.is_v4()) { + addr = boost::asio::ip::make_address_v6( + boost::asio::ip::v4_mapped, addr.to_v4()); + } + out = minx::SockAddr(addr, port); + return true; +} + +// Synchronous dial driver. Owns minx, rudp, two io_contexts, two +// threads. Lifetime bounded by runDial(). Not thread-safe — only the +// runDial() main thread touches public methods; everything inside +// runs on netIO_ or taskIO_. +class Dialer { +public: + using WorkGuard = boost::asio::executor_work_guard< + boost::asio::io_context::executor_type>; + + ~Dialer() { stop(); } + + // Spin up the network stack and open the local UDP socket. + // Returns empty string on success, error message on failure. + std::string start(const minx::SockAddr& peer) { + peer_ = peer; + + minx::MinxConfig mc{}; + mc.instanceName = "cesh-dial"; + mc.randomXVMsToKeep = 0; + mc.randomXInitThreads = 0; + mc.trustLoopback = true; + minx_ = std::make_unique(&listener_, mc); + + minx::RudpConfig rcfg{}; + rcfg.maxChannelsPerPeer = 2; + rcfg.baseTickInterval = std::chrono::milliseconds(1); + rudpListener_.setMinx(minx_.get()); + rudp_ = std::make_unique(&rudpListener_, rcfg); + + { + minx::MinxStdExtensions stdExt; + stdExt.registerExtension( + minx::Rudp::KEY_V0, + [this](const minx::SockAddr& p, uint64_t key, + const minx::Bytes& payload) { + if (rudp_) rudp_->onPacket(p, key, payload, nowMicros()); + }); + minx_->setExtensionHandler(std::move(stdExt).build()); + } + + boundPort_ = minx_->openSocket( + boost::asio::ip::address_v6::any(), 0, netIO_, taskIO_); + if (boundPort_ == 0) return "failed to open local UDP socket"; + + netGuard_ = std::make_unique(netIO_.get_executor()); + taskGuard_ = std::make_unique(taskIO_.get_executor()); + netThread_ = std::thread([this]() { netIO_.run(); }); + taskThread_ = std::thread([this]() { taskIO_.run(); }); + + tickTimer_ = std::make_shared(taskIO_); + boost::asio::post(taskIO_, [this]() { scheduleTick(); }); + + std::mt19937 rng(std::random_device{}()); + channel_ = 0; + while (channel_ == 0) channel_ = rng(); + + return ""; + } + + void stop() { + if (!minx_) return; + if (tickTimer_) { + boost::system::error_code ec; + tickTimer_->cancel(); + } + minx_->closeSocket(false); + if (netGuard_) netGuard_->reset(); + if (taskGuard_) taskGuard_->reset(); + netIO_.stop(); + taskIO_.stop(); + if (netThread_.joinable()) netThread_.join(); + if (taskThread_.joinable()) taskThread_.join(); + stream_.reset(); + tickTimer_.reset(); + rudp_.reset(); + minx_.reset(); + rudpListener_.setMinx(nullptr); + netGuard_.reset(); + taskGuard_.reset(); + boundPort_ = 0; + } + + // Bind handshake on /ces/lua/1. On success, fills sessionToken and + // serverPubkey. Returns empty string on success, error message + // otherwise. `expected` (if non-null) hard-checks the reply pubkey; + // otherwise we TOFU-accept and write the captured pubkey out. + std::string bind(const KeyPair& signer, + const minx::Hash* expected, + uint64_t& sessionToken, + minx::Hash& serverPubkey) { + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto tokenOut = std::make_shared(0); + auto pubkeyOut = std::make_shared(); + boost::asio::post(taskIO_, [this, &signer, expected, + tokenOut, pubkeyOut, run]() { + rudp_->tick(nowMicros()); + stream_ = std::make_shared( + taskIO_.get_executor()); + if (!rudp_->registerChannel(peer_, channel_, stream_)) { + run->set_value("rudp registerChannel failed"); + return; + } + + const uint64_t bindNowUs = nowMicros(); + const std::string name = kLuaProto; + auto bindReq = std::make_shared( + ces::buildBindRequest(name, bindNowUs, signer)); + const auto& pkArr = signer.getPublicKeyAsHash(); + auto clientDigest = std::make_shared< + std::array>( + ces::computeBindRequestDigest( + std::span( + reinterpret_cast(name.data()), + name.size()), + bindNowUs, + std::span(pkArr.data(), pkArr.size()))); + + boost::asio::async_write( + *stream_, boost::asio::buffer(*bindReq), + [this, bindReq, clientDigest, expected, + tokenOut, pubkeyOut, run] + (const boost::system::error_code& ec, std::size_t) { + if (ec) { run->set_value("bind write: " + ec.message()); return; } + auto reply = std::make_shared< + std::array>(); + boost::asio::async_read( + *stream_, boost::asio::buffer(*reply), + [reply, clientDigest, expected, + tokenOut, pubkeyOut, run] + (const boost::system::error_code& ec2, std::size_t) { + if (ec2) { + run->set_value("bind read: " + ec2.message()); return; + } + auto r = ces::parseBindReply( + std::span( + reply->data(), reply->size())); + if (r.status != ces::CES_PLEX_OK) { + run->set_value("bind NACK from server"); + return; + } + if (!ces::verifyBindReply( + r, + std::span( + clientDigest->data(), clientDigest->size()))) { + run->set_value("bind reply digest/sig verify failed"); + return; + } + if (expected && std::memcmp(expected->data(), + r.serverPubkey.data(), + expected->size()) != 0) { + run->set_value("bind reply pubkey != expected"); + return; + } + std::memcpy(pubkeyOut->data(), r.serverPubkey.data(), + pubkeyOut->size()); + *tokenOut = r.channelSessionToken; + run->set_value(""); + }); + }); + }); + if (fut.wait_for(kBindTimeout) != std::future_status::ready) + return "bind handshake timeout"; + std::string err = fut.get(); + if (err.empty()) { + sessionToken = *tokenOut; + serverPubkey = *pubkeyOut; + } + return err; + } + + // ATTACH verb. Fills outStatus + outConnId. Returns empty string on + // wire success (status itself may be a CES_ERROR_*); error message + // on transport failure (write/read errored, no reply, etc.). + std::string attach(const KeyPair& signer, + uint64_t sessionToken, + uint64_t pid, + uint8_t& outStatus, + uint64_t& outConnId, + std::string& outHello) { + ces::Bytes preamble; + ces::Buffer::put(preamble, pid); + + Signature sig = ces::signPerOp( + signer, kVerbAttach, + std::span(preamble.data(), preamble.size()), + sessionToken); + + // Wire shape: [u8 verb][u32 BE preamble_len][preamble][65 sig]. + const size_t totalSize = ces::CES_PLEX_VERB_SIZE + + ces::CES_PLEX_PREAMBLE_LEN_SIZE + + preamble.size() + sig.size(); + minx::Bytes wire(totalSize); + minx::Buffer buf(wire); + buf.put(kVerbAttach); + buf.put(static_cast(preamble.size())); + buf.put(std::span(preamble.data(), preamble.size())); + buf.put(sig); + + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto wireBuf = std::make_shared(std::move(wire)); + auto strm = stream_; + // Outputs are written by handlers on taskIO_ and copied to the + // caller's refs only once the future is ready — never through the + // stack refs, so a late handler can't poke a freed frame on timeout. + auto statusOut = std::make_shared(0xFF); + auto connIdOut = std::make_shared(0); + auto helloOut = std::make_shared(); + + boost::asio::post(taskIO_, + [strm, wireBuf, statusOut, connIdOut, helloOut, run]() { + boost::asio::async_write( + *strm, boost::asio::buffer(*wireBuf), + [strm, wireBuf, statusOut, connIdOut, helloOut, run] + (const boost::system::error_code& ec, std::size_t) { + if (ec) { + run->set_value("attach write: " + ec.message()); return; + } + auto stBuf = std::make_shared>(); + boost::asio::async_read( + *strm, boost::asio::buffer(*stBuf), + [strm, stBuf, statusOut, connIdOut, helloOut, run] + (const boost::system::error_code& ec2, std::size_t) { + if (ec2) { + run->set_value("attach read status: " + ec2.message()); + return; + } + uint8_t status = (*stBuf)[0]; + *statusOut = status; + if (status != CES_OK) { + auto tr = std::make_shared( + ces::CES_PLEX_RESP_TRAILER_SIZE); + boost::asio::async_read( + *strm, boost::asio::buffer(*tr), + [tr, run](const boost::system::error_code& ec3, std::size_t) { + run->set_value( + ec3 ? ("attach read tail: " + ec3.message()) : std::string()); + }); + return; + } + // OK preamble: [connId u64][helloLen u32], then [hello][trailer]. + auto head = std::make_shared>(); + boost::asio::async_read( + *strm, boost::asio::buffer(*head), + [strm, head, connIdOut, helloOut, run] + (const boost::system::error_code& ec3, std::size_t) { + if (ec3) { + run->set_value("attach read head: " + ec3.message()); + return; + } + *connIdOut = ces::Buffer::peek(head->data()); + uint32_t helloLen = ces::Buffer::peek( + std::span(head->data(), head->size()), 8); + auto rest = std::make_shared( + static_cast(helloLen) + + ces::CES_PLEX_RESP_TRAILER_SIZE); + boost::asio::async_read( + *strm, boost::asio::buffer(*rest), + [rest, helloLen, helloOut, run]( + const boost::system::error_code& ec4, std::size_t) { + if (!ec4 && helloLen > 0) + helloOut->assign( + reinterpret_cast(rest->data()), helloLen); + run->set_value( + ec4 ? ("attach read rest: " + ec4.message()) + : std::string()); + }); + }); + }); + }); + }); + if (fut.wait_for(kAttachTimeout) != std::future_status::ready) + return "attach timeout"; + std::string err = fut.get(); + if (err.empty()) { + outStatus = *statusOut; + outConnId = *connIdOut; + outHello = *helloOut; + } + return err; + } + + // External-signing bind: same handshake as bind(), but the request was signed + // elsewhere (no private key here). `pubkey` is the 32-byte client key; `sig` + // is over computeBindRequestDigest(kLuaProto, timeUs, pubkey). + std::string bindExt(std::span pubkey, uint64_t timeUs, + const Signature& sig, const minx::Hash* expected, + uint64_t& sessionToken, minx::Hash& serverPubkey) { + const std::string name = kLuaProto; + auto bindReq = std::make_shared( + ces::buildBindRequestSigned(name, timeUs, pubkey, sig)); + auto clientDigest = std::make_shared< + std::array>( + ces::computeBindRequestDigest( + std::span( + reinterpret_cast(name.data()), name.size()), + timeUs, pubkey)); + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto tokenOut = std::make_shared(0); + auto pubkeyOut = std::make_shared(); + boost::asio::post(taskIO_, [this, bindReq, clientDigest, expected, + tokenOut, pubkeyOut, run]() { + rudp_->tick(nowMicros()); + stream_ = std::make_shared(taskIO_.get_executor()); + if (!rudp_->registerChannel(peer_, channel_, stream_)) { + run->set_value("rudp registerChannel failed"); return; + } + boost::asio::async_write( + *stream_, boost::asio::buffer(*bindReq), + [this, bindReq, clientDigest, expected, tokenOut, pubkeyOut, run] + (const boost::system::error_code& ec, std::size_t) { + if (ec) { run->set_value("bind write: " + ec.message()); return; } + auto reply = std::make_shared< + std::array>(); + boost::asio::async_read( + *stream_, boost::asio::buffer(*reply), + [reply, clientDigest, expected, tokenOut, pubkeyOut, run] + (const boost::system::error_code& ec2, std::size_t) { + if (ec2) { run->set_value("bind read: " + ec2.message()); return; } + auto r = ces::parseBindReply( + std::span( + reply->data(), reply->size())); + if (r.status != ces::CES_PLEX_OK) { + run->set_value("bind NACK from server"); return; + } + if (!ces::verifyBindReply(r, std::span( + clientDigest->data(), clientDigest->size()))) { + run->set_value("bind reply digest/sig verify failed"); return; + } + if (expected && std::memcmp(expected->data(), + r.serverPubkey.data(), expected->size()) != 0) { + run->set_value("bind reply pubkey != expected"); return; + } + std::memcpy(pubkeyOut->data(), r.serverPubkey.data(), + pubkeyOut->size()); + *tokenOut = r.channelSessionToken; + run->set_value(""); + }); + }); + }); + if (fut.wait_for(kBindTimeout) != std::future_status::ready) + return "bind handshake timeout"; + std::string err = fut.get(); + if (err.empty()) { sessionToken = *tokenOut; serverPubkey = *pubkeyOut; } + return err; + } + + // External-signing ATTACH: same as attach(), but `sig` was produced elsewhere + // over computePerOpDigest(kVerbAttach, preamble=pid, sessionToken). + std::string attachExt(const Signature& sig, uint64_t pid, + uint8_t& outStatus, uint64_t& outConnId) { + ces::Bytes preamble; + ces::Buffer::put(preamble, pid); + const size_t totalSize = ces::CES_PLEX_VERB_SIZE + + ces::CES_PLEX_PREAMBLE_LEN_SIZE + + preamble.size() + sig.size(); + minx::Bytes wire(totalSize); + minx::Buffer buf(wire); + buf.put(kVerbAttach); + buf.put(static_cast(preamble.size())); + buf.put(std::span(preamble.data(), preamble.size())); + buf.put(sig); + + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto wireBuf = std::make_shared(std::move(wire)); + auto strm = stream_; + auto statusOut = std::make_shared(0xFF); + auto connIdOut = std::make_shared(0); + boost::asio::post(taskIO_, [strm, wireBuf, statusOut, connIdOut, run]() { + boost::asio::async_write( + *strm, boost::asio::buffer(*wireBuf), + [strm, wireBuf, statusOut, connIdOut, run] + (const boost::system::error_code& ec, std::size_t) { + if (ec) { run->set_value("attach write: " + ec.message()); return; } + auto stBuf = std::make_shared>(); + boost::asio::async_read( + *strm, boost::asio::buffer(*stBuf), + [strm, stBuf, statusOut, connIdOut, run] + (const boost::system::error_code& ec2, std::size_t) { + if (ec2) { run->set_value("attach read status: " + ec2.message()); return; } + uint8_t status = (*stBuf)[0]; + *statusOut = status; + if (status != CES_OK) { + auto tr = std::make_shared( + ces::CES_PLEX_RESP_TRAILER_SIZE); + boost::asio::async_read( + *strm, boost::asio::buffer(*tr), + [tr, run](const boost::system::error_code& ec3, std::size_t) { + run->set_value( + ec3 ? ("attach read tail: " + ec3.message()) : std::string()); + }); + return; + } + auto head = std::make_shared>(); + boost::asio::async_read( + *strm, boost::asio::buffer(*head), + [strm, head, connIdOut, run] + (const boost::system::error_code& ec3, std::size_t) { + if (ec3) { run->set_value("attach read head: " + ec3.message()); return; } + *connIdOut = ces::Buffer::peek(head->data()); + uint32_t helloLen = ces::Buffer::peek( + std::span(head->data(), head->size()), 8); + auto rest = std::make_shared( + static_cast(helloLen) + ces::CES_PLEX_RESP_TRAILER_SIZE); + boost::asio::async_read( + *strm, boost::asio::buffer(*rest), + [rest, run](const boost::system::error_code& ec4, std::size_t) { + run->set_value( + ec4 ? ("attach read rest: " + ec4.message()) : std::string()); + }); + }); + }); + }); + }); + if (fut.wait_for(kAttachTimeout) != std::future_status::ready) + return "attach timeout"; + std::string err = fut.get(); + if (err.empty()) { outStatus = *statusOut; outConnId = *connIdOut; } + return err; + } + + // Drive the data pump until the channel closes (EOF) or a signal + // arrives. Returns the exit code (0 / 130 / 143). All operations + // run on taskIO_; the calling thread blocks on a promise. + int runDataPump() { + auto exitCode = std::make_shared>(); + auto exitFut = exitCode->get_future(); + auto exitSet = std::make_shared>(false); + + auto stdinFd = std::make_shared< + boost::asio::posix::stream_descriptor>(taskIO_, STDIN_FILENO); + auto signals = std::make_shared( + taskIO_, SIGINT, SIGTERM); + + auto setExit = [exitCode, exitSet](int code) { + bool expected = false; + if (exitSet->compare_exchange_strong(expected, true)) + exitCode->set_value(code); + }; + + auto strm = stream_; + + // The two recursive pump callbacks are owned here by shared_ptr and + // re-armed through weak_ptrs. taskIO_ keeps running until the Dialer + // is destroyed (well after this frame returns on exit), so a late + // continuation must not re-enter a callback whose frame is gone: + // lock() yields null once this frame drops the last strong ref. + + // ---- channel → stdout pump ---- + auto chanBuf = std::make_shared>(); + auto readChan = std::make_shared>(); + std::weak_ptr> readChanW = readChan; + *readChan = [strm, chanBuf, readChanW, stdinFd, signals, setExit]() { + strm->async_read_some( + boost::asio::buffer(*chanBuf), + [strm, chanBuf, readChanW, stdinFd, signals, setExit] + (const boost::system::error_code& ec, std::size_t n) { + if (ec) { + // Channel closed by any party. Cancel stdin reader and + // signal handler so taskIO_ can drain. + boost::system::error_code cec; + stdinFd->cancel(cec); + signals->cancel(cec); + setExit(0); + return; + } + if (n > 0) { + // Blocking write to stdout — io thread is the only writer. + // EINTR-resume; any other write error means stdout is gone, + // which we treat as terminal. + const uint8_t* p = chanBuf->data(); + std::size_t left = n; + while (left > 0) { + ssize_t w = ::write(STDOUT_FILENO, p, left); + if (w < 0) { + if (errno == EINTR) continue; + // Tear down: stdout died, no point in keeping the + // channel up. + boost::system::error_code cec; + strm->close(); + stdinFd->cancel(cec); + signals->cancel(cec); + setExit(1); + return; + } + p += w; left -= static_cast(w); + } + } + if (auto self = readChanW.lock()) (*self)(); + }); + }; + + // ---- stdin → channel pump ---- + auto stdinBuf = std::make_shared>(); + auto readStdin = std::make_shared>(); + std::weak_ptr> readStdinW = readStdin; + *readStdin = [strm, stdinFd, stdinBuf, readStdinW]() { + stdinFd->async_read_some( + boost::asio::buffer(*stdinBuf), + [strm, stdinBuf, readStdinW] + (const boost::system::error_code& ec, std::size_t n) { + if (ec) { + // EOF (boost::asio::error::eof) or cancel. Half-close: stop + // reading stdin, leave the channel up so the program can + // drain replies. Don't close the stream here. + return; + } + if (n == 0) { + if (auto self = readStdinW.lock()) (*self)(); + return; + } + auto out = std::make_shared( + stdinBuf->begin(), stdinBuf->begin() + n); + boost::asio::async_write( + *strm, boost::asio::buffer(*out), + [readStdinW, out] + (const boost::system::error_code& wec, std::size_t) { + if (wec) { + // Channel write failed — channel reader will pick up + // the close on its next async_read_some. + return; + } + if (auto self = readStdinW.lock()) (*self)(); + }); + }); + }; + + // ---- signal handler ---- + signals->async_wait( + [setExit, strm, stdinFd] + (const boost::system::error_code& ec, int signo) { + if (ec) return; + // Active close → channel reader will see eof and tear down. + // exitCode reflects the signal. + boost::system::error_code cec; + strm->close(); + stdinFd->cancel(cec); + int code = (signo == SIGTERM) ? 143 : 130; + setExit(code); + }); + + boost::asio::post(taskIO_, [readStdin, readChan]() { + (*readStdin)(); + (*readChan)(); + }); + + int code = exitFut.get(); + + // Best-effort flush of stdout. Channel reader has already drained + // up to its eof; nothing else owes us bytes. + boost::system::error_code cec; + stdinFd->cancel(cec); + signals->cancel(cec); + return code; + } + + // Synchronous line I/O over the ATTACHed stream: post to taskIO_, wait on a future. + // Mirrors the bind()/attach() future pattern above. Used by DialLineSession. + std::string writeAll(std::span data) { + if (!stream_) return "no stream"; + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto strm = stream_; + auto buf = std::make_shared>(data.begin(), data.end()); + boost::asio::post(taskIO_, [strm, buf, run]() { + boost::asio::async_write( + *strm, boost::asio::buffer(*buf), + [buf, run](const boost::system::error_code& ec, std::size_t) { + run->set_value(ec ? ("write: " + ec.message()) : std::string()); + }); + }); + if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) + return "write timeout"; + return fut.get(); + } + + // Read one '\n'-terminated line (newline dropped) into out. lineBuf_ carries any + // bytes read past the newline to the next call. Touched only on taskIO_. + std::string readLine(std::string& out, std::chrono::milliseconds timeout) { + if (!stream_) return "no stream"; + auto run = std::make_shared>>(); + auto fut = run->get_future(); + auto strm = stream_; + boost::asio::post(taskIO_, [this, strm, run]() { + boost::asio::async_read_until( + *strm, lineBuf_, '\n', + [this, run](const boost::system::error_code& ec, std::size_t) { + if (ec) { run->set_value({"read: " + ec.message(), std::string()}); return; } + std::istream is(&lineBuf_); + std::string line; + std::getline(is, line); + if (!line.empty() && line.back() == '\r') line.pop_back(); + run->set_value({std::string(), line}); + }); + }); + if (fut.wait_for(timeout) != std::future_status::ready) return "read timeout"; + auto pr = fut.get(); + if (!pr.first.empty()) return pr.first; + out = pr.second; + return ""; + } + +private: + void scheduleTick() { + if (!tickTimer_ || !rudp_) return; + tickTimer_->expires_after(std::chrono::milliseconds(10)); + tickTimer_->async_wait( + [this](const boost::system::error_code& ec) { + if (ec || !rudp_) return; + rudp_->tick(nowMicros()); + scheduleTick(); + }); + } + + NoopListener listener_; + DialRudpListener rudpListener_; + std::unique_ptr minx_; + std::unique_ptr rudp_; + boost::asio::io_context netIO_; + boost::asio::io_context taskIO_; + std::unique_ptr netGuard_; + std::unique_ptr taskGuard_; + std::thread netThread_; + std::thread taskThread_; + std::shared_ptr tickTimer_; + uint16_t boundPort_ = 0; + + minx::SockAddr peer_; + uint32_t channel_ = 0; + std::shared_ptr stream_; + boost::asio::streambuf lineBuf_; +}; + +// Map ATTACH status → cesh exit code per spec. +int exitCodeForAttachStatus(uint8_t s) { + switch (s) { + case CES_OK: return 0; + case CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND: return 2; + case CES_ERROR_NOT_LISTENING: return 3; + case CES_ERROR_PROTO_REJECTED: return 5; + default: return 1; + } +} + +const char* attachErrorName(uint8_t s) { + switch (s) { + case CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND: return "instance not found"; + case CES_ERROR_NOT_LISTENING: return "instance not listening"; + case CES_ERROR_PROTO_REJECTED: return "protocol rejected"; + case CES_ERROR_BAD_NAME: return "bad name"; + default: return "attach failed"; + } +} + +} // namespace + +int runDial(const DialArgs& args) { + if (args.rpcPort == 0) { + std::cerr << "Error: --rpc-port is required for `cesh dial`.\n"; + return 1; + } + + // Unbuffered stdio. We use ::write to STDOUT_FILENO directly anyway, + // but flip the FILE* layer too in case a fallback path uses it. + std::setvbuf(stdin, nullptr, _IONBF, 0); + std::setvbuf(stdout, nullptr, _IONBF, 0); + + minx::SockAddr peer; + std::string err; + if (!resolvePeer(args.serverHost, args.rpcPort, peer, err)) { + std::cerr << "Error: " << err << "\n"; + return 1; + } + + Dialer dialer; + if (auto e = dialer.start(peer); !e.empty()) { + std::cerr << "Error: " << e << "\n"; + return 1; + } + + uint64_t sessionToken = 0; + minx::Hash serverPk{}; + const minx::Hash* expected = + args.expectedServerPk.has_value() ? &*args.expectedServerPk : nullptr; + + // --extsign: the signatures come from a tunneler (cesweb) over a stdio + // control handshake; no private key here. Bind, hand back the session token + // so the far side can sign ATTACH over it, then ATTACH and fall into the same + // raw byte pipe. Control I/O is on fd 0/1 and strictly precedes the pump. + if (args.extSign) { + minx::Hash clientPub{}; + if (!hexToBytes(args.clientPubkeyHex, clientPub.data(), clientPub.size())) { + writeControlLine("ERR bad --pubkey"); return 1; + } + std::string bindLine = readControlLine(); // "BIND " + uint64_t timeUs = 0; std::string bindSigHex; + { std::istringstream is(bindLine); std::string tag; + if (!(is >> tag >> timeUs >> bindSigHex) || tag != "BIND") { + writeControlLine("ERR expected BIND"); return 1; } } + Signature bindSig{}; + if (!hexToBytes(bindSigHex, bindSig.data(), bindSig.size())) { + writeControlLine("ERR bad bind sig"); return 1; + } + if (auto e = dialer.bindExt( + std::span(clientPub.data(), clientPub.size()), + timeUs, bindSig, expected, sessionToken, serverPk); !e.empty()) { + writeControlLine("ERR bind: " + e); return 4; + } + writeControlLine("TOKEN " + std::to_string(sessionToken)); + + std::string attLine = readControlLine(); // "ATTACH " + std::string attSigHex; + { std::istringstream is(attLine); std::string tag; + if (!(is >> tag >> attSigHex) || tag != "ATTACH") { + writeControlLine("ERR expected ATTACH"); return 1; } } + Signature attSig{}; + if (!hexToBytes(attSigHex, attSig.data(), attSig.size())) { + writeControlLine("ERR bad attach sig"); return 1; + } + uint8_t st = 0xFF; uint64_t cid = 0; + if (auto e = dialer.attachExt(attSig, args.pid, st, cid); !e.empty()) { + writeControlLine("ERR attach: " + e); return 5; + } + if (st != CES_OK) { + writeControlLine(std::string("ERR ") + attachErrorName(st)); + return exitCodeForAttachStatus(st); + } + writeControlLine("READY"); + return dialer.runDataPump(); + } + + if (auto e = dialer.bind(args.signerKey, expected, + sessionToken, serverPk); + !e.empty()) { + std::cerr << "Error: bind: " << e << "\n"; + return 4; + } + if (!expected && args.verbose) { + std::cerr << "TOFU server pubkey: " + << minx::hashToString(serverPk) << "\n"; + } + + uint8_t attachStatus = 0xFF; + uint64_t connId = 0; + std::string hello; + if (auto e = dialer.attach(args.signerKey, sessionToken, + args.pid, attachStatus, connId, hello); + !e.empty()) { + std::cerr << "Error: attach: " << e << "\n"; + return 5; + } + if (attachStatus != CES_OK) { + std::cerr << "Error: " << attachErrorName(attachStatus) + << " (attach status=" << int(attachStatus) << ")\n"; + return exitCodeForAttachStatus(attachStatus); + } + if (args.verbose) { + std::cerr << "ATTACH ok conn_id=" << connId << "\n"; + } + // The greeting rode the accept: emit it as the first bytes of the stream, + // exactly as if the program had sent it on open. + if (!hello.empty()) { + std::cout.write(hello.data(), static_cast(hello.size())); + std::cout.flush(); + } + + return dialer.runDataPump(); +} + +struct DialLineSession::Impl { + Dialer dialer; + uint64_t token = 0; + bool opened = false; +}; + +DialLineSession::DialLineSession() : impl_(std::make_unique()) {} +DialLineSession::~DialLineSession() = default; + +std::string DialLineSession::open(const DialArgs& args) { + const std::string host = args.serverHost.empty() ? "localhost" : args.serverHost; + minx::SockAddr peer; + std::string err; + if (!resolvePeer(host, args.rpcPort, peer, err)) return err; + std::string e = impl_->dialer.start(peer); + if (!e.empty()) return e; + + minx::Hash serverPk{}; + const minx::Hash* expected = args.expectedServerPk ? &*args.expectedServerPk : nullptr; + e = impl_->dialer.bind(args.signerKey, expected, impl_->token, serverPk); + if (!e.empty()) return "bind: " + e; + + uint8_t status = 0; + uint64_t connId = 0; + std::string hello; // consumed from the accept; the line protocol ignores it + e = impl_->dialer.attach(args.signerKey, impl_->token, args.pid, status, connId, hello); + if (!e.empty()) return "attach: " + e; + if (status != CES_OK) return std::string("attach: ") + attachErrorName(status); + + impl_->opened = true; + return ""; +} + +std::string DialLineSession::request(const std::string& line, std::string& reply, + std::chrono::milliseconds timeout) { + if (!impl_->opened) return "session not open"; + std::string wire = line; + wire.push_back('\n'); + std::string e = impl_->dialer.writeAll(std::span( + reinterpret_cast(wire.data()), wire.size())); + if (!e.empty()) return e; + return impl_->dialer.readLine(reply, timeout); +} + +} // namespace ces diff --git a/vendor/ces/src/cesh/dial.h b/vendor/ces/src/cesh/dial.h new file mode 100644 index 0000000..c834730 --- /dev/null +++ b/vendor/ces/src/cesh/dial.h @@ -0,0 +1,86 @@ +// dial.h — `cesh dial ` implementation entry point. +// +// Opens a bidirectional byte stream from the cesh client to a running +// compute instance over /ces/lua/1, then pipes stdin↔channel↔stdout. +// The behavior is `nc` shaped: +// - byte-clean, no framing, no banners on stdout. +// - stderr only for diagnostics; -v adds a single ATTACH-ok line. +// - stdin EOF = stop sending, keep reading until peer closes (TCP +// half-close style). +// - SIGINT/SIGTERM = active stream close, exit 130/143. +// +// Exit codes: +// 0 clean (peer closed, or stdin EOF then peer closed) +// 1 generic / unknown error +// 2 CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND on ATTACH +// 3 CES_ERROR_NOT_LISTENING on ATTACH +// 4 bind handshake failure (network / sig / NACK) +// 5 ATTACH protocol error (sig/wire issue server-side) +// 130 SIGINT +// 143 SIGTERM +#pragma once + +#include + +#include + +#include +#include +#include +#include +#include + +namespace ces { + +struct DialArgs { + std::string serverHost; // hostname or IP (no port) + uint16_t rpcPort = 0; // CesPlex RUDP port (--rpc-port) + uint64_t pid = 0; + KeyPair signerKey; + // If provided, the bind reply's server pubkey must match. If absent, + // the first reply's pubkey is TOFU-accepted (logged once on stderr). + std::optional expectedServerPk; + bool verbose = false; // -v: print "ATTACH ok conn_id=N" once + + // External-signing mode (--extsign): no wallet/private key in this process. + // The bind + ATTACH signatures are supplied over a tiny stdio control + // handshake, so a tunneler (cesweb) keeps the key in the browser/gateway and + // never hands it to cesh. When set, signerKey is unused; clientPubkeyHex names + // the 32-byte client pubkey (64 hex) that signed. Control protocol: + // stdin <- "BIND \n" + // stdout -> "TOKEN \n" (or "ERR \n" then exit) + // stdin <- "ATTACH \n" + // stdout -> "READY\n" (or "ERR \n" then exit) + // after which the channel is a raw byte pipe exactly like the wallet path. + bool extSign = false; + std::string clientPubkeyHex; +}; + +int runDial(const DialArgs& args); + +// A bound + ATTACHed /ces/lua/1 session that exchanges newline-delimited request/reply +// lines. Lets a caller drive an instance's line protocol (e.g. hylesolo) by reusing the +// exact CesPlex bind + ATTACH handshake runDial uses, without duplicating it. Not +// thread-safe: one request at a time from a single thread. +class DialLineSession { +public: + DialLineSession(); + ~DialLineSession(); + DialLineSession(const DialLineSession&) = delete; + DialLineSession& operator=(const DialLineSession&) = delete; + + // Bind + ATTACH to args.pid (signerKey / rpcPort / serverHost / expectedServerPk used; + // extSign/verbose ignored). Returns "" on success, else an error message. + std::string open(const DialArgs& args); + + // Send `line` (a '\n' is appended) and read back one reply line (without the trailing + // '\n') into `reply`. Returns "" on success, else an error message. + std::string request(const std::string& line, std::string& reply, + std::chrono::milliseconds timeout = std::chrono::seconds(15)); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace ces diff --git a/vendor/ces/src/cesh/main.cpp b/vendor/ces/src/cesh/main.cpp new file mode 100644 index 0000000..4c0cba3 --- /dev/null +++ b/vendor/ces/src/cesh/main.cpp @@ -0,0 +1,2818 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "dial.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace ces; + +// One `--in` token appended to a byte buffer: "hex:XX" | "file:PATH" | "text:S" | bare text. +// The shared shape for every data-in option (file put, hyle put). +static void appendInToken(ces::Bytes& out, const std::string& s) { + if (s.rfind("hex:", 0) == 0) { + ces::Bytes b = ces::parseHex(std::string_view(s).substr(4)); + out.insert(out.end(), b.begin(), b.end()); + } else if (s.rfind("file:", 0) == 0) { + std::ifstream ifs(s.substr(5), std::ios::binary); + if (!ifs) throw std::runtime_error("cannot open " + s.substr(5)); + out.insert(out.end(), std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } else if (s.rfind("text:", 0) == 0) { + const std::string t = s.substr(5); + out.insert(out.end(), t.begin(), t.end()); + } else { + out.insert(out.end(), s.begin(), s.end()); + } +} + +#ifdef CES_HYLE +// Client-side hyle verbs. Isolated in the shell, guarded by the --hyle build; the CES client +// engine (ceslib) never links hyle. Defines HyleCli + runHyle() in an anonymous namespace. +void print_header(const std::string& title); +void print_field(const std::string& key, const std::string& val); +#include "cesh_hyle.inc" +#endif + +// Local UDP bind port for cesh; 0 = auto-assign, allows parallel instances. +constexpr uint16_t CESH_LOCAL_PORT = 0; + +// ============================================================================= +// HELPER: Formatting (CLI-only) +// ============================================================================= + +// Silent/pipe mode (-q/--quiet): stdout carries DATA ONLY — raw bytes for +// content fetches, JSON for structured results — with zero human chrome. +// Human messages and errors go to stderr (errors always do, both modes). +// The two human-output helpers below become no-ops in quiet mode, so the +// per-command quiet branches own everything that reaches stdout. +bool g_quiet = false; + +void print_header(const std::string& title) { + if (g_quiet) return; + std::cout << "\n=== " << title << " ===\n"; +} + +void print_field(const std::string& key, const std::string& val) { + if (g_quiet) return; + std::cout << std::left << std::setw(16) << (key + ":") << val << "\n"; +} + +void print_field(const std::string& key, uint64_t val) { + print_field(key, std::to_string(val)); +} + +// Minimal JSON string escaper for --quiet structured output. cesh has no +// JSON dependency and these objects are tiny, so we hand-roll. +std::string jesc(const std::string& s) { + std::string o; + o.reserve(s.size() + 2); + for (unsigned char c : s) { + switch (c) { + case '"': o += "\\\""; break; + case '\\': o += "\\\\"; break; + case '\n': o += "\\n"; break; + case '\r': o += "\\r"; break; + case '\t': o += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof buf, "\\u%04x", c); + o += buf; + } else { + o += static_cast(c); + } + } + } + return o; +} + +// One compute instance as a JSON object (shared by compute ps/stat/instances +// in --quiet mode). +std::string instanceJson(const ces::CesComputeClient::InstanceInfo& e) { + std::string o = "{"; + o += "\"pid\":" + std::to_string(e.pid); + o += ",\"sourceName\":\"" + jesc(e.sourceName) + "\""; + o += ",\"startedAtUs\":" + std::to_string(e.startedAtUs); + o += ",\"fileBalance\":" + std::to_string(e.fileBalance); + o += ",\"cpuBasisPoints\":"+ std::to_string(e.cpuBasisPoints); + o += ",\"rssBytes\":" + std::to_string(e.rssBytes); + o += ",\"clientPort\":" + std::to_string(e.clientPort); + o += ",\"rpcPort\":" + std::to_string(e.rpcPort); + o += ",\"programPubkey\":\"" + ces::bytesToHex(e.programPubkey) + "\""; + o += "}"; + return o; +} + +// Log level parsing lives in ces/logutil.h (setupLogger). +// Local wrapper ignores unknown levels (cesh's old behavior) by swallowing +// the exception — we don't want cesh to exit on a typo in --log. +static void setup_logger(const std::string& logLevel) { + try { ces::setupLogger(logLevel); } catch (...) { /* tolerate */ } +} + +// ============================================================================= +// MAIN +// ============================================================================= + +int main(int argc, char* argv[]) { + blog::enable("minx"); + blog::enable("powengine"); + +#ifndef CES_GIT_HASH +#define CES_GIT_HASH "unknown" +#endif + CLI::App app{"cesh - CES shell client"}; + app.set_version_flag("--version", std::string(CES_GIT_HASH)); + app.require_subcommand(0, 1); + app.fallthrough(); + app.set_help_all_flag("--help-all", "Show all help including global options"); + + // ---- Global options ---- + + std::string logLevel = "warning"; + const std::string defaultServerEndpoint = + "localhost:" + std::to_string(ces::DEFAULT_PORT); + std::string server_arg = defaultServerEndpoint; + std::string proxy_arg; + std::string actor_arg; + std::string wallet_read_arg, wallet_save_arg; + uint16_t clientPort = CESH_LOCAL_PORT; + bool cacheOnly = false; + bool opt_secp = false; + + app + .add_option("-l,--log", logLevel, + "Log level ([t]race, [d]ebug, [i]nfo, [w]arning, [e]rror, " + "[f]atal)") + ->default_val("warning"); + + app + .add_option("--server", server_arg, + "Server endpoint (host:port)") + ->default_val(defaultServerEndpoint); + + int tries_arg = 3; + app.add_option("--tries", tries_arg, "Number of send attempts (1-16)") + ->default_val(3)->check(CLI::Range(1, 16)); + + app.add_option("--port", clientPort, "Local UDP port") + ->default_val(CESH_LOCAL_PORT); + + app.add_option("--proxy", proxy_arg, + "TCP proxy endpoint (host:port). Overrides --server."); + + uint16_t rpcPort_arg = 0; + app.add_option("--rpc-port", rpcPort_arg, + "Server's CesPlex/file-store UDP port " + "(required for 'file' subcommands)"); + + std::string server_key_arg; + app.add_option("--server-key", server_key_arg, + "Server's 32-byte public key (hex) for response-sig " + "verification. If omitted, the key is taken from the free " + "handshake — no paid server-info query."); + + app.add_option("-a,--actor", actor_arg, + "Acting account (pubkey hex or @index)"); + + app + .add_option("-r,--wallet", wallet_read_arg, + "Load wallet from file") + ->expected(0, 1); + + app.add_flag("--secp", opt_secp, "Use secp256k1 for key gen/import"); + + app.add_flag("-c,--cache-only", cacheOnly, "Lightweight PoW engine (slow)"); + + app.add_flag("-q,--quiet", g_quiet, + "Silent/pipe mode: stdout is data only (raw bytes or JSON), " + "no human messages; errors still go to stderr"); + + // ---- Subcommand: keys ---- + + auto* cmd_keys = app.add_subcommand("keys", "Key management"); + cmd_keys->require_subcommand(0, 1); + cmd_keys->fallthrough(); + + int gen_count = 1; + auto* cmd_keys_gen = + cmd_keys->add_subcommand("gen", "Generate new keys"); + cmd_keys_gen->add_option("count", gen_count, "Number of keys") + ->default_val(1); + cmd_keys_gen + ->add_option("-w,--save", wallet_save_arg, "Save wallet after generating") + ->expected(0, 1); + + bool keys_show_public = false; + auto* cmd_keys_list = + cmd_keys->add_subcommand("list", "List wallet keys"); + cmd_keys_list->add_flag("-p,--public", keys_show_public, + "Include public keys"); + + std::string keys_add_arg; + auto* cmd_keys_add = + cmd_keys->add_subcommand("add", "Add existing private key"); + cmd_keys_add->add_option("key", keys_add_arg, "Private key hex")->required(); + cmd_keys_add + ->add_option("-w,--save", wallet_save_arg, "Save wallet after adding") + ->expected(0, 1); + + cmd_keys->add_subcommand("export", "Print CESH_WALLET export command"); + + // ---- Subcommand: query ---- + + std::string query_account_arg; + auto* cmd_query = app.add_subcommand("query", "Unsigned account query"); + cmd_query->add_option("account", query_account_arg, "Account key or @index") + ->required(); + + // ---- Subcommand: squery ---- + + std::string squery_account_arg; + auto* cmd_squery = + app.add_subcommand("squery", "Signed account query (paid)"); + cmd_squery + ->add_option("account", squery_account_arg, "Account key or @index") + ->required(); + + // ---- Subcommand: transfer ---- + + std::string transfer_dest_arg; + uint64_t transfer_amount_arg = 0; + bool transfer_open = false; + auto* cmd_transfer = app.add_subcommand("transfer", "Transfer funds (safe: fails if dest not found)"); + cmd_transfer->add_option("dest", transfer_dest_arg, "Destination key or @index") + ->required(); + cmd_transfer->add_option("amount", transfer_amount_arg, "Amount")->required(); + cmd_transfer->add_flag("--open", transfer_open, + "Auto-create destination account if not found"); + + uint64_t payment_amount_arg = 0; + int payment_days_arg = 1; + std::string payment_dest_arg; + auto* cmd_payment = app.add_subcommand("payment", "Create payment account"); + cmd_payment->add_option("dest", payment_dest_arg, "Destination key or @index") + ->required(); + cmd_payment->add_option("amount", payment_amount_arg, "Amount")->required(); + cmd_payment->add_option("--days", payment_days_arg, "Payment days") + ->default_val(1); + + std::string cross_dest_arg, cross_server_arg; + uint64_t cross_amount_arg = 0; + auto* cmd_cross = app.add_subcommand("cross", + "Cross-server transfer (send to a key on a peer server)"); + cmd_cross->add_option("dest", cross_dest_arg, "Destination key or @index") + ->required(); + cmd_cross->add_option("amount", cross_amount_arg, "Amount")->required(); + cmd_cross->add_option("server", cross_server_arg, + "Destination server address (host:port)")->required(); + + // ---- Subcommand: gossip ---- + + std::string gossip_msg_arg, gossip_dest_arg; + uint64_t gossip_budget_arg = 0; + auto* cmd_gossip = app.add_subcommand( + "gossip", "Flood a message across the server mesh (paid per hop)"); + cmd_gossip->add_option("msg", gossip_msg_arg, "Message text")->required(); + cmd_gossip->add_option("budget", gossip_budget_arg, + "Propagation budget (whole credits)")->required(); + cmd_gossip->add_option("dest", gossip_dest_arg, + "Target server pubkey (64-hex); omit = broadcast to all"); + + // ---- Subcommand: server-info ---- + + auto* cmd_sinfo = + app.add_subcommand("server-info", "Query extended server info (paid)"); + + // ---- Subcommand: peer-info (unsigned, no actor needed) ---- + + auto* cmd_peer_info = app.add_subcommand( + "peer-info", "Read a server's peer-table slot (unsigned/free)"); + uint16_t peer_info_id_arg = 0; + std::string peer_info_server_arg; + cmd_peer_info->add_option("id", peer_info_id_arg, "Peer-table slot index") + ->required(); + cmd_peer_info->add_option("server", peer_info_server_arg, + "Server endpoint (host:port)")->required(); + + // ---- Subcommand: ping (unsigned, no actor needed) ---- + + auto* cmd_ping = app.add_subcommand( + "ping", "Connect and print server MINX+CES handshake info"); + + // ---- Subcommand: mine ---- + + auto* cmd_mine = app.add_subcommand("mine", "Mining mode"); + uint32_t mine_threads_arg = 1; + cmd_mine->add_option("-t,--threads", mine_threads_arg, + "Number of RandomX hashing threads (default 1; " + "values >hardware_concurrency are clamped)"); + + // ---- Subcommand: asset ---- + + auto* cmd_asset = app.add_subcommand("asset", "Asset operations"); + cmd_asset->require_subcommand(0, 1); + + std::string asset_id_arg, asset_content_arg, asset_hexcontent_arg, asset_target_arg; + uint16_t asset_days_arg = 0; + uint32_t asset_range_count_arg = 0; + std::string asset_range_prefix_arg; + uint64_t asset_price_arg = 0; + uint64_t asset_buy_amount_arg = 0; + + bool asset_private_arg = false; + bool asset_immutable_arg = false; + bool asset_owner_pays_arg = false; + auto* cmd_ac = cmd_asset->add_subcommand("create", "Create asset"); + cmd_ac->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_ac->add_option("--content", asset_content_arg, "Content (text string)"); + cmd_ac->add_option("--hexcontent", asset_hexcontent_arg, "Content (hex bytes)"); + cmd_ac->add_option("--days", asset_days_arg, "Days to fund")->required(); + cmd_ac->add_flag("--private", asset_private_arg, "Make asset private (content hidden from non-owner)"); + cmd_ac->add_flag("--immutable", asset_immutable_arg, "Seal content forever (cannot be updated; owner/price/funding still mutable)"); + cmd_ac->add_flag("--owner-pays", asset_owner_pays_arg, "Auto-fund daily rent from the owner account once prepaid days run out"); + + auto* cmd_arg = cmd_asset->add_subcommand("range", "Atomically create N account-owned cells at a prefix"); + cmd_arg->add_option("count", asset_range_count_arg, "Number of cells")->required(); + cmd_arg->add_option("--days", asset_days_arg, "Days to fund")->required(); + cmd_arg->add_option("--prefix", asset_range_prefix_arg, "24-byte prefix hex (default: random)"); + + auto* cmd_au = cmd_asset->add_subcommand("update", "Full asset update"); + cmd_au->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_au->add_option("--content", asset_content_arg, "Content (text string)"); + cmd_au->add_option("--hexcontent", asset_hexcontent_arg, "Content (hex bytes)"); + cmd_au->add_option("--price", asset_price_arg, "Price in whole credits (0=not for sale)"); + cmd_au->add_option("--target", asset_target_arg, "New owner (key or @index)"); + + auto* cmd_am = + cmd_asset->add_subcommand("meta", "Update asset metadata only"); + cmd_am->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_am->add_option("--price", asset_price_arg, "Price in whole credits (0=not for sale)"); + cmd_am->add_option("--target", asset_target_arg, "New owner (key or @index)"); + + bool asset_owner_pays_off = false; + auto* cmd_aop = + cmd_asset->add_subcommand("owner-pays", "Toggle the owner-pays (auto-fund) bit (owner only)"); + cmd_aop->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_aop->add_flag("--off", asset_owner_pays_off, "Clear the bit (default: set it on)"); + + auto* cmd_af = + cmd_asset->add_subcommand("fast", "Fast content-only update"); + cmd_af->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_af->add_option("--content", asset_content_arg, "Content (text string)"); + cmd_af->add_option("--hexcontent", asset_hexcontent_arg, "Content (hex bytes)"); + + auto* cmd_afd = cmd_asset->add_subcommand("fund", "Fund asset"); + cmd_afd->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_afd->add_option("--days", asset_days_arg, "Days to add")->required(); + + auto* cmd_ab = cmd_asset->add_subcommand("buy", "Buy asset"); + cmd_ab->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_ab->add_option("--amount", asset_buy_amount_arg, "Max price in whole credits")->required(); + + auto* cmd_ag = cmd_asset->add_subcommand("give", "Give asset to new owner"); + cmd_ag->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_ag->add_option("--target", asset_target_arg, "New owner (key or @index)") + ->required(); + + std::string asset_run_input_arg; + uint64_t asset_run_budget_arg = 0; + uint64_t asset_run_allowance_arg = std::numeric_limits::max(); + bool asset_run_nonceless = false; + auto* cmd_ar = cmd_asset->add_subcommand("run", "Execute asset bytecode (VM)"); + cmd_ar->add_option("id", asset_id_arg, "Asset ID or name")->required(); + cmd_ar->add_option("--budget", asset_run_budget_arg, "Gas budget in credits")->required(); + cmd_ar->add_option("--allowance", asset_run_allowance_arg, + "Per-run cap on caller-account debits inside the VM " + "(default: unlimited). Programs read this as their " + "spend budget via io[CESVM_IO_ALLOWANCE]; e.g. " + "/b/dice uses it as the bet amount."); + cmd_ar->add_option("--input", asset_run_input_arg, "Input data (hex string)"); + cmd_ar->add_flag("--nonceless", asset_run_nonceless, "Use auto-nonce (no sequential nonce)"); + + std::string bundle_dir_arg; + auto* cmd_adb = cmd_asset->add_subcommand( + "deploy-bundle", + "Deploy a cesc --bundle directory: create every chunk and key-table " + "asset at its manifest key, then the boot loader under . " + "Chunk/table keys are content-derived, so a re-deploy of the same " + "bundle reuses the assets already on the ledger."); + cmd_adb->add_option("id", asset_id_arg, "Boot asset ID or name")->required(); + cmd_adb->add_option("dir", bundle_dir_arg, + "Bundle directory (contains manifest.txt)")->required(); + cmd_adb->add_option("--days", asset_days_arg, + "Days to fund each created asset")->required(); + + auto* cmd_aq = cmd_asset->add_subcommand("query", "Unsigned asset query"); + cmd_aq->add_option("id", asset_id_arg, "Asset ID or name")->required(); + + auto* cmd_asq = + cmd_asset->add_subcommand("squery", "Signed asset query (paid)"); + cmd_asq->add_option("id", asset_id_arg, "Asset ID or name")->required(); + + // ---- Subcommand: alias (L1 account sidecar) ---- + // Raw byte access to the value image (owner|editor|op|content); cesh does + // not interpret the structure. + auto* cmd_alias = + app.add_subcommand("alias", "Account alias (L1 dependable sidecar)"); + cmd_alias->require_subcommand(0, 1); + uint32_t alias_id_arg = 0; + uint16_t alias_offset_arg = 0; + uint16_t alias_length_arg = ces::ALIAS_VALUE_BYTES; + std::string alias_content_arg, alias_hexcontent_arg; + + auto* cmd_alias_write = + cmd_alias->add_subcommand( + "write", "Patch bytes into an alias's value image (signed)"); + cmd_alias_write->add_option("offset", alias_offset_arg, "Image byte offset") + ->required(); + cmd_alias_write->add_option("--id", alias_id_arg, + "Target alias id (default 0 = my own; " + "created on first use)"); + cmd_alias_write->add_option("--content", alias_content_arg, + "Patch bytes (text)"); + cmd_alias_write->add_option("--hexcontent", alias_hexcontent_arg, + "Patch bytes (hex)"); + + auto* cmd_alias_rm = + cmd_alias->add_subcommand("rm", "Delete this account's alias"); + + std::string alias_run_input_arg; + uint64_t alias_run_budget_arg = 0; + uint64_t alias_run_allowance_arg = std::numeric_limits::max(); + bool alias_run_nonceless = false; + auto* cmd_alias_run = + cmd_alias->add_subcommand( + "run", "Execute an alias's inline program (op = INLINE_PROGRAM)"); + cmd_alias_run->add_option("id", alias_id_arg, "Alias id")->required(); + cmd_alias_run->add_option("--budget", alias_run_budget_arg, + "Gas budget in credits")->required(); + cmd_alias_run->add_option("--allowance", alias_run_allowance_arg, + "Per-run cap on caller-account debits inside the " + "VM (default: unlimited)"); + cmd_alias_run->add_option("--input", alias_run_input_arg, + "Input data (hex string)"); + cmd_alias_run->add_flag("--nonceless", alias_run_nonceless, + "Use auto-nonce (no sequential nonce)"); + + auto* cmd_alias_read = + cmd_alias->add_subcommand( + "read", "Read a byte window of an alias's value image (unsigned)"); + cmd_alias_read->add_option("id", alias_id_arg, "Alias id")->required(); + cmd_alias_read->add_option("offset", alias_offset_arg, + "Image byte offset (default 0)"); + cmd_alias_read->add_option("length", alias_length_arg, + "Bytes to read (default: offset to end of image)"); + + // ---- ramfile subcommands ---- + // In-ledger RAM-backed asset-chain file API (L1) — distinct from the + // `file` command's L2 disk-backed store on rpc_port. Suited to small + // VM-reachable files, not host-scale storage. + auto* cmd_file = app.add_subcommand( + "ramfile", "RAM-backed file storage (asset-chain, L1)"); + cmd_file->require_subcommand(1); + + std::string file_key_arg, file_path_arg, file_meta_arg; + uint16_t file_days_arg = 30; + + // Composable data buffer: --in appends each token in CLI order (text/hex:/file:). + ces::Bytes file_composed_data; + std::string file_in_arg; // CLI11 target + auto inAppender = [&](const std::string& s) { appendInToken(file_composed_data, s); }; + + auto* cmd_fp = cmd_file->add_subcommand("put", "Upload file"); + cmd_fp->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_fp->add_option("--in", file_in_arg, "Data (repeatable): text, text:T, hex:XX, file:path") + ->each(inAppender)->take_all(); + cmd_fp->add_option("--days", file_days_arg, "Days to fund (default 30)"); + cmd_fp->add_option("--meta", file_meta_arg, "Metadata string (up to 121 bytes)"); + + auto* cmd_ft = cmd_file->add_subcommand("touch", "Create empty file"); + cmd_ft->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_ft->add_option("--days", file_days_arg, "Days to fund (default 30)"); + cmd_ft->add_option("--meta", file_meta_arg, "Metadata string"); + + auto* cmd_fg = cmd_file->add_subcommand("get", "Download file"); + cmd_fg->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_fg->add_option("path", file_path_arg, "Local file path")->required(); + + auto* cmd_fi = cmd_file->add_subcommand("info", "Show file metadata"); + cmd_fi->add_option("key", file_key_arg, "File key (asset name)")->required(); + + auto* cmd_fs = cmd_file->add_subcommand("scan", "Scan chain and write .scan file"); + cmd_fs->add_option("key", file_key_arg, "File key (asset name)")->required(); + + std::string file_outfile_arg; + uint64_t file_offset_arg = 0, file_length_arg = 0; + bool file_hex_output = false; + + auto* cmd_fr = cmd_file->add_subcommand("read", "Read bytes at offset (requires .scan)"); + cmd_fr->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_fr->add_option("--offset", file_offset_arg, "Byte offset (default 0)"); + cmd_fr->add_option("--length", file_length_arg, "Bytes to read (0=all from offset)"); + cmd_fr->add_option("--out", file_outfile_arg, "Output file (default: stdout)"); + cmd_fr->add_flag("--hex", file_hex_output, "Output as hex string"); + + auto* cmd_fw = cmd_file->add_subcommand("write", "Write bytes at offset (requires .scan)"); + cmd_fw->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_fw->add_option("--offset", file_offset_arg, "Byte offset")->required(); + cmd_fw->add_option("--in", file_in_arg, "Data (repeatable): text, text:T, hex:XX, file:path") + ->each(inAppender)->take_all(); + + auto* cmd_fa = cmd_file->add_subcommand("append", "Append data to file (requires .scan)"); + cmd_fa->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_fa->add_option("--in", file_in_arg, "Data (repeatable): text, text:T, hex:XX, file:path") + ->each(inAppender)->take_all(); + cmd_fa->add_option("--days", file_days_arg, "Days for new chunks (default 30)"); + + uint64_t file_newsize_arg = 0; + auto* cmd_fz = cmd_file->add_subcommand("resize", "Resize file (requires .scan)"); + cmd_fz->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_fz->add_option("size", file_newsize_arg, "New size in bytes")->required(); + cmd_fz->add_option("--days", file_days_arg, "Days for new chunks if extending"); + + auto* cmd_fh = cmd_file->add_subcommand("rehash", "Recompute SHA256 after writes"); + cmd_fh->add_option("key", file_key_arg, "File key (asset name)")->required(); + + auto* cmd_ff = cmd_file->add_subcommand("fund", "Extend all chunks (requires .scan)"); + cmd_ff->add_option("key", file_key_arg, "File key (asset name)")->required(); + cmd_ff->add_option("--days", file_days_arg, "Days to add (default 30)"); + + // ---- file subcommands (L2 disk-backed file store over CesPlex) ---- + auto* cmd_dfile = app.add_subcommand( + "file", "Disk-backed file storage (L2, via --rpc-port)"); + cmd_dfile->require_subcommand(1); + + std::string df_local_arg, df_remote_arg; + uint64_t df_deposit_arg = 0; + uint64_t df_amount_arg = 0; + uint64_t df_price_arg = 0; + + auto* cmd_dfp = cmd_dfile->add_subcommand("put", "Upload a file"); + cmd_dfp->add_option("local", df_local_arg, "Local file path")->required(); + cmd_dfp->add_option("remote", df_remote_arg, + "Remote path (starts with /h/, /f/, or /p/ — or bare " + "name, auto-prepended with /h//)")->required(); + cmd_dfp->add_option("--deposit", df_deposit_arg, + "Initial credits to deposit into the file's balance. " + "On CREATE: defaults to the 15-min upfront rent. " + "On re-upload of an existing file: always added as a " + "top-up DEPOSIT."); + + auto* cmd_dfg = cmd_dfile->add_subcommand("get", "Download a file"); + cmd_dfg->add_option("remote", df_remote_arg, "Remote path")->required(); + cmd_dfg->add_option("local", df_local_arg, + "Local destination path ('-' or omit = stdout)"); + + auto* cmd_dfs = cmd_dfile->add_subcommand("stat", "Show file metadata"); + cmd_dfs->add_option("remote", df_remote_arg, "Remote path")->required(); + + auto* cmd_dfrm = cmd_dfile->add_subcommand("rm", "Delete a file (owner only)"); + cmd_dfrm->add_option("remote", df_remote_arg, "Remote path")->required(); + + auto* cmd_dfd = cmd_dfile->add_subcommand( + "deposit", "Credit a file's balance"); + cmd_dfd->add_option("remote", df_remote_arg, "Remote path")->required(); + cmd_dfd->add_option("amount", df_amount_arg, "Amount")->required(); + + auto* cmd_dfw = cmd_dfile->add_subcommand( + "withdraw", "Withdraw from a file's balance (owner only)"); + cmd_dfw->add_option("remote", df_remote_arg, "Remote path")->required(); + cmd_dfw->add_option("amount", df_amount_arg, "Amount")->required(); + + auto* cmd_dfsp = cmd_dfile->add_subcommand( + "set-price", "Set per-kilobyte read price (owner only)"); + cmd_dfsp->add_option("remote", df_remote_arg, "Remote path")->required(); + cmd_dfsp->add_option("price", df_price_arg, "Credits per 1024 bytes") + ->required(); + + // ---- compute subcommands (L2 compute, via --rpc-port) ---- + auto* cmd_compute = app.add_subcommand( + "compute", "L2 compute — launch/kill/list/stat a program instance " + "(via --rpc-port)"); + cmd_compute->require_subcommand(1); + + std::string compute_path_arg; + uint64_t compute_pid_arg = 0; + uint64_t compute_value_arg = 0; + // Composable memo buffer for `compute call` (--in appends in CLI order). + ces::Bytes compute_memo_data; + std::string compute_in_arg; + auto computeInAppender = [&](const std::string& s) { + appendInToken(compute_memo_data, s); + }; + // Reply output: raw to stdout, --out (binary-safe), or --hex. + std::string compute_out_arg; + bool compute_hex_output = false; + + auto* cmd_clau = cmd_compute->add_subcommand( + "launch", "Launch a fresh instance of the source file. Mints a new " + "pid every call — multiple instances per source " + "are allowed up to the server's compute_max_instances cap."); + cmd_clau->add_option("remote", compute_path_arg, + "Source file path (/h//…, /f//…, /p/…)") + ->required(); + + auto* cmd_ckil = cmd_compute->add_subcommand( + "kill", "SIGKILL a running instance (owner only)"); + cmd_ckil->add_option("pid", compute_pid_arg, + "pid returned from launch")->required(); + + auto* cmd_cps = cmd_compute->add_subcommand( + "ps", "List running instances owned by the signer"); + + auto* cmd_cst = cmd_compute->add_subcommand( + "stat", "Show status of an instance by pid (owner only)"); + cmd_cst->add_option("pid", compute_pid_arg, + "pid returned from launch")->required(); + + auto* cmd_cinst = cmd_compute->add_subcommand( + "instances", + "List running pids for a given source path. Public — no " + "owner check; useful for discovering services like /s/chat.lua. " + "Prints one numeric pid per line on stdout (empty output = none)."); + cmd_cinst->add_option("remote", compute_path_arg, + "Source file path (/h//…, /f//…, " + "/p/…, or /s/…)")->required(); + + auto* cmd_ccall = cmd_compute->add_subcommand( + "call", + "Paid call into a running instance's on_l2call. Escrows `value` credits " + "from the signer (settled to the instance on delivery, refunded on " + "no-handler or timeout), delivers the --in memo, and prints the " + "program's reply bytes (raw on stdout in -q mode)."); + cmd_ccall->add_option("pid", compute_pid_arg, + "pid returned from launch")->required(); + cmd_ccall->add_option("value", compute_value_arg, + "Credits to pay (may be 0)")->required(); + cmd_ccall->add_option("--in", compute_in_arg, + "Memo (repeatable): text, text:T, hex:XX, file:path") + ->each(computeInAppender)->take_all(); + cmd_ccall->add_option("--out", compute_out_arg, + "Write reply bytes to file (default: stdout)"); + cmd_ccall->add_flag("--hex", compute_hex_output, "Print reply as hex string"); + + // ---- mail subcommands (L2 outbound email, via --rpc-port) ---- + auto* cmd_mail = app.add_subcommand( + "mail", "L2 outbound email — relay a message via builtin:mail " + "(via --rpc-port). The server burns a per-MB anti-spam fee."); + cmd_mail->require_subcommand(1); + + std::string mail_to_arg; + std::string mail_subject_arg; + std::string mail_attach_arg; + ces::Bytes mail_body_data; + std::string mail_in_arg; + auto mailInAppender = [&](const std::string& s) { + appendInToken(mail_body_data, s); + }; + + auto* cmd_msend = cmd_mail->add_subcommand( + "send", "Relay one email. Charges (burns) a per-MB fee from the signer."); + cmd_msend->add_option("--to", mail_to_arg, "Recipient address")->required(); + cmd_msend->add_option("--subject", mail_subject_arg, "Subject line"); + cmd_msend->add_option("--in", mail_in_arg, + "Body (repeatable): text, text:T, hex:XX, file:path") + ->each(mailInAppender)->take_all(); + cmd_msend->add_option("--attach", mail_attach_arg, + "One attachment by store path (e.g. /m//note.pdf)"); + + // ---- dial (open a byte stream to a running compute instance) ---- + auto* cmd_dial = app.add_subcommand( + "dial", + "Open a bidirectional byte stream to a running compute instance " + "over /ces/lua/1 (via --rpc-port). Pipes stdin↔channel↔stdout. " + "stdin EOF half-closes the send side and drains the channel; " + "SIGINT/SIGTERM tears down with exit 130/143."); + uint64_t dial_instance_arg = 0; + bool dial_verbose_arg = false; + cmd_dial->add_option("pid", dial_instance_arg, + "Numeric uint64 returned by `cesh compute launch` " + "or shown in `cesh compute ps`.")->required(); + cmd_dial->add_flag("-v,--verbose", dial_verbose_arg, + "Print one ATTACH-ok line to stderr on success."); + bool dial_extsign_arg = false; + std::string dial_pubkey_arg; + cmd_dial->add_flag("--extsign", dial_extsign_arg, + "External-signing tunnel mode: take bind + ATTACH " + "signatures over a stdio control handshake instead of " + "signing with a wallet (no private key in this process). " + "Requires --pubkey. Used by cesweb so the key stays in the " + "browser/gateway."); + cmd_dial->add_option("--pubkey", dial_pubkey_arg, + "Client public key (64 hex) that signed the bind " + "(required with --extsign)."); + +#ifdef CES_HYLE + // ---- hyle: client-side chain verbs (needs --rpc-port; --hyle build only) ---- + std::vector hyle_args; + std::string hyle_source_arg = "/s/hylesolo.lua"; + std::vector hyle_in_tokens; + std::string hyle_out_arg; + bool hyle_hex_arg = false; + uint64_t hyle_fund_arg = 0; + bool hyle_wait_arg = false; + auto* cmd_hyle = app.add_subcommand( + "hyle", + "Interact with a hyle chain hosted by a compute instance (e.g. hylesolo), over " + "/ces/lua/1. `hyle list` shows instances; every other verb names one by pid. Reads " + "relay through; writes build + sign an op and submit it."); + cmd_hyle->add_option( + "args", hyle_args, + "list | [operands], verb one of: info | config | self | height | " + "account | entry | get | txr | nodekey | " + "put [value] | del | give | rip | " + "transfer | mint | seize | " + "propose --in | approve --in | submit --in " + "(mint/seize/propose/approve are validator-only)")->required(); + cmd_hyle->add_option("--source", hyle_source_arg, + "list: compute source path to enumerate (default /s/hylesolo.lua)."); + cmd_hyle->add_option("--in", hyle_in_tokens, + "put value / submit op bytes, repeatable and appended in order: text:S | " + "hex:XX | file:PATH | bare text. Carries binary or large content a value " + "cannot be typed as."); + cmd_hyle->add_option("--out", hyle_out_arg, + "get: write the raw value bytes to this file (default: stdout)."); + cmd_hyle->add_flag("--hex", hyle_hex_arg, "get: print the value as a hex string."); + cmd_hyle->add_option("--fund", hyle_fund_arg, "put: initial entry funding."); + cmd_hyle->add_flag("--wait", hyle_wait_arg, + "Write verbs: poll txr until the tx applies or is rejected."); +#endif + + // ---- autoexec subcommands ---- + auto* cmd_autoexec = app.add_subcommand("autoexec", "Boot-time program execution"); + cmd_autoexec->require_subcommand(1); + + std::string autoexec_program_arg; + uint64_t autoexec_budget_arg = 10000000; + std::string autoexec_input_arg; + + uint16_t autoexec_days_arg = 30; + auto* cmd_axi = cmd_autoexec->add_subcommand("install", "Install autoexec program"); + cmd_axi->add_option("program", autoexec_program_arg, "Program asset ID or name")->required(); + cmd_axi->add_option("--budget", autoexec_budget_arg, "Gas budget per boot execution"); + cmd_axi->add_option("--input", autoexec_input_arg, + "Input data (hex string; <= ~40 bytes - must fit in the autoexec asset cell)"); + cmd_axi->add_option("--days", autoexec_days_arg, "Days to fund autoexec asset (default 30)"); + + // ---- Parse ---- + + try { + if (argc <= 1) + throw CLI::CallForHelp(); + app.parse(argc, argv); + } catch (const CLI::RequiredError& e) { + auto subs = app.get_subcommands(); + if (!subs.empty()) { + auto* leaf = subs.back(); + auto leafSubs = leaf->get_subcommands(); + if (!leafSubs.empty()) + leaf = leafSubs.back(); + std::cout << leaf->help() << "\n"; + } else { + std::cout << app.help() << "\n"; + } + return 1; + } catch (const CLI::ParseError& e) { + return app.exit(e); + } + + setup_logger(logLevel); + + // ---- Resolve server / proxy from env override ---- + + if (const char* e = std::getenv("CESH_SERVER")) + server_arg = e; + if (const char* e = std::getenv("CESH_PROXY")) + proxy_arg = e; + + bool useProxy = !proxy_arg.empty(); + boost::asio::ip::udp::endpoint server_endpoint; + boost::asio::ip::tcp::endpoint proxy_endpoint; + // `cesh dial` only talks /ces/lua/1 on --rpc-port; it never opens a + // CesClient session and so doesn't need the main server endpoint. + // Skip the resolver to allow `--server localhost` (no :port). peer-info + // resolves its own positional , so it skips this too. + if (!cmd_dial->parsed() && !cmd_peer_info->parsed()) { + try { + if (useProxy) { + proxy_endpoint = ces::Resolver::resolveTcp(proxy_arg); + } else { + server_endpoint = ces::Resolver::resolveUdp(server_arg); + } + } catch (std::exception& e) { + std::cerr << "Resolve error: " << e.what() << "\n"; + return 1; + } + } + + // ---- Session factory ---- + + auto makeSession = [&](const KeyPair* kp = nullptr) + -> std::unique_ptr { + std::unique_ptr s; + if (useProxy) + s = std::make_unique(cacheOnly, proxy_endpoint, kp, + tries_arg); + else + s = std::make_unique(cacheOnly, clientPort, + server_endpoint, kp, tries_arg); + s->client().setTries(tries_arg); + return s; + }; + + // ---- peer-info: positional , unsigned/free, its own session ---- + if (cmd_peer_info->parsed()) { + boost::asio::ip::udp::endpoint ep; + try { + ep = ces::Resolver::resolveUdp(peer_info_server_arg); + } catch (std::exception& e) { + std::cerr << "Resolve error: " << e.what() << "\n"; + return 1; + } + ClientSession sess(cacheOnly, clientPort, ep, nullptr, tries_arg); + sess.client().setTries(tries_arg); + uint16_t count = 0; + bool found = false; + minx::Hash pk{}; + std::string addr; + uint8_t rc = sess.client().queryPeerInfo(peer_info_id_arg, count, found, + pk, addr); + if (rc != CES_OK) { + std::cerr << "Peer-Info Failed: " << errorString(rc) << "\n"; + return 1; + } + print_header("Peer slot " + std::to_string(peer_info_id_arg)); + print_field("peers total", std::to_string(count)); + print_field("found", found ? std::string("yes") : std::string("no")); + if (found) { + print_field("pubkey", minx::hashToString(pk)); + print_field("address", addr); + } + return 0; + } + + // ---- Load wallet ---- + // + // cesh wallet resolution order: + // 1. Explicit --wallet path from CLI + // 2. CESH_WALLET env var (colon-separated keys) + // 3. Default file: ~/.cesh/CESH_WALLET + + Wallet wallet; + auto cesh_home = []() -> std::filesystem::path { + const char* h = std::getenv("HOME"); + if (!h) h = std::getenv("USERPROFILE"); + return h ? std::filesystem::path(h) : std::filesystem::current_path(); + }; + + auto cesh_resolve_path = [&](const std::string& arg) -> std::filesystem::path { + const std::string defaultFilename = "CESH_WALLET"; + std::filesystem::path p = arg.empty() + ? (cesh_home() / ".cesh") : std::filesystem::path(arg); + if (arg.empty() && !std::filesystem::exists(p)) { + try { + std::filesystem::create_directories(p); + setSecurePermission(p); + } catch (...) {} + return p / defaultFilename; + } + if (std::filesystem::is_directory(p)) + return p / defaultFilename; + return p; + }; + + try { + if (app.count("-r") || app.count("--wallet")) { + auto wp = cesh_resolve_path(wallet_read_arg); + if (std::filesystem::exists(wp)) + wallet.loadFromFile(wp); + } else if (const char* envVal = std::getenv("CESH_WALLET")) { + wallet.loadFromString(envVal); + } else { + auto wp = cesh_resolve_path(""); + if (std::filesystem::exists(wp)) + wallet.loadFromFile(wp); + } + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + if (std::getenv("CESH_WALLET")) { + std::cerr << "Hint: $CESH_WALLET is inline keys " + "(\"01abc...:00def...\"), not a file path.\n" + " For a file, use --wallet /path/to/wallet.\n"; + } + return 1; + } + + // ---- Subcommand: keys ---- + + if (cmd_keys->parsed()) { + if (cmd_keys->get_subcommands().empty()) { + std::cout << cmd_keys->help() << "\n"; + return 0; + } + + KeyAlgo algo = opt_secp ? KeyAlgo::SECP256K1 : KeyAlgo::ED25519; + + if (cmd_keys_gen->parsed()) { + int firstNew = wallet.generate(gen_count, algo); + std::cout << "Generated " << gen_count << " " + << (opt_secp ? "secp256k1" : "ed25519") << " keys.\n"; + for (int i = firstNew; i < wallet.size(); ++i) { + KeyPair kp = wallet.keyPair(i); + std::cout << "[@" << i << "] " << Wallet::algoLabel(kp) << " " + << wallet.keyHex(i) << " (" << kp.getPublicKeyHexStr() + << ")\n"; + } + } + + if (cmd_keys_add->parsed()) { + try { + wallet.addKey(keys_add_arg, algo); + std::cout << "Added " + << (opt_secp ? "secp256k1" : "ed25519") + << " key to wallet.\n"; + } catch (std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + } + + if (cmd_keys_list->parsed()) { + for (int i = 0; i < wallet.size(); ++i) { + KeyPair kp = wallet.keyPair(i); + std::cout << "[@" << i << "] " << Wallet::algoLabel(kp) << " " + << wallet.keyHex(i) + << (keys_show_public ? (" (" + kp.getPublicKeyHexStr() + ")") + : "") + << "\n"; + } + } + + if (cmd_keys->get_subcommand("export")->parsed()) { + std::cout << "export CESH_WALLET=\""; + bool first = true; + for (auto& k : wallet.keys()) { + if (!first) + std::cout << ":"; + std::cout << k; + first = false; + } + std::cout << "\"\n"; + } + + if (cmd_keys_gen->count("-w") || cmd_keys_add->count("-w")) { + auto savePath = cesh_resolve_path(wallet_save_arg); + wallet.saveToFile(savePath); + std::cout << "Saved wallet to " << savePath.string() << "\n"; + } + + return 0; + } + + // ---- Subcommand: query (unsigned, no actor needed) ---- + + if (cmd_query->parsed()) { + try { + auto sess = makeSession(); + auto& cc = sess->client(); + + std::string hex = wallet.resolveKey(query_account_arg); + minx::Hash h; + minx::stringToHash(h, hex); + int64_t b; + uint32_t n; + HashPrefix xd{}; + uint64_t xa = 0; + uint32_t xt = 0; + if (cc.queryAccount(Account::getMapKey(h), b, n, xd, xa, xt) == CES_OK) { + if (g_quiet) { + std::cout << "{\"key\":\"" << jesc(hex) << "\",\"balance\":" << b + << ",\"nonce\":" << n + << ",\"lastXferDest\":\"" << jesc(hashPrefixToString(xd)) + << "\",\"lastXferAmount\":" << xa + << ",\"lastXferTime\":" << xt << "}\n"; + } else { + print_header("Account (Unsigned)"); + print_field("Key", hex); + print_field("Balance", b); + print_field("Nonce", n); + print_field("LastXferDest", hashPrefixToString(xd)); + print_field("LastXferAmount", xa); + print_field("LastXferTime", xt); + std::cout << std::endl; + } + } else { + std::cerr << "Query failed (check logs)\n"; + return 1; + } + } catch (std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + return 0; + } + + // ---- Subcommand: ping (unsigned, no actor needed) ---- + + if (cmd_ping->parsed()) { + try { + auto sess = makeSession(); + auto& cc = sess->client(); + if (g_quiet) { + std::cout << "{\"status\":\"ok\"" + << ",\"serverPublicKey\":\"" + << minx::hashToString(cc.getServerKey()) + << "\",\"serverId\":\"" << hashPrefixToString(cc.getServerId()) + << "\",\"minDifficulty\":" + << static_cast(cc.getMinDifficulty()) + << ",\"minSecsPoW\":" + << static_cast(cc.getMinSecsPoW()) + << ",\"pendingPoWs\":" << cc.getPendingPoWs() + << ",\"tps\":" << cc.getTps() + << ",\"rpcPort\":" << cc.getServerRpcPort() << "}\n"; + } else { + std::cout << "status=ok\n" + << "server_key=" << minx::hashToString(cc.getServerKey()) + << "\n" + << "server_id=" << hashPrefixToString(cc.getServerId()) << "\n" + << "min_difficulty=" + << static_cast(cc.getMinDifficulty()) << "\n" + << "min_secs_pow=" + << static_cast(cc.getMinSecsPoW()) << "\n" + << "pending_pows=" << cc.getPendingPoWs() << "\n" + << "tps=" << cc.getTps() << "\n" + << "rpc_port=" << cc.getServerRpcPort() << "\n"; + } + } catch (std::exception& e) { + if (g_quiet) + std::cerr << "{\"status\":\"error\",\"error\":\"" << jesc(e.what()) + << "\"}\n"; + else + std::cout << "status=error\nerror=" << e.what() << "\n"; + return 1; + } + return 0; + } + + // ---- Subcommand: asset query (unsigned, no actor needed) ---- + + if (cmd_aq->parsed()) { + try { + auto sess = makeSession(); + auto& cc = sess->client(); + + auto aid = parseAssetKey(asset_id_arg); + HashPrefix owner{}; + AssetData content{}; + uint16_t balance = 0; + uint32_t price = 0; + uint8_t rc = cc.queryAsset(aid, owner, content, balance, price); + if (rc == CES_OK) { + HashPrefix zero{}; + if (owner == zero) { + std::cerr << "Asset not found.\n"; + return 1; + } + if (g_quiet) { + std::cout << "{\"queryId\":\"" << jesc(asset_id_arg) + << "\",\"owner\":\"" << jesc(hashPrefixToString(owner)) + << "\",\"days\":" << assetDays(balance) + << ",\"private\":" + << (isAssetPrivate(balance) ? "true" : "false") + << ",\"assetOwned\":" + << (isAssetOwned(balance) ? "true" : "false") + << ",\"immutable\":" + << (isAssetImmutable(balance) ? "true" : "false") + << ",\"ownerPays\":" + << (isAssetOwnerPays(balance) ? "true" : "false") + << ",\"price\":" << price + << ",\"contentHex\":\"" << ces::bytesToHex(content) + << "\"}\n"; + } else { + std::string flagsStr; + if (isAssetPrivate(balance)) flagsStr += " private"; + if (isAssetOwned(balance)) flagsStr += " asset-owned"; + if (isAssetImmutable(balance)) flagsStr += " immutable"; + if (isAssetOwnerPays(balance)) flagsStr += " owner-pays"; + print_header("Asset (Unsigned)"); + print_field("Query ID", asset_id_arg); + print_field("Owner ID", hashPrefixToString(owner)); + print_field("Balance", + std::to_string(assetDays(balance)) + " days" + flagsStr); + print_field("Price", price == 0 ? std::string("not for sale") + : std::to_string(price) + " credits"); + print_field("Content", contentToDisplayString(content)); + std::cout << std::endl; + } + } else { + std::cerr << "Query Failed: " << errorString(rc) << "\n"; + return 1; + } + } catch (std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + return 0; + } + + if (cmd_alias_read->parsed()) { + try { + auto sess = makeSession(); + auto& cc = sess->client(); + if (cmd_alias_read->count("length") == 0) { + if (alias_offset_arg > ces::ALIAS_VALUE_BYTES) { + std::cerr << "Error: offset past end of image.\n"; + return 1; + } + alias_length_arg = + static_cast(ces::ALIAS_VALUE_BYTES - alias_offset_arg); + } + ces::Bytes bytes; + bool found = false; + uint8_t rc = cc.readAlias(alias_id_arg, alias_offset_arg, + alias_length_arg, bytes, found); + if (rc != CES_OK) { + std::cerr << "Query Failed: " << errorString(rc) << "\n"; + return 1; + } + if (!found) { + if (g_quiet) std::cout << "{\"found\":false}\n"; + else std::cerr << "Alias not found.\n"; + return g_quiet ? 0 : 1; + } + if (g_quiet) { + // Pipe mode: data only, raw bytes. + std::cout.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + } else { + print_header("Alias Read (Unsigned)"); + print_field("Alias ID", std::to_string(alias_id_arg)); + print_field("Offset", std::to_string(alias_offset_arg)); + print_field("Length", std::to_string(bytes.size())); + print_field("Bytes", ces::bytesToHex(bytes)); + std::cout << std::endl; + } + } catch (std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + return 0; + } + + // ---- All remaining subcommands need an actor key ---- + + if (cmd_asset->parsed() && cmd_asset->get_subcommands().empty()) { + std::cout << cmd_asset->help() << "\n"; + return 0; + } + + // ---- dial --extsign — external-signing tunnel; no wallet/private key. ---- + // Handled before actor/wallet resolution: there is deliberately no key here; + // the bind + ATTACH signatures arrive over a stdio control handshake. + if (cmd_dial->parsed() && dial_extsign_arg) { + if (dial_pubkey_arg.empty()) { + std::cerr << "Error: --extsign requires --pubkey <64 hex>.\n"; + return 1; + } + DialArgs da; + da.serverHost = server_arg; + if (auto colon = da.serverHost.rfind(':'); colon != std::string::npos) + da.serverHost = da.serverHost.substr(0, colon); + if (da.serverHost.empty()) da.serverHost = "localhost"; + da.rpcPort = rpcPort_arg; + da.pid = dial_instance_arg; + da.verbose = dial_verbose_arg; + da.extSign = true; + da.clientPubkeyHex = dial_pubkey_arg; + if (!server_key_arg.empty()) { + minx::Hash pk{}; + try { minx::stringToHash(pk, server_key_arg); da.expectedServerPk = pk; } + catch (...) { std::cerr << "Error: bad --server-key.\n"; return 1; } + } + return runDial(da); + } + + bool needs_actor = + (cmd_squery->parsed() || cmd_transfer->parsed() || cmd_payment->parsed() || + cmd_cross->parsed() || cmd_sinfo->parsed() || cmd_mine->parsed() || + cmd_asset->parsed() || cmd_file->parsed() || cmd_autoexec->parsed() || + cmd_dfile->parsed() || cmd_compute->parsed() || cmd_dial->parsed() || + cmd_mail->parsed() || cmd_gossip->parsed() || cmd_alias_write->parsed() || + cmd_alias_rm->parsed() || cmd_alias_run->parsed()); + +#ifdef CES_HYLE + needs_actor = needs_actor || cmd_hyle->parsed(); +#endif + + if (!needs_actor) + return 0; + + KeyPair actorKey; + try { + actorKey = wallet.resolveActor(actor_arg); + } catch (std::exception& e) { + std::cerr << e.what() << "\n"; + return 1; + } + + // ---- dial — bypass the main UDP CesClient (only needs --rpc-port) ---- + if (cmd_dial->parsed()) { + DialArgs da; + // server_arg may be "host" or "host:port"; the trailing :port (if + // present) is the main CES port, not the rpc port. We only want + // the host here. + da.serverHost = server_arg; + if (auto colon = da.serverHost.rfind(':'); + colon != std::string::npos) + da.serverHost = da.serverHost.substr(0, colon); + if (da.serverHost.empty()) da.serverHost = "localhost"; + da.rpcPort = rpcPort_arg; + da.pid = dial_instance_arg; + da.signerKey = actorKey; + da.verbose = dial_verbose_arg; + return runDial(da); + } + +#ifdef CES_HYLE + // ---- hyle — build/sign/relay to a chain-hosting instance (only needs --rpc-port) ---- + if (cmd_hyle->parsed()) { + HyleCli hc; + hc.host = server_arg; + if (auto colon = hc.host.rfind(':'); colon != std::string::npos) + hc.host = hc.host.substr(0, colon); + if (hc.host.empty()) hc.host = "localhost"; + hc.rpcPort = rpcPort_arg; + hc.actorKey = actorKey; + if (!server_key_arg.empty()) { + minx::Hash pk{}; + try { minx::stringToHash(pk, server_key_arg); hc.expectedServerPk = pk; } + catch (...) { std::cerr << "Error: bad --server-key.\n"; return 1; } + } + hc.source = hyle_source_arg; + hc.inTokens = hyle_in_tokens; + hc.outPath = hyle_out_arg; + hc.hexOut = hyle_hex_arg; + hc.args = hyle_args; + hc.fund = hyle_fund_arg; + hc.wait = hyle_wait_arg; + hc.quiet = g_quiet; + return runHyle(hc); + } +#endif + + try { + auto sess = makeSession(&actorKey); + auto& cc = sess->client(); + + // ---- alias signed handlers (write / rm) ---- + if (cmd_alias_write->parsed()) { + ces::Bytes bytes; + if (!alias_hexcontent_arg.empty()) + bytes = ces::parseHex(alias_hexcontent_arg); + else if (!alias_content_arg.empty()) + bytes.assign(alias_content_arg.begin(), alias_content_arg.end()); + if (alias_offset_arg + bytes.size() > ces::ALIAS_VALUE_BYTES) { + std::cerr << "Error: patch runs past end of image (" + << ces::ALIAS_VALUE_BYTES << " bytes).\n"; + return 1; + } + uint32_t id = 0; + uint8_t rc = cc.writeAlias(alias_id_arg, alias_offset_arg, bytes, id); + if (rc != CES_OK) { + std::cerr << "Alias write failed: " << errorString(rc) << "\n"; + return 1; + } + if (g_quiet) std::cout << "{\"aliasId\":" << id << "}\n"; + else { + print_header("Alias Write"); + print_field("Alias ID", std::to_string(id)); + std::cout << "Success.\n"; + } + return 0; + } + if (cmd_alias_run->parsed()) { + ces::Bytes input; + if (!alias_run_input_arg.empty()) + input = ces::parseHex(alias_run_input_arg); + uint64_t vmError = 0, budgetUsed = 0; + ces::Bytes output; + uint8_t rc = cc.runAlias(alias_id_arg, alias_run_budget_arg, input, + vmError, budgetUsed, output, + alias_run_nonceless, alias_run_allowance_arg); + if (rc == CES_OK) { + if (g_quiet) { + std::cout << "{\"vmError\":" << vmError + << ",\"budgetUsed\":" << budgetUsed + << ",\"outputHex\":\"" << ces::bytesToHex(output) + << "\"}\n"; + } else { + print_header("Alias Executed"); + print_field("Alias ID", std::to_string(alias_id_arg)); + print_field("VM Error", std::to_string(vmError)); + print_field("Budget Used", std::to_string(budgetUsed)); + if (!output.empty()) + print_field("Output", ces::bytesToHex(output)); + std::cout << "Success.\n"; + } + return 0; + } + std::cerr << "Alias run failed: " << errorString(rc) + << " (vmError " << vmError << ")\n"; + return 1; + } + if (cmd_alias_rm->parsed()) { + uint8_t rc = cc.deleteAlias(); + if (rc != CES_OK) { + std::cerr << "Alias delete failed: " << errorString(rc) << "\n"; + return 1; + } + if (!g_quiet) { print_header("Alias Deleted"); std::cout << "Success.\n"; } + return 0; + } + + // ---- handleAsset handler ---- + auto handleAsset = [&]() -> int { + // Resolve content from --content (text) or --hexcontent (hex) + auto resolveContent = [&]() -> AssetData { + if (!asset_hexcontent_arg.empty()) + return parseHexContent(asset_hexcontent_arg); + if (!asset_content_arg.empty()) + return parseAssetContent(asset_content_arg); + throw std::runtime_error("Specify --content or --hexcontent"); + }; + + if (cmd_ac->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + auto ctn = resolveContent(); + uint8_t rc = cc.createAsset(aid, ctn, asset_days_arg, + asset_private_arg, asset_immutable_arg, + asset_owner_pays_arg); + if (rc == CES_OK) { + print_header("Asset Created"); + print_field("Asset", asset_id_arg); + print_field("Days", asset_days_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Create Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_arg->parsed()) { + minx::Hash firstKey{}; + if (asset_range_prefix_arg.empty()) { + minx::Hash r = KeyPair().getPublicKeyAsHash(); // fresh random entropy + for (int i = 0; i < 24; ++i) firstKey[i] = r[i]; + } else { + auto pb = ces::parseHex(asset_range_prefix_arg); + if (pb.size() < 24) { + std::cerr << "prefix must be at least 24 bytes of hex\n"; + return 1; + } + for (int i = 0; i < 24; ++i) firstKey[i] = pb[i]; + } + uint8_t rc = cc.createAssetRange(firstKey, asset_range_count_arg, + asset_days_arg); + if (rc == CES_OK) { + print_header("Asset Range Created"); + print_field("Cells", asset_range_count_arg); + print_field("Handle", minx::hashToString(firstKey)); + std::cout << "Success.\n"; + } else { + std::cerr << "Create Range Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_au->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + auto ctn = resolveContent(); + HashPrefix own; + if (asset_target_arg.empty()) + own = Account::getMapKey(actorKey.getPublicKeyAsHash()); + else { + minx::Hash t; + minx::stringToHash(t, wallet.resolveKey(asset_target_arg)); + own = Account::getMapKey(t); + } + uint32_t storedPrice; + if (validatePrice(asset_price_arg, storedPrice) != 0) { + std::cerr << "Invalid price. Max: " << UINT32_MAX << "\n"; + return 1; + } + uint8_t rc = cc.updateAsset(aid, own, ctn, storedPrice); + if (rc == CES_OK) { + print_header("Asset Updated"); + print_field("Asset", asset_id_arg); + print_field("Price (whole credits)", asset_price_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Update Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_am->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + HashPrefix own; + if (asset_target_arg.empty()) + own = Account::getMapKey(actorKey.getPublicKeyAsHash()); + else { + minx::Hash t; + minx::stringToHash(t, wallet.resolveKey(asset_target_arg)); + own = Account::getMapKey(t); + } + uint32_t storedPrice; + if (validatePrice(asset_price_arg, storedPrice) != 0) { + std::cerr << "Invalid price. Max: " << UINT32_MAX << "\n"; + return 1; + } + uint8_t rc = cc.updateAssetMeta(aid, own, storedPrice); + if (rc == CES_OK) { + print_header("Asset Meta Updated"); + print_field("Asset", asset_id_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Meta Update Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_aop->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + uint8_t rc = cc.setAssetOwnerPays(aid, !asset_owner_pays_off); + if (rc == CES_OK) { + if (!g_quiet) { + print_header(asset_owner_pays_off ? "Asset Owner-Pays Disabled" + : "Asset Owner-Pays Enabled"); + print_field("Asset", asset_id_arg); + std::cout << "Success.\n"; + } + } else { + std::cerr << "Owner-Pays Toggle Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_af->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + auto ctn = resolveContent(); + uint8_t rc = cc.updateAssetFast(aid, ctn); + if (rc == CES_OK) { + print_header("Asset Fast-Updated"); + print_field("Asset", asset_id_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Fast Update Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_afd->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + uint8_t rc = cc.fundAsset(aid, asset_days_arg); + if (rc == CES_OK) { + print_header("Asset Funded"); + print_field("Asset", asset_id_arg); + print_field("Days Added", asset_days_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Fund Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_ab->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + uint64_t realAmount = asset_buy_amount_arg * ces::PRICE_UNIT; + uint8_t rc = cc.buyAsset(aid, realAmount); + if (rc == CES_OK) { + print_header("Asset Purchased"); + print_field("Asset", asset_id_arg); + print_field("Paid (whole credits)", asset_buy_amount_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Buy Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_ag->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + minx::Hash t; + minx::stringToHash(t, wallet.resolveKey(asset_target_arg)); + uint8_t rc = cc.giveAsset(aid, Account::getMapKey(t)); + if (rc == CES_OK) { + print_header("Asset Given"); + print_field("Asset", asset_id_arg); + print_field("New Owner", asset_target_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Give Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_ar->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + ces::Bytes input; + if (!asset_run_input_arg.empty()) + input = ces::parseHex(asset_run_input_arg); + uint64_t vmError = 0, budgetUsed = 0; + ces::Bytes output; + uint8_t rc = cc.runAsset(aid, asset_run_budget_arg, input, + vmError, budgetUsed, output, + asset_run_nonceless, + asset_run_allowance_arg); + uint64_t allowanceUsed = cc.getLastRunAssetAllowanceUsed(); + if (rc == CES_OK) { + print_header("Asset Executed"); + print_field("Asset", asset_id_arg); + print_field("VM Error", std::to_string(vmError)); + print_field("Budget Used", std::to_string(budgetUsed)); + print_field("Allowance Used", std::to_string(allowanceUsed)); + if (!output.empty()) + print_field("Output", ces::bytesToHex(output)); + std::cout << "Success.\n"; + } else { + print_header("Asset Execution Failed"); + print_field("Error", errorString(rc)); + print_field("VM Error", std::to_string(vmError)); + print_field("Budget Used", std::to_string(budgetUsed)); + print_field("Allowance Used", std::to_string(allowanceUsed)); + return 1; + } + + } else if (cmd_adb->parsed()) { + // Deploy a cesc --bundle directory. manifest.txt lines are + // "chunk <64-hex-key> ", "table <64-hex-key> [root]" + // and one "boot - boot.bin"; comments start with '#'. Chunks and + // tables are created at their manifest keys first, the boot loader + // last under the user-chosen id. Chunk/table keys are derived from + // their content, so CES_ERROR_ASSET_EXISTS means the identical + // block is already on the ledger: count it as reused and move on. + std::filesystem::path dir(bundle_dir_arg); + std::ifstream mf(dir / "manifest.txt"); + if (!mf) { + std::cerr << "Deploy Failed: cannot read " + << (dir / "manifest.txt").string() << "\n"; + return 1; + } + auto readBlock = [&](const std::string& file) -> AssetData { + std::ifstream f(dir / file, std::ios::binary); + AssetData d{}; + if (!f.read(reinterpret_cast(d.data()), + static_cast(d.size())) || + f.peek() != std::ifstream::traits_type::eof()) + throw std::runtime_error("bundle block " + file + " is not exactly " + + std::to_string(d.size()) + " bytes"); + return d; + }; + uint64_t created = 0, reused = 0; + std::string line, bootFile; + while (std::getline(mf, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream ls(line); + std::string kind, keyHex, file; + ls >> kind >> keyHex >> file; + if (kind == "boot") { + bootFile = file; + continue; + } + if ((kind != "chunk" && kind != "table") || file.empty()) { + std::cerr << "Deploy Failed: bad manifest line: " << line << "\n"; + return 1; + } + uint8_t rc = cc.createAsset(parseAssetKey(keyHex), readBlock(file), + asset_days_arg); + if (rc == CES_OK) { + ++created; + } else if (rc == CES_ERROR_ASSET_EXISTS) { + ++reused; + } else { + std::cerr << "Deploy Failed: " << kind << " " << file << " (" + << keyHex << "): " << errorString(rc) << "\n"; + return 1; + } + } + if (bootFile.empty()) { + std::cerr << "Deploy Failed: manifest has no boot line\n"; + return 1; + } + uint8_t rc = cc.createAsset(parseAssetKey(asset_id_arg), + readBlock(bootFile), asset_days_arg); + if (rc != CES_OK) { + std::cerr << "Deploy Failed: boot asset '" << asset_id_arg + << "': " << errorString(rc) << "\n"; + return 1; + } + if (g_quiet) { + std::cout << "{\"boot\":\"" << jesc(asset_id_arg) + << "\",\"blocksCreated\":" << created + << ",\"blocksReused\":" << reused << "}\n"; + } else { + print_header("Bundle Deployed"); + print_field("Boot Asset", asset_id_arg); + print_field("Blocks Created", created); + print_field("Blocks Reused", reused); + print_field("Days", asset_days_arg); + std::cout << "Success.\n"; + } + + } else if (cmd_asq->parsed()) { + auto aid = parseAssetKey(asset_id_arg); + std::vector vec; + uint8_t rc = cc.queryAssetSigned(aid, 0, vec); + if (rc == CES_OK && !vec.empty()) { + const auto& a = vec[0]; + std::string flagsStr; + if (isAssetPrivate(a.balance)) flagsStr += " private"; + if (isAssetOwned(a.balance)) flagsStr += " asset-owned"; + if (isAssetImmutable(a.balance)) flagsStr += " immutable"; + if (isAssetOwnerPays(a.balance)) flagsStr += " owner-pays"; + print_header("Asset (Signed)"); + print_field("Query ID", asset_id_arg); + print_field("Owner ID", hashPrefixToString(a.ownerId)); + print_field("Balance", std::to_string(assetDays(a.balance)) + " days" + flagsStr); + print_field("Price", a.price == 0 ? std::string("not for sale") + : std::to_string(a.price) + " credits"); + print_field("Content", contentToDisplayString(a.content)); + std::cout << std::endl; + } else { + std::cerr << "Query Failed: " << errorString(rc) << "\n"; + return 1; + } + } + return 0; + }; + + // ---- handleFile handler ---- + auto handleFile = [&]() -> int { + if (cmd_fp->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + // file_composed_data was built by the --in callback (inAppender). + auto& fileData = file_composed_data; + + size_t numChunks = fileData.empty() ? 0 + : (fileData.size() + RAMFILE_CHUNK_DATA_SIZE - 1) / RAMFILE_CHUNK_DATA_SIZE; + + uint8_t rc = ramfilePut(cc, fkey, fileData.data(), fileData.size(), + file_days_arg, + reinterpret_cast(file_meta_arg.data()), + file_meta_arg.size(), + [&](size_t done, size_t total) { + std::cout << "\r " << done << "/" << total + << " chunks" << std::flush; + }); + std::cout << "\n"; + if (rc == CES_OK) { + print_header("File Uploaded"); + print_field("Key", file_key_arg); + print_field("Size", std::to_string(fileData.size()) + " bytes"); + print_field("Chunks", numChunks); + print_field("Days", file_days_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Upload Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_ft->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + uint8_t rc = ramfilePut(cc, fkey, nullptr, 0, file_days_arg, + reinterpret_cast(file_meta_arg.data()), + file_meta_arg.size()); + if (rc == CES_OK) { + print_header("File Created"); + print_field("Key", file_key_arg); + print_field("Days", file_days_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Touch Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_fg->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + ces::Bytes fileData; + RamfileHeader header; + bool mismatch = false; + uint8_t rc = ramfileGet(cc, fkey, fileData, &header, &mismatch); + if (rc == CES_OK) { + std::ofstream ofs(file_path_arg, std::ios::binary); + if (!ofs) { + std::cerr << "Error: cannot write " << file_path_arg << "\n"; + return 1; + } + ofs.write(reinterpret_cast(fileData.data()), + fileData.size()); + print_header("File Downloaded"); + print_field("Key", file_key_arg); + print_field("Size", std::to_string(fileData.size()) + " bytes"); + if (mismatch) + print_field("WARNING", "SHA256 mismatch (file may be dirty)"); + std::cout << "Success.\n"; + } else { + std::cerr << "Download Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_fi->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + HashPrefix owner; + AssetData headContent; + uint16_t headBalance = 0; + uint32_t headPrice = 0; + uint8_t rc = cc.queryAsset(fkey, owner, headContent, headBalance, headPrice); + if (rc != CES_OK) { + std::cerr << "Info Failed: " << errorString(rc) << "\n"; + return 1; + } + auto header = parseRamfileHeader(headContent); + if (!header.valid) { + std::cerr << "Error: not a file (bad magic)\n"; + return 1; + } + print_header("File Info"); + print_field("Key", file_key_arg); + print_field("Size", std::to_string(header.fileSize) + " bytes"); + print_field("SHA256", ces::bytesToHex(header.contentHash)); + if (header.createdTime) + print_field("Created", std::to_string(header.createdTime) + " us"); + if (header.modifiedTime) + print_field("Modified", std::to_string(header.modifiedTime) + " us"); + print_field("Head Days", assetDays(headBalance)); + // Display metadata if non-empty + size_t metaLen = 0; + for (size_t i = 0; i < RAMFILE_HEAD_META_SIZE; ++i) + if (header.metadata[i]) metaLen = i + 1; + if (metaLen > 0) { + bool isText = true; + for (size_t i = 0; i < metaLen; ++i) + if (header.metadata[i] < 32 || header.metadata[i] > 126) + isText = false; + if (isText) + print_field("Metadata", + std::string(reinterpret_cast(header.metadata.data()), metaLen)); + else + print_field("Metadata", std::to_string(metaLen) + " bytes (binary)"); + } + + } else if (cmd_fr->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + auto scanPath = buildRamfileScanFilename(fkey, server_arg); + auto keys = readRamfileScan(scanPath); + if (keys.empty()) { + std::cerr << "Error: scan file not found: " << scanPath << "\n"; + return 1; + } + uint64_t len = file_length_arg; + if (len == 0) len = UINT64_MAX; // read all from offset + ces::Bytes got; + uint8_t rc = ramfileRead(cc, keys, file_offset_arg, len, got); + if (rc != CES_OK) { + std::cerr << "Read Failed: " << errorString(rc) << "\n"; + return 1; + } + if (!file_outfile_arg.empty()) { + std::ofstream ofs(file_outfile_arg, std::ios::binary); + ofs.write(reinterpret_cast(got.data()), got.size()); + print_header("File Read"); + print_field("Bytes", got.size()); + print_field("Output", file_outfile_arg); + } else if (file_hex_output) { + std::cout << ces::bytesToHex(got) << "\n"; + } else { + std::cout.write(reinterpret_cast(got.data()), got.size()); + } + + } else if (cmd_fw->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + auto scanPath = buildRamfileScanFilename(fkey, server_arg); + auto keys = readRamfileScan(scanPath); + if (keys.empty()) { + std::cerr << "Error: scan file not found: " << scanPath << "\n"; + return 1; + } + auto& wdata = file_composed_data; + if (wdata.empty()) { + std::cerr << "Error: specify --in \n"; return 1; + } + uint8_t rc = ramfileWrite(cc, keys, file_offset_arg, wdata.data(), wdata.size()); + if (rc == CES_OK) { + print_header("File Written"); + print_field("Offset", file_offset_arg); + print_field("Bytes", wdata.size()); + std::cout << "Success.\n"; + } else { + std::cerr << "Write Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_fa->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + auto scanPath = buildRamfileScanFilename(fkey, server_arg); + auto keys = readRamfileScan(scanPath); + if (keys.empty()) { + std::cerr << "Error: scan file not found: " << scanPath << "\n"; + return 1; + } + auto& adata = file_composed_data; + if (adata.empty()) { + std::cerr << "Error: specify --in \n"; return 1; + } + + // Append is the composition of + // (1) ramfileResize to grow the declared size (allocating new + // chunks via the current chain's tail if needed) and (2) + // ramfileWrite to place the appended bytes into the newly-grown + // region. Read the current declared size from the head first + // so we know where to start writing. + HashPrefix headOwner; + AssetData headContent; + uint16_t headBalance = 0; + uint32_t headPrice = 0; + uint8_t rc = cc.queryAsset(keys[0], headOwner, headContent, + headBalance, headPrice); + if (rc != CES_OK) { + std::cerr << "Append Failed (query head): " + << errorString(rc) << "\n"; + return 1; + } + RamfileHeader hdr = parseRamfileHeader(headContent); + if (!hdr.valid) { + std::cerr << "Append Failed: head is not a valid file header\n"; + return 1; + } + uint64_t oldSize = hdr.fileSize; + + rc = ramfileResize(cc, keys, oldSize + adata.size(), file_days_arg); + if (rc != CES_OK) { + std::cerr << "Append Failed (resize): " + << errorString(rc) << "\n"; + return 1; + } + + rc = ramfileWrite(cc, keys, oldSize, adata.data(), adata.size()); + if (rc == CES_OK) { + // Update scan file with new keys (resize may have added chunks). + writeRamfileScan(scanPath, keys); + print_header("File Appended"); + print_field("Bytes added", adata.size()); + print_field("Total chunks", keys.size() - 1); + std::cout << "Success.\n"; + } else { + std::cerr << "Append Failed (write): " + << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_fz->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + auto scanPath = buildRamfileScanFilename(fkey, server_arg); + auto keys = readRamfileScan(scanPath); + if (keys.empty()) { + std::cerr << "Error: scan file not found: " << scanPath << "\n"; + return 1; + } + uint8_t rc = ramfileResize(cc, keys, file_newsize_arg, file_days_arg); + if (rc == CES_OK) { + writeRamfileScan(scanPath, keys); + print_header("File Resized"); + print_field("Key", file_key_arg); + print_field("New size", std::to_string(file_newsize_arg) + " bytes"); + print_field("Chunks", keys.size() - 1); + std::cout << "Success.\n"; + } else { + std::cerr << "Resize Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_fh->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + uint8_t rc = ramfileRehash(cc, fkey); + if (rc == CES_OK) { + print_header("File Rehashed"); + print_field("Key", file_key_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Rehash Failed: " << errorString(rc) << "\n"; + return 1; + } + + } else if (cmd_fs->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + std::vector keys; + uint8_t rc = ramfileScan(cc, fkey, keys); + if (rc != CES_OK) { + std::cerr << "Scan Failed: " << errorString(rc) << "\n"; + return 1; + } + auto scanPath = buildRamfileScanFilename(fkey, server_arg); + writeRamfileScan(scanPath, keys); + print_header("File Scanned"); + print_field("Key", file_key_arg); + print_field("Assets", keys.size()); + print_field("Scan file", scanPath); + std::cout << "Success.\n"; + + } else if (cmd_ff->parsed()) { + auto fkey = parseAssetKey(file_key_arg); + auto scanPath = buildRamfileScanFilename(fkey, server_arg); + auto keys = readRamfileScan(scanPath); + if (keys.empty()) { + std::cerr << "Error: scan file not found: " << scanPath << "\n"; + std::cerr << "Run 'cesh ramfile scan " << file_key_arg << "' first.\n"; + return 1; + } + uint8_t rc = ramfileFundFromScan(cc, keys, file_days_arg); + if (rc == CES_OK) { + print_header("File Funded"); + print_field("Key", file_key_arg); + print_field("Assets funded", keys.size()); + print_field("Days Added", file_days_arg); + std::cout << "Success.\n"; + } else { + std::cerr << "Fund Failed: " << errorString(rc) << "\n"; + return 1; + } + } + return 0; + }; + + // Shared prologue for the L2 verb handlers (file / compute / mail): + // require --rpc-port, strip :port from --server (that is the main CES + // port; the rpc port is the separate flag), and resolve the server pubkey + // for response-sig verification with NO paid query (explicit --server-key + // wins; otherwise reuse the key the free MINX handshake already learned + // on connect). Returns false on a usage error (message printed). + struct L2Target { + std::string host; + minx::Hash serverPk{}; + bool hasServerPk = false; + }; + auto resolveL2Target = [&](const char* what, L2Target& out) -> bool { + if (rpcPort_arg == 0) { + std::cerr << "Error: " << what << " requires --rpc-port\n"; + return false; + } + out.host = server_arg; + auto colon = out.host.rfind(':'); + if (colon != std::string::npos) out.host = out.host.substr(0, colon); + if (out.host.empty()) out.host = "localhost"; + if (!server_key_arg.empty()) { + try { + minx::stringToHash(out.serverPk, server_key_arg); + out.hasServerPk = true; + } catch (...) { + std::cerr << "Error: bad --server-key hex\n"; + return false; + } + } else { + out.serverPk = cc.getServerKey(); + minx::Hash zero{}; + out.hasServerPk = (out.serverPk != zero); + } + return true; + }; + + // ---- handleDiskFile handler (L2 disk-backed file store) ---- + auto handleDiskFile = [&]() -> int { + L2Target tgt; + if (!resolveL2Target("file", tgt)) return 1; + auto& [host, serverPk, hasServerPk] = tgt; + if (!hasServerPk) { + std::cerr << "Warn: no server key; response sig verification disabled.\n"; + } + + // feeFileRent/feeFileWrite are only needed to size the DEFAULT upfront + // deposit for `put` when the caller didn't pass --deposit. Fetch them + // via the paid server-info ONLY in that case — get/stat, and any put + // with an explicit --deposit, never trigger a paid query. + uint64_t feeFileRent = 0; + uint64_t feeFileWrite = 0; + if (cmd_dfp->parsed() && df_deposit_arg == 0) { + std::vector entries; + uint8_t sirc = cc.queryServerInfo(entries); + if (sirc != CES_OK) { + std::cerr << "Error: queryServerInfo failed: " + << errorString(sirc) << "\n"; + return 1; + } + for (const auto& e : entries) { + if (e.key == "feeFileRent") + { try { feeFileRent = std::stoull(e.value); } catch (...) {} } + else if (e.key == "feeFileWrite") + { try { feeFileWrite = std::stoull(e.value); } catch (...) {} } + } + } + + // Helper: auto-prepend /h// if the path doesn't start with /. + auto normalizePath = [&](const std::string& raw) -> std::string { + if (!raw.empty() && raw[0] == '/') return raw; + return "/h/" + actorKey.getPublicKeyHexStr() + "/" + raw; + }; + + // Helper: compute the 15-min upfront-rent minimum for a new file of + // `size` bytes at the server's current feeFileRent. + auto upfrontFor = [&](uint64_t size) -> uint64_t { + if (feeFileRent == 0 || size == 0) return 0; + // 15 min = 900 seconds = 9e8 us. A day = 8.64e10 us. + // owed = size * rate * 9e8 / 8.64e10 (floor). Use __uint128_t. + __uint128_t owed = static_cast<__uint128_t>(size) * + static_cast<__uint128_t>(feeFileRent) * + static_cast<__uint128_t>(900'000'000ull); + owed /= static_cast<__uint128_t>(86'400'000'000ull); + if (owed > std::numeric_limits::max()) + return std::numeric_limits::max(); + return static_cast(owed); + }; + + CesFileClient cfc; + uint8_t rc = cfc.connect(host, rpcPort_arg, actorKey); + if (rc != CES_OK) { + std::cerr << "Error: file-store connect failed: " + << errorString(rc) << "\n"; + return 1; + } + if (hasServerPk) cfc.setServerPubkey(serverPk); + + // Sub-MB chunks: the server caps WRITE/READ at 1 MB, and RUDP's + // per-channel reorder buffer is also 1 MB, so a 1 MB chunk leaves + // no headroom for retransmits. 512 KB stays within both limits. + constexpr size_t kChunkSize = 512 * 1024; + + // ---- put ---- + if (cmd_dfp->parsed()) { + // Load local file fully into memory. + std::ifstream in(df_local_arg, std::ios::binary); + if (!in) { + std::cerr << "Error: cannot open local file: " << df_local_arg + << "\n"; + return 1; + } + ces::Bytes bytes( + (std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + + std::string remote = normalizePath(df_remote_arg); + + // STAT first to decide CREATE vs RESIZE path. + CesFileClient::StatInfo info; + uint8_t srcRc = cfc.stat(remote, info); + bool exists = (srcRc == CES_OK); + + // /s/ is unmetered on the server (see file_handler.cpp: + // isServerZone). The client's per-byte top-up math here would + // compute nonsense: e.g. 275 MB × 18280 cr/byte ≈ 5 peta-credits. + // Detect and skip. + const bool remoteIsServerZone = + remote.size() >= 3 && remote[0] == '/' && + remote[1] == 's' && remote[2] == '/'; + + if (exists) { + if (!remoteIsServerZone) { + // Auto top-up: make sure the file has enough balance to + // complete the re-upload. Need = writeCost for the new + // content + upfront rent on any growth delta. Any user- + // specified --deposit is added on top as a bonus. + uint64_t grow = (bytes.size() > info.size) + ? (bytes.size() - info.size) : 0; + // feeFileWrite is credits per KB (1024 bytes, ceil). + uint64_t writeKb = (bytes.size() + 1023) / 1024; + // Upfront on the growth delta is BURNED on APPEND/RESIZE + // — we add another 15-min retention float on top so the + // new bytes actually live past the APPEND. + uint64_t need = writeKb * feeFileWrite + upfrontFor(grow) * 2; + uint64_t shortfall = (need > info.fileBalance) + ? (need - info.fileBalance) : 0; + uint64_t topUp = shortfall + df_deposit_arg; + if (topUp > 0) { + uint64_t newBal = 0; + uint8_t drc = cfc.deposit(remote, topUp, newBal); + if (drc != CES_OK) { + std::cerr << "Error: deposit failed: " << errorString(drc) + << "\n"; + return 1; + } + } + } + // Resize if size changed. + if (info.size != bytes.size()) { + uint64_t newSize = 0; + uint8_t rrc = cfc.resize(remote, bytes.size(), + newSize); + if (rrc != CES_OK) { + std::cerr << "Error: resize failed: " << errorString(rrc) + << "\n"; + return 1; + } + } + } else if (srcRc == CES_ERROR_FILE_NOT_FOUND) { + // CREATE path. Decide deposit. + // Minimum = (15-min upfront rent) + (write cost to fill). + // The server checks the upfront at CREATE and the write + // cost at each WRITE; a naive deposit defaults to their + // sum so a small file "just uploads." + // /s/ short-circuits — unmetered on the server, the deposit + // field is forced to 0 by the server regardless. + uint64_t deposit; + if (remoteIsServerZone) { + deposit = 0; + } else { + uint64_t upfront = upfrontFor(bytes.size()); + // feeFileWrite is credits per KB (1024 bytes, ceil). + uint64_t writeKb = (bytes.size() + 1023) / 1024; + uint64_t writeCost = writeKb * feeFileWrite; + // Default deposit = upfront_burn + writeCost + 15-min of + // retention. Upfront is burned at CREATE (not refunded), + // so we add it twice: once for the burn, once to leave + // a 15-min rent float in file_balance after the WRITE. + uint64_t minDeposit = upfront * 2 + writeCost; + deposit = df_deposit_arg > 0 ? df_deposit_arg : minDeposit; + if (deposit < upfront) { + std::cerr << "Error: --deposit " << deposit + << " is below the 15-min upfront rent minimum (" + << upfront << "). Raise --deposit or reduce " + << "the file size.\n"; + return 1; + } + } + uint64_t createdBalance = 0, costDebited = 0; + uint8_t crc = cfc.create(remote, bytes.size(), + /*price_per_kb=*/0, deposit, + createdBalance, costDebited); + if (crc != CES_OK) { + std::cerr << "Error: create failed: " << errorString(crc) + << "\n"; + return 1; + } + } else { + std::cerr << "Error: STAT failed: " << errorString(srcRc) << "\n"; + return 1; + } + + // Stream the bytes in 1-MB chunks. + size_t written = 0; + while (written < bytes.size()) { + size_t chunkLen = std::min(kChunkSize, bytes.size() - written); + ces::Bytes chunk(bytes.begin() + written, + bytes.begin() + written + chunkLen); + uint64_t newBal = 0; + uint8_t wrc = cfc.write(remote, written, chunk, + newBal); + if (wrc != CES_OK) { + std::cerr << "Error: write at offset " << written << " failed: " + << errorString(wrc) << "\n"; + return 1; + } + written += chunkLen; + } + + // Re-STAT for final info. + CesFileClient::StatInfo fi; + if (cfc.stat(remote, fi) == CES_OK) { + print_header("File Uploaded"); + print_field("Remote", remote); + print_field("Size", fi.size); + print_field("Balance", fi.fileBalance); + print_field("Price/KB", fi.pricePerKb); + std::cout << std::endl; + } else { + std::cout << "File uploaded (" << bytes.size() << " bytes) to " + << remote << "\n"; + } + return 0; + } + + // ---- get ---- + if (cmd_dfg->parsed()) { + std::string remote = normalizePath(df_remote_arg); + CesFileClient::StatInfo info; + uint8_t srcRc = cfc.stat(remote, info); + if (srcRc != CES_OK) { + std::cerr << "Error: STAT failed: " << errorString(srcRc) << "\n"; + return 1; + } + + // Stream each chunk straight to the sink — never buffer the whole file + // in RAM (peak is one chunk). To a real path: write a sibling ".part" + // and atomically rename over the destination on success (rename(2) + // replaces any existing file, so "already there" needs no handling); a + // mid-download failure leaves the original untouched and removes the + // .part. To stdout (the data-pipe / cesweb form): write chunks as they + // arrive — and because the file now grows incrementally, a watcher can + // stat it for live progress. On failure exit nonzero so the caller + // discards its own partial. + const bool toStdout = df_local_arg.empty() || df_local_arg == "-"; + std::ofstream fout; + std::filesystem::path partPath; + if (!toStdout) { + partPath = std::filesystem::path(df_local_arg + ".part"); + fout.open(partPath, std::ios::binary | std::ios::trunc); + if (!fout) { + std::cerr << "Error: cannot open local destination: " + << df_local_arg << "\n"; + return 1; + } + } + std::ostream& sink = toStdout ? std::cout : fout; + + uint64_t offset = 0; + while (offset < info.size) { + uint32_t chunkLen = static_cast( + std::min(kChunkSize, info.size - offset)); + ces::Bytes chunk; + minx::Hash rh; + uint8_t rrc = cfc.read(remote, offset, chunkLen, chunk, rh); + if (rrc != CES_OK) { + std::cerr << "Error: read at offset " << offset << " failed: " + << errorString(rrc) << "\n"; + if (!toStdout) { fout.close(); std::error_code ec; + std::filesystem::remove(partPath, ec); } + return 1; + } + sink.write(reinterpret_cast(chunk.data()), chunk.size()); + if (!sink) { + std::cerr << "Error: write failed at offset " << offset << "\n"; + if (!toStdout) { fout.close(); std::error_code ec; + std::filesystem::remove(partPath, ec); } + return 1; + } + offset += chunk.size(); + } + + if (toStdout) { + std::cout.flush(); + return 0; + } + fout.close(); + if (!fout) { + std::cerr << "Error: failed to flush " << df_local_arg << "\n"; + std::error_code ec; std::filesystem::remove(partPath, ec); + return 1; + } + std::error_code ec; + std::filesystem::rename(partPath, df_local_arg, ec); + if (ec) { + std::cerr << "Error: rename to " << df_local_arg << " failed: " + << ec.message() << "\n"; + std::filesystem::remove(partPath, ec); + return 1; + } + + print_header("File Downloaded"); + print_field("Remote", remote); + print_field("Local", df_local_arg); + print_field("Size", offset); + print_field("Balance", info.fileBalance); + std::cout << std::endl; + return 0; + } + + // ---- stat ---- + if (cmd_dfs->parsed()) { + std::string remote = normalizePath(df_remote_arg); + CesFileClient::StatInfo info; + uint8_t srcRc = cfc.stat(remote, info); + if (srcRc != CES_OK) { + std::cerr << "STAT Failed: " << errorString(srcRc) << "\n"; + return 1; + } + if (g_quiet) { + std::cout << "{\"remote\":\"" << jesc(remote) + << "\",\"owner\":\"" << minx::hashToString(info.ownerPubkey) + << "\",\"size\":" << info.size + << ",\"balance\":" << info.fileBalance + << ",\"pricePerKb\":" << info.pricePerKb + << ",\"createdUs\":" << info.createdUs + << ",\"modifiedUs\":" << info.modifiedUs << "}\n"; + return 0; + } + print_header("File Info"); + print_field("Remote", remote); + print_field("Owner", minx::hashToString(info.ownerPubkey)); + print_field("Size", info.size); + print_field("Balance", info.fileBalance); + print_field("Price/KB", info.pricePerKb); + print_field("Created", info.createdUs); + print_field("Modified", info.modifiedUs); + std::cout << std::endl; + return 0; + } + + // ---- rm ---- + if (cmd_dfrm->parsed()) { + std::string remote = normalizePath(df_remote_arg); + uint64_t refunded = 0; + uint8_t drc = cfc.deleteFile(remote, refunded); + if (drc != CES_OK) { + std::cerr << "Error: delete failed: " << errorString(drc) << "\n"; + return 1; + } + print_header("File Deleted"); + print_field("Remote", remote); + print_field("Refunded", refunded); + std::cout << std::endl; + return 0; + } + + // ---- deposit ---- + if (cmd_dfd->parsed()) { + std::string remote = normalizePath(df_remote_arg); + uint64_t newBal = 0; + uint8_t drc = cfc.deposit(remote, df_amount_arg, newBal); + if (drc != CES_OK) { + std::cerr << "Error: deposit failed: " << errorString(drc) << "\n"; + return 1; + } + print_header("File Deposit"); + print_field("Remote", remote); + print_field("Balance", newBal); + std::cout << std::endl; + return 0; + } + + // ---- withdraw ---- + if (cmd_dfw->parsed()) { + std::string remote = normalizePath(df_remote_arg); + uint64_t newBal = 0; + uint8_t wrc = cfc.withdraw(remote, df_amount_arg, newBal); + if (wrc != CES_OK) { + std::cerr << "Error: withdraw failed: " << errorString(wrc) << "\n"; + return 1; + } + print_header("File Withdraw"); + print_field("Remote", remote); + print_field("Balance", newBal); + std::cout << std::endl; + return 0; + } + + // ---- set-price ---- + if (cmd_dfsp->parsed()) { + std::string remote = normalizePath(df_remote_arg); + uint64_t newPrice = 0; + uint8_t src = cfc.setPrice(remote, df_price_arg, newPrice); + if (src != CES_OK) { + std::cerr << "Error: set-price failed: " << errorString(src) + << "\n"; + return 1; + } + print_header("File Price Updated"); + print_field("Remote", remote); + print_field("Price/KB", newPrice); + std::cout << std::endl; + return 0; + } + + std::cerr << "Unknown file subcommand.\n"; + return 1; + }; + + // ---- handleCompute handler ---- + auto handleCompute = [&]() -> int { + L2Target tgt; + if (!resolveL2Target("compute", tgt)) return 1; + auto& [host, serverPk, hasServerPk] = tgt; + + auto normalizePath = [&](const std::string& raw) -> std::string { + if (!raw.empty() && raw[0] == '/') return raw; + return "/h/" + actorKey.getPublicKeyHexStr() + "/" + raw; + }; + + CesComputeClient cc2; + uint8_t rc = cc2.connect(host, rpcPort_arg, actorKey); + if (rc != CES_OK) { + std::cerr << "Error: compute connect failed: " + << errorString(rc) << "\n"; + return 1; + } + if (hasServerPk) cc2.setServerPubkey(serverPk); + + if (cmd_clau->parsed()) { + std::string remote = normalizePath(compute_path_arg); + uint64_t id = 0, startedAt = 0; + uint8_t lrc = cc2.launch(remote, id, startedAt); + if (lrc != CES_OK) { + std::cerr << "LAUNCH Failed: " << errorString(lrc) << "\n"; + return 1; + } + if (g_quiet) { + std::cout << "{\"pid\":" << id + << ",\"startedAtUs\":" << startedAt << "}\n"; + return 0; + } + print_header("Compute Launched"); + print_field("Remote", remote); + print_field("Instance", id); + print_field("StartedAt", startedAt); + std::cout << std::endl; + return 0; + } + + if (cmd_ckil->parsed()) { + uint8_t krc = cc2.kill(compute_pid_arg); + if (krc != CES_OK) { + std::cerr << "KILL Failed: " << errorString(krc) << "\n"; + return 1; + } + if (g_quiet) { + std::cout << "{\"pid\":" << compute_pid_arg + << ",\"killed\":true}\n"; + return 0; + } + print_header("Compute Killed"); + print_field("Instance", compute_pid_arg); + std::cout << std::endl; + return 0; + } + + if (cmd_cps->parsed()) { + std::vector list; + uint8_t prc = cc2.list(list); + if (prc != CES_OK) { + std::cerr << "LIST Failed: " << errorString(prc) << "\n"; + return 1; + } + if (g_quiet) { + std::cout << "["; + for (size_t i = 0; i < list.size(); ++i) + std::cout << (i ? "," : "") << instanceJson(list[i]); + std::cout << "]\n"; + return 0; + } + print_header("Compute Instances"); + if (list.empty()) { + std::cout << " (none)\n"; + } else { + for (auto& e : list) { + print_field("Instance", e.pid); + print_field("Remote", e.sourceName); + print_field("StartedAt", e.startedAtUs); + print_field("FileBalance", e.fileBalance); + print_field("CpuBp", e.cpuBasisPoints); + print_field("RssBytes", e.rssBytes); + print_field("ClientPort", e.clientPort); + print_field("RpcPort", e.rpcPort); + std::cout << " --\n"; + } + } + std::cout << std::endl; + return 0; + } + + if (cmd_cst->parsed()) { + CesComputeClient::InstanceInfo info; + uint8_t src = cc2.stat(compute_pid_arg, info); + if (src != CES_OK) { + std::cerr << "STAT Failed: " << errorString(src) << "\n"; + return 1; + } + if (g_quiet) { + std::cout << instanceJson(info) << "\n"; + return 0; + } + print_header("Compute Status"); + print_field("Instance", info.pid); + print_field("Remote", info.sourceName); + print_field("StartedAt", info.startedAtUs); + print_field("FileBalance", info.fileBalance); + print_field("CpuBp", info.cpuBasisPoints); + print_field("RssBytes", info.rssBytes); + print_field("ClientPort", info.clientPort); + print_field("RpcPort", info.rpcPort); + std::cout << std::endl; + return 0; + } + + if (cmd_cinst->parsed()) { + std::vector insts; + std::string remote = normalizePath(compute_path_arg); + uint8_t src = cc2.instances(remote, insts); + if (src != CES_OK) { + std::cerr << "INSTANCES Failed: " << errorString(src) << "\n"; + return 1; + } + if (g_quiet) { + // JSON array with ports — the form the web gateway consumes. + std::cout << "["; + for (size_t i = 0; i < insts.size(); ++i) + std::cout << (i ? "," : "") << instanceJson(insts[i]); + std::cout << "]\n"; + return 0; + } + // Human mode: one id per line, no header — pipeable into + // `head -1 | xargs cesh dial`. (Ports are in the -q JSON or stat.) + for (auto& e : insts) std::cout << e.pid << "\n"; + return 0; + } + + if (cmd_ccall->parsed()) { + ces::Bytes reply; + uint8_t crc = cc2.call(compute_pid_arg, compute_value_arg, + compute_memo_data, reply); + if (crc != CES_OK) { + std::cerr << "CALL Failed: " << errorString(crc) << "\n"; + return 1; + } + // Reply is data: --out to a file (binary-safe), --hex as a hex string, + // else raw to stdout (same convention as `file read`). + if (!compute_out_arg.empty()) { + std::ofstream ofs(compute_out_arg, std::ios::binary); + ofs.write(reinterpret_cast(reply.data()), reply.size()); + if (!g_quiet) { + print_header("Compute Call"); + print_field("Instance", compute_pid_arg); + print_field("Paid", compute_value_arg); + print_field("Bytes", reply.size()); + print_field("Output", compute_out_arg); + } + } else if (compute_hex_output) { + std::cout << ces::bytesToHex(reply) << "\n"; + } else { + std::cout.write(reinterpret_cast(reply.data()), + static_cast(reply.size())); + } + return 0; + } + + std::cerr << "Unknown compute subcommand.\n"; + return 1; + }; + + // ---- handleMail handler ---- + auto handleMail = [&]() -> int { + L2Target tgt; + if (!resolveL2Target("mail", tgt)) return 1; + auto& [host, serverPk, hasServerPk] = tgt; + + CesMailClient mc; + uint8_t rc = mc.connect(host, rpcPort_arg, actorKey); + if (rc != CES_OK) { + std::cerr << "Error: mail connect failed: " << errorString(rc) << "\n"; + return 1; + } + if (hasServerPk) mc.setServerPubkey(serverPk); + + if (cmd_msend->parsed()) { + uint8_t src = mc.send(mail_to_arg, mail_subject_arg, mail_body_data, + mail_attach_arg); + if (src != CES_OK) { + std::cerr << "SEND Failed: " << errorString(src) << "\n"; + return 1; + } + if (g_quiet) { + std::cout << "{\"sent\":true}\n"; + return 0; + } + print_header("Mail Sent"); + print_field("To", mail_to_arg); + print_field("Subject", mail_subject_arg); + print_field("Bytes", mail_body_data.size()); + if (!mail_attach_arg.empty()) print_field("Attach", mail_attach_arg); + std::cout << std::endl; + return 0; + } + + std::cerr << "Unknown mail subcommand.\n"; + return 1; + }; + + // ---- handleAutoexec handler ---- + auto handleAutoexec = [&]() -> int { + if (cmd_axi->parsed()) { + auto programId = parseAssetKey(autoexec_program_arg); + ces::Bytes input; + if (!autoexec_input_arg.empty()) + input = ces::parseHex(autoexec_input_arg); + + HashPrefix myPrefix = Account::getMapKey(actorKey.getPublicKeyAsHash()); + auto autoKey = buildAutoexecKey(myPrefix); + auto autoContent = buildAutoexecContent( + programId, autoexec_budget_arg, input, actorKey, cc.getServerId()); + if (!autoContent) { + std::cerr << "Install Failed: input too large for autoexec asset " + "(max ~40 bytes of --input)\n"; + return 1; + } + + uint8_t rc = cc.createAsset(autoKey, *autoContent, autoexec_days_arg); + if (rc == CES_OK) { + std::string keyHex = minx::hashToString(autoKey); + print_header("Autoexec Installed"); + print_field("Program", autoexec_program_arg); + print_field("Budget", autoexec_budget_arg); + print_field("Autoexec Key", keyHex); + std::cout << "\nTo disable: cesh asset fast " << keyHex + << " --hexcontent 00\n"; + std::cout << "Success.\n"; + } else { + std::cerr << "Install Failed: " << errorString(rc) << "\n"; + return 1; + } + } + return 0; + }; + + + // ---- squery ---- + + if (cmd_squery->parsed()) { + minx::Hash t; + minx::stringToHash(t, wallet.resolveKey(squery_account_arg)); + std::vector vec; + uint8_t rc = cc.queryAccountSigned(Account::getMapKey(t), 0, vec); + if (rc == CES_OK && !vec.empty()) { + print_header("Account (Signed)"); + print_field("Key", minx::hashToString(vec[0].key)); + print_field("Balance", vec[0].balance); + print_field("Nonce", vec[0].nonce); + print_field("LastXferDest", hashPrefixToString(vec[0].lastXferDest)); + print_field("LastXferAmount", vec[0].lastXferAmount); + print_field("LastXferTime", vec[0].lastXferTime); + std::cout << std::endl; + } else { + std::cerr << "Query Failed: " << errorString(rc) << "\n"; + return 1; + } + } + + // ---- transfer ---- + + else if (cmd_transfer->parsed()) { + minx::Hash dest; + minx::stringToHash(dest, wallet.resolveKey(transfer_dest_arg)); + int64_t nb; + uint8_t rc; + if (transfer_open) + rc = cc.openTransfer(dest, transfer_amount_arg, nb); + else + rc = cc.transfer(dest, transfer_amount_arg, nb); + if (rc == CES_OK) { + print_header("Transfer Sent"); + print_field("To", transfer_dest_arg); + print_field("Amount", transfer_amount_arg); + print_field("Rem. Bal", nb); + std::cout << "Success.\n"; + } else { + std::cerr << "Transfer Failed: " << errorString(rc) << "\n"; + return 1; + } + } + + else if (cmd_payment->parsed()) { + minx::Hash dest; + minx::stringToHash(dest, wallet.resolveKey(payment_dest_arg)); + int64_t nb; + uint8_t rc = cc.createPayment(dest, payment_amount_arg, + static_cast(payment_days_arg), nb); + if (rc == CES_OK) { + print_header("Payment Created"); + print_field("To", payment_dest_arg); + print_field("Amount", payment_amount_arg); + print_field("Days", (uint64_t)payment_days_arg); + print_field("Rem. Bal", nb); + std::cout << "Success.\n"; + } else { + std::cerr << "Payment Failed: " << errorString(rc) << "\n"; + return 1; + } + } + + else if (cmd_cross->parsed()) { + minx::Hash dest; + minx::stringToHash(dest, wallet.resolveKey(cross_dest_arg)); + int64_t nb; + uint8_t rc = cc.crossTransfer(dest, cross_amount_arg, + cross_server_arg, nb); + if (rc == CES_OK) { + print_header("Cross-Transfer Sent"); + print_field("To", cross_dest_arg); + print_field("Amount", cross_amount_arg); + print_field("Server", cross_server_arg); + print_field("Rem. Bal", nb); + std::cout << "Success.\n"; + } else { + std::cerr << "Cross-Transfer Failed: " << errorString(rc) << "\n"; + return 1; + } + } + + else if (cmd_gossip->parsed()) { + minx::Hash dest{}; // all-zero = broadcast + if (!gossip_dest_arg.empty()) + minx::stringToHash(dest, wallet.resolveKey(gossip_dest_arg)); + ces::Bytes msgBytes(gossip_msg_arg.begin(), gossip_msg_arg.end()); + uint8_t rc = cc.gossip(msgBytes, gossip_budget_arg, dest); + if (rc == CES_OK) { + if (g_quiet) { + std::cout << "{\"ok\":true}\n"; + } else { + print_header("Gossip Sent"); + print_field("Bytes", (uint64_t)msgBytes.size()); + print_field("Budget", gossip_budget_arg); + print_field("Dest", gossip_dest_arg.empty() ? "broadcast" + : gossip_dest_arg); + std::cout << "Success.\n"; + } + } else { + std::cerr << "Gossip Failed: " << errorString(rc) << "\n"; + return 1; + } + } + + // ---- server-info ---- + + else if (cmd_sinfo->parsed()) { + std::vector entries; + uint8_t rc = cc.queryServerInfo(entries); + if (rc == CES_OK) { + if (g_quiet) { + std::cout << "{"; + for (size_t i = 0; i < entries.size(); ++i) + std::cout << (i ? "," : "") << "\"" << jesc(entries[i].key) + << "\":\"" << jesc(entries[i].value) << "\""; + std::cout << "}\n"; + } else { + print_header("Server Info"); + for (const auto& e : entries) + print_field(e.key, e.value); + std::cout << std::endl; + } + } else { + std::cerr << "Server Info Failed: " << errorString(rc) << "\n"; + return 1; + } + } + + // ---- mine ---- + + else if (cmd_mine->parsed()) { + // Clamp threads to [1, hardware_concurrency]. 0 fallback if the + // platform can't report concurrency. + const unsigned hw = std::max(1u, std::thread::hardware_concurrency()); + uint32_t n = mine_threads_arg; + if (n < 1) n = 1; + if (n > hw) n = hw; + std::cout << "Mining... (threads=" << n << ")\n"; + auto result = mineOnce(cc, 1, {}, static_cast(n), [](int r) { + std::cout << "Status: " << r << "\r" << std::flush; + }); + if (result.success) { + std::cout << "\nSuccess! Credit: " << result.credit << "\n"; + } else if (result.status == minx::MINX_SOLUTION_UNTIMELY) { + std::cout << "\nRejected (Untimely)\n"; + } else if (result.status < 0) { + std::cerr << "Mining failed to init\n"; + return 1; + } else { + std::cerr << "\nSubmission failed (server unresponsive)\n"; + return 1; + } + } + + // ---- asset ---- + + else if (cmd_asset->parsed()) return handleAsset(); + + // ---- file (L1, ramfile) ---- + + else if (cmd_file->parsed()) return handleFile(); + + // ---- file (L2, disk) ---- + + else if (cmd_dfile->parsed()) return handleDiskFile(); + + // ---- compute (L2 program instances) ---- + + else if (cmd_compute->parsed()) return handleCompute(); + + // ---- mail (L2 outbound email) ---- + + else if (cmd_mail->parsed()) return handleMail(); + + // ---- autoexec ---- + + else if (cmd_autoexec->parsed()) return handleAutoexec(); + + } catch (std::exception& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } + + return 0; +} diff --git a/vendor/ces/src/ceslib/accounts.cpp b/vendor/ces/src/ceslib/accounts.cpp new file mode 100644 index 0000000..e46a5bc --- /dev/null +++ b/vendor/ces/src/ceslib/accounts.cpp @@ -0,0 +1,230 @@ +#include +#include +#include +#include + +LOG_MODULE("acc"); + +namespace ces { + +Accounts::Accounts(const std::string& dataDir, uint64_t minAcc, + uint64_t flushValue, size_t bufferSize) + : store_(dataDir, logkv::StoreFlags::createDir, bufferSize), + flushValue_(flushValue) { + store_.getObjects().reserve(minAcc); + // Sum positive balances only (negative = payment markers) + for (const auto& [key, acc] : store_.getObjects()) { + if (acc.getBalance() > 0) + totalCredits_ += acc.getBalance(); + } +} + +Accounts::ActiveAccount Accounts::get(const HashPrefix& id) { + return ActiveAccount{*this, id, store_.find(id)}; +} + +Accounts::ActiveAccount Accounts::get(const minx::Hash& key) { + return get(Account::getMapKey(key)); +} + +Accounts::ActiveAccount Accounts::getFirst() { + auto it = store_.begin(); + if (it != store_.end()) { + return ActiveAccount{*this, it->first, it}; + } + return ActiveAccount{*this, HashPrefix{}, it}; +} + +void Accounts::createAccount(const HashPrefix& id, const Account& acc) { + { + Account::SerModeGuard guard(Account::SerMode::Full); + store_.update(id, acc); + } + if (acc.getBalance() > 0) + totalCredits_ += acc.getBalance(); + LOGTRACE << "createAccount" << VAR(id) << VAR(acc.getBalance()); +} + +void Accounts::checkFlush(uint64_t amount) { + flushAccumulator_ += amount; + if (flushAccumulator_ > flushValue_) { + flushAccumulator_ = 0; + store_.flush(); + } +} + +bool Accounts::checkAddOverflow(uint64_t a, uint64_t b, uint64_t& res) { +#if defined(__GNUC__) || defined(__clang__) + return __builtin_add_overflow(a, b, &res); +#else + res = a + b; + return res < a; +#endif +} + +bool Accounts::ActiveAccount::exists() const { + return it != parent.store_.end(); +} + +Account& Accounts::ActiveAccount::data() { return it->second; } + +const Account& Accounts::ActiveAccount::data() const { return it->second; } + +int64_t Accounts::ActiveAccount::balance() const { + + return exists() ? it->second.getBalance() : 0; +} + +uint32_t Accounts::ActiveAccount::nonce() const { + + return exists() ? it->second.getNonce() : 0; +} + +uint8_t Accounts::ActiveAccount::validateSpend(uint64_t amount, uint64_t fee, + uint32_t reqNonce, + int64_t errFee) { + if (!exists()) { + LOGDEBUG << "validateSpend: origin not found"; + return CES_ERROR_ORIGIN_NOT_FOUND; + } + + uint64_t totalDeduction; + if (Accounts::checkAddOverflow(amount, fee, totalDeduction)) { + LOGDEBUG << "validateSpend: overflow" << VAR(id) << VAR(amount) << VAR(fee); + chargeError(errFee); + return CES_ERROR_INSUFFICIENT_BALANCE; + } + + int64_t bal = balance(); + if (bal < 0 || static_cast(bal) < totalDeduction) { + LOGDEBUG << "validateSpend: insufficient" << VAR(id) << VAR(bal) + << VAR(totalDeduction); + chargeError(errFee); + return CES_ERROR_INSUFFICIENT_BALANCE; + } + + if (reqNonce != 0 && + reqNonce != CES_NONCELESS && + reqNonce != nonce() + 1) { + LOGDEBUG << "validateSpend: wrong nonce" << VAR(id) << VAR(reqNonce) + << VAR(nonce()); + chargeError(errFee); + return CES_ERROR_WRONG_NONCE; + } + + return CES_OK; +} + +void Accounts::ActiveAccount::debit(uint64_t totalAmount) { + if (!exists()) + return; + + int64_t oldBal = balance(); + int64_t newBal = oldBal - static_cast(totalAmount); + + if (newBal <= 0) { + parent.totalCredits_ -= std::max(0, oldBal); + parent.store_.erase(it); + it = parent.store_.end(); + LOGTRACE << "debit: account deleted" << VAR(id) << VAR(oldBal) + << VAR(totalAmount); + } else { + parent.totalCredits_ -= static_cast(totalAmount); + data().setBalance(newBal); + data().setNonce(nonce() + 1); + parent.store_.persist(it); + LOGTRACE << "debit" << VAR(id) << VAR(newBal); + } +} + +void Accounts::ActiveAccount::debitTransfer(uint64_t totalAmount, + const HashPrefix& destId, + uint64_t xferAmount) { + if (!exists()) + return; + + int64_t oldBal = balance(); + int64_t newBal = oldBal - static_cast(totalAmount); + + if (newBal <= 0) { + parent.totalCredits_ -= std::max(0, oldBal); + parent.store_.erase(it); + it = parent.store_.end(); + LOGTRACE << "debitTransfer: account deleted" << VAR(id) << VAR(oldBal) + << VAR(totalAmount); + } else { + parent.totalCredits_ -= static_cast(totalAmount); + data().setBalance(newBal); + data().setNonce(nonce() + 1); + data().setLastXferDest(destId); + data().setLastXferAmount(xferAmount); + data().setLastXferTime(static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count())); + Account::SerModeGuard guard(Account::SerMode::Transfer); + parent.store_.persist(it); + LOGTRACE << "debitTransfer" << VAR(id) << VAR(newBal) << VAR(destId) + << VAR(xferAmount); + } +} + +void Accounts::ActiveAccount::credit(uint64_t amount) { + if (!exists()) + return; + + int64_t current = balance(); + int64_t newBal; + if (static_cast(BALANCE_MAX) - + static_cast(current) >= + amount) { + newBal = current + static_cast(amount); + } else { + newBal = BALANCE_MAX; + } + parent.totalCredits_ += (newBal - current); + data().setBalance(newBal); + parent.store_.persist(it); + LOGTRACE << "credit" << VAR(id) << VAR(amount) << VAR(newBal); +} + +void Accounts::ActiveAccount::settlePayment(uint64_t amount) { + if (!exists()) + return; + parent.totalCredits_ += static_cast(amount); + data().setBalance(amount); + data().setNonce(0); + parent.store_.persist(it); + LOGTRACE << "settlePayment" << VAR(id) << VAR(amount); +} + +void Accounts::ActiveAccount::chargeError(int64_t errFee) { + if (!exists()) + return; + + int64_t oldBal = balance(); + // Payment accounts (balance < 0) hold no spendable credits. Charging an error + // fee gives b = oldBal - errFee <= 0, which would erase the account — so any + // failed signed op from the payee's key (e.g. a signed self-query) would + // destroy the pending payment. Skip the fee; it settles or expires via daily + // maintenance. (The negative balance is excluded from totalCredits_.) + if (oldBal < 0) + return; + + int64_t b = oldBal - errFee; + if (b <= 0) { + parent.totalCredits_ -= std::max(0, oldBal); + parent.store_.erase(it); + it = parent.store_.end(); + LOGTRACE << "chargeError: account deleted" << VAR(id) << VAR(oldBal) + << VAR(errFee); + } else { + parent.totalCredits_ -= errFee; + data().setBalance(b); + parent.store_.persist(it); + LOGTRACE << "chargeError" << VAR(id) << VAR(errFee) << VAR(b); + } + parent.checkFlush(errFee); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/aliases.cpp b/vendor/ces/src/ceslib/aliases.cpp new file mode 100644 index 0000000..a155a1b --- /dev/null +++ b/vendor/ces/src/ceslib/aliases.cpp @@ -0,0 +1,70 @@ +#include +#include + +LOG_MODULE("als"); + +namespace ces { + +Aliases::Aliases(const std::string& dataDir, uint64_t minAlias, + uint64_t flushValue, size_t bufferSize) + : store_(dataDir, logkv::StoreFlags::createDir, bufferSize), + flushValue_(flushValue) { + store_.getObjects().reserve(minAlias); +} + +Aliases::ActiveAlias Aliases::get(uint32_t id) { + return ActiveAlias{*this, id, store_.find(id)}; +} + +Aliases::ActiveAlias Aliases::getFirst() { + auto it = store_.begin(); + if (it != store_.end()) { + return ActiveAlias{*this, it->first, it}; + } + return ActiveAlias{*this, 0u, it}; +} + +void Aliases::checkFlush(uint64_t amount) { + flushAccumulator_ += amount; + if (flushAccumulator_ > flushValue_) { + flushAccumulator_ = 0; + store_.flush(); + } +} + +bool Aliases::ActiveAlias::exists() const { return it != parent.store_.end(); } + +Alias& Aliases::ActiveAlias::data() { return it->second; } +const Alias& Aliases::ActiveAlias::data() const { return it->second; } + +HashPrefix Aliases::ActiveAlias::getOwner() const { + return exists() ? it->second.getOwner() : HashPrefix{}; +} +HashPrefix Aliases::ActiveAlias::getEditor() const { + return exists() ? it->second.getEditor() : HashPrefix{}; +} +uint16_t Aliases::ActiveAlias::getOp() const { + return exists() ? it->second.getOp() : 0; +} +const AliasData& Aliases::ActiveAlias::getContent() const { + static const AliasData kEmpty{}; + return exists() ? it->second.getContent() : kEmpty; +} + +void Aliases::ActiveAlias::setOwner(const HashPrefix& owner) { + persistWithMode(Alias::SerMode::Full, [&](Alias& a) { a.setOwner(owner); }); +} + +void Aliases::ActiveAlias::setOp(uint16_t op) { + persistWithMode(Alias::SerMode::Full, [&](Alias& a) { a.setOp(op); }); +} + +void Aliases::ActiveAlias::setContent(const AliasData& content) { + persistWithMode(Alias::SerMode::Full, [&](Alias& a) { a.setContent(content); }); +} + +void Aliases::ActiveAlias::updateValue(const Alias& value) { + persistWithMode(Alias::SerMode::Full, [&](Alias& a) { a = value; }); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/assets.cpp b/vendor/ces/src/ceslib/assets.cpp new file mode 100644 index 0000000..4886023 --- /dev/null +++ b/vendor/ces/src/ceslib/assets.cpp @@ -0,0 +1,106 @@ +#include +#include + +LOG_MODULE("ast"); + +namespace ces { + +Assets::Assets(const std::string& dataDir, uint64_t minAsset, + uint64_t flushValue, size_t bufferSize) + : store_(dataDir, logkv::StoreFlags::createDir, bufferSize), + flushValue_(flushValue) { + store_.getObjects().reserve(minAsset); +} + +Assets::ActiveAsset Assets::get(const minx::Hash& assetId) { + return ActiveAsset{*this, assetId, store_.find(assetId)}; +} + +Assets::ActiveAsset Assets::getFirst() { + auto it = store_.begin(); + if (it != store_.end()) { + return ActiveAsset{*this, it->first, it}; + } + return ActiveAsset{*this, minx::Hash{}, it}; +} + +void Assets::checkFlush(uint64_t amount) { + flushAccumulator_ += amount; + if (flushAccumulator_ > flushValue_) { + flushAccumulator_ = 0; + store_.flush(); + } +} + +bool Assets::ActiveAsset::exists() const { return it != parent.store_.end(); } + +Asset& Assets::ActiveAsset::data() { return it->second; } +const Asset& Assets::ActiveAsset::data() const { return it->second; } + +HashPrefix Assets::ActiveAsset::getOwnerId() const { + return exists() ? it->second.getOwnerId() : HashPrefix{}; +} +uint32_t Assets::ActiveAsset::getPrice() const { + return exists() ? it->second.getPrice() : 0; +} +uint16_t Assets::ActiveAsset::getBalance() const { + return exists() ? it->second.getBalance() : 0; +} +const AssetData& Assets::ActiveAsset::getContent() const { + static const AssetData kEmpty{}; + return exists() ? it->second.getContent() : kEmpty; +} + +void Assets::ActiveAsset::setContent(const AssetData& content) { + if (!exists()) + return; + data().setContent(content); + // RAM only — no WAL persist. Survives via snapshots. + LOGTRACE << "setContent" << VAR(id); +} + +void Assets::ActiveAsset::setPrice(uint32_t price) { + persistWithMode(Asset::SerMode::Meta, + [&](Asset& a) { a.setPrice(price); }); + if (exists()) { + LOGTRACE << "setPrice" << VAR(id) << VAR(price); + } +} + +void Assets::ActiveAsset::setOwner(const HashPrefix& newOwner) { + persistWithMode(Asset::SerMode::Meta, + [&](Asset& a) { a.setOwnerId(newOwner); }); + if (exists()) { + LOGTRACE << "setOwner" << VAR(id) << VAR(newOwner); + } +} + +void Assets::ActiveAsset::setBalance(uint16_t newBalance) { + persistWithMode(Asset::SerMode::Balance, + [&](Asset& a) { a.setBalance(newBalance); }); + if (exists()) { + LOGTRACE << "setBalance" << VAR(id) << VAR(newBalance); + } +} + +void Assets::ActiveAsset::updateFull(const HashPrefix& newOwner, + const AssetData& content, uint32_t price) { + persistWithMode(Asset::SerMode::Full, [&](Asset& a) { + a.setOwnerId(newOwner); + a.setContent(content); + a.setPrice(price); + }); + LOGTRACE << "updateFull" << VAR(id) << VAR(newOwner) << VAR(price); +} + +void Assets::ActiveAsset::transferOwnership(const HashPrefix& newOwner) { + persistWithMode(Asset::SerMode::Meta, [&](Asset& a) { + a.setOwnerId(newOwner); + a.setPrice(0); + }); + if (exists()) { + LOGTRACE << "transferOwnership" << VAR(id) << VAR(newOwner); + } +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/cesco.cpp b/vendor/ces/src/ceslib/cesco.cpp new file mode 100644 index 0000000..5f03d44 --- /dev/null +++ b/vendor/ces/src/ceslib/cesco.cpp @@ -0,0 +1,367 @@ +/** + * cesco.cpp — CES Console implementation + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +LOG_MODULE("cesco"); + +namespace ces { + +// ============================================================================= +// CescoSession +// ============================================================================= + +CescoSession::CescoSession(Socket socket, CesServer& server) + : socket_(std::move(socket)), server_(server) { + interpreter_ = [this](const uint8_t* data, size_t len) { + builtinInterpreter(data, len); + }; +} + +void CescoSession::start() { + enqueue("cesco> "); + doRead(); +} + +void CescoSession::send(const std::string& data) { + // Cross-thread entry: marshal onto the session's executor so all socket + // I/O happens on the cesco strand (e.g. the logic-strand snapshot callback + // routes its reply here). + auto self = shared_from_this(); + boost::asio::post(socket_.get_executor(), + [this, self, data]() { enqueue(data); }); +} + +void CescoSession::enqueue(const std::string& data) { + // Cesco-strand only. One async_write in flight at a time; the rest queue. + writeQueue_.push_back(data); + if (!writing_) + doWrite(); +} + +void CescoSession::doWrite() { + writing_ = true; + auto self = shared_from_this(); + boost::asio::async_write(socket_, boost::asio::buffer(writeQueue_.front()), + [this, self](boost::system::error_code ec, size_t) { + if (ec) { + if (ec != boost::asio::error::operation_aborted) { + LOGDEBUG << "cesco send error" << SVAR(ec); + } + writing_ = false; + return; + } + writeQueue_.pop_front(); + if (!writeQueue_.empty()) { + doWrite(); + } else { + writing_ = false; + if (closing_) { + boost::system::error_code ic; + socket_.close(ic); + } + } + }); +} + +void CescoSession::doRead() { + auto self = shared_from_this(); + socket_.async_read_some(boost::asio::buffer(readBuf_), + [this, self](boost::system::error_code ec, size_t len) { + if (ec) { + if (ec != boost::asio::error::eof && + ec != boost::asio::error::operation_aborted) { + LOGDEBUG << "cesco read error" << SVAR(ec); + } + return; + } + interpreter_(readBuf_.data(), len); + doRead(); + }); +} + +void CescoSession::builtinInterpreter(const uint8_t* data, size_t len) { + for (size_t i = 0; i < len; ++i) { + uint8_t b = data[i]; + + // Ctrl+C (ETX) or Ctrl+D (EOT) → close after the farewell flushes + if (b == 0x03 || b == 0x04) { + closing_ = true; + enqueue("bye\n"); + return; + } + + // Backspace / DEL + if (b == 0x08 || b == 0x7F) { + if (!lineBuffer_.empty()) + lineBuffer_.pop_back(); + continue; + } + + if (b == '\r') + continue; + + if (b == '\n') { + // Trim whitespace + auto line = lineBuffer_; + lineBuffer_.clear(); + while (!line.empty() && line.back() == ' ') line.pop_back(); + while (!line.empty() && line.front() == ' ') line.erase(line.begin()); + + if (!line.empty()) { + std::string response = dispatchCommand(line); + if (!response.empty()) + enqueue(response); + if (closing_) + return; + } + enqueue("cesco> "); + continue; + } + + // Bound the line buffer so a client that never sends a newline can't grow + // it without limit. 64 KiB is far beyond any real admin command. + if (lineBuffer_.size() >= 64 * 1024) { + lineBuffer_.clear(); + enqueue("line too long\ncesco> "); + continue; + } + lineBuffer_ += static_cast(b); + } +} + +// Parse a decimal non-negative integer. Returns false on empty, +// leading sign, non-digit, or overflow. Writes result into `out`. +static bool parseU64(const std::string& s, uint64_t& out) { + if (s.empty()) return false; + uint64_t v = 0; + for (char c : s) { + if (c < '0' || c > '9') return false; + if (v > (std::numeric_limits::max() - (c - '0')) / 10) + return false; + v = v * 10 + (c - '0'); + } + out = v; + return true; +} + +// Parse a 64-char lowercase-hex pubkey. Returns false on bad length +// or non-hex chars. +static bool parsePubkeyHex(const std::string& s, minx::Hash& out) { + if (s.size() != 64) return false; + auto nib = [](char c, uint8_t& v) -> bool { + if (c >= '0' && c <= '9') { v = c - '0'; return true; } + if (c >= 'a' && c <= 'f') { v = c - 'a' + 10; return true; } + if (c >= 'A' && c <= 'F') { v = c - 'A' + 10; return true; } + return false; + }; + for (size_t i = 0; i < 32; ++i) { + uint8_t hi, lo; + if (!nib(s[i * 2], hi) || !nib(s[i * 2 + 1], lo)) return false; + out[i] = (hi << 4) | lo; + } + return true; +} + +std::string CescoSession::dispatchCommand(const std::string& line) { + if (line == "h" || line == "help") { + return + "Commands:\n" + " snapshot Write a snapshot of accounts and assets\n" + " credit Mint credits into an account (creates if missing)\n" + " debit Burn credits from an account (clamped to balance)\n" + " netbill Show per-channel RUDP billing snapshot\n" + " h, help Show this help\n" + " q, quit, exit Close this session\n" + " Ctrl+C Close this session\n" + "\n" + "pubkey is 64 lowercase hex chars (the account's full public key).\n" + "\n"; + } + + if (line == "netbill") { + auto* nb = server_._channelMeter(); + if (!nb) { + return "netbill: ChannelMeter not active (rpc port disabled).\n"; + } + auto rows = nb->snapshot(); + std::ostringstream oss; + oss << "RUDP per-channel billing snapshot (" << rows.size() + << " channel(s)):\n"; + if (rows.empty()) { + return oss.str(); + } + static const char* kHex = "0123456789abcdef"; + auto hexPfx = [&](const HashPrefix& p) { + std::string s; + s.reserve(16); + for (auto b : p) { + s.push_back(kHex[(b >> 4) & 0xF]); + s.push_back(kHex[b & 0xF]); + } + return s; + }; + oss << std::left + << std::setw(28) << "peer" + << std::setw(8) << "cid" + << std::setw(28) << "tag" + << std::setw(18) << "payer" + << std::right + << std::setw(10) << "bs-tot" + << std::setw(10) << "br-tot" + << std::setw(12) << "mem-bs-tot" + << std::setw(8) << "d-snd" + << std::setw(8) << "d-rcv" + << std::setw(10) << "d-mem-bs" + << std::setw(8) << "d-age" + << "\n"; + for (const auto& r : rows) { + std::ostringstream peerStr; + peerStr << r.peer; + oss << std::left + << std::setw(28) << peerStr.str() + << std::setw(8) << r.channelId + << std::setw(28) << r.tag + << std::setw(18) << hexPfx(r.payerPfx) + << std::right + << std::setw(10) << r.metrics.bytesSent + << std::setw(10) << r.metrics.bytesReceived + << std::setw(12) << r.metrics.memoryByteSeconds + << std::setw(8) << r.deltaBytesSent + << std::setw(8) << r.deltaBytesReceived + << std::setw(10) << r.deltaMemByteSeconds + << std::setw(8) << r.deltaAgeSec + << "\n"; + } + return oss.str(); + } + + if (line == "q" || line == "quit" || line == "exit") { + closing_ = true; + enqueue("bye\n"); + return ""; + } + + if (line == "snapshot") { + auto self = shared_from_this(); + server_.liveSnapshot([self](bool /*ok*/, std::string msg) { + self->send(msg + "\ncesco> "); + }); + return "Snapshot requested...\n"; + } + + // credit / debit: "verb " + { + std::istringstream iss(line); + std::string verb, amountStr, pubkeyStr, extra; + iss >> verb >> amountStr >> pubkeyStr; + bool isCredit = (verb == "credit"); + bool isDebit = (verb == "debit"); + if (isCredit || isDebit) { + if (amountStr.empty() || pubkeyStr.empty() || (iss >> extra)) { + return std::string("Usage: ") + verb + + " \n"; + } + uint64_t amount = 0; + if (!parseU64(amountStr, amount) || amount == 0) { + return "Bad amount: " + amountStr + + " (must be a positive decimal integer)\n"; + } + if (amount > static_cast( + std::numeric_limits::max())) { + return "Amount too large (exceeds int64_t range).\n"; + } + minx::Hash key; + if (!parsePubkeyHex(pubkeyStr, key)) { + return "Bad pubkey: " + pubkeyStr + + " (must be exactly 64 lowercase hex chars)\n"; + } + int64_t signedAmount = static_cast(amount); + if (isCredit) { + server_._brr(key, signedAmount); + LOGINFO << "cesco credit" << VAR(signedAmount) << SVAR(pubkeyStr); + return "Credited " + amountStr + " to " + pubkeyStr + "\n"; + } else { + server_._burn(key, signedAmount); + LOGINFO << "cesco debit" << VAR(signedAmount) << SVAR(pubkeyStr); + return "Debited " + amountStr + " from " + pubkeyStr + "\n"; + } + } + } + + return "Unknown command: " + line + "\nType 'h' for help.\n"; +} + +// ============================================================================= +// Cesco +// ============================================================================= + +Cesco::Cesco(boost::asio::io_context& io, CesServer& server) + : io_(io), server_(server) {} + +Cesco::~Cesco() { + stop(); +} + +bool Cesco::listen(const std::string& socketPath) { + socketPath_ = socketPath; + + // Remove stale socket file if it exists + std::error_code fec; + std::filesystem::remove(socketPath_, fec); + + try { + boost::asio::local::stream_protocol::endpoint ep(socketPath_); + acceptor_ = std::make_unique( + io_, ep); + LOGINFO << "cesco listening" << SVAR(socketPath_); + doAccept(); + return true; + } catch (std::exception& e) { + LOGERROR << "cesco listen failed" << SVAR(socketPath_) << SVAR(e.what()); + return false; + } +} + +void Cesco::stop() { + if (acceptor_) { + boost::system::error_code ec; + acceptor_->close(ec); + acceptor_.reset(); + } + if (!socketPath_.empty()) { + std::error_code fec; + std::filesystem::remove(socketPath_, fec); + socketPath_.clear(); + } +} + +void Cesco::doAccept() { + acceptor_->async_accept( + [this](boost::system::error_code ec, + boost::asio::local::stream_protocol::socket socket) { + if (ec) { + if (ec != boost::asio::error::operation_aborted) { + LOGDEBUG << "cesco accept error" << SVAR(ec); + } + return; + } + LOGDEBUG << "cesco new connection"; + auto session = std::make_shared( + std::move(socket), server_); + session->start(); + doAccept(); + }); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/cesplex/endpoint.cpp b/vendor/ces/src/ceslib/cesplex/endpoint.cpp new file mode 100644 index 0000000..02a00dc --- /dev/null +++ b/vendor/ces/src/ceslib/cesplex/endpoint.cpp @@ -0,0 +1,148 @@ +// endpoint.cpp — implementation of CesPlexEndpoint. +// +// This is purely the io harness: Minx + Rudp + two io threads + the Rudp +// tick. The CesPlex and ChannelMeter it drives already accept an external +// io_context, so they plug straight onto taskIO_ with no changes. The +// bring-up + teardown ordering mirrors CesServer's proven rpc path (which +// stays its own bespoke, multi-tenant strand and does NOT use this). + +#include +#include // getMicrosSinceEpoch +#include // runGuardedThread + +#include +#include + +#include +#include + +#include + +LOG_MODULE("plex"); + +namespace ces { + +void CesPlexEndpoint::RudpListener::onSend(const minx::SockAddr& peer, + const minx::Bytes& bytes) { + // The bytes already carry the MinxStdExtensions routing key Rudp + // prepended; sendExtension takes the payload as-is. Swallow the + // "no socket" exception that Rudp can trigger (e.g. an HS_CLOSE) at any + // point during teardown after the socket has closed. + if (!ep_->minx_) return; + try { + ep_->minx_->sendExtension(peer, bytes); + } catch (const std::exception&) { + // Socket already closed; nothing to do. + } +} + +std::shared_ptr +CesPlexEndpoint::RudpListener::onAccept(const minx::SockAddr& peer, + uint32_t channelId) { + // Inbound HS_OPEN → hand to CesPlex. A null cesplex (or one with no + // resolved bindings) rejects silently. Outbound channels never fire + // onAccept; this path is purely inbound. + if (!ep_->cesplex_) return nullptr; + return ep_->cesplex_->acceptInbound(peer, channelId); +} + +CesPlexEndpoint::CesPlexEndpoint(uint16_t port, + CesPlexHost* host, + std::map mounts, + minx::MinxConfig minxCfg, + minx::RudpConfig rudpCfg, + std::chrono::seconds meterTick) + : host_(host) { + minx_ = std::make_unique(&minxListener_, std::move(minxCfg)); + minx_->setServerKey(host_->cesplexSigningKey().getPublicKeyAsHash()); + + // Construct the Rudp transport before opening the socket so no packet can + // hit an unwired handler. The Rudp::Listener forwards onSend to minx_ and + // onAccept to cesplex_. + rudp_ = std::make_unique(&rudpListener_, std::move(rudpCfg), + minx_.get()); + + // ChannelMeter before CesPlex, so CesPlex's Session can track() each + // channel as it binds. Both take taskIO_ as their external strand. + meter_ = std::make_unique(*rudp_, taskIO_, host_, meterTick); + cesplex_ = std::make_unique(*rudp_, taskIO_, host_, meter_.get()); + // Mount handler objects before the socket opens (taskIO_ threads not started + // yet, so no concurrent acceptInbound can race the bindings map). + for (const auto& [proto, handler] : mounts) { + if (handler) cesplex_->mount(proto, handler); + } + + // Route inbound Rudp-family packets into the Rudp state machine. + { + minx::MinxStdExtensions stdExt; + stdExt.registerExtension( + minx::Rudp::KEY_V0, + [this](const minx::SockAddr& peer, uint64_t key, + const minx::Bytes& payload) { + if (rudp_) + rudp_->onPacket(peer, key, payload, getMicrosSinceEpoch()); + }); + minx_->setExtensionHandler(std::move(stdExt).build()); + } + + boundPort_ = minx_->openSocket(boost::asio::ip::address_v6::any(), port, + netIO_, taskIO_); + if (boundPort_ == 0) { + LOGWARNING << "CesPlexEndpoint: failed to open socket" << VAR(port); + // Nothing started yet — tear down in reverse construction order. + cesplex_.reset(); + meter_.reset(); + rudp_.reset(); + minx_.reset(); + return; + } + + LOGINFO << "CesPlexEndpoint listening" << VAR(boundPort_); + netThread_ = std::thread( + [this]() { runGuardedThread([this]{ netIO_.run(); }, "cesplexEndpointNetIO"); }); + taskThread_ = std::thread( + [this]() { runGuardedThread([this]{ taskIO_.run(); }, "cesplexEndpointTaskIO"); }); + + // Rudp tick pulse on taskIO_; reschedules itself until running_ clears. + tickTimer_ = std::make_shared(taskIO_); + boost::asio::post(taskIO_, [this]() { scheduleTick(); }); +} + +CesPlexEndpoint::~CesPlexEndpoint() { + running_.store(false); + if (!minx_) return; // socket never opened (or already torn down) + + if (tickTimer_) { + boost::system::error_code ec; + tickTimer_->cancel(); + } + minx_->closeSocket(false); + netIO_.stop(); + taskIO_.stop(); + if (netThread_.joinable()) netThread_.join(); + if (taskThread_.joinable()) taskThread_.join(); + tickTimer_.reset(); + // Threads joined: no callback can race the destructors now. Tear down in + // reverse construction order — CesPlex holds RudpStreams into rudp_, and + // the meter references rudp_, so both go before rudp_. + cesplex_.reset(); + meter_.reset(); + rudp_.reset(); + minx_.reset(); +} + +void CesPlexEndpoint::scheduleTick() { + if (!tickTimer_ || !running_.load()) return; + tickTimer_->expires_after(std::chrono::milliseconds(10)); + tickTimer_->async_wait([this](const boost::system::error_code& ec) { + if (ec || !running_.load() || !rudp_) return; + rudp_->tick(getMicrosSinceEpoch()); + scheduleTick(); + }); +} + +bool CesPlexEndpoint::hasAnyBinding() const { + return cesplex_ && cesplex_->hasAnyBinding(); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/cesplex/meter.cpp b/vendor/ces/src/ceslib/cesplex/meter.cpp new file mode 100644 index 0000000..0a08b0f --- /dev/null +++ b/vendor/ces/src/ceslib/cesplex/meter.cpp @@ -0,0 +1,233 @@ +// meter.cpp — implementation of ChannelMeter. + +#include + +#include + +#include + +#include +#include +#include +#include + +LOG_MODULE("plex"); + +namespace ces { + +namespace { + +constexpr uint64_t kMicrosPerSecond = 1'000'000; + +uint64_t nowMicros() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +std::string hexPrefix(const HashPrefix& p) { + static const char* kHex = "0123456789abcdef"; + std::string out; + out.reserve(16); + for (auto b : p) { + out.push_back(kHex[(b >> 4) & 0xF]); + out.push_back(kHex[b & 0xF]); + } + return out; +} + +bool isZeroPrefix(const HashPrefix& p) { + for (auto b : p) if (b != 0) return false; + return true; +} + +// Per-tick delta from a monotonic cumulative counter, guarded against +// regression. RUDP counters increase within a channel incarnation, but a +// (peer, channelId) reused by a fresh channel before the stale MeteredChannel is +// evicted resets the counter to a small value — so `cur - last` would +// unsigned-underflow to a near-2^64 delta (a huge spurious usage spike). +// Treat a regression as a fresh incarnation: measure the new counter from zero. +inline uint64_t guardedDelta(uint64_t cur, uint64_t last) { + return cur >= last ? cur - last : cur; +} + +} // namespace + +ChannelMeter::ChannelMeter(minx::Rudp& rudp, + boost::asio::io_context& io, + CesPlexHost* host, + std::chrono::seconds tickInterval) + : rudp_(rudp), io_(io), host_(host), tickInterval_(tickInterval), + timer_(std::make_shared(io)) { + // Kick the first tick from io_'s strand. The timer's lambda owns + // its own copy of the shared_ptr so cancel() at destruction + // suffices to break the chain even if we destruct mid-tick. + boost::asio::post(io_, [this]() { scheduleTick(); }); + LOGINFO << "ChannelMeter started" + << VAR(tickInterval_.count()); +} + +ChannelMeter::~ChannelMeter() { + // Cancel the timer chain. Pending tick lambdas observe the cancel + // (ec != 0) and return without rearming. The chain dies when the + // last shared_ptr ref drops. + if (timer_) { + timer_->cancel(); + } + // No logging here. This destructor can run during static destruction (its owner + // is a file-scope global in cesluajitd's luarpc endpoint), after Boost.Log's core + // is already destroyed, which would SEGV. Lifecycle is visible from the matching + // construction log instead. +} + +void ChannelMeter::track(const minx::SockAddr& peer, uint32_t channelId, + std::string tag, HashPrefix payerPfx) { + boost::asio::post(io_, + [this, peer, channelId, tag = std::move(tag), payerPfx] + () mutable { + auto key = std::make_pair(peer, channelId); + auto& mc = channels_[key]; + mc.tag = std::move(tag); + mc.payerPfx = payerPfx; + // Seed lastMeteredAtUs so the first tick's deltaAgeSec measures + // from track() instead of from epoch zero. + if (mc.lastMeteredAtUs == 0) { + mc.lastMeteredAtUs = nowMicros(); + } + LOGDEBUG << "track" << SVAR(peer) << VAR(channelId) + << SVAR(mc.tag) + << SVAR(hexPrefix(mc.payerPfx)); + }); +} + +std::vector +ChannelMeter::snapshot() const { + // const-cast: we only read channels_, but we need a mutable lambda + // to capture this for the post. The post itself touches only + // channels_, which is owned by io_'s strand — no concurrent + // writers from outside. + auto self = const_cast(this); + auto promise = std::make_shared>>(); + auto future = promise->get_future(); + boost::asio::post(io_, [self, promise]() { + std::vector out; + out.reserve(self->channels_.size()); + for (const auto& [key, mc] : self->channels_) { + ChannelSnapshot s; + s.peer = key.first; + s.channelId = key.second; + s.tag = mc.tag; + s.payerPfx = mc.payerPfx; + auto m = self->rudp_.metricsFor(key.first, key.second); + if (m) s.metrics = *m; + s.deltaBytesSent = mc.deltaBytesSent; + s.deltaBytesReceived = mc.deltaBytesReceived; + s.deltaMemByteSeconds = mc.deltaMemByteSeconds; + s.deltaAgeSec = mc.deltaAgeSec; + out.push_back(std::move(s)); + } + promise->set_value(std::move(out)); + }); + return future.get(); +} + +void ChannelMeter::_runTick() { + auto promise = std::make_shared>(); + auto fut = promise->get_future(); + boost::asio::post(io_, [this, promise]() { + doTick(); + promise->set_value(); + }); + fut.get(); +} + +void ChannelMeter::_testSetBaseline(const minx::SockAddr& peer, + uint32_t channelId, + uint64_t lastBytesSent, + uint64_t lastBytesReceived, + uint64_t lastMemByteSeconds) { + auto promise = std::make_shared>(); + auto fut = promise->get_future(); + boost::asio::post(io_, [=, this]() { + auto it = channels_.find(std::make_pair(peer, channelId)); + if (it != channels_.end()) { + it->second.lastBytesSent = lastBytesSent; + it->second.lastBytesReceived = lastBytesReceived; + it->second.lastMemByteSeconds = lastMemByteSeconds; + } + promise->set_value(); + }); + fut.get(); +} + +void ChannelMeter::scheduleTick() { + if (!timer_) return; + timer_->expires_after(tickInterval_); + auto t = timer_; // keep alive + t->async_wait([this, t](const boost::system::error_code& ec) { + if (ec) return; // cancelled + doTick(); + scheduleTick(); + }); +} + +void ChannelMeter::doTick() { + const uint64_t now = nowMicros(); + // Walk channels_: drop those whose Rudp metrics are gone (the host + // closed them), and report this tick's measured resource usage for the + // rest. The meter measures; it does not price, account, or close — the + // host does all of that. A channel the host closes for non-payment + // simply vanishes from metricsFor() on the next tick and is dropped. + for (auto it = channels_.begin(); it != channels_.end(); ) { + const auto& key = it->first; + auto& mc = it->second; + auto m = rudp_.metricsFor(key.first, key.second); + if (!m) { + LOGDEBUG << "evict (channel gone)" + << SVAR(key.first) << VAR(key.second) + << SVAR(mc.tag); + it = channels_.erase(it); + continue; + } + mc.deltaBytesSent = guardedDelta(m->bytesSent, mc.lastBytesSent); + mc.deltaBytesReceived = guardedDelta(m->bytesReceived, + mc.lastBytesReceived); + mc.deltaMemByteSeconds = guardedDelta(m->memoryByteSeconds, + mc.lastMemByteSeconds); + // Guard a backwards system-clock jump (now < lastMeteredAtUs) the same way + // guardedDelta guards the byte counters, so a clock step can't bill a huge + // spurious age. + mc.deltaAgeSec = (now > mc.lastMeteredAtUs) + ? (now - mc.lastMeteredAtUs) / kMicrosPerSecond + : 0; + + // Report measured usage to the host. It prices the usage in its own + // units, charges the payer, and closes (peer, channelId) itself if the + // payer can't cover it. Skipped when there's no host (observability + // mode) or no bound payer. + if (host_ && !isZeroPrefix(mc.payerPfx)) { + host_->cesplexReportUsage( + mc.payerPfx, key.first, key.second, + CesPlexUsage{.bytesSent = mc.deltaBytesSent, + .bytesReceived = mc.deltaBytesReceived, + .memByteSeconds = mc.deltaMemByteSeconds, + .ageSeconds = mc.deltaAgeSec}); + } + + LOGDEBUG << "tick" + << SVAR(mc.tag) + << SVAR(hexPrefix(mc.payerPfx)) + << SVAR(key.first) << VAR(key.second) + << VAR(mc.deltaBytesSent) + << VAR(mc.deltaBytesReceived) + << VAR(mc.deltaMemByteSeconds) + << VAR(mc.deltaAgeSec); + mc.lastBytesSent = m->bytesSent; + mc.lastBytesReceived = m->bytesReceived; + mc.lastMemByteSeconds = m->memoryByteSeconds; + mc.lastMeteredAtUs = now; + ++it; + } +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/cesplex/mux.cpp b/vendor/ces/src/ceslib/cesplex/mux.cpp new file mode 100644 index 0000000..ef81266 --- /dev/null +++ b/vendor/ces/src/ceslib/cesplex/mux.cpp @@ -0,0 +1,348 @@ +// mux.cpp — CesPlex implementation +// +// See include/ces/cesplex/mux.h for the design. This file +// implements the CesPlex class: per-session bind-handshake state +// machine, wiring into Rudp's channel-opened + receive callbacks, and +// the hand-off to mounted handlers on OK. + +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +LOG_MODULE("plex"); + +namespace ces { + +// --------------------------------------------------------------------------- +// Session — per-channel state for the select handshake +// --------------------------------------------------------------------------- +// +// Lifecycle: +// 1. Created when CesPlex::acceptInbound fires from the server's +// Rudp::Listener::onAccept. The Session owns a freshly-built +// RudpStream that RUDP wires as the channel handler. +// 2. Read the bind preamble (variable name + fixed-size tail of 137 B): +// [u16 name_len] [name] [u64 client_time_us] +// [32 client_pubkey] [32 sha256] [65 sig] +// 3. Verify sig over recomputed digest using client_pubkey. If +// verify fails or the name's not bound, send a signed NACK reply +// and drop. Otherwise build BoundChannelContext from the verified +// pubkey + the channel's sessionToken. +// 4. Build + write the signed bind reply (status=OK + current rate +// disclosure). On write success, hand the stream + bound context +// to handler->serve(), drop our reference. +// +// Continuations capture `self = shared_from_this()`; sessions_ holds +// a strong ref until handoff or NACK-completion. + +struct CesPlex::Session : std::enable_shared_from_this { + CesPlex& parent; + minx::SockAddr peer; + uint32_t channelId; + + // The RudpStream IS the channel handler in the new RUDP API. + // The Session holds it across the bind handshake; on OK the strong + // ref is moved to the application handler. + std::shared_ptr stream; + + // Bind-preamble read buffers. + std::array lenBuf{}; + ces::Bytes nameBuf; + // Tail = [u64 time_us][32 pubkey][32 sha256][65 sig] = 137 bytes. + std::array tailBuf{}; + + // Outbound bind reply. Held as a member so the async_write has a + // stable buffer address. + minx::Bytes replyBuf; + + Session(CesPlex& p, const minx::SockAddr& pr, uint32_t ch, + std::shared_ptr s) + : parent(p), peer(pr), channelId(ch), stream(std::move(s)) {} + + void start() { + readNameLen(); + } + + void readNameLen() { + auto self = shared_from_this(); + boost::asio::async_read( + *stream, boost::asio::buffer(lenBuf), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->drop("read name length"); return; } + const uint16_t len = ces::Buffer::peek(self->lenBuf.data()); + if (len == 0 || len > CES_PLEX_MAX_NAME_LEN) { + LOGDEBUG << "CesPlex: bad bind name length" + << SVAR(self->peer) << VAR(self->channelId) + << VAR(len); + self->sendNackAndDrop("bad name length"); + return; + } + self->nameBuf.resize(len); + self->readName(); + }); + } + + void readName() { + auto self = shared_from_this(); + boost::asio::async_read( + *stream, boost::asio::buffer(nameBuf), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->drop("read name"); return; } + self->readTail(); + }); + } + + void readTail() { + auto self = shared_from_this(); + boost::asio::async_read( + *stream, boost::asio::buffer(tailBuf), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->drop("read bind tail"); return; } + self->verifyAndDispatch(); + }); + } + + void verifyAndDispatch() { + // Tail layout: [u64 time_us][32 pubkey][32 sha256][65 sig]. + std::span tailSpan(tailBuf.data(), tailBuf.size()); + minx::ConstBuffer reader(tailSpan); + auto clientTimeUs = reader.get(); + auto clientPubkey = reader.get(); + auto claimedSha = reader.get>(); + auto sig = reader.get(); + + // Recompute digest. + const auto& pkBytes = clientPubkey.getHash(); + auto computed = computeBindRequestDigest( + std::span(nameBuf.data(), nameBuf.size()), + clientTimeUs, + std::span(pkBytes.data(), pkBytes.size())); + if (computed != claimedSha) { + LOGDEBUG << "CesPlex: bind digest mismatch" + << SVAR(peer) << VAR(channelId); + sendNackAndDrop("digest mismatch"); + return; + } + + if (!clientPubkey.verifySignature( + std::span(computed.data(), computed.size()), + sig)) { + LOGDEBUG << "CesPlex: bind sig verify FAILED" + << SVAR(peer) << VAR(channelId); + sendNackAndDrop("sig verify failed"); + return; + } + + // Freshness gate: the signed client_time_us is otherwise unconstrained, and + // the request binds no channelId/sessionToken (assigned after bind), so a + // captured bind would replay on fresh channels. Reject stale/future ones. + const uint64_t nowUs = nowMicrosForCesplex(); + if (clientTimeUs > nowUs + CES_PLEX_BIND_FUTURE_DRIFT_US || + clientTimeUs + CES_PLEX_BIND_MAX_AGE_US < nowUs) { + LOGDEBUG << "CesPlex: stale bind time" + << SVAR(peer) << VAR(channelId) + << VAR(clientTimeUs) << VAR(nowUs); + sendNackAndDrop("stale bind time"); + return; + } + + // Lookup handler. + const std::string name( + reinterpret_cast(nameBuf.data()), nameBuf.size()); + auto it = parent.bindings_.find(name); + if (it == parent.bindings_.end()) { + LOGDEBUG << "CesPlex: NACK — no handler" + << SVAR(peer) << VAR(channelId) << SVAR(name); + sendNackAndDrop("unknown protocol"); + return; + } + + LOGDEBUG << "CesPlex: bind OK" + << SVAR(peer) << VAR(channelId) << SVAR(name); + + // Build the BoundChannelContext we'll hand to the application + // handler. Anchored by the RUDP session token. + BoundChannelContext bound; + bound.boundPubkey = clientPubkey; + bound.payerPfx = getHashPrefix(clientPubkey.getHash()); + bound.sessionToken = parent.rudp_.sessionToken(peer, channelId); + + BindReplyFields reply; + reply.status = CES_PLEX_OK; + reply.serverTimeUs = nowMicrosForCesplex(); + reply.channelSessionToken = bound.sessionToken; + reply.serverProtoVersion = CES_PLEX_PROTO_VERSION_V1; + bound.serverBoundAtUs = reply.serverTimeUs; + + // Sign the reply with the host's keypair. + if (!parent.host_) { + // No host (test path) — can't sign. Drop. + LOGWARNING << "CesPlex: no host, cannot sign bind reply"; + drop("no host keypair"); + return; + } + replyBuf = buildBindReply( + reply, + std::span(claimedSha.data(), claimedSha.size()), + parent.host_->cesplexSigningKey()); + + // Begin metering this channel. Each tick measures its resource deltas + // and reports them to the host, which prices them however it likes. + // The host may opt a protocol out (server-to-server peer mesh). + if (parent.channelMeter_ && + (!parent.host_ || parent.host_->cesplexChannelMetered(name))) { + parent.channelMeter_->track( + peer, channelId, "plex:" + name, bound.payerPfx); + } + + sendReplyAndHandOff(it->second, std::move(bound)); + } + + void sendReplyAndHandOff(CesPlexHandler* handler, + BoundChannelContext bound) { + auto self = shared_from_this(); + boost::asio::async_write( + *stream, boost::asio::buffer(replyBuf), + [self, handler, bound = std::move(bound)] + (const boost::system::error_code& ec, std::size_t) mutable { + if (ec) { self->drop("write bind reply"); return; } + // Move the stream to the handler. RUDP keeps its own + // shared_ptr (the stream is the channel handler), so per- + // channel bytes keep flowing. + auto streamToPass = std::move(self->stream); + self->parent.sessions_.erase( + std::make_pair(self->peer, self->channelId)); + handler->serve(std::move(streamToPass), std::move(bound)); + }); + } + + // NACK path: send a signed reply with status=NACK so the client can + // surface a clean PROTO_REJECTED instead of a hang. The reply is + // signed with the server key; clientSha256 is whatever the client + // sent (we still bind to it so the client can verify the reply was + // for their bind attempt). + void sendNackAndDrop(const char* why) { + LOGDEBUG << "CesPlex: sending NACK reply" + << SVAR(peer) << VAR(channelId) << SVAR(why); + if (!parent.host_) { + // Can't sign — just drop without reply. + drop(why); + return; + } + BindReplyFields reply; + reply.status = CES_PLEX_NACK; + reply.serverTimeUs = nowMicrosForCesplex(); + reply.channelSessionToken = parent.rudp_.sessionToken(peer, channelId); + reply.serverProtoVersion = CES_PLEX_PROTO_VERSION_V1; + + // Use the client's claimed sha256 if we got far enough to read + // the tail; otherwise zero. Bind reply digest covers it either + // way; client may not be able to verify a NACK that came before + // it sent its preamble. + std::array clientSha{}; + std::memcpy(clientSha.data(), + tailBuf.data() + sizeof(uint64_t) + CES_PLEX_PUBKEY_SIZE, + CES_PLEX_SHA256_SIZE); + + replyBuf = buildBindReply( + reply, + std::span(clientSha.data(), clientSha.size()), + parent.host_->cesplexSigningKey()); + auto self = shared_from_this(); + boost::asio::async_write( + *stream, boost::asio::buffer(replyBuf), + [self, why](const boost::system::error_code&, std::size_t) { + self->drop(why); + }); + } + + void drop(const char* why) { + LOGTRACE << "CesPlex: dropping session" + << SVAR(peer) << VAR(channelId) << SVAR(why); + // Graceful close: let the in-flight NACK (or whatever the caller + // queued just before drop) drain into Rudp's sendBuf, then fire + // HS_CLOSE within kRudpStreamCloseTimeout. Skipping shutdown() + // here would leave the channel alive until idle GC (60s) — correct, + // but a long stall for a waiting dial client. + if (stream) { + stream->shutdown(kRudpStreamCloseTimeout); + stream.reset(); + } + parent.sessions_.erase(std::make_pair(peer, channelId)); + } + + static uint64_t nowMicrosForCesplex() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + } +}; + +// --------------------------------------------------------------------------- +// CesPlex +// --------------------------------------------------------------------------- + +CesPlex::CesPlex(minx::Rudp& rudp, + boost::asio::io_context& io, + CesPlexHost* host, + ChannelMeter* meter) + : rudp_(rudp), io_(io), host_(host), channelMeter_(meter) { + // No bindings at construction: the host mounts handler objects via mount(). + LOGINFO << "CesPlex: constructed"; +} + +CesPlex::~CesPlex() { + // Sessions that are still mid-handshake just get dropped — their + // streams will close when the last reference goes. The callbacks + // on rpcRudp_ that point at us are cleared by CesServer before + // destruction; this is just defensive cleanup. + sessions_.clear(); +} + +void CesPlex::mount(const std::string& proto, CesPlexHandler* handler) { + if (handler) { + bindings_[proto] = handler; + LOGINFO << "CesPlex: mount instance" << SVAR(proto); + } else { + bindings_.erase(proto); + } +} + +std::shared_ptr CesPlex::acceptInbound( + const minx::SockAddr& peer, uint32_t channelId) { + // Build the per-channel stream first — RUDP wires it as the + // channel handler at registration time and dispatches per-channel + // events (bytes, close) directly to it. The Session uses the + // stream as a regular Asio AsyncStream to drive the select + // handshake. + auto key = std::make_pair(peer, channelId); + if (sessions_.find(key) != sessions_.end()) { + // Duplicate accept for coords we already track: reject so RUDP does not + // wire a second stream over the in-flight one (see the header contract). + LOGDEBUG << "CesPlex: duplicate inbound accept, rejecting" + << SVAR(peer) << VAR(channelId); + return nullptr; + } + auto stream = std::make_shared(io_.get_executor()); + auto session = + std::make_shared(*this, peer, channelId, stream); + sessions_.emplace(key, session); + session->start(); + LOGTRACE << "CesPlex: new inbound channel" + << SVAR(peer) << VAR(channelId); + return stream; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/cesplex/session.cpp b/vendor/ces/src/ceslib/cesplex/session.cpp new file mode 100644 index 0000000..01a9ef5 --- /dev/null +++ b/vendor/ces/src/ceslib/cesplex/session.cpp @@ -0,0 +1,883 @@ +// session.cpp — the per-op CesPlex layer for handlers and clients (see mux.h). +// +// Two halves: the server-side signed-request loop (cesPlexServe / +// CesPlexRequest) that builtin:file and builtin:compute drive their verb +// streams through, and the client-side CesPlexChannel (the protocol, over an +// injected transport) that CesPlexClient and the compute child's endpoint both +// compose, and that CesFileClient / CesComputeClient are thin verb wrappers +// over. + +#include +#include // kRudpStreamCloseTimeout, CesPlexHost +#include +#include // ces::sha256 +#include +#include // runGuardedThread + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +LOG_MODULE("plex"); + +namespace ces { + +// =========================================================================== +// Server side — signed-request loop +// =========================================================================== + +namespace { + +void plexReadVerb(std::shared_ptr stream, + BoundChannelContext bound, CesPlexHost* host, + std::shared_ptr proto); + +// Read [u32 preamble_len][preamble][65 sig], verify, peel reqNonce, +// dispatch. On any wire error the chain just stops (the channel closes +// when its captured stream refs release). +void plexReadEnvelope(std::shared_ptr req) { + auto self = req; + auto lenBuf = std::make_shared>(); + boost::asio::async_read( + *req->stream, boost::asio::buffer(*lenBuf), + [self, lenBuf](const boost::system::error_code& ec, std::size_t) { + if (ec) return; + uint32_t preLen = ces::Buffer::peek(lenBuf->data()); + if (preLen == 0 || preLen > 4096) { + // Envelope length out of bounds — caller wire bug, BAD_INPUT. + self->error(CES_ERROR_BAD_INPUT); + return; + } + auto preBuf = std::make_shared(preLen); + boost::asio::async_read( + *self->stream, boost::asio::buffer(*preBuf), + [self, preBuf](const boost::system::error_code& e2, std::size_t) { + if (e2) return; + auto tail = + std::make_shared>(); + boost::asio::async_read( + *self->stream, boost::asio::buffer(*tail), + [self, preBuf, tail] + (const boost::system::error_code& e3, std::size_t) { + if (e3) return; + std::memcpy(self->sig.data(), tail->data(), CES_PLEX_SIG_SIZE); + self->reqSigHash = ces::sigDedupHash(self->sig); + if (!ces::verifyPerOp( + self->bound, self->verb, + std::span(preBuf->data(), preBuf->size()), + self->sig)) { + // Per-op sig didn't verify against the bound pubkey. + self->error(CES_ERROR_BAD_INPUT); + return; + } + // Preamble = [8B per-op salt][4B reqNonce][verb args]. The + // salt is a client uniquifier folded into the sig (so a + // repeated op doesn't collide in dedup); the server skips it. + if (preBuf->size() < sizeof(uint64_t) + sizeof(uint32_t)) { + self->error(CES_ERROR_BAD_INPUT); + return; + } + self->reqNonce = ces::Buffer::peek( + preBuf->data() + sizeof(uint64_t)); + ces::Bytes preRest( + preBuf->begin() + sizeof(uint64_t) + sizeof(uint32_t), + preBuf->end()); + self->proto->dispatch(self, std::move(preRest)); + }); + }); + }); +} + +// Read one verb byte; if the handler accepts it, build a request and +// read its envelope. An unaccepted verb (unknown, or handler unbound — +// accepts() folds in the bound check) ends the channel. +void plexReadVerb(std::shared_ptr stream, + BoundChannelContext bound, CesPlexHost* host, + std::shared_ptr proto) { + auto verbBuf = std::make_shared>(); + auto sharedStream = stream; + auto sharedBound = std::make_shared(std::move(bound)); + boost::asio::async_read( + *sharedStream, boost::asio::buffer(*verbBuf), + [verbBuf, sharedStream, host, sharedBound, proto] + (const boost::system::error_code& ec, std::size_t) { + if (ec) return; + uint8_t verb = (*verbBuf)[0]; + if (!proto->accepts || !proto->accepts(verb)) { + LOGDEBUG << "cesplex: unaccepted verb; dropping channel" + << VAR(int(verb)); + return; // drop — stream closes when captured refs release + } + auto req = std::make_shared(); + req->stream = sharedStream; + req->host = host; + req->verb = verb; + req->bound = *sharedBound; + req->proto = proto; + plexReadEnvelope(req); + }); +} + +} // namespace + +void cesPlexServe(std::shared_ptr stream, + BoundChannelContext bound, CesPlexHost* host, + CesPlexProtocol proto) { + plexReadVerb(std::move(stream), std::move(bound), host, + std::make_shared(std::move(proto))); +} + +void CesPlexRequest::respond(uint8_t status, ces::Bytes preamble, + ces::Bytes extraBody) { + auto str = stream; + auto bnd = bound; + auto h = host; + auto prt = proto; + auto env = std::make_shared( + buildPerOpResponse(h->cesplexSigningKey(), verb, status, + preamble, reqSigHash)); + auto body = std::make_shared(std::move(extraBody)); + boost::asio::async_write( + *str, boost::asio::buffer(*env), + [str, env, body, bnd, h, prt] + (const boost::system::error_code& ec, std::size_t) mutable { + if (ec) return; + if (!body->empty()) { + boost::asio::async_write( + *str, boost::asio::buffer(*body), + [str, body, bnd, h, prt] + (const boost::system::error_code& ec2, std::size_t) mutable { + if (ec2) return; + plexReadVerb(str, std::move(bnd), h, prt); + }); + } else { + plexReadVerb(str, std::move(bnd), h, prt); + } + }); +} + +void CesPlexRequest::respondAndClose(uint8_t status, ces::Bytes preamble) { + auto str = stream; + auto env = std::make_shared( + buildPerOpResponse(host->cesplexSigningKey(), verb, status, + preamble, reqSigHash)); + boost::asio::async_write( + *str, boost::asio::buffer(*env), + [str, env](const boost::system::error_code&, std::size_t) { + // Graceful close after the error reply drains into Rudp's sendBuf. + str->shutdown(kRudpStreamCloseTimeout); + }); + stream.reset(); +} + +// =========================================================================== +// Client side — CesPlexChannel (protocol) + CesPlexClient (owned transport) +// =========================================================================== + +namespace { + +constexpr auto kVerbTimeout = std::chrono::seconds(60); +constexpr auto kConnectTimeout = std::chrono::seconds(10); + +uint64_t nowMicros() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +class NoopMinxListener : public minx::MinxListener {}; + +// Outbound-only Rudp::Listener: forwards onSend to the local Minx, +// rejects any inbound HS_OPEN. +class PlexClientRudpListener : public minx::Rudp::Listener { +public: + void setMinx(minx::Minx* m) { minx_ = m; } + void onSend(const minx::SockAddr& peer, + const minx::Bytes& bytes) override { + if (!minx_) return; + try { + minx_->sendExtension(peer, bytes); + } catch (const std::exception&) { + // Socket already closed during teardown. + } + } +private: + minx::Minx* minx_ = nullptr; +}; + +} // namespace + +// --------------------------------------------------------------------------- +// CesPlexChannel::Impl — the per-channel CesPlex client PROTOCOL over an +// INJECTED transport. Owns no mechanics: it borrows a task io_context (where +// its async stream ops run) and a Rudp (where it opens its channel). The bind +// handshake, per-op envelope signing, and the verb drive loop all live here, +// once — so CesPlexClient (owned Minx/Rudp/threads) and the compute child's +// CesPlex endpoint (an already-running Rudp) share one client codec. +// +// Threading: the borrowed taskIO_ must be run by some OTHER thread; every verb +// posts its async I/O there and blocks the CALLING thread on a future (caller +// thread must differ from the taskIO_ thread, else deadlock). +// --------------------------------------------------------------------------- +class CesPlexChannel::Impl { +public: + Impl(boost::asio::io_context& taskIO, minx::Rudp* rudp) + : taskIO_(taskIO), rudp_(rudp) {} + + // Open a fresh channel to `peer` and run the signed bind for `protocol`, + // signed by `signerKey` (also the per-op signer + billed principal). + uint8_t select(const minx::SockAddr& peer, const std::string& protocol, + const KeyPair& signerKey) { + peer_ = peer; + protocol_ = protocol; + signerKey_ = std::make_unique(signerKey); + std::mt19937 rng(std::random_device{}()); + channel_ = 0; + while (channel_ == 0) channel_ = rng(); + return doSelect(); + } + + void reset() { stream_.reset(); } + + void setServerPubkey(const minx::Hash& pk) { + serverPk_ = pk; + hasServerPk_ = true; + } + + const KeyPair& signerKey() const { return *signerKey_; } + uint64_t boundSessionToken() const { return boundSessionToken_; } + + // Reopen the channel with a fresh channel_id + select handshake, used after + // the server closes the channel following an error. The old channel's + // per-peer slot is freed in doSelect (RST) before the new bind: Rudp owns the + // channel handler, so resetting our stream does not release it -- the old + // channel lingers ESTABLISHED and would hit rpcRudpMaxChannelsPerPeer. + // Closing it is deterministic; waiting on the peer's HS_CLOSE to free the + // slot is not (its arrival is RTT-bound, unbounded under loss). + uint8_t reselect() { + closeBeforeSelect_ = channel_; + stream_.reset(); + std::mt19937 rng(std::random_device{}()); + channel_ = 0; + while (channel_ == 0) channel_ = rng(); + return doSelect(); + } + + void markDirty() { streamDirty_ = true; } + + uint8_t ensureClean() { + if (!streamDirty_) return CES_OK; + streamDirty_ = false; + return reselect(); + } + + // Read + verify the server-signed response trailer. Hash/sig failures + // are advisory (logged, returns true); returns false only on read error. + bool readAndVerifyTail(uint8_t status, uint8_t verb, + const ces::Bytes& preamble) { + if (!stream_) return false; + // Heap-owned so a late read after a wait_for timeout can't write into + // a freed stack buffer (the async read isn't cancelled on timeout). + auto tail = + std::make_shared>(); + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto strm = stream_; + boost::asio::post(taskIO_, [strm, tail, run]() mutable { + boost::asio::async_read( + *strm, boost::asio::buffer(*tail), + [run, tail](const boost::system::error_code& ec, std::size_t) { + run->set_value(!ec); + }); + }); + if (fut.wait_for(kVerbTimeout) != std::future_status::ready) return false; + if (!fut.get()) return false; + + // Trailer: [u64 time_us][u64 req_sig_hash][sha256 digest][sig]. + constexpr size_t kReqSigHashOff = ces::CES_PLEX_TIME_US_SIZE; + constexpr size_t kDigestOff = + kReqSigHashOff + ces::CES_PLEX_REQ_SIG_HASH_SIZE; + constexpr size_t kSigOff = kDigestOff + ces::CES_PLEX_SHA256_SIZE; + + uint64_t timeUs = ces::Buffer::peek(tail->data()); + std::array reqSigHash{}; + std::memcpy(reqSigHash.data(), tail->data() + kReqSigHashOff, + reqSigHash.size()); + std::array claimedHash{}; + std::memcpy(claimedHash.data(), tail->data() + kDigestOff, + claimedHash.size()); + Signature sig{}; + std::memcpy(sig.data(), tail->data() + kSigOff, sig.size()); + + ces::Buffer hashIn(ces::CES_PLEX_STATUS_SIZE + ces::CES_PLEX_VERB_SIZE + + preamble.size() + ces::CES_PLEX_TIME_US_SIZE + + ces::CES_PLEX_REQ_SIG_HASH_SIZE); + hashIn.put(status) + .put(verb) + .putBytes(std::span(preamble)) + .put(timeUs) + .putBytes(std::span( + reqSigHash.data(), reqSigHash.size())); + minx::Hash computed = ces::sha256(hashIn.data(), hashIn.size()); + + if (std::memcmp(computed.data(), claimedHash.data(), + claimedHash.size()) != 0) { + LOGERROR << "cesplexclient: response hash mismatch"; + return true; + } + if (!hasServerPk_) { + LOGERROR + << "cesplexclient: server pubkey not set; response sig unverified"; + return true; + } + PublicKey pk(serverPk_); + if (!pk.verifySignature( + std::span(computed.data(), computed.size()), sig)) { + LOGERROR << "cesplexclient: response sig verification FAILED"; + } + return true; + } + + // Blocking write posted on taskIO_. Two overloads: minx::Bytes for the + // bind-bounded envelopes (≤1280), ces::Bytes for larger payloads. + bool writeAll(const minx::Bytes& bytes) { + return writeAllImpl(std::make_shared(bytes)); + } + bool writeAll(const ces::Bytes& bytes) { + return writeAllImpl(std::make_shared(bytes)); + } + + bool readExact(ces::Bytes& out, size_t n) { + if (!stream_) return false; + // Heap-owned (see readAndVerifyTail): survives a post-timeout late read. + auto buf = std::make_shared(n); + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto strm = stream_; + boost::asio::post(taskIO_, + [strm, buf, run]() mutable { + boost::asio::async_read( + *strm, boost::asio::buffer(*buf), + [run, buf](const boost::system::error_code& ec, std::size_t) { + run->set_value(!ec); + }); + }); + if (fut.wait_for(kVerbTimeout) != std::future_status::ready) return false; + if (!fut.get()) return false; + out = std::move(*buf); + return true; + } + + minx::Bytes buildEnvelope(uint8_t verb, + std::span preamble) { + // Prepend an 8-byte per-op salt so two ops with identical (verb, + // preamble) still sign differently — otherwise the server's sig-dedup + // treats a repeated query as a replay and skips its fee. The salt is + // framework header (the server strips it); monotonic per client is + // enough, since the sig also covers the per-channel sessionToken. + ces::Bytes signedPre; + signedPre.reserve(sizeof(uint64_t) + preamble.size()); + ces::Buffer::put(signedPre, opSalt_++); + signedPre.insert(signedPre.end(), preamble.begin(), preamble.end()); + std::span sp(signedPre.data(), signedPre.size()); + + Signature sig = ces::signPerOp(*signerKey_, verb, sp, boundSessionToken_); + // [u32 len][salt+preamble][65 sig] + const size_t total = 4 + signedPre.size() + ces::CES_PLEX_SIG_SIZE; + minx::Bytes wire(total); + minx::Buffer buf(wire); + buf.put(static_cast(signedPre.size())); + buf.put(sp); + buf.put(sig); + return wire; + } + + uint8_t driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + const std::function& respBodyLen, + const ces::Bytes& extraBodyToSend, + ces::Bytes& outPreamble, + ces::Bytes& outBody) { + if (ensureClean() != CES_OK) return CES_ERROR_INTERNAL; + + auto fail = [&](uint8_t rc) -> uint8_t { + markDirty(); + return rc; + }; + + ces::Bytes verbByte{verb}; + if (!writeAll(verbByte)) return fail(CES_ERROR_INTERNAL); + if (!writeAll(envelope)) return fail(CES_ERROR_INTERNAL); + if (!extraBodyToSend.empty() && !writeAll(extraBodyToSend)) + return fail(CES_ERROR_INTERNAL); + + ces::Bytes statusBuf; + if (!readExact(statusBuf, 1)) return fail(CES_ERROR_INTERNAL); + uint8_t status = statusBuf[0]; + + outPreamble.clear(); + outBody.clear(); + if (status == CES_OK) { + if (respFixedPreambleLen > 0 && + !readExact(outPreamble, respFixedPreambleLen)) + return fail(CES_ERROR_INTERNAL); + if (readVariablePreamble && !readVariablePreamble(outPreamble)) + return fail(CES_ERROR_INTERNAL); + } + + if (!readAndVerifyTail(status, verb, outPreamble)) + return fail(CES_ERROR_INTERNAL); + + if (status != CES_OK) return status; + + if (respBodyLen) { + uint64_t bodyLen = respBodyLen(outPreamble); + if (bodyLen > 0 && !readExact(outBody, bodyLen)) + return fail(CES_ERROR_INTERNAL); + } + return CES_OK; + } + +private: + bool writeAllImpl(std::shared_ptr buf) { + if (!stream_) return false; + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto strm = stream_; + boost::asio::post(taskIO_, [strm, buf, run]() mutable { + boost::asio::async_write( + *strm, boost::asio::buffer(*buf), + [run, buf](const boost::system::error_code& ec, std::size_t) { + run->set_value(!ec); + }); + }); + if (fut.wait_for(kVerbTimeout) != std::future_status::ready) return false; + return fut.get(); + } + bool writeAllImpl(std::shared_ptr buf) { + if (!stream_) return false; + auto run = std::make_shared>(); + auto fut = run->get_future(); + auto strm = stream_; + boost::asio::post(taskIO_, [strm, buf, run]() mutable { + boost::asio::async_write( + *strm, boost::asio::buffer(*buf), + [run, buf](const boost::system::error_code& ec, std::size_t) { + run->set_value(!ec); + }); + }); + if (fut.wait_for(kVerbTimeout) != std::future_status::ready) return false; + return fut.get(); + } + + // Parse + verify the signed bind reply. On OK, stash sessionToken and + // TOFU-capture the server pubkey if not already pinned. + uint8_t parseBindReply( + const std::array& buf, + const std::array& clientDigest) { + auto r = ces::parseBindReply( + std::span( + buf.data(), buf.size())); + if (r.status != ces::CES_PLEX_OK) return CES_ERROR_PROTO_REJECTED; + if (!ces::verifyBindReply( + r, + std::span(clientDigest.data(), clientDigest.size()))) { + LOGERROR << "cesplexclient: bind reply digest/sig verify FAILED"; + return CES_ERROR_INTERNAL; + } + if (hasServerPk_) { + if (std::memcmp(serverPk_.data(), r.serverPubkey.data(), + serverPk_.size()) != 0) { + LOGERROR << "cesplexclient: bind reply pubkey ≠ expected"; + return CES_ERROR_INTERNAL; + } + } else { + std::memcpy(serverPk_.data(), r.serverPubkey.data(), serverPk_.size()); + hasServerPk_ = true; + } + boundSessionToken_ = r.channelSessionToken; + return CES_OK; + } + + uint8_t doSelect() { + auto run = std::make_shared>(); + auto fut = run->get_future(); + boost::asio::post(taskIO_, [this, run]() { + // Seed Rudp's clock before registerChannel so the fresh channel + // isn't idle-GC'd on the next tick. + rudp_->tick(nowMicros()); + // Free a prior (errored) channel's per-peer slot before binding the new + // one, ordered before registerChannel on this same IO thread. RST frees + // the slot now and tells the peer to drop its side (a no-op if the peer + // already closed). Skipped on the initial select (closeBeforeSelect_==0). + if (closeBeforeSelect_ != 0) { + rudp_->closeChannel(peer_, closeBeforeSelect_); + closeBeforeSelect_ = 0; + } + stream_ = std::make_shared(taskIO_.get_executor()); + if (!rudp_->registerChannel(peer_, channel_, stream_)) { + run->set_value(CES_ERROR_INTERNAL); + return; + } + + const uint64_t bindNowUs = nowMicros(); + auto bindReq = std::make_shared( + ces::buildBindRequest(protocol_, bindNowUs, *signerKey_)); + const auto& pkArr = signerKey_->getPublicKeyAsHash(); + auto clientDigest = std::make_shared< + std::array>( + ces::computeBindRequestDigest( + std::span( + reinterpret_cast(protocol_.data()), + protocol_.size()), + bindNowUs, + std::span(pkArr.data(), pkArr.size()))); + + boost::asio::async_write( + *stream_, boost::asio::buffer(*bindReq), + [this, bindReq, clientDigest, run] + (const boost::system::error_code& ec, std::size_t) { + if (ec) { run->set_value(CES_ERROR_INTERNAL); return; } + auto reply = std::make_shared< + std::array>(); + boost::asio::async_read( + *stream_, boost::asio::buffer(*reply), + [this, reply, clientDigest, run] + (const boost::system::error_code& ec2, std::size_t) { + if (ec2) { run->set_value(CES_ERROR_INTERNAL); return; } + run->set_value(parseBindReply(*reply, *clientDigest)); + }); + }); + }); + if (fut.wait_for(kConnectTimeout) != std::future_status::ready) { + LOGERROR << "cesplexclient: bind handshake timed out"; + return CES_ERROR_INTERNAL; + } + uint8_t rc = fut.get(); + LOGDEBUG << "cesplexclient: bind complete" + << VAR(int(rc)) << VAR(channel_) << SVAR(protocol_); + return rc; + } + + boost::asio::io_context& taskIO_; // borrowed: run by another thread + minx::Rudp* rudp_; // borrowed: where we open our channel + + std::string protocol_; + minx::SockAddr peer_; + uint32_t channel_ = 0; + std::shared_ptr stream_; + + bool hasServerPk_ = false; + minx::Hash serverPk_{}; + + std::unique_ptr signerKey_; + uint64_t boundSessionToken_ = 0; + uint64_t opSalt_ = 1; // per-op envelope uniquifier (see buildEnvelope) + + bool streamDirty_ = false; + uint32_t closeBeforeSelect_ = 0; // old channel to RST in doSelect before rebind +}; + +// ---- CesPlexChannel public forwards ---- + +CesPlexChannel::CesPlexChannel(boost::asio::io_context& taskIO, + minx::Rudp* rudp) + : impl_(std::make_unique(taskIO, rudp)) {} +CesPlexChannel::~CesPlexChannel() = default; + +uint8_t CesPlexChannel::select(const minx::SockAddr& peer, + const std::string& protocol, + const KeyPair& signerKey) { + return impl_->select(peer, protocol, signerKey); +} + +void CesPlexChannel::reset() { impl_->reset(); } + +void CesPlexChannel::setServerPubkey(const minx::Hash& pk) { + impl_->setServerPubkey(pk); +} + +uint64_t CesPlexChannel::boundSessionToken() const { + return impl_->boundSessionToken(); +} + +minx::Bytes CesPlexChannel::buildEnvelope( + uint8_t verb, std::span preamble) { + return impl_->buildEnvelope(verb, preamble); +} + +uint8_t CesPlexChannel::driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + const std::function& respBodyLen, + const ces::Bytes& extraBodyToSend, + ces::Bytes& outPreamble, + ces::Bytes& outBody) { + return impl_->driveVerb(verb, envelope, respFixedPreambleLen, + readVariablePreamble, respBodyLen, + extraBodyToSend, outPreamble, outBody); +} + +uint8_t CesPlexChannel::driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + ces::Bytes& outPreamble) { + ces::Bytes dummyBody; + return impl_->driveVerb(verb, envelope, respFixedPreambleLen, + readVariablePreamble, nullptr, {}, outPreamble, + dummyBody); +} + +bool CesPlexChannel::readExact(ces::Bytes& out, size_t n) { + return impl_->readExact(out, n); +} + +// --------------------------------------------------------------------------- +// CesPlexClient::Impl — owned mechanics (Minx + Rudp + two io threads + tick) +// composing one CesPlexChannel over them. This is cesh's / the tests' client; +// a process that already runs a Rudp builds a CesPlexChannel directly instead. +// --------------------------------------------------------------------------- +class CesPlexClient::Impl { +public: + Impl() : listener_(std::make_unique()) {} + ~Impl() { stop(); } + + uint8_t connect(const std::string& host, uint16_t rpcPort, + const std::string& protocol, const KeyPair& signerKey) { + boost::system::error_code ec; + boost::asio::io_context ioc; + boost::asio::ip::udp::resolver res(ioc); + boost::asio::ip::address addr; + auto results = res.resolve(host, std::to_string(rpcPort), ec); + if (ec || results.empty()) { + LOGERROR << "cesplexclient: resolve failed" + << SVAR(host) << SVAR(ec.message()); + return CES_ERROR_INTERNAL; + } + addr = results.begin()->endpoint().address(); + if (addr.is_v4()) { + // Normalize v4 to v4-mapped-v6 so the server's v6 SockAddr keys match. + addr = boost::asio::ip::make_address_v6( + boost::asio::ip::v4_mapped, addr.to_v4()); + } + minx::SockAddr peer(addr, rpcPort); + + minx::MinxConfig mc{}; + mc.instanceName = "plexc"; + mc.randomXVMsToKeep = 0; + mc.randomXInitThreads = 0; + mc.trustLoopback = true; + minx_ = std::make_unique(listener_.get(), mc); + + // Margin for reselect() racing an old channel's teardown; tight tick + // cadence (1 ms) so bulk WRITE/READ aren't throttled by RUDP's pulse. + minx::RudpConfig rcfg{}; + rcfg.maxChannelsPerPeer = 8; + rcfg.baseTickInterval = std::chrono::milliseconds(1); + rudpListener_.setMinx(minx_.get()); + rudp_ = std::make_unique(&rudpListener_, rcfg); + + { + minx::MinxStdExtensions stdExt; + stdExt.registerExtension( + minx::Rudp::KEY_V0, + [this](const minx::SockAddr& p, uint64_t key, + const minx::Bytes& payload) { + if (rudp_) rudp_->onPacket(p, key, payload, nowMicros()); + }); + minx_->setExtensionHandler(std::move(stdExt).build()); + } + + boundPort_ = minx_->openSocket( + boost::asio::ip::address_v6::any(), 0, netIO_, taskIO_); + if (boundPort_ == 0) { + LOGERROR << "cesplexclient: failed to open local UDP socket"; + return CES_ERROR_INTERNAL; + } + + netGuard_ = std::make_unique(netIO_.get_executor()); + taskGuard_ = std::make_unique(taskIO_.get_executor()); + netThread_ = std::thread( + [this]() { runGuardedThread([this]{ netIO_.run(); }, "cesplexClientNetIO"); }); + taskThread_ = std::thread( + [this]() { runGuardedThread([this]{ taskIO_.run(); }, "cesplexClientTaskIO"); }); + + tickTimer_ = std::make_shared(taskIO_); + boost::asio::post(taskIO_, [this]() { scheduleTick(); }); + + // The protocol driver rides our owned transport. + chan_ = std::make_unique(taskIO_, rudp_.get()); + if (hasPendingServerPk_) chan_->setServerPubkey(pendingServerPk_); + return chan_->select(peer, protocol, signerKey); + } + + void stop() { + if (!minx_) return; + if (tickTimer_) { + boost::system::error_code ec; + tickTimer_->cancel(); + } + minx_->closeSocket(false); + if (netGuard_) netGuard_->reset(); + if (taskGuard_) taskGuard_->reset(); + netIO_.stop(); + taskIO_.stop(); + if (netThread_.joinable()) netThread_.join(); + if (taskThread_.joinable()) taskThread_.join(); + chan_.reset(); + tickTimer_.reset(); + rudp_.reset(); + minx_.reset(); + rudpListener_.setMinx(nullptr); + netGuard_.reset(); + taskGuard_.reset(); + boundPort_ = 0; + } + + CesPlexChannel* channel() { return chan_.get(); } + + void setServerPubkey(const minx::Hash& pk) { + // Usually called BEFORE connect() — chan_ isn't built yet. Remember it + // and pin it onto the channel at connect time, so the bind reply is + // verified against the caller's expected pubkey instead of TOFU'd. + pendingServerPk_ = pk; + hasPendingServerPk_ = true; + if (chan_) chan_->setServerPubkey(pk); + } + minx::Bytes buildEnvelope(uint8_t verb, std::span preamble) { + return chan_ ? chan_->buildEnvelope(verb, preamble) : minx::Bytes{}; + } + uint8_t driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + const std::function& respBodyLen, + const ces::Bytes& extraBodyToSend, + ces::Bytes& outPreamble, + ces::Bytes& outBody) { + if (!chan_) return CES_ERROR_INTERNAL; + return chan_->driveVerb(verb, envelope, respFixedPreambleLen, + readVariablePreamble, respBodyLen, + extraBodyToSend, outPreamble, outBody); + } + bool readExact(ces::Bytes& out, size_t n) { + return chan_ && chan_->readExact(out, n); + } + +private: + using WorkGuard = boost::asio::executor_work_guard< + boost::asio::io_context::executor_type>; + + void scheduleTick() { + if (!tickTimer_ || !rudp_) return; + tickTimer_->expires_after(std::chrono::milliseconds(10)); + tickTimer_->async_wait( + [this](const boost::system::error_code& ec) { + if (ec || !rudp_) return; + rudp_->tick(nowMicros()); + scheduleTick(); + }); + } + + std::unique_ptr listener_; + PlexClientRudpListener rudpListener_; + std::unique_ptr minx_; + std::unique_ptr rudp_; + boost::asio::io_context netIO_; + boost::asio::io_context taskIO_; + std::unique_ptr netGuard_; + std::unique_ptr taskGuard_; + std::thread netThread_; + std::thread taskThread_; + std::shared_ptr tickTimer_; + uint16_t boundPort_ = 0; + bool hasPendingServerPk_ = false; + minx::Hash pendingServerPk_{}; + // Declared last → destroyed first, before the taskIO_/rudp_ it borrows. + std::unique_ptr chan_; +}; + +// ---- CesPlexClient public forwards ---- + +CesPlexClient::CesPlexClient() : impl_(std::make_unique()) {} +CesPlexClient::~CesPlexClient() = default; + +uint8_t CesPlexClient::connect(const std::string& host, uint16_t rpcPort, + const std::string& protocol, + const KeyPair& signerKey) { + return impl_->connect(host, rpcPort, protocol, signerKey); +} + +void CesPlexClient::disconnect() { impl_->stop(); } + +CesPlexChannel* CesPlexClient::channel() { return impl_->channel(); } + +void CesPlexClient::setServerPubkey(const minx::Hash& pk) { + impl_->setServerPubkey(pk); +} + +minx::Bytes CesPlexClient::buildEnvelope( + uint8_t verb, std::span preamble) { + return impl_->buildEnvelope(verb, preamble); +} + +uint8_t CesPlexClient::driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + const std::function& respBodyLen, + const ces::Bytes& extraBodyToSend, + ces::Bytes& outPreamble, + ces::Bytes& outBody) { + return impl_->driveVerb(verb, envelope, respFixedPreambleLen, + readVariablePreamble, respBodyLen, + extraBodyToSend, outPreamble, outBody); +} + +uint8_t CesPlexClient::driveVerb( + uint8_t verb, + const minx::Bytes& envelope, + size_t respFixedPreambleLen, + const std::function& readVariablePreamble, + ces::Bytes& outPreamble) { + ces::Bytes dummyBody; + return impl_->driveVerb(verb, envelope, respFixedPreambleLen, + readVariablePreamble, nullptr, {}, outPreamble, + dummyBody); +} + +bool CesPlexClient::readExact(ces::Bytes& out, size_t n) { + return impl_->readExact(out, n); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/cesplex/wire.cpp b/vendor/ces/src/ceslib/cesplex/wire.cpp new file mode 100644 index 0000000..82da52e --- /dev/null +++ b/vendor/ces/src/ceslib/cesplex/wire.cpp @@ -0,0 +1,250 @@ +// wire.cpp — implementation of the bind contract + per-op +// envelope helpers. See include/ces/cesplex/wire.h for the design. +// +// All wire serialization goes through ces::Buffer / minx::Buffer / +// logkv::serializer — no hand-rolled BE shifts here. + +#include +#include +#include + +#include + +#include + +namespace ces { + +// --------------------------------------------------------------------------- +// Bind preamble — client side +// --------------------------------------------------------------------------- + +std::array +computeBindRequestDigest(std::span name, + uint64_t clientTimeUs, + std::span clientPubkey) { + CryptoPP::SHA256 h; + ces::shaUpdate(h, static_cast(name.size())); + if (!name.empty()) h.Update(name.data(), name.size()); + ces::shaUpdate(h, clientTimeUs); + h.Update(clientPubkey.data(), clientPubkey.size()); + std::array out; + h.Final(out.data()); + return out; +} + +minx::Bytes buildBindRequest(const std::string& name, + uint64_t clientTimeUs, + const KeyPair& clientKey) { + const auto& pkArr = clientKey.getPublicKeyAsHash(); + std::span nameSpan( + reinterpret_cast(name.data()), name.size()); + std::span pkSpan(pkArr.data(), pkArr.size()); + auto digest = computeBindRequestDigest(nameSpan, clientTimeUs, pkSpan); + + Signature sig = clientKey.signData( + std::span(digest.data(), digest.size())); + + const size_t total = + CES_PLEX_NAME_LEN_SIZE + name.size() + CES_PLEX_BIND_REQ_TAIL_SIZE; + minx::Bytes bytes(total); + minx::Buffer buf(bytes); + buf.put(static_cast(name.size())); + buf.put(nameSpan); + buf.put(clientTimeUs); + buf.put(pkArr); + buf.put(digest); + buf.put(sig); + return bytes; +} + +minx::Bytes buildBindRequestSigned(const std::string& name, + uint64_t clientTimeUs, + std::span clientPubkey, + const Signature& sig) { + std::span nameSpan( + reinterpret_cast(name.data()), name.size()); + auto digest = computeBindRequestDigest(nameSpan, clientTimeUs, clientPubkey); + + const size_t total = + CES_PLEX_NAME_LEN_SIZE + name.size() + CES_PLEX_BIND_REQ_TAIL_SIZE; + minx::Bytes bytes(total); + minx::Buffer buf(bytes); + buf.put(static_cast(name.size())); + buf.put(nameSpan); + buf.put(clientTimeUs); + buf.put(clientPubkey); + buf.put(digest); + buf.put(sig); + return bytes; +} + +// --------------------------------------------------------------------------- +// Bind reply — server side +// --------------------------------------------------------------------------- + +namespace { + +// Build the bytes-to-be-hashed for the reply digest: +// status || clientSha256 || serverTimeUs || serverPubkey || +// channelSessionToken || serverProtoVersion. +std::array +computeBindReplyDigestRaw(uint8_t status, + std::span clientSha256, + uint64_t serverTimeUs, + std::span serverPubkey, + uint64_t channelSessionToken, + uint32_t serverProtoVersion) { + CryptoPP::SHA256 h; + h.Update(&status, 1); + h.Update(clientSha256.data(), clientSha256.size()); + ces::shaUpdate(h, serverTimeUs); + h.Update(serverPubkey.data(), serverPubkey.size()); + ces::shaUpdate(h, channelSessionToken); + ces::shaUpdate(h, serverProtoVersion); + std::array out; + h.Final(out.data()); + return out; +} + +} // namespace + +std::array +computeBindReplyDigest(const ParsedBindReply& reply, + std::span clientSha256) { + std::span pkSpan( + reply.serverPubkey.data(), reply.serverPubkey.size()); + return computeBindReplyDigestRaw( + reply.status, clientSha256, reply.serverTimeUs, pkSpan, + reply.channelSessionToken, reply.serverProtoVersion); +} + +minx::Bytes buildBindReply(const BindReplyFields& fields, + std::span clientSha256, + const KeyPair& serverKey) { + const auto& pkArr = serverKey.getPublicKeyAsHash(); + std::span pkSpan(pkArr.data(), pkArr.size()); + auto digest = computeBindReplyDigestRaw( + fields.status, clientSha256, fields.serverTimeUs, pkSpan, + fields.channelSessionToken, fields.serverProtoVersion); + + Signature sig = serverKey.signData( + std::span(digest.data(), digest.size())); + + minx::Bytes bytes(CES_PLEX_BIND_REPLY_TOTAL_SIZE); + minx::Buffer buf(bytes); + buf.put(fields.status); + buf.put(fields.serverTimeUs); + buf.put(pkArr); + buf.put(fields.channelSessionToken); + buf.put(fields.serverProtoVersion); + buf.put(digest); + buf.put(sig); + return bytes; +} + +ParsedBindReply parseBindReply( + std::span buf) { + ParsedBindReply r; + std::span bufSpan(buf); + minx::ConstBuffer reader(bufSpan); + reader.get(r.status); + reader.get(r.serverTimeUs); + reader.get(r.serverPubkey); + reader.get(r.channelSessionToken); + reader.get(r.serverProtoVersion); + reader.get(r.sha256); + reader.get(r.sig); + return r; +} + +bool verifyBindReply(const ParsedBindReply& reply, + std::span clientSha256) { + auto computed = computeBindReplyDigest(reply, clientSha256); + if (computed != reply.sha256) return false; + minx::Hash pkHash; + std::memcpy(pkHash.data(), reply.serverPubkey.data(), pkHash.size()); + PublicKey serverPk(pkHash); + return serverPk.verifySignature( + std::span(computed.data(), computed.size()), + reply.sig); +} + +// --------------------------------------------------------------------------- +// Per-op envelope +// --------------------------------------------------------------------------- + +std::array +computePerOpDigest(uint8_t verb, + std::span preamble, + uint64_t sessionToken) { + CryptoPP::SHA256 h; + h.Update(&verb, 1); + if (!preamble.empty()) h.Update(preamble.data(), preamble.size()); + ces::shaUpdate(h, sessionToken); + std::array out; + h.Final(out.data()); + return out; +} + +Signature signPerOp(const KeyPair& signer, + uint8_t verb, + std::span preamble, + uint64_t sessionToken) { + auto digest = computePerOpDigest(verb, preamble, sessionToken); + return signer.signData( + std::span(digest.data(), digest.size())); +} + +bool verifyPerOp(const BoundChannelContext& bound, + uint8_t verb, + std::span preamble, + const Signature& sig) { + auto digest = computePerOpDigest(verb, preamble, bound.sessionToken); + return bound.boundPubkey.verifySignature( + std::span(digest.data(), digest.size()), sig); +} + +uint64_t sigDedupHash(const Signature& sig) { + // Skip the 1-byte algorithm decorator at sig[0] and read sig[1..8] + // as a big-endian u64. + return ces::Buffer::peek( + std::span(sig.data() + 1, 8), 0); +} + +ces::Bytes buildPerOpResponse(const KeyPair& serverKey, + uint8_t verb, + uint8_t status, + std::span preamble, + uint64_t reqSigHash) { + const uint64_t timeUs = getMicrosSinceEpoch(); + + // Digest binds the verb: status || verb || preamble || time_us || + // req_sig_hash. verb is hashed but not emitted on the wire. + ces::Buffer hashIn; + hashIn.put(status) + .put(verb) + .putBytes(preamble) + .put(timeUs) + .put(reqSigHash); + // Plain single SHA256 of the buffer — identical to ces::sha256, which is + // what the client recomputes to verify (kept inline so this low-level + // envelope module doesn't pull in a higher-layer header for it). + minx::Hash digest; + CryptoPP::SHA256().CalculateDigest( + digest.data(), hashIn.data(), hashIn.size()); + + // Wire: [status][preamble][time_us][req_sig_hash][sha256][sig]. + ces::Buffer out( + CES_PLEX_STATUS_SIZE + preamble.size() + CES_PLEX_RESP_TRAILER_SIZE); + out.put(status) + .putBytes(preamble) + .put(timeUs) + .put(reqSigHash) + .put(digest); + Signature sig = serverKey.signData( + std::span(digest.data(), digest.size())); + out.put(sig); + return std::move(out).take(); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/cesvm.cpp b/vendor/ces/src/ceslib/cesvm.cpp new file mode 100644 index 0000000..79f9db1 --- /dev/null +++ b/vendor/ces/src/ceslib/cesvm.cpp @@ -0,0 +1,1807 @@ +/** + * CesVM implementation. + * GVM core (github.com/FluxBP/gvm) adapted for CES server-side execution. + */ + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include + +LOG_MODULE("cesvm"); + +namespace ces { + +// Opcode numbers, operand encoding bits, and stack caps now live in +// cesvm.h as the single source of truth. Keep short local aliases for +// the bits that show up a lot in this file so the parser stays +// readable. +static constexpr uint8_t STACK = CESVM_OP_STACK; +static constexpr uint8_t REG_PTR = CESVM_REG_PTR; +static constexpr uint8_t SHORT_VAL = CESVM_SHORT_VAL; +static constexpr uint8_t MAX_SHORT_VAL = CESVM_MAX_SHORT_VAL; + +// Plain unit conversion for the SYS_SCHEDULE gas formula. +static constexpr uint64_t US_PER_SEC = 1'000'000; + +CesVM::CesVM() : rng_(std::random_device{}()) { + std::memset(io_, 0, sizeof(io_)); +} + +CesVMResult CesVM::execute(const ces::Bytes& code, + CesVMHost& host, uint64_t budget, + uint64_t gasMult) { + CesVMResult result; + std::memset(io_, 0, sizeof(io_)); + stackClear(); + ctxClear(); + term_ = CESVM_OK; + budget_ = budget; + budgetUsed_ = 0; + gasMult_ = gasMult ? gasMult : 1; + + // Copy initial code into mutable code buffer. An oversize input is a + // hard error (CESVM_CODESIZE) rather than a silent truncate — a + // program whose tail got chopped would fail in subtle ways further + // along, far from the actual cause. + if (code.size() > CESVM_MAX_CODE) { + result.error = CESVM_CODESIZE; + return result; + } + code_ = code; + + // Preload immutable context into fixed io memory locations + size_t inputLen = std::min(host.input.size(), size_t(CESVM_MAX_INPUT)); + io_[CESVM_IO_INPUT_LEN] = inputLen; + io_[CESVM_IO_OUTPUT_LEN] = 0; + io_[CESVM_IO_BUDGET] = budget; + io_[CESVM_IO_START_TIME] = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + io_[CESVM_IO_ALLOWANCE] = host.allowance; + io_[CESVM_IO_BUDGET_REMAINING] = budget; + io_[CESVM_IO_INVOKE_KIND] = host.invokeKind; + writeIoBytes(CESVM_IO_CALLER_KEY, host.callerKey.data(), KEY_SIZE); + writeIoBytes(CESVM_IO_SELF_KEY, host.selfAssetKey.data(), KEY_SIZE); + writeIoBytes(CESVM_IO_PROGRAM_OWNER, host.programOwner.data(), + host.programOwner.size()); + if (inputLen > 0) + writeIoBytes(CESVM_IO_INPUT, host.input.data(), inputLen); + + // Per-op gas precompute (see billOp): an overflowing COST_PER_OP*gasMult + // matches bill()'s overflow guard -- halt as budget-exhausted on the + // first op. + opCostOvf_ = CESVM_COST_PER_OP > + std::numeric_limits::max() / gasMult_; + opCost_ = opCostOvf_ ? 0 : CESVM_COST_PER_OP * gasMult_; + +#if CESVM_OPT_PREDECODE + if (legacyCore_) runLegacy(host, result); + else runFast(host, result); +#else + runLegacy(host, result); +#endif + + result.error = term_; + result.budgetUsed = budgetUsed_; + + // Read output from fixed io location + size_t outLen = std::min(io_[CESVM_IO_OUTPUT_LEN], uint64_t(CESVM_MAX_OUTPUT)); + if (outLen > 0) { + result.output.resize(outLen); + readIoBytes(CESVM_IO_OUTPUT, result.output.data(), outLen); + } + + return result; +} + +// --- Reference interpreter core --- + +void CesVM::runLegacy(CesVMHost& host, CesVMResult& result) { + while (!term_ && PC() < code_.size()) { + result.opsExecuted++; + if (!billOp()) { PC()++; break; } + stepSlow(host); + } +} + +// Execute exactly one instruction at PC. The base per-op cost has already +// been billed by the caller (runLegacy or the fast core's dispatch); +// variable extra costs (HOSTV's per-arg writes, MOV/CMP/FIL per-cell) are +// billed here. This switch is the reference semantics: the fast core must +// match it observably, and replays through it anything it does not +// fast-path. +void CesVM::stepSlow(CesVMHost& host) { + uint64_t op1, op2; + uint8_t opcode = code_[PC()++]; + + switch (opcode) { + case OP_NOP: + break; + case OP_TERM: + PC() = UINT64_MAX; + break; + case OP_SET: + op1 = read(); + op2 = read(); + get(op1) = op2; + break; + case OP_JMP: + op1 = read(true); + PC() = op1; + break; + case OP_ADD: + op1 = read(); op2 = read(); R() = op1 + op2; break; + case OP_ADD | STACK: + op2 = pop(); op1 = pop(); push(op1 + op2); break; + case OP_SUB: + // Two's-complement subtraction. Wraps silently on underflow + // (consistent with ADD/MUL/NEG); programs that need to detect + // "would go negative" branch on a CMP first. + op1 = read(); op2 = read(); + R() = op1 - op2; + break; + case OP_SUB | STACK: + op2 = pop(); op1 = pop(); + push(op1 - op2); + break; + case OP_MUL: + op1 = read(); op2 = read(); R() = op1 * op2; break; + case OP_MUL | STACK: + op2 = pop(); op1 = pop(); push(op1 * op2); break; + case OP_DIV: + op1 = read(); op2 = read(); + if (op2) R() = op1 / op2; else term_ = CESVM_DIVZERO; + break; + case OP_DIV | STACK: + op2 = pop(); op1 = pop(); + if (op2) push(op1 / op2); else term_ = CESVM_DIVZERO; + break; + case OP_MOD: + op1 = read(); op2 = read(); + if (op2) R() = op1 % op2; else term_ = CESVM_DIVZERO; + break; + case OP_MOD | STACK: + op2 = pop(); op1 = pop(); + if (op2) push(op1 % op2); else term_ = CESVM_DIVZERO; + break; + case OP_OR: + op1 = read(); op2 = read(); R() = op1 | op2; break; + case OP_OR | STACK: + op2 = pop(); op1 = pop(); push(op1 | op2); break; + case OP_ANDL: + op1 = read(); op2 = read(); R() = op1 && op2; break; + case OP_ANDL | STACK: + op2 = pop(); op1 = pop(); push(op1 && op2); break; + case OP_XOR: + op1 = read(); op2 = read(); R() = op1 ^ op2; break; + case OP_XOR | STACK: + op2 = pop(); op1 = pop(); push(op1 ^ op2); break; + case OP_NOT: + // Bitwise complement (per x86/ARM/MIPS convention: NOT = ~x). + op1 = read(); R() = ~op1; break; + case OP_NOT | STACK: + op1 = pop(); push(~op1); break; + case OP_LNOT: + // Logical NOT: 0 → 1, anything else → 0. + op1 = read(); R() = !op1; break; + case OP_LNOT | STACK: + op1 = pop(); push(!op1); break; + case OP_SHL: + op1 = read(); op2 = read(); + // Shifting a uint64_t by >= 64 bits is UB per [expr.shift]/1. + // Reject attacker bytecode that would trigger it. + if (op2 >= 64) { term_ = CESVM_SEGFAULT; break; } + R() = op1 << op2; break; + case OP_SHL | STACK: + op2 = pop(); op1 = pop(); + if (op2 >= 64) { term_ = CESVM_SEGFAULT; break; } + push(op1 << op2); break; + case OP_SHR: + op1 = read(); op2 = read(); + if (op2 >= 64) { term_ = CESVM_SEGFAULT; break; } + R() = op1 >> op2; break; + case OP_SHR | STACK: + op2 = pop(); op1 = pop(); + if (op2 >= 64) { term_ = CESVM_SEGFAULT; break; } + push(op1 >> op2); break; + case OP_SAR: + // Arithmetic shift right — preserves the sign bit. C++20 guarantees + // signed `>>` is arithmetic (P0907R4); same UB rule for op2 >= 64 + // applies as in SHR/SHL. + op1 = read(); op2 = read(); + if (op2 >= 64) { term_ = CESVM_SEGFAULT; break; } + R() = static_cast(static_cast(op1) >> + static_cast(op2)); break; + case OP_SAR | STACK: + op2 = pop(); op1 = pop(); + if (op2 >= 64) { term_ = CESVM_SEGFAULT; break; } + push(static_cast(static_cast(op1) >> + static_cast(op2))); break; + case OP_INC: + op1 = read(); ++get(op1); break; + case OP_DEC: + op1 = read(); --get(op1); break; + case OP_PUSH: + op1 = read(); push(op1); break; + case OP_POP: + op1 = read(); get(op1) = pop(); break; + case OP_AND: + op1 = read(); op2 = read(); R() = op1 & op2; break; + case OP_AND | STACK: + op2 = pop(); op1 = pop(); push(op1 & op2); break; + case OP_HOST: + hostCall(host); + break; + case OP_HOSTX: + hostCall(host); + if (!term_ && S() != 0) term_ = CESVM_ABORT; + break; + case OP_ABORT: + term_ = CESVM_ABORT; + break; + case OP_VPUSH: + op1 = read(); op2 = read(); + ++get(op1); get(get(op1)) = op2; + break; + case OP_VPOP: + op1 = read(); op2 = read(); + get(op2) = get(op1); --get(op1); + break; + case OP_CALL: { + op1 = read(true); + // Hard cap on call depth to close a stack-recursion DoS vector + // analogous to the data-stack cap in push(). 256 frames is plenty + // for any realistic program; deeper recursion almost always + // indicates a bug. + if (!ctxPush()) break; + PC() = op1; + break; + } + case OP_CALL | STACK: { + // Stack form: target popped from the data stack instead of read + // inline. Equivalent to OP_CALLR for a runtime-computed target, + // but consumes the value off the stack rather than dereferencing + // a cell. Same call-depth cap as OP_CALL. + op1 = pop(); + if (!ctxPush()) break; + PC() = op1; + break; + } + case OP_JMPR: + // Indirect JMP: target comes from a regular operand, so it can + // be dereferenced through a cell (e.g. R after SYS_LOAD_CODE + // wrote the loaded block's offset there). + op1 = read(); + PC() = op1; + break; + case OP_CALLR: { + // Indirect CALL: same shape as OP_CALL but the target is a + // runtime value. See OP_JMPR. + op1 = read(); + if (!ctxPush()) break; + PC() = op1; + break; + } + case OP_HOSTV: + case OP_HOSTXV: { + // Variadic syscall dispatch. Reads (syscall_num, arg_count, + // arg0, arg1, ...) inline, populates io[3] = syscall_num and + // io[4..4+N-1] = args, then calls hostCall. OP_HOSTXV + // additionally promotes a nonzero S on return to CESVM_ABORT. + // + // Two-phase execution: read and validate all args into a local + // buffer first, then commit to io. This means a truncated or + // malformed arg stream (which would set term_ = CESVM_CODESIZE + // mid-read) can't leave io half-populated in a state the + // syscall would see as half-filled. + uint64_t syscallNum = read(); + if (term_) break; + uint64_t argCount = read(); + if (term_) break; + if (argCount > CESVM_MAX_HOSTV_ARGS) { + term_ = CESVM_SEGFAULT; + break; + } + // Bill the equivalent of (argCount + 1) OP_SETs up front: one + // for the io[3] syscall-num write, N for the arg writes. Same + // total gas as the old OP_SET × (N+1) + OP_HOSTX pattern would + // have charged, so HOSTV saves bytecode without discounting + // gas. + if (!bill(CESVM_COST_PER_OP * (argCount + 1))) break; + uint64_t argBuf[CESVM_MAX_HOSTV_ARGS]; + for (uint64_t i = 0; i < argCount; ++i) { + argBuf[i] = read(); + if (term_) break; + } + if (term_) break; + // Commit: all reads succeeded, now populate io slots atomically + // (no partial state on parser failure). + io_[3] = syscallNum; + for (uint64_t i = 0; i < argCount; ++i) { + io_[4 + i] = argBuf[i]; + } + hostCall(host); + if (opcode == OP_HOSTXV && !term_ && S() != 0) { + term_ = CESVM_ABORT; + } + break; + } + case OP_RET: { + op1 = read(); + if (ctxEmpty()) { term_ = CESVM_RET; break; } + ctxRestorePop(); + R() = op1; + break; + } + case OP_JF: + op1 = read(); + if (!op1) { PC() = read(true); } + else { PC() += 2; } + break; + case OP_JF | STACK: + op1 = pop(); + if (!op1) { PC() = read(true); } + else { PC() += 2; } + break; + case OP_JT: + op1 = read(); + if (op1) { PC() = read(true); } + else { PC() += 2; } + break; + case OP_JT | STACK: + op1 = pop(); + if (op1) { PC() = read(true); } + else { PC() += 2; } + break; + case OP_EQ: + op1 = read(); op2 = read(); R() = op1 == op2; break; + case OP_EQ | STACK: + op2 = pop(); op1 = pop(); push(op1 == op2); break; + case OP_NE: + op1 = read(); op2 = read(); R() = op1 != op2; break; + case OP_NE | STACK: + op2 = pop(); op1 = pop(); push(op1 != op2); break; + case OP_GT: + op1 = read(); op2 = read(); R() = op1 > op2; break; + case OP_GT | STACK: + op2 = pop(); op1 = pop(); push(op1 > op2); break; + case OP_LT: + op1 = read(); op2 = read(); R() = op1 < op2; break; + case OP_LT | STACK: + op2 = pop(); op1 = pop(); push(op1 < op2); break; + case OP_GE: + op1 = read(); op2 = read(); R() = op1 >= op2; break; + case OP_GE | STACK: + op2 = pop(); op1 = pop(); push(op1 >= op2); break; + case OP_LE: + op1 = read(); op2 = read(); R() = op1 <= op2; break; + case OP_LE | STACK: + op2 = pop(); op1 = pop(); push(op1 <= op2); break; + case OP_SLT: + op1 = read(); op2 = read(); + R() = static_cast(op1) < static_cast(op2); break; + case OP_SLT | STACK: + op2 = pop(); op1 = pop(); + push(static_cast(op1) < static_cast(op2)); break; + case OP_SGT: + op1 = read(); op2 = read(); + R() = static_cast(op1) > static_cast(op2); break; + case OP_SGT | STACK: + op2 = pop(); op1 = pop(); + push(static_cast(op1) > static_cast(op2)); break; + case OP_SGE: + op1 = read(); op2 = read(); + R() = static_cast(op1) >= static_cast(op2); break; + case OP_SGE | STACK: + op2 = pop(); op1 = pop(); + push(static_cast(op1) >= static_cast(op2)); break; + case OP_SLE: + op1 = read(); op2 = read(); + R() = static_cast(op1) <= static_cast(op2); break; + case OP_SLE | STACK: + op2 = pop(); op1 = pop(); + push(static_cast(op1) <= static_cast(op2)); break; + case OP_ADDX: + // Checked unsigned add: a wrap halts with CESVM_OVERFLOW instead + // of producing a mod-2^64 result. Same shape for SUBX/MULX below. + op1 = read(); op2 = read(); + if (op1 > UINT64_MAX - op2) { term_ = CESVM_OVERFLOW; break; } + R() = op1 + op2; break; + case OP_ADDX | STACK: + op2 = pop(); op1 = pop(); + if (op1 > UINT64_MAX - op2) { term_ = CESVM_OVERFLOW; break; } + push(op1 + op2); break; + case OP_SUBX: + op1 = read(); op2 = read(); + if (op2 > op1) { term_ = CESVM_OVERFLOW; break; } + R() = op1 - op2; break; + case OP_SUBX | STACK: + op2 = pop(); op1 = pop(); + if (op2 > op1) { term_ = CESVM_OVERFLOW; break; } + push(op1 - op2); break; + case OP_MULX: + op1 = read(); op2 = read(); + if (op2 != 0 && op1 > UINT64_MAX / op2) { term_ = CESVM_OVERFLOW; break; } + R() = op1 * op2; break; + case OP_MULX | STACK: + op2 = pop(); op1 = pop(); + if (op2 != 0 && op1 > UINT64_MAX / op2) { term_ = CESVM_OVERFLOW; break; } + push(op1 * op2); break; + case OP_ASSERT: + op1 = read(); + if (!op1) term_ = CESVM_ABORT; + break; + case OP_ASSERT | STACK: + op1 = pop(); + // pop() on an empty stack already set CESVM_UNDERFLOW and + // returned 0; do not misreport that crash as an assert failure. + if (!op1 && !term_) term_ = CESVM_ABORT; + break; + case OP_DUP: + // pop-then-push-twice inherits the existing edge semantics: empty + // stack halts with CESVM_UNDERFLOW via pop(), a stack at cap + // halts with CESVM_SEGFAULT on the second push(). + op1 = pop(); + push(op1); + push(op1); + break; + case OP_NEG: + // Arithmetic two's-complement negate. Wraps at 0 (NEG of 0 is 0; + // NEG of INT64_MIN is itself — that's the well-known x86 quirk + // and we replicate it: -x = 0 - x mod 2^64). + op1 = read(); + R() = static_cast(0) - op1; + break; + case OP_NEG | STACK: + op1 = pop(); + push(static_cast(0) - op1); + break; + case OP_ORL: + op1 = read(); op2 = read(); R() = op1 || op2; break; + case OP_ORL | STACK: + op2 = pop(); op1 = pop(); push(op1 || op2); break; + case OP_RND: + R() = rng_(); + break; + case OP_RND | STACK: + push(rng_()); + break; + case OP_TIME: + R() = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + break; + case OP_TIME | STACK: + push(static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + break; + case OP_MOV: { + // MOV dst, src, count — copy count cells from io[src] to io[dst] + op1 = read(); // dst + op2 = read(); // src + uint64_t cnt = read(); // count + if (cnt > CESVM_IO_SIZE || + op1 > CESVM_IO_SIZE - cnt || op2 > CESVM_IO_SIZE - cnt) { + term_ = CESVM_SEGFAULT; break; + } + if (!billMul(cnt, CESVM_COST_PER_CELL)) break; + std::memmove(&io_[op1], &io_[op2], cnt * sizeof(uint64_t)); + break; + } + case OP_LDB: { + // LDB — R = byte at byte_offset (read from io as byte array) + op1 = read(); // byte offset + auto* base = reinterpret_cast(io_); + if (op1 >= CESVM_IO_SIZE * sizeof(uint64_t)) { + term_ = CESVM_SEGFAULT; break; + } + R() = base[op1]; + break; + } + case OP_LDB | STACK: { + op1 = pop(); + auto* base = reinterpret_cast(io_); + if (op1 >= CESVM_IO_SIZE * sizeof(uint64_t)) { + term_ = CESVM_SEGFAULT; break; + } + push(base[op1]); + break; + } + case OP_STB: { + // STB — store low byte of value at byte_offset + op1 = read(); // byte offset + op2 = read(); // value (low byte used) + auto* base = reinterpret_cast(io_); + if (op1 >= CESVM_IO_SIZE * sizeof(uint64_t)) { + term_ = CESVM_SEGFAULT; break; + } + base[op1] = static_cast(op2 & 0xFF); + break; + } + case OP_STB | STACK: { + op2 = pop(); // value + op1 = pop(); // byte offset + auto* base = reinterpret_cast(io_); + if (op1 >= CESVM_IO_SIZE * sizeof(uint64_t)) { + term_ = CESVM_SEGFAULT; break; + } + base[op1] = static_cast(op2 & 0xFF); + break; + } + case OP_CMP: { + // CMP a, b, count → R = 1 if io[a..a+count-1] == io[b..b+count-1] + op1 = read(); // a (cell offset) + op2 = read(); // b (cell offset) + uint64_t cnt = read(); + if (cnt > CESVM_IO_SIZE || + op1 > CESVM_IO_SIZE - cnt || op2 > CESVM_IO_SIZE - cnt) { + term_ = CESVM_SEGFAULT; break; + } + if (!billMul(cnt, CESVM_COST_PER_CELL)) break; + R() = (std::memcmp(&io_[op1], &io_[op2], cnt * sizeof(uint64_t)) == 0) ? 1 : 0; + break; + } + case OP_FIL: { + // FIL dst, val, count → fill io[dst..dst+count-1] with val + op1 = read(); // dst (cell offset) + op2 = read(); // value (uint64_t) + uint64_t cnt = read(); + if (cnt > CESVM_IO_SIZE || op1 > CESVM_IO_SIZE - cnt) { + term_ = CESVM_SEGFAULT; break; + } + if (!billMul(cnt, CESVM_COST_PER_CELL)) break; + for (uint64_t i = 0; i < cnt; ++i) + io_[op1 + i] = op2; + break; + } + default: + term_ = CESVM_OPCODE; + } +} + +#if CESVM_OPT_PREDECODE + +// ============================================================================ +// Fast interpreter core: memoized operand pre-decode plus (optionally) +// computed-goto dispatch. See the CESVM_OPT_* block in cesvm.h for the +// contract. stepSlow defines the semantics; instructions this core cannot +// statically decode (variadic HOSTV/HOSTXV, operands dereferencing cell 0, +// any encoding anomaly) keep h = H_SLOW and replay through stepSlow -- +// same bytes, same faults, same side effects. +// ============================================================================ + +// Dense fast-handler indices. The X-macro keeps the enum and the threaded +// dispatch table in lockstep; H_SLOW must stay first (a zero-initialized +// Decoded dispatches to it, and decodeAt leaves h = H_SLOW on any anomaly). +#define CESVM_FAST_HANDLERS(X) \ + X(SLOW) X(NOP) X(TERM) X(SET) X(JMP) X(INC) X(DEC) X(PUSH) X(POP) \ + X(HOST) X(HOSTX) X(ABORT) X(VPUSH) X(VPOP) X(CALL) X(CALL_S) X(CALLR) \ + X(JMPR) X(RET) X(JF) X(JF_S) X(JT) X(JT_S) X(DUP) X(RND) X(RND_S) \ + X(TIME) X(TIME_S) X(MOV) X(CMP) X(FIL) X(LDB) X(LDB_S) X(STB) X(STB_S) \ + X(ADD) X(ADD_S) X(SUB) X(SUB_S) X(MUL) X(MUL_S) X(DIV) X(DIV_S) \ + X(MOD) X(MOD_S) X(OR) X(OR_S) X(AND) X(AND_S) X(XOR) X(XOR_S) \ + X(ANDL) X(ANDL_S) X(ORL) X(ORL_S) X(NOT) X(NOT_S) X(LNOT) X(LNOT_S) \ + X(NEG) X(NEG_S) X(SHL) X(SHL_S) X(SHR) X(SHR_S) X(SAR) X(SAR_S) \ + X(EQ) X(EQ_S) X(NE) X(NE_S) X(GT) X(GT_S) X(LT) X(LT_S) X(GE) X(GE_S) \ + X(LE) X(LE_S) X(SLT) X(SLT_S) X(SGT) X(SGT_S) X(SGE) X(SGE_S) \ + X(SLE) X(SLE_S) X(ADDX) X(ADDX_S) X(SUBX) X(SUBX_S) X(MULX) X(MULX_S) \ + X(ASSERT) X(ASSERT_S) + +enum : uint16_t { +#define X(n) H_##n, + CESVM_FAST_HANDLERS(X) +#undef X + H_COUNT +}; + +// Static per-opcode decode plan: fast handler, inline operand count, and +// whether a 2-byte literal jump target follows the operands (the +// read(true) form). H_SLOW rows never reach decodeAt's operand walk. +struct FastOpInfo { + uint16_t h = H_SLOW; + uint8_t nops = 0; + bool jumpTail = false; +}; + +static constexpr std::array kFastOpInfo = [] { + std::array t{}; + auto op = [&](uint8_t code, uint16_t h, uint8_t nops, bool tail = false) { + t[code] = {h, nops, tail}; + }; + op(OP_NOP, H_NOP, 0); op(OP_TERM, H_TERM, 0); + op(OP_SET, H_SET, 2); op(OP_JMP, H_JMP, 0, true); + op(OP_ADD, H_ADD, 2); op(OP_SUB, H_SUB, 2); + op(OP_MUL, H_MUL, 2); op(OP_DIV, H_DIV, 2); + op(OP_MOD, H_MOD, 2); op(OP_OR, H_OR, 2); + op(OP_ANDL, H_ANDL, 2); op(OP_XOR, H_XOR, 2); + op(OP_NOT, H_NOT, 1); op(OP_SHL, H_SHL, 2); + op(OP_SHR, H_SHR, 2); op(OP_INC, H_INC, 1); + op(OP_DEC, H_DEC, 1); op(OP_PUSH, H_PUSH, 1); + op(OP_POP, H_POP, 1); op(OP_AND, H_AND, 2); + op(OP_HOST, H_HOST, 0); op(OP_VPUSH, H_VPUSH, 2); + op(OP_VPOP, H_VPOP, 2); op(OP_CALL, H_CALL, 0, true); + op(OP_RET, H_RET, 1); op(OP_JF, H_JF, 1, true); + op(OP_JT, H_JT, 1, true); op(OP_EQ, H_EQ, 2); + op(OP_NE, H_NE, 2); op(OP_GT, H_GT, 2); + op(OP_LT, H_LT, 2); op(OP_GE, H_GE, 2); + op(OP_LE, H_LE, 2); op(OP_NEG, H_NEG, 1); + op(OP_ORL, H_ORL, 2); op(OP_RND, H_RND, 0); + op(OP_TIME, H_TIME, 0); op(OP_MOV, H_MOV, 3); + op(OP_LDB, H_LDB, 1); op(OP_STB, H_STB, 2); + op(OP_CMP, H_CMP, 3); op(OP_FIL, H_FIL, 3); + op(OP_HOSTX, H_HOSTX, 0); op(OP_ABORT, H_ABORT, 0); + op(OP_JMPR, H_JMPR, 1); op(OP_CALLR, H_CALLR, 1); + // OP_HOSTV / OP_HOSTXV stay H_SLOW: their operand count is a runtime + // value, so there is nothing static to pre-decode. + op(OP_SAR, H_SAR, 2); op(OP_LNOT, H_LNOT, 1); + op(OP_SLT, H_SLT, 2); op(OP_SGT, H_SGT, 2); + op(OP_SGE, H_SGE, 2); op(OP_SLE, H_SLE, 2); + op(OP_ADDX, H_ADDX, 2); op(OP_SUBX, H_SUBX, 2); + op(OP_MULX, H_MULX, 2); op(OP_ASSERT, H_ASSERT, 1); + op(OP_DUP, H_DUP, 0); + // Stack variants: operands come off the data stack, so no inline + // operands; JF/JT keep their literal jump tail. + auto sop = [&](uint8_t code, uint16_t h, bool tail = false) { + t[code | STACK] = {h, 0, tail}; + }; + sop(OP_ADD, H_ADD_S); sop(OP_SUB, H_SUB_S); sop(OP_MUL, H_MUL_S); + sop(OP_DIV, H_DIV_S); sop(OP_MOD, H_MOD_S); sop(OP_OR, H_OR_S); + sop(OP_ANDL, H_ANDL_S); sop(OP_XOR, H_XOR_S); sop(OP_NOT, H_NOT_S); + sop(OP_SHL, H_SHL_S); sop(OP_SHR, H_SHR_S); sop(OP_AND, H_AND_S); + sop(OP_CALL, H_CALL_S); sop(OP_JF, H_JF_S, true); sop(OP_JT, H_JT_S, true); + sop(OP_EQ, H_EQ_S); sop(OP_NE, H_NE_S); sop(OP_GT, H_GT_S); + sop(OP_LT, H_LT_S); sop(OP_GE, H_GE_S); sop(OP_LE, H_LE_S); + sop(OP_NEG, H_NEG_S); sop(OP_ORL, H_ORL_S); sop(OP_RND, H_RND_S); + sop(OP_TIME, H_TIME_S); sop(OP_LDB, H_LDB_S); sop(OP_STB, H_STB_S); + sop(OP_SAR, H_SAR_S); sop(OP_LNOT, H_LNOT_S); sop(OP_SLT, H_SLT_S); + sop(OP_SGT, H_SGT_S); sop(OP_SGE, H_SGE_S); sop(OP_SLE, H_SLE_S); + sop(OP_ADDX, H_ADDX_S); sop(OP_SUBX, H_SUBX_S); sop(OP_MULX, H_MULX_S); + sop(OP_ASSERT, H_ASSERT_S); + return t; +}(); + +// Decode the instruction at pc0 into d, mirroring read() byte for byte. +// Any anomaly (truncation, oversized payload width, PC-dereferencing +// operand) leaves h = H_SLOW so execution replays through stepSlow and +// faults (or behaves) exactly as the reference core would. +void CesVM::decodeAt(uint64_t pc0, Decoded& d) { + d.epoch = epoch_; + d.h = H_SLOW; + d.regptr = 0; + const FastOpInfo& info = kFastOpInfo[code_[pc0]]; + if (info.h == H_SLOW) return; + const size_t size = code_.size(); + uint64_t p = pc0 + 1; + for (uint8_t i = 0; i < info.nops; ++i) { + if (p >= size) return; + uint8_t control = code_[p++]; + uint8_t v = control & MAX_SHORT_VAL; + uint64_t val; + if (control & SHORT_VAL) { + val = v; + } else { + if (v > sizeof(val)) return; + if (v > size || p > size - v) return; + val = 0; + std::memcpy(&val, &code_[p], v); + p += v; + } + if (control & REG_PTR) { + // A dereference of cell 0 observes the mid-instruction PC, which + // the fast core does not materialize per-operand. + if (val == 0) return; + d.regptr |= static_cast(1u << i); + } + d.val[i] = val; + } + if (info.jumpTail) { + if (p + 2 > size) return; + d.target = static_cast(code_[p] | + (static_cast(code_[p + 1]) << 8)); + p += 2; + } + d.nextPc = static_cast(p); + d.nops = info.nops; + d.h = info.h; +} + +void CesVM::runFast(CesVMHost& host, CesVMResult& result) { + if (++epoch_ == 0) { // epoch wrap: hard-invalidate the whole table + dtab_.clear(); + epoch_ = 1; + } + if (dtab_.size() < code_.size()) dtab_.resize(code_.size()); + + uint64_t pc = 0; + const Decoded* d = nullptr; + uint64_t a = 0, b = 0, c = 0; + +// Operand i: pre-decoded immediate, or a cell dereference through get() +// (which bounds-checks and faults exactly like the reference core). +#define VMOP(i) ((d->regptr & (1u << (i))) ? get(d->val[i]) : d->val[i]) + +#if CESVM_OPT_THREADED +// Label-as-value jump table + computed goto: a deliberate GCC/Clang extension +// (the threaded-dispatch fast core). Both constructs are non-ISO, so -Wpedantic +// flags them; suppress it just for this dispatch prologue rather than dropping +// the flag for the whole lib. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpedantic" + static const void* const kTbl[] = { +#define X(n) &&VL_##n, + CESVM_FAST_HANDLERS(X) +#undef X + }; +#define VM_TARGET(n) VL_##n: +#define VM_NEXT goto vm_dispatch + +vm_dispatch: + if (term_) return; + pc = io_[0]; + if (pc >= code_.size()) return; + if (pc >= dtab_.size()) dtab_.resize(code_.size()); // SYS_LOAD_CODE grew + d = &dtab_[pc]; + if (d->epoch != epoch_) decodeAt(pc, dtab_[pc]); + ++result.opsExecuted; + if (!billOp()) { io_[0] = pc + 1; return; } + io_[0] = d->nextPc; + goto *kTbl[d->h]; +#pragma GCC diagnostic pop +#else +#define VM_TARGET(n) case H_##n: +#define VM_NEXT continue + + for (;;) { + if (term_) return; + pc = io_[0]; + if (pc >= code_.size()) return; + if (pc >= dtab_.size()) dtab_.resize(code_.size()); // SYS_LOAD_CODE grew + d = &dtab_[pc]; + if (d->epoch != epoch_) decodeAt(pc, dtab_[pc]); + ++result.opsExecuted; + if (!billOp()) { io_[0] = pc + 1; return; } + io_[0] = d->nextPc; + switch (d->h) { +#endif + + VM_TARGET(SLOW) { + // Replay through the reference interpreter: reset PC to the opcode + // byte (the base cost is already billed) and run one instruction. + io_[0] = pc; + stepSlow(host); + } VM_NEXT; + + VM_TARGET(NOP) VM_NEXT; + + VM_TARGET(TERM) { io_[0] = UINT64_MAX; } VM_NEXT; + + VM_TARGET(SET) { a = VMOP(0); b = VMOP(1); get(a) = b; } VM_NEXT; + + VM_TARGET(JMP) { io_[0] = d->target; } VM_NEXT; + + VM_TARGET(INC) { a = VMOP(0); ++get(a); } VM_NEXT; + + VM_TARGET(DEC) { a = VMOP(0); --get(a); } VM_NEXT; + + VM_TARGET(PUSH) { a = VMOP(0); push(a); } VM_NEXT; + + VM_TARGET(POP) { a = VMOP(0); get(a) = pop(); } VM_NEXT; + + VM_TARGET(HOST) { hostCall(host); } VM_NEXT; + + VM_TARGET(HOSTX) { + hostCall(host); + if (!term_ && S() != 0) term_ = CESVM_ABORT; + } VM_NEXT; + + VM_TARGET(ABORT) { term_ = CESVM_ABORT; } VM_NEXT; + + VM_TARGET(VPUSH) { + a = VMOP(0); b = VMOP(1); + ++get(a); get(get(a)) = b; + } VM_NEXT; + + VM_TARGET(VPOP) { + a = VMOP(0); b = VMOP(1); + get(b) = get(a); --get(a); + } VM_NEXT; + + // ctxPush snapshots the registers with PC = nextPc (the return + // address); on depth-cap failure it sets CESVM_SEGFAULT and the + // dispatch check exits. + VM_TARGET(CALL) { if (ctxPush()) io_[0] = d->target; } VM_NEXT; + + VM_TARGET(CALL_S) { a = pop(); if (ctxPush()) io_[0] = a; } VM_NEXT; + + VM_TARGET(CALLR) { a = VMOP(0); if (ctxPush()) io_[0] = a; } VM_NEXT; + + VM_TARGET(JMPR) { a = VMOP(0); io_[0] = a; } VM_NEXT; + + VM_TARGET(RET) { + a = VMOP(0); + if (ctxEmpty()) { term_ = CESVM_RET; } + else { ctxRestorePop(); R() = a; } + } VM_NEXT; + + VM_TARGET(JF) { a = VMOP(0); if (!a) io_[0] = d->target; } VM_NEXT; + VM_TARGET(JF_S) { a = pop(); if (!a) io_[0] = d->target; } VM_NEXT; + VM_TARGET(JT) { a = VMOP(0); if (a) io_[0] = d->target; } VM_NEXT; + VM_TARGET(JT_S) { a = pop(); if (a) io_[0] = d->target; } VM_NEXT; + + VM_TARGET(DUP) { a = pop(); push(a); push(a); } VM_NEXT; + + VM_TARGET(RND) { R() = rng_(); } VM_NEXT; + VM_TARGET(RND_S) { push(rng_()); } VM_NEXT; + + VM_TARGET(TIME) { + R() = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + } VM_NEXT; + + VM_TARGET(TIME_S) { + push(static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + } VM_NEXT; + + VM_TARGET(MOV) { + a = VMOP(0); b = VMOP(1); c = VMOP(2); + if (c > CESVM_IO_SIZE || + a > CESVM_IO_SIZE - c || b > CESVM_IO_SIZE - c) { + term_ = CESVM_SEGFAULT; + } else if (billMul(c, CESVM_COST_PER_CELL)) { + std::memmove(&io_[a], &io_[b], c * sizeof(uint64_t)); + } + } VM_NEXT; + + VM_TARGET(CMP) { + a = VMOP(0); b = VMOP(1); c = VMOP(2); + if (c > CESVM_IO_SIZE || + a > CESVM_IO_SIZE - c || b > CESVM_IO_SIZE - c) { + term_ = CESVM_SEGFAULT; + } else if (billMul(c, CESVM_COST_PER_CELL)) { + R() = (std::memcmp(&io_[a], &io_[b], c * sizeof(uint64_t)) == 0) ? 1 : 0; + } + } VM_NEXT; + + VM_TARGET(FIL) { + a = VMOP(0); b = VMOP(1); c = VMOP(2); + if (c > CESVM_IO_SIZE || a > CESVM_IO_SIZE - c) { + term_ = CESVM_SEGFAULT; + } else if (billMul(c, CESVM_COST_PER_CELL)) { + for (uint64_t i = 0; i < c; ++i) + io_[a + i] = b; + } + } VM_NEXT; + + VM_TARGET(LDB) { + a = VMOP(0); + if (a >= CESVM_IO_SIZE * sizeof(uint64_t)) term_ = CESVM_SEGFAULT; + else R() = reinterpret_cast(io_)[a]; + } VM_NEXT; + + VM_TARGET(LDB_S) { + a = pop(); + if (a >= CESVM_IO_SIZE * sizeof(uint64_t)) term_ = CESVM_SEGFAULT; + else push(reinterpret_cast(io_)[a]); + } VM_NEXT; + + VM_TARGET(STB) { + a = VMOP(0); b = VMOP(1); + if (a >= CESVM_IO_SIZE * sizeof(uint64_t)) term_ = CESVM_SEGFAULT; + else reinterpret_cast(io_)[a] = static_cast(b & 0xFF); + } VM_NEXT; + + VM_TARGET(STB_S) { + b = pop(); a = pop(); // value first, offset second (matches stepSlow) + if (a >= CESVM_IO_SIZE * sizeof(uint64_t)) term_ = CESVM_SEGFAULT; + else reinterpret_cast(io_)[a] = static_cast(b & 0xFF); + } VM_NEXT; + +// Two-operand ops with register and stack forms and no extra checks. +#define VM_BIN(NAME, EXPR) \ + VM_TARGET(NAME) { a = VMOP(0); b = VMOP(1); R() = (EXPR); } VM_NEXT; \ + VM_TARGET(NAME##_S) { b = pop(); a = pop(); push(EXPR); } VM_NEXT; + + VM_BIN(ADD, a + b) + VM_BIN(SUB, a - b) + VM_BIN(MUL, a * b) + VM_BIN(OR, a | b) + VM_BIN(AND, a & b) + VM_BIN(XOR, a ^ b) + VM_BIN(ANDL, a && b) + VM_BIN(ORL, a || b) + VM_BIN(EQ, a == b) + VM_BIN(NE, a != b) + VM_BIN(GT, a > b) + VM_BIN(LT, a < b) + VM_BIN(GE, a >= b) + VM_BIN(LE, a <= b) + VM_BIN(SLT, static_cast(a) < static_cast(b)) + VM_BIN(SGT, static_cast(a) > static_cast(b)) + VM_BIN(SGE, static_cast(a) >= static_cast(b)) + VM_BIN(SLE, static_cast(a) <= static_cast(b)) +#undef VM_BIN + + VM_TARGET(DIV) { + a = VMOP(0); b = VMOP(1); + if (b) R() = a / b; else term_ = CESVM_DIVZERO; + } VM_NEXT; + + VM_TARGET(DIV_S) { + b = pop(); a = pop(); + if (b) push(a / b); else term_ = CESVM_DIVZERO; + } VM_NEXT; + + VM_TARGET(MOD) { + a = VMOP(0); b = VMOP(1); + if (b) R() = a % b; else term_ = CESVM_DIVZERO; + } VM_NEXT; + + VM_TARGET(MOD_S) { + b = pop(); a = pop(); + if (b) push(a % b); else term_ = CESVM_DIVZERO; + } VM_NEXT; + + VM_TARGET(SHL) { + a = VMOP(0); b = VMOP(1); + if (b >= 64) term_ = CESVM_SEGFAULT; else R() = a << b; + } VM_NEXT; + + VM_TARGET(SHL_S) { + b = pop(); a = pop(); + if (b >= 64) term_ = CESVM_SEGFAULT; else push(a << b); + } VM_NEXT; + + VM_TARGET(SHR) { + a = VMOP(0); b = VMOP(1); + if (b >= 64) term_ = CESVM_SEGFAULT; else R() = a >> b; + } VM_NEXT; + + VM_TARGET(SHR_S) { + b = pop(); a = pop(); + if (b >= 64) term_ = CESVM_SEGFAULT; else push(a >> b); + } VM_NEXT; + + VM_TARGET(SAR) { + a = VMOP(0); b = VMOP(1); + if (b >= 64) term_ = CESVM_SEGFAULT; + else R() = static_cast(static_cast(a) >> + static_cast(b)); + } VM_NEXT; + + VM_TARGET(SAR_S) { + b = pop(); a = pop(); + if (b >= 64) term_ = CESVM_SEGFAULT; + else push(static_cast(static_cast(a) >> + static_cast(b))); + } VM_NEXT; + + VM_TARGET(NOT) { a = VMOP(0); R() = ~a; } VM_NEXT; + VM_TARGET(NOT_S) { a = pop(); push(~a); } VM_NEXT; + VM_TARGET(LNOT) { a = VMOP(0); R() = !a; } VM_NEXT; + VM_TARGET(LNOT_S) { a = pop(); push(!a); } VM_NEXT; + VM_TARGET(NEG) { a = VMOP(0); R() = static_cast(0) - a; } VM_NEXT; + VM_TARGET(NEG_S) { a = pop(); push(static_cast(0) - a); } VM_NEXT; + + VM_TARGET(ADDX) { + a = VMOP(0); b = VMOP(1); + if (a > UINT64_MAX - b) term_ = CESVM_OVERFLOW; else R() = a + b; + } VM_NEXT; + + VM_TARGET(ADDX_S) { + b = pop(); a = pop(); + if (a > UINT64_MAX - b) term_ = CESVM_OVERFLOW; else push(a + b); + } VM_NEXT; + + VM_TARGET(SUBX) { + a = VMOP(0); b = VMOP(1); + if (b > a) term_ = CESVM_OVERFLOW; else R() = a - b; + } VM_NEXT; + + VM_TARGET(SUBX_S) { + b = pop(); a = pop(); + if (b > a) term_ = CESVM_OVERFLOW; else push(a - b); + } VM_NEXT; + + VM_TARGET(MULX) { + a = VMOP(0); b = VMOP(1); + if (b != 0 && a > UINT64_MAX / b) term_ = CESVM_OVERFLOW; + else R() = a * b; + } VM_NEXT; + + VM_TARGET(MULX_S) { + b = pop(); a = pop(); + if (b != 0 && a > UINT64_MAX / b) term_ = CESVM_OVERFLOW; + else push(a * b); + } VM_NEXT; + + VM_TARGET(ASSERT) { + a = VMOP(0); + if (!a) term_ = CESVM_ABORT; + } VM_NEXT; + + VM_TARGET(ASSERT_S) { + a = pop(); + // pop() on an empty stack already set CESVM_UNDERFLOW; do not + // misreport that crash as an assert failure. + if (!a && !term_) term_ = CESVM_ABORT; + } VM_NEXT; + +#if !CESVM_OPT_THREADED + default: + // Unreachable: decodeAt only emits indices with handlers. A loud + // halt beats a silent no-op if table and handlers ever diverge. + term_ = CESVM_HOST; + VM_NEXT; + } + } +#endif + +#undef VM_TARGET +#undef VM_NEXT +#undef VMOP +} + +#endif // CESVM_OPT_PREDECODE + +// --- GVM operand decoding --- + +uint64_t CesVM::read(bool jumpSkipControl) { + if (PC() >= code_.size()) { + term_ = CESVM_CODESIZE; + return 0; + } + uint8_t control; + if (jumpSkipControl) + control = 2; // 2-byte address + else + control = code_[PC()++]; + + uint8_t v = control & MAX_SHORT_VAL; + bool regptr = control & REG_PTR; + bool shortval = control & SHORT_VAL; + uint64_t val; + if (shortval) { + val = v; + } else { + val = 0; + // v comes from attacker bytecode (low 6 bits = 0..63). Reject + // anything that wouldn't fit in a uint64_t — otherwise the memcpy + // below would write past `val` on the stack. + if (v > sizeof(val)) { + term_ = CESVM_OPCODE; + return 0; + } + if (v > code_.size() || PC() > code_.size() - v) { + term_ = CESVM_CODESIZE; + return 0; + } + std::memcpy(&val, &code_[PC()], v); + PC() += v; + } + if (regptr) + val = get(val); + return val; +} + +bool CesVM::bill(uint64_t cost) { + // Overflow guard on cost * gasMult_, symmetric with billMul(). An absurd + // feeVmMult could otherwise wrap a real cost to a small value and underbill; + // on wrap, halt as budget-exhausted. (gasMult_ is forced >= 1 in execute().) + if (gasMult_ != 0 && cost > std::numeric_limits::max() / gasMult_) { + term_ = CESVM_BUDGET; + io_[CESVM_IO_BUDGET_REMAINING] = 0; + return false; + } + cost *= gasMult_; + // Use cost <= budget_ - budgetUsed_ to avoid uint64_t overflow on the + // sum. Equivalent to (budgetUsed_ + cost > budget_) when no wrap. + if (cost > budget_ - budgetUsed_) { + term_ = CESVM_BUDGET; + io_[CESVM_IO_BUDGET_REMAINING] = 0; // mirror reflects exhausted state + return false; + } + budgetUsed_ += cost; + // Mirror the remaining budget into io[CESVM_IO_BUDGET_REMAINING] so + // programs can read their current gas headroom mid-run and bail + // gracefully before a hard CESVM_BUDGET abort. Symmetric with the + // allowance mirror in hostCall, but updated at op granularity + // because budget is consumed by every op, not just by syscalls. + io_[CESVM_IO_BUDGET_REMAINING] = budget_ - budgetUsed_; + return true; +} + +bool CesVM::billCredits(uint64_t raw) { + if (raw > budget_ - budgetUsed_) { + term_ = CESVM_BUDGET; + io_[CESVM_IO_BUDGET_REMAINING] = 0; + return false; + } + budgetUsed_ += raw; + io_[CESVM_IO_BUDGET_REMAINING] = budget_ - budgetUsed_; + return true; +} + +bool CesVM::billMul(uint64_t a, uint64_t b) { + // Overflow-safe bill(a * b). Covers the case where attacker bytecode + // supplies huge `a` (e.g. OP_MOV cnt) to make a*b wrap to a small + // value that slips past bill()'s budget check. + if (b != 0 && a > UINT64_MAX / b) { + term_ = CESVM_BUDGET; + return false; + } + return bill(a * b); +} + +// --- IO memory byte helpers --- + +void CesVM::readIoBytes(uint64_t ioOffset, uint8_t* out, size_t len) { + // io is uint64_t cells; bytes are packed little-endian + size_t totalBytes = CESVM_IO_SIZE * sizeof(uint64_t); + if (ioOffset > UINT64_MAX / sizeof(uint64_t)) { + term_ = CESVM_SEGFAULT; + return; + } + size_t byteOffset = ioOffset * sizeof(uint64_t); + if (len > totalBytes || byteOffset > totalBytes - len) { + term_ = CESVM_SEGFAULT; + return; + } + std::memcpy(out, reinterpret_cast(io_) + byteOffset, len); +} + +void CesVM::writeIoBytes(uint64_t ioOffset, const uint8_t* data, size_t len) { + size_t totalBytes = CESVM_IO_SIZE * sizeof(uint64_t); + if (ioOffset > UINT64_MAX / sizeof(uint64_t)) { + term_ = CESVM_SEGFAULT; + return; + } + size_t byteOffset = ioOffset * sizeof(uint64_t); + if (len > totalBytes || byteOffset > totalBytes - len) { + term_ = CESVM_SEGFAULT; + return; + } + std::memcpy(reinterpret_cast(io_) + byteOffset, data, len); +} + +// --- CES syscall dispatch --- + +void CesVM::hostCall(CesVMHost& host) { + uint64_t syscall = io_[3]; // SYSCALL register + + switch (syscall) { + case SYS_NOP: + if (!bill(CESVM_COST_PER_SYSCALL)) return; + S() = CES_OK; + break; + + case SYS_READ_ACCOUNT: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + HashPrefix id; + readIoBytes(io_[4], id.data(), id.size()); + if (term_) return; + if (!billCredits(host.feeQuery)) return; + R() = static_cast(host.readAccountBalance(id)); + io_[5] = host.readAccountNonce(id); + io_[6] = host.readAccountAliasId(id); // 0 = no alias + S() = CES_OK; + break; + } + + case SYS_TRANSFER: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash dest; + readIoBytes(io_[4], dest.data(), dest.size()); + if (term_) return; + // Protocol fee comes out of the run's pre-paid budget, not the + // user's allowance. Halts the VM (CESVM_BUDGET) if budget is + // insufficient — symmetric with how bill() handles gas exhaustion. + if (!billCredits(host.feeTx)) return; + S() = host.transfer(dest, io_[5]); + break; + } + + case SYS_OWNER_TRANSFER: { + // Same shape as SYS_TRANSFER, but debits the program's owner instead + // of the caller. Protocol fee still comes from the caller's budget — + // they invoked the syscall. + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash dest; + readIoBytes(io_[4], dest.data(), dest.size()); + if (term_) return; + if (!billCredits(host.feeTx)) return; + S() = host.ownerTransfer(dest, io_[5]); + break; + } + + case SYS_DEPOSIT: { + // caller -> programOwner. Both endpoints implicit; io[4] = amount. + if (!bill(CESVM_COST_PER_SYSCALL)) return; + if (!billCredits(host.feeTx)) return; + S() = host.deposit(io_[4]); + break; + } + + case SYS_WITHDRAW: { + // programOwner -> caller. Both endpoints implicit; io[4] = amount. + if (!bill(CESVM_COST_PER_SYSCALL)) return; + if (!billCredits(host.feeTx)) return; + S() = host.withdraw(io_[4]); + break; + } + + case SYS_READ_ASSET: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + if (!billCredits(host.feeQuery)) return; + HashPrefix owner; + AssetData content; + uint16_t balance = 0; + uint32_t price = 0; + if (host.readAsset(key, owner, content, balance, price)) { + // Inputs are contiguous at io[4..6] so OP_HOSTXV can populate + // them without padding; outputs land at io[7] (balance) and + // io[8] (price), past the input range. + writeIoBytes(io_[5], owner.data(), owner.size()); + writeIoBytes(io_[6], content.data(), content.size()); + io_[7] = balance; + io_[8] = price; + S() = CES_OK; + } else { + S() = CES_ERROR_ASSET_NOT_FOUND; + } + break; + } + + case SYS_CREATE_ASSET_RANDOM: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + AssetData content{}; + readIoBytes(io_[4], content.data(), CESVM_CODE_BLOCK); + if (term_) return; + uint16_t days = static_cast(io_[5]); + // Asset rent is protocol overhead (paying for slot occupancy), + // not user spending — bill from budget. Prepaid days run through + // the attenuation helper so deep funding can't lock in a low rate. + uint32_t totalDays = 2u + assetDays(days); + if (!billCredits(computePrepayCost(host.feeAssetRaw, host.assetRentMultBp, totalDays, 0))) return; + minx::Hash newKey; + for (auto& b : newKey) b = static_cast(rng_()); + S() = host.createAsset(newKey, content, days); + if (S() == CES_OK) + writeIoBytes(io_[6], newKey.data(), newKey.size()); + break; + } + + case SYS_CREATE_ASSET_RANGE: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + uint64_t n = io_[4]; + if (n == 0 || n > CESVM_MAX_ASSET_RANGE) { + S() = CES_ERROR_BAD_INPUT; + break; + } + // Gas scales with the cells created: this base plus one per extra cell. + if (n > 1 && !bill(CESVM_COST_PER_SYSCALL * (n - 1))) return; + uint16_t days = static_cast(io_[5]); + uint64_t per = computePrepayCost(host.feeAssetRaw, host.assetRentMultBp, + 2u + assetDays(days), 0); + if (!billCredits(per * n)) return; + // Fresh 24-byte entropy prefix, index suffix zeroed (cell 0). Retry a new + // prefix on the astronomically rare collision; the host creates nothing on + // collision so the retry is clean. + minx::Hash firstKey{}; + uint8_t rc = CES_ERROR_ASSET_EXISTS; + for (uint64_t attempt = 0; attempt < CESVM_ASSET_RANGE_RETRIES; ++attempt) { + for (int i = 0; i < 24; ++i) firstKey[i] = static_cast(rng_()); + for (int i = 24; i < 32; ++i) firstKey[i] = 0; + rc = host.createAssetRange(firstKey, static_cast(n), days); + if (rc != CES_ERROR_ASSET_EXISTS) break; + } + S() = rc; + if (rc == CES_OK) writeIoBytes(io_[6], firstKey.data(), firstKey.size()); + break; + } + + case SYS_UPDATE_ASSET: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + AssetData content{}; + readIoBytes(io_[5], content.data(), CESVM_CODE_BLOCK); + if (term_) return; + if (!billCredits(host.feeAsset)) return; + S() = host.updateAsset(key, content); + break; + } + + case SYS_UPDATE_ASSET_META: { + // Owner+price update with no content I/O — bills feeTx (cheaper tier), + // matching CES_UPDATE_ASSET_META on the wire. + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + HashPrefix newOwner; + readIoBytes(io_[5], newOwner.data(), newOwner.size()); + if (term_) return; + if (!billCredits(host.feeTx)) return; + S() = host.updateAssetMeta(key, newOwner, static_cast(io_[6])); + break; + } + + case SYS_REFILL: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + // Grow this run's budget by what the host grants (bounded host-side by the + // refill ceiling and the caller's balance). The caller is charged post-run + // for gas actually consumed past the free grant (executeVmRun), not for the + // grant itself, so unused refill costs nothing. + uint64_t granted = host.refillGas(io_[4]); + budget_ += granted; + io_[CESVM_IO_BUDGET_REMAINING] = budget_ - budgetUsed_; + R() = granted; + S() = CES_OK; + break; + } + + case SYS_READ_ALIAS: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + // io[4]=alias id, io[5]=offset, io[6]=len, io[7]=dest cell ptr + uint32_t aliasId = static_cast(io_[4]); + uint64_t off = io_[5]; + uint64_t len = io_[6]; + if (off > ALIAS_VALUE_BYTES || len > ALIAS_VALUE_BYTES || + off + len > ALIAS_VALUE_BYTES) { + S() = CES_ERROR_BAD_INPUT; + break; + } + if (!billCredits(host.feeQuery)) return; + ces::Bytes tmp(len); + if (!host.readAlias(aliasId, static_cast(off), + static_cast(len), tmp.data())) { + S() = CES_ERROR_ALIAS_NOT_FOUND; + break; + } + if (len > 0) { + writeIoBytes(io_[7], tmp.data(), len); + if (term_) return; + } + R() = len; + S() = CES_OK; + break; + } + + case SYS_WRITE_ALIAS: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + // io[4]=alias id, io[5]=offset, io[6]=len, io[7]=src cell ptr + uint32_t aliasId = static_cast(io_[4]); + uint64_t off = io_[5]; + uint64_t len = io_[6]; + if (off > ALIAS_VALUE_BYTES || len > ALIAS_VALUE_BYTES || + off + len > ALIAS_VALUE_BYTES) { + S() = CES_ERROR_BAD_INPUT; + break; + } + ces::Bytes tmp(len); + if (len > 0) { + readIoBytes(io_[7], tmp.data(), len); + if (term_) return; + } + if (!billCredits(host.feeAlias)) return; + S() = host.writeAlias(aliasId, static_cast(off), tmp.data(), + static_cast(len)); + break; + } + + case SYS_LOAD_CODE_ALIAS: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + if (code_.size() + ALIAS_INLINE_CODE_BYTES > CESVM_MAX_CODE) { + term_ = CESVM_CODEFULL; + return; + } + uint32_t aliasId = static_cast(io_[4]); + if (!billCredits(host.feeQuery)) return; + uint64_t offset = code_.size(); + code_.resize(offset + ALIAS_INLINE_CODE_BYTES); + if (!host.readAlias(aliasId, ALIAS_OFF_CONTENT, ALIAS_INLINE_CODE_BYTES, + &code_[offset])) { + code_.resize(offset); + S() = CES_ERROR_ALIAS_NOT_FOUND; + break; + } + R() = offset; + S() = CES_OK; + break; + } + + case SYS_SCHEDULE_ALIAS: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + // io[4]=alias id, io[5]=budget, io[6]=child_allowance, + // io[7]=input_ptr, io[8]=input_len, io[9]=time_us + // Same billing and allowance carve as SYS_SCHEDULE. + uint32_t aliasId = static_cast(io_[4]); + uint64_t childBudget = io_[5]; + uint64_t childAllowance = io_[6]; + size_t inputLen = std::min(io_[8], uint64_t(CESVM_MAX_INPUT)); + ces::Bytes input(inputLen); + if (inputLen > 0) { + readIoBytes(io_[7], input.data(), inputLen); + if (term_) return; + } + uint64_t time_us = io_[9]; + uint64_t now = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + uint64_t duration = (time_us > now) ? (time_us - now) : 0; + uint64_t hostingCost = CESVM_SCHEDULE_BASE_COST + + CESVM_SCHEDULE_PER_SEC * duration / US_PER_SEC; + if (!bill(hostingCost)) return; + if (host.allowance != std::numeric_limits::max()) { + if (childAllowance > host.allowance) { + S() = CES_ERROR_ALLOWANCE_EXCEEDED; + break; + } + host.allowance -= childAllowance; + } + S() = host.scheduleAlias(aliasId, childBudget, childAllowance, + input.data(), inputLen, time_us); + if (S() != CES_OK && + host.allowance != std::numeric_limits::max()) { + host.allowance += childAllowance; + } + break; + } + + case SYS_FUND_ASSET: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + uint16_t days = static_cast(io_[5]); + // Read existing days off the asset to drive correct attenuation. + HashPrefix _o; AssetData _c; uint16_t _bal = 0; uint32_t _p = 0; + uint32_t held = host.readAsset(key, _o, _c, _bal, _p) + ? assetDays(_bal) : 0u; + // The day field caps at 0x0FFF, and VmHost::fundAsset clamps the grant + // to it -- so bill only for the days actually granted, not the full + // request (mirrors the wire fundAsset fix; otherwise funding a near-cap + // asset overcharges for days it never receives). + uint32_t granted = std::min(0x0FFF, held + days) - held; + if (!billCredits(host.feeTx + computePrepayCost(host.feeAssetRaw, + host.assetRentMultBp, + granted, held))) return; + S() = host.fundAsset(key, days); + break; + } + + case SYS_BUY_ASSET: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + if (!billCredits(host.feeTx)) return; + // Purchase price stays allowance-bound — it's spending toward the + // seller, not protocol overhead. + S() = host.buyAsset(key, io_[5]); + break; + } + + case SYS_GIVE_ASSET: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + HashPrefix newOwner; + readIoBytes(io_[5], newOwner.data(), newOwner.size()); + if (term_) return; + if (!billCredits(host.feeTx)) return; + S() = host.giveAsset(key, newOwner); + break; + } + + case SYS_SEND_UDP: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + // Disabled: raw UDP is insecure. Use SYS_SEND_CLIENT instead. + S() = CES_ERROR_DISABLED; + break; + // Original implementation (kept for potential future re-enabling): + // char addrBuf[64] = {}; + // readIoBytes(io_[4], reinterpret_cast(addrBuf), 63); + // if (term_) return; + // uint16_t port = static_cast(io_[5]); + // size_t dataLen = std::min(io_[7], uint64_t(minx::MAX_DATA_SIZE)); + // ces::Bytes data(dataLen); + // readIoBytes(io_[6], data.data(), dataLen); + // if (term_) return; + // host.sendUdp(addrBuf, port, data.data(), dataLen); + // S() = CES_OK; + // break; + } + + case SYS_HASH: { + if (!bill(CESVM_COST_PER_MEMOP)) return; + uint64_t dataOff = io_[4]; + uint64_t len = std::min(io_[5], uint64_t(CESVM_MAX_CODE)); + uint64_t outOff = io_[6]; + size_t totalBytes = CESVM_IO_SIZE * sizeof(uint64_t); + // dataOff/outOff are attacker-controlled cell indices; the subsequent + // "* sizeof(uint64_t)" must not wrap, and neither may the "+ len" sum. + if (dataOff > UINT64_MAX / sizeof(uint64_t) || + outOff > UINT64_MAX / sizeof(uint64_t)) { + term_ = CESVM_SEGFAULT; return; + } + size_t dataByte = dataOff * sizeof(uint64_t); + size_t outByte = outOff * sizeof(uint64_t); + if (len > totalBytes || dataByte > totalBytes - len || + outByte > totalBytes - sizeof(minx::Hash)) { + term_ = CESVM_SEGFAULT; return; + } + if (!billMul(len, CESVM_COST_PER_BYTE)) return; + auto* base = reinterpret_cast(io_); + CryptoPP::SHA256().CalculateDigest(base + outByte, base + dataByte, len); + S() = CES_OK; + break; + } + + case SYS_VERIFY_SIG: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + uint64_t dataLen = std::min(io_[5], uint64_t(minx::MAX_DATA_SIZE)); + if (!bill(dataLen * CESVM_COST_PER_BYTE + CESVM_COST_VERIFY_EC)) return; + ces::Bytes data(dataLen); + readIoBytes(io_[4], data.data(), dataLen); + if (term_) return; + uint8_t sig[SIG_SIZE]; + readIoBytes(io_[6], sig, SIG_SIZE); + if (term_) return; + uint8_t pubkey[KEY_SIZE]; + readIoBytes(io_[7], pubkey, KEY_SIZE); + if (term_) return; + R() = host.verifySig(data.data(), dataLen, sig, pubkey) ? 1 : 0; + S() = CES_OK; + break; + } + + case SYS_CROSS_TRANSFER: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash dest; + readIoBytes(io_[4], dest.data(), dest.size()); + if (term_) return; + char addrBuf[64] = {}; + readIoBytes(io_[6], reinterpret_cast(addrBuf), 63); + if (term_) return; + if (!billCredits(host.feeTx)) return; + // Cross-transfer amount stays allowance-bound (it's spending). The + // host validates peer + queue + debit synchronously and returns a + // proper code; only the network dispatch is deferred to commit. + S() = host.crossTransfer(dest, io_[5], addrBuf); + break; + } + + case SYS_CREATE_ASSET: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + AssetData content{}; + readIoBytes(io_[5], content.data(), CESVM_CODE_BLOCK); + if (term_) return; + uint16_t days = static_cast(io_[6]); + if (!billCredits(computePrepayCost(host.feeAssetRaw, host.assetRentMultBp, 2u + assetDays(days), 0))) return; + S() = host.createAsset(key, content, days); + break; + } + + case SYS_LOAD_CODE: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + if (code_.size() + CESVM_CODE_BLOCK > CESVM_MAX_CODE) { + term_ = CESVM_CODEFULL; + return; + } + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + if (!billCredits(host.feeQuery)) return; + HashPrefix owner; + AssetData content; + uint16_t balance = 0; + uint32_t price = 0; + if (!host.readAsset(key, owner, content, balance, price)) { + S() = CES_ERROR_ASSET_NOT_FOUND; + break; + } + uint64_t offset = code_.size(); + code_.resize(code_.size() + CESVM_CODE_BLOCK); + std::memcpy(&code_[offset], content.data(), CESVM_CODE_BLOCK); + R() = offset; + S() = CES_OK; + break; + } + + case SYS_SEND_CLIENT: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + // io[4] = account prefix ptr, io[5] = data ptr, io[6] = data len + HashPrefix clientId; + readIoBytes(io_[4], clientId.data(), clientId.size()); + if (term_) return; + size_t dataLen = std::min(io_[6], uint64_t(minx::MAX_DATA_SIZE)); + ces::Bytes data(dataLen); + readIoBytes(io_[5], data.data(), dataLen); + if (term_) return; + if (!billCredits(host.feeSendClient)) return; + R() = host.sendClient(clientId, data.data(), dataLen) ? 1 : 0; + S() = CES_OK; + break; + } + + case SYS_SCHEDULE: { + if (!bill(CESVM_COST_PER_SYSCALL)) return; + // io[4]=asset_key_ptr, io[5]=budget, io[6]=child_allowance, + // io[7]=input_ptr, io[8]=input_len, io[9]=time_us + minx::Hash assetKey; + readIoBytes(io_[4], assetKey.data(), assetKey.size()); + if (term_) return; + uint64_t childBudget = io_[5]; + uint64_t childAllowance = io_[6]; + size_t inputLen = std::min(io_[8], uint64_t(CESVM_MAX_INPUT)); + ces::Bytes input(inputLen); + if (inputLen > 0) { + readIoBytes(io_[7], input.data(), inputLen); + if (term_) return; + } + uint64_t time_us = io_[9]; + // Compute hosting cost: base + per_us * duration + uint64_t now = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + uint64_t duration = (time_us > now) ? (time_us - now) : 0; + uint64_t hostingCost = CESVM_SCHEDULE_BASE_COST + + CESVM_SCHEDULE_PER_SEC * duration / US_PER_SEC; + if (!bill(hostingCost)) return; + // Decrement parent's allowance by the child's allotment. UINT64_MAX + // sentinel means "no enforcement"; we treat any childAllowance as + // ≤ UINT64_MAX and pass it through unchanged in that case (the child + // also gets the unbounded sentinel only when both parent and request + // are UINT64_MAX). Otherwise the parent must have ≥ childAllowance + // remaining or this fails ALLOWANCE_EXCEEDED — and once we accept, + // the parent loses that headroom for any subsequent SYS_SCHEDULE or + // direct spend in this run. + // + // NOTE: allowance bounds caller-account *debits* (transfers/fees), NOT + // gas — childBudget is intentionally not carved here. See review F2: the + // lack of any spawned-gas exposure bound is a design gap, not bounded by + // conflating it with the debit allowance. + if (host.allowance != std::numeric_limits::max()) { + if (childAllowance > host.allowance) { + S() = CES_ERROR_ALLOWANCE_EXCEEDED; + break; + } + host.allowance -= childAllowance; + } + S() = host.schedule(assetKey, childBudget, childAllowance, + input.data(), inputLen, time_us); + // If schedule rejected, refund the parent's allowance so we don't + // burn headroom on a no-op. + if (S() != CES_OK && + host.allowance != std::numeric_limits::max()) { + host.allowance += childAllowance; + } + break; + } + + case SYS_CREATE_ASSET_MANAGED: { + // Like SYS_CREATE_ASSET but the created asset is owned by the boot asset + // (asset-owned), not by the runner. Caller pays, program owns. + // io[4]=key_ptr, io[5]=content_ptr, io[6]=days + if (!bill(CESVM_COST_PER_SYSCALL)) return; + minx::Hash key; + readIoBytes(io_[4], key.data(), key.size()); + if (term_) return; + AssetData content{}; + readIoBytes(io_[5], content.data(), CESVM_CODE_BLOCK); + if (term_) return; + uint16_t days = static_cast(io_[6]); + if (!billCredits(computePrepayCost(host.feeAssetRaw, host.assetRentMultBp, 2u + assetDays(days), 0))) return; + S() = host.createAssetManaged(key, content, days); + break; + } + + case SYS_RPC: { + // MINX/RUDP stream call. See SYS_RPC in cesvm.h for the io layout + // and wire protocol. Returns CES_OK (queued), CES_ERROR_DISABLED + // (rpcPort == 0 on this server), or an upfront validation error. + // Actual call outcome arrives later via the scheduled followup. + if (!bill(CESVM_COST_PER_SYSCALL)) return; + + // Host string (max 255 bytes). + uint64_t hostLen = io_[5]; + // Wire field caps host string at 255 bytes; reject before doing any + // I/O. BAD_INPUT, not INTERNAL — the program supplied a too-long + // hostname, the server is fine. + if (hostLen > 255) { S() = CES_ERROR_BAD_INPUT; break; } + std::string hostStr(hostLen, '\0'); + if (hostLen > 0) { + readIoBytes(io_[4], reinterpret_cast(hostStr.data()), + hostLen); + if (term_) return; + } + + uint16_t port = static_cast(io_[6]); + + minx::Hash fileHeadKey; + readIoBytes(io_[7], fileHeadKey.data(), fileHeadKey.size()); + if (term_) return; + + minx::Hash followupKey; + readIoBytes(io_[8], followupKey.data(), followupKey.size()); + if (term_) return; + + uint64_t followupBudget = io_[9]; + uint32_t followupTag = static_cast(io_[10]); + + S() = host.rpc(hostStr, port, fileHeadKey, followupKey, + followupBudget, followupTag); + break; + } + + case SYS_L2_CALL: { + // Paid, reliable call into an in-CES (L2) built-in. See SYS_L2_CALL in + // cesvm.h for the io layout. The syscall owns the burn: a synchronous + // reject burns nothing; CES_OK burns `value` and enqueues. Settlement is + // delivery-based and resolved later via a followup run (INVOKE_L2_RETURN). + if (!bill(CESVM_COST_PER_SYSCALL)) return; + + // Discriminator: 8 raw bytes (sha256 of the built-in name, truncated), + // matched as a flat array so routing is endian-independent. + uint8_t disc[8]; + readIoBytes(io_[4], disc, sizeof(disc)); + if (term_) return; + + uint64_t value = io_[5]; + + // Provider-ABI blob, opaque to core. Bounded to keep the inline path small. + constexpr uint64_t kMaxL2Blob = 1024; + uint64_t blobLen = io_[7]; + if (blobLen > kMaxL2Blob) { S() = CES_ERROR_BAD_INPUT; break; } + std::vector blob(blobLen); + if (blobLen > 0) { + readIoBytes(io_[6], blob.data(), blobLen); + if (term_) return; + } + + minx::Hash followupKey; + readIoBytes(io_[8], followupKey.data(), followupKey.size()); + if (term_) return; + + uint64_t followupBudget = io_[9]; + uint32_t followupTag = static_cast(io_[10]); + + S() = host.l2call(disc, value, blob.data(), blob.size(), + followupKey, followupBudget, followupTag); + break; + } + + default: + term_ = CESVM_SYSCALL; + } + + // Mirror remaining allowance back into io memory so VM programs can read + // their per-run spending headroom (and branch on it) the same way they + // read the initial budget from io[CESVM_IO_BUDGET]. + io_[CESVM_IO_ALLOWANCE] = host.allowance; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/client.cpp b/vendor/ces/src/ceslib/client.cpp new file mode 100644 index 0000000..492a4c9 --- /dev/null +++ b/vendor/ces/src/ceslib/client.cpp @@ -0,0 +1,1643 @@ +#include + +#include +LOG_MODULE("ccl"); + +#include + +#include +#include + +#include +#include +#include +#include + +#include +#include + +namespace ces { + +// Plain unit conversion, used when the server reports its minimum-time-before- +// accepting-PoW in minutes and we need an absolute UTC-seconds deadline. +static constexpr uint64_t SECS_PER_MIN = 60; + +// After this many seconds without a fresh INFO, `ensureServerTicket()` forces +// a new handshake — otherwise the cached `serverTicket_` goes stale. +static constexpr uint64_t SERVER_TICKET_REFRESH_SECS = 15; + +// Polling interval while waiting for the RandomX VM/dataset to finish +// initializing before `mine()` can call `proveWork()`. +static constexpr int POW_INIT_POLL_MS = 1000; + +// Normalize to v4-mapped IPv6, the form inbound sender addresses take on the +// dual-stack v6 socket, so the fence can compare addresses directly. +static minx::SockAddr normalizeV6(const boost::asio::ip::udp::endpoint& ep) { + auto a = ep.address(); + if (a.is_v4()) + a = boost::asio::ip::make_address_v6(boost::asio::ip::v4_mapped, a.to_v4()); + return minx::SockAddr(a, ep.port()); +} + +CesClient::CesClient(const boost::asio::ip::udp::endpoint& serverEndpoint, + bool useDataset, const minx::MinxConfig& config) + : currentTarget_(normalizeV6(serverEndpoint)) { + LOGTRACE << "CesClient (UDP)"; + transport_ = + std::make_unique(this, serverEndpoint, config); + transport_->setUseDataset(useDataset); +} + +CesClient::CesClient(const boost::asio::ip::tcp::endpoint& proxyEndpoint, + bool useDataset) { + LOGTRACE << "CesClient (TCP)"; + transport_ = std::make_unique(this, proxyEndpoint); + transport_->setUseDataset(useDataset); +} + +CesClient::~CesClient() { + LOGTRACE << "~CesClient"; + stop(); + LOGTRACE << "~CesClient done"; +} + +void CesClient::setKey(const KeyPair& keyPair) { keyPair_ = keyPair; } + +bool CesClient::start(uint16_t localPort) { + LOGTRACE << "start"; + // Never let a transport-layer exception (e.g. thread/socket creation hitting + // a sandboxed compute child's RLIMIT_AS/RLIMIT_NOFILE) propagate to callers: + // every caller already treats false as "could not start". This keeps the Lua + // networking APIs (ces.ping / ces.remote_*) from throwing into the VM. + bool ok = false; + try { + ok = transport_->start(localPort); + } catch (const std::exception& e) { + LOGDEBUG << "start threw" << SVAR(e.what()); + return false; + } + LOGTRACE << "start done" << VAR(ok); + return ok; +} + +bool CesClient::stop() { + if (!transport_) + return true; + LOGTRACE << "stop"; + transport_->stop(); + LOGTRACE << "stop done"; + return true; +} + +bool CesClient::connect() { + if (connected_) { + disconnect(); + } + LOGTRACE << "connect"; + + try { + for (int i = 0; i < tries_; ++i) { + if (getInfo()) { + LOGTRACE << "connect ok"; + return connected_ = true; + } + if (i + 1 < tries_) { + LOGTRACE << "connect failed; retrying..." << VAR(i) << VAR(tries_); + if (!ces::sleep(retryIntervalMs_)) { + LOGTRACE << "connect interrupted"; + return false; + } + } + } + } catch (const std::exception& e) { + LOGDEBUG << "connect threw" << SVAR(e.what()); + return connected_ = false; + } + + LOGTRACE << "connect out of tries, failed"; + return connected_ = false; +} + +bool CesClient::disconnect() { + if (!connected_) { + return true; + } + LOGTRACE << "disconnect"; + serverKey_ = {}; + serverMinDiff_ = 0; + serverMinPoWTimestamp_ = 0; + serverTicket_ = 0; + serverTicketLastTime_ = 0; + connected_ = false; + return true; +} + +bool CesClient::setRemoteEndpoint( + const boost::asio::ip::udp::endpoint& serverEndpoint) { + // Move the fence to the new server before redirecting sends. + { + std::lock_guard lk(targetMutex_); + currentTarget_ = normalizeV6(serverEndpoint); + } + if (!transport_->setRemoteEndpoint(serverEndpoint)) + return false; + // Drop per-peer session state; the next connect() re-handshakes. + disconnect(); + return true; +} + +bool CesClient::setRemoteEndpoint( + const boost::asio::ip::tcp::endpoint& proxyEndpoint) { + // TCP mode has no inbound address fence, so currentTarget_ is left alone. + if (!transport_->setRemoteEndpoint(proxyEndpoint)) + return false; + disconnect(); + return true; +} + +bool CesClient::isCurrentPeer(const minx::SockAddr& addr) const { + // TCP proxy mode has a single fixed peer (the proxy); no address fence. + if (transport_ && transport_->isTcp()) return true; + std::lock_guard lk(targetMutex_); + return normalizeV6(addr) == currentTarget_; +} + +bool CesClient::getInfo() { + LOGTRACE << "getInfo"; + minx::MinxGetInfo msg{0, transport_->generatePassword(), {}}; + + uint64_t g = serverInfoGen_; + for (int i = 0; i < tries_; ++i) { + LOGTRACE << "getInfo send message" << VAR(i) << VAR(tries_); + transport_->sendGetInfo(msg); + LOGTRACE << "getInfo wait for reply"; + auto res = + ces::waitFor(retryIntervalMs_, [&]() { return g < serverInfoGen_; }); + switch (res) { + case ces::WaitResult::Success: + LOGTRACE << "getInfo got new info"; + return true; + case ces::WaitResult::Interrupted: + LOGTRACE << "getInfo interrupted"; + return false; + case ces::WaitResult::Timeout: + break; + } + LOGTRACE << "getInfo timeout; retrying..." << VAR(i) << VAR(tries_); + } + LOGTRACE << "getInfo out of tries, failed"; + return false; +} + +int CesClient::proveWork(const minx::MinxProveWork& msg, + minx::Hash& beneficiary, uint64_t& creditAmount, + uint64_t& time) { + + if (!ensureServerTicket()) { + LOGTRACE << "proveWork can't get a server ticket for minx prove work"; + return minx::MINX_SOLUTION_UNKNOWN; + } + + LOGTRACE << "proveWork"; + + for (int i = 0; i < tries_; ++i) { + // Fresh password (server spends on first receive) + minx::MinxProveWork pwmsg{msg.version, + transport_->generatePassword(), + serverTicket_, + msg.ckey, + msg.hdata, + msg.time, + msg.nonce, + msg.solution, + msg.data}; // carry the appData (hdata commits to + // it); dropping it here is why a mined + // server's address never reached the + // peer it mined — inbound discovery. + + // Capture gen right before sending so stale responses are already behind us + uint64_t g = proveWorkGen_; + + LOGTRACE << "proveWork sending message" << VAR(i) << VAR(tries_); + transport_->sendProveWork(pwmsg); + LOGTRACE << "proveWork waiting for receipt"; + + // Inner loop: wait for a gen bump, drain mismatched responses + int staleCount = 0; + while (staleCount < tries_) { + auto res = + ces::waitFor(retryIntervalMs_, [&]() { return g < proveWorkGen_; }); + + if (res == ces::WaitResult::Interrupted) + return minx::MINX_SOLUTION_UNKNOWN; + + if (res == ces::WaitResult::Timeout) { + LOGTRACE << "proveWork timeout; retrying..." << VAR(i) + << VAR(tries_); + break; // retry send + } + + // Got a response — check if it's for our solution + if (proveWorkSolHash_ == msg.solution) { + LOGTRACE << "proveWork got a receipt" << BVAR(proveWorkBeneficiary_) + << VAR(proveWorkCreditAmount_) + << VAR(proveWorkServerTimeSecsEpoch_) + << BVAR(proveWorkSolHash_); + beneficiary = proveWorkBeneficiary_; + if (beneficiary != msg.ckey) { + LOGWARNING << "proveWork server informed mismatched beneficiary" + << BVAR(beneficiary) << BVAR(msg.ckey); + } + creditAmount = proveWorkCreditAmount_; + time = proveWorkServerTimeSecsEpoch_; + return minx::MINX_SOLUTION_SPENT; + } + + // Stale response for a different solution — drain it and keep waiting + // without re-sending (the server already has our request) + LOGTRACE << "proveWork got stale receipt for another solution; draining" + << BVAR(proveWorkSolHash_) << BVAR(msg.solution); + g = proveWorkGen_; + staleCount++; + } + } + + LOGTRACE << "proveWork failed to get a receipt; querying PoW status..."; + + if (!ensureServerTicket()) { + LOGTRACE << "proveWork can't get a server ticket for queryPoW"; + return minx::MINX_SOLUTION_UNKNOWN; + } + + CesUnsignedQuerySolution cesUnsignedQuerySolution{msg.time, msg.solution}; + + for (int i = 0; i < tries_; ++i) { + // Fresh password each retry + minx::MinxMessage pqmsg{0, transport_->generatePassword(), serverTicket_, + cesUnsignedQuerySolution.toBytes()}; + uint64_t g = solQueryGen_; + + LOGTRACE << "proveWork queryPoW sending message" << VAR(i) + << VAR(tries_); + transport_->sendMessage(pqmsg); + LOGTRACE << "proveWork queryPoW waiting for reply"; + + int staleCount = 0; + while (staleCount < tries_) { + auto res = + ces::waitFor(retryIntervalMs_, [&]() { return g < solQueryGen_; }); + + if (res == ces::WaitResult::Interrupted) { + LOGTRACE << "proveWork queryPoW interrupted"; + return minx::MINX_SOLUTION_UNKNOWN; + } + + if (res == ces::WaitResult::Timeout) { + LOGTRACE << "proveWork queryPoW timeout; retrying..." << VAR(i) + << VAR(tries_); + break; // retry send + } + + if (solQueryHash_ == msg.solution) { + LOGTRACE << "proveWork queryPoW got answer" << VAR(solQueryCode_) + << BVAR(solQueryHash_); + beneficiary = {}; + creditAmount = 0; + time = 0; + return solQueryCode_; + } + + LOGTRACE << "proveWork queryPoW got stale answer; draining" + << BVAR(solQueryHash_) << BVAR(msg.solution); + g = solQueryGen_; + staleCount++; + } + } + LOGTRACE << "proveWork queryPoW out of tries"; + return minx::MINX_SOLUTION_UNKNOWN; +} + +uint8_t CesClient::queryAccount(const ces::HashPrefix& accountMapKey, + int64_t& balance, uint32_t& nonce, + ces::HashPrefix& lastXferDest, + uint64_t& lastXferAmount, + uint32_t& lastXferTime) { + LOGTRACE << "queryAccount"; + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + + CesUnsignedQueryAccount cesUnsignedQueryAccount{accountMapKey}; + minx::MinxMessage msg{0, transport_->generatePassword(), serverTicket_, + cesUnsignedQueryAccount.toBytes()}; + + uint64_t g = accQueryGen_; + int staleCount = 0; + for (int i = 0; i < tries_; ++i) { + LOGTRACE << "query account sending message" << VAR(i) << VAR(tries_); + transport_->sendMessage(msg); + LOGTRACE << "query account waiting for reply"; + auto res = + ces::waitFor(retryIntervalMs_, [&]() { return g < accQueryGen_; }); + + switch (res) { + case ces::WaitResult::Success: + if (accQueryId_ != accountMapKey) { + LOGTRACE << "got queryAccount response mismatched ID; ignoring"; + g = accQueryGen_; + if (++staleCount < tries_) { --i; } + continue; + } + balance = accQueryBal_; + nonce = accQueryNonce_; + lastXferDest = accQueryLastXferDest_; + lastXferAmount = accQueryLastXferAmount_; + lastXferTime = accQueryLastXferTime_; + return CES_OK; + case ces::WaitResult::Interrupted: + return CES_ERROR_INTERNAL; + case ces::WaitResult::Timeout: + break; + } + LOGTRACE << "queryAccount timeout; retrying..." << VAR(i) + << VAR(tries_); + } + return CES_ERROR_TIMEOUT; +} + +uint8_t CesClient::queryAccount(const ces::HashPrefix& accountMapKey, + int64_t& balance, uint32_t& nonce) { + ces::HashPrefix xd{}; + uint64_t xa = 0; + uint32_t xt = 0; + return queryAccount(accountMapKey, balance, nonce, xd, xa, xt); +} + +uint8_t CesClient::getMyNonce(uint32_t& outNextNonce) { + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + int64_t bal = 0; + uint32_t nonce = 0; + if (queryAccount(myId, bal, nonce) != CES_OK) + return CES_ERROR_INTERNAL; + outNextNonce = nonce + 1; + return CES_OK; +} + +uint8_t CesClient::queryAccountSigned(const ces::HashPrefix& accountMapKey, + uint8_t items, + std::vector& accounts) { + LOGTRACE << "queryAccountSigned"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesQueryAccount req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.queryId = accountMapKey; + req.items = items; + + uint8_t rc = sendSigned(req, accSignedQueryGen_, accSignedQueryResultCode_, + [&] { + return accSignedQueryReqNonce_ == reqNonce && + accSignedQueryOriginId_ == myId; + }); + + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) { + if (accSignedQueryResultCode_ == CES_OK) + accounts = accSignedQueryAccounts_; + else + accounts.clear(); + } + return rc; +} + +uint8_t CesClient::transfer(const ces::Hash& dest, uint64_t amount, + int64_t& newOriginBal) { + LOGTRACE << "transfer (safe)"; + newOriginBal = std::numeric_limits::max(); + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + CesTransfer req; + req.originId = keyPair_.getPublicKeyAsHash(); + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.destKey = dest; + req.amount = amount; + + uint8_t rc = sendSigned(req, transferGen_, transferResultCode_, [&] { + return transferResultNonce_ == reqNonce; + }); + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) + newOriginBal = transferNewOriginBal_; + return rc; +} + +uint8_t CesClient::openTransfer(const ces::Hash& dest, uint64_t amount, + int64_t& newOriginBal) { + LOGTRACE << "openTransfer"; + newOriginBal = std::numeric_limits::max(); + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + CesOpenTransfer req; + req.originId = keyPair_.getPublicKeyAsHash(); + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.destKey = dest; + req.amount = amount; + + uint8_t rc = sendSigned(req, openTransferGen_, openTransferResultCode_, [&] { + return openTransferResultNonce_ == reqNonce; + }); + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) + newOriginBal = openTransferNewOriginBal_; + return rc; +} + +uint8_t CesClient::createPayment(const ces::Hash& dest, uint64_t amount, + uint8_t days, int64_t& newOriginBal) { + LOGTRACE << "createPayment"; + newOriginBal = std::numeric_limits::max(); + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + CesCreatePayment req; + req.originId = keyPair_.getPublicKeyAsHash(); + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.destKey = dest; + req.amount = amount; + req.days = days; + + uint8_t rc = sendSigned(req, createPaymentGen_, createPaymentResultCode_, [&] { + return createPaymentResultNonce_ == reqNonce; + }); + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) + newOriginBal = createPaymentNewOriginBal_; + return rc; +} + +uint8_t CesClient::crossTransfer(const ces::Hash& dest, uint64_t amount, + const std::string& destServer, + int64_t& newOriginBal) { + LOGTRACE << "crossTransfer"; + newOriginBal = std::numeric_limits::max(); + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + CesCrossTransfer req; + req.originId = keyPair_.getPublicKeyAsHash(); + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.destKey = dest; + req.amount = amount; + req.destServer = destServer; + + uint8_t rc = sendSigned(req, crossTransferGen_, crossTransferResultCode_, [&] { + return crossTransferResultNonce_ == reqNonce; + }); + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) + newOriginBal = crossTransferNewOriginBal_; + return rc; +} + +uint8_t CesClient::bulkTransfer(const std::vector& transfers, + int64_t& newOriginBal, uint8_t& successfulCount) { + LOGTRACE << "bulkTransfer"; + newOriginBal = std::numeric_limits::max(); + successfulCount = 0; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + + CesBulkTransfer req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.transfers = transfers; + + uint8_t rc = sendSigned(req, bulkTransferGen_, bulkTransferResultCode_, [&] { + return bulkTransferResultNonce_ == reqNonce; + }); + + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) { + newOriginBal = bulkTransferNewOriginBal_; + successfulCount = bulkTransferSuccessfulCount_; + return rc; + } + if (rc == CES_ERROR_INTERNAL) + return rc; + + // Reply lost: the outcome is unknown (a partial bulk advances the nonce too, + // so a nonce check can't distinguish full from partial). Report TIMEOUT with + // the explicit unknown sentinel rather than guessing a count; callers needing + // the result re-query the destinations. + successfulCount = BULK_COUNT_UNKNOWN; + return CES_ERROR_TIMEOUT; +} + +std::optional +CesClient::mine(const uint8_t extraDifficulty, + const std::map& appData, + int numThreads, uint64_t startNonce, uint64_t maxIters) { + LOGTRACE << "mine"; + if (!ensureServerTicket()) { + LOGDEBUG << "mine can't refresh ticket / info"; + return {}; + } + transport_->createPoWEngine(serverKey_); + while (!transport_->checkPoWEngine(serverKey_)) { + if (!ces::sleep(POW_INIT_POLL_MS)) + return {}; + } + + // Serialize app data and compute hdata = SHA256(serialized) + minx::Hash hdata = {}; + std::vector serializedData; + if (!appData.empty()) { + // Use logkv serialization + size_t sz = logkv::serializer>::get_size(appData); + serializedData.resize(sz); + logkv::serializer>::write( + serializedData.data(), sz, appData); + } + // Always hash (empty payload → hash of empty = deterministic) + CryptoPP::SHA256().CalculateDigest( + hdata.data(), + reinterpret_cast(serializedData.data()), + serializedData.size()); + + int targetDiff = serverMinDiff_ + extraDifficulty; + auto myPubKey = keyPair_.getPublicKeyAsHash(); + auto result = transport_->proveWork(myPubKey, hdata, serverKey_, targetDiff, + numThreads, startNonce, maxIters); + + // Attach serialized data to the PoW message + if (result && !serializedData.empty()) + result->data = std::move(serializedData); + + return result; +} + +// ============================================================================= +// ASSET OPERATIONS +// ============================================================================= + +uint8_t CesClient::createAsset(const Hash& assetId, const AssetData& content, + uint16_t days, bool private_, bool immutable, + bool ownerPays) { + LOGTRACE << "createAsset"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesCreateAsset req; + req.ownerId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.content = content; + req.amount = assetBalance(days, private_, /*aowned=*/false, immutable, ownerPays); + req.price = 0; + + return sendSigned(req, createAssetGen_, createAssetResultCode_, [&] { + return createAssetResultNonce_ == reqNonce && + createAssetResultOriginId_ == myId; + }); +} + +uint8_t CesClient::createAssetRange(const Hash& firstKey, uint32_t count, + uint16_t days) { + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + CesCreateAssetRange req; + req.ownerId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.firstKey = firstKey; + req.count = count; + req.days = days; + return sendSigned(req, createAssetRangeGen_, createAssetRangeResultCode_, [&] { + return createAssetRangeResultNonce_ == reqNonce && + createAssetRangeResultOriginId_ == myId; + }); +} + +uint8_t CesClient::writeAlias(uint32_t aliasId, uint16_t offset, + const ces::Bytes& bytes, uint32_t& outAliasId) { + outAliasId = 0; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + CesSetAlias req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.aliasId = aliasId; + req.offset = offset; + req.bytes = bytes; + uint8_t rc = sendSigned(req, setAliasGen_, setAliasResultCode_, [&] { + return setAliasResultNonce_ == reqNonce && + setAliasResultOriginId_ == myId; + }); + if (rc == CES_OK) + outAliasId = setAliasResultId_; + return rc; +} + +uint8_t CesClient::deleteAlias() { + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + CesDeleteAlias req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + return sendSigned(req, deleteAliasGen_, deleteAliasResultCode_, [&] { + return deleteAliasResultNonce_ == reqNonce && + deleteAliasResultOriginId_ == myId; + }); +} + +uint8_t CesClient::readAlias(uint32_t aliasId, uint16_t offset, + uint16_t length, ces::Bytes& outBytes, + bool& outFound) { + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + CesQueryAlias req{aliasId, offset, length}; + minx::MinxMessage msg{0, transport_->generatePassword(), serverTicket_, + req.toBytes()}; + uint64_t g = queryAliasGen_; + int staleCount = 0; + for (int i = 0; i < tries_; ++i) { + transport_->sendMessage(msg); + auto res = + ces::waitFor(retryIntervalMs_, [&]() { return g < queryAliasGen_; }); + switch (res) { + case ces::WaitResult::Success: + if (queryAliasResultId_ != aliasId || + queryAliasResultOffset_ != offset) { + g = queryAliasGen_; + if (++staleCount < tries_) { --i; } + continue; + } + outFound = queryAliasResultFound_ != 0; + outBytes = queryAliasResultBytes_; + return CES_OK; + case ces::WaitResult::Interrupted: + return CES_ERROR_INTERNAL; + case ces::WaitResult::Timeout: + break; + } + } + return CES_ERROR_TIMEOUT; +} + +uint8_t CesClient::registerKeyName(const ces::Bytes& name) { + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + CesRegisterKeyName req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.name = name; + return sendSigned(req, registerKeyNameGen_, registerKeyNameResultCode_, [&] { + return registerKeyNameResultNonce_ == reqNonce && + registerKeyNameResultOriginId_ == myId; + }); +} + +uint8_t CesClient::clearKeyName() { + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + CesClearKeyName req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + return sendSigned(req, clearKeyNameGen_, clearKeyNameResultCode_, [&] { + return clearKeyNameResultNonce_ == reqNonce && + clearKeyNameResultOriginId_ == myId; + }); +} + +uint8_t CesClient::queryKeyName(const Hash& key, ces::Bytes& outName, + bool& outFound) { + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + CesQueryKeyName req; + req.key = key; + minx::MinxMessage msg{0, transport_->generatePassword(), serverTicket_, + req.toBytes()}; + uint64_t g = queryKeyNameGen_; + int staleCount = 0; + for (int i = 0; i < tries_; ++i) { + transport_->sendMessage(msg); + auto res = ces::waitFor(retryIntervalMs_, + [&]() { return g < queryKeyNameGen_; }); + switch (res) { + case ces::WaitResult::Success: + if (queryKeyNameResultKey_ != key) { + g = queryKeyNameGen_; + if (++staleCount < tries_) { --i; } + continue; + } + outFound = queryKeyNameResultFound_ != 0; + outName = queryKeyNameResultName_; + return CES_OK; + case ces::WaitResult::Interrupted: + return CES_ERROR_INTERNAL; + case ces::WaitResult::Timeout: + break; + } + } + return CES_ERROR_TIMEOUT; +} + +uint8_t CesClient::queryKeyNameByName(const ces::Bytes& name, Hash& outKey, + bool& outFound) { + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + CesQueryKeyNameByName req; + req.name = name; + minx::MinxMessage msg{0, transport_->generatePassword(), serverTicket_, + req.toBytes()}; + uint64_t g = qknByNameGen_; + int staleCount = 0; + for (int i = 0; i < tries_; ++i) { + transport_->sendMessage(msg); + auto res = ces::waitFor(retryIntervalMs_, + [&]() { return g < qknByNameGen_; }); + switch (res) { + case ces::WaitResult::Success: + if (qknByNameResultName_ != name) { // stale reply from a prior lookup + g = qknByNameGen_; + if (++staleCount < tries_) { --i; } + continue; + } + outFound = qknByNameResultFound_ != 0; + outKey = qknByNameResultKey_; + return CES_OK; + case ces::WaitResult::Interrupted: + return CES_ERROR_INTERNAL; + case ces::WaitResult::Timeout: + break; + } + } + return CES_ERROR_TIMEOUT; +} + +uint8_t CesClient::updateAsset(const Hash& assetId, const HashPrefix& newOwner, + const AssetData& content, uint32_t price) { + LOGTRACE << "updateAsset"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesUpdateAsset req; + req.ownerId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.content = content; + req.price = price; + req.newOwnerId = newOwner; + + return sendSigned(req, updateAssetGen_, updateAssetResultCode_, [&] { + return updateAssetResultNonce_ == reqNonce && + updateAssetResultOwnerId_ == myId; + }); +} + +uint8_t CesClient::updateAssetMeta(const Hash& assetId, + const HashPrefix& newOwner, uint32_t price) { + LOGTRACE << "updateAssetMeta"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesUpdateAssetMeta req; + req.ownerId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.newOwnerId = newOwner; + req.price = price; + + return sendSigned(req, updateAssetMetaGen_, updateAssetMetaResultCode_, [&] { + return updateAssetMetaResultNonce_ == reqNonce && + updateAssetMetaResultOwnerId_ == myId; + }); +} + +uint8_t CesClient::setAssetOwnerPays(const Hash& assetId, bool ownerPays) { + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + CesSetAssetOwnerPays req; + req.ownerId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.ownerPays = ownerPays ? 1 : 0; + return sendSigned(req, setAssetOwnerPaysGen_, setAssetOwnerPaysResultCode_, [&] { + return setAssetOwnerPaysResultNonce_ == reqNonce && + setAssetOwnerPaysResultOwnerId_ == myId; + }); +} + +uint8_t CesClient::updateAssetFast(const Hash& assetId, + const AssetData& content) { + LOGTRACE << "updateAssetFast"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesUpdateAssetFast req; + req.ownerId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.content = content; + + return sendSigned(req, updateAssetFastGen_, updateAssetFastResultCode_, [&] { + return updateAssetFastResultNonce_ == reqNonce && + updateAssetFastResultOwnerId_ == myId; + }); +} + +uint8_t CesClient::fundAsset(const Hash& assetId, uint16_t days) { + LOGTRACE << "fundAsset"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesFundAsset req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.amount = days; + + return sendSigned(req, fundAssetGen_, fundAssetResultCode_, [&] { + return fundAssetResultNonce_ == reqNonce && + fundAssetResultOriginId_ == myId; + }); +} + +uint8_t CesClient::buyAsset(const Hash& assetId, uint64_t amount) { + LOGTRACE << "buyAsset"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesBuyAsset req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.priceLimit = amount; + + return sendSigned(req, buyAssetGen_, buyAssetResultCode_, [&] { + return buyAssetResultNonce_ == reqNonce && + buyAssetResultOriginId_ == myId; + }); +} + +uint8_t CesClient::giveAsset(const Hash& assetId, const HashPrefix& newOwner) { + LOGTRACE << "giveAsset"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesGiveAsset req; + req.ownerId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.newOwnerId = newOwner; + + return sendSigned(req, giveAssetGen_, giveAssetResultCode_, [&] { + return giveAssetResultNonce_ == reqNonce && + giveAssetResultOwnerId_ == myId; + }); +} + +uint8_t CesClient::runAsset(const Hash& assetId, uint64_t budget, + const ces::Bytes& input, + uint64_t& outVmError, uint64_t& outBudgetUsed, + ces::Bytes& outOutput, + bool nonceless, + uint64_t allowance) { + LOGTRACE << "runAsset"; + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + + uint32_t reqNonce; + if (nonceless) { + reqNonce = CES_NONCELESS; + } else { + if (getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + } + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesRunAsset req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.budget = budget; + req.allowance = allowance; + req.time = getMicrosSinceEpoch(); + req.input = input; + + // Nonceless mode uses a server-assigned nonce, so the nonce match is + // skipped — we only check originId. + uint8_t rc = sendSigned(req, runAssetGen_, runAssetResultCode_, [&] { + if (nonceless) + return runAssetResultOriginId_ == myId; + return runAssetResultNonce_ == reqNonce && + runAssetResultOriginId_ == myId; + }); + + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) { + outVmError = runAssetResultVmError_; + outBudgetUsed = runAssetResultBudgetUsed_; + outOutput = runAssetResultOutput_; + } + return rc; +} + +uint8_t CesClient::runAlias(uint32_t aliasId, uint64_t budget, + const ces::Bytes& input, + uint64_t& outVmError, uint64_t& outBudgetUsed, + ces::Bytes& outOutput, + bool nonceless, + uint64_t allowance) { + LOGTRACE << "runAlias"; + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + + uint32_t reqNonce; + if (nonceless) { + reqNonce = CES_NONCELESS; + } else { + if (getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + } + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesRunAlias req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.aliasId = aliasId; + req.budget = budget; + req.allowance = allowance; + req.time = getMicrosSinceEpoch(); + req.input = input; + + uint8_t rc = sendSigned(req, runAliasGen_, runAliasResultCode_, [&] { + if (nonceless) + return runAliasResultOriginId_ == myId; + return runAliasResultNonce_ == reqNonce && + runAliasResultOriginId_ == myId; + }); + + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) { + outVmError = runAliasResultVmError_; + outBudgetUsed = runAliasResultBudgetUsed_; + outOutput = runAliasResultOutput_; + } + return rc; +} + +uint8_t CesClient::gossip(const ces::Bytes& msg, uint64_t budget, + const Hash& dest) { + LOGTRACE << "gossip"; + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesGossip req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = CES_NONCELESS; // gossip dedups by msgId, not nonce + req.authorId = myFullKey; + req.dest = dest; + req.budget = budget; + req.time = getMicrosSinceEpoch(); + req.msg = msg; + + // Stable msgId = sha256(author || time || msg) so a retried send dedups + // server-side instead of injecting a second flood. + ces::Bytes seed; + seed.insert(seed.end(), myFullKey.begin(), myFullKey.end()); + uint64_t t = req.time; + for (int i = 0; i < 8; ++i) + seed.push_back(static_cast((t >> (i * 8)) & 0xff)); + seed.insert(seed.end(), msg.begin(), msg.end()); + CryptoPP::SHA256().CalculateDigest(req.msgId.data(), seed.data(), seed.size()); + + return sendSigned(req, gossipGen_, gossipResultCode_, + [&] { return gossipResultOriginId_ == myId; }); +} + +uint8_t CesClient::queryAssetSigned(const Hash& assetId, uint8_t items, + std::vector& assets) { + LOGTRACE << "queryAsset"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + CesQueryAsset req; + req.originId = keyPair_.getPublicKeyAsHash(); + req.serverId = getServerId(); + req.reqNonce = reqNonce; + req.assetId = assetId; + req.items = items; + + // Loose match (gen only, no reqNonce/originId): the asset-query response + // pipeline doesn't track which request is which. + uint8_t rc = sendSigned(req, assetQueryGen_, assetQueryResultCode_, + [] { return true; }); + + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) { + if (assetQueryResultCode_ == CES_OK) + assets = assetQueryAssets_; + else + assets.clear(); + } + return rc; +} + +uint8_t CesClient::queryAsset(const Hash& assetId, HashPrefix& outOwner, + AssetData& outContent, uint16_t& outBalance, + uint32_t& outPrice) { + LOGTRACE << "queryAsset (unsigned)"; + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + + CesUnsignedQueryAsset req; + req.assetId = assetId; + + minx::MinxMessage msg{0, transport_->generatePassword(), serverTicket_, + req.toBytes()}; + uint64_t g = assetUnsignedQueryGen_; + int staleCount = 0; + + for (int i = 0; i < tries_; ++i) { + transport_->sendMessage(msg); + auto res = ces::waitFor(retryIntervalMs_, + [&]() { return g < assetUnsignedQueryGen_; }); + + switch (res) { + case ces::WaitResult::Success: + if (assetUnsignedQueryId_ != assetId) { + LOGTRACE << "got queryAsset response mismatched ID; ignoring"; + g = assetUnsignedQueryGen_; + if (++staleCount < tries_) { --i; } + continue; + } + outOwner = assetUnsignedQueryOwner_; + outContent = assetUnsignedQueryContent_; + outBalance = assetUnsignedQueryBalance_; + outPrice = assetUnsignedQueryPrice_; + return CES_OK; + + case ces::WaitResult::Interrupted: + return CES_ERROR_INTERNAL; + case ces::WaitResult::Timeout: + break; + } + LOGTRACE << "queryAsset timeout; retrying..." << VAR(i) << VAR(tries_); + } + return CES_ERROR_TIMEOUT; +} + +uint8_t CesClient::queryPeerInfo(uint16_t index, uint16_t& outCount, bool& outFound, + Hash& outPubkey, std::string& outAddress) { + if (!ensureServerTicket()) + return CES_ERROR_INTERNAL; + + CesUnsignedQueryPeerInfo req; + req.index = index; + minx::MinxMessage msg{0, transport_->generatePassword(), serverTicket_, + req.toBytes()}; + uint64_t g = peerQueryGen_; + int staleCount = 0; + + for (int i = 0; i < tries_; ++i) { + transport_->sendMessage(msg); + auto res = ces::waitFor(retryIntervalMs_, + [&]() { return g < peerQueryGen_; }); + switch (res) { + case ces::WaitResult::Success: + if (peerQueryIndex_ != index) { + g = peerQueryGen_; + if (++staleCount < tries_) { --i; } + continue; + } + outCount = peerQueryCount_; + outFound = peerQueryFound_ != 0; + outPubkey = peerQueryPubkey_; + { + const auto& a = peerQueryAddress_; + size_t n = 0; + while (n < a.size() && a[n] != 0) ++n; + outAddress.assign(reinterpret_cast(a.data()), n); + } + return CES_OK; + case ces::WaitResult::Interrupted: + return CES_ERROR_INTERNAL; + case ces::WaitResult::Timeout: + break; + } + } + return CES_ERROR_TIMEOUT; +} + +uint8_t CesClient::queryServerInfo(std::vector& outEntries) { + LOGTRACE << "queryServerInfo"; + uint32_t reqNonce; + if (!ensureServerTicket() || getMyNonce(reqNonce) != CES_OK) + return CES_ERROR_INTERNAL; + + Hash myFullKey = keyPair_.getPublicKeyAsHash(); + HashPrefix myId = Account::getMapKey(myFullKey); + + CesQueryServerInfo req; + req.originId = myFullKey; + req.serverId = getServerId(); + req.reqNonce = reqNonce; + + uint8_t rc = sendSigned(req, serverInfoExtGen_, serverInfoExtResultCode_, [&] { + return serverInfoExtReqNonce_ == reqNonce && + serverInfoExtOriginId_ == myId; + }); + + if (rc != CES_ERROR_INTERNAL && rc != CES_ERROR_TIMEOUT) { + if (serverInfoExtResultCode_ == CES_OK) + outEntries = serverInfoExtEntries_; + else + outEntries.clear(); + } + return rc; +} + +// ============================================================================= +// MESSAGE DISPATCH +// ============================================================================= + +void CesClient::incomingInfo(const minx::SockAddr& addr, + const minx::MinxInfo& msg) { + // INFO is unsigned and sets serverKey_; drop one from a previous server. + if (!isCurrentPeer(addr)) return; + LOGTRACE << "incomingInfo" << VAR(msg.data.size()); + serverTicket_ = msg.gpassword; + serverTicketLastTime_ = minx::getSecsSinceEpoch(); + if (msg.data.size() < 7) { + LOGTRACE << "incomingInfo got broken data"; + return; + } + serverMinDiff_ = msg.difficulty; + serverKey_ = msg.skey; + minx::ConstBuffer buf(msg.data); + serverMinSecsPoW_ = buf.get(); + serverPendingPoWs_ = buf.get(); + serverTps_ = buf.get(); + serverRpcPort_ = buf.get(); + serverMinPoWTimestamp_ = + minx::getSecsSinceEpoch() + serverMinSecsPoW_ * SECS_PER_MIN; + ++serverInfoGen_; +} + +void CesClient::incomingApplication(const minx::SockAddr& addr, + const uint8_t /* code */, + const minx::Bytes& data) { + if (!isCurrentPeer(addr)) return; + if (appCallback_ && !data.empty()) + appCallback_(reinterpret_cast(data.data()), data.size()); +} + +void CesClient::incomingMessage(const minx::SockAddr& addr, + const minx::MinxMessage& msg) { + // Drop a straggler from a previous server before it clobbers serverTicket_. + if (!isCurrentPeer(addr)) return; + LOGTRACE << "incomingMessage"; + serverTicket_ = msg.gpassword; + serverTicketLastTime_ = minx::getSecsSinceEpoch(); + + if (msg.data.empty()) + return; + + minx::ConstBuffer buf(msg.data); + uint8_t code = buf.get(); + + try { + PublicKey verifier(serverKey_); + + // Deserialize a signed response, verify its signature, invoke the + // per-response field-copy lambda, then bump the generation counter. + // On sig-fail: log and do nothing (caller's request will retry or time + // out normally). + auto handleSigned = [&](auto res, const char* name, + std::atomic& gen, + auto&& copy) { + if (!res.fromBytes(msg.data, verifier)) { + LOGTRACE << name << " result sig fail"; + return; + } + copy(res); + ++gen; + }; + + // Same as handleSigned but for unsigned responses (no verifier). + auto handleUnsigned = [&](auto res, std::atomic& gen, + auto&& copy) { + res.fromBytes(msg.data); + copy(res); + ++gen; + }; + + switch (code) { + case CES_TRANSFER_RESULT: + handleSigned(CesTransferResult{}, "transfer", transferGen_, [&](auto& r) { + transferResultCode_ = r.rcode; + transferResultNonce_ = r.reqNonce; + transferNewOriginBal_ = r.originNewBalance; + }); + break; + + case CES_OPEN_TRANSFER_RESULT: + handleSigned(CesOpenTransferResult{}, "open transfer", openTransferGen_, + [&](auto& r) { + openTransferResultCode_ = r.rcode; + openTransferResultNonce_ = r.reqNonce; + openTransferNewOriginBal_ = r.originNewBalance; + }); + break; + + case CES_CREATE_PAYMENT_RESULT: + handleSigned(CesCreatePaymentResult{}, "create payment", + createPaymentGen_, [&](auto& r) { + createPaymentResultCode_ = r.rcode; + createPaymentResultNonce_ = r.reqNonce; + createPaymentNewOriginBal_ = r.originNewBalance; + }); + break; + + case CES_CROSS_TRANSFER_RESULT: + handleSigned(CesCrossTransferResult{}, "cross transfer", + crossTransferGen_, [&](auto& r) { + crossTransferResultCode_ = r.rcode; + crossTransferResultNonce_ = r.reqNonce; + crossTransferNewOriginBal_ = r.originNewBalance; + }); + break; + + case CES_BULK_TRANSFER_RESULT: + handleSigned(CesBulkTransferResult{}, "bulk transfer", bulkTransferGen_, + [&](auto& r) { + bulkTransferResultCode_ = r.rcode; + bulkTransferSuccessfulCount_ = r.successfulCount; + bulkTransferResultNonce_ = r.reqNonce; + bulkTransferNewOriginBal_ = r.originNewBalance; + }); + break; + + case CES_QUERY_ACCOUNT_RESULT: + handleSigned(CesQueryAccountResult{}, "signed query account", + accSignedQueryGen_, [&](auto& r) { + accSignedQueryOriginId_ = r.originId; + accSignedQueryReqNonce_ = r.reqNonce; + accSignedQueryResultCode_ = r.rcode; + accSignedQueryAccounts_ = r.accounts; + }); + break; + + case CES_SET_ALIAS_RESULT: + handleSigned(CesSetAliasResult{}, "set alias", setAliasGen_, + [&](auto& r) { + setAliasResultOriginId_ = r.originId; + setAliasResultNonce_ = r.reqNonce; + setAliasResultId_ = r.aliasId; + setAliasResultCode_ = r.rcode; + }); + break; + + case CES_DELETE_ALIAS_RESULT: + handleSigned(CesDeleteAliasResult{}, "delete alias", deleteAliasGen_, + [&](auto& r) { + deleteAliasResultOriginId_ = r.originId; + deleteAliasResultNonce_ = r.reqNonce; + deleteAliasResultCode_ = r.rcode; + }); + break; + + case CES_QUERY_ALIAS_RESULT: + handleUnsigned(CesQueryAliasResult{}, queryAliasGen_, [&](auto& r) { + queryAliasResultId_ = r.aliasId; + queryAliasResultOffset_ = r.offset; + queryAliasResultBytes_ = r.bytes; + queryAliasResultFound_ = r.found; + }); + break; + + case CES_REGISTER_KEYNAME_RESULT: + handleSigned(CesRegisterKeyNameResult{}, "register keyname", + registerKeyNameGen_, [&](auto& r) { + registerKeyNameResultOriginId_ = r.originId; + registerKeyNameResultNonce_ = r.reqNonce; + registerKeyNameResultCode_ = r.rcode; + }); + break; + + case CES_CLEAR_KEYNAME_RESULT: + handleSigned(CesClearKeyNameResult{}, "clear keyname", clearKeyNameGen_, + [&](auto& r) { + clearKeyNameResultOriginId_ = r.originId; + clearKeyNameResultNonce_ = r.reqNonce; + clearKeyNameResultCode_ = r.rcode; + }); + break; + + case CES_QUERY_KEYNAME_RESULT: + handleUnsigned(CesQueryKeyNameResult{}, queryKeyNameGen_, [&](auto& r) { + queryKeyNameResultKey_ = r.key; + queryKeyNameResultName_ = r.name; + queryKeyNameResultFound_ = r.found; + }); + break; + + case CES_QUERY_KEYNAME_BY_NAME_RESULT: + handleUnsigned(CesQueryKeyNameByNameResult{}, qknByNameGen_, + [&](auto& r) { + qknByNameResultName_ = r.name; + qknByNameResultFound_ = r.found; + qknByNameResultKey_ = r.key; + }); + break; + + case CES_UNSIGNED_QUERY_ACCOUNT_RESULT: + handleUnsigned(CesUnsignedQueryAccountResult{}, accQueryGen_, + [&](auto& r) { + accQueryId_ = r.queryId; + accQueryBal_ = r.bal; + accQueryNonce_ = r.nonce; + accQueryLastXferDest_ = r.lastXferDest; + accQueryLastXferAmount_ = r.lastXferAmount; + accQueryLastXferTime_ = r.lastXferTime; + accQueryAliasId_ = r.aliasId; + }); + break; + + case CES_UNSIGNED_QUERY_SOLUTION_RESULT: + handleUnsigned(CesUnsignedQuerySolutionResult{}, solQueryGen_, + [&](auto& r) { + solQueryHash_ = r.querySolution; + solQueryCode_ = r.queryResult; + }); + break; + + case CES_PROVE_WORK_RESULT: + handleSigned(CesProveWorkResult{}, "prove work", proveWorkGen_, + [&](auto& r) { + proveWorkSolHash_ = r.solution; + proveWorkBeneficiary_ = r.beneficiary; + proveWorkCreditAmount_ = r.creditAmount; + proveWorkServerTimeSecsEpoch_ = r.serverTime; + }); + break; + + case CES_CREATE_ASSET_RESULT: + handleSigned(CesCreateAssetResult{}, "create asset", createAssetGen_, + [&](auto& r) { + createAssetResultOriginId_ = r.ownerId; + createAssetResultNonce_ = r.reqNonce; + createAssetResultCode_ = r.rcode; + }); + break; + + case CES_CREATE_ASSET_RANGE_RESULT: + handleSigned(CesCreateAssetRangeResult{}, "create asset range", + createAssetRangeGen_, [&](auto& r) { + createAssetRangeResultOriginId_ = r.ownerId; + createAssetRangeResultNonce_ = r.reqNonce; + createAssetRangeResultCode_ = r.rcode; + }); + break; + + case CES_UPDATE_ASSET_RESULT: + handleSigned(CesUpdateAssetResult{}, "update asset", updateAssetGen_, + [&](auto& r) { + updateAssetResultOwnerId_ = r.ownerId; + updateAssetResultNonce_ = r.reqNonce; + updateAssetResultCode_ = r.rcode; + }); + break; + + case CES_UPDATE_ASSET_META_RESULT: + handleSigned(CesUpdateAssetMetaResult{}, "update asset meta", + updateAssetMetaGen_, [&](auto& r) { + updateAssetMetaResultOwnerId_ = r.ownerId; + updateAssetMetaResultNonce_ = r.reqNonce; + updateAssetMetaResultCode_ = r.rcode; + }); + break; + + case CES_SET_ASSET_OWNER_PAYS_RESULT: + handleSigned(CesSetAssetOwnerPaysResult{}, "set asset owner-pays", + setAssetOwnerPaysGen_, [&](auto& r) { + setAssetOwnerPaysResultOwnerId_ = r.ownerId; + setAssetOwnerPaysResultNonce_ = r.reqNonce; + setAssetOwnerPaysResultCode_ = r.rcode; + }); + break; + + case CES_UPDATE_ASSET_FAST_RESULT: + handleSigned(CesUpdateAssetFastResult{}, "update asset fast", + updateAssetFastGen_, [&](auto& r) { + updateAssetFastResultOwnerId_ = r.ownerId; + updateAssetFastResultNonce_ = r.reqNonce; + updateAssetFastResultCode_ = r.rcode; + }); + break; + + case CES_FUND_ASSET_RESULT: + handleSigned(CesFundAssetResult{}, "fund asset", fundAssetGen_, + [&](auto& r) { + fundAssetResultOriginId_ = r.originId; + fundAssetResultNonce_ = r.reqNonce; + fundAssetResultCode_ = r.rcode; + }); + break; + + case CES_BUY_ASSET_RESULT: + handleSigned(CesBuyAssetResult{}, "buy asset", buyAssetGen_, + [&](auto& r) { + buyAssetResultOriginId_ = r.originId; + buyAssetResultNonce_ = r.reqNonce; + buyAssetResultCode_ = r.rcode; + }); + break; + + case CES_GIVE_ASSET_RESULT: + handleSigned(CesGiveAssetResult{}, "give asset", giveAssetGen_, + [&](auto& r) { + giveAssetResultOwnerId_ = r.ownerId; + giveAssetResultNonce_ = r.reqNonce; + giveAssetResultCode_ = r.rcode; + }); + break; + + case CES_RUN_ASSET_RESULT: + handleSigned(CesRunAssetResult{}, "run asset", runAssetGen_, + [&](auto& r) { + runAssetResultOriginId_ = r.originId; + runAssetResultNonce_ = r.reqNonce; + runAssetResultCode_ = r.rcode; + runAssetResultVmError_ = r.vmError; + runAssetResultBudgetUsed_ = r.budgetUsed; + runAssetResultAllowanceUsed_ = r.allowanceUsed; + runAssetResultOutput_ = std::move(r.output); + }); + break; + + case CES_RUN_ALIAS_RESULT: + handleSigned(CesRunAliasResult{}, "run alias", runAliasGen_, + [&](auto& r) { + runAliasResultOriginId_ = r.originId; + runAliasResultNonce_ = r.reqNonce; + runAliasResultCode_ = r.rcode; + runAliasResultVmError_ = r.vmError; + runAliasResultBudgetUsed_ = r.budgetUsed; + runAliasResultAllowanceUsed_ = r.allowanceUsed; + runAliasResultOutput_ = std::move(r.output); + }); + break; + + case CES_GOSSIP_RESULT: + handleSigned(CesGossipResult{}, "gossip", gossipGen_, [&](auto& r) { + gossipResultCode_ = r.rcode; + gossipResultOriginId_ = r.originId; + }); + break; + + case CES_QUERY_ASSET_RESULT: + handleSigned(CesQueryAssetResult{}, "asset query", assetQueryGen_, + [&](auto& r) { + assetQueryReqNonce_ = r.reqNonce; + assetQueryResultCode_ = r.rcode; + assetQueryAssets_ = r.assets; + }); + break; + + case CES_UNSIGNED_QUERY_ASSET_RESULT: + handleUnsigned(CesUnsignedQueryAssetResult{}, assetUnsignedQueryGen_, + [&](auto& r) { + assetUnsignedQueryId_ = r.assetId; + assetUnsignedQueryOwner_ = r.ownerId; + assetUnsignedQueryContent_ = r.content; + assetUnsignedQueryBalance_ = r.balance; + assetUnsignedQueryPrice_ = r.price; + }); + break; + + case CES_QUERY_PEER_INFO_RESULT: + handleUnsigned(CesUnsignedQueryPeerInfoResult{}, peerQueryGen_, + [&](auto& r) { + peerQueryIndex_ = r.index; + peerQueryCount_ = r.peerCount; + peerQueryFound_ = r.found; + peerQueryPubkey_ = r.pubkey; + peerQueryAddress_ = r.address; + }); + break; + + case CES_QUERY_SERVER_INFO_RESULT: + handleSigned(CesQueryServerInfoResult{}, "server info ext", + serverInfoExtGen_, [&](auto& r) { + serverInfoExtOriginId_ = r.originId; + serverInfoExtReqNonce_ = r.reqNonce; + serverInfoExtResultCode_ = r.rcode; + serverInfoExtEntries_ = std::move(r.entries); + }); + break; + + default: + LOGTRACE << "incomingMessage unknown message" << VAR(code); + throw std::runtime_error("Unknown message code"); + } + } catch (const std::exception& e) { + LOGTRACE << "incomingMessage parse error: " << e.what(); + } +} + +bool CesClient::ensureServerTicket() { + if ((serverTicket_ == 0) || + (minx::getSecsSinceEpoch() > + serverTicketLastTime_ + SERVER_TICKET_REFRESH_SECS)) { + if (!getInfo()) { + LOGTRACE << "ensureServerTicket can't refresh ticket"; + return false; + } + } + return true; +} + +} // namespace ces \ No newline at end of file diff --git a/vendor/ces/src/ceslib/client_only_compat.cpp b/vendor/ces/src/ceslib/client_only_compat.cpp new file mode 100644 index 0000000..3b8ef33 --- /dev/null +++ b/vendor/ces/src/ceslib/client_only_compat.cpp @@ -0,0 +1,20 @@ +// Compiled ONLY under CES_CLIENT_ONLY: the few free functions whose home +// translation unit is server-side (excluded from the client subset) but whose +// declaration is used by client code. + +#include +#include + +#include +#include + +namespace ces { + +// Home TU: ramfilestore.cpp (declared in ces/ramfilestore.h). +minx::Hash sha256(const uint8_t* data, size_t len) { + minx::Hash h; + CryptoPP::SHA256().CalculateDigest(h.data(), data, len); + return h; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/clientasync.cpp b/vendor/ces/src/ceslib/clientasync.cpp new file mode 100644 index 0000000..343d0d0 --- /dev/null +++ b/vendor/ces/src/ceslib/clientasync.cpp @@ -0,0 +1,402 @@ +#include +#include +#include + +namespace ces { + +CesClientAsync::CesClientAsync(boost::asio::io_context& io, + const boost::asio::ip::udp::endpoint& serverEndpoint, + const KeyPair& keyPair, + const Hash& peerServerKey, + size_t numChannels, + int maxRetries) + : io_(io), socket_(io, boost::asio::ip::udp::v6()), + serverEp_(serverEndpoint), sweepTimer_(io), keyPair_(keyPair), + peerServerKey_(peerServerKey), maxRetries_(maxRetries) { + boost::system::error_code ec; + socket_.set_option(boost::asio::ip::v6_only(false), ec); + // The socket is v6 dual-stack; a plain-IPv4 destination endpoint can't be sent + // on it (the AF_INET sockaddr is rejected). Normalize a v4 endpoint to + // v4-mapped-v6 (::ffff:a.b.c.d) so a peer whose address resolves to plain IPv4 + // — a v4 literal or a v4-first DNS result — is actually reachable. The CesPlex + // session path already does this; settlement was the outbound path missing it. + if (serverEp_.address().is_v4()) + serverEp_ = boost::asio::ip::udp::endpoint( + boost::asio::ip::make_address_v6(boost::asio::ip::v4_mapped, + serverEp_.address().to_v4()), + serverEp_.port()); + channels_.resize(numChannels); + LOGDEBUG << "CesClientAsync: " << numChannels << " channels to " << serverEp_; + startReceive(); +} + +CesClientAsync::~CesClientAsync() { close(); } + +void CesClientAsync::close() { + if (closed_) return; + closed_ = true; + { boost::system::error_code ec; sweepTimer_.cancel(); } + { boost::system::error_code ec; socket_.close(ec); } + failAll(CES_ERROR_INTERNAL); +} + +// ---- Public API ---- + +void CesClientAsync::openTransfer(const Hash& destKey, uint64_t amount, Callback cb) { + boost::asio::post(io_, [this, destKey, amount, cb = std::move(cb)]() mutable { + if (closed_) { cb(CES_ERROR_INTERNAL); return; } + if (queue_.size() >= MAX_QUEUE) { cb(CES_ERROR_QUEUE_FULL); return; } + ++pendingCount_; + + QueuedOp op; + op.destKey = destKey; + op.amount = amount; + op.cb = std::move(cb); + // Give up after the receiver's dedup window: past it, a retry is rejected + // stale anyway, so retrying is pointless. A bounded sender-side deadline also + // covers a half-up peer (answers handshakes, never replies to the op). Giving + // up is conserved -- the amount stays parked in the vostro, no reversal. + op.deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(SETTLEMENT_DEADLINE_MS); + + CesOpenTransfer msg; + msg.originId = keyPair_.getPublicKeyAsHash(); + msg.serverId = Account::getMapKey(peerServerKey_); + msg.reqNonce = CES_NONCELESS; + msg.destKey = destKey; + msg.amount = amount; + msg.time = ces::getMicrosSinceEpoch(); + op.signedPayload = msg.toBytes(keyPair_); + + queue_.push_back(std::move(op)); + dispatch(); + }); +} + +void CesClientAsync::gossip(const Hash& authorId, const Hash& msgId, + const Hash& dest, uint64_t budget, + const ces::Bytes& msg, GossipCallback cb) { + boost::asio::post(io_, [this, authorId, msgId, dest, budget, msg, + cb = std::move(cb)]() mutable { + if (closed_) { cb(CES_ERROR_INTERNAL, 0); return; } + if (queue_.size() >= MAX_QUEUE) { cb(CES_ERROR_QUEUE_FULL, 0); return; } + // Gossip yields to settlement: ride this client only when nothing is backed + // up waiting for a channel. Any settlement backlog rules gossip off this peer + // (best-effort burn, paid 0) so cross-transfers own the channels. In-flight + // ops do not count: a saturated but not overflowing client still carries + // gossip; the first op that cannot get a channel shuts gossip out. + if (!queue_.empty()) { + cb(CES_ERROR_QUEUE_FULL, 0); + return; + } + ++pendingCount_; + + QueuedOp op; + op.kind = OpKind::Gossip; + op.gcb = std::move(cb); + op.deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(GOSSIP_DEADLINE_MS); + + CesGossip m; + m.originId = keyPair_.getPublicKeyAsHash(); + m.serverId = Account::getMapKey(peerServerKey_); + m.reqNonce = CES_NONCELESS; + m.authorId = authorId; + m.msgId = msgId; + m.dest = dest; + m.budget = budget; + m.time = ces::getMicrosSinceEpoch(); + m.msg = msg; + op.signedPayload = m.toBytes(keyPair_); + + queue_.push_back(std::move(op)); + dispatch(); + }); +} + +// ---- Dispatch ---- + +void CesClientAsync::dispatch() { + if (closed_ || queue_.empty()) return; + + for (auto& ch : channels_) { + if (queue_.empty()) break; + if (ch.state == ChState::Idle) { + handshake(ch); + } else if (ch.state == ChState::Ready) { + ch.currentOp = std::move(queue_.front()); + queue_.pop_front(); + sendOp(ch); + } + } + + if (!queue_.empty()) + startSweepTimer(); +} + +// ---- Channel operations ---- + +void CesClientAsync::handshake(Channel& ch) { + ch.state = ChState::Handshaking; + ch.sentGPass = genPassword(); + ch.retries = 0; + ch.sentAt = std::chrono::steady_clock::now(); + sendGetInfo(ch); + startSweepTimer(); +} + +void CesClientAsync::sendGetInfo(Channel& ch) { + minx::ArrayBuffer<64> buf; + buf.put(minx::MINX_GET_INFO); + buf.put(0); + buf.put(ch.sentGPass); + sendBuf(buf); +} + +void CesClientAsync::sendOp(Channel& ch) { + ch.state = ChState::Busy; + ch.sentGPass = genPassword(); + ch.retries = 0; + ch.sentAt = std::chrono::steady_clock::now(); + + minx::ArrayBuffer<512> buf; + buf.put(minx::MINX_MESSAGE); + buf.put(0); + buf.put(ch.sentGPass); + buf.put(ch.ticket); + ch.ticket = 0; + auto& payload = ch.currentOp.signedPayload; + buf.put(std::span( + reinterpret_cast(payload.data()), payload.size())); + sendBuf(buf); + + startSweepTimer(); +} + +// ---- Receive ---- + +void CesClientAsync::startReceive() { + if (closed_) return; + socket_.async_receive_from( + boost::asio::buffer(recvBuf_), recvEp_, + [this](const boost::system::error_code& ec, size_t bytes) { + onReceive(ec, bytes); + }); +} + +void CesClientAsync::onReceive(const boost::system::error_code& ec, size_t bytes) { + if (ec || closed_) return; + // A malformed/truncated reply must not throw out of here (a fromBytes parse can + // throw): that would skip startReceive() and leave the client permanently deaf + // to this peer. Drop the bad datagram, keep the receive loop alive. + try { + if (bytes >= 1) { + switch (recvBuf_[0]) { + case minx::MINX_INFO: handleInfo(recvBuf_.data(), bytes); break; + case minx::MINX_MESSAGE: handleMessage(recvBuf_.data(), bytes); break; + default: break; + } + } + } catch (const std::exception&) { + // Bad packet from the peer; ignore it. + } + startReceive(); +} + +// MINX envelope preamble on the wire: code + version + gpassword + spassword. +// `parseMinxEnvelope` advances the buffer past these fields. INFO additionally +// carries a 32-byte server key and a 1-byte difficulty; MESSAGE carries at +// least one CES opcode byte of payload after the envelope. +static constexpr size_t MINX_ENVELOPE_BYTES = + sizeof(uint8_t) // code + + sizeof(uint8_t) // version + + sizeof(uint64_t) // gpassword + + sizeof(uint64_t); // spassword + +static void parseMinxEnvelope(minx::ConstBuffer& buf, + uint64_t& gpass, uint64_t& spass) { + buf.get(); // code + buf.get(); // version + gpass = buf.get(); + spass = buf.get(); +} + +void CesClientAsync::handleInfo(const uint8_t* data, size_t len) { + constexpr size_t MIN = + MINX_ENVELOPE_BYTES + + sizeof(minx::Hash) // skey + + sizeof(uint8_t); // difficulty + if (len < MIN) return; + + minx::ConstBuffer buf(std::span(data, len)); + uint64_t gpass, spass; + parseMinxEnvelope(buf, gpass, spass); + minx::Hash skey = buf.get(); + + for (auto& ch : channels_) { + if (ch.state == ChState::Handshaking && ch.sentGPass == spass) { + ch.ticket = gpass; + ch.serverKey = skey; + ch.state = ChState::Ready; + dispatch(); + return; + } + } +} + +void CesClientAsync::handleMessage(const uint8_t* data, size_t len) { + constexpr size_t MIN = + MINX_ENVELOPE_BYTES + + sizeof(uint8_t); // first CES opcode byte + if (len < MIN) return; + + minx::ConstBuffer buf(std::span(data, len)); + uint64_t gpass, spass; + parseMinxEnvelope(buf, gpass, spass); + + auto cesData = buf.getRemainingBytesSpan(); + if (cesData.empty()) return; + uint8_t opcode = cesData[0]; + if (opcode != CES_OPEN_TRANSFER_RESULT && opcode != CES_GOSSIP_RESULT) return; + + for (auto& ch : channels_) { + if (ch.state != ChState::Busy || ch.sentGPass != spass) continue; + + PublicKey verifier(ch.serverKey); + minx::Bytes cesBytes(cesData.begin(), cesData.end()); + uint8_t rcode = CES_ERROR_INTERNAL; + uint64_t paid = 0; + + if (opcode == CES_OPEN_TRANSFER_RESULT && + ch.currentOp.kind == OpKind::OpenTransfer) { + CesOpenTransferResult res; + if (!res.fromBytes(cesBytes, verifier)) { + LOGDEBUG << "CesClientAsync: ch" << chIdx(ch) << " sig verify failed"; + return; + } + rcode = res.rcode; + } else if (opcode == CES_GOSSIP_RESULT && + ch.currentOp.kind == OpKind::Gossip) { + CesGossipResult res; + if (!res.fromBytes(cesBytes, verifier)) { + LOGDEBUG << "CesClientAsync: ch" << chIdx(ch) << " sig verify failed"; + return; + } + rcode = res.rcode; + paid = res.paid; + } else { + return; // response opcode does not match the in-flight op kind + } + + ch.ticket = gpass; + ch.state = ChState::Ready; + --pendingCount_; + QueuedOp done = std::move(ch.currentOp); + ch.currentOp = {}; + fireOpCallback(done, rcode, paid); + dispatch(); + return; + } +} + +// ---- Sweep ---- + +void CesClientAsync::sweep() { + if (closed_) return; + + auto now = std::chrono::steady_clock::now(); + bool anyActive = false; + + for (auto& ch : channels_) { + if (ch.state == ChState::Handshaking) { + anyActive = true; + if (now - ch.sentAt <= std::chrono::milliseconds(HANDSHAKE_RETRY_MS)) + continue; + if (++ch.retries >= maxRetries_) { + ch.state = ChState::Idle; + } else { + ch.sentGPass = genPassword(); + ch.sentAt = now; + sendGetInfo(ch); + } + } + + else if (ch.state == ChState::Busy) { + anyActive = true; + bool isGossip = ch.currentOp.kind == OpKind::Gossip; + // Give the op up at its deadline (gossip ~5s, settlement ~1h); unset + // deadline (epoch) means retry within reachability indefinitely. + if (ch.currentOp.deadline != std::chrono::steady_clock::time_point{} && + now >= ch.currentOp.deadline) { + --pendingCount_; + QueuedOp done = std::move(ch.currentOp); + ch.currentOp = {}; + ch.state = ChState::Idle; + fireOpCallback(done, CES_ERROR_TIMEOUT, 0); + continue; + } + int retryMs = isGossip ? GOSSIP_RETRY_MS : RETRY_MS; + if (now - ch.sentAt <= std::chrono::milliseconds(retryMs)) + continue; + if (++ch.retries >= maxRetries_) { + --pendingCount_; + QueuedOp done = std::move(ch.currentOp); + ch.currentOp = {}; + ch.state = ChState::Idle; + fireOpCallback(done, CES_ERROR_TIMEOUT, 0); + } else { + queue_.push_front(std::move(ch.currentOp)); + ch.currentOp = {}; + handshake(ch); + } + } + } + + dispatch(); + + if (anyActive || !queue_.empty()) + startSweepTimer(); +} + +void CesClientAsync::startSweepTimer() { + sweepTimer_.expires_after(std::chrono::seconds(1)); + sweepTimer_.async_wait([this](const boost::system::error_code& ec) { + if (ec || closed_) return; + sweep(); + }); +} + +// ---- Cleanup ---- + +void CesClientAsync::failAll(uint8_t rc) { + for (auto& ch : channels_) { + if (ch.state == ChState::Busy) + fireOpCallback(ch.currentOp, rc, 0); + ch.state = ChState::Idle; + ch.currentOp = {}; + } + while (!queue_.empty()) { + fireOpCallback(queue_.front(), rc, 0); + queue_.pop_front(); + } + pendingCount_.store(0, std::memory_order_relaxed); +} + +// ---- Helpers ---- + +uint64_t CesClientAsync::genPassword() { return rngDist_(rng_); } + +template +void CesClientAsync::sendBuf(minx::ArrayBuffer& buf) { + // Copy the framed bytes into a heap buffer owned by the completion handler. + // async_send_to references the storage until the send completes, but `buf` + // is a stack local in the caller and would be destroyed before then. + auto span = buf.getBackingSpan(); + auto data = std::make_shared(span.data(), + span.data() + buf.getSize()); + socket_.async_send_to( + boost::asio::buffer(*data), serverEp_, + [data](const boost::system::error_code&, size_t) {}); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/extension_manager.cpp b/vendor/ces/src/ceslib/extension_manager.cpp new file mode 100644 index 0000000..b1a3849 --- /dev/null +++ b/vendor/ces/src/ceslib/extension_manager.cpp @@ -0,0 +1,367 @@ +// extension_manager.cpp - see ces/extension_manager.h. + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace ces { +namespace { + +// A name must be a bare basename: letters/digits/._- only, no "..". Keeps every +// derived path inside the catalog or /s/. +bool validName(const std::string& n) { + if (n.empty() || n.size() > 64) return false; + for (char c : n) + if (!(std::isalnum(static_cast(c)) || c == '.' || + c == '_' || c == '-')) + return false; + if (n.find("..") != std::string::npos) return false; + return true; +} + +// builtin:file is a per-server object now; the /s/ file ops route through it. +// Thin guarded wrappers keep the old "" / false-on-disabled contract. +std::string fhReadServerFile(CesServer* s, const std::string& name) { + FileHandler* fh = s ? s->fileHandler() : nullptr; + return fh ? fh->readServerFile(name) : std::string(); +} +bool fhWriteServerFile(CesServer* s, const std::string& name, + const std::string& content) { + FileHandler* fh = s ? s->fileHandler() : nullptr; + return fh && fh->writeServerFile(name, content); +} +bool fhRemoveServerFile(CesServer* s, const std::string& name) { + FileHandler* fh = s ? s->fileHandler() : nullptr; + return fh && fh->removeServerFile(name); +} + +std::string catalogDir(CesServer* s) { return s->_config().cesExtensionsDir; } +fs::path storeSDir(CesServer* s) { + return fs::path(s->_config().cesFileStoreDir) / "s"; +} +fs::path catalogLua(CesServer* s, const std::string& n) { + return fs::path(catalogDir(s)) / (n + ".lua"); +} +fs::path sLua(CesServer* s, const std::string& n) { + return storeSDir(s) / (n + ".lua"); +} +std::string srcName(const std::string& n) { return "/s/" + n + ".lua"; } + +// Pid of the running instance of /s/.lua, or 0. +uint64_t runningPid(CesServer* server, const std::string& name) { + ComputeHandler* h = server ? server->computeHandler() : nullptr; + if (!h) return 0; + std::string src = srcName(name); + for (auto& st : h->snapshot()) + if (st.source == src) return st.pid; + return 0; +} + +// /s/ file I/O is in the file handler (FileHandler::read/write/removeServerFile), +// reached via the guarded fhReadServerFile / fhWriteServerFile / fhRemoveServerFile. + +struct ProbedManifest { std::string name, version, description; }; + +std::string shellQuote(const std::string& s) { + std::string r = "'"; + for (char c : s) { if (c == '\'') r += "'\\''"; else r += c; } + r += "'"; + return r; +} + +// Identity for a NOT-running extension: run its file through +// `cesluajitd --manifest`, which loadfile-evaluates the static CES_MANIFEST table +// in a no-op sandbox (no source-text parsing). Cached by (path, mtime) so the +// subprocess runs once per file version, not per dashboard poll. Empty on any +// failure (binary missing, no manifest) -> the row shows its filename. +ProbedManifest probeManifest(CesServer* s, const fs::path& path) { + static std::mutex mx; + static std::map> cache; + std::error_code ec; + auto mt = fs::last_write_time(path, ec); + if (ec) return {}; + int64_t key = static_cast(mt.time_since_epoch().count()); + std::string ps = path.string(); + { + std::lock_guard lk(mx); + auto it = cache.find(ps); + if (it != cache.end() && it->second.first == key) return it->second.second; + } + ProbedManifest out; + std::string cmd = shellQuote(s->_config().cesComputeChildBinary) + + " --manifest " + shellQuote(ps) + " 2>/dev/null"; + if (FILE* p = ::popen(cmd.c_str(), "r")) { + std::string* fields[3] = { &out.name, &out.version, &out.description }; + char line[1024]; + for (int i = 0; i < 3 && std::fgets(line, sizeof(line), p); i++) { + std::string v(line); + while (!v.empty() && (v.back() == '\n' || v.back() == '\r')) v.pop_back(); + *fields[i] = v; + } + ::pclose(p); + } + std::lock_guard lk(mx); + cache[ps] = { key, out }; + return out; +} + +// Pull a /s/.lua source path back to its bare name, "" if it isn't one. +std::string nameFromSource(const std::string& src) { + const std::string pre = "/s/", suf = ".lua"; + if (src.size() <= pre.size() + suf.size()) return ""; + if (src.compare(0, pre.size(), pre) != 0) return ""; + if (src.compare(src.size() - suf.size(), suf.size(), suf) != 0) return ""; + std::string n = src.substr(pre.size(), src.size() - pre.size() - suf.size()); + return validName(n) ? n : std::string(); +} + +} // namespace + +std::vector extensionList(CesServer* server) { + std::map byName; + std::error_code ec; + + if (!catalogDir(server).empty()) + for (auto& e : fs::directory_iterator(catalogDir(server), ec)) { + if (e.path().extension() != ".lua") continue; + std::string n = e.path().stem().string(); + if (!validName(n)) continue; + byName[n].name = n; + byName[n].available = true; + } + ec.clear(); + for (auto& e : fs::directory_iterator(storeSDir(server), ec)) { + if (e.path().extension() != ".lua") continue; + std::string n = e.path().stem().string(); + if (!validName(n)) continue; + byName[n].name = n; + byName[n].installed = true; + } + ComputeHandler* ch = server->computeHandler(); + for (auto& st : ch ? ch->snapshot() : std::vector{}) { + std::string n = nameFromSource(st.source); + if (n.empty()) continue; + // Invariant: enabled => installed. A running instance whose /s/ source is gone is + // an ORPHAN (uninstalled out from under it, a crash, a race, or an uncooperative + // teardown). Never report it as enabled, and force-kill it. killBySource is an + // idempotent SIGKILL that does not trust the extension to cooperate, so the state + // converges: a later scan finds the orphan gone. This keeps the reported state + // machine consistent (no enabled-and-not-installed) on every query. + auto found = byName.find(n); + if (found == byName.end() || !found->second.installed) { + if (ch) ch->killBySource(st.source); + continue; + } + ExtensionItem& it = found->second; + it.name = n; + it.enabled = true; + it.pid = st.pid; + ComputeExtInfo info; + if (ch->extInfo(st.pid, info)) { + // Identity from the live CES_MANIFEST report (present even with no contract). + if (!info.name.empty()) it.displayName = info.name; + it.version = info.version; + it.description = info.description; + // Admin contract from ces.extension_admin{} (may be absent — dice). + if (info.isExtension) { + it.isExtension = true; + it.caps = info.caps; + it.commands = info.commands; + } + } + } + // Not-running rows: harvest CES_MANIFEST from the file via the cesluajitd probe + // (cached). Enabled rows already have identity from the live report above. + for (auto& kv : byName) { + auto& it = kv.second; + if (it.enabled) continue; + fs::path f = it.available ? catalogLua(server, it.name) : sLua(server, it.name); + ProbedManifest pm = probeManifest(server, f); + if (!pm.name.empty()) it.displayName = pm.name; + it.version = pm.version; + it.description = pm.description; + } + std::vector out; + out.reserve(byName.size()); + for (auto& kv : byName) out.push_back(std::move(kv.second)); + return out; +} + +bool extensionInstall(CesServer* server, const std::string& name) { + if (!validName(name) || catalogDir(server).empty()) return false; + std::error_code ec; + if (!fs::exists(catalogLua(server, name), ec)) return false; + // The catalog is an external operator directory, not the file store: read it + // directly, then hand the bytes to the file store to land in /s/. + std::ifstream in(catalogLua(server, name), std::ios::binary); + if (!in) return false; + std::ostringstream ss; + ss << in.rdbuf(); + return fhWriteServerFile(server, "/s/" + name + ".lua", ss.str()); +} + +bool extensionUninstall(CesServer* server, const std::string& name) { + if (!validName(name)) return false; + if (ComputeHandler* h = server->computeHandler()) h->killBySource(srcName(name)); + fhRemoveServerFile(server, "/s/" + name + ".lua"); + return true; +} + +bool extensionEnable(CesServer* server, const std::string& name, std::string& errOut) { + if (!validName(name)) return false; + std::error_code ec; + if (!fs::exists(sLua(server, name), ec)) return false; + ComputeHandler* h = server->computeHandler(); + // enableExtension marshals onto rpcTaskIO (launchInternal MUST run on that strand) + // and is idempotent + singleton, so a button-mash cannot double-launch or race the + // instance/port state. On failure it fills errOut with the extension's own crash line. + return h && h->enableExtension(srcName(name), errOut); +} + +bool extensionDisable(CesServer* server, const std::string& name) { + if (!validName(name)) return false; + if (ComputeHandler* h = server->computeHandler()) h->killBySource(srcName(name)); + return true; +} + +bool extensionStatus(CesServer* server, const std::string& name, + std::vector>& kv) { + ComputeHandler* h = server->computeHandler(); + if (!h) return false; + uint64_t pid = runningPid(server, name); + if (!pid) return false; + ces::Bytes out; + if (!h->extRequest(pid, kComputeExtReqStatus, ces::Bytes{}, out, 2000)) + return false; + if (out.size() < 2) return true; + size_t o = 0; + uint16_t count = ces::Buffer::peek(out.data()); + o = 2; + for (uint16_t i = 0; i < count; i++) { + if (out.size() < o + 2) break; + uint16_t kn = ces::Buffer::peek(out.data() + o); o += 2; + if (out.size() < o + kn) break; + std::string k(reinterpret_cast(out.data()) + o, kn); o += kn; + if (out.size() < o + 2) break; + uint16_t vn = ces::Buffer::peek(out.data() + o); o += 2; + if (out.size() < o + vn) break; + std::string v(reinterpret_cast(out.data()) + o, vn); o += vn; + kv.emplace_back(std::move(k), std::move(v)); + } + return true; +} + +bool extensionCommand(CesServer* server, const std::string& name, + const std::string& id, const std::string& arg, + std::string& out) { + ComputeHandler* h = server->computeHandler(); + if (!h) return false; + uint64_t pid = runningPid(server, name); + if (!pid) return false; + ces::Bytes in; + in.push_back(static_cast((id.size() >> 8) & 0xFF)); + in.push_back(static_cast(id.size() & 0xFF)); + in.insert(in.end(), id.begin(), id.end()); + in.insert(in.end(), arg.begin(), arg.end()); + ces::Bytes outb; + if (!h->extRequest(pid, kComputeExtReqCommand, in, outb, 5000)) + return false; + out.assign(reinterpret_cast(outb.data()), outb.size()); + return true; +} + +bool extensionPanel(CesServer* server, const std::string& name, + std::string& frame) { + ComputeHandler* h = server->computeHandler(); + if (!h) return false; + uint64_t pid = runningPid(server, name); + if (!pid) return false; + ces::Bytes out; + if (!h->extRequest(pid, kComputeExtReqPanelRender, ces::Bytes{}, out, 2000)) + return false; + frame.assign(reinterpret_cast(out.data()), out.size()); + return true; +} + +bool extensionPanelEvent(CesServer* server, const std::string& name, + const std::string& eventJson, std::string& frame) { + ComputeHandler* h = server->computeHandler(); + if (!h) return false; + uint64_t pid = runningPid(server, name); + if (!pid) return false; + ces::Bytes in(eventJson.begin(), eventJson.end()); + ces::Bytes out; + if (!h->extRequest(pid, kComputeExtReqPanelEvent, in, out, 5000)) + return false; + frame.assign(reinterpret_cast(out.data()), out.size()); + return true; +} + +void extensionPanelWatch(CesServer* server, const std::string& name, bool on) { + ComputeHandler* h = server->computeHandler(); + if (!h) return; + uint64_t pid = runningPid(server, name); + if (pid) h->extPanelWatch(pid, on); +} + +std::string extensionConfigGet(CesServer* server, const std::string& name) { + if (!validName(name)) return ""; + return fhReadServerFile(server, "/s/" + name + ".conf"); +} + +// Make the on-disk /s/.conf take effect on the running instance. Config is +// read at launch, so a program that registered on_config hot-reloads live; one +// that did not can only pick up the change by relaunching. Either way an edit +// actually takes effect -- writing the file and changing nothing (a silent +// no-op) is worse than having no editor. No-op if the extension is not running. +void applyExtConfig(CesServer* server, const std::string& name) { + ComputeHandler* h = server->computeHandler(); + if (!h) return; + uint64_t pid = runningPid(server, name); + if (!pid) return; + ComputeExtInfo info; + if (h->extInfo(pid, info) && (info.caps & kComputeExtCapOnConfig)) { + h->extConfig(pid, fhReadServerFile(server, "/s/" + name + ".conf")); // hot-reload + } else { + h->killBySource(srcName(name)); // relaunch: + h->launchInternal(srcName(name)); // re-read at launch + } +} + +bool extensionConfigSet(CesServer* server, const std::string& name, + const std::string& text) { + if (!validName(name)) return false; + if (!fhWriteServerFile(server, "/s/" + name + ".conf", text)) return false; + applyExtConfig(server, name); + return true; +} + +bool extensionConfigReset(CesServer* server, const std::string& name) { + if (!validName(name)) return false; + ComputeHandler* h = server->computeHandler(); + if (!h) return false; + uint64_t pid = runningPid(server, name); + if (!pid) return false; + ComputeExtInfo info; + if (!h->extInfo(pid, info)) return false; + if (!fhWriteServerFile(server, "/s/" + name + ".conf", info.configDefaults)) return false; + applyExtConfig(server, name); + return true; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/keynames.cpp b/vendor/ces/src/ceslib/keynames.cpp new file mode 100644 index 0000000..408b1cd --- /dev/null +++ b/vendor/ces/src/ceslib/keynames.cpp @@ -0,0 +1,133 @@ +#include + +#include + +#include + +LOG_MODULE("knm"); + +namespace ces { + +KeyNames::KeyNames(const std::string& dataDir, uint64_t minKeyName, + uint64_t flushValue, size_t bufferSize) + : store_(dataDir, logkv::StoreFlags::createDir, bufferSize), + flushValue_(flushValue) { + store_.getObjects().reserve(minKeyName); + rebuildReverse(); +} + +KeyNameData KeyNames::normalize(const KeyNameData& name) { + // Spaces and underscores are THE SAME character: both map to '_'. Leading and + // trailing underscores (i.e. boundary spaces) are stripped, so a name can + // neither begin nor end with one. The result is the filesystem-safe, + // exact-match key stored in the ledger and used verbatim as the /f// + // path segment; pretty renderers turn '_' back into ' '. + size_t len = 0; + while (len < name.size() && name[len] != 0) ++len; + KeyNameData mapped{}; + for (size_t i = 0; i < len; ++i) + mapped[i] = (name[i] == ' ') ? static_cast('_') : name[i]; + size_t begin = 0, end = len; + while (begin < end && mapped[begin] == '_') ++begin; + while (end > begin && mapped[end - 1] == '_') --end; + KeyNameData out{}; + for (size_t i = begin; i < end; ++i) out[i - begin] = mapped[i]; + return out; +} + +bool KeyNames::validName(const KeyNameData& name) { + if (name[0] == 0) return false; // non-empty + // A canonical (normalized) name never begins with these: '.'/'-' are hidden/ + // option-like on a filesystem; a leading '_' means a boundary space that + // normalize() strips. + if (name[0] == '.' || name[0] == '-' || name[0] == '_') return false; + bool ended = false; + for (size_t i = 0; i < name.size(); ++i) { + const uint8_t b = name[i]; + if (b == 0) { // zero-padding: only a trailing run is allowed + ended = true; + continue; + } + if (ended) return false; // a byte after a NUL hole -> not a padded name + if (b < 0x20) return false; // ASCII control + // Path/URL/filesystem breakers (ASCII). A canonical name carries no space + // (normalize turned each into '_'); '_' itself is safe and allowed. + static const char* bad = "/\\:*?\"<>|#%&{}[]^~;@=+,`'()$!"; + if (b < 0x80 && std::strchr(bad, static_cast(b)) != nullptr) + return false; + } + return true; +} + +void KeyNames::rebuildReverse() { + byName_.clear(); + byName_.reserve(store_.getObjects().size()); + for (const auto& [key, kn] : store_) { + if (kn.getName() == KeyNameData{}) continue; // erased + byName_.emplace(normalize(kn.getName()), key); + } +} + +KeyNames::RegisterResult KeyNames::registerName(const Hash& key, + const KeyNameData& name, + uint64_t maxKeyName) { + // The stored name IS the normalized form (spaces -> underscores, trimmed): + // one canonical representation, so the forward store, the reverse index, and + // the /f// path all agree. + const KeyNameData norm = normalize(name); + if (!validName(norm)) return RegisterResult::BadName; + + // Name uniqueness: reject only if a DIFFERENT key holds it. + if (auto it = byName_.find(norm); it != byName_.end() && it->second != key) + return RegisterResult::NameTaken; + + auto existing = store_.find(key); + const bool isNew = (existing == store_.end() || + existing->second.getName() == KeyNameData{}); + if (isNew && maxKeyName != 0 && + store_.getObjects().size() >= maxKeyName) + return RegisterResult::CapacityFull; + + // Free the old reverse entry if this key is renaming. + if (existing != store_.end() && + existing->second.getName() != KeyNameData{}) { + byName_.erase(normalize(existing->second.getName())); + } + + KeyName kn(norm); + store_.update(key, kn); + byName_[norm] = key; + return RegisterResult::Ok; +} + +bool KeyNames::clearName(const Hash& key) { + auto it = store_.find(key); + if (it == store_.end() || it->second.getName() == KeyNameData{}) return false; + byName_.erase(normalize(it->second.getName())); + store_.erase(key); + return true; +} + +bool KeyNames::nameForKey(const Hash& key, KeyNameData& outName) const { + auto it = store_.find(key); + if (it == store_.end() || it->second.getName() == KeyNameData{}) return false; + outName = it->second.getName(); + return true; +} + +bool KeyNames::keyForName(const KeyNameData& name, Hash& outKey) const { + auto it = byName_.find(name); + if (it == byName_.end()) return false; + outKey = it->second; + return true; +} + +void KeyNames::checkFlush(uint64_t amount) { + flushAccumulator_ += amount; + if (flushAccumulator_ > flushValue_) { + flushAccumulator_ = 0; + store_.flush(); + } +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/builtin_site.cpp b/vendor/ces/src/ceslib/l2/builtin_site.cpp new file mode 100644 index 0000000..f6ebbc7 --- /dev/null +++ b/vendor/ces/src/ceslib/l2/builtin_site.cpp @@ -0,0 +1,93 @@ +#include + +namespace ces { + +namespace { + +// /s/welcome — a single, self-contained page. It assumes nothing about the +// gateway in front of it (it's just a CES file): links to its own assets are +// relative, the "rest of this server" link is relative to the /s/ zone, and the +// only absolute links are external. So it renders correctly however a gateway +// maps it. +const char* kIndexHtml = R"HTML( + + + +CES — hello from /s/ + + +
+ CES +
+

Hello from CES.

+

This page is a file on a CES server, fetched to your browser.

+
+
+ +

CES is a small public server: it keeps a ledger of credits and lets people +store files and run little programs against it. This page lives in its +/s/ zone — the space an operator publishes and gives away for +free. Your browser can't speak CES (it's UDP only), so a gateway fetched these +bytes and handed them to you over the web.

+ +

There's nothing special about this file — it's plain HTML the server is +serving. Files, pages, and programs are all just entries in the same ledger.

+ +

Look around

+ + +
Served from /s/welcome/ — shipped with every CES server.
+ + +)HTML"; + +const char* kStyleCss = R"CSS(:root{color-scheme:light dark} +*{box-sizing:border-box} +body{font:17px/1.65 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; + max-width:42rem;margin:0 auto;padding:3rem 1.2rem;color:#1c1c1e} +header{display:flex;align-items:center;gap:1.1rem;margin-bottom:1.6rem} +header h1{font-size:1.8rem;margin:0;line-height:1.1} +.sub{margin:.35rem 0 0;color:#666} +h2{font-size:1.05rem;margin:2rem 0 .5rem;color:#444} +p{margin:.9rem 0} +ul{list-style:none;padding:0} +li{padding:.55rem .9rem;margin:.45rem 0;background:#0a7d3312;border-radius:8px} +code{background:#00000010;padding:.12em .4em;border-radius:4px;font-size:.9em} +a{color:#0a7d33;text-decoration:none;font-weight:600} +a:hover{text-decoration:underline} +footer{margin-top:2.5rem;padding-top:1rem;border-top:1px solid #00000014; + color:#999;font-size:.88rem} +footer code{font-weight:400;color:inherit} +)CSS"; + +const char* kLogoSvg = R"SVG( + + + +C + +)SVG"; + +constexpr const char* kSitePrefix = "/s/welcome/"; + +} // namespace + +std::vector builtinSiteFiles() { + return { + {"welcome/index.html", kIndexHtml}, + {"welcome/style.css", kStyleCss}, + {"welcome/logo.svg", kLogoSvg}, + }; +} + +bool isBuiltinSitePath(const std::string& cesName) { + const std::string p = kSitePrefix; + return cesName.size() > p.size() && + cesName.compare(0, p.size(), p) == 0; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/compute_client.cpp b/vendor/ces/src/ceslib/l2/compute_client.cpp new file mode 100644 index 0000000..86e25ba --- /dev/null +++ b/vendor/ces/src/ceslib/l2/compute_client.cpp @@ -0,0 +1,279 @@ +// compute_client.cpp - CesComputeClient. +// +// A thin verb layer over a CesPlexChannel (see cesplex/session.h) — the +// shared CesPlex client protocol. Each method is just the verb's preamble +// building + response parsing; the channel drives the wire on whatever +// transport it was handed (connect() = owned socket, attach() = external). + +#include +#include +#include +#include // ces::sha256 +#include + +#include +#include +#include +#include + +namespace ces { + +namespace { + +constexpr uint8_t kVerbLaunch = 0x01; +constexpr uint8_t kVerbKill = 0x02; +constexpr uint8_t kVerbList = 0x03; +constexpr uint8_t kVerbStat = 0x04; +constexpr uint8_t kVerbInstances = 0x05; +constexpr uint8_t kVerbCall = 0x06; + +constexpr const char* kComputeProto = "/ces/compute/1"; + +// Parse a STAT response preamble: after the 72-byte fixed header +// (id | started_at | file_balance | cpu_bp | rss_bytes | client_port | +// rpc_port | program_pubkey) comes u16 name_len + name. Pulls the variable +// tail off the channel. +uint8_t statVariableReader(CesPlexChannel& chan, + CesComputeClient::InstanceInfo& out, + ces::Bytes& preamble) { + ces::Bytes lenBuf; + if (!chan.readExact(lenBuf, 2)) return CES_ERROR_INTERNAL; + preamble.insert(preamble.end(), lenBuf.begin(), lenBuf.end()); + uint16_t nameLen = ces::Buffer::peek(lenBuf.data()); + ces::Bytes nameBuf; + if (nameLen > 0) { + if (!chan.readExact(nameBuf, nameLen)) return CES_ERROR_INTERNAL; + preamble.insert(preamble.end(), nameBuf.begin(), nameBuf.end()); + } + out.pid = ces::Buffer::peek(preamble.data()); + out.startedAtUs = ces::Buffer::peek(preamble.data() + 8); + out.fileBalance = ces::Buffer::peek(preamble.data() + 16); + out.cpuBasisPoints = ces::Buffer::peek(preamble.data() + 24); + out.rssBytes = ces::Buffer::peek(preamble.data() + 28); + out.clientPort = ces::Buffer::peek(preamble.data() + 36); + out.rpcPort = ces::Buffer::peek(preamble.data() + 38); + std::memcpy(out.programPubkey.data(), preamble.data() + 40, 32); + out.sourceName.assign(nameBuf.begin(), nameBuf.end()); + return CES_OK; +} + +} // namespace + +class CesComputeClient::Impl { +public: + // See CesFileClient::Impl: `owned` drives connect(); in attach() mode + // `chan` points at a channel the caller owns. The verb codec rides `chan`. + CesPlexClient owned; + CesPlexChannel* chan = nullptr; +}; + +CesComputeClient::CesComputeClient() : impl_(std::make_unique()) {} +CesComputeClient::~CesComputeClient() = default; + +uint8_t CesComputeClient::connect(const std::string& host, uint16_t rpcPort, + const KeyPair& signerKey) { + uint8_t rc = impl_->owned.connect(host, rpcPort, kComputeProto, signerKey); + if (rc == CES_OK) impl_->chan = impl_->owned.channel(); + return rc; +} + +// Drive verbs over a channel the caller owns + has already select()ed. +// Mutually exclusive with connect(). +void CesComputeClient::attach(CesPlexChannel& channel) { + impl_->chan = &channel; +} + +void CesComputeClient::disconnect() { + impl_->owned.disconnect(); + impl_->chan = nullptr; +} + +void CesComputeClient::setServerPubkey(const minx::Hash& pk) { + if (impl_->chan) impl_->chan->setServerPubkey(pk); + else impl_->owned.setServerPubkey(pk); +} + +uint8_t CesComputeClient::launch(const std::string& name, + uint64_t& outInstanceId, + uint64_t& outStartedAtUs) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbLaunch, pre); + + ces::Bytes resp; + uint8_t rc = impl_->chan->driveVerb(kVerbLaunch, env, /*fixedPre=*/16, + nullptr, resp); + if (rc != CES_OK) return rc; + outInstanceId = ces::Buffer::peek(resp.data()); + outStartedAtUs = ces::Buffer::peek(resp.data() + 8); + return CES_OK; +} + +uint8_t CesComputeClient::kill(uint64_t pid) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, pid); + auto env = impl_->chan->buildEnvelope(kVerbKill, pre); + + ces::Bytes resp; + return impl_->chan->driveVerb(kVerbKill, env, /*fixedPre=*/0, nullptr, resp); +} + +uint8_t CesComputeClient::list(std::vector& out) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + auto env = impl_->chan->buildEnvelope(kVerbList, pre); + + out.clear(); + // Variable preamble: read u32 count, then count × entries. + auto readVariable = [&](ces::Bytes& preamble) -> bool { + ces::Bytes countBuf; + if (!impl_->chan->readExact(countBuf, 4)) return false; + preamble.insert(preamble.end(), countBuf.begin(), countBuf.end()); + uint32_t count = ces::Buffer::peek(countBuf.data()); + for (uint32_t i = 0; i < count; ++i) { + ces::Bytes header; + if (!impl_->chan->readExact(header, sizeof(uint64_t) + sizeof(uint16_t))) + return false; + preamble.insert(preamble.end(), header.begin(), header.end()); + uint16_t nameLen = ces::Buffer::peek(header.data() + 8); + ces::Bytes nameBuf; + if (nameLen > 0) { + if (!impl_->chan->readExact(nameBuf, nameLen)) return false; + preamble.insert(preamble.end(), nameBuf.begin(), nameBuf.end()); + } + // Per-entry trailer: startedAtUs | fileBalance | cpuBp | rssBytes | + // client_port | rpc_port | program_pubkey. + ces::Bytes tail; + if (!impl_->chan->readExact(tail, sizeof(uint64_t) + sizeof(uint64_t) + + sizeof(uint32_t) + sizeof(uint64_t) + + sizeof(uint16_t) + sizeof(uint16_t) + + 32)) + return false; + preamble.insert(preamble.end(), tail.begin(), tail.end()); + + InstanceInfo info; + info.pid = ces::Buffer::peek(header.data()); + info.sourceName.assign(nameBuf.begin(), nameBuf.end()); + info.startedAtUs = ces::Buffer::peek(tail.data()); + info.fileBalance = ces::Buffer::peek(tail.data() + 8); + info.cpuBasisPoints = ces::Buffer::peek(tail.data() + 16); + info.rssBytes = ces::Buffer::peek(tail.data() + 20); + info.clientPort = ces::Buffer::peek(tail.data() + 28); + info.rpcPort = ces::Buffer::peek(tail.data() + 30); + std::memcpy(info.programPubkey.data(), tail.data() + 32, 32); + out.push_back(std::move(info)); + } + return true; + }; + + ces::Bytes resp; + uint8_t rc = impl_->chan->driveVerb(kVerbList, env, /*fixedPre=*/0, + readVariable, resp); + if (rc != CES_OK) out.clear(); + return rc; +} + +uint8_t CesComputeClient::stat(uint64_t pid, InstanceInfo& out) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, pid); + auto env = impl_->chan->buildEnvelope(kVerbStat, pre); + + out = InstanceInfo{}; + auto reader = [this, &out](ces::Bytes& preamble) -> bool { + return statVariableReader(*impl_->chan, out, preamble) == CES_OK; + }; + ces::Bytes resp; + return impl_->chan->driveVerb(kVerbStat, env, /*fixedPre=*/72, reader, resp); +} + +uint8_t CesComputeClient::instances(const std::string& path, + std::vector& out) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, static_cast(path.size())); + pre.insert(pre.end(), path.begin(), path.end()); + auto env = impl_->chan->buildEnvelope(kVerbInstances, pre); + + out.clear(); + // Variable preamble: read u32 count, then count × fixed 64-byte entries: + // id | started_at | cpu_bp | rss_bytes | client_port | rpc_port | + // program_pubkey. + auto reader = [this, &out, path](ces::Bytes& preamble) -> bool { + ces::Bytes countBuf; + if (!impl_->chan->readExact(countBuf, 4)) return false; + preamble.insert(preamble.end(), countBuf.begin(), countBuf.end()); + uint32_t count = ces::Buffer::peek(countBuf.data()); + // Don't pre-allocate on a server-declared count; a hostile value would OOM. + // The loop self-limits: readExact fails once the real entries run out. + out.reserve(count < 65536 ? count : 65536); + for (uint32_t i = 0; i < count; ++i) { + ces::Bytes e; + if (!impl_->chan->readExact(e, 64)) return false; + preamble.insert(preamble.end(), e.begin(), e.end()); + InstanceInfo info; + info.pid = ces::Buffer::peek(e.data()); + info.startedAtUs = ces::Buffer::peek(e.data() + 8); + info.cpuBasisPoints = ces::Buffer::peek(e.data() + 16); + info.rssBytes = ces::Buffer::peek(e.data() + 20); + info.clientPort = ces::Buffer::peek(e.data() + 28); + info.rpcPort = ces::Buffer::peek(e.data() + 30); + std::memcpy(info.programPubkey.data(), e.data() + 32, 32); + info.sourceName = path; // the query key, echoed for convenience + out.push_back(std::move(info)); + } + return true; + }; + ces::Bytes resp; + uint8_t rc = impl_->chan->driveVerb(kVerbInstances, env, /*fixedPre=*/0, + reader, resp); + if (rc != CES_OK) out.clear(); + return rc; +} + +uint8_t CesComputeClient::call(uint64_t pid, uint64_t value, + const ces::Bytes& memo, ces::Bytes& reply) { + if (memo.size() > CES_L2_CALL_MAX_MEMO) return CES_ERROR_BAD_INPUT; + // The memo rides as the request body (hash-committed in the preamble, like + // file WRITE), so it is not packet-bounded. + minx::Hash memoHash = ces::sha256(memo.data(), memo.size()); + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, pid); + ces::Buffer::put(pre, value); + ces::Buffer::put(pre, static_cast(memo.size())); + pre.insert(pre.end(), memoHash.begin(), memoHash.end()); + auto env = impl_->chan->buildEnvelope(kVerbCall, pre); + + reply.clear(); + // Reply framing: [u32 len][32 sha256(reply)] fixed preamble + `len` reply + // bytes as the body. The body follows the response's signed tail, so it + // rides respBodyLen, not the preamble reader. The declared length is capped + // at CES_L2_CALL_MAX_REPLY so a hostile or buggy server can't make us + // allocate an unbounded response (the server truncates to the same cap). + constexpr size_t kCallRespPre = sizeof(uint32_t) + 32; + auto bodyLen = [](const ces::Bytes& preamble) -> uint64_t { + if (preamble.size() < kCallRespPre) return 0; + uint64_t declared = ces::Buffer::peek(preamble.data()); + return declared < CES_L2_CALL_MAX_REPLY ? declared : CES_L2_CALL_MAX_REPLY; + }; + ces::Bytes outPre; + uint8_t rc = impl_->chan->driveVerb(kVerbCall, env, /*fixedPre=*/kCallRespPre, + /*readVariablePreamble=*/nullptr, bodyLen, + /*extraBodyToSend=*/memo, outPre, reply); + if (rc != CES_OK) { reply.clear(); return rc; } + // The response sig covers only the preamble; the digest in it is what + // authenticates the reply bytes. + minx::Hash gotHash = ces::sha256(reply.data(), reply.size()); + if (outPre.size() < kCallRespPre || + std::memcmp(gotHash.data(), outPre.data() + sizeof(uint32_t), 32) != 0) { + reply.clear(); + return CES_ERROR_INTERNAL; + } + return CES_OK; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/compute_handler.cpp b/vendor/ces/src/ceslib/l2/compute_handler.cpp new file mode 100644 index 0000000..c85eeaf --- /dev/null +++ b/vendor/ces/src/ceslib/l2/compute_handler.cpp @@ -0,0 +1,4062 @@ +// compute_handler.cpp - builtin:compute CesPlex handler. +// +// See the header for the bind-prereq list. +// +// One child process per running instance (default cesluajitd, a sandboxed +// LuaJIT VM; cescompmockd is the no-Lua test stub), connected via a named +// Unix domain socket. The supervisor ticks periodically, samples the child's +// /proc CPU + RSS, debits slot/cpu/rss/bucket fees from the source file's +// file_balance via the file handler's debitBalance, and SIGKILLs the instance when a +// debit would delete the source file. A 15-minute upfront slot+rss fee is +// debited at LAUNCH to prevent create-and-abandon on the scheduler. +// +// Verbs (wire format mirrors builtin:file — preamble-first, signed +// envelope binding to sha256(verb || preamble), server-signed +// response): +// 0x01 LAUNCH (owner): u16 path_len, path +// resp: u64 pid, u64 started_at_us +// 0x02 KILL (owner): u64 pid +// resp: (empty) +// 0x03 LIST (any, scoped to signer's own): (no preamble beyond reqNonce) +// resp: u32 count, [u64 id, u16 path_len, path, +// u64 started_at_us, u64 file_balance, +// u32 cpu_bp, u64 rss_bytes, +// u16 client_port, u16 rpc_port]* +// 0x04 STAT (any): u64 pid +// resp: u64 pid, u64 started_at_us, u64 file_balance, +// u32 cpu_bp, u64 rss_bytes, u16 client_port, u16 rpc_port, +// u16 path_len, path +// 0x05 INSTANCES (any): u16 path_len, path +// resp: u32 count, [u64 id, u64 started_at_us, u32 cpu_bp, +// u64 rss_bytes, u16 client_port, u16 rpc_port]* +// +// Inspectability: STAT (by id) and INSTANCES (by source path) are public +// to any signer and expose a live instance's leased ports, so anyone can +// discover a running service and dial it — relayed via the server's own +// rpc port (/ces/lua/1) or direct to the instance's own host port +// (/ces/luarpc/1). A port reads 0 when the instance got no lease. Only +// LAUNCH/KILL stay owner-gated (they mutate); LIST is scoped to the +// signer's own instances. file_balance is funding info on a public +// ledger (already readable via the file handler), so it rides along. +// +// The signed-request loop (verb read, envelope verify, server-signed +// response) is the shared cesPlexServe engine in cesplex/mux.h. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +LOG_MODULE("compute"); + +namespace ces { + +namespace { + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +constexpr uint8_t kVerbLaunch = 0x01; +constexpr uint8_t kVerbKill = 0x02; +constexpr uint8_t kVerbList = 0x03; +constexpr uint8_t kVerbStat = 0x04; +constexpr uint8_t kVerbInstances = 0x05; +constexpr uint8_t kVerbCall = 0x06; // paid call into a live instance's on_l2call + +constexpr uint16_t kMaxNameLen = 512; // matches file handler +constexpr uint16_t kAppPayloadMax = 1024; + +// Supervisor cadence is taken from cfg.computeTickIntervalMs (60 s +// default in production). One tick does procfs sampling + slot-fee +// debit through the source file's sidecar — each of those is +// measurable in microseconds per instance, so 60 s gives us decent +// eviction responsiveness with near-zero steady-state overhead. +// Tests override the cadence down to 1 s so CPU/RSS assertions +// don't need a full-minute wait. + +// LAUNCH-time burn against create-and-abandon churn. At LAUNCH the +// source file_balance is debited feeComputeSlotSec × kUpfrontSeconds +// — a commitment fee, not a runtime credit. Billing starts at +// t=0 as usual. +constexpr uint64_t kUpfrontSeconds = 15 * 60; + +// Socket-accept timeout for the cesluad-style handshake (ms). If the +// child fails to connect back within this window we SIGKILL it and +// return CES_ERROR_INTERNAL. +constexpr int kAcceptTimeoutMs = 2000; + +// IPC tags (mirror cesluajitd/main.cpp). +constexpr uint8_t kIpcTagBootstrap = 0x00; +constexpr uint8_t kIpcTagDeliver = 0x01; +constexpr uint8_t kIpcTagApiCall = 0x02; +constexpr uint8_t kIpcTagApiReply = 0x03; +// /ces/lua/1 connection routing tags. Server↔child for forwarding +// bytes between Lua programs and external users. +constexpr uint8_t kIpcTagConnOpened = 0x04; // server → child +constexpr uint8_t kIpcTagConnDataIn = 0x05; // server → child +constexpr uint8_t kIpcTagConnClosed = 0x06; // server → child +constexpr uint8_t kIpcTagConnDataOut = 0x07; // child → server +constexpr uint8_t kIpcTagConnClose = 0x08; // child → server +constexpr uint8_t kIpcTagListenOn = 0x09; // child → server +constexpr uint8_t kIpcTagListenOff = 0x0a; // child → server +constexpr uint8_t kIpcTagLog = 0x0b; // child → server (ces.log) +constexpr uint8_t kIpcTagHostLog = 0x0c; // child → server (host C++) +constexpr uint8_t kIpcTagNetUsage = 0x15; // child -> server (channel usage) + +// Extension contract (must match cesluajitd TAG_EXT_*). +constexpr uint8_t kIpcTagExtRegister = 0x0d; // child → server +constexpr uint8_t kIpcTagExtReq = 0x0e; // server → child (status/command) +constexpr uint8_t kIpcTagExtRep = 0x0f; // child → server (reply) +constexpr uint8_t kIpcTagExtConfig = 0x10; // server → child (on_config) +constexpr uint8_t kIpcTagExtDisableSelf = 0x11; // child → server +constexpr uint8_t kIpcTagExtManifest = 0x12; // child → server (ces.manifest) +constexpr uint8_t kIpcTagExtSaveConfig = 0x19; // child → server (persist conf) +constexpr uint8_t kIpcTagExtUiPush = 0x1a; // child → server (panel frame) +constexpr uint8_t kIpcTagExtUiWatch = 0x1b; // server → child ([u8 on]) + +constexpr uint8_t kIpcTagGossipIn = 0x13; // server → child (flooded message) +constexpr uint8_t kIpcTagGossipOut = 0x14; // child → server (ces.gossip.send) + +// Paid L2 call routed into a live instance's on_l2call (VM syscall or CALL verb). +constexpr uint8_t kIpcTagL2CallIn = 0x1c; // server → child (paid call) +constexpr uint8_t kIpcTagL2CallResult = 0x1d; // child → server (delivered/no-handler) +constexpr uint8_t kIpcTagL2CallReply = 0x1f; // child → server (on_l2call return bytes) +#ifdef CES_MAIL +constexpr uint8_t kIpcTagMailOut = 0x1e; // child → server (ces.mail.send) +#endif +// Refund a call the instance never acknowledged (crashed / wedged). +constexpr uint64_t kL2CallTimeoutUs = 30'000'000; // 30 s + +// Peer-mesh messaging (must match cesluajitd TAG_PEER_*). Targeted, free, +// service-tagged messages over /ces/peer/1 between same-named extensions. +constexpr uint8_t kIpcTagPeerMsgIn = 0x16; // server → child (mesh message) +constexpr uint8_t kIpcTagPeerMsgOut = 0x17; // child → server (ces.peer.send) +constexpr uint8_t kIpcTagPeerListen = 0x18; // child → server (ces.peer.listen) + +constexpr uint8_t kExtReqStatus = 0x00; +constexpr uint8_t kExtReqCommand = 0x01; +constexpr size_t kLuaProgramLogMax = 4096; // cap a program log line + +constexpr uint16_t kApiMethodClientSend = 0x0001; +constexpr uint16_t kApiMethodFileCreate = 0x0100; +constexpr uint16_t kApiMethodFileWrite = 0x0101; +constexpr uint16_t kApiMethodFileRead = 0x0102; +constexpr uint16_t kApiMethodFileStat = 0x0103; +constexpr uint16_t kApiMethodFileDeposit = 0x0104; +constexpr uint16_t kApiMethodFileWithdraw = 0x0105; +constexpr uint16_t kApiMethodFileSetPrice = 0x0106; +constexpr uint16_t kApiMethodFileDelete = 0x0107; +constexpr uint16_t kApiMethodFileAppend = 0x0108; +constexpr uint16_t kApiMethodFileResize = 0x0109; +// kv-file (logkv-backed) bindings: the persistent key-value store a program +// reaches via ces.store(path). Same in-process file path, kv verbs. +constexpr uint16_t kApiMethodKvCreate = 0x010a; +constexpr uint16_t kApiMethodKvPut = 0x010b; +constexpr uint16_t kApiMethodKvGet = 0x010c; +constexpr uint16_t kApiMethodKvErase = 0x010d; +constexpr uint16_t kApiMethodKvIter = 0x010e; +constexpr uint16_t kApiMethodKvDeposit = 0x010f; +constexpr uint16_t kApiMethodKvRange = 0x0110; +// Ledger / RNG bindings exposed to the Lua sandbox: +// TRANSFER — program-initiated transfer from owner's account. +// /s/ programs see the server's account, which is +// bottomless by boot top-up. +// RANDOM_BYTES — crypto-grade RNG, n ≤ 256. +// ACCOUNT_READ — unsigned account query, no fee. +constexpr uint16_t kApiMethodTransfer = 0x0200; +constexpr uint16_t kApiMethodCrossTransfer = 0x0201; +constexpr uint16_t kApiMethodRandomBytes = 0x0202; +constexpr uint16_t kApiMethodAccountRead = 0x0203; +constexpr uint16_t kApiMethodKeyName = 0x0204; +// Per-instance rotating bucket cache (minx::BucketCache wrapper): +// BUCKET_NEW — allocate a bucket with TTL + size cap; returns u32 id +// BUCKET_PUT — set k → v in the bucket +// BUCKET_GET — read v for k, or "missing" status +// Used by Lua programs that need replay-protection tables with +// guaranteed forgetting (e.g. dice.lua's per-user last-consumed +// transfer time). Capacity is billed on the supervisor tick via +// feeBucketByteSec (see committedBytes below). +constexpr uint16_t kApiMethodBucketNew = 0x0210; +constexpr uint16_t kApiMethodBucketPut = 0x0211; +constexpr uint16_t kApiMethodBucketGet = 0x0212; + +// ces.authentic_asset_create(asset_id, recipient_pubkey, payload, days). +// Mints an IMMUTABLE asset whose first 32 bytes are the program's +// identity hash sha256(source_file_bytes || source_file_path), looked +// up from the source file's sidecar (computed lazily on first call, +// cached until the file is content-modified). User payload occupies +// the remaining 178 bytes of asset content. The new asset is owned +// by `recipient_pubkey` (may differ from the program's owner — the +// typical case is "program mints loot to a player"). Asset rent is +// paid by the program's owner account. +constexpr uint16_t kApiMethodAuthenticAssetCreate = 0x0220; +constexpr uint16_t kApiMethodPeers = 0x0230; +constexpr uint16_t kApiMethodServerInfo = 0x0239; +constexpr uint16_t kApiMethodPeerAdd = 0x0231; +constexpr uint16_t kApiMethodPeerRemove = 0x0232; +constexpr uint16_t kApiMethodPeerTargetSet = 0x0233; +constexpr uint16_t kApiMethodPeerTargetGet = 0x0234; +// ces.request_funds(addr, amount): petition the server to fund THIS program's key +// at remote `addr` — a regular server-signed transfer at the remote (NOT +// settlement), gated by the global funding rate. +constexpr uint16_t kApiMethodRequestFunds = 0x0235; +constexpr uint16_t kApiMethodPeerGrief = 0x0236; +constexpr uint16_t kApiMethodPeerBan = 0x0237; +constexpr uint16_t kApiMethodServerSign = 0x0238; + +// Authentic-asset content layout (a compute-SDK concept, opaque to the +// server): first 32 bytes are the program-identity hash +// sha256(source_bytes || source_path); the rest is the user payload. The +// handler assembles the full AssetData and hands raw bytes to createAssetAsync. +constexpr size_t AUTHENTIC_ASSET_HASH_SIZE = 32; +constexpr size_t AUTHENTIC_ASSET_PAYLOAD_SIZE = + std::tuple_size_v - AUTHENTIC_ASSET_HASH_SIZE; + +// File-verb codes mirror file_handler.cpp. Exposed to +// FileHandler::exec via FileExecReq.verb. +constexpr uint8_t kFileVerbCreate = 0x01; +constexpr uint8_t kFileVerbWrite = 0x02; +constexpr uint8_t kFileVerbRead = 0x03; +constexpr uint8_t kFileVerbStat = 0x04; +constexpr uint8_t kFileVerbDeposit = 0x05; +constexpr uint8_t kFileVerbWithdraw = 0x06; +constexpr uint8_t kFileVerbSetPrice = 0x07; +constexpr uint8_t kFileVerbDelete = 0x08; +constexpr uint8_t kFileVerbAppend = 0x09; +constexpr uint8_t kFileVerbResize = 0x0a; +constexpr uint8_t kFileVerbKvCreate = 0x0b; +constexpr uint8_t kFileVerbKvPut = 0x0c; +constexpr uint8_t kFileVerbKvGet = 0x0d; +constexpr uint8_t kFileVerbKvErase = 0x0e; +constexpr uint8_t kFileVerbKvIter = 0x0f; +constexpr uint8_t kFileVerbKvDeposit = 0x10; +constexpr uint8_t kFileVerbKvRange = 0x11; + +constexpr uint8_t kApiStatusOk = 0x00; +constexpr uint8_t kApiStatusNotConnected = 0x01; +constexpr uint8_t kApiStatusInsufficient = 0x02; +constexpr uint8_t kApiStatusDenied = 0x03; // privileged API, non-/s/ caller +constexpr uint8_t kApiStatusBucketFull = 0x04; // bucket at capacity, put refused +constexpr uint8_t kApiStatusInternal = 0xFF; + +constexpr uint32_t kIpcMaxFrameLen = 2 * 1024 * 1024; // 2 MB safety cap + +// Per-instance cap on queued outbound IPC frames before a best-effort +// DELIVER (incoming CES_APP_COMPUTE_MSG) is dropped instead of enqueued. +// CES_APP_COMPUTE_MSG is a lossy lane by contract — an undeliverable +// message is dropped silently — so shedding it when the child is behind +// is correct, and it bounds server memory against a remote flood aimed at +// a slow or non-reading program. Correctness-critical frames (API replies, +// conn routing, bootstrap) ignore this cap; they are flow-controlled by +// other means (request/response, RUDP windowing, one-shot). +constexpr size_t kMaxDeliverBacklog = 1024; + +} // namespace +namespace { + +// All BE serialization goes through ces::Buffer (see ces/buffer.h). + +// --------------------------------------------------------------------------- +// File-handler cross-calls. builtin:file is a per-server object; compute +// reaches its primitives through the bound server. Each wrapper matches the +// old contract (false / no-op when the file feature is unavailable). +// --------------------------------------------------------------------------- + +inline FileHandler* fileHOf(CesServer* sv) { + return sv ? sv->fileHandler() : nullptr; +} +inline bool fhDebitBalance(CesServer* sv, const std::string& name, uint64_t amount) { + FileHandler* fh = fileHOf(sv); return fh && fh->debitBalance(name, amount); +} +inline bool fhCreditBalance(CesServer* sv, const std::string& name, uint64_t amount) { + FileHandler* fh = fileHOf(sv); return fh && fh->creditBalance(name, amount); +} +inline bool fhReadOwnerAndBalance(CesServer* sv, const std::string& name, + std::array& o, uint64_t& b) { + FileHandler* fh = fileHOf(sv); return fh && fh->readOwnerAndBalance(name, o, b); +} +inline bool fhReadProgramPubkey(CesServer* sv, const std::string& name, + std::array& o) { + FileHandler* fh = fileHOf(sv); return fh && fh->readProgramPubkey(name, o); +} +inline bool fhReadProgramPrivkey(CesServer* sv, const std::string& name, + std::array& o) { + FileHandler* fh = fileHOf(sv); return fh && fh->readProgramPrivkey(name, o); +} +inline bool fhGetProgramHash(CesServer* sv, const std::string& name, + std::array& o) { + FileHandler* fh = fileHOf(sv); return fh && fh->getProgramHash(name, o); +} + +} // namespace + +// UnixSocket/UnixAcceptor + Instance + ExtPending live in the named ces +// namespace so the handler header can forward-declare Instance/ExtPending +// (its members hold them only through shared_ptr). +using UnixSocket = boost::asio::local::stream_protocol::socket; +using UnixAcceptor = boost::asio::local::stream_protocol::acceptor; + +struct Instance : std::enable_shared_from_this { + // Owning handler (per-server). Set at creation; the supervisor state the + // free helpers touch hangs off here. + ComputeHandler* owner = nullptr; + // CES process id -- the computing-layer "pid" (1, 2, 3, ...). Server-assigned, + // monotonic, never reused. This is what Lua, the protocol, and the UI speak. + uint64_t pid = 0; + std::string sourceName; + std::string lastLog; // last ces.log / host line this instance emitted; its crash diagnostic + std::array ownerPk{}; + // Program account pubkey from the source file's sidecar. + std::array programPubkey{}; + // Program account ed25519 private half, copied to the child at bootstrap + // so it can sign its own remote ops. + std::array programPrivkey{}; + std::array progPrefix{}; // first 8B of sha256(sourceName) + // OS process id of the cesluajitd child. Internal bookkeeping only + // (waitpid/kill//proc) -- never spoken in Lua, the protocol, or the UI; the + // CES-facing process id is `pid` above. + pid_t ospid = -1; + // UDP port the server statically assigned this instance for its + // outbound CES client, from the configured compute port range. 0 = + // no range configured (instance has no network). Handed to the child + // in the bootstrap frame; freed back to the pool on death. + uint16_t clientPort = 0; + // UDP port the server reserved for this instance's inbound CesPlex host + // (/ces/luarpc/1), from the same range. 0 = none. Independent of + // clientPort; freed back to the pool on death. + uint16_t rpcPort = 0; + uint64_t startedAtUs = 0; + uint64_t lastTickUs = 0; // supervisor's last-charged wall time + uint64_t upfrontDeposit = 0; + // CPU + RSS monitoring. Sampled each supervisor tick from + // /proc//stat + /proc//statm. CPU is in basis points of + // one core: 10000 = 100% of a single CPU; sustained ≥10000 on a + // multi-core box means this (single-threaded Lua) child is fully + // saturating its core. cpuBasisPoints reflects usage between the + // last two samples, not cumulative since launch. + uint64_t lastCpuTicks = 0; // utime+stime at last sample (clock ticks) + uint64_t lastSampleUs = 0; // wall-clock at last sample + uint32_t cpuBasisPoints = 0; // 0..10000, of one core + uint64_t rssBytes = 0; // resident pages × page size, last sample + std::string socketPath; + // Async-I/O endpoint to the child. Wraps the accepted fd as an + // asio::local::stream_protocol::socket. All reads and writes run + // on rpcTaskIO_. + std::shared_ptr peer; + // Outbound frame queue. Kept serial: when empty and caller wants + // to write, we start async_write of the new head; completion pops + // head and, if the queue is non-empty, starts the next write. + // Lets bootstrap + deliver + api-reply interleave safely from + // different call sites without torn frames. + std::deque> outbox; + bool writing = false; + // Inbound-frame read state. + std::array rxLenBuf{}; + ces::Bytes rxBodyBuf; + + // Identity, reported live via ces.manifest{} (EXT_MANIFEST). Independent of the + // contract — a program may have a manifest with no contract (dice) or both. + std::string extName, extVersion, extDescription; + // Launched via [extension] / launchInternal (operator infrastructure), not a user + // LAUNCH verb. Exempt from computeMaxInstances: that cap bounds USER instances, and a + // user filling it must never block the operator's own extensions. Extensions are + // bounded by the configured compute port range instead. + bool internalLaunch = false; + // Admin contract. Populated when the child sends EXT_REGISTER + // (ces.extension_admin{}); honored only for /s/ instances. + bool isExtension = false; + uint8_t extCaps = 0; + std::vector> extCommands; // {id, label} + std::string extConfigDefaults; + + // /ces/lua/1 connection state. The accept gate (default closed) is + // flipped by the child via TAG_LISTEN_ON / TAG_LISTEN_OFF. The + // routing table for active connections is owned by the lua handler + // (compute_lua_handler.cpp), keyed by (pid, connId); the + // supervisor calls into it via forward-declared dispatchers when + // CONN_DATA_OUT / CONN_CLOSE frames arrive from the child. + bool acceptsConnections = false; + // Optional greeting (opening bytes) declared via ces.conn.set_listener{hello}; + // delivered atomically in each ATTACH reply. Empty = request-driven (HTTP-ish). + std::vector hello; + uint64_t nextConnId = 1; + + // Per-instance rotating bucket caches, surfaced to Lua as + // ces.bucket_new(ttl_secs, max_entries, max_entry_bytes). + // Caches die with the instance — the bucket map clears when the + // Instance is destroyed. BucketCache itself is thread-safe + // (internal mutex), so reads/writes from the rpcTaskIO_ strand + // are fine without extra locks. + // + // committedBytes is the worst-case footprint pre-declared at + // bucket_new time: max_entries × max_entry_bytes. It's what the + // supervisor bills against, so the program pays a predictable + // capacity rent regardless of actual fill. Per-entry size is + // capped at put time against max_entry_bytes (key + value sum). + struct LuaBucket { + std::shared_ptr> cache; + uint32_t maxEntries = 0; + uint32_t maxEntryBytes = 0; // klen + vlen cap per entry + uint64_t committedBytes = 0; // maxEntries × maxEntryBytes + }; + std::map buckets; + uint32_t nextBucketId = 1; +}; + +// In-flight EXT_REQ correlation. The reply (EXT_REP) lands on rpcTaskIO_ in +// handleChildFrame; the caller (e.g. the webadmin worker thread) blocks on the +// shared state until it is filled or times out. corr_id is host-allocated for +// this direction, separate from the child-allocated api_call corr_ids. +struct ExtPending { + uint16_t corr = 0; + bool dead = false; // timed out -> drop a late reply (rpcTaskIO_-only) + std::mutex m; + std::condition_variable cv; + bool done = false; + bool ok = false; + ces::Bytes reply; // EXT_REP payload after the status byte +}; + +namespace { + +// RAII reservation token: ++pendingLaunches_ on construct, -- on destruct. +// Held (via shared_ptr) across the async LAUNCH chain and released once the +// instance lands in instances_ or the launch fails. +struct LaunchSlot { + ComputeHandler* H; + explicit LaunchSlot(ComputeHandler* h) : H(h) { ++H->pendingLaunches_; } + ~LaunchSlot() { if (H->pendingLaunches_ > 0) --H->pendingLaunches_; } + LaunchSlot(const LaunchSlot&) = delete; + LaunchSlot& operator=(const LaunchSlot&) = delete; +}; + +// USER launch slots spoken for = user instances + in-flight launches. The LAUNCH cap is +// checked against this. Operator extensions (internalLaunch) are exempt -- bounded by the +// compute port range, not this cap -- so a user filling the cap never blocks an extension. +inline std::size_t launchSlotsInUse(ComputeHandler& H) { + std::size_t n = static_cast(H.pendingLaunches_); + for (const auto& kv : H.instances_) if (!kv.second->internalLaunch) ++n; + return n; +} + +// L2 compute program port allocator. Each running instance gets one UDP +// port for its child's outbound CES client, claimed from the configured +// range [computePortBase, computePortBase + computePortCount - 1]. The +// server owns the whole lifecycle — no child picks its own port — so a +// firewalled L2 host can open exactly this range. Strand-only (rpcTaskIO_). +// +// Claim the lowest free port in the configured range. Returns false if +// the range is configured (base != 0) but fully spoken for. With base +// == 0 there is no range: returns true with out = 0, which the child +// reads as "no network" — its outbound remote_* verbs fail cleanly +// rather than binding an unreachable ephemeral port. +bool allocateComputePort(ComputeHandler& H, const CesConfig& cfg, uint16_t& out) { + if (cfg.computePortBase == 0) { out = 0; return true; } + uint32_t base = cfg.computePortBase; + uint32_t end = base + cfg.computePortCount; // exclusive + for (uint32_t p = base; p < end && p <= 0xFFFF; ++p) { + uint16_t port = static_cast(p); + if (H.usedComputePorts_.insert(port).second) { out = port; return true; } + } + return false; +} + +void releaseComputePort(ComputeHandler& H, uint16_t port) { + if (port != 0) H.usedComputePorts_.erase(port); +} + +// RAII reservation for a claimed compute port — mirrors LaunchSlot. +// Holds the port across the async launch chain and returns it to the +// pool on destruction unless commit()ted. A failed launch drops the +// lease and frees the port; on success the port is committed to the +// Instance, and killByPid frees it when the instance dies. +struct PortLease { + ComputeHandler* H; + uint16_t port = 0; + bool committed = false; + PortLease(ComputeHandler* h, uint16_t p) : H(h), port(p) {} + ~PortLease() { if (!committed) releaseComputePort(*H, port); } + void commit() { committed = true; } + PortLease(const PortLease&) = delete; + PortLease& operator=(const PortLease&) = delete; +}; + +// --------------------------------------------------------------------------- +// Unix socket / process helpers +// --------------------------------------------------------------------------- + +std::filesystem::path resolveWorkDir(const CesConfig& cfg) { + if (!cfg.cesComputeWorkDir.empty()) return cfg.cesComputeWorkDir; + return std::filesystem::path(cfg.dataDir.string()) / "cescompute"; +} + +std::filesystem::path instanceSocketPath(const CesConfig& cfg, uint64_t pid) { + return resolveWorkDir(cfg) / (std::to_string(pid) + ".sock"); +} + +// Compute 8B prefix = first 8 bytes of sha256(name). +std::array progPrefixOf(const std::string& name) { + minx::Hash h = ces::sha256( + reinterpret_cast(name.data()), name.size()); + std::array pf{}; + std::memcpy(pf.data(), h.data(), 8); + return pf; +} + +// Create + bind + listen on a Unix socket at `path`. Returns fd or +// -errno. +int createListenSocket(const std::string& path) { + // Clean up any stale socket file; we own this path. + std::error_code ec; + std::filesystem::remove(path, ec); + + int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) return -errno; + sockaddr_un a{}; + a.sun_family = AF_UNIX; + if (path.size() >= sizeof(a.sun_path)) { ::close(fd); return -ENAMETOOLONG; } + std::memcpy(a.sun_path, path.data(), path.size()); + if (::bind(fd, reinterpret_cast(&a), sizeof(a)) < 0) { + int e = errno; ::close(fd); return -e; + } + if (::listen(fd, 1) < 0) { + int e = errno; ::close(fd); return -e; + } + return fd; +} + +// fork+exec the configured child binary. argv[1] is the IPC socket +// path; argv[2] (optional) is the non-root user to drop to if the +// server is running as root. Returns pid > 0 on success, -errno +// on failure. +pid_t spawnChild(const std::string& binary, + const std::string& sockPath, + const std::string& dropUser, + uint64_t memMaxBytes, + uint32_t clientPoolSize) { + // Build argv strings before fork — only async-signal-safe work may run + // between fork and exec. dropUser is passed positionally ("" when no + // drop is requested) so memMax lands at a fixed argv slot. + std::string memMaxStr = std::to_string(memMaxBytes); + std::string poolStr = std::to_string(clientPoolSize); + pid_t pid = ::fork(); + if (pid < 0) return -errno; + if (pid == 0) { + // Child. Close stdin; leave stdout/stderr for the runtime's + // panic messages (which should only ever fire on host bugs). + ::close(STDIN_FILENO); + const char* arg0 = binary.c_str(); + const char* arg1 = sockPath.c_str(); + const char* arg2 = dropUser.c_str(); // "" = no privilege drop + const char* arg3 = memMaxStr.c_str(); // RLIMIT_AS ceiling, bytes + const char* arg4 = poolStr.c_str(); // #3 verb-client worker pool size + ::execlp(arg0, arg0, arg1, arg2, arg3, arg4, nullptr); + std::_Exit(127); + } + return pid; +} + +// SIGKILL + reap the pid. Best-effort — returns true on success, but +// callers shouldn't branch on it: a reaped child is a reaped child. +bool killAndReap(pid_t pid) { + if (pid <= 0) return true; + ::kill(pid, SIGKILL); + // Non-blocking reap loop. The child is dead or dying; WNOHANG + // shouldn't wait, but run a short retry in case of SIGKILL delivery + // latency. + for (int i = 0; i < 50; ++i) { + int status = 0; + pid_t r = ::waitpid(pid, &status, WNOHANG); + if (r == pid || r < 0) return true; + // Not reaped yet. + ::usleep(1000); + } + return false; +} + +// --------------------------------------------------------------------------- +// CPU + RSS sampling from /proc +// --------------------------------------------------------------------------- +// +// /proc//stat — many space-separated fields; field 2 is the comm +// in parens and may itself contain spaces/parens. Safe parse: split +// AFTER the last ')'. utime (field 14) and stime (field 15) become +// the 12th + 13th tokens of that tail. +// /proc//statm — 7 space-separated page counts; field 2 is +// "resident" (the RSS in pages). + +struct ProcSample { + uint64_t ticks = 0; // utime + stime, in clock ticks + uint64_t rssBytes = 0; +}; + +bool readProcSample(pid_t pid, ProcSample& out) { + { + std::string path = "/proc/" + std::to_string(pid) + "/stat"; + std::ifstream f(path); + if (!f) return false; + std::string line; + if (!std::getline(f, line)) return false; + auto rp = line.rfind(')'); + if (rp == std::string::npos || rp + 2 >= line.size()) return false; + std::istringstream ss(line.substr(rp + 2)); + std::vector toks; + std::string t; + while (ss >> t) toks.push_back(t); + // Indices 11 + 12 correspond to utime (14th field) + stime (15th field). + if (toks.size() < 13) return false; + uint64_t utime = 0, stime = 0; + try { + utime = std::stoull(toks[11]); + stime = std::stoull(toks[12]); + } catch (...) { + return false; + } + out.ticks = utime + stime; + } + { + std::string path = "/proc/" + std::to_string(pid) + "/statm"; + std::ifstream f(path); + if (!f) return false; + uint64_t sizePages = 0, residentPages = 0; + if (!(f >> sizePages >> residentPages)) return false; + long ps = ::sysconf(_SC_PAGESIZE); + if (ps <= 0) ps = 4096; + out.rssBytes = residentPages * static_cast(ps); + } + return true; +} + +// Sample the instance's process and refresh cpuBasisPoints + rssBytes. +// Stale state is kept on failure (process may have gone; the zombie +// reap path is responsible for teardown). CPU basis points are the +// mean over the interval since last sample — so a busy-loop Lua will +// pin at ~10000 (= 100% of one core). +void sampleInstanceProc(Instance& inst, uint64_t nowUs) { + if (inst.ospid <= 0) return; + ProcSample s; + if (!readProcSample(inst.ospid, s)) return; + uint64_t deltaUs = (nowUs > inst.lastSampleUs) + ? (nowUs - inst.lastSampleUs) : 0; + uint64_t deltaTicks = (s.ticks >= inst.lastCpuTicks) + ? (s.ticks - inst.lastCpuTicks) : 0; + long tps = ::sysconf(_SC_CLK_TCK); + if (tps <= 0) tps = 100; + uint32_t bp = 0; + if (deltaUs > 0) { + // bp = deltaTicks * 10000 * 1e6 / (tps * deltaUs) + // 128-bit to avoid overflow on longer intervals. + __uint128_t num = + static_cast<__uint128_t>(deltaTicks) * 10000ull * 1'000'000ull; + __uint128_t den = + static_cast<__uint128_t>(tps) * deltaUs; + __uint128_t q = (den > 0) ? (num / den) : 0; + uint64_t bp64 = (q > 10000) ? 10000 : static_cast(q); + bp = static_cast(bp64); + } + inst.cpuBasisPoints = bp; + inst.rssBytes = s.rssBytes; + inst.lastCpuTicks = s.ticks; + inst.lastSampleUs = nowUs; +} + +// Tear down per-instance resources (sockets, socket file). Does not +// manipulate instances_ / byPrefix_ / byName_ — caller handles the +// registry. +void teardownInstance(Instance& inst) { + if (inst.peer) { + boost::system::error_code ec; + inst.peer->close(ec); + inst.peer.reset(); + } + if (!inst.socketPath.empty()) { + std::error_code ec; + std::filesystem::remove(inst.socketPath, ec); + } +} + +// Public instance catalog (/s/instances.html): a pre-computed page listing +// every live /s/-sourced instance with its pid, source, start time, and +// endpoints. The /s/ pid set changes only at launch commit, kill/death, and +// boot, so the catalog is regenerated at those boundaries and served as a +// plain /s/ file -- readers (cwb's portless luarpc://) get static content, +// never a per-hit query fan-out. Non-/s/ sources are capabilities and stay +// unlisted by design; INSTANCES answers exact-source queries. Runs on the +// rpcTaskIO strand (every call site already does). +void regenerateInstanceCatalog(ComputeHandler& H) { + CesServer* server = H.server_; + if (!server) return; + FileHandler* fh = server->fileHandler(); + if (!fh) return; + std::string host = server->_config().serverName; // "host[:port]" or "" + if (auto c = host.find(':'); c != std::string::npos) host.resize(c); + const uint16_t plexPort = server->_config().rpcPort; + + auto esc = [](const std::string& s) { + std::string o; + o.reserve(s.size()); + for (char c : s) { + if (c == '&') o += "&"; + else if (c == '<') o += "<"; + else if (c == '>') o += ">"; + else if (c == '"') o += """; + else o += c; + } + return o; + }; + + std::vector pids; + for (auto& [pid, inst] : H.instances_) + if (inst->sourceName.rfind("/s/", 0) == 0) pids.push_back(pid); + std::sort(pids.begin(), pids.end()); + + std::string html = + "" + "" + "/s/ \xe2\x80\x94 public instances" + "" + "

/s/ \xe2\x80\x94 public instances

"; + if (pids.empty()) { + html += "

No public instances running.

"; + } else { + html += "" + ""; + for (uint64_t pid : pids) { + auto& inst = H.instances_[pid]; + char when[40] = ""; + std::time_t t = static_cast(inst->startedAtUs / 1000000ULL); + std::tm tmv{}; + gmtime_r(&t, &tmv); + std::strftime(when, sizeof when, "%Y-%m-%d %H:%M UTC", &tmv); + html += ""; + } + html += "
pidsourcestartedopen
" + std::to_string(pid) + "sourceName) + "\">" + esc(inst->sourceName) + + "" + when + ""; + if (!host.empty()) { + html += "relay"; + if (inst->rpcPort) + html += " rpcPort) + "/\">direct"; + } else { + html += "relay via /ces/lua/1"; + if (inst->rpcPort) + html += ", rpc port " + std::to_string(inst->rpcPort); + } + html += "
"; + } + html += "
auto-generated catalog of running /s/ instances (" + + std::to_string(pids.size()) + + "); regenerated on instance start/stop
\n"; + fh->writeServerFile("/s/instances.html", html); + + // Machine-readable sidecar: one "pidsourcerpc_port" line per live + // /s/ instance, regenerated in lockstep with the HTML (so it is always fresh). + // A friendly directory (e.g. cwb.lua) reads this to link an app by its source + // to its live lua://@host/ serving without parsing HTML or querying the + // network -- the pid is authoritative and never goes stale between restarts. + std::string idx; + for (uint64_t pid : pids) { + auto& inst = H.instances_[pid]; + idx += std::to_string(pid) + "\t" + inst->sourceName + "\t" + + std::to_string(inst->rpcPort) + "\n"; + } + fh->writeServerFile("/s/instances.idx", idx); +} + +// Kill an instance by pid, remove from registries, clean up resources. +// No fee refund on kill: the upfront slot-fee paid for a commitment the +// host already honored by running. +void killByPid(ComputeHandler& H, uint64_t pid) { + auto it = H.instances_.find(pid); + if (it == H.instances_.end()) return; + auto inst = it->second; + // Record why this instance is going away (its last log line), so a failed enable + // can tell the operator what the extension actually said before it died. + H.lastExtDeath_[inst->sourceName] = { getMicrosSinceEpoch(), inst->lastLog }; + // Tear down any /ces/lua/1 connections routed to this instance + // before we drop the registry entry, so the lua handler can + // still find it (and so the bytes-and-onClosed cascade fires + // while the supervisor is still in a coherent state). + if (CesServer* sv = H.server_; sv && sv->luaHandler()) + sv->luaHandler()->onInstanceDying(pid); + killAndReap(inst->ospid); + teardownInstance(*inst); + releaseComputePort(H, inst->clientPort); + releaseComputePort(H, inst->rpcPort); + // Drop this instance's gossip sink registration (refcounted). + if (CesServer* sv = H.server_) + sv->unregisterSinkTarget(inst->programPubkey); + H.instances_.erase(it); + if (auto pit = H.byPrefix_.find(inst->progPrefix); pit != H.byPrefix_.end()) { + pit->second.erase(pid); + if (pit->second.empty()) H.byPrefix_.erase(pit); + } + if (auto nit = H.byName_.find(inst->sourceName); nit != H.byName_.end()) { + nit->second.erase(pid); + if (nit->second.empty()) H.byName_.erase(nit); + } + // Drop any peer-mesh service tags this instance registered. + for (auto sit = H.serviceTags_.begin(); sit != H.serviceTags_.end();) { + if (sit->second == pid) sit = H.serviceTags_.erase(sit); + else ++sit; + } + LOGDEBUG << "instance terminated" + << VAR(pid) << SVAR(inst->sourceName); + // Not during handler teardown: the reconcile inside the regen sync-hops to + // logicStrand_, whose io threads are already joined by the time + // ComputeHandler::stop() kills instances -- the hop would wait forever + // (shutdown deadlock). A dying server serves no catalog; boot rebuilds it. + if (!H.stopped_.load() && inst->sourceName.rfind("/s/", 0) == 0) + regenerateInstanceCatalog(H); +} + +// --------------------------------------------------------------------------- +// IPC framing helpers (host ↔ cesluajitd) +// --------------------------------------------------------------------------- +// +// Frame layout (both directions): +// [u32 BE length][u8 tag][u16 BE corr_id][body] +// +// `length` covers tag + corr_id + body. Frames that arrive with a +// malformed length or that miss the pipe cause the instance to be +// killed — we treat IPC errors as terminal. + +// Forward decls for the async reader + dispatcher. +void startIpcReader(std::shared_ptr inst); +void handleChildFrame(std::shared_ptr inst); +void handleChildApiCall(std::shared_ptr inst, + uint16_t corr_id, + const uint8_t* body, size_t bodyLen); + +// Build a framed outbound message. Caller fills tag, corr_id, body +// bytes; this returns the full wire packet with the length prefix. +std::shared_ptr makeFrame( + uint8_t tag, uint16_t corr_id, + const uint8_t* body, size_t body_len) { + uint32_t len = static_cast( + sizeof(uint8_t) + sizeof(uint16_t) + body_len); // tag + corr + body + ces::Buffer buf(sizeof(uint32_t) + len); // length prefix + frame + buf.put(len) + .put(tag) + .put(corr_id); + if (body_len > 0) { + buf.putBytes(std::span(body, body_len)); + } + return std::make_shared(std::move(buf).take()); +} + +// Forward decl: after a write completes, this tries to kick the +// next one if the outbox has more. +void kickOutboundIfIdle(std::shared_ptr inst); + +// Enqueue a pre-framed packet for async_write. If the outbox was +// idle, kicks off the next write. +void enqueueOutbound(std::shared_ptr inst, + std::shared_ptr frame) { + if (!inst->peer) return; + inst->outbox.push_back(std::move(frame)); + kickOutboundIfIdle(inst); +} + +void kickOutboundIfIdle(std::shared_ptr inst) { + if (!inst->peer) return; + if (inst->writing) return; + if (inst->outbox.empty()) return; + inst->writing = true; + auto head = inst->outbox.front(); + boost::asio::async_write( + *inst->peer, boost::asio::buffer(*head), + [inst, head](const boost::system::error_code& ec, std::size_t) { + inst->writing = false; + if (!inst->outbox.empty()) inst->outbox.pop_front(); + if (ec) { + LOGDEBUG << "ipc write failed" + << VAR(inst->pid) << SVAR(ec.message()); + killByPid(*inst->owner, inst->pid); + return; + } + kickOutboundIfIdle(inst); + }); +} + +// Send a TAG_DELIVER frame to the child. +// Body = [8B sender_pfx][payload bytes]. +void sendDeliverFrame(std::shared_ptr inst, + const std::array& senderPfx, + const uint8_t* payload, size_t payloadLen) { + // Best-effort lane: if the child is already deep behind on its outbound + // queue, drop this message rather than grow the server's memory without + // bound. A program that wants every message must keep draining. + if (inst->peer && inst->outbox.size() >= kMaxDeliverBacklog) { + LOGDEBUG << "deliver dropped (outbox full)" + << VAR(inst->pid) << VAR(inst->outbox.size()); + return; + } + ces::Bytes body; + body.reserve(sizeof(senderPfx) + payloadLen); + body.insert(body.end(), senderPfx.begin(), senderPfx.end()); + if (payloadLen > 0) + body.insert(body.end(), payload, payload + payloadLen); + enqueueOutbound(inst, makeFrame(kIpcTagDeliver, 0, + body.data(), body.size())); +} + +// Send a TAG_API_REPLY frame to the child. Reply body is a 1-byte +// status code optionally followed by a method-specific payload. +void sendApiReply(std::shared_ptr inst, + uint16_t corr_id, uint8_t status) { + uint8_t body = status; + enqueueOutbound(inst, makeFrame(kIpcTagApiReply, corr_id, &body, 1)); +} + +void sendApiReplyWithBody(std::shared_ptr inst, + uint16_t corr_id, uint8_t status, + const ces::Bytes& tail) { + ces::Bytes body; + body.reserve(sizeof(uint8_t) + tail.size()); + body.push_back(status); + body.insert(body.end(), tail.begin(), tail.end()); + enqueueOutbound(inst, + makeFrame(kIpcTagApiReply, corr_id, body.data(), body.size())); +} + +// ---- Extension funding worker. ces.request_funds reserves from the server's +// global rate bucket (server->extFundingGrant) on the rpc strand, then a bounded +// set of detached threads run the actual server-signed remote open-transfer +// off-strand (CesClient is blocking) and reply to the child async. On failure the +// reservation is refunded, so the granted amount the child sees is the CONFIRMED one. +// The in-flight count is the per-server handler's fundingInFlight_ member. +constexpr int kFundingMaxInFlight = 4; + +void fundingWorker(std::shared_ptr inst, uint16_t corr, + minx::Hash dest, uint64_t amount, std::string destServer, + CesServer* server, ces::KeyPair serverKey) { + struct Guard { + ComputeHandler* H; + ~Guard() { H->fundingInFlight_.fetch_sub(1); } + } guard{inst->owner}; + bool ok = false; + try { + auto ep = ces::Resolver::resolveUdp(destServer); + // Lean client (one round-trip): small recv ring, no PoW/spam machinery — + // the server-sized default would alloc ~32MB per transfer. + minx::MinxConfig ccfg{"fundcl"}; + ccfg.recvBuffersSize = 256; + ccfg.spamSampleRate = 0; + ccfg.randomXVMsToKeep = 0; + ccfg.randomXInitThreads = 0; + ccfg.trustLoopback = true; + ces::CesClient client(ep, /*useDataset=*/false, ccfg); + client.setKey(serverKey); + if (client.start(0) && client.connect()) { + int64_t newBal = 0; + // Open-transfer, not a safe transfer: the program's account almost never + // exists at the remote on the first grant, and a safe transfer rejects a + // missing destination (CES_ERROR_TARGET_NOT_FOUND). Open mode creates it. + // Still a direct signed op (origin = server's reserve there, dest = + // program), NOT a cross-transfer/settlement. + ok = (client.openTransfer(dest, amount, newBal) == CES_OK); + client.disconnect(); + } + client.stop(); + } catch (...) { + ok = false; + } + if (!ok) server->extFundingRefund(amount); // failed -> give the rate back + uint64_t granted = ok ? amount : 0; + if (ok) + LOGINFO << "ext funding: server sent " << amount + << " to a program account at " << destServer; + else + LOGDEBUG << "ext funding: transfer to " << destServer + << " failed; refunded " << amount; + try { + boost::asio::post(inst->peer->get_executor(), [inst, corr, granted]() { + ces::Bytes tail; + ces::Buffer::put(tail, granted); + sendApiReplyWithBody(inst, corr, kApiStatusOk, tail); + }); + } catch (...) { + } +} + +// Send the bootstrap frame at LAUNCH time. Body layout: +// [8B prog_prefix][32B owner_pubkey][32B program_pubkey] +// [32B program_privkey][2B client_port BE][2B rpc_port BE] +// [1B privileged][32B server_secret][8B start_time_us BE][u32 BE src_len][src bytes] +// - server_secret: the server's own ed25519 private half, sent ONLY to a /s/ +// (operator-write-only) instance on an ed25519 server; 32 zero bytes otherwise. +// Consumed in-process by ces.hyle.net.start to run as a hyle validator under +// the server identity; the raw key is never surfaced to the Lua sandbox. +// - privileged: 1 for an operator /s/ program (server-deployed, runs under the +// server identity), 0 otherwise. Gates operator-only API like ces.log so an +// untrusted user program can't reach it. +// - prog_prefix: first 8B of sha256(source path); used as the +// "prog_pfx" field on outbound CES_APP_COMPUTE_MSG packets so the +// remote CES client can demux by program. +// - owner_pubkey: full 32B pubkey of the source file's owner. +// Programs surface it via ces.owner_pubkey(). +// - program_pubkey: full 32B pubkey of the file's dedicated program +// account — the pool ces.transfer spends from. Programs surface it +// via ces.program_pubkey() and advertise it as their receive address +// (e.g. a game's "house"), so deposits and payouts share one pool. +// - program_privkey: the program account's ed25519 private half, so the +// program can sign its own remote ops. +// - start_time_us: this instance's birth wall-clock micros (same +// value as inst->startedAtUs). Programs use it as a freshness +// anchor for replay protection — a payment whose lastXferTime is +// ≤ this couldn't have been intended for this program-instance. +// - client_port: the UDP port the server reserved for this instance's +// outbound CES client (0 = no range → the instance has no network). +// The child binds its client to this port so it sends from a known, +// firewall-configurable source port. +// - rpc_port: the UDP port the server reserved for this instance's inbound +// CesPlex host (/ces/luarpc/1). 0 = none → the instance hosts nothing. +// Independent of client_port: an instance may get one, both, or neither. +void sendBootstrapFrame(std::shared_ptr inst, + const uint8_t* src, size_t srcLen) { + ces::Bytes body; + body.reserve(sizeof(inst->progPrefix) + sizeof(inst->ownerPk) + + sizeof(inst->programPubkey) + sizeof(inst->programPrivkey) + + sizeof(uint16_t) + sizeof(uint16_t) + 1 + 32 + sizeof(uint64_t) + + sizeof(uint32_t) + srcLen); + body.insert(body.end(), + inst->progPrefix.begin(), inst->progPrefix.end()); + body.insert(body.end(), + inst->ownerPk.begin(), inst->ownerPk.end()); + body.insert(body.end(), + inst->programPubkey.begin(), inst->programPubkey.end()); + body.insert(body.end(), + inst->programPrivkey.begin(), inst->programPrivkey.end()); + ces::Buffer::put(body, inst->clientPort); + ces::Buffer::put(body, inst->rpcPort); + body.push_back(isServerZone(inst->sourceName) ? 1 : 0); + { + std::array ssec{}; + if (isServerZone(inst->sourceName) && inst->owner->server_ && + inst->owner->server_->_serverKeyPair().getAlgorithm() == ces::KeyAlgo::ED25519) { + const minx::Hash& sk = inst->owner->server_->_serverKeyPair().getPrivateKey(); + std::memcpy(ssec.data(), sk.data(), 32); + } + body.insert(body.end(), ssec.begin(), ssec.end()); + } + ces::Buffer::put(body, inst->startedAtUs); + ces::Buffer::put(body, static_cast(srcLen)); + if (srcLen > 0) + body.insert(body.end(), src, src + srcLen); + enqueueOutbound(inst, makeFrame(kIpcTagBootstrap, 0, + body.data(), body.size())); +} + +// Async-read one frame from the child. On success, dispatches and +// re-arms itself for the next frame. +void startIpcReader(std::shared_ptr inst) { + // Hold the socket shared_ptr for the whole read: teardownInstance() may close() + // and reset() inst->peer while a read is pending (rampant when instances churn + // under rapid enable/disable). Capturing `sock` keeps the UnixSocket alive so a + // concurrent teardown becomes a clean operation_aborted here, not a use-after-free. + auto sock = inst->peer; + if (!sock) return; + boost::asio::async_read( + *sock, boost::asio::buffer(inst->rxLenBuf), + [inst, sock](const boost::system::error_code& ec, std::size_t) { + if (ec) { + // Child closed its end (or the socket was torn down). Reap and clean up. + killByPid(*inst->owner, inst->pid); + return; + } + uint32_t len = ces::Buffer::peek(inst->rxLenBuf.data()); + if (len < 3 || len > kIpcMaxFrameLen) { + LOGDEBUG << "ipc bad frame len" + << VAR(inst->pid) << VAR(len); + killByPid(*inst->owner, inst->pid); + return; + } + inst->rxBodyBuf.assign(len, 0); + boost::asio::async_read( + *sock, boost::asio::buffer(inst->rxBodyBuf), + [inst, sock](const boost::system::error_code& ec2, std::size_t) { + if (ec2) { + killByPid(*inst->owner, inst->pid); + return; + } + handleChildFrame(inst); + // Re-arm only if the instance is still live AND still owns this socket + // (teardown nulls inst->peer). startIpcReader re-checks inst->peer too. + if (inst->owner->instances_.count(inst->pid) && inst->peer == sock) + startIpcReader(inst); + }); + }); +} + +// (Cross-handler dispatchers for the lua handler are forward-declared +// at the top of this file, near the IPC tag constants, so they're +// visible to both handleChildFrame and killByPid.) + +// Emit a privileged (/s/) program's ces.log line under the "compute" module -- +// the deployed program talking, hosted by builtin:compute. Tagged with the +// readable source path and the CES pid (the instance id: the computing-layer +// process id, server-assigned and never reused -- NOT the internal ospid). The +// program chooses the level (0=trace 1=debug 2=info 3=warn 4=error; unknown -> +// info); the operator chooses what's visible via blog's "compute" level. +void emitProgramLog(uint64_t pid, const std::string& source, + uint8_t level, const char* msg, size_t len) { + std::string body(msg, len); + switch (level) { + case 0: LOGTRACE << source << " pid " << pid << ": " << body; break; + case 1: LOGDEBUG << source << " pid " << pid << ": " << body; break; + case 3: LOGWARNING << source << " pid " << pid << ": " << body; break; + case 4: LOGERROR << source << " pid " << pid << ": " << body; break; + default: LOGINFO << source << " pid " << pid << ": " << body; break; + } +} + +// Emit a log line from the C++ HOST side of a compute instance (cesluajitd's own +// runtime -- e.g. a failed outbound ces.ping), as distinct from the program's +// own ces.log. The "[host]" marker tells the operator this is the runtime +// talking, not the deployed program. Same instance + source prefix; the level is +// chosen host-side. Like program logs, gated to /s/ instances: a child controls +// its IPC socket, so "[host]" attribution is only as trustworthy as the source. +void emitHostLog(uint64_t pid, const std::string& source, + uint8_t level, const char* msg, size_t len) { + std::string body(msg, len); + switch (level) { + case 0: LOGTRACE << source << " pid " << pid << " [host]: " << body; break; + case 1: LOGDEBUG << source << " pid " << pid << " [host]: " << body; break; + case 3: LOGWARNING << source << " pid " << pid << " [host]: " << body; break; + case 4: LOGERROR << source << " pid " << pid << " [host]: " << body; break; + default: LOGINFO << source << " pid " << pid << " [host]: " << body; break; + } +} + +// Read /s/.conf for a /s/.lua source ("" if absent). The startup +// config push: an extension's persisted config is delivered to its on_config the +// moment it registers, so its live state matches the file from the first tick. +std::string readExtensionConfig(CesServer* server, const std::string& sourceName) { + if (!server || sourceName.empty() || sourceName[0] != '/') return ""; + auto dot = sourceName.rfind(".lua"); + if (dot == std::string::npos) return ""; + std::string conf = sourceName.substr(0, dot) + ".conf"; // "/s/.conf" + std::filesystem::path p = + std::filesystem::path(server->_config().cesFileStoreDir) / conf.substr(1); + std::ifstream f(p, std::ios::binary); + if (!f) return ""; + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +void handleChildFrame(std::shared_ptr inst) { + const auto& body = inst->rxBodyBuf; + if (body.size() < 3) return; + uint8_t tag = body[0]; + uint16_t corr = ces::Buffer::peek(body.data() + 1); + // /ces/lua/1 routing tags. Don't carry corr_id semantics — corr + // is reserved zero by the child. + if (tag == kIpcTagListenOn || tag == kIpcTagListenOff) { + inst->acceptsConnections = (tag == kIpcTagListenOn); + // LISTEN_ON payload after the [u8 tag][u16 corr] header is the greeting. + if (tag == kIpcTagListenOn && body.size() > 3) + inst->hello.assign(body.begin() + 3, body.end()); + else + inst->hello.clear(); + LOGDEBUG << "listener gate" + << VAR(inst->pid) << VAR(inst->acceptsConnections); + return; + } + // `body` still carries the [u8 tag][u16 corr_id] frame header; the + // routing payload begins after it. + constexpr size_t kIpcHdr = sizeof(uint8_t) + sizeof(uint16_t); + if (tag == kIpcTagConnDataOut) { + // Payload: [u64 conn_id][u32 BE len][len bytes] + constexpr size_t kConnIdOff = kIpcHdr; + constexpr size_t kLenOff = kConnIdOff + sizeof(uint64_t); + constexpr size_t kDataOff = kLenOff + sizeof(uint32_t); + if (body.size() < kDataOff) return; + uint64_t connId = ces::Buffer::peek(body.data() + kConnIdOff); + uint32_t dlen = ces::Buffer::peek(body.data() + kLenOff); + if (body.size() < kDataOff + dlen) return; + if (CesServer* sv = inst->owner->server_; sv && sv->luaHandler()) + sv->luaHandler()->handleConnDataOut(inst->pid, connId, + body.data() + kDataOff, dlen); + return; + } + if (tag == kIpcTagConnClose) { + if (body.size() < kIpcHdr + sizeof(uint64_t)) return; + uint64_t connId = ces::Buffer::peek(body.data() + kIpcHdr); + if (CesServer* sv = inst->owner->server_; sv && sv->luaHandler()) + sv->luaHandler()->handleConnClose(inst->pid, connId); + return; + } + if (tag == kIpcTagL2CallResult) { + // Payload: [u64 callId][u8 status] (0 = delivered, else no-handler). The + // child sends this the moment it accepts (or refuses) a paid L2 call. + // Delivered marks the pending call; no-handler settles it (refund). + if (body.size() < kIpcHdr + sizeof(uint64_t) + 1) return; + uint64_t callId = ces::Buffer::peek(body.data() + kIpcHdr); + bool delivered = body[kIpcHdr + sizeof(uint64_t)] == 0; + inst->owner->l2Result(callId, delivered); + return; + } + if (tag == kIpcTagL2CallReply) { + // Payload: [u64 callId][reply bytes]. on_l2call's return; settles the call + // Delivered and routes the bytes to its sink (channel respond / followup). + if (body.size() < kIpcHdr + sizeof(uint64_t)) return; + uint64_t callId = ces::Buffer::peek(body.data() + kIpcHdr); + const uint8_t* rb = body.data() + kIpcHdr + sizeof(uint64_t); + size_t rlen = body.size() - kIpcHdr - sizeof(uint64_t); + inst->owner->l2Reply(callId, rb, rlen); + return; + } +#ifdef CES_MAIL + if (tag == kIpcTagMailOut) { + // ces.mail.send from a program. Routes into builtin:mail, which burns the + // program's OWN account per encoded MB (the charge is the anti-spam gate; + // no /s/ restriction). Payload: + // [u16 toLen][to][u16 subjLen][subject][u32 bodyLen][body] + // [u16 pathLen][attachment path] (BE). + const uint8_t* p = body.data() + kIpcHdr; + size_t n = body.size() - kIpcHdr, off = 0; + auto has = [&](size_t k) { return off + k <= n; }; + if (!has(2)) return; + uint16_t toLen = ces::Buffer::peek(p + off); off += 2; + if (!has(toLen)) return; + std::string to(reinterpret_cast(p + off), toLen); off += toLen; + if (!has(2)) return; + uint16_t sjLen = ces::Buffer::peek(p + off); off += 2; + if (!has(sjLen)) return; + std::string subj(reinterpret_cast(p + off), sjLen); off += sjLen; + if (!has(4)) return; + uint32_t bdLen = ces::Buffer::peek(p + off); off += 4; + if (!has(bdLen)) return; + std::string bdy(reinterpret_cast(p + off), bdLen); off += bdLen; + std::string path; + if (has(2)) { + uint16_t pLen = ces::Buffer::peek(p + off); off += 2; + if (has(pLen)) + path.assign(reinterpret_cast(p + off), pLen); + } + CesServer* sv = inst->owner->server_; + if (sv && sv->mailHandler()) { + minx::Hash payer; + std::memcpy(payer.data(), inst->programPubkey.data(), 32); + sv->mailHandler()->mailSubmit(payer, to, subj, bdy, path); + } + return; + } +#endif // CES_MAIL + if (tag == kIpcTagGossipOut) { + // Payload: [u64 budget BE][32 dest][u32 BE len][len bytes]. ces.gossip.send + // from the program: originate a flood from this server (server-funded out + // of its own peer reserves). One-way. + constexpr size_t kBudgetOff = kIpcHdr; + constexpr size_t kDestOff = kBudgetOff + sizeof(uint64_t); + constexpr size_t kLenOff = kDestOff + sizeof(minx::Hash); + constexpr size_t kMsgOff = kLenOff + sizeof(uint32_t); + if (body.size() < kMsgOff) return; + uint64_t budget = ces::Buffer::peek(body.data() + kBudgetOff); + minx::Hash dest; + std::memcpy(dest.data(), body.data() + kDestOff, sizeof(minx::Hash)); + uint32_t mlen = ces::Buffer::peek(body.data() + kLenOff); + if (body.size() < kMsgOff + mlen) return; + CesServer* server = inst->owner->server_; + if (server) { + // Charge the program's source file_balance the full budget up front so a + // non-/s/ program can't flood on the OPERATOR's PoW reserves without + // bound. The debit no-ops on /s/ (operator extensions stay free); on a + // metered program with too little balance it deletes the source (compute + // out-of-funds semantics) and we skip the originate. Bounds a program's + // total gossip to its funding -- the same discipline as every compute fee. + if (budget > 0 && !fhDebitBalance(inst->owner->server_, inst->sourceName, budget)) + return; + ces::Bytes m(body.data() + kMsgOff, body.data() + kMsgOff + mlen); + uint64_t fanned = server->originateGossip(m, budget, dest); + // Refund the reserve-backpressure surplus (what couldn't fan out) to the + // program's file_balance -- charge only what actually propagated. No-ops + // on /s/ (decorative there). + if (fanned < budget) + fhCreditBalance(inst->owner->server_, inst->sourceName, budget - fanned); + } + return; + } + if (tag == kIpcTagPeerListen) { + // Payload: [u16 service_len][service]. Register this instance as the + // handler for inbound /ces/peer/1 messages tagged `service`. + constexpr size_t kSlenOff = kIpcHdr; + constexpr size_t kSvcOff = kSlenOff + sizeof(uint16_t); + if (body.size() < kSvcOff) return; + uint16_t slen = ces::Buffer::peek(body.data() + kSlenOff); + if (body.size() < kSvcOff + slen) return; + std::string service(reinterpret_cast(body.data() + kSvcOff), + slen); + if (service.empty()) return; + inst->owner->serviceTags_[service] = inst->pid; + LOGDEBUG << "peer service registered" << VAR(inst->pid) << SVAR(service); + return; + } + if (tag == kIpcTagPeerMsgOut) { + // Payload: [32 dest_pubkey][u16 service_len][service][payload]. + // ces.peer.send: relay over /ces/peer/1 to the dest peer's same service. + constexpr size_t kDestOff = kIpcHdr; + constexpr size_t kSlenOff = kDestOff + sizeof(minx::Hash); + constexpr size_t kSvcOff = kSlenOff + sizeof(uint16_t); + if (body.size() < kSvcOff) return; + minx::Hash dest; + std::memcpy(dest.data(), body.data() + kDestOff, sizeof(minx::Hash)); + uint16_t slen = ces::Buffer::peek(body.data() + kSlenOff); + size_t payOff = kSvcOff + slen; + if (body.size() < payOff) return; + std::string service(reinterpret_cast(body.data() + kSvcOff), + slen); + size_t plen = body.size() - payOff; + if (CesServer* sv = inst->owner->server_; sv && sv->peerHandler()) + sv->peerHandler()->sendMessage(dest, service, body.data() + payOff, plen); + return; + } + if (tag == kIpcTagLog) { + // Payload: [u8 level][message bytes]. ces.log from a privileged (/s/) + // program. Level + message are the program's; the instance + program prefix + // are stamped host-side (not trusted from the child). Emitted through blog + // under the "lua" module. One-way. /s/-only -- a non-privileged (or + // compromised) child can't inject attributed program log lines. + if (!isServerZone(inst->sourceName)) return; + if (body.size() < kIpcHdr + 1) return; + uint8_t level = body[kIpcHdr]; + size_t mlen = body.size() - kIpcHdr - 1; + if (mlen > kLuaProgramLogMax) mlen = kLuaProgramLogMax; + const char* msg = reinterpret_cast(body.data() + kIpcHdr + 1); + // Remember the last thing the program said: if it dies right after, this is the + // crash diagnostic surfaced to the operator (e.g. by a failed enable). + inst->lastLog.assign(msg, mlen); + emitProgramLog(inst->pid, inst->sourceName, level, msg, mlen); + return; + } + if (tag == kIpcTagHostLog) { + // Payload: [u8 level][message bytes]. A log line from cesluajitd's own C++ + // host (not the program's ces.log) -- e.g. an unexpected exception in an + // outbound ces.* call. Same gating + stamping as program logs; rendered + // with a "[host]" marker so the runtime is distinguishable from the program. + if (!isServerZone(inst->sourceName)) return; + if (body.size() < kIpcHdr + 1) return; + uint8_t level = body[kIpcHdr]; + size_t mlen = body.size() - kIpcHdr - 1; + if (mlen > kLuaProgramLogMax) mlen = kLuaProgramLogMax; + const char* msg = reinterpret_cast(body.data() + kIpcHdr + 1); + inst->lastLog.assign("[host] ").append(msg, mlen); // host-side death diagnostic + emitHostLog(inst->pid, inst->sourceName, level, msg, mlen); + return; + } + if (tag == kIpcTagNetUsage) { + // Payload: [8B payerPfx][u64 bytesSent][u64 bytesReceived][u64 memByteSec] + // [u64 ageSeconds]. The child's endpoint meter reporting one + // channel tick. The payer is this instance's own program prefix for an + // OUTBOUND channel (bill the source file's file_balance) or the remote + // caller's prefix for an INBOUND one (bill that caller). One-way, no reply. + constexpr size_t kNeed = kIpcHdr + 8 + 4 * sizeof(uint64_t); + if (body.size() < kNeed) return; + size_t o = kIpcHdr; + ces::HashPrefix payer{}; + std::memcpy(payer.data(), body.data() + o, payer.size()); o += payer.size(); + ces::CesPlexUsage usage{}; + usage.bytesSent = ces::Buffer::peek(body.data() + o); o += 8; + usage.bytesReceived = ces::Buffer::peek(body.data() + o); o += 8; + usage.memByteSeconds = ces::Buffer::peek(body.data() + o); o += 8; + usage.ageSeconds = ces::Buffer::peek(body.data() + o); + CesServer* server = inst->owner->server_; + if (!server) return; + const uint64_t amount = server->priceNetUsage(usage); + if (amount == 0) return; + ces::HashPrefix self{}; + std::memcpy(self.data(), inst->programPubkey.data(), self.size()); + if (payer == self) + fhDebitBalance(inst->owner->server_, inst->sourceName, amount); // outbound: instance pays + else + server->debitNetworkBill(payer, amount); // inbound: caller pays + return; + } + if (tag == kIpcTagExtManifest) { + // ces.manifest{} -> store identity (name/version/description). Reported live; + // independent of the admin contract. /s/ only. Body: [lp name][lp ver][lp desc]. + if (!isServerZone(inst->sourceName)) return; + size_t o = kIpcHdr; + auto getLp = [&](std::string& dst) -> bool { + if (body.size() < o + 2) return false; + uint16_t n = ces::Buffer::peek(body.data() + o); o += 2; + if (body.size() < o + n) return false; + dst.assign(reinterpret_cast(body.data() + o), n); o += n; + return true; + }; + getLp(inst->extName); getLp(inst->extVersion); getLp(inst->extDescription); + return; + } + if (tag == kIpcTagExtRegister) { + // ces.extension_admin{} -> store caps + commands + defaults. /s/ only. No + // metadata here: name/version/description arrive via ces.manifest (above). + // Body after header: [u8 caps][u16 cmdCount]([lp id][lp label])* + // [lp config_defaults]. lp = u16 len + bytes. + if (!isServerZone(inst->sourceName)) return; + size_t o = kIpcHdr; + if (body.size() < o + 1) return; + inst->extCaps = body[o]; o += 1; + auto getLp = [&](std::string& dst) -> bool { + if (body.size() < o + 2) return false; + uint16_t n = ces::Buffer::peek(body.data() + o); o += 2; + if (body.size() < o + n) return false; + dst.assign(reinterpret_cast(body.data() + o), n); o += n; + return true; + }; + if (body.size() < o + 2) return; + uint16_t cmdCount = ces::Buffer::peek(body.data() + o); o += 2; + inst->extCommands.clear(); + for (uint16_t i = 0; i < cmdCount; i++) { + std::string id, label; + if (!getLp(id) || !getLp(label)) return; + inst->extCommands.emplace_back(std::move(id), std::move(label)); + } + getLp(inst->extConfigDefaults); + inst->isExtension = true; + // Deliver the persisted config now so the extension's live state matches its + // /s/.conf from the first tick (not only after a Save/Reset). + if (inst->extCaps & kComputeExtCapOnConfig) { + std::string cfg = readExtensionConfig(inst->owner->server_, inst->sourceName); + if (!cfg.empty()) + enqueueOutbound(inst, makeFrame(kIpcTagExtConfig, 0, + reinterpret_cast(cfg.data()), cfg.size())); + } + LOGDEBUG << "extension registered" + << VAR(inst->pid) << SVAR(inst->sourceName) << VAR(int(inst->extCaps)); + return; + } + if (tag == kIpcTagExtRep) { + // Reply to a host EXT_REQ: [u8 status][payload]. Match corr -> pending. + auto pit = inst->owner->extPending_.find(corr); + if (pit == inst->owner->extPending_.end()) return; + auto pend = pit->second; + inst->owner->extPending_.erase(pit); + std::lock_guard lk(pend->m); + if (!pend->dead) { + const uint8_t* p = body.data() + kIpcHdr; + size_t plen = body.size() - kIpcHdr; + pend->ok = (plen >= 1 && p[0] == kApiStatusOk); + if (plen >= 1) pend->reply.assign(p + 1, p + plen); + pend->done = true; + pend->cv.notify_all(); + } + return; + } + if (tag == kIpcTagExtDisableSelf) { + killByPid(*inst->owner, inst->pid); + return; + } + if (tag == kIpcTagExtUiPush) { + // Unsolicited panel render/toast frame (the mene push bridge or the + // child's change-detect tick). Relay to the host's registered sink + // (webadmin broadcasts it to watching WebSocket clients). Drop silently + // when no sink is registered. + if (!isServerZone(inst->sourceName) || !inst->isExtension) return; + const std::string& src = inst->sourceName; // "/s/.lua" + auto dot = src.rfind(".lua"); + if (dot == std::string::npos || dot <= 3) return; + std::string name = src.substr(3, dot - 3); + std::string frame(reinterpret_cast(body.data()) + kIpcHdr, + body.size() - kIpcHdr); + if (!frame.empty()) { + CesServer* server = inst->owner->server_; + if (server) server->notifyExtPanelPush(name, frame); + } + return; + } + if (tag == kIpcTagExtSaveConfig) { + // ces.extension_admin.save_config(text): persist the instance's OWN + // /s/.conf. /s/ extensions only; the path is derived from the + // instance's source, never from the frame, so an extension can only ever + // write its own conf. One-way, no on_config echo — the caller already + // applied the values it is saving. writeServerFile is direct file-handler + // I/O (no strand hop), safe on rpcTaskIO. + if (!isServerZone(inst->sourceName) || !inst->isExtension) return; + const std::string& src = inst->sourceName; + auto dot = src.rfind(".lua"); + if (dot == std::string::npos) return; + std::string conf = src.substr(0, dot) + ".conf"; + std::string text(reinterpret_cast(body.data()) + kIpcHdr, + body.size() - kIpcHdr); + if (text.size() > 65535) text.resize(65535); + CesServer* server = inst->owner->server_; + FileHandler* fh = server ? server->fileHandler() : nullptr; + if (!fh || !fh->writeServerFile(conf, text)) { + LOGDEBUG << "extension save_config failed" + << VAR(inst->pid) << SVAR(conf); + } + return; + } + if (tag != kIpcTagApiCall) { + // Child is only supposed to send API_CALL or one of the lua + // routing tags above. Anything else is a protocol error. + LOGDEBUG << "unexpected child tag" + << VAR(inst->pid) << VAR(int(tag)); + killByPid(*inst->owner, inst->pid); + return; + } + if (body.size() < 5) { + sendApiReply(inst, corr, kApiStatusInternal); + return; + } + handleChildApiCall(inst, corr, body.data() + 3, body.size() - 3); +} + +void handleChildApiCall(std::shared_ptr inst, + uint16_t corr_id, + const uint8_t* args, size_t argsLen) { + if (argsLen < 2) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + uint16_t method = ces::Buffer::peek(args); + const uint8_t* mbody = args + 2; + size_t mlen = argsLen - 2; + if (method == kApiMethodClientSend) { + // Body: [8B target_pfx][u16 BE len][bytes] + if (mlen < sizeof(uint64_t) + sizeof(uint16_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + HashPrefix target{}; + std::memcpy(target.data(), mbody, 8); + uint16_t plen = ces::Buffer::peek(mbody + 8); + if (plen > kAppPayloadMax) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + if (mlen < size_t(sizeof(uint64_t) + sizeof(uint16_t) + plen)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + const uint8_t* payload = mbody + 10; + // Build the CES_APP_COMPUTE_MSG packet. Wire format (app-data, + // after MINX strips its opcode byte): + // [1B flags=0][8B prog_pfx][2B len BE][N payload] + minx::Bytes pkt; + ces::Buffer::put(pkt, 0); // flags + pkt.insert(pkt.end(), + inst->progPrefix.begin(), inst->progPrefix.end()); + ces::Buffer::put(pkt, plen); + pkt.insert(pkt.end(), + reinterpret_cast(payload), + reinterpret_cast(payload) + plen); + CesServer* server = inst->owner->server_; + bool ok = server && server->send(target, CES_APP_COMPUTE_MSG, pkt); + sendApiReply(inst, corr_id, + ok ? kApiStatusOk : kApiStatusNotConnected); + return; + } + + // ---- ces.transfer(target_pubkey, amount). Origin is the file's + // dedicated PROGRAM account (ces.program_pubkey()), NOT the owner — + // the program spends its own bankroll, not the deployer's wallet. This + // is what makes a deposit-funded game like /s/dice net-zero: bets are + // transferred into the program account and winnings are paid back out + // of it. (File-store ops are the ones billed to the owner's authority; + // transfers are not.) On /s/ the boot reconcile auto-tops the program + // account; off /s/ the deployer funds it with `cesh file deposit`. + // Reply: [u8 status][u64 BE new_origin_balance]. + if (method == kApiMethodTransfer) { + if (mlen < ces::KEY_SIZE + sizeof(uint64_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + minx::Hash dest{}; + std::memcpy(dest.data(), mbody, 32); + uint64_t amount = ces::Buffer::peek(mbody + 32); + CesServer* server = inst->owner->server_; + if (!server) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + // Origin = the file's program account. + minx::Hash origin{}; + std::memcpy(origin.data(), inst->programPubkey.data(), 32); + auto inst_cap = inst; + server->_l2Transfer(origin, dest, amount, + [inst_cap, corr_id](uint8_t rc, int64_t newBal) { + ces::Bytes tail; + ces::Buffer::put(tail, static_cast( + newBal < 0 ? 0 : newBal)); + sendApiReplyWithBody(inst_cap, corr_id, rc, tail); + }, + inst->peer->get_executor()); + return; + } + + // ---- ces.cross_transfer(dest_pubkey, amount, dest_server). The home + // server is the cross-transfer originator: debit the program's account + // here, settle `amount` to `dest` on peer `dest_server`. Origin = the + // program account, same as ces.transfer. + // Args: [32B dest][u64 amount][u8 srv_len][srv]. Reply: [u8 status][u64 BE bal]. + if (method == kApiMethodCrossTransfer) { + if (mlen < ces::KEY_SIZE + sizeof(uint64_t) + 1) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint8_t srvLen = mbody[ces::KEY_SIZE + sizeof(uint64_t)]; + if (srvLen == 0 || + mlen < ces::KEY_SIZE + sizeof(uint64_t) + 1 + srvLen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + minx::Hash dest{}; + std::memcpy(dest.data(), mbody, 32); + uint64_t amount = ces::Buffer::peek(mbody + 32); + std::string destServer( + reinterpret_cast(mbody + ces::KEY_SIZE + sizeof(uint64_t) + 1), + srvLen); + CesServer* server = inst->owner->server_; + if (!server) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + minx::Hash origin{}; + std::memcpy(origin.data(), inst->programPubkey.data(), 32); + auto inst_cap = inst; + server->_l2CrossTransfer(origin, dest, amount, destServer, + [inst_cap, corr_id](uint8_t rc, int64_t newBal) { + ces::Bytes tail; + ces::Buffer::put(tail, static_cast( + newBal < 0 ? 0 : newBal)); + sendApiReplyWithBody(inst_cap, corr_id, rc, tail); + }, + inst->peer->get_executor()); + return; + } + + // ---- ces.request_funds(amount, dest_server): petition the server to fund THIS + // program's key at the remote. Reserve from the global rate bucket, then a + // detached worker does a server-signed REGULAR transfer at the remote (origin = + // server's reserve there, dest = program), replying with the CONFIRMED granted + // amount (the reservation is refunded if the transfer fails). NOT settlement. + // Args: [u64 amount][u8 srvLen][srv]. Reply: [u8 status][u64 BE granted]. + if (method == kApiMethodRequestFunds) { + if (mlen < sizeof(uint64_t) + 1) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint64_t amount = ces::Buffer::peek(mbody); + uint8_t srvLen = mbody[sizeof(uint64_t)]; + if (srvLen == 0 || mlen < sizeof(uint64_t) + 1 + srvLen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + std::string destServer( + reinterpret_cast(mbody + sizeof(uint64_t) + 1), srvLen); + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + auto reply = [&](uint64_t g) { + ces::Bytes tail; + ces::Buffer::put(tail, g); + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, tail); + }; + uint64_t granted = server->extFundingGrant(amount); + LOGDEBUG << "ext funding: petition " << amount << " at " << destServer + << " -> reserved " << granted; + if (granted == 0) { reply(0); return; } // budget exhausted / off + if (inst->owner->fundingInFlight_.load() >= kFundingMaxInFlight) { // busy -> back off + server->extFundingRefund(granted); reply(0); return; + } + minx::Hash dest{}; + std::memcpy(dest.data(), inst->programPubkey.data(), 32); + inst->owner->fundingInFlight_.fetch_add(1); + try { + std::thread(fundingWorker, inst, corr_id, dest, granted, destServer, + server, server->_serverKeyPair()).detach(); + } catch (...) { + inst->owner->fundingInFlight_.fetch_sub(1); + server->extFundingRefund(granted); reply(0); + } + return; + } + + // ---- ces.random_bytes(n). Pulls n ≤ 256 bytes from the host's + // thread-local AutoSeededRandomPool (CryptoPP). Synchronous — no + // strand hop. + // Reply: [u8 status][n bytes]. + if (method == kApiMethodRandomBytes) { + if (mlen < 2) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint16_t n = ces::Buffer::peek(mbody); + if (n == 0 || n > 256) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + ces::Bytes tail(n); + ces::getThreadLocalPRNG().GenerateBlock( + reinterpret_cast(tail.data()), n); + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, tail); + return; + } + + // ---- ces.bucket_new(ttl_secs, max_entries, max_entry_bytes). + // Per-instance rotating cache; entries last between ttl_secs and + // 2×ttl_secs. Worst-case footprint = max_entries × max_entry_bytes + // is what the supervisor bills against (predictable capacity rent), + // not actual fill — so a program declares its budget upfront. + // Args: [u32 BE ttl_secs][u32 BE max_entries][u32 BE max_entry_bytes] + // Reply: [u8 status][u32 BE bucket_id] + if (method == kApiMethodBucketNew) { + if (mlen < sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint32_t ttl = ces::Buffer::peek(mbody); + uint32_t maxE = ces::Buffer::peek(mbody + 4); + uint32_t maxB = ces::Buffer::peek(mbody + 8); + if (ttl == 0 || maxE == 0 || maxB == 0 || + maxE > 1'000'000 || maxB > 65'536) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint32_t id = inst->nextBucketId++; + Instance::LuaBucket lb; + lb.cache = std::make_shared>( + maxE, static_cast(ttl)); + lb.maxEntries = maxE; + lb.maxEntryBytes = maxB; + lb.committedBytes = + static_cast(maxE) * static_cast(maxB); + inst->buckets[id] = std::move(lb); + ces::Bytes tail; + ces::Buffer::put(tail, id); + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, tail); + return; + } + + // ---- ces.bucket_put(handle, key, value). + // klen + vlen must fit in the bucket's declared max_entry_bytes + // (the per-entry budget the program committed to at bucket_new). + // Args: [u32 BE bucket_id][u16 BE klen][k][u32 BE vlen][v] + // Reply: [u8 status] + if (method == kApiMethodBucketPut) { + if (mlen < sizeof(uint32_t) + sizeof(uint16_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint32_t id = ces::Buffer::peek(mbody); + size_t off = 4; + uint16_t klen = ces::Buffer::peek(mbody + off); off += 2; + if (off + klen + 4 > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + std::string key(reinterpret_cast(mbody + off), klen); + off += klen; + uint32_t vlen = ces::Buffer::peek(mbody + off); off += 4; + if (off + vlen > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + auto it = inst->buckets.find(id); + if (it == inst->buckets.end()) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + if (klen + vlen > it->second.maxEntryBytes) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + std::string val(reinterpret_cast(mbody + off), vlen); + if (!it->second.cache->tryPut(key, val)) { + sendApiReply(inst, corr_id, kApiStatusBucketFull); return; + } + sendApiReply(inst, corr_id, kApiStatusOk); + return; + } + + // ---- ces.bucket_get(handle, key). + // Args: [u32 BE bucket_id][u16 BE klen][k] + // Reply: [u8 status][u8 found_flag][u32 BE vlen][v] + // status is OK whether found or not; the found_flag distinguishes. + // Internal status only on malformed args / bad handle. + if (method == kApiMethodBucketGet) { + if (mlen < sizeof(uint32_t) + sizeof(uint16_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint32_t id = ces::Buffer::peek(mbody); + size_t off = 4; + uint16_t klen = ces::Buffer::peek(mbody + off); off += 2; + if (off + klen > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + std::string key(reinterpret_cast(mbody + off), klen); + auto it = inst->buckets.find(id); + if (it == inst->buckets.end()) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + auto v = it->second.cache->get(key); + ces::Bytes tail; + if (v.has_value()) { + tail.push_back(1); + ces::Buffer::put(tail, static_cast(v->size())); + tail.insert(tail.end(), v->begin(), v->end()); + } else { + tail.push_back(0); + } + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, tail); + return; + } + + // ---- ces.account_read(pubkey). Read-only ledger access. No fee. + // Reply: [u8 status][i64 BE balance][u32 BE nonce] + // [8B last_xfer_dest][u64 BE last_xfer_amount] + // [u32 BE last_xfer_time]. + if (method == kApiMethodAccountRead) { + if (mlen < 32) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + minx::Hash key{}; + std::memcpy(key.data(), mbody, 32); + CesServer* server = inst->owner->server_; + if (!server) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + auto inst_cap = inst; + server->_l2QueryAccount(key, + [inst_cap, corr_id](int64_t bal, uint32_t nonce, + HashPrefix lastDest, uint64_t lastAmount, + uint32_t lastTime) { + ces::Bytes tail; + // Cast int64 → uint64 bit-pattern preserves sign for the + // child's 8-byte read. Account balances on the wire are + // signed (payment accounts); ces.account_read on the Lua + // side returns a Lua number, which is float-double — fine + // for everyday balances, may lose precision past 2^53. The + // dice game's bet sizes are far below that. + ces::Buffer::put(tail, static_cast(bal)); + ces::Buffer::put(tail, nonce); + tail.insert(tail.end(), lastDest.begin(), lastDest.end()); + ces::Buffer::put(tail, lastAmount); + ces::Buffer::put(tail, lastTime); + sendApiReplyWithBody(inst_cap, corr_id, kApiStatusOk, tail); + }, + inst->peer->get_executor()); + return; + } + + // ---- ces.keyname(pubkey) → the key_name registered to that key on THIS + // server ("" if none). Reply: [u8 status][name bytes]. Read-only, no fee. + // Lets an app (e.g. Vellum) gate entry on whether the caller has a name. + if (method == kApiMethodKeyName) { + if (mlen < 32) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + minx::Hash key{}; + std::memcpy(key.data(), mbody, 32); + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + auto inst_cap = inst; + server->_l2QueryKeyName(key, + [inst_cap, corr_id](const std::string& name) { + ces::Bytes tail(name.begin(), name.end()); + sendApiReplyWithBody(inst_cap, corr_id, kApiStatusOk, tail); + }, + inst->peer->get_executor()); + return; + } + + // ---- ces.peers() → the server's peer-table snapshot. + // Request: (no args) + // Reply body (after the u8 status): [u16 count] then per peer + // [32B ckey][u16 addr_len][addr][u8 flags][u16 rpc_port] + // flags: bit0 reachable, bit1 verified, bit2 outbound, bit3 inbound + // Same data as the public CES_QUERY_PEER_INFO opcode; _peerSnapshot() + // locks the peer-table mutex internally, so it is safe off logicStrand_. + if (method == kApiMethodPeers) { + CesServer* server = inst->owner->server_; + if (!server) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + auto peers = server->_peerSnapshot(); + // A grief-banned peer must not register to extensions: hide it from ces.peers(). + const uint64_t nowSecs = minx::getSecsSinceEpoch(); + peers.erase(std::remove_if(peers.begin(), peers.end(), + [&](const CesServer::PeerInfo& p) { + return p.bannedUntil != 0 && nowSecs < p.bannedUntil; }), peers.end()); + ces::Bytes body; + ces::Buffer::put(body, static_cast(peers.size())); + for (const auto& p : peers) { + body.insert(body.end(), p.ckey.begin(), p.ckey.end()); + ces::Buffer::put( + body, static_cast(p.declaredAddress.size())); + body.insert(body.end(), p.declaredAddress.begin(), + p.declaredAddress.end()); + uint8_t flags = 0; + if (p.reachable) flags |= 0x01; + if (p.verified) flags |= 0x02; + if (p.outbound) flags |= 0x04; + if (p.inbound) flags |= 0x08; + body.push_back(flags); + ces::Buffer::put(body, p.rpcPort); + // Lifetime PoW exchanged with the peer (raw units): the econ signal an + // autopeering extension uses to tell a committed peering from a dead one. + ces::Buffer::put(body, p.totalInboundPoW); + ces::Buffer::put(body, p.totalOutboundPoW); + } + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, body); + return; + } + + // ces.server_info() -> the server's public stats. A CES server is a public + // entity (only its private key is secret), so this is unguarded and read-only. + // Generic named-KV reply: u16 nInts, [u16 klen, key, u64 val]*; then u16 nStrs, + // [u16 klen, key, u16 vlen, val]*. Adding a stat never touches the Lua parser. + if (method == kApiMethodServerInfo) { + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + const CesConfig& c = server->_config(); + auto st = server->_adminStats(); + const std::pair ints[] = { + {"accounts", st.accounts}, {"assets", st.assets}, {"aliases", st.aliases}, + {"circulating", static_cast(st.circulating)}, {"tx_count", st.txCount}, + {"tps", server->getTps()}, {"min_difficulty", c.minDiff}, + {"fee_tx", c.feeTx}, {"fee_query", c.feeQuery}, {"fee_account", c.feeAccount}, + {"rpc_port", static_cast(server->_rpcBoundPort())}, + }; + const std::pair strs[] = { + {"version", &c.version}, {"server_name", &c.serverName}, + }; + ces::Bytes body; + ces::Buffer::put(body, static_cast(sizeof(ints) / sizeof(ints[0]))); + for (const auto& kv : ints) { + ces::Buffer::put(body, static_cast(kv.first.size())); + body.insert(body.end(), kv.first.begin(), kv.first.end()); + ces::Buffer::put(body, kv.second); + } + ces::Buffer::put(body, static_cast(sizeof(strs) / sizeof(strs[0]))); + for (const auto& kv : strs) { + ces::Buffer::put(body, static_cast(kv.first.size())); + body.insert(body.end(), kv.first.begin(), kv.first.end()); + ces::Buffer::put(body, static_cast(kv.second->size())); + body.insert(body.end(), kv.second->begin(), kv.second->end()); + } + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, body); + return; + } + + // ---- Peering control (privileged: /s/ programs only). The supervisor is the + // authority -- it gates on the instance's own source zone, so the boundary + // holds even if a child bypasses cesluajitd's registration-time gate. + + // ces.add_peer(pubkey, address) — establish an outbound peering. + // Request: [32B pubkey][address bytes]. Reply: [u8 status]. + if (method == kApiMethodPeerAdd) { + if (!isServerZone(inst->sourceName)) { + sendApiReply(inst, corr_id, kApiStatusDenied); return; + } + if (mlen <= 32 || mlen > 32 + 256) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + minx::Hash ckey{}; + std::memcpy(ckey.data(), mbody, 32); + std::string address(reinterpret_cast(mbody + 32), mlen - 32); + server->_addOutboundPeer(ckey, address); + sendApiReply(inst, corr_id, kApiStatusOk); + return; + } + + // ces.remove_peer(pubkey) — drop a peering. + // Request: [32B pubkey]. Reply: [u8 status][u8 removed]. + if (method == kApiMethodPeerRemove) { + if (!isServerZone(inst->sourceName)) { + sendApiReply(inst, corr_id, kApiStatusDenied); return; + } + if (mlen < 32) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + minx::Hash ckey{}; + std::memcpy(ckey.data(), mbody, 32); + bool removed = server->_removePeer(ckey); + ces::Bytes body; + body.push_back(removed ? 1 : 0); + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, body); + return; + } + + // ces.grief_peer(pubkey) — raise a peer's grief; the C++ side bans it at the + // threshold. Request: [32B pubkey]. Reply: [u8 status]. + if (method == kApiMethodPeerGrief) { + if (!isServerZone(inst->sourceName)) { + sendApiReply(inst, corr_id, kApiStatusDenied); return; + } + if (mlen < 32) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + minx::Hash ckey{}; + std::memcpy(ckey.data(), mbody, 32); + // Optional [u32 BE amount] after the pubkey; absent (older callers) => 1. + uint32_t amount = (mlen >= 36) ? ces::Buffer::peek(mbody + 32) : 1; + server->griefPeer(ckey, amount); + sendApiReply(inst, corr_id, kApiStatusOk); + return; + } + + if (method == kApiMethodPeerBan) { + if (!isServerZone(inst->sourceName)) { + sendApiReply(inst, corr_id, kApiStatusDenied); return; + } + if (mlen < 32) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + minx::Hash ckey{}; + std::memcpy(ckey.data(), mbody, 32); + server->banPeer(ckey); + sendApiReply(inst, corr_id, kApiStatusOk); + return; + } + + // ces.serverSign(bytes) — sign with the server key for a privileged /s/ extension. + // The host applies the reserved domain tag and hashes (server.cpp), so the signed + // message is always SHA256(tag || bytes). Request: [bytes]. Reply: [u8 status][sig]. + if (method == kApiMethodServerSign) { + if (!isServerZone(inst->sourceName)) { + sendApiReply(inst, corr_id, kApiStatusDenied); return; + } + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + ces::Signature sig = server->serverSign(mbody, mlen); + ces::Bytes tail(sig.begin(), sig.end()); + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, tail); + return; + } + + // ces.set_peer_target(credits) — set the reserve-mining target the peer miner + // drives toward. Request: [u64 BE]. Reply: [u8 status]. + if (method == kApiMethodPeerTargetSet) { + if (!isServerZone(inst->sourceName)) { + sendApiReply(inst, corr_id, kApiStatusDenied); return; + } + if (mlen < sizeof(uint64_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + server->_setPeerTarget(ces::Buffer::peek(mbody)); + sendApiReply(inst, corr_id, kApiStatusOk); + return; + } + + // ces.peer_target() — read the current reserve-mining target. + // Reply: [u8 status][u64 BE target]. + if (method == kApiMethodPeerTargetGet) { + if (!isServerZone(inst->sourceName)) { + sendApiReply(inst, corr_id, kApiStatusDenied); return; + } + CesServer* server = inst->owner->server_; + if (!server) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + ces::Bytes body; + ces::Buffer::put(body, server->_peerTarget()); + sendApiReplyWithBody(inst, corr_id, kApiStatusOk, body); + return; + } + + // ---- ces.authentic_asset_create(asset_id, recipient_pubkey, + // payload, days). + // Request: [32B asset_id][32B recipient_pubkey][u16 BE days][payload <= 178B] + // Reply: [u8 status] (CES_OK or error_code_t) + if (method == kApiMethodAuthenticAssetCreate) { + constexpr size_t kAuthHeaderLen = + sizeof(minx::Hash) + ces::KEY_SIZE + sizeof(uint16_t); + if (mlen < kAuthHeaderLen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + minx::Hash assetId{}; + std::memcpy(assetId.data(), mbody, 32); + minx::Hash recipient{}; + std::memcpy(recipient.data(), mbody + 32, 32); + uint16_t days = ces::Buffer::peek(mbody + 64); + const uint8_t* payload = mbody + kAuthHeaderLen; + size_t payloadLen = mlen - kAuthHeaderLen; + if (payloadLen > AUTHENTIC_ASSET_PAYLOAD_SIZE) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + + CesServer* server = inst->owner->server_; + if (!server) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + + // Lookup (or compute on first use) the program-identity hash + // from the source file's sidecar. Done synchronously here on + // rpcTaskIO_ — it's a single sha256 of a small file (Lua + // source). After the first hit it's cached in the sidecar. + std::array progHash{}; + if (!fhGetProgramHash(inst->owner->server_, inst->sourceName, progHash)) { + sendApiReply(inst, corr_id, CES_ERROR_INTERNAL); return; + } + + // Assemble the 210-byte content: [32B programHash][payload, zero-padded]. + AssetData content{}; + std::memcpy(content.data(), progHash.data(), AUTHENTIC_ASSET_HASH_SIZE); + if (payloadLen > 0) + std::memcpy(content.data() + AUTHENTIC_ASSET_HASH_SIZE, payload, payloadLen); + + // IMMUTABLE; not private, not asset-owned. createAsset adds the + // standard 1-day grace and re-derives the flags from this balance. + uint16_t balance = assetBalance(days, /*priv=*/false, /*aowned=*/false, + /*immutable=*/true); + + // Origin = the file's program account. + minx::Hash origin{}; + std::memcpy(origin.data(), inst->programPubkey.data(), 32); + HashPrefix recipientPrefix = ces::Account::getMapKey(recipient); + + auto inst_cap = inst; + server->createAssetAsync( + origin, recipientPrefix, assetId, content, balance, + [inst_cap, corr_id](uint8_t rc) { + sendApiReply(inst_cap, corr_id, rc); + }, + inst->peer->get_executor()); + return; + } + + // ---- File verbs. Reply tail formats are verb-specific (see each + // case); reply status is a raw error_code_t (CES_OK = 0x00 on + // success; any non-zero is a file-handler error, e.g. + // FILE_NOT_FOUND=0x16, NOT_OWNER=0x0a, INSUFFICIENT_BALANCE=0x03, + // BAD_NAME=0x18). The Lua side exposes this as the first return + // value of ces.file_*. + + // Helper: parse [u16 BE name_len][name] starting at mbody+start. + // Returns true with outName populated, false on malformed. + auto parseName = [](const uint8_t* mbody, size_t mlen, size_t& off, + std::string& outName) -> bool { + if (off + 2 > mlen) return false; + uint16_t nl = ces::Buffer::peek(mbody + off); + off += 2; + if (nl == 0 || off + nl > mlen) return false; + outName.assign(reinterpret_cast(mbody + off), nl); + off += nl; + return true; + }; + + CesServer* server = inst->owner->server_; + if (!server) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + auto cbEx = inst->peer->get_executor(); + + FileExecReq req{}; + req.ownerPubkey = inst->ownerPk; + req.sourceName = inst->sourceName; + + size_t off = 0; + switch (method) { + case kApiMethodFileStat: { + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbStat; + break; + } + case kApiMethodFileRead: { + if (mlen < sizeof(uint64_t) + sizeof(uint32_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.offset = ces::Buffer::peek(mbody + off); off += 8; + req.length = ces::Buffer::peek(mbody + off); off += 4; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbRead; + break; + } + case kApiMethodFileWrite: { + if (mlen < 8) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.offset = ces::Buffer::peek(mbody + off); off += 8; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + if (off + 4 > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint32_t blen = ces::Buffer::peek(mbody + off); off += 4; + if (off + blen > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.body.assign(mbody + off, mbody + off + blen); + off += blen; + req.verb = kFileVerbWrite; + break; + } + case kApiMethodFileAppend: { + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + if (off + 4 > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint32_t blen = ces::Buffer::peek(mbody + off); off += 4; + if (off + blen > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.body.assign(mbody + off, mbody + off + blen); + off += blen; + req.verb = kFileVerbAppend; + break; + } + case kApiMethodFileCreate: { + if (mlen < sizeof(uint64_t) + sizeof(uint64_t) + sizeof(uint64_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.size = ces::Buffer::peek(mbody + off); off += 8; + req.pricePerKb = ces::Buffer::peek(mbody + off); off += 8; + req.initialDeposit = ces::Buffer::peek(mbody + off); off += 8; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbCreate; + break; + } + case kApiMethodFileDeposit: + case kApiMethodFileWithdraw: { + if (mlen < 8) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.amount = ces::Buffer::peek(mbody + off); off += 8; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = (method == kApiMethodFileDeposit) + ? kFileVerbDeposit : kFileVerbWithdraw; + break; + } + case kApiMethodFileSetPrice: { + if (mlen < 8) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.pricePerKb = ces::Buffer::peek(mbody + off); off += 8; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbSetPrice; + break; + } + case kApiMethodFileDelete: { + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbDelete; + break; + } + case kApiMethodFileResize: { + if (mlen < 8) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.size = ces::Buffer::peek(mbody + off); off += 8; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbResize; + break; + } + case kApiMethodKvCreate: { + // [u64 price_per_kb][u64 initial_deposit][u16 namelen][name] + if (mlen < sizeof(uint64_t) * 2) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.pricePerKb = ces::Buffer::peek(mbody + off); off += 8; + req.initialDeposit = ces::Buffer::peek(mbody + off); off += 8; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbKvCreate; + break; + } + case kApiMethodKvPut: { + // [u64 deposit][u16 keylen][key][u32 vallen][value][u16 namelen][name] + if (mlen < sizeof(uint64_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.amount = ces::Buffer::peek(mbody + off); off += 8; + std::string keyStr; + if (!parseName(mbody, mlen, off, keyStr)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.key.assign(keyStr.begin(), keyStr.end()); + if (off + 4 > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + uint32_t vlen = ces::Buffer::peek(mbody + off); off += 4; + if (off + vlen > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.value.assign(mbody + off, mbody + off + vlen); off += vlen; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbKvPut; + break; + } + case kApiMethodKvDeposit: { + // [u64 amount][u16 keylen][key][u16 namelen][name] + if (mlen < sizeof(uint64_t)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.amount = ces::Buffer::peek(mbody + off); off += 8; + std::string keyStr; + if (!parseName(mbody, mlen, off, keyStr)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.key.assign(keyStr.begin(), keyStr.end()); + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbKvDeposit; + break; + } + case kApiMethodKvGet: + case kApiMethodKvErase: { + // [u16 keylen][key][u16 namelen][name] + std::string keyStr; + if (!parseName(mbody, mlen, off, keyStr)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.key.assign(keyStr.begin(), keyStr.end()); + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = (method == kApiMethodKvGet) + ? kFileVerbKvGet : kFileVerbKvErase; + break; + } + case kApiMethodKvIter: { + // [u16 namelen][name] + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbKvIter; + break; + } + case kApiMethodKvRange: { + // [u16 lo_len][lo][u16 hi_len][hi][u64 limit][u16 namelen][name] + // lo/hi may be empty (start/end of store), so parse lengths inline. + auto parseOptBytes = [&](ces::Bytes& outB) -> bool { + if (off + 2 > mlen) return false; + uint16_t bl = ces::Buffer::peek(mbody + off); off += 2; + if (off + bl > mlen) return false; + outB.assign(mbody + off, mbody + off + bl); off += bl; + return true; + }; + if (!parseOptBytes(req.rangeLo) || !parseOptBytes(req.rangeHi) || + off + 8 > mlen) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.amount = ces::Buffer::peek(mbody + off); off += 8; + if (!parseName(mbody, mlen, off, req.name)) { + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + req.verb = kFileVerbKvRange; + break; + } + default: + sendApiReply(inst, corr_id, kApiStatusInternal); return; + } + + // Dispatch to the in-process file primitive. Callback builds the + // method-specific reply tail (only on OK) and sends API_REPLY. + uint16_t saved_method = method; + auto inst_cap = inst; + FileHandler* fh = server->fileHandler(); + if (!fh) { sendApiReply(inst, corr_id, kApiStatusInternal); return; } + fh->exec(req, + [inst_cap, corr_id, saved_method](FileExecResp resp) { + if (resp.status != CES_OK) { + sendApiReply(inst_cap, corr_id, resp.status); + return; + } + ces::Bytes tail; + switch (saved_method) { + case kApiMethodFileStat: { + tail.insert(tail.end(), + resp.ownerPubkey.begin(), resp.ownerPubkey.end()); + ces::Buffer::put(tail, resp.fileBalance); + ces::Buffer::put(tail, resp.pricePerKb); + ces::Buffer::put(tail, resp.size); + ces::Buffer::put(tail, resp.createdUs); + ces::Buffer::put(tail, resp.modifiedUs); + break; + } + case kApiMethodFileRead: { + ces::Buffer::put(tail, static_cast(resp.data.size())); + tail.insert(tail.end(), resp.data.begin(), resp.data.end()); + break; + } + case kApiMethodFileCreate: + case kApiMethodFileWrite: + case kApiMethodFileDeposit: + case kApiMethodFileWithdraw: { + ces::Buffer::put(tail, resp.fileBalance); + break; + } + case kApiMethodFileSetPrice: { + ces::Buffer::put(tail, resp.pricePerKb); + break; + } + case kApiMethodFileDelete: { + ces::Buffer::put(tail, resp.refunded); + break; + } + case kApiMethodFileAppend: + case kApiMethodFileResize: { + ces::Buffer::put(tail, resp.fileBalance); + ces::Buffer::put(tail, resp.size); + break; + } + case kApiMethodKvCreate: { + ces::Buffer::put(tail, resp.fileBalance); + break; + } + case kApiMethodKvPut: { + ces::Buffer::put(tail, resp.fileBalance); + ces::Buffer::put(tail, resp.size); + break; + } + case kApiMethodKvGet: { + tail.push_back(resp.found ? 1 : 0); + ces::Buffer::put(tail, + static_cast(resp.value.size())); + tail.insert(tail.end(), resp.value.begin(), resp.value.end()); + break; + } + case kApiMethodKvErase: { + ces::Buffer::put(tail, resp.size); + break; + } + case kApiMethodKvDeposit: { + ces::Buffer::put(tail, resp.fileBalance); + break; + } + case kApiMethodKvRange: { + ces::Buffer::put(tail, + static_cast(resp.rangeEnd.size())); + tail.insert(tail.end(), resp.rangeEnd.begin(), resp.rangeEnd.end()); + ces::Buffer::put(tail, + static_cast(resp.keys.size())); + for (size_t i = 0; i < resp.keys.size(); ++i) { + ces::Buffer::put(tail, + static_cast(resp.keys[i].size())); + tail.insert(tail.end(), resp.keys[i].begin(), resp.keys[i].end()); + ces::Buffer::put(tail, + static_cast(resp.values[i].size())); + tail.insert(tail.end(), resp.values[i].begin(), resp.values[i].end()); + } + break; + } + case kApiMethodKvIter: { + ces::Buffer::put(tail, + static_cast(resp.keys.size())); + for (const auto& k : resp.keys) { + ces::Buffer::put(tail, + static_cast(k.size())); + tail.insert(tail.end(), k.begin(), k.end()); + } + break; + } + default: break; + } + sendApiReplyWithBody(inst_cap, corr_id, resp.status, tail); + }, cbEx); +} + +// Read the Lua source from disk for a source-file path (e.g. +// /h//echo.lua). Returns empty vector on any failure. The +// handler is the caller; it already validated that the file exists +// via the file handler's readOwnerAndBalance. +ces::Bytes readSourceBytes(const CesConfig& cfg, + const std::string& name) { + auto p = std::filesystem::path(cfg.cesFileStoreDir) / + name.substr(1); // name is "/h/..."; drop the leading / + std::ifstream f(p, std::ios::binary); + if (!f) return {}; + ces::Bytes out( + (std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + return out; +} + +// --------------------------------------------------------------------------- +// Verb dispatch — the signed-request loop lives in the CesPlex framework +// (cesPlexServe / CesPlexRequest, see cesplex/mux.h). ReqCtx aliases +// the framework request so the dispatchers below need no changes; the +// thin senders forward to its respond/error helpers. +// --------------------------------------------------------------------------- + +using ReqCtx = ces::CesPlexRequest; + +// The CesPlex bus is host-generic (it knows only CesPlexHost). builtin:compute +// is a CES core feature, so its host is always the CesServer — recover the +// concrete server for the ledger-facing calls below. +inline CesServer* reqServer(const std::shared_ptr& ctx) { + return static_cast(ctx->host); +} + +inline void sendResponseAndLoop(std::shared_ptr ctx, uint8_t status, + ces::Bytes preamble) { + ctx->respond(status, std::move(preamble)); +} +inline void sendErrorAndLoop(std::shared_ptr ctx, uint8_t status) { + ctx->error(status); +} + +// Verb dispatch forward decls. +void dispatchLaunch (std::shared_ptr ctx, ces::Bytes pre); +void dispatchKill (std::shared_ptr ctx, ces::Bytes pre); +void dispatchList (std::shared_ptr ctx, ces::Bytes pre); +void dispatchStat (std::shared_ptr ctx, ces::Bytes pre); +void dispatchInstances (std::shared_ptr ctx, ces::Bytes pre); +void dispatchCall (std::shared_ptr ctx, ces::Bytes pre); + +// --------------------------------------------------------------------------- +// Helper: compute slot-fee window in credits. +// --------------------------------------------------------------------------- + +uint64_t slotFeePerSec(const CesConfig& cfg) { + int64_t s = cfg.feeComputeSlotSec; + if (s <= 0) { + // Derive: rent on a nominal 1 KB file per second. + // feeFileRent is credits per (byte × day). Seconds-per-day = 86400. + // result = feeFileRent * 1024 / 86400, floored to >= 1. + int64_t rent = cfg.feeFileRent; + if (rent <= 0) rent = 1; + int64_t v = (rent * 1024) / 86'400; + if (v < 1) v = 1; + return static_cast(v); + } + return static_cast(s); +} + +// --------------------------------------------------------------------------- +// LAUNCH +// --------------------------------------------------------------------------- + +// In-flight LAUNCH accept state. Held by shared_ptr so the async_accept +// completion and the deadline timer share one `finished` guard — +// whichever fires first wins, the other no-ops. Everything runs on +// rpcTaskIO_, so the bool needs no atomic. +struct LaunchAccept { + std::shared_ptr inst; + std::shared_ptr acceptor; + std::shared_ptr peer; + std::shared_ptr timer; + ces::Bytes src; + std::function done; + uint64_t now = 0; + uint64_t upfront = 0; + bool finished = false; + std::shared_ptr slot; + std::shared_ptr portLease; + std::shared_ptr rpcPortLease; +}; + +// Strand-only (rpcTaskIO_). Allocate a fresh instance, spawn the child +// binary, and asynchronously await its connect-back on the per-instance +// Unix socket. On success: register in instances_, send the bootstrap +// frame, arm the IPC reader, invoke `done(id)`. On any failure: +// `done(0)` after tearing down whatever was allocated. +// +// The connect-back is awaited via async_accept bounded by a +// kAcceptTimeoutMs timer — it must NEVER block rpcTaskIO_, which also +// drives every other CesPlex channel, ChannelMeter, and the +// supervisor. (The old synchronous poll()-accept stalled all of them +// for up to kAcceptTimeoutMs on every launch.) +// +// Caller has already validated source-file existence + ownership and +// (if applicable) debited the upfront commitment fee from file_balance. +// `upfront` is recorded on the Instance for visibility, not debited +// here. `now` is the instance's birth wall-clock and the start of its +// first billing tick. +void allocateAndSpawnInstance( + CesServer* server, const std::string& name, + const std::array& ownerPk, + uint64_t upfront, uint64_t now, bool internal, + std::shared_ptr slot, + std::shared_ptr portLease, + std::shared_ptr rpcPortLease, + std::function done) { + const auto& cfg = server->_config(); + + auto workDir = resolveWorkDir(cfg); + std::error_code ec; + std::filesystem::create_directories(workDir, ec); + + // Read the source's program-account keypair from its sidecar. + std::array programPubkey{}; + std::array programPrivkey{}; + fhReadProgramPubkey(server, name, programPubkey); + fhReadProgramPrivkey(server, name, programPrivkey); + + ComputeHandler* H = server->computeHandler(); + uint64_t pid = H->nextPid_++; + auto inst = std::make_shared(); + inst->owner = H; + inst->pid = pid; + inst->sourceName = name; + inst->ownerPk = ownerPk; + inst->programPubkey = programPubkey; + inst->programPrivkey = programPrivkey; + inst->progPrefix = progPrefixOf(name); + inst->socketPath = instanceSocketPath(cfg, pid).string(); + inst->upfrontDeposit = upfront; + inst->internalLaunch = internal; + + int lfd = createListenSocket(inst->socketPath); + if (lfd < 0) { + LOGWARNING << "socket create failed" << VAR(lfd); + done(0); + return; + } + + // Slurp the Lua source before spawning — if the source file is gone + // or unreadable on disk, fail cleanly rather than after spawning a + // child with nothing to run. + auto srcBytes = readSourceBytes(cfg, name); + if (srcBytes.empty()) { + LOGWARNING << "source read failed" << SVAR(name); + ::close(lfd); + teardownInstance(*inst); + done(0); + return; + } + + pid_t ospid = spawnChild(cfg.cesComputeChildBinary, + inst->socketPath, + cfg.cesComputeUser, + cfg.computeProcessMemMax, + cfg.computeClientPoolSize); + if (ospid <= 0) { + LOGWARNING << "spawn failed" + << VAR(ospid) << SVAR(cfg.cesComputeChildBinary); + ::close(lfd); + teardownInstance(*inst); + done(0); + return; + } + inst->ospid = ospid; + + auto io = server->_rpcTaskIOExecutor(); + auto st = std::make_shared(); + st->inst = inst; + st->acceptor = std::make_shared(io); + st->peer = std::make_shared(io); + st->timer = std::make_shared(io); + st->src = std::move(srcBytes); + st->done = std::move(done); + st->now = now; + st->upfront = upfront; + st->slot = std::move(slot); + st->portLease = std::move(portLease); + st->rpcPortLease = std::move(rpcPortLease); + + // Adopt the listen fd into the acceptor — it now owns + closes it. + boost::system::error_code aec; + st->acceptor->assign(boost::asio::local::stream_protocol(), lfd, aec); + if (aec) { + LOGWARNING << "acceptor assign failed" + << SVAR(aec.message()); + ::close(lfd); + killAndReap(pid); + teardownInstance(*inst); + st->done(0); + return; + } + + // Deadline: the child must connect back within kAcceptTimeoutMs. On + // expiry, abort the accept, reap the child, tear the instance down. + st->timer->expires_after(std::chrono::milliseconds(kAcceptTimeoutMs)); + st->timer->async_wait([st](const boost::system::error_code& tec) { + if (tec || st->finished) return; + st->finished = true; + boost::system::error_code ig; + st->acceptor->close(ig); + LOGWARNING << "accept timed out" << VAR(st->inst->pid); + killAndReap(st->inst->ospid); + teardownInstance(*st->inst); + st->done(0); + }); + + // Async accept the child's connect-back. Never blocks the strand. + st->acceptor->async_accept( + *st->peer, + [st](const boost::system::error_code& cec) { + if (st->finished) return; + st->finished = true; + boost::system::error_code ig; + st->timer->cancel(ig); + st->acceptor->close(ig); + auto inst = st->inst; + if (cec) { + LOGWARNING << "accept failed" << SVAR(cec.message()); + killAndReap(inst->ospid); + teardownInstance(*inst); + st->done(0); + return; + } + // Handler stopped mid-launch (server shutting down): don't register + // a zombie into instances_ after teardown ran. + ComputeHandler& H = *inst->owner; + if (H.stopped_.load()) { + killAndReap(inst->ospid); + teardownInstance(*inst); + st->done(0); + return; + } + inst->peer = st->peer; + inst->startedAtUs = st->now; + inst->lastTickUs = st->now; + inst->lastSampleUs = st->now; + inst->lastCpuTicks = 0; + + // Commit the reserved port to the instance: killByPid now + // owns freeing it, so the lease must not also free it on drop. + inst->clientPort = st->portLease->port; + st->portLease->commit(); + inst->rpcPort = st->rpcPortLease->port; + st->rpcPortLease->commit(); + + H.instances_[inst->pid] = inst; + H.byPrefix_[inst->progPrefix].insert(inst->pid); + H.byName_[inst->sourceName].insert(inst->pid); + H.launchingUntil_.erase(inst->sourceName); // registered; byName_ now dedups + // Make this program's account a gossip sink target. + if (CesServer* sv = H.server_) + sv->registerSinkTarget(inst->programPubkey); + // Registered in instances_ now — drop the launch-slot reservation + // so it isn't double-counted against the cap. + st->slot.reset(); + + // Bootstrap the child with its Lua source + identity (incl. the + // assigned client port), then arm the IPC reader loop. + sendBootstrapFrame(inst, st->src.data(), st->src.size()); + startIpcReader(inst); + + LOGINFO << "launched" + << VAR(inst->pid) << SVAR(inst->sourceName) + << VAR(inst->ospid) << VAR(st->upfront); + if (inst->sourceName.rfind("/s/", 0) == 0) regenerateInstanceCatalog(H); + st->done(inst->pid); + }); +} + +// Charge the bound signer `cost` (with NONCELESS dedup) in one atomic +// _l2Transact. Returns the status + whether this was a replay; the verb then +// runs its `after` body inline. allowMissingOrigin lets a no-account signer read +// public verbs for free (cross-server discovery). reqNonce==0 opts out of dedup +// (LAUNCH). +struct SignerChargeResult { uint8_t status; bool duplicate; }; +SignerChargeResult chargeSignerSync(CesServer* server, const ces::PublicKey& signer, + int64_t cost, uint32_t reqNonce, uint64_t sigHash, + int64_t errFee, bool allowMissingOrigin) { + minx::Hash signerHash = signer.getHash(); + uint8_t status = CES_ERROR_INTERNAL; + bool duplicate = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + if (reqNonce == CES_NONCELESS && t.isReplay(sigHash)) { + duplicate = true; status = CES_OK; return; + } + uint8_t r = t.signerSpend(signerHash, static_cast(cost), reqNonce, errFee); + if (r != CES_OK) { + if (allowMissingOrigin && r == CES_ERROR_ORIGIN_NOT_FOUND) { status = CES_OK; return; } + status = r; return; + } + if (reqNonce == CES_NONCELESS) t.recordDedup(sigHash); + status = CES_OK; + }); + return { status, duplicate }; +} + +void dispatchLaunch(std::shared_ptr ctx, ces::Bytes pre) { + if (pre.size() < 2) { + sendErrorAndLoop(ctx, CES_ERROR_INTERNAL); return; + } + uint16_t nameLen = ces::Buffer::peek(pre.data()); + if (nameLen == 0 || nameLen > kMaxNameLen || pre.size() < sizeof(uint16_t) + nameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + std::string name(reinterpret_cast(pre.data() + 2), nameLen); + + const auto& cfg = reqServer(ctx)->_config(); + + // Check instance cap against registered + in-flight launches. LAUNCH + // always mints a fresh id; multiple instances of the same source path + // are allowed up to the cap. + if (launchSlotsInUse(*reqServer(ctx)->computeHandler()) >= cfg.computeMaxInstances) { + sendErrorAndLoop(ctx, CES_ERROR_COMPUTE_MAX_INSTANCES); return; + } + + // Source-file owner + balance check via the file handler. + std::array ownerPk{}; + uint64_t fileBalance = 0; + if (!fhReadOwnerAndBalance(reqServer(ctx), name, ownerPk, fileBalance)) { + sendErrorAndLoop(ctx, CES_ERROR_FILE_NOT_FOUND); return; + } + if (std::memcmp(ownerPk.data(), ctx->bound.boundPubkey.getHash().data(), 32) != 0) { + sendErrorAndLoop(ctx, CES_ERROR_NOT_OWNER); return; + } + // /s/ programs are operator-deployed and unmetered: supervisor billing + // no-ops on them (their file_balance is decorative), so the LAUNCH-time + // upfront commitment must be waived too. Otherwise a /s/ program -- the + // "ships standard" model (dht, dice) -- cannot be launched via the explicit + // verb at all, only via the internal extension path. + const bool serverZone = isServerZone(name); + + // Discounted slot rate for the upfront commitment (LAUNCH-time price). + uint64_t slot = reqServer(ctx)->discountFee( + FeeKind::ComputeSlot, slotFeePerSec(cfg)); + uint64_t upfront = serverZone ? 0 : slot * kUpfrontSeconds; + + if (!serverZone && fileBalance < upfront) { + sendErrorAndLoop(ctx, CES_ERROR_COMPUTE_FUND_TOO_LOW); return; + } + + // Compute dedup hash + signer for the _l2 call. + const ces::PublicKey& signer = ctx->bound.boundPubkey; + + // Claim the child's outbound-client port from the configured range, + // best-effort. An exhausted (or zero) range leaves clientPort 0; the + // instance still launches — it stays reachable via the server's own rpc + // port (/ces/lua/1 ATTACH relay), so compute_port_count == 0 is a valid + // config. Only the child's OUTBOUND remote_* verbs go dark, and they + // error permanently on port 0. + uint16_t clientPort = 0; + allocateComputePort(*reqServer(ctx)->computeHandler(), cfg, clientPort); + auto portLease = std::make_shared(reqServer(ctx)->computeHandler(), clientPort); + + // Second best-effort lease: the child's inbound CesPlex host port + // (/ces/luarpc/1). Independent of clientPort — exhaustion here leaves + // rpcPort 0 (the instance hosts nothing) without failing the launch. + uint16_t rpcPort = 0; + allocateComputePort(*reqServer(ctx)->computeHandler(), cfg, rpcPort); + auto rpcPortLease = std::make_shared(reqServer(ctx)->computeHandler(), rpcPort); + + // Reserve a launch slot now and hold it across the async validate + + // spawn chain (released when the instance registers or the launch + // fails). Race-free: nothing else runs on this strand between the cap + // check above and here, so the reservation reflects that decision. + auto launchSlot = std::make_shared(reqServer(ctx)->computeHandler()); + + auto after = [ctx, name, upfront, ownerPk, launchSlot, portLease, rpcPortLease]( + uint8_t rc, bool /*duplicate*/) mutable { + if (rc != CES_OK) { sendErrorAndLoop(ctx, rc); return; } + // LAUNCH passes reqNonce=0 (opted out of dedup) so `duplicate` is never + // true here; each call independently mints + charges a fresh instance. + + // Debit the 15-min upfront from the source file's file_balance. + // This is a commitment the host honors by starting + monitoring + // the instance; no refund on KILL. + if (!fhDebitBalance(reqServer(ctx), name, upfront)) { + sendErrorAndLoop(ctx, CES_ERROR_COMPUTE_FUND_TOO_LOW); return; + } + + uint64_t now = getMicrosSinceEpoch(); + allocateAndSpawnInstance( + reqServer(ctx), name, ownerPk, upfront, now, /*internal=*/false, std::move(launchSlot), + std::move(portLease), std::move(rpcPortLease), + [ctx, now](uint64_t pid) { + if (pid == 0) { + sendErrorAndLoop(ctx, CES_ERROR_INTERNAL); return; + } + ces::Bytes resp; + ces::Buffer::put(resp, pid); + ces::Buffer::put(resp, now); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); + }); + }; + + // LAUNCH is non-idempotent (each call mints a fresh instance), and the + // NONCELESS sig-dedup can't survive a channel reselect anyway, so opt + // out of it: pass reqNonce=0 ("no dedup, no nonce ordering") instead of + // the wire CES_NONCELESS. Every LAUNCH is then independently + // fee-validated and spawns — a same-name relaunch is a real second + // instance, charged, not a dedup-skipped freebie that still spawns. + auto chg = chargeSignerSync( + reqServer(ctx), signer, + static_cast(reqServer(ctx)->discountFee(FeeKind::Query, cfg.feeQuery)), + /*reqNonce=*/0, ctx->reqSigHash, + static_cast(cfg.getFeeError()), /*allowMissingOrigin=*/false); + after(chg.status, chg.duplicate); +} + +// --------------------------------------------------------------------------- +// KILL +// --------------------------------------------------------------------------- + +void dispatchKill(std::shared_ptr ctx, ces::Bytes pre) { + // Wire: [u64 pid]. Truncated preamble = caller wire-format bug, + // not a server failure → BAD_INPUT. + if (pre.size() < 8) { sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; } + uint64_t pid = ces::Buffer::peek(pre.data()); + + auto it = reqServer(ctx)->computeHandler()->instances_.find(pid); + if (it == reqServer(ctx)->computeHandler()->instances_.end()) { + sendErrorAndLoop(ctx, CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND); return; + } + if (std::memcmp(it->second->ownerPk.data(), ctx->bound.boundPubkey.getHash().data(), 32) != 0) { + sendErrorAndLoop(ctx, CES_ERROR_NOT_OWNER); return; + } + + const auto& cfg = reqServer(ctx)->_config(); + const ces::PublicKey& signer = ctx->bound.boundPubkey; + + auto after = [ctx, pid](uint8_t rc, bool duplicate) { + if (rc != CES_OK) { sendErrorAndLoop(ctx, rc); return; } + // Duplicate (resent envelope): the kill already committed; don't re-run it + // (idempotent anyway), just reply OK so the wire shape matches. + if (duplicate) { sendResponseAndLoop(ctx, CES_OK, {}); return; } + killByPid(*reqServer(ctx)->computeHandler(), pid); + sendResponseAndLoop(ctx, CES_OK, {}); + }; + + auto chg = chargeSignerSync( + reqServer(ctx), signer, + static_cast(reqServer(ctx)->discountFee(FeeKind::Query, cfg.feeQuery)), + ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()), /*allowMissingOrigin=*/false); + after(chg.status, chg.duplicate); +} + +// --------------------------------------------------------------------------- +// LIST +// --------------------------------------------------------------------------- + +void dispatchList(std::shared_ptr ctx, ces::Bytes /* pre */) { + const auto& cfg = reqServer(ctx)->_config(); + const ces::PublicKey& signer = ctx->bound.boundPubkey; + + auto after = [ctx](uint8_t rc, bool /*duplicate*/) { + if (rc != CES_OK) { sendErrorAndLoop(ctx, rc); return; } + // Read-only: a duplicate just re-reads current state — correct, no skip. + ces::Bytes resp; + uint32_t countOff = resp.size(); + ces::Buffer::put(resp, 0); // placeholder + uint32_t count = 0; + for (auto& [pid, inst] : reqServer(ctx)->computeHandler()->instances_) { + if (std::memcmp(inst->ownerPk.data(), + ctx->bound.boundPubkey.getHash().data(), 32) != 0) continue; + ces::Buffer::put(resp, inst->pid); + ces::Buffer::put(resp, static_cast(inst->sourceName.size())); + resp.insert(resp.end(), + reinterpret_cast(inst->sourceName.data()), + reinterpret_cast(inst->sourceName.data()) + + inst->sourceName.size()); + ces::Buffer::put(resp, inst->startedAtUs); + // file_balance as of now — a convenience for clients. We read + // it via the file handler (rent-roll included). If the file + // was deleted out from under us, report 0. + std::array opk{}; + uint64_t bal = 0; + fhReadOwnerAndBalance(reqServer(ctx), inst->sourceName, opk, bal); + ces::Buffer::put(resp, bal); + // CPU basis points + RSS bytes from last supervisor sample. + ces::Buffer::put(resp, inst->cpuBasisPoints); + ces::Buffer::put(resp, inst->rssBytes); + // Leased ports (0 = none): outbound CES-client, inbound luarpc host. + ces::Buffer::put(resp, inst->clientPort); + ces::Buffer::put(resp, inst->rpcPort); + resp.insert(resp.end(), inst->programPubkey.begin(), inst->programPubkey.end()); + count++; + } + // Patch count. + ces::Buffer::poke(resp.data() + countOff, count); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); + }; + + auto chg = chargeSignerSync( + reqServer(ctx), signer, + static_cast(reqServer(ctx)->discountFee(FeeKind::Query, cfg.feeQuery)), + ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()), /*allowMissingOrigin=*/false); + after(chg.status, chg.duplicate); +} + +// --------------------------------------------------------------------------- +// STAT +// --------------------------------------------------------------------------- + +void dispatchStat(std::shared_ptr ctx, ces::Bytes pre) { + // Wire: [u64 pid]. ID is the only identity — a path can refer + // to N instances, so name-keyed STAT is not well-defined (use INSTANCES). + // Public: any signer may inspect a live instance — its uptime, last + // cpu/rss sample, and leased ports — so a running service is discoverable + // and dialable by anyone. Only LAUNCH/KILL stay owner-gated. + if (pre.size() < 8) { sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; } + uint64_t pid = ces::Buffer::peek(pre.data()); + + auto it = reqServer(ctx)->computeHandler()->instances_.find(pid); + if (it == reqServer(ctx)->computeHandler()->instances_.end()) { + sendErrorAndLoop(ctx, CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND); return; + } + std::string name = it->second->sourceName; + + // file_balance is best-effort: read the sidecar (which also rolls rent), + // but a missing source file (instance about to be reaped) is not fatal + // to inspection — report the live instance with balance 0. + std::array ownerPk{}; + uint64_t fileBalance = 0; + fhReadOwnerAndBalance(reqServer(ctx), name, ownerPk, fileBalance); + + const auto& cfg = reqServer(ctx)->_config(); + const ces::PublicKey& signer = ctx->bound.boundPubkey; + + auto after = [ctx, name, fileBalance, pid](uint8_t rc, + bool /*duplicate*/) { + if (rc != CES_OK) { sendErrorAndLoop(ctx, rc); return; } + // Read-only: a duplicate just re-reads current state — correct, no skip. + auto it = reqServer(ctx)->computeHandler()->instances_.find(pid); + if (it == reqServer(ctx)->computeHandler()->instances_.end()) { + sendErrorAndLoop(ctx, CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND); return; + } + auto& inst = *it->second; + ces::Bytes resp; + ces::Buffer::put(resp, inst.pid); + ces::Buffer::put(resp, inst.startedAtUs); + ces::Buffer::put(resp, fileBalance); + ces::Buffer::put(resp, inst.cpuBasisPoints); + ces::Buffer::put(resp, inst.rssBytes); + // Leased ports (0 = none): outbound CES-client, inbound luarpc host. + ces::Buffer::put(resp, inst.clientPort); + ces::Buffer::put(resp, inst.rpcPort); + resp.insert(resp.end(), inst.programPubkey.begin(), inst.programPubkey.end()); + ces::Buffer::put(resp, static_cast(name.size())); + resp.insert(resp.end(), + reinterpret_cast(name.data()), + reinterpret_cast(name.data()) + + name.size()); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); + }; + + // STAT is public to any signer: allow a signer with no account here to read + // for free (cross-server discovery). + auto chg = chargeSignerSync( + reqServer(ctx), signer, + static_cast(reqServer(ctx)->discountFee(FeeKind::Query, cfg.feeQuery)), + ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()), /*allowMissingOrigin=*/true); + after(chg.status, chg.duplicate); +} + +// --------------------------------------------------------------------------- +// INSTANCES — public discovery: list ids for a given source path +// --------------------------------------------------------------------------- +// +// Wire in: [u16 path_len][path] +// Wire out: [u32 count][u64 id, u64 started_at_us, u32 cpu_bp, +// u64 rss_bytes, u16 client_port, u16 rpc_port]* +// +// No owner check, no file-existence check, no path validation beyond +// the length cap. The path is just a key into byName_; absent -> empty +// list. Same per-op fee as STAT/LIST so signers can't free-flood the +// lookup, but anyone with a credited account can ask. Each entry carries +// the instance's leased ports so a single call discovers a service AND +// where to dial it (the source path is the query key, so it isn't echoed +// per entry). +void dispatchInstances(std::shared_ptr ctx, + ces::Bytes pre) { + if (pre.size() < 2) { sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; } + uint16_t nameLen = ces::Buffer::peek(pre.data()); + if (nameLen == 0 || nameLen > kMaxNameLen || + pre.size() < sizeof(uint16_t) + nameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + std::string name(reinterpret_cast(pre.data() + 2), nameLen); + + const auto& cfg = reqServer(ctx)->_config(); + const ces::PublicKey& signer = ctx->bound.boundPubkey; + + auto after = [ctx, name](uint8_t rc, bool /*duplicate*/) { + if (rc != CES_OK) { sendErrorAndLoop(ctx, rc); return; } + // Read-only: a duplicate just re-reads current state — correct, no skip. + ces::Bytes resp; + uint32_t countOff = resp.size(); + ces::Buffer::put(resp, 0); // placeholder, patched below + uint32_t count = 0; + auto it = reqServer(ctx)->computeHandler()->byName_.find(name); + if (it != reqServer(ctx)->computeHandler()->byName_.end()) { + // std::set ⇒ ascending iteration; clients shouldn't depend on + // the order. Two clients querying the same path back-to-back + // see the same list as long as no LAUNCH/KILL hit in between. + for (uint64_t pid : it->second) { + auto iit = reqServer(ctx)->computeHandler()->instances_.find(pid); + if (iit == reqServer(ctx)->computeHandler()->instances_.end()) continue; // index/registry skew + auto& inst = *iit->second; + ces::Buffer::put(resp, inst.pid); + ces::Buffer::put(resp, inst.startedAtUs); + ces::Buffer::put(resp, inst.cpuBasisPoints); + ces::Buffer::put(resp, inst.rssBytes); + ces::Buffer::put(resp, inst.clientPort); + ces::Buffer::put(resp, inst.rpcPort); + resp.insert(resp.end(), inst.programPubkey.begin(), inst.programPubkey.end()); + count++; + } + } + ces::Buffer::poke(resp.data() + countOff, count); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); + }; + + // INSTANCES is public to any signer: allow a signer with no account here + // (a peer's P2P node discovering us) to read for free. + auto chg = chargeSignerSync( + reqServer(ctx), signer, + static_cast(reqServer(ctx)->discountFee(FeeKind::Query, cfg.feeQuery)), + ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()), /*allowMissingOrigin=*/true); + after(chg.status, chg.duplicate); +} + +// --------------------------------------------------------------------------- +// Supervisor tick — per-second slot-fee debit + SIGKILL on exhaustion. +// --------------------------------------------------------------------------- + +void supervisorTick(ComputeHandler& H) { + CesServer* server = H.server_; + if (!server) return; + const auto& cfg = server->_config(); + uint64_t now = getMicrosSinceEpoch(); + + // Refund any SYS_L2_CALL whose target instance never acknowledged (crashed + // or wedged past the deadline). Cheap: usually empty. + H.l2SweepTimeouts(now); + + // Discounted rates for this tick. The metrics pulse refreshes the + // FeeKind multipliers from l2cpu (slot/cpu) and l2mem (rss/bucket), + // so the supervisor pays "today's price" — no lock-in across ticks. + uint64_t slot = server->discountFee(FeeKind::ComputeSlot, + slotFeePerSec(cfg)); + uint64_t rssRate = server->discountFee(FeeKind::ComputeRss, + static_cast(cfg.feeComputeRssByteDay > 0 + ? cfg.feeComputeRssByteDay : 0)); + uint64_t cpuRate = server->discountFee(FeeKind::ComputeCpu, + static_cast(cfg.feeComputeCpuSec > 0 + ? cfg.feeComputeCpuSec : 0)); + uint64_t bucketRate = server->discountFee(FeeKind::BucketByteSec, + static_cast(cfg.feeBucketByteSec > 0 + ? cfg.feeBucketByteSec : 0)); + + std::vector toKill; + for (auto& [pid, inst] : H.instances_) { + // One tick: procfs sample (CPU delta + RSS) + compound debit. + // Runs at cfg.computeTickIntervalMs cadence (default 60 s). + sampleInstanceProc(*inst, now); + if (now <= inst->lastTickUs) continue; // still in prepaid window + uint64_t elapsedUs = now - inst->lastTickUs; + + // Slot: flat overhead. debit = slot * elapsed_sec. + __uint128_t slotDebit = + static_cast<__uint128_t>(slot) * elapsedUs / 1'000'000ull; + + // RAM: byte-day → debit = rssBytes * rate * elapsed_sec / 86400. + __uint128_t rssDebit = 0; + if (rssRate > 0 && inst->rssBytes > 0) { + rssDebit = static_cast<__uint128_t>(inst->rssBytes) + * static_cast<__uint128_t>(rssRate) + * static_cast<__uint128_t>(elapsedUs) + / (static_cast<__uint128_t>(86400ull) * 1'000'000ull); + } + + // CPU: core-second. debit = cpuBp * rate * elapsed_sec / 10000. + __uint128_t cpuDebit = 0; + if (cpuRate > 0 && inst->cpuBasisPoints > 0) { + cpuDebit = static_cast<__uint128_t>(inst->cpuBasisPoints) + * static_cast<__uint128_t>(cpuRate) + * static_cast<__uint128_t>(elapsedUs) + / (static_cast<__uint128_t>(10000ull) * 1'000'000ull); + } + + // Bucket capacity rent: sum committedBytes across all of this + // instance's buckets, debit at the per-byte-second rate. + // committedBytes is the worst-case footprint declared at + // bucket_new (max_entries × max_entry_bytes) — predictable, + // not sampled. + __uint128_t bucketDebit = 0; + if (bucketRate > 0 && !inst->buckets.empty()) { + uint64_t totalBytes = 0; + for (auto& [_bid, lb] : inst->buckets) totalBytes += lb.committedBytes; + if (totalBytes > 0) { + bucketDebit = static_cast<__uint128_t>(totalBytes) + * static_cast<__uint128_t>(bucketRate) + * static_cast<__uint128_t>(elapsedUs) + / 1'000'000ull; + } + } + + __uint128_t total = slotDebit + rssDebit + cpuDebit + bucketDebit; + // Clamp to uint64 max (reaching it would mean a misconfigured rate, not + // real usage). + uint64_t debit = (total > static_cast<__uint128_t>(UINT64_MAX)) + ? UINT64_MAX : static_cast(total); + if (debit == 0) continue; + if (!fhDebitBalance(server, inst->sourceName, debit)) { + toKill.push_back(pid); + } else { + inst->lastTickUs = now; + } + + // Reap zombies: if the child exited on its own, drop the + // instance. No restart — owner re-LAUNCHes manually. + int status = 0; + pid_t r = ::waitpid(inst->ospid, &status, WNOHANG); + if (r == inst->ospid) { + // A child terminated by a signal crashed (e.g. SIGSEGV on a bad shutdown). + // Surface it loudly: a silent DEBUG line is why such crashes went unnoticed. + if (WIFSIGNALED(status)) { + ++H.crashedInstances_; + LOGWARNING << "compute instance crashed" << VAR(pid) + << " signal=" << WTERMSIG(status) << SVAR(inst->sourceName); + } else { + LOGDEBUG << "child exited" << VAR(pid) << VAR(status); + } + toKill.push_back(pid); + } + } + for (uint64_t pid : toKill) killByPid(H, pid); +} + +void scheduleNextTick(ComputeHandler& H) { + if (!H.tickTimer_) return; + if (!H.tickRunning_.load()) return; + CesServer* server = H.server_; + uint32_t ms = (server && server->_config().computeTickIntervalMs > 0) + ? server->_config().computeTickIntervalMs : 60000u; + H.tickTimer_->expires_after(std::chrono::milliseconds(ms)); + ComputeHandler* Hp = &H; + H.tickTimer_->async_wait([Hp](const boost::system::error_code& ec) { + if (ec) return; + supervisorTick(*Hp); + scheduleNextTick(*Hp); + }); +} + +// --------------------------------------------------------------------------- +// File-deletion interlock: any file handler delete path fires our +// callback. If the deleted file has a running instance, kill it. +// --------------------------------------------------------------------------- + +void onFileDeleted(ComputeHandler& H, const std::string& name) { + // Runs on whatever thread drove the deletion — typically rpcTaskIO_, + // but we don't assume. Hop onto rpcTaskIO_ so we can touch the + // instance registry without taking a lock. + CesServer* server = H.server_; + if (!server) return; + auto io = server->_rpcTaskIOExecutor(); + if (!io) return; + ComputeHandler* Hp = &H; + boost::asio::post(io, [Hp, name]() { + auto it = Hp->byName_.find(name); + if (it == Hp->byName_.end()) return; + // Snapshot ids — killByPid mutates byName_. + std::vector ids(it->second.begin(), it->second.end()); + for (uint64_t pid : ids) killByPid(*Hp, pid); + }); +} + +// CALL(pid, amount, memo) -> reply. A client is just another caller of the L2 +// call: the bound signer's `amount` is escrowed and settled to the instance's +// program account exactly as SYS_L2_CALL does (never drawn back from the payee), +// the memo rides as the request body (hash-committed, up to CES_L2_CALL_MAX_MEMO +// -- RUDP, not packet-bounded), and the held request is answered by the shared +// completeL2Call with the program's reply, or an error on no-handler / timeout. +// Preamble (after nonce): [u64 pid BE][u64 amount BE][u32 memoLen][32 memoHash]; +// body = memo bytes. +void dispatchCall(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t pid = 0, value = 0; + uint32_t memoLen = 0; + std::array memoHash{}; + try { + pid = buf.get(); + value = buf.get(); + memoLen = buf.get(); + memoHash = buf.get>(); + } catch (const std::out_of_range&) { + ctx->errorAndClose(CES_ERROR_BAD_INPUT); return; // body length unknown + } + if (memoLen > CES_L2_CALL_MAX_MEMO) { + ctx->errorAndClose(CES_ERROR_BAD_INPUT); return; // body in flight -> close + } + + // Consume the memo body before any check that could loop, so the wire stays + // in sync (mirrors file WRITE). memoLen == 0 is a valid bare paid call. + auto memo = std::make_shared(memoLen); + auto finish = [ctx, pid, value, memo, memoHash]() { + minx::Hash got = ces::sha256(memo->data(), memo->size()); + if (std::memcmp(got.data(), memoHash.data(), 32) != 0) { + sendErrorAndLoop(ctx, CES_ERROR_INTERNAL); return; + } + CesServer* server = reqServer(ctx); + ComputeHandler* H = server->computeHandler(); + // Check the target exists before escrowing (no burn-then-refund in the + // common case); a race with instance death still refunds via NoHandler. + if (H->instances_.find(pid) == H->instances_.end()) { + sendErrorAndLoop(ctx, CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND); return; + } + // Per-op fee, like every compute verb. CALL is non-idempotent (each call + // escrows and delivers a fresh memo), so like LAUNCH it opts out of the + // NONCELESS sig-dedup: reqNonce=0, a resent envelope is a real, + // independently charged second call. The fee is not refunded on + // no-handler / timeout; only the escrowed `value` is. + const auto& cfg = server->_config(); + auto chg = chargeSignerSync( + server, ctx->bound.boundPubkey, + static_cast(server->discountFee(FeeKind::Query, cfg.feeQuery)), + /*reqNonce=*/0, ctx->reqSigHash, + static_cast(cfg.getFeeError()), /*allowMissingOrigin=*/false); + if (chg.status != CES_OK) { sendErrorAndLoop(ctx, chg.status); return; } + minx::Hash signer = ctx->bound.boundPubkey.getHash(); + ces::Bytes blob; // [u64 pid BE][memo]: the provider-ABI the syscall builds + ces::Buffer::put(blob, pid); + blob.insert(blob.end(), memo->begin(), memo->end()); + uint8_t rc = server->enqueueChannelL2Call(H, signer, value, + std::move(blob), ctx); + if (rc != CES_OK) { sendErrorAndLoop(ctx, rc); return; } + // Deferred: completeL2Call responds on ctx and loops to the next verb. + }; + if (memoLen == 0) { finish(); return; } + boost::asio::async_read( + *ctx->stream, boost::asio::buffer(*memo), + [finish](const boost::system::error_code& ec, std::size_t) { + if (ec) return; // stream dead + finish(); + }); +} + +} // namespace + +// --------------------------------------------------------------------------- +// ComputeHandler methods +// --------------------------------------------------------------------------- + +ComputeHandler::ComputeHandler(CesServer* server) : server_(server) {} +ComputeHandler::~ComputeHandler() = default; + +void ComputeHandler::serve(std::shared_ptr stream, + BoundChannelContext bound) { + if (stopped_.load()) return; + CesPlexProtocol proto; + ComputeHandler* self = this; + // accepts() also gates "still serving?" — false on stop() ends the loop. + proto.accepts = [self](uint8_t verb) { + return !self->stopped_.load() && + verb >= kVerbLaunch && verb <= kVerbCall; + }; + proto.dispatch = [](std::shared_ptr ctx, ces::Bytes pre) { + switch (ctx->verb) { + case kVerbLaunch: dispatchLaunch (ctx, std::move(pre)); break; + case kVerbKill: dispatchKill (ctx, std::move(pre)); break; + case kVerbList: dispatchList (ctx, std::move(pre)); break; + case kVerbStat: dispatchStat (ctx, std::move(pre)); break; + case kVerbInstances: dispatchInstances(ctx, std::move(pre)); break; + case kVerbCall: dispatchCall (ctx, std::move(pre)); break; + default: ctx->error(CES_ERROR_BAD_INPUT); break; + } + }; + cesPlexServe(std::move(stream), std::move(bound), server_, + std::move(proto)); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Read a running instance's extension metadata (manifest + caps + commands + +// config defaults). Synchronous hop onto rpcTaskIO_. false if pid is unknown. +bool ComputeHandler::extInfo(uint64_t pid, ComputeExtInfo& out) { + CesServer* server = server_; + if (!server) return false; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return false; + std::mutex m; std::condition_variable cv; bool done = false, found = false; + ComputeExtInfo info; + boost::asio::post(ex, [&]() { + auto it = instances_.find(pid); + if (it != instances_.end()) { + auto& inst = it->second; + info.name = inst->extName; + info.version = inst->extVersion; + info.description = inst->extDescription; + info.isExtension = inst->isExtension; + info.caps = inst->extCaps; + info.commands = inst->extCommands; + info.configDefaults = inst->extConfigDefaults; + found = true; + } + std::lock_guard lk(m); done = true; cv.notify_all(); + }); + std::unique_lock lk(m); + cv.wait(lk, [&]{ return done; }); + if (found) out = std::move(info); + return found; +} + +// Send an EXT_REQ (status/command) to a running extension and block for the +// EXT_REP, up to timeoutMs. `out` is the reply payload (after the status byte). +// false on timeout, unknown pid, non-extension, or a child-side error. +bool ComputeHandler::extRequest(uint64_t pid, uint8_t kind, const ces::Bytes& in, + ces::Bytes& out, int timeoutMs) { + CesServer* server = server_; + if (!server) return false; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return false; + auto pend = std::make_shared(); + boost::asio::post(ex, [this, pend, pid, kind, in]() { + auto it = instances_.find(pid); + if (it == instances_.end() || !it->second->isExtension) { + std::lock_guard lk(pend->m); + pend->done = true; pend->ok = false; pend->cv.notify_all(); + return; + } + uint16_t corr = extCorr_++; if (corr == 0) corr = extCorr_++; + pend->corr = corr; + extPending_[corr] = pend; + ces::Bytes b; b.reserve(1 + in.size()); + b.push_back(kind); + b.insert(b.end(), in.begin(), in.end()); + enqueueOutbound(it->second, makeFrame(kIpcTagExtReq, corr, b.data(), b.size())); + }); + std::unique_lock lk(pend->m); + bool got = pend->cv.wait_for(lk, std::chrono::milliseconds(timeoutMs), + [&]{ return pend->done; }); + bool ok = got && pend->ok; + if (ok) out = pend->reply; + lk.unlock(); + if (!got) { + boost::asio::post(ex, [this, pend]() { + pend->dead = true; + if (pend->corr) extPending_.erase(pend->corr); + }); + } + return ok; +} + +// Tell a running extension whether any webadmin client is watching its panel +// (drives the child's change-detect push tick). One-way, best-effort, +// idempotent — webadmin re-sends it periodically so a relaunched child +// re-arms on its own. +void ComputeHandler::extPanelWatch(uint64_t pid, bool on) { + CesServer* server = server_; + if (!server) return; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return; + boost::asio::post(ex, [this, pid, on]() { + auto it = instances_.find(pid); + if (it == instances_.end() || !it->second->isExtension) return; + uint8_t b = on ? 1 : 0; + enqueueOutbound(it->second, makeFrame(kIpcTagExtUiWatch, 0, &b, 1)); + }); +} + +// Push a config blob to a running extension (one-way on_config). Best-effort. +void ComputeHandler::extConfig(uint64_t pid, const std::string& cfg) { + CesServer* server = server_; + if (!server) return; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return; + boost::asio::post(ex, [this, pid, cfg]() { + auto it = instances_.find(pid); + if (it == instances_.end() || !it->second->isExtension) return; + enqueueOutbound(it->second, makeFrame(kIpcTagExtConfig, 0, + reinterpret_cast(cfg.data()), cfg.size())); + }); +} + +void ComputeHandler::fundingDrain() { + // Bounded wait for in-flight ces.request_funds remote transfers so the + // CesServer isn't torn down under one. Each is bounded by CesClient's own + // network timeout anyway. + for (int i = 0; i < 100 && fundingInFlight_.load() > 0; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); +} + +void ComputeHandler::killBySource(const std::string& sourceName) { + CesServer* server = server_; + if (!server) return; + auto io = server->_rpcTaskIOExecutor(); + if (!io) return; + boost::asio::post(io, [this, sourceName]() { + auto it = byName_.find(sourceName); + if (it == byName_.end()) return; + std::vector ids(it->second.begin(), it->second.end()); + for (uint64_t pid : ids) killByPid(*this, pid); + }); +} + +uint8_t ComputeHandler::start() { + CesServer* server = server_; + const auto& cfg = server->_config(); + + if (cfg.computeMaxInstances == 0) + return CES_ERROR_COMPUTE_DISABLED; + + // The file handler must be up (per-server object, gated by the file-store cap). + if (server->fileHandler() == nullptr) { + LOGERROR << "requires builtin:file; refusing to bind"; + return CES_ERROR_COMPUTE_NO_FILE_HANDLER; + } + + // Create the workdir eagerly — if we can't, bail now. + auto workDir = resolveWorkDir(cfg); + std::error_code ec; + std::filesystem::create_directories(workDir, ec); + if (ec) { + LOGERROR << "workdir unusable" + << SVAR(workDir.string()); + return CES_ERROR_INTERNAL; + } + + // Register the file-deletion interlock into THIS server's file handler. + if (FileHandler* fh = server->fileHandler()) { + fh->registerDeletionCallback( + [this](const std::string& name) { onFileDeleted(*this, name); }); + } + + // Start supervisor timer on rpcTaskIO_. + auto io = server->_rpcTaskIOExecutor(); + if (!io) { + LOGERROR << "rpcTaskIO not available"; + return CES_ERROR_INTERNAL; + } + tickTimer_ = std::make_shared(io); + tickRunning_.store(true); + scheduleNextTick(*this); + + LOGINFO << "bound" + << VAR(cfg.computeMaxInstances) + << SVAR(cfg.cesComputeChildBinary) + << SVAR(workDir.string()); + return CES_OK; +} + +void ComputeHandler::stop() { + // SIGKILL every instance and cancel the tick. + stopped_.store(true); + tickRunning_.store(false); + if (tickTimer_) { + boost::system::error_code ec; + tickTimer_->cancel(); + } + // Kill all instances. Iterate over a snapshot since killByPid erases. + std::vector ids; + ids.reserve(instances_.size()); + for (auto& [pid, _] : instances_) ids.push_back(pid); + for (uint64_t pid : ids) killByPid(*this, pid); + tickTimer_.reset(); +} + +uint8_t ComputeHandler::launchInternal(const std::string& name) { + CesServer* server = server_; + if (!server) return CES_ERROR_COMPUTE_DISABLED; + const auto& cfg = server->_config(); + if (cfg.computeMaxInstances == 0) return CES_ERROR_COMPUTE_DISABLED; + // No user-cap check: extensions are operator infrastructure, exempt from + // computeMaxInstances (which bounds USER instances). A user filling the cap must never + // block the operator's own extensions; they are bounded by the compute port range. + uint16_t clientPort = 0; + allocateComputePort(*this, cfg, clientPort); // best-effort; 0 = local-only + auto portLease = std::make_shared(this, clientPort); + uint16_t rpcPort = 0; + allocateComputePort(*this, cfg, rpcPort); // best-effort 2nd port (/ces/luarpc/1 host) + auto rpcPortLease = std::make_shared(this, rpcPort); + + // Source file must exist; ownership must be the server. /s/-zone + // requirement is enforced at deploy time by the file handler's + // writeServerFile, so we don't double-check the path shape here. + std::array ownerPk{}; + uint64_t fileBalance = 0; + if (!fhReadOwnerAndBalance(server, name, ownerPk, fileBalance)) { + return CES_ERROR_FILE_NOT_FOUND; + } + std::array serverPk{}; + std::memcpy(serverPk.data(), + server->_serverKeyPair().getPublicKeyAsHash().data(), 32); + if (ownerPk != serverPk) { + LOGWARNING << "internal launch: source not owned by server" + << SVAR(name); + return CES_ERROR_NOT_OWNER; + } + + // /s/ files are unmetered → no upfront fee, no rent debit. Pass + // upfront=0 so the Instance's bookkeeping field is honest about + // what was committed. + // + // The spawn awaits the child's connect-back asynchronously; success + // or failure is logged from the callback. CES_OK here means + // "validated + spawn started," not "child connected." + uint64_t now = getMicrosSinceEpoch(); + allocateAndSpawnInstance(server, name, ownerPk, 0, now, /*internal=*/true, + std::make_shared(this), std::move(portLease), std::move(rpcPortLease), + [name](uint64_t pid) { + if (pid == 0) { + LOGWARNING << "internal launch spawn failed" + << SVAR(name); + } + }); + return CES_OK; +} + +void ComputeHandler::onApplicationMsg( + const uint8_t* data, std::size_t len, + const std::array& senderPfx) { + if (!server_) return; + // Wire shape (op byte already stripped by CesServer::incomingApplication): + // [1B flags][8B prog_pfx][2B len BE][N payload] + if (len < sizeof(uint8_t) + sizeof(uint64_t) + sizeof(uint16_t)) return; + if (data[0] != 0) return; // flags must be 0 in v1 + std::array pfx{}; + std::memcpy(pfx.data(), data + 1, 8); + uint16_t payloadLen = ces::Buffer::peek(data + 9); + if (payloadLen > kAppPayloadMax) return; + if (len < static_cast(11 + payloadLen)) return; + + auto it = byPrefix_.find(pfx); + if (it == byPrefix_.end()) return; // no local instance for this prefix; drop + // Broadcast to every local instance sharing this content-addressed + // prefix. Sibling instances see sibling traffic — same as if they + // were on different servers in the swarm. + for (uint64_t pid : it->second) { + auto inst = instances_.find(pid); + if (inst == instances_.end()) continue; + sendDeliverFrame(inst->second, senderPfx, data + 11, payloadLen); + } +} + +bool _computeTestReadProcSample(int pid, + uint64_t& outTicks, + uint64_t& outRssBytes) { + ProcSample s; + if (!readProcSample(static_cast(pid), s)) return false; + outTicks = s.ticks; + outRssBytes = s.rssBytes; + return true; +} + +void ComputeHandler::testForceTick() { + CesServer* server = server_; + if (!server) return; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return; + // Post a blocking supervisorTick onto the CesPlex strand and wait + // for it to finish, so the caller sees side effects synchronously. + std::mutex m; + std::condition_variable cv; + bool done = false; + boost::asio::post(ex, [&]() { + supervisorTick(*this); + std::lock_guard lk(m); + done = true; + cv.notify_all(); + }); + std::unique_lock lk(m); + cv.wait(lk, [&]{ return done; }); +} + +size_t ComputeHandler::testFloodDeliver(uint64_t pid, size_t count) { + CesServer* server = server_; + if (!server) return 0; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return 0; + // Push `count` best-effort DELIVER frames at the instance back-to-back + // inside a SINGLE strand task. Because the task never yields, no + // async_write completion runs between pushes — nothing drains — so the + // outbox depth read at the end is the exact saturation point: the cap + // (kMaxDeliverBacklog) when the flood guard is in place, or `count` when + // it is not. Deterministic, with no socket-buffer or timing dependence. + std::mutex m; + std::condition_variable cv; + bool done = false; + size_t depth = 0; + boost::asio::post(ex, [&]() { + auto it = instances_.find(pid); + if (it != instances_.end()) { + std::array sender{}; + std::array payload{}; + for (size_t i = 0; i < count; ++i) + sendDeliverFrame(it->second, sender, payload.data(), payload.size()); + depth = it->second->outbox.size(); + } + std::lock_guard lk(m); + done = true; + cv.notify_all(); + }); + std::unique_lock lk(m); + cv.wait(lk, [&]{ return done; }); + return depth; +} + +uint16_t ComputeHandler::testInstanceClientPort(uint64_t pid) { + CesServer* server = server_; + if (!server) return 0; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return 0; + std::mutex m; + std::condition_variable cv; + bool done = false; + uint16_t port = 0; + boost::asio::post(ex, [&]() { + auto it = instances_.find(pid); + if (it != instances_.end()) port = it->second->clientPort; + std::lock_guard lk(m); + done = true; + cv.notify_all(); + }); + std::unique_lock lk(m); + cv.wait(lk, [&]{ return done; }); + return port; +} + +uint16_t ComputeHandler::testInstanceRpcPort(uint64_t pid) { + CesServer* server = server_; + if (!server) return 0; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return 0; + std::mutex m; + std::condition_variable cv; + bool done = false; + uint16_t port = 0; + boost::asio::post(ex, [&]() { + auto it = instances_.find(pid); + if (it != instances_.end()) port = it->second->rpcPort; + std::lock_guard lk(m); + done = true; + cv.notify_all(); + }); + std::unique_lock lk(m); + cv.wait(lk, [&]{ return done; }); + return port; +} + +// --------------------------------------------------------------------------- +// /ces/lua/1 + /ces/peer/1 cross-handler primitives — the lua / peer handlers +// call into these from rpcTaskIO_'s strand. See compute_handler.h. +// --------------------------------------------------------------------------- + +bool ComputeHandler::instanceExists(uint64_t pid) { + return instances_.find(pid) != instances_.end(); +} + +void ComputeHandler::regenerateInstanceCatalogNow() { + regenerateInstanceCatalog(*this); +} + +std::vector ComputeHandler::snapshot() { + std::vector out; + CesServer* server = server_; + if (!server) return out; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return out; + uint64_t nowUs = getMicrosSinceEpoch(); + std::mutex m; + std::condition_variable cv; + bool done = false; + boost::asio::post(ex, [&]() { + out.reserve(instances_.size()); + for (auto& [pid, inst] : instances_) { + ComputeInstanceStat s; + s.pid = inst->pid; + s.source = inst->sourceName; + s.cpuBasisPoints = inst->cpuBasisPoints; + s.rssBytes = inst->rssBytes; + s.uptimeSecs = (inst->startedAtUs && nowUs > inst->startedAtUs) + ? (nowUs - inst->startedAtUs) / 1000000ULL + : 0; + s.clientPort = inst->clientPort; + s.rpcPort = inst->rpcPort; + out.push_back(std::move(s)); + } + std::lock_guard lk(m); + done = true; + cv.notify_all(); + }); + std::unique_lock lk(m); + cv.wait(lk, [&] { return done; }); + return out; +} + +bool ComputeHandler::enableExtension(const std::string& source, std::string& errOut) { + CesServer* server = server_; + if (!server) return false; + auto ex = server->_rpcTaskIOExecutor(); + if (!ex) return false; + uint64_t launchStart = getMicrosSinceEpoch(); + // Run `fn` on the compute strand and block for it (like snapshot()). + auto onStrand = [&](auto&& fn) { + std::mutex m; + std::condition_variable cv; + bool done = false; + boost::asio::post(ex, [&]() { + fn(); + std::lock_guard lk(m); + done = true; + cv.notify_all(); + }); + std::unique_lock lk(m); + cv.wait(lk, [&] { return done; }); + }; + auto isRunning = [&]() { + bool r = false; + onStrand([&]() { + auto it = byName_.find(source); + r = (it != byName_.end() && !it->second.empty()); + }); + return r; + }; + + // Step 1: idempotent singleton launch on the strand. launchInternal MUST run there. + bool alreadyRunning = false, validated = false; + onStrand([&]() { + uint64_t now = getMicrosSinceEpoch(); + auto it = byName_.find(source); + if (it != byName_.end() && !it->second.empty()) { alreadyRunning = true; return; } + auto lit = launchingUntil_.find(source); + if (lit != launchingUntil_.end() && lit->second > now) { validated = true; return; } + launchingUntil_[source] = now + 5000000ULL; // 5s covers connect-back + if (launchInternal(source) == CES_OK) validated = true; + else launchingUntil_.erase(source); // failed validation (bad file/owner) + }); + if (alreadyRunning) return true; + if (!validated) return false; + + // Step 2: report the TRUTH, not "spawn started". CES_OK from launchInternal means + // only that a child was spawned -- a broken extension (e.g. one that self-terminates + // on load) registers for an instant, or never, then dies. Require a STABLE running + // window (running on two consecutive checks). Otherwise enable honestly reports + // failure instead of a misleading "ok". Poll up to ~2s (good extensions connect in ~ms). + int stable = 0; + for (int i = 0; i < 20; i++) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (isRunning()) { if (++stable >= 2) return true; } + else stable = 0; + } + // Never stabilized -> it crashed on launch or exited. Surface the extension's own + // last words (its final ces.log / host line), recorded on death after launchStart. + onStrand([&]() { + auto dit = lastExtDeath_.find(source); + if (dit != lastExtDeath_.end() && dit->second.first >= launchStart && !dit->second.second.empty()) + errOut = dit->second.second; + }); + return false; +} + +bool ComputeHandler::instanceAcceptsConnections(uint64_t pid) { + auto it = instances_.find(pid); + if (it == instances_.end()) return false; + return it->second->acceptsConnections; +} + +std::vector ComputeHandler::instanceHello(uint64_t pid) { + auto it = instances_.find(pid); + if (it == instances_.end()) return {}; + return it->second->hello; +} + +uint64_t ComputeHandler::openConnection(uint64_t pid, + const std::array& userPubkey) { + auto it = instances_.find(pid); + if (it == instances_.end()) return 0; + auto inst = it->second; + uint64_t connId = inst->nextConnId++; + // Body: [u64 conn_id BE][32B user_pubkey]. + ces::Bytes body; + body.reserve(sizeof(uint64_t) + sizeof(userPubkey)); + ces::Buffer::put(body, connId); + body.insert(body.end(), userPubkey.begin(), userPubkey.end()); + enqueueOutbound(inst, makeFrame(kIpcTagConnOpened, 0, + body.data(), body.size())); + return connId; +} + +void ComputeHandler::sendConnDataIn(uint64_t pid, uint64_t connId, + const uint8_t* data, std::size_t len) { + auto it = instances_.find(pid); + if (it == instances_.end()) return; + // Body: [u64 conn_id BE][u32 BE len][len bytes]. + ces::Bytes body; + body.reserve(sizeof(uint64_t) + sizeof(uint32_t) + len); + ces::Buffer::put(body, connId); + ces::Buffer::put(body, static_cast(len)); + if (len > 0) body.insert(body.end(), data, data + len); + enqueueOutbound(it->second, makeFrame(kIpcTagConnDataIn, 0, + body.data(), body.size())); +} + +void ComputeHandler::sendConnClosed(uint64_t pid, uint64_t connId, + uint8_t reason) { + auto it = instances_.find(pid); + if (it == instances_.end()) return; + // Body: [u64 conn_id BE][u8 reason]. + ces::Bytes body; + body.reserve(sizeof(uint64_t) + sizeof(uint8_t)); + ces::Buffer::put(body, connId); + body.push_back(reason); + enqueueOutbound(it->second, makeFrame(kIpcTagConnClosed, 0, + body.data(), body.size())); +} + +void ComputeHandler::routePeerMsg(const std::string& service, const minx::Hash& fromKey, + const uint8_t* data, std::size_t len) { + // Route an inbound /ces/peer/1 message (from the peer handler) to the local + // instance that registered `service` via ces.peer.listen. Single instance, + // not a broadcast. + auto sit = serviceTags_.find(service); + if (sit == serviceTags_.end()) return; + auto it = instances_.find(sit->second); + if (it == instances_.end()) return; + // Body to child: [32 from][u16 service_len][service][u32 BE len][payload]. + ces::Bytes body; + body.reserve(sizeof(minx::Hash) + sizeof(uint16_t) + service.size() + + sizeof(uint32_t) + len); + body.insert(body.end(), fromKey.begin(), fromKey.end()); + ces::Buffer::put(body, static_cast(service.size())); + body.insert(body.end(), service.begin(), service.end()); + ces::Buffer::put(body, static_cast(len)); + if (len > 0) body.insert(body.end(), data, data + len); + enqueueOutbound(it->second, makeFrame(kIpcTagPeerMsgIn, 0, + body.data(), body.size())); +} + +void ComputeHandler::deliverGossip(const minx::Hash& author, + const minx::Hash& sender, + const minx::Hash& msgId, + const minx::Hash& dest, + const uint8_t* msg, std::size_t len) { + if (instances_.empty()) return; + // Body: [32 author][32 sender][32 msgId][32 dest][u32 BE len][len bytes]. + ces::Bytes body; + body.reserve(4 * sizeof(minx::Hash) + sizeof(uint32_t) + len); + body.insert(body.end(), author.begin(), author.end()); + body.insert(body.end(), sender.begin(), sender.end()); + body.insert(body.end(), msgId.begin(), msgId.end()); + body.insert(body.end(), dest.begin(), dest.end()); + ces::Buffer::put(body, static_cast(len)); + if (len > 0) body.insert(body.end(), msg, msg + len); + // Fan to every local instance; the child calls its on_gossip if defined. + for (auto& [pid, inst] : instances_) + enqueueOutbound(inst, makeFrame(kIpcTagGossipIn, 0, + body.data(), body.size())); +} + +uint8_t ComputeHandler::cesplexL2Call(const L2CallRequest& req, + L2CallReport report) { + // Blob (provider-ABI): [u64 pid BE][payload...]. Paid RPC to a specific live + // instance, addressed by pid. The pid is ephemeral (per launch); a caller + // learns it from compute STAT / INSTANCES (a VM caller gets it from its own + // run input). pid is deliberately the ONLY address: the source file is a + // factory, not an addressable service, and whether instance identity (pid) + // or file identity (shared program account) wins is an open design question. + // Do not add a stable-address mode without resolving it; the bare layout + // means any second mode is a breaking blob change. A refusal here surfaces + // upstream as a refund. + if (req.blob.size() < sizeof(uint64_t)) return CES_ERROR_BAD_INPUT; + uint64_t pid = ces::Buffer::peek(req.blob.data()); + const uint8_t* payload = req.blob.data() + sizeof(uint64_t); + std::size_t payloadLen = req.blob.size() - sizeof(uint64_t); + auto it = instances_.find(pid); + if (it == instances_.end()) return CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND; + auto inst = it->second; + // Frame body: [u64 callId][u64 value][32 payer][u32 payloadLen][payload]. + ces::Bytes b; + ces::Buffer::put(b, req.callId); + ces::Buffer::put(b, req.value); + b.insert(b.end(), req.payer.begin(), req.payer.end()); + ces::Buffer::put(b, static_cast(payloadLen)); + if (payloadLen) b.insert(b.end(), payload, payload + payloadLen); + enqueueOutbound(inst, makeFrame(kIpcTagL2CallIn, 0, b.data(), b.size())); + minx::Hash payee; + std::memcpy(payee.data(), inst->programPubkey.data(), + inst->programPubkey.size()); + PendingL2 p; + p.report = std::move(report); + p.payee = payee; + p.deadlineUs = getMicrosSinceEpoch() + kL2CallTimeoutUs; + pendingL2_[req.callId] = std::move(p); + return CES_OK; // accepted; the reply frame or the timeout sweep settles it +} + +void ComputeHandler::l2Result(uint64_t callId, bool delivered) { + auto it = pendingL2_.find(callId); + if (it == pendingL2_.end()) return; // already settled (timed out) / unknown + if (delivered) { + it->second.delivered = true; // wait for the reply frame to settle + return; + } + // No on_l2call handler: refund now (empty reply), the payee is never touched. + it->second.report(callId, L2CallOutcome::NoHandler, minx::Hash{}, ces::Bytes{}); + pendingL2_.erase(it); +} + +void ComputeHandler::l2Reply(uint64_t callId, const uint8_t* data, size_t len) { + auto it = pendingL2_.find(callId); + if (it == pendingL2_.end()) return; // already settled (timed out) / unknown + it->second.report(callId, L2CallOutcome::Delivered, it->second.payee, + ces::Bytes(data, data + len)); + pendingL2_.erase(it); +} + +void ComputeHandler::l2SweepTimeouts(uint64_t nowUs) { + for (auto it = pendingL2_.begin(); it != pendingL2_.end();) { + if (nowUs >= it->second.deadlineUs) { + // Delivered-then-silent keeps the payment (empty reply); a call that + // never delivered is refunded. Either way the payee is never drawn from. + if (it->second.delivered) + it->second.report(it->first, L2CallOutcome::Delivered, it->second.payee, + ces::Bytes{}); + else + it->second.report(it->first, L2CallOutcome::Timeout, minx::Hash{}, + ces::Bytes{}); + it = pendingL2_.erase(it); + } else { + ++it; + } + } +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/compute_lua_handler.cpp b/vendor/ces/src/ceslib/l2/compute_lua_handler.cpp new file mode 100644 index 0000000..8b9726e --- /dev/null +++ b/vendor/ces/src/ceslib/l2/compute_lua_handler.cpp @@ -0,0 +1,328 @@ +// compute_lua_handler.cpp - builtin:lua handler (per-server object). See header. +// +// Mounts /ces/lua/1. Routes a user's signed-bound RUDP channel to a running +// cesluajitd Lua program by handling a one-shot ATTACH verb and from then on +// shoveling raw bytes both ways via the supervisor's IPC primitives. +// +// One LuaHandler per CesServer; no process-global state. Instance-death +// cascade tears down all that instance's routed connections; explicit Lua +// close, user-side close, billing eviction, and RUDP idle GC each reach the +// same per-connection teardown via the read-loop's error path. + +#include +#include + +#include +#include +#include // ces::sha256 +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +LOG_MODULE("lua"); + +namespace ces { + +namespace { + +// ATTACH verb code. Single-verb protocol; once ATTACH succeeds the +// channel is in DATA mode. +constexpr uint8_t kVerbAttach = 0x01; + +// CONN_CLOSED reason codes pushed to the child. Best-effort; the +// program treats any nonzero value as "unexpected end." +constexpr uint8_t kCloseReasonNormal = 0x00; // peer-closed cleanly +constexpr uint8_t kCloseReasonInternal = 0x01; // server bookkeeping issue +constexpr uint8_t kCloseReasonInstance = 0x02; // instance dying +constexpr uint8_t kCloseReasonProgram = 0x03; // Lua called conn:close() + +} // namespace + +struct LuaConnCtx : std::enable_shared_from_this { + std::shared_ptr stream; + BoundChannelContext bound; + uint64_t pid = 0; + uint64_t connId = 0; + bool attached = false; + bool closed = false; + std::array readBuf{}; + // Outbound write serialization. RudpStream forbids overlapping + // async_writes; the child can fire conn:write() bursts that arrive + // back-to-back. Queue them and kick the next only after the previous + // async_write completes. + std::deque> writeQueue; + bool writing = false; +}; + +// --------------------------------------------------------------------------- +// LuaHandler +// --------------------------------------------------------------------------- + +LuaHandler::LuaHandler(CesServer* server) : server_(server) {} + +LuaHandler::~LuaHandler() { stop(); } + +// Tear down a connection from the server side. Drops the routing entry, +// sends CONN_CLOSED to the child, asks RUDP for a graceful close. Safe to +// call multiple times -- a second call is a no-op once `closed`. +void LuaHandler::teardownConn(std::shared_ptr ctx, uint8_t reason, + bool notifyChild) { + if (ctx->closed) return; + ctx->closed = true; + if (ctx->attached && notifyChild) { + if (ComputeHandler* h = server_->computeHandler()) + h->sendConnClosed(ctx->pid, ctx->connId, reason); + } + if (ctx->attached) { + conns_.erase({ctx->pid, ctx->connId}); + } + // Graceful close so the user's dial sees a clean EOF instead of idle GC. + if (ctx->stream) { + ctx->stream->shutdown(kRudpStreamCloseTimeout); + ctx->stream.reset(); + } +} + +// Drain the per-conn writeQueue onto the stream, one async_write at a time. +void LuaHandler::kickConnWrite(std::shared_ptr ctx) { + if (ctx->writing || ctx->writeQueue.empty() + || ctx->closed || !ctx->stream) { + return; + } + ctx->writing = true; + auto head = ctx->writeQueue.front(); + auto stream = ctx->stream; + boost::asio::async_write( + *stream, boost::asio::buffer(*head), + [this, ctx, head](const boost::system::error_code& ec, std::size_t) { + ctx->writing = false; + if (!ctx->writeQueue.empty()) ctx->writeQueue.pop_front(); + if (ec) { + teardownConn(ctx, kCloseReasonInternal, ctx->attached); + return; + } + kickConnWrite(ctx); + }); +} + +// Send the ATTACH reply (signed by server). On OK, hands off to DATA mode. +void LuaHandler::sendAttachReply(std::shared_ptr ctx, + uint8_t status, uint64_t reqSigHash, + const std::vector& hello) { + minx::Bytes preamble; + if (status == CES_OK) { + ces::Buffer::put(preamble, ctx->connId); + // Optional server-declared greeting: [u32 len][bytes], inside the signed + // preamble. len 0 => request-driven (HTTP-style); len > 0 => the program + // speaks first (a terminal-style service), and these are its opening bytes. + ces::Buffer::put(preamble, static_cast(hello.size())); + preamble.insert(preamble.end(), hello.begin(), hello.end()); + } + auto env = std::make_shared( + buildPerOpResponse( + server_->_serverKeyPair(), kVerbAttach, status, + std::span( + reinterpret_cast(preamble.data()), preamble.size()), + reqSigHash)); + auto stream = ctx->stream; + // ATTACH-reply is always the first write on a fresh ctx -- no other writes + // can have been queued before it, so wire the OK->dataReadLoop hand-off + // directly here without competing with the generic kickConnWrite path. + ctx->writing = true; + boost::asio::async_write( + *stream, boost::asio::buffer(*env), + [this, ctx, env, status](const boost::system::error_code& ec, + std::size_t) { + ctx->writing = false; + if (ec || status != CES_OK) { + teardownConn(ctx, kCloseReasonInternal, ctx->attached); + return; + } + kickConnWrite(ctx); // drain anything the child sent meanwhile + dataReadLoop(ctx); + }); +} + +// Read the per-op envelope for the ATTACH verb: +// [u8 verb=0x01][u32 preamble_len][preamble][65 sig], preamble = [u64 pid]. +void LuaHandler::readAttachVerb(std::shared_ptr ctx) { + auto stream = ctx->stream; + auto verbBuf = std::make_shared>(); + boost::asio::async_read( + *stream, boost::asio::buffer(*verbBuf), + [this, ctx, verbBuf](const boost::system::error_code& ec, std::size_t) { + if (ec) { teardownConn(ctx, kCloseReasonInternal, false); return; } + uint8_t verb = (*verbBuf)[0]; + if (verb != kVerbAttach) { + LOGDEBUG << "builtin:lua bad first verb" << VAR(int(verb)); + teardownConn(ctx, kCloseReasonInternal, false); + return; + } + auto lenBuf = std::make_shared>(); + boost::asio::async_read( + *ctx->stream, boost::asio::buffer(*lenBuf), + [this, ctx, lenBuf](const boost::system::error_code& e2, std::size_t) { + if (e2) { teardownConn(ctx, kCloseReasonInternal, false); return; } + uint32_t preLen = ces::Buffer::peek( + std::span(*lenBuf), 0); + if (preLen != 8) { + teardownConn(ctx, kCloseReasonInternal, false); + return; + } + auto preBuf = std::make_shared(preLen); + boost::asio::async_read( + *ctx->stream, boost::asio::buffer(*preBuf), + [this, ctx, preBuf](const boost::system::error_code& e3, + std::size_t) { + if (e3) { teardownConn(ctx, kCloseReasonInternal, false); return; } + auto sigBuf = std::make_shared>(); + boost::asio::async_read( + *ctx->stream, boost::asio::buffer(*sigBuf), + [this, ctx, preBuf, sigBuf] + (const boost::system::error_code& e4, std::size_t) { + if (e4) { + teardownConn(ctx, kCloseReasonInternal, false); + return; + } + if (!ces::verifyPerOp( + ctx->bound, kVerbAttach, + std::span( + preBuf->data(), preBuf->size()), + *sigBuf)) { + LOGDEBUG << "builtin:lua ATTACH sig verify FAILED"; + teardownConn(ctx, kCloseReasonInternal, false); + return; + } + uint64_t pid = ces::Buffer::peek( + std::span(*preBuf), 0); + uint64_t sigHash = ces::sigDedupHash(*sigBuf); + + ComputeHandler* ch = server_->computeHandler(); + if (!ch || !ch->instanceExists(pid)) { + sendAttachReply( + ctx, CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND, sigHash); + return; + } + if (!ch->instanceAcceptsConnections(pid)) { + sendAttachReply(ctx, CES_ERROR_NOT_LISTENING, sigHash); + return; + } + std::array userPk{}; + std::memcpy(userPk.data(), + ctx->bound.boundPubkey.getHash().data(), 32); + uint64_t connId = ch->openConnection(pid, userPk); + if (connId == 0) { + sendAttachReply( + ctx, CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND, sigHash); + return; + } + ctx->pid = pid; + ctx->connId = connId; + ctx->attached = true; + conns_.emplace(ConnKey{pid, connId}, ctx); + LOGDEBUG << "builtin:lua attached" + << VAR(pid) << VAR(connId); + sendAttachReply(ctx, CES_OK, sigHash, + ch->instanceHello(pid)); + }); + }); + }); + }); +} + +// DATA mode read loop: read bytes from the stream, forward to the child via +// the supervisor's IPC. On error (channel closed by any party), tear down. +void LuaHandler::dataReadLoop(std::shared_ptr ctx) { + if (ctx->closed || !ctx->stream) return; + auto stream = ctx->stream; + stream->async_read_some( + boost::asio::buffer(ctx->readBuf), + [this, ctx](const boost::system::error_code& ec, std::size_t n) { + if (ec) { + uint8_t reason = kCloseReasonNormal; + if (ctx->stream) { + auto cr = ctx->stream->getCloseReason(); + if (cr && *cr != minx::Rudp::CloseReason::PEER_CLOSED) { + reason = kCloseReasonInternal; + } + } + teardownConn(ctx, reason, true); + return; + } + if (n > 0) { + if (ComputeHandler* h = server_->computeHandler()) + h->sendConnDataIn(ctx->pid, ctx->connId, ctx->readBuf.data(), n); + } + dataReadLoop(ctx); + }); +} + +void LuaHandler::serve(std::shared_ptr stream, + BoundChannelContext bound) { + auto ctx = std::make_shared(); + ctx->stream = std::move(stream); + ctx->bound = std::move(bound); + readAttachVerb(ctx); +} + +void LuaHandler::stop() { + // Tear down all known conns. We don't notify the children -- the compute + // supervisor's own teardown does that via onInstanceDying as it kills each. + for (auto& [_, ctx] : conns_) { + ctx->closed = true; + if (ctx->stream) { + ctx->stream->shutdown(kRudpStreamCloseTimeout); + ctx->stream.reset(); + } + } + conns_.clear(); +} + +void LuaHandler::handleConnDataOut(uint64_t pid, uint64_t connId, + const uint8_t* data, std::size_t len) { + auto it = conns_.find({pid, connId}); + if (it == conns_.end()) return; + auto ctx = it->second; + if (ctx->closed || !ctx->stream || len == 0) return; + ctx->writeQueue.push_back(std::make_shared(data, data + len)); + kickConnWrite(ctx); +} + +void LuaHandler::handleConnClose(uint64_t pid, uint64_t connId) { + auto it = conns_.find({pid, connId}); + if (it == conns_.end()) return; + // Program asked us to close. Don't echo CONN_CLOSED back to it. + teardownConn(it->second, kCloseReasonProgram, /*notifyChild=*/false); +} + +void LuaHandler::onInstanceDying(uint64_t pid) { + // Tear down every connection routed to this instance. Don't notify the + // child (it's about to die anyway). + for (auto it = conns_.begin(); it != conns_.end(); ) { + if (it->first.first != pid) { ++it; continue; } + auto ctx = it->second; + ctx->closed = true; + if (ctx->stream) { + ctx->stream->shutdown(kRudpStreamCloseTimeout); + ctx->stream.reset(); + } + it = conns_.erase(it); + } +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/file_client.cpp b/vendor/ces/src/ceslib/l2/file_client.cpp new file mode 100644 index 0000000..94f85ef --- /dev/null +++ b/vendor/ces/src/ceslib/l2/file_client.cpp @@ -0,0 +1,327 @@ +// file_client.cpp - CesFileClient. +// +// A thin verb layer over a CesPlexChannel (see cesplex/session.h) — the +// shared CesPlex client protocol. Each method here is just the verb's +// preamble building, optional streamed body, and response parsing; the +// channel drives the wire (the per-op signed envelope + the server-signed +// response trailer) on whatever transport it was handed: a CesPlexClient's +// owned socket via connect(), or an external endpoint via attach(). + +#include +#include +#include +#include // ces::sha256 +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { + +namespace { + +constexpr uint8_t kVerbCreate = 0x01; +constexpr uint8_t kVerbWrite = 0x02; +constexpr uint8_t kVerbRead = 0x03; +constexpr uint8_t kVerbStat = 0x04; +constexpr uint8_t kVerbDeposit = 0x05; +constexpr uint8_t kVerbWithdraw = 0x06; +constexpr uint8_t kVerbSetPrice = 0x07; +constexpr uint8_t kVerbDelete = 0x08; +constexpr uint8_t kVerbAppend = 0x09; +constexpr uint8_t kVerbResize = 0x0a; +constexpr uint8_t kVerbKvDeposit = 0x10; // fund a key in a kv-store (any signer) + +constexpr const char* kFileProto = "/ces/file/1"; + +} // namespace + +class CesFileClient::Impl { +public: + // Owned transport, used by connect() (the cesh / test path). In attach() + // mode `owned` stays idle and `chan` points at a channel the caller owns + // (e.g. the compute child's CesPlex endpoint), so the verb codec below is + // one implementation regardless of who owns the socket. + CesPlexClient owned; + CesPlexChannel* chan = nullptr; +}; + +CesFileClient::CesFileClient() : impl_(std::make_unique()) {} +CesFileClient::~CesFileClient() = default; + +uint8_t CesFileClient::connect(const std::string& host, uint16_t rpcPort, + const KeyPair& signerKey) { + uint8_t rc = impl_->owned.connect(host, rpcPort, kFileProto, signerKey); + if (rc == CES_OK) impl_->chan = impl_->owned.channel(); + return rc; +} + +// Drive verbs over a channel the caller owns + has already select()ed (e.g. +// the compute child's CesPlex endpoint). Mutually exclusive with connect(). +void CesFileClient::attach(CesPlexChannel& channel) { impl_->chan = &channel; } + +void CesFileClient::disconnect() { + impl_->owned.disconnect(); + impl_->chan = nullptr; +} + +void CesFileClient::setServerPubkey(const minx::Hash& pk) { + if (impl_->chan) impl_->chan->setServerPubkey(pk); + else impl_->owned.setServerPubkey(pk); +} + +// ---- CREATE ---- +uint8_t CesFileClient::create( + const std::string& name, + uint64_t size, uint64_t pricePerKb, uint64_t initialDeposit, + uint64_t& outFileBalance, uint64_t& outCostDebited) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, size); + ces::Buffer::put(pre, pricePerKb); + ces::Buffer::put(pre, initialDeposit); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbCreate, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbCreate, env, /*fixedPre=*/16, + nullptr, nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + outFileBalance = ces::Buffer::peek(resp.data()); + outCostDebited = ces::Buffer::peek(resp.data() + 8); + return CES_OK; +} + +// ---- WRITE ---- +uint8_t CesFileClient::write( + const std::string& name, + uint64_t offset, const ces::Bytes& content, + uint64_t& outFileBalance) { + minx::Hash contentHash = ces::sha256(content.data(), content.size()); + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, offset); + ces::Buffer::put(pre, static_cast(content.size())); + pre.insert(pre.end(), contentHash.begin(), contentHash.end()); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbWrite, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbWrite, env, /*fixedPre=*/8, + nullptr, nullptr, content, resp, body); + if (rc != CES_OK) return rc; + outFileBalance = ces::Buffer::peek(resp.data()); + return CES_OK; +} + +// ---- READ ---- +uint8_t CesFileClient::read( + const std::string& name, + uint64_t offset, uint32_t length, + ces::Bytes& outContent, minx::Hash& outRangeHash) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, offset); + ces::Buffer::put(pre, length); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbRead, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb( + kVerbRead, env, + /*fixedPre=*/sizeof(uint64_t) + sizeof(minx::Hash), + nullptr, + [length](const ces::Bytes& p) -> uint64_t { + // Cap the server-declared body length at what we asked for, so a hostile + // or buggy server can't make us allocate an unbounded response. + uint64_t declared = ces::Buffer::peek(p.data()); + return declared < length ? declared : length; + }, + {}, resp, body); + if (rc != CES_OK) return rc; + std::memcpy(outRangeHash.data(), resp.data() + sizeof(uint64_t), + sizeof(minx::Hash)); + outContent = std::move(body); + return CES_OK; +} + +// ---- STAT ---- +uint8_t CesFileClient::stat(const std::string& name, StatInfo& outInfo) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbStat, pre); + + // STAT fixed preamble: owner_pubkey + file_balance + price_per_kb + + // size; the variable hook pulls the two timestamps. + constexpr size_t kStatFixedPre = + ces::KEY_SIZE + sizeof(uint64_t) * 3; + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb( + kVerbStat, env, + /*fixedPre=*/kStatFixedPre, + [this](ces::Bytes& p) -> bool { + ces::Bytes ts; + if (!impl_->chan->readExact(ts, 16)) return false; + p.insert(p.end(), ts.begin(), ts.end()); + return true; + }, + nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + + ces::Buffer buf(std::move(resp)); + try { + outInfo.ownerPubkey = buf.get>(); + outInfo.fileBalance = buf.get(); + outInfo.pricePerKb = buf.get(); + outInfo.size = buf.get(); + outInfo.createdUs = buf.get(); + outInfo.modifiedUs = buf.get(); + } catch (const std::out_of_range&) { + return CES_ERROR_INTERNAL; + } + return CES_OK; +} + +// ---- DEPOSIT ---- +uint8_t CesFileClient::deposit(const std::string& name, + uint64_t amount, uint64_t& outFileBalance) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, amount); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbDeposit, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbDeposit, env, /*fixedPre=*/8, + nullptr, nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + outFileBalance = ces::Buffer::peek(resp.data()); + return CES_OK; +} + +// ---- KV_DEPOSIT (fund a key in a kv-store; any signer, no owner check) ---- +uint8_t CesFileClient::kvDeposit(const std::string& name, const ces::Bytes& key, + uint64_t amount, uint64_t& outCellBalance) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, amount); + ces::Buffer::put(pre, static_cast(key.size())); + pre.insert(pre.end(), key.begin(), key.end()); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbKvDeposit, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbKvDeposit, env, /*respFixedPre=*/8, + nullptr, nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + outCellBalance = ces::Buffer::peek(resp.data()); + return CES_OK; +} + +// ---- WITHDRAW ---- +uint8_t CesFileClient::withdraw(const std::string& name, + uint64_t amount, uint64_t& outFileBalance) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, amount); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbWithdraw, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbWithdraw, env, /*fixedPre=*/8, + nullptr, nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + outFileBalance = ces::Buffer::peek(resp.data()); + return CES_OK; +} + +// ---- SET_PRICE ---- +uint8_t CesFileClient::setPrice(const std::string& name, + uint64_t newPrice, uint64_t& outPrice) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, newPrice); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbSetPrice, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbSetPrice, env, /*fixedPre=*/8, + nullptr, nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + outPrice = ces::Buffer::peek(resp.data()); + return CES_OK; +} + +// ---- DELETE ---- +uint8_t CesFileClient::deleteFile(const std::string& name, + uint64_t& outRefunded) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbDelete, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbDelete, env, /*fixedPre=*/8, + nullptr, nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + outRefunded = ces::Buffer::peek(resp.data()); + return CES_OK; +} + +// ---- APPEND ---- +uint8_t CesFileClient::append(const std::string& name, + const ces::Bytes& content, + uint64_t& outFileBalance, + uint64_t& outNewSize) { + minx::Hash contentHash = ces::sha256(content.data(), content.size()); + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, static_cast(content.size())); + pre.insert(pre.end(), contentHash.begin(), contentHash.end()); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbAppend, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb( + kVerbAppend, env, /*fixedPre=*/sizeof(uint64_t) + sizeof(uint64_t), + nullptr, nullptr, content, resp, body); + if (rc != CES_OK) return rc; + outFileBalance = ces::Buffer::peek(resp.data()); + outNewSize = ces::Buffer::peek(resp.data() + 8); + return CES_OK; +} + +// ---- RESIZE ---- +uint8_t CesFileClient::resize(const std::string& name, + uint64_t newSize, uint64_t& outNewSize) { + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, newSize); + ces::Buffer::put(pre, static_cast(name.size())); + pre.insert(pre.end(), name.begin(), name.end()); + auto env = impl_->chan->buildEnvelope(kVerbResize, pre); + + ces::Bytes resp, body; + uint8_t rc = impl_->chan->driveVerb(kVerbResize, env, /*fixedPre=*/8, + nullptr, nullptr, {}, resp, body); + if (rc != CES_OK) return rc; + outNewSize = ces::Buffer::peek(resp.data()); + return CES_OK; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/file_handler.cpp b/vendor/ces/src/ceslib/l2/file_handler.cpp new file mode 100644 index 0000000..3be1381 --- /dev/null +++ b/vendor/ces/src/ceslib/l2/file_handler.cpp @@ -0,0 +1,3672 @@ +// file_handler.cpp - builtin:file CesPlex handler (v2) +// +// Disk-backed file storage. This file implements: +// +// - Name validation + filesystem-path mapping (5-component cap, +// leading slash, no dotfiles, etc.) +// - TOML sidecar load/save with write-rename atomicity +// - Per-server .store.toml guarded by the handler's store-meta mutex +// - 8 verbs: CREATE, WRITE, READ, STAT (free unsigned), DEPOSIT, +// WITHDRAW, SET_PRICE, DELETE +// - Preamble-first wire framing; sig commits to preamble before +// body bytes arrive; handler can verify+charge before streaming +// - Server-signed response envelope on every verb (bound to the +// request's sig hash so clients get a receipt) +// - Handler loops on one channel: read verb -> serve -> read next verb +// - Rent GC + startup reconciliation hooks for CesServer +// +// Economic rule: every signed op pays feeQuery from signer's account +// (the nonce/dedup machinery the rest of CES already uses). "Big" +// economic flows (amount deposited, amount withdrawn, write cost, +// read price) go where the spec says - file_balance, or between +// signer and file_balance, as each verb defines. STAT is free. +// +// Fee-discount policy. The per-channel RUDP rates that apply to a +// bound CesPlex channel are discounted at the ChannelMeter tick +// layer under FeeKind::Net (see cesplex/meter.cpp). The per-verb +// feeQuery debits dispatched here against the bound signer are +// intentionally raw - they are the flat anti-spam toll on top of +// the channel-level dynamic pricing. The compute handler discounts +// its per-verb feeQuery via FeeKind::Query because compute jobs are +// discrete one-shot work consuming l1cpu; file-handler verbs are +// bound-channel work whose dynamic component is already priced via +// Net, so the per-verb toll stays flat by design. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +LOG_MODULE("file"); + +namespace ces { + +namespace { + +// --------------------------------------------------------------------------- +// Deletion notify - fire the owning handler's registered callbacks right +// after a file is deleted internally. The callback list + its mutex, the +// store-meta mutex, and the kv cache are all per-server FileHandler members, +// reached from these free helpers via server->fileHandler(). +// --------------------------------------------------------------------------- + +void notifyDeletion(CesServer* server, const std::string& name) { + FileHandler* fh = server ? server->fileHandler() : nullptr; + if (!fh) return; + std::vector> cbs; + { + std::lock_guard lk(fh->deletionCallbacksMutex_); + cbs = fh->deletionCallbacks_; // copy under lock; fire outside + } + for (auto& cb : cbs) { + try { cb(name); } catch (...) {} + } +} + +// --------------------------------------------------------------------------- +// Protocol constants +// --------------------------------------------------------------------------- + +constexpr uint8_t kVerbCreate = 0x01; +constexpr uint8_t kVerbWrite = 0x02; +constexpr uint8_t kVerbRead = 0x03; +constexpr uint8_t kVerbStat = 0x04; +constexpr uint8_t kVerbDeposit = 0x05; +constexpr uint8_t kVerbWithdraw = 0x06; +constexpr uint8_t kVerbSetPrice = 0x07; +constexpr uint8_t kVerbDelete = 0x08; +constexpr uint8_t kVerbAppend = 0x09; // extend-write N bytes past size +constexpr uint8_t kVerbResize = 0x0a; // set new size (sparse extend or truncate) + +// File types stored in the sidecar. A flat file is opaque bytes; a kv file is +// a logkv key-value store whose content path is a directory of logkv files. +constexpr uint8_t kFileTypeFlat = 0; +constexpr uint8_t kFileTypeKv = 1; + +// kv-file verbs. Exec-only except kVerbKvDeposit, which also has a wire path +// (external funders bind /ces/file/1 and fund a key). +constexpr uint8_t kVerbKvCreate = 0x0b; +constexpr uint8_t kVerbKvPut = 0x0c; +constexpr uint8_t kVerbKvGet = 0x0d; +constexpr uint8_t kVerbKvErase = 0x0e; +constexpr uint8_t kVerbKvIter = 0x0f; +constexpr uint8_t kVerbKvDeposit = 0x10; +constexpr uint8_t kVerbKvRange = 0x11; + +// Max kv key length (bytes). Values are bounded by kMaxWriteLen like a write. +constexpr size_t kMaxKvKeyLen = 256; + +// Hard cap on a KV_RANGE response payload (sum of returned key+value bytes), +// well under the IPC frame so the reply always fits. A caller's requested +// budget is clamped to this; one in-range pair is always returned so a lone +// oversized value can't stall a paginated scan. +constexpr uint64_t kKvRangeMaxBytes = 8u * 1024 * 1024; + +constexpr uint16_t kMaxNameLen = 512; +constexpr uint32_t kMaxWriteLen = 1024 * 1024; // 1 MB per WRITE +constexpr uint32_t kMaxReadLen = 1024 * 1024; // 1 MB per READ +constexpr size_t kMaxPathComponents = 5; +constexpr uint64_t kMaxFileSize = 64ull * 1024 * 1024 * 1024; // 64 GB + +constexpr const char* kSidecarSuffix = ".sidecar.toml"; +constexpr const char* kStoreMetaName = ".store.toml"; +// Auto-generated catalog of the /s/ zone (see regenerateServerIndex). +constexpr const char* kServerIndexName = "/s/index.html"; +// Sidecar schema: `program_pubkey` (32B reference to a real Account in +// accountStore_) replaced the old sidecar-resident `file_balance` pool; +// the file's credits now live in the ledger account store like any other +// account (pays daily rent, supports deposit/withdraw/transfer, shared by +// every running instance). Older sidecars carrying the removed +// content_sha256 field are rejected on load. +constexpr uint32_t kSidecarVersion = 5; + +// Microseconds per day - denominator in per-byte-day rent math. +constexpr uint64_t kUsecsPerDay = 86'400'000'000ull; + +// --------------------------------------------------------------------------- +// Hex helpers +// --------------------------------------------------------------------------- + +std::string hexOf(const uint8_t* data, size_t len) { + static const char kHex[] = "0123456789abcdef"; + std::string out; + out.reserve(len * 2); + for (size_t i = 0; i < len; ++i) { + out.push_back(kHex[data[i] >> 4]); + out.push_back(kHex[data[i] & 0xF]); + } + return out; +} + +bool hexTo(const std::string& s, uint8_t* out, size_t outLen) { + if (s.size() != outLen * 2) return false; + auto nibble = [](char c, uint8_t& v) -> bool { + if (c >= '0' && c <= '9') { v = c - '0'; return true; } + if (c >= 'a' && c <= 'f') { v = c - 'a' + 10; return true; } + if (c >= 'A' && c <= 'F') { v = c - 'A' + 10; return true; } + return false; + }; + for (size_t i = 0; i < outLen; ++i) { + uint8_t hi, lo; + if (!nibble(s[i * 2], hi) || !nibble(s[i * 2 + 1], lo)) return false; + out[i] = (hi << 4) | lo; + } + return true; +} + +// --------------------------------------------------------------------------- +// Name validation + path mapping +// --------------------------------------------------------------------------- + +// Validates a CES file name against the spec. Returns CES_OK or a +// specific CES_ERROR_BAD_NAME. Never throws. +// +// Four mandatory zones - the first path component must be one of: +// "h" -> /h/<64 hex>/ (self-owned "home" dir; see dispatchCreate) +// "f" -> /f// (asset-gated registered namespace) +// "p" -> /p//<...> (public, first-come-first-served) +// "s" -> /s//<...> (server-deployed, unmetered, outside +// the cap - only the server's own +// private key may CREATE/WRITE here) +// +// The minimum path is // (2 components). Zone-specific +// rules on the second component are enforced here; ledger/asset checks +// are the caller's job (dispatchCreate). +uint8_t validateCesFileName(const std::string& name) { + if (name.empty()) return CES_ERROR_BAD_NAME; + if (name[0] != '/') return CES_ERROR_BAD_NAME; + if (name.size() > kMaxNameLen) return CES_ERROR_BAD_NAME; + if (name.size() == 1) return CES_ERROR_BAD_NAME; // just "/" + if (name.back() == '/') return CES_ERROR_BAD_NAME; + + // Walk components. + size_t n = name.size(); + size_t comp_count = 0; + size_t i = 1; + size_t firstStart = 0, firstLen = 0; + size_t secondStart = 0, secondLen = 0; + const size_t sidecarSuffixLen = std::strlen(kSidecarSuffix); + while (i < n) { + size_t start = i; + while (i < n && name[i] != '/') { + if (name[i] == '\0') return CES_ERROR_BAD_NAME; + ++i; + } + size_t len = i - start; + if (len == 0) return CES_ERROR_BAD_NAME; // empty component + if (len > 255) return CES_ERROR_BAD_NAME; // filesystem limit + // Reject any component starting with '.' - one check covers ".", "..", + // and dotfiles, blocking traversal and hidden entries. + if (name[start] == '.') return CES_ERROR_BAD_NAME; + // ".sidecar.toml" is reserved: a component ending in it would map a + // content path onto a sidecar file on disk. Reject in any component. + if (len >= sidecarSuffixLen && + name.compare(start + len - sidecarSuffixLen, + sidecarSuffixLen, kSidecarSuffix) == 0) + return CES_ERROR_BAD_NAME; + ++comp_count; + if (comp_count > kMaxPathComponents) return CES_ERROR_BAD_NAME; + if (comp_count == 1) { firstStart = start; firstLen = len; } + else if (comp_count == 2) { secondStart = start; secondLen = len; } + if (i < n) ++i; // skip '/' + } + + // Must have at least 2 components total: zone + something. + if (comp_count < 2) return CES_ERROR_BAD_NAME; + + // First component must be exactly one of the zone markers. + if (firstLen != 1) return CES_ERROR_BAD_NAME; + char zone = name[firstStart]; + if (zone != 'h' && zone != 'f' && zone != 'p' && zone != 's' && + zone != 'm') + return CES_ERROR_BAD_NAME; + + // Zone-specific rules on the second component. + if (zone == 'h' || zone == 'm') { + // Must be exactly 64 lowercase hex chars = 32-byte pubkey. /m/ is the + // private mail zone: same account-keyed naming as /h/, but not + // wire-readable (only builtin:mail reads it, in-process). + if (secondLen != 64) return CES_ERROR_BAD_NAME; + for (size_t j = 0; j < secondLen; ++j) { + char c = name[secondStart + j]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + return CES_ERROR_BAD_NAME; + } + } + // /f/, /p/, /s/ all accept any valid segment (already filtered by + // the dotfile / . / .. / length rules above). Asset-ownership check + // for /f/ and server-key check for /s/ happen in checkZoneOwnership. + return CES_OK; +} + +// Per-KB fee arithmetic. feeFileWrite and feeFileRead are charged +// "credits per 1024 bytes" (ceiling-rounded); at the default values +// a per-byte reading would make a 1 MB file cost 20 B credits. Use +// this for every length -> fee conversion. +inline uint64_t kbCeil(uint64_t bytes) { + return (bytes + 1023u) / 1024u; +} + +std::filesystem::path resolveContentPath(const std::string& dir, + const std::string& name) { + // name starts with '/', strip it for relative path under dir. + std::string rel = name.substr(1); + return std::filesystem::path(dir) / rel; +} + +std::filesystem::path resolveSidecarPath(const std::string& dir, + const std::string& name) { + auto p = resolveContentPath(dir, name); + p += kSidecarSuffix; + return p; +} + +std::filesystem::path storeMetaPath(const std::string& dir) { + return std::filesystem::path(dir) / kStoreMetaName; +} + +// --------------------------------------------------------------------------- +// kv file type: a logkv key-value store living in the file's content +// directory. The file driver owns the directory (creation, billing, eviction) +// the same way it owns flat files; logkv only uses the directory. Stores are +// opened lazily and cached, keyed by file name; access is serialized by +// the kv cache mutex (logkv::Store has no internal locking). Keys and values are byte +// strings (logkv::Bytes); logkv treats an empty value as "absent", so a kv +// PUT of an empty value is an erase and callers must not rely on storing one. +// --------------------------------------------------------------------------- +// Ordered backing (std::map, not unordered): keys iterate and bisect in sorted +// byte order, so range queries are native (lower_bound/upper_bound) and RBSR +// gets the sorted keyspace it needs. logkv::Store needs only standard +// associative API, which std::map provides. +// +// The order is UNSIGNED byte order (memcmp). logkv::Bytes is vector whose +// default operator< compares signed chars, which would sort a 0x80-prefixed key +// before a 0x00-prefixed one; DHT keys are hashes treated as unsigned, so the +// comparator must match memcmp. A 2-arg alias carries the comparator through +// logkv::Store's M template parameter. +struct KvKeyLess { + bool operator()(const logkv::Bytes& a, const logkv::Bytes& b) const { + size_t n = a.size() < b.size() ? a.size() : b.size(); + int c = n ? std::memcmp(a.data(), b.data(), n) : 0; + if (c != 0) return c < 0; + return a.size() < b.size(); + } +}; +template using KvMap = std::map; +using KvStore = logkv::Store; + +} // namespace (anon) + +// Per-server kv-store cache: the open-store map plus the mutex serializing +// access to it and to logkv (which has no internal locking). Held by the +// FileHandler; the free helpers below reach it via server->fileHandler()->kv_. +// TODO: those free helpers take CesServer* and round-trip server->fileHandler() +// to get back to effectively `this`. Passing the FileHandler* (or making them +// methods) would drop the round-trip; only valid because they run inside a +// mounted handler. +struct FileKvCache { + std::mutex mutex; + std::unordered_map> stores; + // Live byte size per open store: sum of (key + stored value) bytes in the RAM + // map. Seeded from the map on open, updated by the mutation cores, re-derived + // on the daily sweep. Drives event-log compaction. Keyed by absolute content + // path, like `stores`. + std::unordered_map liveBytes; + // Last value written to the sidecar `size` for this store (its estimated disk + // use = live bytes + events-on-disk, floored). Cached so the per-op check + // needs no sidecar read, and held equal to the sidecar so the global store + // meta stays consistent (global total == sum of sidecar sizes). + std::unordered_map reservedBytes; +}; + +namespace { + +// Compact a kv store's append-only events log once it outgrows the live data by +// more than this floor (so a small store does not snapshot on every write). +constexpr uint64_t kKvCompactFloorBytes = 64 * 1024; + +// Open (or return the cached) logkv store for kv-file `name`. The content +// directory must already exist (the driver creates it at CREATE). Returns +// nullptr if the directory is missing or logkv fails to load. Caller holds +// the kv cache mutex. +KvStore* kvStoreOpenLocked(CesServer* server, const std::string& dir, + const std::string& name) { + auto& stores = server->fileHandler()->kv_->stores; + auto cPath = resolveContentPath(dir, name); + // Cache key is the ABSOLUTE content path, not the bare CES name: a logical + // name reused across store dirs (a stop/start with dir reuse) must not alias. + std::string key = cPath.string(); + std::error_code ec; + auto it = stores.find(key); + if (it != stores.end()) { + // A cached store whose directory was deleted (rent GC / DELETE) is stale: + // drop it (closes logkv's file handles) and fail. The op's sidecar read + // reports FILE_NOT_FOUND. This lazy check lets delete paths just + // remove_all the dir without taking the kv mutex (avoids a lock-order + // inversion with gcReclaim, which deletes under the store-meta mutex). + if (std::filesystem::is_directory(cPath, ec)) return it->second.get(); + stores.erase(it); + server->fileHandler()->kv_->liveBytes.erase(key); + server->fileHandler()->kv_->reservedBytes.erase(key); + return nullptr; + } + if (!std::filesystem::is_directory(cPath, ec)) return nullptr; + try { + auto st = std::make_unique(cPath.string()); // loads existing data + KvStore* raw = st.get(); + // Seed the live byte size from the just-loaded map. + uint64_t lb = 0; + for (auto& kv : raw->getObjects()) lb += kv.first.size() + kv.second.size(); + stores.emplace(key, std::move(st)); + server->fileHandler()->kv_->liveBytes[key] = lb; + return raw; + } catch (...) { + return nullptr; + } +} + +// ces::Bytes (vector) <-> logkv::Bytes (vector). +inline logkv::Bytes toLogkvBytes(const ces::Bytes& b) { + const char* p = reinterpret_cast(b.data()); + return logkv::Bytes(p, p + b.size()); +} +inline ces::Bytes fromLogkvBytes(const logkv::Bytes& b) { + const uint8_t* p = reinterpret_cast(b.data()); + return ces::Bytes(p, p + b.size()); +} + +// --------------------------------------------------------------------------- +// Per-cell billing header. Every kv value is stored as +// [balance u64 BE][last_charged_us u64 BE][user bytes] +// The file service owns the 16-byte header: KV_DEPOSIT/KV_PUT fund the cell, +// the daily sweep counts rent down against it and erases the key at balance 0, +// and GET strips it (the program sees only its own bytes). `balance` is a +// prepaid rent counter in credits (not a days-to-live: rent is recomputed at +// the live fee rate, so a fee change can never strand a cell). The credits that +// fund a cell are burned to the server on deposit; a kv file has NO program +// account, so this header is the sole record. +// --------------------------------------------------------------------------- +constexpr size_t kKvHeaderLen = 16; + +struct KvCell { + uint64_t balance = 0; + uint64_t lastChargedUs = 0; + const char* user = nullptr; // pointer into the stored value (after header) + size_t userLen = 0; +}; + +// Parse a stored value into its header + user span. Returns false if the value +// is too short to hold a header (the file service writes every kv value, so +// this is a fail-safe). +inline bool kvCellParse(const logkv::Bytes& v, KvCell& out) { + if (v.size() < kKvHeaderLen) return false; + const uint8_t* p = reinterpret_cast(v.data()); + out.balance = ces::Buffer::peek(p); + out.lastChargedUs = ces::Buffer::peek(p + 8); + out.user = v.data() + kKvHeaderLen; + out.userLen = v.size() - kKvHeaderLen; + return true; +} + +// Build a stored value from header fields + user bytes (raw pointer span). +inline logkv::Bytes kvCellBuild(uint64_t balance, uint64_t lastChargedUs, + const char* user, size_t userLen) { + logkv::Bytes v; + v.resize(kKvHeaderLen + userLen); + uint8_t* p = reinterpret_cast(v.data()); + ces::Buffer::poke(p, balance); + ces::Buffer::poke(p + 8, lastChargedUs); + if (userLen) std::memcpy(v.data() + kKvHeaderLen, user, userLen); + return v; +} + +// Verify that a CES name doesn't collide with an existing path. +// Specifically: every prefix of the name's directory chain must +// either not exist, or exist as a directory (not a file); and the +// name itself must not collide with an existing directory. +uint8_t checkPathConflict(const std::string& dir, + const std::string& name, + bool createMode) { + namespace fs = std::filesystem; + fs::path full = resolveContentPath(dir, name); + // Check intermediate parents. + fs::path cur = dir; + std::string rel = name.substr(1); + size_t start = 0; + while (true) { + size_t slash = rel.find('/', start); + if (slash == std::string::npos) break; + std::string comp = rel.substr(start, slash - start); + cur /= comp; + std::error_code ec; + if (fs::exists(cur, ec)) { + if (!fs::is_directory(cur, ec)) return CES_ERROR_PATH_CONFLICT; + } + start = slash + 1; + } + // If CREATE: full path must not already be a directory or file. + // If not CREATE: full path must exist and be a regular file. + std::error_code ec; + if (createMode) { + if (fs::exists(full, ec)) { + if (fs::is_directory(full, ec)) return CES_ERROR_PATH_CONFLICT; + return CES_ERROR_FILE_EXISTS; + } + } else { + if (!fs::exists(full, ec)) return CES_ERROR_FILE_NOT_FOUND; + if (!fs::is_regular_file(full, ec)) return CES_ERROR_PATH_CONFLICT; + } + return CES_OK; +} + +// --------------------------------------------------------------------------- +// Sidecar TOML +// --------------------------------------------------------------------------- + +struct Sidecar { + uint32_t version = kSidecarVersion; + std::string name; + std::array owner_pubkey{}; + // The file's "program account" keypair, allocated at CREATE. For a FLAT file + // this keys a real Account in accountStore_ (the file's withdrawable balance, + // referenced by every running instance): program_pubkey keys the account, + // program_privkey is the private half the program signs its own remote ops + // with. The private half lives only here on disk (server-side) - STAT never + // returns it (allowlist), and the sidecar is unreachable as content + // (.sidecar.toml is a reserved suffix). Inbound transfers work normally; the + // account pays daily rent like any account. A kv file carries the same + // keypair for symmetry but does NOT back a ledger account: its cells hold + // prepaid rent burned at deposit, so there is nothing to hold, withdraw, or + // sum against. + std::array program_pubkey{}; + std::array program_privkey{}; + uint64_t price_per_kb = 0; + uint64_t size = 0; + uint64_t created_us = 0; + uint64_t modified_us = 0; + // Microseconds since epoch of the last moment rent was charged + // against this file. Every non-DEPOSIT op rolls rent forward from + // this to now before proceeding. Initialized to created_us at + // CREATE. + uint64_t last_rent_us = 0; + // Lazily-computed sha256(content || path) used by authentic + // asset minting. All-zero means "not yet computed" - recompute + // on demand. Cleared back to all-zero by any content-mutating + // verb (WRITE, APPEND, RESIZE) so the next mint recomputes. + std::array program_hash{}; + // kFileTypeFlat (opaque bytes) or kFileTypeKv (logkv key-value store whose + // content path is a directory). Optional in the sidecar; sidecars written + // before this field load as flat. + uint8_t type = kFileTypeFlat; +}; + +bool writeSidecar(const std::filesystem::path& path, const Sidecar& s) { + // Serialize through tomlplusplus (symmetric with readSidecar) so name is + // escaped. A hand-rolled `key = "val"` dump corrupts the sidecar when that + // field contains quotes or newlines, which it can: + // validateCesFileName only filters '/' and NUL. + toml::table tbl; + tbl.insert_or_assign("version", static_cast(s.version)); + tbl.insert_or_assign("name", s.name); + tbl.insert_or_assign("owner_pubkey", + hexOf(s.owner_pubkey.data(), s.owner_pubkey.size())); + tbl.insert_or_assign("program_pubkey", + hexOf(s.program_pubkey.data(), s.program_pubkey.size())); + tbl.insert_or_assign("program_privkey", + hexOf(s.program_privkey.data(), s.program_privkey.size())); + tbl.insert_or_assign("price_per_kb", static_cast(s.price_per_kb)); + tbl.insert_or_assign("size", static_cast(s.size)); + tbl.insert_or_assign("created_us", static_cast(s.created_us)); + tbl.insert_or_assign("modified_us", static_cast(s.modified_us)); + tbl.insert_or_assign("last_rent_us", static_cast(s.last_rent_us)); + tbl.insert_or_assign("program_hash", + hexOf(s.program_hash.data(), s.program_hash.size())); + tbl.insert_or_assign("type", static_cast(s.type)); + + std::filesystem::path tmp = path; + tmp += ".tmp"; + { + std::ofstream f(tmp, std::ios::binary | std::ios::trunc); + if (!f) return false; + f << tbl << '\n'; + if (!f.good()) return false; + f.flush(); + } + std::error_code ec; + std::filesystem::rename(tmp, path, ec); + return !ec; +} + +bool readSidecar(const std::filesystem::path& path, Sidecar& s) { + std::error_code ec; + if (!std::filesystem::exists(path, ec)) return false; + toml::table tbl; + try { + tbl = toml::parse_file(path.string()); + } catch (...) { + return false; + } + auto getU64 = [&](const char* k, uint64_t& out) -> bool { + auto v = tbl[k].value(); + if (!v) return false; + out = static_cast(*v); + return true; + }; + auto getStr = [&](const char* k, std::string& out) -> bool { + auto v = tbl[k].value(); + if (!v) return false; + out = *v; + return true; + }; + uint64_t version = 0; + if (!getU64("version", version)) return false; + if (version != kSidecarVersion) return false; + if (!getStr("name", s.name)) return false; + std::string ownerHex; + if (!getStr("owner_pubkey", ownerHex)) return false; + if (!hexTo(ownerHex, s.owner_pubkey.data(), s.owner_pubkey.size())) + return false; + std::string progPubkeyHex; + if (!getStr("program_pubkey", progPubkeyHex)) return false; + if (!hexTo(progPubkeyHex, s.program_pubkey.data(), + s.program_pubkey.size())) + return false; + std::string progPrivkeyHex; + if (!getStr("program_privkey", progPrivkeyHex)) return false; + if (!hexTo(progPrivkeyHex, s.program_privkey.data(), + s.program_privkey.size())) + return false; + if (!getU64("price_per_kb", s.price_per_kb)) return false; + if (!getU64("size", s.size)) return false; + if (!getU64("created_us", s.created_us)) return false; + if (!getU64("modified_us", s.modified_us)) return false; + if (!getU64("last_rent_us", s.last_rent_us)) return false; + // program_hash is optional (added later, missing in older sidecars + // and on the freshly-emitted form before the first authentic-mint). + // Treat missing or unparseable as "not yet computed" (all-zero). + std::string hashHex; + if (getStr("program_hash", hashHex)) { + if (!hexTo(hashHex, s.program_hash.data(), s.program_hash.size())) + s.program_hash.fill(0); + } else { + s.program_hash.fill(0); + } + // type is optional (sidecars predating it load as flat). + uint64_t typeVal = kFileTypeFlat; + getU64("type", typeVal); + s.type = static_cast(typeVal); + s.version = kSidecarVersion; + return true; +} + +// --------------------------------------------------------------------------- +// Global store metadata (.store.toml) +// --------------------------------------------------------------------------- + +struct StoreMeta { + uint64_t total_files = 0; + uint64_t total_bytes = 0; + // Microseconds-since-epoch of the last gcReclaim run. Used to + // debounce JIT GC on CREATE so a flood of over-cap requests can't + // force a full-store scan on every failed CREATE. + uint64_t last_gc_us = 0; +}; + +// Debounce window for JIT GC on CREATE. If the last gcReclaim ran +// less than this long ago, a CREATE that can't fit in the cap fails +// with STORE_FULL without rescanning - the just-completed GC already +// reflects the best we can do. +// +// This is also the upfront-rent window: every op that grows a file's +// size (CREATE, APPEND, RESIZE-grow) requires the file_balance to +// be able to cover 15 minutes of rent on the new bytes. Reasoning: +// a griefer can pin bytes in the cap for at most one GC cycle. Making +// them prepay that cycle's rent forces the grief to be economically +// painful - for the size of cap they're locking, at the rent rate +// for the window. +constexpr uint64_t kGcDebounceUs = 15ull * 60 * 1'000'000; // 15 min + +bool readStoreMeta(const std::filesystem::path& path, StoreMeta& m) { + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + m = StoreMeta{}; + return true; + } + toml::table tbl; + try { + tbl = toml::parse_file(path.string()); + } catch (...) { + return false; + } + m.total_files = static_cast( + tbl["total_files"].value_or(0)); + m.total_bytes = static_cast( + tbl["total_bytes"].value_or(0)); + m.last_gc_us = static_cast( + tbl["last_gc_us"].value_or(0)); + return true; +} + +bool writeStoreMeta(const std::filesystem::path& path, const StoreMeta& m) { + std::ostringstream oss; + oss << "version = 1\n"; + oss << "total_files = " << m.total_files << "\n"; + oss << "total_bytes = " << m.total_bytes << "\n"; + oss << "last_gc_us = " << m.last_gc_us << "\n"; + std::filesystem::path tmp = path; + tmp += ".tmp"; + { + std::ofstream f(tmp, std::ios::binary | std::ios::trunc); + if (!f) return false; + const std::string body = oss.str(); + f.write(body.data(), body.size()); + if (!f.good()) return false; + f.flush(); + } + std::error_code ec; + std::filesystem::rename(tmp, path, ec); + return !ec; +} + +// Updates the store meta by (delta_files, delta_bytes). Caller must +// hold the store-meta mutex. Creates .store.toml if missing. +void adjustStoreMeta(const std::string& dir, int64_t df, int64_t db) { + auto p = storeMetaPath(dir); + StoreMeta m; + readStoreMeta(p, m); // tolerant of missing file + if (df >= 0) m.total_files += static_cast(df); + else m.total_files -= std::min(m.total_files, uint64_t(-df)); + if (db >= 0) m.total_bytes += static_cast(db); + else m.total_bytes -= std::min(m.total_bytes, uint64_t(-db)); + writeStoreMeta(p, m); +} + +// --------------------------------------------------------------------------- +// Rent math + per-op rent collection +// --------------------------------------------------------------------------- + +// Owed rent in credits for `elapsed_us` microseconds, floored. +// Uses __uint128_t for safety - realistic inputs don't come close to +// overflow, but a malicious tamper of last_rent_us or extreme values +// can; we clamp to UINT64_MAX in that case (effectively "dead file"). +uint64_t computeOwedRent(uint64_t size, int64_t feeRent, + uint64_t lastRentUs, uint64_t nowUs) { + if (nowUs <= lastRentUs) return 0; + if (feeRent <= 0 || size == 0) return 0; + __uint128_t owed = static_cast<__uint128_t>(size) + * static_cast<__uint128_t>(static_cast(feeRent)) + * static_cast<__uint128_t>(nowUs - lastRentUs); + owed /= static_cast<__uint128_t>(kUsecsPerDay); + if (owed > std::numeric_limits::max()) + return std::numeric_limits::max(); + return static_cast(owed); +} + +// Roll rent forward on a file up to now. Mutates `sc` in place. +// +// - If the file can cover the owed rent: deducts it, advances +// `last_rent_us`, returns true. Caller is responsible for writing +// the updated sidecar to disk (bundles nicely with the op's own +// sidecar update, if any). +// - If the owed rent exceeds balance: deletes content + sidecar on +// disk, bumps store meta (-1 file, -size bytes), returns false. +// Caller should treat this as CES_ERROR_FILE_NOT_FOUND for the +// in-flight op. +// - If owed is zero (same microsecond, free-tier, etc.): no-op, +// returns true. Caller may skip writing the sidecar. +// True iff sc.program_pubkey is non-zero (the file has a program account). +bool sidecarHasProgramAccount(const Sidecar& sc) { + for (auto b : sc.program_pubkey) + if (b != 0) return true; + return false; +} + +// Read the file's program-account balance synchronously (sync hop +// to logicStrand_). Returns 0 if the account doesn't exist (e.g., +// rent-collected by daily maintenance) or the sidecar has no +// program_pubkey (defensive - shouldn't happen post-v5). +uint64_t readProgramAccountBalance(CesServer* server, const Sidecar& sc) { + if (!sidecarHasProgramAccount(sc)) return 0; + minx::Hash pubkey{}; + std::memcpy(pubkey.data(), sc.program_pubkey.data(), 32); + int64_t bal = server->_l2ProgramAccountBalanceSync(pubkey); + return bal < 0 ? 0 : static_cast(bal); +} + +// Atomically debit `amount` from the file's program account. +// Returns (true, newBalance) on success or (false, currentBalance) +// on insufficient funds / missing account. Caller decides what +// "insufficient" means policy-wise. +struct ProgAcctDebitResult { bool ok; uint64_t newBalance; }; +ProgAcctDebitResult +debitProgramAccount(CesServer* server, const Sidecar& sc, uint64_t amount) { + if (!sidecarHasProgramAccount(sc)) return {false, 0}; + minx::Hash pubkey{}; + std::memcpy(pubkey.data(), sc.program_pubkey.data(), 32); + auto r = server->_l2DebitProgramAccountSync( + pubkey, static_cast(amount)); + return {r.ok, + static_cast(r.newBalance < 0 ? 0 : r.newBalance)}; +} + +// Credit `amount` into the file's program account. Creates the +// account from thin air if missing (server-mediated mint). +void creditProgramAccount(CesServer* server, const Sidecar& sc, uint64_t amount) { + if (!sidecarHasProgramAccount(sc)) return; + minx::Hash pubkey{}; + std::memcpy(pubkey.data(), sc.program_pubkey.data(), 32); + server->_l2CreditProgramAccountSync(pubkey, static_cast(amount)); +} + +// Top up a /s/ program account to the server's per-extension local budget (credit +// only the deficit, never burn). Shared by the boot/lazy reconcile and the daily +// sweep. cap 0 = off. Returns true if it credited. +static bool topUpProgramAccountToCap(CesServer* server, const Sidecar& sc) { + if (!server || !sidecarHasProgramAccount(sc)) return false; + uint64_t cap = server->extLocalBudget(); + if (cap == 0) return false; + uint64_t bal = readProgramAccountBalance(server, sc); + if (bal < cap) { creditProgramAccount(server, sc, cap - bal); return true; } + return false; +} + +// Reconcile one /s/ file: ensure it has a valid server-owned sidecar (minting a +// fresh program keypair on first sight, preserving an existing one across +// reboots, rewriting a stale one) and top up its program account. Pure disk +// plus a sync hop to logicStrand_ for the credit, so it MUST run off +// logicStrand_ (the file/rpc strand or boot). Returns the resulting sidecar in +// `out`; false if the content file is unreadable. Shared by the boot scan +// (reconcileServerZone) and the on-access lazy path (loadSidecar). +bool reconcileOneServerZoneFile(CesServer* server, const std::string& dir, + const std::string& name, Sidecar& out) { + namespace fs = std::filesystem; + auto cPath = resolveContentPath(dir, name); + std::error_code sec; + uint64_t size = static_cast(fs::file_size(cPath, sec)); + if (sec) return false; + + std::array serverPk{}; + std::memcpy(serverPk.data(), + server->_serverKeyPair().getPublicKeyAsHash().data(), 32); + + auto sPath = resolveSidecarPath(dir, name); + Sidecar existing{}; + bool haveExisting = readSidecar(sPath, existing); + + // Preserve an existing non-zero program keypair across reboots so assets + // already stamped with that identity stay resolvable; generate a fresh one + // only on first sight. + std::array programPubkey{}; + std::array programPrivkey{}; + bool existingHasPubkey = haveExisting && sidecarHasProgramAccount(existing); + if (existingHasPubkey) { + programPubkey = existing.program_pubkey; + programPrivkey = existing.program_privkey; + } else { + ces::KeyPair kp = ces::KeyPair::generate(); + std::memcpy(programPubkey.data(), kp.getPublicKeyAsHash().data(), 32); + std::memcpy(programPrivkey.data(), kp.getPrivateKey().data(), 32); + } + + bool sidecarOk = haveExisting && + existing.size == size && + existing.owner_pubkey == serverPk && + existing.name == name && + existingHasPubkey; + + out = existing; + if (!sidecarOk) { + Sidecar s{}; + s.version = kSidecarVersion; + s.name = name; + s.owner_pubkey = serverPk; + s.program_pubkey = programPubkey; + s.program_privkey = programPrivkey; + s.price_per_kb = 0; + s.size = size; + s.created_us = haveExisting && existing.created_us > 0 + ? existing.created_us : getMicrosSinceEpoch(); + s.modified_us = getMicrosSinceEpoch(); + s.last_rent_us = s.modified_us; + if (!writeSidecar(sPath, s)) { + LOGWARNING << "/s/ sidecar write failed" << SVAR(name); + return false; + } + LOGDEBUG << "/s/ sidecar generated" << SVAR(name) << VAR(size); + out = s; + } + + // Top up the program account to the per-extension local budget (deficit only). + topUpProgramAccountToCap(server, out); + return true; +} + +// Fetch a file's sidecar by name. For the /s/ zone, if the sidecar is missing +// but the content file is present (the operator dropped it on disk without a +// signed CREATE), mint it on the fly via the same reconcile the boot scan runs +// -- so "cp into /s/ and use it" works without a restart. The generated /s/ +// catalog and the bundled welcome site are static server content whose sidecars +// are seeded elsewhere; they are never auto-minted a program account. MUST run +// off logicStrand_ (the lazy mint may top up the program account via a sync +// hop). Non-/s/ names: a plain readSidecar, no auto-create. +bool loadSidecar(CesServer* server, const std::string& dir, + const std::string& name, Sidecar& sc) { + auto sPath = resolveSidecarPath(dir, name); + if (readSidecar(sPath, sc)) return true; + if (!isServerZone(name)) return false; + if (name == kServerIndexName || isBuiltinSitePath(name)) return false; + if (!server) return false; + return reconcileOneServerZoneFile(server, dir, name, sc); +} + +bool chargeRentOrDelete(CesServer* server, + const std::filesystem::path& cPath, + const std::filesystem::path& sPath, + Sidecar& sc, + int64_t feeRent, + const std::string& dir) { + // /s/ files are unmetered. Never accrue rent, never die of + // exhaustion. Caller may skip writing the sidecar (last_rent_us + // is never advanced). + if (isServerZone(sc.name)) return true; + // kv files pay rent per cell (the daily sweep counts down each cell's prepaid + // header), not per whole-file byte, and have no program account to debit. + // They never accrue whole-file rent here, and never die of it. + if (sc.type == kFileTypeKv) return true; + uint64_t now = getMicrosSinceEpoch(); + uint64_t owed = computeOwedRent( + sc.size, feeRent, sc.last_rent_us, now); + if (owed == 0) return true; + + if (!server) return false; + minx::Hash pubkey{}; + std::memcpy(pubkey.data(), sc.program_pubkey.data(), 32); + auto r = server->_l2DebitProgramAccountSync( + pubkey, static_cast(owed)); + bool dead = !r.ok; + + if (dead) { + // Dead. Delete and bump meta. + std::error_code ec; + if (sc.type == kFileTypeKv) std::filesystem::remove_all(cPath, ec); + else std::filesystem::remove(cPath, ec); + std::filesystem::remove(sPath, ec); + // Best-effort rmdir of empty parents. + { + std::filesystem::path p = cPath.parent_path(); + std::filesystem::path base = dir; + while (p != base) { + std::error_code rmec; + if (!std::filesystem::remove(p, rmec)) break; + p = p.parent_path(); + } + } + { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(dir, -1, -static_cast(sc.size)); + } + notifyDeletion(server, sc.name); + return false; + } + sc.last_rent_us = now; + return true; +} + +// --------------------------------------------------------------------------- +// Wire helpers (BE int readers/writers on byte vectors) +// --------------------------------------------------------------------------- + +// All BE serialization goes through ces::Buffer (see ces/buffer.h). + +// --------------------------------------------------------------------------- +// Common per-request state +// --------------------------------------------------------------------------- + +// The signed-request loop lives in the CesPlex framework (cesPlexServe / +// CesPlexRequest, see cesplex/mux.h). ReqCtx aliases the framework +// request so the dispatchers below need no changes; the thin senders +// forward to its respond/error helpers. respond's `extraBody` streams a +// READ payload after the envelope; errorAndClose drops the channel for a +// body-bearing verb rejected before its trailing body was consumed (a +// loop there would desync the stream). + +using ReqCtx = ces::CesPlexRequest; + +// The CesPlex bus is host-generic (it knows only CesPlexHost). builtin:file +// is a CES core feature, so its host is always the CesServer - recover the +// concrete server for the ledger-facing calls below. +inline CesServer* reqServer(const std::shared_ptr& ctx) { + return static_cast(ctx->host); +} + +inline void sendResponseAndLoop(std::shared_ptr ctx, uint8_t status, + ces::Bytes preamble, + ces::Bytes extraBody = {}) { + ctx->respond(status, std::move(preamble), std::move(extraBody)); +} +inline void sendErrorAndLoop(std::shared_ptr ctx, uint8_t status) { + ctx->error(status); +} +inline void sendErrorAndClose(std::shared_ptr ctx, uint8_t status) { + ctx->errorAndClose(status); +} + +// Per-verb dispatch after signed envelope is verified and debited. +// Declared here; defined below verb-by-verb. +void dispatchCreate (std::shared_ptr ctx, ces::Bytes pre); +void dispatchStat (std::shared_ptr ctx, ces::Bytes pre); +void dispatchAppend (std::shared_ptr ctx, ces::Bytes pre); +void dispatchResize (std::shared_ptr ctx, ces::Bytes pre); + +// JIT rent GC - forward decl so dispatchers that grow total_bytes +// (CREATE, APPEND, RESIZE-grow) can call it below. Caller must hold +// the store-meta mutex. +uint64_t gcReclaim(CesServer* server, const std::string& dir, int64_t feeRent, + uint64_t bytesNeeded); + +// Cap-and-GC helper. Returns CES_OK if `addBytes` fits under `cap` +// (possibly after a debounced GC), CES_ERROR_STORE_FULL otherwise. +// Caller must hold the store-meta mutex. Does not bump total_bytes - +// caller commits after the op's disk work succeeds. +uint8_t checkCapAndMaybeGc(CesServer* server, + const std::string& dir, + uint64_t cap, + int64_t feeRent, + uint64_t addBytes); +void dispatchWrite (std::shared_ptr ctx, ces::Bytes pre); +void dispatchRead (std::shared_ptr ctx, ces::Bytes pre); +void dispatchDeposit(std::shared_ptr ctx, ces::Bytes pre); +void dispatchKvDeposit(std::shared_ptr ctx, ces::Bytes pre); +void dispatchWithdraw(std::shared_ptr ctx, ces::Bytes pre); +void dispatchSetPrice(std::shared_ptr ctx, ces::Bytes pre); +void dispatchDelete (std::shared_ptr ctx, ces::Bytes pre); + +// --------------------------------------------------------------------------- +// Dispatch: CREATE +// preamble (after reqNonce): +// u64 size, u64 price_per_kb, u64 initial_deposit, +// u16 ct_len, ct, u16 name_len, name +// --------------------------------------------------------------------------- + +// Extract zone ('h', 'f', or 'p') and the second-component offset + +// length from a name that has already passed validateCesFileName. +// Pre: name starts with ///... with zone exactly 1 +// char. Returns zone char, and span of the second component. +void extractZoneAndNamespace(const std::string& name, + char& zone, + std::string& secondComponent) { + zone = name[1]; + size_t start = 3; // past "/x/" + size_t end = name.find('/', start); + if (end == std::string::npos) end = name.size(); + secondComponent = name.substr(start, end - start); +} + +// Hex-decode 64 lowercase hex chars into a 32-byte Hash. Pre: +// already validated as 64 lowercase-hex (via validateCesFileName). +void hexDecodePubkey32(const std::string& hex64, minx::Hash& out) { + auto nib = [](char c) -> uint8_t { + if (c >= '0' && c <= '9') return c - '0'; + return c - 'a' + 10; + }; + for (size_t i = 0; i < 32; ++i) { + out[i] = static_cast( + (nib(hex64[i * 2]) << 4) | nib(hex64[i * 2 + 1])); + } +} + +// Perform the zone-ownership check for a CREATE. For /h/ and /p/ +// this returns synchronously via `onDone`. For /f/ it hops to the +// ledger strand to check asset ownership and calls `onDone` from +// cbExecutor. +void checkZoneOwnership( + CesServer* server, + const std::string& name, + const std::array& signerKey, + boost::asio::any_io_executor cbExecutor, + std::function onDone) { + char zone; + std::string second; + extractZoneAndNamespace(name, zone, second); + if (zone == 'p') { + onDone(CES_OK); + return; + } + if (zone == 's') { + // Server-deployed zone: only the server's own public key may + // create (or otherwise mutate) files here. The operator signs + // with their server private key - same one in server.toml. + const auto& srvPk = server->_serverKeyPair().getPublicKeyAsHash(); + if (std::memcmp(srvPk.data(), signerKey.data(), 32) != 0) { + onDone(CES_ERROR_NOT_OWNER); + } else { + onDone(CES_OK); + } + return; + } + if (zone == 'h' || zone == 'm') { + // /h/ home dir and /m/ private mail zone: both account-keyed, owner is the + // 64-hex pubkey in the path. (Wire READ/STAT on /m/ is denied separately; + // only builtin:mail reads it in-process.) + minx::Hash pathPk; + hexDecodePubkey32(second, pathPk); + if (std::memcmp(pathPk.data(), signerKey.data(), 32) != 0) { + onDone(CES_ERROR_NOT_OWNER); + } else { + onDone(CES_OK); + } + return; + } + // zone == 'f': async ownership check. /f// is owned SOLELY by the + // key_name holder (crypto-owned, unsquattable); there is no asset gate. + ces::PublicKey signer(signerKey); + server->_l2CheckFZoneOwner( + second, signer, + [onDone](bool isOwner) { + onDone(isOwner ? CES_OK : CES_ERROR_NOT_OWNER); + }, + cbExecutor); +} + +// --------------------------------------------------------------------------- +// /s/ auto-index - the one zone where enumeration is safe. +// +// /s/ is the only WRITE-operator-only zone (server key alone can create/write +// there), so listing it can only ever reveal operator-curated, public content - +// no untrusted uploads, no abuse surface. Every other zone is deliberately +// non-enumerable (knowing the path is the capability). So we keep a generated +// /s/index.html catalog in sync: regenerated on every /s/ file-set change, and +// at boot if it is missing. +// +// Pure disk I/O on the file/rpc strand: it never touches logicStrand_ (no fee, +// no program account), and it writes the index DIRECTLY here - not through the +// CREATE verb - so it can never re-trigger itself (no churn loop). /s/ changes +// only when the operator acts, so unconditional regen is fine forever. +void regenerateServerIndex(CesServer* server, const std::string& dir) { + if (!server) return; + namespace fs = std::filesystem; + std::error_code ec; + fs::path sRoot = fs::path(dir) / "s"; + if (!fs::exists(sRoot, ec)) return; + + const std::string suffix = kSidecarSuffix; + std::vector names; // CES names, e.g. "/s/dice.lua" + for (auto it = fs::recursive_directory_iterator(sRoot, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (!it->is_regular_file()) continue; + std::string fn = it->path().filename().string(); + if (fn.size() >= suffix.size() && + fn.compare(fn.size() - suffix.size(), suffix.size(), suffix) == 0) + continue; // skip sidecars + fs::path rel = fs::relative(it->path(), fs::path(dir), ec); + if (ec) { ec.clear(); continue; } + std::string nm = "/" + rel.generic_string(); + if (nm == kServerIndexName) continue; // never list the index itself + names.push_back(std::move(nm)); + } + std::sort(names.begin(), names.end()); + + std::string html = + "" + "" + "/s/ \xe2\x80\x94 server catalog" + "" + "

/s/ \xe2\x80\x94 server catalog

    "; + for (const auto& nm : names) + html += "
  • " + nm + "
  • "; + html += "
auto-generated index of the operator-curated /s/ zone (" + + std::to_string(names.size()) + " files)
\n"; + + // Write content atomically (temp + rename) so a concurrent READ never sees a + // partial index. + fs::path cPath = resolveContentPath(dir, kServerIndexName); + fs::create_directories(cPath.parent_path(), ec); + fs::path tmp = cPath; tmp += ".tmp"; + { + std::ofstream f(tmp, std::ios::binary | std::ios::trunc); + if (!f) { LOGWARNING << "/s/ index: open failed"; return; } + f.write(html.data(), static_cast(html.size())); + if (!f.good()) { LOGWARNING << "/s/ index: write failed"; return; } + } + fs::rename(tmp, cPath, ec); + if (ec) { + LOGWARNING << "/s/ index: rename failed"; + std::filesystem::remove(tmp, ec); + return; + } + + // Minimal sidecar so READ serves it (owner = server; no program account - a + // generated static file, not a program). reconcileServerZone skips this name, + // so the zero program account is never "fixed up". Pure disk, no logicStrand_. + Sidecar s{}; + s.version = kSidecarVersion; + s.name = kServerIndexName; + std::memcpy(s.owner_pubkey.data(), + server->_serverKeyPair().getPublicKeyAsHash().data(), 32); + s.price_per_kb = 0; + s.size = html.size(); + s.created_us = getMicrosSinceEpoch(); + s.modified_us = s.created_us; + s.last_rent_us = s.created_us; + writeSidecar(resolveSidecarPath(dir, kServerIndexName), s); + LOGDEBUG << "/s/ index regenerated" << VAR(names.size()); +} + +// Hook fired at the success of a /s/ file-set change (CREATE / DELETE). Guarded +// to the /s/ zone and self-bypassing (the index write above never goes through +// the verbs, so this can't recurse). Runs on the file/rpc strand. +void noteServerZoneMutation(CesServer* server, const std::string& name) { + if (!isServerZone(name)) return; + if (name == kServerIndexName) return; // recursion guard (belt + braces) + if (!server) return; + regenerateServerIndex(server, server->_config().cesFileStoreDir); +} + +// --------------------------------------------------------------------------- +// A verb is a pure body plus an injected billing policy, run inside one ledger +// transaction (CesServer::_l2Transact). bill returns {rc, duplicate}: rc != OK +// is an error; duplicate is an idempotent replay (no re-credit); rc == OK and +// not duplicate proceeds with the mutation. The session adapter supplies +// signerBilling (network signer + dedup); the in-process adapter supplies a +// source-balance policy. +struct L2ChargeResult { uint8_t rc; bool duplicate; }; +using L2Billing = std::function; + +// Network billing: signer pays `cost`, with NONCELESS replay dedup. +inline L2Billing signerBilling(const ces::PublicKey& signer, uint32_t reqNonce, + uint64_t sigHash, int64_t errFee) { + minx::Hash signerHash = signer.getHash(); + return [signerHash, reqNonce, sigHash, errFee] + (ces::LedgerTxn& t, int64_t cost) -> L2ChargeResult { + if (reqNonce == CES_NONCELESS && t.isReplay(sigHash)) return { CES_OK, true }; + uint8_t rc = t.signerSpend(signerHash, static_cast(cost), reqNonce, errFee); + if (rc != CES_OK) return { rc, false }; + if (reqNonce == CES_NONCELESS) t.recordDedup(sigHash); + return { CES_OK, false }; + }; +} + +struct CreateOutcome { uint8_t status; uint64_t fileBalance; uint64_t costDebited; }; + +// CREATE core, shared by both adapters; runs after the async zone gate. +// Off-strand: path/cap/GC/upfront checks, keypair gen, file + sidecar write. +// One transaction: dedup, charge (feeQuery + initialDeposit), account mint. +CreateOutcome createCore(CesServer* server, const std::string& name, + uint64_t size, uint64_t pricePerKb, + uint64_t initialDeposit, const minx::Hash& caller, + const L2Billing& bill, uint8_t type = kFileTypeFlat) { + const auto& cfg = server->_config(); + uint8_t rc = checkPathConflict(cfg.cesFileStoreDir, name, /*createMode=*/true); + if (rc != CES_OK) return { rc, 0, 0 }; + const bool serverZone = isServerZone(name); + if (!serverZone) { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + uint8_t capRc = checkCapAndMaybeGc(server, cfg.cesFileStoreDir, cfg.cesFileStoreMaxBytes, + cfg.feeFileRent, size); + if (capRc != CES_OK) return { capRc, 0, 0 }; + } + uint64_t upfrontBurn = 0; + if (serverZone || type == kFileTypeKv) { + // /s/ is unmetered; a kv store starts empty and has no store-level pot + // (cells are funded, and burned, per-key). Neither mints a program account. + initialDeposit = 0; + } else { + upfrontBurn = computeOwedRent(size, cfg.feeFileRent, 0, kGcDebounceUs); + if (initialDeposit < upfrontBurn) return { CES_ERROR_INSUFFICIENT_BALANCE, 0, 0 }; + } + const uint64_t programAccountInitial = + initialDeposit > upfrontBurn ? initialDeposit - upfrontBurn : 0; + const uint64_t costDebited = + static_cast(cfg.feeQuery) + initialDeposit; + + ces::KeyPair kp = ces::KeyPair::generate(); // off-strand; never on the logic strand + minx::Hash progPub = kp.getPublicKeyAsHash(); + minx::Hash progPriv = kp.getPrivateKey(); + + uint8_t status = CES_ERROR_INTERNAL; + bool duplicate = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult c = bill(t, static_cast(costDebited)); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { duplicate = true; status = CES_OK; return; } + // Flat files hold a real, withdrawable balance in a program account. kv + // files hold none: each cell carries prepaid rent burned at deposit, so no + // account is minted (the sidecar keypair stays, unbacked). + if (type != kFileTypeKv) + t.credit(progPub, static_cast(programAccountInitial)); + status = CES_OK; + }); + if (status != CES_OK) return { status, 0, 0 }; + if (duplicate) return { CES_OK, programAccountInitial, costDebited }; + + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + std::error_code ec; + std::filesystem::create_directories(cPath.parent_path(), ec); + if (ec) return { CES_ERROR_INTERNAL, 0, 0 }; + if (type == kFileTypeKv) { + // kv content is a directory: the driver creates it, logkv uses it. + std::filesystem::create_directory(cPath, ec); + if (ec) return { CES_ERROR_INTERNAL, 0, 0 }; + std::lock_guard lk(server->fileHandler()->kv_->mutex); + if (!kvStoreOpenLocked(server, cfg.cesFileStoreDir, name)) { + std::filesystem::remove_all(cPath, ec); + return { CES_ERROR_INTERNAL, 0, 0 }; + } + } else { + { std::ofstream f(cPath, std::ios::binary | std::ios::trunc); + if (!f) return { CES_ERROR_INTERNAL, 0, 0 }; } + std::filesystem::resize_file(cPath, size, ec); + if (ec) return { CES_ERROR_INTERNAL, 0, 0 }; + } + + Sidecar s{}; + s.version = kSidecarVersion; + s.name = name; + std::memcpy(s.owner_pubkey.data(), caller.data(), 32); + std::memcpy(s.program_pubkey.data(), progPub.data(), 32); + std::memcpy(s.program_privkey.data(), progPriv.data(), 32); + s.price_per_kb = pricePerKb; + // A flat file's sidecar size is its byte length; a kv store's is its estimated + // disk use (live + events), which starts at the compaction floor. + s.size = (type == kFileTypeKv) ? kKvCompactFloorBytes : size; + s.type = type; + s.created_us = getMicrosSinceEpoch(); + s.modified_us = s.created_us; + s.last_rent_us = s.created_us; + if (!writeSidecar(sPath, s)) { + std::filesystem::remove(cPath, ec); + return { CES_ERROR_INTERNAL, 0, 0 }; + } + if (!serverZone) { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(cfg.cesFileStoreDir, +1, + static_cast(type == kFileTypeKv ? kKvCompactFloorBytes : size)); + } + noteServerZoneMutation(server, name); + return { CES_OK, programAccountInitial, costDebited }; +} + +void dispatchCreate(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t size = 0, pricePerKb = 0, initialDeposit = 0; + std::string name; + try { + size = buf.get(); + pricePerKb = buf.get(); + initialDeposit = buf.get(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + + if (size == 0 || size > kMaxFileSize) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) { sendErrorAndLoop(ctx, rc); return; } + + // Zone-ownership gate. For /h/ this resolves sync; for /f/ it + // hops to the ledger strand and back. After it resolves, we + // finish the CREATE under `finishCreate` below. + auto finishCreate = + [ctx, size, pricePerKb, initialDeposit, name] + (uint8_t zoneRc) mutable { + if (zoneRc != CES_OK) { sendErrorAndLoop(ctx, zoneRc); return; } + + const auto& cfg = reqServer(ctx)->_config(); + CreateOutcome out = createCore( + reqServer(ctx), name, size, pricePerKb, initialDeposit, + ctx->bound.boundPubkey.getHash(), + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes pre; + ces::Buffer::put(pre, out.fileBalance); + ces::Buffer::put(pre, out.costDebited); + sendResponseAndLoop(ctx, CES_OK, std::move(pre)); + }; // end finishCreate lambda + + // Kick off the zone check. /h/ and /p/ resolve synchronously; + // /f/ hops to logicStrand_ to verify asset ownership. + checkZoneOwnership( + reqServer(ctx), name, ctx->bound.boundPubkey.getHash(), + ctx->stream->get_executor(), std::move(finishCreate)); +} + +// --------------------------------------------------------------------------- +// Dispatch: WRITE +// preamble (after reqNonce): +// u64 offset, u32 length, u8[32] content_hash, +// u16 name_len, name +// body: `length` bytes +// --------------------------------------------------------------------------- + +struct WriteBodyState : std::enable_shared_from_this { + std::shared_ptr ctx; + ces::Bytes body; + uint64_t offset = 0; + uint32_t length = 0; + std::array contentHash{}; + std::string name; +}; + +struct WriteOutcome { uint8_t status; uint64_t balance; }; + +// WRITE core: overwrite [offset, offset+len) with `body`. feeQuery via bill + +// writeCost from the target program account in one transaction. writeCost +// balance is checked before the fee, so an insufficient target burns no fee and +// never touches the source. +WriteOutcome writeCore(CesServer* server, const std::string& name, + uint64_t offset, const ces::Bytes& body, + const minx::Hash& caller, const L2Billing& bill) { + uint32_t length = static_cast(body.size()); + if (length == 0 || length > kMaxWriteLen) return { CES_ERROR_BAD_INPUT, 0 }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, 0 }; + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0 }; + if (offset > sc.size || offset + uint64_t(length) > sc.size) + return { CES_ERROR_BAD_INPUT, 0 }; + uint64_t writeCost = isServerZone(name) ? 0 : kbCeil(length) * uint64_t(cfg.feeFileWrite); + writeSidecar(sPath, sc); // persist rent advance before disk + minx::Hash prog{}; + std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + + uint8_t status = CES_ERROR_INTERNAL; + bool duplicate = false; + uint64_t newBal = 0; + server->_l2Transact([&](ces::LedgerTxn& t) { + if (writeCost > 0 && t.balance(prog) < static_cast(writeCost)) { + status = CES_ERROR_INSUFFICIENT_BALANCE; return; // before fee + } + L2ChargeResult c = bill(t, static_cast(cfg.feeQuery)); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { duplicate = true; status = CES_OK; newBal = static_cast(t.balance(prog)); return; } + if (writeCost > 0) t.debitAccount(prog, writeCost); + newBal = static_cast(t.balance(prog)); + status = CES_OK; + }); + if (status != CES_OK) return { status, 0 }; + if (!duplicate) { + FILE* fp = std::fopen(cPath.string().c_str(), "rb+"); + if (!fp) return { CES_ERROR_INTERNAL, 0 }; + if (std::fseek(fp, static_cast(offset), SEEK_SET) != 0) { + std::fclose(fp); return { CES_ERROR_INTERNAL, 0 }; + } + size_t wrote = std::fwrite(body.data(), 1, body.size(), fp); + std::fflush(fp); std::fclose(fp); + if (wrote != body.size()) return { CES_ERROR_INTERNAL, 0 }; + Sidecar sc2{}; + if (!readSidecar(sPath, sc2)) return { CES_ERROR_INTERNAL, 0 }; + sc2.modified_us = getMicrosSinceEpoch(); + sc2.program_hash.fill(0); // content changed -> invalidate cached hash + if (!writeSidecar(sPath, sc2)) return { CES_ERROR_INTERNAL, 0 }; + } + return { CES_OK, newBal }; +} + +void dispatchWrite(std::shared_ptr ctx, ces::Bytes pre) { + // Parse + length-bound must precede the body stream (close on failure, since + // the body is in flight). The body is always consumed before writeCore runs, + // so every post-stream outcome can loop (the wire stays in sync). + ces::Buffer buf(std::move(pre)); + uint64_t offset = 0; + uint32_t length = 0; + std::array contentHash{}; + std::string name; + try { + offset = buf.get(); + length = buf.get(); + contentHash = buf.get>(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndClose(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndClose(ctx, CES_ERROR_BAD_INPUT); return; + } + if (length == 0 || length > kMaxWriteLen) { + sendErrorAndClose(ctx, CES_ERROR_BAD_INPUT); return; + } + ces::PublicKey signer = ctx->bound.boundPubkey; + uint32_t reqNonce = ctx->reqNonce; + uint64_t sigHash = ctx->reqSigHash; + int64_t errFee = static_cast(reqServer(ctx)->_config().getFeeError()); + auto body = std::make_shared(length); + + boost::asio::async_read( + *ctx->stream, boost::asio::buffer(*body), + [ctx, offset, contentHash, name, signer, reqNonce, sigHash, errFee, body] + (const boost::system::error_code& ec, std::size_t) { + if (ec) return; // stream dead + minx::Hash got = ces::sha256(body->data(), body->size()); + if (std::memcmp(got.data(), contentHash.data(), 32) != 0) { + sendErrorAndLoop(ctx, CES_ERROR_INTERNAL); return; + } + WriteOutcome out = writeCore( + reqServer(ctx), name, offset, *body, signer.getHash(), + signerBilling(signer, reqNonce, sigHash, errFee)); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.balance); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); + }); +} + +// --------------------------------------------------------------------------- +// Dispatch: READ +// preamble (after reqNonce): +// u64 offset, u32 length, u16 name_len, name +// --------------------------------------------------------------------------- + +struct ReadOutcome { uint8_t status; ces::Bytes data; }; + +// READ core: three-cost model in one transaction. Signer pays feeQuery + IO + +// price; the price portion credits the file's program account for a non-owner, +// non-duplicate read. The disk read runs off-strand; a duplicate re-reads the +// unchanged bytes without re-crediting. +ReadOutcome readCore(CesServer* server, const std::string& name, + uint64_t offset, uint32_t length, const minx::Hash& caller, + const L2Billing& bill) { + if (length == 0 || length > kMaxReadLen) return { CES_ERROR_BAD_INPUT, {} }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, {} }; + // The /m/ mail zone is private: nothing reads it over the wire or from a + // program. Only builtin:mail reads it in-process (readAttachment). + if (name.size() > 1 && name[1] == 'm') return { CES_ERROR_NOT_OWNER, {} }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!loadSidecar(server, cfg.cesFileStoreDir, name, sc)) return { CES_ERROR_FILE_NOT_FOUND, {} }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, {} }; + writeSidecar(sPath, sc); + if (offset > sc.size || offset + uint64_t(length) > sc.size) + return { CES_ERROR_BAD_INPUT, {} }; + + const bool isOwner = (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) == 0); + const bool serverZone = isServerZone(name); + const uint64_t readIoCost = serverZone ? 0 : kbCeil(length) * uint64_t(cfg.feeFileRead); + const uint64_t readPrice = (!serverZone && !isOwner) ? kbCeil(length) * sc.price_per_kb : 0; + const int64_t cost = static_cast(cfg.feeQuery) + + static_cast(readIoCost) + static_cast(readPrice); + minx::Hash prog{}; + std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + + uint8_t status = CES_ERROR_INTERNAL; + bool credited = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult c = bill(t, cost); + if (c.rc != CES_OK) { status = c.rc; return; } + if (!c.duplicate && readPrice > 0) { // owner already excluded (readPrice==0) + t.credit(prog, static_cast(readPrice)); + credited = true; + } + status = CES_OK; + }); + if (status != CES_OK) return { status, {} }; + if (credited) { sc.modified_us = getMicrosSinceEpoch(); writeSidecar(sPath, sc); } + + std::ifstream f(cPath, std::ios::binary); + if (!f) return { CES_ERROR_INTERNAL, {} }; + f.seekg(static_cast(offset), std::ios::beg); + ces::Bytes data(length); + f.read(reinterpret_cast(data.data()), length); + if (f.gcount() != static_cast(length)) return { CES_ERROR_INTERNAL, {} }; + return { CES_OK, std::move(data) }; +} + +void dispatchRead(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t offset = 0; + uint32_t length = 0; + std::string name; + try { + offset = buf.get(); + length = buf.get(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + const auto& cfg = reqServer(ctx)->_config(); + ReadOutcome out = readCore( + reqServer(ctx), name, offset, length, ctx->bound.boundPubkey.getHash(), + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + // [u64 length][u8[32] sha256_of_range] + body + minx::Hash h = ces::sha256(out.data.data(), out.data.size()); + ces::Bytes resp; + ces::Buffer::put(resp, uint64_t(out.data.size())); + resp.insert(resp.end(), h.begin(), h.end()); + sendResponseAndLoop(ctx, CES_OK, std::move(resp), std::move(out.data)); +} + +// --------------------------------------------------------------------------- +// Dispatch: DEPOSIT +// preamble (after reqNonce): +// u64 amount, u16 name_len, name +// --------------------------------------------------------------------------- + +struct DepositOutcome { uint8_t status; uint64_t balance; }; + +// DEPOSIT core: credit a file's program account, charged via `bill`. One body, +// one strand transaction (dedup + debit + credit + balance, atomic). +DepositOutcome depositCore(CesServer* server, const std::string& name, + uint64_t amount, const L2Billing& bill) { + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + minx::Hash prog{}; + std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + const int64_t cost = static_cast(cfg.feeQuery) + + static_cast(amount); + + uint8_t status = CES_ERROR_INTERNAL; + uint64_t bal = 0; + bool mutated = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult c = bill(t, cost); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { status = CES_OK; bal = static_cast(t.balance(prog)); return; } + t.credit(prog, static_cast(amount)); + status = CES_OK; bal = static_cast(t.balance(prog)); mutated = true; + }); + if (status == CES_OK && mutated) { + sc.modified_us = getMicrosSinceEpoch(); + writeSidecar(sPath, sc); + } + return { status, bal }; +} + +void dispatchDeposit(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t amount = 0; + std::string name; + try { + amount = buf.get(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + const auto& cfg = reqServer(ctx)->_config(); + DepositOutcome out = depositCore( + reqServer(ctx), name, amount, + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.balance); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); +} + +// --------------------------------------------------------------------------- +// Dispatch: WITHDRAW (owner only) +// preamble (after reqNonce): +// u64 amount, u16 name_len, name +// --------------------------------------------------------------------------- + +struct WithdrawOutcome { uint8_t status; uint64_t balance; }; + +// WITHDRAW core: owner pulls `amount` from the program account to creditDest. +// Off-strand authz, then one transaction. Balance is checked before dedup/fee, +// so a replay whose first withdraw drained the balance returns INSUFFICIENT +// rather than idempotent-OK. +WithdrawOutcome withdrawCore(CesServer* server, const std::string& name, + uint64_t amount, const minx::Hash& caller, + const std::optional& creditDest, + const L2Billing& bill) { + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, 0 }; + writeSidecar(sPath, sc); + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0 }; + minx::Hash prog{}; + std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + + uint8_t status = CES_ERROR_INTERNAL; + uint64_t bal = 0; + bool mutated = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + if (t.balance(prog) < static_cast(amount)) { + status = CES_ERROR_INSUFFICIENT_BALANCE; return; // before fee + } + L2ChargeResult c = bill(t, static_cast(cfg.feeQuery)); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { status = CES_OK; bal = static_cast(t.balance(prog)); return; } + t.debitAccount(prog, amount); // balance checked above + if (creditDest) t.credit(*creditDest, static_cast(amount)); + status = CES_OK; bal = static_cast(t.balance(prog)); mutated = true; + }); + if (status == CES_OK && mutated) { + sc.modified_us = getMicrosSinceEpoch(); + writeSidecar(sPath, sc); + } + return { status, bal }; +} + +void dispatchWithdraw(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t amount = 0; + std::string name; + try { + amount = buf.get(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + const auto& cfg = reqServer(ctx)->_config(); + minx::Hash signerHash = ctx->bound.boundPubkey.getHash(); + WithdrawOutcome out = withdrawCore( + reqServer(ctx), name, amount, signerHash, signerHash, // owner == signer == dest + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.balance); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); +} + +// --------------------------------------------------------------------------- +// Dispatch: SET_PRICE (owner only) +// preamble (after reqNonce): +// u64 price_per_kb, u16 name_len, name +// --------------------------------------------------------------------------- + +struct SetPriceOutcome { uint8_t status; uint64_t price; }; + +// SET_PRICE core: owner sets price_per_kb. Off-strand authz; fee in one +// transaction; the sidecar price is written off-strand after a non-duplicate +// commit (a replay leaves the already-set price). +SetPriceOutcome setPriceCore(CesServer* server, const std::string& name, + uint64_t newPrice, const minx::Hash& caller, + const L2Billing& bill) { + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, 0 }; + writeSidecar(sPath, sc); + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0 }; + + uint8_t status = CES_ERROR_INTERNAL; + bool mutated = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult c = bill(t, static_cast(cfg.feeQuery)); + if (c.rc != CES_OK) { status = c.rc; return; } + status = CES_OK; mutated = !c.duplicate; + }); + if (status != CES_OK) return { status, 0 }; + if (mutated) { + sc.price_per_kb = newPrice; + sc.modified_us = getMicrosSinceEpoch(); + writeSidecar(sPath, sc); + } + return { CES_OK, sc.price_per_kb }; +} + +void dispatchSetPrice(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t newPrice = 0; + std::string name; + try { + newPrice = buf.get(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + const auto& cfg = reqServer(ctx)->_config(); + SetPriceOutcome out = setPriceCore( + reqServer(ctx), name, newPrice, ctx->bound.boundPubkey.getHash(), + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.price); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); +} + +// --------------------------------------------------------------------------- +// Dispatch: STAT +// preamble (after reqNonce): u16 name_len, name +// +// Like every verb, STAT is on a bound channel and verifies through the +// same envelope; the channel signer pays feeQuery. No unsigned fast path. +// --------------------------------------------------------------------------- + +struct StatOutcome { + uint8_t status; + std::array owner; + uint64_t fileBalance, price, size, createdUs, modifiedUs; +}; + +// STAT core: read-only metadata + program-account balance, charged feeQuery. +// loadSidecar lazy-mints a dropped /s/ file on first stat. A duplicate replay +// just re-reads (read-only). +StatOutcome statCore(CesServer* server, const std::string& name, + const L2Billing& bill) { + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, {}, 0, 0, 0, 0, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!loadSidecar(server, cfg.cesFileStoreDir, name, sc)) + return { CES_ERROR_FILE_NOT_FOUND, {}, 0, 0, 0, 0, 0 }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, {}, 0, 0, 0, 0, 0 }; + // For a kv file the reported size is its live data (the counter), not the + // sidecar's disk-use estimate; and kv rent is per-cell (chargeRentOrDelete is + // a no-op for kv) so there is no rent advance to persist -- skip the sidecar + // write. Flat files persist the rent advance. + uint64_t reportSize = sc.size; + if (sc.type == kFileTypeKv) { + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + if (kvStoreOpenLocked(server, cfg.cesFileStoreDir, name)) + reportSize = server->fileHandler()->kv_->liveBytes[ + resolveContentPath(cfg.cesFileStoreDir, name).string()]; + } else { + writeSidecar(sPath, sc); + } + + minx::Hash prog{}; + std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + uint8_t status = CES_ERROR_INTERNAL; + uint64_t bal = 0; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult c = bill(t, static_cast(cfg.feeQuery)); + if (c.rc != CES_OK) { status = c.rc; return; } + bal = static_cast(t.balance(prog)); // read-only, dup-safe + status = CES_OK; + }); + if (status != CES_OK) return { status, {}, 0, 0, 0, 0, 0 }; + return { CES_OK, sc.owner_pubkey, bal, sc.price_per_kb, reportSize, + sc.created_us, sc.modified_us }; +} + +void dispatchStat(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + std::string name; + try { + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + // /m/ mail zone is private: no wire STAT (metadata) either. + if (name.size() > 1 && name[1] == 'm') { + sendErrorAndLoop(ctx, CES_ERROR_NOT_OWNER); return; + } + const auto& cfg = reqServer(ctx)->_config(); + StatOutcome out = statCore( + reqServer(ctx), name, + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + // [u8[32] owner][u64 file_balance][u64 price][u64 size][u64 created][u64 modified] + ces::Bytes resp; + resp.insert(resp.end(), out.owner.begin(), out.owner.end()); + ces::Buffer::put(resp, out.fileBalance); + ces::Buffer::put(resp, out.price); + ces::Buffer::put(resp, out.size); + ces::Buffer::put(resp, out.createdUs); + ces::Buffer::put(resp, out.modifiedUs); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); +} + +// --------------------------------------------------------------------------- +// Dispatch: DELETE (owner only) +// preamble (after reqNonce): +// u16 name_len, name +// --------------------------------------------------------------------------- + +struct DeleteOutcome { uint8_t status; uint64_t refunded; }; + +// DELETE core: owner removes a file. Fee + drain-refund (program account -> +// creditDest) are one atomic transaction; the disk removal happens off-strand +// after a non-duplicate commit. A replay returns refund 0 (already gone). +DeleteOutcome deleteCore(CesServer* server, const std::string& name, + const minx::Hash& caller, + const std::optional& creditDest, + const L2Billing& bill) { + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, 0 }; + writeSidecar(sPath, sc); + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0 }; + + minx::Hash prog{}; std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + uint8_t status = CES_ERROR_INTERNAL; + uint64_t refund = 0; + bool mutated = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult c = bill(t, static_cast(cfg.feeQuery)); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { status = CES_OK; return; } // already deleted + int64_t bal = t.balance(prog); // drain to dest + if (bal > 0) { t.debitAccount(prog, static_cast(bal)); + if (creditDest) t.credit(*creditDest, bal); + refund = static_cast(bal); } + status = CES_OK; mutated = true; + }); + if (status != CES_OK) return { status, 0 }; + if (mutated) { + uint64_t size = sc.size; + std::error_code ec; + if (sc.type == kFileTypeKv) std::filesystem::remove_all(cPath, ec); + else std::filesystem::remove(cPath, ec); + std::filesystem::remove(sPath, ec); + if (!isServerZone(name)) { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(cfg.cesFileStoreDir, -1, -static_cast(size)); + } + std::filesystem::path p = cPath.parent_path(); + std::filesystem::path base = cfg.cesFileStoreDir; + while (p != base) { + std::error_code rmec; + if (!std::filesystem::remove(p, rmec)) break; + p = p.parent_path(); + } + notifyDeletion(server, name); + noteServerZoneMutation(server, name); + } + return { CES_OK, refund }; +} + +void dispatchDelete(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + std::string name; + try { + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + const auto& cfg = reqServer(ctx)->_config(); + minx::Hash signerHash = ctx->bound.boundPubkey.getHash(); + DeleteOutcome out = deleteCore( + reqServer(ctx), name, signerHash, signerHash, // owner == signer == refund dest + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.refunded); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); +} + +// --------------------------------------------------------------------------- +// Dispatch: APPEND (owner only) - extend-write `length` bytes past +// current end. Like WRITE, but grows size, so it charges feeFileWrite +// AND hits the store-wide cap (with JIT GC fallback). +// preamble (after reqNonce): +// u32 length, u8[32] content_hash, u16 name_len, name +// body: `length` bytes +// --------------------------------------------------------------------------- + +struct AppendBodyState : std::enable_shared_from_this { + std::shared_ptr ctx; + ces::Bytes body; + uint32_t length = 0; + uint64_t oldSize = 0; + std::array contentHash{}; + std::string name; +}; + +struct AppendOutcome { uint8_t status; uint64_t balance; uint64_t size; }; + +// APPEND core: extend the file with `body`. Like WRITE plus grow: cap check + +// writeCost + upfront-rent, all from the target program account, with the +// grow-cost balance checked before the fee. +AppendOutcome appendCore(CesServer* server, const std::string& name, + const ces::Bytes& body, const minx::Hash& caller, + const L2Billing& bill) { + uint32_t length = static_cast(body.size()); + if (length == 0 || length > kMaxWriteLen) return { CES_ERROR_BAD_INPUT, 0, 0 }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0, 0 }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, 0, 0 }; + writeSidecar(sPath, sc); + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0, 0 }; + if (length > kMaxFileSize - sc.size) return { CES_ERROR_BAD_INPUT, 0, 0 }; + + const bool serverZone = isServerZone(name); + if (!serverZone) { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + uint8_t capRc = checkCapAndMaybeGc(server, cfg.cesFileStoreDir, cfg.cesFileStoreMaxBytes, + cfg.feeFileRent, length); + if (capRc != CES_OK) return { capRc, 0, 0 }; + } + uint64_t writeCost = serverZone ? 0 : kbCeil(length) * uint64_t(cfg.feeFileWrite); + uint64_t upfront = serverZone ? 0 : computeOwedRent(length, cfg.feeFileRent, 0, kGcDebounceUs); + uint64_t oldSize = sc.size; + minx::Hash prog{}; + std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + + uint8_t status = CES_ERROR_INTERNAL; + bool duplicate = false; + uint64_t newBal = 0; + server->_l2Transact([&](ces::LedgerTxn& t) { + uint64_t totalDebit = writeCost + upfront; + if (totalDebit > 0 && t.balance(prog) < static_cast(totalDebit)) { + status = CES_ERROR_INSUFFICIENT_BALANCE; return; // before fee + } + L2ChargeResult c = bill(t, static_cast(cfg.feeQuery)); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { duplicate = true; status = CES_OK; newBal = static_cast(t.balance(prog)); return; } + if (totalDebit > 0) t.debitAccount(prog, totalDebit); + newBal = static_cast(t.balance(prog)); + status = CES_OK; + }); + if (status != CES_OK) return { status, 0, 0 }; + if (duplicate) { + Sidecar scd{}; + if (!readSidecar(sPath, scd)) return { CES_ERROR_INTERNAL, 0, 0 }; + return { CES_OK, newBal, scd.size }; + } + FILE* fp = std::fopen(cPath.string().c_str(), "rb+"); + if (!fp) return { CES_ERROR_INTERNAL, 0, 0 }; + if (std::fseek(fp, static_cast(oldSize), SEEK_SET) != 0) { + std::fclose(fp); return { CES_ERROR_INTERNAL, 0, 0 }; + } + size_t wrote = std::fwrite(body.data(), 1, body.size(), fp); + std::fflush(fp); std::fclose(fp); + if (wrote != body.size()) return { CES_ERROR_INTERNAL, 0, 0 }; + Sidecar sc2{}; + if (!readSidecar(sPath, sc2)) return { CES_ERROR_INTERNAL, 0, 0 }; + sc2.size = oldSize + length; + sc2.modified_us = getMicrosSinceEpoch(); + sc2.program_hash.fill(0); + if (!writeSidecar(sPath, sc2)) return { CES_ERROR_INTERNAL, 0, 0 }; + if (!serverZone) { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(cfg.cesFileStoreDir, 0, static_cast(length)); + } + return { CES_OK, newBal, sc2.size }; +} + +void dispatchAppend(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint32_t length = 0; + std::array contentHash{}; + std::string name; + try { + length = buf.get(); + contentHash = buf.get>(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndClose(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndClose(ctx, CES_ERROR_BAD_INPUT); return; + } + if (length == 0 || length > kMaxWriteLen) { + sendErrorAndClose(ctx, CES_ERROR_BAD_INPUT); return; + } + ces::PublicKey signer = ctx->bound.boundPubkey; + uint32_t reqNonce = ctx->reqNonce; + uint64_t sigHash = ctx->reqSigHash; + int64_t errFee = static_cast(reqServer(ctx)->_config().getFeeError()); + auto body = std::make_shared(length); + + boost::asio::async_read( + *ctx->stream, boost::asio::buffer(*body), + [ctx, contentHash, name, signer, reqNonce, sigHash, errFee, body] + (const boost::system::error_code& ec, std::size_t) { + if (ec) return; + minx::Hash got = ces::sha256(body->data(), body->size()); + if (std::memcmp(got.data(), contentHash.data(), 32) != 0) { + sendErrorAndLoop(ctx, CES_ERROR_INTERNAL); return; + } + AppendOutcome out = appendCore( + reqServer(ctx), name, *body, signer.getHash(), + signerBilling(signer, reqNonce, sigHash, errFee)); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.balance); + ces::Buffer::put(resp, out.size); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); + }); +} + +// --------------------------------------------------------------------------- +// Dispatch: RESIZE (owner only) - change file's logical size. Sparse +// on grow (ftruncate, no bytes transferred; same as CREATE's sparse +// allocation - no feeFileWrite charge). Truncates tail on shrink. +// Cap check fires only on growth. +// preamble (after reqNonce): +// u64 new_size, u16 name_len, name +// --------------------------------------------------------------------------- + +struct ResizeOutcome { uint8_t status; uint64_t size; uint64_t balance; }; + +// RESIZE core: grow (cap + upfront burn from the target program account) or +// shrink. No body. Off-strand authz; fee + upfront fold into one transaction, +// balance checked before the fee. +ResizeOutcome resizeCore(CesServer* server, const std::string& name, + uint64_t newSize, const minx::Hash& caller, + const L2Billing& bill) { + if (newSize == 0 || newSize > kMaxFileSize) return { CES_ERROR_BAD_INPUT, 0, 0 }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0, 0 }; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return { CES_ERROR_FILE_NOT_FOUND, 0, 0 }; + writeSidecar(sPath, sc); + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0, 0 }; + + int64_t bytesDelta = static_cast(newSize) - static_cast(sc.size); + const bool serverZone = isServerZone(name); + uint64_t upfront = 0; + if (bytesDelta > 0 && !serverZone) { + { std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + uint8_t capRc = checkCapAndMaybeGc(server, cfg.cesFileStoreDir, cfg.cesFileStoreMaxBytes, + cfg.feeFileRent, static_cast(bytesDelta)); + if (capRc != CES_OK) return { capRc, 0, 0 }; } + upfront = computeOwedRent(static_cast(bytesDelta), cfg.feeFileRent, 0, kGcDebounceUs); + } + minx::Hash prog{}; + std::memcpy(prog.data(), sc.program_pubkey.data(), 32); + + uint8_t status = CES_ERROR_INTERNAL; + bool duplicate = false; + uint64_t bal = 0; + server->_l2Transact([&](ces::LedgerTxn& t) { + if (upfront > 0 && t.balance(prog) < static_cast(upfront)) { + status = CES_ERROR_INSUFFICIENT_BALANCE; return; // before fee + } + L2ChargeResult c = bill(t, static_cast(cfg.feeQuery)); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { duplicate = true; status = CES_OK; bal = static_cast(t.balance(prog)); return; } + if (upfront > 0) t.debitAccount(prog, upfront); + bal = static_cast(t.balance(prog)); + status = CES_OK; + }); + if (status != CES_OK) return { status, 0, 0 }; + if (duplicate) { + Sidecar scd{}; + if (!readSidecar(sPath, scd)) return { CES_ERROR_INTERNAL, 0, 0 }; + return { CES_OK, scd.size, bal }; + } + std::error_code ec; + std::filesystem::resize_file(cPath, newSize, ec); + if (ec) return { CES_ERROR_INTERNAL, 0, 0 }; + Sidecar sc2{}; + if (!readSidecar(sPath, sc2)) return { CES_ERROR_INTERNAL, 0, 0 }; + sc2.size = newSize; + sc2.modified_us = getMicrosSinceEpoch(); + sc2.program_hash.fill(0); + if (!writeSidecar(sPath, sc2)) return { CES_ERROR_INTERNAL, 0, 0 }; + if (bytesDelta != 0 && !serverZone) { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(cfg.cesFileStoreDir, 0, bytesDelta); + } + return { CES_OK, sc2.size, bal }; +} + +void dispatchResize(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t newSize = 0; + std::string name; + try { + newSize = buf.get(); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + const auto& cfg = reqServer(ctx)->_config(); + ResizeOutcome out = resizeCore( + reqServer(ctx), name, newSize, ctx->bound.boundPubkey.getHash(), + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.size); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); +} + +// --------------------------------------------------------------------------- +// kv-file cores. Billing differs from the flat cores: a kv cell's RENT is NOT +// rolled per-op. It is charged only by the daily sweep (sweepKvRent, run from +// CesServer::dailyTaskTick), which charges every cell its per-byte rent and +// erases zero-balance keys. A PUT pays the per-KB write cost (feeFileWrite; 0 in +// the /s/ zone) plus feeQuery from the bound signer, credits any deposit into the +// cell balance and the file's program account, and on growth runs the cap/GC +// check (checkCapAndMaybeGc) -- there is no 15-min upfront rent on kv puts. +// Logical size lives in the sidecar (size). The logkv store is accessed under the +// kv cache mutex; the ledger transaction is taken while holding it (no path takes +// the store-meta mutex then the kv mutex, so the brief store-meta acquisitions +// here cannot deadlock). +// --------------------------------------------------------------------------- + +struct KvPutOutcome { uint8_t status; uint64_t balance; uint64_t size; }; +struct KvGetOutcome { uint8_t status; ces::Bytes value; bool found; }; +struct KvEraseOutcome { uint8_t status; uint64_t size; }; +struct KvIterOutcome { uint8_t status; std::vector keys; }; +struct KvDepositOutcome { uint8_t status; uint64_t balance; }; +struct KvRangeOutcome { uint8_t status; ces::Bytes effectiveHi; + std::vector keys; + std::vector values; }; + +// Update a kv store's sidecar `size` -- its estimated disk use (live bytes + +// current events log, floored) -- and the matching global store meta, but only +// when that estimate has moved by at least the floor since the last write, so +// ordinary ops write no sidecar. reservedBytes caches the last-written size +// (seeded from the sidecar on first touch) so the check needs no sidecar read +// and stays equal to the sidecar (global total == sum of sidecar sizes). Caller +// holds the kv mutex. +void kvRefreshQuotaLocked(CesServer* server, const std::string& dir, + const std::string& name, KvStore* st) { + auto& cache = *server->fileHandler()->kv_; + std::string key = resolveContentPath(dir, name).string(); + auto sPath = resolveSidecarPath(dir, name); + + auto rit = cache.reservedBytes.find(key); + Sidecar sc{}; + bool haveSc = false; + uint64_t reserved; + if (rit == cache.reservedBytes.end()) { + // First touch since open: seed the reservation from the persisted sidecar. + if (!readSidecar(sPath, sc)) return; + haveSc = true; + reserved = sc.size; + cache.reservedBytes[key] = reserved; + } else { + reserved = rit->second; + } + + uint64_t live = cache.liveBytes[key]; + uint64_t estDisk = + std::max(kKvCompactFloorBytes, live + st->getEventsFileSize()); + uint64_t diff = estDisk > reserved ? estDisk - reserved : reserved - estDisk; + if (diff < kKvCompactFloorBytes) return; // immaterial: no write + + if (!haveSc && !readSidecar(sPath, sc)) return; + sc.size = estDisk; + sc.modified_us = getMicrosSinceEpoch(); + if (!writeSidecar(sPath, sc)) return; + if (!isServerZone(name)) { + std::lock_guard mlk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(dir, 0, + static_cast(estDisk) - static_cast(reserved)); + } + cache.reservedBytes[key] = estDisk; +} + +// After a kv mutation: update the live byte counter (sizeDelta bytes, 0 for a +// deposit that only rewrites the fixed header), compact the events log if it has +// outgrown the live data, then update the sidecar disk-size estimate. Returns +// the new live size. Caller holds the kv mutex. +uint64_t kvOnMutationLocked(CesServer* server, const std::string& dir, + const std::string& name, KvStore* st, int64_t sizeDelta) { + auto& cache = *server->fileHandler()->kv_; + std::string key = resolveContentPath(dir, name).string(); + uint64_t& lb = cache.liveBytes[key]; + if (sizeDelta < 0) { + uint64_t d = static_cast(-sizeDelta); + lb = lb > d ? lb - d : 0; + } else { + lb += static_cast(sizeDelta); + } + uint64_t logSize = st->getEventsFileSize(); + if (logSize > kKvCompactFloorBytes && logSize > lb) + st->save(logkv::StoreSaveMode::syncSave); + uint64_t live = lb; + kvRefreshQuotaLocked(server, dir, name, st); + return live; +} + +// Store (or overwrite) a key's record, adding `deposit` to the cell's prepaid +// rent (burned to the server, recorded in the cell header). No whole-store rent +// here; the daily sweep charges rent. The funder pays a one-time write cost. An +// overwrite keeps the existing cell balance and last-charged stamp, so funding +// survives content updates. +KvPutOutcome kvPutCore(CesServer* server, const std::string& name, + const ces::Bytes& key, const ces::Bytes& value, + const minx::Hash& caller, uint64_t deposit, + const L2Billing& bill) { + if (key.empty() || key.size() > kMaxKvKeyLen) return { CES_ERROR_BAD_INPUT, 0, 0 }; + if (value.empty() || value.size() > kMaxWriteLen) return { CES_ERROR_BAD_INPUT, 0, 0 }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0, 0 }; + if (sc.type != kFileTypeKv) return { CES_ERROR_BAD_INPUT, 0, 0 }; + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0, 0 }; + + const bool serverZone = isServerZone(name); + logkv::Bytes k = toLogkvBytes(key); + uint64_t now = getMicrosSinceEpoch(); + + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + KvStore* st = kvStoreOpenLocked(server, cfg.cesFileStoreDir, name); + if (!st) return { CES_ERROR_INTERNAL, 0, 0 }; + + uint64_t cellBalance = 0, lastCharged = now, oldStored = 0; + { auto it = st->find(k); + if (it != st->end()) { + KvCell c{}; + if (kvCellParse(it->second, c)) { cellBalance = c.balance; lastCharged = c.lastChargedUs; } + oldStored = key.size() + it->second.size(); + } } + uint64_t newStored = key.size() + kKvHeaderLen + value.size(); + int64_t storedDelta = static_cast(newStored) - static_cast(oldStored); + uint64_t grow = storedDelta > 0 ? static_cast(storedDelta) : 0; + + if (grow > 0 && !serverZone) { + std::lock_guard mlk(server->fileHandler()->storeMetaMutex_); + uint8_t capRc = checkCapAndMaybeGc(server, cfg.cesFileStoreDir, cfg.cesFileStoreMaxBytes, + cfg.feeFileRent, grow); + if (capRc != CES_OK) return { capRc, 0, 0 }; + } + uint64_t writeCost = serverZone ? 0 : kbCeil(value.size()) * uint64_t(cfg.feeFileWrite); + + uint8_t status = CES_ERROR_INTERNAL; + bool duplicate = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult c = bill(t, static_cast(writeCost + deposit)); + if (c.rc != CES_OK) { status = c.rc; return; } + if (c.duplicate) { duplicate = true; status = CES_OK; return; } + // The deposit is burned here (charged via bill, never credited to any + // account), exactly like writeCost; the cell header below holds it as + // prepaid rent. + status = CES_OK; + }); + if (status != CES_OK) return { status, 0, 0 }; + if (duplicate) return { CES_OK, cellBalance, sc.size }; + + cellBalance += deposit; + logkv::Bytes nv = kvCellBuild(cellBalance, lastCharged, + reinterpret_cast(value.data()), value.size()); + st->update(k, nv); + st->flush(true); + uint64_t live = + kvOnMutationLocked(server, cfg.cesFileStoreDir, name, st, storedDelta); + return { CES_OK, cellBalance, live }; +} + +// Add `amount` to an existing key's prepaid rent. Any signer; no owner check. +// The amount is burned (charged to the signer, credited nowhere) and recorded +// in the cell header. The key must already exist. +KvDepositOutcome kvDepositCore(CesServer* server, const std::string& name, + const ces::Bytes& key, uint64_t amount, + const L2Billing& bill) { + if (key.empty() || key.size() > kMaxKvKeyLen) return { CES_ERROR_BAD_INPUT, 0 }; + if (amount == 0) return { CES_ERROR_BAD_INPUT, 0 }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + if (sc.type != kFileTypeKv) return { CES_ERROR_BAD_INPUT, 0 }; + logkv::Bytes k = toLogkvBytes(key); + + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + KvStore* st = kvStoreOpenLocked(server, cfg.cesFileStoreDir, name); + if (!st) return { CES_ERROR_INTERNAL, 0 }; + auto it = st->find(k); + if (it == st->end()) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + KvCell c{}; + if (!kvCellParse(it->second, c)) return { CES_ERROR_INTERNAL, 0 }; + // Copy the user bytes before the update (c.user points into the stored value). + logkv::Bytes nv = kvCellBuild(c.balance + amount, c.lastChargedUs, c.user, c.userLen); + + uint8_t status = CES_ERROR_INTERNAL; + bool duplicate = false; + server->_l2Transact([&](ces::LedgerTxn& t) { + L2ChargeResult ch = bill(t, static_cast(amount)); + if (ch.rc != CES_OK) { status = ch.rc; return; } + if (ch.duplicate) { duplicate = true; status = CES_OK; return; } + // Burned, not credited: the cell's prepaid-rent counter (updated below) is + // the only record; there is no program account to hold it. + status = CES_OK; + }); + if (status != CES_OK) return { status, 0 }; + if (duplicate) return { CES_OK, c.balance }; + + st->update(k, nv); + st->flush(true); + // A deposit rewrites only the fixed header, so the live size is unchanged; + // still check compaction, since the rewrite appended to the events log. + kvOnMutationLocked(server, cfg.cesFileStoreDir, name, st, 0); + return { CES_OK, c.balance + amount }; +} + +KvGetOutcome kvGetCore(CesServer* server, const std::string& name, + const ces::Bytes& key, const L2Billing& bill) { + (void)bill; // reads are free at the kv layer (bandwidth is billed at the channel) + if (key.empty() || key.size() > kMaxKvKeyLen) return { CES_ERROR_BAD_INPUT, {}, false }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, {}, false }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, {}, false }; + if (sc.type != kFileTypeKv) return { CES_ERROR_BAD_INPUT, {}, false }; + + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + KvStore* st = kvStoreOpenLocked(server, cfg.cesFileStoreDir, name); + if (!st) return { CES_ERROR_INTERNAL, {}, false }; + auto it = st->find(toLogkvBytes(key)); + if (it == st->end()) return { CES_OK, {}, false }; + KvCell c{}; + if (!kvCellParse(it->second, c)) return { CES_ERROR_INTERNAL, {}, false }; + ces::Bytes user(reinterpret_cast(c.user), + reinterpret_cast(c.user) + c.userLen); + return { CES_OK, std::move(user), true }; +} + +KvEraseOutcome kvEraseCore(CesServer* server, const std::string& name, + const ces::Bytes& key, const minx::Hash& caller, + const L2Billing& bill) { + (void)bill; // erase is free at the kv layer + if (key.empty() || key.size() > kMaxKvKeyLen) return { CES_ERROR_BAD_INPUT, 0 }; + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, 0 }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, 0 }; + if (sc.type != kFileTypeKv) return { CES_ERROR_BAD_INPUT, 0 }; + if (std::memcmp(sc.owner_pubkey.data(), caller.data(), 32) != 0) + return { CES_ERROR_NOT_OWNER, 0 }; + uint64_t live = 0; + { + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + KvStore* st = kvStoreOpenLocked(server, cfg.cesFileStoreDir, name); + if (!st) return { CES_ERROR_INTERNAL, 0 }; + logkv::Bytes k = toLogkvBytes(key); + auto it = st->find(k); + if (it != st->end()) { + uint64_t oldStored = key.size() + it->second.size(); + st->erase(k); st->flush(true); + live = kvOnMutationLocked(server, cfg.cesFileStoreDir, name, st, + -static_cast(oldStored)); + } else { + live = server->fileHandler()->kv_->liveBytes[ + resolveContentPath(cfg.cesFileStoreDir, name).string()]; + } + } + // The cell's prepaid rent was burned at deposit, so erase forfeits nothing to + // reclaim; kvOnMutationLocked updates the live counter and the sidecar size. + return { CES_OK, live }; +} + +KvIterOutcome kvIterCore(CesServer* server, const std::string& name, + const L2Billing& bill) { + (void)bill; // iteration is free at the kv layer + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, {} }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, {} }; + if (sc.type != kFileTypeKv) return { CES_ERROR_BAD_INPUT, {} }; + + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + KvStore* st = kvStoreOpenLocked(server, cfg.cesFileStoreDir, name); + if (!st) return { CES_ERROR_INTERNAL, {} }; + std::vector keys; + for (auto& kv : st->getObjects()) keys.push_back(fromLogkvBytes(kv.first)); + return { CES_OK, std::move(keys) }; +} + +// Ordered range scan: return sorted (key, value) pairs in [lo, hi) up to a byte +// budget, with the effective upper bound covered. lo empty = start of store; +// hi empty = end of store. effectiveHi == hi means the whole range was +// delivered; effectiveHi < hi is the next undelivered key (resume point), so +// the caller continues with range(effectiveHi, hi). At least one in-range pair +// is always returned so a lone oversized value cannot stall a scan. Read-only, +// unbilled at the kv layer (the channel meters the bytes). +KvRangeOutcome kvRangeCore(CesServer* server, const std::string& name, + const ces::Bytes& lo, const ces::Bytes& hi, + uint64_t maxBytes) { + uint8_t rc = validateCesFileName(name); + if (rc != CES_OK) return { rc, {}, {}, {} }; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return { CES_ERROR_FILE_NOT_FOUND, {}, {}, {} }; + if (sc.type != kFileTypeKv) return { CES_ERROR_BAD_INPUT, {}, {}, {} }; + + if (maxBytes == 0 || maxBytes > kKvRangeMaxBytes) maxBytes = kKvRangeMaxBytes; + const bool hasHi = !hi.empty(); + logkv::Bytes hiB = toLogkvBytes(hi); + + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + KvStore* st = kvStoreOpenLocked(server, cfg.cesFileStoreDir, name); + if (!st) return { CES_ERROR_INTERNAL, {}, {}, {} }; + auto& m = st->getObjects(); + + KvRangeOutcome out{}; out.status = CES_OK; + out.effectiveHi = hi; // default: whole range delivered + uint64_t used = 0; + auto less = m.key_comp(); // the map's unsigned-byte order + auto it = lo.empty() ? m.begin() : m.lower_bound(toLogkvBytes(lo)); + for (; it != m.end(); ++it) { + if (hasHi && !less(it->first, hiB)) break; // reached hi (same order as the map) + KvCell c{}; + if (!kvCellParse(it->second, c)) continue; + uint64_t entryBytes = it->first.size() + c.userLen; + if (!out.keys.empty() && used + entryBytes > maxBytes) { + out.effectiveHi = fromLogkvBytes(it->first); // truncated: resume here + return out; + } + out.keys.push_back(fromLogkvBytes(it->first)); + out.values.emplace_back(reinterpret_cast(c.user), + reinterpret_cast(c.user) + c.userLen); + used += entryBytes; + } + return out; // ran to hi or end of store +} + +// Wire KV_DEPOSIT: a bound signer funds a key in a kv-store directly. Any +// signer, no owner check. Preamble: u64 amount, u16 key_len, key, u16 name_len, +// name. Defined here, after kvDepositCore, so KvDepositOutcome is in scope; the +// serve switch reaches it through the forward declaration above. +void dispatchKvDeposit(std::shared_ptr ctx, ces::Bytes pre) { + ces::Buffer buf(std::move(pre)); + uint64_t amount = 0; + ces::Bytes key; + std::string name; + try { + amount = buf.get(); + uint16_t klen = buf.get(); + if (klen == 0 || klen > kMaxKvKeyLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + key = buf.getBytes(klen); + uint16_t nameLen = buf.get(); + if (nameLen == 0 || nameLen > kMaxNameLen) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_NAME); return; + } + name = buf.getBytes(nameLen); + } catch (const std::out_of_range&) { + sendErrorAndLoop(ctx, CES_ERROR_BAD_INPUT); return; + } + const auto& cfg = reqServer(ctx)->_config(); + // kvDepositCore touches logkv, which throws on a disk error. The CesPlex + // dispatch path does not catch, so contain it here (other file dispatchers + // use error codes and never throw). + KvDepositOutcome out{}; + try { + out = kvDepositCore( + reqServer(ctx), name, key, amount, + signerBilling(ctx->bound.boundPubkey, ctx->reqNonce, ctx->reqSigHash, + static_cast(cfg.getFeeError()))); + } catch (...) { + sendErrorAndLoop(ctx, CES_ERROR_INTERNAL); return; + } + if (out.status != CES_OK) { sendErrorAndLoop(ctx, out.status); return; } + ces::Bytes resp; + ces::Buffer::put(resp, out.balance); + sendResponseAndLoop(ctx, CES_OK, std::move(resp)); +} + +// --------------------------------------------------------------------------- +// Rent + reconciliation (called from CesServer) +// --------------------------------------------------------------------------- + +void walkFileStore(const std::filesystem::path& root, + const std::function& visitor) { + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::exists(root, ec)) return; + const std::string suffix = kSidecarSuffix; + for (auto it = fs::recursive_directory_iterator(root, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (!it->is_regular_file()) continue; + auto p = it->path(); + std::string fn = p.filename().string(); + if (fn.size() <= suffix.size()) continue; + if (fn.compare(fn.size() - suffix.size(), suffix.size(), suffix) != 0) + continue; + Sidecar s{}; + if (!readSidecar(p, s)) continue; + // Content path = sidecar path minus the ".sidecar.toml" suffix. + auto contentPath = p; + contentPath.replace_filename(fn.substr(0, fn.size() - suffix.size())); + visitor(p, contentPath, s); + } +} + +uint64_t gcReclaim(CesServer* server, const std::string& dir, int64_t feeRent, + uint64_t bytesNeeded) { + uint64_t reclaimed = 0; + int64_t files_delta = 0; + int64_t bytes_delta = 0; + uint64_t now = getMicrosSinceEpoch(); + walkFileStore(std::filesystem::path(dir), + [&](const std::filesystem::path& sp, + const std::filesystem::path& cp, Sidecar& s) { + if (bytesNeeded > 0 && reclaimed >= bytesNeeded) return; + // /s/ files are exempt from rent-based GC - unmetered by + // definition. + if (isServerZone(s.name)) return; + uint64_t owed = computeOwedRent( + s.size, feeRent, s.last_rent_us, now); + uint64_t bal = server ? readProgramAccountBalance(server, s) : 0; + if (owed > bal) { + // Dead. Delete content + sidecar. Best-effort rmdir of + // empty parents. + std::error_code ec; + if (s.type == kFileTypeKv) std::filesystem::remove_all(cp, ec); + else std::filesystem::remove(cp, ec); + std::filesystem::remove(sp, ec); + std::filesystem::path p = cp.parent_path(); + std::filesystem::path base = dir; + while (p != base) { + std::error_code rmec; + if (!std::filesystem::remove(p, rmec)) break; + p = p.parent_path(); + } + reclaimed += s.size; + files_delta -= 1; + bytes_delta -= static_cast(s.size); + notifyDeletion(server, s.name); + } + // Live files: leave alone. Their rent advances on next touch. + }); + if (files_delta != 0 || bytes_delta != 0) + adjustStoreMeta(dir, files_delta, bytes_delta); + return reclaimed; +} + +uint8_t checkCapAndMaybeGc(CesServer* server, + const std::string& dir, + uint64_t cap, + int64_t feeRent, + uint64_t addBytes) { + StoreMeta m; + auto metaP = storeMetaPath(dir); + readStoreMeta(metaP, m); + if (m.total_bytes + addBytes <= cap) return CES_OK; + uint64_t now = getMicrosSinceEpoch(); + if (m.last_gc_us != 0 && now - m.last_gc_us < kGcDebounceUs) { + return CES_ERROR_STORE_FULL; + } + uint64_t need = (m.total_bytes + addBytes) - cap; + uint64_t reclaimed = gcReclaim(server, dir, feeRent, need); + readStoreMeta(metaP, m); + m.last_gc_us = now; + writeStoreMeta(metaP, m); + LOGINFO << "file-store: JIT GC" << VAR(need) << VAR(reclaimed); + if (m.total_bytes + addBytes > cap) return CES_ERROR_STORE_FULL; + return CES_OK; +} + +// In-process verb execution (cross-handler path). Parallel to dispatch* but +// takes an already-authorized owner pubkey: no wire I/O, sig verify, nonce, or +// dedup. Wire fees still apply, paid from req.sourceName's file_balance (the +// program's wallet), not the owner's account. Each exec* completes by calling +// cb(resp) on cbEx; the only strand hop is the /f/-zone ownership check on CREATE. + +// In-process (exec) adapters: the same verb cores as the session path, billed to +// the source file's program account instead of a network signer. The source +// charge is three parts: resolveSourceForBilling (rent-roll the source off-strand +// -> its program pubkey), sourceBilling (debit it inside the core's transaction), +// and deleteExhaustedSource (delete the source when it cannot pay). /s/ sources +// are unmetered. +uint8_t resolveSourceForBilling(CesServer* server, const std::string& sourceName, + minx::Hash& outProg, bool& outMetered) { + outMetered = false; + if (isServerZone(sourceName)) return CES_OK; // /s/ unmetered + if (sourceName.empty()) return CES_ERROR_INTERNAL; // compute must set it + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, sourceName); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, sourceName); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return CES_ERROR_FILE_NOT_FOUND; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, cfg.cesFileStoreDir)) + return CES_ERROR_FILE_NOT_FOUND; + writeSidecar(sPath, sc); + std::memcpy(outProg.data(), sc.program_pubkey.data(), 32); + outMetered = true; + return CES_OK; +} + +void deleteExhaustedSource(CesServer* server, const std::string& sourceName) { + if (isServerZone(sourceName)) return; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, sourceName); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, sourceName); + Sidecar sc{}; + uint64_t size = readSidecar(sPath, sc) ? sc.size : 0; + std::error_code ec; + std::filesystem::remove(cPath, ec); + std::filesystem::remove(sPath, ec); + std::filesystem::path p = cPath.parent_path(); + std::filesystem::path base = cfg.cesFileStoreDir; + while (p != base) { + std::error_code rmec; + if (!std::filesystem::remove(p, rmec)) break; + p = p.parent_path(); + } + { std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(cfg.cesFileStoreDir, -1, -static_cast(size)); } + notifyDeletion(server, sourceName); +} + +// The flag is set iff the SOURCE itself could not pay (so the adapter knows to +// delete the exhausted source). A target-side INSUFFICIENT leaves it clear. +inline L2Billing sourceBilling(minx::Hash sourceProg, bool metered, + bool* sourceInsufficient) { + return [sourceProg, metered, sourceInsufficient] + (ces::LedgerTxn& t, int64_t cost) -> L2ChargeResult { + if (!metered) return { CES_OK, false }; // /s/ unmetered + if (!t.debitAccount(sourceProg, static_cast(cost))) { + if (sourceInsufficient) *sourceInsufficient = true; + return { CES_ERROR_INSUFFICIENT_BALANCE, false }; + } + return { CES_OK, false }; + }; +} + +inline L2Billing noBilling() { + return [](ces::LedgerTxn&, int64_t) -> L2ChargeResult { return { CES_OK, false }; }; +} + +inline minx::Hash hashOf(const std::array& a) { + minx::Hash h{}; std::memcpy(h.data(), a.data(), 32); return h; +} + +// Resolve source, run the verb core with source billing, delete the source on +// exhaustion, post the FileExecResp. Returns false (already replied) if the +// source could not be resolved. +template +void execWithSource(CesServer* server, const FileExecReq& req, + std::function cb, + boost::asio::any_io_executor cbEx, Fn&& run) { + minx::Hash srcProg{}; bool metered = false; + uint8_t srcRc = resolveSourceForBilling(server, req.sourceName, srcProg, metered); + if (srcRc != CES_OK) { + FileExecResp r; r.status = srcRc; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + bool sourceInsufficient = false; + FileExecResp r = run(sourceBilling(srcProg, metered, &sourceInsufficient), + srcProg, metered); + // Delete the source only when the SOURCE itself ran out (not a target-side + // INSUFFICIENT) - mirrors the old FileHandler::debitBalance exhaustion-delete. + if (sourceInsufficient && metered) + deleteExhaustedSource(server, req.sourceName); + boost::asio::post(cbEx, [cb, r]() { cb(r); }); +} + +void execStat(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + // STAT is free for the in-process caller (no source charge). + StatOutcome out = statCore(server, req.name, noBilling()); + FileExecResp r; r.status = out.status; + if (out.status == CES_OK) { + r.ownerPubkey = out.owner; + r.fileBalance = out.fileBalance; + r.pricePerKb = out.price; + r.size = out.size; + r.createdUs = out.createdUs; + r.modifiedUs = out.modifiedUs; + } + boost::asio::post(cbEx, [cb, r]() { cb(r); }); +} + +void execRead(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + ReadOutcome out = readCore(server, req.name, req.offset, req.length, + hashOf(req.ownerPubkey), bill); + FileExecResp r; r.status = out.status; + if (out.status == CES_OK) r.data = std::move(out.data); + return r; + }); +} + +void execSetPrice(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + SetPriceOutcome out = setPriceCore(server, req.name, req.pricePerKb, + hashOf(req.ownerPubkey), bill); + FileExecResp r; r.status = out.status; r.pricePerKb = out.price; + return r; + }); +} + +void execWrite(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + WriteOutcome out = writeCore(server, req.name, req.offset, req.body, + hashOf(req.ownerPubkey), bill); + FileExecResp r; r.status = out.status; r.fileBalance = out.balance; + return r; + }); +} + +void execDeposit(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + DepositOutcome out = depositCore(server, req.name, req.amount, bill); + FileExecResp r; r.status = out.status; r.fileBalance = out.balance; + return r; + }); +} + +void execWithdraw(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash srcProg, bool metered) -> FileExecResp { + // Withdrawn amount returns to the source program account (burned if /s/). + std::optional dest = + metered ? std::optional(srcProg) : std::nullopt; + WithdrawOutcome out = withdrawCore(server, req.name, req.amount, + hashOf(req.ownerPubkey), dest, bill); + FileExecResp r; r.status = out.status; r.fileBalance = out.balance; + return r; + }); +} + +void execDelete(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash srcProg, bool metered) -> FileExecResp { + // Refund returns to the source program account (burned if /s/). + std::optional dest = + metered ? std::optional(srcProg) : std::nullopt; + DeleteOutcome out = deleteCore(server, req.name, hashOf(req.ownerPubkey), + dest, bill); + FileExecResp r; r.status = out.status; r.refunded = out.refunded; + return r; + }); +} + +void execResize(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + ResizeOutcome out = resizeCore(server, req.name, req.size, + hashOf(req.ownerPubkey), bill); + FileExecResp r; r.status = out.status; r.fileBalance = out.balance; + r.size = out.size; + return r; + }); +} + +void execAppend(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + AppendOutcome out = appendCore(server, req.name, req.body, + hashOf(req.ownerPubkey), bill); + FileExecResp r; r.status = out.status; r.fileBalance = out.balance; + r.size = out.size; + return r; + }); +} + +void execCreate(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + if (req.size == 0 || req.size > kMaxFileSize) { + FileExecResp r; r.status = CES_ERROR_BAD_INPUT; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + uint8_t rc = validateCesFileName(req.name); + if (rc != CES_OK) { + FileExecResp r; r.status = rc; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + std::array signerKey = req.ownerPubkey; + std::string nameCopy = req.name; + auto finish = [server, cb, cbEx, req = std::move(req)](uint8_t zoneRc) mutable { + if (zoneRc != CES_OK) { + FileExecResp r; r.status = zoneRc; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + minx::Hash srcProg{}; bool metered = false; + uint8_t srcRc = resolveSourceForBilling(server, req.sourceName, srcProg, metered); + if (srcRc != CES_OK) { + FileExecResp r; r.status = srcRc; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + bool sourceInsufficient = false; + CreateOutcome out = createCore( + server, req.name, req.size, req.pricePerKb, req.initialDeposit, + hashOf(req.ownerPubkey), + sourceBilling(srcProg, metered, &sourceInsufficient)); + if (sourceInsufficient && metered) deleteExhaustedSource(server, req.sourceName); + FileExecResp r; r.status = out.status; r.fileBalance = out.fileBalance; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); + }; + checkZoneOwnership(server, nameCopy, signerKey, cbEx, std::move(finish)); +} + +void execKvPut(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + KvPutOutcome out = kvPutCore(server, req.name, req.key, req.value, + hashOf(req.ownerPubkey), req.amount, bill); + FileExecResp r; r.status = out.status; r.fileBalance = out.balance; + r.size = out.size; return r; + }); +} + +void execKvDeposit(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + KvDepositOutcome out = kvDepositCore(server, req.name, req.key, + req.amount, bill); + FileExecResp r; r.status = out.status; r.fileBalance = out.balance; + return r; + }); +} + +void execKvGet(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + KvGetOutcome out = kvGetCore(server, req.name, req.key, bill); + FileExecResp r; r.status = out.status; r.value = std::move(out.value); + r.found = out.found; return r; + }); +} + +void execKvErase(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + KvEraseOutcome out = kvEraseCore(server, req.name, req.key, + hashOf(req.ownerPubkey), bill); + FileExecResp r; r.status = out.status; r.size = out.size; return r; + }); +} + +void execKvIter(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing bill, minx::Hash, bool) -> FileExecResp { + KvIterOutcome out = kvIterCore(server, req.name, bill); + FileExecResp r; r.status = out.status; r.keys = std::move(out.keys); return r; + }); +} + +void execKvRange(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + execWithSource(server, req, cb, cbEx, + [&](L2Billing, minx::Hash, bool) -> FileExecResp { + KvRangeOutcome out = kvRangeCore(server, req.name, req.rangeLo, + req.rangeHi, req.amount); + FileExecResp r; r.status = out.status; + r.keys = std::move(out.keys); r.values = std::move(out.values); + r.rangeEnd = std::move(out.effectiveHi); return r; + }); +} + +// kv CREATE mirrors flat CREATE: zone-check, then createCore with type=kv and +// size 0 (kv logical size starts empty and grows with PUT). +void execKvCreate(CesServer* server, FileExecReq req, + std::function cb, + boost::asio::any_io_executor cbEx) { + uint8_t rc = validateCesFileName(req.name); + if (rc != CES_OK) { + FileExecResp r; r.status = rc; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + std::array signerKey = req.ownerPubkey; + std::string nameCopy = req.name; + auto finish = [server, cb, cbEx, req = std::move(req)](uint8_t zoneRc) mutable { + if (zoneRc != CES_OK) { + FileExecResp r; r.status = zoneRc; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + minx::Hash srcProg{}; bool metered = false; + uint8_t srcRc = resolveSourceForBilling(server, req.sourceName, srcProg, metered); + if (srcRc != CES_OK) { + FileExecResp r; r.status = srcRc; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); return; + } + bool sourceInsufficient = false; + CreateOutcome out = createCore( + server, req.name, /*size=*/0, req.pricePerKb, req.initialDeposit, + hashOf(req.ownerPubkey), + sourceBilling(srcProg, metered, &sourceInsufficient), kFileTypeKv); + if (sourceInsufficient && metered) deleteExhaustedSource(server, req.sourceName); + FileExecResp r; r.status = out.status; r.fileBalance = out.fileBalance; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); + }; + checkZoneOwnership(server, nameCopy, signerKey, cbEx, std::move(finish)); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +FileHandler::FileHandler(CesServer* server) + : server_(server), kv_(std::make_unique()) {} + +FileHandler::~FileHandler() { stop(); } + +void FileHandler::stop() { + stopped_.store(true); + // Release kv handles so logkv flushes and a rebind reloads from disk. Each + // mutation already flushed, so the flush here is belt-and-braces. + std::lock_guard lk(kv_->mutex); + for (auto& [k, st] : kv_->stores) { try { st->flush(true); } catch (...) {} } + kv_->stores.clear(); +} + +void FileHandler::serve(std::shared_ptr stream, + BoundChannelContext bound) { + CesServer* server = server_; + CesPlexProtocol proto; + // accepts() also gates "still serving?" - false after stop() ends the loop. + proto.accepts = [this](uint8_t verb) { + return !stopped_.load() && + (verb == kVerbStat || (verb >= kVerbCreate && verb <= kVerbResize) || + verb == kVerbKvDeposit); + }; + proto.dispatch = [](std::shared_ptr ctx, ces::Bytes pre) { + switch (ctx->verb) { + case kVerbCreate: dispatchCreate (ctx, std::move(pre)); break; + case kVerbWrite: dispatchWrite (ctx, std::move(pre)); break; + case kVerbRead: dispatchRead (ctx, std::move(pre)); break; + case kVerbStat: dispatchStat (ctx, std::move(pre)); break; + case kVerbDeposit: dispatchDeposit (ctx, std::move(pre)); break; + case kVerbWithdraw: dispatchWithdraw (ctx, std::move(pre)); break; + case kVerbSetPrice: dispatchSetPrice (ctx, std::move(pre)); break; + case kVerbDelete: dispatchDelete (ctx, std::move(pre)); break; + case kVerbAppend: dispatchAppend (ctx, std::move(pre)); break; + case kVerbResize: dispatchResize (ctx, std::move(pre)); break; + case kVerbKvDeposit: dispatchKvDeposit(ctx, std::move(pre)); break; + default: ctx->error(CES_ERROR_BAD_INPUT); break; + } + }; + cesPlexServe(std::move(stream), std::move(bound), server, std::move(proto)); +} + +void FileHandler::sweepKvRent() { + CesServer* server = server_; + const auto& cfg = server->_config(); + const int64_t feeRent = cfg.feeFileRent; + const uint64_t now = getMicrosSinceEpoch(); + + // Collect metered kv-store names first (the walk does not hold the kv mutex). + std::vector stores; + walkFileStore(cfg.cesFileStoreDir, + [&](const std::filesystem::path&, const std::filesystem::path&, Sidecar& s) { + if (s.type == kFileTypeKv && !isServerZone(s.name)) stores.push_back(s.name); + }); + + for (const auto& name : stores) { + // logkv update/flush throw on a disk error. This runs on taskIO_, so an + // escaped throw would terminate the server; contain it per store (one bad + // store is skipped, the rest still sweep). + try { + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) continue; // GC'd between walk and now + if (sc.type != kFileTypeKv) continue; + + uint64_t liveBytes = 0; + { + std::lock_guard kvLk(server->fileHandler()->kv_->mutex); + KvStore* st = kvStoreOpenLocked(server, cfg.cesFileStoreDir, name); + if (!st) continue; + std::vector toErase; + std::vector> toUpdate; + for (auto& kv : st->getObjects()) { + KvCell c{}; + if (!kvCellParse(kv.second, c)) continue; + uint64_t bytes = kv.first.size() + kv.second.size(); + uint64_t owed = computeOwedRent(bytes, feeRent, c.lastChargedUs, now); + uint64_t charge = owed < c.balance ? owed : c.balance; + uint64_t nb = c.balance - charge; + if (nb == 0) { + toErase.push_back(kv.first); + } else { + // Copy user bytes now: c.user points into the stored value. + toUpdate.emplace_back(kv.first, kvCellBuild(nb, now, c.user, c.userLen)); + liveBytes += bytes; + } + } + for (auto& [k, v] : toUpdate) st->update(k, v); + for (auto& k : toErase) st->erase(k); + if (!toUpdate.empty() || !toErase.empty()) st->flush(true); + // The sweep iterated the whole live map, so set the live counter from + // it (correcting any drift), then compact: the sweep's rewrites have + // grown the events log; once it outgrows the live data, fold it into a + // fresh snapshot. + server->fileHandler()->kv_->liveBytes[ + resolveContentPath(cfg.cesFileStoreDir, name).string()] = liveBytes; + uint64_t logSize = st->getEventsFileSize(); + if (logSize > kKvCompactFloorBytes && logSize > liveBytes) + st->save(logkv::StoreSaveMode::syncSave); + // Rent was prepaid (burned) at deposit; the sweep only counts it down in + // each cell header and evicts at 0. No ledger debit. Update the sidecar + // disk-size estimate against the now-smaller live data. + kvRefreshQuotaLocked(server, cfg.cesFileStoreDir, name, st); + } + } catch (const std::exception& e) { + LOGDEBUG << "kv rent sweep skipped a store" << SVAR(name) << SVAR(e.what()); + } catch (...) { + LOGDEBUG << "kv rent sweep skipped a store" << SVAR(name); + } + } +} + +bool FileHandler::readProgramPubkey( + const std::string& name, + std::array& outProgramPubkey) { + CesServer* server = server_; + if (!server) return false; + const auto& cfg = server->_config(); + Sidecar sc{}; + if (!loadSidecar(server, cfg.cesFileStoreDir, name, sc)) return false; + outProgramPubkey = sc.program_pubkey; + return true; +} + +// Read the file's program-account ed25519 private key from its sidecar. +// All-zero on success means the account has no signing key (no remote +// signing). False if the sidecar is unreadable. +bool FileHandler::readProgramPrivkey( + const std::string& name, + std::array& outProgramPrivkey) { + CesServer* server = server_; + if (!server) return false; + const auto& cfg = server->_config(); + Sidecar sc{}; + if (!loadSidecar(server, cfg.cesFileStoreDir, name, sc)) return false; + outProgramPrivkey = sc.program_privkey; + return true; +} + +bool FileHandler::readOwnerAndBalance( + const std::string& name, + std::array& outOwnerPubkey, + uint64_t& outFileBalance) { + CesServer* server = server_; + if (!server) return false; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!loadSidecar(server, cfg.cesFileStoreDir, name, sc)) return false; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, + cfg.cesFileStoreDir)) { + // chargeRentOrDelete already fired notifyDeletion + removed disk. + return false; + } + if (!writeSidecar(sPath, sc)) return false; + outOwnerPubkey = sc.owner_pubkey; + outFileBalance = readProgramAccountBalance(server, sc); + return true; +} + +bool FileHandler::creditBalance(const std::string& name, uint64_t amount) { + CesServer* server = server_; + if (!server) return false; + if (amount == 0) return true; + // /s/ files are unmetered - operator donates via the reconcile-time + // top-up. Treat additional credits as a no-op; the /s/ balance is + // decorative and never consulted for billing. + if (isServerZone(name)) return true; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return false; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, + cfg.cesFileStoreDir)) { + return false; // chargeRentOrDelete already notified + } + creditProgramAccount(server, sc, amount); + sc.modified_us = getMicrosSinceEpoch(); + if (!writeSidecar(sPath, sc)) return false; + return true; +} + +bool FileHandler::getProgramHash( + const std::string& name, + std::array& outHash) { + CesServer* server = server_; + if (!server) return false; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return false; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, + cfg.cesFileStoreDir)) { + return false; + } + + // Cached? + bool cached = false; + for (auto b : sc.program_hash) if (b != 0) { cached = true; break; } + if (cached) { + if (!writeSidecar(sPath, sc)) return false; // persist rent advance + outHash = sc.program_hash; + return true; + } + + // Compute sha256(content || name) by streaming the file. + CryptoPP::SHA256 hasher; + std::ifstream f(cPath, std::ios::binary); + if (!f) return false; + std::array buf; + while (f) { + f.read(buf.data(), static_cast(buf.size())); + auto got = f.gcount(); + if (got > 0) { + hasher.Update(reinterpret_cast(buf.data()), + static_cast(got)); + } + } + if (f.bad()) return false; + hasher.Update(reinterpret_cast(name.data()), + name.size()); + std::array digest{}; + hasher.Final(reinterpret_cast(digest.data())); + + // All-zero is the "uncomputed" sentinel. A real SHA-256 is non-zero with + // overwhelming probability; if one ever comes out all-zero, treat it as a + // failure rather than storing it as the sentinel. + bool digestZero = true; + for (auto b : digest) if (b != 0) { digestZero = false; break; } + if (digestZero) return false; + + sc.program_hash = digest; + if (!writeSidecar(sPath, sc)) return false; + outHash = digest; + return true; +} + +bool FileHandler::debitBalance(const std::string& name, uint64_t amount) { + CesServer* server = server_; + if (!server) return false; + // /s/ files are unmetered - operator donates everything via the + // reconcile-time top-up. Treat any debit as a successful no-op so + // the compute supervisor tick / /s/ program ops don't accidentally + // trip the balance-exhausted deletion path. + if (isServerZone(name)) return true; + const auto& cfg = server->_config(); + auto sPath = resolveSidecarPath(cfg.cesFileStoreDir, name); + auto cPath = resolveContentPath(cfg.cesFileStoreDir, name); + Sidecar sc{}; + if (!readSidecar(sPath, sc)) return false; + if (!chargeRentOrDelete(server, cPath, sPath, sc, cfg.feeFileRent, + cfg.cesFileStoreDir)) { + return false; // chargeRentOrDelete already notified + } + + auto debit = debitProgramAccount(server, sc, amount); + bool insufficient = !debit.ok; + + if (insufficient) { + // Debit exhaustion - treat exactly like rent exhaustion: delete. + std::error_code ec; + std::filesystem::remove(cPath, ec); + std::filesystem::remove(sPath, ec); + { + std::filesystem::path p = cPath.parent_path(); + std::filesystem::path base = cfg.cesFileStoreDir; + while (p != base) { + std::error_code rmec; + if (!std::filesystem::remove(p, rmec)) break; + p = p.parent_path(); + } + } + { + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + adjustStoreMeta(cfg.cesFileStoreDir, -1, + -static_cast(sc.size)); + } + notifyDeletion(server, sc.name); + return false; + } + sc.modified_us = getMicrosSinceEpoch(); + if (!writeSidecar(sPath, sc)) return false; + return true; +} + +void FileHandler::registerDeletionCallback( + std::function cb) { + if (!cb) return; + std::lock_guard lk(deletionCallbacksMutex_); + deletionCallbacks_.push_back(std::move(cb)); +} + +void FileHandler::exec( + const FileExecReq& req, + std::function cb, + boost::asio::any_io_executor cbEx) { + CesServer* server = server_; + if (!server) { + FileExecResp r; r.status = CES_ERROR_DISABLED; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); + return; + } + // The kv cores call into logkv, which throws std::runtime_error on any + // filesystem/write error (disk full, IO fault); the synchronous exec path + // runs on rpcTaskIO, so an escaped throw would std::terminate the server. A + // catch-all here converts it into an INTERNAL reply: the verb fails, the + // server lives. The flat cores never throw past their own narrow catches, so + // this is the real guard for the kv verbs. + try { + switch (req.verb) { + case kVerbCreate: execCreate (server, req, cb, cbEx); return; + case kVerbWrite: execWrite (server, req, cb, cbEx); return; + case kVerbRead: execRead (server, req, cb, cbEx); return; + case kVerbStat: execStat (server, req, cb, cbEx); return; + case kVerbDeposit: execDeposit (server, req, cb, cbEx); return; + case kVerbWithdraw: execWithdraw(server, req, cb, cbEx); return; + case kVerbSetPrice: execSetPrice(server, req, cb, cbEx); return; + case kVerbDelete: execDelete (server, req, cb, cbEx); return; + case kVerbAppend: execAppend (server, req, cb, cbEx); return; + case kVerbResize: execResize (server, req, cb, cbEx); return; + case kVerbKvCreate: execKvCreate(server, req, cb, cbEx); return; + case kVerbKvPut: execKvPut (server, req, cb, cbEx); return; + case kVerbKvGet: execKvGet (server, req, cb, cbEx); return; + case kVerbKvErase: execKvErase (server, req, cb, cbEx); return; + case kVerbKvIter: execKvIter (server, req, cb, cbEx); return; + case kVerbKvDeposit: execKvDeposit(server, req, cb, cbEx); return; + case kVerbKvRange: execKvRange (server, req, cb, cbEx); return; + default: break; + } + } catch (const std::exception& e) { + LOGDEBUG << "exec threw" << VAR((int)req.verb) << SVAR(e.what()); + } catch (...) { + LOGDEBUG << "exec threw (non-std)" << VAR((int)req.verb); + } + FileExecResp r; r.status = CES_ERROR_INTERNAL; + boost::asio::post(cbEx, [cb, r]() { cb(r); }); +} + + +// Walk /s/ and ensure every regular non-sidecar file has a +// well-formed sidecar pointing at the server's pubkey. Handles two cases: +// * sidecar missing entirely (operator dropped a fresh file) +// * sidecar present but stale (size changed, owner not server, or +// parse-failure). Rewritten in place; /s/ is unmetered so there's no +// balance to preserve. +// Already-correct sidecars are left alone (no atomic rewrite churn). +// +// Each /s/ file gets a dedicated program account in accountStore_, with a +// fresh ed25519 keypair minted on first reconcile and preserved across +// reboots (the keypair stays in the sidecar). Every reconcile pass tops the +// program account up to the default; the operator funds the program's bankroll. +void reconcileServerZone(CesServer* server, const std::string& dir) { + namespace fs = std::filesystem; + fs::path sRoot = fs::path(dir) / "s"; + std::error_code ec; + if (!fs::exists(sRoot, ec)) return; + + const std::string suffix = kSidecarSuffix; + for (auto it = fs::recursive_directory_iterator(sRoot, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (!it->is_regular_file()) continue; + auto cPath = it->path(); + std::string fn = cPath.filename().string(); + // Skip sidecars themselves. + if (fn.size() >= suffix.size() && + fn.compare(fn.size() - suffix.size(), suffix.size(), suffix) == 0) + continue; + + // Compute the canonical CES name: "/s/". it->path() is absolute; + // strip the dir prefix and prepend "/". + fs::path rel = fs::relative(cPath, fs::path(dir)); + std::string name = "/" + rel.generic_string(); + // The generated /s/ catalog and the bundled welcome site are static server + // content (seeded with their own sidecars elsewhere) - never mint them a + // program account. + if (name == kServerIndexName) continue; + if (isBuiltinSitePath(name)) continue; + + // Same per-file mint the on-access lazy path uses: one mechanism, two + // callers. Boot eagerly walks the whole zone so already-deployed programs + // are stamped and topped up before launchExtensions runs. + Sidecar s{}; + reconcileOneServerZoneFile(server, dir, name, s); + } +} + +// Daily per-extension local-budget sweep: top every /s/ program account up to the +// server's extLocalBudget (deficit only). Runs off logicStrand_ (the top-up +// sync-hops to it), called from dailyTaskTick. No-op if the file feature or the +// budget is off. +void FileHandler::sweepExtensionBudget() { + CesServer* server = server_; + const auto& cfg = server->_config(); + // No cap gate: /s/ is unmetered and present whenever file is mounted (this + // is a FileHandler method, so the handler exists), even at cap 0. The only + // gate is the budget itself. + if (server->extLocalBudget() == 0) return; + namespace fs = std::filesystem; + const std::string& dir = cfg.cesFileStoreDir; + fs::path sRoot = fs::path(dir) / "s"; + std::error_code ec; + if (!fs::exists(sRoot, ec)) return; + const std::string suffix = kSidecarSuffix; + size_t topped = 0; + for (auto it = fs::recursive_directory_iterator(sRoot, ec); + !ec && it != fs::recursive_directory_iterator(); it.increment(ec)) { + if (!it->is_regular_file()) continue; + std::string fn = it->path().filename().string(); + if (fn.size() >= suffix.size() && + fn.compare(fn.size() - suffix.size(), suffix.size(), suffix) == 0) continue; + fs::path rel = fs::relative(it->path(), fs::path(dir)); + std::string name = "/" + rel.generic_string(); + if (name == kServerIndexName || isBuiltinSitePath(name)) continue; + Sidecar sc{}; + if (!loadSidecar(server, dir, name, sc)) continue; + if (topUpProgramAccountToCap(server, sc)) ++topped; + } + LOGINFO << "extension budget swept" << VAR(topped); +} + +// Publish the bundled /s/welcome site into the file store. Forced by the file +// feature (no switch) and overwritten on every boot, so the served demo always +// matches the binary. Pure disk on the file/rpc strand (like +// regenerateServerIndex): no verbs, no logicStrand_, no program account. Each +// file gets a minimal server-owned sidecar; reconcileServerZone skips these +// names (isBuiltinSitePath), so they're never minted one. +void seedBuiltinSite(CesServer* server, const std::string& dir) { + if (!server) return; + namespace fs = std::filesystem; + std::array serverPk{}; + std::memcpy(serverPk.data(), + server->_serverKeyPair().getPublicKeyAsHash().data(), 32); + for (const auto& f : builtinSiteFiles()) { + const std::string name = "/s/" + f.relPath; + std::error_code ec; + fs::path cPath = resolveContentPath(dir, name); + fs::create_directories(cPath.parent_path(), ec); + fs::path tmp = cPath; tmp += ".tmp"; + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) { LOGWARNING << "/s/ site open failed" << SVAR(name); continue; } + out.write(f.content.data(), static_cast(f.content.size())); + if (!out.good()) { LOGWARNING << "/s/ site write failed" << SVAR(name); continue; } + } + fs::rename(tmp, cPath, ec); + if (ec) { LOGWARNING << "/s/ site rename failed" << SVAR(name); fs::remove(tmp, ec); continue; } + + Sidecar s{}; + s.version = kSidecarVersion; + s.name = name; + s.owner_pubkey = serverPk; + s.price_per_kb = 0; + s.size = f.content.size(); + s.created_us = getMicrosSinceEpoch(); + s.modified_us = s.created_us; + s.last_rent_us = s.created_us; + writeSidecar(resolveSidecarPath(dir, name), s); + } + LOGDEBUG << "/s/welcome site published"; +} + +void FileHandler::startupReconcile() { + CesServer* server = server_; + if (!server) return; + const std::string& dir = server->_config().cesFileStoreDir; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + // Publish the bundled /s/welcome site (forced with the file feature), then + // auto-generate sidecars for any /s/ files the operator dropped on disk. + seedBuiltinSite(server, dir); + reconcileServerZone(server, dir); + // (Re)generate the /s/ catalog at boot so it reflects the seeded site plus + // whatever the operator dropped. Runtime /s/ changes keep it current after. + regenerateServerIndex(server, dir); + StoreMeta m{}; + walkFileStore(dir, [&m](const std::filesystem::path&, + const std::filesystem::path&, Sidecar& s) { + // /s/ files live outside the cap - don't count them. + if (isServerZone(s.name)) return; + m.total_files += 1; + m.total_bytes += s.size; + }); + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + writeStoreMeta(storeMetaPath(dir), m); + LOGINFO << "store reconciled" + << VAR(m.total_files) << VAR(m.total_bytes); +} + +// --- /s/ file read/write/remove for L2 cross-handler use (the extension manager) - +// A /s/ file is content-on-disk plus a reconcile-stamped sidecar. The write is +// atomic and re-stamps the sidecar; it runs off logicStrand_ (its reconcile +// sync-hops to it), like the boot scan. + +std::string FileHandler::readServerFile(const std::string& name) { + CesServer* server = server_; + if (!server || !isServerZone(name)) return ""; + std::ifstream f(resolveContentPath(server->_config().cesFileStoreDir, name), + std::ios::binary); + if (!f) return ""; + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +bool FileHandler::attachmentSize(const std::string& name, uint64_t& outSize) { + CesServer* server = server_; + if (!server) return false; + std::error_code ec; + auto sz = std::filesystem::file_size( + resolveContentPath(server->_config().cesFileStoreDir, name), ec); + if (ec) return false; + outSize = sz; + return true; +} + +uint8_t FileHandler::readAttachment(const std::string& name, uint64_t maxBytes, + ces::Bytes& out) { + CesServer* server = server_; + if (!server) return CES_ERROR_INTERNAL; + auto cPath = resolveContentPath(server->_config().cesFileStoreDir, name); + std::error_code ec; + auto sz = std::filesystem::file_size(cPath, ec); + if (ec) return CES_ERROR_FILE_NOT_FOUND; + if (sz > maxBytes) return CES_ERROR_BAD_INPUT; + std::ifstream f(cPath, std::ios::binary); + if (!f) return CES_ERROR_FILE_NOT_FOUND; + std::ostringstream ss; + ss << f.rdbuf(); + const std::string s = ss.str(); + out.assign(s.begin(), s.end()); + return CES_OK; +} + +bool FileHandler::writeServerFile(const std::string& name, const std::string& content) { + CesServer* server = server_; + if (!server || !isServerZone(name)) return false; + const std::string& dir = server->_config().cesFileStoreDir; + auto cPath = resolveContentPath(dir, name); + std::error_code ec; + std::filesystem::create_directories(cPath.parent_path(), ec); + // Atomic temp + rename: a crash mid-write never leaves a half file. + auto tmp = cPath; tmp += ".tmp"; + { + std::ofstream f(tmp, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write(content.data(), static_cast(content.size())); + if (!f.good()) return false; + } + std::filesystem::rename(tmp, cPath, ec); + if (ec) return false; + // Re-stamp the sidecar to the new content (reconcileOneServerZoneFile). + Sidecar sc{}; + reconcileOneServerZoneFile(server, dir, name, sc); + return true; +} + +bool FileHandler::removeServerFile(const std::string& name) { + CesServer* server = server_; + if (!server || !isServerZone(name)) return false; + const std::string& dir = server->_config().cesFileStoreDir; + std::error_code ec; + std::filesystem::remove(resolveContentPath(dir, name), ec); + std::filesystem::remove(resolveSidecarPath(dir, name), ec); + return true; +} + +bool FileHandler::storeStats(uint64_t& outTotalFiles, uint64_t& outTotalBytes) { + CesServer* server = server_; + if (!server) return false; + const std::string& dir = server->_config().cesFileStoreDir; + StoreMeta m{}; + std::lock_guard lk(server->fileHandler()->storeMetaMutex_); + readStoreMeta(storeMetaPath(dir), m); // tolerant of a missing file + outTotalFiles = m.total_files; + outTotalBytes = m.total_bytes; + return true; +} + + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/mail_client.cpp b/vendor/ces/src/ceslib/l2/mail_client.cpp new file mode 100644 index 0000000..8bffcc8 --- /dev/null +++ b/vendor/ces/src/ceslib/l2/mail_client.cpp @@ -0,0 +1,86 @@ +// mail_client.cpp - CesMailClient. See mail_client.h. +// +// One verb (SEND) over a CesPlexChannel: build the preamble, drive the wire. +// The transport is whatever the channel was handed (connect() = owned socket, +// attach() = external). + +#include +#include +#include +#include // ces::sha256 +#include + +#include + +namespace ces { + +namespace { + +constexpr uint8_t kVerbMailSend = 1; +constexpr const char* kMailProto = "/ces/mail/1"; + +} // namespace + +class CesMailClient::Impl { +public: + // As in CesFileClient::Impl: `owned` drives connect(); attach() points `chan` + // at a channel the caller owns. The verb codec rides `chan`. + CesPlexClient owned; + CesPlexChannel* chan = nullptr; +}; + +CesMailClient::CesMailClient() : impl_(std::make_unique()) {} +CesMailClient::~CesMailClient() = default; + +uint8_t CesMailClient::connect(const std::string& host, uint16_t rpcPort, + const KeyPair& signerKey) { + uint8_t rc = impl_->owned.connect(host, rpcPort, kMailProto, signerKey); + if (rc == CES_OK) impl_->chan = impl_->owned.channel(); + return rc; +} + +void CesMailClient::attach(CesPlexChannel& channel) { + impl_->chan = &channel; +} + +void CesMailClient::disconnect() { + impl_->owned.disconnect(); + impl_->chan = nullptr; +} + +void CesMailClient::setServerPubkey(const minx::Hash& pk) { + if (impl_->chan) impl_->chan->setServerPubkey(pk); + else impl_->owned.setServerPubkey(pk); +} + +uint8_t CesMailClient::send(const std::string& to, const std::string& subject, + const ces::Bytes& text, + const std::string& attachmentPath) { + // Headers ride the signed preamble, which must fit the MTU-bounded + // envelope (~1.2 KB total); 1024 leaves margin for the framing. The body + // streams separately (hash-committed), so its size is not envelope-bounded. + if (to.size() + subject.size() + attachmentPath.size() > 1024 || + text.size() > UINT32_MAX) + return CES_ERROR_BAD_INPUT; + + minx::Hash textHash = ces::sha256(text.data(), text.size()); + ces::Bytes pre; + ces::Buffer::put(pre, CES_NONCELESS); + ces::Buffer::put(pre, static_cast(to.size())); + pre.insert(pre.end(), to.begin(), to.end()); + ces::Buffer::put(pre, static_cast(subject.size())); + pre.insert(pre.end(), subject.begin(), subject.end()); + ces::Buffer::put(pre, static_cast(text.size())); + pre.insert(pre.end(), textHash.begin(), textHash.end()); + ces::Buffer::put(pre, static_cast(attachmentPath.size())); + pre.insert(pre.end(), attachmentPath.begin(), attachmentPath.end()); + auto env = impl_->chan->buildEnvelope(kVerbMailSend, pre); + + ces::Bytes outPre, outBody; + return impl_->chan->driveVerb(kVerbMailSend, env, /*fixedPre=*/0, + /*readVariablePreamble=*/nullptr, + /*respBodyLen=*/nullptr, + /*extraBodyToSend=*/text, outPre, outBody); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/l2/mail_handler.cpp b/vendor/ces/src/ceslib/l2/mail_handler.cpp new file mode 100644 index 0000000..e05b5b3 --- /dev/null +++ b/vendor/ces/src/ceslib/l2/mail_handler.cpp @@ -0,0 +1,169 @@ +// mail_handler.cpp - see mail_handler.h. + +#ifdef CES_MAIL + +#include + +#include +#include +#include +#include // ces::sha256 +#include +#include + +#include +#include +#include +#include +#include + +LOG_MODULE("mail") + +namespace ces { +namespace { + +constexpr uint8_t kVerbMailSend = 1; +constexpr uint64_t kOneMB = 1024ull * 1024; + +uint64_t ceilMB(uint64_t bytes) { return (bytes + kOneMB - 1) / kOneMB; } + +// Estimate the encoded (on-the-wire) size of a message from its parts, without +// doing any encoding. base64 is deterministic (+~33% plus CRLF wrapping); the +// fixed 300s cover the RFC 5322 / MIME headers. +uint64_t estimateEncoded(size_t textLen, uint64_t attSize, bool hasAtt) { + uint64_t total = 300 + textLen; + if (hasAtt) { + uint64_t b64 = ((attSize + 2) / 3) * 4; // base64 length + b64 += (b64 / 76) * 2; // CRLF line wrapping + total += b64 + 300; // + MIME part headers + } + return total; +} + +std::string baseName(const std::string& path) { + auto p = path.find_last_of('/'); + return p == std::string::npos ? path : path.substr(p + 1); +} + +// For an /m/<64hex>/... path, is it owned by `payer`? The 64-hex is the owner. +bool mZoneOwnedBy(const std::string& path, const minx::Hash& payer) { + if (path.size() < 3 + 64 || path[1] != 'm' || path[2] != '/') return false; + static const char* H = "0123456789abcdef"; + std::string payerHex; + payerHex.reserve(64); + for (uint8_t b : payer) { + payerHex.push_back(H[b >> 4]); + payerHex.push_back(H[b & 0xF]); + } + return path.compare(3, 64, payerHex) == 0; +} + +// SEND. The body text streams as the request body (hash-committed in the +// preamble, like file WRITE / compute CALL), so it is not bounded by the +// envelope; only the small headers ride the signed preamble. +// Preamble (after nonce): [u16 toLen][to][u16 sjLen][subject] +// [u32 textLen][32 textHash][u16 pathLen][path]; body = text bytes. +void dispatchSend(std::shared_ptr req, ces::Bytes pre, + MailHandler* self) { + ces::Buffer buf(std::move(pre)); + std::string to, subject, path; + uint32_t textLen = 0; + std::array textHash{}; + try { + uint16_t toLen = buf.get(); + to = buf.getBytes(toLen); + uint16_t sjLen = buf.get(); + subject = buf.getBytes(sjLen); + textLen = buf.get(); + textHash = buf.get>(); + uint16_t pLen = buf.get(); + path = buf.getBytes(pLen); + } catch (const std::out_of_range&) { + req->errorAndClose(CES_ERROR_BAD_INPUT); // body length unknown + return; + } + const auto& cfg = static_cast(req->host)->_config(); + if (to.empty() || textLen > cfg.mailMaxEncodedBytes) { + req->errorAndClose(CES_ERROR_BAD_INPUT); // body in flight -> close + return; + } + // Consume the body before any check that could loop, so the wire stays in + // sync (mirrors compute CALL). textLen == 0 is a valid empty-body message. + auto text = std::make_shared(textLen); + auto finish = [req, to, subject, path, text, textHash, self]() { + minx::Hash got = ces::sha256(text->data(), text->size()); + if (std::memcmp(got.data(), textHash.data(), 32) != 0) { + req->error(CES_ERROR_INTERNAL); + return; + } + minx::Hash signer = req->bound.boundPubkey.getHash(); + uint8_t status = self->mailSubmit( + signer, to, subject, std::string(text->begin(), text->end()), path); + req->respond(status, {}); + }; + if (textLen == 0) { finish(); return; } + boost::asio::async_read( + *req->stream, boost::asio::buffer(*text), + [finish](const boost::system::error_code& ec, std::size_t) { + if (ec) return; // stream dead + finish(); + }); +} + +} // namespace + +void MailHandler::serve(std::shared_ptr stream, + BoundChannelContext bound) { + MailHandler* self = this; + CesPlexProtocol proto; + proto.accepts = [this](uint8_t verb) { + return !stopped_.load() && verb == kVerbMailSend; + }; + proto.dispatch = [self](std::shared_ptr req, ces::Bytes pre) { + dispatchSend(req, std::move(pre), self); + }; + cesPlexServe(std::move(stream), std::move(bound), server_, std::move(proto)); +} + +uint8_t MailHandler::mailSubmit(const minx::Hash& payer, const std::string& to, + const std::string& subject, + const std::string& text, + const std::string& attachmentPath) { + const auto& cfg = server_->_config(); + const bool hasAtt = !attachmentPath.empty(); + uint64_t attSize = 0; + if (hasAtt) { + // A private /m/ attachment may be sent only by its owner (the payer). + if (attachmentPath.size() > 1 && attachmentPath[1] == 'm' && + !mZoneOwnedBy(attachmentPath, payer)) + return CES_ERROR_NOT_OWNER; + if (!server_->fileHandler() || + !server_->fileHandler()->attachmentSize(attachmentPath, attSize)) + return CES_ERROR_FILE_NOT_FOUND; + } + const uint64_t encoded = estimateEncoded(text.size(), attSize, hasAtt); + if (encoded > cfg.mailMaxEncodedBytes) return CES_ERROR_BAD_INPUT; // too big + const uint64_t price = ceilMB(encoded) * cfg.mailFeePerMB; + if (!server_->mailChargeSync(payer, price)) + return CES_ERROR_INSUFFICIENT_BALANCE; + + // Charged (fee burned). Now do the encode/send work. + CesServer::MailMessage m; + m.to = to; + m.subject = subject; + m.body = text; + if (hasAtt) { + ces::Bytes content; + uint8_t rc = server_->fileHandler()->readAttachment( + attachmentPath, cfg.mailMaxEncodedBytes, content); + if (rc != CES_OK) return rc; // the fee is already burned (anti-spam) + m.attachmentName = baseName(attachmentPath); + m.attachmentData.assign(content.begin(), content.end()); + } + server_->mailDeliver(m); + return CES_OK; +} + +} // namespace ces + +#endif // CES_MAIL diff --git a/vendor/ces/src/ceslib/l2/peer_handler.cpp b/vendor/ces/src/ceslib/l2/peer_handler.cpp new file mode 100644 index 0000000..940e4ec --- /dev/null +++ b/vendor/ces/src/ceslib/l2/peer_handler.cpp @@ -0,0 +1,539 @@ +// peer_handler.cpp - builtin:peer handler (per-server object). See the header. +// +// One PeerHandler per CesServer; no process-global state. Owns the link state +// machine, the reconcile pass, and the service-tagged message bus extensions +// ride. The lower-pubkey side dials, the higher accepts. +// +// A link keeps TWO RUDP channels to its peer: a control channel and a bulk +// channel, both bound to /ces/peer/1 and both delivering identical frames to +// the same service router. Outbound frames are split by SIZE -- small ones ride +// control, large ones ride bulk -- so a long download never head-of-line-blocks +// consensus. The two channels are independent RUDP streams, so this is a purely +// local routing choice: the peers need not agree which channel is which. A link +// needs only ONE channel up to work; if the other never opens or dies, every +// frame rides the survivor (a latency/QoS regression, not a lost link) until the +// reconcile pass regenerates it. On establish each channel is marked persistent +// (RudpStream::setPersistent) so RUDP's idle GC never closes it. + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +LOG_MODULE("plex"); + +namespace ces { + +namespace { + +// Reconcile cadence: dial missing links, regenerate a link's missing channel, +// drop links whose peer left the table. No keepalive role (channels persistent). +constexpr int kReconcileIntervalMs = 15000; +// Bind-handshake deadline; a dial whose peer never replies is torn down and +// retried on the next reconcile. +constexpr int kDialTimeoutSec = 10; +// Max bytes in one mesh-message frame (after the 4-byte length prefix). Large +// enough for a bulk sync piece; a degraded single-channel link carries such a +// piece on the control channel too. Kept well under the RUDP per-channel reorder +// cap (1 MB) so a fully-reordered frame still reassembles. Server-to-server mesh +// only, so a trusted bound. +constexpr uint32_t kMaxPeerFrame = 256 * 1024; +// Frames larger than this take the bulk channel, smaller ones the control channel. +// Set just under a full bulk piece (max_message, 64 KiB) so only max-sized chunker +// pieces leave the control lane; a consensus proposal, even a large one, stays on it. +// Only a QoS choice: both lanes feed one handler and pieces reassemble by offset. +constexpr uint32_t kBulkThreshold = 60 * 1024; + +uint64_t nowMicros() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +} // namespace + +// One physical RUDP channel of a link (control or bulk). A link holds two. +struct PeerChannel { + uint32_t channelId = 0; + std::shared_ptr stream; + bool established = false; + bool dialing = false; // a dial handshake is in flight + std::array readBuf{}; + ces::Bytes rxBuf; // inbound framing accumulator + std::deque> writeQueue; + bool writing = false; + // Dial-phase scratch. + minx::Bytes bindReqBuf; + std::array bindReplyBuf{}; + std::array bindReqDigest{}; + std::shared_ptr dialTimer; + + bool up() const { return established && stream != nullptr; } +}; + +struct PeerLink : std::enable_shared_from_this { + minx::Hash ckey{}; // remote peer identity + minx::SockAddr endpoint; // remote plex endpoint (dial only) + bool outbound = false; // true = we dialed (we regenerate channels) + bool closed = false; + PeerChannel ctrl; // small frames + PeerChannel bulk; // large frames + bool anyUp() const { return ctrl.up() || bulk.up(); } +}; + +// --------------------------------------------------------------------------- +// Pure reconcile decision (free, stateless) +// --------------------------------------------------------------------------- + +PeerLinkActions computePeerLinkActions( + const std::vector& peers, + const minx::Hash& ourKey, + const std::set& currentLinks) { + PeerLinkActions out; + std::set peerSet; + for (const auto& p : peers) peerSet.insert(p.ckey); + for (const auto& ck : currentLinks) { + if (!peerSet.count(ck)) out.toDrop.push_back(ck); + } + for (const auto& p : peers) { + if (!p.dialable) continue; + if (currentLinks.count(p.ckey)) continue; + if (!(ourKey < p.ckey)) continue; // higher side waits for inbound + out.toDial.push_back(p.ckey); + } + return out; +} + +// --------------------------------------------------------------------------- +// PeerHandler +// --------------------------------------------------------------------------- + +PeerHandler::PeerHandler(CesServer* server) : server_(server) {} + +PeerHandler::~PeerHandler() { stop(); } + +void PeerHandler::teardownLink(std::shared_ptr link) { + if (!link || link->closed) return; + link->closed = true; + for (PeerChannel* ch : {&link->ctrl, &link->bulk}) { + if (ch->dialTimer) { + ch->dialTimer->cancel(); + ch->dialTimer.reset(); + } + if (ch->stream) { + ch->stream->shutdown(kRudpStreamCloseTimeout); + ch->stream.reset(); + } + } + { + std::lock_guard lk(linkMutex_); + established_.erase(link->ckey); + } + auto it = links_.find(link->ckey); + if (it != links_.end() && it->second == link) links_.erase(it); +} + +// Close ONE channel of a link. The link survives on the other channel; only when +// both are down does the link tear down. The dialer regenerates the gap on the +// next reconcile. +void PeerHandler::closeChannel(std::shared_ptr link, bool bulk) { + if (!link || link->closed) return; + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + if (ch.dialTimer) { + ch.dialTimer->cancel(); + ch.dialTimer.reset(); + } + if (ch.stream) { + ch.stream->shutdown(kRudpStreamCloseTimeout); + ch.stream.reset(); + } + ch.established = false; + ch.dialing = false; + ch.writing = false; + ch.rxBuf.clear(); + ch.writeQueue.clear(); + if (!link->anyUp()) { + teardownLink(link); + return; + } + LOGDEBUG << "peer " << (bulk ? "bulk" : "control") << " channel down" + << SVAR(minx::hashToString(link->ckey)); +} + +void PeerHandler::establishChannel(std::shared_ptr link, bool bulk) { + if (link->closed) return; + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + const bool wasUp = link->anyUp(); + ch.established = true; + ch.dialing = false; + if (ch.dialTimer) { + boost::system::error_code ec; + ch.dialTimer->cancel(); + ch.dialTimer.reset(); + } + if (ch.stream) ch.stream->setPersistent(); + if (!wasUp) { + std::lock_guard lk(linkMutex_); + established_.insert(link->ckey); + } + LOGINFO << (wasUp ? (bulk ? "peer bulk channel up" : "peer control channel up") : "peer-link up") + << VAR(link->outbound) << SVAR(minx::hashToString(link->ckey)); + channelReadLoop(link, bulk); +} + +void PeerHandler::channelReadLoop(std::shared_ptr link, bool bulk) { + if (link->closed) return; + PeerChannel& setup = bulk ? link->bulk : link->ctrl; + if (!setup.stream) return; + auto stream = setup.stream; + stream->async_read_some( + boost::asio::buffer(setup.readBuf), + [this, link, bulk](const boost::system::error_code& ec, std::size_t n) { + if (link->closed) return; + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + if (ec) { closeChannel(link, bulk); return; } + if (n > 0) { + ch.rxBuf.insert(ch.rxBuf.end(), ch.readBuf.data(), ch.readBuf.data() + n); + } + // Frames: [u32 total][u16 service_len][service][payload]. + size_t off = 0; + while (ch.rxBuf.size() - off >= sizeof(uint32_t)) { + uint32_t total = ces::Buffer::peek(ch.rxBuf.data() + off); + if (total < sizeof(uint16_t) || total > kMaxPeerFrame) { + closeChannel(link, bulk); + return; + } + if (ch.rxBuf.size() - off < sizeof(uint32_t) + total) break; + const uint8_t* f = ch.rxBuf.data() + off + sizeof(uint32_t); + uint16_t slen = ces::Buffer::peek(f); + if (sizeof(uint16_t) + static_cast(slen) > total) { + closeChannel(link, bulk); + return; + } + std::string service(reinterpret_cast(f + sizeof(uint16_t)), slen); + const uint8_t* payload = f + sizeof(uint16_t) + slen; + size_t plen = total - sizeof(uint16_t) - slen; + if (ComputeHandler* h = server_->computeHandler()) + h->routePeerMsg(service, link->ckey, payload, plen); + if (link->closed) return; + off += sizeof(uint32_t) + total; + } + if (off > 0) { + ch.rxBuf.erase(ch.rxBuf.begin(), ch.rxBuf.begin() + off); + } + channelReadLoop(link, bulk); + }); +} + +void PeerHandler::kickWrite(std::shared_ptr link, bool bulk) { + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + if (ch.writing || ch.writeQueue.empty() || link->closed || !ch.stream) return; + ch.writing = true; + auto head = ch.writeQueue.front(); + auto stream = ch.stream; + boost::asio::async_write( + *stream, boost::asio::buffer(*head), + [this, link, bulk, head](const boost::system::error_code& ec, std::size_t) { + if (link->closed) return; + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + ch.writing = false; + if (!ch.writeQueue.empty()) ch.writeQueue.pop_front(); + if (ec) { closeChannel(link, bulk); return; } + kickWrite(link, bulk); + }); +} + +void PeerHandler::readDialBindReply(std::shared_ptr link, bool bulk) { + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + auto stream = ch.stream; + boost::asio::async_read( + *stream, boost::asio::buffer(ch.bindReplyBuf), + [this, link, bulk](const boost::system::error_code& ec, std::size_t) { + if (link->closed) return; + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + if (ec) { closeChannel(link, bulk); return; } + auto r = ces::parseBindReply( + std::span( + ch.bindReplyBuf.data(), ch.bindReplyBuf.size())); + if (r.status != ces::CES_PLEX_OK) { closeChannel(link, bulk); return; } + if (!ces::verifyBindReply( + r, std::span(ch.bindReqDigest.data(), + ch.bindReqDigest.size()))) { + closeChannel(link, bulk); + return; + } + if (std::memcmp(r.serverPubkey.data(), link->ckey.data(), + link->ckey.size()) != 0) { + closeChannel(link, bulk); // misroute / MITM + return; + } + establishChannel(link, bulk); + // Once control is up, open the bulk channel for QoS. Bulk failing later is + // harmless -- reconcile retries and the link runs degraded meanwhile. + if (!bulk && link->outbound && !link->bulk.up() && !link->bulk.dialing) + dialChannel(link, /*bulk=*/true); + }); +} + +void PeerHandler::dialChannel(std::shared_ptr link, bool bulk) { + if (link->closed) return; + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + if (ch.up() || ch.dialing) return; + minx::Rudp* rudp = server_->_rpcRudp(); + if (!rudp) return; + auto exec = server_->_rpcTaskIOExecutor(); + if (!exec) return; + + ch.dialing = true; + std::random_device rd; + ch.channelId = static_cast(rd()); + ch.stream = std::make_shared(exec); + + rudp->tick(nowMicros()); + if (!rudp->registerChannel(link->endpoint, ch.channelId, ch.stream)) { + closeChannel(link, bulk); + return; + } + + ch.dialTimer = std::make_shared(exec); + ch.dialTimer->expires_after(std::chrono::seconds(kDialTimeoutSec)); + std::weak_ptr wl = link; + ch.dialTimer->async_wait([this, wl, bulk](const boost::system::error_code& ec) { + if (ec) return; + if (auto l = wl.lock()) { + PeerChannel& c = bulk ? l->bulk : l->ctrl; + if (!c.up()) closeChannel(l, bulk); + } + }); + + const uint64_t now = nowMicros(); + ch.bindReqBuf = ces::buildBindRequest( + CES_PEER_PROTO, now, server_->_serverKeyPair()); + { + const auto& pkArr = server_->_serverKeyPair().getPublicKeyAsHash(); + std::span nameSpan( + reinterpret_cast(CES_PEER_PROTO), + std::strlen(CES_PEER_PROTO)); + ch.bindReqDigest = ces::computeBindRequestDigest( + nameSpan, now, + std::span(pkArr.data(), pkArr.size())); + } + auto stream = ch.stream; + boost::asio::async_write( + *stream, boost::asio::buffer(ch.bindReqBuf), + [this, link, bulk](const boost::system::error_code& ec, std::size_t) { + if (link->closed) return; + if (ec) { closeChannel(link, bulk); return; } + readDialBindReply(link, bulk); + }); +} + +void PeerHandler::dialPeer(const minx::Hash& ckey, + const minx::SockAddr& endpoint) { + if (links_.count(ckey)) return; // already linked or dialing + auto link = std::make_shared(); + link->ckey = ckey; + link->endpoint = endpoint; + link->outbound = true; + links_[ckey] = link; // reserve so reconcile won't redial + dialChannel(link, /*bulk=*/false); // control first; bulk follows on establish +} + +void PeerHandler::reconcileOnce() { + auto peers = server_->_peerLinkTargets(); + const minx::Hash ourKey = server_->_serverKeyPair().getPublicKeyAsHash(); + std::set current; + for (const auto& [k, _] : links_) current.insert(k); + + auto actions = computePeerLinkActions(peers, ourKey, current); + for (const auto& ck : actions.toDrop) { + auto it = links_.find(ck); + if (it != links_.end()) teardownLink(it->second); + } + for (const auto& ck : actions.toDial) { + for (const auto& p : peers) { + if (p.ckey == ck && p.dialable) { dialPeer(ck, p.endpoint); break; } + } + } + // Regenerate a missing channel on our outbound links (control died, or the + // bulk channel never opened / dropped). Snapshot first: dialing can teardown. + std::vector> outbound; + for (const auto& [k, link] : links_) + if (!link->closed && link->outbound) outbound.push_back(link); + for (const auto& link : outbound) { + if (link->closed) continue; + if (!link->ctrl.up() && !link->ctrl.dialing) dialChannel(link, /*bulk=*/false); + if (link->anyUp() && !link->bulk.up() && !link->bulk.dialing) + dialChannel(link, /*bulk=*/true); + } +} + +void PeerHandler::scheduleReconcile() { + if (!reconcileTimer_ || !running_.load()) return; + reconcileTimer_->expires_after( + std::chrono::milliseconds(kReconcileIntervalMs)); + auto t = reconcileTimer_; + t->async_wait([this, t](const boost::system::error_code& ec) { + if (ec) return; + if (!running_.load()) return; + reconcileOnce(); + scheduleReconcile(); + }); +} + +void PeerHandler::serve(std::shared_ptr stream, + BoundChannelContext bound) { + minx::Hash ckey = bound.boundPubkey.getHash(); + // Every server speaks /ces/peer/1 (a builtin), so the bind always succeeds; + // the mesh is peer-only, so a binder not in our peer table is refused HERE. + if (!server_->_isPeerByKey(ckey)) { + stream->shutdown(kRudpStreamCloseTimeout); + return; + } + std::shared_ptr link; + bool bulk = false; + auto it = links_.find(ckey); + if (it != links_.end() && !it->second->closed) { + link = it->second; + if (!link->ctrl.up()) { + bulk = false; // (re)fill the control slot + } else if (!link->bulk.up()) { + bulk = true; // fill the bulk slot + } else { + // Both channels already up: a fresh bind means the peer restarted / + // re-dialed. Replace the link and take this as its new control channel. + teardownLink(link); + link.reset(); + } + } + if (!link) { + link = std::make_shared(); + link->ckey = ckey; + link->outbound = false; + links_[ckey] = link; + bulk = false; + } + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + if (ch.stream) { // drop a stale half-open stream in this slot + ch.stream->shutdown(kRudpStreamCloseTimeout); + ch.stream.reset(); + } + ch.stream = std::move(stream); + ch.channelId = 0; // inbound; the id is the dialer's, not tracked here + establishChannel(link, bulk); +} + +void PeerHandler::start() { + running_.store(true); + auto exec = server_->_rpcTaskIOExecutor(); + if (!exec) return; + reconcileTimer_ = std::make_shared(exec); + boost::asio::post(exec, [this]() { + if (!running_.load()) return; + reconcileOnce(); + scheduleReconcile(); + }); +} + +void PeerHandler::stop() { + running_.store(false); + if (reconcileTimer_) { + boost::system::error_code ec; + reconcileTimer_->cancel(); + reconcileTimer_.reset(); + } + for (auto& [k, link] : links_) { + link->closed = true; + for (PeerChannel* ch : {&link->ctrl, &link->bulk}) { + if (ch->dialTimer) { + boost::system::error_code ec; + ch->dialTimer->cancel(); + } + if (ch->stream) { + ch->stream->shutdown(kRudpStreamCloseTimeout); + ch->stream.reset(); + } + } + } + links_.clear(); + { + std::lock_guard lk(linkMutex_); + established_.clear(); + } +} + +bool PeerHandler::isLinked(const minx::Hash& ckey) { + std::lock_guard lk(linkMutex_); + return established_.count(ckey) > 0; +} + +bool PeerHandler::hasLink(const minx::Hash& destKey) { + auto it = links_.find(destKey); + return it != links_.end() && !it->second->closed && it->second->anyUp(); +} + +void PeerHandler::reconcileNow() { + auto exec = server_->_rpcTaskIOExecutor(); + if (!exec) return; + std::promise done; + boost::asio::post(exec, [this, &done]() { + reconcileOnce(); + done.set_value(); + }); + done.get_future().get(); +} + +void PeerHandler::sendMessage(const minx::Hash& destKey, + const std::string& service, + const uint8_t* data, std::size_t len) { + auto it = links_.find(destKey); + if (it == links_.end()) return; + auto link = it->second; + if (link->closed || !link->anyUp()) return; + if (service.size() > 0xFFFF) return; + uint32_t total = + static_cast(sizeof(uint16_t) + service.size() + len); + if (total > kMaxPeerFrame) return; + + // Prefer the size-appropriate channel; fall back to the survivor when only one + // is up (degraded latency, still delivered). + const bool big = total > kBulkThreshold; + bool bulk; + if ((big ? link->bulk : link->ctrl).up()) bulk = big; + else if ((big ? link->ctrl : link->bulk).up()) bulk = !big; + else return; + + PeerChannel& ch = bulk ? link->bulk : link->ctrl; + auto frame = std::make_shared(); + frame->reserve(sizeof(uint32_t) + total); + ces::Buffer::put(*frame, total); + ces::Buffer::put(*frame, static_cast(service.size())); + frame->insert(frame->end(), service.begin(), service.end()); + if (len > 0) frame->insert(frame->end(), data, data + len); + ch.writeQueue.push_back(frame); + kickWrite(link, bulk); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/lang/bundle.cpp b/vendor/ces/src/ceslib/lang/bundle.cpp new file mode 100644 index 0000000..af9501e --- /dev/null +++ b/vendor/ces/src/ceslib/lang/bundle.cpp @@ -0,0 +1,138 @@ +#include + +#include +#include + +#include + +#include + +namespace ces { + +namespace { + +constexpr uint64_t kBodyBase = CESVM_CODE_BLOCK; // loader pads to 210 +constexpr size_t kKeysPerTable = 5; +constexpr uint64_t kNextKeyCell = kKeysPerTable * 4; // cell 20 in the table + +minx::Hash bundleKey(const char* tag, std::string_view salt, uint32_t idx, + const ces::Bytes& body) { + CryptoPP::SHA256 h; + h.Update(reinterpret_cast(tag), std::strlen(tag)); + h.Update(reinterpret_cast(salt.data()), salt.size()); + uint8_t be[4] = {static_cast(idx >> 24), + static_cast(idx >> 16), + static_cast(idx >> 8), + static_cast(idx)}; + h.Update(be, sizeof(be)); + h.Update(body.data(), body.size()); + minx::Hash out; + h.Final(out.data()); + return out; +} + +// The boot loader: walk the key-table chain, SYS_LOAD_CODE every chunk, +// zero the scratch cells this stub used, jump to the body at 210. +AssetData buildLoader(const minx::Hash& rootTableKey) { + VmProgram p; + Region tkey = p.allocHash(); + Region ckey = p.allocHash(); + Region zero4 = p.allocHash(); + Region ownerOut = p.allocHashPrefix(); + Region idx = p.allocCell(); + Region table = p.allocContent(); + + VmLabel tableLoop = p.label(); + VmLabel slotLoop = p.label(); + VmLabel done = p.label(); + + p.writeBytesToIo(tkey.cell, rootTableKey.data(), rootTableKey.size()); + p.fil(Imm(zero4.cell), Imm(0), Imm(4)); + + p.place(tableLoop); + p.sysReadAsset({.keyPtr = tkey, + .ownerOutCell = ownerOut, + .contentOutCell = table}); + p.set(Imm(idx.cell), Imm(0)); + + p.place(slotLoop); + p.mul(Ref(idx.cell), Imm(4)); + p.add(Ref(CESVM_CELL_R), Imm(table.cell)); + p.mov(Imm(ckey.cell), Ref(CESVM_CELL_R), Imm(4)); + p.cmp(Imm(ckey.cell), Imm(zero4.cell), Imm(4)); + p.jt(Ref(CESVM_CELL_R), done); // zero chunk key: no more chunks + p.sysLoadCode({.keyPtr = ckey}); + p.inc(Imm(idx.cell)); + p.lt(Ref(idx.cell), Imm(kKeysPerTable)); + p.jt(Ref(CESVM_CELL_R), slotLoop); + p.copy(tkey.cell, table.cell + kNextKeyCell, 4); + p.cmp(Imm(tkey.cell), Imm(zero4.cell), Imm(4)); + p.jt(Ref(CESVM_CELL_R), done); // zero next-table key: chain ends + p.jmp(tableLoop); + + p.place(done); + // Leave the body a fresh VM: zero every cell the loader or its + // syscalls touched -- R, S, SYSCALL, the arg registers (READ_ASSET + // writes balance/price into io[7]/io[8]), and the whole scratch band. + // Cells 752+ are execute()-preloaded context the loader never writes; + // PC is set by the jmpr. + p.fil(Imm(CESVM_CELL_R), Imm(0), Imm(CESVM_IO_INPUT_LEN - CESVM_CELL_R)); + p.jmpr(Imm(kBodyBase)); + + return p.buildBootBlock(); +} + +} // namespace + +CesBundle bundleProgram(const ces::Bytes& body, std::string_view salt) { + if (body.empty()) throw CesBundleError("bundle: empty program body"); + + CesBundle b; + + if (body.size() <= AssetData{}.size()) { + std::memcpy(b.boot.data(), body.data(), body.size()); + return b; + } + if (body.size() > CESVM_MAX_CODE - kBodyBase) { + throw CesBundleError( + "bundle: body is " + std::to_string(body.size()) + + " bytes, exceeds the code space behind the boot block (" + + std::to_string(CESVM_MAX_CODE - kBodyBase) + ")"); + } + + const size_t blockSize = AssetData{}.size(); + const size_t nChunks = (body.size() + blockSize - 1) / blockSize; + for (size_t i = 0; i < nChunks; ++i) { + AssetData chunk{}; + const size_t off = i * blockSize; + const size_t len = std::min(blockSize, body.size() - off); + std::memcpy(chunk.data(), body.data() + off, len); + b.chunks.push_back(chunk); + b.chunkKeys.push_back( + bundleKey("ces.bundle.chunk", salt, static_cast(i), body)); + } + + const size_t nTables = (nChunks + kKeysPerTable - 1) / kKeysPerTable; + for (size_t j = 0; j < nTables; ++j) { + b.tableKeys.push_back( + bundleKey("ces.bundle.table", salt, static_cast(j), body)); + } + for (size_t j = 0; j < nTables; ++j) { + AssetData table{}; + for (size_t s = 0; s < kKeysPerTable; ++s) { + const size_t chunkIdx = j * kKeysPerTable + s; + if (chunkIdx >= nChunks) break; + std::memcpy(table.data() + s * 32, b.chunkKeys[chunkIdx].data(), 32); + } + if (j + 1 < nTables) { + std::memcpy(table.data() + kNextKeyCell * 8, b.tableKeys[j + 1].data(), + 32); + } + b.tables.push_back(table); + } + + b.boot = buildLoader(b.tableKeys[0]); + return b; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/lang/casm.cpp b/vendor/ces/src/ceslib/lang/casm.cpp new file mode 100644 index 0000000..a334fe0 --- /dev/null +++ b/vendor/ces/src/ceslib/lang/casm.cpp @@ -0,0 +1,459 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { + +namespace { + +struct Insn { + CesVMOpcode op; + int nops; +}; + +// Plain opcode-then-operands mnemonics, emitted via VmProgram::rawOp. +// jmp/call/jf/jt (label targets) and hostv/hostxv (variadic) are +// dispatched separately. +const std::unordered_map& insnTable() { + static const std::unordered_map t = { + {"nop", {OP_NOP, 0}}, {"term", {OP_TERM, 0}}, + {"abort", {OP_ABORT, 0}}, {"host", {OP_HOST, 0}}, + {"hostx", {OP_HOSTX, 0}}, {"rnd", {OP_RND, 0}}, + {"time", {OP_TIME, 0}}, {"dup", {OP_DUP, 0}}, + {"not", {OP_NOT, 1}}, {"lnot", {OP_LNOT, 1}}, + {"neg", {OP_NEG, 1}}, {"inc", {OP_INC, 1}}, + {"dec", {OP_DEC, 1}}, {"push", {OP_PUSH, 1}}, + {"pop", {OP_POP, 1}}, {"ldb", {OP_LDB, 1}}, + {"jmpr", {OP_JMPR, 1}}, {"callr", {OP_CALLR, 1}}, + {"ret", {OP_RET, 1}}, + {"assert", {OP_ASSERT, 1}}, {"require", {OP_ASSERT, 1}}, + {"set", {OP_SET, 2}}, {"add", {OP_ADD, 2}}, + {"sub", {OP_SUB, 2}}, {"mul", {OP_MUL, 2}}, + {"div", {OP_DIV, 2}}, {"mod", {OP_MOD, 2}}, + {"or", {OP_OR, 2}}, {"and", {OP_AND, 2}}, + {"xor", {OP_XOR, 2}}, {"shl", {OP_SHL, 2}}, + {"shr", {OP_SHR, 2}}, {"sar", {OP_SAR, 2}}, + {"andl", {OP_ANDL, 2}}, {"orl", {OP_ORL, 2}}, + {"eq", {OP_EQ, 2}}, {"ne", {OP_NE, 2}}, + {"gt", {OP_GT, 2}}, {"lt", {OP_LT, 2}}, + {"ge", {OP_GE, 2}}, {"le", {OP_LE, 2}}, + {"slt", {OP_SLT, 2}}, {"sgt", {OP_SGT, 2}}, + {"sge", {OP_SGE, 2}}, {"sle", {OP_SLE, 2}}, + {"addx", {OP_ADDX, 2}}, {"subx", {OP_SUBX, 2}}, + {"mulx", {OP_MULX, 2}}, {"stb", {OP_STB, 2}}, + {"mov", {OP_MOV, 3}}, {"cmp", {OP_CMP, 3}}, + {"fil", {OP_FIL, 3}}, + }; + return t; +} + +const std::unordered_map& syscallTable() { + static const std::unordered_map t = { + {"NOP", SYS_NOP}, {"READ_ACCOUNT", SYS_READ_ACCOUNT}, + {"TRANSFER", SYS_TRANSFER}, {"READ_ASSET", SYS_READ_ASSET}, + {"CREATE_ASSET_RANDOM", SYS_CREATE_ASSET_RANDOM}, + {"CREATE_ASSET_RANGE", SYS_CREATE_ASSET_RANGE}, + {"UPDATE_ASSET", SYS_UPDATE_ASSET}, {"FUND_ASSET", SYS_FUND_ASSET}, + {"BUY_ASSET", SYS_BUY_ASSET}, {"GIVE_ASSET", SYS_GIVE_ASSET}, + {"HASH", SYS_HASH}, {"VERIFY_SIG", SYS_VERIFY_SIG}, + {"CROSS_TRANSFER", SYS_CROSS_TRANSFER}, {"LOAD_CODE", SYS_LOAD_CODE}, + {"CREATE_ASSET", SYS_CREATE_ASSET}, {"SEND_CLIENT", SYS_SEND_CLIENT}, + {"SCHEDULE", SYS_SCHEDULE}, + {"CREATE_ASSET_MANAGED", SYS_CREATE_ASSET_MANAGED}, + {"RPC", SYS_RPC}, {"OWNER_TRANSFER", SYS_OWNER_TRANSFER}, + {"DEPOSIT", SYS_DEPOSIT}, {"WITHDRAW", SYS_WITHDRAW}, + {"UPDATE_ASSET_META", SYS_UPDATE_ASSET_META}, + }; + return t; +} + +struct LabelInfo { + VmLabel label; + bool placed = false; + size_t firstLine = 0; +}; + +class Assembler { +public: + Assembler(std::string_view source, uint64_t codeBase) : source_(source) { + pgm_.setBaseOffset(codeBase); + // Register aliases and protocol cells. + for (uint64_t i = 0; i < 4; ++i) { + syms_["a" + std::to_string(i)] = CESVM_CELL_ARG0 + i; + } + for (uint64_t i = 0; i < 8; ++i) { + syms_["g" + std::to_string(i)] = CESVM_CELL_GPR0 + i; + } + syms_["pc"] = CESVM_CELL_PC; + syms_["r"] = CESVM_CELL_R; + syms_["s"] = CESVM_CELL_S; + syms_["sys"] = CESVM_CELL_SYSCALL; + syms_["input_len"] = CESVM_IO_INPUT_LEN; + syms_["output_len"] = CESVM_IO_OUTPUT_LEN; + syms_["budget"] = CESVM_IO_BUDGET; + syms_["start_time"] = CESVM_IO_START_TIME; + syms_["caller_key"] = CESVM_IO_CALLER_KEY; + syms_["self_key"] = CESVM_IO_SELF_KEY; + syms_["output"] = CESVM_IO_OUTPUT; + syms_["input"] = CESVM_IO_INPUT; + syms_["allowance"] = CESVM_IO_ALLOWANCE; + syms_["gas_left"] = CESVM_IO_BUDGET_REMAINING; + } + + ces::Bytes run() { + size_t pos = 0; + while (pos < source_.size()) { + size_t eol = source_.find('\n', pos); + if (eol == std::string_view::npos) eol = source_.size(); + line_++; + assembleLine(std::string(source_.substr(pos, eol - pos))); + pos = eol + 1; + } + for (const auto& [name, info] : labels_) { + if (!info.placed) { + fail("label '" + name + "' referenced (line " + + std::to_string(info.firstLine) + ") but never defined"); + } + } + try { + return pgm_.buildBytes(); + } catch (const VmProgramError& e) { + throw CasmError(std::string("build: ") + e.what()); + } + } + +private: + std::string_view source_; + VmProgram pgm_; + std::unordered_map syms_; + std::unordered_map labels_; + size_t line_ = 0; + + [[noreturn]] void fail(const std::string& what) const { + throw CasmError("line " + std::to_string(line_) + ": " + what); + } + + // Cut the line at the first ; or # outside a string literal. + static std::string stripComment(const std::string& raw) { + bool inStr = false; + for (size_t i = 0; i < raw.size(); ++i) { + char c = raw[i]; + if (inStr) { + if (c == '\\') { ++i; continue; } + if (c == '"') inStr = false; + continue; + } + if (c == '"') { inStr = true; continue; } + if (c == ';' || c == '#') return raw.substr(0, i); + } + return raw; + } + + static std::string trim(const std::string& s) { + size_t b = s.find_first_not_of(" \t\r"); + if (b == std::string::npos) return {}; + size_t e = s.find_last_not_of(" \t\r"); + return s.substr(b, e - b + 1); + } + + static bool isIdent(const std::string& s) { + if (s.empty()) return false; + if (!std::isalpha(static_cast(s[0])) && s[0] != '_') + return false; + for (char c : s) { + if (!std::isalnum(static_cast(c)) && c != '_') + return false; + } + return true; + } + + // Numeric literal: decimal or 0x hex, optional leading '-' (wraps). + bool parseNumber(const std::string& s, uint64_t& out) const { + if (s.empty()) return false; + bool negate = (s[0] == '-'); + const std::string body = negate ? s.substr(1) : s; + if (body.empty()) return false; + for (char c : body) { + if (!std::isalnum(static_cast(c))) return false; + } + char* end = nullptr; + errno = 0; + uint64_t v = std::strtoull(body.c_str(), &end, 0); + if (errno != 0 || end == nullptr || *end != '\0') return false; + out = negate ? (0 - v) : v; + return true; + } + + // number | symbol, optionally displaced by +number / -number. + uint64_t evalValue(const std::string& expr) const { + std::string s = trim(expr); + if (s.empty()) fail("empty operand"); + uint64_t num; + if (parseNumber(s, num)) return num; + // symbol with optional +N / -N displacement (scan from position 1 + // so a leading '-' sign is not read as a displacement operator) + size_t op = s.find_first_of("+-", 1); + if (op != std::string::npos) { + const std::string base = trim(s.substr(0, op)); + const std::string disp = trim(s.substr(op + 1)); + uint64_t d; + if (!parseNumber(disp, d)) fail("bad displacement '" + disp + "'"); + return s[op] == '+' ? evalValue(base) + d : evalValue(base) - d; + } + auto it = syms_.find(s); + if (it == syms_.end()) fail("unknown symbol '" + s + "'"); + return it->second; + } + + VmVal parseOperand(const std::string& raw) const { + std::string s = trim(raw); + if (s.size() >= 2 && s.front() == '[' && s.back() == ']') { + return Ref(evalValue(s.substr(1, s.size() - 2))); + } + return Imm(evalValue(s)); + } + + // Split an operand list on top-level commas. + std::vector splitOperands(const std::string& s) const { + std::vector out; + std::string cur; + bool inStr = false; + for (size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + if (inStr) { + cur += c; + if (c == '\\' && i + 1 < s.size()) { cur += s[++i]; continue; } + if (c == '"') inStr = false; + continue; + } + if (c == '"') { inStr = true; cur += c; continue; } + if (c == ',') { out.push_back(trim(cur)); cur.clear(); continue; } + cur += c; + } + if (!trim(cur).empty()) out.push_back(trim(cur)); + for (const auto& o : out) { + if (o.empty()) fail("empty operand in list"); + } + return out; + } + + std::string parseStringLiteral(const std::string& raw) const { + std::string s = trim(raw); + if (s.size() < 2 || s.front() != '"' || s.back() != '"') + fail("expected string literal"); + std::string out; + for (size_t i = 1; i + 1 < s.size(); ++i) { + char c = s[i]; + if (c != '\\') { out += c; continue; } + if (i + 2 >= s.size()) fail("dangling escape"); + char e = s[++i]; + switch (e) { + case 'n': out += '\n'; break; + case 't': out += '\t'; break; + case '0': out += '\0'; break; + case '\\': out += '\\'; break; + case '"': out += '"'; break; + default: fail(std::string("unknown escape '\\") + e + "'"); + } + } + return out; + } + + VmLabel getLabel(const std::string& name) { + if (!isIdent(name)) fail("bad label name '" + name + "'"); + auto it = labels_.find(name); + if (it == labels_.end()) { + it = labels_.emplace(name, LabelInfo{pgm_.label(), false, line_}).first; + } + return it->second.label; + } + + void defineSymbol(const std::string& name, uint64_t value) { + if (!isIdent(name)) fail("bad symbol name '" + name + "'"); + if (syms_.count(name)) fail("symbol '" + name + "' already defined"); + syms_[name] = value; + } + + void assembleLine(const std::string& raw) { + std::string s = trim(stripComment(raw)); + if (s.empty()) return; + + // Label definition. + if (s.back() == ':') { + const std::string name = trim(s.substr(0, s.size() - 1)); + getLabel(name); + auto& info = labels_[name]; + if (info.placed) fail("label '" + name + "' defined twice"); + pgm_.place(info.label); + info.placed = true; + return; + } + + // Split head (mnemonic or directive) from the operand tail. + size_t sp = s.find_first_of(" \t"); + std::string head = (sp == std::string::npos) ? s : s.substr(0, sp); + std::string tail = (sp == std::string::npos) ? "" : trim(s.substr(sp + 1)); + for (auto& c : head) c = std::tolower(static_cast(c)); + + if (head[0] == '.') { directive(head, tail); return; } + + auto ops = tail.empty() ? std::vector{} + : splitOperands(tail); + + // Stack-mode suffix. + bool stackMode = false; + if (head.size() > 2 && head.compare(head.size() - 2, 2, ".s") == 0) { + stackMode = true; + head = head.substr(0, head.size() - 2); + } + + try { + instruction(head, stackMode, ops); + } catch (const VmProgramError& e) { + fail(e.what()); + } + } + + void directive(const std::string& head, const std::string& tail) { + // Directives take a name/cell first and one value after it; the + // separator is whitespace, with an optional comma tolerated. + size_t sp = tail.find_first_of(" \t,"); + std::string first = (sp == std::string::npos) ? tail : tail.substr(0, sp); + std::string rest = + (sp == std::string::npos) ? "" : trim(tail.substr(sp + 1)); + if (!rest.empty() && rest[0] == ',') rest = trim(rest.substr(1)); + first = trim(first); + if (first.empty() || rest.empty()) + fail(head + " takes: name/cell and a value"); + + if (head == ".equ") { + defineSymbol(first, evalValue(rest)); + } else if (head == ".alloc") { + uint64_t count = evalValue(rest); + try { + defineSymbol(first, pgm_.alloc(count).cell); + } catch (const VmProgramError& e) { + fail(e.what()); + } + } else if (head == ".at") { + defineSymbol(first, evalValue(rest)); + } else if (head == ".string") { + pgm_.writeBytesToIo(evalValue(first), parseStringLiteral(rest)); + } else { + fail("unknown directive '" + head + "'"); + } + } + + void instruction(const std::string& head, bool stackMode, + const std::vector& ops) { + // Label-target family. + if (head == "jmp" || head == "call") { + if (stackMode) { + if (head == "jmp") fail("jmp has no stack variant"); + if (!ops.empty()) fail("call.s pops its target; no operands"); + pgm_.stackOp(OP_CALL); + return; + } + if (ops.size() != 1) fail(head + " takes: label"); + if (head == "jmp") pgm_.jmp(getLabel(ops[0])); + else pgm_.call(getLabel(ops[0])); + return; + } + if (head == "jf" || head == "jt") { + if (stackMode) { + if (ops.size() != 1) fail(head + ".s takes: label"); + if (head == "jf") pgm_.jfStack(getLabel(ops[0])); + else pgm_.jtStack(getLabel(ops[0])); + return; + } + if (ops.size() != 2) fail(head + " takes: cond, label"); + if (head == "jf") pgm_.jf(parseOperand(ops[0]), getLabel(ops[1])); + else pgm_.jt(parseOperand(ops[0]), getLabel(ops[1])); + return; + } + + // Variadic host dispatch. + if (head == "hostv" || head == "hostxv") { + if (stackMode) fail(head + " has no stack variant"); + if (ops.empty()) fail(head + " takes: syscall, args..."); + uint64_t sysNum; + auto it = syscallTable().find(ops[0]); + if (it != syscallTable().end()) { + sysNum = it->second; + } else if (!parseNumber(ops[0], sysNum)) { + fail("unknown syscall '" + ops[0] + "'"); + } + std::vector args; + for (size_t i = 1; i < ops.size(); ++i) + args.push_back(parseOperand(ops[i])); + // hostv/hostxv take an initializer_list; go through the builder's + // encoding by emitting via the generic path instead. + if (args.size() > CESVM_MAX_HOSTV_ARGS) + fail("too many syscall args"); + emitHostV(head == "hostxv", sysNum, args); + return; + } + + // Plain table mnemonics. + auto it = insnTable().find(head); + if (it == insnTable().end()) fail("unknown mnemonic '" + head + "'"); + const Insn& in = it->second; + + if (stackMode) { + if (!ops.empty()) fail(head + ".s pops its operands; none allowed"); + pgm_.stackOp(in.op); + return; + } + if (static_cast(ops.size()) != in.nops) { + fail(head + " takes " + std::to_string(in.nops) + " operand(s), got " + + std::to_string(ops.size())); + } + std::vector vals; + for (const auto& o : ops) vals.push_back(parseOperand(o)); + switch (vals.size()) { + case 0: pgm_.rawOp(in.op, {}); break; + case 1: pgm_.rawOp(in.op, {vals[0]}); break; + case 2: pgm_.rawOp(in.op, {vals[0], vals[1]}); break; + case 3: pgm_.rawOp(in.op, {vals[0], vals[1], vals[2]}); break; + default: fail("internal: bad operand count"); + } + } + + void emitHostV(bool xv, uint64_t sysNum, const std::vector& args) { + switch (args.size()) { + case 0: hv(xv, sysNum, {}); break; + case 1: hv(xv, sysNum, {args[0]}); break; + case 2: hv(xv, sysNum, {args[0], args[1]}); break; + case 3: hv(xv, sysNum, {args[0], args[1], args[2]}); break; + case 4: hv(xv, sysNum, {args[0], args[1], args[2], args[3]}); break; + case 5: hv(xv, sysNum, {args[0], args[1], args[2], args[3], + args[4]}); break; + case 6: hv(xv, sysNum, {args[0], args[1], args[2], args[3], + args[4], args[5]}); break; + case 7: hv(xv, sysNum, {args[0], args[1], args[2], args[3], + args[4], args[5], args[6]}); break; + default: fail("too many syscall args (max 7 via casm)"); + } + } + + void hv(bool xv, uint64_t n, std::initializer_list a) { + if (xv) pgm_.hostxv(n, a); + else pgm_.hostv(n, a); + } +}; + +} // namespace + +ces::Bytes casmAssemble(std::string_view source, uint64_t codeBase) { + return Assembler(source, codeBase).run(); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/lang/cesl.cpp b/vendor/ces/src/ceslib/lang/cesl.cpp new file mode 100644 index 0000000..7e2002a --- /dev/null +++ b/vendor/ces/src/ceslib/lang/cesl.cpp @@ -0,0 +1,1144 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ces { + +namespace { + +enum class Tok { End, Ident, Num, Str, Punct }; + +struct Token { + Tok t; + std::string s; + uint64_t num = 0; + size_t line = 1; +}; + +// Thrown by the constant-expression evaluator when it meets a +// non-constant atom; callers backtrack to runtime codegen. +struct NotConst {}; + +struct SysCall { + uint64_t num; + size_t nargs; +}; + +const std::unordered_map& sysTable() { + static const std::unordered_map t = { + {"read_account", {SYS_READ_ACCOUNT, 1}}, + {"transfer", {SYS_TRANSFER, 2}}, + {"read_asset", {SYS_READ_ASSET, 3}}, + {"create_asset_random", {SYS_CREATE_ASSET_RANDOM, 3}}, + {"create_asset_range", {SYS_CREATE_ASSET_RANGE, 3}}, + {"update_asset", {SYS_UPDATE_ASSET, 2}}, + {"fund_asset", {SYS_FUND_ASSET, 2}}, + {"buy_asset", {SYS_BUY_ASSET, 2}}, + {"give_asset", {SYS_GIVE_ASSET, 2}}, + {"hash", {SYS_HASH, 3}}, + {"verify_sig", {SYS_VERIFY_SIG, 4}}, + {"cross_transfer", {SYS_CROSS_TRANSFER, 3}}, + {"load_code", {SYS_LOAD_CODE, 1}}, + {"create_asset", {SYS_CREATE_ASSET, 3}}, + {"send_client", {SYS_SEND_CLIENT, 3}}, + {"schedule", {SYS_SCHEDULE, 6}}, + {"create_asset_managed", {SYS_CREATE_ASSET_MANAGED, 3}}, + {"rpc", {SYS_RPC, 7}}, + {"owner_transfer", {SYS_OWNER_TRANSFER, 2}}, + {"deposit", {SYS_DEPOSIT, 1}}, + {"withdraw", {SYS_WITHDRAW, 1}}, + {"update_asset_meta", {SYS_UPDATE_ASSET_META, 3}}, + {"refill", {SYS_REFILL, 1}}, + }; + return t; +} + +const std::unordered_set& reservedNames() { + static const std::unordered_set r = [] { + std::unordered_set s = { + "const", "let", "fn", "if", "else", "while", "break", "continue", + "return", "require", "abort", + "random", "now", "peek", "getb", "memcmp", + "slt", "sgt", "sge", "sle", "sar", + "poke", "setb", "copy", "fill", "emit_string", + }; + for (const auto& [name, sc] : sysTable()) { + s.insert(name); + s.insert("try_" + name); + } + return s; + }(); + return r; +} + +struct Sym { + enum Kind { Const, Var, Region, RoVar, RwVar } kind; + uint64_t cell = 0; // Var / Region / RoVar / RwVar + uint64_t count = 0; // Region + uint64_t k = 0; // Const +}; + +struct FnInfo { + VmLabel label{}; + std::vector paramNames; + std::vector paramCells; + std::vector callees; + size_t line = 0; +}; + +// Binary operator levels below && (index 0 binds loosest). && and || +// are handled separately for short-circuit emission. +struct BinOp { + const char* tok; + CesVMOpcode op; +}; +const std::vector>& binLevels() { + static const std::vector> lv = { + {{"|", OP_OR}}, + {{"^", OP_XOR}}, + {{"&", OP_AND}}, + {{"==", OP_EQ}, {"!=", OP_NE}}, + {{"<=", OP_LE}, {">=", OP_GE}, {"<", OP_LT}, {">", OP_GT}}, + {{"<<", OP_SHL}, {">>", OP_SHR}}, + {{"+%", OP_ADD}, {"-%", OP_SUB}, {"+", OP_ADDX}, {"-", OP_SUBX}}, + {{"*%", OP_MUL}, {"*", OP_MULX}, {"/", OP_DIV}, {"%", OP_MOD}}, + }; + return lv; +} + +class Compiler { +public: + Compiler(std::string_view source, uint64_t codeBase) : src_(source) { + pgm_.setBaseOffset(codeBase); + } + + ces::Bytes run() { + lex(); + // Temps first so the hottest cells get short-encoded addresses. + tmpBase_ = pgm_.alloc(kMaxTmp).cell; + predeclare(); + prepass(); + + // Top-level statements become the boot code; fn bodies are skipped + // here and compiled after the final term. + pos_ = 0; + curFn_.clear(); + while (peek().t != Tok::End) { + if (peek().t == Tok::Ident && peek().s == "fn") { skipFn(); continue; } + stmt(); + } + pgm_.term(); + + // Second sweep: compile each top-level fn, skipping everything else. + pos_ = 0; + int depth = 0; + while (peek().t != Tok::End) { + const Token& t = peek(); + if (t.t == Tok::Punct) { + if (t.s == "(" || t.s == "{" || t.s == "[") depth++; + else if (t.s == ")" || t.s == "}" || t.s == "]") depth--; + next(); + continue; + } + if (depth == 0 && t.t == Tok::Ident && t.s == "fn") { + compileFn(); + continue; + } + next(); + } + + checkRecursion(); + try { + return pgm_.buildBytes(); + } catch (const VmProgramError& e) { + throw CeslError(std::string("build: ") + e.what()); + } + } + +private: + std::string_view src_; + std::vector toks_; + size_t pos_ = 0; + VmProgram pgm_; + std::vector> scopes_; + std::unordered_map fns_; + std::string curFn_; + struct Loop { VmLabel brk, cont; }; + std::vector loops_; + + static constexpr int kMaxTmp = 12; + uint64_t tmpBase_ = 0; + int tmpDepth_ = 0; + + // ---- diagnostics ---- + + size_t curLine() const { + return toks_[std::min(pos_, toks_.size() - 1)].line; + } + + [[noreturn]] void err(const std::string& m) const { + throw CeslError("line " + std::to_string(curLine()) + ": " + m); + } + + // ---- lexer ---- + + void lex() { + size_t line = 1; + size_t i = 0; + const size_t n = src_.size(); + while (i < n) { + char c = src_[i]; + if (c == '\n') { line++; i++; continue; } + if (std::isspace(static_cast(c))) { i++; continue; } + if (c == '/' && i + 1 < n && src_[i + 1] == '/') { + while (i < n && src_[i] != '\n') i++; + continue; + } + if (std::isdigit(static_cast(c))) { + size_t j = i; + while (j < n && std::isalnum(static_cast(src_[j]))) j++; + const std::string body(src_.substr(i, j - i)); + char* end = nullptr; + errno = 0; + uint64_t v = std::strtoull(body.c_str(), &end, 0); + if (errno != 0 || end == nullptr || *end != '\0') { + throw CeslError("line " + std::to_string(line) + + ": bad number '" + body + "'"); + } + toks_.push_back({Tok::Num, body, v, line}); + i = j; + continue; + } + if (std::isalpha(static_cast(c)) || c == '_') { + size_t j = i; + while (j < n && (std::isalnum(static_cast(src_[j])) || + src_[j] == '_')) j++; + toks_.push_back({Tok::Ident, std::string(src_.substr(i, j - i)), + 0, line}); + i = j; + continue; + } + if (c == '"') { + std::string out; + size_t j = i + 1; + while (j < n && src_[j] != '"') { + char d = src_[j]; + if (d == '\n') break; + if (d == '\\' && j + 1 < n) { + char e = src_[++j]; + switch (e) { + case 'n': out += '\n'; break; + case 't': out += '\t'; break; + case '0': out += '\0'; break; + case '\\': out += '\\'; break; + case '"': out += '"'; break; + default: + throw CeslError("line " + std::to_string(line) + + ": unknown escape"); + } + j++; + continue; + } + out += d; + j++; + } + if (j >= n || src_[j] != '"') { + throw CeslError("line " + std::to_string(line) + + ": unterminated string"); + } + toks_.push_back({Tok::Str, out, 0, line}); + i = j + 1; + continue; + } + static const char* two[] = {"+%", "-%", "*%", "<<", ">>", "<=", ">=", + "==", "!=", "&&", "||"}; + bool matched = false; + if (i + 1 < n) { + const std::string pair{c, src_[i + 1]}; + for (const char* p : two) { + if (pair == p) { + toks_.push_back({Tok::Punct, pair, 0, line}); + i += 2; + matched = true; + break; + } + } + } + if (matched) continue; + static const std::string singles = "+-*/%<>=!~&|^(){}[],;"; + if (singles.find(c) != std::string::npos) { + toks_.push_back({Tok::Punct, std::string(1, c), 0, line}); + i++; + continue; + } + throw CeslError("line " + std::to_string(line) + + ": unexpected character '" + std::string(1, c) + "'"); + } + toks_.push_back({Tok::End, "", 0, line}); + } + + // ---- token cursor ---- + + const Token& peek(size_t off = 0) const { + size_t i = pos_ + off; + return toks_[std::min(i, toks_.size() - 1)]; + } + const Token& next() { + const Token& t = toks_[std::min(pos_, toks_.size() - 1)]; + if (pos_ < toks_.size() - 1) pos_++; + return t; + } + bool acceptP(const std::string& p) { + if (peek().t == Tok::Punct && peek().s == p) { next(); return true; } + return false; + } + void expectP(const std::string& p) { + if (!acceptP(p)) err("expected '" + p + "'"); + } + std::string expectIdent() { + if (peek().t != Tok::Ident) err("expected identifier"); + return next().s; + } + + // ---- symbols ---- + + void predeclare() { + scopes_.push_back({}); + auto& g = scopes_[0]; + g["input"] = {Sym::Region, CESVM_IO_INPUT, 128, 0}; + g["output"] = {Sym::Region, CESVM_IO_OUTPUT, 128, 0}; + g["caller_key"] = {Sym::Region, CESVM_IO_CALLER_KEY, 4, 0}; + g["self_key"] = {Sym::Region, CESVM_IO_SELF_KEY, 4, 0}; + g["r"] = {Sym::RoVar, CESVM_CELL_R, 0, 0}; + g["s"] = {Sym::RoVar, CESVM_CELL_S, 0, 0}; + g["arg0"] = {Sym::RoVar, CESVM_CELL_ARG0, 0, 0}; + g["arg1"] = {Sym::RoVar, CESVM_CELL_ARG1, 0, 0}; + g["arg2"] = {Sym::RoVar, CESVM_CELL_ARG2, 0, 0}; + g["arg3"] = {Sym::RoVar, CESVM_CELL_ARG3, 0, 0}; + g["input_len"] = {Sym::RoVar, CESVM_IO_INPUT_LEN, 0, 0}; + g["budget_start"] = {Sym::RoVar, CESVM_IO_BUDGET, 0, 0}; + g["start_time"] = {Sym::RoVar, CESVM_IO_START_TIME, 0, 0}; + g["allowance_left"] = {Sym::RoVar, CESVM_IO_ALLOWANCE, 0, 0}; + g["gas_left"] = {Sym::RoVar, CESVM_IO_BUDGET_REMAINING, 0, 0}; + g["output_len"] = {Sym::RwVar, CESVM_IO_OUTPUT_LEN, 0, 0}; + g["PRICE_UNIT"] = {Sym::Const, 0, 0, 100000000}; + // Account-hook context: which event invoked this run, and the named event + // kinds a trigger branches on. For a hook the event descriptor is in the + // `input` region: input[0..3]=counterparty key, input[4]=amount, + // input[5]=account balance at fire time. + g["invoke_kind"] = {Sym::RoVar, CESVM_IO_INVOKE_KIND, 0, 0}; + g["INVOKE_DIRECT"] = {Sym::Const, 0, 0, INVOKE_DIRECT}; + g["INVOKE_SCHEDULED"] = {Sym::Const, 0, 0, INVOKE_SCHEDULED}; + g["INVOKE_XFER_IN"] = {Sym::Const, 0, 0, INVOKE_HOOK_XFER_IN}; + g["INVOKE_XFER_OUT"] = {Sym::Const, 0, 0, INVOKE_HOOK_XFER_OUT}; + g["INVOKE_XFER_VM"] = {Sym::Const, 0, 0, INVOKE_HOOK_XFER_VM}; + g["INVOKE_SETTLE_IN"] = {Sym::Const, 0, 0, INVOKE_HOOK_SETTLE_IN}; + g["INVOKE_SETTLE_OUT"] = {Sym::Const, 0, 0, INVOKE_HOOK_SETTLE_OUT}; + } + + Sym* find(const std::string& name) { + for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { + auto f = it->find(name); + if (f != it->end()) return &f->second; + } + return nullptr; + } + + void define(const std::string& name, Sym sym) { + if (reservedNames().count(name)) err("'" + name + "' is reserved"); + if (fns_.count(name)) err("'" + name + "' is a function"); + if (scopes_.back().count(name)) err("'" + name + "' already defined"); + scopes_.back()[name] = sym; + } + + // ---- temps ---- + + uint64_t reserveTmp() { + if (tmpDepth_ >= kMaxTmp) err("expression too complex"); + return tmpBase_ + tmpDepth_++; + } + void releaseTmp(int n) { tmpDepth_ -= n; } + + // ---- pre-pass: register top-level fns, allocate param cells ---- + + void prepass() { + int depth = 0; + for (size_t i = 0; i < toks_.size(); ++i) { + const Token& t = toks_[i]; + if (t.t == Tok::Punct) { + if (t.s == "(" || t.s == "{" || t.s == "[") depth++; + else if (t.s == ")" || t.s == "}" || t.s == "]") depth--; + continue; + } + if (depth != 0 || t.t != Tok::Ident || t.s != "fn") continue; + pos_ = i + 1; + FnInfo fi; + fi.line = t.line; + const std::string name = expectIdent(); + if (reservedNames().count(name)) err("'" + name + "' is reserved"); + if (fns_.count(name)) err("function '" + name + "' already defined"); + expectP("("); + if (!acceptP(")")) { + for (;;) { + fi.paramNames.push_back(expectIdent()); + if (!acceptP(",")) break; + } + expectP(")"); + } + for (size_t p = 0; p < fi.paramNames.size(); ++p) { + for (size_t q = 0; q < p; ++q) { + if (fi.paramNames[p] == fi.paramNames[q]) + err("duplicate parameter '" + fi.paramNames[p] + "'"); + } + fi.paramCells.push_back(allocCells(1)); + } + fi.label = pgm_.label(); + fns_[name] = std::move(fi); + } + } + + uint64_t allocCells(uint64_t n) { + try { + return pgm_.alloc(n).cell; + } catch (const VmProgramError& e) { + err(e.what()); + } + } + + // ---- statement / fn skipping (pass A) ---- + + void skipFn() { + next(); // fn + expectIdent(); + expectP("("); + int d = 1; + while (d > 0) { + const Token& t = next(); + if (t.t == Tok::End) err("unterminated parameter list"); + if (t.t == Tok::Punct && t.s == "(") d++; + if (t.t == Tok::Punct && t.s == ")") d--; + } + expectP("{"); + d = 1; + while (d > 0) { + const Token& t = next(); + if (t.t == Tok::End) err("unterminated fn body"); + if (t.t == Tok::Punct && t.s == "{") d++; + if (t.t == Tok::Punct && t.s == "}") d--; + } + } + + // ---- pass B: fn bodies ---- + + void compileFn() { + next(); // fn + const std::string name = expectIdent(); + FnInfo& fi = fns_.at(name); + expectP("("); + if (!acceptP(")")) { + for (;;) { + expectIdent(); + if (!acceptP(",")) break; + } + expectP(")"); + } + curFn_ = name; + pgm_.place(fi.label); + scopes_.push_back({}); + for (size_t i = 0; i < fi.paramNames.size(); ++i) { + define(fi.paramNames[i], {Sym::Var, fi.paramCells[i], 0, 0}); + } + expectP("{"); + while (!acceptP("}")) { + if (peek().t == Tok::End) err("unterminated fn body"); + stmt(); + } + pgm_.ret(Imm(0)); + scopes_.pop_back(); + curFn_.clear(); + } + + void checkRecursion() { + // 0 = unvisited, 1 = on the current path, 2 = done. + std::unordered_map color; + for (const auto& [name, fi] : fns_) dfs(name, color); + } + void dfs(const std::string& name, std::unordered_map& c) { + int& col = c[name]; + if (col == 2) return; + if (col == 1) { + throw CeslError("recursion is not supported: call cycle involving '" + + name + "' (line " + + std::to_string(fns_.at(name).line) + ")"); + } + col = 1; + for (const auto& callee : fns_.at(name).callees) dfs(callee, c); + col = 2; + } + + // ---- statements ---- + + void block() { + expectP("{"); + scopes_.push_back({}); + while (!acceptP("}")) { + if (peek().t == Tok::End) err("unterminated block"); + stmt(); + } + scopes_.pop_back(); + } + + void stmt() { + const Token& t = peek(); + if (t.t == Tok::Ident) { + const std::string& k = t.s; + if (k == "fn") err("fn definitions are top-level only"); + if (k == "let") { letStmt(); return; } + if (k == "const") { constStmt(); return; } + if (k == "if") { ifStmt(); return; } + if (k == "while") { whileStmt(); return; } + if (k == "break" || k == "continue") { + next(); + expectP(";"); + if (loops_.empty()) err("'" + k + "' outside a loop"); + pgm_.jmp(k == "break" ? loops_.back().brk : loops_.back().cont); + return; + } + if (k == "return") { returnStmt(); return; } + if (k == "require") { + next(); expectP("("); + expr(); + expectP(")"); expectP(";"); + pgm_.stackOp(OP_ASSERT); + return; + } + if (k == "abort") { + next(); expectP("("); expectP(")"); expectP(";"); + pgm_.abort(); + return; + } + if (k == "poke") { + // Both operands ride the VM stack until all user code has run; + // a temp held across the value expression would be clobbered if + // that expression calls a function (shared temp pool). + next(); expectP("("); + expr(); + expectP(","); + expr(); + expectP(")"); expectP(";"); + uint64_t tV = reserveTmp(); + pgm_.pop(Imm(tV)); + uint64_t tA = reserveTmp(); + pgm_.pop(Imm(tA)); + pgm_.set(Ref(tA), Ref(tV)); + releaseTmp(2); + return; + } + if (k == "setb") { + // STB stack form pops value then byte offset: push offset, value. + next(); expectP("("); + expr(); + expectP(","); + expr(); + expectP(")"); expectP(";"); + pgm_.stackOp(OP_STB); + return; + } + if (k == "copy" || k == "fill") { + next(); expectP("("); + int reserved = 0; + std::vector args = argOperands(3, reserved); + expectP(")"); expectP(";"); + if (k == "copy") pgm_.mov(args[0], args[1], args[2]); + else pgm_.fil(args[0], args[1], args[2]); + releaseTmp(reserved); + return; + } + if (k == "emit_string") { + next(); expectP("("); + const std::string rname = expectIdent(); + Sym* sym = find(rname); + if (!sym || sym->kind != Sym::Region) + err("emit_string target must be a region"); + expectP(","); + if (peek().t != Tok::Str) err("expected string literal"); + const std::string text = next().s; + expectP(")"); expectP(";"); + if (text.size() > sym->count * 8) + err("string does not fit the region"); + pgm_.writeBytesToIo(sym->cell, text); + return; + } + if (isAssignment()) { assignStmt(); return; } + } + // Expression statement: evaluate and drop the value. + expr(); + expectP(";"); + uint64_t td = reserveTmp(); + pgm_.pop(Imm(td)); + releaseTmp(1); + } + + bool isAssignment() const { + if (peek().t != Tok::Ident) return false; + const Token& t1 = peek(1); + if (t1.t != Tok::Punct) return false; + if (t1.s == "=") return true; + if (t1.s != "[") return false; + // scan to the matching ] and check for '=' + int d = 1; + size_t i = pos_ + 2; + while (i < toks_.size() && d > 0) { + const Token& t = toks_[i]; + if (t.t == Tok::Punct) { + if (t.s == "[") d++; + else if (t.s == "]") d--; + } + i++; + } + return i < toks_.size() && toks_[i].t == Tok::Punct && toks_[i].s == "="; + } + + void letStmt() { + next(); // let + const std::string name = expectIdent(); + if (acceptP("[")) { + uint64_t n = 0; + try { + n = constExpr(); + } catch (const NotConst&) { + err("region size must be a constant expression"); + } + expectP("]"); + expectP(";"); + if (n == 0) err("region size must be nonzero"); + define(name, {Sym::Region, allocCells(n), n, 0}); + return; + } + const uint64_t cell = allocCells(1); + define(name, {Sym::Var, cell, 0, 0}); + if (acceptP("=")) { + expr(); + pgm_.pop(Imm(cell)); + } + expectP(";"); + } + + void constStmt() { + next(); // const + const std::string name = expectIdent(); + expectP("="); + uint64_t v = 0; + try { + v = constExpr(); + } catch (const NotConst&) { + err("const initializer must be a constant expression"); + } + expectP(";"); + define(name, {Sym::Const, 0, 0, v}); + } + + void ifStmt() { + next(); // if + expectP("("); + expr(); + expectP(")"); + VmLabel elseL = pgm_.label(); + pgm_.jfStack(elseL); + block(); + if (peek().t == Tok::Ident && peek().s == "else") { + next(); + VmLabel endL = pgm_.label(); + pgm_.jmp(endL); + pgm_.place(elseL); + if (peek().t == Tok::Ident && peek().s == "if") ifStmt(); + else block(); + pgm_.place(endL); + } else { + pgm_.place(elseL); + } + } + + void whileStmt() { + next(); // while + VmLabel top = pgm_.label(); + VmLabel end = pgm_.label(); + pgm_.place(top); + expectP("("); + expr(); + expectP(")"); + pgm_.jfStack(end); + loops_.push_back({end, top}); + block(); + loops_.pop_back(); + pgm_.jmp(top); + pgm_.place(end); + } + + void returnStmt() { + next(); // return + const bool hasVal = !(peek().t == Tok::Punct && peek().s == ";"); + if (hasVal) expr(); + expectP(";"); + if (!curFn_.empty()) { + if (hasVal) { + pgm_.pop(Imm(CESVM_CELL_R)); + pgm_.ret(Ref(CESVM_CELL_R)); + } else { + pgm_.ret(Imm(0)); + } + return; + } + // Top level: the returned value becomes the 8-byte program output. + if (hasVal) { + pgm_.pop(Imm(CESVM_IO_OUTPUT)); + pgm_.set(Imm(CESVM_IO_OUTPUT_LEN), Imm(8)); + } + pgm_.term(); + } + + void assignStmt() { + const std::string name = expectIdent(); + Sym* sym = find(name); + if (!sym) err("unknown identifier '" + name + "'"); + if (acceptP("[")) { + if (sym->kind != Sym::Region) err("'" + name + "' is not a region"); + const size_t save = pos_; + const int saveTmp = tmpDepth_; + try { + uint64_t idx = constExpr(); + expectP("]"); + expectP("="); + if (idx >= sym->count) err("index out of range"); + expr(); + expectP(";"); + pgm_.pop(Imm(sym->cell + idx)); + return; + } catch (const NotConst&) { + pos_ = save; + tmpDepth_ = saveTmp; + } + expr(); // dynamic index + pgm_.push(Imm(sym->cell)); + pgm_.stackOp(OP_ADD); + // The address stays on the VM stack across the right-hand side; a + // temp held here would be clobbered if the RHS calls a function + // (shared temp pool across frames). + expectP("]"); + expectP("="); + expr(); + expectP(";"); + uint64_t tV = reserveTmp(); + pgm_.pop(Imm(tV)); + uint64_t tA = reserveTmp(); + pgm_.pop(Imm(tA)); + pgm_.set(Ref(tA), Ref(tV)); + releaseTmp(2); + return; + } + expectP("="); + if (sym->kind != Sym::Var && sym->kind != Sym::RwVar) + err("cannot assign to '" + name + "'"); + expr(); + expectP(";"); + pgm_.pop(Imm(sym->cell)); + } + + // ---- expressions (leave exactly one value on the VM data stack) ---- + + void expr() { orExpr(); } + + void orExpr() { + andExpr(); + while (acceptP("||")) { + VmLabel Lt = pgm_.label(); + VmLabel Le = pgm_.label(); + pgm_.jtStack(Lt); + andExpr(); + pgm_.stackOp(OP_LNOT); + pgm_.stackOp(OP_LNOT); + pgm_.jmp(Le); + pgm_.place(Lt); + pgm_.push(Imm(1)); + pgm_.place(Le); + } + } + + void andExpr() { + binExpr(0); + while (acceptP("&&")) { + VmLabel Lf = pgm_.label(); + VmLabel Le = pgm_.label(); + pgm_.jfStack(Lf); + binExpr(0); + pgm_.stackOp(OP_LNOT); + pgm_.stackOp(OP_LNOT); + pgm_.jmp(Le); + pgm_.place(Lf); + pgm_.push(Imm(0)); + pgm_.place(Le); + } + } + + const BinOp* matchLevel(size_t lvl) const { + if (peek().t != Tok::Punct) return nullptr; + for (const auto& b : binLevels()[lvl]) { + if (peek().s == b.tok) return &b; + } + return nullptr; + } + + void binExpr(size_t lvl) { + if (lvl >= binLevels().size()) { unaryExpr(); return; } + binExpr(lvl + 1); + while (const BinOp* b = matchLevel(lvl)) { + next(); + binExpr(lvl + 1); + pgm_.stackOp(b->op); + } + } + + void unaryExpr() { + if (peek().t == Tok::Punct) { + if (acceptP("!")) { unaryExpr(); pgm_.stackOp(OP_LNOT); return; } + if (acceptP("~")) { unaryExpr(); pgm_.stackOp(OP_NOT); return; } + if (acceptP("-")) { unaryExpr(); pgm_.stackOp(OP_NEG); return; } + } + primary(); + } + + void primary() { + const Token& t = peek(); + if (t.t == Tok::Num) { next(); pgm_.push(Imm(t.num)); return; } + if (t.t == Tok::Punct && t.s == "(") { + next(); + expr(); + expectP(")"); + return; + } + if (t.t == Tok::Ident) { identExpr(); return; } + err("expected expression"); + } + + void identExpr() { + const std::string name = expectIdent(); + + // Value builtins. + if (name == "random") { callParens0(); pgm_.stackOp(OP_RND); return; } + if (name == "now") { callParens0(); pgm_.stackOp(OP_TIME); return; } + if (name == "getb") { + expectP("("); + expr(); + expectP(")"); + pgm_.stackOp(OP_LDB); + return; + } + if (name == "peek") { + expectP("("); + expr(); + expectP(")"); + uint64_t tA = reserveTmp(); + pgm_.pop(Imm(tA)); + uint64_t tV = reserveTmp(); + pgm_.mov(Imm(tV), Ref(tA), Imm(1)); + pgm_.push(Ref(tV)); + releaseTmp(2); + return; + } + if (name == "memcmp") { + expectP("("); + int reserved = 0; + std::vector args = argOperands(3, reserved); + expectP(")"); + pgm_.cmp(args[0], args[1], args[2]); + releaseTmp(reserved); + pgm_.push(Ref(CESVM_CELL_R)); + return; + } + if (name == "slt" || name == "sgt" || name == "sge" || name == "sle" || + name == "sar") { + expectP("("); + expr(); + expectP(","); + expr(); + expectP(")"); + pgm_.stackOp(name == "slt" ? OP_SLT + : name == "sgt" ? OP_SGT + : name == "sge" ? OP_SGE + : name == "sle" ? OP_SLE + : OP_SAR); + return; + } + + // Syscall builtins: hostxv (abort on error, yield R) or the try_ + // variant: hostv (yield S). + bool tryVariant = false; + std::string sysName = name; + if (name.rfind("try_", 0) == 0) { + tryVariant = true; + sysName = name.substr(4); + } + auto sc = sysTable().find(sysName); + if (sc != sysTable().end() && (tryVariant || name == sysName)) { + expectP("("); + int reserved = 0; + std::vector args = argOperands(sc->second.nargs, reserved); + expectP(")"); + emitHostV(!tryVariant, sc->second.num, args); + releaseTmp(reserved); + pgm_.push(Ref(tryVariant ? CESVM_CELL_S : CESVM_CELL_R)); + return; + } + + // User function call. + auto fit = fns_.find(name); + if (fit != fns_.end()) { + if (peek().t != Tok::Punct || peek().s != "(") + err("function '" + name + "' used as a value"); + next(); + FnInfo& fi = fit->second; + const size_t n = fi.paramCells.size(); + for (size_t i = 0; i < n; ++i) { + if (i) expectP(","); + expr(); + } + expectP(")"); + // Args were evaluated left to right onto the stack; land them in + // the param cells only now, so an argument expression that itself + // calls into this callee's callees cannot clobber them. + for (size_t i = n; i > 0; --i) { + pgm_.pop(Imm(fi.paramCells[i - 1])); + } + if (!curFn_.empty()) fns_.at(curFn_).callees.push_back(name); + pgm_.call(fi.label); + pgm_.push(Ref(CESVM_CELL_R)); + return; + } + + // Plain symbol. + Sym* sym = find(name); + if (!sym) err("unknown identifier '" + name + "'"); + switch (sym->kind) { + case Sym::Const: + pgm_.push(Imm(sym->k)); + return; + case Sym::Var: + case Sym::RoVar: + case Sym::RwVar: + pgm_.push(Ref(sym->cell)); + return; + case Sym::Region: + break; + } + if (acceptP("[")) { + const size_t save = pos_; + const int saveTmp = tmpDepth_; + try { + uint64_t idx = constExpr(); + expectP("]"); + if (idx >= sym->count) err("index out of range"); + pgm_.push(Ref(sym->cell + idx)); + return; + } catch (const NotConst&) { + pos_ = save; + tmpDepth_ = saveTmp; + } + expr(); // dynamic index + expectP("]"); + pgm_.push(Imm(sym->cell)); + pgm_.stackOp(OP_ADD); + uint64_t tA = reserveTmp(); + pgm_.pop(Imm(tA)); + uint64_t tV = reserveTmp(); + pgm_.mov(Imm(tV), Ref(tA), Imm(1)); + pgm_.push(Ref(tV)); + releaseTmp(2); + return; + } + // Bare region name: its first cell index (a pointer). + pgm_.push(Imm(sym->cell)); + } + + void callParens0() { + expectP("("); + expectP(")"); + } + + // Materialize a comma-separated argument list as VmVal operands for a + // register-mode emission (syscalls, cmp/mov/fil). Arguments are + // evaluated left to right onto the VM data stack and landed in temps + // only after the last argument's code has run: the temp pool is + // shared across call frames at runtime, so a temp held across an + // argument expression that calls a function would be clobbered by the + // callee's own temp use. Only immutable atoms (literals, consts, + // region base addresses) are captured directly with no code; + // variables go through the stack so they are read at their textual + // position, not at dispatch time. + std::vector argOperands(size_t n, int& reserved) { + std::vector vals(n); + std::vector onStack(n, false); + for (size_t i = 0; i < n; ++i) { + if (i) expectP(","); + const Token& t = peek(); + const Token& t2 = peek(1); + const bool endsArg = + t2.t == Tok::Punct && (t2.s == "," || t2.s == ")"); + bool trivial = false; + if (endsArg) { + if (t.t == Tok::Num) { + next(); + vals[i] = Imm(t.num); + trivial = true; + } else if (t.t == Tok::Ident) { + Sym* sym = find(t.s); + if (sym && sym->kind == Sym::Const) { + next(); + vals[i] = Imm(sym->k); + trivial = true; + } else if (sym && sym->kind == Sym::Region) { + next(); + vals[i] = Imm(sym->cell); + trivial = true; + } + } + } + if (!trivial) { + expr(); + onStack[i] = true; + } + } + for (size_t i = n; i > 0; --i) { + if (!onStack[i - 1]) continue; + uint64_t tc = reserveTmp(); + reserved++; + pgm_.pop(Imm(tc)); + vals[i - 1] = Ref(tc); + } + return vals; + } + + void emitHostV(bool xv, uint64_t n, const std::vector& a) { + switch (a.size()) { + case 1: hv(xv, n, {a[0]}); break; + case 2: hv(xv, n, {a[0], a[1]}); break; + case 3: hv(xv, n, {a[0], a[1], a[2]}); break; + case 4: hv(xv, n, {a[0], a[1], a[2], a[3]}); break; + case 5: hv(xv, n, {a[0], a[1], a[2], a[3], a[4]}); break; + case 6: hv(xv, n, {a[0], a[1], a[2], a[3], a[4], a[5]}); break; + case 7: hv(xv, n, {a[0], a[1], a[2], a[3], a[4], a[5], a[6]}); break; + default: err("internal: bad syscall arg count"); + } + } + void hv(bool xv, uint64_t n, std::initializer_list a) { + if (xv) pgm_.hostxv(n, a); + else pgm_.hostv(n, a); + } + + // ---- constant expressions (pure; consume the same tokens as expr) ---- + + uint64_t constExpr() { return cOr(); } + + uint64_t cOr() { + uint64_t v = cAnd(); + while (acceptP("||")) v = (v || cAnd()) ? 1 : 0; + return v; + } + uint64_t cAnd() { + uint64_t v = cBin(0); + while (acceptP("&&")) v = (v && cBin(0)) ? 1 : 0; + return v; + } + uint64_t cBin(size_t lvl) { + if (lvl >= binLevels().size()) return cUnary(); + uint64_t v = cBin(lvl + 1); + while (const BinOp* b = matchLevel(lvl)) { + next(); + v = foldOp(b->op, v, cBin(lvl + 1)); + } + return v; + } + uint64_t cUnary() { + if (peek().t == Tok::Punct) { + if (acceptP("!")) return cUnary() == 0 ? 1 : 0; + if (acceptP("~")) return ~cUnary(); + if (acceptP("-")) return 0 - cUnary(); + } + return cPrimary(); + } + uint64_t cPrimary() { + const Token& t = peek(); + if (t.t == Tok::Num) { next(); return t.num; } + if (t.t == Tok::Punct && t.s == "(") { + next(); + uint64_t v = cOr(); + expectP(")"); + return v; + } + if (t.t == Tok::Ident) { + Sym* sym = find(t.s); + if (sym && sym->kind == Sym::Const) { + next(); + return sym->k; + } + } + throw NotConst{}; + } + + uint64_t foldOp(CesVMOpcode op, uint64_t a, uint64_t b) { + const uint64_t kMax = std::numeric_limits::max(); + switch (op) { + case OP_ADDX: + if (a > kMax - b) err("constant expression overflows"); + return a + b; + case OP_SUBX: + if (b > a) err("constant expression underflows"); + return a - b; + case OP_MULX: + if (b != 0 && a > kMax / b) err("constant expression overflows"); + return a * b; + case OP_ADD: return a + b; + case OP_SUB: return a - b; + case OP_MUL: return a * b; + case OP_DIV: + if (b == 0) err("division by zero in constant expression"); + return a / b; + case OP_MOD: + if (b == 0) err("division by zero in constant expression"); + return a % b; + case OP_OR: return a | b; + case OP_XOR: return a ^ b; + case OP_AND: return a & b; + // The VM faults (CESVM_SEGFAULT) on shift counts >= 64; keep the + // fold consistent with runtime semantics by rejecting them. + case OP_SHL: + case OP_SHR: + if (b >= 64) err("shift count >= 64 in constant expression"); + return op == OP_SHL ? a << b : a >> b; + case OP_EQ: return a == b; + case OP_NE: return a != b; + case OP_LT: return a < b; + case OP_GT: return a > b; + case OP_LE: return a <= b; + case OP_GE: return a >= b; + default: err("internal: unexpected fold op"); + } + } +}; + +} // namespace + +ces::Bytes ceslCompile(std::string_view source, uint64_t codeBase) { + return Compiler(source, codeBase).run(); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/ramfilestore.cpp b/vendor/ces/src/ceslib/ramfilestore.cpp new file mode 100644 index 0000000..2133b78 --- /dev/null +++ b/vendor/ces/src/ceslib/ramfilestore.cpp @@ -0,0 +1,605 @@ +#include +#include + + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ces { + +// Random 32-byte chunk keys collide with negligible probability, but a user who +// pins their RNG or reuses a seed could hit repeated ASSET_EXISTS rejections. +// Retry this many times before surfacing the error. +static constexpr int MAX_CHUNK_COLLISION_RETRIES = 10; + +// Upper bound on the bytes ramfileGet pre-reserves from a head's declared +// size, so a corrupt/hostile head can't request a giant allocation up front. +// The output still grows past this if the real chain actually carries more. +static constexpr size_t RAMFILE_GET_RESERVE_CAP = 64u * 1024 * 1024; + +// --- Helpers --- + +minx::Hash sha256(const uint8_t* data, size_t len) { + minx::Hash h; + CryptoPP::SHA256().CalculateDigest(h.data(), data, len); + return h; +} + +static uint64_t nowMicros() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +static bool isZeroHash(const minx::Hash& h) { + for (auto b : h) if (b != 0) return false; + return true; +} + +// All BE serialization goes through ces::Buffer (see ces/buffer.h). + +// --- File format --- + +RamfileHeader parseRamfileHeader(const AssetData& c) { + RamfileHeader h; + if (c[0] != RAMFILE_MAGIC) return h; + h.fileSize = ces::Buffer::peek(&c[1]); + std::memcpy(h.contentHash.data(), &c[9], 32); + h.createdTime = ces::Buffer::peek(&c[RAMFILE_HEAD_CTIME_OFFSET]); + h.modifiedTime = ces::Buffer::peek(&c[RAMFILE_HEAD_MTIME_OFFSET]); + std::memcpy(h.metadata.data(), &c[RAMFILE_HEAD_META_OFFSET], RAMFILE_HEAD_META_SIZE); + std::memcpy(h.firstChunk.data(), &c[RAMFILE_NEXT_OFFSET], RAMFILE_NEXT_SIZE); + h.valid = true; + return h; +} + +AssetData buildRamfileHeader(uint64_t fileSize, const minx::Hash& contentHash, + uint64_t createdTime, uint64_t modifiedTime, + const uint8_t* metadata, size_t metaLen, + const minx::Hash& firstChunk) { + AssetData d{}; + d[0] = RAMFILE_MAGIC; + ces::Buffer::poke(&d[1], fileSize); + std::memcpy(&d[9], contentHash.data(), 32); + ces::Buffer::poke(&d[RAMFILE_HEAD_CTIME_OFFSET], createdTime); + ces::Buffer::poke(&d[RAMFILE_HEAD_MTIME_OFFSET], modifiedTime); + if (metadata && metaLen > 0) { + size_t n = std::min(metaLen, RAMFILE_HEAD_META_SIZE); + std::memcpy(&d[RAMFILE_HEAD_META_OFFSET], metadata, n); + } + std::memcpy(&d[RAMFILE_NEXT_OFFSET], firstChunk.data(), RAMFILE_NEXT_SIZE); + return d; +} + +AssetData buildRamfileChunk(const uint8_t* data, size_t dataLen, + const minx::Hash& nextKey) { + AssetData d{}; + size_t n = std::min(dataLen, RAMFILE_CHUNK_DATA_SIZE); + if (data && n > 0) + std::memcpy(d.data(), data, n); + std::memcpy(&d[RAMFILE_NEXT_OFFSET], nextKey.data(), RAMFILE_NEXT_SIZE); + return d; +} + +// Fetch the head asset at `headKey`, parse the file header, and return it. +// Returns CES_OK on success, CES_ERROR_INTERNAL if the content is not a valid +// file header, or the underlying query error code. +static uint8_t readHead(CesClient& client, const minx::Hash& headKey, + RamfileHeader& outHeader, AssetData* outRawContent = nullptr) { + HashPrefix owner; + AssetData content; + uint16_t balance = 0; + uint32_t price = 0; + uint8_t rc = client.queryAsset(headKey, owner, content, balance, price); + if (rc != CES_OK) return rc; + outHeader = parseRamfileHeader(content); + if (!outHeader.valid) return CES_ERROR_INTERNAL; + if (outRawContent) *outRawContent = content; + return CES_OK; +} + +// Rebuild the head content from `header` and write it via updateAssetFast. +// Bumps `modifiedTime` to now. Pass an explicit `contentHash` (zeros for +// "dirty" / unknown, or an actual hash for rehash). +static uint8_t touchHead(CesClient& client, const minx::Hash& headKey, + RamfileHeader& header, + const minx::Hash& contentHash) { + header.contentHash = contentHash; + header.modifiedTime = nowMicros(); + AssetData headContent = buildRamfileHeader( + header.fileSize, header.contentHash, + header.createdTime, header.modifiedTime, + header.metadata.data(), RAMFILE_HEAD_META_SIZE, + header.firstChunk); + return client.updateAssetFast(headKey, headContent); +} + +// Create a chain of chunks covering `data[0..totalLen)` by splitting it into +// RAMFILE_CHUNK_DATA_SIZE-sized pieces, working backward so each chunk can embed +// the key of its successor. `terminalNextKey` is the "next" pointer of the +// last chunk (zero hash for a new tail, or an existing chunk key when +// prepending before an existing chain). +// +// On success, appends the newly-created chunk keys in FORWARD order (first +// chunk → last chunk) to `outKeys`. Chunk keys are random uint256 values +// with retry-on-collision. Returns CES_OK or the underlying error. +// +static uint8_t createChunkChain(CesClient& client, + const uint8_t* data, size_t totalLen, + uint16_t days, + const minx::Hash& terminalNextKey, + std::vector& outKeys) { + const size_t count = + (totalLen + RAMFILE_CHUNK_DATA_SIZE - 1) / RAMFILE_CHUNK_DATA_SIZE; + if (count == 0) return CES_OK; + + std::mt19937_64 rng(std::random_device{}()); + minx::Hash nextKey = terminalNextKey; + const size_t firstAppendIdx = outKeys.size(); + + for (size_t ri = 0; ri < count; ++ri) { + const size_t i = count - 1 - ri; + const size_t off = i * RAMFILE_CHUNK_DATA_SIZE; + const size_t len = std::min(RAMFILE_CHUNK_DATA_SIZE, totalLen - off); + + auto content = buildRamfileChunk(data + off, len, nextKey); + + minx::Hash chunkKey; + uint8_t rc = CES_ERROR_INTERNAL; + for (int attempt = 0; attempt < MAX_CHUNK_COLLISION_RETRIES; ++attempt) { + for (auto& b : chunkKey) b = static_cast(rng()); + rc = client.createAsset(chunkKey, content, days); + if (rc == CES_OK) break; + if (rc != CES_ERROR_ASSET_EXISTS) return rc; + } + if (rc != CES_OK) return rc; + + nextKey = chunkKey; + outKeys.push_back(chunkKey); + } + + // We pushed backward — flip the newly-added range to forward order. + std::reverse(outKeys.begin() + firstAppendIdx, outKeys.end()); + return CES_OK; +} + +// --- Upload --- + +uint8_t ramfilePut(CesClient& client, const minx::Hash& headKey, + const uint8_t* data, size_t dataLen, + uint16_t days, + const uint8_t* metadata, size_t metaLen, + std::function progress) { + // Create the chunk chain (backward, so each chunk knows its successor). + std::vector chunkKeys; + uint8_t rc = createChunkChain(client, data, dataLen, days, + minx::Hash{}, chunkKeys); + if (rc != CES_OK) return rc; + + const size_t numChunks = chunkKeys.size(); + if (progress) + progress(numChunks, numChunks + 1); // +1 for head + + // Build and create head asset + minx::Hash firstChunkKey = numChunks > 0 ? chunkKeys[0] : minx::Hash{}; + minx::Hash contentHash = sha256(data, dataLen); + uint64_t now = nowMicros(); + auto headContent = buildRamfileHeader(dataLen, contentHash, + now, now, + metadata, metaLen, firstChunkKey); + + rc = client.createAsset(headKey, headContent, days); + if (rc != CES_OK) return rc; + + if (progress) + progress(numChunks + 1, numChunks + 1); + + return CES_OK; +} + +// --- Download --- + +uint8_t ramfileGet(CesClient& client, const minx::Hash& headKey, + ces::Bytes& outData, + RamfileHeader* outHeader, + bool* hashMismatch) { + RamfileHeader header; + uint8_t rc = readHead(client, headKey, header); + if (rc != CES_OK) return rc; + if (outHeader) *outHeader = header; + + outData.clear(); + if (header.fileSize == 0) { + if (hashMismatch) *hashMismatch = false; + return CES_OK; + } + + // Walk chunk chain + outData.reserve(std::min(header.fileSize, RAMFILE_GET_RESERVE_CAP)); + minx::Hash nextKey = header.firstChunk; + std::set visited; + + while (!isZeroHash(nextKey) && outData.size() < header.fileSize) { + if (!visited.insert(nextKey).second) return CES_ERROR_INTERNAL; // cycle + HashPrefix chunkOwner; + AssetData chunkContent; + uint16_t chunkBalance = 0; + uint32_t chunkPrice = 0; + rc = client.queryAsset(nextKey, chunkOwner, chunkContent, chunkBalance, chunkPrice); + if (rc != CES_OK) return rc; + + // Extract payload + size_t remaining = header.fileSize - outData.size(); + size_t take = std::min(remaining, RAMFILE_CHUNK_DATA_SIZE); + outData.insert(outData.end(), chunkContent.begin(), + chunkContent.begin() + take); + + // Follow next pointer + std::memcpy(nextKey.data(), &chunkContent[RAMFILE_NEXT_OFFSET], RAMFILE_NEXT_SIZE); + } + + // Verify hash + if (hashMismatch) { + minx::Hash actual = sha256(outData.data(), outData.size()); + *hashMismatch = (actual != header.contentHash); + } + + return CES_OK; +} + +// --- Fund --- + +uint8_t ramfileFund(CesClient& client, const minx::Hash& headKey, uint16_t days) { + // Fund head + uint8_t rc = client.fundAsset(headKey, days); + if (rc != CES_OK) return rc; + + // Read head to get first chunk + RamfileHeader header; + rc = readHead(client, headKey, header); + if (rc != CES_OK) return rc; + + // Walk and fund each chunk + minx::Hash nextKey = header.firstChunk; + std::set visited; + while (!isZeroHash(nextKey)) { + if (!visited.insert(nextKey).second) return CES_ERROR_INTERNAL; // cycle + rc = client.fundAsset(nextKey, days); + if (rc != CES_OK) return rc; + + // Read chunk to follow chain + HashPrefix chunkOwner; + AssetData chunkContent; + uint16_t chunkBalance = 0; + uint32_t chunkPrice = 0; + rc = client.queryAsset(nextKey, chunkOwner, chunkContent, chunkBalance, chunkPrice); + if (rc != CES_OK) return rc; + + std::memcpy(nextKey.data(), &chunkContent[RAMFILE_NEXT_OFFSET], RAMFILE_NEXT_SIZE); + } + + return CES_OK; +} + +// --- Scan --- + +uint8_t ramfileScan(CesClient& client, const minx::Hash& headKey, + std::vector& outKeys) { + outKeys.clear(); + outKeys.push_back(headKey); + + RamfileHeader header; + uint8_t rc = readHead(client, headKey, header); + if (rc != CES_OK) return rc; + + minx::Hash nextKey = header.firstChunk; + std::set visited; + while (!isZeroHash(nextKey)) { + if (!visited.insert(nextKey).second) return CES_ERROR_INTERNAL; // cycle + outKeys.push_back(nextKey); + + HashPrefix chunkOwner; + AssetData chunkContent; + uint16_t chunkBalance = 0; + uint32_t chunkPrice = 0; + rc = client.queryAsset(nextKey, chunkOwner, chunkContent, chunkBalance, chunkPrice); + if (rc != CES_OK) return rc; + + std::memcpy(nextKey.data(), &chunkContent[RAMFILE_NEXT_OFFSET], RAMFILE_NEXT_SIZE); + } + + return CES_OK; +} + +uint8_t ramfileFundFromScan(CesClient& client, + const std::vector& keys, uint16_t days) { + for (auto& key : keys) { + uint8_t rc = client.fundAsset(key, days); + if (rc != CES_OK) return rc; + } + return CES_OK; +} + +// --- Scan file I/O --- + +static bool isReadableUTF8(const minx::Hash& key) { + // Check if key is a short null-terminated printable ASCII string + size_t len = 0; + for (size_t i = 0; i < key.size(); ++i) { + if (key[i] == 0) break; + if (key[i] < 32 || key[i] > 126) return false; + len = i + 1; + } + return len > 0 && len <= 31; +} + +std::string buildRamfileScanFilename(const minx::Hash& headKey, + const std::string& serverAddr) { + std::string name; + if (isReadableUTF8(headKey)) { + size_t len = 0; + for (size_t i = 0; i < headKey.size() && headKey[i]; ++i) len++; + name = std::string(reinterpret_cast(headKey.data()), len); + // isReadableUTF8 accepts the full printable-ASCII range, including '/' and + // '.', so a key like "../../evil" used verbatim would yield a traversing + // filename. Neutralize path separators and a leading dot so the name stays a + // single in-dir component. + for (auto& c : name) if (c == '/' || c == '\\') c = '_'; + if (!name.empty() && name[0] == '.') name[0] = '_'; + } else { + name = minx::hashToString(headKey); + } + // Replace ':' with '.' in server address (port separator breaks filenames) + std::string safeAddr = serverAddr; + for (auto& c : safeAddr) if (c == ':') c = '.'; + return name + "@" + safeAddr + ".scan"; +} + +void writeRamfileScan(const std::string& path, + const std::vector& keys) { + std::ofstream ofs(path); + for (auto& key : keys) + ofs << minx::hashToString(key) << "\n"; +} + +std::vector readRamfileScan(const std::string& path) { + std::vector keys; + std::ifstream ifs(path); + if (!ifs) return keys; + std::string line; + while (std::getline(ifs, line)) { + if (line.empty()) continue; + minx::Hash h; + try { + minx::stringToHash(h, line); + } catch (const std::exception&) { + continue; // skip a malformed line instead of failing the whole scan + } + keys.push_back(h); + } + return keys; +} + +// --- Read at offset --- + +uint8_t ramfileRead(CesClient& client, const std::vector& keys, + uint64_t offset, uint64_t length, + ces::Bytes& outData) { + if (keys.empty()) return CES_ERROR_INTERNAL; + outData.clear(); + + // Read head to get file size + RamfileHeader header; + uint8_t rc = readHead(client, keys[0], header); + if (rc != CES_OK) return rc; + HashPrefix owner; // used below for chunk queries + + if (offset >= header.fileSize) return CES_OK; // past end + uint64_t available = header.fileSize - offset; + if (length > available) length = available; + outData.reserve(length); + + // Find starting chunk: keys[0] = head (no data), keys[1..] = data chunks + // Each chunk holds RAMFILE_CHUNK_DATA_SIZE bytes + size_t startChunk = offset / RAMFILE_CHUNK_DATA_SIZE; // 0-based chunk index + size_t startByte = offset % RAMFILE_CHUNK_DATA_SIZE; // byte within chunk + size_t chunkKeyIdx = startChunk + 1; // keys[0] is head + + uint64_t remaining = length; + while (remaining > 0 && chunkKeyIdx < keys.size()) { + AssetData chunkContent; + uint16_t cd = 0; uint32_t cp = 0; + rc = client.queryAsset(keys[chunkKeyIdx], owner, chunkContent, cd, cp); + if (rc != CES_OK) return rc; + + size_t chunkDataAvail = RAMFILE_CHUNK_DATA_SIZE - startByte; + size_t take = std::min(static_cast(chunkDataAvail), remaining); + outData.insert(outData.end(), + chunkContent.begin() + startByte, + chunkContent.begin() + startByte + take); + remaining -= take; + startByte = 0; // subsequent chunks start at 0 + ++chunkKeyIdx; + } + + return CES_OK; +} + +// --- Write at offset --- + +// Writes bytes at `offset` into the file's pre-allocated chain. Bounded +// by chain capacity (keys.size() - 1) * RAMFILE_CHUNK_DATA_SIZE, not by the +// declared `header.fileSize`. If the write extends past the current +// declared size, `header.fileSize` is grown to cover the written range. +// The declared size is never shrunk here. +// +// The write walks chunks via the scan file. On the first chunk-level +// failure (query returns non-OK, updateAssetFast returns non-OK — most +// commonly CES_ERROR_NOT_OWNER if ownership of a pre-existing chunk +// has changed), the function stops mid-stream, touches the head (with +// whatever the new fileSize is), and returns the underlying error. +// +// Return semantics: +// CES_OK — every byte was written +// CES_ERROR_INTERNAL — ran out of chain capacity before `dataLen` bytes +// were consumed (no chunk error; just ran out) +// other error — first chunk-level error encountered +// +// Partial-write observability: if the write extended past the old +// `fileSize`, the new `fileSize` reflects exactly how far the write +// reached. If the write was entirely within the old `fileSize` region, +// `fileSize` is unchanged and the caller cannot tell from the header +// alone how much landed — that region is indeterminate on failure. + +uint8_t ramfileWrite(CesClient& client, const std::vector& keys, + uint64_t offset, const uint8_t* data, size_t dataLen) { + if (keys.empty()) return CES_ERROR_INTERNAL; + + RamfileHeader header; + uint8_t rc = readHead(client, keys[0], header); + if (rc != CES_OK) return rc; + + const uint64_t capacity = + static_cast(keys.size() - 1) * RAMFILE_CHUNK_DATA_SIZE; + if (offset > capacity) return CES_ERROR_INTERNAL; + + // Walk chunks from the starting position, writing payload. + size_t chunkKeyIdx = (offset / RAMFILE_CHUNK_DATA_SIZE) + 1; // keys[0] is head + size_t startByte = offset % RAMFILE_CHUNK_DATA_SIZE; + size_t written = 0; + uint8_t writeErr = CES_OK; + + while (written < dataLen && chunkKeyIdx < keys.size()) { + HashPrefix chunkOwner; + AssetData chunkContent; + uint16_t cd = 0; + uint32_t cp = 0; + rc = client.queryAsset(keys[chunkKeyIdx], chunkOwner, chunkContent, + cd, cp); + if (rc != CES_OK) { writeErr = rc; break; } + + size_t space = RAMFILE_CHUNK_DATA_SIZE - startByte; + size_t take = std::min(space, dataLen - written); + std::memcpy(&chunkContent[startByte], data + written, take); + + rc = client.updateAssetFast(keys[chunkKeyIdx], chunkContent); + if (rc != CES_OK) { writeErr = rc; break; } + + written += take; + startByte = 0; + ++chunkKeyIdx; + } + + // Extend fileSize if the write pushed past it. fileSize never shrinks + // here — a short write that overlaps the old live region leaves + // fileSize unchanged and the caller cannot observe the partial write + // from the header alone. + const uint64_t writeEnd = offset + written; + if (writeEnd > header.fileSize) header.fileSize = writeEnd; + + // Always touch the head (zero hash + bump mtime). Even on partial + // writes, the file is now dirty. + rc = touchHead(client, keys[0], header, minx::Hash{}); + if (rc != CES_OK) return rc; + + if (writeErr != CES_OK) return writeErr; + if (written < dataLen) return CES_ERROR_INTERNAL; // ran out of capacity + return CES_OK; +} + +// --- Resize --- + +// Resize the file's declared content size. On grow, allocates new chunks +// only if the target exceeds the current chain capacity, so a shrink +// followed by a grow back within the old capacity costs no chunk work. +// +// On shrink, only the declared size moves; old chunks stay linked in the +// chain as reusable capacity for a future grow or write. + +uint8_t ramfileResize(CesClient& client, std::vector& keys, + uint64_t newSize, uint16_t days) { + if (keys.empty()) return CES_ERROR_INTERNAL; + + RamfileHeader header; + uint8_t rc = readHead(client, keys[0], header); + if (rc != CES_OK) return rc; + + if (newSize == header.fileSize) return CES_OK; // nop + + if (newSize > header.fileSize) { + // Check current chain capacity (total bytes the existing chunks + // can hold, ignoring declared size). + const uint64_t capacity = + static_cast(keys.size() - 1) * RAMFILE_CHUNK_DATA_SIZE; + + if (newSize > capacity) { + // Need to extend the chain. Allocate enough new zero-filled + // chunks to cover the shortfall. Backward-chained via + // createChunkChain so each chunk carries its successor's key. + const uint64_t extraBytes = newSize - capacity; + const size_t extraChunksBytes = + static_cast( + ((extraBytes + RAMFILE_CHUNK_DATA_SIZE - 1) / RAMFILE_CHUNK_DATA_SIZE) + * RAMFILE_CHUNK_DATA_SIZE); + ces::Bytes zeros(extraChunksBytes, 0); + + std::vector newKeys; + rc = createChunkChain(client, zeros.data(), zeros.size(), + days, minx::Hash{}, newKeys); + if (rc != CES_OK) return rc; + if (newKeys.empty()) return CES_ERROR_INTERNAL; + + // Splice the new chunks onto the existing chain's tail. If the + // old chain was empty (keys.size() == 1, head-only), patch the + // head's firstChunk field. Otherwise patch the previous last + // chunk's next-pointer to point at the first new chunk. + if (keys.size() == 1) { + header.firstChunk = newKeys.front(); + } else { + HashPrefix owner; + AssetData tailContent; + uint16_t cd = 0; + uint32_t cp = 0; + rc = client.queryAsset(keys.back(), owner, tailContent, cd, cp); + if (rc != CES_OK) return rc; + std::memcpy(&tailContent[RAMFILE_NEXT_OFFSET], + newKeys.front().data(), RAMFILE_NEXT_SIZE); + rc = client.updateAssetFast(keys.back(), tailContent); + if (rc != CES_OK) return rc; + } + + keys.insert(keys.end(), newKeys.begin(), newKeys.end()); + } + + header.fileSize = newSize; + return touchHead(client, keys[0], header, minx::Hash{}); + } + + // Truncate: just update the size in the head. Old chunks stay on disk + // (they expire naturally via rent, or remain as dangling capacity for + // a future ramfileResize grow / ramfileWrite extend). ramfileGet / ramfileRead + // respect fileSize and stop reading. + header.fileSize = newSize; + return touchHead(client, keys[0], header, minx::Hash{}); +} + +// --- Rehash --- + +uint8_t ramfileRehash(CesClient& client, const minx::Hash& headKey) { + ces::Bytes data; + RamfileHeader header; + uint8_t rc = ramfileGet(client, headKey, data, &header, nullptr); + if (rc != CES_OK) return rc; + + return touchHead(client, headKey, header, sha256(data.data(), data.size())); +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/server.cpp b/vendor/ces/src/ceslib/server.cpp new file mode 100644 index 0000000..c59f3c7 --- /dev/null +++ b/vendor/ces/src/ceslib/server.cpp @@ -0,0 +1,8598 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +LOG_MODULE("csv"); + +namespace ces { + +constexpr size_t RANDOMX_VMS_TO_KEEP = 256; + +// Spam filter (MINX count-min sketch, bucketed per /24 or /56). Servers run the +// max threshold; with 1-in-SPAM_SAMPLE_RATE sampling on handshaked packets that +// is a ~33.5M packets/window budget — the server-grade setting. (P2P nodes use a +// far lower threshold; MINX treats threshold 0 as disabled.) +constexpr uint16_t SPAM_SAMPLE_RATE = 512; + +// Upper bound on `taskThreads` config value (sanity check). +constexpr int MAX_TASK_THREADS = 256; + +// Cap on accounts logged at DEBUG during startup. +constexpr int BOOT_ACCOUNT_DUMP_MAX = 32; + +// Refuse to delegate PoW verification when the MINX work queue exceeds this. +constexpr size_t POW_QUEUE_DELEGATE_LIMIT = 50000; + +// Throttle: only refresh the cached PoW queue size every N seconds. +constexpr uint64_t POW_QUEUE_REFRESH_SECS = 3; + +// PoW mining reward base: amount = (1 << (difficulty - 1)) * POW_REWARD_BASE. +constexpr uint64_t POW_REWARD_BASE = 1000; +// Ceiling on the difficulty used for the mint reward. (1<<(D-1))*POW_REWARD_BASE +// must not overflow uint64 (and the shift must stay < 64). With base≈2^10, D=54 +// gives (1<<53)*1000 ≈ 9.0e18, well under 2^64; D≥56 overflows and D≥65 +// is shift UB. A higher-difficulty solution still mints — capped at this reward. +// The exponential reward is bounded; the solution's validity is not. +constexpr int MAX_POW_DIFFICULTY = 54; + +// Reply retransmission schedule (UDP loss recovery on server -> client). +// Each delay is relative to the previous retransmission, not the original send. +constexpr int REPLY_FAST_DELAY_MS = 250; // first retransmit after initial +constexpr int REPLY_SLOW_DELAY_MS = 1750; // second retransmit after first +constexpr int REPLY_TIMER_INITIAL_MS = 50; // startup tick +constexpr int REPLY_TIMER_TICK_MS = 20; // steady-state tick + +// Daily maintenance timer: fires at 09:00 UTC every day. +constexpr uint64_t SECS_PER_DAY = 86400; +constexpr uint64_t DAILY_MAINTENANCE_HOUR_UTC = 9; +// Network throughput is metered per KiB (see feeNetKiB* sentinel note). +constexpr uint64_t BYTES_PER_KIB = 1024; + +// Peer miner policy: skip peers whose minimum difficulty exceeds our own +// minDifficulty by more than MARGIN, or whose absolute difficulty exceeds MAX. +constexpr uint8_t PEER_MINER_DIFF_MARGIN = 6; +constexpr uint8_t PEER_MINER_DIFF_MAX = 24; +// How many difficulty levels above a peer's own minimum the smart-difficulty +// miner may scale UP to when sizing a single solution to the remaining credit +// gap. This bound is RELATIVE to the peer's floor, not absolute, on purpose: a +// solution N levels up costs 2^N times the per-solution RandomX work the peer's +// network is calibrated for, so an absolute cap would let a low-minDiff peer +// pull the miner into a solution thousands of times too large — locking the +// thread for hours/days on one peer and starving every other peer of its +// reserve top-up. Bounding it at +5 keeps each solution cheap enough that the +// cycle returns quickly and the miner fills every peer's quota round-robin. +constexpr uint8_t PEER_MINER_MAX_DIFF_ABOVE = 5; + +constexpr const char* ACCOUNTS_DATA_SUBDIRECTORY = "accounts"; +constexpr const char* ASSETS_DATA_SUBDIRECTORY = "assets"; +constexpr const char* ALIASES_DATA_SUBDIRECTORY = "aliases"; +constexpr const char* KEYNAMES_DATA_SUBDIRECTORY = "keynames"; + +// Asset-range cell conventions, shared by the VM syscall host and the +// CES_CREATE_ASSET_RANGE handler: cell i's key is prefix||i with a native index +// in the last 8 bytes; cell 0's content carries the range length as a native +// uint32 at bytes 0..3. +static void rangeSetCellIndex(minx::Hash& key, uint64_t index) { + std::memcpy(key.data() + 24, &index, sizeof(index)); +} +static void rangeSetLength(AssetData& content, uint32_t count) { + std::memcpy(content.data(), &count, sizeof(count)); +} + +// Shared write-auth check for VM host callbacks. +// - Immutable assets: nobody may write. +// - Asset-owned assets: only the executing boot asset itself can write. +// - Account-owned assets: runner or program owner can write. +// The `owner == programOwner` branch is a capability: it lets the run +// rewrite programOwner's assets without programOwner signing. Sound only +// while programOwner has consented to this bytecode (see the programOwner +// field doc in cesvm.h). A run executing code its programOwner did not +// consent to must pass programOwner empty, so only this branch's +// caller-owned assets remain writable. +// Returns CES_OK, CES_ERROR_IMMUTABLE, or CES_ERROR_NOT_OWNER. +static inline uint8_t checkAssetWriteAuth(const Asset& asset, + const HashPrefix& caller, + const HashPrefix& programOwner, + const minx::Hash& selfAssetKey) { + // IMMUTABLE seals content; no caller (not even owner) may rewrite. + if (isAssetImmutable(asset.getBalance())) + return CES_ERROR_IMMUTABLE; + const auto& owner = asset.getOwnerId(); + if (isAssetOwned(asset.getBalance())) { + if (owner != Account::getMapKey(selfAssetKey)) + return CES_ERROR_NOT_OWNER; + } else { + if (owner != caller && owner != programOwner) + return CES_ERROR_NOT_OWNER; + } + return CES_OK; +} + +// Saturating credit: add `amount` to a balance, clamping at BALANCE_MAX (the +// int48 account cap) instead of wrapping — matching the wire path's +// ActiveAccount::credit so the VM-host and wire credit paths never diverge on +// overflow. Reachable only at ~1.4M credits on one account; keeps the two +// paths symmetric and stops a credit from overflowing the 48-bit field. +static inline int64_t saturatingAddBalance(int64_t cur, uint64_t amount) { + if (cur >= 0 && + static_cast(BALANCE_MAX - cur) < amount) + return BALANCE_MAX; + return cur + static_cast(amount); +} + +// =========================================================================== +// SYS_RPC dispatcher — helpers + RpcSession class +// =========================================================================== +// +// Defined here (above the CesServer constructor / start / stop) +// alongside the three-stage dispatcher trio (queueRpc / executeRpc / +// completeRpc, further down in the file). Each RpcSession owns a +// RudpStream that IS the per-channel handler — RUDP routes inbound +// bytes into the stream directly via the new ChannelHandler API, so +// the server's listener has nothing to demux at receive time. + +namespace { + +// Walk a cesh file chain via the server's asset store (no client +// round trip), returning `header.fileSize` bytes of payload. Pure +// read, runs on the logic strand. Returns CES_OK with outBytes +// populated, or an error code. +uint8_t readFileChunkBytes(Assets::AssetStore& store, + const minx::Hash& headKey, + ces::Bytes& outBytes, + size_t maxBytes = std::numeric_limits::max()) { + auto it = store.find(headKey); + if (it == store.end()) return CES_ERROR_ASSET_NOT_FOUND; + AssetData headContent = it->second.getContent(); + RamfileHeader header = parseRamfileHeader(headContent); + if (!header.valid) return CES_ERROR_INTERNAL; + // Enforce the caller's cap BEFORE reserve() — a hostile file header + // with fileSize = 4 GiB would otherwise trigger that allocation. + if (header.fileSize > maxBytes) return CES_ERROR_INSUFFICIENT_PAYMENT; + if (header.fileSize == 0) { outBytes.clear(); return CES_OK; } + outBytes.clear(); + outBytes.reserve(header.fileSize); + minx::Hash nextKey = header.firstChunk; + uint64_t remaining = header.fileSize; + while (remaining > 0) { + bool zero = true; + for (auto b : nextKey) if (b) { zero = false; break; } + if (zero) break; + auto chunkIt = store.find(nextKey); + if (chunkIt == store.end()) return CES_ERROR_INTERNAL; + AssetData chunkContent = chunkIt->second.getContent(); + size_t take = std::min( + remaining, static_cast(RAMFILE_CHUNK_DATA_SIZE)); + outBytes.insert(outBytes.end(), + chunkContent.begin(), + chunkContent.begin() + take); + remaining -= take; + std::memcpy(nextKey.data(), &chunkContent[RAMFILE_NEXT_OFFSET], + RAMFILE_NEXT_SIZE); + } + return CES_OK; +} + +} // anonymous namespace + +// --------------------------------------------------------------------------- +// RpcSession: one outbound SYS_RPC call in flight +// --------------------------------------------------------------------------- + +class CesServer::RpcSession + : public std::enable_shared_from_this { +public: + using Callback = std::function; + + RpcSession(boost::asio::io_context& io, + minx::Rudp& rudp, + const minx::SockAddr& peer, + uint32_t channelId, + ces::Bytes requestBody, + uint32_t responseTimeoutMs, + size_t maxResponseBytes, + ChannelMeter* netBilling, + const ces::KeyPair& serverKey, + Callback cb) + : io_(io), + rudp_(rudp), + peer_(peer), + channelId_(channelId), + stream_(std::make_shared(io.get_executor())), + requestBody_(std::move(requestBody)), + responseTimeoutMs_(responseTimeoutMs), + maxResponseBytes_(maxResponseBytes), + channelMeter_(netBilling), + serverKey_(serverKey), + cb_(std::move(cb)), + timeoutTimer_(io) {} + + // Kick off the request/response exchange. Must be called on + // rpcTaskIO_ after the session is registered in rpcSessions_. + // + // Wire sequence: + // + // 1. Send CesPlex select header: [u16 BE 11]["/ces/rpc/1"] + // This commits the channel to the "rpc" protocol via CesPlex's + // multiplexer, so a single secondary port can carry multiple + // protocols. (See include/ces/cesplex/mux.h for the mux + // wire format.) + // + // 2. Read back one status byte: + // 0x01 = OK -> proceed to step 3 + // 0x00 = NACK -> fail with CES_ERROR_PROTO_REJECTED + // NACK means the target doesn't mount /ces/rpc/1. CES servers + // don't mount it by default — rpc is an outbound-only capability + // on CES. Content-server binaries and the test MockRpcServer do. + // + // 3. Write the signed envelope. + // 4. Read [u32 BE len][body] response. + // + // Costs one extra RTT (steps 1-2) for a uniform L2 bus where every + // protocol on the secondary port, including rpc, is gated by the same + // select handshake. + void start() { + // Seed Rudp's clock before registerChannel — registerChannel + // stamps the channel's lastActivityUs from currentTimeUs_, + // which is zero until the first tick(). The next tick() would + // then jump the clock and idle-GC the fresh channel. + rudp_.tick(getMicrosSinceEpoch()); + // Bind the stream to the channel — RUDP starts the handshake on + // the next pulse and routes per-channel events into the stream + // automatically. Failure means the per-peer channel cap is + // exhausted; surface as INTERNAL. + if (!rudp_.registerChannel(peer_, channelId_, stream_)) { + finish(CES_ERROR_INTERNAL, {}); + return; + } + // ChannelMeter tracking happens AFTER the bind reply lands — + // we need to know the bound rate schedule the remote committed + // before we can bill against it. See trackOnBindOk(). + arm_timeout(); + write_signed_bind(); + } + + const minx::SockAddr& peer() const { return peer_; } + uint32_t channelId() const { return channelId_; } + +private: + // Hard-coded CesPlex protocol name for the rpc protocol. Matches + // the builtin registration on the receive side. + static constexpr const char* kRpcProtoName = "/ces/rpc/1"; + + boost::asio::io_context& io_; + minx::Rudp& rudp_; + minx::SockAddr peer_; + uint32_t channelId_; + std::shared_ptr stream_; + ces::Bytes requestBody_; + uint32_t responseTimeoutMs_; + size_t maxResponseBytes_; + ChannelMeter* channelMeter_; + const ces::KeyPair& serverKey_; + Callback cb_; + boost::asio::steady_timer timeoutTimer_; + // Wire buffers (signed bind contract). + minx::Bytes bindReqBuf_; + std::array bindReplyBuf_{}; + std::array bindReqDigest_{}; + ces::Bytes requestWireBuf_; + std::array respLenBuf_{}; + ces::Bytes respBodyBuf_; + bool finished_ = false; + + // Send the signed bind preamble for /ces/rpc/1, signed by the + // server's keypair (we are server-as-client here). + void write_signed_bind() { + const uint64_t nowUs = getMicrosSinceEpoch(); + bindReqBuf_ = ces::buildBindRequest(kRpcProtoName, nowUs, serverKey_); + // Stash the digest so we can verify the reply binds to it. + { + const auto& pkArr = serverKey_.getPublicKeyAsHash(); + std::span nameSpan( + reinterpret_cast(kRpcProtoName), + std::strlen(kRpcProtoName)); + bindReqDigest_ = ces::computeBindRequestDigest( + nameSpan, nowUs, + std::span(pkArr.data(), pkArr.size())); + } + auto self = shared_from_this(); + boost::asio::async_write( + *stream_, boost::asio::buffer(bindReqBuf_), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->finish(CES_ERROR_INTERNAL, {}); return; } + self->read_signed_bind_reply(); + }); + } + + void read_signed_bind_reply() { + auto self = shared_from_this(); + boost::asio::async_read( + *stream_, boost::asio::buffer(bindReplyBuf_), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->finish(CES_ERROR_INTERNAL, {}); return; } + auto r = ces::parseBindReply( + std::span( + self->bindReplyBuf_.data(), self->bindReplyBuf_.size())); + // Reject NACK before any sig check (server may have NACKed + // before reading our preamble; signed-NACK contract is best- + // effort). + if (r.status != ces::CES_PLEX_OK) { + self->finish(CES_ERROR_PROTO_REJECTED, {}); + return; + } + if (!ces::verifyBindReply( + r, + std::span(self->bindReqDigest_.data(), + self->bindReqDigest_.size()))) { + self->finish(CES_ERROR_INTERNAL, {}); + return; + } + // (No expected-pubkey check in this code path — accept the + // key the server presented; SYS_RPC callers don't pre-know + // their target server's identity.) + // Bind reply landed; register the outbound channel with the + // local ChannelMeter for delta tracking. Billing uses the + // local CesConfig rates (consistent with inbound channels) + // and the local server's own pubkey as payer — which is the + // bottomless server-self account, so the debit is effectively + // observability. The remote's disclosed rates from the bind + // reply are not enforced locally; the remote's own ledger + // bills however it likes on its end. + if (self->channelMeter_) { + std::ostringstream tag; + tag << "rpc-out:" << self->peer_; + self->channelMeter_->track( + self->peer_, self->channelId_, tag.str(), + getHashPrefix(self->serverKey_.getPublicKeyAsHash())); + } + self->write_request_body(); + }); + } + + void write_request_body() { + // Wire layout post-bind: [u32 body_len][body bytes]. + requestWireBuf_.clear(); + requestWireBuf_.reserve(sizeof(uint32_t) + requestBody_.size()); + ces::Buffer::put( + requestWireBuf_, static_cast(requestBody_.size())); + ces::Buffer::putBytes( + requestWireBuf_, std::span(requestBody_)); + auto self = shared_from_this(); + boost::asio::async_write( + *stream_, boost::asio::buffer(requestWireBuf_), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->finish(CES_ERROR_INTERNAL, {}); return; } + self->read_length(); + }); + } + + void arm_timeout() { + auto self = shared_from_this(); + timeoutTimer_.expires_after( + std::chrono::milliseconds(responseTimeoutMs_)); + timeoutTimer_.async_wait( + [self](const boost::system::error_code& ec) { + if (ec) return; // cancelled + self->finish(CES_ERROR_TIMEOUT, {}); + }); + } + + void read_length() { + auto self = shared_from_this(); + boost::asio::async_read( + *stream_, boost::asio::buffer(respLenBuf_), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->finish(CES_ERROR_INTERNAL, {}); return; } + uint32_t len = ces::Buffer::peek( + std::span(self->respLenBuf_), 0); + if (len > self->maxResponseBytes_) { + self->finish(CES_ERROR_INTERNAL, {}); + return; + } + if (len == 0) { + self->finish(CES_OK, {}); + return; + } + self->respBodyBuf_.resize(len); + self->read_body(); + }); + } + + void read_body() { + auto self = shared_from_this(); + boost::asio::async_read( + *stream_, boost::asio::buffer(respBodyBuf_), + [self](const boost::system::error_code& ec, std::size_t) { + if (ec) { self->finish(CES_ERROR_INTERNAL, {}); return; } + self->finish(CES_OK, std::move(self->respBodyBuf_)); + }); + } + + void finish(uint8_t rc, ces::Bytes body) { + if (finished_) return; + finished_ = true; + boost::system::error_code ec; + timeoutTimer_.cancel(); + if (stream_) stream_->close(); + if (cb_) { + cb_(rc, std::move(body)); + cb_ = nullptr; + } + } +}; + +// --------------------------------------------------------------------------- +// CesServer::VmHost — production CesVMHost. Constructed inline at each VM +// run site with a CesServer& and a VmHostSetup that carries the per-run +// policy (undo-log hooks, deferred sinks, allowance, verifySig toggle). +// One vtable, no per-run closure construction. +// --------------------------------------------------------------------------- + +class CesServer::VmHost final : public CesVMHost { +public: + VmHost(CesServer& server, const VmHostSetup& setup) + : server_(server), + caller_(setup.callerPrefix), + programOwnerPrefix_(setup.programOwnerPrefix), + saveAccountFn_(setup.saveAccountFn), + saveAssetFn_(setup.saveAssetFn), + saveAliasFn_(setup.saveAliasFn), + udpSink_(setup.sendUdpFn), + crossSink_(setup.crossTransferFn), + l2Sink_(setup.l2CallFn), + scheduleSink_(setup.scheduleFn), + scheduleAliasSink_(setup.scheduleAliasFn), + creditHookSink_(setup.creditHookFn), + enableVerifySig_(setup.enableVerifySig) { + this->allowance = setup.allowance; + // Per-op fees mirror the protocol-side handlers (transfer/createAsset/...). + // Each VM syscall that mutates or reads the ledger debits the same fee + // its UDP-equivalent would debit, on top of the gas cost — otherwise a + // VM run would be a multi-thousand-x discount on every state operation + // and bypass the rent and tx-fee economics. Flat-discounted + // fees ship to the VM with the multiplier already applied; syscall + // handlers bill them as-is. + this->feeQuery = server_.discountFee(FeeKind::Query, server_.cfg_.feeQuery); + this->feeTx = server_.discountFee(FeeKind::Tx, server_.cfg_.feeTx); + this->feeAsset = server_.discountFee(FeeKind::AssetRent, server_.cfg_.feeAsset); + this->feeAccount = server_.discountFee(FeeKind::AccountRent, server_.cfg_.feeAccount); + this->feeAlias = server_.discountFee(FeeKind::AccountRent, + server_.cfg_.feeAccount * ALIAS_BYTES / ACCOUNT_BYTES); + // SYS_SEND_CLIENT has no UDP equivalent; pick a placeholder bounded by + // existing fees. TODO: resolve this fee/cost properly — outbound push + // is its own resource (presence cache + UDP bandwidth) and probably + // wants its own config knob with a per-recipient rate cap. + this->feeSendClient = server_.discountFee(FeeKind::Tx, + std::min(server_.cfg_.feeQuery * 10, server_.cfg_.feeTx)); + // Inputs for attenuated prepay math (CREATE/FUND asset). The bp is + // snapshotted at VmHost construction (start of the program run), + // not re-read per syscall. A program's view of "now" is the moment + // it started; the metrics tick refreshes the multiplier at 1 Hz so + // any drift across a sub-second VM run is invisible. Scheduled and + // RPC-followup runs construct a fresh VmHost at fire time, so they + // see today's bp too. + this->feeAssetRaw = server_.cfg_.feeAsset; + this->assetRentMultBp = server_.getFeeMult(FeeKind::AssetRent); + } + + // ---- Reads -------------------------------------------------------------- + int64_t readAccountBalance(const HashPrefix& id) override { + auto aa = server_.accounts_.get(id); + return aa.exists() ? aa.balance() : 0; + } + uint32_t readAccountNonce(const HashPrefix& id) override { + auto aa = server_.accounts_.get(id); + return aa.exists() ? aa.nonce() : 0; + } + uint32_t readAccountAliasId(const HashPrefix& id) override { + auto aa = server_.accounts_.get(id); + return aa.exists() ? aa.data().getAliasId() : 0; + } + bool readAlias(uint32_t id, uint32_t offset, uint32_t len, + uint8_t* dest) override { + Alias al; + if (!server_.queryAlias(id, al)) return false; + if (offset > ALIAS_VALUE_BYTES || len > ALIAS_VALUE_BYTES || + offset + len > ALIAS_VALUE_BYTES) + return false; + if (len > 0) std::memcpy(dest, al.imageData() + offset, len); + return true; + } + bool readAsset(const minx::Hash& key, HashPrefix& owner, AssetData& content, + uint16_t& balance, uint32_t& price) override { + auto aa = server_.assets_.get(key); + if (!aa.exists()) return false; + owner = aa.data().getOwnerId(); + content = aa.data().getContent(); + // Raw 16-bit balance: bits 0..11 days (0..4095), bit 12 owner-pays, bit 13 + // immut, bit 14 aowned, bit 15 priv. Programs mask with 0x0FFF for days; + // the unstripped form is what lets them branch on the flag bits at all. + balance = aa.data().getBalance(); + price = aa.data().getPrice(); + return true; + } + + // ---- Caller debit chokepoint -------------------------------------------- + uint8_t debitCaller(uint64_t amount) override { + if (amount == 0) return CES_OK; + if (amount > this->allowance) return CES_ERROR_ALLOWANCE_EXCEEDED; + auto callerIt = server_.accounts_->find(caller_); + if (callerIt == server_.accounts_->end()) return CES_ERROR_ORIGIN_NOT_FOUND; + if (callerIt->second.getBalance() < static_cast(amount)) + return CES_ERROR_INSUFFICIENT_BALANCE; + maybeSaveAccount(caller_); + callerIt->second.setBalance(callerIt->second.getBalance() - + static_cast(amount)); + // Saturate at 0 if we're already at UINT64_MAX (no enforcement) so the + // io-mirrored remaining-allowance value still makes sense for programs + // that read it without prior knowledge of the sentinel. + if (this->allowance != std::numeric_limits::max()) + this->allowance -= amount; + return CES_OK; + } + + // ---- Value-bearing writes ---------------------------------------------- + uint8_t transfer(const minx::Hash& dest, uint64_t amount) override { + if (uint8_t rc = checkCredit(dest, amount); rc != CES_OK) return rc; + if (uint8_t rc = debitCaller(amount); rc != CES_OK) return rc; + creditDest(dest, amount); + return CES_OK; + } + + uint8_t ownerTransfer(const minx::Hash& dest, uint64_t amount) override { + if (uint8_t rc = checkCredit(dest, amount); rc != CES_OK) return rc; + if (uint8_t rc = debitProgramOwner(amount); rc != CES_OK) return rc; + creditDest(dest, amount); + return CES_OK; + } + + uint8_t deposit(uint64_t amount) override { + if (uint8_t rc = checkCredit(programOwnerPrefix_, amount); rc != CES_OK) return rc; + if (uint8_t rc = debitCaller(amount); rc != CES_OK) return rc; + auto ownerIt = server_.accounts_->find(programOwnerPrefix_); + if (ownerIt == server_.accounts_->end()) return CES_ERROR_ORIGIN_NOT_FOUND; + maybeSaveAccount(programOwnerPrefix_); + ownerIt->second.setBalance( + saturatingAddBalance(ownerIt->second.getBalance(), amount)); + return CES_OK; + } + + uint8_t withdraw(uint64_t amount) override { + // programOwner -> caller. Not allowance-bound (the owner deployed the + // bytecode, so they consented). Caller is the recipient — must exist + // (they signed the run) so the credit always lands. + if (uint8_t rc = checkCredit(caller_, amount); rc != CES_OK) return rc; + if (uint8_t rc = debitProgramOwner(amount); rc != CES_OK) return rc; + auto callerIt = server_.accounts_->find(caller_); + if (callerIt == server_.accounts_->end()) return CES_ERROR_ORIGIN_NOT_FOUND; + maybeSaveAccount(caller_); + callerIt->second.setBalance( + saturatingAddBalance(callerIt->second.getBalance(), amount)); + return CES_OK; + } + + uint64_t refillGas(uint64_t requested) override { + if (this->refillCeiling == 0) return 0; + uint64_t room = this->refillCeiling > this->refilledTotal + ? this->refillCeiling - this->refilledTotal : 0; + uint64_t granted = std::min(requested, room); + // Cap by what the caller can cover for the post-run charge: keep the + // granted total at or below the account balance, so the (<= granted-total) + // post-run debit in executeVmRun always clears. No debit here. + auto it = server_.accounts_->find(caller_); + int64_t bal = (it != server_.accounts_->end()) ? it->second.getBalance() : 0; + uint64_t avail = bal > static_cast(this->refilledTotal) + ? static_cast(bal) - this->refilledTotal : 0; + granted = std::min(granted, avail); + this->refilledTotal += granted; + return granted; + } + + // ---- Asset writes ------------------------------------------------------- + uint8_t createAsset(const minx::Hash& key, const AssetData& content, + uint16_t days) override { + if (key == minx::Hash{}) return CES_ERROR_BAD_INPUT; // reserved sentinel + auto it = server_.assets_->find(key); + if (it != server_.assets_->end()) return CES_ERROR_ASSET_EXISTS; + maybeSaveAsset(key); + bool priv = isAssetPrivate(days); + bool immut = isAssetImmutable(days); + uint32_t storeDays = 1u + assetDays(days); + if (storeDays > 0x0FFF) storeDays = 0x0FFF; + Asset newAsset(caller_, content, + assetBalance(static_cast(storeDays), priv, + /*aowned=*/false, immut, + isAssetOwnerPays(days)), 0); + server_.assets_->getObjects().emplace(key, newAsset); + return CES_OK; + } + + uint8_t createAssetManaged(const minx::Hash& key, const AssetData& content, + uint16_t days) override { + if (key == minx::Hash{}) return CES_ERROR_BAD_INPUT; // reserved sentinel + auto it = server_.assets_->find(key); + if (it != server_.assets_->end()) return CES_ERROR_ASSET_EXISTS; + maybeSaveAsset(key); + // No boot asset (self is the zero sentinel): nothing for a managed + // asset to be owned by. + if (this->selfAssetKey == minx::Hash{}) return CES_ERROR_DISABLED; + bool priv = isAssetPrivate(days); + bool immut = isAssetImmutable(days); + uint32_t storeDays = 1u + assetDays(days); + if (storeDays > 0x0FFF) storeDays = 0x0FFF; + HashPrefix bootOwner = Account::getMapKey(this->selfAssetKey); + Asset newAsset(bootOwner, content, + assetBalance(static_cast(storeDays), priv, + /*aowned=*/true, immut, + isAssetOwnerPays(days)), 0); + server_.assets_->getObjects().emplace(key, newAsset); + return CES_OK; + } + + // Atomically create n account-owned cells keyed firstKey with its last 8 + // bytes = 0..n-1 written in native order (matching CesVM readIoBytes/ + // writeIoBytes, so a program's counter cell drops straight into the key). + // Collision-checked: any existing target key abandons the batch untouched so + // the caller can retry a fresh prefix. Cell 0 carries uint32_t n at + // content[0..3], native. + uint8_t createAssetRange(const minx::Hash& firstKey, uint32_t n, + uint16_t days) override { + minx::Hash key = firstKey; + for (uint32_t i = 0; i < n; ++i) { + rangeSetCellIndex(key, i); + if (server_.assets_->find(key) != server_.assets_->end()) + return CES_ERROR_ASSET_EXISTS; + } + bool priv = isAssetPrivate(days); + bool immut = isAssetImmutable(days); + uint32_t storeDays = 1u + assetDays(days); + if (storeDays > 0x0FFF) storeDays = 0x0FFF; + auto bal = assetBalance(static_cast(storeDays), priv, + /*aowned=*/false, immut, isAssetOwnerPays(days)); + for (uint32_t i = 0; i < n; ++i) { + rangeSetCellIndex(key, i); + maybeSaveAsset(key); + AssetData content{}; + if (i == 0) rangeSetLength(content, n); + server_.assets_->getObjects().emplace(key, Asset(caller_, content, bal, 0)); + } + return CES_OK; + } + + uint8_t updateAsset(const minx::Hash& key, const AssetData& content) override { + auto it = server_.assets_->find(key); + if (it == server_.assets_->end()) return CES_ERROR_ASSET_NOT_FOUND; + uint8_t authRc = checkAssetWriteAuth( + it->second, caller_, programOwnerPrefix_, this->selfAssetKey); + if (authRc != CES_OK) return authRc; + maybeSaveAsset(key); + it->second.setContent(content); + return CES_OK; + } + + uint8_t updateAssetMeta(const minx::Hash& key, const HashPrefix& newOwner, + uint32_t price) override { + auto it = server_.assets_->find(key); + if (it == server_.assets_->end()) return CES_ERROR_ASSET_NOT_FOUND; + // Use the same write-auth path as updateAsset/giveAsset — the VM + // permits programOwner and asset-owned chains, not just strict caller + // ownership the way the wire CES_UPDATE_ASSET_META does. + uint8_t authRc = checkAssetWriteAuth( + it->second, caller_, programOwnerPrefix_, this->selfAssetKey); + if (authRc != CES_OK) return authRc; + maybeSaveAsset(key); + it->second.setOwnerId(newOwner); + it->second.setPrice(price); + return CES_OK; + } + + uint8_t fundAsset(const minx::Hash& key, uint16_t days) override { + auto it = server_.assets_->find(key); + if (it == server_.assets_->end()) return CES_ERROR_ASSET_NOT_FOUND; + maybeSaveAsset(key); + bool priv = isAssetPrivate (it->second.getBalance()); + bool aowned = isAssetOwned (it->second.getBalance()); + bool immut = isAssetImmutable(it->second.getBalance()); + uint16_t curDays = assetDays(it->second.getBalance()); + uint32_t newDays = curDays + days; + if (newDays > 0x0FFF) newDays = 0x0FFF; + it->second.setBalance( + assetBalance(static_cast(newDays), priv, aowned, immut, + isAssetOwnerPays(it->second.getBalance()))); + return CES_OK; + } + + uint8_t buyAsset(const minx::Hash& key, uint64_t maxPrice) override { + auto it = server_.assets_->find(key); + if (it == server_.assets_->end()) return CES_ERROR_ASSET_NOT_FOUND; + uint32_t storedPrice = it->second.getPrice(); + if (storedPrice == 0) return CES_ERROR_NOT_FOR_SALE; + uint64_t realPrice = storedToRealPrice(storedPrice); + if (realPrice > maxPrice) return CES_ERROR_INSUFFICIENT_PAYMENT; + if (uint8_t rc = debitCaller(realPrice); rc != CES_OK) return rc; + HashPrefix sellerId = it->second.getOwnerId(); + auto sellerIt = server_.accounts_->find(sellerId); + if (sellerIt != server_.accounts_->end()) { + maybeSaveAccount(sellerId); + sellerIt->second.setBalance( + saturatingAddBalance(sellerIt->second.getBalance(), realPrice)); + } + maybeSaveAsset(key); + it->second.setOwnerId(caller_); + it->second.setPrice(0); + return CES_OK; + } + + uint8_t giveAsset(const minx::Hash& key, const HashPrefix& newOwner) override { + auto it = server_.assets_->find(key); + if (it == server_.assets_->end()) return CES_ERROR_ASSET_NOT_FOUND; + uint8_t authRc = checkAssetWriteAuth( + it->second, caller_, programOwnerPrefix_, this->selfAssetKey); + if (authRc != CES_OK) return authRc; + maybeSaveAsset(key); + it->second.setOwnerId(newOwner); + it->second.setPrice(0); + return CES_OK; + } + + // ---- Deferred / optional side effects ----------------------------------- + void sendUdp(const std::string& addr, uint16_t port, + const uint8_t* data, size_t len) override { + if (udpSink_) udpSink_(addr, port, data, len); + } + + // Cross-transfer is special. Unlike the other syscalls, two concerns + // touch the VM side: ledger mutation (caller debit + peer vostro credit + // — must be rollback-safe via the undo log) and network dispatch (fire + // settlement over CesClientAsync — must NOT fire if the VM later aborts). + // Both are handled here, synchronously on the logic strand; the outer + // crossSink decides *when* the network dispatch happens: + // - CES_RUN_ASSET path: sink buffers into deferredCrossXfers and the + // commit branch fires them after a successful VM run. A VM abort + // rolls back the ledger via the undo log and naturally drops the + // buffered dispatch on scope exit. + // - executeScheduledRun path: sink fires immediately (no rollback). + // + // VM users get the same "free local transfer" discount as SYS_TRANSFER + // (no txFee), since they're already paying gas for the syscall. The + // operation is net-zero on totalCredits_ (caller - amount, vostro + + // amount), so bypassing ActiveAccount bookkeeping is safe here, the + // same way it is for SYS_TRANSFER above. + uint8_t crossTransfer(const minx::Hash& dest, uint64_t amount, + const std::string& server) override { + if (!crossSink_) return CES_ERROR_DISABLED; + minx::Hash peerKey{}; + bool peerFound = false; + { + std::lock_guard lock(server_.peerTableMutex_); + for (auto& p : server_.peerTable_) { + if (p.declaredAddress == server && p.reachable) { + peerKey = p.ckey; + peerFound = true; + break; + } + } + } + if (!peerFound) { + LOGDEBUG << "VM cross-transfer: unknown/unreachable peer" << SVAR(server); + return CES_ERROR_UNKNOWN_PEER; + } + auto* settlementClient = server_.getOrCreateSettlementClient(server, peerKey); + if (!settlementClient || settlementClient->load() >= 95) { + LOGDEBUG << "VM cross-transfer: settlement queue full" << SVAR(server); + return CES_ERROR_QUEUE_FULL; + } + if (uint8_t rc = debitCaller(amount); rc != CES_OK) { + LOGDEBUG << "VM cross-transfer: debit failed" << VAR(amount) << VAR(rc); + return rc; + } + HashPrefix peerPrefix = Account::getMapKey(peerKey); + maybeSaveAccount(peerPrefix); + auto peerIt = server_.accounts_->find(peerPrefix); + if (peerIt != server_.accounts_->end()) { + peerIt->second.setBalance( + saturatingAddBalance(peerIt->second.getBalance(), amount)); + } else { + Account newAcc(peerKey, static_cast(amount), 0); + server_.accounts_->getObjects().emplace(peerPrefix, newAcc); + } + crossSink_(dest, amount, server, peerKey); + return CES_OK; + } + + bool sendClient(const HashPrefix& clientId, + const uint8_t* data, size_t len) override { + minx::Bytes payload(data, data + len); + return server_.send(clientId, payload); + } + + uint8_t schedule(const minx::Hash& assetId, uint64_t budget, + uint64_t allowance, + const uint8_t* input, size_t inputLen, + uint64_t time_us) override { + // Route through the sink so executeVmRun can undo the enqueue if the VM + // later aborts — an aborted run must not leave a scheduled run behind. + if (!scheduleSink_) return CES_ERROR_DISABLED; + return scheduleSink_(assetId, budget, allowance, + {input, input + inputLen}, time_us); + } + + uint8_t scheduleAlias(uint32_t aliasId, uint64_t budget, uint64_t allowance, + const uint8_t* input, size_t inputLen, + uint64_t time_us) override { + if (!scheduleAliasSink_) return CES_ERROR_DISABLED; + return scheduleAliasSink_(aliasId, budget, allowance, + {input, input + inputLen}, time_us); + } + + // Patch an alias's value image as the programOwner principal. Same rules + // as the wire CES_SET_ALIAS minus create-on-first-use (a VM run cannot + // mint cells; it patches cells that exist). A principal-less run (empty + // programOwner: every gate, foreign-code hooks, bare CES_RUN_ASSET) gets + // NOT_OWNER — no consenting principal means no write authority, and the + // same emptiness keeps gates pure. Undo-log tracked; the wire path's + // hook-target validation runs against the resulting image with the + // principal as setter. + uint8_t writeAlias(uint32_t id, uint32_t offset, const uint8_t* src, + uint32_t len) override { + if (programOwnerPrefix_ == HashPrefix{}) return CES_ERROR_NOT_OWNER; + if (id == 0) return CES_ERROR_ALIAS_NOT_FOUND; + auto al = server_.aliases_.get(id); + if (!al.exists()) return CES_ERROR_ALIAS_NOT_FOUND; + size_t patchFloor; + if (al.getOwner() == programOwnerPrefix_) + patchFloor = ALIAS_PATCH_MIN_OWNER; + else if (al.getEditor() == programOwnerPrefix_) + patchFloor = ALIAS_PATCH_MIN_EDITOR; + else + return CES_ERROR_NOT_OWNER; + if (offset > ALIAS_VALUE_BYTES || len > ALIAS_VALUE_BYTES || + offset + len > ALIAS_VALUE_BYTES) + return CES_ERROR_BAD_INPUT; + if (offset < patchFloor) // wire parity: a floor violation is NOT_OWNER + return CES_ERROR_NOT_OWNER; + Alias next = al.data(); + if (len > 0) std::memcpy(next.imageData() + offset, src, len); + if (aliasOpIsHook(next.getOp())) { + minx::Hash triggerKey{}; + std::memcpy(triggerKey.data(), next.getContent().data(), KEY_SIZE); + auto trigger = server_.assets_.get(triggerKey); + bool ok = trigger.exists() && + (isAssetImmutable(trigger.data().getBalance()) || + trigger.data().getOwnerId() == programOwnerPrefix_); + if (!ok) return CES_ERROR_HOOK_TARGET; + } + if (saveAliasFn_) saveAliasFn_(id); + al.data() = next; + return CES_OK; + } + + uint8_t rpc(const std::string& host, uint16_t port, + const minx::Hash& fileHeadKey, + const minx::Hash& followupProgramKey, + uint64_t followupBudget, + uint32_t followupInputTag) override { + LOGTRACE << "sys_rpc host callback fired" << SVAR(host) << VAR(port) + << VAR(followupBudget) << VAR(followupInputTag); + PendingRpc pending; + pending.host = host; + pending.port = port; + pending.fileHeadKey = fileHeadKey; + pending.followupProgramKey = followupProgramKey; + pending.selfAssetKey = this->selfAssetKey; + pending.callerPrefix = Account::getMapKey(this->callerKey); + pending.programOwnerPrefix = this->programOwner; + pending.followupBudget = followupBudget; + pending.followupAllowance = this->allowance; + pending.followupInputTag = followupInputTag; + return server_.queueRpc(std::move(pending)); + } + + uint8_t l2call(const uint8_t* disc, uint64_t value, + const uint8_t* blob, size_t blobLen, + const minx::Hash& followupKey, uint64_t followupBudget, + uint32_t followupTag) override { + if (!l2Sink_) return CES_ERROR_DISABLED; + // Discriminator: first 8 bytes of sha256(built-in name), matched as a + // flat array. peek is a fixed big-endian read, so the routing + // key is identical on any architecture. + uint64_t discKey = ces::Buffer::peek(disc); + auto it = server_.l2Registry_.find(discKey); + if (it == server_.l2Registry_.end()) return CES_ERROR_UNSUPPORTED; + if (server_.l2PendingCount_.load() >= server_.cfg_.l2MaxPending) + return CES_ERROR_QUEUE_FULL; // backpressure; nothing burned + // Burn: park `value` in the bottomless self-account. Undo-logged (caller + // and self both saved), so an abort rolls it back; on commit executeVmRun + // posts the drain. + minx::Hash selfKey = server_.serverKeyPair_.getPublicKeyAsHash(); + if (uint8_t rc = transfer(selfKey, value); rc != CES_OK) return rc; + CesServer::PendingL2Call p; + p.handler = it->second; + p.payerKey = this->callerKey; + p.value = value; + p.blob.assign(blob, blob + blobLen); + p.followupProgramKey = followupKey; + p.followupBudget = followupBudget; + p.followupAllowance = this->allowance; // remaining, like rpc + p.followupTag = followupTag; + l2Sink_(std::move(p)); + return CES_OK; + } + + bool verifySig(const uint8_t* data, size_t dataLen, + const uint8_t* sig, const uint8_t* pubkey) override { + if (!enableVerifySig_) return false; + try { + Hash keyHash; + std::memcpy(keyHash.data(), pubkey, 32); + PublicKey pk(keyHash); + Signature sigArr; + std::memcpy(sigArr.data(), sig, 65); + return pk.verifySignature( + std::span(data, dataLen), sigArr); + } catch (...) { + return false; + } + } + +private: + CesServer& server_; + HashPrefix caller_; + HashPrefix programOwnerPrefix_; + std::function saveAccountFn_; + std::function saveAssetFn_; + std::function saveAliasFn_; + std::function udpSink_; + std::function crossSink_; + std::function l2Sink_; + std::function scheduleSink_; + std::function scheduleAliasSink_; + std::function creditHookSink_; + bool enableVerifySig_; + + void maybeSaveAccount(const HashPrefix& id) { + if (saveAccountFn_) saveAccountFn_(id); + } + void maybeSaveAsset(const minx::Hash& key) { + if (saveAssetFn_) saveAssetFn_(key); + } + + // Returns CES_ERROR_BALANCE_OVERFLOW if crediting `amount` onto the account + // at `prefix` (or a fresh 0-balance account) would exceed the int48 cap. + // Value moves check this before debiting, so a move never saturate-burns. + uint8_t checkCredit(const HashPrefix& prefix, uint64_t amount) { + auto it = server_.accounts_->find(prefix); + int64_t bal = (it != server_.accounts_->end()) ? it->second.getBalance() : 0; + return creditWouldOverflow(bal, amount) ? CES_ERROR_BALANCE_OVERFLOW : CES_OK; + } + uint8_t checkCredit(const minx::Hash& key, uint64_t amount) { + return checkCredit(Account::getMapKey(key), amount); + } + + // Credit-side of any transfer. Both transfer (caller-as-source) and + // ownerTransfer (programOwner-as-source) credit through here. + void creditDest(const minx::Hash& dest, uint64_t amount) { + HashPrefix destPrefix = Account::getMapKey(dest); + maybeSaveAccount(destPrefix); + auto destIt = server_.accounts_->find(destPrefix); + if (destIt != server_.accounts_->end()) { + destIt->second.setBalance( + saturatingAddBalance(destIt->second.getBalance(), amount)); + } else { + Account newAcc(dest, amount, 0); + server_.accounts_->getObjects().emplace(destPrefix, newAcc); + } + // Record a deferred XFER_VM hook (fires as a watch after the run commits). + if (creditHookSink_) creditHookSink_(dest, amount); + } + + // Debit programOwner's account directly. No allowance check — the owner + // deployed the bytecode and thereby consented to its semantics. The + // caller still pays the syscall's protocol fee (debited separately in + // the SYS_OWNER_TRANSFER handler), but the value transfer drains the + // owner. Returns ORIGIN_NOT_FOUND if programOwner has no account + // (e.g. an asset-owned program chain). + uint8_t debitProgramOwner(uint64_t amount) { + if (amount == 0) return CES_OK; + auto ownerIt = server_.accounts_->find(programOwnerPrefix_); + if (ownerIt == server_.accounts_->end()) return CES_ERROR_ORIGIN_NOT_FOUND; + if (ownerIt->second.getBalance() < static_cast(amount)) + return CES_ERROR_INSUFFICIENT_BALANCE; + maybeSaveAccount(programOwnerPrefix_); + ownerIt->second.setBalance(ownerIt->second.getBalance() - + static_cast(amount)); + return CES_OK; + } +}; + +// --------------------------------------------------------------------------- +// CesRpcRudpListener — Rudp::Listener wired into CesServer +// --------------------------------------------------------------------------- + +void CesRpcRudpListener::onSend(const minx::SockAddr& peer, + const minx::Bytes& bytes) { + // minx::Bytes already start with the MinxStdExtensions routing key + // that Rudp prepended. sendExtension takes the payload as-is. + // Swallow the "no socket" exception during teardown — Rudp can + // emit HS_CLOSE from RudpStream::close() at any point in the + // shutdown sequence, including after rpcMinx_->closeSocket(). + if (!owner_->rpcMinx_) return; + try { + owner_->rpcMinx_->sendExtension(peer, bytes); + } catch (const std::exception&) { + // Socket already closed; nothing to do. + } +} + +std::shared_ptr +CesRpcRudpListener::onAccept(const minx::SockAddr& peer, + uint32_t channelId) { + // CesPlex inactive → reject inbound HS_OPEN silently. Outbound + // channels (SYS_RPC) don't fire onAccept; this path is purely + // inbound. + if (!owner_->cesplex_) return nullptr; + return owner_->cesplex_->acceptInbound(peer, channelId); +} + +// Normalize the operator hello banner: strip trailing CR/LF, then cap the +// UTF-8 encoding at HELLO_MAX_BYTES, backing up off any continuation byte so +// a multi-byte codepoint is never split. +static std::string normalizeHello(std::string s) { + while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) + s.pop_back(); + if (s.size() > CesServer::HELLO_MAX_BYTES) { + size_t cut = CesServer::HELLO_MAX_BYTES; + while (cut > 0 && (static_cast(s[cut]) & 0xC0) == 0x80) + --cut; + s.resize(cut); + } + return s; +} + +CesServer::CesServer(const CesConfig& config) + : cfg_(config), + logicStrand_(boost::asio::make_strand(taskIO_)), + serverKeyPair_(config.serverPrivKey, config.serverKeyAlgo), + accounts_((config.dataDir / ACCOUNTS_DATA_SUBDIRECTORY).string(), + config.minAcc, config.flushValue, + config.accountStoreBufferSize), + assets_((config.dataDir / ASSETS_DATA_SUBDIRECTORY).string(), + config.minAsset, config.flushValue, config.assetStoreBufferSize), + aliases_((config.dataDir / ALIASES_DATA_SUBDIRECTORY).string(), + config.minAlias, config.flushValue), + keyNames_((config.dataDir / KEYNAMES_DATA_SUBDIRECTORY).string(), + config.minKeyName, config.flushValue), + presence_(config.presenceCacheSize) { + // Default every fee multiplier to full price (10000 bp). The metrics + // pulse overwrites these once a tick from the gauge each kind is + // mapped to — but only when feeDiscountEnabled is true. + for (auto& m : feeMult_) + m.store(10000, std::memory_order_relaxed); + + // Register our own pubkey as a sink target: a gossip with dest == us is + // terminal and its budget burns into our bottomless self-account. + { + Hash self = serverKeyPair_.getPublicKeyAsHash(); + localSinkKeys_[Account::getMapKey(self)] = {self, 1}; + } + + // Seed the runtime peer target from config. The miner reads this atomic + // (not cfg_.peerTarget) so the dashboard can change it live. + peerTarget_.store(cfg_.peerTarget, std::memory_order_relaxed); + maxPeers_.store(cfg_.maxPeers < 1 ? 1 : cfg_.maxPeers, std::memory_order_relaxed); + // Seed the live extension-funding rate from config (the dashboard sets it live + // thereafter; the bucket reads this member, not cfg_). Start the bucket full so + // a boot-configured budget is available immediately, like a dashboard set. + extFundingRatePerDay_ = cfg_.extFundingPerDay; + extFundingAllowance_ = static_cast(cfg_.extFundingPerDay); + extLocalBudget_.store(cfg_.extLocalBudget, std::memory_order_relaxed); + minx_ = std::make_unique( + this, minx::MinxConfig{ + .instanceName = "sv", + .minProveWorkTimestamp = cfg_.minProveWorkTimestamp, + .spendSlotSize = cfg_.spendSlotSize, + .randomXVMsToKeep = RANDOMX_VMS_TO_KEEP, + .randomXInitThreads = 0, + .spamThreshold = minx::MinxConfig::MAX_SPAM_THRESHOLD, + .spamSampleRate = SPAM_SAMPLE_RATE, + .trustLoopback = true, + .recvBuffersSize = cfg_.recvBuffersSize}); + if (cfg_.taskThreads < 1 || cfg_.taskThreads > MAX_TASK_THREADS) { + throw std::runtime_error("bad value for taskThreads"); + } + + // Default the three file-storage fee knobs. Physical cost model: + // feeFileRent — retention (per-byte-day), disk 100x cheaper + // than asset-cell RAM. An asset is 256 B of RAM + // costing feeAsset per day, so the per-byte-day + // RAM rate is feeAsset / 256 and disk is that + // divided by 100. + // feeFileWrite — network + SSD write (per-byte), ~10 days of + // rent per byte + // feeFileRead — SSD read (per-byte). Most of what was previously + // bundled here moved to feeNetKiB* (it was + // network bandwidth, not SSD work). The remainder + // covers the SSD's read-bandwidth-share — a small + // floor since reads have no wear cost. + // Zero values derive the default; minimum clamp of 1 prevents + // free ops when feeAsset is tuned unusually low in tests. + if (cfg_.feeFileRent == 0) { + cfg_.feeFileRent = static_cast(cfg_.feeAsset) / 100 / 256; + if (cfg_.feeFileRent == 0) cfg_.feeFileRent = 1; + } + if (cfg_.feeFileWrite == 0) { + // The network share is billed separately via feeNetKiBReceived + // against the body bytes. Wear stays heavy: WRITE pays ~9x + // per-byte-day rent per KB, vs READ's ~0.125x, preserving NAND's + // ~10:1 wear-vs-bandwidth asymmetry. + cfg_.feeFileWrite = cfg_.feeFileRent * 9; + if (cfg_.feeFileWrite == 0) cfg_.feeFileWrite = 1; + } + if (cfg_.feeFileRead == 0) { + // The network share is billed separately via feeNetKiBSent. What + // remains here is the SSD read-bandwidth share — a small floor (no + // wear, no replacement cost, just bus time and power). + cfg_.feeFileRead = cfg_.feeFileRent / 8; + if (cfg_.feeFileRead == 0) cfg_.feeFileRead = 1; + } + // --- RUDP-tier billing rates --- + // The bundled file/query fees implicitly priced networking; we + // pull that share out and bill it explicitly per-byte/per-second + // here. ChannelMeter reads these into each channel's bound + // contract at bind time and debits the payer per tick. + // + // 0 is a sentinel meaning "derive a default", not "free": each feeNet* of 0 + // is overwritten below with a value from the ledger anchors (floored to >= 1). + // A live server always meters and evicts; setting 0 re-derives, it does not + // disable metering. An explicit non-zero is honored. + if (cfg_.feeNetKiBSent == 0) { + // Per-KiB throughput rate, priced below disk retention since network is a + // regenerative flow. Anchor: feeFileRent/2 per KiB. + uint64_t v = static_cast(cfg_.feeFileRent) / 2; + if (v == 0) v = 1; + cfg_.feeNetKiBSent = v; + } + if (cfg_.feeNetKiBReceived == 0) { + cfg_.feeNetKiBReceived = cfg_.feeNetKiBSent; + } + if (cfg_.feeNetMemByteDay == 0) { + // RAM equivalence: a byte sitting in a RUDP buffer for a day + // costs the same as a byte of ledger RAM for a day. Same anchor + // as feeComputeRssByteDay. + uint64_t v = static_cast(cfg_.feeAsset) / 256; + if (v == 0) v = 1; + cfg_.feeNetMemByteDay = v; + } + if (cfg_.feeNetChannelSec == 0) { + // "Channel is open" rate. Anchor: one asset-day of supervisor + + // bookkeeping overhead per second (≈ 296/sec at stock). + uint64_t v = static_cast(cfg_.feeAsset) / 86400; + if (v == 0) v = 1; + cfg_.feeNetChannelSec = v; + } + // feeQuery default lives in the BASE_FEE_QUERY constant (see the + // comment on its declaration). + // --- Compute fees (see comments on CesConfig). Derive from the + // ledger anchors; explicit non-zero values are honored as-is. --- + if (cfg_.feeComputeSlotSec == 0) { + cfg_.feeComputeSlotSec = static_cast(cfg_.feeAsset) / 86400; + if (cfg_.feeComputeSlotSec == 0) cfg_.feeComputeSlotSec = 1; + } + if (cfg_.feeComputeRssByteDay == 0) { + cfg_.feeComputeRssByteDay = static_cast(cfg_.feeAsset) / 256; + if (cfg_.feeComputeRssByteDay == 0) cfg_.feeComputeRssByteDay = 1; + } + if (cfg_.feeComputeCpuSec == 0) { + cfg_.feeComputeCpuSec = 5'000'000; + } + // Bucket cache capacity rent: same byte-day basis as RSS, but + // charged per-second since buckets are a standing committed + // footprint rather than a sampled measurement. + if (cfg_.feeBucketByteSec == 0) { + int64_t v = cfg_.feeComputeRssByteDay / 86400; + if (v == 0) v = 1; + cfg_.feeBucketByteSec = v; + } + // Default file store dir under dataDir. Used only when the + // feature is active (cesFileStoreMaxBytes > 0); harmless to set + // regardless. + if (cfg_.cesFileStoreDir.empty()) { + cfg_.cesFileStoreDir = + (cfg_.dataDir / "cesfilestore").string(); + } + + LOGDEBUG << "CesServer fees" + << VAR(cfg_.feeAsset) << VAR(cfg_.feeAccount) + << VAR(cfg_.feeTx) << VAR(cfg_.feeQuery) + << VAR(cfg_.feeFileRent) << VAR(cfg_.feeFileWrite) + << VAR(cfg_.feeFileRead) + << VAR(cfg_.feeNetKiBSent) << VAR(cfg_.feeNetKiBReceived) + << VAR(cfg_.feeNetMemByteDay) << VAR(cfg_.feeNetChannelSec) + << VAR(cfg_.feeComputeSlotSec) + << VAR(cfg_.feeComputeRssByteDay) + << VAR(cfg_.feeComputeCpuSec); + + minx_->setServerKey(serverKeyPair_.getPublicKeyAsHash()); + minx_->setMinimumDifficulty(cfg_.minDiff); + minx_->setUseDataset(true); + + // Load persisted peer data, then seed with outbound config peers + loadPeerData(); + loadHelloFromFile(); + for (auto& pc : cfg_.peers) { + minx::Hash key; + minx::stringToHash(key, pc.pubKeyHex); + upsertPeer(key, pc.address, 0); + // Mark as outbound + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey == key) { p.outbound = true; break; } + } + } + + if (cfg_.maxAcc < cfg_.minAcc) + throw std::runtime_error("maxAcc must be >= minAcc"); + if (cfg_.maxAsset < cfg_.minAsset) + throw std::runtime_error("maxAsset must be >= minAsset"); + + // Pre-threading startup logs (No locks needed, runs before threads spawn) + { + LOGDEBUG << "--- DB LOADED: " << accounts_->getObjects().size() + << " ACCOUNTS ---"; + int shown = 0; + for (auto& [prefix, acc] : accounts_->getObjects()) { + if (shown >= BOOT_ACCOUNT_DUMP_MAX) { + LOGDEBUG << "... (" + << (accounts_->getObjects().size() - BOOT_ACCOUNT_DUMP_MAX) + << " more accounts omitted) ..."; + break; + } + ++shown; + minx::Hash fullKey = acc.getKey(prefix); + LOGDEBUG << "[" << std::setw(2) << shown << "] " + << minx::hashToString(fullKey) + << " | Balance: " << acc.getBalance() + << " | Nonce: " << acc.getNonce(); + } + } + LOGDEBUG << "-----------------------------------------------"; +} + +CesServer::~CesServer() { + LOGTRACE << "~CesServer"; + stop(false); + LOGTRACE << "~CesServer done"; +} + +uint16_t CesServer::start(uint16_t serverPort) { + if (running_) + return 0; + if (netIO_.stopped()) + netIO_.restart(); + if (taskIO_.stopped()) + taskIO_.restart(); + LOGDEBUG << "server opening socket" << VAR(serverPort); + receiving_ = true; + running_ = true; + uint16_t boundPort = minx_->openSocket(boost::asio::ip::address_v6::any(), + serverPort, netIO_, taskIO_); + if (boundPort == 0) { + LOGDEBUG << "server failed to open socket"; + receiving_ = false; + running_ = false; + return 0; + } + boundPort_ = boundPort; + LOGDEBUG << "server opened socket" << VAR(boundPort); + LOGDEBUG << "starting IO threads" << VAR(cfg_.taskThreads); + netIOThread_ = + std::thread([this]() { runGuardedThread([this]{ netIO_.run(); }, "netIO"); }); + for (int i = 0; i < cfg_.taskThreads; ++i) { + taskIOThreads_.emplace_back( + [this]() { runGuardedThread([this]{ taskIO_.run(); }, "taskIO"); }); + } + LOGDEBUG << "starting verifyPoW thread"; + verifyPoWThread_ = std::thread([this]() { + while (running_) { + try { + minx_->verifyPoWs(); + } catch (const std::exception& e) { + LOGERROR << "verifyPoW handler escaped an exception; continuing" + << SVAR(e.what()); + } catch (...) { + LOGERROR << "verifyPoW handler escaped an unknown exception; continuing"; + } + ces::sleep(100); + } + }); + metricsStartTimer(); + dailyTaskStartTimer(); + replyStartTimer(); + cronStartTimer(); + // Top up the server's own account to near-INT64_MAX. Runs once + // per boot, before any other strand work that might read or + // mutate account state. See topUpServerAccount() for rationale. + postLogic( [this]() { topUpServerAccount(); }); + // Deploy server-built /b/ bytecode programs over whatever + // sits at their well-known asset keys. Posted after topUp so the + // server account exists (these programs run as owner = server). + postLogic( [this]() { deployBuiltinVmPrograms(); }); + // Run autoexec cron assets on boot (async, on the strand) + postLogic( [this]() { runAutoexec(); }); + // Start settlement worker (async cross-transfer delivery) + if (settlementIO_.stopped()) + settlementIO_.restart(); + settlementWorkGuard_ = std::make_unique< + boost::asio::executor_work_guard>( + settlementIO_.get_executor()); + settlementThread_ = std::thread( + [this]() { runGuardedThread([this]{ settlementIO_.run(); }, "settlementIO"); }); + + // SYS_RPC bridge: if the operator configured a dedicated RPC UDP + // port (cfg_.rpcPort != 0), bind a second Minx instance on it with + // a RUDP transport layered on its EXTENSION lane. + // + // Build order: + // 1. Create rpcMinx_ with a no-op listener (the RPC Minx carries + // no CES protocol traffic). + // 2. Create rpcRudp_ (passive state machine, no threads). + // 3. Wire rpcRudp_'s send callback → rpcMinx_->sendExtension. + // 4. Wire rpcRudp_'s receive callback (empty placeholder until + // the SYS_RPC dispatcher lands; any inbound message is + // dropped with a TRACE log). + // 5. Register Rudp's KEY_V0 routing key with a MinxStdExtensions + // builder and install the resulting dispatcher as rpcMinx_'s + // extension handler. + // 6. openSocket, start rpcNetIO_ / rpcTaskIO_ threads. + // 7. Start the Rudp tick timer on rpcTaskIO_ (20ms cadence). + // + // All rpcRudp_ access (push, onPacket, tick, flush, callbacks) runs + // on rpcTaskIO_'s single thread, so no mutex is needed on Rudp + // state. The extension handler is invoked from rpcMinx_'s taskIO + // callback path, which IS rpcTaskIO_ — same thread. + if (cfg_.rpcPort != 0 || cfg_.rpcAutoPort) { + LOGDEBUG << "rpc: opening dedicated MINX socket" << VAR(cfg_.rpcPort); + if (rpcNetIO_.stopped()) + rpcNetIO_.restart(); + if (rpcTaskIO_.stopped()) + rpcTaskIO_.restart(); + + rpcMinx_ = std::make_unique( + &rpcListener_, minx::MinxConfig{ + .instanceName = "rpc", + .minProveWorkTimestamp = cfg_.minProveWorkTimestamp, + .spendSlotSize = cfg_.spendSlotSize, + .randomXVMsToKeep = 0, // no PoW engine on the RPC port + .randomXInitThreads = 0, + .spamThreshold = minx::MinxConfig::MAX_SPAM_THRESHOLD, + .spamSampleRate = SPAM_SAMPLE_RATE, + .trustLoopback = true, + .recvBuffersSize = cfg_.recvBuffersSize}); + rpcMinx_->setServerKey(serverKeyPair_.getPublicKeyAsHash()); + + // Construct the Rudp transport before opening the socket so no + // packet can hit an unwired handler. The Rudp::Listener + // (rpcRudpListener_) is bound at construction; it forwards onSend + // to rpcMinx_ and onAccept to CesPlex when the latter is alive. + // Per-channel pacing comes from config; defaults are + // PER_CHANNEL_UNLIMITED so this is a no-op on stock deployments. + minx::RudpConfig rudpCfg{}; + rudpCfg.perChannelBytesPerSecond = cfg_.rpcRudpBytesPerSecond; + rudpCfg.perChannelBurstBytes = cfg_.rpcRudpBurstBytes; + rudpCfg.maxChannelsPerPeer = cfg_.rpcRudpMaxChannelsPerPeer; + if (cfg_.rpcRudpMaxReorderBytesPerChannel >= 0) + rudpCfg.maxReorderBytesPerChannel = + static_cast(cfg_.rpcRudpMaxReorderBytesPerChannel); + if (cfg_.rpcRudpMaxReorderMsgsPerChannel >= 0) + rudpCfg.maxReorderMessagesPerChannel = + static_cast(cfg_.rpcRudpMaxReorderMsgsPerChannel); + rudpCfg.channelInactivityTimeout = + std::chrono::seconds(cfg_.rpcRudpChannelIdleSecs); + // RUDP's default 100 ms pulse emits only one packet per + // channel-with-data per pulse. For a 275 MB file-store upload + // that's ~198000 packets × 100 ms = 5.5 hours. 1 ms pulse gets + // us ~1000 pkt/s per channel — the pulse only fires when there's + // data to send, so a quiet channel stays quiet. + rudpCfg.baseTickInterval = std::chrono::milliseconds(1); + rpcRudp_ = std::make_unique( + &rpcRudpListener_, rudpCfg, rpcMinx_.get()); + + // ChannelMeter: per-channel billing tick that debits the bound + // payer at the channel's bound rates. Constructed before CesPlex so + // CesPlex's Session can call track() with the bound rate schedule + // at acceptInbound time. + channelMeter_ = std::make_unique( + *rpcRudp_, rpcTaskIO_, this); + + // CesPlex — the L2 protocol multiplexer. Built whenever the plex port is + // up. Builtins are builtins: their C++ is linked and every server speaks + // the whole suite. CesPlex resolves the operator's cfg mounts (file / + // compute / lua, plus any non-core handler) via the builtin registry; + // builtin:peer is a per-server object mounted directly. Whether a mounted + // handler does real work is the handler's own decision (its capability / + // params); a disabled or unauthorized bind is refused IN THE HANDLER. + // Construction happens before the socket is opened so the listener sees a + // coherent CesPlex pointer before any packet can arrive. + { + // CesPlex is impl-agnostic: each [cesplex_mounts] entry wires a protocol + // name to a builtin handler impl. The map is the ONLY thing that decides + // what is served -- nothing auto-mounts (auto would hardcode the impl + // choice). A protocol absent from the map is simply not served. + // + // resolveBuiltin is the hardcoded list of core builtin implementors: it + // maps a "builtin:" string to exactly one concrete class, the + // single place that knows name->class. Each role is constructed once as a + // per-server object (lazily, so several protos may share one handler) and + // returned as the base handler to mount. An alternative impl (e.g. + // "builtin:peeralt" -> a second peer class) is a new case here -- but the + // role members hold concrete types today, so a real alternative needs the + // role's abstract base first. There is no global registry: a target this + // switch does not know is logged and skipped (the server serves only what + // it can construct). + auto resolveBuiltin = [this](const std::string& target) -> CesPlexHandler* { + if (target == "builtin:peer") { + if (!peerHandler_) peerHandler_ = std::make_unique(this); + return peerHandler_.get(); + } + if (target == "builtin:lua") { + if (!luaHandler_) luaHandler_ = std::make_unique(this); + return luaHandler_.get(); + } + if (target == "builtin:file") { + if (!fileHandler_) fileHandler_ = std::make_unique(this); + return fileHandler_.get(); + } + if (target == "builtin:compute") { + if (!computeHandler_) + computeHandler_ = std::make_unique(this); + return computeHandler_.get(); + } +#ifdef CES_MAIL + if (target == "builtin:mail") { + if (!mailHandler_) mailHandler_ = std::make_unique(this); + return mailHandler_.get(); + } +#endif + return nullptr; // unknown builtin -> skipped below + }; + + cesplex_ = std::make_unique( + *rpcRudp_, rpcTaskIO_, this, channelMeter_.get()); + for (const auto& [proto, target] : cfg_.cesplexMounts) { + if (CesPlexHandler* h = resolveBuiltin(target)) { + cesplex_->mount(proto, h); + // SYS_L2_CALL routing: key this built-in by the 8-byte discriminator + // = first 8 bytes of sha256(mount target name), read big-endian. A + // VM program addresses the built-in by that same hash. + minx::Hash dh = ces::sha256( + reinterpret_cast(target.data()), target.size()); + l2Registry_[ces::Buffer::peek(dh.data())] = h; + } else { + LOGWARNING << "cesplex: mount skipped, unknown builtin" + << SVAR(proto) << SVAR(target); + } + } + + // Per-role startup hooks, in dependency order. file_store_max_bytes is + // only the metered-zone cap (/s/ is unmetered and served whenever file is + // mounted), not an on/off switch. peer start() kicks off the reconcile + // pass; file before compute (compute needs the file store); compute + // start() self-gates on its prerequisites (instance cap > 0, file handler + // present, work dir) and logs if unmet -- a badly-wired compute mount is + // inert, not a crash. lua has no startup hook. + if (peerHandler_) peerHandler_->start(); + if (fileHandler_) fileHandler_->startupReconcile(); + if (computeHandler_) { + uint8_t rc = computeHandler_->start(); + if (rc != CES_OK) { + LOGWARNING << "compute: handler start refused" << VAR(int(rc)); + } + } + + // Deploy + launch /s/ extensions on the supervisor strand. + boost::asio::post(rpcTaskIO_, [this]() { launchExtensions(); }); + } + + // Register the Rudp family with a MinxStdExtensions builder. + // Rudp::KEY_V0 is the sentinel routing key; the family id is + // what matters, the sub-protocol byte is masked off by stdext. + { + minx::MinxStdExtensions stdExt; + stdExt.registerExtension( + minx::Rudp::KEY_V0, + [this](const minx::SockAddr& peer, uint64_t key, + const minx::Bytes& payload) { + if (rpcRudp_) { + rpcRudp_->onPacket(peer, key, payload, getMicrosSinceEpoch()); + } + }); + rpcMinx_->setExtensionHandler(std::move(stdExt).build()); + } + + rpcBoundPort_ = rpcMinx_->openSocket( + boost::asio::ip::address_v6::any(), cfg_.rpcPort, + rpcNetIO_, rpcTaskIO_); + if (rpcBoundPort_ == 0) { + LOGDEBUG << "rpc: failed to open socket on" << VAR(cfg_.rpcPort); + // Tear down in reverse construction order: CesPlex holds + // references into rpcRudp_ and must go first; channelMeter_ + // also references rpcRudp_. + if (peerHandler_) peerHandler_->stop(); + if (luaHandler_) luaHandler_->stop(); + if (computeHandler_) computeHandler_->stop(); + if (fileHandler_) fileHandler_->stop(); // after compute (depends on file) + cesplex_.reset(); + peerHandler_.reset(); + luaHandler_.reset(); + computeHandler_.reset(); + fileHandler_.reset(); + channelMeter_.reset(); + rpcMinx_.reset(); + rpcRudp_.reset(); + } else { + LOGINFO << "rpc: MINX/RUDP port listening" << VAR(rpcBoundPort_); + rpcNetIOThread_ = std::thread( + [this]() { runGuardedThread([this]{ rpcNetIO_.run(); }, "rpcNetIO"); }); + rpcTaskIOThread_ = std::thread( + [this]() { runGuardedThread([this]{ rpcTaskIO_.run(); }, "rpcTaskIO"); }); + + // Start the Rudp tick pulse. The timer schedules itself + // recursively on rpcTaskIO_ until stop() cancels it. + // Indirection via shared_ptr so the recursive lambda can + // reference itself: a locally-defined recursive std::function would be + // captured before it is assigned. + rpcTickTimer_ = std::make_shared(rpcTaskIO_); + // Server-side rpcRudp tick cadence. baseTickInterval (set above + // to 1 ms) rate-limits packet emission to 1000 pkt/sec/channel; + // this timer just has to call tick() often enough that we don't + // lose pulse budget to the MAX_PULSES_PER_CALL=100 cap. A 10 ms + // timer gives 10 pulses/call × 100 calls/sec = same 1000 pkt/sec + // with 1/10th the wakeup cost. scheduleHalvedFire() accelerates + // the next pulse automatically on inbound traffic. + auto scheduleTick = std::make_shared>(); + *scheduleTick = [this, scheduleTick]() { + if (!rpcTickTimer_ || !running_) return; + rpcTickTimer_->expires_after(std::chrono::milliseconds(10)); + auto timer = rpcTickTimer_; + timer->async_wait( + [this, scheduleTick](const boost::system::error_code& ec) { + if (ec || !running_ || !rpcRudp_) return; + rpcRudp_->tick(getMicrosSinceEpoch()); + (*scheduleTick)(); + }); + }; + boost::asio::post(rpcTaskIO_, [scheduleTick]() { (*scheduleTick)(); }); + } + } + + LOGDEBUG << "server started"; + return boundPort; +} + +void CesServer::stop(bool flushEvents) { + if (!running_) + return; + LOGDEBUG << "stop flagging server for termination"; + receiving_ = false; + const int maxCtrlCCount = 5; + LOGDEBUG << "stop checking that PoW queue is empty"; + while (true) { + size_t powQueueSize = minx_->getVerifyPoWQueueSize(); + if (!powQueueSize) + break; + // Re-read the interrupt count every iteration — capturing it once before + // the loop made the ctrl-C escape dead, so a stuck verify queue could + // hang shutdown with no way out. + int ctrlCCount = ces::interruptCount(); + if (ctrlCCount >= maxCtrlCCount) { + LOGINFO << "stop interrupted waiting for PoW queue to empty out" + << VAR(powQueueSize); + break; + } + LOGTRACE << "stop waiting for PoW queue to empty out" << VAR(powQueueSize) + << VAR(ctrlCCount) << VAR(maxCtrlCCount); + ces::sleep(100); + } + running_ = false; + + // Stop peer miner if running. Flip the run flag under the lifecycle lock so a + // concurrent ensurePeerMinerStarted() either already spawned (joined below) or + // now sees running_==false and won't spawn — no thread can appear past here. + { + std::lock_guard lock(peerMinerLifecycleMutex_); + peerMinerRunning_ = false; + } + if (peerMinerThread_.joinable()) { + LOGDEBUG << "stop stopping peer miner"; + peerMinerThread_.join(); + LOGDEBUG << "stop peer miner stopped"; + } + + LOGDEBUG << "stop closing socket"; + minx_->closeSocket(false); + LOGDEBUG << "stop stopping task timers"; + if (metricsTimer_) { + boost::system::error_code ec; + metricsTimer_->cancel(); + } + if (dailyTimer_) { + boost::system::error_code ec; + dailyTimer_->cancel(); + } + if (replyTimer_) { + boost::system::error_code ec; + replyTimer_->cancel(); + } + if (cronTimer_) { + boost::system::error_code ec; + cronTimer_->cancel(); + } + LOGDEBUG << "stop stopping IO contexts"; + netIO_.stop(); + taskIO_.stop(); + LOGDEBUG << "joining netIO threads"; + if (netIOThread_.joinable()) { + netIOThread_.join(); + } + LOGDEBUG << "joining taskIO threads"; + for (auto& thread : taskIOThreads_) { + if (thread.joinable()) { + thread.join(); + } + } + LOGDEBUG << "joining verifyPoWThread"; + if (verifyPoWThread_.joinable()) { + verifyPoWThread_.join(); + } + LOGDEBUG << "stopping settlement worker"; + // Stop and join settlementThread_ BEFORE closing the clients. close() + // mutates per-channel state, the queue and the socket — all of which + // otherwise live solely on settlementThread_ — so closing while that thread + // still runs settlementIO_ would be a data race. + settlementWorkGuard_.reset(); + settlementIO_.stop(); + if (settlementThread_.joinable()) + settlementThread_.join(); + for (auto& [addr, client] : settlementClients_) + client->close(); + settlementClients_.clear(); + + // SYS_RPC bridge teardown: cancel the Rudp tick timer, close the + // socket, stop io contexts, join threads, destroy Rudp and Minx. + // running_ was already cleared above so any in-flight tick + // scheduling short-circuits. The Rudp instance outlives the + // extension handler's ability to reach it (rpcMinx_ stops + // accepting new packets after closeSocket) but we destroy it only + // after both threads have joined — no more onPacket calls can + // race with the destructor. + if (rpcMinx_) { + LOGDEBUG << "rpc: closing dedicated MINX socket"; + if (rpcTickTimer_) { + boost::system::error_code ec; + rpcTickTimer_->cancel(); + } + rpcMinx_->closeSocket(false); + rpcNetIO_.stop(); + rpcTaskIO_.stop(); + if (rpcNetIOThread_.joinable()) + rpcNetIOThread_.join(); + if (rpcTaskIOThread_.joinable()) + rpcTaskIOThread_.join(); + rpcTickTimer_.reset(); + // CesPlex holds references into rpcRudp_ (via its per-session + // RudpStreams) and registers callbacks on it. Tear it down + // before rpcRudp_ so no callback fires into a half-destroyed + // CesPlex, and no RudpStream outlives its Rudp. Compute unbind + // must run BEFORE its rpcTaskIO_ executor goes away — it cancels + // the supervisor timer and SIGKILLs live instances. + if (peerHandler_) peerHandler_->stop(); + if (luaHandler_) luaHandler_->stop(); + if (computeHandler_) { + computeHandler_->fundingDrain(); // let in-flight request_funds transfers finish + computeHandler_->stop(); + } + if (fileHandler_) fileHandler_->stop(); // after compute (depends on file) + cesplex_.reset(); + peerHandler_.reset(); + luaHandler_.reset(); + computeHandler_.reset(); + fileHandler_.reset(); + channelMeter_.reset(); + rpcRudp_.reset(); + rpcMinx_.reset(); + rpcBoundPort_ = 0; + } + + if (flushEvents) { + LOGINFO << "flushing event logs..."; + accounts_->flush(true); + assets_->flush(true); + LOGINFO << "event logs flushed"; + } + LOGDEBUG << "destroying minx"; + minx_.reset(); + LOGDEBUG << "server stopped"; +} + +void CesServer::pause() { paused_ = true; } +void CesServer::resume() { paused_ = false; } +bool CesServer::send(const HashPrefix& clientId, const minx::Bytes& data) { + auto addr = presence_.get(clientId); + if (!addr) return false; + if (minx_) + minx_->sendApplication(*addr, data); + return true; +} + +bool CesServer::send(const HashPrefix& clientId, uint8_t code, + const minx::Bytes& data) { + auto addr = presence_.get(clientId); + if (!addr) return false; + if (minx_) + minx_->sendApplication(*addr, data, code); + return true; +} + +void CesServer::checkPause() { + while (paused_) { + ces::sleep(10); + } +} + +uint64_t CesServer::getTxCount() { return txCount_.load(); } + +// Smallest PoW difficulty D whose mint, (1<<(D-1))*POW_REWARD_BASE, can cover +// account creation (one fee_account). Below this, a freshly-mined account +// can't be created and the solution is dropped. Uses the file-local mint +// constants POW_REWARD_BASE / MAX_POW_DIFFICULTY defined above. +static uint8_t minViableDifficulty(uint64_t feeAccount) { + if (feeAccount <= POW_REWARD_BASE) return 1; + uint64_t need = (feeAccount + POW_REWARD_BASE - 1) / POW_REWARD_BASE; // ceil + uint8_t d = 1; + while (d < MAX_POW_DIFFICULTY && (1ULL << (d - 1)) < need) ++d; + return d; +} + +void CesServer::createPoWEngine(bool fullMem) { + // Config sanity, loud at boot: can a minimum-difficulty PoW solution actually + // pay for account creation? A solution at difficulty D mints + // (2^(D-1))*POW_REWARD_BASE; creating a new (mined) account requires that mint + // be >= fee_account (server.cpp incomingProveWork: `if (creditAmount < + // feeAccount) return;`). If min_difficulty is below that, freshly-mined + // accounts CANNOT be created — incoming PoW is silently dropped (clients see + // MINX_SOLUTION_UNKNOWN) and the server mints nothing despite looking healthy. + // This is precisely the trap a peer miner falls into. + uint8_t needed = minViableDifficulty(cfg_.feeAccount); + if (cfg_.minDiff < needed) { + uint64_t mintAtMin = + (cfg_.minDiff >= 1 && cfg_.minDiff <= MAX_POW_DIFFICULTY) + ? (1ULL << (cfg_.minDiff - 1)) * POW_REWARD_BASE + : 0; + LOGWARNING << "PoW min_difficulty too low to mint: a freshly mined account" + " cannot pay its own creation fee, so solutions are dropped" + " (clients see MINX_SOLUTION_UNKNOWN) and the server mints" + " nothing despite looking healthy" + << VAR(static_cast(cfg_.minDiff)) << VAR(cfg_.feeAccount) + << "; mint(D)=(2^(D-1))*" << POW_REWARD_BASE + << ", a new account needs mint(D)>=fee_account, but at this" + " difficulty mint=" << mintAtMin << " < fee_account=" + << cfg_.feeAccount << "; FIX: set min_difficulty>=" + << static_cast(needed) << " or fee_account<=" << mintAtMin; + } + minx_->setUseDataset(fullMem); + minx_->createPoWEngine(serverKeyPair_.getPublicKeyAsHash()); +} + +bool CesServer::isPoWEngineReady() { + return minx_->checkPoWEngine(serverKeyPair_.getPublicKeyAsHash()); +} + +bool CesServer::isConnected(const SockAddr& addr) { + auto ip = addr.address(); + std::lock_guard lock(peerTableMutex_); + for (const auto& p : peerTable_) { + if (p.resolvedIP == ip) return true; + } + return false; +} + +int64_t CesServer::resolveFee(int64_t passedFee, int64_t defaultFee) const { + return (passedFee < 0) ? defaultFee : passedFee; +} + +// Must be called on logicStrand_. Returns true if snapshot was taken. +bool CesServer::doSnapshot(const char* reason) { + uint64_t now = minx::getSecsSinceEpoch(); + if (now - lastSnapshotTime_ < SNAPSHOT_COOLDOWN_SECS) { + LOGWARNING << "snapshot debounced (cooldown " << SNAPSHOT_COOLDOWN_SECS + << "s)" << VAL("reason", reason); + return false; + } + lastSnapshotTime_ = now; + LOGINFO << "snapshot" << VAL("reason", reason); + accounts_->flush(true); + accounts_->save(logkv::StoreSaveMode::forkSave); + aliases_->save(logkv::StoreSaveMode::forkSave); + keyNames_->flush(true); + keyNames_->save(logkv::StoreSaveMode::forkSave); + assets_->flush(true); + assets_->save(logkv::StoreSaveMode::forkSave); + return true; +} + +void CesServer::checkAutoSnapshot() { + if (cfg_.maxLogBytes == 0) + return; + // Skip the size read during cooldown — called on every mutation, so an + // over-threshold condition without debounce would spam both the snapshot + // queue and the log. doSnapshot enforces the cooldown too; this just + // dampens here so we don't emit hundreds of "limit reached" INFO lines + // while the previous snapshot is still draining event files. + uint64_t now = minx::getSecsSinceEpoch(); + if (now - lastSnapshotTime_ < SNAPSHOT_COOLDOWN_SECS) + return; + uint64_t accSize = accounts_->getEventsFileSize(); + uint64_t astSize = assets_->getEventsFileSize(); + if (accSize >= cfg_.maxLogBytes || astSize >= cfg_.maxLogBytes) { + LOGINFO << "auto-snapshot: event log size limit reached" + << VAR(accSize) << VAR(astSize) + << VAL("maxLogBytes", cfg_.maxLogBytes); + postLogic( [this]() { + doSnapshot("auto (event log size)"); + }); + } +} + +// ---------------------------------------------------------------------------- +// DISPATCH HELPERS +// ---------------------------------------------------------------------------- + +template +void CesServer::sendSignedReply(const SockAddr& addr, const MinxMessage& msg, + ResT res) { + tpsInc(); + boost::asio::post(taskIO_, [this, addr, msg, res = std::move(res)]() mutable { + reply(addr, MinxMessage{msg.version, minx_->generatePassword(), + msg.gpassword, res.toBytes(serverKeyPair_)}); + }); +} + +template +void CesServer::sendUnsignedReply(const SockAddr& addr, const MinxMessage& msg, + ResT res) { + tpsInc(); + boost::asio::post(taskIO_, [this, addr, msg, res = std::move(res)]() { + reply(addr, MinxMessage{msg.version, minx_->generatePassword(), + msg.gpassword, res.toBytes()}); + }); +} + +template +void CesServer::dispatchSigned(const SockAddr& addr, const MinxMessage& msg, + ReqT req, const Hash& keyField, Fn&& fn, + bool noncelessOk) { + if (!req.verifySignature(msg.data, PublicKey(keyField))) + throw std::runtime_error("bad sig"); + + // NONCELESS is the time-boxed settlement / run-asset escape hatch — only + // the handlers that dedup it (OPEN_TRANSFER, RUN_ASSET) opt in. Any other + // signed op carrying it is misuse: drop it before it reaches validateSpend, + // which would otherwise skip the nonce check and let the op replay. + if (req.reqNonce == CES_NONCELESS && !noncelessOk) + return; + + // Verify server ID to prevent cross-server replay + HashPrefix myId = Account::getMapKey(serverKeyPair_.getPublicKeyAsHash()); + if (req.serverId != myId) { + LOGDEBUG << "dispatchSigned: wrong serverId"; + return; + } + + // Track client presence for push (send()) + HashPrefix kp = Account::getMapKey(keyField); + presence_.put(kp, addr); + { + std::lock_guard lk(presenceReverseMutex_); + presenceReverse_[addr] = kp; + } + + Hash key = keyField; // copy before req is moved + postLogic( + [this, addr, msg, req = std::move(req), key, + fn = std::forward(fn)]() { + HashPrefix prefix = Account::getMapKey(key); + ActiveAccount acc = accounts_.get(prefix); + if (!acc.exists() || acc.data().getKey(prefix) != key) + return; + fn(req, prefix, addr, msg); + }); +} + +CesServer::NoncelessResult CesServer::resolveNonceless( + uint64_t time, const Signature& sig, const HashPrefix& originPrefix, + uint32_t reqNonce, uint32_t& outNonce, uint64_t& outSigHash) { + outNonce = reqNonce; + outSigHash = 0; + if (reqNonce != CES_NONCELESS) + return NoncelessResult::Proceed; + uint64_t now = getMicrosSinceEpoch(); + if (time > now + DEDUP_FUTURE_DRIFT_US || time + DEDUP_WINDOW_US < now) + return NoncelessResult::Stale; + uint64_t sigHash; + std::memcpy(&sigHash, sig.data(), sizeof(sigHash)); + // Check only — the caller records the dedup after the op commits a ledger + // event (recordDedup). A failed op records nothing and stays retryable. + if (isDuplicateDedup(sigHash)) + return NoncelessResult::Duplicate; + outSigHash = sigHash; + auto acc = accounts_.get(originPrefix); + outNonce = acc.exists() ? acc.nonce() + 1 : 1; + return NoncelessResult::Proceed; +} + +void CesServer::onLogicHandlerThrew(const char* what) noexcept { + // Called from postLogic's catch. Must not re-throw (noexcept), so guard the + // log itself. A handler reaching here dropped its op; the strand keeps serving. + try { + LOGERROR << "logic handler threw; op dropped" << SVAR(what ? what : "(unknown)"); + } catch (...) {} +} + +// ---------------------------------------------------------------------------- +// DATA MODEL MUTATORS +// ---------------------------------------------------------------------------- + +uint8_t CesServer::transfer(const minx::Hash& originKey, + const minx::Hash& destKey, uint64_t amount, + TransferMode mode, uint8_t paymentDays, + uint32_t providedNonce, + int64_t& outOriginBalance, int64_t txFee, + int64_t rentFee, int64_t errFee) { + + txFee = discountedFlatFee(txFee, cfg_.feeTx, FeeKind::Tx); + // Keep rentFee raw — it's a per-day rate; the prepay-days creation + // cost below routes through attenuatedFundCost. + rentFee = resolveFee(rentFee, cfg_.feeAccount); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + uint64_t totalDeduction = amount + txFee; + + // GATE hooks, before any state change. Safe mode only: an incoming cross + // settlement (TransferMode::Open) and a payment-account settle + // (TransferMode::Payment) already committed on the origin / are a distinct + // mechanism, so a gate must never reject them (it would break vostro/reserve + // conservation). The origin's OUT gate (self-veto of its own send) fires + // first, then the dest's IN gate (a courtesy signal to the sender). A non-OK + // verdict rejects the whole transfer with nothing mutated. Read-only peeks + // inside the helper: no handle is held across the run, which may rehash maps. + if (mode == TransferMode::Safe) { + // Inbound dust floor: a deposit worth less than the cost of screening it + // fires no inbound hook. Outbound has no floor (your account, your + // footgun), so the OUT gate always runs. + uint64_t floor = hookFreeGrant(); + bool ok = fireAccountHook(originKey, ALIAS_OP_HOOK_GATE, + INVOKE_HOOK_XFER_OUT, destKey, amount) && + (amount < floor || + fireAccountHook(destKey, ALIAS_OP_HOOK_GATE, + INVOKE_HOOK_XFER_IN, originKey, amount)); + if (!ok) { + ActiveAccount o = accounts_.get(Account::getMapKey(originKey)); + if (o.exists()) { + o.chargeError(errFee); + outOriginBalance = o.balance(); + } + LOGDEBUG << "transfer: GATE rejected"; + return CES_ERROR_HOOK_REJECTED; + } + } + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + uint8_t rc = origin.validateSpend(amount, txFee, providedNonce, errFee); + if (rc != CES_OK) { + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: validateSpend failed" << VAR(rc); + return rc; + } + + HashPrefix destId = Account::getMapKey(destKey); + ActiveAccount dest = accounts_.get(destId); + + if (!dest.exists()) { + // Destination doesn't exist + if (mode == TransferMode::Safe) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: safe mode, target not found"; + return CES_ERROR_TARGET_NOT_FOUND; + } + + if (accounts_->getObjects().size() >= cfg_.maxAcc) { + LOGDEBUG << "transfer: max accounts reached"; + return CES_ERROR_INTERNAL; + } + + uint64_t creationCost = + (mode == TransferMode::Payment) + ? attenuatedFundCost(FeeKind::AccountRent, rentFee, + 2u + static_cast(paymentDays), 0) + : attenuatedFundCost(FeeKind::AccountRent, rentFee, 3, 0); + + if (Accounts::checkAddOverflow(totalDeduction, creationCost, + totalDeduction)) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: overflow with create cost"; + return CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE; + } + + if (origin.balance() < static_cast(totalDeduction)) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: insufficient balance for create"; + return CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE; + } + + if (creditWouldOverflow(0, amount)) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: create would exceed balance cap"; + return CES_ERROR_BALANCE_OVERFLOW; + } + + Account newAccount; + if (mode == TransferMode::Payment) { + newAccount = + Account(destKey, -static_cast(amount), 1 + paymentDays); + } else { + newAccount = Account(destKey, amount, 0); + } + accounts_.createAccount(destId, newAccount); + + } else { + // Destination exists + if (mode == TransferMode::Payment) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: payment mode, target already exists"; + return CES_ERROR_INVALID_TARGET_ACCOUNT; + } + + if (dest.data().getKey(dest.id) != destKey) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: wrong target account key"; + return CES_ERROR_WRONG_TARGET_ACCOUNT; + } + + if (dest.balance() < 0) { + if (amount != static_cast(-dest.balance())) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: wrong payment amount" << VAR(amount); + return CES_ERROR_WRONG_PAYMENT_AMOUNT; + } + dest.settlePayment(amount); + } else { + if (creditWouldOverflow(dest.balance(), amount)) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "transfer: destination would exceed balance cap"; + return CES_ERROR_BALANCE_OVERFLOW; + } + dest.credit(amount); + } + } + + origin.debitTransfer(totalDeduction, destId, amount); + outOriginBalance = origin.balance(); + accounts_.checkFlush(totalDeduction); + checkAutoSnapshot(); + + // WATCH hooks: fire AFTER the transfer commits. Observe-only - the verdict is + // ignored, delivery already happened (fail-open). Safe mode only, same + // exemption as the gates above (settlement / payment legs never fire a hook). + // An account has one sidecar, so at most one of GATE (above) / WATCH fires per + // side. Dest's IN watch, then origin's OUT watch. The helper re-peeks fresh, + // so both see post-transfer balances. + if (mode == TransferMode::Safe) { + if (amount >= hookFreeGrant()) // inbound dust floor; outbound has none + fireAccountHook(destKey, ALIAS_OP_HOOK_WATCH, INVOKE_HOOK_XFER_IN, + originKey, amount); + fireAccountHook(originKey, ALIAS_OP_HOOK_WATCH, INVOKE_HOOK_XFER_OUT, + destKey, amount); + } + + LOGTRACE << "transfer ok" << VAR(amount) << VAR(outOriginBalance); + return CES_OK; +} + +uint8_t CesServer::bulkTransfer(const minx::Hash& originKey, + const std::vector& items, + uint32_t providedNonce, + int64_t& outOriginBalance, + uint8_t& outSuccessfulCount, int64_t txFee, + int64_t rentFee, int64_t errFee) { + + txFee = discountedFlatFee(txFee, cfg_.feeTx, FeeKind::Tx); + rentFee = resolveFee(rentFee, cfg_.feeAccount); // raw per-day rate + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + + // Validate nonce and charge the base fee for the envelope ONCE + uint8_t rc = origin.validateSpend(0, txFee, providedNonce, errFee); + if (rc != CES_OK) { + outOriginBalance = origin.balance(); + outSuccessfulCount = 0; + return rc; + } + + outSuccessfulCount = 0; + + // The Stop-And-Commit Loop + for (const auto& item : items) { + // Charge the standard txFee for each internal transfer as well + uint64_t totalDeduction = item.amount + txFee; + + HashPrefix destId = Account::getMapKey(item.destKey); + ActiveAccount dest = accounts_.get(destId); + + if (!dest.exists()) { + // Bulk transfer always auto-creates (Open mode) + if (accounts_->getObjects().size() >= cfg_.maxAcc) { + rc = CES_ERROR_INTERNAL; + break; + } + + uint64_t creationCost = + attenuatedFundCost(FeeKind::AccountRent, rentFee, 3, 0); + + if (Accounts::checkAddOverflow(totalDeduction, creationCost, + totalDeduction)) { + origin.chargeError(errFee); + rc = CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE; + break; + } + if (origin.balance() < static_cast(totalDeduction)) { + origin.chargeError(errFee); + rc = CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE; + break; + } + + if (creditWouldOverflow(0, item.amount)) { + origin.chargeError(errFee); + rc = CES_ERROR_BALANCE_OVERFLOW; + break; + } + + Account newAccount(item.destKey, item.amount, 0); + accounts_.createAccount(destId, newAccount); + } else { + if (dest.data().getKey(dest.id) != item.destKey) { + origin.chargeError(errFee); + rc = CES_ERROR_WRONG_TARGET_ACCOUNT; + break; + } + if (origin.balance() < static_cast(totalDeduction)) { + origin.chargeError(errFee); + rc = CES_ERROR_INSUFFICIENT_BALANCE; + break; + } + + if (dest.balance() < 0) { + if (item.amount != static_cast(-dest.balance())) { + origin.chargeError(errFee); + rc = CES_ERROR_WRONG_PAYMENT_AMOUNT; + break; + } + dest.settlePayment(item.amount); + } else { + if (creditWouldOverflow(dest.balance(), item.amount)) { + origin.chargeError(errFee); + rc = CES_ERROR_BALANCE_OVERFLOW; + break; + } + dest.credit(item.amount); + } + } + + origin.debit(totalDeduction); + accounts_.checkFlush(totalDeduction); + outSuccessfulCount++; + } + + outOriginBalance = origin.balance(); + checkAutoSnapshot(); + return rc; +} + +uint8_t CesServer::queryAccount(const minx::Hash& originKey, + const HashPrefix& queryId, uint8_t items, + uint32_t providedNonce, + int64_t& outOriginBalance, + std::vector& outResults, + int64_t queryFee, int64_t errFee) { + if (items >= CesQueryAccount::MAX_ITEMS) { + throw std::runtime_error("too many items"); + } + + queryFee = discountedFlatFee(queryFee, cfg_.feeQuery, FeeKind::Query); + if (items > 0) { + queryFee += (queryFee * (items + 1)) / CesQueryAccount::MAX_ITEMS; + } + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + + uint8_t rc = origin.validateSpend(0, queryFee, providedNonce, errFee); + if (rc != CES_OK) { + if (origin.exists()) + outOriginBalance = origin.balance(); + return rc; + } + + ActiveAccount target = + LOGKV_IS_EMPTY(queryId) ? accounts_.getFirst() : accounts_.get(queryId); + + if (!target.exists()) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "queryAccount: target not found"; + return CES_ERROR_INVALID_TARGET_ACCOUNT; + } + + auto itQuery = target.it; + size_t count = static_cast(items) + 1; + outResults.reserve(count); + while (outResults.size() < count && itQuery != accounts_->end()) { + AccountEntry entry; + entry.key = itQuery->second.getKey(itQuery->first); + entry.balance = itQuery->second.getBalance(); + entry.nonce = itQuery->second.getNonce(); + entry.lastXferDest = itQuery->second.getLastXferDest(); + entry.lastXferAmount = itQuery->second.getLastXferAmount(); + entry.lastXferTime = itQuery->second.getLastXferTime(); + outResults.push_back(entry); + ++itQuery; + } + + origin.debit(queryFee); + outOriginBalance = origin.balance(); + accounts_.checkFlush(queryFee); + + LOGTRACE << "queryAccount ok" << VAR(outResults.size()); + return CES_OK; +} + +void CesServer::unsignedQueryAccount(const HashPrefix& queryId, + int64_t& outBalance, uint32_t& outNonce, + HashPrefix& outLastXferDest, + uint64_t& outLastXferAmount, + uint32_t& outLastXferTime, + uint32_t& outAliasId) { + ActiveAccount acc = accounts_.get(queryId); + if (acc.exists()) { + outBalance = acc.balance(); + outNonce = acc.nonce(); + outLastXferDest = acc.data().getLastXferDest(); + outLastXferAmount = acc.data().getLastXferAmount(); + outLastXferTime = acc.data().getLastXferTime(); + outAliasId = acc.data().getAliasId(); + } else { + outBalance = 0; + outNonce = 0; + outLastXferDest = {}; + outLastXferAmount = 0; + outLastXferTime = 0; + outAliasId = 0; + } +} + +uint8_t CesServer::queryServerInfo(const minx::Hash& originKey, + uint32_t providedNonce, + int64_t& outOriginBalance, + std::vector& outEntries, + int64_t queryFee, int64_t errFee) { + queryFee = discountedFlatFee(queryFee, cfg_.feeQuery, FeeKind::Query); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + + uint8_t rc = origin.validateSpend(0, queryFee, providedNonce, errFee); + if (rc != CES_OK) { + if (origin.exists()) + outOriginBalance = origin.balance(); + return rc; + } + + origin.debit(queryFee); + outOriginBalance = origin.balance(); + accounts_.checkFlush(queryFee); + + auto kv = [&](const char* k, const std::string& v) { + outEntries.push_back({k, v}); + }; + + kv("totalAccounts", std::to_string(accounts_->getObjects().size())); + kv("totalAssets", std::to_string(assets_->getObjects().size())); + kv("totalAliases", std::to_string(aliases_->getObjects().size())); + kv("totalCredits", std::to_string(circulatingCredits())); + kv("feeAccount", std::to_string(cfg_.feeAccount)); + kv("feeAsset", std::to_string(cfg_.feeAsset)); + kv("feeTx", std::to_string(cfg_.feeTx)); + kv("feeQuery", std::to_string(cfg_.feeQuery)); + kv("feeError", std::to_string(cfg_.getFeeError())); + kv("feeVmMult", std::to_string(cfg_.feeVmMult)); + kv("feeFileRent", std::to_string(cfg_.feeFileRent)); + kv("feeFileWrite", std::to_string(cfg_.feeFileWrite)); + kv("feeFileRead", std::to_string(cfg_.feeFileRead)); + kv("cesFileStoreMaxBytes", std::to_string(cfg_.cesFileStoreMaxBytes)); + kv("minAccounts", std::to_string(cfg_.minAcc)); + kv("maxAccounts", std::to_string(cfg_.maxAcc)); + kv("minAssets", std::to_string(cfg_.minAsset)); + kv("maxAssets", std::to_string(cfg_.maxAsset)); + kv("minAliases", std::to_string(cfg_.minAlias)); + kv("maxAliases", std::to_string(cfg_.maxAlias)); + kv("minDifficulty", std::to_string(cfg_.minDiff)); + kv("spendSlotSize", std::to_string(cfg_.spendSlotSize)); + kv("tps", std::to_string(tpsCurrent_.load())); + { + std::lock_guard lock(peerTableMutex_); + kv("peerCount", std::to_string(peerTable_.size())); + } + kv("serverPublicKey", minx::hashToString(serverKeyPair_.getPublicKeyAsHash())); + // Where this server's CesPlex (file/compute/lua) listens; 0 = disabled. + // Already advertised in the free MINX GetInfo (rdata), mirrored here so the + // paid KV is self-complete too. + kv("rpcPort", std::to_string(rpcBoundPort_)); + if (!cfg_.serverName.empty()) + kv("serverName", cfg_.serverName); + if (!cfg_.version.empty()) + kv("version", cfg_.version); + // Always emit hello, even when empty — an inspector wants to see that the + // field exists and the server simply has no banner set, not have it vanish. + kv("hello", _getHello()); + + return CES_OK; +} + +uint8_t CesServer::crossTransfer(const minx::Hash& originKey, + const minx::Hash& destKey, uint64_t amount, + const std::string& destServer, + uint32_t providedNonce, + int64_t& outOriginBalance, + int64_t txFee, int64_t errFee) { + txFee = discountedFlatFee(txFee, cfg_.feeTx, FeeKind::Tx); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + // 1. Validate origin account + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + uint8_t rc = origin.validateSpend(amount, txFee, providedNonce, errFee); + if (rc != CES_OK) { + outOriginBalance = origin.balance(); + LOGDEBUG << "crossTransfer: validateSpend failed" << VAR(rc); + return rc; + } + + // 2. Find peer in table + minx::Hash peerKey{}; + bool peerFound = false; + { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.declaredAddress == destServer && p.reachable) { + peerKey = p.ckey; + peerFound = true; + break; + } + } + } + if (!peerFound) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "crossTransfer: unknown/unreachable peer" << VAR(destServer); + return CES_ERROR_UNKNOWN_PEER; + } + + // 3. Resolve the settlement client and check queue capacity. A null + // client means the peer's address would not resolve right now + // (getOrCreateSettlementClient already logged it): reject BEFORE any + // debit, so we never commit a local debit with no settlement dispatch + // behind it. Mirrors the unreachable-peer rejection above. + auto* settlementClient = getOrCreateSettlementClient(destServer, peerKey); + if (!settlementClient) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + return CES_ERROR_UNKNOWN_PEER; + } + if (settlementClient->load() >= 95) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + return CES_ERROR_QUEUE_FULL; + } + + // 4. Reject before any debit if the peer's vostro can't hold the amount, so + // a cross-transfer never destroys credits by saturating; then debit origin + // and credit the vostro. + uint64_t totalDeduction = amount + txFee; + HashPrefix peerMapKey = Account::getMapKey(peerKey); + { + ActiveAccount v = accounts_.get(peerMapKey); + int64_t vbal = v.exists() ? v.balance() : 0; + if (creditWouldOverflow(vbal, amount)) { + origin.chargeError(errFee); + outOriginBalance = origin.balance(); + LOGDEBUG << "crossTransfer: vostro would exceed balance cap"; + return CES_ERROR_BALANCE_OVERFLOW; + } + } + origin.debitTransfer(totalDeduction, peerMapKey, amount); + outOriginBalance = origin.balance(); + + ActiveAccount peerVostro = accounts_.get(peerMapKey); + if (peerVostro.exists()) { + peerVostro.credit(amount); + } else { + Account newAcc(peerKey, amount, 0); + accounts_.createAccount(peerMapKey, newAcc); + } + + accounts_.checkFlush(totalDeduction); + + // SETTLE_OUT watch: record this cross-send in the sender's history. Watch + // only (an outbound gate is a porous soft guardrail, not put on the + // settlement path) and fired only after the local debit + vostro credit + // stand. The origin/peerVostro handles above must not be reused after this + // (the run may rehash the maps); the settlement dispatch below uses captured + // values, so they are not. + fireAccountHook(originKey, ALIAS_OP_HOOK_WATCH, INVOKE_HOOK_SETTLE_OUT, + destKey, amount); + + // 5. Dispatch remote transfer (settlementClient is non-null here). The + // callback fires only on a TERMINAL failure: settlement retries until the + // peer confirms, so a non-OK rc means we ultimately gave up (peer stayed + // unreachable past the dedup window). That should not happen in a healthy + // mesh — if it does, it is probably a peering bug — so log every detail at + // INFO. The local debit + vostro credit already stand. + settlementClient->openTransfer(destKey, amount, + [originKey, destKey, peerKey, destServer, amount](uint8_t rc) { + if (rc != CES_OK) { + LOGINFO << "cross-transfer settlement GAVE UP " + "(peer never confirmed; likely a peering bug)" + << VAR(rc) << SVAR(destServer) << VAR(amount) + << BVAR(originKey) << BVAR(destKey) << BVAR(peerKey); + } + }); + checkAutoSnapshot(); + return CES_OK; +} + +uint8_t CesServer::createAsset(const minx::Hash& originKey, + const HashPrefix& ownerId, + const minx::Hash& assetId, + const AssetData& content, uint16_t balance, + uint32_t providedNonce, int64_t rentFee, + int64_t errFee) { + rentFee = resolveFee(rentFee, cfg_.feeAsset); // raw per-day rate + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + // The all-zero key is reserved: it is the VM's "no boot asset" self + // sentinel (an alias program's io self-key), so it must never name a real + // asset. + if (assetId == minx::Hash{}) + return CES_ERROR_BAD_INPUT; + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint64_t totalCost = attenuatedFundCost( + FeeKind::AssetRent, rentFee, + 2u + static_cast(assetDays(balance)), 0); + + uint8_t rc = origin.validateSpend(0, totalCost, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + ActiveAsset asset = assets_.get(assetId); + + if (asset.exists()) { + origin.chargeError(errFee); + LOGDEBUG << "createAsset: asset already exists"; + return CES_ERROR_ASSET_EXISTS; + } + if (assets_->getObjects().size() >= cfg_.maxAsset) { + LOGDEBUG << "createAsset: max assets reached"; + return CES_ERROR_INTERNAL; + } + + origin.debit(totalCost); + accounts_.checkFlush(totalCost); + + bool priv = isAssetPrivate(balance); + bool immut = isAssetImmutable(balance); + uint32_t storeDays = 1u + assetDays(balance); + if (storeDays > 0x0FFF) storeDays = 0x0FFF; + Asset newAsset(ownerId, content, + assetBalance(static_cast(storeDays), priv, + /*aowned=*/false, immut, + isAssetOwnerPays(balance)), 0); + Asset::SerModeGuard guard(Asset::SerMode::Full); + assets_->update(assetId, newAsset); + + assets_.checkFlush(totalCost); + checkAutoSnapshot(); + LOGTRACE << "createAsset ok" << VAR(assetId) << VAR(balance); + return CES_OK; +} + +uint8_t CesServer::createAssetRange(const minx::Hash& originKey, + const HashPrefix& ownerId, + const minx::Hash& firstKey, uint32_t count, + uint16_t days, uint32_t providedNonce, + int64_t rentFee, int64_t errFee) { + rentFee = resolveFee(rentFee, cfg_.feeAsset); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + if (count == 0 || count > CESVM_MAX_ASSET_RANGE || + firstKey == minx::Hash{}) // all-zero key reserved (VM self sentinel) + return CES_ERROR_BAD_INPUT; + + uint64_t perCell = attenuatedFundCost( + FeeKind::AssetRent, rentFee, + 2u + static_cast(assetDays(days)), 0); + uint64_t totalCost = perCell * count; + + uint8_t rc = origin.validateSpend(0, totalCost, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + if (assets_->getObjects().size() + count > cfg_.maxAsset) { + origin.chargeError(errFee); + LOGDEBUG << "createAssetRange: max assets reached"; + return CES_ERROR_INTERNAL; + } + + // Atomic: reject the whole batch if any target key already exists. + minx::Hash key = firstKey; + for (uint32_t i = 0; i < count; ++i) { + rangeSetCellIndex(key, i); + if (assets_.get(key).exists()) { + origin.chargeError(errFee); + return CES_ERROR_ASSET_EXISTS; + } + } + + origin.debit(totalCost); + accounts_.checkFlush(totalCost); + + bool priv = isAssetPrivate(days); + bool immut = isAssetImmutable(days); + uint32_t storeDays = 1u + assetDays(days); + if (storeDays > 0x0FFF) storeDays = 0x0FFF; + auto bal = assetBalance(static_cast(storeDays), priv, + /*aowned=*/false, immut, isAssetOwnerPays(days)); + Asset::SerModeGuard guard(Asset::SerMode::Full); + for (uint32_t i = 0; i < count; ++i) { + rangeSetCellIndex(key, i); + AssetData content{}; + if (i == 0) rangeSetLength(content, count); + assets_->update(key, Asset(ownerId, content, bal, 0)); + } + assets_.checkFlush(totalCost); + checkAutoSnapshot(); + LOGTRACE << "createAssetRange ok" << VAR(count) << VAR(days); + return CES_OK; +} + +uint8_t CesServer::updateAsset(const minx::Hash& originKey, + const minx::Hash& assetId, + const HashPrefix& newOwnerId, + const AssetData& content, uint32_t price, + uint32_t providedNonce, int64_t updateFee, + int64_t errFee) { + updateFee = discountedFlatFee(updateFee, cfg_.feeAsset, FeeKind::AssetRent); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, updateFee, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + ActiveAsset asset = assets_.get(assetId); + + if (!asset.exists()) { + origin.chargeError(errFee); + LOGDEBUG << "updateAsset: not found"; + return CES_ERROR_ASSET_NOT_FOUND; + } + if (asset.getOwnerId() != origin.id) { + origin.chargeError(errFee); + LOGDEBUG << "updateAsset: not owner"; + return CES_ERROR_NOT_OWNER; + } + if (isAssetImmutable(asset.getBalance())) { + origin.chargeError(errFee); + LOGDEBUG << "updateAsset: immutable"; + return CES_ERROR_IMMUTABLE; + } + + origin.debit(updateFee); + accounts_.checkFlush(updateFee); + + asset.updateFull(newOwnerId, content, price); + + assets_.checkFlush(updateFee); + checkAutoSnapshot(); + LOGTRACE << "updateAsset ok" << VAR(assetId); + return CES_OK; +} + +uint8_t CesServer::updateAssetMeta(const minx::Hash& originKey, + const minx::Hash& assetId, + const HashPrefix& newOwnerId, uint32_t price, + uint32_t providedNonce, int64_t updateFee, + int64_t errFee) { + updateFee = discountedFlatFee(updateFee, cfg_.feeTx, FeeKind::Tx); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, updateFee, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + ActiveAsset asset = assets_.get(assetId); + + if (!asset.exists()) { + origin.chargeError(errFee); + LOGDEBUG << "updateAssetMeta: not found"; + return CES_ERROR_ASSET_NOT_FOUND; + } + if (asset.getOwnerId() != origin.id) { + origin.chargeError(errFee); + LOGDEBUG << "updateAssetMeta: not owner"; + return CES_ERROR_NOT_OWNER; + } + + origin.debit(updateFee); + accounts_.checkFlush(updateFee); + + asset.setPrice(price); + asset.setOwner(newOwnerId); + + assets_.checkFlush(updateFee); + checkAutoSnapshot(); + LOGTRACE << "updateAssetMeta ok" << VAR(assetId); + return CES_OK; +} + +uint8_t CesServer::updateAssetFast(const minx::Hash& originKey, + const minx::Hash& assetId, + const AssetData& content, + uint32_t providedNonce, + int64_t fastUpdateFee, int64_t errFee) { + fastUpdateFee = discountedFlatFee(fastUpdateFee, cfg_.feeAsset, + FeeKind::AssetRent); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, fastUpdateFee, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + ActiveAsset asset = assets_.get(assetId); + + if (!asset.exists()) { + origin.chargeError(errFee); + LOGDEBUG << "updateAssetFast: not found"; + return CES_ERROR_ASSET_NOT_FOUND; + } + if (asset.getOwnerId() != origin.id) { + origin.chargeError(errFee); + LOGDEBUG << "updateAssetFast: not owner"; + return CES_ERROR_NOT_OWNER; + } + if (isAssetImmutable(asset.getBalance())) { + origin.chargeError(errFee); + LOGDEBUG << "updateAssetFast: immutable"; + return CES_ERROR_IMMUTABLE; + } + + origin.debit(fastUpdateFee); + accounts_.checkFlush(fastUpdateFee); + + asset.setContent(content); + + checkAutoSnapshot(); + LOGTRACE << "updateAssetFast ok" << VAR(assetId); + return CES_OK; +} + +uint8_t CesServer::fundAsset(const minx::Hash& originKey, + const minx::Hash& assetId, uint16_t balance, + uint32_t providedNonce, int64_t fundFee, + int64_t rentFee, int64_t errFee) { + fundFee = discountedFlatFee(fundFee, cfg_.feeTx, FeeKind::Tx); + rentFee = resolveFee(rentFee, cfg_.feeAsset); // raw per-day rate + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + // Read existing days first so attenuation is correct against current + // prepaid balance — funding when many days are already held costs + // close to full price for the trailing days. + ActiveAsset asset = assets_.get(assetId); + uint32_t held = asset.exists() ? assetDays(asset.getBalance()) : 0u; + // The day field caps at 0x0FFF, so bill only for the days actually + // added: funding past the cap grants fewer days than requested. + uint32_t granted = std::min(0x0FFF, held + balance) - held; + uint64_t rentCost = attenuatedFundCost( + FeeKind::AssetRent, rentFee, granted, held); + uint64_t totalCost = rentCost + fundFee; + + uint8_t rc = origin.validateSpend(0, totalCost, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + if (!asset.exists()) { + origin.chargeError(errFee); + LOGDEBUG << "fundAsset: not found"; + return CES_ERROR_ASSET_NOT_FOUND; + } + + origin.debit(totalCost); + accounts_.checkFlush(totalCost); + + bool priv = isAssetPrivate(asset.getBalance()); + bool aowned = isAssetOwned(asset.getBalance()); + bool immut = isAssetImmutable(asset.getBalance()); + uint16_t curDays = assetDays(asset.getBalance()); + uint32_t newDays = curDays + balance; + if (newDays > 0x0FFF) newDays = 0x0FFF; + asset.setBalance( + assetBalance(static_cast(newDays), priv, aowned, immut, + isAssetOwnerPays(asset.getBalance()))); + + assets_.checkFlush(totalCost); + checkAutoSnapshot(); + LOGTRACE << "fundAsset ok" << VAR(assetId) << VAR(newDays); + return CES_OK; +} + +uint8_t CesServer::setAssetOwnerPays(const minx::Hash& originKey, + const minx::Hash& assetId, bool ownerPays, + uint32_t providedNonce, int64_t fee, + int64_t errFee) { + fee = discountedFlatFee(fee, cfg_.feeTx, FeeKind::Tx); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, fee, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + ActiveAsset asset = assets_.get(assetId); + if (!asset.exists()) { + origin.chargeError(errFee); + return CES_ERROR_ASSET_NOT_FOUND; + } + if (asset.getOwnerId() != origin.id) { + origin.chargeError(errFee); + LOGDEBUG << "setAssetOwnerPays: not owner"; + return CES_ERROR_NOT_OWNER; + } + + uint16_t bal = asset.getBalance(); + asset.setBalance(assetBalance(assetDays(bal), isAssetPrivate(bal), + isAssetOwned(bal), isAssetImmutable(bal), + ownerPays)); + origin.debit(fee); + accounts_.checkFlush(fee); + checkAutoSnapshot(); + LOGTRACE << "setAssetOwnerPays ok" << VAR(assetId) << VAR(ownerPays); + return CES_OK; +} + +uint8_t CesServer::buyAsset(const minx::Hash& originKey, + const minx::Hash& assetId, uint64_t priceLimit, + uint32_t providedNonce, int64_t buyFee, + int64_t errFee) { + buyFee = discountedFlatFee(buyFee, cfg_.feeTx, FeeKind::Tx); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount buyer = accounts_.get(Account::getMapKey(originKey)); + if (!buyer.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + ActiveAsset asset = assets_.get(assetId); + + if (!asset.exists()) { + buyer.chargeError(errFee); + LOGDEBUG << "buyAsset: not found"; + return CES_ERROR_ASSET_NOT_FOUND; + } + + if (asset.getOwnerId() == buyer.id) { + buyer.chargeError(errFee); + LOGDEBUG << "buyAsset: buyer is already owner"; + return CES_ERROR_INVALID_TARGET_ACCOUNT; + } + + uint64_t price = storedToRealPrice(asset.getPrice()); + if (price == 0) { + buyer.chargeError(errFee); + LOGDEBUG << "buyAsset: not for sale"; + return CES_ERROR_NOT_FOR_SALE; + } + if (priceLimit < price) { + buyer.chargeError(errFee); + LOGDEBUG << "buyAsset: price exceeds limit" << VAR(price) + << VAR(priceLimit); + return CES_ERROR_INSUFFICIENT_PAYMENT; + } + + uint8_t rc = buyer.validateSpend(price, buyFee, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + // Reject before debiting the buyer if the seller can't hold the price, so a + // purchase never destroys credits by saturating. + ActiveAccount seller = accounts_.get(asset.getOwnerId()); + if (seller.exists() && creditWouldOverflow(seller.balance(), price)) { + buyer.chargeError(errFee); + LOGDEBUG << "buyAsset: seller would exceed balance cap"; + return CES_ERROR_BALANCE_OVERFLOW; + } + + uint64_t totalCost = price + buyFee; + buyer.debit(totalCost); + accounts_.checkFlush(totalCost); + + if (seller.exists()) { + seller.credit(price); + } + + asset.transferOwnership(Account::getMapKey(originKey)); + + assets_.checkFlush(totalCost); + checkAutoSnapshot(); + LOGTRACE << "buyAsset ok" << VAR(assetId) << VAR(price); + return CES_OK; +} + +// =========================================================================== +// Alias ops (local/aliases.md) +// =========================================================================== + +// Allocate the next alias id from the generator cell (id 0). Seeds it if +// absent, finds a free id at/after next-id (wrapping past 2^32, skipping id 0 +// and occupied slots; live count << 2^32, so a free slot is always near), +// bumps next-id first (a crash between skips one id, harmless), and returns +// the id for the caller to insert at. +static uint32_t allocAliasId(Aliases& aliases) { + Alias gen; + auto g0 = aliases.get(0); + if (g0.exists()) + gen = g0.data(); + else + gen.setOp(ALIAS_OP_SYSTEM); // reserved: never empty, never billed + + uint32_t next = 0; + std::memcpy(&next, gen.getContent().data(), sizeof(next)); + if (next == 0) next = 1; + + uint32_t id = next; + while (id == 0 || aliases.get(id).exists()) + id = (id == UINT32_MAX) ? 1u : id + 1; + + uint32_t bumped = (id == UINT32_MAX) ? 1u : id + 1; + std::memcpy(gen.accessContent().data(), &bumped, sizeof(bumped)); + Alias::SerModeGuard guard(Alias::SerMode::Full); + aliases->update(0, gen); + return id; +} + +void CesServer::_setAliasNextId(uint32_t next) { + Alias gen; + auto g0 = aliases_.get(0); + if (g0.exists()) + gen = g0.data(); + else + gen.setOp(ALIAS_OP_SYSTEM); + std::memcpy(gen.accessContent().data(), &next, sizeof(next)); + Alias::SerModeGuard guard(Alias::SerMode::Full); + aliases_->update(0, gen); +} + +uint8_t CesServer::setAlias(const minx::Hash& originKey, uint32_t aliasId, + uint16_t offset, const ces::Bytes& bytes, + uint32_t providedNonce, uint32_t& outAliasId, + int64_t fee, int64_t errFee) { + fee = discountedFlatFee(fee, cfg_.feeAccount * ALIAS_BYTES / ACCOUNT_BYTES, + FeeKind::AccountRent); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + outAliasId = 0; + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + // Bounds: nobody writes the server-set owner field, and a patch may not run + // past the end of the value image. An illegal write rejects whole. + if (offset < ALIAS_PATCH_MIN_OWNER || + offset + bytes.size() > ALIAS_VALUE_BYTES) + return CES_ERROR_BAD_INPUT; + + // Resolve the target cell and the signer's patch floor. aliasId 0 is the + // signer's own alias (create on first use); nonzero must be a live cell the + // signer owns or edits. + uint32_t targetId = aliasId == 0 ? origin.data().getAliasId() : aliasId; + Aliases::ActiveAlias existing = aliases_.get(targetId); + bool live = targetId != 0 && existing.exists(); + bool create = false; + size_t patchFloor = ALIAS_PATCH_MIN_OWNER; + if (aliasId == 0) { + // Own alias: a stale link (cell reclaimed) counts as none. + create = !(live && existing.getOwner() == origin.id); + } else { + if (!live) + return CES_ERROR_ALIAS_NOT_FOUND; + if (existing.getOwner() == origin.id) + patchFloor = ALIAS_PATCH_MIN_OWNER; + else if (existing.getEditor() == origin.id) + patchFloor = ALIAS_PATCH_MIN_EDITOR; + else + return CES_ERROR_NOT_OWNER; + } + if (offset < patchFloor) + return CES_ERROR_NOT_OWNER; + + // Build the resulting value image (zeroed on create) so hook validation + // sees exactly what would be committed. + Alias next = create ? Alias(origin.id, HashPrefix{}, 0, AliasData{}) + : existing.data(); + if (!bytes.empty()) + std::memcpy(next.imageData() + offset, bytes.data(), bytes.size()); + + // Hook sidecars point at a trigger program that runs with unbounded gas + // headroom relative to the account's own money; if the target could change + // after this check, a stranger could swap in draining bytecode. Require the + // trigger asset to be IMMUTABLE or owned by the setter, so the code the + // account committed to can never change under it. Checked on every patch + // that leaves a hook op in place (the setter here is the signer, owner or + // editor). + if (aliasOpIsHook(next.getOp())) { + minx::Hash triggerKey{}; + std::memcpy(triggerKey.data(), next.getContent().data(), KEY_SIZE); + auto trigger = assets_.get(triggerKey); + bool ok = trigger.exists() && + (isAssetImmutable(trigger.data().getBalance()) || + trigger.data().getOwnerId() == origin.id); + if (!ok) { + LOGDEBUG << "setAlias: hook target not immutable or owned"; + return CES_ERROR_HOOK_TARGET; + } + } + + uint8_t rc = origin.validateSpend(0, static_cast(fee), + providedNonce, errFee); + if (rc != CES_OK) + return rc; + + if (!create) { + // Patch in place: the id stays dependable across edits. + existing.updateValue(next); // persists Full + outAliasId = targetId; + } else { + // No live alias of ours: allocate a fresh id and bind it to the account. + if (aliases_->getObjects().size() >= cfg_.maxAlias) { + origin.chargeError(errFee); + LOGDEBUG << "setAlias: max aliases reached"; + return CES_ERROR_INTERNAL; + } + uint32_t newId = allocAliasId(aliases_); + { + Alias::SerModeGuard guard(Alias::SerMode::Full); + aliases_->update(newId, next); + } + { + origin.data().setAliasId(newId); + Account::SerModeGuard guard(Account::SerMode::Full); + accounts_->persist(origin.it); + } + outAliasId = newId; + } + + // Charge the day (debit persists BalanceNonce, advancing the nonce and + // preserving aliasId). + origin.debit(static_cast(fee)); + accounts_.checkFlush(static_cast(fee)); + aliases_.checkFlush(static_cast(fee)); + checkAutoSnapshot(); + LOGTRACE << "setAlias ok" << VAR(outAliasId) << VAR(offset) + << VAR(bytes.size()); + return CES_OK; +} + +uint8_t CesServer::deleteAlias(const minx::Hash& originKey, + uint32_t providedNonce, int64_t errFee) { + int64_t fee = discountedFlatFee(-1, cfg_.feeQuery, FeeKind::Query); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, static_cast(fee), + providedNonce, errFee); + if (rc != CES_OK) + return rc; + + uint32_t id = origin.data().getAliasId(); + if (id == 0 || !aliases_.get(id).exists()) { + origin.chargeError(errFee); + return CES_ERROR_ALIAS_NOT_FOUND; + } + + aliases_->erase(id); + { + origin.data().setAliasId(0); + Account::SerModeGuard guard(Account::SerMode::Full); + accounts_->persist(origin.it); + } + origin.debit(static_cast(fee)); + + accounts_.checkFlush(static_cast(fee)); + checkAutoSnapshot(); + LOGTRACE << "deleteAlias ok" << VAR(id); + return CES_OK; +} + +bool CesServer::queryAlias(uint32_t aliasId, Alias& out) { + if (aliasId == 0) + return false; // id 0 is the generator cell, not a user alias + auto a = aliases_.get(aliasId); + if (!a.exists()) + return false; + out = a.data(); + return true; +} + +// key_names --------------------------------------------------------------- +// The signer's key IS the entry owner: originKey is the 32-byte public key +// whose signature the dispatch already verified, so binding it to a name can +// never impersonate. Fee + nonce charged to the key's account, like an alias. +uint8_t CesServer::registerKeyName(const minx::Hash& originKey, + const ces::Bytes& name, + uint32_t providedNonce, int64_t errFee) { + // Byte-proportional rent, derived from the account fee at the byte ratio + // (128/64 = 2x), the same base the daily maintenance charges. No independent + // knob: it tracks feeAccount. + int64_t fee = discountedFlatFee( + -1, cfg_.feeAccount * KEYNAME_BYTES / ACCOUNT_BYTES, FeeKind::AccountRent); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, static_cast(fee), + providedNonce, errFee); + if (rc != CES_OK) + return rc; + + KeyNameData nm{}; + std::memcpy(nm.data(), name.data(), + std::min(name.size(), nm.size())); + + KeyNames::RegisterResult res = + keyNames_.registerName(originKey, nm, cfg_.maxKeyName); + if (res != KeyNames::RegisterResult::Ok) { + origin.chargeError(errFee); + switch (res) { + case KeyNames::RegisterResult::NameTaken: return CES_ERROR_KEYNAME_TAKEN; + case KeyNames::RegisterResult::CapacityFull: return CES_ERROR_STORE_FULL; + default: return CES_ERROR_BAD_INPUT; + } + } + + origin.debit(static_cast(fee)); + accounts_.checkFlush(static_cast(fee)); + keyNames_.checkFlush(1); + checkAutoSnapshot(); + LOGTRACE << "registerKeyName ok"; + return CES_OK; +} + +uint8_t CesServer::clearKeyName(const minx::Hash& originKey, + uint32_t providedNonce, int64_t errFee) { + int64_t fee = discountedFlatFee(-1, cfg_.feeQuery, FeeKind::Query); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, static_cast(fee), + providedNonce, errFee); + if (rc != CES_OK) + return rc; + + if (!keyNames_.clearName(originKey)) { + origin.chargeError(errFee); + return CES_ERROR_KEYNAME_NOT_FOUND; + } + origin.debit(static_cast(fee)); + accounts_.checkFlush(static_cast(fee)); + keyNames_.checkFlush(1); + checkAutoSnapshot(); + return CES_OK; +} + +bool CesServer::queryKeyName(const minx::Hash& key, KeyNameData& outName) { + return keyNames_.nameForKey(key, outName); +} + +bool CesServer::queryKeyNameByName(const ces::Bytes& name, minx::Hash& outKey) { + KeyNameData nm{}; + std::memcpy(nm.data(), name.data(), + std::min(name.size(), nm.size())); + return keyNames_.keyForName(KeyNames::normalize(nm), outKey); +} + +uint8_t CesServer::giveAsset(const minx::Hash& originKey, + const minx::Hash& assetId, + const HashPrefix& newOwnerId, + uint32_t providedNonce, int64_t giveFee, + int64_t errFee) { + giveFee = discountedFlatFee(giveFee, cfg_.feeTx, FeeKind::Tx); + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, giveFee, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + ActiveAsset asset = assets_.get(assetId); + + if (!asset.exists()) { + origin.chargeError(errFee); + LOGDEBUG << "giveAsset: not found"; + return CES_ERROR_ASSET_NOT_FOUND; + } + if (asset.getOwnerId() != origin.id) { + origin.chargeError(errFee); + LOGDEBUG << "giveAsset: not owner"; + return CES_ERROR_NOT_OWNER; + } + + origin.debit(giveFee); + accounts_.checkFlush(giveFee); + + asset.transferOwnership(newOwnerId); + + assets_.checkFlush(giveFee); + checkAutoSnapshot(); + LOGTRACE << "giveAsset ok" << VAR(assetId) << VAR(newOwnerId); + return CES_OK; +} + +uint8_t CesServer::queryAsset(const minx::Hash& originKey, + const minx::Hash& assetId, uint8_t items, + uint32_t providedNonce, + std::vector& outResults, + int64_t queryFee, int64_t errFee) { + if (items >= CesQueryAsset::MAX_ITEMS) { + throw std::runtime_error("too many items"); + } + queryFee = discountedFlatFee(queryFee, cfg_.feeQuery, FeeKind::Query); + if (items > 0) { + queryFee += (queryFee * (items + 1)) / CesQueryAsset::MAX_ITEMS; + } + errFee = discountedFlatFee(errFee, cfg_.getFeeError(), FeeKind::Query); + + ActiveAccount origin = accounts_.get(Account::getMapKey(originKey)); + if (!origin.exists()) + return CES_ERROR_ORIGIN_NOT_FOUND; + + uint8_t rc = origin.validateSpend(0, queryFee, providedNonce, errFee); + if (rc != CES_OK) + return rc; + + ActiveAsset target = + LOGKV_IS_EMPTY(assetId) ? assets_.getFirst() : assets_.get(assetId); + + if (!target.exists()) { + origin.chargeError(errFee); + LOGDEBUG << "queryAsset: not found"; + return CES_ERROR_ASSET_NOT_FOUND; + } + + // Treat private assets not owned by requester as not found + HashPrefix requesterPrefix = Account::getMapKey(originKey); + if (isAssetPrivate(target.data().getBalance()) && + target.data().getOwnerId() != requesterPrefix) { + origin.chargeError(errFee); + return CES_ERROR_ASSET_NOT_FOUND; + } + + auto itAsset = target.it; + size_t count = static_cast(items) + 1; + while (outResults.size() < count && itAsset != assets_->end()) { + // Skip private assets not owned by the requester + if (isAssetPrivate(itAsset->second.getBalance()) && + itAsset->second.getOwnerId() != requesterPrefix) { + ++itAsset; + continue; + } + AssetEntry entry; + entry.ownerId = itAsset->second.getOwnerId(); + entry.content = itAsset->second.getContent(); + // Raw 16-bit balance — clients mask with assetDays() for the day count + // and check bits 13/14/15 for immut/aowned/priv. Stripping here would + // hide the flag bits from any wire client. + entry.balance = itAsset->second.getBalance(); + entry.price = itAsset->second.getPrice(); + outResults.push_back(entry); + ++itAsset; + } + + origin.debit(queryFee); + accounts_.checkFlush(queryFee); + + LOGTRACE << "queryAsset ok" << VAR(outResults.size()); + return CES_OK; +} + +void CesServer::unsignedQueryAsset(const minx::Hash& assetId, + HashPrefix& outOwner, AssetData& outContent, + uint16_t& outBalance, uint32_t& outPrice) { + ActiveAsset asset = assets_.get(assetId); + + if (asset.exists()) { + outOwner = asset.getOwnerId(); + outContent = asset.getContent(); + outBalance = assetDays(asset.getBalance()); + outPrice = asset.getPrice(); + } else { + outOwner = {}; + outContent = {}; + outBalance = 0; + outPrice = 0; + } +} + +// ---------------------------------------------------------------------------- +// NETWORK MESSAGE INGESTION +// ---------------------------------------------------------------------------- + +void CesServer::incomingInit(const SockAddr& addr, const MinxInit& msg) { + checkPause(); + LOGTRACE << "got MinxInit" << VAR(addr); + minx_->sendMessage( + addr, + MinxMessage{msg.version, minx_->generatePassword(), msg.gpassword, {}}); +} + +void CesServer::incomingMessage(const SockAddr& addr, const MinxMessage& msg) { + checkPause(); + if (minx_->checkSpam(addr.address())) + return; + + LOGTRACE << "got MinxMessage" << VAR(addr) << SVAR(msg); + try { + ConstBuffer buf(msg.data); + uint8_t opCode = buf.get(); + switch (opCode) { + + case CES_TRANSFER: { + CesTransfer req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesTransfer& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + int64_t newBal = 0; + uint8_t rc = transfer(req.originId, req.destKey, req.amount, + TransferMode::Safe, 0, req.reqNonce, newBal); + + CesTransferResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.destId = Account::getMapKey(req.destKey); + res.amount = req.amount; + res.originNewBalance = newBal; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_OPEN_TRANSFER: { + CesOpenTransfer req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesOpenTransfer& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + auto reply = [&](uint32_t nonce, int64_t bal, uint8_t rc) { + CesOpenTransferResult res; + res.originId = originPrefix; + res.reqNonce = nonce; + res.destId = Account::getMapKey(req.destKey); + res.amount = req.amount; + res.originNewBalance = bal; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }; + + uint32_t effectiveNonce; + uint64_t sigHash = 0; + switch (resolveNonceless(req.time, req.sig, originPrefix, + req.reqNonce, effectiveNonce, sigHash)) { + case NoncelessResult::Stale: + return reply(req.reqNonce, 0, CES_ERROR_WRONG_NONCE); + case NoncelessResult::Duplicate: + return reply(req.reqNonce, 0, CES_OK); + case NoncelessResult::Proceed: + break; + } + + int64_t newBal = 0; + uint8_t rc = transfer(req.originId, req.destKey, req.amount, + TransferMode::Open, 0, effectiveNonce, newBal); + // SETTLE_IN watch: money landed on the dest via an open-transfer + // (the wire form of an incoming cross settlement). transfer() in Open + // mode is hook-exempt (a gate rejecting a committed cross would break + // vostro/reserve), so record it here instead - WATCH ONLY, after the + // credit commits, so there is no conservation risk. No settlement-vs- + // user-open discriminator: Open mode is overloaded and there is no + // reliable local signal (isConnected needs a probed peer; settlement + // can arrive from an unprobed one), so any Open landing on a hooked + // account is recorded. A rare local user open-transfer thus tags as + // SETTLE_IN; the event (a receipt) is real either way. + if (rc == CES_OK && req.amount >= hookFreeGrant()) // inbound floor + fireAccountHook(req.destKey, ALIAS_OP_HOOK_WATCH, + INVOKE_HOOK_SETTLE_IN, req.originId, req.amount); + // Record the dedup only once the transfer has committed: a NONCELESS + // open-transfer that failed (e.g. insufficient balance) leaves no + // event and must stay retryable so a later attempt can land. + if (rc == CES_OK && req.reqNonce == CES_NONCELESS) + recordDedup(sigHash); + reply(effectiveNonce, newBal, rc); + }, /*noncelessOk=*/true); + break; + } + + case CES_CREATE_PAYMENT: { + CesCreatePayment req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesCreatePayment& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + int64_t newBal = 0; + uint8_t rc = transfer(req.originId, req.destKey, req.amount, + TransferMode::Payment, req.days, + req.reqNonce, newBal); + + CesCreatePaymentResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.destId = Account::getMapKey(req.destKey); + res.amount = req.amount; + res.originNewBalance = newBal; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_BULK_TRANSFER: { + CesBulkTransfer req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesBulkTransfer& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + int64_t newBal = 0; + uint8_t successfulCount = 0; + uint8_t rc = bulkTransfer(req.originId, req.transfers, req.reqNonce, + newBal, successfulCount); + + CesBulkTransferResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.rcode = rc; + res.successfulCount = successfulCount; + res.originNewBalance = newBal; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_QUERY_ACCOUNT: { + CesQueryAccount req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesQueryAccount& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + int64_t newBal = 0; + std::vector results; + uint8_t rc = queryAccount(req.originId, req.queryId, req.items, + req.reqNonce, newBal, results); + + CesQueryAccountResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.queryId = req.queryId; + res.rcode = rc; + if (rc == CES_OK) { + res.accounts = std::move(results); + res.items = + res.accounts.empty() ? 0 : static_cast(res.accounts.size() - 1); + } else { + res.items = req.items; + } + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_CREATE_ASSET: { + CesCreateAsset req; + req.fromBytes(msg.data); + Hash key = req.ownerId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesCreateAsset& req, const HashPrefix& ownerPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = createAsset(req.ownerId, Account::getMapKey(req.ownerId), + req.assetId, req.content, + req.amount, req.reqNonce); + + CesCreateAssetResult res; + res.ownerId = ownerPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.amount = req.amount; + res.price = req.price; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_CREATE_ASSET_RANGE: { + CesCreateAssetRange req; + req.fromBytes(msg.data); + Hash key = req.ownerId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesCreateAssetRange& req, const HashPrefix& ownerPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = createAssetRange(req.ownerId, + Account::getMapKey(req.ownerId), + req.firstKey, req.count, req.days, + req.reqNonce); + CesCreateAssetRangeResult res; + res.ownerId = ownerPrefix; + res.reqNonce = req.reqNonce; + res.firstKey = req.firstKey; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_UPDATE_ASSET: { + CesUpdateAsset req; + req.fromBytes(msg.data); + Hash key = req.ownerId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesUpdateAsset& req, const HashPrefix& ownerPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = updateAsset(req.ownerId, req.assetId, req.newOwnerId, + req.content, req.price, req.reqNonce); + + CesUpdateAssetResult res; + res.ownerId = ownerPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.newOwnerId = req.newOwnerId; + res.price = req.price; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_UPDATE_ASSET_META: { + CesUpdateAssetMeta req; + req.fromBytes(msg.data); + Hash key = req.ownerId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesUpdateAssetMeta& req, const HashPrefix& ownerPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = updateAssetMeta(req.ownerId, req.assetId, req.newOwnerId, + req.price, req.reqNonce); + + CesUpdateAssetMetaResult res; + res.ownerId = ownerPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.newOwnerId = req.newOwnerId; + res.price = req.price; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_SET_ASSET_OWNER_PAYS: { + CesSetAssetOwnerPays req; + req.fromBytes(msg.data); + Hash key = req.ownerId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesSetAssetOwnerPays& req, const HashPrefix& ownerPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = setAssetOwnerPays(req.ownerId, req.assetId, + req.ownerPays != 0, req.reqNonce); + CesSetAssetOwnerPaysResult res; + res.ownerId = ownerPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_UPDATE_ASSET_FAST: { + CesUpdateAssetFast req; + req.fromBytes(msg.data); + Hash key = req.ownerId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesUpdateAssetFast& req, const HashPrefix& ownerPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = + updateAssetFast(req.ownerId, req.assetId, req.content, req.reqNonce); + + CesUpdateAssetFastResult res; + res.ownerId = ownerPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_FUND_ASSET: { + CesFundAsset req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesFundAsset& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = + fundAsset(req.originId, req.assetId, req.amount, req.reqNonce); + + CesFundAssetResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.amount = req.amount; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_BUY_ASSET: { + CesBuyAsset req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesBuyAsset& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = + buyAsset(req.originId, req.assetId, req.priceLimit, req.reqNonce); + + CesBuyAssetResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.priceLimit = req.priceLimit; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_GIVE_ASSET: { + CesGiveAsset req; + req.fromBytes(msg.data); + Hash key = req.ownerId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesGiveAsset& req, const HashPrefix& ownerPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = + giveAsset(req.ownerId, req.assetId, req.newOwnerId, req.reqNonce); + + CesGiveAssetResult res; + res.ownerId = ownerPrefix; + res.reqNonce = req.reqNonce; + res.assetId = req.assetId; + res.newOwnerId = req.newOwnerId; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_QUERY_ASSET: { + CesQueryAsset req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesQueryAsset& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + std::vector results; + uint8_t rc = queryAsset(req.originId, req.assetId, req.items, + req.reqNonce, results); + + CesQueryAssetResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.rcode = rc; + if (rc == CES_OK) { + res.assets = std::move(results); + res.items = res.assets.empty() ? 0 : static_cast(res.assets.size() - 1); + } else { + res.items = req.items; + } + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_SET_ALIAS: { + CesSetAlias req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesSetAlias& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint32_t outId = 0; + uint8_t rc = setAlias(req.originId, req.aliasId, req.offset, + req.bytes, req.reqNonce, outId); + CesSetAliasResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.aliasId = outId; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_DELETE_ALIAS: { + CesDeleteAlias req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesDeleteAlias& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = deleteAlias(req.originId, req.reqNonce); + CesDeleteAliasResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_QUERY_ALIAS: { + CesQueryAlias req; + req.fromBytes(msg.data); + postLogic( [this, addr, msg, req]() { + Alias al; + bool found = queryAlias(req.aliasId, al); + CesQueryAliasResult res; + res.aliasId = req.aliasId; + res.offset = req.offset; + size_t off = req.offset, len = req.length; + if (found && off + len <= ALIAS_VALUE_BYTES) { + res.bytes.assign(al.imageData() + off, al.imageData() + off + len); + res.found = 1; + } else { + res.found = 0; // unknown id or out-of-bounds window + } + sendUnsignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_REGISTER_KEYNAME: { + CesRegisterKeyName req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesRegisterKeyName& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = registerKeyName(req.originId, req.name, req.reqNonce); + CesRegisterKeyNameResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_CLEAR_KEYNAME: { + CesClearKeyName req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesClearKeyName& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + uint8_t rc = clearKeyName(req.originId, req.reqNonce); + CesClearKeyNameResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_QUERY_KEYNAME: { + CesQueryKeyName req; + req.fromBytes(msg.data); + postLogic( [this, addr, msg, req]() { + KeyNameData nm{}; + CesQueryKeyNameResult res; + res.key = req.key; + if (queryKeyName(req.key, nm)) { + size_t len = 0; + while (len < nm.size() && nm[len] != 0) ++len; + res.name.assign(nm.data(), nm.data() + len); + res.found = 1; + } else { + res.found = 0; + } + sendUnsignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_QUERY_KEYNAME_BY_NAME: { + CesQueryKeyNameByName req; + req.fromBytes(msg.data); + postLogic( [this, addr, msg, req]() { + Hash key{}; + CesQueryKeyNameByNameResult res; + res.name = req.name; // echo for the client's stale-reply guard + if (queryKeyNameByName(req.name, key)) { + res.found = 1; + res.key = key; + } else { + res.found = 0; + } + sendUnsignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_UNSIGNED_QUERY_ACCOUNT: { + CesUnsignedQueryAccount req; + req.fromBytes(msg.data); + postLogic( [this, addr, msg, req]() { + int64_t bal = 0; + uint32_t nonce = 0; + HashPrefix lastXferDest{}; + uint64_t lastXferAmount = 0; + uint32_t lastXferTime = 0; + uint32_t aliasId = 0; + unsignedQueryAccount(req.accountMapKey, bal, nonce, + lastXferDest, lastXferAmount, lastXferTime, + aliasId); + + CesUnsignedQueryAccountResult res{req.accountMapKey, bal, nonce, + lastXferDest, lastXferAmount, + lastXferTime, aliasId}; + sendUnsignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_UNSIGNED_QUERY_SOLUTION: { + CesUnsignedQuerySolution req; + req.fromBytes(msg.data); + int queryResult = minx_->queryPoW(req.time, req.solution); + CesUnsignedQuerySolutionResult res{req.solution, + static_cast(queryResult)}; + sendUnsignedReply(addr, msg, std::move(res)); + break; + } + + case CES_UNSIGNED_QUERY_ASSET: { + CesUnsignedQueryAsset req; + req.fromBytes(msg.data); + + postLogic( [this, addr, msg, req]() { + HashPrefix owner; + AssetData content; + uint16_t balance; + uint32_t price; + + unsignedQueryAsset(req.assetId, owner, content, balance, price); + + // Privacy: hide CONTENT from unsigned queries. Metadata (owner, days, + // price) stays visible by design — a private asset is "content-private", + // not invisible (see PrivateAssetUnsignedQueryHidesContent). The signed + // path is stricter for non-owners (not-found), but that asymmetry is + // intentional. + auto aa = assets_.get(req.assetId); + if (aa.exists() && isAssetPrivate(aa.data().getBalance())) + content = {}; + + CesUnsignedQueryAssetResult res; + res.assetId = req.assetId; + res.ownerId = owner; + res.content = content; + res.balance = balance; + res.price = price; + sendUnsignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_QUERY_PEER_INFO: { + CesUnsignedQueryPeerInfo req; + req.fromBytes(msg.data); + CesUnsignedQueryPeerInfoResult res; + res.index = req.index; + res.found = 0; + res.pubkey = {}; + res.address = {}; + { + std::lock_guard lock(peerTableMutex_); + res.peerCount = + static_cast(std::min(peerTable_.size(), 0xFFFFu)); + if (req.index < peerTable_.size()) { + const auto& pe = peerTable_[req.index]; + res.found = 1; + res.pubkey = pe.ckey; + size_t n = std::min(pe.declaredAddress.size(), res.address.size()); + std::memcpy(res.address.data(), pe.declaredAddress.data(), n); + } + } + sendUnsignedReply(addr, msg, std::move(res)); + break; + } + + case CES_QUERY_SERVER_INFO: { + CesQueryServerInfo req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesQueryServerInfo& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + int64_t newBal = 0; + std::vector entries; + uint8_t rc = + queryServerInfo(req.originId, req.reqNonce, newBal, entries); + + CesQueryServerInfoResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.rcode = rc; + if (rc == CES_OK) + res.entries = std::move(entries); + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_CROSS_TRANSFER: { + CesCrossTransfer req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesCrossTransfer& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + int64_t newBal = 0; + uint8_t rc = crossTransfer(req.originId, req.destKey, req.amount, + req.destServer, req.reqNonce, newBal); + + CesCrossTransferResult res; + res.originId = originPrefix; + res.reqNonce = req.reqNonce; + res.amount = req.amount; + res.originNewBalance = newBal; + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + }); + break; + } + + case CES_RUN_ASSET: { + CesRunAsset req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesRunAsset& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + handleRunAsset(req, originPrefix, addr, msg); + }, /*noncelessOk=*/true); + break; + } + + case CES_RUN_ALIAS: { + CesRunAlias req; + req.fromBytes(msg.data); + Hash key = req.originId; + dispatchSigned(addr, msg, std::move(req), key, + [this](const CesRunAlias& req, const HashPrefix& originPrefix, + const SockAddr& addr, const MinxMessage& msg) { + handleRunAlias(req, originPrefix, addr, msg); + }, /*noncelessOk=*/true); + break; + } + + case CES_GOSSIP: { + CesGossip req; + req.fromBytes(msg.data); + // Self-certifying: verify the immediate sender's signature directly + // against originId (the op carries the full pubkey; the sender may be a + // peer server with no local account, so there is no ledger lookup here). + PublicKey pk(req.originId); + if (!req.verifySignature(msg.data, pk)) { + LOGDEBUG << "gossip: bad signature"; + return; // drop; see the catch below on why we do not banAddress + } + handleGossip(req, addr, msg); + break; + } + + default: + LOGTRACE << "unknown opcode"; + return; // drop; see the catch below + } + } catch (const std::exception& e) { + // Drop the packet, do NOT banAddress. MINX delivers a MINX_MESSAGE to us once + // the sender spent a valid spassword, but that ticket is a GLOBAL anti-spam + // token (minx spendPassword is a set-erase, not address-bound), so the source + // address is NOT authenticated -- an attacker holding any ticket can spoof a + // victim's IP in a malformed packet and get the victim banned. Banning here is + // a spoofable censorship vector. Flood protection is MINX's job (spam filter, + // PoW gate, non-handshaked-packet limits); a single bad packet just drops. + LOGTRACE << "malformed packet" << VAR(e.what()); + return; + } +} + +// ---------------------------------------------------------------------------- +// executeVmRun +// The neutral VM-execution transaction core, shared by the wire run path +// (handleRunAsset) and the cron path (executeScheduledRun). Precondition: +// the gas budget has already been debited from the caller. Owns the undo +// log, deferred side effects, VM execution, commit-or-revert, the refund of +// unused budget, and the durability flush. See the durability note at the +// commit branch and the flush for why both are required (VM syscalls mutate +// the in-memory store directly, bypassing the WAL). +// ---------------------------------------------------------------------------- + +CesServer::VmRunResult CesServer::executeVmRun(const VmRunRequest& req) { + VmRunResult out; + + // --- Atomic context: undo log + deferred effects --- + struct UndoEntry { + enum Kind { AccountEntry, AssetEntry, AliasEntry, ScheduleEntry } kind; + HashPrefix accountKey; + Account oldAccount; + bool accountExisted; + minx::Hash assetKey; + Asset oldAsset; + bool assetExisted; + uint32_t aliasKey; + Alias oldAlias; + bool aliasExisted; + ScheduleKey scheduleKey; // Schedule kind: the enqueued run to erase + }; + std::vector undoLog; + + struct DeferredUdp { + std::string addr; + uint16_t port; + ces::Bytes data; + }; + struct DeferredCrossXfer { + minx::Hash dest; + uint64_t amount; + std::string server; + minx::Hash peerKey; + }; + struct DeferredHook { + minx::Hash dest; + minx::Hash counterparty; + uint64_t amount; + }; + std::vector deferredUdp; + std::vector deferredCrossXfers; + std::vector deferredHooks; + std::vector> newL2Calls; + + auto saveAccount = [&](const HashPrefix& id) { + auto it = accounts_->find(id); + UndoEntry e{}; + e.kind = UndoEntry::AccountEntry; + e.accountKey = id; + if (it != accounts_->end()) { + e.oldAccount = it->second; + e.accountExisted = true; + } else { + e.accountExisted = false; + } + undoLog.push_back(std::move(e)); + }; + auto saveAsset = [&](const minx::Hash& id) { + auto it = assets_->find(id); + UndoEntry e{}; + e.kind = UndoEntry::AssetEntry; + e.assetKey = id; + if (it != assets_->end()) { + e.oldAsset = it->second; + e.assetExisted = true; + } else { + e.assetExisted = false; + } + undoLog.push_back(std::move(e)); + }; + auto saveAlias = [&](uint32_t id) { + auto it = aliases_->find(id); + UndoEntry e{}; + e.kind = UndoEntry::AliasEntry; + e.aliasKey = id; + if (it != aliases_->end()) { + e.oldAlias = it->second; + e.aliasExisted = true; + } else { + e.aliasExisted = false; + } + undoLog.push_back(std::move(e)); + }; + auto revert = [&]() { + for (auto it = undoLog.rbegin(); it != undoLog.rend(); ++it) { + if (it->kind == UndoEntry::AccountEntry) { + if (it->accountExisted) { + auto mapIt = accounts_->find(it->accountKey); + if (mapIt != accounts_->end()) + mapIt->second = it->oldAccount; + else + accounts_->getObjects().emplace(it->accountKey, it->oldAccount); + } else { + accounts_->getObjects().erase(it->accountKey); + } + } else if (it->kind == UndoEntry::AssetEntry) { + if (it->assetExisted) { + auto mapIt = assets_->find(it->assetKey); + if (mapIt != assets_->end()) + mapIt->second = it->oldAsset; + else + assets_->getObjects().emplace(it->assetKey, it->oldAsset); + } else { + assets_->getObjects().erase(it->assetKey); + } + } else if (it->kind == UndoEntry::AliasEntry) { + if (it->aliasExisted) { + auto mapIt = aliases_->find(it->aliasKey); + if (mapIt != aliases_->end()) + mapIt->second = it->oldAlias; + else + aliases_->getObjects().emplace(it->aliasKey, it->oldAlias); + } else { + aliases_->getObjects().erase(it->aliasKey); + } + } else { // Schedule: undo the SYS_SCHEDULE enqueue + scheduledRuns_.erase(it->scheduleKey); + } + } + }; + + // --- Wire CesVMHost (sinks buffer side effects until commit) --- + VmHostSetup setup; + setup.callerPrefix = req.callerPrefix; + setup.programOwnerPrefix = req.programOwnerPrefix; + setup.saveAccountFn = [&](const HashPrefix& id) { saveAccount(id); }; + setup.saveAssetFn = [&](const minx::Hash& key) { saveAsset(key); }; + setup.saveAliasFn = [&](uint32_t id) { saveAlias(id); }; + setup.sendUdpFn = [&](const std::string& addr, uint16_t port, + const uint8_t* data, size_t len) { + deferredUdp.push_back({addr, port, {data, data + len}}); + }; + setup.crossTransferFn = [&](const minx::Hash& dest, uint64_t amount, + const std::string& server, + const minx::Hash& peerKey) { + deferredCrossXfers.push_back({dest, amount, server, peerKey}); + }; + // SYS_L2_CALL: the host already burned `value` (caller -> self) under the + // undo log; buffer the call and post the drain on commit (below), so an + // aborted run rolls the burn back and never dispatches. + setup.l2CallFn = [&](PendingL2Call p) { + newL2Calls.push_back(std::make_shared(std::move(p))); + }; + // A program's SYS_TRANSFER to a hooked account: record it to fire the dest's + // WATCH after this run commits (XFER_VM). Skip while inside a hook (no + // cascades) and skip un-hooked dests cheaply (aliasId == 0). Counterparty is + // this run's caller (the account whose program moved the credit). + setup.creditHookFn = [&](const minx::Hash& dest, uint64_t amount) { + if (inHook_ || amount < hookFreeGrant()) return; // inbound dust floor + auto it = accounts_->find(Account::getMapKey(dest)); + if (it != accounts_->end() && it->second.getAliasId() != 0) + deferredHooks.push_back({dest, req.callerKey, amount}); + }; + // SYS_SCHEDULE: enqueue synchronously (so the program sees the real + // QUEUE_FULL/OK rc) but record the enqueue in the undo log, so a later VM + // abort rolls it back. scheduledRuns_ is RAM-only, so commit needs no + // re-journal — only the abort path matters. + setup.scheduleFn = [&](const minx::Hash& assetId, uint64_t budget, + uint64_t allowance, const ces::Bytes& input, + uint64_t time_us) -> uint8_t { + ScheduleKey key; + uint8_t rc = scheduleRunUndoable(req.callerPrefix, assetId, budget, + allowance, input, time_us, + /*prepaid=*/false, key); + if (rc == CES_OK) { + UndoEntry e{}; + e.kind = UndoEntry::ScheduleEntry; + e.scheduleKey = key; + undoLog.push_back(std::move(e)); + } + return rc; + }; + // SYS_SCHEDULE_ALIAS: same undo contract, alias-id target. The target must + // be a live ALIAS_OP_INLINE_PROGRAM cell at queue time (re-checked at fire). + setup.scheduleAliasFn = [&](uint32_t aliasId, uint64_t budget, + uint64_t allowance, const ces::Bytes& input, + uint64_t time_us) -> uint8_t { + auto al = aliases_.get(aliasId); + if (aliasId == 0 || !al.exists()) + return CES_ERROR_ALIAS_NOT_FOUND; + if (al.getOp() != ALIAS_OP_INLINE_PROGRAM) + return CES_ERROR_BAD_INPUT; + ScheduleKey key; + uint8_t rc = scheduleRunUndoable(req.callerPrefix, minx::Hash{}, budget, + allowance, input, time_us, + /*prepaid=*/false, key, aliasId); + if (rc == CES_OK) { + UndoEntry e{}; + e.kind = UndoEntry::ScheduleEntry; + e.scheduleKey = key; + undoLog.push_back(std::move(e)); + } + return rc; + }; + setup.enableVerifySig = req.enableVerifySig; + setup.allowance = req.allowance; + + VmHost vmHost(*this, setup); + vmHost.callerKey = req.callerKey; + vmHost.selfAssetKey = req.selfAssetKey; + vmHost.programOwner = req.programOwnerPrefix; + vmHost.input = req.input; + vmHost.invokeKind = req.invokeKind; + vmHost.refillCeiling = req.refillCeiling; + + // --- Execute VM --- + CesVM vm; + ces::Bytes code = req.code; + CesVMResult vmResult; + // Catch any exception that escapes the VM (host lambda throwing, logkv + // serialization error, std::bad_alloc, CryptoPP throw, etc.). A VM + // exception is a server-side failure, not client abuse. + try { + vmResult = vm.execute(code, vmHost, req.budget, req.gasMult); + } catch (...) { + vmResult.error = CESVM_HOST; + } + + out.vmError = vmResult.error; + out.budgetUsed = vmResult.budgetUsed; + // Allowance consumed = initial cap minus what's left. The unlimited + // sentinel skips decrement entirely (see debitCaller), so it reports 0. + if (req.allowance != std::numeric_limits::max() && + vmHost.allowance <= req.allowance) { + out.allowanceUsed = req.allowance - vmHost.allowance; + } else { + out.allowanceUsed = 0; + } + + if (vmResult.error == CESVM_OK) { + // Commit: fire deferred effects, output available. + out.rcode = CES_OK; + out.output = std::move(vmResult.output); + for (auto& udp : deferredUdp) { + try { + if (minx_) { + minx::Bytes payload; + ces::Buffer::putBytes(payload, std::span(udp.data)); + minx_->sendApplication( + SockAddr(Resolver::parseIp(udp.addr), udp.port), payload); + } + } catch (...) {} + } + // Fire deferred cross-transfers (ledger legs already ran via the undo + // log; this only dispatches the wire-level settlement). Fire-and-forget. + for (auto& cx : deferredCrossXfers) { + auto* client = getOrCreateSettlementClient(cx.server, cx.peerKey); + if (client) { + // Terminal-failure callback (see crossTransfer): a non-OK rc means + // settlement gave up after retries — should not happen; log it all + // at INFO so a peering bug is debuggable. + client->openTransfer(cx.dest, cx.amount, + [server = cx.server, peerKey = cx.peerKey, + dest = cx.dest, amount = cx.amount](uint8_t rc) { + if (rc != CES_OK) { + LOGINFO << "VM cross-transfer settlement GAVE UP " + "(peer never confirmed; likely a peering bug)" + << VAR(rc) << SVAR(server) << VAR(amount) + << BVAR(dest) << BVAR(peerKey); + } + }); + } else { + // Null client: the peer address won't resolve right now, so the + // deferred wire settlement is dropped while the VM's vostro credit + // already stands (the ledger legs ran during execution and can't be + // rejected here). Same "should not happen" peering-bug signal — log + // every detail at INFO. + LOGINFO << "VM cross-transfer settlement DROPPED " + "(peer address unresolvable; likely a peering bug)" + << SVAR(cx.server) << VAR(cx.amount) + << BVAR(cx.dest) << BVAR(cx.peerKey); + } + } + // Fire deferred L2 calls: assign the call id, count the in-flight slot, + // and post the drain to rpcTaskIO_. The burn already committed above via + // the undo log; an aborted run never reaches here (newL2Calls discarded). + for (auto& sp : newL2Calls) { + sp->callId = l2NextCallId_++; + l2PendingCount_.fetch_add(1); + boost::asio::post(rpcTaskIO_, [this, sp]() { drainL2Call(sp); }); + } + // Durably journal the VM's committed ledger mutations. VM syscalls mutate + // the in-memory store directly through the undo log (for atomic rollback), + // which bypasses the WAL — so without this the run's effects survive only + // to the next snapshot. Re-journal every touched cell at its final value + // (Full mode handles both modified and VM-created cells); the flush below + // makes the whole run (gas debit + these cells + refund) reach disk + // atomically, preserving conservation across a crash. + for (const auto& e : undoLog) { + if (e.kind == UndoEntry::AccountEntry) { + auto ait = accounts_->find(e.accountKey); + if (ait != accounts_->end()) { + Account::SerModeGuard guard(Account::SerMode::Full); + accounts_->persist(ait); + } + } else if (e.kind == UndoEntry::AssetEntry) { + auto sit = assets_->find(e.assetKey); + if (sit != assets_->end()) { + Asset::SerModeGuard guard(Asset::SerMode::Full); + assets_->persist(sit); + } + } else if (e.kind == UndoEntry::AliasEntry) { + auto lit = aliases_->find(e.aliasKey); + if (lit != aliases_->end()) { + Alias::SerModeGuard guard(Alias::SerMode::Full); + aliases_->persist(lit); + } + } + // Schedule entries: scheduledRuns_ is RAM-only, nothing to journal. + } + } else { + // Revert all atomic mutations. + revert(); + out.rcode = CES_ERROR_VM_FAILED; + } + + // Refund unused budget on every termination path. ABORT and OK get the full + // unused remainder; non-abort failures (crashes) eat a small crash fee. + uint64_t penalty = (vmResult.error == CESVM_OK || + vmResult.error == CESVM_ABORT) + ? 0 + : CESVM_CRASH_FEE; + uint64_t spent = vmResult.budgetUsed + penalty; + // A free budget (the account-hook grant) was debited from no one, so there + // is nothing to refund; crediting the caller with the unused remainder would + // mint. Only refund a budget the caller actually pre-paid. + if (!req.freeBudget && spent < req.budget) { + auto caller = accounts_.get(req.callerPrefix); + if (caller.exists()) + caller.credit(req.budget - spent); + } + + // Refill charge: the caller pays for gas consumed PAST the free grant (what + // SYS_REFILL made available). Applied here, after revert() and outside the + // undo log, so a crash still pays for the strand time it burned -- otherwise + // "refill, compute hard, crash" is free compute. Charge only the consumed + // portion (unused refill is free), bounded by what was granted and by the + // balance. `spent` includes the crash penalty; charge on budgetUsed alone so + // the refill charge is purely the gas the program actually burned. + if (vmHost.refilledTotal > 0 && vmResult.budgetUsed > req.budget) { + uint64_t consumed = + std::min(vmResult.budgetUsed - req.budget, vmHost.refilledTotal); + auto caller = accounts_.get(req.callerPrefix); + if (caller.exists() && consumed > 0) { + uint64_t pay = std::min( + consumed, static_cast(std::max(0, caller.balance()))); + // A refill that consumes the whole balance deletes the account (debit's + // newBal<=0 branch) - the owner's footgun, bounded by the sidecar + // refill ceiling they chose. + if (pay > 0) caller.debit(pay); + } + } + + // Flush the run's ledger events to the WAL. The gas refund just above and + // the VM-mutated cells re-journaled on commit are otherwise unflushed, while + // the pre-run gas debit already flushed — leaving a half-state on the WAL + // that breaks conservation on crash-recovery. Flushing here makes the run + // atomic with respect to durability. + if (!undoLog.empty() || spent < req.budget || vmHost.refilledTotal > 0) { + accounts_->flush(); + assets_->flush(); + aliases_->flush(); + } + + // Fire deferred XFER_VM watch hooks: a program's SYS_TRANSFER credited a + // hooked account. Only on commit (a reverted run never happened), and only as + // a WATCH (the credit is already committed; a program transfer cannot be + // gated). Each fires as a separate top-level run after this one is fully + // committed and flushed; fireAccountHook re-peeks and filters for a WATCH, + // and inHook_ (set during each) stops any cascade. + if (vmResult.error == CESVM_OK) { + for (const auto& h : deferredHooks) { + fireAccountHook(h.dest, ALIAS_OP_HOOK_WATCH, INVOKE_HOOK_XFER_VM, + h.counterparty, h.amount); + } + } + + return out; +} + +uint64_t CesServer::hookFreeGrant() { + uint64_t gasMult = discountFee(FeeKind::VMMult, cfg_.feeVmMult); + if (gasMult == 0) gasMult = 1; + uint64_t fq = discountFee(FeeKind::Query, cfg_.feeQuery); + return CESVM_HOOK_GRANT_OPS * CESVM_COST_PER_OP * gasMult + + CESVM_HOOK_GRANT_READS * (CESVM_COST_PER_SYSCALL * gasMult + fq); +} + +// ---------------------------------------------------------------------------- +// runAccountHook - account-hook (CESVM trigger) execution. +// See the declaration in server.h for the identity/economics model and +// local/account_hooks_design.md for the full design. +// ---------------------------------------------------------------------------- +bool CesServer::runAccountHook(const minx::Hash& hookedKey, ces::Bytes code, + const minx::Hash& selfAssetKey, + const HashPrefix& programOwnerPrefix, + uint64_t invokeKind, + const minx::Hash& counterpartyKey, + uint64_t amount, int64_t balance, + uint64_t refillCeiling) { + // A hook never fires from inside a hook (no cascades, bounded nesting). + if (inHook_) return true; + + uint64_t gasMult = discountFee(FeeKind::VMMult, cfg_.feeVmMult); + if (gasMult == 0) gasMult = 1; + uint64_t grant = hookFreeGrant(); + + // Event descriptor into io[INPUT]: + // [0..31] counterparty pubkey + // [32..39] amount (u64 LE) + // [40..47] account balance at hook fire time (u64 LE): PRE-mutation for a + // GATE (it runs before the credit/debit), POST-mutation for a + // WATCH (it runs after). A gate computes the projected balance as + // balance +/- amount; a watch already sees the settled balance. + ces::Bytes input(48, 0); + std::memcpy(input.data(), counterpartyKey.data(), KEY_SIZE); + ces::Buffer::pokeLE(input.data() + 32, amount); + ces::Buffer::pokeLE(input.data() + 40, + static_cast(balance)); + + VmRunRequest vreq; + vreq.callerPrefix = Account::getMapKey(hookedKey); + vreq.callerKey = hookedKey; + vreq.selfAssetKey = selfAssetKey; // zero = inline (no boot asset) + vreq.programOwnerPrefix = programOwnerPrefix; + vreq.code = std::move(code); + vreq.input = std::move(input); + vreq.budget = grant; + vreq.allowance = 0; // read-only: no caller-account spend + vreq.gasMult = gasMult; + vreq.invokeKind = invokeKind; + vreq.freeBudget = true; // grant debited from no one + vreq.refillCeiling = refillCeiling; // SYS_REFILL cap (0 = off) + + inHook_ = true; + VmRunResult vres; + try { + vres = executeVmRun(vreq); + } catch (...) { + vres.vmError = CESVM_HOST; + } + inHook_ = false; + + // Verdict: clean TERM = accept; ABORT / fault / out-of-gas = reject. + return vres.vmError == CESVM_OK; +} + +bool CesServer::fireAccountHook(const minx::Hash& accountKey, uint16_t wantOp, + uint64_t invokeKind, + const minx::Hash& counterpartyKey, + uint64_t amount) { + ActiveAccount peek = accounts_.get(Account::getMapKey(accountKey)); + if (!(peek.exists() && peek.balance() >= 0 && + peek.data().getKey(peek.id) == accountKey && + peek.data().getAliasId() != 0)) + return true; // no eligible account / no sidecar + auto al = aliases_.get(peek.data().getAliasId()); + uint16_t op = al.exists() ? al.getOp() : ALIAS_OP_NONE; + // Match the requested hook CLASS across pointer and inline forms. + bool wantGate = (wantOp == ALIAS_OP_HOOK_GATE); + if (!(al.exists() && al.getOwner() == peek.id && + (wantGate ? aliasOpIsGate(op) : aliasOpIsWatch(op)))) + return true; // no hook of the requested type + const AliasData& sc = al.data().getContent(); + + ces::Bytes code; + minx::Hash selfAssetKey{}; + HashPrefix principal{}; + uint64_t ceiling; + if (aliasOpIsInline(op)) { + // Inline: the content is the code (fixed area, zero-padded so load bases + // are link-time constants); the ceiling rides the content trailer. The + // hooked account patched this exact bytecode into its own cell — that is + // consent — so a WATCH runs with the account as programOwner + // (allowance-exempt syscalls and alias writes). self stays zero: no + // boot asset. + code.assign(sc.data(), sc.data() + ALIAS_INLINE_CODE_BYTES); + ceiling = ces::Buffer::peekLE( + sc.data() + (ALIAS_OFF_INLINE_CEILING - ALIAS_OFF_CONTENT)); + if (!wantGate) principal = peek.id; + } else { + // Pointer: content[0..32) names the trigger asset, ceiling at [32..40). + minx::Hash triggerKey{}; + std::memcpy(triggerKey.data(), sc.data(), KEY_SIZE); + auto trigger = assets_.get(triggerKey); + if (!trigger.exists()) { + // Rotted/missing trigger (e.g. rent-starved): a GATE fails closed. + LOGDEBUG << "fireAccountHook: trigger asset missing" << BVAR(triggerKey); + return false; + } + const AssetData& content = trigger.data().getContent(); + code.assign(content.begin(), content.end()); + selfAssetKey = triggerKey; + ceiling = ces::Buffer::peekLE(sc.data() + 32); + // A WATCH whose trigger the account itself owns AT FIRE TIME is + // consented code (same trust statement as inline): it runs with the + // account as principal. Foreign immutable code never gets a principal — + // installing a stranger's watch grants it the refill ceiling, never the + // authority to act as the account. + if (!wantGate && trigger.data().getOwnerId() == peek.id) + principal = peek.id; + } + // A GATE runs as a PURE PREDICATE: no refill and no principal, so it has no + // money side effect. That keeps the composed sequence (OUT gate + IN gate + + // the transfer) atomic even though each hook run commits in its own + // executeVmRun and there is no journal enclosing the whole transfer: a later + // gate's reject cannot strand an earlier gate's charge, because gates never + // charge. Only WATCHES (which run AFTER the transfer commits, so there is + // nothing to be atomic with) may refill, spend programOwner through the + // allowance-exempt syscalls, or write cells. A gate that needs more than + // the free grant is too expensive to run + // synchronously before every transfer anyway - that logic belongs in a + // watch. + if (wantGate) ceiling = 0; + int64_t bal = peek.balance(); + // peek's iterator is invalid after this call; do not reuse it. + return runAccountHook(accountKey, std::move(code), selfAssetKey, principal, + invokeKind, counterpartyKey, amount, bal, ceiling); +} + +// ---------------------------------------------------------------------------- +// handleRunAsset +// Wire CES_RUN_ASSET path. Invoked on logicStrand_ with an already-verified +// CesRunAsset. Handles the dedup window, the gas reservation, the future-time +// scheduling fork, and the signed reply; the VM transaction itself runs in +// executeVmRun. +// ---------------------------------------------------------------------------- + +void CesServer::handleRunAsset(const CesRunAsset& req, + const HashPrefix& originPrefix, + const SockAddr& addr, + const MinxMessage& msg) { + CesRunAssetResult res; + res.originId = originPrefix; + res.vmError = CESVM_OK; + res.budgetUsed = 0; + res.allowanceUsed = 0; + + // --- Nonceless dedup --- + uint32_t effectiveNonce; + uint64_t sigHash = 0; + switch (resolveNonceless(req.time, req.sig, originPrefix, + req.reqNonce, effectiveNonce, sigHash)) { + case NoncelessResult::Stale: + res.reqNonce = req.reqNonce; + res.rcode = CES_ERROR_WRONG_NONCE; + sendSignedReply(addr, msg, std::move(res)); + return; + case NoncelessResult::Duplicate: + res.reqNonce = req.reqNonce; + res.rcode = CES_OK; // already processed + sendSignedReply(addr, msg, std::move(res)); + return; + case NoncelessResult::Proceed: + break; + } + res.reqNonce = effectiveNonce; + + // --- Pre-execution: validate and debit gas --- + { + auto origin = accounts_.get(originPrefix); + uint8_t rc = origin.validateSpend(0, req.budget, effectiveNonce, + resolveFee(-1, cfg_.getFeeError())); + if (rc != CES_OK) { + // No gas debited — nothing committed. Leave the dedup unrecorded so a + // NONCELESS run that couldn't afford the budget stays retryable. + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + return; + } + // Reserve the full budget upfront (WAL-logged, nonce incremented). + // Unused portion is refunded once the VM finishes — see the + // refund block after vm.execute returns. + origin.debit(req.budget); + accounts_.checkFlush(req.budget); + } + + // Gas is committed: this is the run's first ledger event, so record the + // NONCELESS dedup here. Everything past this point (asset lookup, VM run, + // vmError) is part of the same committed run and a retry must dedup to it. + if (req.reqNonce == CES_NONCELESS) + recordDedup(sigHash); + + // --- Scheduled execution? --- + if (req.time > 0 && req.reqNonce != CES_NONCELESS) { + uint64_t now = getMicrosSinceEpoch(); + if (req.time > now) { + // Schedule for future execution. Gas was already debited above, so + // mark the run prepaid (executeScheduledRun must not debit again). + // The allowance cap travels verbatim into the eventual execution. + res.rcode = scheduleRun(originPrefix, req.assetId, req.budget, + req.allowance, req.input, req.time, + /*prepaid=*/true); + if (res.rcode != CES_OK) { + // Nothing was queued — refund the upfront budget. + auto refund = accounts_.get(originPrefix); + if (refund.exists()) refund.credit(req.budget); + } + sendSignedReply(addr, msg, std::move(res)); + return; + } + } + + // --- Load program asset --- + auto programAsset = assets_.get(req.assetId); + if (!programAsset.exists()) { + // Nothing ran: refund the reserved budget (the same contract as the + // unused-budget refund inside executeVmRun and the schedule-failure + // branch above). + auto refund = accounts_.get(originPrefix); + if (refund.exists()) refund.credit(req.budget); + res.rcode = CES_ERROR_ASSET_NOT_FOUND; + sendSignedReply(addr, msg, std::move(res)); + return; + } + + HashPrefix programOwnerPrefix = programAsset.data().getOwnerId(); + AssetData programContent = programAsset.data().getContent(); + + // --- Run the VM transaction. executeVmRun owns the undo log, deferred + // effects, commit/abort, refund, and durability flush. --- + VmRunRequest vreq; + vreq.callerPrefix = originPrefix; + vreq.callerKey = req.originId; + vreq.selfAssetKey = req.assetId; + vreq.programOwnerPrefix = programOwnerPrefix; + vreq.code = ces::Bytes(programContent.begin(), programContent.end()); + vreq.input = req.input; + vreq.budget = req.budget; + vreq.allowance = req.allowance; + vreq.gasMult = discountFee(FeeKind::VMMult, cfg_.feeVmMult); + vreq.enableVerifySig = true; + + VmRunResult vres = executeVmRun(vreq); + res.rcode = vres.rcode; + res.vmError = vres.vmError; + res.budgetUsed = vres.budgetUsed; + res.allowanceUsed = vres.allowanceUsed; + res.output = std::move(vres.output); + + tpsInc(); + checkAutoSnapshot(); + sendSignedReply(addr, msg, std::move(res)); +} + +// ---------------------------------------------------------------------------- +// handleRunAlias +// Wire CES_RUN_ALIAS path: public invocation of an ALIAS_OP_INLINE_PROGRAM +// cell. Same dedup / gas-reservation / future-time shape as handleRunAsset; +// the run boots the cell's inline code area with self = 0 (no boot asset) +// and programOwner = the cell's owner (consented code) while the caller +// pays gas and allowance-bounded spend. +// ---------------------------------------------------------------------------- + +void CesServer::handleRunAlias(const CesRunAlias& req, + const HashPrefix& originPrefix, + const SockAddr& addr, + const MinxMessage& msg) { + CesRunAliasResult res; + res.originId = originPrefix; + res.vmError = CESVM_OK; + res.budgetUsed = 0; + res.allowanceUsed = 0; + + // --- Nonceless dedup --- + uint32_t effectiveNonce; + uint64_t sigHash = 0; + switch (resolveNonceless(req.time, req.sig, originPrefix, + req.reqNonce, effectiveNonce, sigHash)) { + case NoncelessResult::Stale: + res.reqNonce = req.reqNonce; + res.rcode = CES_ERROR_WRONG_NONCE; + sendSignedReply(addr, msg, std::move(res)); + return; + case NoncelessResult::Duplicate: + res.reqNonce = req.reqNonce; + res.rcode = CES_OK; // already processed + sendSignedReply(addr, msg, std::move(res)); + return; + case NoncelessResult::Proceed: + break; + } + res.reqNonce = effectiveNonce; + + // --- Pre-execution: validate and debit gas --- + { + auto origin = accounts_.get(originPrefix); + uint8_t rc = origin.validateSpend(0, req.budget, effectiveNonce, + resolveFee(-1, cfg_.getFeeError())); + if (rc != CES_OK) { + res.rcode = rc; + sendSignedReply(addr, msg, std::move(res)); + return; + } + origin.debit(req.budget); + accounts_.checkFlush(req.budget); + } + + if (req.reqNonce == CES_NONCELESS) + recordDedup(sigHash); + + // --- Scheduled execution? --- + if (req.time > 0 && req.reqNonce != CES_NONCELESS) { + uint64_t now = getMicrosSinceEpoch(); + if (req.time > now) { + res.rcode = scheduleRun(originPrefix, minx::Hash{}, req.budget, + req.allowance, req.input, req.time, + /*prepaid=*/true, req.aliasId); + if (res.rcode != CES_OK) { + auto refund = accounts_.get(originPrefix); + if (refund.exists()) refund.credit(req.budget); + } + sendSignedReply(addr, msg, std::move(res)); + return; + } + } + + // --- Load the inline program --- + auto al = aliases_.get(req.aliasId); + if (req.aliasId == 0 || !al.exists() || + al.getOp() != ALIAS_OP_INLINE_PROGRAM) { + // Missing cell, or one that has not opted in to public invocation + // (hooks fire only from their own deposit path; data cells are not + // code). Nothing ran: refund the reserved budget. + auto refund = accounts_.get(originPrefix); + if (refund.exists()) refund.credit(req.budget); + res.rcode = (req.aliasId == 0 || !al.exists()) + ? CES_ERROR_ALIAS_NOT_FOUND + : CES_ERROR_BAD_INPUT; + sendSignedReply(addr, msg, std::move(res)); + return; + } + const AliasData& sc = al.data().getContent(); + + VmRunRequest vreq; + vreq.callerPrefix = originPrefix; + vreq.callerKey = req.originId; + vreq.selfAssetKey = minx::Hash{}; // no boot asset + vreq.programOwnerPrefix = al.getOwner(); // consented: the cell's owner + vreq.code = ces::Bytes(sc.data(), sc.data() + ALIAS_INLINE_CODE_BYTES); + vreq.input = req.input; + vreq.budget = req.budget; + vreq.allowance = req.allowance; + vreq.gasMult = discountFee(FeeKind::VMMult, cfg_.feeVmMult); + vreq.enableVerifySig = true; + vreq.invokeKind = INVOKE_DIRECT_ALIAS; + + VmRunResult vres = executeVmRun(vreq); + res.rcode = vres.rcode; + res.vmError = vres.vmError; + res.budgetUsed = vres.budgetUsed; + res.allowanceUsed = vres.allowanceUsed; + res.output = std::move(vres.output); + + tpsInc(); + checkAutoSnapshot(); + sendSignedReply(addr, msg, std::move(res)); +} + +void CesServer::incomingGetInfo(const SockAddr& addr, const MinxGetInfo& msg) { + checkPause(); + LOGTRACE << "got MinxGetInfo" << VAR(addr); + uint64_t now = minx::getSecsSinceEpoch(); + uint64_t minUntilAcceptPoW; + if (now > cfg_.minProveWorkTimestamp) { + minUntilAcceptPoW = 0; + } else { + minUntilAcceptPoW = (cfg_.minProveWorkTimestamp - now) / 60; + if (minUntilAcceptPoW == 0) + minUntilAcceptPoW = 1; + else if (minUntilAcceptPoW > 255) + minUntilAcceptPoW = 255; + } + uint8_t minSecsPoW = static_cast(minUntilAcceptPoW); + + if (now > lastTimePoWQueueSizeUpdated_ + POW_QUEUE_REFRESH_SECS) { + lastTimePoWQueueSizeUpdated_ = now; + size_t q = minx_->getVerifyPoWQueueSize(); + constexpr size_t kU16Max = std::numeric_limits::max(); + if (q > kU16Max) + q = kU16Max; + powQueueSize_ = q; + } + uint16_t pendingPoWs = powQueueSize_.load(std::memory_order_relaxed); + uint16_t tps = tpsCurrent_.load(std::memory_order_relaxed); + uint16_t rpcPort = rpcBoundPort_; + + minx::Bytes rdata(sizeof(minSecsPoW) + sizeof(pendingPoWs) + sizeof(tps) + + sizeof(rpcPort)); + minx::Buffer rces(rdata); + rces.put(minSecsPoW); + rces.put(pendingPoWs); + rces.put(tps); + rces.put(rpcPort); + auto rmsg = + MinxInfo{msg.version, minx_->generatePassword(), msg.gpassword, + cfg_.minDiff, serverKeyPair_.getPublicKeyAsHash(), std::move(rdata)}; + minx_->sendInfo(addr, rmsg); +} + +void CesServer::incomingInfo(const SockAddr& addr, const MinxInfo& /* msg */) { + checkPause(); + // A server does not expect to RECEIVE a MinxInfo (servers send them, clients + // receive them). Drop it; do NOT banAddress -- the source is not + // address-authenticated, so a ban is spoofable (see incomingMessage's catch). + LOGTRACE << "got MinxInfo" << VAR(addr); +} + +bool CesServer::delegateProveWork(const SockAddr& addr, + const MinxProveWork& /* msg */) { + checkPause(); + if (!receiving_ || !running_) + return false; + if (minx_->checkSpam(addr.address())) + return false; + if (minx_->getVerifyPoWQueueSize() >= POW_QUEUE_DELEGATE_LIMIT) + return false; + return true; +} + +void CesServer::incomingProveWork(const SockAddr& addr, + const MinxProveWork& msg, + const int difficulty) { + checkPause(); + // Reject difficulty < 1: a difficulty-0 solution is ~free (≈half of all + // hashes) and would underflow the `1ULL << (difficulty - 1)` reward shift + // into an astronomical mint. min_difficulty should floor this, but guard + // the mint math directly so a 0 floor can never become a credit faucet. + if (difficulty < 1) + return; + postLogic( [this, addr, msg, difficulty]() { + minx::Hash actualBeneficiaryFullKey; + HashPrefix mapKey = Account::getMapKey(msg.ckey); + // Cap the difficulty used for the reward so the shift/multiply can't + // overflow (or become shift UB) on an extreme-difficulty solution. + int effDiff = (difficulty > MAX_POW_DIFFICULTY) ? MAX_POW_DIFFICULTY + : difficulty; + uint64_t generatedAmount = (1ULL << (effDiff - 1)) * POW_REWARD_BASE; + uint64_t creditAmount = generatedAmount; + + ActiveAccount acc = accounts_.get(mapKey); + + if (!acc.exists()) { + if (accounts_.getStore().getObjects().size() >= cfg_.maxAcc) + return; + actualBeneficiaryFullKey = msg.ckey; + // Account-slot allocation on the PoW-mint side is intentionally + // raw (no FeeKind::AccountRent discount). Daily account rent + // (search for cfg_.feeAccount under daily maintenance below) + // discounts because that is consumer carrying cost; here we + // are charging the structural cost of bringing a new slot + // into existence. Discounting it would subsidize account + // creation from minting, opening a Sybil vector. + if (creditAmount < cfg_.feeAccount) + return; + creditAmount -= cfg_.feeAccount; + if (creditAmount > static_cast(BALANCE_MAX)) + creditAmount = static_cast(BALANCE_MAX); + Account newAccount(msg.ckey, creditAmount, 0); + accounts_.createAccount(mapKey, newAccount); + } else { + if (acc.balance() < 0) + return; + actualBeneficiaryFullKey = acc.data().getKey(mapKey); + acc.credit(creditAmount); + } + + tpsInc(); + + // Collect inbound peer info from appData (if present) + if (!msg.data.empty()) { + try { + std::map appData; + logkv::serializer>::read( + msg.data.data(), msg.data.size(), appData); + auto it = appData.find("server"); + if (it != appData.end() && !it->second.empty()) { + // The peer advertises its listen port; the routable HOST comes from + // the packet we just received from it (addr) — never from anything it + // claims about itself. A peer with a real serverName is kept verbatim. + std::string peerAddr = Resolver::fillHost(it->second, addr.address()); + upsertPeer(msg.ckey, peerAddr, creditAmount); + // Start the peer miner (idempotent) so the discovered inbound peer is + // probed for reachability and the table is persisted each cycle. A + // pure-receiver node (peer_target 0) otherwise never runs the miner, + // so every inbound discovery is in-memory only and lost on restart — + // making the dashboard's inbound list look dead once mining stops. + ensurePeerMinerStarted(); + LOGDEBUG << "inbound peer PoW: " << peerAddr + << " (advertised " << it->second << ") credited " + << creditAmount; + } + } catch (...) { + // Invalid appData — ignore silently + } + } + + CesProveWorkResult res{msg.solution, actualBeneficiaryFullKey, creditAmount, + getSecsSinceEpoch(), {}}; + + boost::asio::post(taskIO_, [this, addr, msg, res]() mutable { + reply(addr, MinxMessage{msg.version, minx_->generatePassword(), + msg.gpassword, res.toBytes(serverKeyPair_)}); + }); + }); +} + +void CesServer::incomingApplication(const SockAddr& addr, const uint8_t code, + const minx::Bytes& data) { + if (code == CES_APP_COMPUTE_MSG) { + // Hop onto rpcTaskIO_ — the compute handler's strand owns the + // instance map + peer sockets. We have a plain minx::Bytes here, copy + // into a shared_ptr so the closure owns it. + auto io = _rpcTaskIOExecutor(); + if (!io) return; // compute feature not wired (rpc port off) + auto buf = std::make_shared(data); + // Stamp the real sender_pfx from the presence cache so the + // program can ces.client_send a reply. If the client isn't + // in presence yet (unsigned-only traffic), the senderPfx + // stays zeroed and the program has no way to reply. Not a + // protocol error — that caller should have done a signed op + // first. + std::array senderPfx{}; + { + std::lock_guard lk(presenceReverseMutex_); + auto it = presenceReverse_.find(addr); + if (it != presenceReverse_.end()) senderPfx = it->second; + } + // presenceReverse_ entries never evict, so a stale (or spoofed-source) + // addr could stamp another client's prefix. Only trust it when the + // forward presence cache still maps that prefix back to this exact addr. + { + auto fwd = presence_.get(senderPfx); + if (!fwd || *fwd != addr) + senderPfx = {}; + } + boost::asio::post(io, [this, buf, senderPfx]() { + if (computeHandler_) + computeHandler_->onApplicationMsg( + reinterpret_cast(buf->data()), buf->size(), + senderPfx); + }); + return; + } + // Unknown application code: drop silently (per the app_code_t contract). No + // banAddress -- the source address is not authenticated (see incomingMessage). +} + +void CesServer::reply(const SockAddr& addr, const MinxMessage& msg) { + try { + minx_->sendMessage(addr, msg); + } catch (...) { + } + + auto now = std::chrono::steady_clock::now(); + { + std::lock_guard lock(replyMutex_); + replyQueueFast_.push_back( + {addr, msg, now + std::chrono::milliseconds(REPLY_FAST_DELAY_MS)}); + } +} + +void CesServer::replyStartTimer() { + if (!replyTimer_) { + replyTimer_ = std::make_shared(taskIO_); + } + replyTimer_->expires_after(std::chrono::milliseconds(REPLY_TIMER_INITIAL_MS)); + replyTimer_->async_wait( + [this](const boost::system::error_code& ec) { replyTick(ec); }); +} + +void CesServer::replyTick(const boost::system::error_code& ec) { + if (ec) + return; + + auto now = std::chrono::steady_clock::now(); + std::lock_guard lock(replyMutex_); + + while (!replyQueueFast_.empty()) { + auto& item = replyQueueFast_.front(); + if (item.triggerTime > now) + break; + + try { + minx_->sendMessage(item.addr, item.msg); + } catch (...) { + } + + item.triggerTime = + now + std::chrono::milliseconds(REPLY_SLOW_DELAY_MS); + replyQueueSlow_.push_back(std::move(item)); + + replyQueueFast_.pop_front(); + } + + while (!replyQueueSlow_.empty()) { + auto& item = replyQueueSlow_.front(); + if (item.triggerTime > now) + break; + + try { + minx_->sendMessage(item.addr, item.msg); + } catch (...) { + } + + replyQueueSlow_.pop_front(); + } + + replyTimer_->expires_after(std::chrono::milliseconds(REPLY_TIMER_TICK_MS)); + replyTimer_->async_wait([this](const auto& e) { replyTick(e); }); +} + +void CesServer::tpsInc() { + tpsGauge_.record(1); + ++txCount_; +} + +// Run the metrics readout / sample / roll / multiplier-refresh logic +// once, on taskIO_, and wait for it to finish. Same path the live +// timer drives — letting tests exercise the wiring without sleeping. +// Serializing on taskIO_ also keeps net-sampler state (lastNetCumulative_, +// netPeakBps_) free of data races against the live tick. +void CesServer::runMetricsTickOnce() { + std::promise done; + auto fut = done.get_future(); + boost::asio::post(taskIO_, [this, &done]() { + metricsCompute(); + done.set_value(); + }); + fut.wait(); +} + +// Body of one tick. Touches mutable net-sampler doubles, must run on +// taskIO_ to serialize with the timer-driven invocation. +void CesServer::metricsCompute() { + // tps: average ops/sec across the 60s window. + uint64_t tpsAvg = tpsGauge_.average(); + constexpr uint64_t kU16Max = std::numeric_limits::max(); + if (tpsAvg > kU16Max) + tpsAvg = kU16Max; + tpsCurrent_.store(static_cast(tpsAvg), std::memory_order_relaxed); + + // l1cpu: strand busy_ns over the 60s window. 100% utilization of one + // strand-thread for 60s = 60 * 1e9 ns. Cap at 10000 bp. + constexpr uint64_t kWindowNs = 60ULL * 1'000'000'000ULL; + uint64_t busyNs = l1cpuGauge_.sum(); + l1cpuBp_.store(clampBp(busyNs * 10000ULL / kWindowNs)); + + // l2cpu: /proc/loadavg field 1, normalized by hardware concurrency. + unsigned int hwc = std::thread::hardware_concurrency(); + if (hwc == 0) hwc = 1; + double loadavg = readLoadAvg(); + uint64_t l2cpuRaw = static_cast(loadavg * 10000.0 / hwc); + l2cpuBp_.store(clampBp(l2cpuRaw)); + + // l2mem: /proc/meminfo (MemTotal-MemAvailable)/MemTotal. + l2memBp_.store(clampBp(readMemUsedBp())); + + // l1memac, l1memas: store sizes — must be read on logicStrand_. + postLogic([this]() { + if (cfg_.maxAcc > 0) { + uint64_t sz = static_cast(accounts_->getObjects().size()); + l1memacBp_.store(clampBp(sz * 10000ULL / cfg_.maxAcc)); + } + if (cfg_.maxAsset > 0) { + uint64_t sz = static_cast(assets_->getObjects().size()); + l1memasBp_.store(clampBp(sz * 10000ULL / cfg_.maxAsset)); + } + }); + + // net: bytes/sec averaged over 60s, with EWMA-decaying RAM peak. + // First tick has no previous reading — establish baseline only. + uint64_t netCumulative = readNetCumulative(); + if (lastNetCumulativeValid_) { + uint64_t delta = (netCumulative >= lastNetCumulative_) + ? netCumulative - lastNetCumulative_ + : 0; + netRxTxGauge_.record(delta); + } + lastNetCumulative_ = netCumulative; + lastNetCumulativeValid_ = true; + + uint64_t currentBps = netRxTxGauge_.average(); + // Lifetime watermark: the highest sustained throughput we've seen on + // this host since process start. "100%" means "as busy as we've ever + // been." Stable reference, no time-decay tunable, never-decreasing + // until a new high arrives. + if (static_cast(currentBps) > netPeakBps_) + netPeakBps_ = static_cast(currentBps); + if (netPeakBps_ > 0.0) { + uint64_t bp = static_cast( + static_cast(currentBps) * 10000.0 / netPeakBps_); + netBp_.store(clampBp(bp)); + } else { + netBp_.store(0); + } + + // Roll all bucket gauges into the next 1s slot. + tpsGauge_.roll(); + l1cpuGauge_.roll(); + netRxTxGauge_.roll(); + + // Refresh per-FeeKind multipliers from the gauge each is mapped to. + // Pinned to 10000 (full price) when the discount is disabled. + // + // Invariant: the mapper must never write 0 into a multiplier just + // because its source gauge is at 0. Two distinct cases: + // (a) the gauge is well-defined and legitimately reads 0 + // (e.g. l1cpuBp_ when the strand is fully idle) + // (b) the gauge is undefined — i.e. its computation requires a + // config knob that's zero, so metricsCompute skips updating + // it (l1memacBp_ when maxAcc=0; l1memasBp_ when maxAsset=0). + // Case (a) is the discount working; case (b) would charge zero + // fees forever, which is the worst possible failure mode. Snap + // undefined gauges to 10000 here so debit sites stay safe. + if (cfg_.feeDiscountEnabled) { + auto set = [&](FeeKind k, uint16_t bp) { + feeMult_[static_cast(k)].store(bp, + std::memory_order_relaxed); + }; + uint16_t l1cpu = static_cast(l1cpuBp_.load()); + uint16_t l1mac = (cfg_.maxAcc > 0) ? static_cast(l1memacBp_.load()) : 10000; + uint16_t l1mas = (cfg_.maxAsset > 0) ? static_cast(l1memasBp_.load()) : 10000; + uint16_t l2cpu = static_cast(l2cpuBp_.load()); + uint16_t l2mem = static_cast(l2memBp_.load()); + // netPeakBps_ is 0 until the first non-zero rx+tx delta is seen. + // Until then, netBp_ reads 0 — undefined for fee purposes, snap + // to 10000 (same pattern as the maxAcc=0 / maxAsset=0 cases). + uint16_t net = (netPeakBps_ > 0.0) ? static_cast(netBp_.load()) : 10000; + set(FeeKind::Tx, l1cpu); + set(FeeKind::Query, l1cpu); + set(FeeKind::VMMult, l1cpu); + set(FeeKind::AccountRent, l1mac); + set(FeeKind::AssetRent, l1mas); + set(FeeKind::ComputeSlot, l2cpu); + set(FeeKind::ComputeCpu, l2cpu); + set(FeeKind::ComputeRss, l2mem); + set(FeeKind::BucketByteSec, l2mem); + set(FeeKind::Net, net); + } else { + for (auto& m : feeMult_) + m.store(10000, std::memory_order_relaxed); + } +} + +// Single 1Hz pulse. Already runs on taskIO_ via the timer — call +// metricsCompute directly (no post + wait dance) and reschedule. +void CesServer::metricsTick(const boost::system::error_code& ec) { + if (ec) + return; + metricsCompute(); + metricsTimer_->expires_at(metricsTimer_->expiry() + std::chrono::seconds(1)); + metricsTimer_->async_wait( + [this](const boost::system::error_code& ec) { metricsTick(ec); }); +} + +// Defers to computePrepayCost (feemult.h) so cesvm.cpp's syscall path +// bills off the same formula as the wire-protocol path. +uint64_t CesServer::attenuatedFundCost(FeeKind k, + uint64_t feePerDay, + uint32_t daysAdded, + uint32_t daysAlreadyHeld) const { + return computePrepayCost(feePerDay, getFeeMult(k), + daysAdded, daysAlreadyHeld); +} + +double CesServer::readLoadAvg() { + std::ifstream f("/proc/loadavg"); + if (!f) return 0.0; + double v = 0.0; + f >> v; + return v; +} + +uint64_t CesServer::readMemUsedBp() { + std::ifstream f("/proc/meminfo"); + if (!f) return 0; + uint64_t total = 0, available = 0; + std::string line; + while (std::getline(f, line)) { + auto digit = line.find_first_of("0123456789"); + if (digit == std::string::npos) continue; + if (line.starts_with("MemTotal:")) { + total = std::stoull(line.substr(digit)); + } else if (line.starts_with("MemAvailable:")) { + available = std::stoull(line.substr(digit)); + } + if (total && available) break; + } + if (total == 0 || available > total) return 0; + uint64_t used = total - available; + return used * 10000ULL / total; +} + +uint64_t CesServer::readNetCumulative() { + // /proc/net/dev format: + // Inter-| Receive | Transmit + // face |bytes packets errs drop fifo frame compressed multicast | bytes packets ... + // eth0: 12345 100 0 0 0 0 0 0 67890 120 ... + // lo: ... + std::ifstream f("/proc/net/dev"); + if (!f) return 0; + std::string line; + // skip two header lines + std::getline(f, line); + std::getline(f, line); + uint64_t total = 0; + while (std::getline(f, line)) { + auto colon = line.find(':'); + if (colon == std::string::npos) continue; + std::string ifname = line.substr(0, colon); + auto first = ifname.find_first_not_of(" \t"); + auto last = ifname.find_last_not_of(" \t"); + if (first == std::string::npos) continue; + ifname = ifname.substr(first, last - first + 1); + if (ifname == "lo") continue; + std::istringstream iss(line.substr(colon + 1)); + uint64_t rx = 0, tx = 0, skip = 0; + iss >> rx; + for (int i = 0; i < 7; ++i) iss >> skip; + iss >> tx; + total += rx + tx; + } + return total; +} + +void CesServer::metricsStartTimer() { + if (!metricsTimer_) + metricsTimer_ = std::make_shared(taskIO_); + metricsTimer_->expires_after(std::chrono::seconds(1)); + metricsTimer_->async_wait( + [this](const boost::system::error_code& ec) { metricsTick(ec); }); +} + +void CesServer::dailyTaskTick(const boost::system::error_code& ec) { + if (ec) + return; + + postLogic( [this]() { + size_t accBefore = 0, accPayExpired = 0, accFeeDeleted = 0, + accFeeDebited = 0; + int64_t creditsDelta = 0; + { + // Daily account rent — billed at today's discounted rate. The + // discount for AccountRent floats with l1memac (account slot + // pressure), so an idle ledger costs ~nothing to keep accounts + // alive while a saturated ledger pays full BASE_FEE_ACCOUNT. + uint64_t dailyAccFee = discountFee(FeeKind::AccountRent, + cfg_.feeAccount); + auto& map = accounts_.getStore().getObjects(); + accBefore = map.size(); + boost::unordered::erase_if(map, [&](auto& pair) { + Account& acc = pair.second; + int64_t bal = acc.getBalance(); + if (bal < 0) { + // Payment account (negative balance = marker, no real credits) + uint32_t nonce = acc.getNonce(); + if (nonce <= 1) { + ++accPayExpired; + return true; + } + acc.setNonce(nonce - 1); + } else { + if (bal <= static_cast(dailyAccFee)) { + creditsDelta -= bal; + ++accFeeDeleted; + return true; + } + creditsDelta -= static_cast(dailyAccFee); + acc.setBalance(bal - static_cast(dailyAccFee)); + ++accFeeDebited; + } + return false; + }); + accounts_.adjustTotalCredits(creditsDelta); + } + size_t astBefore = 0, astExpired = 0, astAutoFunded = 0; + { + // Asset rent is prepaid days that deplete one per pass. An owner-pays + // asset does not die at the floor: the owner account is charged one day + // at the discounted feeAsset rate and the asset is held at 0 days + // (owner-sustained). It dies only when the owner is an asset, is gone, or + // cannot pay. Prepaid days deplete first, so shared funding is spent + // before the owner is billed. The charged rent is burned (like account + // rent), so it leaves circulation via adjustTotalCredits. + uint64_t dailyAssetFee = discountFee(FeeKind::AssetRent, cfg_.feeAsset); + int64_t astCreditsDelta = 0; + auto& map = assets_->getObjects(); + astBefore = map.size(); + boost::unordered::erase_if(map, [&](auto& pair) { + Asset& asset = pair.second; + bool priv = isAssetPrivate(asset.getBalance()); + bool aowned = isAssetOwned(asset.getBalance()); + bool immut = isAssetImmutable(asset.getBalance()); + bool ownerPays = isAssetOwnerPays(asset.getBalance()); + uint16_t days = assetDays(asset.getBalance()); + if (days <= 1) { + if (ownerPays && !aowned) { + ActiveAccount owner = accounts_.get(asset.getOwnerId()); + if (owner.exists() && + owner.balance() > static_cast(dailyAssetFee)) { + owner.data().setBalance(owner.balance() - + static_cast(dailyAssetFee)); + astCreditsDelta -= static_cast(dailyAssetFee); + asset.setBalance(assetBalance(0, priv, aowned, immut, ownerPays)); + ++astAutoFunded; + return false; + } + } + ++astExpired; + return true; + } + asset.setBalance(assetBalance(days - 1, priv, aowned, immut, ownerPays)); + return false; + }); + accounts_.adjustTotalCredits(astCreditsDelta); + } + // Alias rent: sized by the alias's byte footprint (derived from feeAccount + // at ALIAS_BYTES/ACCOUNT_BYTES = 16x), charged to the owner in RAM like + // account/asset rent (the snapshot below captures it). Owner gone or unable + // to pay -> erase the alias and clear its aliasId. The id-generator cell + // (id 0) is never billed. + size_t aliasBefore = 0, aliasReclaimed = 0; + { + uint64_t dailyAliasFee = + discountFee(FeeKind::AccountRent, + cfg_.feeAccount * ALIAS_BYTES / ACCOUNT_BYTES); + auto& map = aliases_->getObjects(); + aliasBefore = map.size(); + int64_t aliasCreditsDelta = 0; + boost::unordered::erase_if(map, [&](auto& pair) { + if (pair.first == 0) + return false; // id-generator cell, never billed + HashPrefix ownerPfx = pair.second.getOwner(); + ActiveAccount owner = accounts_.get(ownerPfx); + if (!owner.exists()) { + ++aliasReclaimed; + return true; + } + int64_t bal = owner.balance(); + if (bal <= static_cast(dailyAliasFee)) { + if (owner.data().getAliasId() == pair.first) + owner.data().setAliasId(0); + ++aliasReclaimed; + return true; + } + owner.data().setBalance(bal - static_cast(dailyAliasFee)); + aliasCreditsDelta -= static_cast(dailyAliasFee); + return false; + }); + accounts_.adjustTotalCredits(aliasCreditsDelta); + } + // key_name rent: charged to the KEY's account (getMapKey of the 32-byte + // key). Account gone or unable to pay -> reclaim the entry. Erasing on the + // store bypasses the wrapper's reverse index, so rebuild it after (once a + // day, O(n), fine). + size_t knBefore = 0, knReclaimed = 0; + { + uint64_t dailyKnFee = discountFee( + FeeKind::AccountRent, cfg_.feeAccount * KEYNAME_BYTES / ACCOUNT_BYTES); + auto& map = keyNames_->getObjects(); + knBefore = map.size(); + int64_t knCreditsDelta = 0; + boost::unordered::erase_if(map, [&](auto& pair) { + if (pair.second.getName() == KeyNameData{}) + return true; // already-empty tombstone + ActiveAccount owner = accounts_.get(Account::getMapKey(pair.first)); + if (!owner.exists()) { + ++knReclaimed; + return true; + } + int64_t bal = owner.balance(); + if (bal <= static_cast(dailyKnFee)) { + ++knReclaimed; + return true; + } + owner.data().setBalance(bal - static_cast(dailyKnFee)); + knCreditsDelta -= static_cast(dailyKnFee); + return false; + }); + accounts_.adjustTotalCredits(knCreditsDelta); + if (knReclaimed > 0) keyNames_.rebuildReverse(); // resync the reverse map + } + LOGINFO << "daily maintenance" + << VAR(accBefore) << VAR(accPayExpired) + << VAR(accFeeDeleted) << VAR(accFeeDebited) + << VAR(creditsDelta) + << VAR(astBefore) << VAR(astExpired) << VAR(astAutoFunded) + << VAR(aliasBefore) << VAR(aliasReclaimed) + << VAR(knBefore) << VAR(knReclaimed); + // Re-clamp the server's own account so incoming transfers can't drift its + // 48-bit balance toward the cap between reboots. + topUpServerAccount(); + doSnapshot("daily maintenance"); + }); + + // Flat-file rent is collected lazily (per-op + JIT GC on CREATE), no daily + // pass. kv-stores differ: each key is a self-renting cell, charged by a + // per-key sweep the file service runs here. This runs on taskIO_, NOT the + // logicStrand: the sweep takes gKvMutex then hops to logicStrand to burn the + // rent (the lock order kv ops use), so running it inside the postLogic block + // above would invert that order and deadlock. + if (fileHandler_) fileHandler_->sweepKvRent(); + if (fileHandler_) fileHandler_->sweepExtensionBudget(); + + dailyTaskStartTimer(); +} + +void CesServer::dailyTaskStartTimer() { + if (!dailyTimer_) + dailyTimer_ = std::make_shared(taskIO_); + auto now = std::chrono::system_clock::now(); + uint64_t nowSec = + std::chrono::duration_cast(now.time_since_epoch()) + .count(); + uint64_t secondsIntoDay = nowSec % SECS_PER_DAY; + uint64_t targetSeconds = DAILY_MAINTENANCE_HOUR_UTC * 3600; + uint64_t secondsToWait; + if (secondsIntoDay < targetSeconds) + secondsToWait = targetSeconds - secondsIntoDay; + else + secondsToWait = (SECS_PER_DAY - secondsIntoDay) + targetSeconds; + dailyTimer_->expires_after(std::chrono::seconds(secondsToWait)); + dailyTimer_->async_wait( + [this](const boost::system::error_code& ec) { dailyTaskTick(ec); }); +} + +void CesServer::_runDailyMaintenance() { + boost::system::error_code ec; + dailyTaskTick(ec); +} + +void CesServer::_brr(const minx::Hash& accountKey, int64_t amount) { + if (running_) { + postLogic( [this, accountKey, amount]() { + _brrInner(accountKey, amount); + }); + } else { + _brrInner(accountKey, amount); + } +} + +bool CesServer::_registerKeyName(const minx::Hash& key, const std::string& name) { + KeyNameData nm{}; + std::memcpy(nm.data(), name.data(), std::min(name.size(), nm.size())); + auto doIt = [&]() { + return keyNames_.registerName(key, nm, cfg_.maxKeyName) == + KeyNames::RegisterResult::Ok; + }; + if (!running_) return doIt(); + std::promise p; + auto f = p.get_future(); + postLogic([&p, &doIt]() { p.set_value(doIt()); }); + return f.get(); +} + +// Boot-time autolaunch of /s/ extensions. For each name in +// cfg_.extensions, calls computeHandler_->launchInternal on +// /s/.lua. The file itself is operator-deployed: drop it into +// /s/, the file handler's startup reconcile auto-generates +// the sidecar before this runs. Source missing → WRN, skip; the +// server keeps running. +// +// Runs on rpcTaskIO_ (caller posts it there). Posted after +// fileHandler startupReconcile so reconcile has already filled in any +// missing /s/ sidecars. +void CesServer::launchExtensions() { + if (!computeHandler_) return; + for (const auto& name : cfg_.extensions) { + std::string path = "/s/" + name + ".lua"; + uint8_t rc = computeHandler_->launchInternal(path); + if (rc != CES_OK) { + LOGWARNING << "extension: launch failed" + << SVAR(name) << SVAR(path) << VAR(int(rc)); + continue; + } + LOGINFO << "extension launched" + << SVAR(name) << SVAR(path); + } + // Ensure /s/instances.html exists from boot (launch commits rewrite it as + // each child registers; this covers the zero-extension case too). + computeHandler_->regenerateInstanceCatalogNow(); +} + +// Resets the server's own account to exactly TARGET, at boot and on every +// daily maintenance pass. Created at TARGET if absent; otherwise its balance +// is forced to TARGET whether it was below or above. +// +// Account balances are 48-bit (BALANCE_MAX), so TARGET sits at 2^46: a deeply +// bottomless balance that still leaves 2^46 of headroom before the cap, with +// the daily re-clamp keeping incoming transfers (dice bets, fee receipts) from +// drifting it toward that ceiling between reboots. Forcing rather than topping +// up heals a stale balance corrupted (negative or near-saturation) by an +// older build. +// +// Runs on logicStrand_ (caller posts it there). +void CesServer::topUpServerAccount() { + static constexpr int64_t TARGET = int64_t(1) << 46; + const minx::Hash& serverPubKey = serverKeyPair_.getPublicKeyAsHash(); + ActiveAccount acc = accounts_.get(serverPubKey); + + // Fresh account: create it at TARGET. + if (!acc.exists()) { + accounts_.createAccount(Account::getMapKey(serverPubKey), + Account(serverPubKey, TARGET, 0)); + LOGINFO << "server account created at bottomless target" << VAR(TARGET); + return; + } + + // Existing account: force the balance to exactly TARGET — reset it + // whether it's below (underfunded) or above (accumulated deposits, or + // a stale value corrupted negative / near-saturation by an older + // build). Forcing rather than topping up means a wrong balance can + // never get stuck overflowed. The direct setBalance bypasses the + // credit/debit tally maintenance, so adjust totalCredits_ by the delta. + int64_t cur = acc.balance(); + if (cur == TARGET) return; + acc.data().setBalance(TARGET); + Account::SerModeGuard guard(Account::SerMode::Full); + accounts_->persist(acc.it); + accounts_.adjustTotalCredits(TARGET - cur); + LOGINFO << "server account forced to bottomless target" + << VAR(cur) << VAR(TARGET); +} + +namespace { + +// /b/dice — fair-coin double-or-nothing as pure-L1 bytecode. +// +// No input data, no preloaded house pubkey: the program uses +// SYS_DEPOSIT (caller → owner) for the bet and SYS_WITHDRAW (owner → +// caller) for the heads payout. Both endpoints are implicit on the +// C++ side. cesh just signs CES_RUN_ASSET with allowance = bet. +// +// Why allowance == bet works: +// - Allowance is the cap on caller-side *spending* (transfer +// amounts), not on protocol fees. +// - Syscall fees (feeTx) come out of the run's pre-paid `budget` +// via CesVM::billCredits — separate accounting bucket. +// +// VM flow: +// 1. Snapshot the bet (= io[ALLOWANCE]) into GPR0 — step 2 will +// drain io[ALLOWANCE] to zero, but we still need 2*bet for the +// heads payout in step 5. +// 2. SYS_DEPOSIT amount = GPR0 (caller pays owner the bet). +// 3. OP_RND fills R; R & 1 = the coin. +// 4. JF tails on R == 0. +// 5. heads: SHL bet, 1 -> R = 2*bet. SYS_WITHDRAW amount = R +// (owner pays caller). Output byte 0 = 0x01. +// 6. tails: output byte 0 = 0x00. +// +// Net heads: caller -N (deposit) +2N (withdraw) = +N. Owner net -N. +// Net tails: caller -N. Owner +N. +ces::AssetData buildDiceVmProgram() { + ces::VmProgram p; + ces::VmLabel tails = p.label(); + + // Snapshot bet — SYS_DEPOSIT will zero io[ALLOWANCE]. + p.set(ces::Imm(ces::CESVM_CELL_GPR0), + ces::Ref(ces::CESVM_IO_ALLOWANCE)); + + // Caller deposits the bet to the owner (the house). sysDeposit emits a + // hostx (abort-on-S!=0) op: this is THE GUARD. SYS_WITHDRAW below is an + // unbounded, allowance-exempt spend of the house (programOwner), so the + // payout must be unreachable unless the bet actually cleared. If the + // caller can't cover the bet, this aborts the run (undo-log rollback) + // before the flip -- an underfunded caller can never win house money. + // Do NOT weaken this to a non-aborting deposit. See the programOwner + // field doc in cesvm.h for the general model. + p.sysDeposit({.amount = ces::Ref(ces::CESVM_CELL_GPR0)}); + + // Coin flip. + p.rnd(); + p.and_(ces::Ref(ces::CESVM_CELL_R), ces::Imm(1)); + p.jf(ces::Ref(ces::CESVM_CELL_R), tails); + + // Heads: owner withdraws 2 * bet to caller. + p.shl(ces::Ref(ces::CESVM_CELL_GPR0), ces::Imm(1)); + p.sysWithdraw({.amount = ces::Ref(ces::CESVM_CELL_R)}); + p.setOutput(ces::Imm(0x01), 1); // HEADS marker + p.term(); + + // Tails: bet stays with the owner, output 0x00. + p.place(tails); + p.setOutput(ces::Imm(0x00), 1); + p.term(); + + return p.buildBootBlock(); +} + +} // namespace + +void CesServer::deployBuiltinVmPrograms() { + // Single shipped program for now: /b/dice. Add more here as they + // come; each entry is (path, bytecode-builder). + const std::string path = "/b/dice"; + AssetData content = buildDiceVmProgram(); + + // Asset key derivation matches cesh's parseAssetKey for short + // names: literal path bytes, zero-padded to 32 bytes. Lets users + // refer to the program as `/b/dice` from the CLI without any extra + // resolver step on the cesh side. + minx::Hash key{}; + std::memcpy(key.data(), path.data(), + std::min(path.size(), key.size())); + + HashPrefix serverOwner = + Account::getMapKey(serverKeyPair_.getPublicKeyAsHash()); + + // Canonical state: server-owned, current bytecode, max days, no + // private/asset-owned/immutable bits, no price. Same struct on + // first boot, on every subsequent boot, and after any squat. + uint16_t bal = assetBalance(/*days=*/0x0FFF, /*priv=*/false, + /*assetOwned=*/false, /*immutable=*/false); + Asset canonical(serverOwner, content, bal, /*price=*/0); + + Asset::SerModeGuard guard(Asset::SerMode::Full); + assets_->update(key, canonical); + + LOGDEBUG << "deployed /b/ bytecode program" << SVAR(path); +} + +void CesServer::_brrInner(const minx::Hash& accountKey, int64_t amount) { + if (amount <= 0) + return; + if (amount > BALANCE_MAX) + amount = BALANCE_MAX; + HashPrefix id = Account::getMapKey(accountKey); + ActiveAccount acc = accounts_.get(id); + if (acc.exists()) { + acc.credit(amount); + } else { + Account newAcc(accountKey, amount, 0); + accounts_.createAccount(id, newAcc); + } +} + +void CesServer::_burn(const minx::Hash& accountKey, int64_t amount) { + if (running_) { + postLogic( [this, accountKey, amount]() { + _burnInner(accountKey, amount); + }); + } else { + _burnInner(accountKey, amount); + } +} + +bool CesServer::_walletSend(const minx::Hash& destKey, uint64_t amount) { + bool ok = false; + std::promise done; + postLogic([&]() { + // One strand task so the balance check and the two ledger moves are atomic. + ActiveAccount sacc = accounts_.get(serverKeyPair_.getPublicKeyAsHash()); + if (amount > 0 && sacc.exists() && sacc.balance() >= 0 && + static_cast(sacc.balance()) >= amount) { + // balance >= amount, so _burnInner debits exactly `amount` (its min() is + // a no-op here); _brrInner credits/creates dest. Tally nets to zero. + _burnInner(serverKeyPair_.getPublicKeyAsHash(), static_cast(amount)); + _brrInner(destKey, static_cast(amount)); + ok = true; + LOGINFO << "wallet send from server account" << VAR(amount); + } + done.set_value(); + }); + done.get_future().get(); + return ok; +} + +void CesServer::_burnInner(const minx::Hash& accountKey, int64_t amount) { + HashPrefix id = Account::getMapKey(accountKey); + ActiveAccount acc = accounts_.get(id); + if (acc.exists()) { + int64_t bal = acc.balance(); + int64_t toDebit = std::min(amount, bal); + if (toDebit > 0) + acc.debit(static_cast(toDebit)); + } +} + +// Concrete LedgerTxn over CesServer's private stores. Constructed and used only +// inside _l2Transact's logicStrand_ task, so every op here runs on the strand. +struct ServerLedgerTxn : ces::LedgerTxn { + CesServer* s; + explicit ServerLedgerTxn(CesServer* srv) : s(srv) {} + + uint8_t signerSpend(const minx::Hash& signer, uint64_t amount, + uint32_t reqNonce, int64_t errFee) override { + // A zero charge needs no account: an accountless signer can run a free op + // (e.g. a feeQuery-0 read of a donated /s/ file). Replay of free ops is + // bounded by the channel's own dedup, not the account. + if (amount == 0) return CES_OK; + auto acc = s->accounts_.get(Account::getMapKey(signer)); + uint8_t rc = acc.validateSpend(amount, 0, reqNonce, errFee); + if (rc != CES_OK) return rc; + acc.debit(amount); // committed event; amount is burned + return CES_OK; + } + bool debitAccount(const minx::Hash& pubkey, uint64_t amount) override { + auto acc = s->accounts_.get(Account::getMapKey(pubkey)); + if (!acc.exists() || acc.balance() < static_cast(amount)) + return false; + acc.debit(amount); + return true; + } + void credit(const minx::Hash& pubkey, int64_t amount) override { + s->_brrInner(pubkey, amount); + } + int64_t balance(const minx::Hash& pubkey) override { + auto acc = s->accounts_.get(Account::getMapKey(pubkey)); + return acc.exists() ? acc.balance() : 0; + } + bool isReplay(uint64_t sigHash) override { return s->isDuplicateDedup(sigHash); } + void recordDedup(uint64_t sigHash) override { s->recordDedup(sigHash); } + bool assetOwnedBy(const minx::Hash& assetId, const minx::Hash& who) override { + Assets::ActiveAsset asset = s->assets_.get(assetId); + return asset.exists() && asset.getOwnerId() == Account::getMapKey(who); + } +}; + +void CesServer::_l2Transact(const std::function& fn) { + std::promise done; + postLogic([this, &fn, &done]() { + ServerLedgerTxn txn(this); + fn(txn); + done.set_value(); + }); + done.get_future().get(); +} + +void CesServer::_l2DebitProgramAccount( + const minx::Hash& pubkey, + int64_t amount, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + postLogic( + [self, pubkey, amount, cb, cbExecutor]() { + HashPrefix id = Account::getMapKey(pubkey); + ActiveAccount acc = self->accounts_.get(id); + if (!acc.exists()) { + if (cb) + boost::asio::post(cbExecutor, + [cb]() { cb(false, 0); }); + return; + } + int64_t bal = acc.balance(); + if (bal < amount) { + if (cb) + boost::asio::post(cbExecutor, + [cb, bal]() { cb(false, bal); }); + return; + } + acc.debit(static_cast(amount)); + int64_t newBal = bal - amount; + if (cb) + boost::asio::post(cbExecutor, + [cb, newBal]() { cb(true, newBal); }); + }); +} + +CesServer::ProgramAccountDebitResult +CesServer::_l2DebitProgramAccountSync( + const minx::Hash& pubkey, int64_t amount) { + std::promise p; + auto fut = p.get_future(); + postLogic( + [this, pubkey, amount, &p]() { + HashPrefix id = Account::getMapKey(pubkey); + ActiveAccount acc = accounts_.get(id); + if (!acc.exists()) { + p.set_value({false, 0}); + return; + } + int64_t bal = acc.balance(); + if (bal < amount) { + p.set_value({false, bal}); + return; + } + acc.debit(static_cast(amount)); + p.set_value({true, bal - amount}); + }); + return fut.get(); +} + +void CesServer::_l2CreditProgramAccountSync( + const minx::Hash& pubkey, int64_t amount) { + std::promise p; + auto fut = p.get_future(); + postLogic( + [this, pubkey, amount, &p]() { + _brrInner(pubkey, amount); + p.set_value(); + }); + fut.get(); +} + +int64_t CesServer::_l2ProgramAccountBalanceSync( + const minx::Hash& pubkey) { + std::promise p; + auto fut = p.get_future(); + postLogic( + [this, pubkey, &p]() { + HashPrefix id = Account::getMapKey(pubkey); + ActiveAccount acc = accounts_.get(id); + p.set_value(acc.exists() ? acc.balance() : 0); + }); + return fut.get(); +} + +void CesServer::_testRegisterL2Handler(const std::string& name, + CesPlexHandler* handler) { + minx::Hash dh = ces::sha256( + reinterpret_cast(name.data()), name.size()); + uint64_t key = ces::Buffer::peek(dh.data()); + std::promise p; + auto fut = p.get_future(); + postLogic([this, key, handler, &p]() { + l2Registry_[key] = handler; + p.set_value(); + }); + fut.get(); +} + +#ifdef CES_MAIL +void CesServer::mailDeliver(const MailMessage& m) { + if (mailSink_) { mailSink_(m); return; } // test / custom relay hook + if (cfg_.mailRelayHost.empty()) { + LOGINFO << "mail send (no relay configured; dropped)" + << SVAR(m.to) << SVAR(m.subject) << VAR(m.body.size()); + return; + } + // Off-strand: SMTP blocks. Everything is copied by value and the worker + // touches no server state, so the detached thread is safe across shutdown. + // Best-effort; no logging from the worker. + SmtpConfig sc; + sc.host = cfg_.mailRelayHost; + sc.port = cfg_.mailRelayPort; + sc.from = cfg_.mailFrom; + sc.user = cfg_.mailUser; + sc.pass = cfg_.mailPass; + MailMessage msg = m; + std::thread([sc, msg]() { + try { + if (msg.attachmentName.empty()) { + ces::smtpSend(sc, msg.to, msg.subject, msg.body, nullptr, nullptr); + } else { + ces::MailAttachment att; + att.filename = msg.attachmentName; + att.data = msg.attachmentData; + ces::smtpSend(sc, msg.to, msg.subject, msg.body, &att, nullptr); + } + } catch (...) {} + }).detach(); +} + +void CesServer::_testSetMailSink(std::function sink) { + mailSink_ = std::move(sink); +} + +bool CesServer::mailChargeSync(const minx::Hash& payer, uint64_t price) { + if (price == 0) return true; + std::promise pr; + auto fut = pr.get_future(); + postLogic([this, payer, price, &pr]() { + HashPrefix id = Account::getMapKey(payer); + ActiveAccount acc = accounts_.get(id); + if (!acc.exists() || acc.balance() < static_cast(price)) { + pr.set_value(false); + return; + } + _burnInner(payer, static_cast(price)); // debit + destroy (burn) + pr.set_value(true); + }); + return fut.get(); +} +#endif // CES_MAIL + +void CesServer::_l2Transfer( + const minx::Hash& originKey, + const minx::Hash& destKey, + uint64_t amount, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + postLogic( + [self, originKey, destKey, amount, cb, cbExecutor]() { + int64_t newBal = 0; + uint8_t rc = self->transfer(originKey, destKey, amount, + TransferMode::Open, 0, + CES_NONCELESS, newBal); + boost::asio::post(cbExecutor, + [cb, rc, newBal]() { cb(rc, newBal); }); + }); +} + +void CesServer::_l2CrossTransfer( + const minx::Hash& originKey, + const minx::Hash& destKey, + uint64_t amount, + const std::string& destServer, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + postLogic( + [self, originKey, destKey, amount, destServer, cb, cbExecutor]() { + int64_t newBal = 0; + uint8_t rc = self->crossTransfer(originKey, destKey, amount, destServer, + CES_NONCELESS, newBal); + boost::asio::post(cbExecutor, + [cb, rc, newBal]() { cb(rc, newBal); }); + }); +} + +// ---- Extension funding rate gate. A token bucket refilling at +// extFundingRatePerDay_ raw units/day, capped at one day's worth. Caller holds +// extFundingMu_. +void CesServer::extFundingRefillLocked() { + int64_t now = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + double cap = static_cast(extFundingRatePerDay_); + if (extFundingLastUs_ == 0) { + extFundingLastUs_ = now; // first touch: just stamp; start empty + } else { + double rate = static_cast(extFundingRatePerDay_) / 86400.0 / 1e6; + double elapsed = static_cast(now - extFundingLastUs_); + if (elapsed > 0) { + extFundingAllowance_ += elapsed * rate; + extFundingLastUs_ = now; + } + } + if (extFundingAllowance_ > cap) extFundingAllowance_ = cap; // also clamps after a rate cut + if (extFundingAllowance_ < 0) extFundingAllowance_ = 0; +} + +uint64_t CesServer::extFundingGrant(uint64_t requested) { + std::lock_guard lk(extFundingMu_); + extFundingRefillLocked(); + uint64_t avail = static_cast(extFundingAllowance_); + uint64_t granted = requested < avail ? requested : avail; + extFundingAllowance_ -= static_cast(granted); + return granted; +} + +void CesServer::extFundingRefund(uint64_t amount) { + std::lock_guard lk(extFundingMu_); + extFundingAllowance_ += static_cast(amount); + double cap = static_cast(extFundingRatePerDay_); + if (extFundingAllowance_ > cap) extFundingAllowance_ = cap; +} + +uint64_t CesServer::extFundingRemaining() { + std::lock_guard lk(extFundingMu_); + extFundingRefillLocked(); + return static_cast(extFundingAllowance_); +} + +void CesServer::extFundingSetPerDay(uint64_t perDay) { + std::lock_guard lk(extFundingMu_); + extFundingRefillLocked(); // settle accrual at the old rate first + bool raised = perDay > extFundingRatePerDay_; + extFundingRatePerDay_ = perDay; + double cap = static_cast(perDay); + if (raised) extFundingAllowance_ = cap; // enable/raise: full now + else if (extFundingAllowance_ > cap) extFundingAllowance_ = cap; // lower: clamp now +} + +void CesServer::createAssetAsync( + const minx::Hash& originKey, + const HashPrefix& ownerId, + const minx::Hash& assetId, + const AssetData& content, + uint16_t balance, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + postLogic( + [self, originKey, ownerId, assetId, content, balance, cb, cbExecutor]() { + uint8_t rc = self->createAsset(originKey, ownerId, assetId, content, + balance, CES_NONCELESS); + boost::asio::post(cbExecutor, [cb, rc]() { cb(rc); }); + }); +} + +void CesServer::_l2QueryAccount( + const minx::Hash& accountKey, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + postLogic( + [self, accountKey, cb, cbExecutor]() { + HashPrefix qid = Account::getMapKey(accountKey); + int64_t bal = 0; + uint32_t nonce = 0; + HashPrefix lastDest{}; + uint64_t lastAmount = 0; + uint32_t lastTime = 0; + uint32_t aliasId = 0; + self->unsignedQueryAccount(qid, bal, nonce, lastDest, + lastAmount, lastTime, aliasId); + boost::asio::post(cbExecutor, + [cb, bal, nonce, lastDest, lastAmount, lastTime]() { + cb(bal, nonce, lastDest, lastAmount, lastTime); + }); + }); +} + +void CesServer::_l2QueryKeyName( + const minx::Hash& key, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + postLogic([self, key, cb, cbExecutor]() { + KeyNameData nm{}; + std::string name; + if (self->keyNames_.nameForKey(key, nm)) { + size_t len = 0; + while (len < nm.size() && nm[len] != 0) ++len; + name.assign(reinterpret_cast(nm.data()), len); + } + boost::asio::post(cbExecutor, [cb, name]() { cb(name); }); + }); +} + +uint64_t CesServer::priceNetUsage(const CesPlexUsage& usage) const { + // Price a channel's measured resource usage in credits at the live discounted + // feeNet* rates. mem-byte-seconds → the per-byte-DAY rate (divide by + // seconds/day); __uint128_t guards the conversion against overflow. + const uint64_t feeChanSec = discountFee(FeeKind::Net, cfg_.feeNetChannelSec); + const uint64_t feeMemDay = discountFee(FeeKind::Net, cfg_.feeNetMemByteDay); + const uint64_t feeKiBSent = discountFee(FeeKind::Net, cfg_.feeNetKiBSent); + const uint64_t feeKiBRecv = discountFee(FeeKind::Net, cfg_.feeNetKiBReceived); + + __uint128_t debit = 0; + // Throughput rates are per KiB, so divide the byte counts by 1024. + debit += (static_cast<__uint128_t>(usage.bytesSent) * feeKiBSent) + / BYTES_PER_KIB; + debit += (static_cast<__uint128_t>(usage.bytesReceived) * feeKiBRecv) + / BYTES_PER_KIB; + debit += (static_cast<__uint128_t>(usage.memByteSeconds) * feeMemDay) + / SECS_PER_DAY; + debit += static_cast<__uint128_t>(usage.ageSeconds) * feeChanSec; + return debit > std::numeric_limits::max() + ? std::numeric_limits::max() + : static_cast(debit); +} + +void CesServer::cesplexReportUsage(const HashPrefix& payer, + const minx::SockAddr& peer, + uint32_t channelId, + const CesPlexUsage& usage) { + const uint64_t amount = priceNetUsage(usage); + if (amount == 0) return; // free at current rates — nothing to charge + + // Debit the payer on logicStrand; if it can't cover the tick, the host + // closes the channel (on the rpc strand). The bus never sees the cost + // and never evicts for non-payment. + _l2DebitNetworkBill( + payer, static_cast(amount), + [this, peer, channelId, amount](bool ok) { + if (ok) return; + LOGDEBUG << "netbill: insufficient funds → close" + << SVAR(peer) << VAR(channelId) << VAR(amount); + if (rpcRudp_) rpcRudp_->closeChannel(peer, channelId); + }, + rpcTaskIO_.get_executor()); +} + +void CesServer::debitNetworkBill(const HashPrefix& payerPfx, uint64_t amount) { + // Per-instance endpoint billing: charge a child's INBOUND luarpc caller. No + // close callback - the child owns the channel (no rpc-side channel to evict). + if (amount == 0) return; + _l2DebitNetworkBill(payerPfx, static_cast(amount), nullptr, + rpcTaskIO_.get_executor()); +} + +void CesServer::_l2DebitNetworkBill( + const HashPrefix& payerPfx, + int64_t amount, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + postLogic( + [self, payerPfx, amount, cb, cbExecutor]() { + bool ok = false; + ActiveAccount acc = self->accounts_.get(payerPfx); + if (acc.exists() && acc.balance() >= amount) { + acc.debit(static_cast(amount)); + ok = true; + } + // Account already-not-exists OR balance < amount → leave alone, + // signal failure. ChannelMeter will closeChannel on the + // callback hop. + if (cb) boost::asio::post(cbExecutor, [cb, ok]() { cb(ok); }); + }); +} + +void CesServer::_l2CheckAssetOwner( + const minx::Hash& assetId, + const ces::PublicKey& signer, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + minx::Hash signerKey = signer.getHash(); + postLogic( + [self, assetId, signerKey, cb, cbExecutor]() { + ActiveAsset asset = self->assets_.get(assetId); + bool isOwner = false; + if (asset.exists()) { + HashPrefix signerPrefix = Account::getMapKey(signerKey); + isOwner = (asset.getOwnerId() == signerPrefix); + } + boost::asio::post(cbExecutor, [cb, isOwner]() { cb(isOwner); }); + }); +} + +void CesServer::_l2CheckFZoneOwner( + const std::string& fname, + const ces::PublicKey& signer, + std::function cb, + boost::asio::any_io_executor cbExecutor) { + auto self = this; + minx::Hash signerKey = signer.getHash(); + postLogic([self, fname, signerKey, cb, cbExecutor]() { + bool ok = false; + // The /f// zone is owned SOLELY by the key_name holder: one source of + // truth, unsquattable, no asset gate (an asset gate would be a second, + // racing source of truth for the same zone). The path segment is already + // the normalized (underscore) form; normalize is idempotent on it. A name + // that cannot be a key_name (empty or >32 bytes) has no owner and no zone. + if (!fname.empty() && fname.size() <= KEYNAME_NAME_BYTES) { + KeyNameData nm{}; + std::memcpy(nm.data(), fname.data(), fname.size()); + Hash knKey{}; + if (self->keyNames_.keyForName(KeyNames::normalize(nm), knKey)) + ok = (knKey == signerKey); + } + boost::asio::post(cbExecutor, [cb, ok]() { cb(ok); }); + }); +} + +void CesServer::liveSnapshot(std::function cb) { + postLogic( [this, cb]() { + try { + if (doSnapshot("cesco")) { + if (cb) cb(true, "Snapshot written."); + } else { + if (cb) cb(true, "Snapshot debounced (cooldown)."); + } + } catch (std::exception& e) { + LOGERROR << "live snapshot failed" << SVAR(e.what()); + if (cb) cb(false, std::string("Snapshot failed: ") + e.what()); + } + }); +} + +void CesServer::_save() { + if (running_) + throw std::runtime_error("_save() cannot be called while server is running"); + accounts_->flush(true); + accounts_->save(logkv::StoreSaveMode::syncSave); + aliases_->save(logkv::StoreSaveMode::syncSave); + keyNames_->flush(true); + keyNames_->save(logkv::StoreSaveMode::syncSave); + assets_->flush(true); + assets_->save(logkv::StoreSaveMode::syncSave); +} + +void CesServer::rotateDedupLocked(uint64_t epochNow) { + if (dedupBaseTime_ == 0) dedupBaseTime_ = epochNow; + if (epochNow - dedupBaseTime_ >= DEDUP_WINDOW_US) { + dedupOlder_ = std::move(dedupCurrent_); + dedupCurrent_.clear(); + dedupBaseTime_ = epochNow; + } +} + +bool CesServer::checkAndInsertDedup(uint64_t sigHash, uint64_t epochNow) { + if (epochNow == 0) epochNow = getMicrosSinceEpoch(); + std::lock_guard lock(dedupMutex_); + rotateDedupLocked(epochNow); + if (dedupOlder_.count(sigHash) || dedupCurrent_.count(sigHash)) + return false; // duplicate + dedupCurrent_.insert(sigHash); + return true; // new +} + +bool CesServer::isDuplicateDedup(uint64_t sigHash, uint64_t epochNow) { + if (epochNow == 0) epochNow = getMicrosSinceEpoch(); + std::lock_guard lock(dedupMutex_); + rotateDedupLocked(epochNow); + return dedupOlder_.count(sigHash) || dedupCurrent_.count(sigHash); +} + +void CesServer::recordDedup(uint64_t sigHash, uint64_t epochNow) { + if (epochNow == 0) epochNow = getMicrosSinceEpoch(); + std::lock_guard lock(dedupMutex_); + rotateDedupLocked(epochNow); + dedupCurrent_.insert(sigHash); +} + +// Even split of `budget` over `caps`, each alloc <= caps[i], the excess from +// capped entries redistributed to those with headroom; `pocket` takes the +// remainder. sum(alloc) + pocket == budget. +std::vector CesServer::splitCapped( + uint64_t budget, const std::vector& caps, uint64_t& pocket) { + std::vector alloc(caps.size(), 0); + uint64_t remaining = budget; + std::vector active; + for (size_t i = 0; i < caps.size(); ++i) + if (caps[i] > 0) active.push_back(i); + while (remaining > 0 && !active.empty()) { + uint64_t share = remaining / active.size(); + if (share == 0) break; // indivisible crumb -> pocket the remainder + bool progress = false; + std::vector still; + for (size_t idx : active) { + uint64_t headroom = caps[idx] - alloc[idx]; + uint64_t give = std::min(share, headroom); + alloc[idx] += give; + remaining -= give; + if (give > 0) progress = true; + if (alloc[idx] < caps[idx]) still.push_back(idx); + } + active.swap(still); + if (!progress) break; + } + pocket = remaining; + return alloc; +} + +// Plan the fan-out: cap each reachable peer (!= excludeKey) at our reserve there +// (ourBalanceThere; -1 unknown -> uncapped) and at maxPeerReserveDisturbance, +// take a random gossipFanoutDegree subset, split `budget` across them. Any-thread +// (peer table mutex). plan.fanned = sum of allocations; the rest is not charged. +CesServer::FanoutPlan CesServer::computeGossipFanout(uint64_t budget, + const Hash& excludeKey) { + FanoutPlan plan; + if (budget == 0) return plan; + struct Cand { std::string address; minx::Hash key; uint64_t cap; }; + std::vector cands; + { + std::lock_guard lock(peerTableMutex_); + for (const auto& p : peerTable_) { + if (!p.reachable || p.declaredAddress.empty()) continue; + if (p.ckey == excludeKey) continue; + uint64_t cap = p.ourBalanceThere >= 0 + ? static_cast(p.ourBalanceThere) + : std::numeric_limits::max(); + if (cfg_.maxPeerReserveDisturbance > 0 && + cap > cfg_.maxPeerReserveDisturbance) + cap = cfg_.maxPeerReserveDisturbance; + if (cap == 0) continue; // no headroom + cands.push_back({p.declaredAddress, p.ckey, cap}); + } + } + if (cands.empty()) return plan; + // Forward to a random gossipFanoutDegree subset (0 = every peer). + if (cfg_.gossipFanoutDegree > 0 && + cands.size() > cfg_.gossipFanoutDegree) { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::shuffle(cands.begin(), cands.end(), rng); + cands.resize(cfg_.gossipFanoutDegree); + } + std::vector caps; + caps.reserve(cands.size()); + for (auto& c : cands) caps.push_back(c.cap); + uint64_t pocket = 0; + std::vector allocs = splitCapped(budget, caps, pocket); + for (size_t i = 0; i < cands.size(); ++i) { + if (allocs[i] == 0) continue; + plan.legs.push_back({cands[i].address, cands[i].key, allocs[i]}); + plan.fanned += allocs[i]; + } + return plan; +} + +// Dispatch a planned fan-out: one re-signed gossip per leg with that leg's +// allocation as its budget. Leg 1: credit the peer's reserve here by what it +// acked draining (leg 2), capped at what we authorized. paid==0 credits +// nothing. Runs on logicStrand_ (touches settlementClients_). +void CesServer::dispatchGossipFanout(const Hash& authorId, const Hash& msgId, + const Hash& dest, const ces::Bytes& msg, + const FanoutPlan& plan) { + for (const auto& leg : plan.legs) { + auto* c = getOrCreateSettlementClient(leg.address, leg.key); + if (!c) continue; + Hash peerKey = leg.key; + uint64_t authorized = leg.alloc; + c->gossip(authorId, msgId, dest, leg.alloc, msg, + [this, peerKey, authorized](uint8_t rc, uint64_t paid) { + if (rc != CES_OK || paid == 0) return; + uint64_t credit = paid < authorized ? paid : authorized; + if (credit > static_cast(BALANCE_MAX)) + credit = static_cast(BALANCE_MAX); + postLogic([this, peerKey, credit]() { + HashPrefix pid = Account::getMapKey(peerKey); + ActiveAccount peer = accounts_.get(pid); + if (peer.exists() && peer.data().getKey(pid) == peerKey) + peer.credit(credit); + else + accounts_.createAccount( + pid, Account(peerKey, static_cast(credit), 0)); + LOGTRACE << "gossip leg1 credit" << VAR(credit) + << SVAR(minx::hashToString(peerKey)); + }); + }); + } +} + +void CesServer::registerSinkTarget(const minx::Hash& pubkey) { + postLogic([this, pubkey]() { + auto& e = localSinkKeys_[Account::getMapKey(pubkey)]; + e.first = pubkey; + ++e.second; + LOGTRACE << "gossip sink target +" + << SVAR(minx::hashToString(pubkey)) << VAR(e.second); + }); +} + +void CesServer::unregisterSinkTarget(const minx::Hash& pubkey) { + postLogic([this, pubkey]() { + auto it = localSinkKeys_.find(Account::getMapKey(pubkey)); + if (it == localSinkKeys_.end()) return; + if (--it->second.second <= 0) localSinkKeys_.erase(it); + }); +} + +// A gossip `dest` with 128 leading zero bits is NOT a real pubkey: the low 128 +// bits are a protocol/type id (a TYPED BROADCAST, delivered to on_gossip for +// demux), or, if fully zero, an untyped broadcast. Such a dest can never be a +// sink, so it floods like a broadcast and we skip the sink path. Only a real +// pubkey (high 128 bits nonzero) is a sink candidate. Reserving the +// 128-zero-prefix space as "not a pubkey" is a CES-wide convention. +static bool gossipDestIsSinkablePubkey(const minx::Hash& dest) { + for (int i = 0; i < 16; ++i) + if (dest[i] != 0) return true; + return false; +} + +void CesServer::handleGossip(const CesGossip& req, const SockAddr& addr, + const MinxMessage& msg) { + // Runs on logicStrand_: race-free dedup and all ledger touches on one thread. + postLogic([this, req, addr, msg]() { + HashPrefix mid = Account::getMapKey(req.msgId); + HashPrefix senderPfx = Account::getMapKey(req.originId); + + auto reply = [&](uint8_t rcode, uint64_t paid) { + CesGossipResult res; + res.originId = senderPfx; + res.rcode = rcode; + res.paid = paid; + sendSignedReply(addr, msg, std::move(res)); + }; + + // SINK: if `dest` is an entity we host (a compute program or this server), + // the gossip is terminal. Drain the sender and credit `dest`'s account here, + // no fan-out. Reply paid == skim so the sender mirrors only the toll (leg 1); + // the delivered remainder has no leg 1. Deduped by msgId so a retransmit + // re-acks without re-draining. A typed-broadcast dest (128 leading zero bits) + // is never a sink -> skip straight to the broadcast fan-out below. + if (gossipDestIsSinkablePubkey(req.dest)) { + HashPrefix destPfx = Account::getMapKey(req.dest); + auto sit = localSinkKeys_.find(destPfx); + if (sit != localSinkKeys_.end() && sit->second.first == req.dest) { + auto seen = gossipSeen_.get(mid); + if (seen.has_value()) { + reply(CES_OK, seen->sender == senderPfx ? seen->paid : 0); + return; + } + uint64_t senderReserve = 0; + { + ActiveAccount s = accounts_.get(senderPfx); + if (s.exists() && s.data().getKey(senderPfx) == req.originId && + s.balance() > 0) + senderReserve = static_cast(s.balance()); + } + uint64_t collectable = std::min(req.budget, senderReserve); + uint64_t skim = 0; + if (collectable > 0) { + skim = + static_cast(discountedFlatFee(-1, cfg_.feeTx, FeeKind::Tx)); + if (skim > collectable) skim = collectable; + uint64_t delivered = collectable - skim; + if (delivered > static_cast(BALANCE_MAX)) + delivered = static_cast(BALANCE_MAX); + { + ActiveAccount s = accounts_.get(senderPfx); + if (s.exists()) s.debit(collectable); // leg 2 + } + bool toSelf = (req.dest == serverKeyPair_.getPublicKeyAsHash()); + if (!toSelf && delivered > 0) { + ActiveAccount d = accounts_.get(destPfx); + if (d.exists() && d.data().getKey(destPfx) == req.dest) + d.credit(delivered); + else + accounts_.createAccount( + destPfx, Account(req.dest, static_cast(delivered), 0)); + } + // toSelf: nothing credited, the delivered amount burns + gossipSinkCount_.fetch_add(1, std::memory_order_relaxed); + LOGINFO << "gossip sink" << SVAR(minx::hashToString(req.dest)) + << VAR(collectable) << VAR(skim) << VAR(delivered) + << VAR(toSelf); + } + gossipSeen_.put(mid, GossipCharge{senderPfx, skim}); + deliverGossipLocal(req.authorId, req.originId, req.msgId, req.dest, + req.msg); + reply(CES_OK, skim); // leg 1: sender mirrors only our skim + return; + } + } + + auto seen = gossipSeen_.get(mid); + if (seen.has_value()) { + // Duplicate. Re-ack the SAME amount only to the peer we actually charged + // (so a retry from it completes leg 1 exactly once); a cycle copy from + // any other peer was never charged -> ack 0. Never re-charge/re-forward. + reply(CES_OK, seen->sender == senderPfx ? seen->paid : 0); + LOGTRACE << "gossip dup" << SVAR(minx::hashToString(req.msgId)); + return; + } + + // First sight: collect what the sender authorized and holds (leg 2), skim, + // plan the fan-out, charge skim + what we push, ack the charge (leg 1), + // deliver, dispatch. + uint64_t senderReserve = 0; + { + ActiveAccount s = accounts_.get(senderPfx); + if (s.exists() && s.data().getKey(senderPfx) == req.originId && + s.balance() > 0) + senderReserve = static_cast(s.balance()); + } + uint64_t collectable = std::min(req.budget, senderReserve); + if (collectable == 0) { + gossipSeen_.put(mid, GossipCharge{senderPfx, 0}); + reply(CES_ERROR_INSUFFICIENT_BALANCE, 0); + return; + } + uint64_t skim = + static_cast(discountedFlatFee(-1, cfg_.feeTx, FeeKind::Tx)); + if (skim > collectable) skim = collectable; + uint64_t forwardable = collectable - skim; + FanoutPlan plan = computeGossipFanout(forwardable, req.originId); + uint64_t charge = skim + plan.fanned; + { + ActiveAccount s = accounts_.get(senderPfx); + if (s.exists()) s.debit(charge); // leg 2 + } + gossipSeen_.put(mid, GossipCharge{senderPfx, charge}); + gossipRecvCount_.fetch_add(1, std::memory_order_relaxed); + reply(CES_OK, charge); // leg 1 + LOGINFO << "gossip recv" << SVAR(minx::hashToString(req.msgId)) + << VAR(collectable) << VAR(skim) << VAR(plan.fanned) << VAR(charge); + deliverGossipLocal(req.authorId, req.originId, req.msgId, req.dest, req.msg); + dispatchGossipFanout(req.authorId, req.msgId, req.dest, req.msg, plan); + }); +} + +uint64_t CesServer::originateGossip(const ces::Bytes& msg, uint64_t budget, + const Hash& dest) { + // Author a gossip: msgId from author+time+msg, seed it seen so a loop-back + // dedups, then fan out. + Hash authorId = serverKeyPair_.getPublicKeyAsHash(); + uint64_t t = getMicrosSinceEpoch(); + ces::Bytes seed; + seed.insert(seed.end(), authorId.begin(), authorId.end()); + for (int i = 0; i < 8; ++i) + seed.push_back(static_cast((t >> (i * 8)) & 0xff)); + seed.insert(seed.end(), msg.begin(), msg.end()); + Hash msgId = sha256(seed.data(), seed.size()); + + gossipSeen_.put(Account::getMapKey(msgId), GossipCharge{}); + // Plan synchronously so the caller gets `fanned` (to refund the remainder). + // Self-deliver, then dispatch on logicStrand_ (settlementClients_ is + // strand-only). The author pays nothing to itself; the fan-out spends this + // server's reserves at peers. + FanoutPlan plan = computeGossipFanout(budget, /*excludeKey=*/Hash{}); + LOGINFO << "gossip originate" << SVAR(minx::hashToString(msgId)) + << VAR(budget) << VAR(plan.fanned); + deliverGossipLocal(authorId, authorId, msgId, dest, msg); + postLogic([this, authorId, msgId, dest, msg, plan]() { + dispatchGossipFanout(authorId, msgId, dest, msg, plan); + }); + return plan.fanned; +} + +void CesServer::deliverGossipLocal(const Hash& author, const Hash& sender, + const Hash& msgId, const Hash& dest, + const ces::Bytes& msg) { + // The compute instance map lives on rpcTaskIO_; hop onto it. Copy the bytes + // into a shared_ptr the closure owns. No-op when the rpc port (and thus + // compute) is off. + auto io = _rpcTaskIOExecutor(); + if (!io) return; + auto m = std::make_shared(msg); + boost::asio::post(io, [this, author, sender, msgId, dest, m]() { + if (computeHandler_) + computeHandler_->deliverGossip(author, sender, msgId, dest, + m->data(), m->size()); + }); +} + +CesClientAsync* CesServer::getOrCreateSettlementClient( + const std::string& address, const minx::Hash& peerKey) { + auto it = settlementClients_.find(address); + if (it != settlementClients_.end()) + return it->second.get(); + try { + // Prefer the endpoint the peer miner already resolved off-strand. A + // cross-transfer only reaches here for a `reachable` peer, and the miner + // sets reachability and resolvedEndpoint together, so this avoids a blocking + // getaddrinfo on the logic strand (the ledger heartbeat). resolveUdp stays + // as a fallback for a reachable peer with no cached endpoint. + boost::asio::ip::udp::endpoint ep; + bool haveEp = false; + { + std::lock_guard lock(peerTableMutex_); + for (const auto& p : peerTable_) { + if (p.ckey == peerKey && p.resolvedEndpointValid) { + ep = p.resolvedEndpoint; + haveEp = true; + break; + } + } + } + if (!haveEp) ep = Resolver::resolveUdp(address); + auto client = std::make_unique( + settlementIO_, ep, serverKeyPair_, peerKey, + CesClientAsync::DEFAULT_CHANNELS, cfg_.settlementMaxRetries); + auto* ptr = client.get(); + settlementClients_[address] = std::move(client); + return ptr; + } catch (std::exception& e) { + LOGWARNING << "getOrCreateSettlementClient: " << e.what() + << VAR(address); + return nullptr; + } +} + +// ============================================================================= +// Peer Table +// ============================================================================= + +// Split "host:port" (and "[ipv6]:port") on the last colon into host + port. False +// if there is no port. No-throw, for comparing address representations. +static bool splitHostPort(const std::string& a, + std::string& host, std::string& port) { + auto pos = a.find_last_of(':'); + if (pos == std::string::npos || pos == 0 || pos + 1 >= a.size()) return false; + host = a.substr(0, pos); + port = a.substr(pos + 1); + if (host.size() >= 2 && host.front() == '[' && host.back() == ']') + host = host.substr(1, host.size() - 2); + return !host.empty(); +} + +void CesServer::upsertPeer(const minx::Hash& ckey, const std::string& address, + uint64_t inboundCredit) { + // NOTE: this runs on the logic strand (incomingProveWork posts here with an + // UNTRUSTED appData["server"] address). Do NOT resolve DNS here — a blocking + // getaddrinfo on a hostile hostname would stall the whole ledger thread. + // resolvedIP is populated off-strand by the peer miner when it probes the + // peer (which already resolves declaredAddress, and is cost-gated to the + // top peers by inbound PoW). + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey == ckey) { + // Banned: refuse to re-add or upgrade until the ban expires, so neither the + // peer maintenance nor an extension can resurrect a grief-banned peer. + if (p.bannedUntil != 0 && minx::getSecsSinceEpoch() < p.bannedUntil) return; + // Address-claim policy. An UNVERIFIED entry's address is up for grabs: + // whatever PoW spoke last wins (unverified claims brawl to the death, and + // none is trusted while they do). But a VERIFIED address is STICKY — an + // inbound PoW (inboundCredit > 0) carries no proof of identity, so it must + // never move a binding we paid a signed server-info to confirm. That is + // the whole anti-takeover rule: you cannot re-point a known server key with + // unsigned work. Operator/discovery updates (inboundCredit == 0) stay + // authoritative. On any real address change, reset the probe state so the + // newcomer is re-checked — and re-verified — from scratch. + bool fromInbound = (inboundCredit > 0); + bool mayWrite = !address.empty() && (!fromInbound || !p.verified); + // A peer re-added under a different address representation (a hostname vs the + // IP it resolves to) is not a real change: keep the binding so a re-add never + // resets/re-verifies a settled entry. Resolution stays off-strand (resolvedIP + // is set by the miner); compare the incoming host to it on the same port. + bool addrChanged = p.declaredAddress != address; + if (addrChanged && !p.resolvedIP.is_unspecified()) { + std::string nh, np, dh, dp; + if (splitHostPort(address, nh, np) && + splitHostPort(p.declaredAddress, dh, dp) && + nh == p.resolvedIP.to_string() && np == dp) + addrChanged = false; + } + if (mayWrite && addrChanged) { + p.declaredAddress = address; + p.verified = false; + p.reachable = false; + p.lastCheckTime = 0; + } + p.totalInboundPoW += inboundCredit; + if (inboundCredit > 0) p.lastInboundTime = minx::getSecsSinceEpoch(); + return; + } + } + // New entry. Bound the in-memory table so sustained inbound PoW from many + // distinct keys can't grow it without limit between restarts (persistence and + // the miner already keep only the top MAX_PERSISTED_PEERS). Operator/server + // adds pass inboundCredit == 0 (configured peers, outbound adds, reachability + // probes) and are always admitted; only the untrusted inbound-discovery path + // (inboundCredit > 0) is capped. When full, make room by evicting the weakest + // non-outbound resident (lowest accumulated inbound PoW) — NOT by refusing the + // newcomer: a fresh peer always arrives with just one solution's credit, so + // comparing that against residents' accumulated PoW would lock every new peer + // out forever. Outbound (operator-pinned) peers are never evicted; if every + // slot is one, drop the newcomer. + if (inboundCredit > 0 && peerTable_.size() >= maxInmemPeers()) { + const uint64_t nowSecs = minx::getSecsSinceEpoch(); + auto victim = peerTable_.end(); + for (auto it = peerTable_.begin(); it != peerTable_.end(); ++it) { + if (it->outbound) continue; + // Never evict an active ban: the tombstone must outlive the newcomer, or the + // banned peer would be silently re-admitted. + if (it->bannedUntil != 0 && nowSecs < it->bannedUntil) continue; + if (victim == peerTable_.end() || + it->totalInboundPoW < victim->totalInboundPoW) + victim = it; + } + if (victim == peerTable_.end()) + return; // every slot is outbound-pinned or an active ban + peerTable_.erase(victim); + } + PeerEntry pe; + pe.ckey = ckey; + pe.declaredAddress = address; + pe.totalInboundPoW = inboundCredit; + if (inboundCredit > 0) pe.lastInboundTime = minx::getSecsSinceEpoch(); + peerTable_.push_back(pe); +} + +void CesServer::_markPeerReachable(const minx::Hash& ckey, + const std::string& address) { + upsertPeer(ckey, address, 0); + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey == ckey) { + p.reachable = true; + return; + } + } +} + +bool CesServer::_isPeerReachable(const minx::Hash& ckey) { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey == ckey) return p.reachable; + } + return false; +} + +bool CesServer::_isPeerByKey(const minx::Hash& ckey) { + std::lock_guard lock(peerTableMutex_); + for (const auto& p : peerTable_) { + if (p.ckey == ckey) + // A banned peer is not a peer: the bind gate rejects it. + return !(p.bannedUntil != 0 && minx::getSecsSinceEpoch() < p.bannedUntil); + } + return false; +} + +std::vector CesServer::_peerLinkTargets() { + std::vector out; + std::lock_guard lock(peerTableMutex_); + out.reserve(peerTable_.size()); + for (const auto& p : peerTable_) { + // Only reachable peers are mesh targets. An unreachable peer (the peer + // miner flips this on probe failure) drops out of the set, so the + // reconcile tears its link down; it reappears when reachable again. + // This is the liveness signal that replaces a channel keepalive. + if (!p.reachable) continue; + if (p.bannedUntil != 0 && minx::getSecsSinceEpoch() < p.bannedUntil) continue; + PeerLinkTarget t; + t.ckey = p.ckey; + t.dialable = (p.rpcPort != 0) && !p.resolvedIP.is_unspecified(); + if (t.dialable) { + // The rpc RUDP socket is IPv6 dual-stack (v6_only=false), so it reports a + // v4 peer's address in v4-mapped form (::ffff:a.b.c.d). Dial a v4 peer + // under that mapped form, else the outbound channel is keyed by the bare + // v4 address while the acceptor's bind reply arrives as ::ffff:..., the + // keys never match, and the handshake stalls (SYN delivered, reply lost). + auto ip = p.resolvedIP; + if (ip.is_v4()) + ip = boost::asio::ip::make_address_v6(boost::asio::ip::v4_mapped, + ip.to_v4()); + t.endpoint = minx::SockAddr(ip, p.rpcPort); + } + out.push_back(t); + } + return out; +} + +bool CesServer::cesplexChannelMetered(const std::string& proto) { + // POSSIBLE TODO: this keys off the proto NAME, not the handler identity. If + // builtin:peer is ever wired at a non-standard proto name, its channel would + // be metered. Keying off "is this the peerHandler_ instance" would be robust. + return proto != ces::CES_PEER_PROTO; +} + +void CesServer::_testAddPeerWithRpc(const minx::Hash& ckey, + const std::string& declaredAddr, + const boost::asio::ip::address& ip, + uint16_t rpcPort) { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey == ckey) { + p.declaredAddress = declaredAddr; + p.resolvedIP = ip; + p.resolvedEndpoint = minx::SockAddr(ip, rpcPort); + p.resolvedEndpointValid = true; + p.rpcPort = rpcPort; + p.reachable = true; + p.outbound = true; + return; + } + } + PeerEntry pe; + pe.ckey = ckey; + pe.declaredAddress = declaredAddr; + pe.resolvedIP = ip; + pe.resolvedEndpoint = minx::SockAddr(ip, rpcPort); + pe.resolvedEndpointValid = true; + pe.rpcPort = rpcPort; + pe.reachable = true; + pe.outbound = true; + peerTable_.push_back(pe); +} + +void CesServer::_testCompletePeering(const minx::Hash& ckey, int64_t reserve, + uint64_t inboundPoW) { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey == ckey) { + p.verified = true; + p.reachable = true; + p.ourBalanceThere = std::max(p.ourBalanceThere, reserve); + if (p.totalInboundPoW < inboundPoW) p.totalInboundPoW = inboundPoW; + return; + } + } +} + +void CesServer::_mountCesPlexHandler(const std::string& proto, + CesPlexHandler* handler) { + if (!cesplex_) return; + std::promise done; + boost::asio::post(rpcTaskIO_, [&]() { + cesplex_->mount(proto, handler); + done.set_value(); + }); + done.get_future().wait(); +} + +// ============================================================================= +// Dashboard / admin surface +// ============================================================================= + +CesServer::AdminStats CesServer::_adminStats() { + std::promise pr; + postLogic([&]() { + AdminStats s; + s.circulating = circulatingCredits(); + s.accounts = accounts_->getObjects().size(); + s.assets = assets_->getObjects().size(); + s.aliases = aliases_->getObjects().size(); + s.txCount = txCount_.load(); + pr.set_value(s); + }); + return pr.get_future().get(); +} + +std::vector CesServer::_peerVostroBalances( + const std::vector& keys) { + std::promise> pr; + postLogic([&]() { + std::vector out; + out.reserve(keys.size()); + for (const auto& k : keys) { + ActiveAccount acc = accounts_.get(Account::getMapKey(k)); + out.push_back(acc.exists() ? acc.data().getBalance() : 0); + } + pr.set_value(std::move(out)); + }); + return pr.get_future().get(); +} + +CesServer::AdminAccount CesServer::_adminQueryAccount( + const minx::Hash& accountKey) { + std::promise pr; + postLogic([&]() { + AdminAccount a; + ActiveAccount acc = accounts_.get(Account::getMapKey(accountKey)); + if (acc.exists()) { + const Account& d = acc.data(); + // The map is keyed by the 8-byte prefix only; confirm the full 32-byte + // identity by matching the stored 24-byte keyTail. A mismatch means a + // DIFFERENT account occupies this prefix (collision), not the one asked + // for — so the queried account does not exist. + if (d.getKeyTail() == getHashTail(accountKey)) { + a.exists = true; + a.balance = d.getBalance(); + a.nonce = d.getNonce(); + a.lastXferDest = d.getLastXferDest(); + a.lastXferAmount = d.getLastXferAmount(); + a.lastXferTime = d.getLastXferTime(); + } else { + a.prefixTaken = true; + } + } + pr.set_value(a); + }); + return pr.get_future().get(); +} + +CesServer::AdminAsset CesServer::_adminQueryAsset(const minx::Hash& assetId) { + std::promise pr; + postLogic([&]() { + AdminAsset a; + ActiveAsset as = assets_.get(assetId); + if (as.exists()) { + a.exists = true; + a.owner = as.getOwnerId(); + a.balance = as.getBalance(); + a.price = as.getPrice(); + a.content = as.getContent(); + } + pr.set_value(a); + }); + return pr.get_future().get(); +} + +CesServer::FileStat CesServer::_fileStat(const std::string& path) { + FileStat out; + // File feature needs the cap and CesPlex actually up; gate on the BOUND rpc + // port (cfg_.rpcPort may be 0 under auto-port) so we never post to a dead IO. + if (cfg_.cesFileStoreMaxBytes == 0 || _rpcBoundPort() == 0) return out; + out.enabled = true; + FileExecReq req; + req.verb = 0x04; // kVerbStat — public, no signer/owner required + req.name = path; + std::promise pr; + // fileHandlerExec runs the verb body (sidecar read-modify-write + lazy rent) + // on the CALLER thread; only the callback is posted to the executor. Run the + // whole call on rpcTaskIO so it stays serialized with the handler's other + // verbs instead of racing them from this web thread, then block on the future. + boost::asio::post(_rpcTaskIOExecutor(), [this, &req, &pr]() { + if (!fileHandler_) { + FileExecResp r; r.status = CES_ERROR_DISABLED; pr.set_value(std::move(r)); + return; + } + fileHandler_->exec(req, [&pr](FileExecResp r) { pr.set_value(std::move(r)); }, + _rpcTaskIOExecutor()); + }); + FileExecResp r = pr.get_future().get(); + if (r.status == CES_OK) { + out.found = true; + out.ownerPubkey = r.ownerPubkey; + out.fileBalance = r.fileBalance; + out.size = r.size; + out.pricePerKb = r.pricePerKb; + out.createdUs = r.createdUs; + out.modifiedUs = r.modifiedUs; + } + return out; +} + +std::vector CesServer::_peerSnapshot() { + std::vector out; + std::lock_guard lock(peerTableMutex_); + out.reserve(peerTable_.size()); + for (const auto& p : peerTable_) { + PeerInfo pi; + pi.ckey = p.ckey; + pi.declaredAddress = p.declaredAddress; + if (!p.resolvedIP.is_unspecified()) + pi.resolvedIP = p.resolvedIP.to_string(); + pi.outbound = p.outbound; + pi.inbound = (p.totalInboundPoW > 0); + pi.reachable = p.reachable; + pi.verified = p.verified; + pi.ourBalanceThere = p.ourBalanceThere; + pi.totalInboundPoW = p.totalInboundPoW; + pi.totalOutboundPoW = p.totalOutboundPoW; + pi.lastInboundTime = p.lastInboundTime; + pi.lastCheckTime = p.lastCheckTime; + pi.pingFailures = p.pingFailures; + pi.rpcPort = p.rpcPort; + pi.grief = p.grief; + pi.bannedUntil = p.bannedUntil; + out.push_back(std::move(pi)); + } + return out; +} + +void CesServer::_addOutboundPeer(const minx::Hash& ckey, + const std::string& address) { + upsertPeer(ckey, address, 0); // locks peerTableMutex_ internally + { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey == ckey) { + p.outbound = true; + break; + } + } + } + // Persist now so a dashboard-added peer survives restart even if the + // miner (which also persists each cycle) hasn't ticked yet. + savePeerData(); + LOGINFO << "peer added via admin" << SVAR(address); + // Start the probe/mine thread if it wasn't running — so the new peer gets + // its reachability checked right away, even at target 0. + ensurePeerMinerStarted(); +} + +bool CesServer::_removePeer(const minx::Hash& ckey) { + bool removed = false; + { + std::lock_guard lock(peerTableMutex_); + auto it = std::remove_if(peerTable_.begin(), peerTable_.end(), + [&](const PeerEntry& p) { return p.ckey == ckey; }); + if (it != peerTable_.end()) { + peerTable_.erase(it, peerTable_.end()); + removed = true; + } + } + if (removed) { + savePeerData(); + LOGINFO << "peer removed via admin" << SVAR(minx::hashToString(ckey)); + } + return removed; +} + +uint32_t CesServer::griefPeer(const minx::Hash& ckey, uint32_t amount) { + if (amount == 0) amount = 1; + uint32_t result = 0; + bool banned = false; + { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey != ckey) continue; + uint64_t now = minx::getSecsSinceEpoch(); + if (p.bannedUntil != 0 && now >= p.bannedUntil) { p.grief = 0; p.bannedUntil = 0; } + p.grief += amount; + p.lastDecayTime = now; // a fresh offense restarts the decay clock + if (cfg_.peerGriefBanThreshold != 0 && p.grief >= cfg_.peerGriefBanThreshold && + p.bannedUntil == 0) { + p.bannedUntil = now + cfg_.peerBanSecs; + banned = true; + } + result = p.grief; + break; + } + } + if (banned) { + savePeerData(); + LOGINFO << "peer banned for grief" << SVAR(minx::hashToString(ckey)); + } + return result; +} + +void CesServer::banPeer(const minx::Hash& ckey) { + // Conclusive evidence (an extension is certain, e.g. an incompatible params hash an + // honest peer cannot emit): ban immediately, not via the accumulating counter. The + // ban is enforced and lifted exactly like a grief ban (hidden from ces.peers(), + // refused at bind, not dialed, removed when it expires). + bool banned = false; + { + std::lock_guard lock(peerTableMutex_); + for (auto& p : peerTable_) { + if (p.ckey != ckey) continue; + if (p.bannedUntil == 0) { + p.bannedUntil = minx::getSecsSinceEpoch() + cfg_.peerBanSecs; + banned = true; + } + break; + } + } + if (banned) { + savePeerData(); + LOGINFO << "peer banned (conclusive)" << SVAR(minx::hashToString(ckey)); + } +} + +Signature CesServer::serverSign(const uint8_t* data, size_t len) { + static constexpr char kAttestTag[] = "CES_EXT_ATTEST_V1"; + constexpr size_t tagLen = sizeof(kAttestTag) - 1; + std::vector buf; + buf.reserve(tagLen + len); + buf.insert(buf.end(), kAttestTag, kAttestTag + tagLen); + buf.insert(buf.end(), data, data + len); + minx::Hash digest = sha256(buf.data(), buf.size()); + return serverKeyPair_.signData(std::span(digest.data(), digest.size())); +} + +void CesServer::_setPeerTarget(uint64_t target) { + peerTarget_.store(target); + LOGINFO << "peer target set via admin" << VAR(target); + if (target > 0) ensurePeerMinerStarted(); +} + +void CesServer::_setMaxPeers(size_t n) { + if (n < 1) n = 1; + maxPeers_.store(n); + LOGINFO << "max peers set via admin" << VAR(n); +} + +CesServer::RemoteServerInfo CesServer::_inspectRemoteServer( + const std::string& address, bool fetchPaidInfo) { + RemoteServerInfo out; + boost::asio::ip::udp::endpoint ep; + try { + ep = Resolver::resolveUdp(address); + } catch (std::exception& e) { + LOGDEBUG << "inspect: resolve failed" << SVAR(address) << SVAR(e.what()); + return out; + } + try { + // No RandomX dataset — this is a handshake/query probe, not mining. + CesClient client(ep, false); + client.setKey(serverKeyPair_); + client.start(0); + // Snappy interactive probe: an unreachable host should fail fast (a few + // seconds), not the default nested-retry handshake (tens of seconds). + client.setTries(2); + if (client.connect()) { + out.reachable = true; + out.serverKey = client.getServerKey(); + out.minDifficulty = client.getMinDifficulty(); + // The paid KV info needs a funded account on the peer; if we have + // none the query just errors and entries stays empty. + if (fetchPaidInfo) { + std::vector entries; + if (client.queryServerInfo(entries) == CES_OK) + out.entries = std::move(entries); + } + client.disconnect(); + } + client.stop(); + } catch (std::exception& e) { + LOGDEBUG << "inspect: client error" << SVAR(address) << SVAR(e.what()); + } + return out; +} + +CesServer::RemoteMineResult CesServer::_mineRemoteServer( + const std::string& address, int count) { + RemoteMineResult r; + if (count < 1) count = 1; + boost::asio::ip::udp::endpoint ep; + try { + ep = Resolver::resolveUdp(address); + } catch (std::exception& e) { + r.error = std::string("resolve: ") + e.what(); + return r; + } + try { + // Cache-only RandomX: an occasional dashboard bootstrap mine doesn't + // justify a fresh ~2 GB dataset (the server already holds one for + // verification — a second would risk OOM). Slower per hash, but this is + // a few solutions, not the continuous peer miner. + CesClient client(ep, false); + client.setKey(serverKeyPair_); + client.start(0); + if (!client.connect()) { + r.error = "unreachable"; + client.stop(); + return r; + } + std::map appData; + appData["server"] = cfg_.serverName.empty() + ? (":" + std::to_string(boundPort_)) : cfg_.serverName; + for (int i = 0; i < count && ces::notInterrupted(); ++i) { + auto m = mineOnce(client, 1, appData); + if (m.success) { r.ok = true; r.credit += m.credit; } + else { r.status = m.status; break; } + } + client.disconnect(); + client.stop(); + } catch (std::exception& e) { + r.error = e.what(); + } + return r; +} + +void CesServer::loadHelloFromFile() { + auto path = (cfg_.dataDir / "hello.txt").string(); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) return; + try { + std::ifstream f(path, std::ios::binary); + std::ostringstream ss; + ss << f.rdbuf(); + std::lock_guard lock(helloMutex_); + helloMessage_ = normalizeHello(ss.str()); + LOGINFO << "loaded hello banner" << VAR(helloMessage_.size()); + } catch (std::exception& e) { + LOGWARNING << "failed to read hello.txt" << SVAR(e.what()); + } +} + +std::string CesServer::_getHello() { + std::lock_guard lock(helloMutex_); + return helloMessage_; +} + +std::string CesServer::_setHello(const std::string& raw) { + std::string norm = normalizeHello(raw); + auto path = (cfg_.dataDir / "hello.txt").string(); + { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f << norm; + } + { + std::lock_guard lock(helloMutex_); + helloMessage_ = norm; + } + LOGINFO << "hello banner set via admin" << VAR(norm.size()); + return norm; +} + +std::string CesServer::_loadHelloFile(bool& existed) { + auto path = (cfg_.dataDir / "hello.txt").string(); + std::error_code ec; + existed = std::filesystem::exists(path, ec); + if (!existed) return std::string(); + std::ifstream f(path, std::ios::binary); + std::ostringstream ss; + ss << f.rdbuf(); + return normalizeHello(ss.str()); +} + +bool CesServer::_setConfigKnob(const std::string& key, uint64_t value) { + // Mutate on logicStrand_. Base fees (account/asset/tx/query/vm) are also read + // there during charging, so they are race-free. The L2/net fee and cap fields + // are read off-strand (meter, compute supervisor, file handler, dashboard), so + // those reads formally race this write; being aligned word-size scalars they + // can only read the old or new value, never a torn one, taking effect next + // tick (benign in practice). The fully race-free form would publish the config + // as an immutable shared_ptr snapshot (RCU) loaded atomically, + // skipped here as the only writer is this rare operator action. + bool ok = false; + std::promise done; + postLogic([&]() { + if (key == "fee_account") { cfg_.feeAccount = value; ok = true; } + else if (key == "fee_asset") { cfg_.feeAsset = value; ok = true; } + else if (key == "fee_tx") { cfg_.feeTx = value; ok = true; } + else if (key == "fee_query") { cfg_.feeQuery = value; ok = true; } + else if (key == "fee_vm_mult") { cfg_.feeVmMult = value; ok = true; } + // L2 file-store fees (per byte-day / per KB), live per-op like the base fees. + else if (key == "fee_file_rent") { cfg_.feeFileRent = static_cast(value); ok = true; } + else if (key == "fee_file_write") { cfg_.feeFileWrite = static_cast(value); ok = true; } + else if (key == "fee_file_read") { cfg_.feeFileRead = static_cast(value); ok = true; } + // L2 compute fees, live on the supervisor tick. Keys are the TOML keys + // verbatim (no token-dropping drift): the switch IS the config name. + else if (key == "fee_compute_slot_sec") { cfg_.feeComputeSlotSec = static_cast(value); ok = true; } + else if (key == "fee_compute_cpu_sec") { cfg_.feeComputeCpuSec = static_cast(value); ok = true; } + else if (key == "fee_compute_rss_byte_day") { cfg_.feeComputeRssByteDay = static_cast(value); ok = true; } + else if (key == "fee_compute_net_byte") { cfg_.feeComputeNetByte = static_cast(value); ok = true; } + else if (key == "fee_bucket_byte_sec") { cfg_.feeBucketByteSec = static_cast(value); ok = true; } + // ChannelMeter (net metering) rates, live on the meter tick. Floored to >= 1 + // to preserve the constructor invariant that feeNet* is never a literal 0 -- + // a runtime 0 would silently DISABLE the meter (the ctor's 0-sentinel + // derivation only runs at startup, so it would not re-derive here). To get + // the derived default back, restart; to go cheap, set 1. + else if (key == "fee_net_kib_sent") { cfg_.feeNetKiBSent = value ? value : 1; ok = true; } + else if (key == "fee_net_kib_received") { cfg_.feeNetKiBReceived = value ? value : 1; ok = true; } + else if (key == "fee_net_channel_sec") { cfg_.feeNetChannelSec = value ? value : 1; ok = true; } + else if (key == "fee_net_mem_byte_day") { cfg_.feeNetMemByteDay = value ? value : 1; ok = true; } + // Feature caps: the value is read live (file GC / compute LAUNCH admission), + // but the handler binds/unbinds only at boot, so the 0 boundary is frozen in + // both directions — a feature off at boot can't be enabled live, and an + // enabled one can't be zeroed live. The `current != 0 && value != 0` guard + // enforces exactly that; crossing 0 needs a config edit + restart. + else if (key == "file_store_max_bytes") { + if (cfg_.cesFileStoreMaxBytes != 0 && value != 0) { cfg_.cesFileStoreMaxBytes = value; ok = true; } + } + else if (key == "compute_max_instances") { + if (cfg_.computeMaxInstances != 0 && value != 0) { cfg_.computeMaxInstances = static_cast(value); ok = true; } + } + else if (key == "fee_discount_enabled") { + cfg_.feeDiscountEnabled = (value != 0); ok = true; + } + else if (key == "min_difficulty") { + // The PoW floor is not swallowed on construction: minx_ reads its minDiff_ + // live on every solution (filterPoW), and cfg_.minDiff is read live when we + // advertise server-info, so updating both here takes effect immediately. We + // only gate the main minting port; rpcMinx_ has no minting floor. + if (value >= 1 && value <= static_cast(MAX_POW_DIFFICULTY)) { + cfg_.minDiff = static_cast(value); + if (minx_) minx_->setMinimumDifficulty(cfg_.minDiff); + ok = true; + } + } + if (ok) { LOGINFO << "config knob set via admin" << SVAR(key) << VAR(value); } + done.set_value(); + }); + done.get_future().get(); + return ok; +} + +std::string CesServer::_exportConfig(std::string* errReason) { + auto path = (cfg_.dataDir / "ces.toml").string(); + try { + std::ostringstream o; + o << "# CES server config — exported live from the running server.\n" + << "# Feed it back on the next boot with: ces --config " << path << "\n" + << "# Peers persist in peerdata.toml and the hello banner in hello.txt;\n" + << "# both load automatically and are intentionally NOT duplicated here.\n\n"; + + // The WHOLE live effective config, so a re-feed reproduces the running + // server and set-vs-default is never ambiguous. Every key is exactly the + // TOML/CLI name (the switch IS the config name — no drift). Top-level + // scalars first; [table]s last (TOML scoping). Add new CesConfig fields + // here when you add them, or the export silently rots. + o << "data_dir = \"" << cfg_.dataDir.string() << "\"\n"; + o << "port = " << boundPort_ << "\n"; + o << "# server_key is the 32-byte private key (same identity on re-feed).\n"; + o << "server_key = \"" << minx::hashToString(cfg_.serverPrivKey) << "\"\n"; + if (!cfg_.serverName.empty()) + o << "server_name = \"" << cfg_.serverName << "\"\n"; + o << "min_difficulty = " << static_cast(cfg_.minDiff) << "\n"; + o << "spend_slot_size = " << cfg_.spendSlotSize << "\n"; + o << "threads = " << cfg_.taskThreads << "\n"; + o << "min_accounts = " << cfg_.minAcc << "\n"; + o << "max_accounts = " << cfg_.maxAcc << "\n"; + o << "min_assets = " << cfg_.minAsset << "\n"; + o << "max_assets = " << cfg_.maxAsset << "\n"; + o << "min_aliases = " << cfg_.minAlias << "\n"; + o << "max_aliases = " << cfg_.maxAlias << "\n"; + o << "flush_value = " << cfg_.flushValue << "\n"; + o << "max_log_size_gb = " + << (cfg_.maxLogBytes / (1024ULL * 1024 * 1024)) << "\n\n"; + + o << "# Boot/engine flags (no_pow_engine, cache_only_pow, pow_delay,\n" + "# log_level) are launch-time choices not held in the live config;\n" + "# carry them over from your launch flags / base config if you used them.\n\n"; + + o << "# Base fees\n"; + o << "fee_account = " << cfg_.feeAccount << "\n"; + o << "fee_asset = " << cfg_.feeAsset << "\n"; + o << "fee_tx = " << cfg_.feeTx << "\n"; + o << "fee_query = " << cfg_.feeQuery << "\n"; + o << "fee_vm_mult = " << cfg_.feeVmMult << "\n"; + o << "fee_discount_enabled = " + << (cfg_.feeDiscountEnabled ? "true" : "false") << "\n\n"; + + o << "# L2 file-store fees\n"; + o << "fee_file_rent = " << cfg_.feeFileRent << "\n"; + o << "fee_file_write = " << cfg_.feeFileWrite << "\n"; + o << "fee_file_read = " << cfg_.feeFileRead << "\n\n"; + + o << "# L2 compute fees\n"; + o << "fee_compute_slot_sec = " << cfg_.feeComputeSlotSec << "\n"; + o << "fee_compute_cpu_sec = " << cfg_.feeComputeCpuSec << "\n"; + o << "fee_compute_rss_byte_day = " << cfg_.feeComputeRssByteDay << "\n"; + o << "fee_compute_net_byte = " << cfg_.feeComputeNetByte << "\n"; + o << "fee_bucket_byte_sec = " << cfg_.feeBucketByteSec << "\n\n"; + + o << "# ChannelMeter net-metering rates (0 = derive a non-zero default, NOT free)\n"; + o << "fee_net_kib_sent = " << cfg_.feeNetKiBSent << "\n"; + o << "fee_net_kib_received = " << cfg_.feeNetKiBReceived << "\n"; + o << "fee_net_channel_sec = " << cfg_.feeNetChannelSec << "\n"; + o << "fee_net_mem_byte_day = " << cfg_.feeNetMemByteDay << "\n\n"; + + o << "# Peering — peer_target is the LIVE runtime value (dashboard edits land here).\n"; + o << "peer_target = " << peerTarget_.load() << "\n"; + o << "peer_miner_interval = " << cfg_.peerMinerIntervalSecs << "\n"; + o << "peer_pow_inbound_reciprocation_bps = " + << cfg_.peerPowInboundReciprocationBps << "\n"; + o << "max_peers = " << maxPeers_.load() << "\n"; + o << "settlement_max_retries = " << cfg_.settlementMaxRetries << "\n"; + o << "max_peer_reserve_disturbance = " + << cfg_.maxPeerReserveDisturbance << "\n"; + o << "gossip_fanout_degree = " << cfg_.gossipFanoutDegree << "\n\n"; + + o << "# Interfaces\n"; + if (!cfg_.adminSocket.empty()) + o << "admin_socket = \"" << cfg_.adminSocket << "\"\n"; + o << "web_port = " << cfg_.webPort << "\n"; + o << "web_bind = \"" << cfg_.webBind << "\"\n"; + o << "rpc_port = " << cfg_.rpcPort << "\n"; + o << "rpc_max_pending = " << cfg_.rpcMaxPending << "\n"; + o << "rpc_max_request_bytes = " << cfg_.rpcMaxRequestBytes << "\n"; + o << "rpc_max_response_bytes = " << cfg_.rpcMaxResponseBytes << "\n"; + o << "rpc_response_timeout_ms = "<< cfg_.rpcResponseTimeoutMs << "\n"; + o << "rpc_rudp_bytes_per_second = " << cfg_.rpcRudpBytesPerSecond << "\n"; + o << "rpc_rudp_burst_bytes = " << cfg_.rpcRudpBurstBytes << "\n\n"; + + o << "# File store / compute\n"; + o << "file_store_max_bytes = " << cfg_.cesFileStoreMaxBytes << "\n"; + if (!cfg_.cesFileStoreDir.empty()) + o << "file_store_dir = \"" << cfg_.cesFileStoreDir << "\"\n"; + o << "compute_max_instances = " << cfg_.computeMaxInstances << "\n"; + o << "compute_port_base = " << cfg_.computePortBase << "\n"; + o << "compute_port_count = " << cfg_.computePortCount << "\n"; + o << "compute_process_mem_max = " << cfg_.computeProcessMemMax << "\n"; + o << "compute_client_pool_size = " << cfg_.computeClientPoolSize << "\n"; + if (!cfg_.cesComputeUser.empty()) + o << "compute_user = \"" << cfg_.cesComputeUser << "\"\n"; + if (!cfg_.cesComputeWorkDir.empty()) + o << "compute_work_dir = \"" << cfg_.cesComputeWorkDir << "\"\n"; + if (!cfg_.cesComputeChildBinary.empty()) + o << "compute_child_binary = \"" << cfg_.cesComputeChildBinary << "\"\n"; + if (!cfg_.cesExtensionsDir.empty()) + o << "extensions_dir = \"" << cfg_.cesExtensionsDir << "\"\n"; + o << "ext_funding_per_day = " << extFundingPerDay() << "\n"; + o << "ext_local_budget = " << extLocalBudget() << "\n\n"; + + // Tables last (everything after a [table] header belongs to that table). + if (!cfg_.cesplexMounts.empty()) { + o << "[cesplex_mounts]\n"; + for (const auto& [proto, handler] : cfg_.cesplexMounts) + o << "\"" << proto << "\" = \"" << handler << "\"\n"; + o << "\n"; + } + + o << "[rpc_rudp]\n"; + o << "max_channels_per_peer = " << cfg_.rpcRudpMaxChannelsPerPeer << "\n"; + o << "max_reorder_bytes_per_channel = " + << cfg_.rpcRudpMaxReorderBytesPerChannel << "\n"; + o << "max_reorder_msgs_per_channel = " + << cfg_.rpcRudpMaxReorderMsgsPerChannel << "\n"; + o << "channel_idle_secs = " << cfg_.rpcRudpChannelIdleSecs << "\n\n"; + + o << "# The [extension] enabled set + [[peers]] are dynamic (dashboard /\n"; + o << "# peerdata.toml) and not captured here; peers reload from\n"; + o << "# peerdata.toml automatically. Re-add any [extension] = 1 you rely on.\n"; + + std::ofstream f(path, std::ios::trunc); + f << o.str(); + if (!f) { + std::string why = std::strerror(errno); + LOGWARNING << "config export: write failed" << SVAR(path) << SVAR(why); + if (errReason) *errReason = "write to " + path + " failed: " + why; + return ""; + } + LOGINFO << "config exported via admin" << SVAR(path); + return path; + } catch (std::exception& e) { + LOGWARNING << "config export failed" << SVAR(e.what()); + if (errReason) *errReason = e.what(); + return ""; + } +} + +void CesServer::_runAutoexecSync() { + std::promise done; + postLogic( [this, &done]() { + runAutoexec(); + done.set_value(); + }); + done.get_future().wait(); +} + +void CesServer::_drainLogic() { + if (!running_) return; + std::promise done; + postLogic( [&done]() { done.set_value(); }); + done.get_future().wait(); +} + +bool CesServer::_executeScheduledRunSync(const HashPrefix& callerPrefix, + const minx::Hash& assetId, + uint64_t budget, uint64_t allowance, + const ces::Bytes& input) { + std::promise done; + postLogic( [&]() { + ScheduledRun run; + run.callerPrefix = callerPrefix; + run.assetId = assetId; + run.budget = budget; + run.allowance = allowance; + run.input = input; + done.set_value(executeScheduledRun(run)); + }); + return done.get_future().get(); +} + +void CesServer::_primePresence(const HashPrefix& prefix, + const minx::SockAddr& addr) { + presence_.put(prefix, addr); + std::lock_guard lk(presenceReverseMutex_); + presenceReverse_[addr] = prefix; +} + +void CesServer::loadPeerData() { + auto path = (cfg_.dataDir / "peerdata.toml").string(); + try { + if (!std::filesystem::exists(path)) return; + auto tbl = toml::parse_file(path); + if (auto peers = tbl["peers"].as_array()) { + for (auto& p : *peers) { + if (auto t = p.as_table()) { + PeerEntry pe; + auto keyHex = (*t)["key"].value_or(std::string("")); + if (keyHex.empty()) continue; + minx::stringToHash(pe.ckey, keyHex); + pe.declaredAddress = (*t)["address"].value_or(std::string("")); + // resolvedIP is left empty; the peer miner fills it on first probe + // (off-strand). Keeps DNS off any path that could be hit eagerly. + pe.totalInboundPoW = static_cast( + (*t)["total_inbound_pow"].value_or(int64_t(0))); + pe.totalOutboundPoW = static_cast( + (*t)["total_outbound_pow"].value_or(int64_t(0))); + pe.ourBalanceThere = (*t)["our_balance"].value_or(int64_t(-1)); + pe.lastInboundTime = static_cast( + (*t)["last_inbound_time"].value_or(int64_t(0))); + pe.lastCheckTime = static_cast( + (*t)["last_check_time"].value_or(int64_t(0))); + pe.reachable = (*t)["reachable"].value_or(false); + pe.verified = (*t)["verified"].value_or(false); + pe.outbound = (*t)["outbound"].value_or(false); + pe.pingFailures = static_cast( + (*t)["ping_failures"].value_or(int64_t(0))); + peerTable_.push_back(pe); + } + } + } + LOGINFO << "loaded " << peerTable_.size() << " peer(s) from " << path; + } catch (std::exception& e) { + LOGWARNING << "failed to load peer data: " << e.what(); + } +} + +void CesServer::savePeerData() { + std::lock_guard lock(peerTableMutex_); + auto path = (cfg_.dataDir / "peerdata.toml").string(); + + // Never persist a banned peer: the ban is RAM-only (a restart clears all bans), + // and writing it would either leave junk in the file or reload it as a live peer. + const uint64_t nowSecs = minx::getSecsSinceEpoch(); + auto sorted = peerTable_; + sorted.erase(std::remove_if(sorted.begin(), sorted.end(), + [&](const PeerEntry& p) { return p.bannedUntil != 0 && nowSecs < p.bannedUntil; }), + sorted.end()); + // Sort by totalInboundPoW descending, take top 100 + std::sort(sorted.begin(), sorted.end(), [](const PeerEntry& a, const PeerEntry& b) { + return a.totalInboundPoW > b.totalInboundPoW; + }); + if (sorted.size() > maxPersistedPeers()) sorted.resize(maxPersistedPeers()); + + try { + std::ofstream f(path); + f << "# CES peer data (auto-generated, top peers by inbound PoW)\n\n"; + for (auto& pe : sorted) { + f << "[[peers]]\n"; + f << "key = \"" << minx::hashToString(pe.ckey) << "\"\n"; + f << "address = \"" << pe.declaredAddress << "\"\n"; + f << "total_inbound_pow = " << pe.totalInboundPoW << "\n"; + f << "total_outbound_pow = " << pe.totalOutboundPoW << "\n"; + f << "our_balance = " << pe.ourBalanceThere << "\n"; + f << "last_inbound_time = " << pe.lastInboundTime << "\n"; + f << "last_check_time = " << pe.lastCheckTime << "\n"; + f << "reachable = " << (pe.reachable ? "true" : "false") << "\n"; + f << "verified = " << (pe.verified ? "true" : "false") << "\n"; + f << "outbound = " << (pe.outbound ? "true" : "false") << "\n"; + f << "ping_failures = " << pe.pingFailures << "\n\n"; + } + LOGDEBUG << "saved " << sorted.size() << " peer(s) to " << path; + } catch (std::exception& e) { + LOGWARNING << "failed to save peer data: " << e.what(); + } +} + +// ============================================================================= +// Peer Miner (unified: outbound + inbound autopeering) +// ============================================================================= + +void CesServer::ensurePeerMinerStarted() { + // Hold the lifecycle lock across the running_ check + CAS + thread assign so + // it's atomic w.r.t. stop() flipping peerMinerRunning_ and deciding whether to + // join — a solution arriving at shutdown can't spawn a thread after stop() has + // already passed the join. + std::lock_guard lock(peerMinerLifecycleMutex_); + if (!running_) return; + // peerMinerRunning_ is both the loop's run flag and the one-shot spawn + // guard: only the thread that flips it false→true creates the miner. + bool expected = false; + if (peerMinerRunning_.compare_exchange_strong(expected, true)) + peerMinerThread_ = std::thread(&CesServer::peerMinerLoop, this); +} + +void CesServer::startPeerMiner() { + bool havePeers; + { std::lock_guard lock(peerTableMutex_); havePeers = !peerTable_.empty(); } + // Run the miner whenever there's something to do: a target to mine toward, + // OR peers whose reachability/verification we want kept fresh (the dashboard + // and settlement both want live peer state, even at target 0 — mining itself + // stays gated on the target inside the loop). + if (peerTarget_.load() == 0 && !havePeers) return; + LOGINFO << "starting peer miner thread" << VAR(peerTarget_.load()) + << VAR(havePeers); + ensurePeerMinerStarted(); +} + +void CesServer::peerMinerLoop() { + LOGINFO << "peer miner loop started"; + + auto serverPubKey = serverKeyPair_.getPublicKeyAsHash(); + auto serverMapKey = Account::getMapKey(serverPubKey); + + std::string myServerAddr = cfg_.serverName.empty() + ? ":" + std::to_string(boundPort_) + : cfg_.serverName; + + while (peerMinerRunning_) { + // A background maintenance thread must never take the process down: any + // error in a cycle is logged and the loop keeps going (next cycle retries). + try { + // Peer-table maintenance: evict dead peers, drop expired bans, decay grief. + { + std::lock_guard lock(peerTableMutex_); + const uint64_t nowSecs = minx::getSecsSinceEpoch(); + // A grief ban that has expired removes the peer outright: it re-enters fresh + // (grief 0) if it returns and is worth re-discovering, otherwise it is gone. + // Combined with the ping-failure eviction in one pass. + auto it = std::remove_if(peerTable_.begin(), peerTable_.end(), + [&](const PeerEntry& p) { + if (p.pingFailures >= PEER_EVICTION_THRESHOLD) return true; + if (p.bannedUntil != 0 && nowSecs >= p.bannedUntil) return true; + return false; + }); + if (it != peerTable_.end()) { + LOGINFO << "peer miner: removing " << std::distance(it, peerTable_.end()) + << " dead/ban-expired peer(s)"; + peerTable_.erase(it, peerTable_.end()); + } + // Decay sub-threshold grief on the survivors. Never decay a banned peer (it is + // serving its ban); only decay a peer that has grief and is not banned. + for (auto& p : peerTable_) { + if (p.grief == 0 || p.bannedUntil != 0) continue; + if (p.lastDecayTime == 0) { p.lastDecayTime = nowSecs; continue; } + if (nowSecs <= p.lastDecayTime) continue; + uint64_t steps = (nowSecs - p.lastDecayTime) / PEER_GRIEF_DECAY_SECS; + if (steps == 0) continue; + p.grief = (steps >= p.grief) ? 0 : (p.grief - static_cast(steps)); + p.lastDecayTime += steps * PEER_GRIEF_DECAY_SECS; + } + } + + // Snapshot top 100 peers by totalInboundPoW for this round + std::vector candidates; + { + std::lock_guard lock(peerTableMutex_); + candidates = peerTable_; + } + std::sort(candidates.begin(), candidates.end(), + [](const PeerEntry& a, const PeerEntry& b) { + return a.totalInboundPoW > b.totalInboundPoW; + }); + if (candidates.size() > maxPersistedPeers()) candidates.resize(maxPersistedPeers()); + + // Two disjoint mining modes, selected by peer.outbound: + // outbound (trusted): maintain a CREDIT LEVEL — target = peerTarget, + // progress = our queried reserve there (ourBalanceThere). + // inbound (untrusted): pure PoW reciprocation — target = H_in * bps / + // 10000, progress = our self-counted lifetime PoW on them + // (totalOutboundPoW). Never trusts a remote balance reading. bps = + // peerPowInboundReciprocationBps (0 = off); for bps <= 10000 our total + // mined stays <= their proven work, for any number of hosts. + const uint64_t inboundBps = cfg_.peerPowInboundReciprocationBps; + auto mineTarget = [&](const PeerEntry& p) -> int64_t { + if (p.outbound) return static_cast(peerTarget_.load()); + if (inboundBps == 0) return 0; + // H_in * bps / 10000, split so it stays within uint64 for realistic H_in. + const uint64_t hin = p.totalInboundPoW; + return static_cast((hin / 10000) * inboundBps + + (hin % 10000) * inboundBps / 10000); + }; + auto mineProgress = [](const PeerEntry& p) -> int64_t { + // Outbound peering needs BOTH a credit reserve and a PoW stake at target; + // mine while either lags. Using min() means funding (peerfunder, a peer's + // bonus) can fill the reserve but cannot stand in for our PoW: a funded + // reserve with zero PoW still mines until the PoW reaches target. + return p.outbound + ? std::min(p.ourBalanceThere, static_cast(p.totalOutboundPoW)) + : static_cast(p.totalOutboundPoW); + }; + + // Merge probe + mining results for every candidate we touched this round + // back into peerTable_. `lastCheckTime != 0` means the probe loop actually + // ran on this candidate (either success or failure path updates it). + // Called twice per cycle: once right after probing so reachability is + // visible to cross-transfers immediately, and once after mining to persist + // the updated reserve/PoW totals. The post-probe call matters because + // mining a single peer can run RandomX for a long time; without it a peer + // that needs mining holds its fresh reachability hostage to that mining, + // and cross-transfers fail CES_ERROR_UNKNOWN_PEER for the whole duration. + auto mergeCandidates = [&]() { + std::lock_guard lock(peerTableMutex_); + for (const auto& c : candidates) { + if (c.lastCheckTime == 0) continue; + for (auto& p : peerTable_) { + if (p.ckey == c.ckey) { + p.ourBalanceThere = c.ourBalanceThere; + p.totalOutboundPoW = c.totalOutboundPoW; + p.reachable = c.reachable; + p.verified = c.verified; + p.pingFailures = c.pingFailures; + p.rpcPort = c.rpcPort; + p.lastCheckTime = c.lastCheckTime; + p.resolvedIP = c.resolvedIP; + p.resolvedEndpoint = c.resolvedEndpoint; + p.resolvedEndpointValid = c.resolvedEndpointValid; + break; + } + } + } + }; + + // Pick the neediest reachable peer: largest positive gap (target - progress). + int bestIdx = -1; + int64_t bestGap = 0; + + for (int i = 0; i < static_cast(candidates.size()); ++i) { + auto& peer = candidates[i]; + if (peer.declaredAddress.empty()) continue; + + // Always probe every peer for reachability — a free connect + handshake + // + unsigned balance query, independent of whether we'll mine it. Mining + // is gated separately below on the credit target, so a peer with target 0 + // still reports accurate reachability/verification (e.g. to the + // dashboard). Reachability TRANSITIONS log at INFO (a lifecycle event); + // steady-state re-checks stay at DEBUG so a 60 s probe loop isn't spam. + bool wasReachable = peer.reachable; + bool everChecked = (peer.lastCheckTime != 0); + + // Try to connect and query balance (this IS the verification) + try { + auto ep = Resolver::resolveUdp(peer.declaredAddress); + // Populate resolvedIP off-strand (here on the miner thread) — this is + // the single source for it, feeding isConnected's peer-IP trust check. + peer.resolvedIP = ep.address(); + // And the full endpoint, so settlement dispatch never has to resolve + // DNS on the logic strand (see getOrCreateSettlementClient). + peer.resolvedEndpoint = ep; + peer.resolvedEndpointValid = true; + CesClient client(ep, false); + client.setKey(serverKeyPair_); // to sign + pay for the verification query + client.start(0); + if (!client.connect()) { + peer.reachable = false; + peer.pingFailures++; + peer.lastCheckTime = minx::getSecsSinceEpoch(); + if (wasReachable || !everChecked) + LOGINFO << "peer miner: unreachable " << peer.declaredAddress + << " failures=" << peer.pingFailures; + else + LOGDEBUG << "peer miner: unreachable " << peer.declaredAddress + << " failures=" << peer.pingFailures; + client.stop(); + continue; + } + + // The connect/getInfo above is the FREE liveness probe — reachability, + // never trust. `verified` is deliberately NOT set from the key the peer + // merely CLAIMS in getInfo (a lying host can echo any pubkey); it flips + // true only via the signed server-info below, and stays sticky after. + peer.reachable = true; + // The handshake already carries the peer's rpc port; capture it so the + // peer table (and ces.peers()) expose where to reach the peer's CesPlex + // handlers, no separate ces.ping needed on the discovery hot path. + peer.rpcPort = client.getServerRpcPort(); + peer.pingFailures = 0; + peer.lastCheckTime = minx::getSecsSinceEpoch(); + + // Our reserve at the peer — the mining-progress reading AND the + // affordability gate for the paid verification below. + int64_t balance = 0; + uint32_t nonce = 0; + uint8_t rc = client.queryAccount(serverMapKey, balance, nonce); + peer.ourBalanceThere = (rc == CES_OK) ? balance : 0; + + // Verification toll, paid once. A peer becomes `verified` only after a + // SIGNED, paid server-info whose reply authenticates against its key — + // proof the box actually holds the private half (an impostor that merely + // echoes the pubkey in getInfo can't sign the reply). Fire it only while + // still unverified, the claimed key matches what we expect, and we hold + // enough at the peer to cover the query fee; otherwise the free getInfo + // was liveness only. Once verified it is sticky — and upsertPeer won't let + // an unsigned inbound PoW move a verified address. So an inbound PoW can + // never bind a key to an address we didn't go confirm and pay to prove. + if (!peer.verified && + client.getServerKey() == peer.ckey && + peer.ourBalanceThere >= + static_cast(std::max(cfg_.feeQuery, 1))) { + std::vector entries; + if (client.queryServerInfo(entries) == CES_OK) { + peer.verified = true; + LOGINFO << "peer miner: verified " << peer.declaredAddress + << " via signed server-info"; + } + } + + if (!wasReachable || !everChecked) { + LOGINFO << "peer miner: reachable " << peer.declaredAddress + << " verified=" << peer.verified; + } + + client.disconnect(); + client.stop(); + + } catch (std::exception& e) { + peer.reachable = false; + peer.pingFailures++; + peer.lastCheckTime = minx::getSecsSinceEpoch(); + if (wasReachable || !everChecked) + LOGINFO << "peer miner: error checking " << peer.declaredAddress + << " " << e.what(); + else + LOGDEBUG << "peer miner: error checking " << peer.declaredAddress + << " " << e.what() << " failures=" << peer.pingFailures; + continue; + } + + // Outbound needs a valid reserve reading; inbound is self-measured. + if (peer.outbound && peer.ourBalanceThere < 0) continue; + int64_t gap = mineTarget(peer) - mineProgress(peer); + if (gap > bestGap) { + bestGap = gap; + bestIdx = i; + } + } + + // Make reachability visible before the (possibly long) mining step below. + mergeCandidates(); + + if (bestIdx >= 0) { + auto& peer = candidates[bestIdx]; + LOGDEBUG << "peer miner: mining on " << peer.declaredAddress + << " (" << (peer.outbound ? "outbound R=" : "inbound Hout=") + << mineProgress(peer) << " target=" << mineTarget(peer) << ")"; + + try { + auto ep = Resolver::resolveUdp(peer.declaredAddress); + // The peer miner ALWAYS uses the full RandomX dataset. + // + // TOMBSTONE — do NOT "upgrade" this to a cache-only / light-mode option + // (a previous one was deliberately removed). It buys nothing: the miner + // holds exactly ONE dataset at a time (it mines a single peer per cycle), + // so there is no memory to save — while cache mode cripples the hash rate + // so badly that reaching any real credit target crawls. If you want cheap + // PoW for simulations or tests, MOCK the proofs; never run real mining in + // cache mode. Full dataset, always. + CesClient client(ep, /*useFullDataset=*/true); + client.setKey(serverKeyPair_); + client.start(0); + + if (client.connect()) { + // Skip peers with unreasonable difficulty + uint8_t peerDiff = client.getMinDifficulty(); + uint8_t maxDiff = std::max( + cfg_.minDiff + PEER_MINER_DIFF_MARGIN, PEER_MINER_DIFF_MAX); + if (peerDiff > maxDiff) { + LOGDEBUG << "peer miner: skipping " << peer.declaredAddress + << " (diff " << (int)peerDiff << " > max " << (int)maxDiff << ")"; + } else { + std::map appData; + appData["server"] = myServerAddr; + + // Smart difficulty: scale UP from the peer's floor toward the + // remaining credit gap, so a large target is reached in a few big + // solutions instead of thousands of tiny ones. The hash cost per + // credit is constant regardless of difficulty, so only the + // per-solution network/cycle overhead differs — fewer, bigger + // solutions amortize it. mint(D) = 2^(D-1) * POW_REWARD_BASE; grow D + // while the next solution's mint still fits the gap. Floor = peerDiff + // (never below: a fresh reserve account needs mint(D) >= the peer's + // feeAccount, which a correctly configured peer keeps below + // mint(peerDiff); and a missing account makes gap == full target, so + // the floor is moot — the gap forces a high difficulty anyway). Cap + // RELATIVE to peerDiff (PEER_MINER_MAX_DIFF_ABOVE) so one solution's + // work stays a small multiple of what this peer expects — the cycle + // returns quickly and the miner round-robins quotas across all peers + // instead of locking for hours on one oversized solution. + int64_t gap = mineTarget(peer) - mineProgress(peer); + uint8_t mineCap = peerDiff + PEER_MINER_MAX_DIFF_ABOVE; + uint8_t mineDiff = peerDiff; + while (mineDiff < mineCap && + (uint64_t(1) << mineDiff) * POW_REWARD_BASE <= + static_cast(std::max(gap, 0))) + ++mineDiff; + LOGDEBUG << "peer miner: mining " << peer.declaredAddress + << " at difficulty " << static_cast(mineDiff) + << " (peerMin=" << static_cast(peerDiff) + << " gap=" << gap << ")"; + + // Mark "actively mining" only around the real PoW work, so the + // dashboard can distinguish hashing from a merely-looping thread. + const uint64_t expectedHashes = + mineDiff < 64 ? (uint64_t{1} << mineDiff) : UINT64_MAX; + uint64_t chunkIters = expectedHashes / 64; + if (chunkIters < 256) chunkIters = 256; + if (chunkIters > (1u << 20)) chunkIters = 1u << 20; + { + std::lock_guard lk(peerMinerActivityMutex_); + peerMinerMining_ = true; + peerMinerMiningPeer_ = peer.declaredAddress; + peerMinerMiningDiff_ = mineDiff; + peerMinerMiningStartSecs_ = minx::getSecsSinceEpoch(); + peerMinerHashesTried_ = 0; + peerMinerExpectedHashes_ = expectedHashes; + } + auto mineT0 = std::chrono::steady_clock::now(); + // Chunked search so the dashboard sees live hashes-tried during a + // long solve (a single solve at high difficulty can take minutes). + auto result = mineOnce( + client, mineDiff - peerDiff, appData, 1, nullptr, + [this](uint64_t tried) { + std::lock_guard lk(peerMinerActivityMutex_); + peerMinerHashesTried_ = tried; + }, + chunkIters); + double mineSecs = std::chrono::duration( + std::chrono::steady_clock::now() - mineT0).count(); + { + std::lock_guard lk(peerMinerActivityMutex_); + peerMinerMining_ = false; + // Smoothed hash-rate estimate for the dashboard ETA: a solution at + // difficulty D takes ~2^D hashes in expectation, so rate ≈ 2^D / + // solve_time. EMA so one unlucky/lucky solve doesn't whipsaw it. + if (result.success && mineSecs > 0.0 && mineDiff < 64) { + double rate = std::ldexp(1.0, mineDiff) / mineSecs; // 2^D / secs + peerMinerHashRate_ = peerMinerHashRate_ > 0.0 + ? 0.5 * peerMinerHashRate_ + 0.5 * rate + : rate; + } + peerMinerHashesTried_ = 0; + peerMinerExpectedHashes_ = 0; + } + if (result.success) { + LOGDEBUG << "peer miner: mined " << result.credit + << " credits on " << peer.declaredAddress; + peer.totalOutboundPoW += result.credit; + if (peer.ourBalanceThere >= 0) + peer.ourBalanceThere += static_cast(result.credit); + } else { + LOGDEBUG << "peer miner: mine failed on " << peer.declaredAddress + << " status=" << result.status; + } + } + client.disconnect(); + } + client.stop(); + } catch (std::exception& e) { + { // mineOnce may have thrown mid-hash — don't leave "mining" stuck on. + std::lock_guard lk(peerMinerActivityMutex_); + peerMinerMining_ = false; + } + LOGDEBUG << "peer miner: mining error " << peer.declaredAddress + << " " << e.what(); + } + } else { + LOGTRACE << "peer miner: all peers at target or unreachable"; + } + + // Persist the post-mining reserve/PoW totals (reachability was already + // merged right after the probe loop above). + mergeCandidates(); + savePeerData(); + + // Heartbeat — a visible "the thread is alive and just cycled" signal for + // the dashboard, so peering isn't an opaque background process. + lastPeerMinerCycle_.store(minx::getSecsSinceEpoch(), + std::memory_order_relaxed); + peerMinerCycles_.fetch_add(1, std::memory_order_relaxed); + } catch (const std::exception& e) { + LOGWARNING << "peer miner: cycle error (continuing): " << e.what(); + } catch (...) { + LOGWARNING << "peer miner: unknown cycle error (continuing)"; + } + + // Sleep between cycles + for (int i = 0; i < cfg_.peerMinerIntervalSecs && peerMinerRunning_; ++i) + ces::sleep(1000); + } + + savePeerData(); + LOGINFO << "peer miner loop stopped"; +} + +// --- Cron (scheduled runAsset) --- + +void CesServer::cronStartTimer() { + cronTimer_ = std::make_shared(taskIO_); + cronTimer_->expires_after( + std::chrono::milliseconds(CesConfig::CRON_TICK_INTERVAL_MS)); + auto timer = cronTimer_; + cronTimer_->async_wait( + boost::asio::bind_executor(logicStrand_, + [this, timer](const boost::system::error_code& ec) { + auto t0 = std::chrono::steady_clock::now(); + cronTick(ec); + auto dt = std::chrono::steady_clock::now() - t0; + l1cpuGauge_.record(static_cast( + std::chrono::duration_cast(dt).count())); + })); +} + + +void CesServer::cronTick(const boost::system::error_code& ec) { + if (ec || !running_) return; + + uint64_t now = getMicrosSinceEpoch(); + uint64_t deadline = now + CesConfig::CRON_TICK_DEADLINE_MS * 1000; + + while (!scheduledRuns_.empty()) { + auto it = scheduledRuns_.begin(); + if (it->first.timeUs > now) break; // not expired yet + + LOGTRACE << "cronTick: executing entry" + << VAR(it->first.timeUs) << VAR(now); + auto run = std::move(it->second); + scheduledRuns_.erase(it); + // Swallow any exception that escapes executeScheduledRun (host + // lambda throwing, logkv serialization error, std::bad_alloc, etc.). + // The scheduledRuns_ entry has already been erased above, so the + // cron job is gone either way. No logging: a misbehaving program + // shouldn't get to spam the server's logs as it fails out. + try { + executeScheduledRun(run); + } catch (...) { + } + + // Check deadline + if (getMicrosSinceEpoch() > deadline) break; + } + + // Reschedule + cronTimer_->expires_after( + std::chrono::milliseconds(CesConfig::CRON_TICK_INTERVAL_MS)); + auto timer = cronTimer_; + cronTimer_->async_wait( + boost::asio::bind_executor(logicStrand_, + [this, timer](const boost::system::error_code& ec) { + auto t0 = std::chrono::steady_clock::now(); + cronTick(ec); + auto dt = std::chrono::steady_clock::now() - t0; + l1cpuGauge_.record(static_cast( + std::chrono::duration_cast(dt).count())); + })); +} + +bool CesServer::executeScheduledRun(ScheduledRun& run) { + // This runs on the logic strand — same as a normal runAsset handler + // but without network, without signature, without nonce. + // The caller is the account that scheduled it. + + auto origin = accounts_.get(run.callerPrefix); + if (!origin.exists()) + return false; + + // Caller's full key (reconstructed from prefix + tail) for the VM io + // preload — captured before the gas debit, which can delete a just-emptied + // account and invalidate the accessor. + minx::Hash callerKey = origin.data().getKey(run.callerPrefix); + + // Reserve the full budget upfront; unused is refunded inside executeVmRun. + // A prepaid run (future-time wire CES_RUN_ASSET) was already debited at + // submission — don't double-charge it. + if (!run.prepaid) { + if (origin.balance() < static_cast(run.budget)) + return false; + origin.debit(run.budget); + accounts_.checkFlush(run.budget); + } + + // Load program: an asset (aliasId == 0) or an alias's inline code area. + VmRunRequest vreq; + if (run.aliasId != 0) { + auto al = aliases_.get(run.aliasId); + // The cell must still exist and still opt in as a public program at fire + // time (the owner may have repurposed or dropped it since queue time). + if (!al.exists() || al.getOp() != ALIAS_OP_INLINE_PROGRAM) + return true; // program gone but account OK + const AliasData& sc = al.data().getContent(); + vreq.selfAssetKey = minx::Hash{}; // no boot asset + vreq.programOwnerPrefix = al.getOwner(); // consented: the cell's owner + vreq.code = ces::Bytes(sc.data(), sc.data() + ALIAS_INLINE_CODE_BYTES); + vreq.invokeKind = INVOKE_SCHEDULED_ALIAS; + } else { + auto programAsset = assets_.get(run.assetId); + if (!programAsset.exists()) return true; // program gone but account OK + const AssetData& programContent = programAsset.data().getContent(); + vreq.selfAssetKey = run.assetId; + vreq.programOwnerPrefix = programAsset.data().getOwnerId(); + vreq.code = ces::Bytes(programContent.begin(), programContent.end()); + // Scheduled / autoexec / RPC-followup runs all fire through this path; + // they self-describe as SCHEDULED (autoexec, a boot-time scheduled run, + // shares it). A distinct AUTOEXEC kind would need a flag on the run + // record. A SYS_L2_CALL resolution followup carries INVOKE_L2_RETURN. + vreq.invokeKind = run.invokeKind; + } + + // Same transactional core as the wire path: a scheduled run is now atomic + // (undo-log rollback, durable re-journal, deferred cross-transfer) instead + // of mutating directly. The raw (undiscounted) gas multiplier preserves the + // prepaid budget contract locked in at schedule time; wire CES_RUN_ASSET + // discounts via FeeKind::VMMult because the caller spends a fresh budget. + vreq.callerPrefix = run.callerPrefix; + vreq.callerKey = callerKey; + vreq.input = run.input; + vreq.budget = run.budget; + vreq.allowance = run.allowance; + vreq.gasMult = cfg_.feeVmMult; + vreq.enableVerifySig = false; + executeVmRun(vreq); + + tpsInc(); + return true; +} + +uint8_t CesServer::scheduleRun(const HashPrefix& callerPrefix, + const minx::Hash& assetId, + uint64_t budget, + uint64_t allowance, + const ces::Bytes& input, + uint64_t time_us, bool prepaid, + uint32_t aliasId, + uint64_t invokeKind) { + ScheduleKey key; + return scheduleRunUndoable(callerPrefix, assetId, budget, allowance, input, + time_us, prepaid, key, aliasId, invokeKind); +} + +uint8_t CesServer::scheduleRunUndoable(const HashPrefix& callerPrefix, + const minx::Hash& assetId, + uint64_t budget, uint64_t allowance, + const ces::Bytes& input, + uint64_t time_us, bool prepaid, + ScheduleKey& outKey, + uint32_t aliasId, + uint64_t invokeKind) { + if (scheduledRuns_.size() >= cfg_.maxScheduledEntries) + return CES_ERROR_QUEUE_FULL; + if (time_us == 0) time_us = 1; // "next tick" + outKey = ScheduleKey{time_us, scheduledSeq_++}; + LOGTRACE << "scheduleRun" << VAR(time_us) << VAR(budget) << VAR(allowance) + << VAR(aliasId) << VAR(scheduledRuns_.size()); + scheduledRuns_.emplace( + outKey, + ScheduledRun{callerPrefix, assetId, budget, allowance, input, prepaid, + aliasId, invokeKind}); + return CES_OK; +} + +// =========================================================================== +// SYS_RPC dispatcher trio — queueRpc / executeRpc / completeRpc +// =========================================================================== +// +// Three-stage flow, each stage on a specific thread: +// +// 1. queueRpc — LOGIC STRAND +// Validate destination + file auth, walk the chain to materialize +// the raw request bytes, post to rpcTaskIO_. No per-op envelope — +// the signed bind contract authenticates the channel once at open, +// and the body flows raw on it. +// +// 2. executeRpc — rpcTaskIO_ THREAD +// Resolve (host, port) to a SockAddr (numeric IPs only for MVP), +// allocate a channel_id, construct an RpcSession bound to a +// RudpStream, register in rpcSessions_, kick off the write. +// +// 3. completeRpc — LOGIC STRAND +// Write response bytes into the same file chain, update +// header.fileSize, schedule the followup VM program. +// +// The RpcSession class lives near the top of this file (above +// CesServer::CesServer) because start()'s Rudp receive callback demuxes +// to shared_ptr and needs the complete type at the point +// where the lambda is defined. + +// --------------------------------------------------------------------------- +// queueRpc (logic strand) +// --------------------------------------------------------------------------- + +uint8_t CesServer::queueRpc(PendingRpc pending) { + LOGTRACE << "queueRpc enter" << SVAR(pending.host) << VAR(pending.port); + + if (!rpcRudp_ || !rpcMinx_ || + (cfg_.rpcPort == 0 && !cfg_.rpcAutoPort)) { + LOGDEBUG << "queueRpc: rpc port not configured on this server"; + return CES_ERROR_DISABLED; + } + if (pending.host.empty() || pending.host.size() > 255) { + return CES_ERROR_INTERNAL; + } + if (pending.port == 0) { + return CES_ERROR_INTERNAL; + } + + // Backpressure: bounce before touching the asset store or allocating + // any request bytes. rpcPendingCount_ is incremented after we pass + // this check and decremented in completeRpc. + if (rpcPendingCount_.load() >= cfg_.rpcMaxPending) { + LOGDEBUG << "queueRpc: queue full" + << VAR(rpcPendingCount_.load()) << VAR(cfg_.rpcMaxPending); + return CES_ERROR_QUEUE_FULL; + } + + // Validate the request file: must exist and be writable by the + // caller. (Readable → writable for our purposes; the completion + // path writes the response into this same file.) + auto headIt = assets_->find(pending.fileHeadKey); + if (headIt == assets_->end()) return CES_ERROR_ASSET_NOT_FOUND; + uint8_t authRc = checkAssetWriteAuth( + headIt->second, pending.callerPrefix, pending.programOwnerPrefix, + pending.selfAssetKey); + if (authRc != CES_OK) return authRc; + + // Walk the chain and materialize the request bytes. The cap is + // enforced inside readFileChunkBytes BEFORE the destination vector + // reserves, so a hostile fileSize in the head can't drive a huge + // allocation. No per-rpc envelope — the body flows raw on the bound + // channel; the signed bind contract authenticates the sender (this + // server) once at channel open. + uint8_t readRc = readFileChunkBytes(*assets_, pending.fileHeadKey, + pending.requestBody, + cfg_.rpcMaxRequestBytes); + if (readRc != CES_OK) return readRc; + + // Post to rpcTaskIO_. rpcRudp_ access must happen on that thread. + rpcPendingCount_.fetch_add(1); + auto shared = std::make_shared(std::move(pending)); + boost::asio::post(rpcTaskIO_, + [this, shared]() { executeRpc(shared); }); + return CES_OK; +} + +// --------------------------------------------------------------------------- +// executeRpc (rpcTaskIO_ thread) +// --------------------------------------------------------------------------- + +void CesServer::executeRpc(std::shared_ptr pending) { + LOGTRACE << "executeRpc enter" << SVAR(pending->host) + << VAR(pending->port); + + if (!rpcRudp_) { + postLogic( + [this, pending]() { completeRpc(pending, CES_ERROR_INTERNAL, {}); }); + return; + } + + // Resolve host. For MVP we only support numeric IPs (address_v4 / + // address_v6 from a plain string). DNS resolution can be added as + // a follow-up if a real use case demands it. + boost::system::error_code resolveEc; + auto addr = Resolver::parseIp(pending->host, resolveEc); + if (resolveEc) { + LOGDEBUG << "executeRpc: host is not a numeric IP" + << SVAR(pending->host); + postLogic( + [this, pending]() { completeRpc(pending, CES_ERROR_INTERNAL, {}); }); + return; + } + minx::SockAddr peer(addr, pending->port); + // Channel ID from the OS CSPRNG. rpcTaskIO_ is a single thread, so a + // function-local random_device is fine. A collision within a live peer + // session map is negligibly unlikely and not a correctness issue: the + // inserted session would replace the older entry, which is acceptable here. + std::random_device rd; + const uint32_t channelId = static_cast(rd()); + + auto self = this; + auto session = std::make_shared( + rpcTaskIO_, *rpcRudp_, peer, channelId, + std::move(pending->requestBody), + cfg_.rpcResponseTimeoutMs, cfg_.rpcMaxResponseBytes, + channelMeter_.get(), serverKeyPair_, + [self, pending, peer, channelId] + (uint8_t rc, ces::Bytes body) { + // Runs on rpcTaskIO_'s thread. Unregister the session from the + // demux map, then hop to the logic strand to apply the response + // to the file and schedule the followup. + self->rpcSessions_.erase(std::make_pair(peer, channelId)); + boost::asio::post(self->logicStrand_, + [self, pending, rc, body = std::move(body)]() mutable { + self->completeRpc(pending, rc, std::move(body)); + }); + }); + + rpcSessions_[std::make_pair(peer, channelId)] = session; + session->start(); +} + +// --------------------------------------------------------------------------- +// completeRpc (logic strand) +// --------------------------------------------------------------------------- + +void CesServer::completeRpc(std::shared_ptr pending, + uint8_t errorCode, + ces::Bytes responseBody) { + LOGTRACE << "completeRpc enter" << VAR(int(errorCode)) + << VAR(responseBody.size()); + + rpcPendingCount_.fetch_sub(1); + + uint32_t status = (errorCode == CES_OK) ? 0u : uint32_t(errorCode); + const uint32_t wireBodyLen = static_cast(responseBody.size()); + uint32_t bytesWritten = 0; + + if (status == 0) { + auto headIt = assets_->find(pending->fileHeadKey); + if (headIt == assets_->end()) { + status = CES_ERROR_ASSET_NOT_FOUND; + } else { + uint8_t authRc = checkAssetWriteAuth( + headIt->second, pending->callerPrefix, + pending->programOwnerPrefix, pending->selfAssetKey); + if (authRc != CES_OK) { + status = authRc; + } else { + AssetData headContent = headIt->second.getContent(); + RamfileHeader header = parseRamfileHeader(headContent); + if (!header.valid) { + status = CES_ERROR_INTERNAL; + } else { + // Walk the chain, writing the response bytes into each + // chunk. Stops at end of response, end of chain, or the + // first auth/query failure. Preserves each chunk's next- + // pointer so the chain topology doesn't change. + minx::Hash nextKey = header.firstChunk; + while (bytesWritten < wireBodyLen) { + bool zero = true; + for (auto b : nextKey) if (b) { zero = false; break; } + if (zero) break; + auto chunkIt = assets_->find(nextKey); + if (chunkIt == assets_->end()) break; + if (checkAssetWriteAuth( + chunkIt->second, pending->callerPrefix, + pending->programOwnerPrefix, + pending->selfAssetKey) != CES_OK) break; + + AssetData chunkContent = chunkIt->second.getContent(); + minx::Hash chunkNext; + std::memcpy(chunkNext.data(), + &chunkContent[RAMFILE_NEXT_OFFSET], + RAMFILE_NEXT_SIZE); + + size_t take = std::min( + wireBodyLen - bytesWritten, + static_cast(RAMFILE_CHUNK_DATA_SIZE)); + AssetData newChunk{}; + std::memcpy(newChunk.data(), + responseBody.data() + bytesWritten, take); + std::memcpy(&newChunk[RAMFILE_NEXT_OFFSET], + chunkNext.data(), RAMFILE_NEXT_SIZE); + chunkIt->second.setContent(newChunk); + + bytesWritten += static_cast(take); + nextKey = chunkNext; + } + + // Rewrite the head with the new declared size = bytesWritten. + // Zero the content hash (dirty) and bump mtime. + const uint64_t now = getMicrosSinceEpoch(); + headIt->second.setContent(buildRamfileHeader( + bytesWritten, minx::Hash{}, + header.createdTime, now, + header.metadata.data(), RAMFILE_HEAD_META_SIZE, + header.firstChunk)); + } + } + } + } + + // Followup input: [u32 tag][u32 status][u32 wire_body_len] + // [u32 bytes_written][32 file_head_key] + // Integers go into VM cells in little-endian (matches the cell layout + // the VM reads when accessing input). + ces::Bytes input(48, 0); + ces::Buffer::pokeLE(input.data() + 0, pending->followupInputTag); + ces::Buffer::pokeLE(input.data() + 4, status); + ces::Buffer::pokeLE(input.data() + 8, wireBodyLen); + ces::Buffer::pokeLE(input.data() + 12, bytesWritten); + std::memcpy(input.data() + 16, pending->fileHeadKey.data(), 32); + + if (scheduleRun(pending->callerPrefix, pending->followupProgramKey, + pending->followupBudget, pending->followupAllowance, + input, 0) != CES_OK) { + LOGDEBUG << "completeRpc: failed to schedule followup (queue full)" + << SVAR(pending->followupProgramKey); + } + + if (_rpcCompletionObserver) + _rpcCompletionObserver(static_cast(status)); +} + +// --------------------------------------------------------------------------- +// SYS_L2_CALL — drainL2Call (rpcTaskIO_) + completeL2Call (logic strand) +// --------------------------------------------------------------------------- + +void CesServer::drainL2Call(std::shared_ptr pending) { + L2CallRequest req; + req.callId = pending->callId; + req.payer = pending->payerKey; + req.value = pending->value; + req.blob = pending->blob; + // The handler either refuses synchronously (returns non-OK => refund now) + // or accepts (CES_OK) and reports the delivery outcome later via this + // callback, which hops to the logic strand and settles exactly once. + auto self = pending; + L2CallReport report = + [this, self](uint64_t /*callId*/, L2CallOutcome outcome, + const minx::Hash& payee, const ces::Bytes& reply) { + auto rep = std::make_shared(reply); + boost::asio::post(logicStrand_, + [this, self, outcome, payee, rep]() { + completeL2Call(self, outcome, payee, *rep); + }); + }; + uint8_t rc = pending->handler->cesplexL2Call(req, report); + if (rc != CES_OK) { + // Synchronous refuse (e.g. the target instance is gone): refund the payer. + // Keep the concrete code so a channel caller sees why, not the generic + // outcome mapping. + pending->syncRefuseRc = rc; + boost::asio::post(logicStrand_, + [this, pending]() { + completeL2Call(pending, L2CallOutcome::NoHandler, minx::Hash{}, + ces::Bytes{}); + }); + } +} + +void CesServer::completeL2Call(std::shared_ptr pending, + L2CallOutcome outcome, + const minx::Hash& payee, + const ces::Bytes& reply) { + if (pending->resolved) return; // idempotent: ignore duplicate reports + pending->resolved = true; + l2PendingCount_.fetch_sub(1); + + // Settle from the self-account (holds the burned value; bottomless). Zero + // fees so exactly `value` passes through. Delivered pays the payee; a + // failure (NoHandler / Timeout) refunds the payer. The payee is never drawn + // from: a payment, once delivered, is final. A Delivered report with no + // payee is treated as a refund (handler-contract violation, fail safe). + minx::Hash selfKey = serverKeyPair_.getPublicKeyAsHash(); + bool payeeZero = true; + for (auto b : payee) if (b) { payeeZero = false; break; } + bool delivered = (outcome == L2CallOutcome::Delivered && !payeeZero); + minx::Hash target = delivered ? payee : pending->payerKey; + int64_t outBal = 0; + uint8_t rc = transfer(selfKey, target, pending->value, + TransferMode::Open, 0, CES_NONCELESS, outBal, + /*txFee=*/0, /*rentFee=*/0, /*errFee=*/0); + if (rc != CES_OK) { + LOGDEBUG << "completeL2Call: settle transfer failed" + << VAR(int(rc)) << VAR(int(outcome)) << VAR(pending->value); + } + + // Reply sink. A client CALL verb (replyCtx set) responds on its held request; + // a VM caller resolves through the INVOKE_L2_RETURN followup run. Exactly one. + if (pending->replyCtx) { + auto ctx = pending->replyCtx; + // Failure mapping for the channel caller, one code per cause: a + // synchronous handler refusal carries its concrete code (e.g. + // COMPUTE_INSTANCE_NOT_FOUND when the instance died mid-flight); + // NoHandler = the program defines no on_l2call (UNSUPPORTED); a call + // never delivered before the deadline = TIMEOUT. + uint8_t errRc = + pending->syncRefuseRc != 0 + ? pending->syncRefuseRc + : static_cast(outcome == L2CallOutcome::Timeout + ? CES_ERROR_TIMEOUT + : CES_ERROR_UNSUPPORTED); + // The reply rides RUDP: capped at the channel ceiling, not packet-bounded. + ces::Bytes rep(reply); + if (rep.size() > CES_L2_CALL_MAX_REPLY) rep.resize(CES_L2_CALL_MAX_REPLY); + boost::asio::post(rpcTaskIO_, + [ctx, delivered, errRc, rep = std::move(rep)]() mutable { + if (delivered) { + // Response framing: [u32 len][32 sha256(reply)] preamble + `len` + // reply bytes as body. The response sig covers only the preamble, + // so the digest is what makes the body tamper-evident (same shape + // as file READ's range hash). + ces::Bytes pre; + ces::Buffer::put(pre, static_cast(rep.size())); + minx::Hash h = sha256(rep.data(), rep.size()); + pre.insert(pre.end(), h.begin(), h.end()); + ctx->respond(CES_OK, std::move(pre), std::move(rep)); + } else { + ctx->error(errRc); + } + }); + return; + } + + // Followup run (INVOKE_L2_RETURN): notify the caller of the resolution. Fires + // as the payer, next tick, input io[INPUT]=tag, io[INPUT+1]=outcome (0 + // delivered, 1 no-handler, 2 timeout), io[INPUT+2..]=reply truncated to the + // VM's input window. Skipped when no followup key was set. + bool fuZero = true; + for (auto b : pending->followupProgramKey) if (b) { fuZero = false; break; } + if (!fuZero) { + ces::Bytes fin(16, 0); + ces::Buffer::pokeLE(fin.data() + 0, pending->followupTag); + ces::Buffer::pokeLE(fin.data() + 8, static_cast(outcome)); + size_t n = std::min(reply.size(), CES_L2_CALL_VM_REPLY); + fin.insert(fin.end(), reply.begin(), reply.begin() + n); + if (scheduleRun(Account::getMapKey(pending->payerKey), + pending->followupProgramKey, pending->followupBudget, + pending->followupAllowance, fin, 0, false, 0, + INVOKE_L2_RETURN) != CES_OK) { + LOGDEBUG << "completeL2Call: followup schedule failed"; + } + } +} + +uint8_t CesServer::enqueueChannelL2Call(CesPlexHandler* handler, + const minx::Hash& payer, uint64_t value, + ces::Bytes blob, + std::shared_ptr replyCtx) { + if (l2PendingCount_.load() >= cfg_.l2MaxPending) return CES_ERROR_QUEUE_FULL; + // Escrow burn: park `value` in the bottomless self-account. Same model as + // the VM syscall; settlement mints self -> payee or refunds self -> payer. + if (value > 0 && + !l2TransferSync(payer, serverKeyPair_.getPublicKeyAsHash(), value)) + return CES_ERROR_INSUFFICIENT_BALANCE; + auto pending = std::make_shared(); + pending->handler = handler; + pending->payerKey = payer; + pending->value = value; + pending->blob = std::move(blob); + pending->replyCtx = std::move(replyCtx); + postLogic([this, pending]() { + pending->callId = l2NextCallId_++; + l2PendingCount_.fetch_add(1); + boost::asio::post(rpcTaskIO_, [this, pending]() { drainL2Call(pending); }); + }); + return CES_OK; +} + +bool CesServer::l2TransferSync(const minx::Hash& from, const minx::Hash& to, + uint64_t amount) { + if (amount == 0) return true; + std::promise pr; + auto fut = pr.get_future(); + postLogic([this, from, to, amount, &pr]() { + int64_t outBal = 0; + uint8_t rc = transfer(from, to, amount, TransferMode::Open, 0, CES_NONCELESS, + outBal, /*txFee=*/0, /*rentFee=*/0, /*errFee=*/0); + pr.set_value(rc == CES_OK); + }); + return fut.get(); +} + +// --- Autoexec (cron assets on boot) --- + +void CesServer::runAutoexec() { + // Key layout: [8 zero bytes][8 CRON_MAGIC BE][16 rest] + size_t found = 0, executed = 0; + std::vector toDelete; // collect dead autoexec assets + + for (auto& [key, asset] : assets_->getObjects()) { + // Check key pattern: first 8 bytes zero, next 8 bytes = magic. + // peek reads BE so the comparison is direct. + if (ces::Buffer::peek(key.data()) != 0) continue; + if (ces::Buffer::peek(key.data() + 8) != + CesConfig::AUTOEXEC_KEY_MAGIC) continue; + + found++; + LOGINFO << "autoexec: found autoexec asset" << SVAR(key); + + // Parse content: [2 byte BE length][packet bytes including opcode] + try { + AssetData content = asset.getContent(); + uint16_t pktLen = ces::Buffer::peek( + std::span(content.data(), content.size()), 0); + if (pktLen < 10 || pktLen > 208) { + LOGTRACE << "autoexec: bad packet length, deleting" << VAR(pktLen); + toDelete.push_back(key); + continue; + } + minx::Bytes packet; + ces::Buffer::putBytes(packet, + std::span(content.data() + 2, pktLen)); + + CesRunAsset req; + req.fromBytes(packet); + + // Verify signature + PublicKey pk(req.originId); + if (!req.verifySignature(packet, pk)) { + LOGTRACE << "autoexec: bad signature, deleting"; + toDelete.push_back(key); + continue; + } + + // Verify server ID + HashPrefix myId = Account::getMapKey(serverKeyPair_.getPublicKeyAsHash()); + if (req.serverId != myId) { + LOGTRACE << "autoexec: wrong serverId, deleting"; + toDelete.push_back(key); + continue; + } + + // Execute as a scheduled run (no nonce check, no time check). + // Allowance comes from the signed wire packet stored in the asset + // content — defaults to UINT64_MAX (no enforcement) for the typical + // operator-installed autoexec, but a signer can opt into a cap. + HashPrefix callerPrefix = Account::getMapKey(req.originId); + ScheduledRun run; + run.callerPrefix = callerPrefix; + run.assetId = req.assetId; + run.budget = req.budget; + run.allowance = req.allowance; + run.input = req.input; + if (executeScheduledRun(run)) { + executed++; + LOGINFO << "autoexec: executed" << SVAR(req.assetId); + } else { + // Account gone or broke — mark for deletion + toDelete.push_back(key); + LOGINFO << "autoexec: account dead, will delete" << SVAR(key); + } + + } catch (const std::exception& e) { + LOGTRACE << "autoexec: parse failed, deleting" << VAR(e.what()); + toDelete.push_back(key); + } + } + + // Delete dead autoexec assets (can't erase during iteration) + for (auto& k : toDelete) + assets_->getObjects().erase(k); + + if (found > 0) { + LOGINFO << "autoexec:" << VAR(found) << VAR(executed); + } +} + +} // namespace ces \ No newline at end of file diff --git a/vendor/ces/src/ceslib/util/ctrlc.cpp b/vendor/ces/src/ceslib/util/ctrlc.cpp new file mode 100644 index 0000000..f493232 --- /dev/null +++ b/vendor/ces/src/ceslib/util/ctrlc.cpp @@ -0,0 +1,47 @@ +/** + * ctrlc.cpp + */ + +#if defined(CES_HAVE_STACKTRACE) +#include +#endif +#include +#include +#include + +namespace ces { +namespace internal { +volatile std::sig_atomic_t g_interrupted = 0; +} + +extern "C" void interrupt_handler(int) { internal::g_interrupted += 1; } + +// The stack trace rides libbacktrace (Linux only); elsewhere the handler +// still reports and exits, without a trace. +extern "C" void abrt_handler(int s) { + std::cerr << "SIGABRT\n"; +#if defined(CES_HAVE_STACKTRACE) + std::cerr << boost::stacktrace::stacktrace() << std::endl; +#endif + std::_Exit(s); +} + +extern "C" void segv_handler(int s) { + std::cerr << "SIGSEGV\n"; +#if defined(CES_HAVE_STACKTRACE) + std::cerr << boost::stacktrace::stacktrace() << std::endl; +#endif + std::_Exit(s); +} + +struct SignalInstaller { + SignalInstaller() { + std::signal(SIGINT, interrupt_handler); + std::signal(SIGTERM, interrupt_handler); + std::signal(SIGABRT, abrt_handler); + std::signal(SIGSEGV, segv_handler); + } +}; + +static SignalInstaller auto_installer; +} // namespace ces diff --git a/vendor/ces/src/ceslib/util/helpers.cpp b/vendor/ces/src/ceslib/util/helpers.cpp new file mode 100644 index 0000000..5cad74f --- /dev/null +++ b/vendor/ces/src/ceslib/util/helpers.cpp @@ -0,0 +1,25 @@ +#include + +#include + +LOG_MODULE("csv"); + +namespace ces { + +void runGuardedThread(const std::function& runFn, + const char* threadName) { + for (;;) { + try { + runFn(); + return; // clean completion (e.g. the io_context was stopped) + } catch (const std::exception& e) { + LOGERROR << "worker thread escaped an exception; re-entering its run loop" + << SVAR(threadName) << SVAR(e.what()); + } catch (...) { + LOGERROR << "worker thread escaped an unknown exception; re-entering its" + " run loop" << SVAR(threadName); + } + } +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/util/hex.cpp b/vendor/ces/src/ceslib/util/hex.cpp new file mode 100644 index 0000000..ca78745 --- /dev/null +++ b/vendor/ces/src/ceslib/util/hex.cpp @@ -0,0 +1,42 @@ +#include + +#include + +namespace ces { + +namespace { + +// Returns 0..15 on valid hex char; throws on invalid. +uint8_t hexNibble(char c) { + if (c >= '0' && c <= '9') return static_cast(c - '0'); + if (c >= 'a' && c <= 'f') return static_cast(10 + (c - 'a')); + if (c >= 'A' && c <= 'F') return static_cast(10 + (c - 'A')); + throw std::invalid_argument("parseHex: non-hex character"); +} + +} // namespace + +ces::Bytes parseHex(std::string_view hex) { + ces::Bytes out; + out.reserve(hex.size() / 2); + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + uint8_t hi = hexNibble(hex[i]); + uint8_t lo = hexNibble(hex[i + 1]); + out.push_back(static_cast((hi << 4) | lo)); + } + return out; +} + +std::string bytesToHex(std::span bytes) { + static constexpr char kDigits[] = "0123456789abcdef"; + std::string out; + out.resize(bytes.size() * 2); + for (size_t i = 0; i < bytes.size(); ++i) { + uint8_t b = bytes[i]; + out[i * 2] = kDigits[(b >> 4) & 0xF]; + out[i * 2 + 1] = kDigits[b & 0xF]; + } + return out; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/util/kvcount.cpp b/vendor/ces/src/ceslib/util/kvcount.cpp new file mode 100644 index 0000000..bbd446e --- /dev/null +++ b/vendor/ces/src/ceslib/util/kvcount.cpp @@ -0,0 +1,29 @@ +#include + +#include + +namespace ces { + +uint64_t countCanonicalKvKeyPrefix(std::span canon, uint8_t prefix) { + const size_t len = canon.size(); + if (len < 4) return 0; + size_t off = 0; + const uint32_t count = Buffer::peek(canon, off); + off += 4; + uint64_t matched = 0; + for (uint32_t i = 0; i < count && off + 4 <= len; ++i) { + const uint32_t klen = Buffer::peek(canon, off); + off += 4; + if (klen > len - off) break; + if (klen > 0 && canon[off] == prefix) ++matched; + off += klen; + if (off + 4 > len) break; + const uint32_t vlen = Buffer::peek(canon, off); + off += 4; + if (vlen > len - off) break; + off += vlen; + } + return matched; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/util/resolver.cpp b/vendor/ces/src/ceslib/util/resolver.cpp new file mode 100644 index 0000000..e2298b0 --- /dev/null +++ b/vendor/ces/src/ceslib/util/resolver.cpp @@ -0,0 +1,192 @@ +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace ces { + +namespace { + +// Split "host:port" on the LAST ':' so unbracketed IPv6 literals with +// embedded colons are handled as the caller intends when they omit +// brackets. Callers that need strict IPv6-literal handling should pass +// bracketed syntax ("[::1]:80") and strip brackets before calling. +std::pair splitHostPort(const std::string& hp) { + auto pos = hp.find_last_of(':'); + if (pos == std::string::npos) + throw std::runtime_error("Bad server format (expected host:port)"); + return {hp.substr(0, pos), hp.substr(pos + 1)}; +} + +// Thin wrapper around a one-shot asio resolver. Throws on empty result. +template +auto doResolve(const std::string& host, const std::string& port) { + boost::asio::io_context ioc; + Resolver res(ioc); + auto results = res.resolve(host, port); + if (results.empty()) + throw std::runtime_error("Host unresolved: " + host + ":" + port); + return results; +} + +} // namespace + +boost::asio::ip::udp::endpoint +Resolver::resolveUdp(const std::string& hostPort) { + auto [host, port] = splitHostPort(hostPort); + return *doResolve(host, port).begin(); +} + +boost::asio::ip::udp::endpoint +Resolver::resolveUdp(const std::string& host, uint16_t port) { + return *doResolve( + host, std::to_string(port)).begin(); +} + +boost::asio::ip::tcp::endpoint +Resolver::resolveTcp(const std::string& hostPort) { + auto [host, port] = splitHostPort(hostPort); + return doResolve(host, port) + .begin()->endpoint(); +} + +boost::asio::ip::tcp::endpoint +Resolver::resolveTcp(const std::string& host, uint16_t port) { + return doResolve( + host, std::to_string(port)).begin()->endpoint(); +} + +boost::asio::ip::address Resolver::parseIp(const std::string& ip) { + return boost::asio::ip::make_address(ip); +} + +boost::asio::ip::address +Resolver::parseIp(const std::string& ip, boost::system::error_code& ec) { + return boost::asio::ip::make_address(ip, ec); +} + +std::string Resolver::fillHost(const std::string& advertised, + const boost::asio::ip::address& srcIp) { + auto pos = advertised.find_last_of(':'); + if (pos == std::string::npos) + return advertised; // no port at all — malformed, leave untouched + std::string host = advertised.substr(0, pos); + std::string port = advertised.substr(pos + 1); + // A bracketed IPv6 host ("[..]") still counts as a host. + std::string inner = host; + if (inner.size() >= 2 && inner.front() == '[' && inner.back() == ']') + inner = inner.substr(1, inner.size() - 2); + if (!inner.empty()) + return advertised; // operator serverName present — intentional, verbatim + // Host-less (":port"): fill the host from the observed source IP. + std::string ip = srcIp.to_string(); + const std::string kV4Mapped = "::ffff:"; // unwrap IPv4-mapped IPv6 + if (ip.rfind(kV4Mapped, 0) == 0) + ip = ip.substr(kV4Mapped.size()); + if (ip.find(':') != std::string::npos) + ip = "[" + ip + "]"; // bracket a bare IPv6 literal + return ip + ":" + port; +} + +std::unique_ptr +Resolver::Probe::makeClient(bool useDataset) const { + if (isTcp) + return std::make_unique(tcpEp, useDataset); + return std::make_unique(udpEp, useDataset); +} + +Resolver::Probe Resolver::probe(const std::string& hostPort, + std::function log) { + // Cache successful probes for the process lifetime. Same serverStr + // resolves to the same Probe every time, amortizing DNS + the TCP + // connect test across all callers. + static std::mutex cacheMutex; + static std::unordered_map cache; + { + std::lock_guard lock(cacheMutex); + auto it = cache.find(hostPort); + if (it != cache.end()) { + if (log) log("Probe cache hit: " + hostPort + " -> " + + (it->second.isTcp ? "TCP" : "UDP")); + return it->second; + } + } + + Probe result; + + if (log) log("Probing " + hostPort + " (TCP first, 500ms timeout)..."); + + try { + auto tcpEp = resolveTcp(hostPort); + if (log) log("TCP resolve: " + tcpEp.address().to_string() + ":" + + std::to_string(tcpEp.port())); + + boost::asio::io_context ioc; + boost::asio::ip::tcp::socket sock(ioc); + boost::asio::steady_timer timer(ioc); + timer.expires_after(std::chrono::milliseconds(500)); + + bool connected = false; + bool timed_out = false; + boost::system::error_code connectEc; + + sock.async_connect(tcpEp, [&](boost::system::error_code ec) { + connectEc = ec; + if (!ec) { + connected = true; + timer.cancel(); + } + }); + + timer.async_wait([&](boost::system::error_code ec) { + if (!ec) { + timed_out = true; + boost::system::error_code closeEc; + sock.close(closeEc); + } + }); + + ioc.run(); + + if (connected && !timed_out) { + boost::system::error_code ec; + sock.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + sock.close(ec); + result.isTcp = true; + result.tcpEp = tcpEp; + if (log) log("Probe result: TCP (proxy detected)"); + { + std::lock_guard lock(cacheMutex); + cache[hostPort] = result; + } + return result; + } + + if (log) { + if (timed_out) log("TCP probe: timed out"); + else log("TCP probe: failed (" + connectEc.message() + ")"); + } + } catch (std::exception& e) { + if (log) log("TCP probe exception: " + std::string(e.what())); + } + + // Fall back to UDP. + result.udpEp = resolveUdp(hostPort); + result.isTcp = false; + if (log) log("Probe result: UDP (direct)"); + { + std::lock_guard lock(cacheMutex); + cache[hostPort] = result; + } + return result; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/util/smtp.cpp b/vendor/ces/src/ceslib/util/smtp.cpp new file mode 100644 index 0000000..07a2da5 --- /dev/null +++ b/vendor/ces/src/ceslib/util/smtp.cpp @@ -0,0 +1,255 @@ +// smtp.cpp - see smtp.h. Synchronous SMTP submission over Boost.Asio, with +// opportunistic STARTTLS (OpenSSL) when the relay advertises it. + +#ifdef CES_MAIL + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace ces { +namespace { + +using boost::asio::ip::tcp; +namespace ssl = boost::asio::ssl; + +std::string b64(const std::string& in) { + static const char* T = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + int val = 0, bits = -6; + for (unsigned char c : in) { + val = (val << 8) + c; + bits += 8; + while (bits >= 0) { out.push_back(T[(val >> bits) & 0x3F]); bits -= 6; } + } + if (bits > -6) out.push_back(T[((val << 8) >> (bits + 8)) & 0x3F]); + while (out.size() % 4) out.push_back('='); + return out; +} + +// base64 with CRLF wrapping at 76 chars (MIME attachment bodies). +std::string b64wrapped(const std::string& in) { + std::string raw = b64(in); + std::string out; + out.reserve(raw.size() + raw.size() / 76 * 2 + 2); + for (size_t i = 0; i < raw.size(); i += 76) { + out += raw.substr(i, 76); + out += "\r\n"; + } + return out; +} + +// SMTP dot-stuffing: any line beginning with '.' gets a doubled leading dot so +// it is not mistaken for the end-of-DATA terminator. +std::string dotStuff(const std::string& msg) { + std::string out; + out.reserve(msg.size()); + bool atLineStart = true; + for (char c : msg) { + if (atLineStart && c == '.') out.push_back('.'); + out.push_back(c); + atLineStart = (c == '\n'); + } + return out; +} + +// Read one full SMTP reply (handling multiline continuations) and return its +// 3-digit code, or -1 on error. If `collect` is non-null every reply line is +// appended to it (used to scan EHLO capabilities). Templated so it works on a +// plain tcp::socket and on an ssl::stream after STARTTLS. +template +int readReply(Stream& s, boost::asio::streambuf& buf, std::string* err, + std::string* collect = nullptr) { + for (;;) { + boost::system::error_code ec; + boost::asio::read_until(s, buf, "\r\n", ec); + if (ec) { if (err) *err = "read: " + ec.message(); return -1; } + std::istream is(&buf); + std::string line; + std::getline(is, line); + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (collect) { *collect += line; collect->push_back('\n'); } + if (line.size() < 4) continue; + int code = std::atoi(line.substr(0, 3).c_str()); + if (line[3] == ' ') return code; // final line (space, not '-') + } +} + +// Send `line` + CRLF and require the reply's class to match `expectClass` +// (2 or 3). Returns false (with diagnostic) otherwise. +template +bool cmd(Stream& s, boost::asio::streambuf& buf, const std::string& line, + int expectClass, std::string* err) { + boost::system::error_code ec; + boost::asio::write(s, boost::asio::buffer(line + "\r\n"), ec); + if (ec) { if (err) *err = "write: " + ec.message(); return false; } + int code = readReply(s, buf, err); + if (code < 0) return false; + if (code / 100 != expectClass) { + if (err) *err = "unexpected reply " + std::to_string(code); + return false; + } + return true; +} + +// Assemble the RFC 5322 message (a MIME multipart/mixed when an attachment is +// present, otherwise a plain text body). +std::string buildMessage(const SmtpConfig& cfg, const std::string& to, + const std::string& subject, const std::string& body, + const MailAttachment* attachment) { + const std::string headers = + "From: " + cfg.from + "\r\n" + + "To: " + to + "\r\n" + + "Subject: " + subject + "\r\n"; + if (!attachment) return headers + "\r\n" + body + "\r\n"; + const std::string b = "ces_mime_boundary_a1b2c3d4"; + return + headers + + "MIME-Version: 1.0\r\n" + "Content-Type: multipart/mixed; boundary=\"" + b + "\"\r\n" + "\r\n" + "--" + b + "\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + "\r\n" + + body + "\r\n" + "--" + b + "\r\n" + "Content-Type: " + attachment->contentType + + "; name=\"" + attachment->filename + "\"\r\n" + "Content-Transfer-Encoding: base64\r\n" + "Content-Disposition: attachment; filename=\"" + + attachment->filename + "\"\r\n" + "\r\n" + + b64wrapped(attachment->data) + + "--" + b + "--\r\n"; +} + +// Run the submission over an established stream (plaintext socket or TLS +// stream). `needEhlo` re-issues EHLO first, required after a STARTTLS upgrade. +template +bool deliver(Stream& s, boost::asio::streambuf& buf, const SmtpConfig& cfg, + const std::string& to, const std::string& subject, + const std::string& body, const MailAttachment* attachment, + std::string* err, bool needEhlo) { + if (needEhlo && !cmd(s, buf, "EHLO ces", 2, err)) return false; + if (!cfg.user.empty()) { + if (!cmd(s, buf, "AUTH LOGIN", 3, err)) return false; + if (!cmd(s, buf, b64(cfg.user), 3, err)) return false; + if (!cmd(s, buf, b64(cfg.pass), 2, err)) return false; + } + if (!cmd(s, buf, "MAIL FROM:<" + cfg.from + ">", 2, err)) return false; + if (!cmd(s, buf, "RCPT TO:<" + to + ">", 2, err)) return false; + if (!cmd(s, buf, "DATA", 3, err)) return false; // 354 + // DATA terminator: dot-stuff the body, then append the "." line (cmd adds the + // trailing CRLF, yielding the CRLF "." CRLF end marker). + const std::string message = buildMessage(cfg, to, subject, body, attachment); + if (!cmd(s, buf, dotStuff(message) + ".", 2, err)) return false; // 250 + cmd(s, buf, "QUIT", 2, err); // best-effort + return true; +} + +} // namespace + +bool smtpSend(const SmtpConfig& cfg, const std::string& to, + const std::string& subject, const std::string& body, + const MailAttachment* attachment, std::string* err) { + // Envelope/header fields must not contain CR or LF: an embedded newline would + // inject an extra SMTP command (a second RCPT TO / BCC) or a forged header. + // `to` and `subject` are program-controlled (ces.mail_send validates only + // length), so reject a control char here rather than pass it into the dialog. + // `from` is operator config but checked for symmetry. Body is exempt: it is + // dot-stuffed and framed by the "." terminator, not by bare CRLF. + auto fieldSafe = [](const std::string& s) { + return s.find('\r') == std::string::npos && s.find('\n') == std::string::npos; + }; + if (!fieldSafe(to) || !fieldSafe(subject) || !fieldSafe(cfg.from)) { + if (err) *err = "mail field contains a control character (CR/LF)"; + return false; + } + try { + boost::asio::io_context io; + ssl::context sslctx(ssl::context::tls_client); + ssl::stream stream(io, sslctx); + tcp::socket& sock = stream.next_layer(); + + boost::system::error_code ec; + tcp::resolver res(io); + auto eps = res.resolve(cfg.host, std::to_string(cfg.port), ec); + if (ec) { if (err) *err = "resolve: " + ec.message(); return false; } + boost::asio::connect(sock, eps, ec); + if (ec) { if (err) *err = "connect: " + ec.message(); return false; } + + // Watchdog: bound the whole synchronous exchange so a relay that accepts + // then goes silent cannot hang the caller. On expiry, shut the socket down + // (this unblocks the pending read) and close it; the in-flight op then fails + // and smtpSend returns false. The Stop guard ends the watchdog on every + // return path. + std::mutex wm; + std::condition_variable wcv; + bool finished = false; + std::thread watchdog([&] { + try { + std::unique_lock lk(wm); + if (!wcv.wait_for(lk, std::chrono::seconds(cfg.timeout_secs), + [&] { return finished; })) { + boost::system::error_code ig; + sock.shutdown(boost::asio::socket_base::shutdown_both, ig); + sock.close(ig); + } + } catch (...) { + } + }); + struct Stop { + std::mutex& m; std::condition_variable& cv; bool& f; std::thread& t; + ~Stop() { + { std::lock_guard lk(m); f = true; } + cv.notify_one(); + t.join(); + } + } stop{wm, wcv, finished, watchdog}; + + boost::asio::streambuf buf; + if (readReply(sock, buf, err) / 100 != 2) return false; // 220 greeting + + // EHLO in plaintext; collect the capability list to detect STARTTLS. + boost::asio::write(sock, boost::asio::buffer(std::string("EHLO ces\r\n")), ec); + if (ec) { if (err) *err = "write: " + ec.message(); return false; } + std::string caps; + int ehlo = readReply(sock, buf, err, &caps); + if (ehlo < 0) return false; + if (ehlo / 100 != 2) { + if (err) *err = "EHLO rejected " + std::to_string(ehlo); + return false; + } + + if (caps.find("STARTTLS") != std::string::npos) { + if (!cmd(sock, buf, "STARTTLS", 2, err)) return false; // 220 + // Opportunistic TLS: encrypt, do not verify the peer cert. + stream.set_verify_mode(ssl::verify_none); + SSL_set_tlsext_host_name(stream.native_handle(), cfg.host.c_str()); // SNI + stream.handshake(ssl::stream_base::client, ec); + if (ec) { if (err) *err = "tls handshake: " + ec.message(); return false; } + // RFC 3207: re-issue EHLO over the encrypted channel, then send. + return deliver(stream, buf, cfg, to, subject, body, attachment, err, true); + } + // No STARTTLS advertised: proceed in plaintext (trusted hop or test mock). + return deliver(sock, buf, cfg, to, subject, body, attachment, err, false); + } catch (const std::exception& e) { + if (err) *err = std::string("smtp exception: ") + e.what(); + return false; + } +} + +} // namespace ces + +#endif // CES_MAIL diff --git a/vendor/ces/src/ceslib/util/vmprogram.cpp b/vendor/ces/src/ceslib/util/vmprogram.cpp new file mode 100644 index 0000000..88c5856 --- /dev/null +++ b/vendor/ces/src/ceslib/util/vmprogram.cpp @@ -0,0 +1,686 @@ +#include +#include + + +#include +#include +#include +#include + +namespace ces { + +// Opcode numbers and control-byte encoding bits come from cesvm.h — +// the single source of truth that cesvm.cpp's parser, this builder, +// and every test file share. Local aliases here keep the encoder +// readable without leaking the "CESVM_" prefix into every line. +static constexpr uint8_t SHORT_VAL_BIT = CESVM_SHORT_VAL; +static constexpr uint8_t REG_PTR_BIT = CESVM_REG_PTR; +static constexpr uint64_t MAX_SHORT_VAL = CESVM_MAX_SHORT_VAL; + +// =========================================================================== +// Construction +// =========================================================================== + +VmProgram::VmProgram() { + code_.reserve(256); // typical small program size +} + +// =========================================================================== +// Internal emit helpers +// =========================================================================== + +void VmProgram::emitByte(uint8_t b) { + code_.push_back(b); +} + +void VmProgram::emitOpcode(uint8_t op) { + code_.push_back(op); + lastWasTerminator_ = (op == OP_TERM || op == OP_ABORT); +} + +void VmProgram::emitVal(VmVal v) { + // The CesVM's read() function decodes one operand as follows: + // control = next byte + // regptr = (control >> 7) & 1 + // shortval = (control >> 6) & 1 + // low6 = control & 0x3F + // if shortval: value = low6 + // else: value = LE integer read from `low6` bytes following + // if regptr: value = io[value] + // + // To encode a VmVal, pick the shortest form that represents `value` + // and layer the REG_PTR bit on top if deref is requested. + + if (v.value <= MAX_SHORT_VAL) { + // Short form: value fits in 6 bits, 1 byte total. + uint8_t ctrl = static_cast(SHORT_VAL_BIT | (v.value & MAX_SHORT_VAL)); + if (v.deref) ctrl |= REG_PTR_BIT; + code_.push_back(ctrl); + return; + } + + // Wide form: pick minimum number of bytes needed to hold `value`. + uint8_t bytes = 1; + for (uint64_t limit = uint64_t{1} << 8; + bytes < 8 && v.value >= limit; + limit <<= 8, ++bytes) { + // keep bumping + } + // For values >= 2^56 the loop exits at bytes == 8 without + // re-checking, which is exactly what we want (8 bytes covers the + // full uint64_t range). + + uint8_t ctrl = bytes; + if (v.deref) ctrl |= REG_PTR_BIT; + code_.push_back(ctrl); + ces::Buffer::putLE(code_, v.value, bytes); +} + +void VmProgram::emitLabelRef(VmLabel l) { + // Jump/call targets are always 2 bytes little-endian with no control + // byte — the VM's read(jumpSkipControl=true) path forces control=2 + // and reads 2 bytes directly. Emit a placeholder now and remember to + // patch it at build time. + if (l.id >= labels_.size()) { + throw VmProgramError("vmprogram: label reference out of range"); + } + relocs_.push_back({code_.size(), l.id}); + code_.push_back(0); + code_.push_back(0); +} + +// =========================================================================== +// Label management +// =========================================================================== + +VmLabel VmProgram::label() { + VmLabel l{labels_.size()}; + labels_.push_back(std::numeric_limits::max()); // unplaced + return l; +} + +Region VmProgram::alloc(uint64_t count) { + // Scratch range is io[CESVM_REG_SIZE .. CESVM_IO_INPUT_LEN). Beyond + // CESVM_IO_INPUT_LEN the cells are protocol-fixed (context, output, + // input, allowance) and not usable as program-private scratch. + if (count == 0) { + throw VmProgramError("vmprogram: alloc(0) is not allowed"); + } + if (scratchTop_ + count > CESVM_IO_INPUT_LEN) { + throw VmProgramError( + "vmprogram: scratch region exhausted — requested " + + std::to_string(count) + " cells at top=" + + std::to_string(scratchTop_) + ", limit=" + + std::to_string(CESVM_IO_INPUT_LEN)); + } + Region r{scratchTop_, count}; + scratchTop_ += count; + return r; +} + +VmProgram& VmProgram::place(VmLabel l) { + if (l.id >= labels_.size()) { + throw VmProgramError("vmprogram: place() called with invalid label"); + } + if (labels_[l.id] != std::numeric_limits::max()) { + throw VmProgramError("vmprogram: label placed twice"); + } + labels_[l.id] = code_.size(); + return *this; +} + +void VmProgram::resolveLabelsOrThrow() { + for (const auto& r : relocs_) { + if (r.labelId >= labels_.size()) { + throw VmProgramError("vmprogram: reloc against unknown label"); + } + if (labels_[r.labelId] == std::numeric_limits::max()) { + throw VmProgramError( + "vmprogram: label referenced but never placed (id=" + + std::to_string(r.labelId) + ")"); + } + const uint64_t target = labels_[r.labelId] + baseOffset_; + if (target > 0xFFFFu) { + throw VmProgramError( + "vmprogram: label offset exceeds 16-bit range (offset=" + + std::to_string(target) + ")"); + } + // VM bytecode operands are little-endian. + ces::Buffer::pokeLE(code_.data() + r.offset, + static_cast(target)); + } +} + +// =========================================================================== +// Low-level opcode methods +// =========================================================================== + +VmProgram& VmProgram::nop() { emitOpcode(OP_NOP); return *this; } +VmProgram& VmProgram::term() { emitOpcode(OP_TERM); return *this; } +VmProgram& VmProgram::abort() { emitOpcode(OP_ABORT); return *this; } +VmProgram& VmProgram::host() { emitOpcode(OP_HOST); return *this; } +VmProgram& VmProgram::hostx() { emitOpcode(OP_HOSTX); return *this; } +VmProgram& VmProgram::rnd() { emitOpcode(OP_RND); return *this; } +VmProgram& VmProgram::time() { emitOpcode(OP_TIME); return *this; } + +VmProgram& VmProgram::jmp(VmLabel target) { + emitOpcode(OP_JMP); + emitLabelRef(target); + return *this; +} + +VmProgram& VmProgram::jf(VmVal cond, VmLabel target) { + emitOpcode(OP_JF); + emitVal(cond); + emitLabelRef(target); + return *this; +} + +VmProgram& VmProgram::jt(VmVal cond, VmLabel target) { + emitOpcode(OP_JT); + emitVal(cond); + emitLabelRef(target); + return *this; +} + +VmProgram& VmProgram::call(VmLabel target) { + emitOpcode(OP_CALL); + emitLabelRef(target); + return *this; +} + +VmProgram& VmProgram::ret(VmVal retVal) { + emitOpcode(OP_RET); + emitVal(retVal); + return *this; +} + +VmProgram& VmProgram::jmpr(VmVal target) { + emitOpcode(OP_JMPR); + emitVal(target); + return *this; +} + +VmProgram& VmProgram::callr(VmVal target) { + emitOpcode(OP_CALLR); + emitVal(target); + return *this; +} + +VmProgram& VmProgram::set(VmVal dst, VmVal src) { + emitOpcode(OP_SET); + emitVal(dst); + emitVal(src); + return *this; +} + +VmProgram& VmProgram::mov(VmVal dst, VmVal src, VmVal count) { + emitOpcode(OP_MOV); + emitVal(dst); + emitVal(src); + emitVal(count); + return *this; +} + +VmProgram& VmProgram::cmp(VmVal a, VmVal b, VmVal count) { + emitOpcode(OP_CMP); + emitVal(a); + emitVal(b); + emitVal(count); + return *this; +} + +VmProgram& VmProgram::fil(VmVal dst, VmVal val, VmVal count) { + emitOpcode(OP_FIL); + emitVal(dst); + emitVal(val); + emitVal(count); + return *this; +} + +VmProgram& VmProgram::ldb(VmVal byteOffset) { + emitOpcode(OP_LDB); + emitVal(byteOffset); + return *this; +} + +VmProgram& VmProgram::stb(VmVal byteOffset, VmVal val) { + emitOpcode(OP_STB); + emitVal(byteOffset); + emitVal(val); + return *this; +} + +VmProgram& VmProgram::inc(VmVal cell) { + emitOpcode(OP_INC); + emitVal(cell); + return *this; +} + +VmProgram& VmProgram::dec(VmVal cell) { + emitOpcode(OP_DEC); + emitVal(cell); + return *this; +} + +// --- Binary arithmetic / logic --- + +VmProgram& VmProgram::add(VmVal a, VmVal b) { emitOpcode(OP_ADD); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::sub(VmVal a, VmVal b) { emitOpcode(OP_SUB); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::mul(VmVal a, VmVal b) { emitOpcode(OP_MUL); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::div(VmVal a, VmVal b) { emitOpcode(OP_DIV); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::mod(VmVal a, VmVal b) { emitOpcode(OP_MOD); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::or_(VmVal a, VmVal b) { emitOpcode(OP_OR); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::and_(VmVal a, VmVal b) { emitOpcode(OP_AND); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::xor_(VmVal a, VmVal b) { emitOpcode(OP_XOR); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::not_(VmVal a) { emitOpcode(OP_NOT); emitVal(a); return *this; } +VmProgram& VmProgram::lnot(VmVal a) { emitOpcode(OP_LNOT); emitVal(a); return *this; } +VmProgram& VmProgram::neg(VmVal a) { emitOpcode(OP_NEG); emitVal(a); return *this; } +VmProgram& VmProgram::shl(VmVal a, VmVal b) { emitOpcode(OP_SHL); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::shr(VmVal a, VmVal b) { emitOpcode(OP_SHR); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::sar(VmVal a, VmVal b) { emitOpcode(OP_SAR); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::andl(VmVal a, VmVal b) { emitOpcode(OP_ANDL); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::orl(VmVal a, VmVal b) { emitOpcode(OP_ORL); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::eq(VmVal a, VmVal b) { emitOpcode(OP_EQ); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::ne(VmVal a, VmVal b) { emitOpcode(OP_NE); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::gt(VmVal a, VmVal b) { emitOpcode(OP_GT); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::lt(VmVal a, VmVal b) { emitOpcode(OP_LT); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::ge(VmVal a, VmVal b) { emitOpcode(OP_GE); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::le(VmVal a, VmVal b) { emitOpcode(OP_LE); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::slt(VmVal a, VmVal b) { emitOpcode(OP_SLT); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::sgt(VmVal a, VmVal b) { emitOpcode(OP_SGT); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::sge(VmVal a, VmVal b) { emitOpcode(OP_SGE); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::sle(VmVal a, VmVal b) { emitOpcode(OP_SLE); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::addx(VmVal a, VmVal b) { emitOpcode(OP_ADDX); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::subx(VmVal a, VmVal b) { emitOpcode(OP_SUBX); emitVal(a); emitVal(b); return *this; } +VmProgram& VmProgram::mulx(VmVal a, VmVal b) { emitOpcode(OP_MULX); emitVal(a); emitVal(b); return *this; } + +VmProgram& VmProgram::require(VmVal cond) { + emitOpcode(OP_ASSERT); + emitVal(cond); + return *this; +} + +// --- Stack --- + +VmProgram& VmProgram::push(VmVal v) { + emitOpcode(OP_PUSH); + emitVal(v); + return *this; +} + +VmProgram& VmProgram::pop(VmVal cell) { + emitOpcode(OP_POP); + emitVal(cell); + return *this; +} + +VmProgram& VmProgram::dup() { + emitOpcode(OP_DUP); + return *this; +} + +VmProgram& VmProgram::stackOp(CesVMOpcode op) { + // Only opcodes whose stack form reads no inline operand bytes. JF/JT + // stack forms carry a 2-byte label and go through jfStack()/jtStack(). + switch (op) { + case OP_ADD: case OP_SUB: case OP_MUL: case OP_DIV: case OP_MOD: + case OP_OR: case OP_AND: case OP_ANDL: case OP_XOR: case OP_NOT: + case OP_LNOT: case OP_SHL: case OP_SHR: case OP_SAR: case OP_EQ: + case OP_NE: case OP_GT: case OP_LT: case OP_GE: case OP_LE: + case OP_NEG: case OP_ORL: case OP_RND: case OP_TIME: case OP_LDB: + case OP_STB: case OP_CALL: + case OP_SLT: case OP_SGT: case OP_SGE: case OP_SLE: + case OP_ADDX: case OP_SUBX: case OP_MULX: case OP_ASSERT: + break; + default: + throw VmProgramError( + "vmprogram: opcode " + std::to_string(op) + + " has no operand-free stack variant"); + } + emitOpcode(static_cast(op | CESVM_OP_STACK)); + return *this; +} + +VmProgram& VmProgram::jfStack(VmLabel target) { + emitOpcode(static_cast(OP_JF | CESVM_OP_STACK)); + emitLabelRef(target); + return *this; +} + +VmProgram& VmProgram::jtStack(VmLabel target) { + emitOpcode(static_cast(OP_JT | CESVM_OP_STACK)); + emitLabelRef(target); + return *this; +} + +VmProgram& VmProgram::rawOp(CesVMOpcode op, + std::initializer_list operands) { + switch (op) { + case OP_JMP: case OP_CALL: case OP_JF: case OP_JT: + case OP_HOSTV: case OP_HOSTXV: + throw VmProgramError( + "vmprogram: rawOp cannot emit opcode " + std::to_string(op) + + "; its wire shape is not opcode-then-plain-operands"); + default: + break; + } + emitOpcode(op); + for (const auto& v : operands) emitVal(v); + return *this; +} + +// =========================================================================== +// High-level convenience helpers +// =========================================================================== + +VmProgram& VmProgram::copy(uint64_t dstCell, uint64_t srcCell, + uint64_t count) { + return mov(Imm(dstCell), Imm(srcCell), Imm(count)); +} + +VmProgram& VmProgram::copyFromInput(uint64_t dstCell, + uint64_t inputCellOffset, + uint64_t count) { + return copy(dstCell, CESVM_IO_INPUT + inputCellOffset, count); +} + +VmProgram& VmProgram::copyCallerKeyTo(uint64_t dstCell) { + return copy(dstCell, CESVM_IO_CALLER_KEY, 4); +} + +VmProgram& VmProgram::copySelfKeyTo(uint64_t dstCell) { + return copy(dstCell, CESVM_IO_SELF_KEY, 4); +} + +VmProgram& VmProgram::setOutput(VmVal value, uint64_t byteLen) { + set(Imm(CESVM_IO_OUTPUT_LEN), Imm(byteLen)); + set(Imm(CESVM_IO_OUTPUT), value); + return *this; +} + +VmProgram& VmProgram::setOutputBytes(uint64_t srcCell, uint64_t count, + uint64_t byteLen) { + copy(CESVM_IO_OUTPUT, srcCell, count); + set(Imm(CESVM_IO_OUTPUT_LEN), Imm(byteLen)); + return *this; +} + +VmProgram& VmProgram::ldbFromCell(uint64_t cellIndex, + uint64_t byteOffsetInCell) { + return ldb(Imm(byteInCell(cellIndex, byteOffsetInCell))); +} + +VmProgram& VmProgram::stbInCell(uint64_t cellIndex, + uint64_t byteOffsetInCell, + VmVal val) { + return stb(Imm(byteInCell(cellIndex, byteOffsetInCell)), val); +} + +VmProgram& VmProgram::loadCodeAndCall(VmVal keyPtr) { + // Load the library block, then immediately CALL through R (where + // SYS_LOAD_CODE just wrote the loaded block's offset). Emitting + // both in this single helper guarantees no builder call in + // between could clobber R. + sysLoadCode({.keyPtr = keyPtr}); + return callr(Ref(CESVM_CELL_R)); +} + +VmProgram& VmProgram::loadCodeAndJmp(VmVal keyPtr) { + sysLoadCode({.keyPtr = keyPtr}); + return jmpr(Ref(CESVM_CELL_R)); +} + +VmProgram& VmProgram::writeBytesToIo(uint64_t dstCell, const uint8_t* bytes, + size_t len) { + // Pack bytes into u64 cells little-endian, eight per cell. A + // trailing partial cell is padded with zeros. Each cell becomes one + // OP_SET instruction. The emitVal encoder picks the shortest wide + // form for each packed value, so small strings (ASCII, likely all + // bytes < 128) still emit efficient bytecode. + const size_t cells = (len + 7) / 8; + for (size_t c = 0; c < cells; ++c) { + uint64_t val = 0; + for (size_t b = 0; b < 8; ++b) { + const size_t idx = c * 8 + b; + if (idx < len) { + val |= static_cast(bytes[idx]) << (b * 8); + } + } + set(Imm(dstCell + c), Imm(val)); + } + return *this; +} + +VmProgram& VmProgram::writeBytesToIo(uint64_t dstCell, std::string_view s) { + return writeBytesToIo(dstCell, + reinterpret_cast(s.data()), s.size()); +} + +// =========================================================================== +// Typed syscall wrappers +// =========================================================================== + +// --------------------------------------------------------------------------- +// Ledger / asset / crypto syscall wrappers +// --------------------------------------------------------------------------- +// +// Each of these lowers to one OP_HOSTXV with the syscall number and +// the args in the order the cesvm.cpp dispatcher reads them. The +// syscall ABIs put all input slots contiguously starting at io[4] so +// the wrapper can pass exactly N args with no padding; any output +// slots live past the input range and are written directly by the +// syscall. + +VmProgram& VmProgram::sysReadAccount(ReadAccountArgs a) { + return hostxv(SYS_READ_ACCOUNT, {a.prefixPtr}); +} + +VmProgram& VmProgram::sysTransfer(TransferArgs a) { + return hostxv(SYS_TRANSFER, {a.destKeyPtr, a.amount}); +} + +VmProgram& VmProgram::sysOwnerTransfer(OwnerTransferArgs a) { + return hostxv(SYS_OWNER_TRANSFER, {a.destKeyPtr, a.amount}); +} + +VmProgram& VmProgram::sysDeposit(DepositArgs a) { + return hostxv(SYS_DEPOSIT, {a.amount}); +} + +VmProgram& VmProgram::sysWithdraw(WithdrawArgs a) { + return hostxv(SYS_WITHDRAW, {a.amount}); +} + +VmProgram& VmProgram::sysRefill(RefillArgs a) { + // hostv, not hostxv: SYS_REFILL never fails (it grants 0 at worst), so a + // nonzero S must not abort. The program reads R for the granted amount. + return hostv(SYS_REFILL, {a.amount}); +} + +VmProgram& VmProgram::sysReadAsset(ReadAssetArgs a) { + // Three contiguous inputs at io[4..6]: key cell-index, owner-out + // cell-index, content-out cell-index. Balance and price are written + // directly by the syscall into io[7] and io[8] respectively. + return hostxv(SYS_READ_ASSET, {a.keyPtr, a.ownerOutCell, a.contentOutCell}); +} + +VmProgram& VmProgram::sysCreateAssetRandom(CreateAssetRandomArgs a) { + return hostxv(SYS_CREATE_ASSET_RANDOM, {a.contentPtr, a.days, a.keyOutPtr}); +} + +VmProgram& VmProgram::sysCreateAssetRange(CreateAssetRangeArgs a) { + return hostxv(SYS_CREATE_ASSET_RANGE, {a.count, a.days, a.keyOutPtr}); +} + +VmProgram& VmProgram::sysCreateAsset(CreateAssetArgs a) { + return hostxv(SYS_CREATE_ASSET, {a.keyPtr, a.contentPtr, a.days}); +} + +VmProgram& VmProgram::sysCreateAssetManaged(CreateAssetManagedArgs a) { + return hostxv(SYS_CREATE_ASSET_MANAGED, {a.keyPtr, a.contentPtr, a.days}); +} + +VmProgram& VmProgram::sysUpdateAsset(UpdateAssetArgs a) { + return hostxv(SYS_UPDATE_ASSET, {a.keyPtr, a.contentPtr}); +} + +VmProgram& VmProgram::sysUpdateAssetMeta(UpdateAssetMetaArgs a) { + return hostxv(SYS_UPDATE_ASSET_META, {a.keyPtr, a.newOwnerPtr, a.newPrice}); +} + +VmProgram& VmProgram::sysFundAsset(FundAssetArgs a) { + return hostxv(SYS_FUND_ASSET, {a.keyPtr, a.days}); +} + +VmProgram& VmProgram::sysBuyAsset(BuyAssetArgs a) { + return hostxv(SYS_BUY_ASSET, {a.keyPtr, a.maxPrice}); +} + +VmProgram& VmProgram::sysGiveAsset(GiveAssetArgs a) { + return hostxv(SYS_GIVE_ASSET, {a.keyPtr, a.newOwnerPtr}); +} + +VmProgram& VmProgram::sysHash(HashArgs a) { + return hostxv(SYS_HASH, {a.dataPtr, a.len, a.outPtr}); +} + +VmProgram& VmProgram::sysVerifySig(VerifySigArgs a) { + return hostxv(SYS_VERIFY_SIG, {a.dataPtr, a.dataLen, a.sigPtr, a.pubkeyPtr}); +} + +VmProgram& VmProgram::sysCrossTransfer(CrossTransferArgs a) { + return hostxv(SYS_CROSS_TRANSFER, {a.destKeyPtr, a.amount, a.serverPtr}); +} + +VmProgram& VmProgram::sysLoadCode(LoadCodeArgs a) { + return hostxv(SYS_LOAD_CODE, {a.keyPtr}); +} + +VmProgram& VmProgram::sysSendClient(SendClientArgs a) { + return hostxv(SYS_SEND_CLIENT, {a.clientIdPtr, a.dataPtr, a.dataLen}); +} + +VmProgram& VmProgram::sysSchedule(ScheduleArgs a) { + return hostxv(SYS_SCHEDULE, { + a.assetKeyPtr, + a.budget, + a.childAllowance, + a.inputPtr, + a.inputLen, + a.timeUs, + }); +} + +VmProgram& VmProgram::sysReadAlias(ReadAliasArgs a) { + return hostxv(SYS_READ_ALIAS, {a.aliasId, a.offset, a.len, a.destPtr}); +} + +VmProgram& VmProgram::sysWriteAlias(WriteAliasArgs a) { + return hostxv(SYS_WRITE_ALIAS, {a.aliasId, a.offset, a.len, a.srcPtr}); +} + +VmProgram& VmProgram::sysLoadCodeAlias(LoadCodeAliasArgs a) { + return hostxv(SYS_LOAD_CODE_ALIAS, {a.aliasId}); +} + +VmProgram& VmProgram::sysScheduleAlias(ScheduleAliasArgs a) { + return hostxv(SYS_SCHEDULE_ALIAS, { + a.aliasId, + a.budget, + a.childAllowance, + a.inputPtr, + a.inputLen, + a.timeUs, + }); +} + +VmProgram& VmProgram::sysRpc(RpcArgs a) { + // io[3] = SYS_RPC, io[4..10] = the seven args in order. + return hostxv(SYS_RPC, { + a.hostCell, + a.hostLen, + a.port, + a.fileHead, + a.followup, + a.budget, + a.tag, + }); +} + +VmProgram& VmProgram::syscall( + uint64_t syscallNum, + std::initializer_list> slots) { + set(Imm(3), Imm(syscallNum)); + for (const auto& [slot, val] : slots) { + set(Imm(slot), val); + } + return hostx(); +} + +VmProgram& VmProgram::hostv(uint64_t syscallNum, + std::initializer_list args) { + if (args.size() > CESVM_MAX_HOSTV_ARGS) { + throw VmProgramError( + "vmprogram: hostv arg count " + std::to_string(args.size()) + + " exceeds CESVM_MAX_HOSTV_ARGS (" + + std::to_string(CESVM_MAX_HOSTV_ARGS) + ")"); + } + emitOpcode(OP_HOSTV); + emitVal(Imm(syscallNum)); + emitVal(Imm(args.size())); + for (const auto& a : args) emitVal(a); + return *this; +} + +VmProgram& VmProgram::hostxv(uint64_t syscallNum, + std::initializer_list args) { + if (args.size() > CESVM_MAX_HOSTV_ARGS) { + throw VmProgramError( + "vmprogram: hostxv arg count " + std::to_string(args.size()) + + " exceeds CESVM_MAX_HOSTV_ARGS (" + + std::to_string(CESVM_MAX_HOSTV_ARGS) + ")"); + } + emitOpcode(OP_HOSTXV); + emitVal(Imm(syscallNum)); + emitVal(Imm(args.size())); + for (const auto& a : args) emitVal(a); + return *this; +} + +// =========================================================================== +// Build-out +// =========================================================================== + +AssetData VmProgram::buildBootBlock() { + if (baseOffset_ != 0) { + throw VmProgramError( + "vmprogram: buildBootBlock with nonzero base offset (a boot block " + "runs at address 0; base-offset code belongs in a bundle body)"); + } + resolveLabelsOrThrow(); + AssetData out{}; + if (code_.size() > out.size()) { + throw VmProgramError( + "vmprogram: built code is " + std::to_string(code_.size()) + + " bytes, exceeds AssetData boot-block size (" + + std::to_string(out.size()) + ") — use buildBytes() + SYS_LOAD_CODE " + "for programs larger than one asset content block"); + } + std::memcpy(out.data(), code_.data(), code_.size()); + // Trailing bytes are zero-initialized by the AssetData constructor. + return out; +} + +ces::Bytes VmProgram::buildBytes() { + resolveLabelsOrThrow(); + if (code_.size() > CESVM_MAX_CODE) { + throw VmProgramError( + "vmprogram: built code is " + std::to_string(code_.size()) + + " bytes, exceeds CESVM_MAX_CODE (" + std::to_string(CESVM_MAX_CODE) + + ")"); + } + return code_; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/util/wallet.cpp b/vendor/ces/src/ceslib/util/wallet.cpp new file mode 100644 index 0000000..bacd3e9 --- /dev/null +++ b/vendor/ces/src/ceslib/util/wallet.cpp @@ -0,0 +1,410 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +LOG_MODULE("ceslib"); + +namespace ces { + +// mineOnce polling knobs. Kept local to this TU; the blocking client uses its +// own configurable retry interval (CesClient::setRetryIntervalMs) for network +// retries. +static constexpr int MINE_POLL_INTERVAL_MS = 3000; +static constexpr int MINE_UNKNOWN_RESPONSE_RETRIES = 3; + + +// ============================================================================= +// Wallet — key loading +// ============================================================================= + +KeyPair Wallet::loadKey(const std::string& hex) { + if (hex.length() == 66) { + uint8_t type = + static_cast(strtol(hex.substr(0, 2).c_str(), nullptr, 16)); + minx::Hash rawPriv; + minx::stringToHash(rawPriv, hex.substr(2)); + if (type == 0x01) + return KeyPair(rawPriv, KeyAlgo::SECP256K1); + return KeyPair(rawPriv, KeyAlgo::ED25519); + } + minx::Hash rawPriv; + minx::stringToHash(rawPriv, hex); + return KeyPair(rawPriv, KeyAlgo::ED25519); +} + +std::string Wallet::algoPrefix(KeyAlgo algo) { + return (algo == KeyAlgo::SECP256K1) ? "01" : "00"; +} + +const char* Wallet::algoLabel(const KeyPair& kp) { + return (kp.getAlgorithm() == KeyAlgo::SECP256K1) ? "[SECP]" : "[ED ]"; +} + +// ============================================================================= +// Wallet — file I/O +// ============================================================================= + +void Wallet::loadFromFile(const fs::path& path) { + std::ifstream f(path); + if (!f.is_open()) + throw std::runtime_error("Cannot open wallet file: " + path.string()); + std::string line; + while (std::getline(f, line)) { + if (line.empty()) continue; + // Format: KEY [LABEL] — label is everything after the first space + auto sp = line.find(' '); + if (sp != std::string::npos) + addKeyUnique(line.substr(0, sp), line.substr(sp + 1)); + else + addKeyUnique(line); + } +} + +void Wallet::loadFromString(const std::string& colonSeparated) { + std::stringstream ss(colonSeparated); + std::string s; + while (std::getline(ss, s, ':')) + if (!s.empty()) + addKeyUnique(s); // no labels in colon-separated format +} + +void Wallet::saveToFile(const fs::path& path) const { + fs::create_directories(path.parent_path()); + // Create the file empty and tighten its permissions to owner-only BEFORE + // writing any key bytes, so the private keys never sit in a world-readable + // file (re-opening an existing file to truncate does not reset its mode). + { + std::ofstream create(path, std::ios::trunc); + if (!create.is_open()) + throw std::runtime_error("Cannot write wallet file: " + path.string()); + } + auto permErr = setSecurePermission(path); + if (!permErr.empty()) + throw std::runtime_error("Failed to set wallet file permissions: " + permErr); + + std::ofstream f(path, std::ios::trunc); + if (!f.is_open()) + throw std::runtime_error("Cannot write wallet file: " + path.string()); + for (size_t i = 0; i < keys_.size(); ++i) { + f << keys_[i]; + if (i < labels_.size() && !labels_[i].empty()) + f << " " << labels_[i]; + f << "\n"; + } + f.flush(); +} + +// ============================================================================= +// Wallet — key management +// ============================================================================= + +int Wallet::generate(int count, KeyAlgo algo, const std::string& label) { + if (count < 1) + count = 1; + int firstNew = size(); + std::string prefix = algoPrefix(algo); + for (int i = 0; i < count; ++i) { + KeyPair k(algo); + addKeyUnique(prefix + k.getPrivateKeyHexStr(), label); + } + return firstNew; +} + +bool Wallet::addKey(const std::string& hex, KeyAlgo defaultAlgo, + const std::string& label) { + int before = size(); + if (hex.length() == 66) { + addKeyUnique(hex, label); + } else if (hex.length() == 64) { + addKeyUnique(algoPrefix(defaultAlgo) + hex, label); + } else { + throw std::runtime_error( + "Key must be 64 hex chars (raw) or 66 hex chars (qualified)"); + } + return size() > before; +} + +// ============================================================================= +// Wallet — key resolution +// ============================================================================= + +std::string Wallet::resolveKey(const std::string& input) const { + if (input.empty()) + return ""; + if (input[0] == '@') { + if (input.size() == 1) + throw std::runtime_error("Invalid wallet index: @"); + try { + int index = std::stoi(input.substr(1)); + if (index < 0 || index >= size()) + throw std::runtime_error("Index bounds"); + return loadKey(keys_[index]).getPublicKeyHexStr(); + } catch (...) { + throw std::runtime_error("Invalid wallet index: " + input); + } + } + return input; +} + +KeyPair Wallet::resolveActor(const std::string& actorArg) const { + if (!actorArg.empty()) { + std::string target = resolveKey(actorArg); + for (auto& k : keys_) { + KeyPair kp = loadKey(k); + if (kp.getPublicKeyHexStr() == target) + return kp; + } + throw std::runtime_error("Actor key not found in wallet"); + } + if (!keys_.empty()) + return loadKey(keys_[0]); + throw std::runtime_error("No keys in wallet. Use 'keys gen' first"); +} + +int Wallet::findByLabel(const std::string& label) const { + for (size_t i = 0; i < labels_.size(); ++i) + if (labels_[i] == label) + return static_cast(i); + return -1; +} + +void Wallet::addKeyUnique(const std::string& key, const std::string& label) { + // Validate at insertion so callers get a clean error at load time, + // not a terminate() from deep inside loadKey() during the first sign. + try { + (void)loadKey(key); + } catch (const std::exception&) { + throw std::runtime_error("invalid wallet key: \"" + key + "\""); + } + if (std::find(keys_.begin(), keys_.end(), key) == keys_.end()) { + keys_.push_back(key); + labels_.push_back(label); + } +} + +// ============================================================================= +// Parsing helpers +// ============================================================================= + +bool is32ByteHex(const std::string& s) { + if (s.length() != 64) + return false; + for (char c : s) { + if (!std::isxdigit(static_cast(c))) + return false; + } + return true; +} + +namespace { + +// If `input` is exactly 64 hex chars, decode as a 32-byte hash. +std::optional tryParseHashHex(const std::string& input) { + if (!is32ByteHex(input)) return std::nullopt; + Hash h{}; + auto bytes = parseHex(input); + std::copy(bytes.begin(), bytes.end(), h.begin()); + return h; +} + +// "Smart string" parser shared by asset-key (N=32) and asset-content +// (N=210): if input parses as a 32-byte hex hash, the output holds +// those 32 bytes followed by zeros; otherwise input is treated as +// raw bytes (capped at N) followed by zeros. +template +std::array parseHashOrText(const std::string& input) { + static_assert(N >= 32, "parseHashOrText assumes N can hold a hash"); + std::array data{}; + if (input.empty()) return data; + if (auto h = tryParseHashHex(input)) { + std::copy(h->begin(), h->end(), data.begin()); + return data; + } + if (input.size() > N) + throw std::runtime_error("Input string exceeds limit (" + + std::to_string(N) + " bytes)"); + std::copy(input.begin(), input.end(), data.begin()); + return data; +} + +} // namespace + +Hash parseAssetKey(const std::string& input) { + return parseHashOrText<32>(input); +} + +AssetData parseAssetContent(const std::string& input) { + return parseHashOrText<210>(input); +} + +AssetData parseHexContent(const std::string& hexStr) { + auto bytes = parseHex(hexStr); + if (bytes.size() > 210) + throw std::runtime_error("Hex content exceeds 210 bytes (420 hex chars)"); + AssetData data; + data.fill(0); + std::copy(bytes.begin(), bytes.end(), data.begin()); + return data; +} + +std::string contentToDisplayString(const AssetData& data) { + // Heuristic: detect pure printable-ASCII with a NUL-terminator pattern + // and render as text. Otherwise emit the full content as hex (210 bytes + // = 420 chars), no truncation, so binary asset content round-trips + // losslessly. + bool isText = true; + size_t len = 0; + for (auto c : data) { + if (c == 0) + break; + if (c < 32 || c > 126) + isText = false; + len++; + } + if (len > 0 && isText) + return std::string(reinterpret_cast(data.data()), len); + + std::stringstream ss; + ss << std::hex << std::setfill('0'); + for (size_t i = 0; i < data.size(); ++i) + ss << std::setw(2) << static_cast(data[i]); + return ss.str(); +} + +// ============================================================================= +// ClientSession +// ============================================================================= + +ClientSession::ClientSession(bool cacheOnly, uint16_t port, + const boost::asio::ip::udp::endpoint& ep, + const KeyPair* kp, int tries) + : isTcp(false) { + client_.emplace(ep, !cacheOnly); + client_->start(port); + client_->setTries(tries); + if (kp) + client_->setKey(*kp); + if (!client_->connect()) + throw std::runtime_error("Connect failed"); +} + +ClientSession::ClientSession(bool cacheOnly, + const boost::asio::ip::tcp::endpoint& proxyEp, + const KeyPair* kp, int tries) + : isTcp(true) { + client_.emplace(proxyEp, !cacheOnly); + client_->start(0); + client_->setTries(tries); + if (kp) + client_->setKey(*kp); + if (!client_->connect()) + throw std::runtime_error("Connect through proxy failed"); +} + +ClientSession::ClientSession(bool cacheOnly, const std::string& serverStr, + const KeyPair* kp, int tries) { + // CesClient is non-movable, so we hold it inside an std::optional and + // emplace it AFTER probing — no default-construct-then-replace dance. + auto probe = Resolver::probe(serverStr); + isTcp = probe.isTcp; + if (probe.isTcp) + client_.emplace(probe.tcpEp, !cacheOnly); + else + client_.emplace(probe.udpEp, !cacheOnly); + + client_->start(0); + client_->setTries(tries); + if (kp) + client_->setKey(*kp); + if (!client_->connect()) + throw std::runtime_error( + std::string("Connect failed (") + (probe.isTcp ? "TCP" : "UDP") + ")"); +} + +ClientSession::~ClientSession() { + if (client_) { + client_->disconnect(); + client_->stop(); + } +} + +// ============================================================================= +// Mining helper +// ============================================================================= + +MineResult mineOnce(CesClient& client, int extraDifficulty, + const std::map& appData, + int numThreads, + std::function statusCallback, + std::function progressCallback, + uint64_t chunkIters) { + std::optional w; + if (progressCallback && chunkIters > 0) { + // Bounded-window search so a long solve reports live progress. mine() + // returns nullopt when a window is exhausted (continue, next window) OR on a + // ticket/engine failure (returns ~instantly without hashing); distinguish by + // the call's wall time and bail after a few instant failures. + // MinxProveWork is move-constructible but not move-assignable (const member), + // so populate the optional with emplace (construction), never assignment. + uint64_t nonce = 0; + int instantFails = 0; + while (ces::notInterrupted()) { + auto t0 = std::chrono::steady_clock::now(); + auto found = client.mine(extraDifficulty, appData, numThreads, nonce, + chunkIters); + if (found) { w.emplace(std::move(*found)); break; } + double secs = std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + if (secs < 0.02) { + if (++instantFails >= 5) return {false, 0, -1}; + } else { + instantFails = 0; + nonce += chunkIters; + progressCallback(nonce); + } + } + if (!w) return {false, 0, 0}; + } else { + auto found = client.mine(extraDifficulty, appData, numThreads); + if (!found) + return {false, 0, -1}; + w.emplace(std::move(*found)); + } + + int attempts = 0; + while (ces::notInterrupted()) { + minx::Hash b; + uint64_t credit, t; + int r = client.proveWork(*w, b, credit, t); + + if (statusCallback) + statusCallback(r); + + if (r == minx::MINX_SOLUTION_SPENT) + return {true, credit, r}; + + if (r == minx::MINX_SOLUTION_UNTIMELY) + return {false, 0, r}; + + if (r == minx::MINX_SOLUTION_UNKNOWN) { + if (++attempts > MINE_UNKNOWN_RESPONSE_RETRIES) + return {false, 0, r}; + } + + ces::sleep(MINE_POLL_INTERVAL_MS); + } + + return {false, 0, 0}; +} + +} // namespace ces diff --git a/vendor/ces/src/ceslib/webadmin.cpp b/vendor/ces/src/ceslib/webadmin.cpp new file mode 100644 index 0000000..f427ec1 --- /dev/null +++ b/vendor/ces/src/ceslib/webadmin.cpp @@ -0,0 +1,3186 @@ +/** + * webadmin.cpp — CES server localhost web dashboard. + * + * See ces/webadmin.h for the security model (loopback + SSH tunnel, no auth) + * and architecture (one acceptor + per-connection session, like Cesco). + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +LOG_MODULE("web"); + +namespace ces { + +// ============================================================================= +// LogRing — bounded in-memory tail of recent log lines +// ============================================================================= + +LogRing& LogRing::instance() { + static LogRing ring; + return ring; +} + +void LogRing::push(std::string text) { + std::lock_guard lock(mu_); + Line ln; + ln.seq = nextSeq_++; + ln.ts = minx::getSecsSinceEpoch(); + ln.text = std::move(text); + lines_.push_back(std::move(ln)); + while (lines_.size() > kCap) + lines_.pop_front(); +} + +std::vector LogRing::since(uint64_t sinceSeq, + uint64_t& outHi) const { + std::lock_guard lock(mu_); + std::vector out; + for (const auto& l : lines_) + if (l.seq > sinceSeq) + out.push_back(l); + outHi = nextSeq_ - 1; + return out; +} + +// ============================================================================= +// Boost.Log sink → LogRing +// +// blog is a Boost.Log facade with no sink hook, but the core takes custom +// sinks. We add a text_ostream sink whose stream is backed by a streambuf +// that splits on '\n' and pushes each finished line into the ring. The +// formatter mirrors the console one's attribute extraction (minus color and +// timestamp — the ring stamps unix time at push) so VAR(x) fields show up. +// ============================================================================= + +namespace { + +class RingStreambuf : public std::streambuf { +protected: + int overflow(int c) override { + if (c == EOF) return c; + char ch = static_cast(c); + if (ch == '\n') flushLine(); + else cur_.push_back(ch); + return c; + } + std::streamsize xsputn(const char* s, std::streamsize n) override { + for (std::streamsize i = 0; i < n; ++i) { + if (s[i] == '\n') flushLine(); + else cur_.push_back(s[i]); + } + return n; + } + +private: + void flushLine() { + if (!cur_.empty()) { + LogRing::instance().push(std::move(cur_)); + cur_.clear(); + } + } + std::string cur_; +}; + +void webLogFormatter(boost::log::record_view const& rec, + boost::log::formatting_ostream& strm) { + auto sev = rec[boost::log::trivial::severity]; + if (sev) { + switch (sev.get()) { + case blog::trace: strm << "TRC"; break; + case blog::debug: strm << "DBG"; break; + case blog::info: strm << "INF"; break; + case blog::warning: strm << "WRN"; break; + case blog::error: strm << "ERR"; break; + case blog::fatal: strm << "FTL"; break; + default: strm << "LOG"; break; + } + strm << ' '; + } + if (auto ch = boost::log::extract("Channel", rec)) { + const std::string& c = ch.get(); + if (!c.empty()) strm << c << ' '; + } + if (auto msg = boost::log::extract("Message", rec)) + strm << msg.get(); + + for (auto const& a : rec.attribute_values()) { + const std::string& name = a.first.string(); + if (name == "Severity" || name == "Message" || name == "Channel" || + name == "TimeStamp" || name == "File" || name == "Line" || + name == "Inst") + continue; + strm << ' ' << name << '='; + if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract>(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract>(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract>(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract>(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << static_cast(v.get()); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << static_cast(v.get()); + else if (auto v = boost::log::extract(name, rec)) strm << (v.get() ? "true" : "false"); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else if (auto v = boost::log::extract(name, rec)) strm << v.get(); + else strm << "[?]"; + } + + if (auto file = boost::log::extract("File", rec)) { + if (auto line = boost::log::extract("Line", rec)) + strm << ' ' << boost::filesystem::path(file.get()).filename().string() + << ':' << line.get(); + } +} + +using text_sink = + boost::log::sinks::synchronous_sink; +boost::shared_ptr g_logSink; + +void installLogSink() { + if (g_logSink) return; + static RingStreambuf ringbuf; // process-lifetime; sink may detach/reattach + auto backend = boost::make_shared(); + backend->add_stream(boost::shared_ptr(new std::ostream(&ringbuf))); + backend->auto_flush(true); + auto sink = boost::make_shared(backend); + sink->set_formatter(&webLogFormatter); + boost::log::core::get()->add_sink(sink); + g_logSink = sink; +} + +void removeLogSink() { + if (!g_logSink) return; + boost::log::core::get()->remove_sink(g_logSink); + g_logSink.reset(); +} + +uint64_t g_webStartUnix = 0; + +// Worker threads for the blocking remote ops (inspect/mine). They hold a +// CesServer& and a session shared_ptr, so they must finish before the server +// is torn down — joined in WebAdmin::stop() rather than detached. A mine +// respects ces::notInterrupted(), so a Ctrl-C unwinds these promptly. +std::mutex g_workerMu; +std::vector g_workers; + +void addWorker(std::thread t) { + std::lock_guard lock(g_workerMu); + g_workers.push_back(std::move(t)); +} +void joinWorkers() { + std::vector ws; + { + std::lock_guard lock(g_workerMu); + ws.swap(g_workers); + } + for (auto& t : ws) + if (t.joinable()) t.join(); +} + +// --------------------------------------------------------------------------- +// Small text helpers (JSON out, form/query parse, hex) +// --------------------------------------------------------------------------- + +std::string jesc(const std::string& s) { + std::string o; + o.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': o += "\\\""; break; + case '\\': o += "\\\\"; break; + case '\n': o += "\\n"; break; + case '\r': o += "\\r"; break; + case '\t': o += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + static const char* hex = "0123456789abcdef"; + o += "\\u00"; + o += hex[(c >> 4) & 0xF]; + o += hex[c & 0xF]; + } else { + o += c; + } + } + } + return o; +} + +// JSON string literal (quoted + escaped). +std::string jstr(const std::string& s) { return "\"" + jesc(s) + "\""; } + +std::string hexOf(std::span b) { return bytesToHex(b); } +std::string hexOfHash(const minx::Hash& h) { + return bytesToHex(std::span(h.data(), h.size())); +} +std::string hexOfPrefix(const HashPrefix& p) { + return bytesToHex(std::span(p.data(), p.size())); +} + +int hexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +// Parse exactly 64 hex chars into a 32-byte hash. Returns false otherwise. +bool parseHash64(const std::string& s, minx::Hash& out) { + if (s.size() != 64) return false; + for (size_t i = 0; i < 32; ++i) { + int hi = hexNibble(s[i * 2]), lo = hexNibble(s[i * 2 + 1]); + if (hi < 0 || lo < 0) return false; + out[i] = static_cast((hi << 4) | lo); + } + return true; +} + +bool parseU64(const std::string& s, uint64_t& out) { + if (s.empty()) return false; + uint64_t v = 0; + for (char c : s) { + if (c < '0' || c > '9') return false; + if (v > (UINT64_MAX - static_cast(c - '0')) / 10) return false; + v = v * 10 + static_cast(c - '0'); + } + out = v; + return true; +} + +std::string urlDecode(const std::string& s) { + std::string o; + o.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + if (c == '+') { + o += ' '; + } else if (c == '%' && i + 2 < s.size()) { + int hi = hexNibble(s[i + 1]), lo = hexNibble(s[i + 2]); + if (hi >= 0 && lo >= 0) { o += static_cast((hi << 4) | lo); i += 2; } + else o += c; + } else { + o += c; + } + } + return o; +} + +std::map parseKV(const std::string& s) { + std::map m; + size_t i = 0; + while (i < s.size()) { + size_t amp = s.find('&', i); + std::string pair = s.substr(i, amp == std::string::npos ? std::string::npos : amp - i); + size_t eq = pair.find('='); + if (eq != std::string::npos) + m[urlDecode(pair.substr(0, eq))] = urlDecode(pair.substr(eq + 1)); + else if (!pair.empty()) + m[urlDecode(pair)] = ""; + if (amp == std::string::npos) break; + i = amp + 1; + } + return m; +} + +std::string getParam(const std::map& m, + const std::string& k) { + auto it = m.find(k); + return it == m.end() ? std::string() : it->second; +} + +// --------------------------------------------------------------------------- +// JSON endpoint builders +// --------------------------------------------------------------------------- + +// Host unicast IP addresses, non-loopback first, so a node with no serverName +// still shows where it is reachable (the bind is all-interfaces; getifaddrs is +// the only way to know the concrete addresses). Linux/POSIX; runs once per +// /api/status on the loopback-only dashboard, so cost is irrelevant. +std::vector hostIpList() { + std::vector normal, loopback; + struct ifaddrs* ifa = nullptr; + if (getifaddrs(&ifa) != 0) return normal; + for (struct ifaddrs* p = ifa; p; p = p->ifa_next) { + if (!p->ifa_addr) continue; + char buf[INET6_ADDRSTRLEN] = {0}; + bool isLoopback = false; + if (p->ifa_addr->sa_family == AF_INET) { + auto* a = reinterpret_cast(p->ifa_addr); + if (!inet_ntop(AF_INET, &a->sin_addr, buf, sizeof buf)) continue; + isLoopback = (ntohl(a->sin_addr.s_addr) & 0xFF000000u) == 0x7F000000u; + } else if (p->ifa_addr->sa_family == AF_INET6) { + auto* a = reinterpret_cast(p->ifa_addr); + if (IN6_IS_ADDR_LINKLOCAL(&a->sin6_addr)) continue; // not reachable alone + if (!inet_ntop(AF_INET6, &a->sin6_addr, buf, sizeof buf)) continue; + isLoopback = IN6_IS_ADDR_LOOPBACK(&a->sin6_addr); + } else { + continue; + } + std::string ip(buf); + auto& bucket = isLoopback ? loopback : normal; + if (std::find(bucket.begin(), bucket.end(), ip) == bucket.end()) + bucket.push_back(ip); + } + freeifaddrs(ifa); + normal.insert(normal.end(), loopback.begin(), loopback.end()); + return normal; +} + +// Emit the peer-miner activity fields (leading commas, appended to an open JSON +// object). Shared by /api/status (header bar) and /api/peers so the two cannot +// drift. miningElapsedSecs is now-startSecs, only while actively mining. +void appendMinerActivityJson(std::ostream& o, CesServer& s) { + auto act = s._peerMinerActivity(); + uint64_t now = minx::getSecsSinceEpoch(); + uint64_t elapsed = (act.mining && act.startSecs && now >= act.startSecs) + ? (now - act.startSecs) : 0; + o << ",\"minerRunning\":" << (s._peerMinerRunning() ? "true" : "false") + << ",\"lastCycle\":" << s._peerMinerLastCycle() + << ",\"cycles\":" << s._peerMinerCycles() + << ",\"miningActive\":" << (act.mining ? "true" : "false") + << ",\"miningPeer\":" << jstr(act.peer) + << ",\"miningDifficulty\":" << static_cast(act.difficulty) + << ",\"miningElapsedSecs\":" << elapsed + << ",\"hashRate\":" << static_cast(act.hashRate) + << ",\"hashesTried\":" << act.hashesTried + << ",\"expectedHashes\":" << act.expectedHashes; +} + +std::string buildStatus(CesServer& s) { + const CesConfig& c = s._config(); + auto stats = s._adminStats(); + std::ostringstream o; + uint64_t now = minx::getSecsSinceEpoch(); + uint64_t uptime = (g_webStartUnix && now >= g_webStartUnix) + ? now - g_webStartUnix : 0; + o << "{"; + o << "\"pubkey\":" << jstr(hexOfHash(s._serverKeyPair().getPublicKeyAsHash())); + o << ",\"serverName\":" << jstr(c.serverName); + o << ",\"boundAddrs\":["; + { + auto ips = hostIpList(); + for (size_t i = 0; i < ips.size(); ++i) { + if (i) o << ","; + o << jstr(ips[i]); + } + } + o << "]"; + o << ",\"version\":" << jstr(c.version); + o << ",\"port\":" << s._boundPort(); + o << ",\"rpcPort\":" << s._rpcBoundPort(); + o << ",\"now\":" << now; + o << ",\"uptime\":" << uptime; + o << ",\"circulating\":" << stats.circulating; + o << ",\"accounts\":" << stats.accounts; + o << ",\"assets\":" << stats.assets; + o << ",\"aliases\":" << stats.aliases; + o << ",\"txCount\":" << stats.txCount; + o << ",\"tps\":" << s.getTps(); + o << ",\"minDifficulty\":" << static_cast(c.minDiff); + o << ",\"gauges\":{" + << "\"l1cpu\":" << s.getL1cpuBp() + << ",\"l2cpu\":" << s.getL2cpuBp() + << ",\"l1memac\":" << s.getL1memacBp() + << ",\"l1memas\":" << s.getL1memasBp() + << ",\"l2mem\":" << s.getL2memBp() + << ",\"net\":" << s.getNetBp() << "}"; + o << ",\"features\":{" + << "\"rpc\":" << (s._rpcBoundPort() != 0 ? "true" : "false") + << ",\"file\":" << (s.fileHandler() != nullptr ? "true" : "false") + << ",\"compute\":" << (s.computeHandler() != nullptr ? "true" : "false") + << ",\"peering\":" << (s._peerTarget() > 0 ? "true" : "false") + << ",\"powReady\":" << (s.isPoWEngineReady() ? "true" : "false") << "}"; + o << ",\"peerCount\":" << s._peerSnapshot().size(); + o << ",\"peerTarget\":" << s._peerTarget(); + appendMinerActivityJson(o, s); + o << ",\"hello\":" << jstr(s._getHello()); + o << "}"; + return o.str(); +} + +std::string buildPeers(CesServer& s) { + auto peers = s._peerSnapshot(); + // The vostro half of each pair: the peer's account on THIS server (what we owe + // them). Looked up from the ledger, not the peer table. + std::vector peerKeys; + peerKeys.reserve(peers.size()); + for (const auto& p : peers) peerKeys.push_back(p.ckey); + auto vostro = s._peerVostroBalances(peerKeys); + std::ostringstream o; + o << "{\"target\":" << s._peerTarget() + << ",\"maxPeers\":" << s._maxPeers(); + appendMinerActivityJson(o, s); + o << ",\"peers\":["; + bool first = true; + for (size_t i = 0; i < peers.size(); ++i) { + const auto& p = peers[i]; + // A banned peer is a RAM-only tombstone; it is hidden everywhere else + // (ces.peers(), dial, bind), so omit it from the dashboard too. The maintenance + // pass removes it when the ban expires. + if (p.bannedUntil != 0 && minx::getSecsSinceEpoch() < p.bannedUntil) continue; + if (!first) o << ","; + first = false; + o << "{\"key\":" << jstr(hexOfHash(p.ckey)) + << ",\"address\":" << jstr(p.declaredAddress) + << ",\"resolvedIP\":" << jstr(p.resolvedIP) + << ",\"outbound\":" << (p.outbound ? "true" : "false") + << ",\"inbound\":" << (p.inbound ? "true" : "false") + << ",\"reachable\":" << (p.reachable ? "true" : "false") + << ",\"verified\":" << (p.verified ? "true" : "false") + << ",\"ourBalanceThere\":" << p.ourBalanceThere + << ",\"theirBalanceHere\":" << (i < vostro.size() ? vostro[i] : 0) + << ",\"totalInboundPoW\":" << p.totalInboundPoW + << ",\"totalOutboundPoW\":" << p.totalOutboundPoW + << ",\"lastInboundTime\":" << p.lastInboundTime + << ",\"lastCheckTime\":" << p.lastCheckTime + << ",\"pingFailures\":" << p.pingFailures + << ",\"grief\":" << p.grief; + // /ces/peer/1 mesh link state: "up" if a channel is live now; "down" + // if the peer is dialable (advertises a plex port) but unlinked; + // "none" if it advertises no plex port (the mesh can't reach it). + const bool linked = s.peerHandler() && s.peerHandler()->isLinked(p.ckey); + const char* rpcLink = + linked ? "up" : (p.rpcPort == 0 ? "none" : "down"); + o << ",\"rpcLink\":" << jstr(rpcLink) << "}"; + } + o << "]}"; + return o.str(); +} + +std::string buildNetbill(CesServer& s) { + std::ostringstream o; + auto* nb = s._channelMeter(); + o << "{\"active\":" << (nb ? "true" : "false") << ",\"rows\":["; + if (nb) { + auto rows = nb->snapshot(); + for (size_t i = 0; i < rows.size(); ++i) { + const auto& r = rows[i]; + if (i) o << ","; + std::ostringstream peer; + peer << r.peer; + o << "{\"peer\":" << jstr(peer.str()) + << ",\"channelId\":" << r.channelId + << ",\"tag\":" << jstr(r.tag) + << ",\"payer\":" << jstr(hexOfPrefix(r.payerPfx)) + << ",\"bytesSent\":" << r.metrics.bytesSent + << ",\"bytesReceived\":" << r.metrics.bytesReceived + << ",\"memByteSec\":" << r.metrics.memoryByteSeconds + << ",\"dSent\":" << r.deltaBytesSent + << ",\"dRecv\":" << r.deltaBytesReceived + << ",\"dMemByteSec\":" << r.deltaMemByteSeconds + << ",\"dAge\":" << r.deltaAgeSec << "}"; + } + } + o << "]}"; + return o.str(); +} + +std::string buildConfig(CesServer& s) { + const CesConfig& c = s._config(); + auto kv = [](std::ostringstream& o, const char* k, uint64_t v, bool first) { + if (!first) o << ","; + o << jstr(k) << ":" << v; + }; + std::ostringstream o; + o << "{\"knobs\":{"; + kv(o, "feeAccount", c.feeAccount, true); + kv(o, "feeAsset", c.feeAsset, false); + kv(o, "feeTx", c.feeTx, false); + kv(o, "feeQuery", c.feeQuery, false); + kv(o, "feeVmMult", c.feeVmMult, false); + kv(o, "minDifficulty", c.minDiff, false); + kv(o, "spendSlotSize", c.spendSlotSize, false); + kv(o, "taskThreads", static_cast(c.taskThreads), false); + kv(o, "maxLogBytes", c.maxLogBytes, false); + kv(o, "minAccounts", c.minAcc, false); + kv(o, "maxAccounts", c.maxAcc, false); + kv(o, "minAssets", c.minAsset, false); + kv(o, "maxAssets", c.maxAsset, false); + kv(o, "minAliases", c.minAlias, false); + kv(o, "maxAliases", c.maxAlias, false); + kv(o, "cesFileStoreMaxBytes", c.cesFileStoreMaxBytes, false); + kv(o, "computeMaxInstances", c.computeMaxInstances, false); + kv(o, "computePortBase", c.computePortBase, false); + kv(o, "computePortCount", c.computePortCount, false); + kv(o, "computeClientPoolSize", static_cast(c.computeClientPoolSize), false); + kv(o, "feeNetKiBSent", c.feeNetKiBSent, false); + kv(o, "feeNetKiBReceived", c.feeNetKiBReceived, false); + kv(o, "feeNetChannelSec", c.feeNetChannelSec, false); + kv(o, "feeNetMemByteDay", c.feeNetMemByteDay, false); + kv(o, "feeFileRent", static_cast(c.feeFileRent), false); + kv(o, "feeFileWrite", static_cast(c.feeFileWrite), false); + kv(o, "feeFileRead", static_cast(c.feeFileRead), false); + kv(o, "feeComputeSlotSec", static_cast(c.feeComputeSlotSec), false); + kv(o, "feeComputeCpuSec", static_cast(c.feeComputeCpuSec), false); + kv(o, "feeComputeRssByteDay", static_cast(c.feeComputeRssByteDay), false); + kv(o, "feeComputeNetByte", static_cast(c.feeComputeNetByte), false); + kv(o, "feeBucketByteSec", static_cast(c.feeBucketByteSec), false); + o << "},\"feeDiscountEnabled\":" << (c.feeDiscountEnabled ? "true" : "false"); + // Per-FeeKind live discount multipliers (basis points 0..10000). + static const std::pair kinds[] = { + {"Tx", FeeKind::Tx}, {"Query", FeeKind::Query}, + {"AccountRent", FeeKind::AccountRent}, {"AssetRent", FeeKind::AssetRent}, + {"VMMult", FeeKind::VMMult}, {"ComputeSlot", FeeKind::ComputeSlot}, + {"ComputeCpu", FeeKind::ComputeCpu}, {"ComputeRss", FeeKind::ComputeRss}, + {"BucketByteSec", FeeKind::BucketByteSec}, {"Net", FeeKind::Net}, + }; + o << ",\"multipliers\":{"; + bool first = true; + for (auto& [name, k] : kinds) { + if (!first) o << ","; + first = false; + o << jstr(name) << ":" << s.getFeeMult(k); + } + o << "}}"; + return o.str(); +} + +// File-store monitoring: feature flag, cap, and live usage from .store.toml. +std::string buildFileStore(CesServer& s) { + const CesConfig& c = s._config(); + std::ostringstream o; + uint64_t files = 0, bytes = 0; + // Enabled = the file handler is mounted (wired in [cesplex_mounts]); the cap + // is a separate knob, not the on/off switch. Stats are best-effort on top. + bool enabled = s.fileHandler() != nullptr; + if (enabled) s.fileHandler()->storeStats(files, bytes); + o << "{\"enabled\":" << (enabled ? "true" : "false") + << ",\"maxBytes\":" << c.cesFileStoreMaxBytes + << ",\"totalFiles\":" << files + << ",\"totalBytes\":" << bytes + << ",\"dir\":" << jstr(c.cesFileStoreDir) << "}"; + return o.str(); +} + +// Compute monitoring: feature flags, port range, and a snapshot of every +// running instance (CPU basis points, RSS, uptime, assigned ports). +std::string buildCompute(CesServer& s) { + const CesConfig& c = s._config(); + std::ostringstream o; + o << "{\"enabled\":" << (s.computeHandler() != nullptr ? "true" : "false") + << ",\"maxInstances\":" << c.computeMaxInstances + << ",\"portBase\":" << c.computePortBase + << ",\"portCount\":" << c.computePortCount + << ",\"processMemMax\":" << c.computeProcessMemMax + << ",\"instances\":["; + auto insts = s.computeHandler() ? s.computeHandler()->snapshot() + : std::vector{}; + bool first = true; + for (auto& i : insts) { + if (!first) o << ","; + first = false; + o << "{\"pid\":" << i.pid + << ",\"source\":" << jstr(i.source) + << ",\"cpuBp\":" << i.cpuBasisPoints + << ",\"rssBytes\":" << i.rssBytes + << ",\"uptimeSecs\":" << i.uptimeSecs + << ",\"clientPort\":" << i.clientPort + << ",\"rpcPort\":" << i.rpcPort << "}"; + } + o << "]}"; + return o.str(); +} + +// Global extension funding budget: the rate in effect + the live remaining +// allowance (both raw units; the dashboard divides by PRICE_UNIT to show credits). +std::string buildFunding(CesServer& s) { + std::ostringstream o; + o << "{\"perDay\":" << s.extFundingPerDay() + << ",\"remaining\":" << s.extFundingRemaining() + << ",\"localBudget\":" << s.extLocalBudget() << "}"; + return o.str(); +} + +std::string buildExtensions(CesServer& s) { + std::ostringstream o; + // Extensions need both the file handler (deploy/read /s/) and the compute + // handler (run them); enabled only when both are mounted. + bool enabled = s.fileHandler() != nullptr && s.computeHandler() != nullptr; + o << "{\"enabled\":" << (enabled ? "true" : "false") + << ",\"catalog\":" << (s._config().cesExtensionsDir.empty() ? "false" : "true") + << ",\"items\":["; + bool first = true; + for (auto& it : extensionList(&s)) { + if (!first) o << ","; + first = false; + o << "{\"name\":" << jstr(it.name) + << ",\"displayName\":" << jstr(it.displayName) + << ",\"available\":" << (it.available ? "true" : "false") + << ",\"installed\":" << (it.installed ? "true" : "false") + << ",\"enabled\":" << (it.enabled ? "true" : "false") + // Single canonical lifecycle state (precedence enabled>installed>available). + // The UI renders buttons from THIS, never from the raw booleans, so a + // contradictory button set (e.g. Install + Disable) is impossible by + // construction. `enabled` already implies `installed` (enforced upstream). + << ",\"state\":\"" + << (it.enabled ? "enabled" : it.installed ? "installed" : it.available ? "available" : "gone") + << "\"" + << ",\"pid\":" << it.pid + << ",\"isExtension\":" << (it.isExtension ? "true" : "false") + << ",\"caps\":" << static_cast(it.caps) + << ",\"version\":" << jstr(it.version) + << ",\"description\":" << jstr(it.description) + << ",\"commands\":["; + bool cf = true; + for (auto& cmd : it.commands) { + if (!cf) o << ","; + cf = false; + o << "{\"id\":" << jstr(cmd.first) << ",\"label\":" << jstr(cmd.second) << "}"; + } + o << "]}"; + } + o << "]}"; + return o.str(); +} + +std::string buildExtensionStatus(CesServer& s, const std::string& name) { + std::vector> kv; + bool ok = extensionStatus(&s, name, kv); + std::ostringstream o; + o << "{\"ok\":" << (ok ? "true" : "false") << ",\"kv\":["; + bool f = true; + for (auto& p : kv) { + if (!f) o << ","; + f = false; + o << "[" << jstr(p.first) << "," << jstr(p.second) << "]"; + } + o << "]}"; + return o.str(); +} + +std::string buildExtensionConfig(CesServer& s, const std::string& name) { + return std::string("{\"text\":") + jstr(extensionConfigGet(&s, name)) + "}"; +} + +std::string buildAccount(CesServer& s, const std::string& keyHex) { + minx::Hash key; + if (!parseHash64(keyHex, key)) + return "{\"error\":\"key must be 64 hex chars\"}"; + auto a = s._adminQueryAccount(key); + std::ostringstream o; + o << "{\"exists\":" << (a.exists ? "true" : "false") + << ",\"prefixTaken\":" << (a.prefixTaken ? "true" : "false"); + if (a.exists) { + o << ",\"balance\":" << a.balance + << ",\"nonce\":" << a.nonce + << ",\"lastXferDest\":" << jstr(hexOfPrefix(a.lastXferDest)) + << ",\"lastXferAmount\":" << a.lastXferAmount + << ",\"lastXferTime\":" << a.lastXferTime; + } + o << "}"; + return o.str(); +} + +std::string buildAsset(CesServer& s, const std::string& keyHex) { + minx::Hash key; + if (!parseHash64(keyHex, key)) + return "{\"error\":\"key must be 64 hex chars\"}"; + auto a = s._adminQueryAsset(key); + std::ostringstream o; + o << "{\"exists\":" << (a.exists ? "true" : "false"); + if (a.exists) { + uint16_t days = a.balance & 0x0FFF; + bool ownerPays = (a.balance & 0x1000) != 0; + bool immutable = (a.balance & 0x2000) != 0; + bool assetOwned = (a.balance & 0x4000) != 0; + bool isPrivate = (a.balance & 0x8000) != 0; + o << ",\"owner\":" << jstr(hexOfPrefix(a.owner)) + << ",\"rawBalance\":" << a.balance + << ",\"days\":" << days + << ",\"ownerPays\":" << (ownerPays ? "true" : "false") + << ",\"immutable\":" << (immutable ? "true" : "false") + << ",\"assetOwned\":" << (assetOwned ? "true" : "false") + << ",\"private\":" << (isPrivate ? "true" : "false") + << ",\"price\":" << a.price + << ",\"content\":" << jstr(hexOf(std::span( + a.content.data(), a.content.size()))); + } + o << "}"; + return o.str(); +} + +// L2 file STAT lookup (public). enabled=false when the file feature is off. +std::string buildFileStat(CesServer& s, const std::string& path) { + if (path.empty()) return "{\"error\":\"path required\"}"; + auto f = s._fileStat(path); + std::ostringstream o; + o << "{\"enabled\":" << (f.enabled ? "true" : "false") + << ",\"found\":" << (f.found ? "true" : "false"); + if (f.found) { + o << ",\"owner\":" << jstr(hexOf(std::span( + f.ownerPubkey.data(), f.ownerPubkey.size()))) + << ",\"size\":" << f.size + << ",\"fileBalance\":" << f.fileBalance + << ",\"pricePerKb\":" << f.pricePerKb + << ",\"createdUs\":" << f.createdUs + << ",\"modifiedUs\":" << f.modifiedUs; + } + o << "}"; + return o.str(); +} + +std::string buildLogsSince(uint64_t sinceSeq, uint64_t* hiOut = nullptr) { + uint64_t hi = 0; + auto lines = LogRing::instance().since(sinceSeq, hi); + std::ostringstream o; + o << "{\"hi\":" << hi << ",\"level\":" << blog::fast_min_level + << ",\"lines\":["; + for (size_t i = 0; i < lines.size(); ++i) { + if (i) o << ","; + o << "{\"seq\":" << lines[i].seq + << ",\"ts\":" << lines[i].ts + << ",\"text\":" << jstr(lines[i].text) << "}"; + } + o << "]}"; + if (hiOut) *hiOut = hi; + return o.str(); +} + +std::string buildLogs(const std::string& query) { + auto q = parseKV(query); + uint64_t sinceSeq = 0; + parseU64(getParam(q, "since"), sinceSeq); + return buildLogsSince(sinceSeq); +} + +std::string buildInspect(CesServer& s, const std::string& address, bool paid) { + if (address.empty()) return "{\"error\":\"address required\"}"; + // Free handshake by default (instant): pubkey + difficulty + reachability, + // which is all you need to add a peer. The paid server-info query only + // returns anything once you hold a balance there, and blocks on a timeout + // otherwise — so it's opt-in, not on the discovery path. + auto info = s._inspectRemoteServer(address, paid); + std::ostringstream o; + o << "{\"address\":" << jstr(address) + << ",\"reachable\":" << (info.reachable ? "true" : "false"); + if (info.reachable) { + o << ",\"serverKey\":" << jstr(hexOfHash(info.serverKey)) + << ",\"minDifficulty\":" << static_cast(info.minDifficulty) + << ",\"info\":{"; + for (size_t i = 0; i < info.entries.size(); ++i) { + if (i) o << ","; + o << jstr(info.entries[i].key) << ":" << jstr(info.entries[i].value); + } + o << "}"; + } + o << "}"; + return o.str(); +} + +std::string buildMine(CesServer& s, const std::string& address, int count) { + if (address.empty()) return "{\"error\":\"address required\"}"; + auto r = s._mineRemoteServer(address, count); + std::ostringstream o; + o << "{\"ok\":" << (r.ok ? "true" : "false") + << ",\"credit\":" << r.credit + << ",\"status\":" << r.status + << ",\"error\":" << jstr(r.error) << "}"; + return o.str(); +} + +std::string buildHello(CesServer& s) { + std::string served = s._getHello(); + bool existed = false; + s._loadHelloFile(existed); + std::ostringstream o; + o << "{\"hello\":" << jstr(served) + << ",\"fileExists\":" << (existed ? "true" : "false") + << ",\"max\":" << CesServer::HELLO_MAX_BYTES << "}"; + return o.str(); +} + +// The panel frame is already wire JSON ({"type":"render",...} or a toast); +// pass it through. A JSON object with no "type" means unavailable. +std::string buildExtensionPanel(CesServer& s, const std::string& name) { + std::string frame; + if (!extensionPanel(&s, name, frame) || frame.empty()) + return "{\"ok\":false,\"error\":\"panel unavailable\"}"; + return frame; +} + +// Case-insensitive header value from a raw HTTP header block. +std::string headerValue(const std::string& head, const std::string& nameLower) { + std::string lower = head; + for (auto& ch : lower) ch = static_cast(::tolower(ch)); + auto pos = lower.find("\r\n" + nameLower + ":"); + if (pos == std::string::npos) return ""; + size_t v = pos + 2 + nameLower.size() + 1; + size_t e = head.find("\r\n", v); + if (e == std::string::npos) e = head.size(); + std::string out = head.substr(v, e - v); + while (!out.empty() && (out.front() == ' ' || out.front() == '\t')) + out.erase(out.begin()); + while (!out.empty() && (out.back() == ' ' || out.back() == '\r')) + out.pop_back(); + return out; +} + +// Is a Host-header value (optionally :port) a loopback name? The dashboard is +// a loopback-only, no-auth server reached through an SSH tunnel; a browser +// carrying any other Host was steered here by a hostile page (DNS rebinding). +bool isLocalHostValue(std::string host) { + // strip :port ("[::1]:8080" keeps the brackets) + auto colon = host.rfind(':'); + if (colon != std::string::npos && host.find(']', colon) == std::string::npos) + host = host.substr(0, colon); + for (auto& ch : host) ch = static_cast(::tolower(ch)); + return host == "127.0.0.1" || host == "localhost" || host == "[::1]" || + host == "::1"; +} + +// Is an Origin header value a local page? Browsers attach Origin to +// cross-site POSTs and every WebSocket upgrade; non-browser clients (cesh +// scripts, curl, tests) send none, which is allowed. +bool isLocalOrigin(const std::string& origin) { + auto sep = origin.find("://"); + if (sep == std::string::npos) return false; + return isLocalHostValue(origin.substr(sep + 3)); +} + +extern const char* kDashboardHtml; // defined at the bottom of this file + +// The mene browser renderer (linked from mene::assets), served at /mene.js +// and /mene.css for the Extensions tab's panel lane. The stylesheet's design +// tokens are re-homed from :root to .menehost so they cannot collide with the +// dashboard's own :root variables; every panel mounts inside a .menehost. +const std::string& meneCssScoped() { + static const std::string css = mene::rendererCssScoped(".menehost"); + return css; +} + +} // namespace + +// ============================================================================= +// WebAdminSession +// ============================================================================= + +WebAdminSession::WebAdminSession(Socket socket, CesServer& server, + WebAdmin& admin) + : socket_(std::move(socket)), server_(server), admin_(admin) {} + +void WebAdminSession::start() { doRead(); } + +void WebAdminSession::doRead() { + auto self = shared_from_this(); + socket_.async_read_some(boost::asio::buffer(readChunk_), + [this, self](boost::system::error_code ec, size_t n) { + if (ec) return; // client closed / error — drop the session + request_.append(readChunk_.data(), n); + if (request_.size() > 4 * 1024 * 1024) { // request too large + respond(413, "text/plain", "request too large"); + return; + } + if (requestComplete()) handleRequest(); + else doRead(); + }); +} + +bool WebAdminSession::requestComplete() { + if (headerEnd_ == 0) { + auto pos = request_.find("\r\n\r\n"); + if (pos == std::string::npos) return false; + headerEnd_ = pos + 4; + // Content-Length (case-insensitive header scan over the header block). + std::string head = request_.substr(0, headerEnd_); + for (auto& ch : head) ch = static_cast(::tolower(ch)); + auto cl = head.find("content-length:"); + if (cl != std::string::npos) { + size_t p = cl + 15; + while (p < head.size() && (head[p] == ' ' || head[p] == '\t')) ++p; + uint64_t v = 0; + while (p < head.size() && head[p] >= '0' && head[p] <= '9') + v = v * 10 + (head[p++] - '0'); + contentLength_ = static_cast(v); + } + } + return request_.size() >= headerEnd_ + contentLength_; +} + +void WebAdminSession::handleRequest() { + // Request line: METHOD SP TARGET SP HTTP/x + std::string method, target; + { + size_t sp1 = request_.find(' '); + size_t sp2 = (sp1 == std::string::npos) ? std::string::npos + : request_.find(' ', sp1 + 1); + if (sp1 == std::string::npos || sp2 == std::string::npos) { + respond(400, "text/plain", "bad request"); + return; + } + method = request_.substr(0, sp1); + target = request_.substr(sp1 + 1, sp2 - sp1 - 1); + } + std::string path = target, query; + if (auto qm = target.find('?'); qm != std::string::npos) { + path = target.substr(0, qm); + query = target.substr(qm + 1); + } + + // Browser-boundary guards for a no-auth loopback server: + // * Host must be a loopback name — a public name resolving here means a + // hostile page steered the browser via DNS rebinding. + // * Origin, when a browser attaches one (cross-site POSTs, every WS + // upgrade), must be a local page — rejects CSRF and cross-origin + // sockets. Non-browser clients send no Origin and are unaffected. + { + std::string head = request_.substr(0, headerEnd_); + std::string host = headerValue(head, "host"); + if (!host.empty() && !isLocalHostValue(host)) { + respond(403, "text/plain", "forbidden: non-local Host"); + return; + } + std::string origin = headerValue(head, "origin"); + if (!origin.empty() && !isLocalOrigin(origin) && + (method == "POST" || path == "/ws")) { + respond(403, "text/plain", "forbidden: cross-origin request"); + return; + } + } + + std::string body = request_.substr(headerEnd_, contentLength_); + try { + route(method, path, query, body); + } catch (std::exception& e) { + respondJson(std::string("{\"error\":") + jstr(e.what()) + "}"); + } +} + +void WebAdminSession::route(const std::string& method, const std::string& path, + const std::string& query, const std::string& body) { + // ---- WebSocket upgrade (the push lane; see wsUpgrade) ---- + if (method == "GET" && path == "/ws") { + wsUpgrade(); + return; + } + + // ---- UI ---- + if (method == "GET" && (path == "/" || path == "/index.html")) { + respond(200, "text/html; charset=utf-8", kDashboardHtml); + return; + } + if (method == "GET" && path == "/favicon.ico") { + respond(204, "image/x-icon", ""); + return; + } + if (method == "GET" && path == "/mene.js") { + respond(200, "application/javascript; charset=utf-8", mene::rendererJs()); + return; + } + if (method == "GET" && path == "/mene.css") { + respond(200, "text/css; charset=utf-8", meneCssScoped()); + return; + } + + // ---- GET JSON ---- + if (method == "GET") { + if (path == "/api/status") { respondJson(buildStatus(server_)); return; } + if (path == "/api/peers") { respondJson(buildPeers(server_)); return; } + if (path == "/api/netbill") { respondJson(buildNetbill(server_)); return; } + if (path == "/api/config") { respondJson(buildConfig(server_)); return; } + if (path == "/api/filestore") { respondJson(buildFileStore(server_)); return; } + if (path == "/api/compute") { respondJson(buildCompute(server_)); return; } + if (path == "/api/extensions") { respondJson(buildExtensions(server_)); return; } + if (path == "/api/funding") { respondJson(buildFunding(server_)); return; } + if (path == "/api/extension_status") { + respondJson(buildExtensionStatus(server_, getParam(parseKV(query), "name"))); + return; + } + if (path == "/api/extension_config") { + respondJson(buildExtensionConfig(server_, getParam(parseKV(query), "name"))); + return; + } + if (path == "/api/extension_panel") { + respondJson(buildExtensionPanel(server_, getParam(parseKV(query), "name"))); + return; + } + if (path == "/api/logs") { respondJson(buildLogs(query)); return; } + if (path == "/api/hello") { respondJson(buildHello(server_)); return; } + if (path == "/api/account") { + respondJson(buildAccount(server_, getParam(parseKV(query), "key"))); + return; + } + if (path == "/api/asset") { + respondJson(buildAsset(server_, getParam(parseKV(query), "key"))); + return; + } + if (path == "/api/filestat") { + respondJson(buildFileStat(server_, getParam(parseKV(query), "path"))); + return; + } + } + + // ---- POST actions ---- + if (method == "POST") { + auto form = parseKV(body); + + if (path == "/api/credit" || path == "/api/debit") { + uint64_t amount = 0; + minx::Hash key; + if (!parseU64(getParam(form, "amount"), amount) || amount == 0 || + amount > static_cast(INT64_MAX)) { + respondJson("{\"error\":\"amount must be a positive integer (<= int64 max)\"}"); + return; + } + if (!parseHash64(getParam(form, "pubkey"), key)) { + respondJson("{\"error\":\"pubkey must be 64 hex chars\"}"); + return; + } + int64_t signedAmt = static_cast(amount); + if (path == "/api/credit") { + server_._brr(key, signedAmt); + respondJson(std::string("{\"ok\":true,\"message\":\"minted ") + + std::to_string(amount) + "\"}"); + } else { + server_._burn(key, signedAmt); + respondJson(std::string("{\"ok\":true,\"message\":\"burned ") + + std::to_string(amount) + "\"}"); + } + return; + } + + if (path == "/api/transfer") { + uint64_t amount = 0; + minx::Hash dest; + if (!parseU64(getParam(form, "amount"), amount) || amount == 0 || + amount > static_cast(INT64_MAX)) { + respondJson("{\"error\":\"amount must be a positive integer (<= int64 max)\"}"); + return; + } + if (!parseHash64(getParam(form, "pubkey"), dest)) { + respondJson("{\"error\":\"pubkey must be 64 hex chars\"}"); + return; + } + bool ok = server_._walletSend(dest, amount); + respondJson(ok ? std::string("{\"ok\":true,\"message\":\"sent ") + + std::to_string(amount) + "\"}" + : "{\"ok\":false,\"error\":\"server balance can't cover it\"}"); + return; + } + + if (path == "/api/snapshot") { + // Offload the wait to a worker thread; the snapshot fork runs on the + // logic strand and the cb wakes us. + runAsync([&server = server_]() -> std::string { + std::promise pr; + server.liveSnapshot([&pr](bool ok, std::string msg) { + pr.set_value(std::string("{\"ok\":") + (ok ? "true" : "false") + + ",\"message\":" + jstr(msg) + "}"); + }); + return pr.get_future().get(); + }); + return; + } + + if (path == "/api/peer_add") { + minx::Hash key; + std::string address = getParam(form, "address"); + if (!parseHash64(getParam(form, "key"), key)) { + respondJson("{\"error\":\"key must be 64 hex chars\"}"); + return; + } + if (address.empty()) { + respondJson("{\"error\":\"address required\"}"); + return; + } + // Verify the key against what the remote actually reports BEFORE adding. + // A wrong key is otherwise silent and costly: the peer is still mined + // toward, burning PoW to build a reserve under a key that isn't really + // the server's, which no cross-transfer can ever use. Free handshake on a + // worker thread (it blocks); reject a mismatch or an unreachable target. + runAsync([&server = server_, key, address]() -> std::string { + auto info = server._inspectRemoteServer(address, /*paid=*/false); + if (!info.reachable) { + return std::string("{\"error\":") + + jstr("could not reach " + address + + " to verify its key — not added") + "}"; + } + if (!(info.serverKey == key)) { + return std::string("{\"error\":") + + jstr("key mismatch: " + address + " reports " + + hexOfHash(info.serverKey) + + " — check the key; not added") + "}"; + } + server._addOutboundPeer(key, address); + return std::string( + "{\"ok\":true,\"message\":\"key verified — peer added\"}"); + }); + return; + } + + if (path == "/api/peer_remove") { + minx::Hash key; + if (!parseHash64(getParam(form, "key"), key)) { + respondJson("{\"error\":\"key must be 64 hex chars\"}"); + return; + } + bool removed = server_._removePeer(key); + respondJson(std::string("{\"ok\":") + (removed ? "true" : "false") + + ",\"message\":" + jstr(removed ? "peer removed" : "peer not found") + "}"); + return; + } + + if (path == "/api/peer_target") { + uint64_t target = 0; + if (!parseU64(getParam(form, "target"), target)) { + respondJson("{\"error\":\"target must be a non-negative integer\"}"); + return; + } + server_._setPeerTarget(target); + respondJson("{\"ok\":true,\"message\":\"target set\"}"); + return; + } + + if (path == "/api/max_peers") { + uint64_t n = 0; + if (!parseU64(getParam(form, "max_peers"), n) || n < 1) { + respondJson("{\"error\":\"max_peers must be a positive integer\"}"); + return; + } + server_._setMaxPeers(static_cast(n)); + respondJson("{\"ok\":true,\"message\":\"max peers set\"}"); + return; + } + + if (path == "/api/config_export") { + std::string err; + std::string p = server_._exportConfig(&err); + if (p.empty()) { + respondJson(std::string("{\"ok\":false,\"error\":") + + jstr(err.empty() ? "export failed (see logs)" : err) + "}"); + return; + } + respondJson(std::string("{\"ok\":true,\"path\":") + jstr(p) + "}"); + return; + } + + if (path == "/api/config_set") { + std::string key = getParam(form, "key"); + uint64_t value = 0; + if (!parseU64(getParam(form, "value"), value)) { + respondJson("{\"ok\":false,\"error\":\"value must be a non-negative integer\"}"); + return; + } + bool ok = server_._setConfigKnob(key, value); + respondJson(ok ? "{\"ok\":true}" + : "{\"ok\":false,\"error\":\"unknown or non-editable knob\"}"); + return; + } + + // ---- Extension lifecycle (dynamic; Config -> Export to persist enabled set). + if (path == "/api/extension_install" || path == "/api/extension_enable" || + path == "/api/extension_disable" || path == "/api/extension_uninstall" || + path == "/api/extension_config_reset") { + std::string name = getParam(form, "name"); + bool ok = false; + std::string err = "action failed"; + if (path == "/api/extension_install") ok = extensionInstall(&server_, name); + else if (path == "/api/extension_enable") { + std::string diag; + ok = extensionEnable(&server_, name, diag); + err = diag.empty() + ? "enable failed: the extension did not start (it crashed on launch or did not stay running)" + : ("enable failed: " + diag); + } + else if (path == "/api/extension_disable") ok = extensionDisable(&server_, name); + else if (path == "/api/extension_uninstall") ok = extensionUninstall(&server_, name); + else ok = extensionConfigReset(&server_, name); + respondJson(ok ? std::string("{\"ok\":true}") + : std::string("{\"ok\":false,\"error\":") + jstr(err) + "}"); + return; + } + if (path == "/api/extension_command") { + std::string out; + bool ok = extensionCommand(&server_, getParam(form, "name"), + getParam(form, "id"), getParam(form, "arg"), out); + respondJson(ok ? std::string("{\"ok\":true,\"result\":") + jstr(out) + "}" + : "{\"ok\":false,\"error\":\"command failed\"}"); + return; + } + if (path == "/api/extension_panel_event") { + std::string frame; + bool ok = extensionPanelEvent(&server_, getParam(form, "name"), + getParam(form, "event"), frame); + respondJson(ok && !frame.empty() + ? frame + : std::string("{\"ok\":false,\"error\":\"panel event failed\"}")); + return; + } + if (path == "/api/extension_config_set") { + bool ok = extensionConfigSet(&server_, getParam(form, "name"), + getParam(form, "text")); + respondJson(ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"save failed\"}"); + return; + } + + // Set the global extension funding rate (raw units/day; the dashboard sends + // raw = credits × PRICE_UNIT). Operator-only by being on this loopback UI. + if (path == "/api/funding_set") { + uint64_t v = 0; + if (!parseU64(getParam(form, "perday"), v)) { + respondJson("{\"ok\":false,\"error\":\"perday must be a non-negative integer\"}"); + return; + } + server_.extFundingSetPerDay(v); + respondJson("{\"ok\":true}"); + return; + } + + // Set the per-extension local budget (raw units; credits x PRICE_UNIT). Takes + // effect on the next boot/daily top-up. + if (path == "/api/local_budget_set") { + uint64_t v = 0; + if (!parseU64(getParam(form, "budget"), v)) { + respondJson("{\"ok\":false,\"error\":\"budget must be a non-negative integer\"}"); + return; + } + server_.extLocalBudgetSet(v); + respondJson("{\"ok\":true}"); + return; + } + + if (path == "/api/loglevel") { + std::string lv = getParam(form, "level"); + int n = -1; + if (lv == "trace" || lv == "0") n = 0; + else if (lv == "debug" || lv == "1") n = 1; + else if (lv == "info" || lv == "2") n = 2; + else if (lv == "warning" || lv == "warn" || lv == "3") n = 3; + else if (lv == "error" || lv == "4") n = 4; + if (n < 0) { respondJson("{\"ok\":false,\"error\":\"bad level\"}"); return; } + blog::set_level(static_cast(n)); + respondJson(std::string("{\"ok\":true,\"level\":") + + std::to_string(blog::fast_min_level) + "}"); + return; + } + + if (path == "/api/hello_save") { + std::string saved = server_._setHello(getParam(form, "text")); + std::ostringstream o; + o << "{\"ok\":true,\"hello\":" << jstr(saved) + << ",\"bytes\":" << saved.size() << "}"; + respondJson(o.str()); + return; + } + + if (path == "/api/hello_load") { + bool existed = false; + std::string content = server_._loadHelloFile(existed); + std::ostringstream o; + o << "{\"ok\":true,\"hello\":" << jstr(content) + << ",\"fileExists\":" << (existed ? "true" : "false") << "}"; + respondJson(o.str()); + return; + } + + if (path == "/api/inspect") { + std::string address = getParam(form, "address"); + bool paid = getParam(form, "paid") == "1"; + runAsync([&server = server_, address, paid]() { + return buildInspect(server, address, paid); + }); + return; + } + + if (path == "/api/mine") { + std::string address = getParam(form, "address"); + uint64_t count = 1; + parseU64(getParam(form, "count"), count); + if (count < 1) count = 1; + if (count > 32) count = 32; // a sane manual cap; mining is heavy + int n = static_cast(count); + runAsync([&server = server_, address, n]() { + return buildMine(server, address, n); + }); + return; + } + } + + respond(404, "application/json", "{\"error\":\"not found\"}"); +} + +void WebAdminSession::runAsync(std::function work) { + auto self = shared_from_this(); + addWorker(std::thread([this, self, work = std::move(work)]() { + std::string body; + try { + body = work(); + } catch (std::exception& e) { + body = std::string("{\"error\":") + jstr(e.what()) + "}"; + } catch (...) { + // A detached worker must never std::terminate the whole server. + body = "{\"error\":\"internal error\"}"; + } + // Marshal the reply back onto the session's executor (the web io thread), + // so all socket writes stay single-threaded like the rest of the server. + boost::asio::post(socket_.get_executor(), + [this, self, body = std::move(body)]() { + respond(200, "application/json", body); + }); + })); +} + +// --------------------------------------------------------------------------- +// WebSocket (RFC 6455) — the dashboard's push lane. After a GET /ws upgrade +// the session leaves HTTP and speaks TEXT frames: +// browser -> server {"type":"hello","ext":name} watch a panel (idempotent; +// replies with a fresh frame) +// {"type":"bye","ext":name} stop watching +// {"type":"event","ext":name,"event":{...}} widget event +// server -> browser {"type":"panel","ext":name,"frame":} +// {"type":"status","data":} every 1s +// Same security posture as the rest of webadmin: loopback bind, no auth. +// --------------------------------------------------------------------------- + +namespace { + +// base64(SHA1(key + RFC6455 magic)) — the Sec-WebSocket-Accept value. +std::string wsAcceptKey(const std::string& key) { + static const char* kMagic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + std::string in = key + kMagic; + CryptoPP::SHA1 sha; + uint8_t digest[CryptoPP::SHA1::DIGESTSIZE]; + sha.CalculateDigest(digest, reinterpret_cast(in.data()), + in.size()); + std::string b64; + CryptoPP::Base64Encoder enc(new CryptoPP::StringSink(b64), + false /* no line breaks */); + enc.Put(digest, sizeof(digest)); + enc.MessageEnd(); + return b64; +} + +// Unmasked server->client frame: FIN+opcode, then 7/16/64-bit length. +std::string wsBuildFrame(uint8_t op, const std::string& payload) { + std::string out; + out.push_back(static_cast(0x80 | op)); + size_t len = payload.size(); + if (len < 126) { + out.push_back(static_cast(len)); + } else if (len <= 0xFFFF) { + out.push_back(static_cast(126)); + out.push_back(static_cast((len >> 8) & 0xFF)); + out.push_back(static_cast(len & 0xFF)); + } else { + out.push_back(static_cast(127)); + for (int i = 7; i >= 0; --i) + out.push_back(static_cast((static_cast(len) >> (i * 8)) & 0xFF)); + } + out += payload; + return out; +} + +// Pull one flat string field ("type"/"ext") out of a wire message. The +// dashboard composes these messages itself and extension names are +// [A-Za-z0-9._-], so a plain scan is exact (no escapes possible). +std::string wireField(const std::string& text, const std::string& name) { + std::string needle = "\"" + name + "\":\""; + auto p = text.find(needle); + if (p == std::string::npos) return ""; + size_t v = p + needle.size(); + auto e = text.find('"', v); + if (e == std::string::npos) return ""; + return text.substr(v, e - v); +} + +// The raw JSON value of the "event" field: a string-aware balanced-brace scan +// (no ordering constraint on the envelope). The extracted value is verified by +// the child's full JSON parser before anything acts on it. +std::string wireEvent(const std::string& text) { + auto p = text.find("\"event\":"); + if (p == std::string::npos) return ""; + size_t i = p + 8; + while (i < text.size() && (text[i] == ' ' || text[i] == '\t')) ++i; + if (i >= text.size() || text[i] != '{') return ""; + int depth = 0; + bool inStr = false, escaped = false; + for (size_t j = i; j < text.size(); ++j) { + char c = text[j]; + if (inStr) { + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') inStr = false; + } else if (c == '"') { + inStr = true; + } else if (c == '{') { + ++depth; + } else if (c == '}') { + if (--depth == 0) return text.substr(i, j - i + 1); + } + } + return ""; +} + +} // namespace + +void WebAdminSession::wsUpgrade() { + std::string head = request_.substr(0, headerEnd_); + std::string key = headerValue(head, "sec-websocket-key"); + std::string upgrade = headerValue(head, "upgrade"); + for (auto& ch : upgrade) ch = static_cast(::tolower(ch)); + if (key.empty() || upgrade != "websocket") { + respond(400, "text/plain", "not a websocket upgrade"); + return; + } + std::string resp = + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + wsAcceptKey(key) + "\r\n\r\n"; + ws_ = true; + responded_ = true; // the HTTP phase is over for this session + // Bytes the client sent after the handshake are the start of the WS stream. + wsIn_ = request_.substr(headerEnd_ + contentLength_); + request_.clear(); + auto self = shared_from_this(); + auto out = std::make_shared(std::move(resp)); + boost::asio::async_write(socket_, boost::asio::buffer(*out), + [this, self, out](boost::system::error_code ec, size_t) { + if (ec) { wsClose(); return; } + // Register only now: a push queued before the 101 finished writing + // would interleave with the handshake bytes on the socket. + admin_.wsRegister(self); + if (!wsProcessBuffer()) { wsClose(); return; } + wsRead(); + }); +} + +void WebAdminSession::wsRead() { + auto self = shared_from_this(); + socket_.async_read_some(boost::asio::buffer(readChunk_), + [this, self](boost::system::error_code ec, size_t n) { + if (ec) { wsClose(); return; } + wsIn_.append(readChunk_.data(), n); + if (wsIn_.size() > 4 * 1024 * 1024) { wsClose(); return; } + if (!wsProcessBuffer()) { wsClose(); return; } + if (!wsClosed_) wsRead(); + }); +} + +bool WebAdminSession::wsProcessBuffer() { + for (;;) { + if (wsIn_.size() < 2) return true; + const unsigned char* p = + reinterpret_cast(wsIn_.data()); + bool fin = (p[0] & 0x80) != 0; + uint8_t opcode = p[0] & 0x0F; + bool masked = (p[1] & 0x80) != 0; + uint64_t len = p[1] & 0x7F; + size_t off = 2; + if (len == 126) { + if (wsIn_.size() < off + 2) return true; + len = (uint64_t(p[off]) << 8) | p[off + 1]; + off += 2; + } else if (len == 127) { + if (wsIn_.size() < off + 8) return true; + len = 0; + for (int i = 0; i < 8; ++i) len = (len << 8) | p[off + i]; + off += 8; + } + if (!masked) return false; // client frames MUST be masked + if (len > (1u << 20)) return false; // sanity cap + if (wsIn_.size() < off + 4 + len) return true; + unsigned char mask[4]; + for (int i = 0; i < 4; ++i) mask[i] = p[off + i]; + off += 4; + std::string payload; + payload.resize(len); + for (uint64_t i = 0; i < len; ++i) + payload[i] = static_cast(p[off + i] ^ mask[i & 3]); + off += len; + wsIn_.erase(0, off); + + switch (opcode) { + case 0x1: // text + case 0x2: // binary + case 0x0: { // continuation + if (opcode == 0x0) { + wsFrag_ += payload; + } else { + wsFrag_ = payload; + wsFragOp_ = opcode; + } + if (fin) { + std::string msg; + msg.swap(wsFrag_); + if (wsFragOp_ == 0x1) wsHandleMessage(msg); + } + break; + } + case 0x9: // ping -> pong + wsOutQ_.push_back(wsBuildFrame(0xA, payload)); + if (!wsWriting_) wsWriteNext(); + break; + case 0xA: // pong + break; + case 0x8: // close: echo, flush, then close + wsOutQ_.push_back(wsBuildFrame(0x8, payload)); + wsClosed_ = true; // stop reading; wsWriteNext closes after the flush + if (!wsWriting_) wsWriteNext(); + return true; + default: + return false; + } + } +} + +void WebAdminSession::wsHandleMessage(const std::string& text) { + std::string type = wireField(text, "type"); + std::string ext = wireField(text, "ext"); + if (type == "hello" && !ext.empty()) { + admin_.wsSubscribe(this, ext); + // Fresh pull so the new watcher paints immediately; later updates arrive + // as unsolicited pushes from the child. + std::string frame; + if (extensionPanel(&server_, ext, frame) && !frame.empty()) { + wsSend("{\"type\":\"panel\",\"ext\":" + jstr(ext) + + ",\"frame\":" + frame + "}"); + } + return; + } + if (type == "bye" && !ext.empty()) { + admin_.wsUnsubscribe(this, ext); + return; + } + if (type == "event" && !ext.empty()) { + std::string ev = wireEvent(text); + std::string frame; + if (!ev.empty() && extensionPanelEvent(&server_, ext, ev, frame) && + !frame.empty()) { + // Reply to the actor; other watchers get the bridge's auto-push. + wsSend("{\"type\":\"panel\",\"ext\":" + jstr(ext) + + ",\"frame\":" + frame + "}"); + } + return; + } + if (type == "logs-on") { + // Push new log lines each tick, starting after the client's last seq + // (so a reconnect doesn't replay what it already renders). + uint64_t since = 0; + parseU64(wireField(text, "since"), since); + wsLogsSub_ = true; + wsLogsSeq_ = since; + wsPushLogs(); // immediate catch-up + return; + } + if (type == "logs-off") { + wsLogsSub_ = false; + return; + } + if (type == "view-on") { + // Subscribe to a pushed tab view; the immediate push paints the tab, the + // tick's hash-gated pushes keep it live. + std::string v = wireField(text, "view"); + std::string data = admin_.viewData(v); + if (!data.empty()) { + wsViews_.insert(v); + wsViewHash_.erase(v); + wsPushView(v, "{\"type\":\"view\",\"view\":\"" + v + "\",\"data\":" + + data + "}", + std::hash{}(data)); + } + return; + } + if (type == "view-off") { + wsViews_.erase(wireField(text, "view")); + return; + } +} + +void WebAdminSession::wsPushView(const std::string& view, + const std::string& msg, size_t hash) { + auto it = wsViewHash_.find(view); + if (it != wsViewHash_.end() && it->second == hash) return; + wsViewHash_[view] = hash; + wsSend(msg); +} + +void WebAdminSession::wsPushLogs() { + if (!wsLogsSub_ || wsClosed_) return; + uint64_t hi = wsLogsSeq_; + std::string data = buildLogsSince(wsLogsSeq_, &hi); + if (hi == wsLogsSeq_) return; // nothing new + wsSend("{\"type\":\"logs\",\"data\":" + data + "}"); + wsLogsSeq_ = hi; +} + +void WebAdminSession::wsSend(const std::string& text) { + if (wsClosed_) return; + // Backpressure: a client that stopped reading (dead tunnel) must not + // accumulate frames without bound. Frames are periodic or change-driven, + // so a healthy client's queue stays near-empty; hitting the cap means the + // peer is gone — drop the connection (the browser auto-reconnects). + if (wsOutQ_.size() >= 1024) { wsClose(); return; } + wsOutQ_.push_back(wsBuildFrame(0x1, text)); + if (!wsWriting_) wsWriteNext(); +} + +void WebAdminSession::wsWriteNext() { + if (wsOutQ_.empty()) { + wsWriting_ = false; + if (wsClosed_) wsClose(); + return; + } + wsWriting_ = true; + auto out = std::make_shared(std::move(wsOutQ_.front())); + wsOutQ_.pop_front(); + auto self = shared_from_this(); + boost::asio::async_write(socket_, boost::asio::buffer(*out), + [this, self, out](boost::system::error_code ec, size_t) { + if (ec) { wsClose(); return; } + wsWriteNext(); + }); +} + +void WebAdminSession::wsClose() { + if (!ws_) return; + admin_.wsUnregister(this); + boost::system::error_code ic; + socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ic); + socket_.close(ic); + wsClosed_ = true; +} + +void WebAdminSession::respondJson(const std::string& json) { + respond(200, "application/json", json); +} + +void WebAdminSession::respond(int status, const std::string& contentType, + const std::string& body) { + if (responded_) return; + responded_ = true; + const char* reason = "OK"; + switch (status) { + case 204: reason = "No Content"; break; + case 400: reason = "Bad Request"; break; + case 404: reason = "Not Found"; break; + case 413: reason = "Payload Too Large"; break; + case 500: reason = "Internal Server Error"; break; + default: reason = "OK"; break; + } + auto out = std::make_shared(); + *out = "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n" + + "Content-Type: " + contentType + "\r\n" + + "Content-Length: " + std::to_string(body.size()) + "\r\n" + + "Connection: close\r\n" + + "Cache-Control: no-store\r\n" + + "\r\n" + body; + auto self = shared_from_this(); + boost::asio::async_write(socket_, boost::asio::buffer(*out), + [this, self, out](boost::system::error_code ec, size_t) { + boost::system::error_code ic; + socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ic); + socket_.close(ic); + (void)ec; + }); +} + +// ============================================================================= +// WebAdmin +// ============================================================================= + +WebAdmin::WebAdmin(boost::asio::io_context& io, CesServer& server) + : io_(io), server_(server) {} + +WebAdmin::~WebAdmin() { stop(); } + +bool WebAdmin::listen(const std::string& bindAddr, uint16_t port, + bool allowPublic) { + boost::system::error_code ec; + auto addr = boost::asio::ip::make_address(bindAddr, ec); + if (ec) { + LOGERROR << "web dashboard: bad bind address" << SVAR(bindAddr) + << SVAR(ec.message()); + return false; + } + if (!addr.is_loopback() && !allowPublic) { + LOGERROR << "web dashboard bind address is NOT loopback and the dashboard " + "has NO authentication; refusing to expose a no-auth credit/" + "debit surface. Bind to loopback (SSH-tunnel to reach it) or set " + "web_allow_public = true to override deliberately" + << SVAR(bindAddr); + return false; + } + if (!addr.is_loopback()) { + LOGWARNING << "web dashboard bind address is NOT loopback — the dashboard " + "has NO authentication; anyone who can reach this address " + "controls the server (web_allow_public override in effect)" + << SVAR(bindAddr); + } + try { + boost::asio::ip::tcp::endpoint ep(addr, port); + acceptor_ = std::make_unique(io_); + acceptor_->open(ep.protocol()); + acceptor_->set_option(boost::asio::socket_base::reuse_address(true)); + acceptor_->bind(ep); + acceptor_->listen(); + boundPort_ = acceptor_->local_endpoint().port(); + g_webStartUnix = minx::getSecsSinceEpoch(); + installLogSink(); + logSinkInstalled_ = true; + // Receive unsolicited extension panel frames (mene push bridge / + // change-detect tick) and fan them out to watching WebSocket clients. + server_.setExtPanelPushHandler( + [this](const std::string& name, const std::string& frame) { + panelPush(name, frame); + }); + LOGINFO << "web dashboard listening (NO AUTH)" + << SVAR(bindAddr) << VAR(port); + doAccept(); + return true; + } catch (std::exception& e) { + LOGERROR << "web dashboard listen failed" << SVAR(bindAddr) << VAR(port) + << SVAR(e.what()); + acceptor_.reset(); + return false; + } +} + +void WebAdmin::stop() { + // Unhook the push sink first: no new frames land while tearing down. + server_.setExtPanelPushHandler(nullptr); + // Close WS sessions + timer on the io thread (their owner). Synchronize: + // this runs off-io (main), so wait for the posted teardown. + std::promise done; + boost::asio::post(io_, [this, &done]() { + stopping_ = true; + if (statusTimer_) statusTimer_->cancel(); + auto sessions = wsSessions_; // wsClose mutates the map + for (auto& [ptr, weak] : sessions) { + if (auto s = weak.lock()) s->wsClose(); + } + done.set_value(); + }); + // Bounded wait: if the io thread is already gone (some tests stop it before + // the dashboard), don't hang shutdown. + done.get_future().wait_for(std::chrono::seconds(2)); + if (acceptor_) { + boost::system::error_code ec; + acceptor_->close(ec); + acceptor_.reset(); + } + // Join any in-flight inspect/mine workers before the server they reference + // is destroyed (main stops the dashboard before CesServer::stop()). + joinWorkers(); + if (logSinkInstalled_) { + removeLogSink(); + logSinkInstalled_ = false; + } +} + +// ---- WebSocket registry / push fan-out (io thread) ------------------------- + +void WebAdmin::wsRegister(const std::shared_ptr& s) { + wsSessions_[s.get()] = s; + armStatusTimer(); +} + +void WebAdmin::wsUnregister(WebAdminSession* s) { + auto it = wsSessions_.find(s); + if (it == wsSessions_.end()) return; + // Release this client's panel watches. + for (const auto& ext : s->wsSubs()) { + auto w = wsWatch_.find(ext); + if (w != wsWatch_.end() && --w->second <= 0) { + wsWatch_.erase(w); + extensionPanelWatch(&server_, ext, false); + } + } + wsSessions_.erase(it); +} + +void WebAdmin::wsSubscribe(WebAdminSession* s, const std::string& ext) { + auto it = wsSessions_.find(s); + if (it == wsSessions_.end()) return; + auto sp = it->second.lock(); + if (!sp) return; + if (sp->wsSubs().insert(ext).second) { + if (++wsWatch_[ext] == 1) extensionPanelWatch(&server_, ext, true); + } +} + +void WebAdmin::wsUnsubscribe(WebAdminSession* s, const std::string& ext) { + auto it = wsSessions_.find(s); + if (it == wsSessions_.end()) return; + auto sp = it->second.lock(); + if (!sp) return; + if (sp->wsSubs().erase(ext) > 0) { + auto w = wsWatch_.find(ext); + if (w != wsWatch_.end() && --w->second <= 0) { + wsWatch_.erase(w); + extensionPanelWatch(&server_, ext, false); + } + } +} + +void WebAdmin::panelPush(const std::string& name, const std::string& frame) { + boost::asio::post(io_, [this, name, frame]() { + if (stopping_ || wsSessions_.empty()) return; + std::string msg = "{\"type\":\"panel\",\"ext\":" + jstr(name) + + ",\"frame\":" + frame + "}"; + for (auto it = wsSessions_.begin(); it != wsSessions_.end();) { + auto sp = it->second.lock(); + if (!sp) { it = wsSessions_.erase(it); continue; } + if (sp->wsSubs().count(name)) sp->wsSend(msg); + ++it; + } + }); +} + +void WebAdmin::armStatusTimer() { + if (stopping_ || statusTimer_) return; + statusTimer_ = std::make_unique(io_); + statusTimer_->expires_after(std::chrono::seconds(1)); + statusTimer_->async_wait([this](boost::system::error_code ec) { + statusTimer_.reset(); + if (ec || stopping_) return; + statusTick(); + if (!wsSessions_.empty()) armStatusTimer(); + }); +} + +std::string WebAdmin::viewData(const std::string& view) { + if (view == "peers") return buildPeers(server_); + if (view == "billing") return buildNetbill(server_); + if (view == "compute") { + // The compute tab renders live instances AND config-derived panels; ship + // both so the tab needs no side fetches. cfg changes rarely, so the + // combined payload's hash keeps pushes change-driven. + return "{\"cp\":" + buildCompute(server_) + + ",\"cfg\":" + buildConfig(server_) + "}"; + } + return ""; +} + +void WebAdmin::viewsTick() { + static const char* kViews[] = {"peers", "billing", "compute"}; + for (const char* v : kViews) { + bool watched = false; + for (auto& [ptr, weak] : wsSessions_) { + auto sp = weak.lock(); + if (sp && sp->wsWatchesView(v)) { watched = true; break; } + } + if (!watched) continue; // nobody looking = the view is never built + std::string data = viewData(v); + size_t h = std::hash{}(data); + std::string msg = std::string("{\"type\":\"view\",\"view\":\"") + v + + "\",\"data\":" + data + "}"; + for (auto it = wsSessions_.begin(); it != wsSessions_.end();) { + auto sp = it->second.lock(); + if (!sp) { it = wsSessions_.erase(it); continue; } + if (sp->wsWatchesView(v)) sp->wsPushView(v, msg, h); + ++it; + } + } +} + +void WebAdmin::statusTick() { + if (wsSessions_.empty()) return; + // Status push replaces the browser's HTTP heartbeat while the socket is up. + std::string msg = "{\"type\":\"status\",\"data\":" + buildStatus(server_) + "}"; + for (auto it = wsSessions_.begin(); it != wsSessions_.end();) { + auto sp = it->second.lock(); + if (!sp) { it = wsSessions_.erase(it); continue; } + sp->wsSend(msg); + sp->wsPushLogs(); // change-driven: sends only when new lines exist + ++it; + } + viewsTick(); // change-driven tab tables for their subscribers + // Re-assert panel watches (idempotent): a relaunched extension child lost + // its watch flag; this heals it within a tick. + for (const auto& [ext, count] : wsWatch_) { + if (count > 0) extensionPanelWatch(&server_, ext, true); + } +} + +void WebAdmin::doAccept() { + acceptor_->async_accept( + [this](boost::system::error_code ec, + boost::asio::ip::tcp::socket socket) { + if (ec) { + if (ec != boost::asio::error::operation_aborted) { + LOGDEBUG << "web accept error" << SVAR(ec.message()); + } + return; + } + std::make_shared(std::move(socket), server_, *this) + ->start(); + doAccept(); + }); +} + +namespace { + +const char* kDashboardHtml = R"DASH( + + + + +CES Server Dashboard + + + + + + +
+ +
+ + +
+
+ + + + up … +
connecting
+
+ + + +
+ +
+
+
+

Load gauges

+

Identity & features

+
+
+ + +
+
+
+

Peering target credits to maintain on each outbound peer

+
+ + +
+
+ current: +
+
+
+

Peer table size peers kept and exposed; RAM holds 3x this

+
+ + +
+
+ current: +
+
+
+
+

Add an outbound peer

+
+ + + +
+

Don't know the key? Use the Inspect tab to discover a server by address, then add it from there.

+
+
+

Peer table

+
+
+
+
+ + +
+
+

Inspect a server discover · mine · peer

+
+ + +
+ +

A free, instant handshake probe: pubkey, min-difficulty, reachability — all you need to add it as a peer. Then mine to bootstrap a reserve.

+
+
+
+ + +
+
+

Transfer send from this node's account

+
node balance:
+ +
+
+ + +
+
+
+

🖨️ Mint create credits

+ +
+
+ + +
+
+
+

Burn destroy credits

+ +
+
+ + +
+
+
+ + +
+
+

Account lookup

+
+ + +
+
+
+
+

Asset lookup

+
+ + +
+
+
+ +
+ + +
+
+

Channel metering per-channel RUDP usage (CesPlex)

+
+
+
+
+

Net metering rates applied immediately · export to persist

+
+
+
+
+ + +
+
+

Live log tail

+
+ + + + + + + +
+
+ + + + + +
+
+
+
+ + +
+
+

Base fees applied immediately · export to persist

+
+
+ +
+ + +
+

Raw credit units (100,000,000 = 1 credit).

+
+
+

Live fee multipliers congestion pricing · % of full fee

+
+
+
+
+ + +
+
+
+
+

File fees applied immediately · export to persist

+
+
+
+
+

Capacity cap is live · export to persist

+
+
+
+
+ + +
+
+
+
+

Compute fees applied immediately · export to persist

+
+
+
+
+

Limits max-instances is live · export to persist

+
+
+
+
+

Running instances builtin:compute

+
+
+
+ + +
+
+
+
+

Funding budget global — all extensions, all remotes

+
A daily-refilling token bucket (capped at one day's worth) that programs draw from via request_funds to spend at remote peers; "remaining" below is the current level. The discovery extension needs it. 0 = off.
+
+ + +
+
+
+
+

Local budget per extension

+
Credits each /s/ program account is topped up to, on boot and daily. 0 = off.
+
+ + +
+
+
+
+
+
+ + +
+
+
+

Config persistence runtime values → a file you can feed back

+

A booted server reads its config once, but the dashboard changes some values live (the peer target). Rather than rewrite your hand-edited config, export the current effective config to <data_dir>/ces.toml, then boot with ces --config <data_dir>/ces.toml to persist them.

+
+
+
+

Maintenance ledger snapshot

+ +

Forks and writes a full ledger snapshot, compacting the event log.

+
+
+
+

Minimum PoW difficulty live · export to persist

+
+ + + + +
+
+
+

Hello banner your server's greeting to the network

+

Served in CES_QUERY_SERVER_INFO as the hello field. Stored in <data_dir>/hello.txt. UTF-8, capped at 160 bytes (trimmed on a codepoint boundary).

+ +
+ 0 / 160 bytes +
+ + +
+
+
+

Server knobs

+
+ +
+
+ + + + +)DASH"; + +} // namespace + +} // namespace ces diff --git a/vendor/ces/src/cesluajitd/api_bucket.inc b/vendor/ces/src/cesluajitd/api_bucket.inc new file mode 100644 index 0000000..3599cfa --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_bucket.inc @@ -0,0 +1,380 @@ +// --------------------------------------------------------------------------- +// ces.bucket_* - per-instance rotating cache (TTL-bounded forgetting). +// +// Backed by minx::BucketCache on the host. Two rotating buckets with +// auto-flip at ttl_secs: an entry is guaranteed present for at least +// ttl_secs and at most 2x ttl_secs before being aged out. Capacity is +// enforced by REFUSAL, not eviction: when the active bucket is full, +// put returns false instead of clearing entries, so the ttl_secs +// retention floor holds regardless of load. Programs that need +// replay-protection state (dice.lua's per-deposit marker, etc.) use +// this so they don't have to keep "all deposits for all eternity." +// +// Surface: +// local b = ces.bucket_new(ttl_secs, max_entries, max_entry_bytes) +// local ok = b:put(key, value) -- true if stored; false if bucket full +// local v = b:get(key) -- string, or nil if missing +// +// `key` and `value` are arbitrary bytes (Lua's lstring carries length). +// Each instance can create multiple buckets; they all die together +// when the instance is killed. +// --------------------------------------------------------------------------- + +// Internal: pull `id` (u32 bucket handle) from a bucket-self table. +static uint32_t bucket_self_id(lua_State* L, int idx) { + lua_getfield(L, idx, "id"); + uint32_t id = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + return id; +} + +int lua_ces_bucket_put(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + uint32_t id = bucket_self_id(L, 1); + size_t klen = 0, vlen = 0; + const char* k = luaL_checklstring(L, 2, &klen); + const char* v = luaL_checklstring(L, 3, &vlen); + if (klen > 65535) { + lua_pushnil(L); + lua_pushstring(L, "key too long"); + return 2; + } + std::vector args; + put_u32(args, id); + put_u16(args, static_cast(klen)); + put_bytes(args, k, klen); + put_u32(args, static_cast(vlen)); + put_bytes(args, v, vlen); + return io_call(L, METHOD_BUCKET_PUT, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st == STATUS_BUCKET_FULL) { lua_pushboolean(L, 0); return 1; } + if (st != STATUS_OK) return push_file_err(L, st); + lua_pushboolean(L, 1); + return 1; + }); +} + +int lua_ces_bucket_get(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + uint32_t id = bucket_self_id(L, 1); + size_t klen = 0; + const char* k = luaL_checklstring(L, 2, &klen); + if (klen > 65535) { + lua_pushnil(L); + lua_pushstring(L, "key too long"); + return 2; + } + std::vector args; + put_u32(args, id); + put_u16(args, static_cast(klen)); + put_bytes(args, k, klen); + return io_call(L, METHOD_BUCKET_GET, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + // Tail: [u8 found_flag][u32 BE vlen][v] + if (reply.body.size() < 2) return push_file_err(L, STATUS_INTERNAL); + uint8_t found = reply.body[1]; + if (!found) { + lua_pushnil(L); + return 1; + } + if (reply.body.size() < sizeof(uint8_t) + sizeof(uint8_t) + sizeof(uint32_t)) + return push_file_err(L, STATUS_INTERNAL); + uint32_t vlen = get_u32(reply.body.data() + 2); + if (reply.body.size() < sizeof(uint8_t) + sizeof(uint8_t) + sizeof(uint32_t) + + vlen) + return push_file_err(L, STATUS_INTERNAL); + lua_pushlstring(L, + reinterpret_cast(reply.body.data() + 6), vlen); + return 1; + }); +} + +// ces.bucket_new(ttl_secs, max_entries, max_entry_bytes) +// → bucket | nil, err +// +// Pre-declares the bucket's worst-case footprint +// (max_entries × max_entry_bytes), which the host bills against on +// every supervisor tick — see feeBucketByteSec in CesConfig. Per-put +// klen+vlen is capped at max_entry_bytes; oversize values reject. +// +// Reasonable defaults for replay-protection caches: +// ces.bucket_new(7200, 100000, 64) +// = 2 hours TTL, up to 100k entries, 64 bytes per entry. +int lua_ces_bucket_new(lua_State* L) { + lua_Integer ttl = luaL_checkinteger(L, 1); + lua_Integer maxE = luaL_checkinteger(L, 2); + lua_Integer maxB = luaL_checkinteger(L, 3); + if (ttl < 1 || ttl > 86400 * 30) { + lua_pushnil(L); + lua_pushstring(L, "ttl_secs must be in [1, 30 days]"); + return 2; + } + if (maxE < 1 || maxE > 1000000) { + lua_pushnil(L); + lua_pushstring(L, "max_entries must be in [1, 1M]"); + return 2; + } + if (maxB < 1 || maxB > 65536) { + lua_pushnil(L); + lua_pushstring(L, "max_entry_bytes must be in [1, 64K]"); + return 2; + } + std::vector args; + put_u32(args, static_cast(ttl)); + put_u32(args, static_cast(maxE)); + put_u32(args, static_cast(maxB)); + return io_call(L, METHOD_BUCKET_NEW, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < sizeof(uint8_t) + sizeof(uint32_t)) + return push_file_err(L, STATUS_INTERNAL); + uint32_t id = get_u32(reply.body.data() + 1); + // Build the bucket-self Lua table. + lua_newtable(L); + lua_pushnumber(L, static_cast(id)); + lua_setfield(L, -2, "id"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "put"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "get"); + return 1; + }); +} + +// ces.file_stat(name) → table | nil, err_code +int lua_ces_file_stat(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + std::vector args; + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_STAT, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + // Tail: [32B owner][u64 fb][u64 ppk][u64 size] + // [u64 createdUs][u64 modifiedUs] + const uint8_t* p = reply.body.data() + 1; + size_t rem = reply.body.size() - 1; + if (rem < WIRE_KEY_LEN + sizeof(uint64_t) * 5) + return push_file_err(L, STATUS_INTERNAL); + const uint8_t* owner = p; p += 32; + uint64_t fb = get_u64(p); p += 8; + uint64_t ppk = get_u64(p); p += 8; + uint64_t sz = get_u64(p); p += 8; + uint64_t crUs = get_u64(p); p += 8; + uint64_t mdUs = get_u64(p); p += 8; + lua_newtable(L); + lua_pushlstring(L, reinterpret_cast(owner), 32); + lua_setfield(L, -2, "owner_pubkey"); + lua_pushnumber(L, double(fb)); lua_setfield(L, -2, "file_balance"); + lua_pushnumber(L, double(ppk)); lua_setfield(L, -2, "price_per_kb"); + lua_pushnumber(L, double(sz)); lua_setfield(L, -2, "size"); + lua_pushnumber(L, double(crUs)); lua_setfield(L, -2, "created_us"); + lua_pushnumber(L, double(mdUs)); lua_setfield(L, -2, "modified_us"); + return 1; + }); +} + +// ces.file_read(name, offset, length) → data | nil, err_code +int lua_ces_file_read(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + lua_Number off_n = luaL_checknumber(L, 2); + lua_Number len_n = luaL_checknumber(L, 3); + if (off_n < 0 || len_n <= 0 || len_n > (1024 * 1024)) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_u64(args, uint64_t(off_n)); + put_u32(args, uint32_t(len_n)); + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_READ, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + constexpr size_t kHdr = sizeof(uint8_t) + sizeof(uint32_t); // status + len + if (reply.body.size() < kHdr) return push_file_err(L, STATUS_INTERNAL); + uint32_t dlen = get_u32(reply.body.data() + sizeof(uint8_t)); + if (reply.body.size() < kHdr + size_t(dlen)) + return push_file_err(L, STATUS_INTERNAL); + lua_pushlstring(L, reinterpret_cast(reply.body.data() + kHdr), + dlen); + return 1; + }); +} + +// ces.file_write(name, offset, data) → true, file_balance | nil, err_code +int lua_ces_file_write(lua_State* L) { + size_t nlen = 0, dlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + lua_Number off_n = luaL_checknumber(L, 2); + const char* data = luaL_checklstring(L, 3, &dlen); + if (off_n < 0 || dlen == 0 || dlen > (1024 * 1024)) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_u64(args, uint64_t(off_n)); + put_name(args, name, nlen); + put_u32(args, uint32_t(dlen)); + put_bytes(args, data, dlen); + return io_call(L, METHOD_FILE_WRITE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + return 2; + }); +} + +// ces.file_append(name, data) → true, file_balance, new_size | nil, err_code +int lua_ces_file_append(lua_State* L) { + size_t nlen = 0, dlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + const char* data = luaL_checklstring(L, 2, &dlen); + if (dlen == 0 || dlen > (1024 * 1024)) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_name(args, name, nlen); + put_u32(args, uint32_t(dlen)); + put_bytes(args, data, dlen); + return io_call(L, METHOD_FILE_APPEND, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 17) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + uint64_t ns = get_u64(reply.body.data() + 9); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + lua_pushnumber(L, double(ns)); + return 3; + }); +} + +// ces.file_create(name, size, price_per_kb, initial_deposit) +// → true, file_balance | nil, err_code +int lua_ces_file_create(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + lua_Number sz_n = luaL_checknumber(L, 2); + lua_Number ppk_n = luaL_optnumber(L, 3, 0); + lua_Number dep_n = luaL_optnumber(L, 4, 0); + if (sz_n <= 0 || ppk_n < 0 || dep_n < 0) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_u64(args, uint64_t(sz_n)); + put_u64(args, uint64_t(ppk_n)); + put_u64(args, uint64_t(dep_n)); + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_CREATE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + return 2; + }); +} + +// ces.file_deposit(name, amount) → true, file_balance | nil, err_code +int lua_ces_file_deposit(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + lua_Number amt_n = luaL_checknumber(L, 2); + if (amt_n < 0) { lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; } + std::vector args; + put_u64(args, uint64_t(amt_n)); + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_DEPOSIT, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + return 2; + }); +} + +// ces.file_withdraw(name, amount) → true, file_balance | nil, err_code +int lua_ces_file_withdraw(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + lua_Number amt_n = luaL_checknumber(L, 2); + if (amt_n < 0) { lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; } + std::vector args; + put_u64(args, uint64_t(amt_n)); + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_WITHDRAW, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + return 2; + }); +} + +// ces.file_set_price(name, price_per_kb) → true, new_price | nil, err_code +int lua_ces_file_set_price(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + lua_Number ppk_n = luaL_checknumber(L, 2); + if (ppk_n < 0) { lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; } + std::vector args; + put_u64(args, uint64_t(ppk_n)); + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_SET_PRICE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t p = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(p)); + return 2; + }); +} + +// ces.file_delete(name) → true, refunded | nil, err_code +int lua_ces_file_delete(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + std::vector args; + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_DELETE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t r = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(r)); + return 2; + }); +} + +// ces.file_resize(name, new_size) → true, file_balance, new_size | nil, err_code +int lua_ces_file_resize(lua_State* L) { + size_t nlen = 0; + const char* name = luaL_checklstring(L, 1, &nlen); + lua_Number sz_n = luaL_checknumber(L, 2); + if (sz_n <= 0) { lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; } + std::vector args; + put_u64(args, uint64_t(sz_n)); + put_name(args, name, nlen); + return io_call(L, METHOD_FILE_RESIZE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 17) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + uint64_t ns = get_u64(reply.body.data() + 9); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + lua_pushnumber(L, double(ns)); + return 3; + }); +} + diff --git a/vendor/ces/src/cesluajitd/api_chan.inc b/vendor/ces/src/cesluajitd/api_chan.inc new file mode 100644 index 0000000..e0c0719 --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_chan.inc @@ -0,0 +1,107 @@ +// --------------------------------------------------------------------------- +// ces.chan — CSP message channel (Hoare; same model as Go's chan). The struct + +// scheduler glue (g_chan_waiters, drain_chan_timeouts) live up by the timers. +// ch = ces.chan() +// ch:send(v) -- enqueue v (any Lua value); never blocks; +// returns false if the channel is closed (drop) +// v = ch:recv([timeout_ms]) -- next value, else nil,"timeout" / nil,"closed". +// default 10000ms; recv from a coroutine; a +// queued value wins immediately (no park) +// ch:close() -- wake parked recv-ers (nil,"closed"); later sends drop +// --------------------------------------------------------------------------- +static const char* kChanMT = "ces.chan.mt"; + +static Channel* check_chan(lua_State* L, int idx) { + void* ud = luaL_checkudata(L, idx, kChanMT); + Channel* ch = ud ? *static_cast(ud) : nullptr; + if (!ch) { luaL_error(L, "invalid channel"); } + return ch; +} + +int lua_chan_new(lua_State* L) { + Channel** ud = static_cast(lua_newuserdata(L, sizeof(Channel*))); + *ud = new Channel(); + luaL_getmetatable(L, kChanMT); + lua_setmetatable(L, -2); + return 1; +} + +int lua_chan_send(lua_State* L) { + Channel* ch = check_chan(L, 1); + if (ch->closed) { lua_pushboolean(L, 0); return 1; } // dropped + // Hand directly to the oldest parked recv-er, if one is still waiting. + while (!ch->recvers.empty()) { + lua_State* co = ch->recvers.front(); + ch->recvers.pop_front(); + if (g_parked.erase(co)) { + g_chan_waiters.erase(co); + lua_checkstack(co, 1); + lua_pushvalue(L, 2); // the value + lua_xmove(L, co, 1); // -> co's stack + g_ready.push_back({co, 1}); // resumed in drain_ready with it + lua_pushboolean(L, 1); + return 1; + } + // co was already woken (timed out): drop it, try the next recv-er + } + // No waiter: enqueue. A registry ref keeps the value alive until recv/gc. + lua_pushvalue(L, 2); + ch->queue.push_back(luaL_ref(L, LUA_REGISTRYINDEX)); + lua_pushboolean(L, 1); + return 1; +} + +int lua_chan_recv(lua_State* L) { + Channel* ch = check_chan(L, 1); + int timeoutMs = static_cast(luaL_optinteger(L, 2, 10000)); + // A queued value wins immediately -- even on a closed channel, drain first. + if (!ch->queue.empty()) { + int ref = ch->queue.front(); + ch->queue.pop_front(); + lua_rawgeti(L, LUA_REGISTRYINDEX, ref); + luaL_unref(L, LUA_REGISTRYINDEX, ref); + return 1; + } + if (ch->closed) { lua_pushnil(L); lua_pushstring(L, "closed"); return 2; } + // Empty + open: park with a timeout backstop. The main chunk can't yield, and + // timeout<=0 is a non-blocking poll -- both return an immediate "timeout". + if (!io_yieldable(L) || timeoutMs <= 0) { + lua_pushnil(L); lua_pushstring(L, "timeout"); return 2; + } + g_parked.insert(L); + ch->recvers.push_back(L); + g_chan_waiters[L] = ChanWaiter{ + ch, timer_now_us() + static_cast(timeoutMs) * 1000ULL}; + return lua_yield(L, 0); +} + +int lua_chan_close(lua_State* L) { + Channel* ch = check_chan(L, 1); + ch->closed = true; + while (!ch->recvers.empty()) { + lua_State* co = ch->recvers.front(); + ch->recvers.pop_front(); + if (g_parked.erase(co)) { + g_chan_waiters.erase(co); + lua_checkstack(co, 2); + lua_pushnil(co); + lua_pushstring(co, "closed"); + g_ready.push_back({co, 2}); + } + } + return 0; +} + +// __gc: release the refs of any never-received values, then free the Channel. +// No recv-er can be parked here -- a parked recv-er holds the userdata alive. +int lua_chan_gc(lua_State* L) { + void* ud = lua_touserdata(L, 1); + if (!ud) return 0; + Channel* ch = *static_cast(ud); + if (!ch) return 0; + for (int ref : ch->queue) luaL_unref(L, LUA_REGISTRYINDEX, ref); + delete ch; + *static_cast(ud) = nullptr; + return 0; +} + diff --git a/vendor/ces/src/cesluajitd/api_clients.inc b/vendor/ces/src/cesluajitd/api_clients.inc new file mode 100644 index 0000000..fadc12d --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_clients.inc @@ -0,0 +1,892 @@ +// --------------------------------------------------------------------------- +// ces.file_client / ces.compute_client — OUTBOUND clients of remote (or this) +// CES servers' /ces/file/1 and /ces/compute/1 handlers. Distinct from the +// LOCAL owner-authority ces.file_* (in-process, billed to the source file): +// these dial a server as the program's OWN identity over the instance's +// reserved rpc-port endpoint (firewall-correct), reusing the SAME C++ verb +// codec (CesFileClient / CesComputeClient) cesh uses — one source of truth. +// +// A handle is a Lua table {id, , close}. The C++ channel+client +// live in a registry keyed by id (Lua-thread-only). Verb methods block the Lua +// thread on the round-trip (the endpoint strand is separate). close() destroys +// the channel ON the endpoint strand (its RudpStream is touched there). +// --------------------------------------------------------------------------- + +// inflight: an offloaded verb is running on a worker thread (set/cleared only on +// the Lua thread). At most one in flight per handle — a second verb on a busy +// handle errors instead of racing a second worker on the same client. closePending: +// close() was called while inflight; the close is deferred to completion so the +// worker's raw client pointer can't be freed under it. Together these stop a +// program from corrupting the host by sharing a handle across coroutines. +struct LuaFileClientEntry { + std::unique_ptr ch; + std::unique_ptr fc; + bool inflight = false; + bool closePending = false; +}; +struct LuaComputeClientEntry { + std::unique_ptr ch; + std::unique_ptr cc; + bool inflight = false; + bool closePending = false; +}; +std::map g_file_clients; // Lua-thread-only +std::map g_compute_clients; // Lua-thread-only +uint64_t g_client_next_id = 1; + +uint64_t client_handle_id(lua_State* L, int idx) { + lua_getfield(L, idx, "id"); + uint64_t id = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + return id; +} +uint64_t client_arg_u64(lua_State* L, int idx) { + return static_cast(luaL_checknumber(L, idx)); +} +int client_push_closed(lua_State* L) { + lua_pushnil(L); + lua_pushstring(L, "client closed"); + return 2; +} +int client_push_err(lua_State* L, uint8_t rc) { + lua_pushnil(L); + lua_pushinteger(L, rc); + return 2; +} + +// Lazy-open the endpoint, resolve addr, build + bind a CesPlexChannel for +// `proto`, signed by the program key. Null + err on any failure. +// Resolve + bind a CesPlexChannel for `proto`. The caller MUST have called +// luarpc_ensure_endpoint() first (on the Lua thread) — this is safe to run on a +// worker thread (the offload path) precisely because it does not touch the lazy +// endpoint setup, only the already-built g_luarpc_io/rudp/signer. +std::unique_ptr client_open_channel( + const std::string& addr, const uint8_t* pk, const char* proto, + std::string& err) { + if (!g_luarpc_io || !g_luarpc_rudp || !g_luarpc_signer) { + err = "networking disabled: instance has no rpc port"; + return nullptr; + } + minx::SockAddr peer; + try { + auto ep = ces::Resolver::resolveUdp(addr); + auto a = ep.address(); + if (a.is_v4()) + a = boost::asio::ip::make_address_v6(boost::asio::ip::v4_mapped, + a.to_v4()); + peer = minx::SockAddr(a, ep.port()); + } catch (const std::exception&) { + err = "address resolve failed"; + return nullptr; + } + auto ch = std::make_unique(*g_luarpc_io, g_luarpc_rudp); + if (pk) { + minx::Hash h{}; + std::memcpy(h.data(), pk, 32); + ch->setServerPubkey(h); + } + uint8_t rc = ch->select(peer, proto, *g_luarpc_signer); + if (rc != ces::CES_OK) { + err = "bind rejected (rc=" + std::to_string(static_cast(rc)) + ")"; + // Destroy the half-open channel on the endpoint strand (its RudpStream is + // touched there), not here on the Lua thread. + boost::asio::post(*g_luarpc_io, [c = std::move(ch)]() { (void)c; }); + return nullptr; + } + return ch; +} + +// Read the (addr, optional server_pubkey) args common to both constructors. +// Returns false + leaves (nil, err) on the stack on a bad pubkey. +bool client_read_dial_args(lua_State* L, std::string& addr, + const uint8_t** pk, uint8_t pkbuf[32]) { + size_t alen = 0; + const char* a = luaL_checklstring(L, 1, &alen); + addr.assign(a, alen); + *pk = nullptr; + if (!lua_isnoneornil(L, 2)) { + size_t plen = 0; + const char* p = luaL_checklstring(L, 2, &plen); + if (plen != 32) { + lua_pushnil(L); + lua_pushstring(L, "server pubkey must be 32 bytes"); + return false; + } + std::memcpy(pkbuf, p, 32); + *pk = pkbuf; + } + return true; +} + +LuaFileClientEntry* file_entry(uint64_t id) { + auto it = g_file_clients.find(id); + return it == g_file_clients.end() ? nullptr : &it->second; +} +LuaComputeClientEntry* compute_entry(uint64_t id) { + auto it = g_compute_clients.find(id); + return it == g_compute_clients.end() ? nullptr : &it->second; +} + +// Destroy a registry entry's channel/client on the endpoint strand (its +// RudpStream is touched there). Generic over the two entry maps. +template +void client_close(Map& reg, uint64_t id) { + auto it = reg.find(id); + if (it == reg.end()) return; + auto entry = std::move(it->second); + reg.erase(it); + if (g_luarpc_io) + boost::asio::post(*g_luarpc_io, [e = std::move(entry)]() { (void)e; }); + // else: no endpoint → entry destroyed here (harmless; can't really happen). +} + +// Run a verb-client round-trip. The handle is arg 1; its entry + arg parse +// happen on the Lua thread BEFORE this (the registry is Lua-thread-only), and +// `job` captures the raw client pointer + by-value args. From a coroutine the +// blocking call is offloaded to the worker pool and the coroutine yields; +// otherwise it runs inline (main-chunk degenerate case). +// +// The raw pointer the worker holds stays valid because this serializes per +// handle: at most one offloaded verb in flight per handle (a second verb on a +// busy handle returns "busy", never a second worker on the same client), and a +// close() that arrives while a verb is in flight is DEFERRED (closePending) to +// completion. So a program sharing a handle across coroutines can never free the +// client under a worker or run two workers on one client. inflight/closePending +// are touched only on the Lua thread (here + the deliver + close). +template +int client_call(lua_State* L, Map& reg, OffloadJob job) { + uint64_t id = client_handle_id(L, 1); + auto it = reg.find(id); + if (it == reg.end()) return client_push_closed(L); + if (io_yieldable(L)) { + if (it->second.inflight) { + lua_pushnil(L); + lua_pushstring(L, "busy"); + return 2; + } + it->second.inflight = true; + g_parked.insert(L); + g_pool_client.ensure(g_client_pool_size); + g_pool_client.submit(L, [job = std::move(job), ®, id]() -> OffloadDeliver { + OffloadDeliver d = job(); // worker: the blocking verb + return [d = std::move(d), ®, id](lua_State* L) -> int { + auto it = reg.find(id); // Lua thread (at resume) + if (it != reg.end()) { + it->second.inflight = false; + if (it->second.closePending) client_close(reg, id); // deferred close + } + return d(L); // verb results (no client ref) + }; + }); + return lua_yield(L, 0); + } + OffloadDeliver deliver = job(); + return deliver(L); +} + +// ---- ces.file_client verbs ---- + +int lua_fc_create(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + uint64_t size = client_arg_u64(L, 3); + uint64_t price = client_arg_u64(L, 4); + uint64_t deposit = client_arg_u64(L, 5); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), size, price, + deposit]() -> OffloadDeliver { + uint64_t fb = 0, cost = 0; + uint8_t rc = fc->create(nm, size, price, deposit, fb, cost); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [fb, cost](lua_State* L) -> int { + lua_pushnumber(L, static_cast(fb)); + lua_pushnumber(L, static_cast(cost)); + return 2; + }; + }); +} + +int lua_fc_write(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + uint64_t offset = client_arg_u64(L, 3); + size_t blen = 0; const char* b = luaL_checklstring(L, 4, &blen); + if (blen == 0 || blen > (1024 * 1024)) { + lua_pushnil(L); lua_pushstring(L, "data must be 1..1048576 bytes"); return 2; + } + ces::Bytes content(reinterpret_cast(b), + reinterpret_cast(b) + blen); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), offset, + content]() -> OffloadDeliver { + uint64_t fb = 0; + uint8_t rc = fc->write(nm, offset, content, fb); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [fb](lua_State* L) -> int { + lua_pushnumber(L, static_cast(fb)); return 1; + }; + }); +} + +int lua_fc_read(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + uint64_t offset = client_arg_u64(L, 3); + uint64_t length64 = client_arg_u64(L, 4); + if (length64 == 0 || length64 > (1024 * 1024)) { + lua_pushnil(L); lua_pushstring(L, "length must be 1..1048576 bytes"); return 2; + } + uint32_t length = static_cast(length64); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), offset, + length]() -> OffloadDeliver { + ces::Bytes content; + minx::Hash rangeHash{}; + uint8_t rc = fc->read(nm, offset, length, content, rangeHash); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [content, rangeHash](lua_State* L) -> int { + lua_pushlstring(L, reinterpret_cast(content.data()), + content.size()); + lua_pushlstring(L, reinterpret_cast(rangeHash.data()), + rangeHash.size()); + return 2; + }; + }); +} + +int lua_fc_append(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + size_t blen = 0; const char* b = luaL_checklstring(L, 3, &blen); + if (blen == 0 || blen > (1024 * 1024)) { + lua_pushnil(L); lua_pushstring(L, "data must be 1..1048576 bytes"); return 2; + } + ces::Bytes content(reinterpret_cast(b), + reinterpret_cast(b) + blen); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), + content]() -> OffloadDeliver { + uint64_t fb = 0, newSize = 0; + uint8_t rc = fc->append(nm, content, fb, newSize); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [fb, newSize](lua_State* L) -> int { + lua_pushnumber(L, static_cast(fb)); + lua_pushnumber(L, static_cast(newSize)); + return 2; + }; + }); +} + +int lua_fc_resize(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + uint64_t newSize = client_arg_u64(L, 3); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), + newSize]() -> OffloadDeliver { + uint64_t outSize = 0; + uint8_t rc = fc->resize(nm, newSize, outSize); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [outSize](lua_State* L) -> int { + lua_pushnumber(L, static_cast(outSize)); return 1; + }; + }); +} + +int lua_fc_deposit(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + uint64_t amount = client_arg_u64(L, 3); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), + amount]() -> OffloadDeliver { + uint64_t fb = 0; + uint8_t rc = fc->deposit(nm, amount, fb); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [fb](lua_State* L) -> int { + lua_pushnumber(L, static_cast(fb)); return 1; + }; + }); +} + +int lua_fc_withdraw(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + uint64_t amount = client_arg_u64(L, 3); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), + amount]() -> OffloadDeliver { + uint64_t fb = 0; + uint8_t rc = fc->withdraw(nm, amount, fb); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [fb](lua_State* L) -> int { + lua_pushnumber(L, static_cast(fb)); return 1; + }; + }); +} + +int lua_fc_set_price(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + uint64_t newPrice = client_arg_u64(L, 3); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen), + newPrice]() -> OffloadDeliver { + uint64_t outPrice = 0; + uint8_t rc = fc->setPrice(nm, newPrice, outPrice); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [outPrice](lua_State* L) -> int { + lua_pushnumber(L, static_cast(outPrice)); return 1; + }; + }); +} + +int lua_fc_delete(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen)]() -> OffloadDeliver { + uint64_t refunded = 0; + uint8_t rc = fc->deleteFile(nm, refunded); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [refunded](lua_State* L) -> int { + lua_pushnumber(L, static_cast(refunded)); return 1; + }; + }); +} + +int lua_fc_stat(lua_State* L) { + auto* e = file_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + ces::CesFileClient* fc = e->fc.get(); + return client_call(L, g_file_clients, [fc, nm = std::string(name, nlen)]() -> OffloadDeliver { + ces::CesFileClient::StatInfo info; + uint8_t rc = fc->stat(nm, info); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [info](lua_State* L) -> int { + lua_newtable(L); + lua_pushlstring(L, reinterpret_cast(info.ownerPubkey.data()), + info.ownerPubkey.size()); + lua_setfield(L, -2, "owner_pubkey"); + lua_pushnumber(L, static_cast(info.fileBalance)); + lua_setfield(L, -2, "file_balance"); + lua_pushnumber(L, static_cast(info.pricePerKb)); + lua_setfield(L, -2, "price_per_kb"); + lua_pushnumber(L, static_cast(info.size)); + lua_setfield(L, -2, "size"); + lua_pushnumber(L, static_cast(info.createdUs)); + lua_setfield(L, -2, "created_us"); + lua_pushnumber(L, static_cast(info.modifiedUs)); + lua_setfield(L, -2, "modified_us"); + return 1; + }; + }); +} + +int lua_fc_close(lua_State* L) { + uint64_t id = client_handle_id(L, 1); + auto it = g_file_clients.find(id); + if (it == g_file_clients.end()) return 0; + if (it->second.inflight) { it->second.closePending = true; return 0; } // defer + client_close(g_file_clients, id); + return 0; +} + +void push_file_handle(lua_State* L, uint64_t id) { + lua_newtable(L); + lua_pushnumber(L, static_cast(id)); lua_setfield(L, -2, "id"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "create"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "write"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "read"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "append"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "resize"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "deposit"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "withdraw"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "set_price"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "delete"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "stat"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "close"); +} + +// Dial `proto` and build a verb-client handle. The blocking bind (ch->select) +// is offloaded to the client pool from a coroutine, so dialing never freezes +// the loop; from the main chunk it binds inline. `makeHandle` registers the +// bound channel and pushes the Lua handle — it always runs on the Lua thread +// (inline, or in the deliver). The half-open channel on a bind failure is +// destroyed inside client_open_channel on the endpoint strand. +using ChannelHandleFn = + std::function)>; +int client_dial(lua_State* L, const std::string& addr, const uint8_t* pk, + const char* proto, ChannelHandleFn makeHandle) { + luarpc_ensure_endpoint(); // Lua thread: lazy endpoint setup (idempotent) + if (!g_luarpc_io || !g_luarpc_rudp || !g_luarpc_signer) { + lua_pushnil(L); + lua_pushstring(L, "networking disabled: instance has no rpc port"); + return 2; + } + std::array pkbuf{}; + bool hasPk = (pk != nullptr); + if (hasPk) std::memcpy(pkbuf.data(), pk, 32); + std::string protoStr(proto); + + if (io_yieldable(L)) { + lua_State* co = L; + g_parked.insert(co); + g_pool_client.ensure(g_client_pool_size); + g_pool_client.submit(co, [addr, pkbuf, hasPk, protoStr, makeHandle]() + -> OffloadDeliver { + std::string err; + auto ch = client_open_channel(addr, hasPk ? pkbuf.data() : nullptr, + protoStr.c_str(), err); + if (!ch) + return [err](lua_State* L) { + lua_pushnil(L); lua_pushlstring(L, err.data(), err.size()); return 2; + }; + auto holder = + std::make_shared>(std::move(ch)); + return [holder, makeHandle](lua_State* L) -> int { + return makeHandle(L, std::move(*holder)); + }; + }); + return lua_yield(L, 0); + } + + std::string err; + auto ch = client_open_channel(addr, pk, proto, err); + if (!ch) { lua_pushnil(L); lua_pushstring(L, err.c_str()); return 2; } + return makeHandle(L, std::move(ch)); +} + +int lua_ces_file_client(lua_State* L) { + std::string addr; const uint8_t* pk = nullptr; uint8_t pkbuf[32]; + if (!client_read_dial_args(L, addr, &pk, pkbuf)) return 2; + return client_dial(L, addr, pk, "/ces/file/1", + [](lua_State* L, std::unique_ptr ch) -> int { + auto fc = std::make_unique(); + fc->attach(*ch); + uint64_t id = g_client_next_id++; + auto& e = g_file_clients[id]; + e.ch = std::move(ch); + e.fc = std::move(fc); + push_file_handle(L, id); + return 1; + }); +} + +// ---- ces.compute_client verbs ---- + +void push_instance_info(lua_State* L, + const ces::CesComputeClient::InstanceInfo& info) { + lua_newtable(L); + lua_pushnumber(L, static_cast(info.pid)); + lua_setfield(L, -2, "pid"); + lua_pushlstring(L, info.sourceName.data(), info.sourceName.size()); + lua_setfield(L, -2, "source_name"); + lua_pushnumber(L, static_cast(info.startedAtUs)); + lua_setfield(L, -2, "started_at_us"); + lua_pushnumber(L, static_cast(info.fileBalance)); + lua_setfield(L, -2, "file_balance"); + lua_pushnumber(L, static_cast(info.cpuBasisPoints)); + lua_setfield(L, -2, "cpu_basis_points"); + lua_pushnumber(L, static_cast(info.rssBytes)); + lua_setfield(L, -2, "rss_bytes"); + lua_pushnumber(L, static_cast(info.clientPort)); + lua_setfield(L, -2, "client_port"); + lua_pushnumber(L, static_cast(info.rpcPort)); + lua_setfield(L, -2, "rpc_port"); + lua_pushlstring(L, reinterpret_cast(info.programPubkey.data()), + info.programPubkey.size()); + lua_setfield(L, -2, "program_pubkey"); +} + +int lua_cc_launch(lua_State* L) { + auto* e = compute_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t nlen = 0; const char* name = luaL_checklstring(L, 2, &nlen); + ces::CesComputeClient* cc = e->cc.get(); + return client_call(L, g_compute_clients, [cc, nm = std::string(name, nlen)]() -> OffloadDeliver { + uint64_t id = 0, startedAt = 0; + uint8_t rc = cc->launch(nm, id, startedAt); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [id, startedAt](lua_State* L) -> int { + lua_pushnumber(L, static_cast(id)); + lua_pushnumber(L, static_cast(startedAt)); + return 2; + }; + }); +} + +int lua_cc_kill(lua_State* L) { + auto* e = compute_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + uint64_t id = client_arg_u64(L, 2); + ces::CesComputeClient* cc = e->cc.get(); + return client_call(L, g_compute_clients, [cc, id]() -> OffloadDeliver { + uint8_t rc = cc->kill(id); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [](lua_State* L) -> int { lua_pushboolean(L, 1); return 1; }; + }); +} + +int lua_cc_stat(lua_State* L) { + auto* e = compute_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + uint64_t id = client_arg_u64(L, 2); + ces::CesComputeClient* cc = e->cc.get(); + return client_call(L, g_compute_clients, [cc, id]() -> OffloadDeliver { + ces::CesComputeClient::InstanceInfo info; + uint8_t rc = cc->stat(id, info); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [info](lua_State* L) -> int { push_instance_info(L, info); return 1; }; + }); +} + +int lua_cc_list(lua_State* L) { + auto* e = compute_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + ces::CesComputeClient* cc = e->cc.get(); + return client_call(L, g_compute_clients, [cc]() -> OffloadDeliver { + std::vector infos; + uint8_t rc = cc->list(infos); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [infos](lua_State* L) -> int { + lua_newtable(L); + for (size_t i = 0; i < infos.size(); ++i) { + push_instance_info(L, infos[i]); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + }; + }); +} + +int lua_cc_instances(lua_State* L) { + auto* e = compute_entry(client_handle_id(L, 1)); + if (!e) return client_push_closed(L); + size_t plen = 0; const char* path = luaL_checklstring(L, 2, &plen); + ces::CesComputeClient* cc = e->cc.get(); + return client_call(L, g_compute_clients, [cc, p = std::string(path, plen)]() -> OffloadDeliver { + std::vector infos; + uint8_t rc = cc->instances(p, infos); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { return client_push_err(L, rc); }; + return [infos](lua_State* L) -> int { + lua_newtable(L); + for (size_t i = 0; i < infos.size(); ++i) { + push_instance_info(L, infos[i]); + lua_rawseti(L, -2, static_cast(i + 1)); + } + return 1; + }; + }); +} + +int lua_cc_close(lua_State* L) { + uint64_t id = client_handle_id(L, 1); + auto it = g_compute_clients.find(id); + if (it == g_compute_clients.end()) return 0; + if (it->second.inflight) { it->second.closePending = true; return 0; } // defer + client_close(g_compute_clients, id); + return 0; +} + +void push_compute_handle(lua_State* L, uint64_t id) { + lua_newtable(L); + lua_pushnumber(L, static_cast(id)); lua_setfield(L, -2, "id"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "launch"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "kill"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "stat"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "list"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "instances"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "close"); +} + +int lua_ces_compute_client(lua_State* L) { + std::string addr; const uint8_t* pk = nullptr; uint8_t pkbuf[32]; + if (!client_read_dial_args(L, addr, &pk, pkbuf)) return 2; + return client_dial(L, addr, pk, "/ces/compute/1", + [](lua_State* L, std::unique_ptr ch) -> int { + auto cc = std::make_unique(); + cc->attach(*ch); + uint64_t id = g_client_next_id++; + auto& e = g_compute_clients[id]; + e.ch = std::move(ch); + e.cc = std::move(cc); + push_compute_handle(L, id); + return 1; + }); +} + +// ces.err — CES error names → codes, for legible error checks. +void install_ces_err_table(lua_State* L) { + lua_newtable(L); + const struct { const char* name; int code; } kErrs[] = { + {"OK", ces::CES_OK}, + {"ORIGIN_NOT_FOUND", ces::CES_ERROR_ORIGIN_NOT_FOUND}, + {"WRONG_NONCE", ces::CES_ERROR_WRONG_NONCE}, + {"INSUFFICIENT_BALANCE", ces::CES_ERROR_INSUFFICIENT_BALANCE}, + {"INSUFFICIENT_BALANCE_WITH_CREATE", + ces::CES_ERROR_INSUFFICIENT_BALANCE_WITH_CREATE}, + {"INVALID_TARGET_ACCOUNT", ces::CES_ERROR_INVALID_TARGET_ACCOUNT}, + {"WRONG_TARGET_ACCOUNT", ces::CES_ERROR_WRONG_TARGET_ACCOUNT}, + {"WRONG_PAYMENT_AMOUNT", ces::CES_ERROR_WRONG_PAYMENT_AMOUNT}, + {"ASSET_EXISTS", ces::CES_ERROR_ASSET_EXISTS}, + {"ASSET_NOT_FOUND", ces::CES_ERROR_ASSET_NOT_FOUND}, + {"NOT_OWNER", ces::CES_ERROR_NOT_OWNER}, + {"NOT_FOR_SALE", ces::CES_ERROR_NOT_FOR_SALE}, + {"INSUFFICIENT_PAYMENT", ces::CES_ERROR_INSUFFICIENT_PAYMENT}, + {"TIMEOUT", ces::CES_ERROR_TIMEOUT}, + {"INTERNAL", ces::CES_ERROR_INTERNAL}, + {"TARGET_NOT_FOUND", ces::CES_ERROR_TARGET_NOT_FOUND}, + {"UNKNOWN_PEER", ces::CES_ERROR_UNKNOWN_PEER}, + {"QUEUE_FULL", ces::CES_ERROR_QUEUE_FULL}, + {"VM_FAILED", ces::CES_ERROR_VM_FAILED}, + {"DISABLED", ces::CES_ERROR_DISABLED}, + {"ALLOWANCE_EXCEEDED", ces::CES_ERROR_ALLOWANCE_EXCEEDED}, + {"PROTO_REJECTED", ces::CES_ERROR_PROTO_REJECTED}, + {"FILE_NOT_FOUND", ces::CES_ERROR_FILE_NOT_FOUND}, + {"FILE_EXISTS", ces::CES_ERROR_FILE_EXISTS}, + {"BAD_NAME", ces::CES_ERROR_BAD_NAME}, + {"PATH_CONFLICT", ces::CES_ERROR_PATH_CONFLICT}, + {"STORE_FULL", ces::CES_ERROR_STORE_FULL}, + {"COMPUTE_DISABLED", ces::CES_ERROR_COMPUTE_DISABLED}, + {"COMPUTE_NO_FILE_HANDLER", ces::CES_ERROR_COMPUTE_NO_FILE_HANDLER}, + {"COMPUTE_FUND_TOO_LOW", ces::CES_ERROR_COMPUTE_FUND_TOO_LOW}, + {"COMPUTE_INSTANCE_NOT_FOUND", ces::CES_ERROR_COMPUTE_INSTANCE_NOT_FOUND}, + {"COMPUTE_MAX_INSTANCES", ces::CES_ERROR_COMPUTE_MAX_INSTANCES}, + {"NOT_LISTENING", ces::CES_ERROR_NOT_LISTENING}, + {"IMMUTABLE", ces::CES_ERROR_IMMUTABLE}, + {"BAD_INPUT", ces::CES_ERROR_BAD_INPUT}, + }; + for (const auto& e : kErrs) { + lua_pushinteger(L, e.code); + lua_setfield(L, -2, e.name); + } + lua_setfield(L, -2, "err"); // ces.err = {...} (ces table is at -2) +} + +void install_ces_api(lua_State* L) { + lua_newtable(L); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "now"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "sha256"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "log"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "sign"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "verify"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "serverSign"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "serverVerify"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "every"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "cancel"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "spawn"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "sleep"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "chan"); + // ces.chan metatable (methods + __gc), created once. + luaL_newmetatable(L, kChanMT); + lua_pushvalue(L, -1); lua_setfield(L, -2, "__index"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "send"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "recv"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "close"); + lua_pushcfunction(L, lua_chan_gc); lua_setfield(L, -2, "__gc"); + lua_pop(L, 1); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "client_recv"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "client_send"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "prog_prefix"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "owner_pubkey"); + lua_pushcfunction(L, guarded);lua_setfield(L, -2, "program_pubkey"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "rpc_port"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "start_time"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "transfer"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "random_bytes"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "account_read"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "keyname"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "peers"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "server_info"); + // Peering CONTROL is operator-only: present solely for privileged (/s/) + // programs. The supervisor enforces the same gate, so the boundary holds even + // if the sandbox were bypassed. + if (g_privileged) { + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "add_peer"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "remove_peer"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "grief_peer"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "ban_peer"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "set_peer_target"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "peer_target"); + } + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "ping"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "peer_info"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "authentic_asset_create"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "bucket_new"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_create"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_write"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_read"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_stat"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_deposit"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_withdraw"); + lua_pushcfunction(L, guarded);lua_setfield(L, -2, "file_set_price"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_delete"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_append"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_resize"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "store"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "file_client"); + lua_pushcfunction(L, guarded);lua_setfield(L, -2, "compute_client"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "remote_account_read"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "remote_transfer"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "remote_cross_transfer"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "cross_transfer"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "request_funds"); + + // ces.conn — the UNIFIED connection API: one listener + run loop + conn shape + // over BOTH transports (relay /ces/lua/1, server-relayed; direct /ces/luarpc/1 + // on this instance's own port). connect() dials out the direct transport. + // Inbound conns from either fan into one set of callbacks; tell them apart, if + // you must, by conn.source (0 = relay, 1 = direct). + lua_newtable(L); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "set_listener"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "run"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "connect"); + lua_setfield(L, -2, "conn"); + + // ces.gossip — flood/route messaging over the server mesh. send(msg, budget + // [, dest]) originates a flood; inbound is delivered by calling the program's + // global on_gossip(msg, {author,sender,msgid,dest}) if it defined one. A node + // delivers its own originated gossip to its own programs too, so local + // extensions stay in step with the swarm. + lua_newtable(L); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "send"); + lua_setfield(L, -2, "gossip"); + +#ifdef CES_MAIL + // ces.mail.send{to=, subject=, body=, attach=} - one-way outbound email via + // the server's relay (builtin:mail). Charged (burned) from the program's own + // account per encoded MB. Present only in a --mail build. + lua_newtable(L); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "send"); + lua_setfield(L, -2, "mail"); +#endif // CES_MAIL + + // ces.peer.send(peer_pubkey, service, bytes) fires a targeted, free message + // over the /ces/peer/1 mesh to a peer server's same-service extension; + // ces.peer.listen(service, fn) registers fn(from_pubkey, bytes) for inbound. + lua_newtable(L); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "send"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "listen"); + lua_setfield(L, -2, "peer"); + + // Direct-transport live-conn registry (the relay's is created lazily in + // set_listener). + lua_newtable(L); + lua_setfield(L, LUA_REGISTRYINDEX, kRegLuaRpcConns); + + // ces.run() — the unified event loop (ces.conn.run aliases it). + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "run"); + + // ces.err — error names → codes. + install_ces_err_table(L); + + // ces.extension_admin — a callable table: ces.extension_admin{...} registers + // the admin contract (via __call); ces.extension_admin.disable_self() turns + // this instance off; ces.extension_admin.save_config(text) persists the + // extension's /s/.conf (panel config forms apply live + save through + // one update). + lua_newtable(L); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "disable_self"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "save_config"); + lua_newtable(L); // metatable + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "__call"); + lua_setmetatable(L, -2); + lua_setfield(L, -2, "extension_admin"); + +#ifdef CES_HYLE + // ces.hyle.solo - extension-only API hosting an in-process solo hyle node (only + // when built with --hyle). Privileged (/s/) programs only: an extension is by + // definition a privileged L2 compute program, so the whole surface is gated. + if (g_privileged) install_ces_hyle_api(L); +#endif + + lua_setglobal(L, "ces"); +} + +// `cesluajitd --manifest `: harvest a program's CES_MANIFEST WITHOUT +// running it as a service. Loads the file in a no-op sandbox — a stub `ces` whose +// every index/call yields itself, so ces.run()/ces.transfer()/... do nothing — +// runs it under a tight instruction budget, then reads the static CES_MANIFEST +// global. CES_MANIFEST is the program's first statement, so it's set even if +// later code aborts. No source-text parsing: the real Lua loader evaluates the +// actual table. Prints name/version/description on three lines. +void probe_budget_hook(lua_State* L, lua_Debug*) { + luaL_error(L, "manifest probe budget exceeded"); +} +int probe_manifest(const char* path) { + std::FILE* fp = std::fopen(path, "rb"); + if (!fp) return 1; + std::string src; + char rbuf[4096]; + size_t n; + while ((n = std::fread(rbuf, 1, sizeof(rbuf), fp)) > 0) src.append(rbuf, n); + std::fclose(fp); + if (!src.empty() && static_cast(src[0]) == 0x1b) return 1; // no bytecode + + lua_State* L = luaL_newstate(); + if (!L) return 1; + load_safe_libs(L); + luaL_dostring(L, // universal no-op `ces` stub + "local s; s = setmetatable({}, {__index=function() return s end," + " __call=function() return s end}); ces = s"); + lua_sethook(L, probe_budget_hook, LUA_MASKCOUNT, 2000000); + + std::string name, version, desc; + if (luaL_loadbufferx(L, src.data(), src.size(), "=probe", "t") == 0) + lua_pcall(L, 0, 0, 0); // errors ignored; CES_MANIFEST set first + lua_sethook(L, nullptr, 0, 0); + lua_getglobal(L, "CES_MANIFEST"); + if (lua_istable(L, -1)) { + int t = lua_gettop(L); + name = ext_str_field(L, t, "name"); + version = ext_str_field(L, t, "version"); + desc = ext_str_field(L, t, "description"); + } + lua_close(L); + std::printf("%s\n%s\n%s\n", name.c_str(), version.c_str(), desc.c_str()); + return 0; +} + diff --git a/vendor/ces/src/cesluajitd/api_conn.inc b/vendor/ces/src/cesluajitd/api_conn.inc new file mode 100644 index 0000000..32f8e90 --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_conn.inc @@ -0,0 +1,382 @@ +// --------------------------------------------------------------------------- +// ces.conn — /ces/lua/1 connection routing. +// --------------------------------------------------------------------------- +// +// Programs that want to accept user RUDP connections call +// ces.conn.set_listener({on_open, on_data, on_close}) to flip the +// server-side accept gate ON, then ces.conn.run() to enter the event +// loop. The runtime dispatches per-connection events to the matching +// callback. conn objects (Lua tables) provide :write(s) / :close() +// and read-only .id / .pubkey fields. +// +// No coroutines, no yielding — callback-driven for v1. Programs that +// need byte-streaming semantics keep their own per-conn state across +// callback invocations. +// +// The accept gate is one bool per instance, server-side. The listener +// table is held in the Lua registry; passing nil to set_listener +// flips the gate OFF (existing connections continue to work). + +// Registry keys. kRegListenerTable is the SINGLE listener shared by BOTH +// transports — relay /ces/lua/1 and direct /ces/luarpc/1. Each transport keeps +// its OWN live-conn table keyed by its native conn id (relay: the supervisor's +// per-instance nextConnId; direct: this endpoint's g_luarpc_next_id) purely for +// inbound routing. Those two native spaces are independent and overlap, so they +// are NEVER the program-facing identity — see g_conn_uid_next. +constexpr const char* kRegListenerTable = "ces.conn.listener"; +// Registry table service(string) -> handler fn, set by ces.peer.listen and +// read by drain_peer to dispatch an inbound /ces/peer/1 message. +constexpr const char* kRegPeerListeners = "ces.peer.listeners"; +constexpr const char* kRegConnsTable = "ces.conn.relay.live"; // [native] = conn + +// Program-facing conn.id: ONE monotonic id owned entirely by this host, handed +// out at open for EITHER transport, so it is unique across both — a program can +// key its own state by conn.id with zero collisions. The native routing id is +// kept privately in conn.__sid. conn.source (below) still reports the origin. +// Lua-thread-only (the dispatchers and connect all run on the Lua thread). +uint64_t g_conn_uid_next = 1; + +// conn.source — informational origin only, NOT identity and NOT used to route. +constexpr int CONN_SOURCE_RELAY = 0; // /ces/lua/1, server-relayed over the UDS +constexpr int CONN_SOURCE_DIRECT = 1; // /ces/luarpc/1, this instance's own rpc port + +// Per-conn close-reason byte (matches kCloseReason* in handler). +constexpr uint8_t CONN_CLOSE_NORMAL = 0x00; +constexpr uint8_t CONN_CLOSE_INTERNAL = 0x01; +constexpr uint8_t CONN_CLOSE_INSTANCE = 0x02; +constexpr uint8_t CONN_CLOSE_PROGRAM = 0x03; + +// Push the registry-stored conn table for `conn_id` onto the stack, +// or nil if not present. Always pushes one value. +void push_conn_table(lua_State* L, uint64_t conn_id) { + lua_getfield(L, LUA_REGISTRYINDEX, kRegConnsTable); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_pushnil(L); + return; + } + // Use double-as-int key (LuaJIT 5.1 has no native u64 in tables; + // double is exact for u64 < 2^53, our nextConnId starts at 1 and + // grows monotonically per instance — well below 2^53). + lua_pushnumber(L, static_cast(conn_id)); + lua_rawget(L, -2); + // Stack: [conns_table, conn_or_nil]. Remove conns_table. + lua_remove(L, -2); +} + +// Send TAG_CONN_DATA_OUT for (conn_id, bytes). Returns true on +// IPC write success. +bool send_conn_data_out(uint64_t conn_id, + const uint8_t* data, size_t len) { + std::vector body; + body.reserve(sizeof(uint64_t) + sizeof(uint32_t) + len); + put_u64(body, conn_id); + put_u32(body, static_cast(len)); + if (len > 0) + body.insert(body.end(), data, data + len); + return write_frame(TAG_CONN_DATA_OUT, 0, body.data(), body.size()); +} + +// Send TAG_CONN_CLOSE for conn_id. +bool send_conn_close(uint64_t conn_id) { + std::vector body; + body.reserve(sizeof(uint64_t)); + put_u64(body, conn_id); + return write_frame(TAG_CONN_CLOSE, 0, body.data(), body.size()); +} + +// conn:write(s) — Lua method on a conn table. +// Returns true on success, nil + err on failure. +int lua_ces_conn_write(lua_State* L) { + if (!lua_istable(L, 1)) { + lua_pushnil(L); lua_pushstring(L, "expected conn table"); return 2; + } + // Reject if marked closed. + lua_getfield(L, 1, "closed"); + bool closed = lua_toboolean(L, -1); + lua_pop(L, 1); + if (closed) { + lua_pushnil(L); lua_pushstring(L, "closed"); return 2; + } + lua_getfield(L, 1, "__sid"); + if (!lua_isnumber(L, -1)) { + lua_pop(L, 1); + lua_pushnil(L); lua_pushstring(L, "missing routing id"); return 2; + } + uint64_t id = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + + size_t n = 0; + const char* s = luaL_checklstring(L, 2, &n); + if (n > 1024 * 1024) { + lua_pushnil(L); + lua_pushstring(L, "payload too large (max 1 MB per write)"); + return 2; + } + if (!send_conn_data_out(id, reinterpret_cast(s), n)) { + lua_pushnil(L); lua_pushstring(L, "ipc write failed"); return 2; + } + lua_pushboolean(L, 1); + return 1; +} + +// conn:close() — Lua method on a conn table. +int lua_ces_conn_close(lua_State* L) { + if (!lua_istable(L, 1)) { + lua_pushnil(L); lua_pushstring(L, "expected conn table"); return 2; + } + lua_getfield(L, 1, "closed"); + bool already = lua_toboolean(L, -1); + lua_pop(L, 1); + if (already) { + lua_pushboolean(L, 1); + return 1; + } + lua_getfield(L, 1, "__sid"); + uint64_t id = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + send_conn_close(id); + // Mark closed locally + drop from g_conns_table. Note: the host + // does not echo back CONN_CLOSED for program-side close (saves + // an unnecessary IPC round-trip). + lua_pushboolean(L, 1); + lua_setfield(L, 1, "closed"); + lua_getfield(L, LUA_REGISTRYINDEX, kRegConnsTable); + if (lua_istable(L, -1)) { + lua_pushnumber(L, static_cast(id)); + lua_pushnil(L); + lua_rawset(L, -3); + } + lua_pop(L, 1); + lua_pushboolean(L, 1); + return 1; +} + +// ces.conn.set_listener(table_or_nil) — the ONE accept gate for both transports. +// Non-nil: stores the listener, sends TAG_LISTEN_ON (relay accept gate), flips +// the direct accept gate on, and lazy-opens this instance's /ces/luarpc/1 +// endpoint (a no-op if it got no rpc port). Inbound conns from EITHER transport +// fan into the same on_open/on_data/on_close; tell them apart, if you must, by +// conn.source. nil: clears the listener and closes both gates (existing conns in +// either live table keep working). +int lua_ces_conn_set_listener(lua_State* L) { + if (lua_isnil(L, 1)) { + lua_pushnil(L); + lua_setfield(L, LUA_REGISTRYINDEX, kRegListenerTable); + g_luarpc_listening.store(false, std::memory_order_relaxed); // direct gate off + { std::lock_guard lk(g_luarpc_hello_mx); g_luarpc_hello.clear(); } + if (!write_frame(TAG_LISTEN_OFF, 0, nullptr, 0)) { // relay gate off + lua_pushnil(L); lua_pushstring(L, "ipc write failed"); return 2; + } + lua_pushboolean(L, 1); + return 1; + } + if (!lua_istable(L, 1)) { + return luaL_error(L, "ces.conn.set_listener: expected table or nil"); + } + // Stash the listener table in the registry — both dispatchers read it. + lua_pushvalue(L, 1); + lua_setfield(L, LUA_REGISTRYINDEX, kRegListenerTable); + // Make sure the relay live-conns table exists. + lua_getfield(L, LUA_REGISTRYINDEX, kRegConnsTable); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_setfield(L, LUA_REGISTRYINDEX, kRegConnsTable); + } else { + lua_pop(L, 1); + } + // The optional greeting (table.hello), read once and used by BOTH transports: + // it rides the relay's ATTACH reply (TAG_LISTEN_ON below) and is stashed for + // the direct luarpc handler to send on conn-open, so the program's on_open + // never needs per-transport code. Stashed before the endpoint opens, so it is + // ready before any inbound direct channel can be served. + size_t helloLen = 0; + const char* hello = nullptr; + lua_getfield(L, 1, "hello"); + if (lua_isstring(L, -1)) hello = lua_tolstring(L, -1, &helloLen); + { + std::lock_guard lk(g_luarpc_hello_mx); + g_luarpc_hello.assign(hello ? hello : "", helloLen); + } + // Direct transport: flip the gate on + open the endpoint (no-op if no port). + g_luarpc_listening.store(true, std::memory_order_relaxed); + luarpc_ensure_endpoint(); + // Relay transport: tell the supervisor to start accepting ATTACHes, carrying + // the same greeting in each ATTACH reply. + bool listenOk = write_frame(TAG_LISTEN_ON, 0, + reinterpret_cast(hello), helloLen); + lua_pop(L, 1); + if (!listenOk) { + lua_pushnil(L); lua_pushstring(L, "ipc write failed"); return 2; + } + lua_pushboolean(L, 1); + return 1; +} + +// Build a fresh relay conn table for native id `conn_id` + `pubkey`. The +// program-facing conn.id is a fresh host uid (unique across transports); the +// native id goes in conn.__sid for routing and keys kRegConnsTable[conn_id] (so +// later DATA_IN / CLOSED frames, which carry the native id, find it). Leaves the +// conn table on the stack top, ready to pass to a Lua callback. +void make_and_register_conn(lua_State* L, uint64_t conn_id, + const uint8_t* pubkey) { + lua_newtable(L); + lua_pushnumber(L, static_cast(g_conn_uid_next++)); + lua_setfield(L, -2, "id"); + lua_pushinteger(L, CONN_SOURCE_RELAY); + lua_setfield(L, -2, "source"); + lua_pushnumber(L, static_cast(conn_id)); // native routing id + lua_setfield(L, -2, "__sid"); + lua_pushlstring(L, reinterpret_cast(pubkey), 32); + lua_setfield(L, -2, "pubkey"); + lua_pushboolean(L, 0); + lua_setfield(L, -2, "closed"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "write"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "close"); + // Register in the live-conns table, keyed by the native id (the wire id the + // dispatcher will look up on later DATA_IN / CLOSED frames). + lua_getfield(L, LUA_REGISTRYINDEX, kRegConnsTable); + if (lua_istable(L, -1)) { + lua_pushnumber(L, static_cast(conn_id)); + lua_pushvalue(L, -3); // copy of the conn table + lua_rawset(L, -3); + } + lua_pop(L, 1); + // Stack-top: the conn table. +} + +// Drop a relay conn from the live-conns registry (after on_close completes, or +// when a CLOSED frame has no handler to run). +void remove_conn(lua_State* L, uint64_t id) { + lua_getfield(L, LUA_REGISTRYINDEX, kRegConnsTable); + if (lua_istable(L, -1)) { + lua_pushnumber(L, static_cast(id)); + lua_pushnil(L); + lua_rawset(L, -3); + } + lua_pop(L, 1); +} + +// Prepare coroutine `co` to run the listener callback for frame `f`: pushes +// [callback, conn, (data)] onto co and returns the arg count (1 or 2), or -1 if +// there is nothing to run (no listener, no callback, or the conn is gone). For +// OPEN the conn is created+registered here (even when on_open is absent, so the +// callback still finds it); for CLOSE the conn is marked closed before the +// callback. Stack-neutral on mainL. +int setup_handler_co(lua_State* mainL, lua_State* co, const Frame& f) { + if (f.body.size() < sizeof(uint64_t)) return -1; + uint64_t id = get_u64(f.body.data()); + constexpr size_t dataHdr = sizeof(uint64_t) + sizeof(uint32_t); + + // Per-tag frame validation up front. + if (f.tag == TAG_CONN_OPENED && + f.body.size() < sizeof(uint64_t) + WIRE_KEY_LEN) return -1; + if (f.tag == TAG_CONN_DATA_IN) { + if (f.body.size() < dataHdr) return -1; + uint32_t dlen = get_u32(f.body.data() + sizeof(uint64_t)); + if (f.body.size() < dataHdr + dlen) return -1; + } + if (f.tag == TAG_CONN_CLOSED && + f.body.size() < sizeof(uint64_t) + sizeof(uint8_t)) return -1; + + lua_getfield(mainL, LUA_REGISTRYINDEX, kRegListenerTable); + if (!lua_istable(mainL, -1)) { lua_pop(mainL, 1); return -1; } + + const char* cbname = (f.tag == TAG_CONN_OPENED) ? "on_open" + : (f.tag == TAG_CONN_DATA_IN) ? "on_data" + : "on_close"; + + if (f.tag == TAG_CONN_OPENED) { + const uint8_t* pk = f.body.data() + sizeof(uint64_t); + make_and_register_conn(mainL, id, pk); // mainL: [listener, conn] + lua_getfield(mainL, -2, cbname); // [listener, conn, cb] + if (!lua_isfunction(mainL, -1)) { lua_pop(mainL, 3); return -1; } + lua_xmove(mainL, co, 1); // cb -> co ; mainL: [listener, conn] + lua_xmove(mainL, co, 1); // conn -> co ; mainL: [listener] + lua_pop(mainL, 1); + return 1; + } + + // DATA_IN / CLOSED: the conn must already exist. + lua_getfield(mainL, -1, cbname); // [listener, cb] + bool hasCb = lua_isfunction(mainL, -1); + push_conn_table(mainL, id); // [listener, cb, conn] (conn or nil) + if (lua_isnil(mainL, -1)) { lua_pop(mainL, 3); return -1; } + if (f.tag == TAG_CONN_CLOSED) { + lua_pushboolean(mainL, 1); + lua_setfield(mainL, -2, "closed"); // close before callback: no write/close + } + if (!hasCb) { lua_pop(mainL, 3); return -1; } + lua_pushvalue(mainL, -2); // [listener, cb, conn, cb] + lua_xmove(mainL, co, 1); // cb -> co ; mainL: [listener, cb, conn] + lua_xmove(mainL, co, 1); // conn -> co ; mainL: [listener, cb] + lua_pop(mainL, 2); + if (f.tag == TAG_CONN_DATA_IN) { + uint32_t dlen = get_u32(f.body.data() + sizeof(uint64_t)); + lua_pushlstring(co, reinterpret_cast(f.body.data() + dataHdr), + dlen); + return 2; + } + return 1; +} + +// Start the next pending frame for conn `id` as a fresh handler coroutine, if +// one is not already live. A frame with nothing to run (no callback / conn +// gone) is skipped; a CLOSED frame so skipped still tears the conn down. Called +// when a frame arrives and when a handler finishes (re-queued via g_conn_advance +// so the resume chain stays flat, never recursive). +static void conn_advance(lua_State* mainL, uint64_t id) { + auto it = g_conn_exec.find(id); + if (it == g_conn_exec.end()) return; + ConnExec& ce = it->second; + if (ce.busy || ce.pending.empty()) return; + Frame f = std::move(ce.pending.front()); + ce.pending.pop_front(); + bool isClose = (f.tag == TAG_CONN_CLOSED); + + lua_State* co = lua_newthread(mainL); + int ref = luaL_ref(mainL, LUA_REGISTRYINDEX); + int nargs = setup_handler_co(mainL, co, f); + if (nargs < 0) { + luaL_unref(mainL, LUA_REGISTRYINDEX, ref); // nothing to run; drop the thread + if (isClose) { remove_conn(mainL, id); g_conn_exec.erase(id); return; } + g_conn_advance.push_back(id); // try the next pending frame + return; + } + g_coro_refs[co] = ref; + ce.busy = true; + g_handler_co[co] = HandlerCo{id, isClose, false}; + resume_coro(mainL, co, nargs); +} + +// Route one inbound CONN_* frame into its conn's serialized queue and request a +// dispatch. The handler runs (as a coroutine) in conn_advance, drained by the +// run loop. Always returns true; framing/socket errors are handled by the +// caller's read path. +bool dispatch_conn_frame(lua_State* L, Frame f) { + if (f.body.size() < sizeof(uint64_t)) return true; + uint64_t id = get_u64(f.body.data()); + g_conn_exec[id].pending.push_back(std::move(f)); + g_conn_advance.push_back(id); + (void)L; + return true; +} + +// Drain conns with a ready frame. Flat: a handler that finishes synchronously +// re-queues its conn via resume_coro, so this while-loop (not C recursion) +// walks a burst. Runs only from the run loop. +static void drain_conn_advance(lua_State* mainL) { + while (!g_conn_advance.empty()) { + uint64_t id = g_conn_advance.front(); + g_conn_advance.pop_front(); + conn_advance(mainL, id); + } +} + +// Forward decl — the unified loop below dispatches luarpc bridge events whose +// handler (dispatch_luarpc_event) is defined later in this file. +void dispatch_luarpc_event(lua_State* L, LuaRpcEvent e); + diff --git a/vendor/ces/src/cesluajitd/api_core.inc b/vendor/ces/src/cesluajitd/api_core.inc new file mode 100644 index 0000000..a0ceafd --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_core.inc @@ -0,0 +1,713 @@ +// --------------------------------------------------------------------------- +// ces.* bindings +// --------------------------------------------------------------------------- + +// Per-instance source program prefix, supplied by bootstrap env. +// Sent as part of outbound CES_APP_COMPUTE_MSG so the remote CES +// client can demux by prog_pfx. +uint8_t g_prog_prefix[8] = {0,0,0,0,0,0,0,0}; +// Per-instance owner pubkey, supplied by bootstrap. Surfaced via +// ces.owner_pubkey() — programs use it (e.g. to know the house +// pubkey for /s/ programs running under the server's identity). +uint8_t g_owner_pubkey[32] = {0}; +// Per-instance program-account pubkey, supplied by bootstrap. Surfaced +// via ces.program_pubkey() — the account ces.transfer spends from, which +// programs advertise as their receive address so deposits and payouts +// share one pool. +uint8_t g_program_pubkey[32] = {0}; +uint8_t g_program_privkey[32] = {0}; +// Per-instance birth wall-clock microseconds, supplied by bootstrap. +// Surfaced via ces.start_time(). Programs use it as a replay- +// protection anchor: any payment with lastXferTime ≤ start_time/1e6 +// cannot have been a fresh deposit aimed at THIS instance. +uint64_t g_start_time_us = 0; +// UDP port the server statically assigned this instance for its +// outbound CES client, supplied by bootstrap. The client binds it so +// every outbound op leaves from a known, firewall-configured source +// port. 0 = no compute port range configured → this instance has no +// network: the outbound remote_* verbs fail with "networking disabled" +// rather than binding an unreachable ephemeral port. +uint16_t g_program_port = 0; +// The static UDP port reserved for this instance's inbound CesPlex host +// (/ces/luarpc/1). 0 = none → the instance hosts nothing. +uint16_t g_rpc_port = 0; +// Whether this instance runs privileged, decided once by the server (source in +// the /s/ zone) and delivered in the bootstrap. The host trusts this verdict; +// it never re-derives privilege. Gates operator-only API such as ces.log. +bool g_privileged = false; + +// The server's own ed25519 secret, delivered in the bootstrap ONLY to a /s/ +// (operator-write-only) instance on an ed25519 server; zero otherwise. Consumed +// IN-PROCESS by the hyle net lifecycle (ces.hyle.net.start reads it directly to +// build the validator KeyPair). It is deliberately NOT surfaced to Lua: handing +// the raw key to a sandboxed program would let it exfiltrate the permanent +// server identity over the CES network APIs, which a sign-oracle would not. +uint8_t g_server_secret[32] = {0}; +bool g_has_server_secret = false; + +// --------------------------------------------------------------------------- +// /ces/luarpc/1 — the protocol this lua host SERVES (and, later, dials) on +// its rpc port. A raw, opaque byte pipe: whatever bytes arrive are handed to +// the program; whatever bytes the program writes are sent. No host framing, +// batching, or buffering — any grammar lives in Lua. +// +// The endpoint runs on its own single strand; the Lua VM runs on main. The +// bridge below crosses that boundary: inbound (endpoint → Lua) via a +// mutex+condvar event queue drained by ces.conn.run(); outbound (Lua → +// endpoint) via asio::post onto the endpoint strand, where the per-conn +// RudpStreams live. Blocking here is fine — the compute child is sandboxed +// and metered, so a stalled strand has no global consequence. +// +// All per-conn stream state (g_luarpc_conns, g_luarpc_next_id) is touched +// ONLY on the endpoint strand: the handler runs there, and the Lua-side +// write/close hop there via post. So no lock guards them. Only the inbound +// event queue is cross-thread (mutex+condvar); the accept gate is atomic. +// --------------------------------------------------------------------------- +namespace { + +struct LuaRpcEvent { + enum class Type { Opened, Data, Closed } type; + uint64_t connId = 0; + std::array peer{}; // Opened + std::string bytes; // Data +}; + +std::mutex g_luarpc_mx; +std::deque g_luarpc_inq; // endpoint pushes, run() drains +int g_luarpc_wakefd = -1; // eventfd; nudges run()'s poll + +// Per-conn serialized handler dispatch for direct (/ces/luarpc/1) conns -- the +// direct-transport twin of g_conn_exec/g_conn_advance (see their comment). Kept +// separate because the direct id space (g_luarpc_next_id) is independent of the +// relay's. +struct LuaRpcExec { std::deque pending; bool busy = false; }; +std::map g_luarpc_exec; // direct conn id -> serialized state +std::deque g_luarpc_advance; // direct conns with an event ready + +// Accept gate: set by ces.conn.set_listener (Lua), read by serve() +// (endpoint). No listener → close-on-connect. +std::atomic g_luarpc_listening{false}; +// The program's set_listener greeting, delivered by the direct luarpc handler on +// conn-open (symmetric with the relay's ATTACH accept). Guarded: written on the +// Lua thread in set_listener, read on the endpoint strand in serve(). +std::mutex g_luarpc_hello_mx; +std::string g_luarpc_hello; + +// The endpoint's task strand, armed in main once the endpoint is up. Null +// until then (and forever if this instance got no rpc port). +boost::asio::io_context* g_luarpc_io = nullptr; + +// Cap on bytes queued but not yet flushed to a direct conn. A peer that stops +// reading cannot grow the instance's memory without bound: past this the conn is +// closed (mirrors CesPlex evicting a channel that can't keep up). +constexpr size_t kLuaRpcMaxQueuedBytes = 8 * 1024 * 1024; + +struct LuaRpcConn { + std::shared_ptr stream; + std::deque writeQ; // RudpStream forbids overlapping writes, + bool writing = false; // so we serialize: one in flight. + bool closing = false; + size_t queuedBytes = 0; // sum of writeQ payloads; bounded by the cap + std::array readBuf{}; +}; +std::map> g_luarpc_conns; // strand-only +uint64_t g_luarpc_next_id = 1; // strand-only + +// Per-channel resource usage the endpoint's ChannelMeter reports (endpoint +// strand); the run() loop drains it into a one-way TAG_NET_USAGE frame to the +// parent (IPC thread; only it may touch g_sock_fd). Each report carries the +// channel's bound payer prefix. Inbound channels carry the remote caller's +// prefix; outbound channels are tracked under this instance's OWN program prefix +// (see luarpc_do_connect). The parent prices the usage and routes the bill by +// payer: own prefix -> source file_balance, anyone else -> that caller. +struct NetUsageRecord { + ces::HashPrefix payer{}; + ces::CesPlexUsage usage{}; +}; +std::mutex g_net_usage_mx; +std::deque g_net_usage_q; + +void luarpc_push(LuaRpcEvent e) { + { + std::lock_guard lk(g_luarpc_mx); + g_luarpc_inq.push_back(std::move(e)); + } + // Nudge the unified run() poll on the Lua thread. The endpoint threads + // can't call into the single-threaded Lua VM, so they queue + wake; run() + // re-enters Lua to dispatch. + if (g_luarpc_wakefd >= 0) { + uint64_t one = 1; + ssize_t w = ::write(g_luarpc_wakefd, &one, sizeof(one)); + (void)w; + } +} + +// Endpoint strand: drain a conn's writeQ, one async_write at a time. +void luarpc_drain_writes(uint64_t id, std::shared_ptr c) { + if (c->writing || c->closing || c->writeQ.empty()) return; + c->writing = true; + auto buf = std::make_shared(std::move(c->writeQ.front())); + c->writeQ.pop_front(); + c->queuedBytes -= buf->size(); + boost::asio::async_write(*c->stream, boost::asio::buffer(*buf), + [id, c, buf](const boost::system::error_code& ec, std::size_t) { + c->writing = false; + if (ec) { + if (!c->closing) luarpc_push({LuaRpcEvent::Type::Closed, id, {}, {}}); + g_luarpc_conns.erase(id); + return; + } + luarpc_drain_writes(id, c); + }); +} + +// Endpoint strand: read whatever arrives → push Data → repeat. A read error +// (peer close / reset) → push Closed + drop the conn. +void luarpc_start_read(uint64_t id, std::shared_ptr c) { + c->stream->async_read_some( + boost::asio::buffer(c->readBuf.data(), c->readBuf.size()), + [id, c](const boost::system::error_code& ec, std::size_t n) { + if (ec) { + if (!c->closing) luarpc_push({LuaRpcEvent::Type::Closed, id, {}, {}}); + g_luarpc_conns.erase(id); + return; + } + LuaRpcEvent e{LuaRpcEvent::Type::Data, id, {}, {}}; + e.bytes.assign(reinterpret_cast(c->readBuf.data()), n); + luarpc_push(std::move(e)); + luarpc_start_read(id, c); + }); +} + +// Called from Lua → hop to the endpoint strand to enqueue + send. +void luarpc_conn_write(uint64_t id, std::string bytes) { + if (!g_luarpc_io) return; + boost::asio::post(*g_luarpc_io, + [id, bytes = std::move(bytes)]() mutable { + auto it = g_luarpc_conns.find(id); + if (it == g_luarpc_conns.end() || it->second->closing) return; + auto& c = it->second; + // Backpressure: a peer that stopped reading must not grow us without + // bound. Past the cap, close the conn instead of queueing more. + if (c->queuedBytes + bytes.size() > kLuaRpcMaxQueuedBytes) { + c->closing = true; + c->stream->shutdown(ces::kRudpStreamCloseTimeout); + return; + } + c->queuedBytes += bytes.size(); + c->writeQ.push_back(std::move(bytes)); + luarpc_drain_writes(id, c); + }); +} + +// Called from Lua → hop to the strand to gracefully close. +void luarpc_conn_close(uint64_t id) { + if (!g_luarpc_io) return; + boost::asio::post(*g_luarpc_io, [id]() { + auto it = g_luarpc_conns.find(id); + if (it == g_luarpc_conns.end()) return; + it->second->closing = true; + it->second->stream->shutdown(ces::kRudpStreamCloseTimeout); + // The read completion (error after shutdown) erases the conn. + }); +} + +// Outbound dialing rides the SAME endpoint Rudp/socket as serving (the user's +// "religiously the same socket"). Armed in main alongside g_luarpc_io. +minx::Rudp* g_luarpc_rudp = nullptr; +ces::ChannelMeter* g_luarpc_meter = nullptr; // = endpoint->meter() +std::shared_ptr g_luarpc_signer; // the program's keypair + +struct LuaRpcConnectResult { + uint8_t status = ces::CES_ERROR_INTERNAL; + uint64_t connId = 0; + std::array peer{}; +}; + +// A finished ces.conn.connect awaiting its coroutine's resume. The endpoint +// thread pushes (under g_luarpc_mx) + nudges g_luarpc_wakefd; the run loop +// drains, builds the conn on the coroutine, and resumes it. The blocking +// (main-chunk) connect path keeps its own promise instead. +struct ConnectDelivery { lua_State* co; LuaRpcConnectResult res; }; +std::deque g_connect_done; + +// Endpoint strand: open an OUTBOUND /ces/luarpc/1 channel to `peer`, drive the +// client bind (signed by the program key), verify the reply AND that the +// server pubkey matches `expectPk` (the program knows who it dialed), and on +// success register the bound stream as a raw conn + start pumping. Reports via +// `pr`. Mirrors CesPlexClient::doSelect, but on the endpoint's own Rudp. +void luarpc_do_connect(minx::SockAddr peer, std::array expectPk, + std::function done, + int timeoutMs) { + if (!g_luarpc_rudp || !g_luarpc_signer || !g_luarpc_io) { + done({ces::CES_ERROR_INTERNAL, 0, {}}); + return; + } + // Seed Rudp's clock so the fresh channel isn't idle-GC'd on the next tick. + g_luarpc_rudp->tick(ces::getMicrosSinceEpoch()); + std::random_device rd; + uint32_t channel = static_cast(rd()); + auto stream = std::make_shared(g_luarpc_io->get_executor()); + if (!g_luarpc_rudp->registerChannel(peer, channel, stream)) { + done({ces::CES_ERROR_INTERNAL, 0, {}}); + return; + } + + // Bind-handshake timeout, bounded AT THE SOURCE: if no reply within + // timeoutMs, close the half-open stream so the pending read fails with ec -> + // done(error), the clean "connect failed" path. Cancelled the instant the + // bind resolves (so it never closes a now-live conn). Beats waiting on RUDP + // idle-GC (~60s), and there is no racing coroutine-side timer to leave an + // orphaned conn behind: a timeout fails before the conn is ever registered. + auto timer = std::make_shared(*g_luarpc_io); + if (timeoutMs > 0) { + timer->expires_after(std::chrono::milliseconds(timeoutMs)); + timer->async_wait([stream](const boost::system::error_code& tec) { + if (tec) return; // cancelled: the bind already resolved + try { stream->close(); } catch (...) {} + }); + } + + static const std::string kProto = "/ces/luarpc/1"; + const uint64_t bindNowUs = ces::getMicrosSinceEpoch(); + auto bindReq = std::make_shared( + ces::buildBindRequest(kProto, bindNowUs, *g_luarpc_signer)); + const auto& pkArr = g_luarpc_signer->getPublicKeyAsHash(); + auto clientDigest = + std::make_shared>( + ces::computeBindRequestDigest( + std::span( + reinterpret_cast(kProto.data()), kProto.size()), + bindNowUs, + std::span(pkArr.data(), pkArr.size()))); + + boost::asio::async_write(*stream, boost::asio::buffer(*bindReq), + [stream, bindReq, clientDigest, done, expectPk, peer, channel, timer] + (const boost::system::error_code& ec, std::size_t) { + if (ec) { timer->cancel(); done({ces::CES_ERROR_INTERNAL, 0, {}}); return; } + auto reply = std::make_shared< + std::array>(); + boost::asio::async_read(*stream, boost::asio::buffer(*reply), + [stream, reply, clientDigest, done, expectPk, peer, channel, timer] + (const boost::system::error_code& ec2, std::size_t) { + timer->cancel(); // bind resolved (ok or fail): disarm the timeout + if (ec2) { done({ces::CES_ERROR_INTERNAL, 0, {}}); return; } + ces::ParsedBindReply r = ces::parseBindReply( + std::span( + reply->data(), reply->size())); + const bool ok = + r.status == ces::CES_PLEX_OK && + ces::verifyBindReply(r, std::span( + clientDigest->data(), clientDigest->size())) && + std::memcmp(r.serverPubkey.data(), expectPk.data(), 32) == 0; + if (!ok) { + done({ces::CES_ERROR_PROTO_REJECTED, 0, {}}); + return; + } + uint64_t id = g_luarpc_next_id++; + auto c = std::make_shared(); + c->stream = stream; + g_luarpc_conns[id] = c; + // Meter this OUTBOUND channel so the instance pays its own server + // (file_balance) for dialing out. Track it under this instance's own + // program prefix: the parent recognizes that as "the instance itself" + // and routes the bill to file_balance (vs an inbound caller's prefix). + if (g_luarpc_meter) { + ces::HashPrefix self{}; + std::memcpy(self.data(), g_program_pubkey, self.size()); + g_luarpc_meter->track(peer, channel, "luarpc:out", self); + } + LuaRpcConnectResult res{ces::CES_OK, id, {}}; + std::memcpy(res.peer.data(), r.serverPubkey.data(), 32); + luarpc_start_read(id, c); + done(res); + }); + }); +} + +// One serve() per inbound /ces/luarpc/1 channel, on the endpoint strand. No +// Lua listener → close-on-connect (drop the stream). Else register the conn, +// announce it (on_open), and start pumping bytes. +class LuaRpcHandler : public ces::CesPlexHandler { +public: + void serve(std::shared_ptr stream, + ces::BoundChannelContext bound) override { + if (!g_luarpc_listening.load(std::memory_order_relaxed)) { + (void)stream; // dropped on return → channel closes + return; + } + uint64_t id = g_luarpc_next_id++; + auto c = std::make_shared(); + c->stream = std::move(stream); + LuaRpcEvent opened{LuaRpcEvent::Type::Opened, id, {}, {}}; + const ces::Hash& pk = bound.boundPubkey.getHash(); + std::memcpy(opened.peer.data(), pk.data(), opened.peer.size()); + g_luarpc_conns[id] = c; + // Deliver the program's set_listener greeting as the opening bytes, the same + // way the relay's ATTACH accept does, so a program's on_open never needs to + // know which transport a conn arrived on. + { + std::string hello; + { + std::lock_guard lk(g_luarpc_hello_mx); + hello = g_luarpc_hello; + } + if (!hello.empty()) { + c->queuedBytes += hello.size(); + c->writeQ.push_back(std::move(hello)); + luarpc_drain_writes(id, c); + } + } + luarpc_push(std::move(opened)); + luarpc_start_read(id, c); + } +}; +LuaRpcHandler g_luaRpcHandler; + +// CesPlexHost for this lua host's rpc endpoint: signs bind replies + per-op +// responses with the program's own keypair. Usage is reported up to the parent +// server, which prices it and bills the caller (inbound) or this instance's +// file_balance (outbound). +class LuaHostPlexHost : public ces::CesPlexHost { +public: + explicit LuaHostPlexHost(const ces::KeyPair& key) : key_(key) {} + const ces::KeyPair& cesplexSigningKey() const override { return key_; } + void cesplexReportUsage(const ces::HashPrefix& payer, + const minx::SockAddr& /*peer*/, + uint32_t /*channelId*/, + const ces::CesPlexUsage& usage) override { + // Endpoint strand: must NOT touch g_sock_fd. Queue the (payer, usage) and + // nudge; run() emits the TAG_NET_USAGE frame on the IPC thread. The parent + // decides who pays from the payer prefix. + { + std::lock_guard lk(g_net_usage_mx); + g_net_usage_q.push_back(NetUsageRecord{payer, usage}); + } + if (g_luarpc_wakefd >= 0) { + uint64_t one = 1; + ssize_t w = ::write(g_luarpc_wakefd, &one, sizeof(one)); + (void)w; + } + } +private: + ces::KeyPair key_; +}; + +// The endpoint is opened LAZILY — the first time the program touches luarpc +// (set_listener or connect). A program that never speaks luarpc opens no +// socket and spawns no endpoint threads. Once up, it stays for the instance's +// life. No-op if already up or if this instance got no rpc port. +std::unique_ptr g_luarpc_host; +std::unique_ptr g_luarpc_endpoint; + +void luarpc_ensure_endpoint() { + if (g_luarpc_endpoint || g_rpc_port == 0) return; + try { + ces::Hash priv{}; + std::memcpy(priv.data(), g_program_privkey, 32); + g_luarpc_signer = + std::make_shared(priv, ces::KeyAlgo::ED25519); + g_luarpc_host = std::make_unique(*g_luarpc_signer); + + // The eventfd the endpoint threads nudge so the unified run() poll wakes. + // Created before the endpoint so it exists before any thread can push. + if (g_luarpc_wakefd < 0) + g_luarpc_wakefd = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + + minx::MinxConfig minxCfg{}; + minxCfg.instanceName = "luarpc"; + minxCfg.randomXVMsToKeep = 0; + minxCfg.randomXInitThreads = 0; + // No PoW engine + spam disabled → dialable without tickets, mirroring the + // server's own rpc port. (Anti-spam posture for a public luarpc port is a + // deliberate decision still owed — see the compute closure design doc.) + minxCfg.spamThreshold = std::numeric_limits::max(); + minxCfg.spamSampleRate = 512; + minxCfg.trustLoopback = true; + + minx::RudpConfig rudpCfg{}; + rudpCfg.baseTickInterval = std::chrono::milliseconds(1); + // The endpoint is a full server+client: it serves /ces/luarpc/1 AND dials + // out (luarpc + file + compute), so one program may hold several channels + // to the SAME peer at once (e.g. a file client then a compute client to + // one server). The Rudp default of 1 channel/peer is far too low; match + // CesPlexClient's headroom. + rudpCfg.maxChannelsPerPeer = 8; + + g_luarpc_endpoint = std::make_unique( + g_rpc_port, g_luarpc_host.get(), + std::map{ + {"/ces/luarpc/1", &g_luaRpcHandler}}, + std::move(minxCfg), std::move(rudpCfg)); + g_luarpc_io = &g_luarpc_endpoint->io(); + g_luarpc_rudp = g_luarpc_endpoint->rudp(); + g_luarpc_meter = g_luarpc_endpoint->meter(); + } catch (const std::exception& e) { + host_log(3, std::string("rpc endpoint setup failed: ") + e.what()); + g_luarpc_endpoint.reset(); + g_luarpc_host.reset(); + g_luarpc_signer.reset(); + } +} +} // namespace +uint16_t g_next_corr_id = 1; + +int lua_ces_now(lua_State* L) { + auto us = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + lua_pushnumber(L, static_cast(us)); + return 1; +} + +int lua_ces_sha256(lua_State* L) { + size_t n = 0; + const char* s = luaL_checklstring(L, 1, &n); + Sha256Ctx c{}; + sha256_init(c); + sha256_update(c, reinterpret_cast(s), n); + uint8_t digest[32]; + sha256_final(c, digest); + lua_pushlstring(L, reinterpret_cast(digest), 32); + return 1; +} + +// ces.log([level,] msg) - operator instrumentation, registered ONLY for +// privileged (/s/) programs (see install_ces_api). The application picks the +// level ("trace"|"debug"|"info"|"warn"|"error", default "info"); the operator +// picks what's visible via blog's "compute" module level. Fire-and-forget over +// a one-way IPC frame; the host (builtin:compute) logs it through blog under +// "compute", tagged with the instance + program prefix (added host-side, not +// trusted from here). Never reaches user programs. Capped so a program can't +// emit huge log lines. +// Level byte: 0=trace 1=debug 2=info 3=warn 4=error (mirrors the compute side). +static uint8_t log_level_from_name(lua_State* L, const char* name) { + if (std::strcmp(name, "trace") == 0) return 0; + if (std::strcmp(name, "debug") == 0) return 1; + if (std::strcmp(name, "info") == 0) return 2; + if (std::strcmp(name, "warn") == 0 || std::strcmp(name, "warning") == 0) return 3; + if (std::strcmp(name, "error") == 0) return 4; + return static_cast(luaL_error(L, "ces.log: invalid level '%s'", name)); +} + +int lua_ces_log(lua_State* L) { + if (!g_privileged) return 0; // no-op for non-/s/ instances (also gated server-side) + uint8_t level = 2; // info + size_t n = 0; + const char* s; + if (lua_gettop(L) >= 2) { + level = log_level_from_name(L, luaL_checkstring(L, 1)); + s = luaL_checklstring(L, 2, &n); + } else { + s = luaL_checklstring(L, 1, &n); + } + if (n > 4096) n = 4096; + std::vector body; + body.reserve(1 + n); + body.push_back(level); + body.insert(body.end(), s, s + n); + write_frame(TAG_LOG, 0, body.data(), body.size()); + return 0; +} + +// ces.sign(bytes) -> sig(65 bytes). Signs with this instance's program key. +// The private key never enters the sandbox: signing happens here in the host +// and only the signature comes back. Decorator byte + 64-byte signature. +int lua_ces_sign(lua_State* L) { + size_t n = 0; + const char* s = luaL_checklstring(L, 1, &n); + ces::Hash priv{}; + std::memcpy(priv.data(), g_program_privkey, 32); + ces::KeyPair kp(priv, ces::KeyAlgo::ED25519); + ces::Signature sig = kp.signData(s, n); + lua_pushlstring(L, reinterpret_cast(sig.data()), sig.size()); + return 1; +} + +// ces.verify(pubkey(32), bytes, sig(65)) -> bool. Verifies any party's +// signature over bytes; the signature's decorator byte selects the algorithm. +int lua_ces_verify(lua_State* L) { + size_t pkn = 0, dn = 0, sn = 0; + const char* pk = luaL_checklstring(L, 1, &pkn); + const char* data = luaL_checklstring(L, 2, &dn); + const char* sigs = luaL_checklstring(L, 3, &sn); + if (pkn != 32 || sn != ces::SIG_SIZE) { + lua_pushboolean(L, 0); + return 1; + } + ces::Hash pkh{}; + std::memcpy(pkh.data(), pk, 32); + ces::PublicKey pub(pkh); + ces::Signature sig{}; + std::memcpy(sig.data(), sigs, ces::SIG_SIZE); + bool ok = false; + try { + ok = pub.verifySignature(data, dn, sig); + } catch (...) { + // A malformed pubkey or signature must verify as false, never throw + // through LuaJIT. + ok = false; + } + lua_pushboolean(L, ok ? 1 : 0); + return 1; +} + +// The reserved domain tag for ces.serverSign / ces.serverVerify. MUST match +// CesServer::serverSign in src/ceslib/server.cpp byte-for-byte: the signer (CES core) +// and the verifier (here) live in different processes, so the tag is duplicated and +// the two must agree. Signing hashes SHA256(tag || bytes); verify mirrors it. +constexpr char kExtAttestTag[] = "CES_EXT_ATTEST_V1"; + +// ces.serverVerify(server_pubkey(32), bytes, sig(65)) -> bool. Verifies a server-key +// attestation produced by ces.serverSign on some server. Pure local crypto (no host +// round-trip): apply the same domain tag, hash, and verify against the claimed server +// pubkey. The 32-byte digest is passed to verifySignature directly (it treats a +// 32-byte input as the message digest), matching the host's tagged-digest signing. +int lua_ces_server_verify(lua_State* L) { + size_t pkn = 0, dn = 0, sn = 0; + const char* pk = luaL_checklstring(L, 1, &pkn); + const char* data = luaL_checklstring(L, 2, &dn); + const char* sigs = luaL_checklstring(L, 3, &sn); + if (pkn != 32 || sn != ces::SIG_SIZE) { + lua_pushboolean(L, 0); + return 1; + } + Sha256Ctx c; + sha256_init(c); + sha256_update(c, reinterpret_cast(kExtAttestTag), + sizeof(kExtAttestTag) - 1); + sha256_update(c, reinterpret_cast(data), dn); + uint8_t digest[32]; + sha256_final(c, digest); + ces::Hash pkh{}; + std::memcpy(pkh.data(), pk, 32); + ces::PublicKey pub(pkh); + ces::Signature sig{}; + std::memcpy(sig.data(), sigs, ces::SIG_SIZE); + bool ok = false; + try { + ok = pub.verifySignature(reinterpret_cast(digest), 32, sig); + } catch (...) { + ok = false; + } + lua_pushboolean(L, ok ? 1 : 0); + return 1; +} + +// ces.client_recv([nowait]) → (sender_pfx:string(8), payload:string) | nil +// +// Default: BLOCKING. Drains the socket first (so any currently- +// queued deliver frames come out), then if the inbox is still +// empty blocks on read() until either a deliver frame arrives +// or the host closes the pipe (which means the supervisor is +// about to SIGKILL us; we return nil and the top-level loop +// exits). +// +// Pass any truthy arg (e.g. `true`) to request non-blocking mode: +// returns nil immediately if no message is queued. +int lua_ces_client_recv(lua_State* L) { + bool nowait = lua_toboolean(L, 1); + auto pop_if_available = [L]() -> int { + if (g_inbox.empty()) return 0; + Frame f = std::move(g_inbox.front()); + g_inbox.pop_front(); + if (f.body.size() < 8) return 0; + lua_pushlstring(L, reinterpret_cast(f.body.data()), 8); + lua_pushlstring(L, reinterpret_cast(f.body.data() + 8), + f.body.size() - 8); + return 2; + }; + if (!drain_socket_nonblocking()) return 0; + if (int r = pop_if_available()) return r; + if (nowait) return 0; + + // Blocking path: read frames until a deliver arrives (or socket + // dies). Replies we don't expect get buffered; non-deliver frames + // (which shouldn't happen) get dropped. + for (;;) { + Frame f; + if (!read_frame(f)) return 0; // socket dead + if (f.tag == TAG_DELIVER) { + g_inbox.push_back(std::move(f)); + return pop_if_available(); + } + if (f.tag == TAG_API_REPLY) { + route_to_queue(std::move(f)); // diverts a parked coroutine's reply to g_io_ready + continue; + } + // Anything else: ignore and keep waiting. + } +} + +// ces.client_send(target_pfx:string(8), bytes:string) → true | nil, err +int lua_ces_client_send(lua_State* L) { + size_t pfx_len = 0, bytes_len = 0; + const char* pfx = luaL_checklstring(L, 1, &pfx_len); + const char* bytes = luaL_checklstring(L, 2, &bytes_len); + if (pfx_len != 8) { + lua_pushnil(L); + lua_pushstring(L, "prefix must be 8 bytes"); + return 2; + } + if (bytes_len > 1024) { + lua_pushnil(L); + lua_pushstring(L, "payload too large (max 1024)"); + return 2; + } + // send_api_call prepends the u16 method; args = [8B target_pfx][u16 BE len][bytes]. + std::vector args; + args.reserve(WIRE_PREFIX_LEN + sizeof(uint16_t) + bytes_len); + args.insert(args.end(), reinterpret_cast(pfx), + reinterpret_cast(pfx) + 8); + put_u16(args, static_cast(bytes_len)); + args.insert(args.end(), reinterpret_cast(bytes), + reinterpret_cast(bytes) + bytes_len); + + // Through io_call so it yields from a coroutine instead of freezing the VM on + // the host round-trip (the only request/response verb that still blocked). + return io_call(L, METHOD_CLIENT_SEND, args, + [](lua_State* L, const Frame& reply) -> int { + uint8_t status = reply.body[0]; + if (status == STATUS_OK) { lua_pushboolean(L, 1); return 1; } + lua_pushnil(L); + switch (status) { + case STATUS_NOT_CONNECTED: lua_pushstring(L, "not_connected"); break; + case STATUS_INSUFFICIENT_BAL: lua_pushstring(L, "insufficient_balance"); break; + default: lua_pushstring(L, "internal"); break; + } + return 2; + }); +} + +// ces.prog_prefix() → string(8) — convenience so scripts can log +// or key state by their own identity. +int lua_ces_prog_prefix(lua_State* L) { + lua_pushlstring(L, reinterpret_cast(g_prog_prefix), 8); + return 1; +} + +// ces.owner_pubkey() → string(32) — the source file's owner pubkey. +// /s/ programs see the server's pubkey here (their "house key"). +int lua_ces_owner_pubkey(lua_State* L) { + lua_pushlstring(L, reinterpret_cast(g_owner_pubkey), 32); + return 1; +} + +// ces.program_pubkey() → string(32) — the file's dedicated program +// account: the pool ces.transfer spends from, and the address a program +// should advertise to receive funds (a game's "house", a service's +// wallet) so deposits and payouts share one pool. +int lua_ces_program_pubkey(lua_State* L) { + lua_pushlstring(L, reinterpret_cast(g_program_pubkey), 32); + return 1; +} + +// ces.start_time() → number — this instance's birth time in +// microseconds since epoch. Stable for the instance's life. Programs +// use it as a freshness anchor: a payment whose lastXferTime is +// ≤ start_time / 1e6 (seconds) cannot have been a fresh deposit +// aimed at THIS instance, so it should not count as a bet. +int lua_ces_start_time(lua_State* L) { + lua_pushnumber(L, static_cast(g_start_time_us)); + return 1; +} + diff --git a/vendor/ces/src/cesluajitd/api_extadmin.inc b/vendor/ces/src/cesluajitd/api_extadmin.inc new file mode 100644 index 0000000..a8edf09 --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_extadmin.inc @@ -0,0 +1,673 @@ +// --------------------------------------------------------------------------- +// ces.extension_admin{} — the extension management contract. The program registers +// callbacks; the host (ExtensionManager via the compute supervisor) invokes +// them over IPC when the program runs as a /s/ extension. Registration ships +// the host a manifest + capability bits + the command menu + config defaults in +// one REGISTER frame; status/command requests round-trip via EXT_REQ/EXT_REP; +// config edits arrive one-way via EXT_CONFIG. +// --------------------------------------------------------------------------- +int g_ext_status_ref = LUA_NOREF; +int g_ext_command_ref = LUA_NOREF; +int g_ext_config_ref = LUA_NOREF; +int g_ext_panel_ref = LUA_NOREF; // mene.app registered via `panel` +bool g_ext_registered = false; + +std::string ext_str_field(lua_State* L, int tbl, const char* key) { + lua_getfield(L, tbl, key); + std::string s; + if (lua_isstring(L, -1)) { + size_t n = 0; const char* p = lua_tolstring(L, -1, &n); s.assign(p, n); + } + lua_pop(L, 1); + return s; +} + +// Parse "key = value" lines (# comments, trimmed) into a fresh Lua table left on +// the stack. Mirrors cesdk's conf.lua so on_config receives a string->string map. +void push_config_table(lua_State* L, const uint8_t* data, size_t len) { + lua_newtable(L); + std::string s(reinterpret_cast(data), len); + auto trim = [](std::string x) { + size_t a = x.find_first_not_of(" \t\r"); + if (a == std::string::npos) return std::string(); + size_t b = x.find_last_not_of(" \t\r"); + return x.substr(a, b - a + 1); + }; + size_t pos = 0; + while (pos <= s.size()) { + size_t nl = s.find('\n', pos); + std::string line = s.substr(pos, nl == std::string::npos ? s.size() - pos : nl - pos); + pos = (nl == std::string::npos) ? s.size() + 1 : nl + 1; + size_t hash = line.find('#'); + if (hash != std::string::npos) line = line.substr(0, hash); + size_t eq = line.find('='); + if (eq == std::string::npos) continue; + std::string k = trim(line.substr(0, eq)); + if (k.empty()) continue; + std::string v = trim(line.substr(eq + 1)); + lua_pushlstring(L, v.data(), v.size()); + lua_setfield(L, -2, k.c_str()); + } +} + +// Serialize a {k=v} table (string->string) as [u16 count]([lp k][lp v])*. +void ext_serialize_status(lua_State* L, int tbl, std::vector& out) { + std::vector pairs; + uint16_t count = 0; + lua_pushnil(L); + while (lua_next(L, tbl) != 0) { + if (lua_type(L, -2) == LUA_TSTRING) { // only string keys (lua_next-safe) + size_t kn = 0, vn = 0; + const char* k = lua_tolstring(L, -2, &kn); + const char* v = lua_tolstring(L, -1, &vn); // string or number; nil otherwise + if (k && v) { put_name(pairs, k, kn); put_name(pairs, v, vn); count++; } + } + lua_pop(L, 1); + } + put_u16(out, count); + put_bytes(out, pairs.data(), pairs.size()); +} + +// Read the program's CES_MANIFEST global — a STATIC table the cesdk bundler +// emits from project.lua (a hand-written /s/ program declares it directly) — and +// report it to the host. Called once when the program enters its run loop. It's +// data, not a call, so `--manifest` can harvest it without running the program. +void send_manifest_from_global(lua_State* L) { + lua_getglobal(L, "CES_MANIFEST"); + if (lua_istable(L, -1)) { + int t = lua_gettop(L); + std::string name = ext_str_field(L, t, "name"); + std::string version = ext_str_field(L, t, "version"); + std::string desc = ext_str_field(L, t, "description"); + std::vector body; + put_name(body, name.data(), name.size()); + put_name(body, version.data(), version.size()); + put_name(body, desc.data(), desc.size()); + write_frame(TAG_EXT_MANIFEST, 0, body.data(), body.size()); + } + lua_pop(L, 1); +} + +// ces.extension_admin{ status=, commands=, on_command=, config_defaults=, +// on_config= }. The spec arrives at stack index 2 (index 1 is the +// ces.extension_admin table itself, via __call). NO metadata here: name/version/ +// description are declared via ces.manifest{} (bundler-generated), not here — +// this call registers the admin contract only. +int lua_ces_extension_admin(lua_State* L) { + luaL_checktype(L, 2, LUA_TTABLE); + std::vector body; + body.push_back(0); // caps placeholder + uint8_t caps = 0; + + lua_getfield(L, 2, "status"); + if (lua_isfunction(L, -1)) { + if (g_ext_status_ref != LUA_NOREF) luaL_unref(L, LUA_REGISTRYINDEX, g_ext_status_ref); + g_ext_status_ref = luaL_ref(L, LUA_REGISTRYINDEX); + caps |= EXT_CAP_STATUS; + } else lua_pop(L, 1); + + std::vector cmds; + uint16_t cmdCount = 0; + lua_getfield(L, 2, "commands"); + if (lua_istable(L, -1)) { + int n = static_cast(lua_objlen(L, -1)); + for (int i = 1; i <= n; i++) { + lua_rawgeti(L, -1, i); + if (lua_istable(L, -1)) { + int c = lua_gettop(L); + std::string id = ext_str_field(L, c, "id"); + std::string label = ext_str_field(L, c, "label"); + if (!id.empty()) { + put_name(cmds, id.data(), id.size()); + put_name(cmds, label.data(), label.size()); + cmdCount++; + } + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + put_u16(body, cmdCount); + put_bytes(body, cmds.data(), cmds.size()); + if (cmdCount > 0) caps |= EXT_CAP_COMMANDS; + + lua_getfield(L, 2, "on_command"); + if (lua_isfunction(L, -1)) { + if (g_ext_command_ref != LUA_NOREF) luaL_unref(L, LUA_REGISTRYINDEX, g_ext_command_ref); + g_ext_command_ref = luaL_ref(L, LUA_REGISTRYINDEX); + } else lua_pop(L, 1); + + std::string defaults; + lua_getfield(L, 2, "config_defaults"); + if (lua_isfunction(L, -1)) { + if (lua_pcall(L, 0, 1, 0) == 0 && lua_isstring(L, -1)) { + size_t n = 0; const char* p = lua_tolstring(L, -1, &n); defaults.assign(p, n); + } + lua_pop(L, 1); + caps |= EXT_CAP_CONFIG_DEFAULTS; + } else if (lua_isstring(L, -1)) { + size_t n = 0; const char* p = lua_tolstring(L, -1, &n); defaults.assign(p, n); + lua_pop(L, 1); + caps |= EXT_CAP_CONFIG_DEFAULTS; + } else lua_pop(L, 1); + if (defaults.size() > 65535) defaults.resize(65535); + put_name(body, defaults.data(), defaults.size()); + + lua_getfield(L, 2, "on_config"); + if (lua_isfunction(L, -1)) { + if (g_ext_config_ref != LUA_NOREF) luaL_unref(L, LUA_REGISTRYINDEX, g_ext_config_ref); + g_ext_config_ref = luaL_ref(L, LUA_REGISTRYINDEX); + caps |= EXT_CAP_ON_CONFIG; + } else lua_pop(L, 1); + + // panel = a mene.app (table with `view` and `update` functions). The host + // drives it via EXT_REQ_PANEL_RENDER / EXT_REQ_PANEL_EVENT; webadmin renders + // the frames with the embedded mene renderer. A present-but-malformed panel + // is a loud error, not a silent skip. + lua_getfield(L, 2, "panel"); + if (!lua_isnil(L, -1)) { + if (!lua_istable(L, -1)) + return luaL_error(L, "ces.extension_admin: panel must be a mene.app table"); + lua_getfield(L, -1, "view"); + bool hasView = lua_isfunction(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "update"); + bool hasUpdate = lua_isfunction(L, -1); + lua_pop(L, 1); + if (!hasView || !hasUpdate) + return luaL_error(L, + "ces.extension_admin: panel must have view and update functions (a mene.app)"); + if (g_ext_panel_ref != LUA_NOREF) luaL_unref(L, LUA_REGISTRYINDEX, g_ext_panel_ref); + g_ext_panel_ref = luaL_ref(L, LUA_REGISTRYINDEX); // pops the panel table + caps |= EXT_CAP_PANEL; + // Arm the mene push bridge: App:dispatch auto-pushes through this global, + // and the program may call panel:push() for spontaneous immediacy. The + // host relays pushes to watching webadmin WebSocket clients. + lua_pushcfunction(L, lua_mene_host_push); + lua_setglobal(L, "__mene_host_push"); + } else lua_pop(L, 1); + + body[0] = caps; + write_frame(TAG_EXT_REGISTER, 0, body.data(), body.size()); + g_ext_registered = true; + return 0; +} + +// ces.extension_admin.disable_self() — the program-initiated Disable (kills this +// instance; the /s/ file stays). +int lua_ces_extension_admin_disable_self(lua_State* L) { + (void)L; + write_frame(TAG_EXT_DISABLE_SELF, 0, nullptr, 0); + return 0; +} + +// ces.extension_admin.save_config(text) — persist `text` as this extension's +// /s/.conf on the host. One-way, no on_config echo: the caller has +// already applied the values it is saving (a panel config form does apply + +// save in one update). Same 64 KB cap as config_defaults. +int lua_ces_extension_admin_save_config(lua_State* L) { + size_t n = 0; + const char* p = luaL_checklstring(L, 1, &n); + if (n > 65535) n = 65535; + write_frame(TAG_EXT_SAVE_CONFIG, 0, + reinterpret_cast(p), n); + return 0; +} + +// Host -> program: EXT_CONFIG (one-way on_config) or EXT_REQ (status/command, +// answered with EXT_REP echoing the corr_id). Returns false on a fatal socket +// write error so the run loop can exit. +bool dispatch_ext_frame(lua_State* L, Frame f) { + if (f.tag == TAG_EXT_UI_WATCH) { + // Host says whether any webadmin client is watching the panel. Watch-on + // resets the change-detect state so the next tick pushes a fresh frame. + bool on = !f.body.empty() && f.body[0] != 0; + if (on && !g_ext_ui_watch) { + g_ext_ui_last_hash = 0; + g_ext_ui_next_check_us = 0; + } + g_ext_ui_watch = on; + return true; + } + if (f.tag == TAG_EXT_CONFIG) { + if (g_ext_config_ref == LUA_NOREF) return true; + lua_rawgeti(L, LUA_REGISTRYINDEX, g_ext_config_ref); + push_config_table(L, f.body.data(), f.body.size()); + if (lua_pcall(L, 1, 0, 0) != 0) { + host_log(3, std::string("ces.extension_admin on_config: ") + lua_err_str(L)); + lua_pop(L, 1); + } + return true; + } + if (f.tag != TAG_EXT_REQ || f.body.empty()) return true; + uint8_t kind = f.body[0]; + std::vector rep; + if (kind == EXT_REQ_STATUS) { + if (g_ext_status_ref == LUA_NOREF) { + rep.push_back(STATUS_INTERNAL); + } else { + lua_rawgeti(L, LUA_REGISTRYINDEX, g_ext_status_ref); + if (lua_pcall(L, 0, 1, 0) != 0) { + host_log(3, std::string("ces.extension_admin status: ") + lua_err_str(L)); + lua_pop(L, 1); + rep.push_back(STATUS_INTERNAL); + } else { + rep.push_back(STATUS_OK); + if (lua_istable(L, -1)) ext_serialize_status(L, lua_gettop(L), rep); + else put_u16(rep, 0); + lua_pop(L, 1); + } + } + } else if (kind == EXT_REQ_COMMAND) { + if (g_ext_command_ref == LUA_NOREF) { + rep.push_back(STATUS_INTERNAL); + } else { + const uint8_t* p = f.body.data() + 1; + size_t left = f.body.size() - 1; + if (left < 2) { rep.push_back(STATUS_INTERNAL); } + else { + uint16_t idlen = (uint16_t(p[0]) << 8) | uint16_t(p[1]); + p += 2; left -= 2; + if (left < idlen) { rep.push_back(STATUS_INTERNAL); } + else { + std::string id(reinterpret_cast(p), idlen); + const uint8_t* arg = p + idlen; + size_t arglen = left - idlen; + lua_rawgeti(L, LUA_REGISTRYINDEX, g_ext_command_ref); + lua_pushlstring(L, id.data(), id.size()); + lua_pushlstring(L, reinterpret_cast(arg), arglen); + if (lua_pcall(L, 2, 1, 0) != 0) { + host_log(3, std::string("ces.extension_admin on_command: ") + lua_err_str(L)); + lua_pop(L, 1); + rep.push_back(STATUS_INTERNAL); + } else { + rep.push_back(STATUS_OK); + if (lua_isstring(L, -1)) { + size_t rn = 0; const char* r = lua_tolstring(L, -1, &rn); + put_bytes(rep, r, rn); + } + lua_pop(L, 1); + } + } + } + } + } else if (kind == EXT_REQ_PANEL_RENDER || kind == EXT_REQ_PANEL_EVENT) { + // Panel frames. A Lua error inside view/update travels as a toast FRAME + // with STATUS_OK (the browser shows it); STATUS_INTERNAL means only "no + // panel registered" so the host can tell capability from failure. + if (g_ext_panel_ref == LUA_NOREF) { + rep.push_back(STATUS_INTERNAL); + } else { + std::string frame = (kind == EXT_REQ_PANEL_RENDER) + ? mene_panel_render(L, g_ext_panel_ref) + : mene_panel_event(L, g_ext_panel_ref, f.body.data() + 1, f.body.size() - 1); + rep.push_back(STATUS_OK); + put_bytes(rep, frame.data(), frame.size()); + } + } else { + rep.push_back(STATUS_INTERNAL); + } + write_frame(TAG_EXT_REP, f.corr_id, rep.data(), rep.size()); + return true; +} + +// Resume one coroutine with `nargs` results already on its stack. A coroutine +// that yields again parks (its waker re-readies it); one that finishes or errors +// is unreferenced. +static void resume_coro(lua_State* mainL, lua_State* co, int nargs) { + int r = lua_resume(co, nargs); + if (r == LUA_YIELD) return; // parked again; nothing to do + if (r != 0) + host_log(3, std::string("scheduled task: ") + lua_err_str(co)); + + // An on_l2call coroutine that just finished: ship its return value (a string, + // or empty on nil / error) back as the L2 reply for this callId. + auto l2 = g_l2call_co.find(co); + if (l2 != g_l2call_co.end()) { + std::vector rb; + put_u64(rb, l2->second); + if (r == 0 && lua_gettop(co) > 0) { + size_t rl = 0; + const char* rs = lua_tolstring(co, -1, &rl); + // Reply ceiling: the host truncates further for a VM caller; a channel + // caller gets up to this many bytes. + if (rs) { + if (rl > ces::CES_L2_CALL_MAX_REPLY) rl = ces::CES_L2_CALL_MAX_REPLY; + rb.insert(rb.end(), rs, rs + rl); + } + } + write_frame(TAG_L2_CALL_REPLY, 0, rb.data(), rb.size()); + g_l2call_co.erase(l2); + } + + auto it = g_coro_refs.find(co); + if (it != g_coro_refs.end()) { + luaL_unref(mainL, LUA_REGISTRYINDEX, it->second); + g_coro_refs.erase(it); + } + // If this was a periodic-timer tick, free the timer so its next fire can run + // (skip-if-busy lifts here). Matched by coroutine pointer, not the registry + // ref, so a cancelled-and-reused ref can't clear the wrong timer. + for (auto& t : g_timers) { + if (t.busy_co == co) { t.busy_co = nullptr; break; } + } + // If this was a conn handler coroutine, free the conn so its next queued + // frame can run -- or, for on_close, tear the conn down now that it is done. + auto h = g_handler_co.find(co); + if (h != g_handler_co.end()) { + uint64_t id = h->second.id; + bool isClose = h->second.isClose, isLuarpc = h->second.isLuarpc; + g_handler_co.erase(h); + if (isLuarpc) { + auto le = g_luarpc_exec.find(id); + if (le != g_luarpc_exec.end()) { + le->second.busy = false; + if (isClose) { remove_luarpc_conn(mainL, id); g_luarpc_exec.erase(id); } + else g_luarpc_advance.push_back(id); + } + } else { + auto ce = g_conn_exec.find(id); + if (ce != g_conn_exec.end()) { + ce->second.busy = false; + if (isClose) { remove_conn(mainL, id); g_conn_exec.erase(id); } + else g_conn_advance.push_back(id); + } + } + } +} + +// Dispatch each pending inbound gossip as its own handler coroutine (like +// ces.spawn), if the program defined a global on_gossip. Frame body: +// [32 author][32 sender][32 msgId][32 dest][u32 len][msg] +// The handler runs on_gossip(msg, {author, sender, msgid, dest}); frames are +// independent, so unlike conn there is no per-key ordering to preserve. No +// on_gossip => the program just doesn't receive gossip. +static void drain_gossip(lua_State* mainL) { + constexpr size_t H = 4 * WIRE_KEY_LEN + sizeof(uint32_t); + while (!g_gossip_q.empty()) { + Frame f = std::move(g_gossip_q.front()); + g_gossip_q.pop_front(); + if (f.body.size() < H) continue; + uint32_t mlen = get_u32(f.body.data() + 4 * WIRE_KEY_LEN); + if (f.body.size() < H + mlen) continue; + + lua_getglobal(mainL, "on_gossip"); + if (!lua_isfunction(mainL, -1)) { lua_pop(mainL, 1); continue; } + + lua_State* co = lua_newthread(mainL); // mainL: [handler, co] + int ref = luaL_ref(mainL, LUA_REGISTRYINDEX); // pop co, ref it; mainL: [handler] + g_coro_refs[co] = ref; + lua_xmove(mainL, co, 1); // handler -> co; co: [handler] + + const uint8_t* b = f.body.data(); + lua_pushlstring(co, reinterpret_cast(b + H), mlen); // arg1: msg + lua_createtable(co, 0, 4); // arg2: meta + lua_pushlstring(co, reinterpret_cast(b + 0 * WIRE_KEY_LEN), + WIRE_KEY_LEN); lua_setfield(co, -2, "author"); + lua_pushlstring(co, reinterpret_cast(b + 1 * WIRE_KEY_LEN), + WIRE_KEY_LEN); lua_setfield(co, -2, "sender"); + lua_pushlstring(co, reinterpret_cast(b + 2 * WIRE_KEY_LEN), + WIRE_KEY_LEN); lua_setfield(co, -2, "msgid"); + lua_pushlstring(co, reinterpret_cast(b + 3 * WIRE_KEY_LEN), + WIRE_KEY_LEN); lua_setfield(co, -2, "dest"); + g_ready.push_back({co, 2}); + } +} + +// Dispatch a paid L2 call to the program's global on_l2call. Frame body: +// [u64 callId][u64 value][32 payer][u32 memoLen][memo] +// Acceptance-based: the instant a handler exists we ack DELIVERED and run +// on_l2call(memo, {payer, value, callid}) as a coroutine; with no handler we ack +// NO_HANDLER so the host refunds. When the coroutine finishes, its return value +// (a string, or nothing) is sent as TAG_L2_CALL_REPLY -- the reply the caller +// gets on its channel (client verb) or followup run (VM syscall). Settlement +// is reply-time: the host mints `value` into the program account only after +// the coroutine finishes, so the program's balance during on_l2call does not +// yet include it. +static void drain_l2call(lua_State* mainL) { + constexpr size_t H = sizeof(uint64_t) + sizeof(uint64_t) + 32 + sizeof(uint32_t); + while (!g_l2call_q.empty()) { + Frame f = std::move(g_l2call_q.front()); + g_l2call_q.pop_front(); + if (f.body.size() < H) continue; + const uint8_t* b = f.body.data(); + uint64_t callId = get_u64(b); + uint64_t value = get_u64(b + 8); + const uint8_t* payer = b + 16; // 32-byte account key + uint32_t mlen = get_u32(b + 48); + if (f.body.size() < H + mlen) continue; + const uint8_t* memo = b + H; + + lua_getglobal(mainL, "on_l2call"); + bool haveHandler = lua_isfunction(mainL, -1); + + // Settle to the host: delivered iff a handler exists. + std::vector rb; + put_u64(rb, callId); + rb.push_back(haveHandler ? 0 : 1); // 0 delivered, 1 no-handler + write_frame(TAG_L2_CALL_RESULT, 0, rb.data(), rb.size()); + + if (!haveHandler) { lua_pop(mainL, 1); continue; } + + lua_State* co = lua_newthread(mainL); // mainL: [handler, co] + int ref = luaL_ref(mainL, LUA_REGISTRYINDEX); // pop co, ref it + g_coro_refs[co] = ref; + g_l2call_co[co] = callId; // capture on_l2call's reply + lua_xmove(mainL, co, 1); // handler -> co + + lua_pushlstring(co, reinterpret_cast(memo), mlen); // arg1: memo + lua_createtable(co, 0, 3); // arg2 + lua_pushlstring(co, reinterpret_cast(payer), 32); + lua_setfield(co, -2, "payer"); + lua_pushnumber(co, static_cast(value)); + lua_setfield(co, -2, "value"); + lua_pushnumber(co, static_cast(callId)); + lua_setfield(co, -2, "callid"); + g_ready.push_back({co, 2}); + } +} + +// Inbound /ces/peer/1 mesh message: [32 from][u16 service_len][service] +// [u32 len][payload]. Dispatch the per-service handler registered via +// ces.peer.listen(service, fn) as fn(from_pubkey, payload) in a coroutine. +static void drain_peer(lua_State* mainL) { + while (!g_peer_q.empty()) { + Frame f = std::move(g_peer_q.front()); + g_peer_q.pop_front(); + if (f.body.size() < WIRE_KEY_LEN + sizeof(uint16_t)) continue; + const uint8_t* b = f.body.data(); + uint16_t slen = get_u16(b + WIRE_KEY_LEN); + size_t svcOff = WIRE_KEY_LEN + sizeof(uint16_t); + size_t lenOff = svcOff + slen; + if (f.body.size() < lenOff + sizeof(uint32_t)) continue; + uint32_t plen = get_u32(b + lenOff); + size_t payOff = lenOff + sizeof(uint32_t); + if (f.body.size() < payOff + plen) continue; + std::string service(reinterpret_cast(b + svcOff), slen); + + // A C++ rider (hyle net consensus) may claim this service and consume the frame. + if (g_peer_msg_hook && g_peer_msg_hook(b, service, b + payOff, plen)) continue; + + lua_getfield(mainL, LUA_REGISTRYINDEX, kRegPeerListeners); // [tbl?] + if (!lua_istable(mainL, -1)) { lua_pop(mainL, 1); continue; } + lua_getfield(mainL, -1, service.c_str()); // [tbl, fn?] + if (!lua_isfunction(mainL, -1)) { lua_pop(mainL, 2); continue; } + lua_remove(mainL, -2); // [fn] + + lua_State* co = lua_newthread(mainL); // [fn, co] + int ref = luaL_ref(mainL, LUA_REGISTRYINDEX); // pop co; [fn] + g_coro_refs[co] = ref; + lua_xmove(mainL, co, 1); // fn -> co; co: [fn] + lua_pushlstring(co, reinterpret_cast(b), WIRE_KEY_LEN); // from + lua_pushlstring(co, reinterpret_cast(b + payOff), plen); // payload + g_ready.push_back({co, 2}); + } +} + +// Resume every ready coroutine (spawn starts, sleep wakeups). Spawns made while +// draining are picked up in the same pass, so a burst of spawns all start before +// the loop blocks again. +static void drain_ready(lua_State* mainL) { + while (!g_ready.empty()) { + ReadyCoro rc = g_ready.front(); + g_ready.pop_front(); + resume_coro(mainL, rc.co, rc.nargs); + } +} + +// Resume coroutines whose host reply has arrived. Runs ONLY from the run loop: +// a reply read by a nested blocking wait_for_reply just waits in g_io_ready +// until control returns here, so there is no nested resume. The decoder pushes +// the call's Lua results onto the coroutine, then we resume it with that many +// values -- mirroring how a normal verb returns. lua_checkstack guards the push; +// a true allocation failure there kills this (RLIMIT_AS-bounded) child, which is +// the intended out-of-memory behavior and cannot harm the host. +static void drain_io_completions(lua_State* mainL) { + while (!g_io_ready.empty()) { + Frame f = std::move(g_io_ready.front()); + g_io_ready.pop_front(); + auto it = g_io_waiters.find(f.corr_id); + if (it == g_io_waiters.end()) continue; + lua_State* co = it->second.co; + IoDecoder decode = std::move(it->second.decode); + g_io_waiters.erase(it); + if (!g_parked.erase(co)) continue; // already resumed by another waker + int n; + if (f.body.empty()) { + lua_checkstack(co, 2); + lua_pushnil(co); + lua_pushstring(co, "ipc_failure"); + n = 2; + } else { + lua_checkstack(co, LUA_MINSTACK); + n = decode(co, f); + } + resume_coro(mainL, co, n); + } +} + +// The UNIFIED event loop — ces.run() (= ces.conn.run). +// Consumes the program: blocks OUTSIDE Lua on every event source at once — +// the host IPC socket (/ces/lua/1 conn frames + client DELIVER) AND the +// luarpc bridge (peer conns on our own rpc port) — and re-enters Lua to +// dispatch each. Calling it is what turns a program into a server. Returns +// only when the IPC socket dies. Pre-luarpc programs that call ces.conn.run +// behave exactly as before (the bridge half just stays empty). +int lua_ces_run(lua_State* L) { + static bool manifest_sent = false; + if (!manifest_sent) { manifest_sent = true; send_manifest_from_global(L); } + // Ensure the wakeup eventfd exists before the loop blocks: worker pools and + // ces.conn.connect nudge it on completion to wake the poll. Without it, an + // offloaded call from an otherwise-idle program would park forever (the poll + // has no other event to wake on). luarpc_ensure_endpoint also creates it, but + // a program using only main-port verbs (ces.ping/remote_*) never goes there. + if (g_luarpc_wakefd < 0) + g_luarpc_wakefd = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + for (;;) { + // Drain everything already queued. CONN_* frames may have been parked by + // an API call's wait_for_reply; luarpc events are queued by the endpoint + // threads. + while (!g_conn_q.empty()) { + Frame qf = std::move(g_conn_q.front()); + g_conn_q.pop_front(); + if (!dispatch_conn_frame(L, std::move(qf))) return 0; + } + for (;;) { + LuaRpcEvent e; + { + std::lock_guard lk(g_luarpc_mx); + if (g_luarpc_inq.empty()) break; + e = std::move(g_luarpc_inq.front()); + g_luarpc_inq.pop_front(); + } + dispatch_luarpc_event(L, std::move(e)); + } + + // Drain channel-usage the endpoint's meter queued and report it to the + // parent (one-way TAG_NET_USAGE). Only this (IPC) thread may write g_sock_fd. + for (;;) { + NetUsageRecord r; + { + std::lock_guard lk(g_net_usage_mx); + if (g_net_usage_q.empty()) break; + r = g_net_usage_q.front(); + g_net_usage_q.pop_front(); + } + std::vector nb; + put_bytes(nb, r.payer.data(), r.payer.size()); + put_u64(nb, r.usage.bytesSent); + put_u64(nb, r.usage.bytesReceived); + put_u64(nb, r.usage.memByteSeconds); + put_u64(nb, r.usage.ageSeconds); + write_frame(TAG_NET_USAGE, 0, nb.data(), nb.size()); + } + + // Fire due periodic timers (ces.every) + sleep wakeups, resume coroutines + // whose host reply arrived, dispatch ready conn handlers, then run every + // ready coroutine before blocking. + fire_due_timers(L); + drain_chan_timeouts(L); + drain_io_completions(L); + drain_connect_done(L); + drain_offload_done(L); + drain_conn_advance(L); + drain_luarpc_advance(L); + drain_gossip(L); + drain_peer(L); + drain_l2call(L); + drain_ready(L); + // Watched panel: render-and-push if the frame changed (500ms budget inside). + mene_ui_tick(L, g_ext_panel_ref); + + // Block until the next event on either source, or the next timer deadline. + // Don't block if a coroutine became ready in the meantime. + pollfd pfds[2]; + pfds[0].fd = g_sock_fd; + pfds[0].events = POLLIN; + pfds[0].revents = 0; + int nfds = 1; + if (g_luarpc_wakefd >= 0) { + pfds[1].fd = g_luarpc_wakefd; + pfds[1].events = POLLIN; + pfds[1].revents = 0; + nfds = 2; + } + bool workPending = !g_ready.empty() || !g_conn_advance.empty() || + !g_luarpc_advance.empty(); + int pollMs = workPending ? 0 : next_timer_timeout_ms(); + if (!workPending && g_ext_ui_watch && g_ext_panel_ref != LUA_NOREF) { + // The change-detect tick needs the loop to wake at its cadence even when + // the program has no timers of its own (next_timer_timeout_ms may be -1). + constexpr int kUiMs = static_cast(kExtUiCheckIntervalUs / 1000); + if (pollMs < 0 || pollMs > kUiMs) pollMs = kUiMs; + } + int pr = ::poll(pfds, nfds, pollMs); + if (pr < 0) { + if (errno == EINTR) continue; + return 0; + } + + // Bridge nudge → clear the eventfd; the next loop drains g_luarpc_inq. + if (nfds == 2 && (pfds[1].revents & POLLIN)) { + uint64_t v; + ssize_t r = ::read(g_luarpc_wakefd, &v, sizeof(v)); + (void)r; + } + // IPC frame ready → read + dispatch/route. + if (pfds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) return 0; + if (pfds[0].revents & POLLIN) { + Frame f; + if (!read_frame(f)) return 0; + if (f.tag == TAG_CONN_OPENED || f.tag == TAG_CONN_DATA_IN || + f.tag == TAG_CONN_CLOSED) { + if (!dispatch_conn_frame(L, std::move(f))) return 0; + } else if (f.tag == TAG_EXT_REQ || f.tag == TAG_EXT_CONFIG || + f.tag == TAG_EXT_UI_WATCH) { + if (!dispatch_ext_frame(L, std::move(f))) return 0; + } else { + route_to_queue(std::move(f)); // DELIVER → g_inbox, API_REPLY → reply_q + } + } + } +} + diff --git a/vendor/ces/src/cesluajitd/api_file.inc b/vendor/ces/src/cesluajitd/api_file.inc new file mode 100644 index 0000000..f1e9102 --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_file.inc @@ -0,0 +1,171 @@ +// --------------------------------------------------------------------------- +// ces.file_* — file-storage bindings. Each call routes through the +// host's fileHandlerExec, which enforces full owner semantics 1:1 +// with `cesh file`. No feeQuery charged (no network signed-op). +// --------------------------------------------------------------------------- + +// BE serialization helpers — local to cesluajitd by design. +// +// The rest of the CES codebase uses `ces::Buffer::put` / `peek` +// (see include/ces/buffer.h) which delegates to logkv::serializer. +// cesluajitd is deliberately minimal — no ceslib, no boost link +// dependency, no MINX/logkv include paths plumbed through CMake — so +// it carries its own byte-shifting. Same wire shape as the canonical +// helpers; just self-contained for the small binary. +void put_u16(std::vector& o, uint16_t v) { + o.push_back(uint8_t((v >> 8) & 0xFF)); + o.push_back(uint8_t( v & 0xFF)); +} +void put_u32(std::vector& o, uint32_t v) { + o.push_back(uint8_t((v >> 24) & 0xFF)); + o.push_back(uint8_t((v >> 16) & 0xFF)); + o.push_back(uint8_t((v >> 8) & 0xFF)); + o.push_back(uint8_t( v & 0xFF)); +} +void put_u64(std::vector& o, uint64_t v) { + for (int i = 7; i >= 0; --i) + o.push_back(uint8_t((v >> (i * 8)) & 0xFF)); +} +void put_bytes(std::vector& o, const void* p, size_t n) { + const uint8_t* b = static_cast(p); + o.insert(o.end(), b, b + n); +} +// Append [u16 BE name_len][name] to buffer. Caller ensures name +// length fits u16. +void put_name(std::vector& o, const char* name, size_t nlen) { + put_u16(o, static_cast(nlen)); + put_bytes(o, name, nlen); +} + +uint16_t get_u16(const uint8_t* p) { + return (uint16_t(p[0]) << 8) | uint16_t(p[1]); +} +uint32_t get_u32(const uint8_t* p) { + return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) + | (uint32_t(p[2]) << 8) | uint32_t(p[3]); +} +uint64_t get_u64(const uint8_t* p) { + uint64_t v = 0; + for (int i = 0; i < 8; ++i) v = (v << 8) | p[i]; + return v; +} + +// Build + send a TAG_API_CALL; returns its corr_id, or 0 on write failure. +// corr_id 0 is reserved for one-way frames, so the counter skips it on wrap. +uint16_t send_api_call(uint16_t method, const std::vector& args) { + std::vector body; + body.reserve(sizeof(uint16_t) + args.size()); + put_u16(body, method); + body.insert(body.end(), args.begin(), args.end()); + uint16_t corr; + do { corr = g_next_corr_id++; } while (corr == 0); + if (!write_frame(TAG_API_CALL, corr, body.data(), body.size())) return 0; + return corr; +} + +// On API error, pushes (nil, err_code). On IPC failure, (nil, +// "ipc_failure"). Caller returns the number this returns. +int push_file_err(lua_State* L, int status) { + lua_pushnil(L); + lua_pushinteger(L, status); + return 2; +} +int push_ipc_fail(lua_State* L) { + lua_pushnil(L); + lua_pushstring(L, "ipc_failure"); + return 2; +} + +// True if the current call can suspend. Only a coroutine yields; the main +// thread (the main chunk, and any handler dispatched on it) cannot. Same guard +// ces.sleep uses. +bool io_yieldable(lua_State* L) { + int isMain = lua_pushthread(L); + lua_pop(L, 1); + return isMain == 0; +} + +// The host round-trip shared by every ces.* verb below. From a coroutine it +// parks (no thread blocked) and resumes when the reply lands; from the main +// thread it blocks in wait_for_reply -- the correct degenerate case, since +// nothing else is running there. `decode` turns the reply body into the call's +// Lua return values and runs either here (blocking path) or in the run loop at +// resume (yield path), never spanning the yield. A reply with an empty body is +// an IPC failure, handled here so decoders only ever see a real body. +int io_call(lua_State* L, uint16_t method, + const std::vector& args, IoDecoder decode) { + uint16_t corr = send_api_call(method, args); + if (corr == 0) return push_ipc_fail(L); + if (io_yieldable(L)) { + g_io_waiters[corr] = IoWaiter{L, std::move(decode)}; + g_parked.insert(L); + return lua_yield(L, 0); + } + Frame reply; + if (!wait_for_reply(corr, reply) || reply.body.empty()) return push_ipc_fail(L); + return decode(L, reply); +} + +// ces.transfer(target_pubkey:string(32), amount:number) +// → true, new_origin_balance | nil, err_code +// +// Sends `amount` credits from the program's owner account to the +// target. /s/ programs run with owner = server (auto-topped to +// near-INT64_MAX every boot), so dice / payouts / etc. just work. +// Programs in /h/ or /f/ pull from their owner's account exactly the +// same way file ops do — i.e. the program acts under the owner's +// authority. +int lua_ces_transfer(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "target pubkey must be 32 bytes"); + return 2; + } + lua_Number amt_n = luaL_checknumber(L, 2); + if (amt_n < 0 || amt_n > 9.2233720368547e18) { + lua_pushnil(L); + lua_pushstring(L, "amount out of range"); + return 2; + } + uint64_t amount = static_cast(amt_n); + + std::vector args; + args.reserve(WIRE_KEY_LEN + sizeof(uint64_t)); + put_bytes(args, pk, 32); + put_u64(args, amount); + + return io_call(L, METHOD_TRANSFER, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) { + lua_pushnil(L); + lua_pushinteger(L, st); + return 2; + } + // Tail: [u64 BE new_origin_balance] + if (reply.body.size() < sizeof(uint8_t) + sizeof(uint64_t)) + return push_file_err(L, STATUS_INTERNAL); + uint64_t newBal = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, static_cast(newBal)); + return 2; + }); +} + +// Minx config for a single outbound round-trip from this compute instance. +// CesClient's default is server-sized: a Minx with DEFAULT_RECV_BUFFERS_SIZE +// (16384) x 2KB recv buffers = ~32MB allocated up front. Constructing that per +// call inside a child capped by RLIMIT_AS throws std::bad_alloc. A client doing +// one blocking request needs only a small recv ring and none of the server's +// PoW/spam machinery, so size it for what it is. +minx::MinxConfig luaClientConfig() { + minx::MinxConfig c{"luacl"}; + c.recvBuffersSize = 256; // one round-trip, not a server's inbound backlog + c.spamSampleRate = 0; // a client does not spam-score its own replies + c.randomXVMsToKeep = 0; // ping/query/transfer never verify PoW + c.randomXInitThreads = 0; + c.trustLoopback = true; + return c; +} + diff --git a/vendor/ces/src/cesluajitd/api_hyle.inc b/vendor/ces/src/cesluajitd/api_hyle.inc new file mode 100644 index 0000000..b528318 --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_hyle.inc @@ -0,0 +1,1092 @@ +// --------------------------------------------------------------------------- +// ces.hyle - a hyle blockchain hosted IN this cesluajitd process. +// Extension-only: registered solely for privileged (/s/) programs. +// +// `ces.hyle.*` is the CHAIN INTERFACE: submit, sudo, govern, read, and the op builders. It +// says nothing about how many nodes are behind it. `ces.hyle.solo.*` is the LIFECYCLE of one +// implementation: a single-validator, transportless chain a program drives by hand, one block +// per tick. A transport-enabled multi-node implementation adds its own lifecycle (ces.hyle.net) +// and leaves the interface alone, so a program written against ces.hyle.* moves from the devnet +// to a real chain unchanged. +// +// Sudo is here in full. A sudo act runs with its guards waived, so it needs a supermajority's +// approval, collected ON CHAIN: propose() opens the proposal with the program's own vote, +// approve() adds more, and the vote reaching quorum executes it. On a one-validator chain that +// single vote is the supermajority, so propose() executes at once; raise the validator count +// and the same path waits for the other members to approve() first. +// +// Only present when the node was built with --hyle (CES_HYLE): hyle_services is statically +// linked, so these calls drive a hyle::services::Runtime DIRECTLY, no host IPC. In-RAM only: +// stop() (or the process dying) drops the chain. +// +// IDENTITY. A node's validator identity is its OWN CES ed25519 identity, chosen by topology: on +// solo it is the program key, so ces.hyle.self() == ces.program_pubkey(); on net it is the SERVER +// key (the peer-mesh identity). Both are one key across two ledgers -- the CES account and the +// chain account are the same 32 bytes -- which lets a payment on one side authorize a mint on the +// other with no mapping table. self()/self_secret()/op.* all report and sign as hyle_active_key(). +// +// Surface (drives extensions/hylesolo.lua; see apps/hylesolo in cesdk for the source): +// solo lifecycle +// ces.hyle.solo.start{ chain_id=, block_pace_ms=, alloc=, fee_transfer=, fee_entry=, +// fee_sudo=, rent_rate=, rip_bounty=, sudo_ttl_secs=, +// credit_autofill_ceiling=, refill_rate= } -> true | nil,err +// ces.hyle.solo.tick() -- advance exactly one height; -> new height +// ces.hyle.solo.stop() -- destroy the node +// chain +// ces.hyle.running() / height() / chain_id() / self() +// ces.hyle.info() -- one table: everything live +// ces.hyle.config() -- the genesis economy +// ces.hyle.account(pubkey) / entry(name) / tx_result(txid) / validators() +// ces.hyle.submit(bytes) -- admit signed ops; -> {txid,..} | nil,reason +// ces.hyle.op.* -- build ops signed AS THIS PROGRAM +// ces.hyle.sudo.op.* -- build a sudo act's inner bytes +// ces.hyle.sudo.propose(inner) / approve(proposer,inner) / pending(proposer) / quorum() +// ces.hyle.vote_add(pk) / vote_remove(pk) +// --------------------------------------------------------------------------- +#ifdef CES_HYLE + +// hyle headers are included at global scope in main.cpp (guarded by CES_HYLE): +// this .inc is textually inside `namespace cesluajitd`, and library/std headers +// must not be included inside a namespace. + +static std::unique_ptr g_hyle_node; +static std::unique_ptr g_hyle_key; // the sole validator (== the program key) +static std::string g_hyle_chain; + +// Lifetime meters (RAM, reset on start): txs summed from the commit event, queries bumped by the readers. +static uint64_t g_hyle_txs = 0; +static uint64_t g_hyle_queries = 0; + +static hyle::wire::View hyle_chain_view() { + return hyle::wire::View(reinterpret_cast(g_hyle_chain.data()), + g_hyle_chain.size()); +} + +static hyle::wire::View hyle_view(const hyle::wire::Bytes& b) { + return hyle::wire::View(b.data(), b.size()); +} + +// Read a uint64 field from the config table at stack index 1 (default if absent/non-numeric). +static uint64_t hyle_cfg_u64(lua_State* L, const char* key, uint64_t def) { + lua_getfield(L, 1, key); + uint64_t v = lua_isnumber(L, -1) ? static_cast(lua_tonumber(L, -1)) : def; + lua_pop(L, 1); + return v; +} + +// Field readers for the op builders: every builder takes one table argument. +static uint64_t hyle_fld_u64(lua_State* L, const char* key, uint64_t def) { + return hyle_cfg_u64(L, key, def); +} + +static hyle::wire::Bytes hyle_fld_bytes(lua_State* L, const char* key) { + lua_getfield(L, 1, key); + size_t n = 0; + const char* s = lua_isstring(L, -1) ? lua_tolstring(L, -1, &n) : nullptr; + hyle::wire::Bytes out; + if (s != nullptr) out.assign(s, s + n); + lua_pop(L, 1); + return out; +} + +// A 32-byte key field. Absent or wrong-sized yields the zero key, which every op path +// treats as "not given" (and which sudo refuses to leave owning anything). +static hyle::PubKey hyle_fld_key(lua_State* L, const char* key) { + hyle::PubKey k{}; + lua_getfield(L, 1, key); + size_t n = 0; + const char* s = lua_isstring(L, -1) ? lua_tolstring(L, -1, &n) : nullptr; + if (s != nullptr && n == 32) std::memcpy(k.data(), s, 32); + lua_pop(L, 1); + return k; +} + +// A key field that must be there. Absent or wrong-sized is an error, not a zero key: +// the zero key is the mint sentinel, and quietly resolving `to` to it would turn a +// mistyped transfer into a burn. +static hyle::PubKey hyle_fld_key_req(lua_State* L, const char* key) { + lua_getfield(L, 1, key); + size_t n = 0; + const char* s = lua_isstring(L, -1) ? lua_tolstring(L, -1, &n) : nullptr; + if (s == nullptr || n != 32) + luaL_error(L, "field '%s' must be a 32-byte pubkey", key); + hyle::PubKey k{}; + std::memcpy(k.data(), s, 32); + lua_pop(L, 1); + return k; +} + +static hyle::PubKey hyle_arg_key(lua_State* L, int idx) { + size_t n = 0; + const char* s = luaL_checklstring(L, idx, &n); + if (n != 32) luaL_error(L, "expected a 32-byte pubkey, got %d bytes", static_cast(n)); + hyle::PubKey k{}; + std::memcpy(k.data(), s, 32); + return k; +} + +static void hyle_push_bytes(lua_State* L, const hyle::wire::Bytes& b) { + lua_pushlstring(L, reinterpret_cast(b.data()), b.size()); +} + +static void hyle_set_u64(lua_State* L, const char* key, uint64_t v) { + lua_pushnumber(L, static_cast(v)); + lua_setfield(L, -2, key); +} + +static void hyle_set_bytes(lua_State* L, const char* key, const uint8_t* p, size_t n) { + lua_pushlstring(L, reinterpret_cast(p), n); + lua_setfield(L, -2, key); +} + +// The program's own hyle identity. Same 32 bytes as ces.program_pubkey(); see IDENTITY above. +static hyle::KeyPair hyle_program_key() { + hyle::PrivKey secret{}; + std::memcpy(secret.data(), g_program_privkey, 32); + return hyle::KeyPair::from_secret(secret); +} + +// The active node identity: the running chain's validator key -- the program key on solo, the +// server key on net (both a node's own CES identity, per topology) -- or the program key before +// any chain starts. self()/self_secret()/op.* sign and report as this, so the same code moves +// from solo to net unchanged. +static hyle::KeyPair hyle_active_key() { + if (g_hyle_key) return *g_hyle_key; + return hyle_program_key(); +} + +// Every interface call needs a chain. Returns false having pushed (nil, reason). +static bool hyle_need_node(lua_State* L) { + if (g_hyle_node) return true; + lua_pushnil(L); + lua_pushstring(L, "no chain running"); + return false; +} + +// 'a' + pubkey, or 'e' + name: hyle's two transfer destinations. +static hyle::wire::Bytes hyle_account_dest(const hyle::PubKey& pk) { + hyle::wire::Bytes b; + b.push_back(hyle::services::ACCOUNT_PREFIX); + b.insert(b.end(), pk.begin(), pk.end()); + return b; +} + +static hyle::wire::Bytes hyle_entry_dest(const hyle::wire::Bytes& name) { + hyle::wire::Bytes b; + b.push_back(hyle::services::ENTRY_PREFIX); + b.insert(b.end(), name.begin(), name.end()); + return b; +} + +// The genesis economy + consensus-rule fields, read from the start{} table. Shared by the solo +// and net lifecycles: the economy is part of the chain definition and must match across nodes. +// Validators and allocations differ per lifecycle and are set by the caller. +static void hyle_fill_config(lua_State* L, hyle::services::Config& c) { + // With rent on, hyle checks rip_bounty <= min(fee_transfer, fee_entry) -- a bounty worth more + // than the fee to create a rippable entry is a pump that mints money out of the cull. + c.fee_transfer = hyle_cfg_u64(L, "fee_transfer", 10); + c.fee_entry = hyle_cfg_u64(L, "fee_entry", 10); + c.fee_sudo = hyle_cfg_u64(L, "fee_sudo", 1); + // At rate 0 an entry is immortal: its balance buys nothing and nothing can cull it. + c.rent_rate = hyle_cfg_u64(L, "rent_rate", 1); + c.rip_bounty = hyle_cfg_u64(L, "rip_bounty", 10); + // A pending sudo proposal past this age cannot execute and is cleared. 0 = never expires. + c.sudo_ttl_secs = hyle_cfg_u64(L, "sudo_ttl_secs", 0); + // Consensus rules (validator-set bounds, max block payload): part of the chain definition. + c.member_cap = static_cast(hyle_cfg_u64(L, "member_cap", 21)); + c.member_floor = static_cast(hyle_cfg_u64(L, "member_floor", 1)); + c.max_value_bytes = hyle_cfg_u64(L, "max_value_bytes", 4u << 20); + // Hard cap on total state RAM in bytes (0 = unbounded). + c.max_state_bytes = hyle_cfg_u64(L, "max_state_bytes", 0); + // Per-block credit autofill: tops each validator toward the ceiling (0 = off). + c.credit_autofill_ceiling = hyle_cfg_u64(L, "credit_autofill_ceiling", 0); + c.refill_rate = hyle_cfg_u64(L, "refill_rate", 0); +} + +// --------------------------------------------------------------------------- +// Lifecycle (solo) +// --------------------------------------------------------------------------- + +int lua_ces_hyle_start(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (g_hyle_node) { lua_pushboolean(L, 1); return 1; } // idempotent: already running + + lua_getfield(L, 1, "chain_id"); + g_hyle_chain = lua_isstring(L, -1) ? lua_tostring(L, -1) : "hylesolo"; + lua_pop(L, 1); + + // The sole validator is derived deterministically from this program's own secret, so the + // chain's identity is stable across restarts and tied to the program. + g_hyle_key = std::make_unique(hyle_program_key()); + + hyle::services::Genesis g; + g.chain_id = g_hyle_chain; + g.validators = { g_hyle_key->pub }; + g.allocations = { { g_hyle_key->pub, hyle_cfg_u64(L, "alloc", 1000000000ull) } }; + hyle_fill_config(L, g.config); + + std::string err; + if (!g.validate(err)) { + g_hyle_key.reset(); + lua_pushnil(L); + lua_pushstring(L, ("hyle genesis invalid: " + err).c_str()); + return 2; + } + + // Node operational config (pacing, RAM history policy): distinct from the genesis above, which + // is the chain's consensus definition. snapshot_interval > 0 snapshots every N blocks and + // drops older ones; else a rolling block_retention window bounds RAM. + hyle::services::NodeOptions opts; + opts.block_pace_ms = hyle_cfg_u64(L, "block_pace_ms", 1000); // Lua drives ticks; advisory + opts.block_retention = hyle_cfg_u64(L, "block_retention", 1024); + opts.snapshot_interval = hyle_cfg_u64(L, "snapshot_interval", 0); + opts.mempool_capacity = static_cast(hyle_cfg_u64(L, "mempool_capacity", 8192)); + // data_dir/evidence_dir left empty: solo is in-RAM, no WAL, and dies on stop. + g_hyle_node = std::make_unique(g, *g_hyle_key, opts, nullptr); + + g_hyle_txs = 0; + g_hyle_queries = 0; + g_hyle_node->app().add_on_commit( + [](const hyle::services::CommitEvent& ev) { g_hyle_txs += ev.txs.size(); }); + + lua_pushboolean(L, 1); + return 1; +} + +int lua_ces_hyle_tick(lua_State* L) { + if (!g_hyle_node) { lua_pushnil(L); return 1; } + g_hyle_node->run_to(g_hyle_node->height() + 1); // solo self-quorum: synchronous, non-blocking + lua_pushnumber(L, static_cast(g_hyle_node->height())); + return 1; +} + +int lua_ces_hyle_stop(lua_State* L) { + g_hyle_node.reset(); + g_hyle_key.reset(); + g_hyle_chain.clear(); + g_hyle_txs = 0; + g_hyle_queries = 0; + lua_pushboolean(L, 1); + return 1; +} + +int lua_ces_hyle_running(lua_State* L) { + lua_pushboolean(L, g_hyle_node ? 1 : 0); + return 1; +} + +// --------------------------------------------------------------------------- +// Lifecycle (net): a real multi-node chain. The validator identity is the SERVER key +// (read in-process from g_server_secret), and consensus rides the CES server peer mesh (ces.peer.*), addressed by +// server pubkey. The driver mirrors morphe's Driver::tick (pump/advance/idle-debounced timeout); +// the extension calls net.tick() on a fine ces.every. No dialing: the peer mesh is already there. +// --------------------------------------------------------------------------- + +// Bridges hyle's Transport to the CES peer mesh. send/broadcast frame [u8 type][u8 ch][payload] +// into a ces.peer.send; inbound frames (via the drain_peer hook) feed on_recv and, for consensus, +// re-flood one hop to defeat equivocation. Addresses peers by CES server pubkey. +struct CesPeerTransport : hyle::services::Transport { + std::string service; // "hyle/" + chain_id + std::vector peers; // the OTHER validators (server pubkeys) + std::deque seenOrder; + std::unordered_set seen; + static constexpr size_t kSeenCap = 8192; + + void frameTo(const hyle::PubKey& dest, uint8_t type, uint8_t ch, + const uint8_t* payload, size_t plen) { + std::vector body; + body.reserve(32 + 2 + service.size() + 2 + plen); + body.insert(body.end(), dest.begin(), dest.end()); + put_u16(body, static_cast(service.size())); + body.insert(body.end(), service.begin(), service.end()); + body.push_back(type); + body.push_back(ch); + if (plen) body.insert(body.end(), payload, payload + plen); + write_frame(TAG_PEER_MSG_OUT, 0, body.data(), body.size()); + } + void send(const hyle::PubKey& dest, hyle::services::MsgType type, + hyle::services::Channel ch, hyle::wire::View payload) override { + frameTo(dest, static_cast(type), static_cast(ch), + payload.data(), payload.size()); + } + void broadcast(hyle::services::MsgType type, hyle::services::Channel ch, + hyle::wire::View payload) override { + for (const auto& p : peers) + frameTo(p, static_cast(type), static_cast(ch), + payload.data(), payload.size()); + } + size_t peer_count() const override { return peers.size(); } + // Bulk pieces are sized to this. Kept small (64 KiB): a small piece drains fast and backs up + // little in the send buffer, yet dwarfs the per-frame framing and 1241-byte RUDP packet overhead. + // The peer mesh size-routes anything this large onto its bulk channel, off the consensus one. + size_t max_message() const override { return 64 * 1024; } + + bool markSeen(const uint8_t* inner, size_t len) { + const hyle::Hash h = hyle::sha256(hyle::wire::View(inner, len)); + uint64_t k = 0; + for (int i = 0; i < 8; ++i) k = (k << 8) | h[i]; + if (!seen.insert(k).second) return false; + seenOrder.push_back(k); + if (seenOrder.size() > kSeenCap) { seen.erase(seenOrder.front()); seenOrder.pop_front(); } + return true; + } + + // Inbound from drain_peer; `data` = inner frame [u8 type][u8 ch][payload]. Returns true iff a + // FRESH consensus/prop message was delivered (drives the timeout clock; Tx and dups return false + // so a mempool stream or a re-flood storm cannot starve a needed timeout). + // + // Dedup gates the re-flood and the timeout clock, never local delivery of consensus or sync + // frames. The engine's liveness rebroadcasts are byte-identical, and a node whose engine lost + // its first copy (rebuilt by catch-up) can only recover the round's votes from a re-delivery; + // sync control and the streamed bulk pieces are point-to-point, never re-flooded, and retried. + bool onInbound(const hyle::PubKey& from, const uint8_t* data, size_t len) { + if (len < 2) return false; + const auto type = static_cast(data[0]); + using MT = hyle::services::MsgType; + const bool sync = (type == MT::ValueReq || type == MT::SnapReq || type == MT::SnapResp || + type == MT::BulkChunk); + const bool consensus = (type == MT::Consensus || type == MT::Prop); + const bool fresh = sync || markSeen(data, len); + if (!fresh && !consensus) return false; + hyle::wire::View pl(data + 2, len - 2); + if (on_recv) on_recv(from, type, pl); + if (consensus && fresh) + for (const auto& p : peers) + if (!(p == from)) frameTo(p, data[0], data[1], data + 2, len - 2); + return consensus && fresh; + } +}; + +static std::unique_ptr g_hyle_net_tp; +static std::chrono::steady_clock::time_point g_hyle_net_last_commit{}; +static std::chrono::steady_clock::time_point g_hyle_net_last_progress{}; +static std::chrono::steady_clock::time_point g_hyle_net_last_regossip{}; +static std::chrono::steady_clock::time_point g_hyle_net_last_syncreq{}; +static uint64_t g_hyle_net_last_h = 0; +static uint64_t g_hyle_net_pace_ms = 1000; +// Deliver one pending consensus timeout after this many ms with NO consensus activity. Paced on +// wall time, not tick count: net.tick rides a cooperative ces.every whose cadence sags under load, +// so a tick-count debounce (morphe's) stretches to seconds and stalls rounds. The clock resets on +// any fresh inbound consensus/prop message (see the peer hook), so an active round -- even below +// quorum, votes still trickling -- never times out early; only a genuine stall (silent proposer) +// fires it. Default is WAN-safe (sub-second RTT keeps the clock reset); tune via conf. +static uint64_t g_hyle_net_timeout_ms = 1000; + +static bool hyleNetDeliverPeer(const uint8_t* from32, const std::string& service, + const uint8_t* payload, size_t plen) { + if (!g_hyle_net_tp || service != g_hyle_net_tp->service) return false; + hyle::PubKey from{}; + std::memcpy(from.data(), from32, 32); + const bool consensusProgress = g_hyle_net_tp->onInbound(from, payload, plen); + if (g_hyle_node) { + // Flush any consensus response this message produced right now, event-driven, rather than + // waiting for the next timer-due net.tick. drain_peer runs after net.tick in the main loop, so + // without this a vote/proposal reply sits in the outbox for up to a full (stretched) timer + // period. A fresh consensus message also resets the no-activity timeout clock. + g_hyle_node->pump(); + if (consensusProgress) g_hyle_net_last_progress = std::chrono::steady_clock::now(); + } + return true; +} + +int lua_ces_hyle_net_start(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (g_hyle_node) { lua_pushnil(L); lua_pushstring(L, "a hyle chain is already running"); return 2; } + if (!g_has_server_secret) { + lua_pushnil(L); + lua_pushstring(L, "server_secret unavailable (need a /s/ extension on an ed25519 server)"); + return 2; + } + hyle::PrivKey secret{}; + std::memcpy(secret.data(), g_server_secret, 32); + auto key = std::make_unique(hyle::KeyPair::from_secret(secret)); + // The derived ed25519 pubkey must equal the server's advertised pubkey (owner for /s/): this + // both authenticates the secret and rejects a non-ed25519 server key (which has no hyle identity). + if (std::memcmp(key->pub.data(), g_owner_pubkey, 32) != 0) { + lua_pushnil(L); lua_pushstring(L, "server key is not ed25519 (no hyle identity)"); return 2; + } + + lua_getfield(L, 1, "chain_id"); + g_hyle_chain = lua_isstring(L, -1) ? lua_tostring(L, -1) : "hylenet"; + lua_pop(L, 1); + + std::vector vals; + lua_getfield(L, 1, "validators"); + if (lua_istable(L, -1)) { + const int n = static_cast(lua_objlen(L, -1)); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + size_t klen = 0; + const char* k = lua_tolstring(L, -1, &klen); + if (k && klen == 32) { hyle::PubKey pk{}; std::memcpy(pk.data(), k, 32); vals.push_back(pk); } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + if (vals.empty()) { + lua_pushnil(L); lua_pushstring(L, "validators: need a list of 32-byte server pubkeys"); return 2; + } + // A node not in the genesis validator set starts as a FOLLOWER: it syncs and awaits promotion + // by a governance vote_add. This is how a new validator joins a running chain. + hyle::services::Genesis g; + g.chain_id = g_hyle_chain; + g.validators = vals; + hyle_fill_config(L, g.config); + std::string err; + if (!g.validate(err)) { + lua_pushnil(L); lua_pushstring(L, ("hyle genesis invalid: " + err).c_str()); return 2; + } + + auto tp = std::make_unique(); + tp->service = "hyle/" + g_hyle_chain; + for (const auto& v : vals) + if (std::memcmp(v.data(), key->pub.data(), 32) != 0) tp->peers.push_back(v); + // Register the service so the server routes inbound mesh messages to us (mirrors ces.peer.listen). + { + std::vector lb; + put_u16(lb, static_cast(tp->service.size())); + lb.insert(lb.end(), tp->service.begin(), tp->service.end()); + write_frame(TAG_PEER_LISTEN, 0, lb.data(), lb.size()); + } + g_hyle_net_tp = std::move(tp); + g_peer_msg_hook = &hyleNetDeliverPeer; + + hyle::services::NodeOptions opts; + opts.block_pace_ms = hyle_cfg_u64(L, "block_pace_ms", 1000); + opts.block_retention = hyle_cfg_u64(L, "block_retention", 1024); + opts.snapshot_interval = hyle_cfg_u64(L, "snapshot_interval", 0); + opts.mempool_capacity = static_cast(hyle_cfg_u64(L, "mempool_capacity", 8192)); + g_hyle_net_pace_ms = opts.block_pace_ms; + g_hyle_net_timeout_ms = hyle_cfg_u64(L, "consensus_timeout_ms", 1000); // no-activity round timeout + + g_hyle_key = std::move(key); + g_hyle_node = std::make_unique(g, *g_hyle_key, opts, g_hyle_net_tp.get()); + g_hyle_txs = 0; + g_hyle_queries = 0; + g_hyle_node->app().add_on_commit( + [](const hyle::services::CommitEvent& ev) { g_hyle_txs += ev.txs.size(); }); + g_hyle_node->begin(); + g_hyle_net_last_h = 0; + g_hyle_net_last_commit = std::chrono::steady_clock::now(); + g_hyle_net_last_progress = g_hyle_net_last_commit; + g_hyle_net_last_regossip = g_hyle_net_last_commit; + lua_pushboolean(L, 1); + return 1; +} + +int lua_ces_hyle_net_tick(lua_State* L) { + if (!g_hyle_node) { lua_pushnil(L); return 1; } + bool prog = g_hyle_node->pump(); + const bool pending = g_hyle_node->app().mempool().size() > 0; + const auto now = std::chrono::steady_clock::now(); + const bool paced = + std::chrono::duration_cast(now - g_hyle_net_last_commit).count() >= + static_cast(g_hyle_net_pace_ms); + if ((pending || paced) && g_hyle_node->advance()) { + prog = true; + g_hyle_node->pump(); // flush the freshly-started height's proposal this tick, not the next + } + // Re-gossip pending mempool txs on a modest cadence. submit() broadcasts once and on_message + // re-floods a tx only on FRESH receipt, so a tx that missed a peer whose mesh channel was + // flapping under load never reaches it -- and unlike consensus (which the engine re-sends on + // timeout) tx delivery has no other self-heal. This is that self-heal. + if (pending && + std::chrono::duration_cast(now - g_hyle_net_last_regossip).count() >= + 1000) { + g_hyle_node->regossip(); + g_hyle_net_last_regossip = now; + } + // Blocksync: if we have fallen behind the head observed from peers, ask for the missing blocks. + // request_sync no-ops at head, so this is cheap; pace it so a long catch-up pulls batch after + // batch without flooding. + if (std::chrono::duration_cast(now - g_hyle_net_last_syncreq).count() >= + 250) { + g_hyle_node->request_sync(); + g_hyle_net_last_syncreq = now; + } + if (g_hyle_node->height() != g_hyle_net_last_h) { + g_hyle_net_last_h = g_hyle_node->height(); + g_hyle_net_last_commit = now; + // Refresh the transport peers from the LIVE validator set so membership changes (add/remove) + // route consensus correctly -- a newly added validator starts receiving, a removed one stops. + if (g_hyle_net_tp && g_hyle_key) { + const auto set = g_hyle_node->node().validators_for(g_hyle_node->height() + 1); + g_hyle_net_tp->peers.clear(); + for (const auto& v : set) { + hyle::PubKey pk{}; + std::memcpy(pk.data(), v.public_key.data(), 32); + if (std::memcmp(pk.data(), g_hyle_key->pub.data(), 32) != 0) + g_hyle_net_tp->peers.push_back(pk); + } + } + } + if (prog) { + g_hyle_net_last_progress = now; + } else if (std::chrono::duration_cast(now - g_hyle_net_last_progress) + .count() >= static_cast(g_hyle_net_timeout_ms)) { + g_hyle_node->fire_one_timeout(); + g_hyle_net_last_progress = now; + } + lua_pushnumber(L, static_cast(g_hyle_node->height())); + return 1; +} + +int lua_ces_hyle_net_stop(lua_State* L) { + g_peer_msg_hook = nullptr; + g_hyle_node.reset(); + g_hyle_key.reset(); + g_hyle_net_tp.reset(); + g_hyle_chain.clear(); + g_hyle_txs = 0; + g_hyle_queries = 0; + lua_pushboolean(L, 1); + return 1; +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +int lua_ces_hyle_height(lua_State* L) { + ++g_hyle_queries; + lua_pushnumber(L, g_hyle_node ? static_cast(g_hyle_node->height()) : 0); + return 1; +} + +int lua_ces_hyle_chain_id(lua_State* L) { + lua_pushlstring(L, g_hyle_chain.data(), g_hyle_chain.size()); + return 1; +} + +int lua_ces_hyle_self(lua_State* L) { + const hyle::KeyPair kp = hyle_active_key(); + lua_pushlstring(L, reinterpret_cast(kp.pub.data()), kp.pub.size()); + return 1; +} + +// The validator's 32-byte private key. This IS absolute chain authority (mint, sudo), so a +// program must only ever hand it to a caller it has authenticated as the owner. A caller +// holding it can act as the node from cesh. +int lua_ces_hyle_self_secret(lua_State* L) { + const hyle::KeyPair kp = hyle_active_key(); + lua_pushlstring(L, reinterpret_cast(kp.priv.data()), kp.priv.size()); + return 1; +} + +// State exposes size()/canonical() but no iteration, so count a/e cells over the canonical KV dump. +// The walk lives in ceslib (countCanonicalKvKeyPrefix), unit-tested there. +static uint64_t hyle_canon_count(uint8_t prefix) { + if (!g_hyle_node) return 0; + auto canon = g_hyle_node->app().store().canonical(); + return ces::countCanonicalKvKeyPrefix(std::span(canon.data(), canon.size()), prefix); +} + +int lua_ces_hyle_info(lua_State* L) { + ++g_hyle_queries; + if (!g_hyle_node) { lua_pushnil(L); return 1; } + const hyle::services::App& app = g_hyle_node->app(); + + lua_newtable(L); + lua_pushlstring(L, g_hyle_chain.data(), g_hyle_chain.size()); + lua_setfield(L, -2, "chain_id"); + hyle_set_u64(L, "height", g_hyle_node->height()); + hyle_set_u64(L, "last_block_time", app.last_timestamp()); + hyle_set_u64(L, "accounts", hyle_canon_count(hyle::services::ACCOUNT_PREFIX)); + hyle_set_u64(L, "entries", hyle_canon_count(hyle::services::ENTRY_PREFIX)); + hyle_set_u64(L, "txs", g_hyle_txs); + hyle_set_u64(L, "queries", g_hyle_queries); + hyle_set_u64(L, "mempool", app.mempool().size()); + hyle_set_u64(L, "sudo_minted", app.sudo_minted()); + hyle_set_u64(L, "sudo_pending", hyle_canon_count(hyle::services::PENDING_PREFIX)); + const uint64_t nmembers = g_hyle_node->node().member_count(); + hyle_set_u64(L, "validators", nmembers); + hyle_set_u64(L, "quorum", (2 * nmembers) / 3 + 1); + hyle_set_u64(L, "balance", app.balance(g_hyle_key->pub)); + + const hyle::Hash h = g_hyle_node->node().composite_hash(); + hyle_set_bytes(L, "app_hash", h.data(), h.size()); + hyle_set_bytes(L, "self", g_hyle_key->pub.data(), g_hyle_key->pub.size()); + return 1; +} + +int lua_ces_hyle_config(lua_State* L) { + if (!hyle_need_node(L)) return 2; + ++g_hyle_queries; + const hyle::services::Config& c = g_hyle_node->app().config(); + lua_newtable(L); + hyle_set_u64(L, "fee_transfer", c.fee_transfer); + hyle_set_u64(L, "fee_entry", c.fee_entry); + hyle_set_u64(L, "fee_sudo", c.fee_sudo); + hyle_set_u64(L, "rent_rate", c.rent_rate); + hyle_set_u64(L, "rip_bounty", c.rip_bounty); + hyle_set_u64(L, "sudo_ttl_secs", c.sudo_ttl_secs); + hyle_set_u64(L, "pbts_window_secs", c.pbts_window_secs); + hyle_set_u64(L, "member_cap", c.member_cap); + hyle_set_u64(L, "member_floor", c.member_floor); + hyle_set_u64(L, "max_value_bytes", c.max_value_bytes); + return 1; +} + +int lua_ces_hyle_account(lua_State* L) { + if (!hyle_need_node(L)) return 2; + ++g_hyle_queries; + const hyle::PubKey pk = hyle_arg_key(L, 1); + const hyle::services::App& app = g_hyle_node->app(); + lua_newtable(L); + hyle_set_u64(L, "balance", app.balance(pk)); + hyle_set_u64(L, "sequence", app.sequence(pk)); + lua_pushboolean(L, app.account_exists(pk) ? 1 : 0); + lua_setfield(L, -2, "exists"); + return 1; +} + +int lua_ces_hyle_entry(lua_State* L) { + if (!hyle_need_node(L)) return 2; + ++g_hyle_queries; + size_t n = 0; + const char* s = luaL_checklstring(L, 1, &n); + const hyle::wire::View name(reinterpret_cast(s), n); + + hyle::services::Entry e; + if (!g_hyle_node->app().entry_info(name, e)) { lua_pushnil(L); return 1; } + + lua_newtable(L); + hyle_set_bytes(L, "owner", e.owner.data(), e.owner.size()); + hyle_set_u64(L, "balance", e.balance); + hyle_set_u64(L, "created", e.created); + hyle_set_u64(L, "last_modified", e.last_modified); + hyle_set_u64(L, "last_rent", e.last_rent); + hyle_set_bytes(L, "payload", e.payload.data(), e.payload.size()); + return 1; +} + +int lua_ces_hyle_tx_result(lua_State* L) { + if (!hyle_need_node(L)) return 2; + ++g_hyle_queries; + size_t n = 0; + const char* s = luaL_checklstring(L, 1, &n); + if (n != 32) luaL_error(L, "expected a 32-byte tx id, got %d bytes", static_cast(n)); + hyle::Hash id{}; + std::memcpy(id.data(), s, 32); + + hyle::services::TxResult r; + if (!g_hyle_node->app().tx_result(id, r)) { lua_pushnil(L); return 1; } + lua_newtable(L); + hyle_set_u64(L, "height", r.height); + lua_pushboolean(L, r.applied ? 1 : 0); + lua_setfield(L, -2, "applied"); + return 1; +} + +int lua_ces_hyle_validators(lua_State* L) { + if (!hyle_need_node(L)) return 2; + ++g_hyle_queries; + const auto set = g_hyle_node->node().validators_for(g_hyle_node->height() + 1); + lua_newtable(L); + int i = 1; + for (const auto& v : set) { + lua_pushlstring(L, reinterpret_cast(v.public_key.data()), v.public_key.size()); + lua_rawseti(L, -2, i++); + } + return 1; +} + +// --------------------------------------------------------------------------- +// submit: admit already-signed ops. CES does not interpret them; each carries its own signature. +// --------------------------------------------------------------------------- + +int lua_ces_hyle_submit(lua_State* L) { + if (!hyle_need_node(L)) return 2; + size_t n = 0; + const char* s = luaL_checklstring(L, 1, &n); + + hyle::services::Decoded d; + try { + d = hyle::services::decode_ops(hyle::wire::View(reinterpret_cast(s), n)); + } catch (const hyle::wire::Error&) { + lua_pushnil(L); + lua_pushstring(L, "malformed op batch"); + return 2; + } + + // Admit each op and hand back its tx id, so the caller can poll tx_result. An op that + // fails admission stops the batch; the ones already admitted stay admitted. + const hyle::wire::View cv = hyle_chain_view(); + std::vector ids; + const char* fail = nullptr; + + for (const auto& o : d.transfers) { + const hyle::services::Admit a = g_hyle_node->submit(o); + if (a != hyle::services::Admit::Ok) { fail = hyle::services::admit_reason(a); break; } + ids.push_back(hyle::services::tx_id(cv, o)); + } + if (fail == nullptr) + for (const auto& o : d.entries) { + const hyle::services::Admit a = g_hyle_node->submit(o); + if (a != hyle::services::Admit::Ok) { fail = hyle::services::admit_reason(a); break; } + ids.push_back(hyle::services::tx_id(cv, o)); + } + if (fail == nullptr) + for (const auto& o : d.sudos) { + const hyle::services::Admit a = g_hyle_node->submit(o); + if (a != hyle::services::Admit::Ok) { fail = hyle::services::admit_reason(a); break; } + ids.push_back(hyle::services::tx_id(cv, o)); + } + + if (fail != nullptr) { + lua_pushnil(L); + lua_pushstring(L, fail); + return 2; + } + if (ids.empty()) { + lua_pushnil(L); + lua_pushstring(L, "no ops"); + return 2; + } + + lua_newtable(L); + int i = 1; + for (const auto& id : ids) { + lua_pushlstring(L, reinterpret_cast(id.data()), id.size()); + lua_rawseti(L, -2, i++); + } + return 1; +} + +// --------------------------------------------------------------------------- +// op builders: the program signing AS ITSELF. Its CES program key IS its chain account, +// so these need no key argument. A user's op is signed by the user's own client and +// arrives here as opaque bytes for submit(); the program never holds a user's key. +// --------------------------------------------------------------------------- + +// Default seq is the committed sequence. Pipelining several ops before the first commits +// needs explicit, consecutive seq values. +static uint64_t hyle_self_seq(lua_State* L, const hyle::PubKey& self) { + lua_getfield(L, 1, "seq"); + const bool given = lua_isnumber(L, -1) != 0; + const uint64_t v = given ? static_cast(lua_tonumber(L, -1)) : 0; + lua_pop(L, 1); + return given ? v : g_hyle_node->app().sequence(self); +} + +static int hyle_push_transfer(lua_State* L, const hyle::wire::Bytes& to, uint64_t amount, + bool max) { + const hyle::KeyPair kp = hyle_active_key(); + const uint64_t seq = hyle_self_seq(L, kp.pub); + hyle::services::Decoded d; + d.transfers.push_back( + hyle::services::make_transfer(kp, hyle_view(to), amount, seq, hyle_chain_view(), max)); + hyle_push_bytes(L, hyle::services::encode_ops(d)); + return 1; +} + +int lua_ces_hyle_op_transfer(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (!hyle_need_node(L)) return 2; + lua_getfield(L, 1, "max"); + const bool max = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + return hyle_push_transfer(L, hyle_account_dest(hyle_fld_key_req(L, "to")), + hyle_fld_u64(L, "amount", 0), max); +} + +// Fund a K,V mapping: a transfer whose destination is the entry itself. Anyone may do it. +int lua_ces_hyle_op_fund(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (!hyle_need_node(L)) return 2; + return hyle_push_transfer(L, hyle_entry_dest(hyle_fld_bytes(L, "name")), + hyle_fld_u64(L, "amount", 0), /*max=*/false); +} + +static int hyle_push_entry(lua_State* L, const hyle::services::EntryOp& op) { + hyle::services::Decoded d; + d.entries.push_back(op); + hyle_push_bytes(L, hyle::services::encode_ops(d)); + return 1; +} + +// Publish or update: first Put owns the name, and only the owner may Put again. +int lua_ces_hyle_op_entry_put(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (!hyle_need_node(L)) return 2; + const hyle::KeyPair kp = hyle_active_key(); + const hyle::wire::Bytes name = hyle_fld_bytes(L, "name"); + const hyle::wire::Bytes payload = hyle_fld_bytes(L, "payload"); + return hyle_push_entry( + L, hyle::services::make_entry_put(kp, hyle_view(name), hyle_self_seq(L, kp.pub), + hyle_fld_u64(L, "fund", 0), hyle_view(payload), + hyle_chain_view())); +} + +int lua_ces_hyle_op_entry_del(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (!hyle_need_node(L)) return 2; + const hyle::KeyPair kp = hyle_active_key(); + const hyle::wire::Bytes name = hyle_fld_bytes(L, "name"); + return hyle_push_entry(L, hyle::services::make_entry_del(kp, hyle_view(name), + hyle_self_seq(L, kp.pub), + hyle_chain_view())); +} + +int lua_ces_hyle_op_entry_give(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (!hyle_need_node(L)) return 2; + const hyle::KeyPair kp = hyle_active_key(); + const hyle::wire::Bytes name = hyle_fld_bytes(L, "name"); + return hyle_push_entry( + L, hyle::services::make_entry_give(kp, hyle_view(name), hyle_self_seq(L, kp.pub), + hyle_fld_key_req(L, "to"), hyle_chain_view())); +} + +// The cull. Unsigned by construction: anyone may reap an entry whose rent ran out, and +// collects rip_bounty for doing it. +int lua_ces_hyle_op_entry_rip(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (!hyle_need_node(L)) return 2; + const hyle::wire::Bytes name = hyle_fld_bytes(L, "name"); + hyle::PubKey culler = hyle_fld_key(L, "culler"); + if (hyle::services::is_mint_sentinel(culler)) culler = hyle_active_key().pub; + return hyle_push_entry(L, hyle::services::make_entry_rip(hyle_view(name), culler)); +} + +// --------------------------------------------------------------------------- +// sudo: run one act with its own guards waived, on a supermajority's authority. +// +// The act is opaque `inner` bytes (an op batch of transfers/entries; only the mint sentinel +// creates credit, every other act must find the money it moves). Approval is collected ON +// CHAIN: propose() opens the proposal carrying the program's own vote, approve() adds more, +// and the vote that reaches quorum executes the act. A one-validator chain is its own +// supermajority, so its propose() reaches quorum at once. propose/approve are ordinary signed +// ops from the program, so they ride submit() into a block; on solo, tick to commit. +// --------------------------------------------------------------------------- + +static int hyle_push_inner(lua_State* L, const hyle::services::Decoded& d) { + hyle_push_bytes(L, hyle::services::encode_ops(d)); + return 1; +} + +// A sudo transfer with no source: mints from the mint sentinel. +int lua_ces_hyle_sudo_op_mint(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + hyle::services::Decoded d; + hyle::services::TransferOp t; + t.from = hyle::services::MINT_SENTINEL; + t.to = hyle_account_dest(hyle_fld_key_req(L, "to")); + t.amount = hyle_fld_u64(L, "amount", 0); + d.transfers.push_back(t); + return hyle_push_inner(L, d); +} + +int lua_ces_hyle_sudo_op_seize(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + hyle::services::Decoded d; + hyle::services::TransferOp t; + t.from = hyle_fld_key_req(L, "from"); + t.to = hyle_account_dest(hyle_fld_key_req(L, "to")); + t.amount = hyle_fld_u64(L, "amount", 0); + d.transfers.push_back(t); + return hyle_push_inner(L, d); +} + +static int hyle_sudo_entry(lua_State* L, hyle::services::EntryKind kind) { + luaL_checktype(L, 1, LUA_TTABLE); + hyle::services::Decoded d; + hyle::services::EntryOp e; + e.kind = kind; + e.signer = hyle_fld_key(L, "owner"); // who the act makes the owner + const hyle::wire::Bytes name = hyle_fld_bytes(L, "name"); + e.name = name; + e.amount = hyle_fld_u64(L, "fund", 0); + e.aux = hyle_fld_key(L, "to"); + e.payload = hyle_fld_bytes(L, "payload"); + d.entries.push_back(e); + return hyle_push_inner(L, d); +} + +int lua_ces_hyle_sudo_op_entry_put(lua_State* L) { + return hyle_sudo_entry(L, hyle::services::EntryKind::Put); +} +int lua_ces_hyle_sudo_op_entry_give(lua_State* L) { + return hyle_sudo_entry(L, hyle::services::EntryKind::Give); +} +int lua_ces_hyle_sudo_op_entry_del(lua_State* L) { + return hyle_sudo_entry(L, hyle::services::EntryKind::Del); +} +int lua_ces_hyle_sudo_op_entry_rip(lua_State* L) { + return hyle_sudo_entry(L, hyle::services::EntryKind::Rip); +} + +// The current validator set and 2/3+1 of it, as the app weighs a proposal's votes. +static std::vector hyle_members() { + std::vector out; + if (!g_hyle_node) return out; + for (const auto& v : g_hyle_node->node().validators_for(g_hyle_node->height() + 1)) { + hyle::PubKey k{}; + std::memcpy(k.data(), v.public_key.data(), v.public_key.size()); + out.push_back(k); + } + return out; +} +static unsigned hyle_quorum() { + return static_cast((2 * hyle_members().size()) / 3 + 1); +} + +int lua_ces_hyle_sudo_quorum(lua_State* L) { + if (!hyle_need_node(L)) return 2; + lua_pushnumber(L, static_cast(hyle_quorum())); + return 1; +} + +// Open a proposal for an act, carrying the program's own vote. On a one-validator chain that +// is quorum, so the next committed block executes it. -> true | nil,reason +int lua_ces_hyle_sudo_propose(lua_State* L) { + if (!hyle_need_node(L)) return 2; + size_t n = 0; + const char* s = luaL_checklstring(L, 1, &n); + const hyle::wire::View inner(reinterpret_cast(s), n); + const hyle::KeyPair kp = hyle_active_key(); + const uint64_t seq = g_hyle_node->app().sequence(kp.pub); + const hyle::services::Admit a = + g_hyle_node->submit(hyle::services::make_sudo_propose(kp, seq, inner, hyle_chain_view())); + if (a != hyle::services::Admit::Ok) { + lua_pushnil(L); + lua_pushstring(L, hyle::services::admit_reason(a)); + return 2; + } + lua_pushboolean(L, 1); + return 1; +} + +// Add the program's approval to a member's open proposal. `inner` is the act being approved, +// exactly as proposed (its hash is what the vote commits to). -> true | nil,reason +int lua_ces_hyle_sudo_approve(lua_State* L) { + if (!hyle_need_node(L)) return 2; + const hyle::PubKey proposer = hyle_arg_key(L, 1); + size_t n = 0; + const char* s = luaL_checklstring(L, 2, &n); + const hyle::Hash inner_hash = + hyle::sha256(hyle::wire::View(reinterpret_cast(s), n)); + const hyle::KeyPair kp = hyle_active_key(); + const uint64_t seq = g_hyle_node->app().sequence(kp.pub); + const hyle::services::Admit a = g_hyle_node->submit( + hyle::services::make_sudo_approve(kp, seq, proposer, inner_hash, hyle_chain_view())); + if (a != hyle::services::Admit::Ok) { + lua_pushnil(L); + lua_pushstring(L, hyle::services::admit_reason(a)); + return 2; + } + lua_pushboolean(L, 1); + return 1; +} + +// Inspect a member's open proposal: the act, how many current members have approved, and how +// many are needed. -> {created, approvals, quorum, inner, inner_hash} | nil +int lua_ces_hyle_sudo_pending(lua_State* L) { + if (!hyle_need_node(L)) return 2; + const hyle::PubKey proposer = hyle_arg_key(L, 1); + hyle::services::Pending p; + if (!g_hyle_node->app().pending_info(proposer, p)) { lua_pushnil(L); return 1; } + const std::vector members = hyle_members(); + lua_newtable(L); + hyle_set_u64(L, "created", p.created); + hyle_set_u64(L, "approvals", g_hyle_node->app().pending_approvals(p, members)); + hyle_set_u64(L, "quorum", static_cast((2 * members.size()) / 3 + 1)); + hyle_set_bytes(L, "inner", p.inner.data(), p.inner.size()); + const hyle::Hash ih = hyle::sha256(hyle::wire::View(p.inner.data(), p.inner.size())); + hyle_set_bytes(L, "inner_hash", ih.data(), ih.size()); + return 1; +} + +// submit(inner) is propose(inner): the one-call path a solo chain uses -- any caller that just +// wants to run an act on a chain it is the sole quorum of. +int lua_ces_hyle_sudo_submit(lua_State* L) { + return lua_ces_hyle_sudo_propose(L); +} + +// --------------------------------------------------------------------------- +// Membership governance. Votes accumulate on-chain until a supermajority agrees; the +// change then takes effect two heights later. A one-validator chain settles at once. +// --------------------------------------------------------------------------- + +static int hyle_vote(lua_State* L, hyle::consensus::Governance::Kind kind) { + if (!hyle_need_node(L)) return 2; + const hyle::PubKey target = hyle_arg_key(L, 1); + g_hyle_node->node().submit_gov_vote(kind, target); + lua_pushboolean(L, 1); + return 1; +} + +int lua_ces_hyle_vote_add(lua_State* L) { + return hyle_vote(L, hyle::consensus::Governance::Kind::Add); +} +int lua_ces_hyle_vote_remove(lua_State* L) { + return hyle_vote(L, hyle::consensus::Governance::Kind::Remove); +} + +// --------------------------------------------------------------------------- + +static void hyle_fn(lua_State* L, const char* name, lua_CFunction fn) { + lua_pushcfunction(L, fn); + lua_setfield(L, -2, name); +} + +// Install the ces.hyle table. Call with the ces table on top of the stack. +void install_ces_hyle_api(lua_State* L) { + lua_newtable(L); // ces.hyle + + hyle_fn(L, "running", guarded); + hyle_fn(L, "height", guarded); + hyle_fn(L, "chain_id", guarded); + hyle_fn(L, "self", guarded); + hyle_fn(L, "self_secret", guarded); + hyle_fn(L, "info", guarded); + hyle_fn(L, "config", guarded); + hyle_fn(L, "account", guarded); + hyle_fn(L, "entry", guarded); + hyle_fn(L, "tx_result", guarded); + hyle_fn(L, "validators", guarded); + hyle_fn(L, "submit", guarded); + hyle_fn(L, "vote_add", guarded); + hyle_fn(L, "vote_remove", guarded); + + lua_newtable(L); // ces.hyle.op + hyle_fn(L, "transfer", guarded); + hyle_fn(L, "fund", guarded); + hyle_fn(L, "entry_put", guarded); + hyle_fn(L, "entry_del", guarded); + hyle_fn(L, "entry_give", guarded); + hyle_fn(L, "entry_rip", guarded); + lua_setfield(L, -2, "op"); + + lua_newtable(L); // ces.hyle.sudo + hyle_fn(L, "propose", guarded); + hyle_fn(L, "approve", guarded); + hyle_fn(L, "pending", guarded); + hyle_fn(L, "submit", guarded); + hyle_fn(L, "quorum", guarded); + lua_newtable(L); // ces.hyle.sudo.op + hyle_fn(L, "mint", guarded); + hyle_fn(L, "seize", guarded); + hyle_fn(L, "entry_put", guarded); + hyle_fn(L, "entry_give", guarded); + hyle_fn(L, "entry_del", guarded); + hyle_fn(L, "entry_rip", guarded); + lua_setfield(L, -2, "op"); + lua_setfield(L, -2, "sudo"); + + lua_newtable(L); // ces.hyle.solo + hyle_fn(L, "start", guarded); + hyle_fn(L, "tick", guarded); + hyle_fn(L, "stop", guarded); + lua_setfield(L, -2, "solo"); + + lua_newtable(L); // ces.hyle.net + hyle_fn(L, "start", guarded); + hyle_fn(L, "tick", guarded); + hyle_fn(L, "stop", guarded); + lua_setfield(L, -2, "net"); + + lua_setfield(L, -2, "hyle"); // ces.hyle = +} + +#endif // CES_HYLE diff --git a/vendor/ces/src/cesluajitd/api_mene.inc b/vendor/ces/src/cesluajitd/api_mene.inc new file mode 100644 index 0000000..d38bf34 --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_mene.inc @@ -0,0 +1,232 @@ +// --------------------------------------------------------------------------- +// mene — the declarative admin-UI library (global `mene`), plus the panel +// frame helpers the extension-admin contract uses. +// +// The library comes embedded in mene::assets (linked; see ) +// and is installed into every program VM as the global `mene`. It is pure Lua +// over the sandbox's computation surface (base/string/table/math); its modules +// require() each other, and the sandbox has no require, so installation +// resolves modules from the embedded sources through a loader that exists only +// during this call — program code never sees a require global. +// +// The browser half comes from the same linked mene::assets (served by +// webadmin), so the wire contract between the Lua encoder here and the JS +// renderer there cannot skew across a server binary. +// --------------------------------------------------------------------------- +// is included by main.cpp at file scope: this .inc is included +// inside namespace cesluajitd, where a header include would re-home ::mene. + +const char* mene_module_src(const char* name) { + std::size_t count = 0; + const ::mene::LuaModule* mods = ::mene::luaModules(count); + for (std::size_t i = 0; i < count; i++) { + if (std::strcmp(mods[i].name, name) == 0) return mods[i].src; + } + return nullptr; +} + +// require(name) over the embedded sources; upvalue 1 is the module cache. +int lua_mene_require(lua_State* L) { + const char* name = luaL_checkstring(L, 1); + lua_getfield(L, lua_upvalueindex(1), name); + if (!lua_isnil(L, -1)) return 1; + lua_pop(L, 1); + const char* src = mene_module_src(name); + if (!src) return luaL_error(L, "module '%s' not found", name); + std::string chunkname = std::string("=") + name; + if (luaL_loadbufferx(L, src, std::strlen(src), chunkname.c_str(), "t") != 0) + return lua_error(L); + lua_call(L, 0, 1); // module chunk returns its table + lua_pushvalue(L, -1); + lua_setfield(L, lua_upvalueindex(1), name); + return 1; +} + +// Install the global `mene`. Called once at VM setup, before any program code. +void install_mene_lib(lua_State* L) { + lua_newtable(L); // module cache (loader upvalue) + lua_pushcclosure(L, lua_mene_require, 1); + lua_pushvalue(L, -1); + lua_setglobal(L, "require"); // inter-module requires resolve here + lua_pushstring(L, "mene"); + if (lua_pcall(L, 1, 1, 0) != 0) { + host_log(4, std::string("mene install failed: ") + lua_err_str(L)); + lua_pop(L, 1); + } else { + lua_setglobal(L, "mene"); + } + lua_pushnil(L); + lua_setglobal(L, "require"); // loader gone again +} + +// ---- panel frame helpers (used by the extension-admin contract) ----------- + +// Minimal JSON string escape for toast frames built on the C++ side. +std::string mene_json_escape(const std::string& s) { + std::string out; + out.reserve(s.size() + 2); + out.push_back('"'); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out.push_back(static_cast(c)); + } + } + } + out.push_back('"'); + return out; +} + +// {"type":"toast","level":"error","text":...} — the panel error shape. Errors +// travel as frames (the browser shows them), never as transport failures. +std::string mene_toast_frame(const std::string& msg) { + return std::string("{\"type\":\"toast\",\"level\":\"error\",\"text\":") + + mene_json_escape(msg) + "}"; +} + +// Only wire frames may leave the VM: the host splices these strings verbatim +// into its own envelope JSON, so an arbitrary program string could otherwise +// spoof other message types (JSON duplicate-key semantics). mene only ever +// produces these two shapes. +bool mene_is_wire_frame(const char* p, size_t n) { + static const char kRender[] = "{\"type\":\"render\""; + static const char kToast[] = "{\"type\":\"toast\""; + return (n >= sizeof(kRender) - 1 && + std::memcmp(p, kRender, sizeof(kRender) - 1) == 0) || + (n >= sizeof(kToast) - 1 && + std::memcmp(p, kToast, sizeof(kToast) - 1) == 0); +} + +// Call app:render_frame() on the registry-pinned panel app; returns a render +// frame, or a toast frame on any Lua error. Balanced stack. +std::string mene_panel_render(lua_State* L, int panelRef) { + int base = lua_gettop(L); + lua_rawgeti(L, LUA_REGISTRYINDEX, panelRef); + lua_getfield(L, -1, "render_frame"); + if (!lua_isfunction(L, -1)) { + lua_settop(L, base); + return mene_toast_frame("panel has no render_frame (not a mene.app?)"); + } + lua_pushvalue(L, -2); // self + if (lua_pcall(L, 1, 1, 0) != 0) { + std::string err = lua_err_str(L); + lua_settop(L, base); + return mene_toast_frame(err); + } + size_t n = 0; + const char* p = lua_tolstring(L, -1, &n); + // Same wire-frame gate as the push path: the reply is spliced into host + // JSON, and a program may have replaced render_frame with its own. + std::string frame = (p && mene_is_wire_frame(p, n)) + ? std::string(p, n) + : mene_toast_frame("panel render did not produce a wire frame"); + lua_settop(L, base); + return frame; +} + +// ---- live push (webadmin WebSocket lane) ---------------------------------- +// Two producers, one wire tag (TAG_EXT_UI_PUSH): +// * the mene push bridge: __mene_host_push, installed when a panel registers. +// mene's App:dispatch auto-pushes through it, and an extension may call +// app:push() for immediacy. Fires the instant state changes. +// * the change-detect tick: while the host says someone is watching +// (TAG_EXT_UI_WATCH), render every ~500ms and push ONLY when the frame's +// hash changed — spontaneous updates (ces.every mutations) reach the +// browser with no polling anywhere and no extension code. +// Both producers update the same last-pushed hash, so neither re-sends what +// the other already delivered. + +bool g_ext_ui_watch = false; +uint64_t g_ext_ui_last_hash = 0; +uint64_t g_ext_ui_next_check_us = 0; +constexpr uint64_t kExtUiCheckIntervalUs = 500 * 1000; +constexpr size_t kExtUiMaxFrame = 1 << 20; // sanity cap on a pushed frame + +uint64_t mene_frame_hash(const char* p, size_t n) { + Sha256Ctx c; + sha256_init(c); + sha256_update(c, reinterpret_cast(p), n); + uint8_t d[32]; + sha256_final(c, d); + uint64_t h = 0; + for (int i = 0; i < 8; i++) h = (h << 8) | d[i]; + return h; +} + +void mene_ui_push_frame(const char* p, size_t n) { + if (n == 0 || n > kExtUiMaxFrame) return; + if (!mene_is_wire_frame(p, n)) return; + g_ext_ui_last_hash = mene_frame_hash(p, n); + write_frame(TAG_EXT_UI_PUSH, 0, reinterpret_cast(p), n); +} + +// __mene_host_push(frame): the mene push bridge target. The mene app always +// hands a complete frame string (render or toast). +int lua_mene_host_push(lua_State* L) { + size_t n = 0; + const char* p = lua_tolstring(L, 1, &n); + if (p) mene_ui_push_frame(p, n); + return 0; +} + +// The change-detect tick, called from the run loop. Cheap when idle: gated on +// watch + a 500ms deadline before any Lua runs. +void mene_ui_tick(lua_State* L, int panelRef) { + if (!g_ext_ui_watch || panelRef == LUA_NOREF) return; + uint64_t now = timer_now_us(); + if (now < g_ext_ui_next_check_us) return; + g_ext_ui_next_check_us = now + kExtUiCheckIntervalUs; + std::string frame = mene_panel_render(L, panelRef); + if (frame.empty() || frame.size() > kExtUiMaxFrame) return; + uint64_t h = mene_frame_hash(frame.data(), frame.size()); + if (h == g_ext_ui_last_hash) return; + g_ext_ui_last_hash = h; + write_frame(TAG_EXT_UI_PUSH, 0, + reinterpret_cast(frame.data()), frame.size()); +} + +// Decode an event JSON and run app:dispatch(event), then render. Any Lua error +// (malformed event, update/view throw) becomes a toast frame. Balanced stack. +std::string mene_panel_event(lua_State* L, int panelRef, + const uint8_t* ev, size_t evLen) { + int base = lua_gettop(L); + // mene.decode_event(evJson) -> {on, value} + lua_getglobal(L, "mene"); + if (!lua_istable(L, -1)) { + lua_settop(L, base); + return mene_toast_frame("mene library not installed"); + } + lua_getfield(L, -1, "decode_event"); + lua_pushlstring(L, reinterpret_cast(ev), evLen); + if (lua_pcall(L, 1, 1, 0) != 0) { + std::string err = lua_err_str(L); + lua_settop(L, base); + return mene_toast_frame(err); + } + // app:dispatch(event) + lua_rawgeti(L, LUA_REGISTRYINDEX, panelRef); + lua_getfield(L, -1, "dispatch"); + if (!lua_isfunction(L, -1)) { + lua_settop(L, base); + return mene_toast_frame("panel has no dispatch (not a mene.app?)"); + } + lua_pushvalue(L, -2); // self + lua_pushvalue(L, -4); // event table + if (lua_pcall(L, 2, 1, 0) != 0) { + std::string err = lua_err_str(L); + lua_settop(L, base); + return mene_toast_frame(err); + } + lua_settop(L, base); + return mene_panel_render(L, panelRef); +} diff --git a/vendor/ces/src/cesluajitd/api_store.inc b/vendor/ces/src/cesluajitd/api_store.inc new file mode 100644 index 0000000..0c7c5d4 --- /dev/null +++ b/vendor/ces/src/cesluajitd/api_store.inc @@ -0,0 +1,272 @@ +// --------------------------------------------------------------------------- +// ces.store: persistent key-value store (a logkv-backed kv-file). +// --------------------------------------------------------------------------- +// +// ces.store(path) returns a handle bound to a kv-file path; its methods +// (:create / :put / :get / :erase / :keys / :deposit) ride the same in-process +// file path as ces.file_*, with kv verbs. Each key is a self-renting cell: a +// deposit funds the key's rent balance, the daily rent sweep charges and evicts +// it at balance 0, and a read returns only the stored bytes. The handle holds +// no host-side state, so a fresh handle for the same path sees the same data. + +// Pull the bound path out of the handle table at stack slot 1 (the `self` in +// db:method(...)). Returns false (handle left on stack cleaned) on a bad table. +bool store_path_arg(lua_State* L, std::string& out) { + if (lua_type(L, 1) != LUA_TTABLE) return false; + lua_getfield(L, 1, "path"); + size_t n = 0; + const char* p = lua_tolstring(L, -1, &n); + if (!p || n == 0) { lua_pop(L, 1); return false; } + out.assign(p, n); + lua_pop(L, 1); + return true; +} + +// db:create(initial_deposit [, price_per_kb]) → true, file_balance | nil, err +int lua_ces_store_create(lua_State* L) { + std::string path; + if (!store_path_arg(L, path)) { + lua_pushnil(L); lua_pushstring(L, "bad store handle"); return 2; + } + lua_Number dep_n = luaL_optnumber(L, 2, 0); + lua_Number ppk_n = luaL_optnumber(L, 3, 0); + if (dep_n < 0 || ppk_n < 0) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_u64(args, uint64_t(ppk_n)); + put_u64(args, uint64_t(dep_n)); + put_name(args, path.data(), path.size()); + return io_call(L, METHOD_KV_CREATE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + return 2; + }); +} + +// db:put(key, value [, deposit]) → true, cell_balance, new_size | nil, err +// `deposit` seeds/tops the key's rent balance (credits keeping it alive). A +// key with balance 0 is evicted on the next daily rent sweep, so a record you +// want to persist must be funded (here or via db:deposit). +int lua_ces_store_put(lua_State* L) { + std::string path; + if (!store_path_arg(L, path)) { + lua_pushnil(L); lua_pushstring(L, "bad store handle"); return 2; + } + size_t klen = 0, vlen = 0; + const char* key = luaL_checklstring(L, 2, &klen); + const char* val = luaL_checklstring(L, 3, &vlen); + lua_Number dep_n = luaL_optnumber(L, 4, 0); + if (klen == 0 || klen > 256 || vlen == 0 || vlen > (1024 * 1024) || dep_n < 0) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_u64(args, uint64_t(dep_n)); // [u64 deposit] + put_name(args, key, klen); // [u16 keylen][key] + put_u32(args, uint32_t(vlen)); // [u32 vallen][value] + put_bytes(args, val, vlen); + put_name(args, path.data(), path.size()); // [u16 namelen][name] + return io_call(L, METHOD_KV_PUT, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 17) return push_file_err(L, STATUS_INTERNAL); + uint64_t fb = get_u64(reply.body.data() + 1); + uint64_t sz = get_u64(reply.body.data() + 9); + lua_pushboolean(L, 1); + lua_pushnumber(L, double(fb)); + lua_pushnumber(L, double(sz)); + return 3; + }); +} + +// db:get(key) → value | nil [, err] +// Missing key returns a single nil (no error). A real error returns +// (nil, err_code), so a caller distinguishes the two by the 2nd value. +int lua_ces_store_get(lua_State* L) { + std::string path; + if (!store_path_arg(L, path)) { + lua_pushnil(L); lua_pushstring(L, "bad store handle"); return 2; + } + size_t klen = 0; + const char* key = luaL_checklstring(L, 2, &klen); + if (klen == 0 || klen > 256) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_name(args, key, klen); + put_name(args, path.data(), path.size()); + return io_call(L, METHOD_KV_GET, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + // Tail: [u8 found][u32 vallen][value] + if (reply.body.size() < 1 + 1 + sizeof(uint32_t)) + return push_file_err(L, STATUS_INTERNAL); + uint8_t found = reply.body[1]; + if (!found) { lua_pushnil(L); return 1; } + uint32_t vlen = get_u32(reply.body.data() + 2); + if (reply.body.size() < 6 + size_t(vlen)) + return push_file_err(L, STATUS_INTERNAL); + lua_pushlstring(L, reinterpret_cast(reply.body.data() + 6), vlen); + return 1; + }); +} + +// db:erase(key) → true | nil, err (erasing a missing key still returns true) +int lua_ces_store_erase(lua_State* L) { + std::string path; + if (!store_path_arg(L, path)) { + lua_pushnil(L); lua_pushstring(L, "bad store handle"); return 2; + } + size_t klen = 0; + const char* key = luaL_checklstring(L, 2, &klen); + if (klen == 0 || klen > 256) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_name(args, key, klen); + put_name(args, path.data(), path.size()); + return io_call(L, METHOD_KV_ERASE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + lua_pushboolean(L, 1); + return 1; + }); +} + +// db:keys() → { key, key, ... } | nil, err +// The array of every key currently stored. (Values are fetched per-key with +// :get — the iterate op carries keys only, which is what set reconciliation +// needs.) +int lua_ces_store_keys(lua_State* L) { + std::string path; + if (!store_path_arg(L, path)) { + lua_pushnil(L); lua_pushstring(L, "bad store handle"); return 2; + } + std::vector args; + put_name(args, path.data(), path.size()); + return io_call(L, METHOD_KV_ITER, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 1 + sizeof(uint32_t)) + return push_file_err(L, STATUS_INTERNAL); + uint32_t cnt = get_u32(reply.body.data() + 1); + const uint8_t* p = reply.body.data() + 5; + const uint8_t* end = reply.body.data() + reply.body.size(); + lua_newtable(L); + for (uint32_t i = 0; i < cnt; ++i) { + if (p + 2 > end) return push_file_err(L, STATUS_INTERNAL); + uint16_t kl = get_u16(p); p += 2; + if (p + kl > end) return push_file_err(L, STATUS_INTERNAL); + lua_pushlstring(L, reinterpret_cast(p), kl); p += kl; + lua_rawseti(L, -2, int(i) + 1); + } + return 1; + }); +} + +// db:range(lo, hi [, max_bytes]) → entries, effective_hi | nil, err +// Ordered scan of [lo, hi): entries is an array of { key=, value= } sorted +// ascending by key. lo/hi may be "" for start/end of store. If effective_hi +// == hi the whole range was delivered; otherwise effective_hi is the next +// undelivered key and the caller continues with db:range(effective_hi, hi). +// Bounded by max_bytes (clamped server-side); always returns at least one +// in-range entry so a scan cannot stall. +int lua_ces_store_range(lua_State* L) { + std::string path; + if (!store_path_arg(L, path)) { + lua_pushnil(L); lua_pushstring(L, "bad store handle"); return 2; + } + size_t lolen = 0, hilen = 0; + const char* lo = luaL_optlstring(L, 2, "", &lolen); + const char* hi = luaL_optlstring(L, 3, "", &hilen); + lua_Number max_n = luaL_optnumber(L, 4, 0); + if (lolen > 256 || hilen > 256 || max_n < 0) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_u16(args, static_cast(lolen)); put_bytes(args, lo, lolen); + put_u16(args, static_cast(hilen)); put_bytes(args, hi, hilen); + put_u64(args, uint64_t(max_n)); + put_name(args, path.data(), path.size()); + return io_call(L, METHOD_KV_RANGE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + const uint8_t* p = reply.body.data() + 1; + const uint8_t* end = reply.body.data() + reply.body.size(); + // [u16 endLen][end][u32 count]( [u16 klen][key][u32 vlen][value] )* + if (p + 2 > end) return push_file_err(L, STATUS_INTERNAL); + uint16_t endLen = get_u16(p); p += 2; + if (p + endLen > end) return push_file_err(L, STATUS_INTERNAL); + const uint8_t* effHi = p; p += endLen; + if (p + 4 > end) return push_file_err(L, STATUS_INTERNAL); + uint32_t cnt = get_u32(p); p += 4; + lua_newtable(L); // entries + for (uint32_t i = 0; i < cnt; ++i) { + if (p + 2 > end) return push_file_err(L, STATUS_INTERNAL); + uint16_t kl = get_u16(p); p += 2; + if (p + kl + 4 > end) return push_file_err(L, STATUS_INTERNAL); + const uint8_t* k = p; p += kl; + uint32_t vl = get_u32(p); p += 4; + if (p + vl > end) return push_file_err(L, STATUS_INTERNAL); + const uint8_t* v = p; p += vl; + lua_newtable(L); + lua_pushlstring(L, reinterpret_cast(k), kl); lua_setfield(L, -2, "key"); + lua_pushlstring(L, reinterpret_cast(v), vl); lua_setfield(L, -2, "value"); + lua_rawseti(L, -2, int(i) + 1); + } + lua_pushlstring(L, reinterpret_cast(effHi), endLen); // effective_hi + return 2; + }); +} + +// db:deposit(key, amount) → cell_balance | nil, err +// Add `amount` to an existing key's rent balance: the funding that keeps the +// record alive against the rent sweep. Any caller may fund any key. +int lua_ces_store_deposit(lua_State* L) { + std::string path; + if (!store_path_arg(L, path)) { + lua_pushnil(L); lua_pushstring(L, "bad store handle"); return 2; + } + size_t klen = 0; + const char* key = luaL_checklstring(L, 2, &klen); + lua_Number amt_n = luaL_checknumber(L, 3); + if (klen == 0 || klen > 256 || amt_n <= 0) { + lua_pushnil(L); lua_pushstring(L, "bad args"); return 2; + } + std::vector args; + put_u64(args, uint64_t(amt_n)); // [u64 amount] + put_name(args, key, klen); // [u16 keylen][key] + put_name(args, path.data(), path.size()); // [u16 namelen][name] + return io_call(L, METHOD_KV_DEPOSIT, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < 9) return push_file_err(L, STATUS_INTERNAL); + uint64_t bal = get_u64(reply.body.data() + 1); + lua_pushnumber(L, double(bal)); + return 1; + }); +} + +// ces.store(path) → handle { path, create, put, get, erase, keys, range, deposit } +int lua_ces_store(lua_State* L) { + size_t plen = 0; + const char* path = luaL_checklstring(L, 1, &plen); + if (plen == 0 || plen > 1024) { + lua_pushnil(L); lua_pushstring(L, "bad path"); return 2; + } + lua_newtable(L); + lua_pushlstring(L, path, plen); lua_setfield(L, -2, "path"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "create"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "put"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "get"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "erase"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "keys"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "range"); + lua_pushcfunction(L, guarded); lua_setfield(L, -2, "deposit"); + return 1; +} + diff --git a/vendor/ces/src/cesluajitd/conn_direct.inc b/vendor/ces/src/cesluajitd/conn_direct.inc new file mode 100644 index 0000000..5779bd2 --- /dev/null +++ b/vendor/ces/src/cesluajitd/conn_direct.inc @@ -0,0 +1,335 @@ +// --------------------------------------------------------------------------- +// Direct transport (/ces/luarpc/1) plumbing for the unified ces.conn API: the +// conn write/close ops, the bridge-event dispatcher, and outbound connect(). +// These feed the SAME listener + run loop as the relay; the only program- +// visible mark of the transport is conn.source == 1. conn write/close hop to +// the endpoint strand; conn.pubkey is the authenticated pubkey of the peer. +// --------------------------------------------------------------------------- +constexpr const char* kRegLuaRpcConns = "ces.conn.direct.live"; // [native] = conn + +// conn:write(bytes) → true | false (false if the conn is already closed). +int lua_ces_luarpc_conn_write(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + size_t n = 0; + const char* s = luaL_checklstring(L, 2, &n); + if (n > 1024 * 1024) { // parity with the relay conn:write cap + lua_pushnil(L); + lua_pushstring(L, "payload too large (max 1 MB per write)"); + return 2; + } + lua_getfield(L, 1, "closed"); + bool closed = lua_toboolean(L, -1); + lua_pop(L, 1); + if (closed) { lua_pushboolean(L, 0); return 1; } + lua_getfield(L, 1, "__sid"); + uint64_t id = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + luarpc_conn_write(id, std::string(s, n)); + lua_pushboolean(L, 1); + return 1; +} + +// conn:close() — graceful close. Idempotent. +int lua_ces_luarpc_conn_close(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__sid"); + uint64_t id = static_cast(lua_tonumber(L, -1)); + lua_pop(L, 1); + lua_pushboolean(L, 1); + lua_setfield(L, 1, "closed"); + // Drop it from the live registry now. The peer-close path does this in + // dispatch, but a program-initiated close sets closing=true on the endpoint + // side, which suppresses the Closed event — so without this the conn table + // would leak in kRegLuaRpcConns forever. + lua_getfield(L, LUA_REGISTRYINDEX, kRegLuaRpcConns); + if (lua_istable(L, -1)) { + lua_pushnumber(L, static_cast(id)); + lua_pushnil(L); + lua_rawset(L, -3); + } + lua_pop(L, 1); + luarpc_conn_close(id); + return 0; +} + +// Build a direct conn table {id=host uid, source=1, __sid=native, pubkey(32B), +// closed, write, close}, key the live table by the native id, and leave it on +// the stack top. Same shape as the relay builder; only source and the transport +// the write/close route to differ. +void make_luarpc_conn(lua_State* L, uint64_t id, const uint8_t* peer) { + lua_newtable(L); + lua_pushnumber(L, static_cast(g_conn_uid_next++)); + lua_setfield(L, -2, "id"); + lua_pushinteger(L, CONN_SOURCE_DIRECT); + lua_setfield(L, -2, "source"); + lua_pushnumber(L, static_cast(id)); // native routing id + lua_setfield(L, -2, "__sid"); + lua_pushlstring(L, reinterpret_cast(peer), 32); + lua_setfield(L, -2, "pubkey"); + lua_pushboolean(L, 0); + lua_setfield(L, -2, "closed"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "write"); + lua_pushcfunction(L, guarded); + lua_setfield(L, -2, "close"); + lua_getfield(L, LUA_REGISTRYINDEX, kRegLuaRpcConns); + if (lua_istable(L, -1)) { + lua_pushnumber(L, static_cast(id)); + lua_pushvalue(L, -3); // the conn table + lua_rawset(L, -3); + } + lua_pop(L, 1); // live table +} + +// Push live-conn[id] (or nil) onto the stack. +void push_luarpc_conn(lua_State* L, uint64_t id) { + lua_getfield(L, LUA_REGISTRYINDEX, kRegLuaRpcConns); + if (!lua_istable(L, -1)) { lua_pop(L, 1); lua_pushnil(L); return; } + lua_pushnumber(L, static_cast(id)); + lua_rawget(L, -2); + lua_remove(L, -2); // drop live table, leave conn-or-nil on top +} + +// Drop a direct conn from the live-conns registry (after on_close, or when a +// close event has no handler to run). +void remove_luarpc_conn(lua_State* L, uint64_t id) { + lua_getfield(L, LUA_REGISTRYINDEX, kRegLuaRpcConns); + if (lua_istable(L, -1)) { + lua_pushnumber(L, static_cast(id)); + lua_pushnil(L); + lua_rawset(L, -3); + } + lua_pop(L, 1); +} + +// Direct-transport twin of setup_handler_co: prepares coroutine `co` to run the +// listener callback for bridge event `e`. Same contract (pushes [cb, conn, +// (data)], returns arg count or -1). +int setup_luarpc_handler_co(lua_State* mainL, lua_State* co, + const LuaRpcEvent& e) { + lua_getfield(mainL, LUA_REGISTRYINDEX, kRegListenerTable); + if (!lua_istable(mainL, -1)) { lua_pop(mainL, 1); return -1; } + + if (e.type == LuaRpcEvent::Type::Opened) { + make_luarpc_conn(mainL, e.connId, e.peer.data()); // [listener, conn] + lua_getfield(mainL, -2, "on_open"); // [listener, conn, cb] + if (!lua_isfunction(mainL, -1)) { lua_pop(mainL, 3); return -1; } + lua_xmove(mainL, co, 1); // cb -> co + lua_xmove(mainL, co, 1); // conn -> co + lua_pop(mainL, 1); + return 1; + } + + const char* cbname = + (e.type == LuaRpcEvent::Type::Data) ? "on_data" : "on_close"; + lua_getfield(mainL, -1, cbname); // [listener, cb] + bool hasCb = lua_isfunction(mainL, -1); + push_luarpc_conn(mainL, e.connId); // [listener, cb, conn] + if (lua_isnil(mainL, -1)) { lua_pop(mainL, 3); return -1; } + if (e.type == LuaRpcEvent::Type::Closed) { + lua_pushboolean(mainL, 1); + lua_setfield(mainL, -2, "closed"); + } + if (!hasCb) { lua_pop(mainL, 3); return -1; } + lua_pushvalue(mainL, -2); // dup cb + lua_xmove(mainL, co, 1); // cb -> co + lua_xmove(mainL, co, 1); // conn -> co + lua_pop(mainL, 2); + if (e.type == LuaRpcEvent::Type::Data) { + lua_pushlstring(co, e.bytes.data(), e.bytes.size()); + return 2; + } + return 1; +} + +// Direct-transport twin of conn_advance. +static void luarpc_advance(lua_State* mainL, uint64_t id) { + auto it = g_luarpc_exec.find(id); + if (it == g_luarpc_exec.end()) return; + LuaRpcExec& ce = it->second; + if (ce.busy || ce.pending.empty()) return; + LuaRpcEvent e = std::move(ce.pending.front()); + ce.pending.pop_front(); + bool isClose = (e.type == LuaRpcEvent::Type::Closed); + + lua_State* co = lua_newthread(mainL); + int ref = luaL_ref(mainL, LUA_REGISTRYINDEX); + int nargs = setup_luarpc_handler_co(mainL, co, e); + if (nargs < 0) { + luaL_unref(mainL, LUA_REGISTRYINDEX, ref); + if (isClose) { remove_luarpc_conn(mainL, id); g_luarpc_exec.erase(id); return; } + g_luarpc_advance.push_back(id); + return; + } + g_coro_refs[co] = ref; + ce.busy = true; + g_handler_co[co] = HandlerCo{id, isClose, true}; + resume_coro(mainL, co, nargs); +} + +// Direct-transport twin of drain_conn_advance. +static void drain_luarpc_advance(lua_State* mainL) { + while (!g_luarpc_advance.empty()) { + uint64_t id = g_luarpc_advance.front(); + g_luarpc_advance.pop_front(); + luarpc_advance(mainL, id); + } +} + +// Resume coroutines whose ces.conn.connect finished (endpoint thread delivered +// the result). Conn construction touches Lua state, so it happens here on the +// run-loop thread, not on the endpoint thread. +static void drain_connect_done(lua_State* mainL) { + for (;;) { + ConnectDelivery d; + { + std::lock_guard lk(g_luarpc_mx); + if (g_connect_done.empty()) break; + d = std::move(g_connect_done.front()); + g_connect_done.pop_front(); + } + if (!g_parked.erase(d.co)) continue; // already resumed (future timeout) + int n; + if (d.res.status != ces::CES_OK) { + lua_checkstack(d.co, 2); + lua_pushnil(d.co); + lua_pushstring(d.co, d.res.status == ces::CES_ERROR_PROTO_REJECTED + ? "bind rejected or wrong peer" + : "connect failed (network)"); + n = 2; + } else { + lua_checkstack(d.co, 2); + make_luarpc_conn(d.co, d.res.connId, d.res.peer.data()); + n = 1; + } + resume_coro(mainL, d.co, n); + } +} + +// Resume coroutines whose offloaded blocking call (a worker-pool job) finished. +// The worker delivered a "deliver" closure that pushes the Lua results here, on +// the run-loop thread. +static void drain_offload_done(lua_State* mainL) { + for (;;) { + OffloadDone d; + { + std::lock_guard lk(g_offload_mx); + if (g_offload_done.empty()) break; + d = std::move(g_offload_done.front()); + g_offload_done.pop_front(); + } + if (!g_parked.erase(d.co)) continue; + lua_checkstack(d.co, LUA_MINSTACK); + int n = d.deliver(d.co); + resume_coro(mainL, d.co, n); + } +} + +// Route one bridge event into its conn's serialized queue and request dispatch; +// the handler runs (as a coroutine) in luarpc_advance, drained by the run loop. +void dispatch_luarpc_event(lua_State* L, LuaRpcEvent e) { + uint64_t id = e.connId; + g_luarpc_exec[id].pending.push_back(std::move(e)); + g_luarpc_advance.push_back(id); + (void)L; +} + +// ces.conn.connect(addr, server_pubkey(32) [, timeout_ms]) → conn | nil, err. +// Dials a remote (or this) lua host's /ces/luarpc/1 out the instance's OWN rpc +// socket — the only outbound path (the relay is inbound-only: the server brings +// users in, it is not a generic egress proxy). From a coroutine it yields; on +// success returns a source=1 conn whose inbound data flows to the unified +// listener's on_data like any other. Optional timeout_ms bounds the bind +// handshake at the source (default 15000); a dead peer fails in ~timeout_ms +// rather than waiting on RUDP idle-GC. (Listen + run are unified — see +// ces.conn.set_listener / ces.run.) +int lua_ces_conn_connect(lua_State* L) { + size_t alen = 0; + const char* addr = luaL_checklstring(L, 1, &alen); + size_t plen = 0; + const char* spk = luaL_checklstring(L, 2, &plen); + if (plen != 32) { + lua_pushnil(L); + lua_pushstring(L, "server pubkey must be 32 bytes"); + return 2; + } + int timeoutMs = static_cast(luaL_optinteger(L, 3, 15000)); + if (timeoutMs < 0) timeoutMs = 0; // 0 = no bind timeout (RUDP idle-GC only) + luarpc_ensure_endpoint(); // lazy-open the port for dialing + if (!g_luarpc_io || !g_luarpc_rudp || !g_luarpc_signer) { + lua_pushnil(L); + lua_pushstring(L, + "networking permanently disabled (this instance has no rpc port)"); + return 2; + } + minx::SockAddr peer; + try { + auto ep = ces::Resolver::resolveUdp(std::string(addr, alen)); + auto a = ep.address(); + if (a.is_v4()) { + a = boost::asio::ip::make_address_v6( + boost::asio::ip::v4_mapped, a.to_v4()); + } + peer = minx::SockAddr(a, ep.port()); + } catch (const std::exception&) { + lua_pushnil(L); + lua_pushstring(L, "address resolve failed"); + return 2; + } + std::array expectPk{}; + std::memcpy(expectPk.data(), spk, 32); + + // From a coroutine: park and let the endpoint's io thread drive the bind, + // then resume on completion via g_connect_done. The loop never freezes; the + // bind is bounded at the source by timeoutMs (see luarpc_do_connect). + if (io_yieldable(L)) { + lua_State* co = L; + g_parked.insert(co); + boost::asio::post(*g_luarpc_io, [peer, expectPk, co, timeoutMs]() { + luarpc_do_connect(peer, expectPk, [co](LuaRpcConnectResult res) { + { + std::lock_guard lk(g_luarpc_mx); + g_connect_done.push_back(ConnectDelivery{co, std::move(res)}); + } + if (g_luarpc_wakefd >= 0) { + uint64_t one = 1; + ssize_t w = ::write(g_luarpc_wakefd, &one, sizeof(one)); + (void)w; + } + }, timeoutMs); + }); + return lua_yield(L, 0); + } + + // Main chunk (cannot yield): block on the round-trip -- the degenerate case. + auto pr = std::make_shared>(); + auto fut = pr->get_future(); + boost::asio::post(*g_luarpc_io, [peer, expectPk, pr, timeoutMs]() { + luarpc_do_connect(peer, expectPk, + [pr](LuaRpcConnectResult res) { pr->set_value(std::move(res)); }, + timeoutMs); + }); + if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) { + lua_pushnil(L); + lua_pushstring(L, "connect timeout"); + return 2; + } + LuaRpcConnectResult res = fut.get(); + if (res.status != ces::CES_OK) { + lua_pushnil(L); + lua_pushstring(L, res.status == ces::CES_ERROR_INTERNAL + ? "connect failed (network)" + : "bind rejected or wrong peer"); + return 2; + } + make_luarpc_conn(L, res.connId, res.peer.data()); + return 1; +} + +// ces.rpc_port() → the UDP port this instance hosts /ces/luarpc/1 on (0 if it +// got none). A serving program advertises (host, this port) so peers can dial. +int lua_ces_rpc_port(lua_State* L) { + lua_pushnumber(L, static_cast(g_rpc_port)); + return 1; +} + diff --git a/vendor/ces/src/cesluajitd/ipc.inc b/vendor/ces/src/cesluajitd/ipc.inc new file mode 100644 index 0000000..57d937e --- /dev/null +++ b/vendor/ces/src/cesluajitd/ipc.inc @@ -0,0 +1,349 @@ +// --------------------------------------------------------------------------- +// IPC frame I/O over the host's Unix socket. +// --------------------------------------------------------------------------- + +constexpr uint8_t TAG_BOOTSTRAP = 0x00; +constexpr uint8_t TAG_DELIVER = 0x01; +constexpr uint8_t TAG_API_CALL = 0x02; +constexpr uint8_t TAG_API_REPLY = 0x03; +// /ces/lua/1 connection routing tags. Mirror compute_handler.cpp. +constexpr uint8_t TAG_CONN_OPENED = 0x04; // server → child +constexpr uint8_t TAG_CONN_DATA_IN = 0x05; // server → child +constexpr uint8_t TAG_CONN_CLOSED = 0x06; // server → child +constexpr uint8_t TAG_CONN_DATA_OUT = 0x07; // child → server +constexpr uint8_t TAG_CONN_CLOSE = 0x08; // child → server +constexpr uint8_t TAG_LISTEN_ON = 0x09; // child → server +constexpr uint8_t TAG_LISTEN_OFF = 0x0a; // child → server +constexpr uint8_t TAG_LOG = 0x0b; // child → server (ces.log, one-way) +constexpr uint8_t TAG_HOST_LOG = 0x0c; // child → server (host C++, one-way) +constexpr uint8_t TAG_NET_USAGE = 0x15; // child -> server (channel usage, one-way) + +// Extension contract (ces.extension_admin{}). REGISTER/REP/DISABLE_SELF flow child → +// server; REQ/CONFIG flow server → child. EXT_REQ carries a corr_id the child +// echoes in EXT_REP so the server can match the reply. +constexpr uint8_t TAG_EXT_REGISTER = 0x0d; // child → server +constexpr uint8_t TAG_EXT_REQ = 0x0e; // server → child (status/command) +constexpr uint8_t TAG_EXT_REP = 0x0f; // child → server (reply) +constexpr uint8_t TAG_EXT_CONFIG = 0x10; // server → child (on_config, one-way) +constexpr uint8_t TAG_EXT_DISABLE_SELF = 0x11; // child → server +constexpr uint8_t TAG_EXT_MANIFEST = 0x12; // child → server (ces.manifest, one-way) +constexpr uint8_t TAG_EXT_SAVE_CONFIG = 0x19; // child → server (persist /s/.conf, one-way) +constexpr uint8_t TAG_EXT_UI_PUSH = 0x1a; // child → server (panel render frame, one-way) +constexpr uint8_t TAG_EXT_UI_WATCH = 0x1b; // server → child ([u8 on]; drives the change-detect tick) + +constexpr uint8_t TAG_L2_CALL_IN = 0x1c; // server → child (paid L2 call -> on_l2call) +constexpr uint8_t TAG_L2_CALL_RESULT = 0x1d; // child → server (delivered / no-handler) +constexpr uint8_t TAG_L2_CALL_REPLY = 0x1f; // child → server (on_l2call return bytes) +#ifdef CES_MAIL +constexpr uint8_t TAG_MAIL_OUT = 0x1e; // child → server (ces.mail.send) +#endif + +constexpr uint8_t TAG_GOSSIP_IN = 0x13; // server → child (a flooded message) +constexpr uint8_t TAG_GOSSIP_OUT = 0x14; // child → server (ces.gossip.send) + +// Peer-mesh messaging (must match compute_handler kIpcTagPeer*). Targeted, +// free, service-tagged messages over /ces/peer/1 between same-named extensions. +constexpr uint8_t TAG_PEER_MSG_IN = 0x16; // server → child (mesh message) +constexpr uint8_t TAG_PEER_MSG_OUT = 0x17; // child → server (ces.peer.send) +constexpr uint8_t TAG_PEER_LISTEN = 0x18; // child → server (ces.peer.listen) + +constexpr uint8_t EXT_REQ_STATUS = 0x00; +constexpr uint8_t EXT_REQ_COMMAND = 0x01; +constexpr uint8_t EXT_REQ_PANEL_RENDER = 0x02; // reply = [status][render/toast frame JSON] +constexpr uint8_t EXT_REQ_PANEL_EVENT = 0x03; // body = event JSON; reply as RENDER + +constexpr uint8_t EXT_CAP_STATUS = 0x01; +constexpr uint8_t EXT_CAP_COMMANDS = 0x02; +constexpr uint8_t EXT_CAP_CONFIG_DEFAULTS = 0x04; +constexpr uint8_t EXT_CAP_ON_CONFIG = 0x08; +constexpr uint8_t EXT_CAP_PANEL = 0x10; // mene UI panel registered + +constexpr uint16_t METHOD_CLIENT_SEND = 0x0001; +constexpr uint16_t METHOD_FILE_CREATE = 0x0100; +constexpr uint16_t METHOD_FILE_WRITE = 0x0101; +constexpr uint16_t METHOD_FILE_READ = 0x0102; +constexpr uint16_t METHOD_FILE_STAT = 0x0103; +constexpr uint16_t METHOD_FILE_DEPOSIT = 0x0104; +constexpr uint16_t METHOD_FILE_WITHDRAW = 0x0105; +constexpr uint16_t METHOD_FILE_SET_PRICE = 0x0106; +constexpr uint16_t METHOD_FILE_DELETE = 0x0107; +constexpr uint16_t METHOD_FILE_APPEND = 0x0108; +constexpr uint16_t METHOD_FILE_RESIZE = 0x0109; +constexpr uint16_t METHOD_KV_CREATE = 0x010a; +constexpr uint16_t METHOD_KV_PUT = 0x010b; +constexpr uint16_t METHOD_KV_GET = 0x010c; +constexpr uint16_t METHOD_KV_ERASE = 0x010d; +constexpr uint16_t METHOD_KV_ITER = 0x010e; +constexpr uint16_t METHOD_KV_DEPOSIT = 0x010f; +constexpr uint16_t METHOD_KV_RANGE = 0x0110; +constexpr uint16_t METHOD_TRANSFER = 0x0200; +constexpr uint16_t METHOD_CROSS_TRANSFER = 0x0201; +constexpr uint16_t METHOD_RANDOM_BYTES = 0x0202; +constexpr uint16_t METHOD_ACCOUNT_READ = 0x0203; +constexpr uint16_t METHOD_KEYNAME = 0x0204; +constexpr uint16_t METHOD_BUCKET_NEW = 0x0210; +constexpr uint16_t METHOD_BUCKET_PUT = 0x0211; +constexpr uint16_t METHOD_BUCKET_GET = 0x0212; +constexpr uint16_t METHOD_AUTHENTIC_ASSET_CREATE = 0x0220; +constexpr uint16_t METHOD_PEERS = 0x0230; +constexpr uint16_t METHOD_PEER_ADD = 0x0231; +constexpr uint16_t METHOD_PEER_REMOVE = 0x0232; +constexpr uint16_t METHOD_PEER_TARGET_SET = 0x0233; +constexpr uint16_t METHOD_PEER_TARGET_GET = 0x0234; +constexpr uint16_t METHOD_REQUEST_FUNDS = 0x0235; +constexpr uint16_t METHOD_PEER_GRIEF = 0x0236; +constexpr uint16_t METHOD_PEER_BAN = 0x0237; +constexpr uint16_t METHOD_SERVER_SIGN = 0x0238; +constexpr uint16_t METHOD_SERVER_INFO = 0x0239; + +constexpr uint8_t STATUS_OK = 0x00; +constexpr uint8_t STATUS_NOT_CONNECTED = 0x01; +constexpr uint8_t STATUS_INSUFFICIENT_BAL = 0x02; +constexpr uint8_t STATUS_BUCKET_FULL = 0x04; +constexpr uint8_t STATUS_INTERNAL = 0xFF; + +// Byte widths of fixed wire fields shared with the host. This TU has no +// ces includes, so these mirror ces::KEY_SIZE / the sha256 digest / the +// 8-byte HashPrefix by value. +constexpr size_t WIRE_KEY_LEN = 32; // pubkey / sha256 / asset id +constexpr size_t WIRE_PREFIX_LEN = 8; // account / program hash prefix + +struct Frame { + uint8_t tag; + uint16_t corr_id; + std::vector body; +}; + +int g_sock_fd = -1; // host IPC socket +std::deque g_inbox; // pending DELIVER frames +std::deque g_reply_q; // pending API_REPLY frames +std::deque g_conn_q; // pending CONN_* frames (drained by run()) +std::deque g_gossip_q; // pending GOSSIP_IN frames (drained by run()) +std::deque g_peer_q; // pending PEER_MSG_IN frames (drained by run()) +std::deque g_l2call_q; // pending L2_CALL_IN frames (drained by run()) + +// Optional C++ interceptor for inbound peer-mesh messages, set by the hyle net +// lifecycle so its consensus frames feed the Runtime directly, not a Lua listener. +// Returns true if it consumed the message. Null when no net chain runs. +bool (*g_peer_msg_hook)(const uint8_t* from32, const std::string& service, + const uint8_t* payload, size_t plen) = nullptr; + +// Cooperative I/O scheduler state (single-threaded: only the IPC thread that +// runs run()/the main chunk touches these). A coroutine that calls a host +// round-trip from inside the event loop parks here instead of spinning in +// wait_for_reply: the verb sends its TAG_API_CALL, records itself by corr_id, +// and yields; the run loop resumes it when the matching TAG_API_REPLY lands. +// Main-chunk / non-coroutine callers cannot yield and keep blocking. +using IoDecoder = std::function; +struct IoWaiter { lua_State* co; IoDecoder decode; }; +std::set g_parked; // coroutines suspended on a host call +std::map g_io_waiters; // corr_id -> parked coroutine + decoder +std::deque g_io_ready; // arrived replies for parked coroutines + +// Per-conn handler dispatch. Each inbound conn event (relay CONN_* frame or +// direct /ces/luarpc/1 event) runs its listener callback (on_open/on_data/ +// on_close) in its OWN coroutine so the callback can make blocking-looking host +// calls that transparently yield -- identically on both transports, as the +// unified ces.conn API promises. Ordering within a conn is preserved: at most +// ONE handler coroutine is live per conn; while it is parked, later events for +// that conn wait in `pending` and run in arrival order. Different conns +// interleave freely. The relay and direct id spaces are separate, so each +// transport keeps its own exec/advance state (isLuarpc tells resume_coro which). +struct ConnExec { std::deque pending; bool busy = false; }; +struct HandlerCo { uint64_t id; bool isClose; bool isLuarpc; }; +std::map g_conn_exec; // relay conn id -> serialized state +std::map g_handler_co; // handler coroutine -> its conn +std::deque g_conn_advance; // relay conns with an event ready +std::map g_coro_refs; // coroutine -> registry ref (keeps it alive) +std::map g_l2call_co; // on_l2call coroutine -> its callId (reply) +static void resume_coro(lua_State* mainL, lua_State* co, int nargs); +static void conn_advance(lua_State* mainL, uint64_t id); +static void luarpc_advance(lua_State* mainL, uint64_t id); +static void drain_luarpc_advance(lua_State* mainL); +static void drain_connect_done(lua_State* mainL); +static void drain_offload_done(lua_State* mainL); +void remove_luarpc_conn(lua_State* L, uint64_t id); +void put_u16(std::vector& o, uint16_t v); +int io_call(lua_State* L, uint16_t method, + const std::vector& args, IoDecoder decode); + +// Blocking read of exactly n bytes from g_sock_fd. Returns true on +// success, false on EOF/error. +bool read_exact(void* out, size_t n) { + uint8_t* p = static_cast(out); + while (n > 0) { + ssize_t r = ::read(g_sock_fd, p, n); + if (r == 0) return false; + if (r < 0) { + if (errno == EINTR) continue; + return false; + } + p += r; + n -= static_cast(r); + } + return true; +} + +bool write_exact(const void* in, size_t n) { + const uint8_t* p = static_cast(in); + while (n > 0) { + ssize_t w = ::write(g_sock_fd, p, n); + if (w < 0) { + if (errno == EINTR) continue; + return false; + } + p += w; + n -= static_cast(w); + } + return true; +} + +// Read the next raw frame from the socket. Returns true on +// success; `out` contains tag + corr_id + body. +bool read_frame(Frame& out) { + uint8_t lenbe[4]; + if (!read_exact(lenbe, 4)) return false; + uint32_t len = (uint32_t(lenbe[0]) << 24) | (uint32_t(lenbe[1]) << 16) + | (uint32_t(lenbe[2]) << 8) | uint32_t(lenbe[3]); + if (len < 3) return false; // must at least have tag + corr_id + if (len > (64u * 1024u * 1024u)) return false; // sanity cap + std::vector hdrbody(len); + if (!read_exact(hdrbody.data(), len)) return false; + out.tag = hdrbody[0]; + out.corr_id = (uint16_t(hdrbody[1]) << 8) | uint16_t(hdrbody[2]); + out.body.assign(hdrbody.begin() + 3, hdrbody.end()); + return true; +} + +bool write_frame(uint8_t tag, uint16_t corr_id, + const uint8_t* body, size_t body_len) { + uint32_t len = static_cast( + sizeof(uint8_t) + sizeof(uint16_t) + body_len); // tag + corr + body + uint8_t hdr[7]; + hdr[0] = uint8_t((len >> 24) & 0xFF); + hdr[1] = uint8_t((len >> 16) & 0xFF); + hdr[2] = uint8_t((len >> 8) & 0xFF); + hdr[3] = uint8_t((len ) & 0xFF); + hdr[4] = tag; + hdr[5] = uint8_t((corr_id >> 8) & 0xFF); + hdr[6] = uint8_t( corr_id & 0xFF); + if (!write_exact(hdr, 7)) return false; + if (body_len > 0 && !write_exact(body, body_len)) return false; + return true; +} + +// Structured log line from the C++ host side of this instance (not the Lua +// program). Rides the same one-way IPC the Lua ces.log uses, but on its own tag +// so the server attributes it to the host, not the program. level: 0 trace, +// 1 debug, 2 info, 3 warning, 4 error. Call only from the run-loop thread (where +// every ces.* C function runs), matching ces.log's threading. +void host_log(uint8_t level, const std::string& msg) { + std::vector body; + body.reserve(1 + msg.size()); + body.push_back(level); + body.insert(body.end(), msg.begin(), msg.end()); + write_frame(TAG_HOST_LOG, 0, body.data(), body.size()); +} + +// Safe-stringify the Lua error value on top of `s`'s stack, for host_log. +std::string lua_err_str(lua_State* s) { + const char* m = lua_tostring(s, -1); + return m ? std::string(m) : std::string("(non-string error)"); +} + +// Exception firewall wrapped around EVERY native function exposed to Lua. A +// std::exception thrown inside a native -- std::bad_alloc under the child's +// RLIMIT_AS, a CryptoPP/Boost.System throw, a length_error from a bad size -- +// must never unwind into LuaJIT as a foreign "C++ exception": uncaught above a +// pcall it can take down the run-loop thread. Catch it here and re-raise as an +// ordinary Lua error the program's pcall can handle, logging it host-side. +// +// Only std::exception is caught, deliberately: LuaJIT's OWN error propagation +// (luaL_error/lua_error, e.g. from a luaL_check* arg failure inside the wrapped +// function) is a foreign exception class, not std::exception, so it passes +// straight through this handler untouched -- preserving real Lua error messages. +// A blanket catch(...) here would swallow that and break Lua error handling. +template +int guarded(lua_State* L) { + try { + return Fn(L); + } catch (const std::exception& e) { + // host_log allocates; never let a failure there mask the original fault. + try { host_log(4, std::string("uncaught native exception: ") + e.what()); } + catch (...) {} + return luaL_error(L, "internal error"); + } +} + +// Try to read one frame without blocking. Returns 1 on success, +// 0 if no frame available right now, -1 on socket error/EOF. +int try_read_frame_nonblocking(Frame& out) { + pollfd pfd{}; + pfd.fd = g_sock_fd; + pfd.events = POLLIN; + int pr = ::poll(&pfd, 1, 0); + if (pr < 0) { + if (errno == EINTR) return 0; + return -1; + } + if (pr == 0) return 0; + if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) return -1; + return read_frame(out) ? 1 : -1; +} + +// Stash a frame into the queue that matches its tag. CONN_* frames +// arriving while the program is blocked on an API call go to g_conn_q; +// ces.conn.run() drains it before reading new frames so the listener +// callbacks never silently miss a connection event. +void route_to_queue(Frame f) { + if (f.tag == TAG_DELIVER) g_inbox.push_back(std::move(f)); + else if (f.tag == TAG_API_REPLY) { + if (g_io_waiters.find(f.corr_id) != g_io_waiters.end()) + g_io_ready.push_back(std::move(f)); // a parked coroutine awaits this reply + else + g_reply_q.push_back(std::move(f)); // a blocking wait_for_reply (or stale) + } + else if (f.tag == TAG_CONN_OPENED || f.tag == TAG_CONN_DATA_IN || + f.tag == TAG_CONN_CLOSED) g_conn_q.push_back(std::move(f)); + else if (f.tag == TAG_GOSSIP_IN) g_gossip_q.push_back(std::move(f)); + else if (f.tag == TAG_PEER_MSG_IN) g_peer_q.push_back(std::move(f)); + else if (f.tag == TAG_L2_CALL_IN) g_l2call_q.push_back(std::move(f)); + // Ignore other tags (BOOTSTRAP is only at startup). +} + +// Absorb any currently-available frames into the per-tag queues. +// Returns false on fatal socket error (caller should exit). +bool drain_socket_nonblocking() { + for (;;) { + Frame f; + int r = try_read_frame_nonblocking(f); + if (r == 0) return true; + if (r < 0) return false; + route_to_queue(std::move(f)); + } +} + +// Blocking wait for the next API reply with the given corr_id. +// Stashes other-tag frames in their per-tag queues so the rest of the +// runtime (ces.client_recv, ces.conn.run) can drain them later. +// Returns false on socket error. +bool wait_for_reply(uint16_t corr_id, Frame& out) { + // Check already-queued replies first. + for (auto it = g_reply_q.begin(); it != g_reply_q.end(); ++it) { + if (it->corr_id == corr_id) { + out = std::move(*it); + g_reply_q.erase(it); + return true; + } + } + for (;;) { + Frame f; + if (!read_frame(f)) return false; + if (f.tag == TAG_API_REPLY && f.corr_id == corr_id) { + out = std::move(f); + return true; + } + route_to_queue(std::move(f)); + } +} + diff --git a/vendor/ces/src/cesluajitd/main.cpp b/vendor/ces/src/cesluajitd/main.cpp new file mode 100644 index 0000000..7113bb2 --- /dev/null +++ b/vendor/ces/src/cesluajitd/main.cpp @@ -0,0 +1,285 @@ +// cesluajitd — LuaJIT-hosted compute child for /ces/compute/1. +// +// Replaces cescompmockd as the default compute runtime. One +// process per running program instance; the program's Lua source +// arrives in the IPC bootstrap frame from the host. The child +// enters a Lua event loop that can react to inbound client +// messages (ces.client_recv) and push replies back +// (ces.client_send). +// +// The child is deliberately minimal: +// * No filesystem API. No os, io, debug, package, require, +// loadfile, dofile, load, loadstring, ffi (no runtime code/bytecode +// loading — bytecode is unverified in LuaJIT and an escape vector). +// * Outbound CesClient (ces.remote_account_read / ces.remote_transfer) +// reaches other CES servers; no inbound network beyond client↔program +// messaging. +// * Lua stays in memory, with RLIMIT_AS as a hard ceiling. +// +// Invocation: cesluajitd [drop_to_user] +// +// When drop_to_user is given and the process is running as root, +// it drops privileges before any Lua code runs. +// +// IPC wire format (both directions): +// +// [u32 BE length][u8 tag][u16 BE corr_id][body] +// +// where `length` covers everything after itself. +// +// Tags: +// 0x00 H→C Bootstrap body = Lua source bytes +// 0x01 H→C Deliver body = [8B sender_pfx][payload bytes] +// 0x02 C→H API call body = [u16 BE method_id][args...] +// 0x03 H→C API reply body = [u8 status][status-specific] +// +// Method IDs (C→H): +// 0x0001 CLIENT_SEND args = [8B target_pfx][u16 BE len][bytes] +// +// API reply status: +// 0x00 ok +// 0x01 target client is not connected +// 0x02 insufficient balance (out of scope in v1; reserved) +// 0xFF internal / malformed +// +// The IPC channel is full-duplex Unix stream. The child runs a +// single-threaded Lua; within Lua, ces.* calls are synchronous +// (write request, drain frames until we see the matching reply). +// Inbound deliver frames that arrive while we're waiting for a +// reply are buffered into the in-process inbox for the next +// ces.client_recv. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +extern "C" { +#include +#include +#include +#include +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "sha256.h" // cesluajitd-local: SHA-256 for ces.sha256 + +#include +#include +#include +#include +#include + +#ifdef CES_HYLE +// hyle-services (the in-process blockchain node behind ces.hyle). Included at +// GLOBAL scope: these pull Boost/std headers that must not land inside the +// cesluajitd namespace below, where the api_*.inc bodies live. +#include +#include +#include +#include +#include // ces-side a/e counter over the canonical KV dump +#endif + +namespace cesluajitd { + +#include "ipc.inc" +#include "startup.inc" +#include "sandbox.inc" +#include "api_core.inc" +#include "api_file.inc" +#include "offload_net.inc" +#include "api_bucket.inc" +#include "api_store.inc" +#include "api_conn.inc" +#include "timers.inc" +#include "api_chan.inc" +#include "api_mene.inc" +#include "api_extadmin.inc" +#include "conn_direct.inc" +#ifdef CES_HYLE +#include "api_hyle.inc" +#endif +#include "api_clients.inc" +} // namespace cesluajitd + +// The entry point lives in the global namespace; bring in the cesluajitd +// helpers it calls (load_safe_libs, connect_unix_socket, run, ...). +using namespace cesluajitd; + +// --------------------------------------------------------------------------- +// Entry point. +// --------------------------------------------------------------------------- + +int main(int argc, char** argv) { + if (argc >= 3 && std::strcmp(argv[1], "--manifest") == 0) + return probe_manifest(argv[2]); + if (argc < 2) { + std::fprintf(stderr, + "usage: %s [drop-to-user] [mem-max-bytes]\n", + argc > 0 ? argv[0] : "cesluajitd"); + return 2; + } + const char* sock_path = argv[1]; + const char* drop_user = argc >= 3 ? argv[2] : ""; + // argv[3]: address-space ceiling in bytes (RLIMIT_AS), from the + // server's compute_process_mem_max. Absent/unparsable → 256 MB. + size_t mem_max_bytes = 256ULL * 1024ULL * 1024ULL; + if (argc >= 4) { + char* end = nullptr; + unsigned long long v = std::strtoull(argv[3], &end, 10); + if (end != argv[3] && v > 0) mem_max_bytes = static_cast(v); + } + // argv[4]: #3 verb-client worker-pool size, from the server's + // compute_client_pool_size. Absent/unparsable → keep the default; clamp to + // [1, 64] so a bad value can neither disable offload nor spawn a thread storm. + if (argc >= 5) { + char* end = nullptr; + long v = std::strtol(argv[4], &end, 10); + if (end != argv[4] && v >= 1 && v <= 64) g_client_pool_size = static_cast(v); + } + + g_sock_fd = connect_unix_socket(sock_path); + if (g_sock_fd < 0) return 1; + + if (!drop_privileges_if_root(drop_user)) return 1; + apply_rlimits(mem_max_bytes); + + // Read bootstrap frame. Body layout (must match + // compute_handler.cpp::sendBootstrapFrame): + // [8B prog_prefix][32B owner_pubkey][32B program_pubkey] + // [32B program_privkey][2B client_port][2B rpc_port][1B privileged] + // [32B server_secret][8B start_time_us BE][u32 BE src_len][src bytes] + Frame bs; + if (!read_frame(bs) || bs.tag != TAG_BOOTSTRAP) { + host_log(4, "bad bootstrap frame"); + return 1; + } + // Field offsets within the bootstrap body (must match + // compute_handler.cpp::sendBootstrapFrame). + constexpr size_t kOffPrefix = 0; + constexpr size_t kOffOwner = kOffPrefix + WIRE_PREFIX_LEN; + constexpr size_t kOffProgram = kOffOwner + WIRE_KEY_LEN; + constexpr size_t kOffPrivkey = kOffProgram + WIRE_KEY_LEN; + constexpr size_t kOffPort = kOffPrivkey + WIRE_KEY_LEN; + constexpr size_t kOffRpcPort = kOffPort + sizeof(uint16_t); + constexpr size_t kOffPriv = kOffRpcPort + sizeof(uint16_t); + constexpr size_t kOffSrvSec = kOffPriv + 1; + constexpr size_t kOffStart = kOffSrvSec + WIRE_KEY_LEN; + constexpr size_t kOffSrcLen = kOffStart + sizeof(uint64_t); + constexpr size_t BS_HEADER = kOffSrcLen + sizeof(uint32_t); + if (bs.body.size() < BS_HEADER) { + host_log(4, "bootstrap too short"); + return 1; + } + std::memcpy(g_prog_prefix, bs.body.data() + kOffPrefix, WIRE_PREFIX_LEN); + std::memcpy(g_owner_pubkey, bs.body.data() + kOffOwner, WIRE_KEY_LEN); + std::memcpy(g_program_pubkey, bs.body.data() + kOffProgram, WIRE_KEY_LEN); + std::memcpy(g_program_privkey, bs.body.data() + kOffPrivkey, WIRE_KEY_LEN); + g_program_port = get_u16(bs.body.data() + kOffPort); + g_rpc_port = get_u16(bs.body.data() + kOffRpcPort); + g_privileged = bs.body[kOffPriv] != 0; + std::memcpy(g_server_secret, bs.body.data() + kOffSrvSec, WIRE_KEY_LEN); + for (size_t i = 0; i < WIRE_KEY_LEN; ++i) + if (g_server_secret[i]) { g_has_server_secret = true; break; } + g_start_time_us = get_u64(bs.body.data() + kOffStart); + uint32_t src_len = get_u32(bs.body.data() + kOffSrcLen); + if (bs.body.size() < BS_HEADER + src_len) { + host_log(4, "bootstrap truncated"); + return 1; + } + const char* src = + reinterpret_cast(bs.body.data() + BS_HEADER); + + // Programs are Lua TEXT only. LuaJIT's loader treats a leading ESC + // (0x1B) as a precompiled-bytecode chunk, and LuaJIT bytecode is + // UNVERIFIED — crafted bytecode is a sandbox-escape vector that bypasses + // the language-level safety entirely. A remote-supplied source must + // never reach the bytecode path: reject a bytecode marker here, and load + // with the explicit text-only mode below. + if (src_len > 0 && static_cast(src[0]) == 0x1b) { + host_log(4, "refusing bytecode program (text only)"); + return 1; + } + + lua_State* L = luaL_newstate(); + if (!L) { + host_log(4, "luaL_newstate failed"); + return 1; + } + load_safe_libs(L); + install_ces_api(L); + install_mene_lib(L); + + // The /ces/luarpc/1 endpoint opens LAZILY — on the program's first + // ces.luarpc.set_listener() or .connect() (see luarpc_ensure_endpoint). A + // program that never speaks luarpc opens no socket and spawns no threads. + + // "t" = text only: a second barrier that refuses bytecode even if the + // leading-byte guard above is bypassed. + if (luaL_loadbufferx(L, src, src_len, "=program", "t") != 0) { + host_log(4, std::string("load failed: ") + lua_err_str(L)); + lua_close(L); + return 1; + } + int rc = 0; + if (lua_pcall(L, 0, 0, 0) != 0) { + host_log(4, std::string("run failed: ") + lua_err_str(L)); + rc = 1; + } + g_pool_main.shutdown(); + g_pool_client.shutdown(); + // Tear down the lazily-opened /ces/luarpc/1 endpoint and its host HERE, while the + // asio runtime and Boost.Log are still alive. As file-scope globals they would + // otherwise be destroyed at static-destruction time, where ~CesPlexEndpoint -> + // ~ChannelMeter logs through an already-destroyed Boost.Log core and SEGVs on exit + // (static destruction order fiasco). reset() is a no-op if the endpoint never opened. + g_luarpc_meter = nullptr; + g_luarpc_endpoint.reset(); + g_luarpc_host.reset(); + if (g_luarpc_wakefd >= 0) { ::close(g_luarpc_wakefd); g_luarpc_wakefd = -1; } + lua_close(L); + return rc; +} diff --git a/vendor/ces/src/cesluajitd/offload_net.inc b/vendor/ces/src/cesluajitd/offload_net.inc new file mode 100644 index 0000000..b92cb70 --- /dev/null +++ b/vendor/ces/src/cesluajitd/offload_net.inc @@ -0,0 +1,1152 @@ +// --------------------------------------------------------------------------- +// Blocking-call offload pools. The network verbs below wrap synchronous C++ +// (CesClient / CesPlexChannel), which would freeze the event loop if run on the +// Lua thread from a coroutine. Instead a worker thread runs the blocking call, +// the coroutine parks, and the run loop resumes it when the worker reports back +// — the same park/yield/resume contract the IPC path uses, just woken by a +// worker instead of a reply frame. Two pools (see the verb sites): +// g_pool_main — 1 worker, the main-port CesClient (reused + re-pointed, +// sharing the single leased outbound port). +// g_pool_client — N workers, the endpoint verb-clients (independent channels). +// A saturated pool does NOT block the loop: the coroutine just stays parked +// until a worker frees. Main-chunk callers (cannot yield) still block inline. +// --------------------------------------------------------------------------- + +// A worker runs the job and returns a "deliver" closure that, on the run-loop +// thread, pushes the call's Lua results and returns the count. +using OffloadDeliver = std::function; +using OffloadJob = std::function; + +struct OffloadDone { lua_State* co; OffloadDeliver deliver; }; +std::deque g_offload_done; // workers push, run() drains +std::mutex g_offload_mx; + +// Worker → run loop: queue the result and nudge run()'s poll (the eventfd is +// the endpoint's, reused here purely as a wakeup). +void offload_complete(lua_State* co, OffloadDeliver deliver) { + { + std::lock_guard lk(g_offload_mx); + g_offload_done.push_back(OffloadDone{co, std::move(deliver)}); + } + if (g_luarpc_wakefd >= 0) { + uint64_t one = 1; + ssize_t w = ::write(g_luarpc_wakefd, &one, sizeof(one)); + (void)w; + } +} + +struct WorkerPool { + std::vector threads; + std::deque> q; + std::mutex mx; + std::condition_variable cv; + bool stop = false; + + void ensure(int n) { + if (!threads.empty()) return; // lazy: started on first use + for (int i = 0; i < n; i++) threads.emplace_back([this]() { run(); }); + } + void run() { + for (;;) { + std::pair item; + { + std::unique_lock lk(mx); + cv.wait(lk, [this]() { return stop || !q.empty(); }); + if (stop && q.empty()) return; + item = std::move(q.front()); + q.pop_front(); + } + OffloadDeliver deliver; + // A thrown job surfaces its reason to the program via the deliver (which + // runs on the Lua thread) -- never host_log from here: a worker must not + // write the IPC socket (it would race the main thread's frames). + try { + deliver = item.second(); + } catch (const std::exception& e) { + std::string msg = e.what(); + deliver = [msg](lua_State* L) { + lua_pushnil(L); lua_pushstring(L, msg.c_str()); return 2; + }; + } catch (...) { + deliver = [](lua_State* L) { + lua_pushnil(L); lua_pushstring(L, "internal"); return 2; + }; + } + offload_complete(item.first, std::move(deliver)); + } + } + void submit(lua_State* co, OffloadJob job) { + { + std::lock_guard lk(mx); + q.push_back({co, std::move(job)}); + } + cv.notify_one(); + } + void shutdown() { + { std::lock_guard lk(mx); stop = true; } + cv.notify_all(); + for (auto& t : threads) if (t.joinable()) t.join(); + threads.clear(); + } +}; + +WorkerPool g_pool_main; // #4: main-port CesClient +WorkerPool g_pool_client; // #3: endpoint verb-clients +// #3 worker count: operator knob (compute_client_pool_size), passed in via +// argv on spawn. Read once at startup; clamped to a sane range. The single +// pool-main worker (#4) is fixed at 1 (port-constrained), not configurable. +int g_client_pool_size = 4; + +// The reused, re-pointable main-port client — one per g_pool_main worker (the +// pool has exactly one). Created lazily, bound once to the instance's leased +// outbound port. Re-points to `ep` and resets tries/retry on EVERY call: the +// client persists across jobs, so its config must not leak between verbs (e.g. +// a prior ces.ping's tries=1 must not weaken a later transfer). Null if the +// port bind fails. Touched ONLY on the pool-main worker thread. +ces::CesClient* poolMainClient(const boost::asio::ip::udp::endpoint& ep, + int tries, int retryMs) { + // g_pool_main is one worker by design: the reused client is held thread_local + // and binds the single g_program_port (bindable once), so a second worker + // would only get a client whose start() fails on the taken port. Assert one + // caller thread so a future ensure(>1) on this pool trips loudly in debug. + static std::atomic owner{std::thread::id{}}; + std::thread::id self = std::this_thread::get_id(), none{}; + owner.compare_exchange_strong(none, self); + assert(owner.load() == self && "poolMainClient: g_pool_main must be 1 worker"); + static thread_local std::unique_ptr c; + if (!c) { + boost::asio::ip::udp::endpoint seed( + boost::asio::ip::address_v6::loopback(), 0); + c = std::make_unique(seed, /*useDataset=*/false, + luaClientConfig()); + if (!c->start(g_program_port)) { c.reset(); return nullptr; } + } + c->setRemoteEndpoint(ep); + c->setTries(tries); + c->setRetryIntervalMs(retryMs); + return c.get(); +} + +// Optional timeout_ms at Lua arg `argn` → the reused client's per-try reply +// wait (default 3000, CesClient's own default). For single-try verbs (queries) +// this is the whole wait; transfers apply it per retry. +int optRetryMs(lua_State* L, int argn) { + int t = static_cast(luaL_optinteger(L, argn, 0)); + return t > 0 ? t : 3000; +} + +// ces.remote_account_read(addr:string, account_pubkey:string(32) [, timeout_ms]) +// → balance, nonce, last_xfer_dest(8B), last_xfer_amount, last_xfer_time +// | nil, err +// +// Resolves `addr` ("ip:port" or "host:port") and queries that account on +// the remote server via the child's own CesClient. Unsigned query — needs +// no key. Blocks the Lua VM for the round-trip (sync). +int lua_ces_remote_account_read(lua_State* L) { + if (g_program_port == 0) { + lua_pushnil(L); + lua_pushstring(L, "networking permanently disabled (this instance has no compute port)"); + return 2; + } + size_t addr_len = 0; + const char* addr = luaL_checklstring(L, 1, &addr_len); + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 2, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "account pubkey must be 32 bytes"); + return 2; + } + boost::asio::ip::udp::endpoint ep; + try { + ep = ces::Resolver::resolveUdp(std::string(addr, addr_len)); + } catch (const std::exception&) { + lua_pushnil(L); + lua_pushstring(L, "address resolve failed"); + return 2; + } + ces::Hash pubkey{}; + std::memcpy(pubkey.data(), pk, 32); + ces::HashPrefix mapKey = ces::Account::getMapKey(pubkey); + int retryMs = optRetryMs(L, 3); // optional timeout_ms (arg 3) + + if (io_yieldable(L)) { + lua_State* co = L; + g_parked.insert(co); + g_pool_main.ensure(1); + g_pool_main.submit(co, [ep, mapKey, retryMs]() -> OffloadDeliver { + ces::CesClient* c = poolMainClient(ep, 1, retryMs); + auto fail = [](lua_State* L) { + lua_pushnil(L); lua_pushinteger(L, STATUS_INTERNAL); return 2; + }; + if (!c) return fail; + if (!c->connect()) return fail; + int64_t bal = 0; uint32_t nonce = 0; ces::HashPrefix ld{}; + uint64_t la = 0; uint32_t lt = 0; + uint8_t rc = c->queryAccount(mapKey, bal, nonce, ld, la, lt); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { + lua_pushnil(L); lua_pushinteger(L, rc); return 2; + }; + return [bal, nonce, ld, la, lt](lua_State* L) -> int { + lua_pushnumber(L, static_cast(bal)); + lua_pushnumber(L, static_cast(nonce)); + lua_pushlstring(L, reinterpret_cast(ld.data()), ld.size()); + lua_pushnumber(L, static_cast(la)); + lua_pushnumber(L, static_cast(lt)); + return 5; + }; + }); + return lua_yield(L, 0); + } + + int64_t balance = 0; + uint32_t nonce = 0; + ces::HashPrefix lastDest{}; + uint64_t lastAmt = 0; + uint32_t lastTime = 0; + uint8_t rc; + try { + ces::CesClient client(ep, /*useDataset=*/false, luaClientConfig()); + client.setTries(1); + client.setRetryIntervalMs(retryMs); + if (!client.start(g_program_port) || !client.connect()) { + lua_pushnil(L); + lua_pushinteger(L, STATUS_INTERNAL); + return 2; + } + rc = client.queryAccount(mapKey, balance, nonce, lastDest, lastAmt, lastTime); + client.disconnect(); + client.stop(); + } catch (const std::exception& e) { + host_log(3, std::string("ces.remote_account_read: ") + e.what()); + lua_pushnil(L); + lua_pushinteger(L, STATUS_INTERNAL); + return 2; + } + if (rc != ces::CES_OK) { + lua_pushnil(L); + lua_pushinteger(L, rc); + return 2; + } + lua_pushnumber(L, static_cast(balance)); + lua_pushnumber(L, static_cast(nonce)); + lua_pushlstring(L, reinterpret_cast(lastDest.data()), + lastDest.size()); + lua_pushnumber(L, static_cast(lastAmt)); + lua_pushnumber(L, static_cast(lastTime)); + return 5; +} + +// ces.ping(addr [, timeout_ms]) → {pubkey(32), rpc_port, min_difficulty} | nil, err +// +// Free MINX GetInfo handshake to a remote server: learns its public key and +// the rpc port that reaches its CesPlex handlers (file/compute/luarpc). A P2P +// node turns a peer's main address (from ces.peers) into the rpc endpoint where +// the peer's DHT instance can be discovered and dialed. Ticketless, so it works +// even against a no-PoW-engine peer. +// +// Optional timeout_ms overrides the single-try reply wait (the CesClient +// default is 3000); a crawler with many hosts can lower it so a dead host fails +// in well under the default 3s. Omitted/<=0 keeps the default. +int lua_ces_ping(lua_State* L) { + if (g_program_port == 0) { + lua_pushnil(L); + lua_pushstring(L, "networking permanently disabled (no compute port)"); + return 2; + } + size_t alen = 0; + const char* addr = luaL_checklstring(L, 1, &alen); + int timeoutMs = static_cast(luaL_optinteger(L, 2, 0)); + boost::asio::ip::udp::endpoint ep; + try { + ep = ces::Resolver::resolveUdp(std::string(addr, alen)); + } catch (const std::exception&) { + lua_pushnil(L); + lua_pushstring(L, "address resolve failed"); + return 2; + } + // Builds the {pubkey, rpc_port, min_difficulty} result table. + auto deliverInfo = [](minx::Hash key, uint16_t rpc, uint8_t md) { + return [key, rpc, md](lua_State* L) -> int { + lua_newtable(L); + lua_pushlstring(L, reinterpret_cast(key.data()), 32); + lua_setfield(L, -2, "pubkey"); + lua_pushnumber(L, static_cast(rpc)); + lua_setfield(L, -2, "rpc_port"); + lua_pushnumber(L, static_cast(md)); + lua_setfield(L, -2, "min_difficulty"); + return 1; + }; + }; + auto deliverErr = [](const char* msg) { + std::string m(msg); + return [m](lua_State* L) -> int { + lua_pushnil(L); + lua_pushlstring(L, m.data(), m.size()); + return 2; + }; + }; + + // From a coroutine: offload the round-trip to the single main-port worker + // (reused re-pointable client) and yield. Otherwise block inline. + if (io_yieldable(L)) { + lua_State* co = L; + g_parked.insert(co); + g_pool_main.ensure(1); + int retryMs = timeoutMs > 0 ? timeoutMs : 3000; + g_pool_main.submit(co, [ep, retryMs, deliverInfo, deliverErr]() + -> OffloadDeliver { + ces::CesClient* c = poolMainClient(ep, 1, retryMs); + if (!c) return deliverErr("networking disabled"); + if (!c->connect()) return deliverErr("unreachable"); + return deliverInfo(c->getServerKey(), c->getServerRpcPort(), + c->getMinDifficulty()); + }); + return lua_yield(L, 0); + } + + minx::Hash serverKey; + uint16_t rpcPort = 0; + uint8_t minDiff = 0; + try { + ces::CesClient client(ep, /*useDataset=*/false, luaClientConfig()); + client.setTries(1); // fast-fail: a departed peer must not freeze the loop + if (timeoutMs > 0) client.setRetryIntervalMs(timeoutMs); + if (!client.start(g_program_port) || !client.connect()) { + lua_pushnil(L); + lua_pushstring(L, "unreachable"); + return 2; + } + serverKey = client.getServerKey(); + rpcPort = client.getServerRpcPort(); + minDiff = client.getMinDifficulty(); + client.disconnect(); + client.stop(); + } catch (const std::exception& e) { + host_log(3, std::string("ces.ping: ") + e.what()); + lua_pushnil(L); + lua_pushstring(L, "ping failed"); + return 2; + } + return deliverInfo(serverKey, rpcPort, minDiff)(L); +} + +// ces.peer_info(addr, index [, timeout_ms]) -> {count, found, pubkey(32)|nil, address|nil} +// | nil, err. One slot of a remote server's public peer table (unsigned, +// unpaid CES_QUERY_PEER_INFO): the total count and the peer at index. +int lua_ces_peer_info(lua_State* L) { + if (g_program_port == 0) { + lua_pushnil(L); + lua_pushstring(L, "networking permanently disabled (this instance has no compute port)"); + return 2; + } + size_t addr_len = 0; + const char* addr = luaL_checklstring(L, 1, &addr_len); + lua_Number idx_n = luaL_checknumber(L, 2); + if (idx_n < 0 || idx_n > 65535) { + lua_pushnil(L); + lua_pushstring(L, "index must be 0..65535"); + return 2; + } + uint16_t index = static_cast(idx_n); + boost::asio::ip::udp::endpoint ep; + try { + ep = ces::Resolver::resolveUdp(std::string(addr, addr_len)); + } catch (const std::exception&) { + lua_pushnil(L); + lua_pushstring(L, "address resolve failed"); + return 2; + } + + int retryMs = optRetryMs(L, 3); // optional timeout_ms (arg 3) + + if (io_yieldable(L)) { + lua_State* co = L; + g_parked.insert(co); + g_pool_main.ensure(1); + g_pool_main.submit(co, [ep, index, retryMs]() -> OffloadDeliver { + ces::CesClient* c = poolMainClient(ep, 1, retryMs); + auto err = [](const char* m) { + std::string s(m); + return [s](lua_State* L) { + lua_pushnil(L); lua_pushlstring(L, s.data(), s.size()); return 2; + }; + }; + if (!c) return err("networking disabled"); + if (!c->connect()) return err("unreachable"); + uint16_t count = 0; bool found = false; ces::Hash pk{}; std::string paddr; + uint8_t rc = c->queryPeerInfo(index, count, found, pk, paddr); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { + lua_pushnil(L); lua_pushinteger(L, rc); return 2; + }; + return [count, found, pk, paddr](lua_State* L) -> int { + lua_newtable(L); + lua_pushnumber(L, static_cast(count)); + lua_setfield(L, -2, "count"); + lua_pushboolean(L, found ? 1 : 0); + lua_setfield(L, -2, "found"); + if (found) { + lua_pushlstring(L, reinterpret_cast(pk.data()), 32); + lua_setfield(L, -2, "pubkey"); + lua_pushlstring(L, paddr.data(), paddr.size()); + lua_setfield(L, -2, "address"); + } + return 1; + }; + }); + return lua_yield(L, 0); + } + + uint16_t count = 0; + bool found = false; + ces::Hash pubkey{}; + std::string paddr; + uint8_t rc; + try { + ces::CesClient client(ep, /*useDataset=*/false, luaClientConfig()); + client.setTries(1); // fast-fail: a slow crawler must not block on a dead host + client.setRetryIntervalMs(retryMs); + if (!client.start(g_program_port) || !client.connect()) { + lua_pushnil(L); + lua_pushstring(L, "unreachable"); + return 2; + } + rc = client.queryPeerInfo(index, count, found, pubkey, paddr); + client.disconnect(); + client.stop(); + } catch (const std::exception& e) { + host_log(3, std::string("ces.peer_info: ") + e.what()); + lua_pushnil(L); + lua_pushstring(L, "peer_info failed"); + return 2; + } + if (rc != ces::CES_OK) { + lua_pushnil(L); + lua_pushinteger(L, rc); + return 2; + } + lua_newtable(L); + lua_pushnumber(L, static_cast(count)); + lua_setfield(L, -2, "count"); + lua_pushboolean(L, found ? 1 : 0); + lua_setfield(L, -2, "found"); + if (found) { + lua_pushlstring(L, reinterpret_cast(pubkey.data()), 32); + lua_setfield(L, -2, "pubkey"); + lua_pushlstring(L, paddr.data(), paddr.size()); + lua_setfield(L, -2, "address"); + } + return 1; +} + +// ces.remote_transfer(addr:string, dest_pubkey:string(32), amount:number [, timeout_ms]) +// → true, new_origin_balance | nil, err +// +// Resolves `addr` and signs a transfer from the program's own account on +// that remote server with the program's private key. Open transfer: creates +// the destination account if it does not exist there yet (safe mode, which +// rejects a missing target, is for humans who mistype an address — a program +// paying out to a fresh account wants it created, like the local ces.transfer). +int lua_ces_remote_transfer(lua_State* L) { + if (g_program_port == 0) { + lua_pushnil(L); + lua_pushstring(L, "networking permanently disabled (this instance has no compute port)"); + return 2; + } + size_t addr_len = 0; + const char* addr = luaL_checklstring(L, 1, &addr_len); + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 2, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "dest pubkey must be 32 bytes"); + return 2; + } + lua_Number amt_n = luaL_checknumber(L, 3); + if (amt_n < 0 || amt_n > 9.2233720368547e18) { + lua_pushnil(L); + lua_pushstring(L, "amount out of range"); + return 2; + } + uint64_t amount = static_cast(amt_n); + boost::asio::ip::udp::endpoint ep; + try { + ep = ces::Resolver::resolveUdp(std::string(addr, addr_len)); + } catch (const std::exception&) { + lua_pushnil(L); + lua_pushstring(L, "address resolve failed"); + return 2; + } + ces::Hash priv{}; + std::memcpy(priv.data(), g_program_privkey, 32); + ces::KeyPair kp(priv, ces::KeyAlgo::ED25519); + ces::Hash dest{}; + std::memcpy(dest.data(), pk, 32); + int retryMs = optRetryMs(L, 4); // optional timeout_ms (arg 4) + + if (io_yieldable(L)) { + lua_State* co = L; + g_parked.insert(co); + g_pool_main.ensure(1); + g_pool_main.submit(co, [ep, kp, dest, amount, retryMs]() -> OffloadDeliver { + ces::CesClient* c = poolMainClient(ep, 3, retryMs); + auto fail = [](lua_State* L) { + lua_pushnil(L); lua_pushinteger(L, STATUS_INTERNAL); return 2; + }; + if (!c) return fail; + c->setKey(kp); + if (!c->connect()) return fail; + int64_t newBal = 0; + uint8_t rc = c->openTransfer(dest, amount, newBal); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { + lua_pushnil(L); lua_pushinteger(L, rc); return 2; + }; + return [newBal](lua_State* L) -> int { + lua_pushboolean(L, 1); + lua_pushnumber(L, static_cast(newBal)); + return 2; + }; + }); + return lua_yield(L, 0); + } + + int64_t newBal = 0; + uint8_t rc; + try { + ces::CesClient client(ep, /*useDataset=*/false, luaClientConfig()); + client.setKey(kp); + client.setRetryIntervalMs(retryMs); + if (!client.start(g_program_port) || !client.connect()) { + lua_pushnil(L); + lua_pushinteger(L, STATUS_INTERNAL); + return 2; + } + rc = client.openTransfer(dest, amount, newBal); + client.disconnect(); + client.stop(); + } catch (const std::exception& e) { + host_log(3, std::string("ces.remote_transfer: ") + e.what()); + lua_pushnil(L); + lua_pushinteger(L, STATUS_INTERNAL); + return 2; + } + if (rc != ces::CES_OK) { + lua_pushnil(L); + lua_pushinteger(L, rc); + return 2; + } + lua_pushboolean(L, 1); + lua_pushnumber(L, static_cast(newBal)); + return 2; +} + +// ces.remote_cross_transfer(addr:string, dest_pubkey:string(32), +// amount:number, dest_server:string [, timeout_ms]) +// → true, new_origin_balance | nil, err +// +// Asks the remote server at `addr` to cross-transfer the program's funds +// held THERE to `dest_pubkey` on `dest_server` (a peer of `addr`). Signed +// with the program's private key — the remote server is the cross originator. +int lua_ces_remote_cross_transfer(lua_State* L) { + if (g_program_port == 0) { + lua_pushnil(L); + lua_pushstring(L, "networking permanently disabled (this instance has no compute port)"); + return 2; + } + size_t addr_len = 0; + const char* addr = luaL_checklstring(L, 1, &addr_len); + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 2, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "dest pubkey must be 32 bytes"); + return 2; + } + lua_Number amt_n = luaL_checknumber(L, 3); + if (amt_n < 0 || amt_n > 9.2233720368547e18) { + lua_pushnil(L); + lua_pushstring(L, "amount out of range"); + return 2; + } + uint64_t amount = static_cast(amt_n); + size_t dsrv_len = 0; + const char* dsrv = luaL_checklstring(L, 4, &dsrv_len); + + boost::asio::ip::udp::endpoint ep; + try { + ep = ces::Resolver::resolveUdp(std::string(addr, addr_len)); + } catch (const std::exception&) { + lua_pushnil(L); + lua_pushstring(L, "address resolve failed"); + return 2; + } + ces::Hash priv{}; + std::memcpy(priv.data(), g_program_privkey, 32); + ces::KeyPair kp(priv, ces::KeyAlgo::ED25519); + ces::Hash dest{}; + std::memcpy(dest.data(), pk, 32); + std::string dsrvStr(dsrv, dsrv_len); + int retryMs = optRetryMs(L, 5); // optional timeout_ms (arg 5) + + if (io_yieldable(L)) { + lua_State* co = L; + g_parked.insert(co); + g_pool_main.ensure(1); + g_pool_main.submit(co, [ep, kp, dest, amount, dsrvStr, retryMs]() + -> OffloadDeliver { + ces::CesClient* c = poolMainClient(ep, 3, retryMs); + auto fail = [](lua_State* L) { + lua_pushnil(L); lua_pushinteger(L, STATUS_INTERNAL); return 2; + }; + if (!c) return fail; + c->setKey(kp); + if (!c->connect()) return fail; + int64_t newBal = 0; + uint8_t rc = c->crossTransfer(dest, amount, dsrvStr, newBal); + if (rc != ces::CES_OK) + return [rc](lua_State* L) { + lua_pushnil(L); lua_pushinteger(L, rc); return 2; + }; + return [newBal](lua_State* L) -> int { + lua_pushboolean(L, 1); + lua_pushnumber(L, static_cast(newBal)); + return 2; + }; + }); + return lua_yield(L, 0); + } + + int64_t newBal = 0; + uint8_t rc; + try { + ces::CesClient client(ep, /*useDataset=*/false, luaClientConfig()); + client.setKey(kp); + client.setRetryIntervalMs(retryMs); + if (!client.start(g_program_port) || !client.connect()) { + lua_pushnil(L); + lua_pushinteger(L, STATUS_INTERNAL); + return 2; + } + rc = client.crossTransfer(dest, amount, std::string(dsrv, dsrv_len), newBal); + client.disconnect(); + client.stop(); + } catch (const std::exception& e) { + host_log(3, std::string("ces.remote_cross_transfer: ") + e.what()); + lua_pushnil(L); + lua_pushinteger(L, STATUS_INTERNAL); + return 2; + } + if (rc != ces::CES_OK) { + lua_pushnil(L); + lua_pushinteger(L, rc); + return 2; + } + lua_pushboolean(L, 1); + lua_pushnumber(L, static_cast(newBal)); + return 2; +} + +// ces.cross_transfer(dest_pubkey:string(32), amount:number, dest_server:string) +// → true, new_origin_balance | nil, err_code +// +// Asks THIS server (home) to cross-transfer the program's funds here to +// `dest_pubkey` on peer `dest_server`. Server-mediated (home is the +// originator); no key needed — runs under owner/program authority like +// ces.transfer. +int lua_ces_cross_transfer(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "dest pubkey must be 32 bytes"); + return 2; + } + lua_Number amt_n = luaL_checknumber(L, 2); + if (amt_n < 0 || amt_n > 9.2233720368547e18) { + lua_pushnil(L); + lua_pushstring(L, "amount out of range"); + return 2; + } + uint64_t amount = static_cast(amt_n); + size_t dsrv_len = 0; + const char* dsrv = luaL_checklstring(L, 3, &dsrv_len); + if (dsrv_len == 0 || dsrv_len > 255) { + lua_pushnil(L); + lua_pushstring(L, "dest_server length out of range"); + return 2; + } + + std::vector args; + args.reserve(WIRE_KEY_LEN + sizeof(uint64_t) + 1 + dsrv_len); + put_bytes(args, pk, 32); + put_u64(args, amount); + args.push_back(static_cast(dsrv_len)); + put_bytes(args, dsrv, dsrv_len); + + return io_call(L, METHOD_CROSS_TRANSFER, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) { + lua_pushnil(L); + lua_pushinteger(L, st); + return 2; + } + if (reply.body.size() < sizeof(uint8_t) + sizeof(uint64_t)) + return push_file_err(L, STATUS_INTERNAL); + uint64_t newBal = get_u64(reply.body.data() + 1); + lua_pushboolean(L, 1); + lua_pushnumber(L, static_cast(newBal)); + return 2; + }); +} + +// ces.request_funds(addr:string, amount:number) → granted:number | nil, err +// +// Petition the SERVER to fund THIS program's key at remote `addr` with up to +// `amount` credits — a server-signed regular transfer AT the remote (origin = the +// server's reserve there), gated by the operator's GLOBAL funding budget (off by +// default). Returns the CONFIRMED granted amount (0..amount). A 0 is NOT an error +// — budget exhausted/off, or the remote transfer failed; the caller backs off and +// retries. The program holds no budget and no authority; it only asks. +int lua_ces_request_funds(lua_State* L) { + size_t addr_len = 0; + const char* addr = luaL_checklstring(L, 1, &addr_len); + if (addr_len == 0 || addr_len > 255) { + lua_pushnil(L); + lua_pushstring(L, "addr length out of range"); + return 2; + } + lua_Number amt_n = luaL_checknumber(L, 2); + if (amt_n < 0 || amt_n > 9.2233720368547e18) { + lua_pushnil(L); + lua_pushstring(L, "amount out of range"); + return 2; + } + uint64_t amount = static_cast(amt_n); + + std::vector args; + args.reserve(sizeof(uint64_t) + 1 + addr_len); + put_u64(args, amount); + args.push_back(static_cast(addr_len)); + put_bytes(args, addr, addr_len); + + return io_call(L, METHOD_REQUEST_FUNDS, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) { + lua_pushnil(L); + lua_pushinteger(L, st); + return 2; + } + if (reply.body.size() < sizeof(uint8_t) + sizeof(uint64_t)) + return push_file_err(L, STATUS_INTERNAL); + uint64_t granted = get_u64(reply.body.data() + 1); + lua_pushnumber(L, static_cast(granted)); + return 1; + }); +} + +// ces.authentic_asset_create(asset_id, recipient_pubkey, payload, days) +// → true | nil, err_code +// +// Mints an IMMUTABLE asset whose first 32 bytes are this program's +// identity hash sha256(source_bytes || source_path), looked up from +// the source file's sidecar. The remaining 178 bytes hold the +// caller-supplied payload (zero-padded if shorter). The asset's +// owner is `recipient_pubkey`. Asset rent is paid by the program's +// owner account (whoever owns the source file). +// +// Args: +// asset_id : string(32) - 32-byte asset key +// recipient_pubkey : string(32) - 32-byte ed25519/secp pubkey of new owner +// payload : string(<=178) - opaque user bytes +// days : integer (1..8191) +int lua_ces_authentic_asset_create(lua_State* L) { + size_t aid_len = 0; + const char* aid = luaL_checklstring(L, 1, &aid_len); + if (aid_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "asset_id must be 32 bytes"); + return 2; + } + size_t rcpt_len = 0; + const char* rcpt = luaL_checklstring(L, 2, &rcpt_len); + if (rcpt_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "recipient_pubkey must be 32 bytes"); + return 2; + } + size_t pl_len = 0; + const char* pl = luaL_checklstring(L, 3, &pl_len); + if (pl_len > 178) { + lua_pushnil(L); + lua_pushstring(L, "payload exceeds 178 bytes"); + return 2; + } + lua_Integer days_i = luaL_checkinteger(L, 4); + if (days_i < 1 || days_i > 8191) { + lua_pushnil(L); + lua_pushstring(L, "days must be in [1, 8191]"); + return 2; + } + + std::vector args; + args.reserve(WIRE_KEY_LEN + WIRE_KEY_LEN + sizeof(uint16_t) + pl_len); + put_bytes(args, aid, 32); + put_bytes(args, rcpt, 32); + put_u16(args, static_cast(days_i)); + if (pl_len > 0) + put_bytes(args, pl, pl_len); + + return io_call(L, METHOD_AUTHENTIC_ASSET_CREATE, args, + [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) { + lua_pushnil(L); + lua_pushinteger(L, st); + return 2; + } + lua_pushboolean(L, 1); + return 1; + }); +} + +// ces.random_bytes(n:integer) → string(n) | nil, err +// +// Crypto-grade randomness from the host's CryptoPP AutoSeededRandomPool. +// Bounded to n ∈ [1, 256] per call; programs needing more should call +// repeatedly. Used by dice for the coin flip. +int lua_ces_random_bytes(lua_State* L) { + lua_Integer n = luaL_checkinteger(L, 1); + if (n < 1 || n > 256) { + lua_pushnil(L); + lua_pushstring(L, "n must be in [1, 256]"); + return 2; + } + std::vector args; + put_u16(args, static_cast(n)); + return io_call(L, METHOD_RANDOM_BYTES, args, + [n](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() < sizeof(uint8_t) + size_t(n)) + return push_file_err(L, STATUS_INTERNAL); + lua_pushlstring(L, + reinterpret_cast(reply.body.data() + 1), + static_cast(n)); + return 1; + }); +} + +// ces.account_read(pubkey:string(32)) +// → table {balance, nonce, last_xfer_dest, last_xfer_amount, +// last_xfer_time} | nil, err_code +// +// Read-only account query. Mirrors the wire's +// CES_UNSIGNED_QUERY_ACCOUNT verb — no fee, no nonce. Account fields +// for a missing account come back as zeros (an honest "no state"). +// Used by dice to verify a player's most recent transfer is the bet. +int lua_ces_account_read(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "pubkey must be 32 bytes"); + return 2; + } + std::vector args; + put_bytes(args, pk, 32); + return io_call(L, METHOD_ACCOUNT_READ, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + // Tail: [i64 BE balance][u32 BE nonce][8B last_xfer_dest] + // [u64 BE last_xfer_amount][u32 BE last_xfer_time] + constexpr size_t TAIL = sizeof(uint64_t) + sizeof(uint32_t) + + WIRE_PREFIX_LEN + sizeof(uint64_t) + + sizeof(uint32_t); + if (reply.body.size() < sizeof(uint8_t) + TAIL) + return push_file_err(L, STATUS_INTERNAL); + const uint8_t* p = reply.body.data() + 1; + uint64_t balU = get_u64(p); p += 8; + uint32_t nonce = get_u32(p); p += 4; + const uint8_t* lastDest = p; p += 8; + uint64_t lastAmt = get_u64(p); p += 8; + uint32_t lastTime = get_u32(p); p += 4; + + // Reinterpret bal as signed via union-cast for payment-account + // negative balances. Lua numbers are doubles, so we project to + // double; payment-account magnitudes are well within precision. + int64_t balS; + std::memcpy(&balS, &balU, sizeof(int64_t)); + + lua_newtable(L); + lua_pushnumber(L, static_cast(balS)); + lua_setfield(L, -2, "balance"); + lua_pushnumber(L, static_cast(nonce)); + lua_setfield(L, -2, "nonce"); + lua_pushlstring(L, reinterpret_cast(lastDest), 8); + lua_setfield(L, -2, "last_xfer_dest"); + lua_pushnumber(L, static_cast(lastAmt)); + lua_setfield(L, -2, "last_xfer_amount"); + lua_pushnumber(L, static_cast(lastTime)); + lua_setfield(L, -2, "last_xfer_time"); + return 1; + }); +} + +// ces.keyname(pubkey:string(32)) -> name:string | nil +// The key_name registered to that pubkey on THIS server (the stored, +// normalized underscore form), or nil if the key has no name. Read-only, +// no fee. Lets an app gate entry on the caller's identity (conn.pubkey). +int lua_ces_keyname(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); + lua_pushstring(L, "pubkey must be 32 bytes"); + return 2; + } + std::vector args; + put_bytes(args, pk, 32); + return io_call(L, METHOD_KEYNAME, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body[0]; + if (st != STATUS_OK) return push_file_err(L, st); + if (reply.body.size() <= 1) { lua_pushnil(L); return 1; } // no name + lua_pushlstring(L, reinterpret_cast(reply.body.data() + 1), + reply.body.size() - 1); + return 1; + }); +} + +// ces.peers() → array of {pubkey(32), address, reachable, verified, +// outbound, inbound}. The local server's peer table snapshot (the same +// data the public CES_QUERY_PEER_INFO opcode exposes). This is the P2P +// overlay's bootstrap topology: a Lua node discovers its server's peers, +// then dials their instances over /ces/luarpc/1. +// ces.server_info() -> a table of the server's public stats (accounts, assets, +// aliases, circulating, tx_count, tps, min_difficulty, fee_*, rpc_port, version, +// server_name). Everything a CES server holds is public except its private key. +// The reply is a generic named-KV blob, so new stats appear here with no parser +// change: u16 nInts, [u16 klen, key, u64]*; u16 nStrs, [u16 klen, key, u16 vlen, +// val]*. +int lua_ces_server_info(lua_State* L) { + std::vector args; + return io_call(L, METHOD_SERVER_INFO, args, [](lua_State* L, const Frame& reply) -> int { + if (reply.body.empty() || reply.body[0] != STATUS_OK) + return push_file_err(L, reply.body.empty() ? STATUS_INTERNAL : reply.body[0]); + const uint8_t* p = reply.body.data() + 1; + const uint8_t* end = reply.body.data() + reply.body.size(); + lua_newtable(L); + if (p + 2 > end) return 1; + uint16_t ni = get_u16(p); p += 2; + for (uint16_t i = 0; i < ni; ++i) { + if (p + 2 > end) break; + uint16_t kl = get_u16(p); p += 2; + if (p + size_t(kl) + 8 > end) break; + std::string k(reinterpret_cast(p), kl); p += kl; + uint64_t v = get_u64(p); p += 8; + lua_pushnumber(L, static_cast(v)); + lua_setfield(L, -2, k.c_str()); + } + if (p + 2 > end) return 1; + uint16_t ns = get_u16(p); p += 2; + for (uint16_t i = 0; i < ns; ++i) { + if (p + 2 > end) break; + uint16_t kl = get_u16(p); p += 2; + if (p + size_t(kl) + 2 > end) break; + std::string k(reinterpret_cast(p), kl); p += kl; + uint16_t vl = get_u16(p); p += 2; + if (p + size_t(vl) > end) break; + lua_pushlstring(L, reinterpret_cast(p), vl); p += vl; + lua_setfield(L, -2, k.c_str()); + } + return 1; + }); +} + +int lua_ces_peers(lua_State* L) { + std::vector args; // no arguments + return io_call(L, METHOD_PEERS, args, [](lua_State* L, const Frame& reply) -> int { + if (reply.body.empty() || reply.body[0] != STATUS_OK) + return push_file_err(L, reply.body.empty() ? STATUS_INTERNAL : reply.body[0]); + const uint8_t* p = reply.body.data() + 1; + const uint8_t* end = reply.body.data() + reply.body.size(); + if (p + 2 > end) return push_file_err(L, STATUS_INTERNAL); + uint16_t count = get_u16(p); p += 2; + lua_newtable(L); + for (uint16_t i = 0; i < count; ++i) { + if (p + 32 + 2 > end) break; + const uint8_t* ckey = p; p += 32; + uint16_t alen = get_u16(p); p += 2; + if (p + size_t(alen) + 1 + 2 + 16 > end) break; // addr,flags,rpc,powIn,powOut + const char* addr = reinterpret_cast(p); p += alen; + uint8_t flags = *p; p += 1; + uint16_t rpcPort = get_u16(p); p += 2; + uint64_t powIn = get_u64(p); p += 8; + uint64_t powOut = get_u64(p); p += 8; + lua_newtable(L); + lua_pushlstring(L, reinterpret_cast(ckey), 32); + lua_setfield(L, -2, "pubkey"); + lua_pushlstring(L, addr, alen); + lua_setfield(L, -2, "address"); + lua_pushboolean(L, (flags & 0x01) ? 1 : 0); lua_setfield(L, -2, "reachable"); + lua_pushboolean(L, (flags & 0x02) ? 1 : 0); lua_setfield(L, -2, "verified"); + lua_pushboolean(L, (flags & 0x04) ? 1 : 0); lua_setfield(L, -2, "outbound"); + lua_pushboolean(L, (flags & 0x08) ? 1 : 0); lua_setfield(L, -2, "inbound"); + lua_pushnumber(L, static_cast(rpcPort)); + lua_setfield(L, -2, "rpc_port"); + lua_pushnumber(L, static_cast(powIn)); + lua_setfield(L, -2, "inbound_pow"); + lua_pushnumber(L, static_cast(powOut)); + lua_setfield(L, -2, "outbound_pow"); + lua_rawseti(L, -2, i + 1); + } + return 1; + }); +} + +// Peering control (privileged: registered only for /s/ programs, and the +// supervisor enforces the same gate). These let an operator-deployed extension +// run the node's peering policy from Lua -- e.g. an autopeering agent that +// learns the network from gossip and decides who to befriend. + +// ces.add_peer(pubkey(32), address) -> true | nil,err. Establish an outbound +// peering; the peer miner then probes it (and mines a reserve if the target>0). +int lua_ces_add_peer(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); lua_pushstring(L, "peer pubkey must be 32 bytes"); return 2; + } + size_t addr_len = 0; + const char* addr = luaL_checklstring(L, 2, &addr_len); + if (addr_len == 0 || addr_len > 256) { + lua_pushnil(L); lua_pushstring(L, "address must be 1..256 bytes"); return 2; + } + std::vector args; + put_bytes(args, pk, 32); + put_bytes(args, addr, addr_len); + return io_call(L, METHOD_PEER_ADD, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body.empty() ? STATUS_INTERNAL : reply.body[0]; + if (st != STATUS_OK) { lua_pushnil(L); lua_pushinteger(L, st); return 2; } + lua_pushboolean(L, 1); + return 1; + }); +} + +// ces.remove_peer(pubkey(32)) -> removed(bool) | nil,err. +int lua_ces_remove_peer(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); lua_pushstring(L, "peer pubkey must be 32 bytes"); return 2; + } + std::vector args; + put_bytes(args, pk, 32); + return io_call(L, METHOD_PEER_REMOVE, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body.empty() ? STATUS_INTERNAL : reply.body[0]; + if (st != STATUS_OK) { lua_pushnil(L); lua_pushinteger(L, st); return 2; } + lua_pushboolean(L, reply.body.size() >= 2 && reply.body[1] != 0); + return 1; + }); +} + +// ces.grief_peer(pubkey(32) [, amount]) -> true | nil,err. Raise a peer's grief in +// the C++ peer table by `amount` (default 1 = confidence in the offense); the C++ +// side bans the peer once grief crosses the threshold and decays it otherwise. +int lua_ces_grief_peer(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); lua_pushstring(L, "peer pubkey must be 32 bytes"); return 2; + } + lua_Integer amt = luaL_optinteger(L, 2, 1); + if (amt < 1) amt = 1; + if (amt > 0xFFFFFFFF) amt = 0xFFFFFFFF; + std::vector args; + put_bytes(args, pk, 32); + put_u32(args, static_cast(amt)); + return io_call(L, METHOD_PEER_GRIEF, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body.empty() ? STATUS_INTERNAL : reply.body[0]; + if (st != STATUS_OK) { lua_pushnil(L); lua_pushinteger(L, st); return 2; } + lua_pushboolean(L, 1); + return 1; + }); +} + +// ces.ban_peer(pubkey(32)) -> true | nil,err. Ban a peer immediately on conclusive +// evidence (bypasses the accumulating grief counter), e.g. a params hash an honest +// peer cannot produce. For uncertain evidence use ces.grief_peer instead. +int lua_ces_ban_peer(lua_State* L) { + size_t pk_len = 0; + const char* pk = luaL_checklstring(L, 1, &pk_len); + if (pk_len != 32) { + lua_pushnil(L); lua_pushstring(L, "peer pubkey must be 32 bytes"); return 2; + } + std::vector args; + put_bytes(args, pk, 32); + return io_call(L, METHOD_PEER_BAN, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body.empty() ? STATUS_INTERNAL : reply.body[0]; + if (st != STATUS_OK) { lua_pushnil(L); lua_pushinteger(L, st); return 2; } + lua_pushboolean(L, 1); + return 1; + }); +} + +// ces.serverSign(bytes) -> sig(65) | nil,err. Sign `bytes` with the SERVER key for a +// privileged /s/ extension. The server private key never enters the sandbox: the host +// (CES core) applies the reserved domain tag, hashes SHA256(tag || bytes), signs, and +// returns only the signature. Denied (nil) for a non-/s/ program. Pairs with +// ces.serverVerify. Used by formation to make a relayed attestation attributable to +// the member's server identity. +int lua_ces_server_sign(lua_State* L) { + size_t n = 0; + const char* s = luaL_checklstring(L, 1, &n); + std::vector args; + put_bytes(args, s, n); + return io_call(L, METHOD_SERVER_SIGN, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body.empty() ? STATUS_INTERNAL : reply.body[0]; + if (st != STATUS_OK) { lua_pushnil(L); lua_pushinteger(L, st); return 2; } + if (reply.body.size() != 1 + ces::SIG_SIZE) { + lua_pushnil(L); lua_pushstring(L, "internal"); return 2; + } + lua_pushlstring(L, reinterpret_cast(reply.body.data() + 1), + ces::SIG_SIZE); + return 1; + }); +} + +// ces.set_peer_target(units) -> true | nil,err. The reserve the peer miner +// aims to accumulate on each peer, in raw internal units (PRICE_UNIT = 1 full +// credit = 100000000 units; the value is stored unscaled). 0 = never mine, +// just keep peers fresh. +int lua_ces_set_peer_target(lua_State* L) { + lua_Number n = luaL_checknumber(L, 1); + if (n < 0 || n > 9.2233720368547e18) { + lua_pushnil(L); lua_pushstring(L, "target out of range"); return 2; + } + std::vector args; + put_u64(args, static_cast(n)); + return io_call(L, METHOD_PEER_TARGET_SET, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body.empty() ? STATUS_INTERNAL : reply.body[0]; + if (st != STATUS_OK) { lua_pushnil(L); lua_pushinteger(L, st); return 2; } + lua_pushboolean(L, 1); + return 1; + }); +} + +// ces.peer_target() -> units(number) | nil,err. Raw internal units (see +// ces.set_peer_target; PRICE_UNIT = 100000000 units per full credit). +int lua_ces_peer_target(lua_State* L) { + std::vector args; // no arguments + return io_call(L, METHOD_PEER_TARGET_GET, args, [](lua_State* L, const Frame& reply) -> int { + uint8_t st = reply.body.empty() ? STATUS_INTERNAL : reply.body[0]; + if (st != STATUS_OK) { lua_pushnil(L); lua_pushinteger(L, st); return 2; } + if (reply.body.size() < 1 + sizeof(uint64_t)) + return push_file_err(L, STATUS_INTERNAL); + uint64_t target = get_u64(reply.body.data() + 1); + lua_pushnumber(L, static_cast(target)); + return 1; + }); +} diff --git a/vendor/ces/src/cesluajitd/sandbox.inc b/vendor/ces/src/cesluajitd/sandbox.inc new file mode 100644 index 0000000..bde1664 --- /dev/null +++ b/vendor/ces/src/cesluajitd/sandbox.inc @@ -0,0 +1,63 @@ +// --------------------------------------------------------------------------- +// Sandbox: load ONLY the safe standard libraries (default-deny). +// --------------------------------------------------------------------------- +// +// Rather than open everything (luaL_openlibs) and remove the dangerous +// libraries afterward, open only the safe subset — base, string, table, +// math, bit — so os / io / debug / package / ffi / jit are never present +// at all, regardless of any removal list. None of the loaded libraries +// expose a filesystem, process, or memory-unsafe surface. A text program +// can only reach what's here plus the ces.* bindings; it cannot fabricate +// access to unbound C (no ffi, no pointers), and bytecode never reaches +// the VM (main()'s program loader is text-only). + +void load_safe_libs(lua_State* L) { + // Open ONLY the safe computation libraries. luaL_openlibs is never + // called, so os, io, debug, package, ffi, and jit are never present in + // the VM at all — there is no removal list to get wrong for those. What + // remains is a pure computation surface: numbers, strings, tables, bit + // ops, plus the ces.* host bindings installed separately. + static const luaL_Reg kSafeLibs[] = { + { "", luaopen_base }, // also brings coroutine.* (in-VM, benign) + { "table", luaopen_table }, + { "string", luaopen_string }, + { "math", luaopen_math }, + { "bit", luaopen_bit }, + { nullptr, nullptr } + }; + for (const luaL_Reg* lib = kSafeLibs; lib->func; ++lib) { + lua_pushcfunction(L, lib->func); + lua_pushstring(L, lib->name); + lua_call(L, 1, 0); + } + + // The base library brings code loaders, environment reflection, GC + // control, and print; remove all of them. After this there is no way + // for program text to load code/bytecode, reach another stack frame's + // environment, drive the collector, or write to stdout (which goes + // nowhere for the daemon child anyway). + static const char* const kRemoveGlobals[] = { + "load", "loadstring", "dofile", "loadfile", // code / bytecode loaders + "getfenv", "setfenv", // environment reflection + "collectgarbage", "gcinfo", // GC control / mem info leak + "newproxy", // userdata fabrication + "print", // stdout goes nowhere here + nullptr + }; + for (int i = 0; kRemoveGlobals[i] != nullptr; ++i) { + lua_pushnil(L); + lua_setglobal(L, kRemoveGlobals[i]); + } + + // string.dump serializes a function to BYTECODE. Nothing here can load + // it back (the loaders are gone, and main()'s program loader is + // text-only), but remove it anyway: the program surface deals in text, + // never bytecode, in either direction. + lua_getglobal(L, "string"); + if (lua_istable(L, -1)) { + lua_pushnil(L); + lua_setfield(L, -2, "dump"); + } + lua_pop(L, 1); +} + diff --git a/vendor/ces/src/cesluajitd/sha256.cpp b/vendor/ces/src/cesluajitd/sha256.cpp new file mode 100644 index 0000000..41aac83 --- /dev/null +++ b/vendor/ces/src/cesluajitd/sha256.cpp @@ -0,0 +1,86 @@ +#include "sha256.h" + +#include +#include + +namespace cesluajitd { +namespace { + +constexpr uint32_t sha256_k[64] = { + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 +}; + +inline uint32_t rotr(uint32_t x, uint32_t n) { return (x >> n) | (x << (32 - n)); } + +void sha256_compress(Sha256Ctx& c, const uint8_t* block) { + uint32_t w[64]; + for (int i = 0; i < 16; ++i) { + w[i] = (uint32_t(block[i*4]) << 24) | (uint32_t(block[i*4+1]) << 16) + | (uint32_t(block[i*4+2]) << 8) | uint32_t(block[i*4+3]); + } + for (int i = 16; i < 64; ++i) { + uint32_t s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >> 3); + uint32_t s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >> 10); + w[i] = w[i-16] + s0 + w[i-7] + s1; + } + uint32_t a=c.h[0],b=c.h[1],ch=c.h[2],d=c.h[3],e=c.h[4],f=c.h[5],g=c.h[6],hh=c.h[7]; + for (int i = 0; i < 64; ++i) { + uint32_t S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25); + uint32_t Ch = (e & f) ^ (~e & g); + uint32_t t1 = hh + S1 + Ch + sha256_k[i] + w[i]; + uint32_t S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22); + uint32_t Mj = (a & b) ^ (a & ch) ^ (b & ch); + uint32_t t2 = S0 + Mj; + hh=g; g=f; f=e; e=d+t1; d=ch; ch=b; b=a; a=t1+t2; + } + c.h[0]+=a; c.h[1]+=b; c.h[2]+=ch; c.h[3]+=d; + c.h[4]+=e; c.h[5]+=f; c.h[6]+=g; c.h[7]+=hh; +} + +} // namespace + +void sha256_init(Sha256Ctx& c) { + c.h[0]=0x6a09e667; c.h[1]=0xbb67ae85; c.h[2]=0x3c6ef372; c.h[3]=0xa54ff53a; + c.h[4]=0x510e527f; c.h[5]=0x9b05688c; c.h[6]=0x1f83d9ab; c.h[7]=0x5be0cd19; + c.len_bits = 0; + c.buflen = 0; +} + +void sha256_update(Sha256Ctx& c, const uint8_t* data, size_t len) { + c.len_bits += uint64_t(len) * 8; + while (len > 0) { + size_t take = std::min(size_t(64) - c.buflen, len); + std::memcpy(c.buf + c.buflen, data, take); + c.buflen += take; + data += take; + len -= take; + if (c.buflen == 64) { sha256_compress(c, c.buf); c.buflen = 0; } + } +} + +void sha256_final(Sha256Ctx& c, uint8_t out[32]) { + uint64_t total = c.len_bits; + uint8_t pad = 0x80; + sha256_update(c, &pad, 1); + uint8_t zero = 0; + while (c.buflen != 56) sha256_update(c, &zero, 1); + uint8_t tail[8]; + for (int i = 0; i < 8; ++i) + tail[i] = uint8_t((total >> ((7 - i) * 8)) & 0xFF); + sha256_update(c, tail, 8); + for (int i = 0; i < 8; ++i) { + out[i*4+0] = uint8_t((c.h[i] >> 24) & 0xFF); + out[i*4+1] = uint8_t((c.h[i] >> 16) & 0xFF); + out[i*4+2] = uint8_t((c.h[i] >> 8) & 0xFF); + out[i*4+3] = uint8_t(c.h[i] & 0xFF); + } +} + +} // namespace cesluajitd diff --git a/vendor/ces/src/cesluajitd/sha256.h b/vendor/ces/src/cesluajitd/sha256.h new file mode 100644 index 0000000..7678ebc --- /dev/null +++ b/vendor/ces/src/cesluajitd/sha256.h @@ -0,0 +1,25 @@ +// SHA-256 for ces.sha256. Self-contained, no ceslib dependency, so cesluajitd +// stays small and independently debuggable. No crypto-strength claim needed: +// programs wanting real integrity already have CES signatures on the wire. +#ifndef CESLUAJITD_SHA256_H +#define CESLUAJITD_SHA256_H + +#include +#include + +namespace cesluajitd { + +struct Sha256Ctx { + uint32_t h[8]; + uint64_t len_bits; + uint8_t buf[64]; + size_t buflen; +}; + +void sha256_init(Sha256Ctx& c); +void sha256_update(Sha256Ctx& c, const uint8_t* data, size_t len); +void sha256_final(Sha256Ctx& c, uint8_t out[32]); + +} // namespace cesluajitd + +#endif // CESLUAJITD_SHA256_H diff --git a/vendor/ces/src/cesluajitd/startup.inc b/vendor/ces/src/cesluajitd/startup.inc new file mode 100644 index 0000000..9fbbf90 --- /dev/null +++ b/vendor/ces/src/cesluajitd/startup.inc @@ -0,0 +1,80 @@ +// --------------------------------------------------------------------------- +// Startup: unix socket connect, uid drop, rlimits, Lua locking. +// --------------------------------------------------------------------------- + +int connect_unix_socket(const char* path) { + int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) { std::perror("cesluajitd: socket"); return -1; } + sockaddr_un a{}; + a.sun_family = AF_UNIX; + if (std::strlen(path) >= sizeof(a.sun_path)) { + std::fprintf(stderr, "cesluajitd: socket path too long\n"); + ::close(fd); + return -1; + } + std::strncpy(a.sun_path, path, sizeof(a.sun_path) - 1); + if (::connect(fd, reinterpret_cast(&a), sizeof(a)) < 0) { + std::perror("cesluajitd: connect"); + ::close(fd); + return -1; + } + return fd; +} + +// If running as root AND user_name is non-empty, drop to that +// account (gid first, then uid). Non-fatal if we're already not +// root; fatal if the drop is requested and any step fails. +bool drop_privileges_if_root(const char* user_name) { + if (::geteuid() != 0) return true; // already non-root, nothing to do + if (user_name == nullptr || user_name[0] == '\0') { + host_log(4, "running as root with no drop-to-user configured; refusing"); + return false; + } + struct passwd* pw = ::getpwnam(user_name); + if (!pw) { + host_log(4, std::string("unknown drop-to-user '") + user_name + "'"); + return false; + } + if (::setgroups(0, nullptr) < 0) { + host_log(4, std::string("setgroups: ") + std::strerror(errno)); + return false; + } + if (::setgid(pw->pw_gid) < 0) { + host_log(4, std::string("setgid: ") + std::strerror(errno)); + return false; + } + if (::setuid(pw->pw_uid) < 0) { + host_log(4, std::string("setuid: ") + std::strerror(errno)); + return false; + } + if (::geteuid() == 0 || ::getegid() == 0) { + host_log(4, "privilege drop didn't stick"); + return false; + } + return true; +} + +void apply_rlimits(size_t mem_max_bytes) { + // Cap glibc's per-thread malloc arenas. Each new thread otherwise gets its + // own 64 MB virtual arena, and the transient CesClient behind the outbound + // networking APIs (ces.ping / ces.remote_*) spawns IO threads per call -- + // a few of those reserve enough address space to trip RLIMIT_AS and throw + // std::bad_alloc. Two arenas is plenty for a mostly single-threaded VM and + // keeps outbound networking inside the documented mem ceiling. + mallopt(M_ARENA_MAX, 2); + // Hard ceilings that cannot be raised from inside Lua. RLIMIT_AS is + // the merciless memory cap (from the server's compute_process_mem_max): + // the kernel denies any allocation past it, so a runaway or malicious + // program can never OOM the host. No RLIMIT_CPU — CPU is billed via + // file_balance depletion, not capped; throttling a paying process + // would be pointless. + rlimit r{}; + r.rlim_cur = r.rlim_max = mem_max_bytes; + ::setrlimit(RLIMIT_AS, &r); + // Descriptor ceiling: stdio, the host UDS, and the outbound CesClient's + // socket plus asio internals (epoll / eventfd / timerfd). Bounded, but + // above the original no-network ceiling. + r.rlim_cur = r.rlim_max = 64; + ::setrlimit(RLIMIT_NOFILE, &r); +} + diff --git a/vendor/ces/src/cesluajitd/timers.inc b/vendor/ces/src/cesluajitd/timers.inc new file mode 100644 index 0000000..e3f2e37 --- /dev/null +++ b/vendor/ces/src/cesluajitd/timers.inc @@ -0,0 +1,340 @@ +// --------------------------------------------------------------------------- +// Periodic timers — ces.every(ms, fn). The run loop fires due callbacks and +// uses the nearest deadline as its poll timeout, so a program can do periodic +// work (gossip rounds, liveness, republish) without an external driver. All on +// the Lua thread, like the conn dispatchers. +// --------------------------------------------------------------------------- +struct LuaTimer { + uint64_t next_us; + uint64_t interval_us; + int ref; // registry ref to the Lua cb (ces.every), or LUA_NOREF + lua_State* resume_co; // coroutine to resume (ces.sleep), or nullptr + bool active; + lua_State* busy_co = nullptr; // periodic tick coroutine in flight, or nullptr. + // Skip-if-busy: never lap a still-parked tick. +}; +std::vector g_timers; + +// Cooperative scheduler. spawn() runs a function as a coroutine; sleep() (and, +// later, yielding I/O) suspends a coroutine so the run loop services everyone +// else. All the hard part lives here in C++ -- the Lua side just calls +// blocking-looking functions and never sees a coroutine. Coroutines are resumed +// ONLY from the run loop, and a primitive yields from leaf Lua, so there is no +// cross-C-boundary yield (LuaJIT's one constraint stays satisfied). +struct ReadyCoro { lua_State* co; int nargs; }; +std::deque g_ready; // coroutines to resume now (g_coro_refs above) + +// ces.chan — CSP message channel. Carries whole Lua VALUES (held as registry +// refs while queued), not bytes: send a table, recv that table. recv parks the +// caller with a timeout backstop (default 10s) so a channel can never introduce +// an unbounded wait -- the property the rest of the async surface guarantees. +// send never blocks (unbounded) and drops on a closed channel. Cleanup: queued +// values are unref'd at close or __gc; a parked recv-er can't pin a channel past +// its timeout, so abandoned channels GC cleanly. +struct Channel { + std::deque queue; // registry refs of sent values (FIFO) + std::deque recvers; // coroutines parked in recv (FIFO) + bool closed = false; +}; +struct ChanWaiter { Channel* ch; uint64_t deadline_us; }; +std::map g_chan_waiters; // co parked on recv -> chan+deadline + +static void chan_remove_recver(Channel* ch, lua_State* co) { + for (auto it = ch->recvers.begin(); it != ch->recvers.end(); ++it) + if (*it == co) { ch->recvers.erase(it); return; } +} + +static uint64_t timer_now_us() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); +} + +// Poll timeout (ms) until the next due timer; -1 if no active timers. +static int next_timer_timeout_ms() { + uint64_t next = std::numeric_limits::max(); + bool any = false; + for (auto& t : g_timers) { + if (t.active) { any = true; if (t.next_us < next) next = t.next_us; } + } + for (auto& kv : g_chan_waiters) { // a parked recv must wake on its timeout + any = true; + if (kv.second.deadline_us < next) next = kv.second.deadline_us; + } + if (!any) return -1; + uint64_t now = timer_now_us(); + if (next <= now) return 0; + uint64_t ms = (next - now) / 1000; + if (ms > 1000000) ms = 1000000; // cap a single sleep at ~1000s + return static_cast(ms) + 1; // round up so we never wake early +} + +static void fire_due_timers(lua_State* L) { + if (g_timers.empty()) return; + uint64_t now = timer_now_us(); + // Index-based: a callback may register more timers (vector may reallocate), + // so never hold a reference across the pcall. + for (size_t i = 0; i < g_timers.size(); ++i) { + if (!g_timers[i].active || g_timers[i].next_us > now) continue; + if (g_timers[i].resume_co) { + // One-shot sleep timer: mark the coroutine ready (the run loop resumes it). + g_ready.push_back({g_timers[i].resume_co, 0}); + g_timers[i].active = false; + continue; + } + int ref = g_timers[i].ref; + // Periodic ces.every: run the callback in its OWN coroutine (like a conn + // handler) so a blocking-looking call inside it parks instead of freezing + // the VM -- while a tick waits on the network, inbound conns still get + // serviced. Skip the fire if the previous tick is still parked (busy_co): + // queueing periodic fires would build an unbounded backlog; a poke wants + // "fire if free, else skip". + if (!g_timers[i].busy_co) { + lua_rawgeti(L, LUA_REGISTRYINDEX, ref); // L: [cb] + if (lua_isfunction(L, -1)) { + lua_State* co = lua_newthread(L); // L: [cb, co] + g_coro_refs[co] = luaL_ref(L, LUA_REGISTRYINDEX); // pop+ref co; L: [cb] + lua_xmove(L, co, 1); // cb -> co; co: [cb] + g_timers[i].busy_co = co; + resume_coro(L, co, 0); // cb() runs; finishes or parks + } else { + lua_pop(L, 1); + } + } + // Re-index (vector may have grown; resume_coro re-entered Lua); advance the + // grid whether the tick fired or was skipped, so a long tick never + // busy-spins. A callback may have cancelled this timer. + if (i < g_timers.size() && g_timers[i].ref == ref && g_timers[i].active) { + g_timers[i].next_us += g_timers[i].interval_us; + if (g_timers[i].next_us <= now) + g_timers[i].next_us = now + g_timers[i].interval_us; // skip missed + } + } + // Reap dead entries (fired one-shot sleeps, cancelled ces.every) so a program + // that sleeps in a loop does not grow g_timers without bound. Inactive timers + // already released their ref; nothing else references them. + g_timers.erase( + std::remove_if(g_timers.begin(), g_timers.end(), + [](const LuaTimer& t) { return !t.active; }), + g_timers.end()); +} + +// Wake recv-ers whose timeout elapsed: deliver (nil, "timeout") to each, unless +// a send/close already woke it (g_parked guards the double-resume). Snapshot +// first -- resuming a coroutine may itself recv/send and mutate g_chan_waiters. +static void drain_chan_timeouts(lua_State* mainL) { + if (g_chan_waiters.empty()) return; + uint64_t now = timer_now_us(); + std::vector due; + for (auto& kv : g_chan_waiters) + if (kv.second.deadline_us <= now) due.push_back(kv.first); + for (lua_State* co : due) { + auto it = g_chan_waiters.find(co); + if (it == g_chan_waiters.end()) continue; + Channel* ch = it->second.ch; + g_chan_waiters.erase(it); + if (!g_parked.erase(co)) continue; // already woken by send/close + chan_remove_recver(ch, co); + lua_checkstack(co, 2); + lua_pushnil(co); + lua_pushstring(co, "timeout"); + resume_coro(mainL, co, 2); + } +} + +// ces.every(ms, fn) -> id. Calls fn() every ms from the run loop. Returns a +// timer id usable with ces.cancel. +int lua_ces_every(lua_State* L) { + lua_Number ms = luaL_checknumber(L, 1); + luaL_checktype(L, 2, LUA_TFUNCTION); + if (ms < 1) ms = 1; + lua_pushvalue(L, 2); + int ref = luaL_ref(L, LUA_REGISTRYINDEX); + uint64_t iv = static_cast(ms * 1000.0); + g_timers.push_back({timer_now_us() + iv, iv, ref, nullptr, true}); + lua_pushinteger(L, ref); + return 1; +} + +// ces.cancel(id) — stop a timer created by ces.every. +int lua_ces_cancel(lua_State* L) { + int ref = static_cast(luaL_checkinteger(L, 1)); + for (auto& t : g_timers) { + if (t.ref == ref && t.active && t.resume_co == nullptr) { + t.active = false; + luaL_unref(L, LUA_REGISTRYINDEX, t.ref); + break; + } + } + return 0; +} + +// ces.spawn(fn) - run fn as a concurrent behavior (coroutine). Returns at once; +// fn starts on the next run-loop turn. Other behaviors keep running while this +// one waits on a timeout-bounded call. +int lua_ces_spawn(lua_State* L) { + luaL_checktype(L, 1, LUA_TFUNCTION); + lua_State* co = lua_newthread(L); // pushes the new thread on L + int ref = luaL_ref(L, LUA_REGISTRYINDEX); // pop + ref it (registry is global) + g_coro_refs[co] = ref; + lua_pushvalue(L, 1); // copy fn to top of L + lua_xmove(L, co, 1); // move fn onto co; co stack = [fn] + g_ready.push_back({co, 0}); // resume with 0 args -> calls fn() + return 0; +} + +// ces.gossip.send(msg, budget [, dest]) — flood a message across the server +// mesh. One-way: the home server originates it and pays the flood out of its +// own peer reserves. dest is a 32-byte server pubkey, or nil/absent to +// broadcast to everyone. +int lua_ces_gossip_send(lua_State* L) { + size_t mlen = 0; + const char* m = luaL_checklstring(L, 1, &mlen); + // The CesGossip wire caps the payload at 1024 bytes; reject oversize here so + // the message can't deliver locally yet get truncated + sig-rejected on peers. + if (mlen > 1024) + return luaL_error(L, "ces.gossip.send: msg exceeds 1024 bytes"); + lua_Number budget = luaL_checknumber(L, 2); + if (budget < 0) budget = 0; + uint8_t zero[32] = {0}; + const uint8_t* dest = zero; + if (!lua_isnoneornil(L, 3)) { + size_t dlen = 0; + const char* d = luaL_checklstring(L, 3, &dlen); + if (dlen != 32) + return luaL_error(L, "ces.gossip.send: dest must be a 32-byte key"); + dest = reinterpret_cast(d); + } + // Body: [u64 budget BE][32 dest][u32 len BE][msg]. + std::vector body; + put_u64(body, static_cast(budget)); + body.insert(body.end(), dest, dest + 32); + put_u32(body, static_cast(mlen)); + body.insert(body.end(), m, m + mlen); + write_frame(TAG_GOSSIP_OUT, 0, body.data(), body.size()); + return 0; +} + +#ifdef CES_MAIL +// ces.mail.send{to=, subject=, body=, attach=} - one-way outbound email via the +// server's relay. Routed into builtin:mail, which estimates the encoded size and +// burns the program's OWN account per encoded MB (the anti-spam gate); no /s/ +// restriction. `attach` is an optional file-store path (a private /m/ path is +// owner-gated to this program). +int lua_ces_mail_send(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "to"); + size_t tolen = 0; + const char* to = luaL_checklstring(L, -1, &tolen); + lua_getfield(L, 1, "subject"); + size_t sjlen = 0; + const char* sj = luaL_optlstring(L, -1, "", &sjlen); + lua_getfield(L, 1, "body"); + size_t bdlen = 0; + const char* bd = luaL_optlstring(L, -1, "", &bdlen); + if (tolen == 0 || tolen > 0xFFFF) + return luaL_error(L, "ces.mail.send: 'to' must be 1..65535 bytes"); + if (sjlen > 0xFFFF) + return luaL_error(L, "ces.mail.send: 'subject' exceeds 65535 bytes"); + if (bdlen > 256u * 1024u) + return luaL_error(L, "ces.mail.send: 'body' exceeds 256 KiB"); + lua_getfield(L, 1, "attach"); + size_t atlen = 0; + const char* at = luaL_optlstring(L, -1, "", &atlen); // file-store path + if (atlen > 0xFFFF) + return luaL_error(L, "ces.mail.send: 'attach' path too long"); + // Body: [u16 toLen][to][u16 subjLen][subject][u32 bodyLen][body] + // [u16 pathLen][attach] (BE). + std::vector body; + body.push_back(uint8_t((tolen >> 8) & 0xFF)); + body.push_back(uint8_t(tolen & 0xFF)); + body.insert(body.end(), to, to + tolen); + body.push_back(uint8_t((sjlen >> 8) & 0xFF)); + body.push_back(uint8_t(sjlen & 0xFF)); + body.insert(body.end(), sj, sj + sjlen); + put_u32(body, static_cast(bdlen)); + body.insert(body.end(), bd, bd + bdlen); + body.push_back(uint8_t((atlen >> 8) & 0xFF)); + body.push_back(uint8_t(atlen & 0xFF)); + body.insert(body.end(), at, at + atlen); + write_frame(TAG_MAIL_OUT, 0, body.data(), body.size()); + return 0; +} +#endif // CES_MAIL + +// ces.peer.send(peer_pubkey, service, bytes) - fire a targeted message to a +// peer's same-service extension over the /ces/peer/1 mesh. Free (unmetered, +// server-to-server), fire-and-forget. No-op if there is no live link to peer. +int lua_ces_peer_send(lua_State* L) { + size_t klen = 0; + const char* k = luaL_checklstring(L, 1, &klen); + if (klen != 32) + return luaL_error(L, "ces.peer.send: peer must be a 32-byte key"); + size_t slen = 0; + const char* s = luaL_checklstring(L, 2, &slen); + if (slen == 0 || slen > 0xFFFF) + return luaL_error(L, "ces.peer.send: service must be 1..65535 bytes"); + size_t plen = 0; + const char* p = luaL_checklstring(L, 3, &plen); + // Body: [32 peer][u16 service_len][service][payload]. + std::vector body; + body.insert(body.end(), k, k + 32); + put_u16(body, static_cast(slen)); + body.insert(body.end(), s, s + slen); + body.insert(body.end(), p, p + plen); + write_frame(TAG_PEER_MSG_OUT, 0, body.data(), body.size()); + return 0; +} + +// ces.peer.listen(service, fn) - register fn(from_pubkey, bytes) as the handler +// for inbound mesh messages tagged `service`. fn nil unregisters. Tells the +// server this instance owns `service` so it routes inbound messages here. +int lua_ces_peer_listen(lua_State* L) { + size_t slen = 0; + const char* s = luaL_checklstring(L, 1, &slen); + if (slen == 0 || slen > 0xFFFF) + return luaL_error(L, "ces.peer.listen: service must be 1..65535 bytes"); + if (!lua_isnoneornil(L, 2) && !lua_isfunction(L, 2)) + return luaL_error(L, "ces.peer.listen: handler must be a function or nil"); + // Ensure the service -> fn registry table exists. + lua_getfield(L, LUA_REGISTRYINDEX, kRegPeerListeners); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setfield(L, LUA_REGISTRYINDEX, kRegPeerListeners); + } + lua_pushlstring(L, s, slen); + if (lua_isnoneornil(L, 2)) lua_pushnil(L); else lua_pushvalue(L, 2); + lua_settable(L, -3); // tbl[service] = fn (or nil) + lua_pop(L, 1); + // Body: [u16 service_len][service]. The server reads a length-prefixed + // service (matching TAG_PEER_MSG_OUT's framing). + std::vector body; + put_u16(body, static_cast(slen)); + body.insert(body.end(), s, s + slen); + if (!write_frame(TAG_PEER_LISTEN, 0, body.data(), body.size())) { + lua_pushnil(L); lua_pushstring(L, "ipc write failed"); return 2; + } + lua_pushboolean(L, 1); + return 1; +} + + +// ces.sleep(ms) - looks blocking, isn't: suspends THIS behavior for ms while +// the run loop services the rest. Only valid inside spawn() (a coroutine), not +// in the main chunk or an event handler (those run on the host thread and +// cannot yield); calling it there is a clean error, not a crash. +int lua_ces_sleep(lua_State* L) { + if (lua_pushthread(L)) { // 1 == this IS the main thread + lua_pop(L, 1); + return luaL_error(L, "ces.sleep: only inside spawn()"); + } + lua_pop(L, 1); + lua_Number ms = luaL_checknumber(L, 1); + if (ms < 0) ms = 0; + uint64_t iv = static_cast(ms * 1000.0); + g_timers.push_back({timer_now_us() + iv, 0, LUA_NOREF, L, true}); + return lua_yield(L, 0); +} + diff --git a/vendor/ces/src/cesproxy/main.cpp b/vendor/ces/src/cesproxy/main.cpp new file mode 100644 index 0000000..91b94e0 --- /dev/null +++ b/vendor/ces/src/cesproxy/main.cpp @@ -0,0 +1,70 @@ +/** + * cesproxy — CES-aware TCP-to-UDP MINX proxy executable. + */ + +#include +#include +#include + +#include + +#include +#include + +int main(int argc, char** argv) { + CLI::App app{"cesproxy — CES-aware TCP-to-UDP MINX proxy"}; + + std::string listenAddr = "0.0.0.0"; + uint16_t listenPort = 443; + std::string upstreamAddr; + uint16_t upstreamPort = 0; + size_t channels = 8; + size_t maxClients = 1000; + size_t maxQueue = 0; + + app.add_option("upstream", upstreamAddr, "Upstream MINX/CES server address") + ->required(); + app.add_option("port", upstreamPort, "Upstream server UDP port")->required(); + app.add_option("--listen,-l", listenAddr, + "TCP listen address (default: 0.0.0.0)"); + app.add_option("--listen-port,-p", listenPort, + "TCP listen port (default: 443)"); + app.add_option("--channels,-c", channels, + "Number of upstream ticket channels (default: 8)"); + app.add_option("--max-clients,-m", maxClients, + "Max simultaneous TCP connections (default: 1000)"); + app.add_option("--max-queue,-q", maxQueue, + "Max queued requests, 0=unlimited (default: 0)"); + + CLI11_PARSE(app, argc, argv); + + boost::asio::ip::tcp::endpoint listenEp; + boost::asio::ip::udp::endpoint upstreamEp; + try { + listenEp = ces::Resolver::resolveTcp(listenAddr, listenPort); + upstreamEp = ces::Resolver::resolveUdp(upstreamAddr, upstreamPort); + } catch (const std::exception& e) { + std::cerr << "cesproxy: address resolve failed: " << e.what() << "\n"; + return 1; + } + + minx::MinxProxyConfig config; + config.numChannels = channels; + config.maxClients = maxClients; + config.maxQueueSize = maxQueue; + + CesProxy proxy(listenEp, upstreamEp, config); + + std::cout << "cesproxy listening on " << listenEp << " -> upstream " + << upstreamEp << " (" << channels << " channels, max " << maxClients + << " clients)\n"; + + // Block until Ctrl+C + while (ces::notInterrupted()) + ces::sleep(100); + + std::cout << "cesproxy stopping...\n"; + proxy.stop(); + + return 0; +} diff --git a/vendor/ces/src/cesqt/about.h b/vendor/ces/src/cesqt/about.h new file mode 100644 index 0000000..637f2e0 --- /dev/null +++ b/vendor/ces/src/cesqt/about.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// ============================================================================= +// AboutWidget — version info and close button +// ============================================================================= + +class AboutWidget : public QWidget { + Q_OBJECT +public: + explicit AboutWidget(QWidget* parent = nullptr) : QWidget(parent) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 20); + + auto* title = new QLabel("CES Wallet"); + title->setStyleSheet( + "font-size: 20pt; font-weight: bold; color: #4ec9b0;"); + layout->addWidget(title); + + layout->addSpacing(10); + + auto addField = [&](const QString& label, const QString& value) { + auto* row = new QHBoxLayout; + auto* lbl = new QLabel(label); + lbl->setStyleSheet( + "font-weight: bold; color: #888; min-width: 120px;"); + auto* val = new QLabel(value); + val->setTextInteractionFlags(Qt::TextSelectableByMouse); + val->setStyleSheet("color: #ccc;"); + row->addWidget(lbl); + row->addWidget(val, 1); + layout->addLayout(row); + }; + +#ifndef CES_GIT_HASH +#define CES_GIT_HASH "unknown" +#endif +#ifndef CES_GIT_DATE +#define CES_GIT_DATE "unknown" +#endif +#ifndef CES_BUILD_TYPE +#define CES_BUILD_TYPE "unknown" +#endif +#ifndef CES_COMPILER +#define CES_COMPILER "unknown" +#endif + + addField("Version:", CES_GIT_HASH); + addField("Date:", CES_GIT_DATE); + addField("Build:", CES_BUILD_TYPE); + addField("Compiler:", CES_COMPILER); + addField("Qt:", QT_VERSION_STR); + + layout->addSpacing(20); + + auto* btnClose = new QPushButton("Close"); + btnClose->setMaximumWidth(100); + connect(btnClose, &QPushButton::clicked, []() { + QApplication::quit(); + }); + layout->addWidget(btnClose); + + layout->addStretch(); + } +}; diff --git a/vendor/ces/src/cesqt/appmodel.h b/vendor/ces/src/cesqt/appmodel.h new file mode 100644 index 0000000..61ab116 --- /dev/null +++ b/vendor/ces/src/cesqt/appmodel.h @@ -0,0 +1,267 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// ============================================================================= +// Currency formatting — 8 decimal places (1 internal unit = 0.00000001) +// ============================================================================= + +inline QString formatAmount(int64_t raw) { + bool negative = raw < 0; + uint64_t abs = negative ? static_cast(-raw) + : static_cast(raw); + uint64_t whole = abs / ces::PRICE_UNIT; + uint64_t frac = abs % ces::PRICE_UNIT; + return QString("%1%2.%3") + .arg(negative ? "-" : "") + .arg(whole) + .arg(frac, 8, 10, QChar('0')); +} + +inline QString formatAmount(uint64_t raw) { + uint64_t whole = raw / ces::PRICE_UNIT; + uint64_t frac = raw % ces::PRICE_UNIT; + return QString("%1.%2") + .arg(whole) + .arg(frac, 8, 10, QChar('0')); +} + +// ============================================================================= +// TrackedAsset — persisted asset entry for the wallet +// ============================================================================= + +struct TrackedAsset { + QString assetKey; // 64-char hex (32 bytes) + QString server; // literal server string (part of identity) + // Cached query result + bool known = false; // false = never queried / no data + QString owner; // HashPrefix hex (16 chars), if known + QString contentHex; // 210 bytes as hex (420 chars), if known + uint16_t days = 0; + uint32_t storedPrice = 0; // whole credits (real = stored * 100,000,000) + QString lastQueryTime; // ISO timestamp + QString lastQueryError; // empty = OK + + // Composite ID for display and lookup + QString fullId() const { return assetKey + "@" + server; } + + static TrackedAsset fromJson(const QJsonObject& obj) { + TrackedAsset a; + a.assetKey = obj.value("assetKey").toString(); + a.server = obj.value("server").toString(); + a.known = obj.value("known").toBool(false); + a.owner = obj.value("owner").toString(); + a.contentHex = obj.value("contentHex").toString(); + a.days = static_cast(obj.value("days").toInt(0)); + a.storedPrice = static_cast( + obj.value("storedPrice").toString("0").toULongLong()); + a.lastQueryTime = obj.value("lastQueryTime").toString(); + a.lastQueryError = obj.value("lastQueryError").toString(); + return a; + } + + QJsonObject toJson() const { + QJsonObject obj; + obj["assetKey"] = assetKey; + obj["server"] = server; + obj["known"] = known; + obj["owner"] = owner; + obj["contentHex"] = contentHex; + obj["days"] = days; + obj["storedPrice"] = QString::number(storedPrice); + obj["lastQueryTime"] = lastQueryTime; + obj["lastQueryError"] = lastQueryError; + return obj; + } + + // Try to display content as text, fall back to hex + QString contentDisplay() const { + if (contentHex.isEmpty()) return "(no data)"; + QByteArray raw = QByteArray::fromHex(contentHex.toLatin1()); + bool isText = true; + int len = 0; + for (int i = 0; i < raw.size(); ++i) { + uint8_t c = static_cast(raw[i]); + if (c == 0) break; + if (c < 32 || c > 126) { isText = false; break; } + len++; + } + if (len > 0 && isText) + return QString::fromLatin1(raw.data(), len); + if (contentHex.size() > 64) + return contentHex.left(64) + "..."; + return contentHex; + } + + // Display asset key as text if valid printable ASCII, otherwise hex + QString keyDisplay() const { + QByteArray raw = QByteArray::fromHex(assetKey.toLatin1()); + int len = 0; + bool isText = true; + for (int i = 0; i < raw.size(); ++i) { + uint8_t c = static_cast(raw[i]); + if (c == 0) break; + if (c < 32 || c > 126) { isText = false; break; } + len++; + } + if (len > 0 && isText) + return QString::fromLatin1(raw.data(), len); + return assetKey; + } + + // Real price in internal units + uint64_t realPrice() const { + return ces::storedToRealPrice(storedPrice); + } +}; + +// ============================================================================= +// AppConfig — JSON configuration +// ============================================================================= + +struct AppConfig { + static QString defaultEntry() { + return "ces.pubcom.org:" + QString::number(ces::DEFAULT_PORT); + } + + // LIFO cap on the "recent transfer destinations" dropdown. + static constexpr int kMaxTransferHistory = 20; + + // Mine throttle max is 1 sleep microsecond / hash = effectively 1 hash/sec. + static constexpr int kMaxMineThrottleUs = 1'000'000; + + QString currentServer; // "" = none selected + QStringList servers; // address book + QMap serverInfo; // cached server info per server addr + int defaultAccount = 0; // last selected account index + int mineThreads = 1; // number of mining threads + int mineThrottleUs = 1000; // microseconds sleep between hash batches + QStringList transferDestHistory; // recent transfer destinations (max 20) + QList trackedAssets; // tracked assets across all servers + + QString dataDirOverride; + + QString configDir() const { + if (!dataDirOverride.isEmpty()) return dataDirOverride; + return QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + } + + QString configPath() const { + return configDir() + "/config.json"; + } + + void load() { + QFile f(configPath()); + if (f.exists() && f.open(QIODevice::ReadOnly)) { + QJsonParseError err; + auto doc = QJsonDocument::fromJson(f.readAll(), &err); + if (!doc.isNull()) { + auto obj = doc.object(); + currentServer = obj.value("currentServer").toString(); + defaultAccount = obj.value("defaultAccount").toInt(0); + mineThreads = std::clamp(obj.value("mineThreads").toInt(1), 1, 64); + mineThrottleUs = std::clamp( + obj.value("mineThrottleUs").toInt(1000), 0, kMaxMineThrottleUs); + transferDestHistory.clear(); + for (auto v : obj.value("transferDestHistory").toArray()) + transferDestHistory.append(v.toString()); + if (transferDestHistory.size() > kMaxTransferHistory) + transferDestHistory = + transferDestHistory.mid(0, kMaxTransferHistory); + trackedAssets.clear(); + for (auto v : obj.value("trackedAssets").toArray()) + trackedAssets.append(TrackedAsset::fromJson(v.toObject())); + servers.clear(); + for (auto v : obj.value("servers").toArray()) + servers.append(v.toString()); + serverInfo.clear(); + auto siObj = obj.value("serverInfo").toObject(); + for (auto it = siObj.begin(); it != siObj.end(); ++it) + serverInfo[it.key()] = it.value().toArray(); + } + } + const QString defEntry = defaultEntry(); + if (!servers.contains(defEntry)) + servers.prepend(defEntry); + } + + void save() const { + QDir().mkpath(configDir()); + QFile f(configPath()); + if (!f.open(QIODevice::WriteOnly)) return; + + QJsonObject obj; + obj["currentServer"] = currentServer; + obj["defaultAccount"] = defaultAccount; + obj["mineThreads"] = mineThreads; + obj["mineThrottleUs"] = mineThrottleUs; + QJsonArray destArr; + for (auto& d : transferDestHistory) destArr.append(d); + obj["transferDestHistory"] = destArr; + QJsonArray assetArr; + for (auto& a : trackedAssets) assetArr.append(a.toJson()); + obj["trackedAssets"] = assetArr; + QJsonArray arr; + for (auto& s : servers) arr.append(s); + obj["servers"] = arr; + QJsonObject siObj; + for (auto it = serverInfo.begin(); it != serverInfo.end(); ++it) + siObj[it.key()] = it.value(); + obj["serverInfo"] = siObj; + + f.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); + } + + int findTrackedAsset(const QString& assetKey, const QString& server) const { + for (int i = 0; i < trackedAssets.size(); ++i) + if (trackedAssets[i].assetKey == assetKey && + trackedAssets[i].server == server) + return i; + return -1; + } + + bool addTrackedAsset(const TrackedAsset& a) { + if (findTrackedAsset(a.assetKey, a.server) >= 0) + return false; + trackedAssets.append(a); + save(); + return true; + } + + bool removeTrackedAsset(const QString& assetKey, const QString& server) { + int idx = findTrackedAsset(assetKey, server); + if (idx < 0) return false; + trackedAssets.removeAt(idx); + save(); + return true; + } + + void updateTrackedAsset(int idx, const TrackedAsset& a) { + if (idx >= 0 && idx < trackedAssets.size()) { + trackedAssets[idx] = a; + save(); + } + } + + void pushTransferDest(const QString& dest) { + transferDestHistory.removeAll(dest); + transferDestHistory.prepend(dest); + while (transferDestHistory.size() > kMaxTransferHistory) + transferDestHistory.removeLast(); + save(); + } +}; diff --git a/vendor/ces/src/cesqt/console.h b/vendor/ces/src/cesqt/console.h new file mode 100644 index 0000000..4e2e562 --- /dev/null +++ b/vendor/ces/src/cesqt/console.h @@ -0,0 +1,286 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================= +// ConsoleInput — input line with history, tab completion, esc to clear +// ============================================================================= + +// Display/retention caps for the console widgets. +namespace cesqt_console_limits { +constexpr int kConsoleMaxBlocks = 10000; // max visible log lines +constexpr int kConsoleMaxHistory = 100; // recalled command history depth +} + +class ConsoleInput : public QLineEdit { + Q_OBJECT +public: + explicit ConsoleInput(QWidget* parent = nullptr) : QLineEdit(parent) {} + + void setCommands(const QStringList& cmds) { + commands_.clear(); + subcommands_.clear(); + for (auto& c : cmds) { + int sp = c.indexOf(' '); + if (sp < 0) { + commands_ << c; + } else { + QString parent = c.left(sp); + QString sub = c.mid(sp + 1); + if (!commands_.contains(parent)) + commands_ << parent; + subcommands_[parent] << sub; + } + } + } + + // Dynamic completions provider (called at tab-time) + using DynCompleter = std::function; + void setDynamicCompleter(DynCompleter fn) { dynComplete_ = fn; } + + void pushHistory(const QString& cmd) { + if (!cmd.isEmpty() && + (history_.isEmpty() || history_.last() != cmd)) + history_.append(cmd); + while (history_.size() > cesqt_console_limits::kConsoleMaxHistory) + history_.removeFirst(); + historyPos_ = history_.size(); + draft_.clear(); + } + + const QStringList& history() const { return history_; } + +signals: + void tabComplete(const QString& options); + +protected: + bool event(QEvent* e) override { + if (e->type() == QEvent::KeyPress) { + auto* ke = static_cast(e); + if (ke->key() == Qt::Key_Tab) { + QString input = text().toLower(); + QString trimmed = input.trimmed(); + bool hasTrailingSpace = input.endsWith(' ') && !trimmed.isEmpty(); + + if (!trimmed.isEmpty()) { + // Try dynamic completer first (handles @N, etc.) + if (dynComplete_) { + QStringList dyn = dynComplete_(trimmed); + if (!dyn.isEmpty()) { + if (dyn.size() == 1) + setText(dyn[0] + " "); + else + emit tabComplete(dyn.join(" ")); + return true; + } + } + + int sp = trimmed.indexOf(' '); + if (sp >= 0) { + // Multi-word: complete subcommand + QString parent = trimmed.left(sp); + QString subPrefix = trimmed.mid(sp + 1); + + // Try dynamic for second word too + if (dynComplete_) { + QStringList dyn = dynComplete_(trimmed); + if (!dyn.isEmpty()) { + if (dyn.size() == 1) + setText(dyn[0] + " "); + else + emit tabComplete(dyn.join(" ")); + return true; + } + } + + auto it = subcommands_.find(parent); + if (it != subcommands_.end()) { + QStringList matches; + for (auto& s : it.value()) + if (s.startsWith(subPrefix)) matches << s; + if (matches.size() == 1) + setText(parent + " " + matches[0] + " "); + else if (matches.size() > 1) + emit tabComplete(matches.join(" ")); + } + } else if (hasTrailingSpace) { + // Exact command with trailing space: show subcommands + auto it = subcommands_.find(trimmed); + if (it != subcommands_.end()) { + QStringList& subs = it.value(); + if (subs.size() == 1) + setText(trimmed + " " + subs[0] + " "); + else if (subs.size() > 1) + emit tabComplete(subs.join(" ")); + } + } else { + // Partial first word: complete command names + QStringList matches; + for (auto& c : commands_) + if (c.startsWith(trimmed)) matches << c; + if (matches.size() == 1) + setText(matches[0] + " "); + else if (matches.size() > 1) + emit tabComplete(matches.join(" ")); + } + } + return true; + } + } + return QLineEdit::event(e); + } + + void keyPressEvent(QKeyEvent* e) override { + if (e->key() == Qt::Key_Up) { + if (!history_.isEmpty() && historyPos_ > 0) { + if (historyPos_ == history_.size()) + draft_ = text(); + --historyPos_; + setText(history_[historyPos_]); + } + return; + } + if (e->key() == Qt::Key_Down) { + if (historyPos_ < history_.size()) { + ++historyPos_; + setText(historyPos_ < history_.size() + ? history_[historyPos_] : draft_); + } + return; + } + if (e->key() == Qt::Key_Escape) { + clear(); + return; + } + QLineEdit::keyPressEvent(e); + } + +private: + QStringList history_; + QStringList commands_; + QMap subcommands_; + DynCompleter dynComplete_; + int historyPos_ = 0; + QString draft_; +}; + +// ============================================================================= +// ConsoleWidget — scrolling log output + interactive command input +// ============================================================================= + +class MainWindow; // forward declaration + +class ConsoleWidget : public QWidget { + Q_OBJECT +public: + void setApp(MainWindow* app) { app_ = app; } + ConsoleInput* input() { return input_; } + + explicit ConsoleWidget(QWidget* parent = nullptr) : QWidget(parent) { + auto* layout = new QVBoxLayout(this); + // Uniform chrome border on all four sides equal to the gap + // between the log pane and the input box, so the console reads + // as a framed pane on its tab rather than edge-to-edge content. + const int gap = 6; + layout->setSpacing(gap); + layout->setContentsMargins(gap, gap, gap, gap); + + log_ = new QPlainTextEdit; + log_->setReadOnly(true); + log_->setMaximumBlockCount(cesqt_console_limits::kConsoleMaxBlocks); + log_->setStyleSheet( + "QPlainTextEdit { background: #1e1e1e; color: #d4d4d4;" + " font-family: monospace; font-size: 10pt; }"); + layout->addWidget(log_); + + input_ = new ConsoleInput; + input_->setPlaceholderText( + "Type a command (Tab to complete, Up/Down for history)"); + input_->setStyleSheet( + "QLineEdit { background: #252526; color: #d4d4d4;" + " font-family: monospace; font-size: 10pt;" + " border: 1px solid #3c3c3c; padding: 4px; }"); + input_->setCommands({"help", "clear", "history", "bal", "bal all", + "keys", "send", "ping", "status", "tron", + "troff", "quit", "exit"}); + layout->addWidget(input_); + + connect(input_, &QLineEdit::returnPressed, + this, &ConsoleWidget::onCommand); + connect(input_, &ConsoleInput::tabComplete, [this](const QString& opts) { + appendCmd(" " + opts); + }); + + instance_ = this; + } + + ~ConsoleWidget() { if (instance_ == this) instance_ = nullptr; } + + // -- Output categories -- + // Log: background system events (gray, suppressible with troff) + void appendLog(const QString& msg) { + if (!trace_) return; + append(msg, QColor("#888888")); + } + // Command echo: user input (teal) + void appendEcho(const QString& msg) { append(msg, QColor("#4ec9b0")); } + // Command output: results from commands (white) + void appendCmd(const QString& msg) { append(msg, QColor("#ffffff")); } + // Error output (red) + void appendError(const QString& msg) { append(msg, QColor("#f44747")); } + + // Thread-safe global log. Can be called from any thread. + static void log(const QString& msg) { + if (!instance_) return; + QMetaObject::invokeMethod(instance_, [msg]() { + instance_->appendLog(msg); + }, Qt::QueuedConnection); + } + +private: + void append(const QString& msg, const QColor& color) { + QString ts = QDateTime::currentDateTime().toString("hh:mm:ss"); + QTextCharFormat fmt; + fmt.setForeground(color); + QTextCursor cursor = log_->textCursor(); + cursor.movePosition(QTextCursor::End); + cursor.insertText("[" + ts + "] " + msg + "\n", fmt); + log_->setTextCursor(cursor); + log_->ensureCursorVisible(); + } + +public: + +private slots: + void onCommand() { + QString cmd = input_->text().trimmed(); + input_->clear(); + if (cmd.isEmpty()) { + execCommand("help"); + return; + } + if (!cmd.startsWith("!")) + input_->pushHistory(cmd); + appendEcho("] " + cmd); + execCommand(cmd); + } + +private: + void execCommand(const QString& cmd); + + MainWindow* app_ = nullptr; + QPlainTextEdit* log_ = nullptr; + ConsoleInput* input_ = nullptr; + bool trace_ = true; + static inline ConsoleWidget* instance_ = nullptr; +}; diff --git a/vendor/ces/src/cesqt/main.cpp b/vendor/ces/src/cesqt/main.cpp new file mode 100644 index 0000000..83e541f --- /dev/null +++ b/vendor/ces/src/cesqt/main.cpp @@ -0,0 +1,4584 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "appmodel.h" +#include "rpcserver.h" +#include "console.h" +#include "about.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#endif + +// ConsoleInput and ConsoleWidget are in console.h + +// ============================================================================= +// UI timer tuning (cesqt main.cpp only) +// ============================================================================= +// Pure responsiveness knobs — not protocol-level. Grouped here so a reviewer +// can see all of them at once rather than hunting for scattered integer +// literals. Adjust freely; no test depends on exact values. + +namespace cesqt_timing { + +// MineWorker::sleepMs() poll granularity. Determines how quickly a running +// mining worker notices a stop request. +constexpr int kMineWorkerTickMs = 100; + +// Delay before retrying after a dropped mining connection. +constexpr int kMineReconnectMs = 3000; + +// Delay between successful mining rounds (throttle to avoid tripping the +// server's per-IP PoW rate limiter). +constexpr int kMineRoundDelayMs = 1000; + +// How long `stopAllWorkers()` waits for a worker thread to finish before +// considering it lost and returning. +constexpr int kWorkerJoinTimeoutMs = 10000; + +// Delay before `autoMineOnce()` fires on fresh-wallet startup (lets the +// rest of the UI settle first). +constexpr int kAutoMineStartDelayMs = 500; + +// Delay for the initial wallet query on refreshKeyList(). +constexpr int kWalletRefreshDelayMs = 100; + +} // namespace cesqt_timing + +// ============================================================================= +// KeyComboBox — QComboBox that copies the public key hex on Ctrl+C +// ============================================================================= + +class CopyableComboBox : public QComboBox { + Q_OBJECT +public: + using QComboBox::QComboBox; +protected: + void keyPressEvent(QKeyEvent* e) override { + if (e->matches(QKeySequence::Copy)) { + QApplication::clipboard()->setText(currentText()); + e->accept(); + return; + } + QComboBox::keyPressEvent(e); + } +}; + +class KeyComboBox : public CopyableComboBox { + Q_OBJECT +public: + using CopyableComboBox::CopyableComboBox; +protected: + void keyPressEvent(QKeyEvent* e) override { + if (e->matches(QKeySequence::Copy)) { + // Extract pubkey hex: last space-separated token in "@N algo PUBKEY" + QString text = currentText(); + int lastSpace = text.lastIndexOf(' '); + if (lastSpace >= 0) { + QApplication::clipboard()->setText(text.mid(lastSpace + 1)); + } else { + QApplication::clipboard()->setText(text); + } + e->accept(); + return; + } + CopyableComboBox::keyPressEvent(e); + } +}; + +// Build a display string for a wallet key: "@N [algo] label pubkey..." +// If label is non-empty, it replaces the pubkey in the display. +static QString formatKeyLabel(int index, const ces::KeyPair& kp, + const std::string& label) { + QString s = QString("@%1 %2 %3") + .arg(index) + .arg(QString::fromUtf8(ces::Wallet::algoLabel(kp))) + .arg(QString::fromStdString(kp.getPublicKeyHexStr())); + if (!label.empty()) + s += " " + QString::fromStdString(label); + return s; +} + +// ============================================================================= +// KeyManagerWidget — wallet key management +// ============================================================================= + +class KeyManagerWidget : public QWidget { + Q_OBJECT +public: + explicit KeyManagerWidget(ConsoleWidget* console, + AppConfig& config, + QWidget* parent = nullptr) + : QWidget(parent), console_(console), config_(config) { + auto* layout = new QVBoxLayout(this); + + walletLabel_ = new QLabel("No wallet loaded"); + walletLabel_->setStyleSheet("color: #888; font-style: italic;"); + layout->addWidget(walletLabel_); + + table_ = new QTableWidget(0, 4); + table_->setHorizontalHeaderLabels({"#", "Algorithm", "Label", "Public Key"}); + table_->horizontalHeader()->setStretchLastSection(true); + table_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + table_->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + table_->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + table_->setSelectionMode(QAbstractItemView::SingleSelection); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + layout->addWidget(table_); + + // Double-click label cell to edit + connect(table_, &QTableWidget::cellDoubleClicked, + this, [this](int row, int col) { + if (col != 2 || row < 0 || row >= wallet_.size()) return; + bool ok; + QString current = QString::fromStdString(wallet_.label(row)); + QString label = QInputDialog::getText(this, "Edit Label", + "Label for @" + QString::number(row) + ":", + QLineEdit::Normal, current, &ok); + if (!ok) return; + wallet_.setLabel(row, label.toStdString()); + saveAndRefresh(); + }); + + auto* btnRow = new QHBoxLayout; + + auto* btnGen = new QPushButton("Generate Key"); + connect(btnGen, &QPushButton::clicked, this, &KeyManagerWidget::generateKey); + btnRow->addWidget(btnGen); + + auto* btnImport = new QPushButton("Import Key"); + connect(btnImport, &QPushButton::clicked, this, &KeyManagerWidget::importKey); + btnRow->addWidget(btnImport); + + auto* btnCopyPub = new QPushButton("Copy Public Key"); + connect(btnCopyPub, &QPushButton::clicked, this, &KeyManagerWidget::copyPublicKey); + btnRow->addWidget(btnCopyPub); + + auto* btnCopyPriv = new QPushButton("Export Private Key"); + connect(btnCopyPriv, &QPushButton::clicked, this, &KeyManagerWidget::exportPrivateKey); + btnRow->addWidget(btnCopyPriv); + + auto* btnDelete = new QPushButton("Delete Key"); + connect(btnDelete, &QPushButton::clicked, this, &KeyManagerWidget::deleteKey); + btnRow->addWidget(btnDelete); + + layout->addLayout(btnRow); + loadWallet(); + } + + const ces::Wallet& wallet() const { return wallet_; } + ces::Wallet& wallet() { return wallet_; } + +signals: + void statusMessage(const QString& msg, int timeout); + void walletChanged(); + +private slots: + void generateKey() { + QStringList algos = {"ED25519", "SECP256K1"}; + bool ok; + QString chosen = QInputDialog::getItem(this, "Generate Key", + "Algorithm:", algos, 0, false, &ok); + if (!ok) return; + + ces::KeyAlgo algo = (chosen == "SECP256K1") + ? ces::KeyAlgo::SECP256K1 : ces::KeyAlgo::ED25519; + wallet_.generate(1, algo); + log("Generated new " + chosen + " key."); + saveAndRefresh(); + } + + void importKey() { + bool ok; + QString hex = QInputDialog::getText(this, "Import Key", + "Private key (64 or 66 hex chars):", QLineEdit::Normal, "", &ok); + if (!ok || hex.isEmpty()) return; + + try { + bool added = wallet_.addKey(hex.toStdString()); + if (!added) { + log("Import: key already exists in wallet."); + QMessageBox::information(this, "Import", "Key already exists in wallet."); + return; + } + log("Imported key successfully."); + saveAndRefresh(); + } catch (std::exception& e) { + log(QString("Import error: %1").arg(e.what())); + QMessageBox::warning(this, "Import Error", e.what()); + } + } + + void copyPublicKey() { + int row = selectedRow(); + if (row < 0) return; + auto kp = wallet_.keyPair(row); + QApplication::clipboard()->setText( + QString::fromStdString(kp.getPublicKeyHexStr())); + emit statusMessage("Public key copied to clipboard", 3000); + } + + void exportPrivateKey() { + int row = selectedRow(); + if (row < 0) return; + + auto reply = QMessageBox::warning(this, "Export Private Key", + "This will copy the PRIVATE key to your clipboard.\n" + "Anyone with this key can control the account.\n\n" + "Continue?", + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (reply != QMessageBox::Yes) return; + + QApplication::clipboard()->setText( + QString::fromStdString(wallet_.keyHex(row))); + emit statusMessage("Private key copied to clipboard", 3000); + } + + void deleteKey() { + int row = selectedRow(); + if (row < 0) return; + + auto kp = wallet_.keyPair(row); + auto reply = QMessageBox::question(this, "Delete Key", + QString("Delete key @%1 (%2)?\n\nThis cannot be undone.") + .arg(row) + .arg(QString::fromStdString(kp.getPublicKeyHexStr()).left(16) + "..."), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (reply != QMessageBox::Yes) return; + + ces::Wallet newWallet; + for (int i = 0; i < wallet_.size(); ++i) { + if (i != row) + newWallet.addKey(wallet_.keyHex(i)); + } + wallet_ = std::move(newWallet); + log(QString("Deleted key @%1.").arg(row)); + saveAndRefresh(); + } + +private: + ces::Wallet wallet_; + QTableWidget* table_ = nullptr; + QLabel* walletLabel_ = nullptr; + ConsoleWidget* console_ = nullptr; + AppConfig& config_; + std::string walletPath_; + bool generatedDefaultKey_ = false; +public: + bool didGenerateDefaultKey() const { return generatedDefaultKey_; } +private: + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } + + int selectedRow() { + auto sel = table_->selectionModel()->selectedRows(); + if (sel.isEmpty()) { + emit statusMessage("No key selected", 3000); + return -1; + } + return sel.first().row(); + } + + QString resolveWalletPath() { + QString dataDir = config_.configDir(); + QDir().mkpath(dataDir); + return dataDir + "/wallet"; + } + + void loadWallet() { + try { + log("Loading wallet..."); + walletPath_ = resolveWalletPath().toStdString(); + log(QString("Wallet path: %1").arg(QString::fromStdString(walletPath_))); + + if (std::filesystem::exists(walletPath_)) { + wallet_.loadFromFile(walletPath_); + log(QString("Loaded %1 key(s).").arg(wallet_.size())); + } + if (wallet_.empty()) { + log("No keys found. Generating default ED25519 key..."); + wallet_.generate(1, ces::KeyAlgo::ED25519); + wallet_.saveToFile(walletPath_); + log("Created wallet with 1 ED25519 key."); + generatedDefaultKey_ = true; + } + + walletLabel_->setText(QString("Wallet: %1 (%2 keys)") + .arg(QString::fromStdString(walletPath_)) + .arg(wallet_.size())); + walletLabel_->setStyleSheet("color: #ccc;"); + } catch (std::exception& e) { + log(QString("Error loading wallet: %1").arg(e.what())); + walletLabel_->setText(QString("Error: %1").arg(e.what())); + } + refreshTable(); + } + +public: + void saveAndRefresh() { + try { + wallet_.saveToFile(walletPath_); + walletLabel_->setText(QString("Wallet: %1 (%2 keys)") + .arg(QString::fromStdString(walletPath_)) + .arg(wallet_.size())); + log(QString("Wallet saved (%1 keys).").arg(wallet_.size())); + } catch (std::exception& e) { + log(QString("Save error: %1").arg(e.what())); + QMessageBox::warning(this, "Save Error", + QString("Failed to save wallet: %1").arg(e.what())); + } + refreshTable(); + emit walletChanged(); + } + + void refreshTable() { + table_->setRowCount(wallet_.size()); + for (int i = 0; i < wallet_.size(); ++i) { + auto kp = wallet_.keyPair(i); + + auto* idxItem = new QTableWidgetItem(QString("@%1").arg(i)); + idxItem->setTextAlignment(Qt::AlignCenter); + table_->setItem(i, 0, idxItem); + + auto* algoItem = new QTableWidgetItem( + QString::fromUtf8(ces::Wallet::algoLabel(kp))); + algoItem->setTextAlignment(Qt::AlignCenter); + table_->setItem(i, 1, algoItem); + + auto* labelItem = new QTableWidgetItem( + QString::fromStdString(wallet_.label(i))); + table_->setItem(i, 2, labelItem); + + auto* pubItem = new QTableWidgetItem( + QString::fromStdString(kp.getPublicKeyHexStr())); + table_->setItem(i, 3, pubItem); + } + } +}; + +// ============================================================================= +// AccountQueryWorker — queries account balance on a background thread +// ============================================================================= + +class AccountQueryWorker : public QThread { + Q_OBJECT +public: + AccountQueryWorker(const QString& server, const std::string& pubKeyHex, + QObject* parent = nullptr) + : QThread(parent), server_(server), pubKeyHex_(pubKeyHex) {} + +signals: + void queryResult(bool ok, int64_t balance, uint32_t nonce, + QString lastDest, uint64_t lastAmount, uint32_t lastTime, + QString error); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(server_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.start(0); + + if (!client.connect()) { + emit queryResult(false, 0, 0, "", 0, 0, + QString("Connect failed (%1)").arg(probe.isTcp ? "TCP" : "UDP")); + client.stop(); + return; + } + + minx::Hash h; + minx::stringToHash(h, pubKeyHex_); + auto mapKey = ces::Account::getMapKey(h); + + int64_t balance = 0; + uint32_t nonce = 0; + ces::HashPrefix lastDest{}; + uint64_t lastAmount = 0; + uint32_t lastTime = 0; + + uint8_t rc = client.queryAccount(mapKey, balance, nonce, + lastDest, lastAmount, lastTime); + + client.disconnect(); + client.stop(); + + if (rc == ces::CES_OK) { + QString notFound = (balance == 0 && nonce == 0) ? "not found" : ""; + emit queryResult(true, balance, nonce, + QString::fromStdString(ces::hashPrefixToString(lastDest)), + lastAmount, lastTime, notFound); + } else { + emit queryResult(false, 0, 0, "", 0, 0, + QString::fromStdString(ces::errorString(rc))); + } + } catch (std::exception& e) { + emit queryResult(false, 0, 0, "", 0, 0, e.what()); + } + } + +private: + QString server_; + std::string pubKeyHex_; +}; + +// ============================================================================= +// AccountWidget — main wallet view with balance display +// ============================================================================= + +class AccountWidget : public QWidget { + Q_OBJECT +public: + explicit AccountWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + + // Key selector row + auto* selRow = new QHBoxLayout; + selRow->addWidget(new QLabel("Account:")); + keyCombo_ = new KeyComboBox; + keyCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + connect(keyCombo_, QOverload::of(&QComboBox::currentIndexChanged), + this, &AccountWidget::onKeyChanged); + selRow->addWidget(keyCombo_); + + auto* btnQuery = new QPushButton("Query"); + connect(btnQuery, &QPushButton::clicked, this, &AccountWidget::querySelected); + selRow->addWidget(btnQuery); + layout->addLayout(selRow); + + // Info display + auto* infoGrid = new QVBoxLayout; + + balanceLabel_ = new QLabel("Balance: —"); + balanceLabel_->setStyleSheet("font-size: 18pt; font-weight: bold; color: #4ec9b0;"); + infoGrid->addWidget(balanceLabel_); + + nonceLabel_ = new QLabel("Nonce: —"); + nonceLabel_->setStyleSheet("color: #ccc;"); + infoGrid->addWidget(nonceLabel_); + + lastXferLabel_ = new QLabel("Last transfer: —"); + lastXferLabel_->setStyleSheet("color: #ccc;"); + infoGrid->addWidget(lastXferLabel_); + + statusLabel_ = new QLabel(""); + statusLabel_->setStyleSheet("color: #888; font-style: italic;"); + infoGrid->addWidget(statusLabel_); + + layout->addLayout(infoGrid); + layout->addStretch(); + + refreshKeyList(); + } + +public slots: + void refreshKeyList() { refreshKeyListImpl(true); } + void refreshKeyListNoQuery() { refreshKeyListImpl(false); } + void refreshKeyListImpl(bool autoQuery) { + // Block signals to avoid triggering onKeyChanged during rebuild + keyCombo_->blockSignals(true); + keyCombo_->clear(); + auto& w = keyManager_.wallet(); + for (int i = 0; i < w.size(); ++i) { + auto kp = w.keyPair(i); + keyCombo_->addItem(formatKeyLabel(i, kp, w.label(i)), i); + } + if (w.empty()) { + balanceLabel_->setText("Balance: —"); + nonceLabel_->setText("Nonce: —"); + lastXferLabel_->setText("Last transfer: —"); + statusLabel_->setText("No keys in wallet."); + } else { + // Restore saved selection (clamped to valid range) + int sel = std::clamp(config_.defaultAccount, 0, w.size() - 1); + keyCombo_->setCurrentIndex(sel); + } + keyCombo_->blockSignals(false); + + // Auto-query the selected account (unless suppressed) + if (autoQuery && !w.empty()) + QTimer::singleShot(0, this, &AccountWidget::querySelected); + } + + void addCredit(uint64_t credit) { + currentBalance_ += static_cast(credit); + balanceLabel_->setText(QString("Balance: %1").arg(formatAmount(currentBalance_))); + } + + void setBalance(int64_t balance) { + currentBalance_ = balance; + balanceLabel_->setText(QString("Balance: %1").arg(formatAmount(currentBalance_))); + } + + int selectedAccountIndex() const { + if (keyCombo_->count() == 0) return -1; + return keyCombo_->currentData().toInt(); + } + +signals: + void statusMessage(const QString& msg, int timeout); + void balanceUpdated(int accountIndex, int64_t balance); + +private slots: + void onKeyChanged(int index) { + if (index < 0) return; + config_.defaultAccount = index; + config_.save(); + querySelected(); + } + +public: + void querySelected() { + if (keyCombo_->count() == 0) { + statusLabel_->setText("No keys to query."); + return; + } + if (config_.currentServer.isEmpty()) { + statusLabel_->setText("No current server set."); + log("Account query failed: no current server configured."); + return; + } + if (querying_) return; + + int idx = keyCombo_->currentData().toInt(); + auto& w = keyManager_.wallet(); + if (idx < 0 || idx >= w.size()) return; + + auto kp = w.keyPair(idx); + std::string pubHex = kp.getPublicKeyHexStr(); + + querying_ = true; + statusLabel_->setText("Querying..."); + statusLabel_->setStyleSheet("color: #dcdcaa; font-style: italic;"); + log(QString("Querying account @%1 on %2...") + .arg(idx).arg(config_.currentServer)); + + auto* worker = new AccountQueryWorker(config_.currentServer, pubHex, this); + connect(worker, &AccountQueryWorker::queryResult, + this, &AccountWidget::onQueryResult); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + + void onQueryResult(bool ok, int64_t balance, uint32_t nonce, + QString lastDest, uint64_t lastAmount, uint32_t lastTime, + QString error) { + querying_ = false; + if (!ok) { + statusLabel_->setText("Error: " + error); + statusLabel_->setStyleSheet("color: #f44747; font-style: italic;"); + log("Account query error: " + error); + return; + } + + if (error == "not found") { + balanceLabel_->setText("Balance: 0 (account not found)"); + nonceLabel_->setText("Nonce: —"); + lastXferLabel_->setText("Last transfer: —"); + statusLabel_->setText("Account does not exist on server yet."); + statusLabel_->setStyleSheet("color: #888; font-style: italic;"); + log("Account not found on server."); + return; + } + + balanceLabel_->setText(QString("Balance: %1").arg(formatAmount(balance))); + nonceLabel_->setText(QString("Nonce: %1").arg(nonce)); + + if (lastAmount > 0) { + lastXferLabel_->setText( + QString("Last transfer: %1 to %2 (t=%3)") + .arg(formatAmount(lastAmount)).arg(lastDest).arg(lastTime)); + } else { + lastXferLabel_->setText("Last transfer: none"); + } + + currentBalance_ = balance; + statusLabel_->setText("Queried successfully."); + statusLabel_->setStyleSheet("color: #4ec9b0; font-style: italic;"); + log(QString("Account balance: %1, nonce: %2").arg(formatAmount(balance)).arg(nonce)); + + emit balanceUpdated(selectedAccountIndex(), balance); + } + +private: + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + QComboBox* keyCombo_ = nullptr; + QLabel* balanceLabel_ = nullptr; + QLabel* nonceLabel_ = nullptr; + QLabel* lastXferLabel_ = nullptr; + QLabel* statusLabel_ = nullptr; + bool querying_ = false; + int64_t currentBalance_ = 0; + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } +}; + +// ============================================================================= +// WalletQueryWorker — queries ALL accounts on a background thread +// ============================================================================= + +struct AccountBalance { + int index; + QString pubKey; // truncated for display + QString fullPubKey; + int64_t balance = 0; + bool exists = false; +}; + +class WalletQueryWorker : public QThread { + Q_OBJECT +public: + WalletQueryWorker(const QString& server, + const std::vector& pubKeys, + QObject* parent = nullptr) + : QThread(parent), server_(server), pubKeys_(pubKeys) {} + +signals: + void allQueried(bool ok, QList accounts, int64_t total, + QString error); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(server_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.start(0); + + if (!client.connect()) { + emit allQueried(false, {}, 0, + QString("Connect failed (%1)").arg(probe.isTcp ? "TCP" : "UDP")); + client.stop(); + return; + } + + QList results; + int64_t total = 0; + + for (size_t i = 0; i < pubKeys_.size(); ++i) { + minx::Hash h; + minx::stringToHash(h, pubKeys_[i]); + auto mapKey = ces::Account::getMapKey(h); + + int64_t balance = 0; + uint32_t nonce = 0; + ces::HashPrefix lastDest{}; + uint64_t lastAmount = 0; + uint32_t lastTime = 0; + + uint8_t rc = client.queryAccount(mapKey, balance, nonce, + lastDest, lastAmount, lastTime); + + AccountBalance ab; + ab.index = static_cast(i); + ab.fullPubKey = QString::fromStdString(pubKeys_[i]); + ab.pubKey = ab.fullPubKey.left(8) + "..." + ab.fullPubKey.right(8); + + if (rc == ces::CES_OK && !(balance == 0 && nonce == 0)) { + ab.balance = balance; + ab.exists = true; + total += balance; + } + results.append(ab); + } + + client.disconnect(); + client.stop(); + + emit allQueried(true, results, total, ""); + } catch (std::exception& e) { + emit allQueried(false, {}, 0, e.what()); + } + } + +private: + QString server_; + std::vector pubKeys_; +}; + +// ============================================================================= +// ClickableLabel — QLabel that emits clicked() +// ============================================================================= + +class ClickableLabel : public QLabel { + Q_OBJECT +public: + using QLabel::QLabel; +signals: + void clicked(); +protected: + void mousePressEvent(QMouseEvent* e) override { + emit clicked(); + QLabel::mousePressEvent(e); + } +}; + +// ============================================================================= +// WalletWidget — aggregate balance view with animated counter +// ============================================================================= + +class WalletWidget : public QWidget { + Q_OBJECT +public: + explicit WalletWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(16, 16, 16, 16); + + // --- Balance card --- + balanceCard_ = new QFrame; + balanceCard_->setStyleSheet( + "QFrame {" + " background: qlineargradient(x1:0, y1:0, x2:1, y2:1," + " stop:0 #1a2332, stop:1 #0d1520);" + " border-radius: 16px;" + "}"); + balanceCard_->setFixedHeight(220); + balanceCard_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + auto* shadow = new QGraphicsDropShadowEffect; + shadow->setBlurRadius(30); + shadow->setColor(QColor(0, 0, 0, 120)); + shadow->setOffset(0, 4); + balanceCard_->setGraphicsEffect(shadow); + + auto* cardLayout = new QVBoxLayout(balanceCard_); + cardLayout->setContentsMargins(24, 16, 24, 16); + cardLayout->setSpacing(4); + + // Title row with refresh link + auto* titleRow = new QHBoxLayout; + auto* titleLabel = new QLabel("Total Balance"); + titleLabel->setStyleSheet( + "color: #6b7b8d; font-size: 11pt; font-weight: bold;" + " letter-spacing: 2px; background: transparent;"); + titleRow->addWidget(titleLabel); + titleRow->addStretch(); + + auto* refreshLink = new ClickableLabel("refresh"); + refreshLink->setStyleSheet( + "color: #4a6a7a; font-size: 9pt; background: transparent;" + " text-decoration: underline;"); + refreshLink->setCursor(Qt::PointingHandCursor); + connect(refreshLink, &ClickableLabel::clicked, + this, &WalletWidget::queryAllFull); + titleRow->addWidget(refreshLink); + cardLayout->addLayout(titleRow); + + balanceLabel_ = new QLabel("—"); + balanceLabel_->setStyleSheet( + "color: #4ec9b0; font-size: 32pt; font-weight: bold;" + " font-family: 'Courier New', monospace; background: transparent;"); + balanceLabel_->setMinimumHeight(50); + cardLayout->addWidget(balanceLabel_); + + statusLabel_ = new QLabel(""); + statusLabel_->setStyleSheet( + "color: #4a5568; font-size: 9pt; font-style: italic;" + " background: transparent;"); + cardLayout->addWidget(statusLabel_); + + layout->addWidget(balanceCard_); + + // --- Per-key breakdown --- + auto* breakdownLabel = new QLabel("Accounts"); + breakdownLabel->setStyleSheet( + "color: #6b7b8d; font-size: 10pt; font-weight: bold;" + " margin-top: 12px;"); + layout->addWidget(breakdownLabel); + + keyTable_ = new QTableWidget(0, 2); + keyTable_->setHorizontalHeaderLabels({"Address", "Balance"}); + keyTable_->verticalHeader()->setVisible(false); + keyTable_->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::Stretch); + keyTable_->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::ResizeToContents); + keyTable_->setSelectionBehavior(QAbstractItemView::SelectRows); + keyTable_->setSelectionMode(QAbstractItemView::SingleSelection); + keyTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); + keyTable_->setShowGrid(false); + keyTable_->setStyleSheet( + "QTableWidget { background: #1a1e26; color: #ccc;" + " border: none; }" + "QTableWidget::item { padding: 4px 8px; }" + "QHeaderView::section { background: #1a1e26; color: #6b7b8d;" + " border: none; padding: 4px 8px; }"); + layout->addWidget(keyTable_); + + // Animation timer (~30 fps) + animTimer_ = new QTimer(this); + animTimer_->setInterval(33); + connect(animTimer_, &QTimer::timeout, + this, &WalletWidget::animationTick); + + // Auto-refresh timer (3 minutes) + autoRefreshTimer_ = new QTimer(this); + autoRefreshTimer_->setInterval(180000); + connect(autoRefreshTimer_, &QTimer::timeout, + this, &WalletWidget::autoRefresh); + autoRefreshTimer_->start(); + + refreshKeyList(); + } + +public slots: + void refreshKeyList() { + // Wallet changed — clear known-existing cache, query all + knownExisting_.clear(); + QTimer::singleShot(cesqt_timing::kWalletRefreshDelayMs, this, + &WalletWidget::queryAllFull); + } + + void addCredit(int accountIndex, uint64_t credit) { + targetBalance_ += static_cast(credit); + if (!animTimer_->isActive()) + animTimer_->start(); + + // Update per-key table if this account is in it + for (auto& a : accounts_) { + if (a.index == accountIndex) { + a.exists = true; + a.balance += static_cast(credit); + knownExisting_.insert(accountIndex); + updateTable(); + break; + } + } + } + + // Full query: all accounts (used on first load, refresh click, wallet change) + void queryAllFull() { + queryAccounts(false); + } + +signals: + void statusMessage(const QString& msg, int timeout); + void walletQueryDone(int64_t total, int existingCount); + +private slots: + // Incremental query: only known-existing accounts (used by auto-refresh) + void queryAccounts(bool existingOnly) { + auto& w = keyManager_.wallet(); + if (w.empty()) { + balanceLabel_->setText("—"); + statusLabel_->setText("No keys in wallet."); + setStatusStyle("#4a5568"); + keyTable_->setRowCount(0); + return; + } + if (config_.currentServer.isEmpty()) { + statusLabel_->setText("No current server configured."); + setStatusStyle("#f44747"); + return; + } + if (querying_) return; + + // Build the list of keys to query + std::vector pubKeys; + queryIndices_.clear(); + + for (int i = 0; i < w.size(); ++i) { + if (!existingOnly || knownExisting_.contains(i)) { + pubKeys.push_back(w.keyPair(i).getPublicKeyHexStr()); + queryIndices_.push_back(i); + } + } + + if (pubKeys.empty()) { + // No known accounts to refresh — nothing to do + return; + } + + querying_ = true; + int total = w.size(); + int queried = static_cast(pubKeys.size()); + if (existingOnly && queried < total) + statusLabel_->setText(QString("Refreshing %1 account(s)...") + .arg(queried)); + else + statusLabel_->setText("Querying all accounts..."); + setStatusStyle("#dcdcaa"); + log(QString("Wallet: querying %1 account(s)...").arg(queried)); + + auto* worker = new WalletQueryWorker( + config_.currentServer, pubKeys, this); + connect(worker, &WalletQueryWorker::allQueried, + this, [this, existingOnly](bool ok, QList accounts, + int64_t total, QString error) { + onAllQueried(ok, accounts, total, error, existingOnly); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + + void onAllQueried(bool ok, QList accounts, + int64_t total, QString error, bool wasIncremental) { + querying_ = false; + + if (!ok) { + statusLabel_->setText("Error: " + error); + setStatusStyle("#f44747"); + log("Wallet query error: " + error); + return; + } + + // Remap indices from worker results back to wallet indices + for (int i = 0; i < accounts.size(); ++i) + accounts[i].index = queryIndices_[i]; + + if (wasIncremental) { + // Merge results into existing accounts_ list + for (auto& a : accounts) { + for (auto& existing : accounts_) { + if (existing.index == a.index) { + existing.balance = a.balance; + existing.exists = a.exists; + break; + } + } + } + // Recalculate total from merged data + total = 0; + for (auto& a : accounts_) + if (a.exists) total += a.balance; + } else { + // Full query — replace everything + accounts_ = accounts; + // Update known-existing cache + knownExisting_.clear(); + for (auto& a : accounts_) + if (a.exists) knownExisting_.insert(a.index); + } + + updateTable(); + + // Animate toward new total + targetBalance_ = total; + if (!animTimer_->isActive()) + animTimer_->start(); + + QString ts = QDateTime::currentDateTime().toString("hh:mm:ss"); + int existCount = 0; + for (auto& a : accounts_) + if (a.exists) existCount++; + statusLabel_->setText(QString("%1 account(s) | updated %2") + .arg(existCount).arg(ts)); + setStatusStyle("#4a5568"); + log(QString("Wallet total: %1 (%2 accounts)") + .arg(formatAmount(total)).arg(existCount)); + emit walletQueryDone(total, existCount); + } + + void animationTick() { + double diff = static_cast(targetBalance_) - displayedBalance_; + + if (std::abs(diff) < 1.0) { + displayedBalance_ = static_cast(targetBalance_); + animTimer_->stop(); + } else { + // Exponential easing — fast start, slow approach + displayedBalance_ += diff * 0.12; + } + + balanceLabel_->setText( + formatAmount(static_cast(std::round(displayedBalance_)))); + } + + void autoRefresh() { + if (!isVisible()) return; + QWidget* w = window(); + if (w && w->isMinimized()) return; + // Auto-refresh only queries known-existing accounts + queryAccounts(true); + } + +private: + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + + QFrame* balanceCard_ = nullptr; + QLabel* balanceLabel_ = nullptr; + QLabel* statusLabel_ = nullptr; + QTableWidget* keyTable_ = nullptr; + + QTimer* animTimer_ = nullptr; + QTimer* autoRefreshTimer_ = nullptr; + + bool querying_ = false; + int64_t targetBalance_ = 0; + double displayedBalance_ = 0.0; +public: + const QList& getAccounts() const { return accounts_; } +private: + QList accounts_; + QSet knownExisting_; + std::vector queryIndices_; + + void updateTable() { + int rowCount = 0; + for (auto& a : accounts_) + if (a.exists) rowCount++; + + keyTable_->setRowCount(rowCount); + int row = 0; + for (auto& a : accounts_) { + if (!a.exists) continue; + + auto* addrItem = new QTableWidgetItem( + QString("@%1 %2").arg(a.index).arg(a.pubKey)); + addrItem->setToolTip(a.fullPubKey); + keyTable_->setItem(row, 0, addrItem); + + auto* balItem = new QTableWidgetItem(formatAmount(a.balance)); + balItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + balItem->setForeground(QColor("#4ec9b0")); + keyTable_->setItem(row, 1, balItem); + row++; + } + } + + void setStatusStyle(const char* color) { + statusLabel_->setStyleSheet( + QString("color: %1; font-size: 9pt; font-style: italic;" + " background: transparent;").arg(color)); + } + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } +}; + +// ============================================================================= +// MineWorker — runs mining loop on a background thread +// ============================================================================= + +class MineWorker : public QThread { + Q_OBJECT +public: + MineWorker(const QString& server, const ces::KeyPair& keyPair, + int extraDifficulty, int numThreads, int throttleUs, + QObject* parent = nullptr) + : QThread(parent), server_(server), keyPair_(keyPair), + extraDiff_(extraDifficulty), numThreads_(numThreads), + throttleUs_(throttleUs) {} + + void requestStop() { stop_.store(true); } + +signals: + void mineStatus(const QString& status); + void solutionFound(uint64_t credit, int resultCode); + void mineError(const QString& error); + void mineFinished(); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(server_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(true); // need dataset for mining + auto& client = *clientPtr; + client.start(0); + client.setKey(keyPair_); + if (!client.connect()) { + emit mineError(QString("Connect failed (%1)") + .arg(probe.isTcp ? "TCP" : "UDP")); + return; + } + + emit mineStatus("Connected. Starting mining loop..."); + + int round = 0; + while (!stop_.load()) { + round++; + emit mineStatus(QString("Round %1: computing proof of work...").arg(round)); + + // Mine in batches so stop_ is checked between them. maxIters (batchSize) + // is the TOTAL nonce span the worker threads share (one atomic counter), + // not a per-thread count, and mine() re-acquires the ticket and rebuilds + // the hash setup on every call -- so size the batch per thread to + // amortize that overhead, and advance the nonce by exactly the span + // tested (advancing by batchSize*threads would skip untested nonces). + const int nThreads = std::max(numThreads_, 1); + const uint64_t batchSize = + static_cast(throttleUs_ > 0 ? 8 : 256) * nThreads; + uint64_t nonce = 0; + auto w = [&]() -> std::optional { + while (!stop_.load()) { + auto r = client.mine(extraDiff_, {}, numThreads_, nonce, batchSize); + if (r) return r; + nonce += batchSize; + if (throttleUs_ > 0) + std::this_thread::sleep_for(std::chrono::microseconds(throttleUs_)); + } + return {}; + }(); + + if (!w) { + if (stop_.load()) break; + emit mineStatus(QString("Round %1: mine() returned no work. " + "Reconnecting...").arg(round)); + client.disconnect(); + sleepMs(cesqt_timing::kMineReconnectMs); + if (stop_.load()) break; + if (!client.connect()) { + emit mineError("Reconnect failed"); + break; + } + continue; + } + if (stop_.load()) break; + + emit mineStatus(QString("Round %1: solution found, submitting...") + .arg(round)); + + // Submit — proveWork already retries internally (3x send + 3x query) + minx::Hash b; + uint64_t credit = 0, t = 0; + int r = client.proveWork(*w, b, credit, t); + + if (r == minx::MINX_SOLUTION_SPENT) { + emit solutionFound(credit, r); + emit mineStatus(QString("Round %1: accepted! Credit %2") + .arg(round).arg(formatAmount(credit))); + } else if (r == minx::MINX_SOLUTION_UNTIMELY) { + emit mineStatus(QString("Round %1: untimely (too old), mining new...") + .arg(round)); + } else if (r == minx::MINX_SOLUTION_UNSPENT) { + emit mineStatus(QString("Round %1: unspent (server hasn't processed yet?), mining new...") + .arg(round)); + } else { + emit mineStatus(QString("Round %1: response lost (likely spent), continuing...") + .arg(round)); + } + + // Brief delay between rounds to avoid triggering server's per-IP + // PoW rate limiter (filterPoW) when solutions come in fast + sleepMs(cesqt_timing::kMineRoundDelayMs); + } + + client.disconnect(); + client.stop(); + } catch (std::exception& e) { + emit mineError(QString(e.what())); + } + emit mineFinished(); + } + +private: + QString server_; + ces::KeyPair keyPair_; + int extraDiff_; + int numThreads_; + int throttleUs_; + std::atomic stop_{false}; + + void sleepMs(int ms) { + using cesqt_timing::kMineWorkerTickMs; + for (int i = 0; i < ms && !stop_.load(); i += kMineWorkerTickMs) + std::this_thread::sleep_for(std::chrono::milliseconds(kMineWorkerTickMs)); + } +}; + +// ============================================================================= +// MiningWidget — mining controls and stats +// ============================================================================= + +class MiningWidget : public QWidget { + Q_OBJECT +public: + explicit MiningWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + + // Account selector + auto* acctRow = new QHBoxLayout; + acctRow->addWidget(new QLabel("Mining account:")); + keyCombo_ = new KeyComboBox; + keyCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + acctRow->addWidget(keyCombo_); + layout->addLayout(acctRow); + + // Settings row + auto* settingsRow = new QHBoxLayout; + settingsRow->addWidget(new QLabel("Threads:")); + threadSpin_ = new QSpinBox; + threadSpin_->setRange(1, 64); + threadSpin_->setValue(config_.mineThreads); + connect(threadSpin_, QOverload::of(&QSpinBox::valueChanged), + this, [this](int val) { + config_.mineThreads = val; + config_.save(); + }); + settingsRow->addWidget(threadSpin_); + settingsRow->addSpacing(16); + settingsRow->addWidget(new QLabel("Throttle (\xc2\xb5s):")); + throttleSpin_ = new QSpinBox; + throttleSpin_->setRange(0, 1000000); + throttleSpin_->setSingleStep(100); + throttleSpin_->setValue(config_.mineThrottleUs); + throttleSpin_->setToolTip("Microseconds to sleep between hash batches (0 = full speed)"); + connect(throttleSpin_, QOverload::of(&QSpinBox::valueChanged), + this, [this](int val) { + config_.mineThrottleUs = val; + config_.save(); + }); + settingsRow->addWidget(throttleSpin_); + settingsRow->addStretch(); + layout->addLayout(settingsRow); + + // Difficulty info + diffLabel_ = new QLabel("Target difficulty: —"); + diffLabel_->setStyleSheet("color: #ccc;"); + layout->addWidget(diffLabel_); + + // Start/Stop button + startStopBtn_ = new QPushButton("Start Mining"); + startStopBtn_->setStyleSheet( + "QPushButton { padding: 8px 24px; font-size: 12pt; font-weight: bold; }"); + connect(startStopBtn_, &QPushButton::clicked, + this, &MiningWidget::toggleMining); + layout->addWidget(startStopBtn_); + + // Status + statusLabel_ = new QLabel("Idle"); + statusLabel_->setStyleSheet("color: #888; font-style: italic;"); + layout->addWidget(statusLabel_); + + // Stats + auto* statsBox = new QGroupBox("Session Stats"); + auto* statsLayout = new QVBoxLayout(statsBox); + solutionsLabel_ = new QLabel("Solutions found: 0"); + solutionsLabel_->setStyleSheet("color: #ccc;"); + statsLayout->addWidget(solutionsLabel_); + creditsLabel_ = new QLabel("Total credits: 0"); + creditsLabel_->setStyleSheet("color: #4ec9b0; font-size: 14pt;"); + statsLayout->addWidget(creditsLabel_); + layout->addWidget(statsBox); + + layout->addStretch(); + + refreshKeyList(); + } + + ~MiningWidget() override { + stopAllWorkers(); + } + +public slots: + void refreshKeyList() { + keyCombo_->blockSignals(true); + keyCombo_->clear(); + auto& w = keyManager_.wallet(); + for (int i = 0; i < w.size(); ++i) { + auto kp = w.keyPair(i); + keyCombo_->addItem(formatKeyLabel(i, kp, w.label(i)), i); + } + if (!w.empty()) { + int sel = std::clamp(config_.defaultAccount, 0, w.size() - 1); + keyCombo_->setCurrentIndex(sel); + } + keyCombo_->blockSignals(false); + updateDifficultyLabel(); + } + + bool isMining() const { return wantMining_; } + + void autoMineOnce() { + if (wantMining_) return; + autoMineOnce_ = true; + startMining(); + log("Auto-mining once for initial credits..."); + } + + int miningAccountIndex() const { + if (!wantMining_ || keyCombo_->count() == 0) return -1; + return keyCombo_->currentData().toInt(); + } + +signals: + void statusMessage(const QString& msg, int timeout); + void creditEarned(int accountIndex, uint64_t credit); + +private slots: + void toggleMining() { + if (wantMining_) { + // Intent: stop. Button switches immediately. + wantMining_ = false; + startStopBtn_->setText("Start Mining"); + threadSpin_->setEnabled(true); + throttleSpin_->setEnabled(true); + keyCombo_->setEnabled(true); + statusLabel_->setText("Stopping..."); + statusLabel_->setStyleSheet("color: #dcdcaa; font-style: italic;"); + log("Stopping mining..."); + if (worker_) + worker_->requestStop(); + } else { + startMining(); + } + } + + void onMineStatus(const QString& status) { + statusLabel_->setText(status); + statusLabel_->setStyleSheet("color: #dcdcaa; font-style: italic;"); + log("Mine: " + status); + } + + void onSolutionFound(uint64_t credit, int /*resultCode*/) { + totalSolutions_++; + totalCredits_ += credit; + solutionsLabel_->setText(QString("Solutions found: %1").arg(totalSolutions_)); + creditsLabel_->setText(QString("Total credits: %1").arg(formatAmount(totalCredits_))); + log(QString("Solution accepted! Credit: %1 (total: %2)") + .arg(formatAmount(credit)).arg(formatAmount(totalCredits_))); + emit creditEarned(miningAccountIndex(), credit); + + if (autoMineOnce_) { + autoMineOnce_ = false; + // Flip intent to stop — submission is already in flight + wantMining_ = false; + startStopBtn_->setText("Start Mining"); + threadSpin_->setEnabled(true); + throttleSpin_->setEnabled(true); + keyCombo_->setEnabled(true); + if (worker_) + worker_->requestStop(); + log("Auto-mine complete. Initial credits deposited."); + } + } + + void onMineError(const QString& error) { + log("Mine error: " + error); + statusLabel_->setText("Error: " + error); + statusLabel_->setStyleSheet("color: #f44747; font-style: italic;"); + } + + void onWorkerFinished() { + worker_ = nullptr; + if (wantMining_) { + // User toggled back to "start" while worker was winding down — restart. + launchWorker(); + } else { + statusLabel_->setText("Idle"); + statusLabel_->setStyleSheet("color: #888; font-style: italic;"); + log("Mining stopped."); + } + } + +private: + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + + QComboBox* keyCombo_ = nullptr; + QSpinBox* threadSpin_ = nullptr; + QSpinBox* throttleSpin_ = nullptr; + QLabel* diffLabel_ = nullptr; + QPushButton* startStopBtn_ = nullptr; + QLabel* statusLabel_ = nullptr; + QLabel* solutionsLabel_ = nullptr; + QLabel* creditsLabel_ = nullptr; + + bool wantMining_ = false; + bool autoMineOnce_ = false; + uint64_t totalSolutions_ = 0; + uint64_t totalCredits_ = 0; + MineWorker* worker_ = nullptr; + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } + + int computeExtraDifficulty() { + // We don't know the server's minDifficulty until we connect, + // but we know the account existence from the Account tab. + // For now, use extraDifficulty = 1 (server minDiff + 1). + // Smart difficulty: if account doesn't exist, we need enough + // credit to cover BASE_FEE_ACCOUNT (6,400,000). + // credit = (1 << (diff-1)) * 1000 + // For diff 14: (1<<13)*1000 = 8,192,000 — first diff that covers it. + // extraDifficulty is added on top of server's minDifficulty, + // so we just pass 1 and let the server enforce the minimum. + // The mineOnce function uses client.mine(extraDifficulty) which + // targets serverMinDiff + extraDifficulty. + return 1; + } + + void updateDifficultyLabel() { + diffLabel_->setText( + QString("Target difficulty: server minimum + 1 " + "(need >= %1 for new account)") + .arg(formatAmount(static_cast(ces::BASE_FEE_ACCOUNT)))); + } + + void startMining() { + auto& w = keyManager_.wallet(); + if (w.empty()) { + statusLabel_->setText("No keys in wallet."); + statusLabel_->setStyleSheet("color: #f44747; font-style: italic;"); + return; + } + if (config_.currentServer.isEmpty()) { + statusLabel_->setText("No current server set."); + statusLabel_->setStyleSheet("color: #f44747; font-style: italic;"); + return; + } + + int idx = keyCombo_->currentData().toInt(); + if (idx < 0 || idx >= w.size()) return; + + wantMining_ = true; + startStopBtn_->setText("Stop Mining"); + threadSpin_->setEnabled(false); + throttleSpin_->setEnabled(false); + keyCombo_->setEnabled(false); + + int threads = threadSpin_->value(); + int throttle = throttleSpin_->value(); + statusLabel_->setText(QString("Starting with %1 thread(s), throttle %2\xc2\xb5s...") + .arg(threads).arg(throttle)); + statusLabel_->setStyleSheet("color: #dcdcaa; font-style: italic;"); + + log(QString("Starting mining: %1 thread(s), throttle %2us, account @%3, server %4") + .arg(threads).arg(throttle).arg(idx).arg(config_.currentServer)); + + launchWorker(); + } + + void launchWorker() { + auto& w = keyManager_.wallet(); + int idx = keyCombo_->currentData().toInt(); + if (idx < 0 || idx >= w.size()) return; + + ces::KeyPair kp = w.keyPair(idx); + int threads = threadSpin_->value(); + int throttle = throttleSpin_->value(); + int extraDiff = computeExtraDifficulty(); + + worker_ = new MineWorker(config_.currentServer, kp, extraDiff, threads, throttle, this); + connect(worker_, &MineWorker::mineStatus, + this, &MiningWidget::onMineStatus); + connect(worker_, &MineWorker::solutionFound, + this, &MiningWidget::onSolutionFound); + connect(worker_, &MineWorker::mineError, + this, &MiningWidget::onMineError); + connect(worker_, &MineWorker::mineFinished, + this, &MiningWidget::onWorkerFinished); + connect(worker_, &QThread::finished, + worker_, &QObject::deleteLater); + worker_->start(); + } + + void stopAllWorkers() { + wantMining_ = false; + if (worker_) { + worker_->requestStop(); + worker_->wait(cesqt_timing::kWorkerJoinTimeoutMs); + worker_ = nullptr; + } + } +}; + +// ============================================================================= +// PingWorker — runs server ping on a background thread +// ============================================================================= + +class PingWorker : public QThread { + Q_OBJECT +public: + PingWorker(const QString& addr, int index, int generation, + QObject* parent = nullptr) + : QThread(parent), addr_(addr), index_(index), generation_(generation) {} + +signals: + void pingResult(int index, int generation, QString status); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(addr_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.start(0); + + if (!client.connect()) { + emit pingResult(index_, generation_, + QString("Offline (connect failed, %1)") + .arg(probe.isTcp ? "TCP" : "UDP")); + client.stop(); + return; + } + + // Handshake info (free, from MINX layer) + uint8_t diff = client.getMinDifficulty(); + uint8_t minSecs = client.getMinSecsPoW(); + uint16_t pending = client.getPendingPoWs(); + uint16_t tps = client.getTps(); + + client.disconnect(); + client.stop(); + + QString transport = probe.isTcp ? "TCP" : "UDP"; + QString status = QString("Online (%1) | diff %2, %3 tps, %4 pending, %5s min") + .arg(transport).arg(diff).arg(tps).arg(pending).arg(minSecs); + + emit pingResult(index_, generation_, status); + } catch (std::exception& e) { + emit pingResult(index_, generation_, + QString("Offline (%1)").arg(e.what())); + } + } + +private: + QString addr_; + int index_; + int generation_; +}; + +// ============================================================================= +// ServerInfoWorker — queries server info (paid) on a background thread +// ============================================================================= + +class ServerInfoWorker : public QThread { + Q_OBJECT +public: + ServerInfoWorker(const QString& server, const ces::KeyPair& keyPair, + QObject* parent = nullptr) + : QThread(parent), server_(server), keyPair_(keyPair) {} + +signals: + void infoResult(bool ok, QList> entries, QString error); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(server_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.setKey(keyPair_); + client.start(0); + if (!client.connect()) { + emit infoResult(false, {}, QString("Connect failed (%1)") + .arg(probe.isTcp ? "TCP" : "UDP")); + client.stop(); + return; + } + + std::vector entries; + uint8_t rc = client.queryServerInfo(entries); + client.disconnect(); + client.stop(); + + if (rc != ces::CES_OK) { + emit infoResult(false, {}, + QString::fromUtf8(ces::errorString(rc))); + return; + } + + QList> result; + for (auto& e : entries) + result.append({QString::fromStdString(e.key), + QString::fromStdString(e.value)}); + emit infoResult(true, result, ""); + } catch (std::exception& e) { + emit infoResult(false, {}, QString::fromUtf8(e.what())); + } + } + +private: + QString server_; + ces::KeyPair keyPair_; +}; + +// ============================================================================= +// ServerManagerWidget — server address book with ping status +// ============================================================================= + +class ServerManagerWidget : public QWidget { + Q_OBJECT +public: + explicit ServerManagerWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + + // Current server label + currentLabel_ = new QLabel; + updateDefaultLabel(); + layout->addWidget(currentLabel_); + + // Server table: Server | Status + table_ = new QTableWidget(0, 2); + table_->setHorizontalHeaderLabels({"Server", "Status"}); + table_->horizontalHeader()->setStretchLastSection(true); + table_->horizontalHeader()->setMinimumSectionSize(50); + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + table_->setSelectionMode(QAbstractItemView::SingleSelection); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + table_->horizontalHeader()->setStretchLastSection(true); + table_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + table_->verticalHeader()->setVisible(false); + layout->addWidget(table_); + + // Buttons + auto* btnRow = new QHBoxLayout; + + auto* btnAdd = new QPushButton("Add Server"); + connect(btnAdd, &QPushButton::clicked, this, &ServerManagerWidget::addServer); + btnRow->addWidget(btnAdd); + + auto* btnEdit = new QPushButton("Edit Server"); + connect(btnEdit, &QPushButton::clicked, this, &ServerManagerWidget::editServer); + btnRow->addWidget(btnEdit); + + auto* btnRemove = new QPushButton("Remove Server"); + connect(btnRemove, &QPushButton::clicked, this, &ServerManagerWidget::removeServer); + btnRow->addWidget(btnRemove); + + auto* btnSetDefault = new QPushButton("Set as Default"); + connect(btnSetDefault, &QPushButton::clicked, this, &ServerManagerWidget::setAsDefault); + btnRow->addWidget(btnSetDefault); + + auto* btnClearDefault = new QPushButton("Clear Default"); + connect(btnClearDefault, &QPushButton::clicked, this, &ServerManagerWidget::clearDefault); + btnRow->addWidget(btnClearDefault); + + btnPingAll_ = new QPushButton("Ping All"); + connect(btnPingAll_, &QPushButton::clicked, this, &ServerManagerWidget::pingAll); + btnRow->addWidget(btnPingAll_); + + btnQueryInfo_ = new QPushButton("Query Info"); + btnQueryInfo_->setToolTip("Query server info (paid, uses default account)"); + connect(btnQueryInfo_, &QPushButton::clicked, this, &ServerManagerWidget::queryInfo); + btnRow->addWidget(btnQueryInfo_); + + layout->addLayout(btnRow); + + // --- Server info table --- + infoTable_ = new QTableWidget(0, 2); + infoTable_->setHorizontalHeaderLabels({"Property", "Value"}); + infoTable_->horizontalHeader()->setStretchLastSection(true); + infoTable_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + infoTable_->verticalHeader()->setVisible(false); + infoTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); + infoTable_->setSelectionMode(QAbstractItemView::SingleSelection); + infoTable_->setFixedHeight(180); + layout->addWidget(infoTable_); + + // Show cached info for initially selected server + connect(table_, &QTableWidget::currentCellChanged, + this, [this](int row, int, int, int) { showCachedInfo(row); }); + + // Initialize status list and refresh + statuses_.resize(config_.servers.size()); + for (auto& s : statuses_) s = "(not checked)"; + refreshTable(); + } + +signals: + void statusMessage(const QString& msg, int timeout); + void allPingsDone(); + void serverListChanged(); + +public slots: + void pingAll() { + // Bump generation to discard results from any previous batch still in flight + pingGeneration_++; + pingsInFlight_ = config_.servers.size(); + + log("Pinging all servers..."); + + for (int i = 0; i < config_.servers.size(); ++i) { + statuses_[i] = "Pinging..."; + auto* worker = new PingWorker(config_.servers[i], i, pingGeneration_, this); + connect(worker, &PingWorker::pingResult, + this, &ServerManagerWidget::onPingResult); + connect(worker, &QThread::finished, + worker, &QObject::deleteLater); + worker->start(); + } + refreshTable(); + } + +private slots: + void addServer() { + bool ok; + QString addr = QInputDialog::getText(this, "Add Server", + "Server address (host:port):", QLineEdit::Normal, "", &ok); + if (!ok || addr.trimmed().isEmpty()) return; + + addr = addr.trimmed(); + if (!addr.contains(':')) + addr += ":" + QString::number(ces::DEFAULT_PORT); + if (config_.servers.contains(addr)) { + QMessageBox::information(this, "Add Server", "Server already in list."); + return; + } + config_.servers.append(addr); + statuses_.append("(not checked)"); + log("Added server: " + addr); + saveAndRefresh(); + } + + void editServer() { + int row = selectedRow(); + if (row < 0) return; + + QString old = config_.servers[row]; + bool ok; + QString addr = QInputDialog::getText(this, "Edit Server", + "Server address (host:port):", QLineEdit::Normal, old, &ok); + if (!ok || addr.trimmed().isEmpty()) return; + + addr = addr.trimmed(); + if (!addr.contains(':')) + addr += ":" + QString::number(ces::DEFAULT_PORT); + bool wasCurrent = (config_.currentServer == old); + config_.servers[row] = addr; + statuses_[row] = "(not checked)"; + if (wasCurrent) config_.currentServer = addr; + log("Edited server: " + old + " -> " + addr); + saveAndRefresh(); + } + + void removeServer() { + int row = selectedRow(); + if (row < 0) return; + + QString addr = config_.servers[row]; + auto reply = QMessageBox::question(this, "Remove Server", + QString("Remove server '%1'?").arg(addr), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (reply != QMessageBox::Yes) return; + + config_.servers.removeAt(row); + statuses_.removeAt(row); + if (config_.currentServer == addr) + config_.currentServer.clear(); + log("Removed server: " + addr); + saveAndRefresh(); + } + + void setAsDefault() { + int row = selectedRow(); + if (row < 0) return; + + config_.currentServer = config_.servers[row]; + log("Current server set to: " + config_.currentServer); + saveAndRefresh(); + } + + void clearDefault() { + config_.currentServer.clear(); + log("Current server cleared."); + saveAndRefresh(); + } + + void queryInfo() { + int row = selectedRow(); + if (row < 0) return; + auto& w = keyManager_.wallet(); + if (w.empty()) { + log("Query Info: no keys in wallet."); + return; + } + if (queryingInfo_) return; + + QString server = config_.servers[row]; + int kidx = std::clamp(config_.defaultAccount, 0, w.size() - 1); + ces::KeyPair kp = w.keyPair(kidx); + + queryingInfo_ = true; + btnQueryInfo_->setEnabled(false); + log("Querying server info: " + server + " (account @" + QString::number(kidx) + ")"); + + auto* worker = new ServerInfoWorker(server, kp, this); + connect(worker, &ServerInfoWorker::infoResult, + this, [this, server](bool ok, QList> entries, QString error) { + queryingInfo_ = false; + btnQueryInfo_->setEnabled(true); + if (!ok) { + log("Server info failed: " + server + " — " + error); + return; + } + // Store as JSON array of [key, value] pairs + QJsonArray arr; + for (auto& e : entries) { + QJsonArray pair; + pair.append(e.first); + pair.append(e.second); + arr.append(pair); + } + config_.serverInfo[server] = arr; + config_.save(); + log(QString("Server info: %1 (%2 properties)").arg(server).arg(entries.size())); + showCachedInfo(server); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + + void onPingResult(int index, int generation, QString status) { + // Discard results from stale ping batches + if (generation != pingGeneration_) return; + + if (index >= 0 && index < statuses_.size()) + statuses_[index] = status; + + log(QString("Ping %1: %2") + .arg(config_.servers.value(index, "?")) + .arg(status)); + + pingsInFlight_--; + if (pingsInFlight_ <= 0) { + pingsInFlight_ = 0; + emit allPingsDone(); + } + refreshTable(); + } + +private: + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + QTableWidget* table_ = nullptr; + QLabel* currentLabel_ = nullptr; + QPushButton* btnPingAll_ = nullptr; + QPushButton* btnQueryInfo_ = nullptr; + QTableWidget* infoTable_ = nullptr; + bool queryingInfo_ = false; +public: + const QStringList& getStatuses() const { return statuses_; } +private: + QStringList statuses_; + int pingsInFlight_ = 0; + int pingGeneration_ = 0; + + void showCachedInfo(int row) { + if (row < 0 || row >= config_.servers.size()) { + infoTable_->setRowCount(0); + return; + } + showCachedInfo(config_.servers[row]); + } + + void showCachedInfo(const QString& server) { + infoTable_->setRowCount(0); + auto it = config_.serverInfo.find(server); + if (it == config_.serverInfo.end()) return; + auto& arr = it.value(); + infoTable_->setRowCount(arr.size()); + for (int i = 0; i < arr.size(); ++i) { + auto pair = arr[i].toArray(); + if (pair.size() >= 2) { + infoTable_->setItem(i, 0, new QTableWidgetItem(pair[0].toString())); + infoTable_->setItem(i, 1, new QTableWidgetItem(pair[1].toString())); + } + } + } + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } + + int selectedRow() { + auto sel = table_->selectionModel()->selectedRows(); + if (sel.isEmpty()) { + emit statusMessage("No server selected", 3000); + return -1; + } + return sel.first().row(); + } + + void updateDefaultLabel() { + if (config_.currentServer.isEmpty()) { + currentLabel_->setText("Current server: (none)"); + currentLabel_->setStyleSheet("color: #888; font-style: italic;"); + } else { + currentLabel_->setText("Current server: " + config_.currentServer); + currentLabel_->setStyleSheet("color: #ccc;"); + } + } + + void saveAndRefresh() { + config_.save(); + refreshTable(); + emit serverListChanged(); + } + + void refreshTable() { + updateDefaultLabel(); + table_->setRowCount(config_.servers.size()); + for (int i = 0; i < config_.servers.size(); ++i) { + // Server column — mark default with a star + bool isDefault = (config_.servers[i] == config_.currentServer); + QString label = config_.servers[i]; + if (isDefault) label = label + " *"; + + auto* addrItem = new QTableWidgetItem(label); + if (isDefault) + addrItem->setForeground(QColor("#4ec9b0")); + table_->setItem(i, 0, addrItem); + + // Status column + QString st = (i < statuses_.size()) ? statuses_[i] : "(not checked)"; + auto* statusItem = new QTableWidgetItem(st); + if (st.startsWith("Online")) + statusItem->setForeground(QColor("#4ec9b0")); + else if (st.startsWith("Offline")) + statusItem->setForeground(QColor("#f44747")); + else if (st.startsWith("Pinging")) + statusItem->setForeground(QColor("#dcdcaa")); + else + statusItem->setForeground(QColor("#888888")); + table_->setItem(i, 1, statusItem); + } + } +}; + +// ============================================================================= +// TransferWorker — performs transfer on a background thread +// ============================================================================= + +class TransferWorker : public QThread { + Q_OBJECT +public: + TransferWorker(const QString& server, const ces::KeyPair& keyPair, + const std::string& destPubKey, uint64_t amount, + bool openTransfer = false, + const QString& crossServer = {}, + QObject* parent = nullptr) + : QThread(parent), server_(server), keyPair_(keyPair), + destPubKey_(destPubKey), amount_(amount), + openTransfer_(openTransfer), crossServer_(crossServer) {} + +signals: + void transferResult(bool ok, uint8_t rc, int64_t newBalance, QString error); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(server_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.setKey(keyPair_); + client.start(0); + + if (!client.connect()) { + emit transferResult(false, 0, 0, + QString("Connect failed (%1)").arg(probe.isTcp ? "TCP" : "UDP")); + client.stop(); + return; + } + + minx::Hash dest; + minx::stringToHash(dest, destPubKey_); + int64_t newBal = 0; + uint8_t rc; + + if (!crossServer_.isEmpty()) + rc = client.crossTransfer(dest, amount_, + crossServer_.toStdString(), newBal); + else if (openTransfer_) + rc = client.openTransfer(dest, amount_, newBal); + else + rc = client.transfer(dest, amount_, newBal); + + client.disconnect(); + client.stop(); + + if (rc == ces::CES_OK) + emit transferResult(true, rc, newBal, ""); + else + emit transferResult(false, rc, newBal, + QString::fromUtf8(ces::errorString(rc))); + } catch (std::exception& e) { + emit transferResult(false, 0, 0, QString::fromUtf8(e.what())); + } + } + +private: + QString server_; + ces::KeyPair keyPair_; + std::string destPubKey_; + uint64_t amount_; + bool openTransfer_; + QString crossServer_; +}; + +// ============================================================================= +// TransferWidget — send funds from selected account +// ============================================================================= + +class TransferWidget : public QWidget { + Q_OBJECT +public: + void setWalletWidget(const WalletWidget* w) { + walletWidget_ = w; + updateInfoLabels(); + } + + explicit TransferWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(16, 16, 16, 16); + layout->setSpacing(12); + + // From account + auto* fromRow = new QHBoxLayout; + fromRow->addWidget(new QLabel("From:")); + keyCombo_ = new KeyComboBox; + keyCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + fromRow->addWidget(keyCombo_); + layout->addLayout(fromRow); + fromInfoLabel_ = new QLabel(""); + fromInfoLabel_->setStyleSheet("color: #6b7b8d; font-size: 9pt; margin-left: 40px;"); + layout->addWidget(fromInfoLabel_); + + // Destination + auto* destRow = new QHBoxLayout; + destRow->addWidget(new QLabel("To:")); + destCombo_ = new QComboBox; + destCombo_->setEditable(true); + destCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + destCombo_->setInsertPolicy(QComboBox::NoInsert); + destCombo_->lineEdit()->setPlaceholderText( + "Public key hex or @index (e.g. @0)"); + destRow->addWidget(destCombo_); + layout->addLayout(destRow); + toInfoLabel_ = new QLabel(""); + toInfoLabel_->setStyleSheet("color: #6b7b8d; font-size: 9pt; margin-left: 40px;"); + layout->addWidget(toInfoLabel_); + + // Amount + auto* amountRow = new QHBoxLayout; + amountRow->addWidget(new QLabel("Amount:")); + amountEdit_ = new QLineEdit; + amountEdit_->setPlaceholderText("0.00000000"); + amountRow->addWidget(amountEdit_); + layout->addLayout(amountRow); + + // Open transfer + openCheck_ = new QCheckBox("Open transfer (pays to create destination account if needed)"); + openCheck_->setChecked(false); + layout->addWidget(openCheck_); + + // Cross-transfer + auto* crossRow = new QHBoxLayout; + crossCheck_ = new QCheckBox("Cross-transfer to:"); + crossRow->addWidget(crossCheck_); + crossCombo_ = new CopyableComboBox; + crossCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + crossCombo_->setEnabled(false); + crossRow->addWidget(crossCombo_); + connect(crossCheck_, &QCheckBox::toggled, this, [this](bool on) { + crossCombo_->setEnabled(on); + if (on) refreshServerList(); + }); + layout->addLayout(crossRow); + + // Send button + auto* btnRow = new QHBoxLayout; + btnRow->addStretch(); + sendBtn_ = new QPushButton("Send"); + sendBtn_->setFixedWidth(120); + connect(sendBtn_, &QPushButton::clicked, this, &TransferWidget::onSend); + btnRow->addWidget(sendBtn_); + btnRow->addStretch(); + layout->addLayout(btnRow); + + // Status + statusLabel_ = new QLabel(""); + statusLabel_->setStyleSheet("font-style: italic; color: #6b7b8d;"); + statusLabel_->setWordWrap(true); + layout->addWidget(statusLabel_); + + layout->addStretch(); + + connect(keyCombo_, QOverload::of(&QComboBox::currentIndexChanged), + this, [this](int) { updateInfoLabels(); }); + connect(destCombo_, &QComboBox::currentTextChanged, + this, [this](const QString&) { updateInfoLabels(); }); + + refreshKeyList(); + } + +public slots: + void refreshKeyList() { + keyCombo_->clear(); + auto& w = keyManager_.wallet(); + for (int i = 0; i < w.size(); ++i) { + auto kp = w.keyPair(i); + keyCombo_->addItem(formatKeyLabel(i, kp, w.label(i)), i); + } + refreshDestCombo(); + refreshServerList(); + updateInfoLabels(); + } + + void updateInfoLabels() { + // From: show cached balance if available + int fromIdx = keyCombo_->currentData().toInt(); + fromInfoLabel_->setText(accountInfoForIndex(fromIdx)); + // To: show cached balance if it's a wallet key (@N) + QString destText = destCombo_->currentText().trimmed(); + if (destText.startsWith('@')) { + QString numPart = destText.mid(1).split(' ').first(); + bool ok; + int toIdx = numPart.toInt(&ok); + if (ok && toIdx >= 0 && toIdx < keyManager_.wallet().size()) { + toInfoLabel_->setText(accountInfoForIndex(toIdx)); + } else { + toInfoLabel_->setText(""); + } + } else if (!destText.isEmpty()) { + toInfoLabel_->setText("External address"); + } else { + toInfoLabel_->setText(""); + } + } + + void refreshServerList() { + crossCombo_->clear(); + if (!config_.currentServer.isEmpty()) + crossCombo_->addItem(config_.currentServer); + for (auto& s : config_.servers) { + if (s != config_.currentServer) + crossCombo_->addItem(s); + } + } + +signals: + void statusMessage(const QString& msg, int timeout); + void transferCompleted(int accountIndex, int64_t newBalance); + +private slots: + void onSend() { + auto& w = keyManager_.wallet(); + if (w.empty()) { + statusLabel_->setText("No keys in wallet."); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + return; + } + if (config_.currentServer.isEmpty()) { + statusLabel_->setText("No current server configured."); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + return; + } + if (sending_) return; + + QString destInput = destCombo_->currentText().trimmed(); + if (destInput.isEmpty()) { + statusLabel_->setText("Enter a destination."); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + return; + } + + // Parse amount: user types decimal like "1.5" meaning 1.50000000 + QString amountStr = amountEdit_->text().trimmed(); + if (amountStr.isEmpty()) { + statusLabel_->setText("Enter an amount."); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + return; + } + + uint64_t amount = 0; + if (!parseAmount(amountStr, amount)) { + statusLabel_->setText("Invalid amount format."); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + return; + } + if (amount == 0) { + statusLabel_->setText("Amount must be > 0."); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + return; + } + + // Resolve destination + std::string resolvedDest; + try { + resolvedDest = w.resolveKey(destInput.toStdString()); + } catch (std::exception& e) { + statusLabel_->setText(QString("Bad destination: %1").arg(e.what())); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + return; + } + + int idx = keyCombo_->currentData().toInt(); + ces::KeyPair kp = w.keyPair(idx); + resolvedDest_ = QString::fromStdString(resolvedDest); + + sending_ = true; + sendBtn_->setEnabled(false); + + QString crossServer; + if (crossCheck_->isChecked()) + crossServer = crossCombo_->currentText().trimmed(); + + if (crossServer.isEmpty()) { + statusLabel_->setText(QString("Sending %1 to %2...") + .arg(formatAmount(amount)) + .arg(resolvedDest_.left(16) + "...")); + log(QString("Transfer: %1 from @%2 to %3") + .arg(formatAmount(amount)).arg(idx).arg(resolvedDest_)); + } else { + statusLabel_->setText(QString("Cross-sending %1 to %2 via %3...") + .arg(formatAmount(amount)) + .arg(resolvedDest_.left(16) + "...") + .arg(crossServer)); + log(QString("Cross-transfer: %1 from @%2 to %3 on %4") + .arg(formatAmount(amount)).arg(idx).arg(resolvedDest_).arg(crossServer)); + } + statusLabel_->setStyleSheet("font-style: italic; color: #dcdcaa;"); + + auto* worker = new TransferWorker( + config_.currentServer, kp, resolvedDest.c_str(), amount, + openCheck_->isChecked(), crossServer, this); + connect(worker, &TransferWorker::transferResult, + this, [this, idx](bool ok, uint8_t rc, int64_t newBal, QString error) { + onTransferResult(ok, rc, newBal, error, idx); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + + void onTransferResult(bool ok, uint8_t rc, int64_t newBal, + QString error, int accountIndex) { + sending_ = false; + sendBtn_->setEnabled(true); + (void)rc; + + if (ok) { + statusLabel_->setText(QString("Sent! New balance: %1") + .arg(formatAmount(newBal))); + statusLabel_->setStyleSheet("font-style: italic; color: #4ec9b0;"); + log(QString("Transfer OK. New balance: %1").arg(formatAmount(newBal))); + + // Push resolved dest to history and refresh + config_.pushTransferDest(resolvedDest_); + refreshDestCombo(); + + emit transferCompleted(accountIndex, newBal); + } else { + statusLabel_->setText("Transfer failed: " + error); + statusLabel_->setStyleSheet("font-style: italic; color: #f44747;"); + log("Transfer failed: " + error); + } + } + +private: + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } + + void refreshDestCombo() { + QString currentText = destCombo_->currentText(); + destCombo_->clear(); + // Wallet keys first (for easy self-transfers) + auto& w = keyManager_.wallet(); + if (w.size() > 0) { + for (int i = 0; i < w.size(); ++i) { + auto kp = w.keyPair(i); + destCombo_->addItem(formatKeyLabel(i, kp, w.label(i))); + } + // Separator before history + if (!config_.transferDestHistory.isEmpty()) + destCombo_->insertSeparator(destCombo_->count()); + } + // History (raw pubkeys from previous transfers) + for (auto& h : config_.transferDestHistory) + destCombo_->addItem(h); + destCombo_->setCurrentText(currentText); + } + + static bool parseAmount(const QString& str, uint64_t& out) { + // Accept "1", "1.5", "0.001", etc. Convert to internal units (8 decimals) + int dotPos = str.indexOf('.'); + if (dotPos < 0) { + // No decimal point — whole units + bool ok; + uint64_t whole = str.toULongLong(&ok); + if (!ok) return false; + if (whole > UINT64_MAX / ces::PRICE_UNIT) return false; // overflow + out = whole * ces::PRICE_UNIT; + return true; + } + QString wholePart = str.left(dotPos); + QString fracPart = str.mid(dotPos + 1); + if (fracPart.size() > 8) return false; // too many decimals + // Pad to 8 digits + while (fracPart.size() < 8) fracPart.append('0'); + + bool ok1, ok2; + uint64_t whole = wholePart.isEmpty() ? 0 : wholePart.toULongLong(&ok1); + if (!wholePart.isEmpty() && !ok1) return false; + uint64_t frac = fracPart.toULongLong(&ok2); + if (!ok2) return false; + out = whole * ces::PRICE_UNIT + frac; + return true; + } + + QString accountInfoForIndex(int idx) { + if (!walletWidget_) return ""; + auto& accounts = walletWidget_->getAccounts(); + for (auto& a : accounts) { + if (a.index == idx) { + if (a.exists) + return QString("Balance: %1").arg(formatAmount(a.balance)); + else + return "Account not found on server"; + } + } + return "Not yet queried"; + } + + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + const WalletWidget* walletWidget_ = nullptr; + QComboBox* keyCombo_ = nullptr; + QComboBox* destCombo_ = nullptr; + QLabel* fromInfoLabel_ = nullptr; + QLabel* toInfoLabel_ = nullptr; + QLineEdit* amountEdit_ = nullptr; + QCheckBox* openCheck_ = nullptr; + QCheckBox* crossCheck_ = nullptr; + QComboBox* crossCombo_ = nullptr; + QPushButton* sendBtn_ = nullptr; + QLabel* statusLabel_ = nullptr; + bool sending_ = false; + QString resolvedDest_; +}; + +// ============================================================================= +// AssetUnsignedQueryWorker — unsigned (free) asset query on background thread +// ============================================================================= + +class AssetUnsignedQueryWorker : public QThread { + Q_OBJECT +public: + AssetUnsignedQueryWorker(const QString& server, const QString& assetKeyHex, + QObject* parent = nullptr) + : QThread(parent), server_(server), assetKeyHex_(assetKeyHex) {} + +signals: + void queryResult(bool ok, QString owner, QString contentHex, + uint16_t days, uint32_t storedPrice, QString error); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(server_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.start(0); + if (!client.connect()) { + emit queryResult(false, "", "", 0, 0, + QString("Connect failed (%1)").arg(probe.isTcp ? "TCP" : "UDP")); + client.stop(); + return; + } + + minx::Hash assetId; + minx::stringToHash(assetId, assetKeyHex_.toStdString()); + ces::HashPrefix owner{}; + ces::AssetData content{}; + uint16_t balance = 0; + uint32_t price = 0; + uint8_t rc = client.queryAsset(assetId, owner, content, balance, price); + + client.disconnect(); + client.stop(); + + if (rc != ces::CES_OK) { + emit queryResult(false, "", "", 0, 0, + QString::fromUtf8(ces::errorString(rc))); + return; + } + + // Check if asset exists (zero owner = not found) + ces::HashPrefix zero{}; + if (owner == zero) { + emit queryResult(false, "", "", 0, 0, "Asset not found"); + return; + } + + // Convert owner to hex + QString ownerHex = QString::fromStdString( + ces::hashPrefixToString(owner)); + // Convert content to hex + QByteArray contentRaw(reinterpret_cast(content.data()), + content.size()); + QString contentHexStr = contentRaw.toHex(); + + // The signal still carries `days` semantics for the existing UI; + // mask off the priv/aowned/immut flag bits (extending the UI to + // display flags is left as a follow-up). + emit queryResult(true, ownerHex, contentHexStr, + ces::assetDays(balance), price, ""); + } catch (std::exception& e) { + emit queryResult(false, "", "", 0, 0, QString::fromUtf8(e.what())); + } + } + +private: + QString server_; + QString assetKeyHex_; +}; + +// ============================================================================= +// AssetActionWorker — signed asset operations on background thread +// ============================================================================= + +class AssetActionWorker : public QThread { + Q_OBJECT +public: + enum Action { Create, UpdateFull, UpdateFast, UpdateMeta, Fund, Give, Buy }; + + AssetActionWorker(Action action, const QString& server, + const ces::KeyPair& keyPair, + const QString& assetKeyHex, + QObject* parent = nullptr) + : QThread(parent), action_(action), server_(server), + keyPair_(keyPair), assetKeyHex_(assetKeyHex) {} + + // Setters for action-specific parameters + void setContent(const ces::AssetData& c) { content_ = c; } + void setDays(uint16_t d) { days_ = d; } + void setStoredPrice(uint32_t p) { storedPrice_ = p; } + void setNewOwner(const ces::HashPrefix& o) { newOwner_ = o; } + void setBuyAmount(uint64_t a) { buyAmount_ = a; } + +signals: + void actionResult(bool ok, uint8_t rc, QString error); + +protected: + void run() override { + try { + auto probe = ces::Resolver::probe(server_.toStdString(), + [](const std::string& m) { ConsoleWidget::log(QString::fromStdString(m)); }); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.setKey(keyPair_); + client.start(0); + if (!client.connect()) { + emit actionResult(false, 0, + QString("Connect failed (%1)").arg(probe.isTcp ? "TCP" : "UDP")); + client.stop(); + return; + } + + minx::Hash assetId; + minx::stringToHash(assetId, assetKeyHex_.toStdString()); + uint8_t rc = 0; + + switch (action_) { + case Create: + rc = client.createAsset(assetId, content_, days_); + break; + case UpdateFull: + rc = client.updateAsset(assetId, newOwner_, content_, storedPrice_); + break; + case UpdateFast: + rc = client.updateAssetFast(assetId, content_); + break; + case UpdateMeta: + rc = client.updateAssetMeta(assetId, newOwner_, storedPrice_); + break; + case Fund: + rc = client.fundAsset(assetId, days_); + break; + case Give: + rc = client.giveAsset(assetId, newOwner_); + break; + case Buy: + rc = client.buyAsset(assetId, buyAmount_); + break; + } + + client.disconnect(); + client.stop(); + + if (rc == ces::CES_OK) + emit actionResult(true, rc, ""); + else + emit actionResult(false, rc, + QString::fromUtf8(ces::errorString(rc))); + } catch (std::exception& e) { + emit actionResult(false, 0, QString::fromUtf8(e.what())); + } + } + +private: + Action action_; + QString server_; + ces::KeyPair keyPair_; + QString assetKeyHex_; + ces::AssetData content_{}; + uint16_t days_ = 0; + uint32_t storedPrice_ = 0; + ces::HashPrefix newOwner_{}; + uint64_t buyAmount_ = 0; +}; + +// ============================================================================= +// AssetsWidget — tracked assets management +// ============================================================================= + +class AssetsWidget : public QWidget { + Q_OBJECT +public: + explicit AssetsWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(8); + + // --- Top: asset selector + add/remove --- + auto* topRow = new QHBoxLayout; + topRow->addWidget(new QLabel("Asset:")); + assetCombo_ = new CopyableComboBox; + assetCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + connect(assetCombo_, QOverload::of(&QComboBox::currentIndexChanged), + this, &AssetsWidget::onAssetSelected); + topRow->addWidget(assetCombo_); + + auto* addBtn = new QPushButton("Track..."); + addBtn->setToolTip("Add an asset to track"); + connect(addBtn, &QPushButton::clicked, this, &AssetsWidget::onTrackAsset); + topRow->addWidget(addBtn); + + removeBtn_ = new QPushButton("Remove"); + removeBtn_->setToolTip("Stop tracking this asset"); + connect(removeBtn_, &QPushButton::clicked, + this, &AssetsWidget::onRemoveAsset); + topRow->addWidget(removeBtn_); + layout->addLayout(topRow); + + // --- Detail view --- + auto* detailGroup = new QGroupBox("Asset Details"); + auto* detailLayout = new QVBoxLayout(detailGroup); + detailLayout->setSpacing(4); + + keyLabel_ = new QLabel("Key: —"); + contentLabel_ = new QLabel("Content: —"); + contentLabel_->setWordWrap(true); + ownerLabel_ = new QLabel("Owner: —"); + daysLabel_ = new QLabel("Days left: —"); + priceLabel_ = new QLabel("Price: —"); + queryTimeLabel_ = new QLabel(""); + queryTimeLabel_->setStyleSheet("color: #6b7b8d; font-size: 9pt;"); + + detailLayout->addWidget(keyLabel_); + detailLayout->addWidget(contentLabel_); + detailLayout->addWidget(ownerLabel_); + detailLayout->addWidget(daysLabel_); + detailLayout->addWidget(priceLabel_); + detailLayout->addWidget(queryTimeLabel_); + layout->addWidget(detailGroup); + + // --- Query button --- + auto* queryRow = new QHBoxLayout; + queryBtn_ = new QPushButton("Query Asset"); + queryBtn_->setToolTip("Fetch fresh data from the asset's server"); + connect(queryBtn_, &QPushButton::clicked, this, &AssetsWidget::onQueryAsset); + queryRow->addWidget(queryBtn_); + queryRow->addStretch(); + layout->addLayout(queryRow); + + // --- Action buttons --- + auto* actionGroup = new QGroupBox("Owner Actions"); + auto* actionLayout = new QVBoxLayout(actionGroup); + + // Fund + auto* fundRow = new QHBoxLayout; + fundRow->addWidget(new QLabel("Fund:")); + fundDaysSpin_ = new QSpinBox; + fundDaysSpin_->setRange(1, 65535); + fundDaysSpin_->setValue(30); + fundDaysSpin_->setSuffix(" days"); + fundRow->addWidget(fundDaysSpin_); + auto* fundBtn = new QPushButton("Fund"); + connect(fundBtn, &QPushButton::clicked, this, &AssetsWidget::onFundAsset); + fundRow->addWidget(fundBtn); + fundRow->addStretch(); + actionLayout->addLayout(fundRow); + + // Update content + auto* updateRow = new QHBoxLayout; + updateRow->addWidget(new QLabel("Update:")); + updateContentEdit_ = new QLineEdit; + updateContentEdit_->setPlaceholderText("New content (text or hex)"); + updateRow->addWidget(updateContentEdit_); + auto* updateBtn = new QPushButton("Update Content"); + connect(updateBtn, &QPushButton::clicked, + this, &AssetsWidget::onUpdateContent); + updateRow->addWidget(updateBtn); + actionLayout->addLayout(updateRow); + + // Set price + auto* priceRow = new QHBoxLayout; + priceRow->addWidget(new QLabel("Price:")); + priceEdit_ = new QLineEdit; + priceEdit_->setPlaceholderText("Whole credits (0=not for sale)"); + priceEdit_->setMaximumWidth(200); + priceRow->addWidget(priceEdit_); + auto* priceBtn = new QPushButton("Set Price"); + connect(priceBtn, &QPushButton::clicked, + this, &AssetsWidget::onSetPrice); + priceRow->addWidget(priceBtn); + priceRow->addStretch(); + actionLayout->addLayout(priceRow); + + // Give + auto* giveRow = new QHBoxLayout; + giveRow->addWidget(new QLabel("Give to:")); + giveDestEdit_ = new QLineEdit; + giveDestEdit_->setPlaceholderText("Public key hex or @index"); + giveRow->addWidget(giveDestEdit_); + auto* giveBtn = new QPushButton("Give"); + connect(giveBtn, &QPushButton::clicked, this, &AssetsWidget::onGiveAsset); + giveRow->addWidget(giveBtn); + actionLayout->addLayout(giveRow); + + layout->addWidget(actionGroup); + + // --- Status --- + statusLabel_ = new QLabel(""); + statusLabel_->setStyleSheet("font-style: italic; color: #6b7b8d;"); + statusLabel_->setWordWrap(true); + layout->addWidget(statusLabel_); + + layout->addStretch(); + refreshAssetList(); + } + +public slots: + void refreshAssetList() { + assetCombo_->blockSignals(true); + int prevIdx = assetCombo_->currentIndex(); + assetCombo_->clear(); + for (auto& a : config_.trackedAssets) + assetCombo_->addItem(a.fullId()); + if (prevIdx >= 0 && prevIdx < assetCombo_->count()) + assetCombo_->setCurrentIndex(prevIdx); + assetCombo_->blockSignals(false); + onAssetSelected(assetCombo_->currentIndex()); + } + +signals: + void statusMessage(const QString& msg, int timeout); + +private slots: + void onAssetSelected(int index) { + if (index < 0 || index >= config_.trackedAssets.size()) { + clearDetail(); + return; + } + auto& a = config_.trackedAssets[index]; + keyLabel_->setText("Key: " + a.keyDisplay()); + if (!a.known) { + contentLabel_->setText("Content: —"); + ownerLabel_->setText("Owner: (not yet queried)"); + daysLabel_->setText("Days left: —"); + priceLabel_->setText("Price: —"); + } else { + updateDetailFromAsset(a); + } + + if (!a.lastQueryTime.isEmpty()) + queryTimeLabel_->setText("Last queried: " + a.lastQueryTime); + else + queryTimeLabel_->setText(""); + if (!a.lastQueryError.isEmpty()) + queryTimeLabel_->setText(queryTimeLabel_->text() + + " (error: " + a.lastQueryError + ")"); + } + + void onQueryAsset() { + int idx = assetCombo_->currentIndex(); + if (idx < 0 || idx >= config_.trackedAssets.size()) return; + if (busy_) return; + + auto& a = config_.trackedAssets[idx]; + busy_ = true; + queryBtn_->setEnabled(false); + setStatus("Querying " + a.fullId() + "...", "#dcdcaa"); + + auto* worker = new AssetUnsignedQueryWorker(a.server, a.assetKey, this); + connect(worker, &AssetUnsignedQueryWorker::queryResult, + this, [this, idx](bool ok, QString owner, QString contentHex, + uint16_t days, uint32_t storedPrice, QString error) { + onQueryResult(idx, ok, owner, contentHex, days, storedPrice, error); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + + void onQueryResult(int idx, bool ok, QString owner, QString contentHex, + uint16_t days, uint32_t storedPrice, QString error) { + busy_ = false; + queryBtn_->setEnabled(true); + + if (idx < 0 || idx >= config_.trackedAssets.size()) return; + + auto& a = config_.trackedAssets[idx]; + a.lastQueryTime = QDateTime::currentDateTime().toString(Qt::ISODate); + + if (ok) { + a.known = true; + a.owner = owner; + a.contentHex = contentHex; + a.days = days; + a.storedPrice = storedPrice; + a.lastQueryError = ""; + setStatus("Query OK.", "#4ec9b0"); + log("Asset query OK: " + a.fullId()); + } else { + a.lastQueryError = error; + setStatus("Query failed: " + error, "#f44747"); + log("Asset query failed: " + a.fullId() + " — " + error); + } + config_.updateTrackedAsset(idx, a); + + if (assetCombo_->currentIndex() == idx) + onAssetSelected(idx); + } + + void onTrackAsset() { + bool ok; + QString input = QInputDialog::getText(this, "Track Asset", + "Asset ID (key@server or just key for current server):", + QLineEdit::Normal, "", &ok); + if (!ok || input.trimmed().isEmpty()) return; + input = input.trimmed(); + + QString assetKey, server; + int atPos = input.lastIndexOf('@'); + if (atPos > 0) { + assetKey = input.left(atPos); + server = input.mid(atPos + 1); + } else { + assetKey = input; + server = config_.currentServer; + } + + if (server.isEmpty()) { + setStatus("No server specified and no current server set.", "#f44747"); + return; + } + + // Validate key: parse it through parseAssetKey to normalize + try { + auto parsed = ces::parseAssetKey(assetKey.toStdString()); + assetKey = QString::fromStdString(minx::hashToString(parsed)); + } catch (...) { + setStatus("Invalid asset key.", "#f44747"); + return; + } + + if (config_.findTrackedAsset(assetKey, server) >= 0) { + setStatus("Already tracking this asset.", "#dcdcaa"); + return; + } + + TrackedAsset ta; + ta.assetKey = assetKey; + ta.server = server; + config_.addTrackedAsset(ta); + refreshAssetList(); + assetCombo_->setCurrentIndex(config_.trackedAssets.size() - 1); + setStatus("Now tracking: " + ta.fullId(), "#4ec9b0"); + log("Tracking asset: " + ta.fullId()); + } + + void onRemoveAsset() { + int idx = assetCombo_->currentIndex(); + if (idx < 0 || idx >= config_.trackedAssets.size()) return; + auto a = config_.trackedAssets[idx]; + + auto reply = QMessageBox::question(this, "Remove Asset", + "Stop tracking " + a.fullId() + "?", + QMessageBox::Yes | QMessageBox::No); + if (reply != QMessageBox::Yes) return; + + config_.removeTrackedAsset(a.assetKey, a.server); + refreshAssetList(); + setStatus("Removed: " + a.fullId(), "#4ec9b0"); + log("Removed asset: " + a.fullId()); + } + + void onFundAsset() { + int idx = assetCombo_->currentIndex(); + if (idx < 0 || idx >= config_.trackedAssets.size()) return; + if (busy_) return; + + ces::KeyPair kp; + if (!getSigningKey(kp)) return; + + auto& a = config_.trackedAssets[idx]; + busy_ = true; + setStatus("Funding asset...", "#dcdcaa"); + + auto* worker = new AssetActionWorker( + AssetActionWorker::Fund, a.server, kp, a.assetKey, this); + worker->setDays(static_cast(fundDaysSpin_->value())); + connectActionResult(worker, idx, "Funded"); + worker->start(); + } + + void onUpdateContent() { + int idx = assetCombo_->currentIndex(); + if (idx < 0 || idx >= config_.trackedAssets.size()) return; + if (busy_) return; + + QString contentStr = updateContentEdit_->text().trimmed(); + if (contentStr.isEmpty()) { setStatus("Enter content.", "#f44747"); return; } + + ces::KeyPair kp; + if (!getSigningKey(kp)) return; + + ces::AssetData content; + try { + content = ces::parseAssetContent(contentStr.toStdString()); + } catch (std::exception& e) { + setStatus(QString("Parse error: %1").arg(e.what()), "#f44747"); + return; + } + + auto& a = config_.trackedAssets[idx]; + busy_ = true; + setStatus("Updating content...", "#dcdcaa"); + + auto* worker = new AssetActionWorker( + AssetActionWorker::UpdateFast, a.server, kp, a.assetKey, this); + worker->setContent(content); + connectActionResult(worker, idx, "Content updated"); + worker->start(); + } + + void onSetPrice() { + int idx = assetCombo_->currentIndex(); + if (idx < 0 || idx >= config_.trackedAssets.size()) return; + if (busy_) return; + + bool ok; + uint64_t wholeCredits = priceEdit_->text().trimmed().toULongLong(&ok); + if (!ok) { setStatus("Invalid price number.", "#f44747"); return; } + + uint32_t stored; + if (ces::validatePrice(wholeCredits, stored) != 0) { + setStatus(QString("Invalid price. Max: %1").arg(UINT32_MAX), "#f44747"); + return; + } + + ces::KeyPair kp; + if (!getSigningKey(kp)) return; + + auto& a = config_.trackedAssets[idx]; + // Keep current owner for meta update + ces::HashPrefix owner{}; + if (a.known && !a.owner.isEmpty()) { + auto ownerBytes = QByteArray::fromHex(a.owner.toLatin1()); + if (ownerBytes.size() >= 8) + std::memcpy(owner.data(), ownerBytes.data(), 8); + } else { + // Use our own key as owner + owner = ces::Account::getMapKey(kp.getPublicKeyAsHash()); + } + + busy_ = true; + setStatus("Setting price...", "#dcdcaa"); + + auto* worker = new AssetActionWorker( + AssetActionWorker::UpdateMeta, a.server, kp, a.assetKey, this); + worker->setNewOwner(owner); + worker->setStoredPrice(stored); + connectActionResult(worker, idx, "Price updated"); + worker->start(); + } + + void onGiveAsset() { + int idx = assetCombo_->currentIndex(); + if (idx < 0 || idx >= config_.trackedAssets.size()) return; + if (busy_) return; + + QString destStr = giveDestEdit_->text().trimmed(); + if (destStr.isEmpty()) { setStatus("Enter destination.", "#f44747"); return; } + + ces::KeyPair kp; + if (!getSigningKey(kp)) return; + + ces::HashPrefix newOwner; + try { + auto& w = keyManager_.wallet(); + std::string resolved = w.resolveKey(destStr.toStdString()); + minx::Hash h; + minx::stringToHash(h, resolved); + newOwner = ces::Account::getMapKey(h); + } catch (std::exception& e) { + setStatus(QString("Bad destination: %1").arg(e.what()), "#f44747"); + return; + } + + auto& a = config_.trackedAssets[idx]; + busy_ = true; + setStatus("Giving asset...", "#dcdcaa"); + + auto* worker = new AssetActionWorker( + AssetActionWorker::Give, a.server, kp, a.assetKey, this); + worker->setNewOwner(newOwner); + connectActionResult(worker, idx, "Asset given"); + worker->start(); + } + +private: + void connectActionResult(AssetActionWorker* worker, int idx, + const QString& successMsg) { + connect(worker, &AssetActionWorker::actionResult, + this, [this, idx, successMsg](bool ok, uint8_t, QString error) { + busy_ = false; + if (ok) { + setStatus(successMsg + "!", "#4ec9b0"); + log(successMsg); + // Auto-query to refresh cached data + if (idx >= 0 && idx < config_.trackedAssets.size() && + assetCombo_->currentIndex() == idx) { + QTimer::singleShot(200, this, &AssetsWidget::onQueryAsset); + } + } else { + setStatus("Failed: " + error, "#f44747"); + log("Action failed: " + error); + } + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + } + + bool getSigningKey(ces::KeyPair& kp) { + auto& w = keyManager_.wallet(); + if (w.empty()) { + setStatus("No keys in wallet.", "#f44747"); + return false; + } + + int idx = assetCombo_->currentIndex(); + if (idx >= 0 && idx < config_.trackedAssets.size()) { + auto& a = config_.trackedAssets[idx]; + if (a.known && !a.owner.isEmpty()) { + // Try to find a local key matching the owner + auto ownerBytes = QByteArray::fromHex(a.owner.toLatin1()); + for (int i = 0; i < w.size(); ++i) { + auto localKp = w.keyPair(i); + ces::HashPrefix localOwner = + ces::Account::getMapKey(localKp.getPublicKeyAsHash()); + QByteArray localBytes(reinterpret_cast(localOwner.data()), + localOwner.size()); + if (localBytes == ownerBytes) { + kp = localKp; + return true; + } + } + setStatus("No local key matches the asset owner.", "#f44747"); + return false; + } + } + // Fall back to first key + kp = w.keyPair(0); + return true; + } + + void clearDetail() { + keyLabel_->setText("Key: —"); + contentLabel_->setText("Content: —"); + ownerLabel_->setText("Owner: —"); + daysLabel_->setText("Days left: —"); + priceLabel_->setText("Price: —"); + queryTimeLabel_->setText(""); + } + + void updateDetailFromAsset(const TrackedAsset& a) { + keyLabel_->setText("Key: " + a.keyDisplay()); + contentLabel_->setText("Content: " + a.contentDisplay()); + + // Check if any local key matches the owner + QString ownerStr = a.owner; + auto& w = keyManager_.wallet(); + auto ownerBytes = QByteArray::fromHex(a.owner.toLatin1()); + for (int i = 0; i < w.size(); ++i) { + ces::HashPrefix localOwner = + ces::Account::getMapKey(w.keyPair(i).getPublicKeyAsHash()); + QByteArray localBytes(reinterpret_cast(localOwner.data()), + localOwner.size()); + if (localBytes == ownerBytes) { + ownerStr = QString("@%1 (%2)").arg(i).arg(a.owner); + break; + } + } + ownerLabel_->setText("Owner: " + ownerStr); + daysLabel_->setText(QString("Days left: %1").arg(a.days)); + if (a.storedPrice == 0) + priceLabel_->setText("Price: not for sale"); + else + priceLabel_->setText(QString("Price: %1 credits") + .arg(a.storedPrice)); + } + + void setStatus(const QString& msg, const char* color) { + statusLabel_->setText(msg); + statusLabel_->setStyleSheet( + QString("font-style: italic; color: %1;").arg(color)); + } + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } + + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + + QComboBox* assetCombo_ = nullptr; + QPushButton* removeBtn_ = nullptr; + QPushButton* queryBtn_ = nullptr; + QLabel* keyLabel_ = nullptr; + QLabel* ownerLabel_ = nullptr; + QLabel* daysLabel_ = nullptr; + QLabel* priceLabel_ = nullptr; + QLabel* contentLabel_ = nullptr; + QLabel* queryTimeLabel_ = nullptr; + QLabel* statusLabel_ = nullptr; + + // Action fields + QSpinBox* fundDaysSpin_ = nullptr; + QLineEdit* updateContentEdit_ = nullptr; + QLineEdit* priceEdit_ = nullptr; + QLineEdit* giveDestEdit_ = nullptr; + + bool busy_ = false; +}; + +// ============================================================================= +// CreateAssetWidget — asset creation with live binary preview +// ============================================================================= + +class CreateAssetWidget : public QWidget { + Q_OBJECT +public: + explicit CreateAssetWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(8); + + // --- Server selector --- + auto* serverRow = new QHBoxLayout; + serverRow->addWidget(new QLabel("Server:")); + serverCombo_ = new CopyableComboBox; + serverCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + serverRow->addWidget(serverCombo_); + layout->addLayout(serverRow); + + // --- Account selector --- + auto* acctRow = new QHBoxLayout; + acctRow->addWidget(new QLabel("Account:")); + keyCombo_ = new KeyComboBox; + keyCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + acctRow->addWidget(keyCombo_); + layout->addLayout(acctRow); + + // --- Asset key --- + auto* keyLabelRow = new QHBoxLayout; + keyLabelRow->addWidget(new QLabel("Asset key:")); + keyHexCheck_ = new QCheckBox("Binary input (hex, e.g. FFA01032)"); + keyLabelRow->addWidget(keyHexCheck_); + keyLabelRow->addStretch(); + layout->addLayout(keyLabelRow); + keyEdit_ = new QLineEdit; + keyEdit_->setPlaceholderText("Text name (max 32 bytes UTF-8)"); + layout->addWidget(keyEdit_); + connect(keyHexCheck_, &QCheckBox::toggled, this, [this](bool hex) { + keyEdit_->setPlaceholderText(hex + ? "Hex bytes, e.g. 48656C6C6F (max 64 hex chars = 32 bytes)" + : "Text name (max 32 bytes UTF-8)"); + updatePreview(); + }); + + // --- Content --- + auto* contentLabelRow = new QHBoxLayout; + contentLabelRow->addWidget(new QLabel("Content:")); + contentHexCheck_ = new QCheckBox("Binary input (hex, e.g. FFA01032)"); + contentLabelRow->addWidget(contentHexCheck_); + contentLabelRow->addStretch(); + layout->addLayout(contentLabelRow); + contentEdit_ = new QPlainTextEdit; + contentEdit_->setPlaceholderText("Text content (max 210 bytes UTF-8)"); + contentEdit_->setFixedHeight(80); + contentEdit_->setLineWrapMode(QPlainTextEdit::WidgetWidth); + contentEdit_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + contentEdit_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + contentEdit_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + layout->addWidget(contentEdit_); + connect(contentHexCheck_, &QCheckBox::toggled, this, [this](bool hex) { + contentEdit_->setPlaceholderText(hex + ? "Hex bytes, e.g. 48656C6C6F (max 420 hex chars = 210 bytes)" + : "Text content (max 210 bytes UTF-8)"); + contentEdit_->viewport()->repaint(); + updatePreview(); + }); + + // --- Days + Create --- + auto* actionRow = new QHBoxLayout; + actionRow->addWidget(new QLabel("Prepaid days:")); + daysSpin_ = new QSpinBox; + daysSpin_->setRange(1, 65535); + daysSpin_->setValue(30); + actionRow->addWidget(daysSpin_); + actionRow->addSpacing(16); + createBtn_ = new QPushButton("Create Asset"); + createBtn_->setStyleSheet( + "QPushButton { padding: 6px 20px; font-weight: bold; }"); + connect(createBtn_, &QPushButton::clicked, + this, &CreateAssetWidget::onCreate); + actionRow->addWidget(createBtn_); + actionRow->addStretch(); + layout->addLayout(actionRow); + + // --- Status --- + statusLabel_ = new QLabel(""); + statusLabel_->setStyleSheet("font-style: italic; color: #6b7b8d;"); + statusLabel_->setWordWrap(true); + layout->addWidget(statusLabel_); + + // --- Live binary preview --- + previewEdit_ = new QPlainTextEdit; + previewEdit_->setReadOnly(true); + previewEdit_->setFont(QFont("monospace", 9)); + previewEdit_->setMinimumHeight(160); + previewEdit_->setLineWrapMode(QPlainTextEdit::WidgetWidth); + previewEdit_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + previewEdit_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + previewEdit_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Expanding); + previewEdit_->setPlainText("Live Preview"); + layout->addWidget(previewEdit_); + + // Connect live preview + connect(keyEdit_, &QLineEdit::textChanged, + this, &CreateAssetWidget::updatePreview); + connect(contentEdit_, &QPlainTextEdit::textChanged, + this, &CreateAssetWidget::updatePreview); + + refreshKeyList(); + refreshServerList(); + } + +public slots: + void refreshKeyList() { + keyCombo_->blockSignals(true); + keyCombo_->clear(); + auto& w = keyManager_.wallet(); + for (int i = 0; i < w.size(); ++i) { + auto kp = w.keyPair(i); + keyCombo_->addItem(formatKeyLabel(i, kp, w.label(i)), i); + } + if (!w.empty()) { + int sel = std::clamp(config_.defaultAccount, 0, w.size() - 1); + keyCombo_->setCurrentIndex(sel); + } + keyCombo_->blockSignals(false); + } + + void refreshServerList() { + serverCombo_->blockSignals(true); + serverCombo_->clear(); + for (auto& s : config_.servers) + serverCombo_->addItem(s); + // Select current server if present + if (!config_.currentServer.isEmpty()) { + int idx = config_.servers.indexOf(config_.currentServer); + if (idx >= 0) serverCombo_->setCurrentIndex(idx); + } + serverCombo_->blockSignals(false); + } + +signals: + void statusMessage(const QString& msg, int timeout); + void assetCreated(const QString& assetKey, const QString& server); + +private slots: + void updatePreview() { + QString text; + text += formatFieldPreview(keyEdit_->text(), 32, keyHexCheck_->isChecked(), "Asset Key"); + text += "\n"; + text += formatFieldPreview(contentEdit_->toPlainText(), 210, contentHexCheck_->isChecked(), "Content"); + previewEdit_->setPlainText(text); + } + + void onCreate() { + QString keyStr = keyEdit_->text().trimmed(); + QString contentStr = contentEdit_->toPlainText().trimmed(); + if (keyStr.isEmpty()) { setStatus("Enter asset key.", "#f44747"); return; } + if (contentStr.isEmpty()) { setStatus("Enter content.", "#f44747"); return; } + if (busy_) return; + + if (serverCombo_->count() == 0) { + setStatus("No servers configured. Add servers in the Servers tab.", "#f44747"); + return; + } + QString server = serverCombo_->currentText(); + + auto& w = keyManager_.wallet(); + if (w.empty()) { + setStatus("No keys in wallet.", "#f44747"); + return; + } + int idx = keyCombo_->currentData().toInt(); + if (idx < 0 || idx >= w.size()) return; + ces::KeyPair kp = w.keyPair(idx); + + ces::AssetData content{}; + minx::Hash keyBytes{}; + try { + if (keyHexCheck_->isChecked()) { + QString cleaned = keyStr; + cleaned.remove(' '); + QByteArray raw = QByteArray::fromHex(cleaned.toLatin1()); + if (raw.size() > 32) throw std::runtime_error("Key hex too long (max 32 bytes)"); + memcpy(keyBytes.data(), raw.data(), raw.size()); + } else { + QByteArray utf8 = keyStr.toUtf8(); + if (utf8.size() > 32) throw std::runtime_error("Key text too long (max 32 bytes)"); + memcpy(keyBytes.data(), utf8.data(), utf8.size()); + } + if (contentHexCheck_->isChecked()) { + QString cleaned = contentStr; + cleaned.remove(' '); + QByteArray raw = QByteArray::fromHex(cleaned.toLatin1()); + if (raw.size() > 210) throw std::runtime_error("Content hex too long (max 210 bytes)"); + memcpy(content.data(), raw.data(), raw.size()); + } else { + QByteArray utf8 = contentStr.toUtf8(); + if (utf8.size() > 210) throw std::runtime_error("Content text too long (max 210 bytes)"); + memcpy(content.data(), utf8.data(), utf8.size()); + } + } catch (std::exception& e) { + setStatus(QString("Parse error: %1").arg(e.what()), "#f44747"); + return; + } + QString assetKeyHex = QString::fromStdString(minx::hashToString(keyBytes)); + + busy_ = true; + setStatus("Creating asset...", "#dcdcaa"); + log(QString("Creating asset %1@%2 (%3 days)") + .arg(assetKeyHex.left(16) + "...").arg(server) + .arg(daysSpin_->value())); + + auto* worker = new AssetActionWorker( + AssetActionWorker::Create, server, kp, assetKeyHex, this); + worker->setContent(content); + worker->setDays(static_cast(daysSpin_->value())); + connect(worker, &AssetActionWorker::actionResult, + this, [this, assetKeyHex, server](bool ok, uint8_t, QString error) { + busy_ = false; + if (ok) { + setStatus("Asset created!", "#4ec9b0"); + log("Created asset: " + assetKeyHex + "@" + server); + TrackedAsset ta; + ta.assetKey = assetKeyHex; + ta.server = server; + config_.addTrackedAsset(ta); + emit assetCreated(assetKeyHex, server); + } else { + setStatus("Create failed: " + error, "#f44747"); + log("Create failed: " + error); + } + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + +private: + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + + QComboBox* serverCombo_ = nullptr; + QComboBox* keyCombo_ = nullptr; + QCheckBox* keyHexCheck_ = nullptr; + QLineEdit* keyEdit_ = nullptr; + QCheckBox* contentHexCheck_ = nullptr; + QPlainTextEdit* contentEdit_ = nullptr; + QSpinBox* daysSpin_ = nullptr; + QPushButton* createBtn_ = nullptr; + QLabel* statusLabel_ = nullptr; + + QPlainTextEdit* previewEdit_ = nullptr; + + bool busy_ = false; + + void setStatus(const QString& msg, const char* color) { + statusLabel_->setText(msg); + statusLabel_->setStyleSheet( + QString("font-style: italic; color: %1;").arg(color)); + } + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } + + + static QString bytesToHexSpaced(const QByteArray& bytes) { + QString result; + for (int i = 0; i < bytes.size(); ++i) { + if (i > 0) result += ' '; + result += QString("%1").arg( + static_cast(bytes[i]), 2, 16, QChar('0')).toUpper(); + } + return result; + } + + static QString tryUtf8Render(const QByteArray& bytes) { + // Find length (up to first zero) + int len = 0; + for (int i = 0; i < bytes.size(); ++i) { + if (bytes[i] == 0) break; + len++; + } + if (len == 0) return "(empty)"; + QByteArray active = bytes.left(len); + // Check valid UTF-8 + auto codec = QStringDecoder(QStringDecoder::Utf8); + QString decoded = codec(active); + if (codec.hasError()) + return "(invalid UTF-8)"; + // Escape control chars for display + QString display; + for (QChar c : decoded) { + if (c == '\n') display += "\xe2\x90\x8a"; // ␊ + else if (c == '\r') display += "\xe2\x90\x8d"; // ␍ + else if (c == '\t') display += "\xe2\x90\x89"; // ␉ + else if (c == ' ') display += "\xe2\x90\xa3"; // ␣ + else if (c.unicode() < 32) display += "\xef\xbf\xbd"; // replacement + else display += c; + } + return display; + } + + QString formatFieldPreview(const QString& input, int fieldSize, bool hexMode, + const char* name) { + if (input.isEmpty()) + return QString("%1 (0/%2 bytes):\n UTF-8: (empty)\n Binary: (empty)").arg(name).arg(fieldSize); + + QByteArray bytes(fieldSize, '\0'); + int activeLen = 0; + + if (hexMode) { + QString cleaned = input; + cleaned.remove(' '); + if (cleaned.size() % 2 != 0) + return QString("%1 (? /%2 bytes — odd hex length)").arg(name).arg(fieldSize); + QByteArray raw = QByteArray::fromHex(cleaned.toLatin1()); + if (raw.size() > fieldSize) + return QString("%1 (%2/%3 bytes — too long!)").arg(name).arg(raw.size()).arg(fieldSize); + memcpy(bytes.data(), raw.data(), raw.size()); + activeLen = raw.size(); + } else { + QByteArray utf8 = input.toUtf8(); + if (utf8.size() > fieldSize) + return QString("%1 (%2/%3 bytes — too long!)").arg(name).arg(utf8.size()).arg(fieldSize); + memcpy(bytes.data(), utf8.data(), utf8.size()); + activeLen = utf8.size(); + } + + return QString("%1 (%2/%3 bytes):\n UTF-8: %4\n Binary: %5") + .arg(name).arg(activeLen).arg(fieldSize) + .arg(tryUtf8Render(bytes)) + .arg(bytesToHexSpaced(bytes)); + } +}; + +// ============================================================================= +// MarketplaceWidget — query and buy assets +// ============================================================================= + +class MarketplaceWidget : public QWidget { + Q_OBJECT +public: + explicit MarketplaceWidget(const KeyManagerWidget& keyManager, + AppConfig& config, ConsoleWidget* console, + QWidget* parent = nullptr) + : QWidget(parent), keyManager_(keyManager), config_(config), + console_(console) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(8); + + // --- Query section --- + auto* queryRow = new QHBoxLayout; + queryRow->addWidget(new QLabel("Asset ID:")); + idEdit_ = new QLineEdit; + idEdit_->setPlaceholderText("key@server or key (uses current server)"); + queryRow->addWidget(idEdit_); + queryBtn_ = new QPushButton("Query"); + connect(queryBtn_, &QPushButton::clicked, + this, &MarketplaceWidget::onQuery); + queryRow->addWidget(queryBtn_); + layout->addLayout(queryRow); + + // --- Result display --- + auto* resultGroup = new QGroupBox("Result"); + auto* resultLayout = new QVBoxLayout(resultGroup); + resultLayout->setSpacing(4); + + resultKey_ = new QLabel("Key: —"); + resultContent_ = new QLabel("Content: —"); + resultContent_->setWordWrap(true); + resultOwner_ = new QLabel("Owner: —"); + resultDays_ = new QLabel("Days left: —"); + resultPrice_ = new QLabel("Price: —"); + + resultLayout->addWidget(resultKey_); + resultLayout->addWidget(resultContent_); + resultLayout->addWidget(resultOwner_); + resultLayout->addWidget(resultDays_); + resultLayout->addWidget(resultPrice_); + layout->addWidget(resultGroup); + + // --- Buy section --- + auto* buyRow = new QHBoxLayout; + buyRow->addWidget(new QLabel("Buy:")); + buyRow->addWidget(new QLabel("Max price:")); + buyAmountEdit_ = new QLineEdit; + buyAmountEdit_->setPlaceholderText("Whole credits (integer, >= asking price)"); + buyAmountEdit_->setMaximumWidth(400); + buyRow->addWidget(buyAmountEdit_); + buyBtn_ = new QPushButton("Buy Asset"); + connect(buyBtn_, &QPushButton::clicked, + this, &MarketplaceWidget::onBuy); + buyRow->addWidget(buyBtn_); + buyRow->addStretch(); + + auto* trackBtn = new QPushButton("Track This"); + trackBtn->setToolTip("Add this asset to your tracked assets list"); + connect(trackBtn, &QPushButton::clicked, + this, &MarketplaceWidget::onTrack); + buyRow->addWidget(trackBtn); + + layout->addLayout(buyRow); + + // --- Account selector for buying --- + auto* accountRow = new QHBoxLayout; + accountRow->addWidget(new QLabel("Buy with account:")); + keyCombo_ = new KeyComboBox; + keyCombo_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + accountRow->addWidget(keyCombo_); + layout->addLayout(accountRow); + + // Status + statusLabel_ = new QLabel(""); + statusLabel_->setStyleSheet("font-style: italic; color: #6b7b8d;"); + statusLabel_->setWordWrap(true); + layout->addWidget(statusLabel_); + + layout->addStretch(); + refreshKeyList(); + } + +public slots: + void refreshKeyList() { + keyCombo_->clear(); + auto& w = keyManager_.wallet(); + for (int i = 0; i < w.size(); ++i) { + auto kp = w.keyPair(i); + keyCombo_->addItem(formatKeyLabel(i, kp, w.label(i)), i); + } + } + +signals: + void statusMessage(const QString& msg, int timeout); + void assetBought(const QString& assetKey, const QString& server); + +private slots: + void onQuery() { + if (busy_) return; + QString input = idEdit_->text().trimmed(); + if (input.isEmpty()) { setStatus("Enter asset ID.", "#f44747"); return; } + + parseAssetId(input); + if (lastServer_.isEmpty()) { + setStatus("No server specified and no default set.", "#f44747"); + return; + } + + busy_ = true; + queryBtn_->setEnabled(false); + setStatus("Querying...", "#dcdcaa"); + log("Market query: " + lastAssetKey_ + "@" + lastServer_); + + auto* worker = new AssetUnsignedQueryWorker( + lastServer_, lastAssetKey_, this); + connect(worker, &AssetUnsignedQueryWorker::queryResult, + this, &MarketplaceWidget::onQueryResult); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + + void onQueryResult(bool ok, QString owner, QString contentHex, + uint16_t days, uint32_t storedPrice, QString error) { + busy_ = false; + queryBtn_->setEnabled(true); + + if (!ok) { + resultKey_->setText("Key: —"); + resultContent_->setText("Content: —"); + resultOwner_->setText("Owner: —"); + resultDays_->setText("Days left: —"); + resultPrice_->setText("Price: —"); + // Clear stale data so Track/Buy can't use previous query's results + lastOwner_.clear(); + lastContentHex_.clear(); + lastDays_ = 0; + lastStoredPrice_ = 0; + setStatus("Query failed: " + error, "#f44747"); + log("Market query failed: " + lastAssetKey_ + " — " + error); + return; + } + + lastOwner_ = owner; + lastDays_ = days; + lastStoredPrice_ = storedPrice; + lastContentHex_ = contentHex; + + // Key display (text if printable ASCII, else hex) + TrackedAsset tmp; + tmp.assetKey = lastAssetKey_; + tmp.contentHex = contentHex; + resultKey_->setText("Key: " + tmp.keyDisplay()); + resultContent_->setText("Content: " + tmp.contentDisplay()); + resultOwner_->setText("Owner: " + owner); + resultDays_->setText(QString("Days left: %1").arg(days)); + QString priceStr; + if (storedPrice == 0) { + priceStr = "not for sale"; + resultPrice_->setText("Price: not for sale"); + } else { + priceStr = QString("%1 credits").arg(storedPrice); + resultPrice_->setText("Price: " + priceStr); + buyAmountEdit_->setText(QString::number(storedPrice)); + } + + setStatus("Query OK.", "#4ec9b0"); + log(QString("Market query: %1 | owner %2 | %3 days | %4") + .arg(tmp.keyDisplay()).arg(owner.left(16) + "...") + .arg(days).arg(priceStr)); + } + + void onBuy() { + if (busy_) return; + if (lastAssetKey_.isEmpty()) { + setStatus("Query an asset first.", "#f44747"); + return; + } + if (lastStoredPrice_ == 0) { + setStatus("Asset is not for sale.", "#f44747"); + return; + } + + bool ok; + uint64_t wholeCredits = buyAmountEdit_->text().trimmed().toULongLong(&ok); + if (!ok || wholeCredits == 0) { + setStatus("Invalid buy amount.", "#f44747"); + return; + } + if (wholeCredits > UINT64_MAX / ces::PRICE_UNIT) { + setStatus("Buy amount too large.", "#f44747"); + return; + } + uint64_t amount = wholeCredits * ces::PRICE_UNIT; + + auto& w = keyManager_.wallet(); + if (w.empty()) { setStatus("No keys in wallet.", "#f44747"); return; } + int kidx = keyCombo_->currentData().toInt(); + ces::KeyPair kp = w.keyPair(kidx); + + busy_ = true; + buyBtn_->setEnabled(false); + setStatus("Buying asset...", "#dcdcaa"); + log(QString("Buying asset %1@%2 for %3 credits") + .arg(lastAssetKey_.left(16) + "...").arg(lastServer_).arg(wholeCredits)); + + auto* worker = new AssetActionWorker( + AssetActionWorker::Buy, lastServer_, kp, lastAssetKey_, this); + worker->setBuyAmount(amount); + connect(worker, &AssetActionWorker::actionResult, + this, [this](bool ok2, uint8_t, QString error) { + busy_ = false; + buyBtn_->setEnabled(true); + if (ok2) { + setStatus("Purchase successful!", "#4ec9b0"); + log("Bought asset: " + lastAssetKey_ + "@" + lastServer_); + // Auto-track + TrackedAsset ta; + ta.assetKey = lastAssetKey_; + ta.server = lastServer_; + config_.addTrackedAsset(ta); + emit assetBought(lastAssetKey_, lastServer_); + // Re-query to see new ownership + QTimer::singleShot(200, this, &MarketplaceWidget::onQuery); + } else { + setStatus("Buy failed: " + error, "#f44747"); + log("Buy failed: " + error); + } + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); + } + + void onTrack() { + if (lastAssetKey_.isEmpty() || lastServer_.isEmpty()) { + setStatus("Query an asset first.", "#f44747"); + return; + } + if (config_.findTrackedAsset(lastAssetKey_, lastServer_) >= 0) { + setStatus("Already tracking this asset.", "#dcdcaa"); + return; + } + TrackedAsset ta; + ta.assetKey = lastAssetKey_; + ta.server = lastServer_; + // Store last query result if we have one + if (!lastOwner_.isEmpty()) { + ta.known = true; + ta.owner = lastOwner_; + ta.contentHex = lastContentHex_; + ta.days = lastDays_; + ta.storedPrice = lastStoredPrice_; + ta.lastQueryTime = QDateTime::currentDateTime().toString(Qt::ISODate); + } + config_.addTrackedAsset(ta); + setStatus("Now tracking: " + ta.fullId(), "#4ec9b0"); + log("Tracking asset from marketplace: " + ta.fullId()); + emit assetBought(lastAssetKey_, lastServer_); // reuse signal to refresh Assets tab + } + +private: + void parseAssetId(const QString& input) { + int atPos = input.lastIndexOf('@'); + if (atPos > 0) { + QString keyPart = input.left(atPos); + lastServer_ = input.mid(atPos + 1); + try { + auto parsed = ces::parseAssetKey(keyPart.toStdString()); + lastAssetKey_ = QString::fromStdString(minx::hashToString(parsed)); + } catch (...) { + lastAssetKey_ = keyPart; + } + } else { + lastServer_ = config_.currentServer; + try { + auto parsed = ces::parseAssetKey(input.toStdString()); + lastAssetKey_ = QString::fromStdString(minx::hashToString(parsed)); + } catch (...) { + lastAssetKey_ = input; + } + } + } + + void setStatus(const QString& msg, const char* color) { + statusLabel_->setText(msg); + statusLabel_->setStyleSheet( + QString("font-style: italic; color: %1;").arg(color)); + } + + void log(const QString& msg) { + if (console_) console_->appendLog(msg); + } + + const KeyManagerWidget& keyManager_; + AppConfig& config_; + ConsoleWidget* console_ = nullptr; + + QLineEdit* idEdit_ = nullptr; + QPushButton* queryBtn_ = nullptr; + QPushButton* buyBtn_ = nullptr; + QLabel* resultKey_ = nullptr; + QLabel* resultContent_ = nullptr; + QLabel* resultOwner_ = nullptr; + QLabel* resultDays_ = nullptr; + QLabel* resultPrice_ = nullptr; + QLineEdit* buyAmountEdit_ = nullptr; + QComboBox* keyCombo_ = nullptr; + QLabel* statusLabel_ = nullptr; + + bool busy_ = false; + QString lastAssetKey_; + QString lastServer_; + QString lastOwner_; + QString lastContentHex_; + uint16_t lastDays_ = 0; + uint32_t lastStoredPrice_ = 0; +}; + +// ============================================================================= +// MainWindow — tabbed container +// ============================================================================= +// AboutWidget is in about.h + +// ============================================================================= + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + #include "mainwindow_api.inc" + + MainWindow(const QString& dataDirOverride = {}, + uint16_t rpcPortOverride = 0, + bool autoApprove = false) + : autoApprove_(autoApprove) { + resize(900, 520); + + // Console tab (created first so others can log during init) + console_ = new ConsoleWidget; + console_->appendLog("CES Wallet starting..."); + + // About tab + aboutWidget_ = new AboutWidget; + + // Load config + if (!dataDirOverride.isEmpty()) + config_.dataDirOverride = dataDirOverride; + console_->appendLog("Config: " + config_.configPath()); + config_.load(); + if (config_.servers.isEmpty()) { + console_->appendLog("No servers configured."); + } else { + console_->appendLog(QString("%1 server(s) in address book.").arg(config_.servers.size())); + } + if (!config_.currentServer.isEmpty()) { + console_->appendLog("Current server: " + config_.currentServer); + } + + // Key manager tab + keyManager_ = new KeyManagerWidget(console_, config_); + connect(keyManager_, &KeyManagerWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + + // Account tab (main view) + accountWidget_ = new AccountWidget(*keyManager_, config_, console_); + connect(keyManager_, &KeyManagerWidget::walletChanged, + accountWidget_, &AccountWidget::refreshKeyList); + connect(accountWidget_, &AccountWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + connect(accountWidget_, &AccountWidget::balanceUpdated, + this, [this](int /*accountIndex*/, int64_t /*balance*/) { + // Account tab queried fresh data — refresh Wallet aggregate + walletWidget_->queryAllFull(); + }); + + // Wallet tab (aggregate balance view) + walletWidget_ = new WalletWidget(*keyManager_, config_, console_); + connect(keyManager_, &KeyManagerWidget::walletChanged, + walletWidget_, &WalletWidget::refreshKeyList); + + // Mining tab + miningWidget_ = new MiningWidget(*keyManager_, config_, console_); + connect(keyManager_, &KeyManagerWidget::walletChanged, + miningWidget_, &MiningWidget::refreshKeyList); + connect(miningWidget_, &MiningWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + connect(miningWidget_, &MiningWidget::creditEarned, + this, [this](int accountIndex, uint64_t credit) { + if (accountIndex >= 0 && + accountIndex == accountWidget_->selectedAccountIndex()) + accountWidget_->addCredit(credit); + // Also update the wallet aggregate view (total + per-key table) + walletWidget_->addCredit(accountIndex, credit); + }); + + // Transfer tab + transferWidget_ = new TransferWidget(*keyManager_, config_, console_); + transferWidget_->setWalletWidget(walletWidget_); + connect(keyManager_, &KeyManagerWidget::walletChanged, + transferWidget_, &TransferWidget::refreshKeyList); + connect(walletWidget_, &WalletWidget::walletQueryDone, + this, [this](int64_t, int) { transferWidget_->updateInfoLabels(); }); + connect(transferWidget_, &TransferWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + connect(transferWidget_, &TransferWidget::transferCompleted, + this, [this](int accountIndex, int64_t newBalance) { + // Update account tab if viewing the same account + if (accountIndex >= 0 && + accountIndex == accountWidget_->selectedAccountIndex()) { + accountWidget_->setBalance(newBalance); + } + // Trigger wallet refresh to update aggregate + walletWidget_->queryAllFull(); + }); + + // Create Asset tab + createAssetWidget_ = new CreateAssetWidget(*keyManager_, config_, console_); + connect(keyManager_, &KeyManagerWidget::walletChanged, + createAssetWidget_, &CreateAssetWidget::refreshKeyList); + connect(createAssetWidget_, &CreateAssetWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + connect(createAssetWidget_, &CreateAssetWidget::assetCreated, + this, [this](const QString&, const QString&) { + assetsWidget_->refreshAssetList(); + }); + + // Assets tab + assetsWidget_ = new AssetsWidget(*keyManager_, config_, console_); + connect(assetsWidget_, &AssetsWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + + // Marketplace tab + marketplaceWidget_ = new MarketplaceWidget(*keyManager_, config_, console_); + connect(keyManager_, &KeyManagerWidget::walletChanged, + marketplaceWidget_, &MarketplaceWidget::refreshKeyList); + connect(marketplaceWidget_, &MarketplaceWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + // When marketplace tracks/buys an asset, refresh the Assets tab + connect(marketplaceWidget_, &MarketplaceWidget::assetBought, + this, [this](const QString&, const QString&) { + assetsWidget_->refreshAssetList(); + }); + + // Server manager tab + serverManager_ = new ServerManagerWidget(*keyManager_, config_, console_); + connect(serverManager_, &ServerManagerWidget::statusMessage, + this, [this](const QString& msg, int timeout) { + statusBar()->showMessage(msg, timeout); + }); + connect(serverManager_, &ServerManagerWidget::serverListChanged, + createAssetWidget_, &CreateAssetWidget::refreshServerList); + + // Tree-on-left navigation + stacked-pages on the right. Replaces + // the flat tab strip — categories group naturally and there's room + // to grow without a multi-row hack. Top-level items that have + // children get a placeholder summary page for now; we'll fill them + // in as features land. + auto* nav = new QTreeWidget; + nav->setHeaderHidden(true); + nav->setIndentation(18); + nav->setUniformRowHeights(true); + nav->setFrameShape(QFrame::NoFrame); + { + // Slightly larger font than the page body — sidebar labels + // are scan-targets, not body text. + QFont f = nav->font(); + f.setPointSize(f.pointSize() + 2); + nav->setFont(f); + } + // Tabbed-sidebar look: the tree sits on a dark bg (terminal-ish); + // the selected row paints in the chrome color so it visually + // bleeds into the page area to its right — like a tab connecting + // to its content panel. Bold-on-selection adds a second cue; + // ::item padding gives the vertical breathing room well-designed + // sidebars (VS Code, IDEs, settings dialogs) all share. + nav->setStyleSheet( + "QTreeWidget { " + " background: #1e1e1e; " + " color: #d0d0d0; " + " outline: none; " + "}" + "QTreeWidget::item { padding: 6px 4px; }" + "QTreeWidget::item:selected { " + " background: palette(window); " + " color: palette(window-text); " + "}"); + auto* pages = new QStackedWidget; + + auto bindPage = [pages](QTreeWidgetItem* item, QWidget* w) { + int idx = pages->addWidget(w); + item->setData(0, Qt::UserRole, idx); + }; + auto helpPage = [](const QString& title, const QString& body) { + auto* w = new QWidget; + auto* lay = new QVBoxLayout(w); + lay->addStretch(); + auto* lbl = new QLabel(QStringLiteral( + "

%1

%2

").arg(title, body)); + lbl->setAlignment(Qt::AlignCenter); + lbl->setWordWrap(true); + lay->addWidget(lbl); + lay->addStretch(); + return w; + }; + + auto* walletTop = new QTreeWidgetItem(nav, QStringList{"Wallet"}); + bindPage(walletTop, helpPage("Wallet", + "Accounts, transfers, and keys.")); + { + auto* it = new QTreeWidgetItem(walletTop, QStringList{"Overview"}); + bindPage(it, walletWidget_); + it = new QTreeWidgetItem(walletTop, QStringList{"Account"}); + bindPage(it, accountWidget_); + it = new QTreeWidgetItem(walletTop, QStringList{"Transfer"}); + bindPage(it, transferWidget_); + it = new QTreeWidgetItem(walletTop, QStringList{"Keys"}); + bindPage(it, keyManager_); + } + + auto* mining = new QTreeWidgetItem(nav, QStringList{"Mining"}); + bindPage(mining, miningWidget_); + + auto* assetsTop = new QTreeWidgetItem(nav, QStringList{"Assets"}); + bindPage(assetsTop, helpPage("Assets", + "Create, fund, browse, and trade assets on the current server.")); + { + auto* it = new QTreeWidgetItem(assetsTop, QStringList{"Create"}); + bindPage(it, createAssetWidget_); + it = new QTreeWidgetItem(assetsTop, QStringList{"Browse"}); + bindPage(it, assetsWidget_); + it = new QTreeWidgetItem(assetsTop, QStringList{"Market"}); + bindPage(it, marketplaceWidget_); + } + + auto* servers = new QTreeWidgetItem(nav, QStringList{"Servers"}); + bindPage(servers, serverManager_); + + auto* consoleItem = new QTreeWidgetItem(nav, QStringList{"Console"}); + bindPage(consoleItem, console_); + + auto* about = new QTreeWidgetItem(nav, QStringList{"About"}); + bindPage(about, aboutWidget_); + + nav->expandAll(); + + connect(nav, &QTreeWidget::currentItemChanged, pages, + [pages](QTreeWidgetItem* cur, QTreeWidgetItem* prev) { + // Page switch. + if (cur) { + bool ok = false; + int idx = cur->data(0, Qt::UserRole).toInt(&ok); + if (ok) pages->setCurrentIndex(idx); + } + // Bold-on-selection — the only visual indicator since the + // selection bg matches the chrome. + auto setBold = [](QTreeWidgetItem* it, bool b) { + if (!it) return; + QFont f = it->font(0); + f.setBold(b); + it->setFont(0, f); + }; + setBold(prev, false); + setBold(cur, true); + }); + + // If the user collapses a parent whose descendant is currently + // selected, the selection rectangle would disappear into the + // collapsed branch. Promote the current selection to the collapsed + // parent so the visual cue stays visible (and the page swaps to + // the parent's summary page). + connect(nav, &QTreeWidget::itemCollapsed, nav, + [nav](QTreeWidgetItem* collapsed) { + QTreeWidgetItem* cur = nav->currentItem(); + if (!cur) return; + for (auto* p = cur->parent(); p; p = p->parent()) { + if (p == collapsed) { + nav->setCurrentItem(collapsed); + return; + } + } + }); + + // Pin the nav to the widest label + indentation chrome + 20 px + // breathing room. No splitter — categories don't need to be + // resizable, and pages get all the leftover space. Measure with + // bold metrics so the selected (bolded) item still fits. + { + QFont boldFont = nav->font(); + boldFont.setBold(true); + QFontMetrics fm(boldFont); + int widest = 0; + std::function walk = + [&](QTreeWidgetItem* it, int depth) { + int w = fm.horizontalAdvance(it->text(0)) + + depth * nav->indentation(); + widest = std::max(widest, w); + for (int i = 0; i < it->childCount(); ++i) + walk(it->child(i), depth + 1); + }; + for (int i = 0; i < nav->topLevelItemCount(); ++i) + walk(nav->topLevelItem(i), 0); + nav->setFixedWidth(widest + nav->indentation() + 30); + } + + // Set the initial selection AFTER the signal is wired so the + // bold-on-selection handler fires for the first item too. + nav->setCurrentItem(walletTop); + + // Wrap the tree in a dark-bg column with 6 px top/bottom padding + // so the topmost selected item doesn't butt directly against the + // (whiteish) window title bar — without that gap, selecting + // "Wallet" reads as if it's activating the title chrome itself. + auto* navColumn = new QWidget; + navColumn->setFixedWidth(nav->width()); + navColumn->setStyleSheet("background: #1e1e1e;"); + navColumn->setAutoFillBackground(true); + auto* navLayout = new QVBoxLayout(navColumn); + navLayout->setContentsMargins(0, 6, 0, 6); + navLayout->setSpacing(0); + navLayout->addWidget(nav); + + auto* central = new QWidget; + auto* row = new QHBoxLayout(central); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(0); + row->addWidget(navColumn); + row->addWidget(pages, 1); + setCentralWidget(central); + + // Ctrl+Q bypasses the close-to-tray behavior and quits outright — + // standard Linux GUI quit chord, no conflict with copy/paste. + auto* quitShortcut = new QShortcut(QKeySequence("Ctrl+Q"), this); + quitShortcut->setContext(Qt::ApplicationShortcut); + connect(quitShortcut, &QShortcut::activated, + qApp, &QApplication::quit); + + // Wire console to app API + console_->setApp(this); + + // Dynamic tab completion: complete the last word if it starts with @ + console_->input()->setDynamicCompleter([this](const QString& input) -> QStringList { + // Find the last word being typed + int lastSpace = input.lastIndexOf(' '); + QString lastWord = (lastSpace >= 0) ? input.mid(lastSpace + 1) : input; + QString beforeLast = (lastSpace >= 0) ? input.left(lastSpace + 1) : ""; + + if (!lastWord.startsWith("@")) return {}; + + QStringList results; + auto& w = keyManager_->wallet(); + for (int i = 0; i < w.size(); ++i) { + QString candidate = QString("@%1").arg(i); + if (candidate.startsWith(lastWord)) + results << (beforeLast + candidate); + } + return results; + }); + + updateWindowTitle(); + connect(serverManager_, &ServerManagerWidget::serverListChanged, + this, &MainWindow::updateWindowTitle); + + // Auto-ping all servers once the event loop starts + QTimer::singleShot(0, serverManager_, &ServerManagerWidget::pingAll); + + // Auto-mine once for fresh wallets + if (keyManager_->didGenerateDefaultKey() && !config_.currentServer.isEmpty()) { + QTimer::singleShot(cesqt_timing::kAutoMineStartDelayMs, this, [this]() { + miningWidget_->autoMineOnce(); + }); + } + + // Start RPC server for browser wallet integration + RpcWalletBridge bridge; + bridge.findByLabel = [this](const std::string& l) { + return keyManager_->wallet().findByLabel(l); + }; + bridge.generateKey = [this](const std::string& l) { + return keyManager_->wallet().generate(1, ces::KeyAlgo::ED25519, l); + }; + bridge.keyPair = [this](int i) { return keyManager_->wallet().keyPair(i); }; + bridge.pubKeyHex = [this](int i) { + return keyManager_->wallet().keyPair(i).getPublicKeyHexStr(); + }; + bridge.label = [this](int i) { return keyManager_->wallet().label(i); }; + bridge.saveAndRefresh = [this]() { keyManager_->saveAndRefresh(); }; + bridge.getAccounts = [this]() -> QList { + QList result; + for (auto& a : walletWidget_->getAccounts()) + result.append({a.index, a.balance, a.exists}); + return result; + }; + bridge.currentServer = [this]() { return config_.currentServer; }; + rpcServer_ = new RpcServer(std::move(bridge), console_, autoApprove_, this); + rpcServer_->start(rpcPortOverride ? rpcPortOverride + : RpcServer::DEFAULT_PORT); + } + + void updateWindowTitle() { + QString server = config_.currentServer; + if (server.isEmpty()) { + setWindowTitle("CES Wallet"); + } else { + const QString defaultSuffix = ":" + QString::number(ces::DEFAULT_PORT); + if (server.endsWith(defaultSuffix)) + server.chop(defaultSuffix.size()); + setWindowTitle(server + " - CES Wallet"); + } + } + + void showFromTray() { + show(); + raise(); + activateWindow(); + } + + void setTrayIcon(QSystemTrayIcon* icon) { trayIcon_ = icon; } + +protected: + void closeEvent(QCloseEvent* e) override { + if (trayIcon_ && trayIcon_->isVisible()) { + hide(); + e->ignore(); + } else { + e->accept(); + } + } + +private: + AppConfig config_; + ConsoleWidget* console_ = nullptr; + WalletWidget* walletWidget_ = nullptr; + AccountWidget* accountWidget_ = nullptr; + MiningWidget* miningWidget_ = nullptr; + TransferWidget* transferWidget_ = nullptr; + CreateAssetWidget* createAssetWidget_ = nullptr; + AssetsWidget* assetsWidget_ = nullptr; + MarketplaceWidget* marketplaceWidget_ = nullptr; + KeyManagerWidget* keyManager_ = nullptr; + ServerManagerWidget* serverManager_ = nullptr; + AboutWidget* aboutWidget_ = nullptr; + RpcServer* rpcServer_ = nullptr; + bool autoApprove_ = false; + QSystemTrayIcon* trayIcon_ = nullptr; +}; + +// ============================================================================= +// Console command execution (defined here where MainWindow is complete) +// ============================================================================= + +void ConsoleWidget::execCommand(const QString& cmd) { + QString lower = cmd.toLower().trimmed(); + bool ok = false; + + if (lower == "help" || lower == "?") { + appendCmd("Available commands:"); + appendCmd(" help - Show this message"); + appendCmd(" clear - Clear the console"); + appendCmd(" history - Show command history"); + appendCmd(" !N - Recall command N from history"); + appendCmd(" bal - Show selected account balance"); + appendCmd(" bal all - Show all account balances"); + appendCmd(" keys - List wallet keys"); + appendCmd(" ping - Ping all servers"); + appendCmd(" @ - Show current account"); + appendCmd(" @N - Switch to account N"); + appendCmd(" send - Transfer funds"); + appendCmd(" status - Show app status"); + appendCmd(" tron - Enable trace logging (default)"); + appendCmd(" troff - Suppress trace logging"); + appendCmd(" quit - Close the application"); + } else if (lower == "clear" || lower == "cls") { + log_->clear(); + } else if (lower == "history" || lower == "hist") { + auto& h = input_->history(); + if (h.isEmpty()) { + appendCmd(" (empty)"); + } else { + for (int i = 0; i < h.size(); ++i) + appendCmd(QString(" %1: %2").arg(i + 1).arg(h[i])); + } + } else if (lower.startsWith("!")) { + bool ok; + int idx = lower.mid(1).toInt(&ok); + auto& h = input_->history(); + if (ok && idx >= 1 && idx <= h.size()) { + QString recalled = h[idx - 1]; + input_->pushHistory(recalled); + appendCmd("] " + recalled); + execCommand(recalled); + } else { + appendError("Invalid history reference: " + cmd); + } + } else if (lower == "quit" || lower == "exit") { + QApplication::quit(); + } else if (lower == "keys" || lower == "k") { + if (!app_) { appendError("Not connected to app."); return; } + int n = app_->keyCount(); + if (n == 0) { + appendCmd(" No keys in wallet."); + } else { + for (int i = 0; i < n; ++i) + appendCmd(QString(" @%1 %2").arg(i).arg(app_->publicKeyHex(i))); + } + } else if (lower == "ping" || lower == "p") { + if (!app_) { appendError("Not connected to app."); return; } + auto r = app_->pingAllSync(); + if (r) { + for (int i = 0; i < r->servers.size(); ++i) { + QString st = (i < r->statuses.size()) ? r->statuses[i] : "?"; + appendCmd(QString(" %1: %2").arg(r->servers[i]).arg(st)); + } + } else { + appendError("Ping timed out."); + } + } else if (lower == "bal" || lower == "b") { + if (!app_) { appendError("Not connected to app."); return; } + auto r = app_->queryBalanceSync(); + if (r) + appendCmd(QString("@%1 balance: %2") + .arg(app_->selectedAccount()).arg(formatAmount(r->balance))); + else + appendError("Query timed out."); + } else if (lower.startsWith("bal @") || lower.startsWith("b @")) { + if (!app_) { appendError("Not connected to app."); return; } + QString idxStr = lower.mid(lower.indexOf('@') + 1).trimmed(); + int idx; + if (idxStr.isEmpty()) { + idx = app_->selectedAccount(); + ok = true; + } else { + idx = idxStr.toInt(&ok); + } + if (!ok || idx < 0 || idx >= app_->keyCount()) { + appendError(QString("Invalid account. Range: @0..@%1") + .arg(app_->keyCount() - 1)); + } else { + auto r = app_->queryAccountSync(idx); + if (r) + appendCmd(QString("@%1 balance: %2") + .arg(idx).arg(formatAmount(r->balance))); + else + appendError("Query failed."); + } + } else if (lower == "bal all" || lower == "b all") { + if (!app_) { appendError("Not connected to app."); return; } + auto r = app_->queryWalletSync(); + if (r) { + for (auto& a : r->accounts) + appendCmd(QString(" @%1 %2 %3") + .arg(a.index).arg(formatAmount(a.balance)).arg(a.fullPubKey)); + appendCmd(QString("Total: %1 (%2 accounts)") + .arg(formatAmount(r->total)).arg(r->count)); + } else { + appendError("Wallet query timed out."); + } + } else if (lower == "status") { + if (!app_) { appendError("Not connected to app."); return; } + appendCmd(QString("Keys: %1").arg(app_->keyCount())); + appendCmd(QString("Current server: %1") + .arg(app_->currentServer().isEmpty() ? "(none)" : app_->currentServer())); + appendCmd(QString("Selected account: @%1").arg(app_->selectedAccount())); + appendCmd(QString("Mining: %1").arg(app_->isMining() ? "active" : "idle")); + appendCmd(QString("Servers: %1").arg(app_->config().servers.size())); + appendCmd(QString("Tracked assets: %1").arg(app_->config().trackedAssets.size())); + } else if (lower == "@") { + if (!app_) { appendError("Not connected to app."); return; } + int idx = app_->selectedAccount(); + appendCmd(QString("Selected account: @%1 %2") + .arg(idx).arg(app_->publicKeyHex(idx))); + } else if (lower.startsWith("@") && lower.size() > 1) { + if (!app_) { appendError("Not connected to app."); return; } + bool ok; + int idx = lower.mid(1).toInt(&ok); + if (!ok || idx < 0 || idx >= app_->keyCount()) { + appendError(QString("Invalid account index. Range: @0..@%1") + .arg(app_->keyCount() - 1)); + } else { + app_->config().defaultAccount = idx; + app_->config().save(); + app_->accountWidget()->refreshKeyListNoQuery(); + appendCmd(QString("@%1 %2") + .arg(idx).arg(app_->publicKeyHex(idx))); + } + } else if (lower.startsWith("send ") || lower.startsWith("s ")) { + if (!app_) { appendError("Not connected to app."); return; } + // Parse: send + auto parts = cmd.trimmed().split(' ', Qt::SkipEmptyParts); + if (parts.size() < 3) { + appendError("Usage: send "); + appendCmd(" amount: decimal (e.g. 1.5)"); + appendCmd(" dest: public key hex or @N"); + return; + } + // Parse amount (decimal → internal units) + QString amtStr = parts[1]; + double amtDbl = amtStr.toDouble(&ok); + if (!ok || amtDbl <= 0) { + appendError("Invalid amount: " + amtStr); + return; + } + uint64_t amount = static_cast( + amtDbl * static_cast(ces::PRICE_UNIT) + 0.5); + + // Resolve destination + QString destStr = parts[2]; + QString destHex; + if (destStr == "@") { + destHex = app_->publicKeyHex(app_->selectedAccount()); + } else if (destStr.startsWith("@")) { + int di = destStr.mid(1).toInt(&ok); + if (!ok || di < 0 || di >= app_->keyCount()) { + appendError("Invalid destination index: " + destStr); + return; + } + destHex = app_->publicKeyHex(di); + } else { + destHex = destStr; + } + + int fromIdx = app_->selectedAccount(); + QString destDisplay = destStr; + if (destStr == "@") + destDisplay = QString("@%1").arg(app_->selectedAccount()); + else if (!destStr.startsWith("@")) + destDisplay = destHex; + appendCmd(QString("Sending %1 from @%2 to %3...") + .arg(formatAmount(amount)).arg(fromIdx).arg(destDisplay)); + + auto r = app_->transferSync(fromIdx, destHex, amount); + if (!r) { + appendError("Transfer failed (connection error)."); + } else if (r->rc == 0) { + appendCmd(QString("Sent! New balance: %1").arg(formatAmount(r->newBalance))); + // Update transfer history + app_->config().pushTransferDest(destHex); + } else { + appendError(QString("Transfer error: %1").arg( + QString::fromStdString(ces::errorString(r->rc)))); + } + } else if (lower == "tron") { + trace_ = true; + appendCmd("Trace on."); + } else if (lower == "troff") { + trace_ = false; + appendCmd("Trace off."); + } else { + appendError("Unknown command: " + cmd + " (type 'help' for commands)"); + } +} + +// ============================================================================= +// Main — system tray + window +// ============================================================================= + +int main(int argc, char* argv[]) { + // Parse CLI args before Qt init + bool noDaemon = false; + bool autoApprove = false; + std::string dataDirOverride; + uint16_t rpcPortOverride = 0; + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--no-daemon") { + noDaemon = true; + } else if (arg == "--autoapprove") { + autoApprove = true; + } else if (arg == "--datadir" && i + 1 < argc) { + dataDirOverride = argv[++i]; + } else if (arg == "--rpcport" && i + 1 < argc) { + rpcPortOverride = static_cast(std::stoi(argv[++i])); + } else if (arg == "--help" || arg == "-h") { + std::cout << "cesqt - CES Wallet (Qt GUI)\n\n" + << "Usage: cesqt [options]\n\n" + << "Options:\n" + << " --datadir Override config/wallet directory\n" + << " --rpcport Override RPC port (default 21008)\n" + << " --autoapprove Auto-approve all RPC origin requests\n" + << " --no-daemon Stay attached to the terminal\n" + << " --help, -h Show this help and exit\n"; + return 0; + } else { + std::cerr << "Unknown option: " << arg << "\n" + << "Run 'cesqt --help' for usage.\n"; + return 1; + } + } + + // Single-instance guard is set up after QApplication (needs config dir) + + // Detach from terminal (Unix only) +#ifndef _WIN32 + if (!noDaemon) { + pid_t pid = fork(); + if (pid < 0) _exit(1); + if (pid > 0) _exit(0); + setsid(); + } +#endif + + QApplication app(argc, argv); + app.setApplicationName("cesqt"); + app.setOrganizationName("ces"); + QApplication::setQuitOnLastWindowClosed(false); + + // Route SIGTERM/SIGINT into Qt's event loop. ceslib's static signal + // installer (src/ceslib/ctrlc.cpp) installs a poll-flag handler at + // static-init time, which is correct for the server but leaves cesqt + // ignoring SIGTERM since its Qt loop never polls that flag. Override + // here with a self-pipe → QSocketNotifier → app.quit() so stopqt + // can shut it down gracefully without escalating to SIGKILL. + static int sigPipe[2] = {-1, -1}; + if (::pipe(sigPipe) == 0) { + auto* notifier = new QSocketNotifier(sigPipe[0], + QSocketNotifier::Read, &app); + QObject::connect(notifier, &QSocketNotifier::activated, &app, + [&app](int) { + char b = 0; + ssize_t r = ::read(sigPipe[0], &b, 1); + (void)r; + app.quit(); + }); + auto handler = +[](int) { + const char b = 1; + ssize_t n = ::write(sigPipe[1], &b, 1); + (void)n; + }; + ::signal(SIGTERM, handler); + ::signal(SIGINT, handler); + } + + // Single-instance guard (per datadir, so sandboxed instances don't conflict) + QString lockDir = dataDirOverride.empty() + ? QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) + : QString::fromStdString(dataDirOverride); + QDir().mkpath(lockDir); + QLockFile lockFile(lockDir + "/cesqt.lock"); + lockFile.setStaleLockTime(0); + if (!lockFile.tryLock(0)) { + qint64 pid = 0; + QString hostname, appname; + lockFile.getLockInfo(&pid, &hostname, &appname); + std::cerr << "cesqt is already running (pid " << pid << ").\n"; + return 1; + } + + MainWindow window(QString::fromStdString(dataDirOverride), rpcPortOverride, + autoApprove); + + // System tray icon + QSystemTrayIcon trayIcon; + trayIcon.setIcon(app.style()->standardIcon(QStyle::SP_ComputerIcon)); + trayIcon.setToolTip("CES Wallet"); + + QMenu trayMenu; + auto* showAction = trayMenu.addAction("Show Wallet"); + QObject::connect(showAction, &QAction::triggered, &window, + &MainWindow::showFromTray); + trayMenu.addSeparator(); + auto* quitAction = trayMenu.addAction("Quit"); + QObject::connect(quitAction, &QAction::triggered, &app, &QApplication::quit); + + trayIcon.setContextMenu(&trayMenu); + trayIcon.show(); + + window.setTrayIcon(&trayIcon); + + QObject::connect(&trayIcon, &QSystemTrayIcon::activated, + [&window](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::DoubleClick) + window.showFromTray(); + }); + + window.show(); + + return app.exec(); +} + +#include "main.moc" diff --git a/vendor/ces/src/cesqt/mainwindow_api.inc b/vendor/ces/src/cesqt/mainwindow_api.inc new file mode 100644 index 0000000..32f6d15 --- /dev/null +++ b/vendor/ces/src/cesqt/mainwindow_api.inc @@ -0,0 +1,163 @@ +// ============================================================================= +// Programmatic API for console access +// #include'd inside MainWindow class body (public section) +// ============================================================================= + +// Sync helper: trigger an action, block until a signal fires, return result. +// Usage: auto r = syncCall(sender, &Class::signal, [&](auto... args) { +// return ResultType{args...}; +// }, [&]() { trigger(); }); +template +static std::optional syncCall(Sender* sender, Signal signal, + Capture capture, Trigger trigger, + int timeoutMs = 10000) { + std::optional result; + QEventLoop loop; + auto conn = QObject::connect(sender, signal, + [&](auto&&... args) { + result = capture(std::forward(args)...); + loop.quit(); + }); + QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit); + trigger(); + loop.exec(); + QObject::disconnect(conn); + return result; +} + +// -- Wallet / Keys -- +const ces::Wallet& wallet() const { return keyManager_->wallet(); } +int keyCount() const { return keyManager_->wallet().size(); } +ces::KeyPair keyPairAt(int idx) const { + return keyManager_->wallet().keyPair(idx); +} +QString publicKeyHex(int idx) const { + return QString::fromStdString( + keyManager_->wallet().keyPair(idx).getPublicKeyHexStr()); +} + +// -- Config -- +AppConfig& config() { return config_; } +const AppConfig& config() const { return config_; } +QString currentServer() const { return config_.currentServer; } + +// -- Account -- +int selectedAccount() const { + return accountWidget_->selectedAccountIndex(); +} +void querySelectedAccount() { accountWidget_->querySelected(); } +AccountWidget* accountWidget() { return accountWidget_; } + +struct BalanceResult { int64_t balance = 0; }; +std::optional queryBalanceSync() { + return syncCall( + accountWidget_, &AccountWidget::balanceUpdated, + [](int, int64_t bal) { return BalanceResult{bal}; }, + [this]() { accountWidget_->querySelected(); }); +} + +// Query a specific account by index (doesn't change selection). +std::optional queryAccountSync(int idx) { + if (idx < 0 || idx >= keyManager_->wallet().size()) + return std::nullopt; + if (config_.currentServer.isEmpty()) + return std::nullopt; + try { + auto probe = ces::Resolver::probe(config_.currentServer.toStdString()); + auto client = probe.makeClient(false); + client->start(0); + if (!client->connect()) return std::nullopt; + auto kp = keyManager_->wallet().keyPair(idx); + auto mapKey = ces::Account::getMapKey(kp.getPublicKeyAsHash()); + int64_t balance = 0; + uint32_t nonce = 0; + uint8_t rc = client->queryAccount(mapKey, balance, nonce); + client->disconnect(); + client->stop(); + if (rc != 0) return std::nullopt; + return BalanceResult{balance}; + } catch (...) { + return std::nullopt; + } +} + +// -- Wallet aggregate -- +void refreshWalletBalance() { walletWidget_->queryAllFull(); } + +struct WalletAccountInfo { int index; QString pubKey; QString fullPubKey; + int64_t balance; bool exists; }; +struct WalletResult { int64_t total = 0; int count = 0; + QList accounts; }; +std::optional queryWalletSync() { + auto r = syncCall( + walletWidget_, &WalletWidget::walletQueryDone, + [this](int64_t total, int count) { + WalletResult wr; + wr.total = total; + wr.count = count; + for (auto& a : walletWidget_->getAccounts()) { + if (a.exists) + wr.accounts.append({a.index, a.pubKey, a.fullPubKey, a.balance, a.exists}); + } + return wr; + }, + [this]() { walletWidget_->queryAllFull(); }, + 15000); + return r; +} + +// -- Mining -- +bool isMining() const { return miningWidget_->isMining(); } + +// -- Servers -- +void pingAllServers() { serverManager_->pingAll(); } + +struct PingResults { QStringList servers; QStringList statuses; }; +std::optional pingAllSync() { + return syncCall( + serverManager_, &ServerManagerWidget::allPingsDone, + [this]() { + PingResults r; + r.servers = config_.servers; + r.statuses = serverManager_->getStatuses(); + return r; + }, + [this]() { serverManager_->pingAll(); }, + 30000); +} + +// -- Transfer (sync, blocking) -- +struct TransferResult { int64_t newBalance = 0; uint8_t rc = 0; }; +std::optional transferSync(int fromIdx, const QString& destHex, + uint64_t amount) { + if (fromIdx < 0 || fromIdx >= keyManager_->wallet().size()) + return std::nullopt; + if (config_.currentServer.isEmpty()) + return std::nullopt; + try { + auto probe = ces::Resolver::probe(config_.currentServer.toStdString()); + auto client = probe.makeClient(false); + auto kp = keyManager_->wallet().keyPair(fromIdx); + client->setKey(kp); + client->start(0); + if (!client->connect()) return std::nullopt; + + minx::Hash dest; + minx::stringToHash(dest, destHex.toStdString()); + int64_t newBal = 0; + uint8_t rc = client->transfer(dest, amount, newBal); + + client->disconnect(); + client->stop(); + + TransferResult r; + r.newBalance = newBal; + r.rc = rc; + return r; + } catch (...) { + return std::nullopt; + } +} + +// -- Console -- +void consoleLog(const QString& msg) { console_->appendLog(msg); } diff --git a/vendor/ces/src/cesqt/rpcserver.cpp b/vendor/ces/src/cesqt/rpcserver.cpp new file mode 100644 index 0000000..ec95a2b --- /dev/null +++ b/vendor/ces/src/cesqt/rpcserver.cpp @@ -0,0 +1,579 @@ +#include "rpcserver.h" + +#include +#include + +#include +#include + +#include "console.h" + +RpcServer::RpcServer(RpcWalletBridge bridge, ConsoleWidget* console, + bool autoApprove, QObject* parent) + : QObject(parent), bridge_(std::move(bridge)), console_(console), + autoApprove_(autoApprove) {} + +bool RpcServer::start(uint16_t port) { + server_ = new QTcpServer(this); + connect(server_, &QTcpServer::newConnection, + this, &RpcServer::onNewConnection); + + if (!server_->listen(QHostAddress::LocalHost, port)) { + log("RPC: failed to listen on port " + QString::number(port)); + return false; + } + log("RPC: listening on localhost:" + QString::number(port)); + return true; +} + +void RpcServer::stop() { + if (server_) { + server_->close(); + server_->deleteLater(); + server_ = nullptr; + } +} + +void RpcServer::onNewConnection() { + while (auto* socket = server_->nextPendingConnection()) { + connect(socket, &QTcpSocket::readyRead, this, &RpcServer::onReadyRead); + connect(socket, &QTcpSocket::disconnected, this, &RpcServer::onDisconnected); + } +} + +void RpcServer::onReadyRead() { + auto* socket = qobject_cast(sender()); + if (!socket) return; + + QByteArray data = socket->property("_buf").toByteArray(); + data += socket->readAll(); + // Bound the buffered request so a large/absent Content-Length can't grow it + // without limit (localhost, but still). + if (data.size() > 4 * 1024 * 1024) { + socket->close(); + return; + } + socket->setProperty("_buf", data); + + int headerEnd = data.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + + int contentLength = 0; + int clPos = data.indexOf("Content-Length:"); + if (clPos < 0) clPos = data.indexOf("content-length:"); + if (clPos >= 0) { + int clEnd = data.indexOf("\r\n", clPos); + contentLength = data.mid(clPos + 15, clEnd - clPos - 15).trimmed().toInt(); + } + + int bodyStart = headerEnd + 4; + if (data.size() - bodyStart < contentLength) return; + + auto req = parseHttp(data); + socket->setProperty("_buf", QByteArray()); + + if (!req.valid) { + sendResponse(socket, 400, R"({"error":"Bad request"})"); + return; + } + + if (req.method == "OPTIONS") { + sendCorsHeaders(socket, req.origin); + return; + } + + if (req.method != "POST") { + sendResponse(socket, 405, R"({"error":"Method not allowed"})", req.origin); + return; + } + + QJsonParseError err; + auto doc = QJsonDocument::fromJson(req.body, &err); + if (doc.isNull() || !doc.isObject()) { + sendResponse(socket, 400, + R"({"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null})", + req.origin); + return; + } + + auto rpcReq = doc.object(); + auto id = rpcReq.value("id"); + + int keyIndex = resolveOrigin(req.origin); + if (keyIndex < 0) { + QJsonObject errResp; + errResp["jsonrpc"] = "2.0"; + errResp["error"] = QJsonObject{ + {"code", -32000}, {"message", "Origin denied by user"}}; + errResp["id"] = id; + sendResponse(socket, 403, + QJsonDocument(errResp).toJson(QJsonDocument::Compact), req.origin); + return; + } + + auto result = dispatch(rpcReq, keyIndex); + result["id"] = id; + result["jsonrpc"] = "2.0"; + + sendResponse(socket, 200, + QJsonDocument(result).toJson(QJsonDocument::Compact), req.origin); +} + +void RpcServer::onDisconnected() { + auto* socket = qobject_cast(sender()); + if (socket) socket->deleteLater(); +} + +RpcServer::HttpRequest RpcServer::parseHttp(const QByteArray& raw) { + HttpRequest req; + int firstLine = raw.indexOf("\r\n"); + if (firstLine < 0) return req; + + QString requestLine = QString::fromUtf8(raw.left(firstLine)); + auto parts = requestLine.split(' '); + if (parts.size() < 2) return req; + req.method = parts[0]; + + int originPos = raw.indexOf("\r\nOrigin:"); + if (originPos < 0) originPos = raw.indexOf("\r\norigin:"); + if (originPos >= 0) { + int originEnd = raw.indexOf("\r\n", originPos + 2); + req.origin = QString::fromUtf8( + raw.mid(originPos + 9, originEnd - originPos - 9)).trimmed(); + } + + int bodyStart = raw.indexOf("\r\n\r\n") + 4; + if (bodyStart < raw.size()) + req.body = raw.mid(bodyStart); + + req.valid = true; + return req; +} + +void RpcServer::sendResponse(QTcpSocket* socket, int status, + const QByteArray& body, const QString& origin) { + QByteArray resp = "HTTP/1.1 " + QByteArray::number(status) + " OK\r\n" + "Content-Type: application/json\r\n" + "Content-Length: " + QByteArray::number(body.size()) + "\r\n"; + if (!origin.isEmpty()) { + resp += "Access-Control-Allow-Origin: " + origin.toUtf8() + "\r\n"; + resp += "Access-Control-Allow-Methods: POST, OPTIONS\r\n"; + resp += "Access-Control-Allow-Headers: Content-Type\r\n"; + } + resp += "\r\n"; + resp += body; + socket->write(resp); + socket->flush(); + socket->disconnectFromHost(); +} + +void RpcServer::sendCorsHeaders(QTcpSocket* socket, const QString& origin) { + QByteArray resp = "HTTP/1.1 204 No Content\r\n" + "Access-Control-Allow-Origin: " + origin.toUtf8() + "\r\n" + "Access-Control-Allow-Methods: POST, OPTIONS\r\n" + "Access-Control-Allow-Headers: Content-Type\r\n" + "Access-Control-Max-Age: 86400\r\n" + "\r\n"; + socket->write(resp); + socket->flush(); + socket->disconnectFromHost(); +} + +int RpcServer::resolveOrigin(const QString& origin) { + if (origin.isEmpty()) + return -1; + + std::string label = origin.toStdString(); + int idx = bridge_.findByLabel(label); + if (idx >= 0) + return idx; + + if (!autoApprove_) { + auto reply = QMessageBox::question(nullptr, "Wallet Access Request", + QString("%1\n\nwants wallet access.\nA new account will be created for this app.\n\nAllow?") + .arg(origin), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (reply != QMessageBox::Yes) { + log("RPC: denied origin " + origin); + return -1; + } + } else { + log("RPC: auto-approved origin " + origin); + } + + idx = bridge_.generateKey(label); + bridge_.saveAndRefresh(); + log("RPC: approved origin " + origin + " → @" + QString::number(idx)); + tryAutoFund(idx); + return idx; +} + +QJsonObject RpcServer::dispatch(const QJsonObject& request, int keyIndex) { + QString method = request.value("method").toString(); + auto params = request.value("params").toObject(); + + if (method == "ping") { + return QJsonObject{{"result", "pong"}}; + } + + if (method == "getAccount") { + return QJsonObject{{"result", QJsonObject{ + {"publicKey", QString::fromStdString(bridge_.pubKeyHex(keyIndex))}, + {"index", keyIndex}, + {"label", QString::fromStdString(bridge_.label(keyIndex))} + }}}; + } + + // --- Unsigned: queryAsset (free, no key needed) --- + if (method == "queryAsset") { + QString assetKey = params.value("key").toString(); + if (assetKey.isEmpty()) + return errorResult(-32602, "Missing 'key' parameter"); + + QString server = bridge_.currentServer(); + if (server.isEmpty()) + return errorResult(-32000, "No current server configured"); + + try { + auto probe = ces::Resolver::probe(server.toStdString()); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + client.start(0); + if (!client.connect()) { + client.stop(); + return errorResult(-32000, "Connect failed"); + } + + minx::Hash aid = parseAssetKey(assetKey); + + ces::HashPrefix owner; + ces::AssetData content; + uint16_t balance = 0; + uint32_t price = 0; + client.queryAsset(aid, owner, content, balance, price); + client.disconnect(); + client.stop(); + + // Format content as text if printable, else hex + QString contentStr = QString::fromStdString( + ces::contentToDisplayString(content)); + QString ownerHex; + for (auto b : owner) ownerHex += QString("%1").arg(b, 2, 16, QChar('0')); + + return QJsonObject{{"result", QJsonObject{ + {"owner", ownerHex}, + {"content", contentStr}, + {"days", ces::assetDays(balance)}, + {"ownerPays", ces::isAssetOwnerPays(balance)}, + {"private", ces::isAssetPrivate(balance)}, + {"assetOwned", ces::isAssetOwned(balance)}, + {"immutable", ces::isAssetImmutable(balance)}, + {"price", static_cast(price)} + }}}; + } catch (std::exception& e) { + return errorResult(-32000, e.what()); + } + } + + // --- Signed operations (require approved origin + key) --- + + if (method == "createAsset") { + QString assetKey = params.value("key").toString(); + QString content = params.value("content").toString(); + int days = params.value("days").toInt(30); + if (assetKey.isEmpty() || content.isEmpty()) + return errorResult(-32602, "Missing 'key' or 'content' parameter"); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash aid = parseAssetKey(assetKey); + + ces::AssetData ad{}; + QByteArray contentUtf8 = content.toUtf8(); + memcpy(ad.data(), contentUtf8.data(), std::min(contentUtf8.size(), 210)); + + uint8_t rc = client.createAsset(aid, ad, static_cast(days)); + if (rc == ces::CES_OK) + return QJsonObject{{"result", "ok"}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "fundAsset") { + QString assetKey = params.value("key").toString(); + int days = params.value("days").toInt(1); + if (assetKey.isEmpty()) + return errorResult(-32602, "Missing 'key' parameter"); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash aid = parseAssetKey(assetKey); + + uint8_t rc = client.fundAsset(aid, static_cast(days)); + if (rc == ces::CES_OK) + return QJsonObject{{"result", "ok"}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "transfer") { + QString dest = params.value("dest").toString(); + double amount = params.value("amount").toDouble(0); + bool open = params.value("open").toBool(false); + if (dest.isEmpty() || amount <= 0) + return errorResult(-32602, "Missing 'dest' or 'amount' parameter"); + + uint64_t intAmount = static_cast( + amount * static_cast(ces::PRICE_UNIT)); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash destKey; + if (dest.size() == 64) + minx::stringToHash(destKey, dest.toStdString()); + else { + // Try wallet resolution (@N) + std::string resolved = dest.toStdString(); + destKey.fill(0); + minx::stringToHash(destKey, resolved); + } + + int64_t newBal = 0; + uint8_t rc; + if (open) + rc = client.openTransfer(destKey, intAmount, newBal); + else + rc = client.transfer(destKey, intAmount, newBal); + + if (rc == ces::CES_OK) + return QJsonObject{{"result", QJsonObject{ + {"newBalance", QString::number(newBal)}, + {"amount", QString::number(intAmount)} + }}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "updateAsset") { + QString assetKey = params.value("key").toString(); + QString content = params.value("content").toString(); + int price = params.value("price").toInt(0); + if (assetKey.isEmpty() || content.isEmpty()) + return errorResult(-32602, "Missing 'key' or 'content' parameter"); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash aid = parseAssetKey(assetKey); + ces::AssetData ad{}; + QByteArray contentUtf8 = content.toUtf8(); + memcpy(ad.data(), contentUtf8.data(), std::min(contentUtf8.size(), 210)); + ces::HashPrefix owner = ces::Account::getMapKey(bridge_.keyPair(keyIndex).getPublicKeyAsHash()); + uint8_t rc = client.updateAsset(aid, owner, ad, static_cast(price)); + if (rc == ces::CES_OK) return QJsonObject{{"result", "ok"}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "updateAssetFast") { + QString assetKey = params.value("key").toString(); + QString content = params.value("content").toString(); + if (assetKey.isEmpty() || content.isEmpty()) + return errorResult(-32602, "Missing 'key' or 'content' parameter"); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash aid = parseAssetKey(assetKey); + ces::AssetData ad{}; + QByteArray contentUtf8 = content.toUtf8(); + memcpy(ad.data(), contentUtf8.data(), std::min(contentUtf8.size(), 210)); + uint8_t rc = client.updateAssetFast(aid, ad); + if (rc == ces::CES_OK) return QJsonObject{{"result", "ok"}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "updateAssetMeta") { + QString assetKey = params.value("key").toString(); + int price = params.value("price").toInt(0); + if (assetKey.isEmpty()) + return errorResult(-32602, "Missing 'key' parameter"); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash aid = parseAssetKey(assetKey); + ces::HashPrefix owner = ces::Account::getMapKey(bridge_.keyPair(keyIndex).getPublicKeyAsHash()); + uint8_t rc = client.updateAssetMeta(aid, owner, static_cast(price)); + if (rc == ces::CES_OK) return QJsonObject{{"result", "ok"}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "buyAsset") { + QString assetKey = params.value("key").toString(); + double maxPrice = params.value("maxPrice").toDouble(0); + if (assetKey.isEmpty() || maxPrice <= 0) + return errorResult(-32602, "Missing 'key' or 'maxPrice' parameter"); + + uint64_t intPrice = static_cast( + maxPrice * static_cast(ces::PRICE_UNIT)); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash aid = parseAssetKey(assetKey); + uint8_t rc = client.buyAsset(aid, intPrice); + if (rc == ces::CES_OK) return QJsonObject{{"result", "ok"}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "giveAsset") { + QString assetKey = params.value("key").toString(); + QString newOwner = params.value("newOwner").toString(); + if (assetKey.isEmpty() || newOwner.isEmpty()) + return errorResult(-32602, "Missing 'key' or 'newOwner' parameter"); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash aid = parseAssetKey(assetKey); + minx::Hash ownerKey; + minx::stringToHash(ownerKey, newOwner.toStdString()); + ces::HashPrefix ownerPrefix = ces::Account::getMapKey(ownerKey); + uint8_t rc = client.giveAsset(aid, ownerPrefix); + if (rc == ces::CES_OK) return QJsonObject{{"result", "ok"}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "crossTransfer") { + QString dest = params.value("dest").toString(); + QString server = params.value("server").toString(); + double amount = params.value("amount").toDouble(0); + if (dest.isEmpty() || server.isEmpty() || amount <= 0) + return errorResult(-32602, "Missing 'dest', 'server', or 'amount'"); + + uint64_t intAmount = static_cast( + amount * static_cast(ces::PRICE_UNIT)); + + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + minx::Hash destKey; + minx::stringToHash(destKey, dest.toStdString()); + int64_t newBal = 0; + uint8_t rc = client.crossTransfer(destKey, intAmount, + server.toStdString(), newBal); + if (rc == ces::CES_OK) + return QJsonObject{{"result", QJsonObject{ + {"newBalance", QString::number(newBal)}, + {"amount", QString::number(intAmount)} + }}}; + return errorResult(-32000, ces::errorString(rc)); + }); + } + + if (method == "queryBalance") { + // Query the app's own account balance + return executeOnServer(keyIndex, [&](ces::CesClient& client) { + ces::HashPrefix myId = ces::Account::getMapKey( + bridge_.keyPair(keyIndex).getPublicKeyAsHash()); + int64_t balance = 0; + uint32_t nonce = 0; + client.queryAccount(myId, balance, nonce); + return QJsonObject{{"result", QJsonObject{ + {"balance", QString::number(balance)}, + {"nonce", static_cast(nonce)} + }}}; + }); + } + + return errorResult(-32601, "Method not found: " + method.toStdString()); +} + +minx::Hash RpcServer::parseAssetKey(const QString& key) { + minx::Hash aid; + if (key.size() == 64) { + minx::stringToHash(aid, key.toStdString()); + } else { + aid.fill(0); + QByteArray utf8 = key.toUtf8(); + memcpy(aid.data(), utf8.data(), std::min(utf8.size(), 32)); + } + return aid; +} + +QJsonObject RpcServer::errorResult(int code, const std::string& msg) { + return QJsonObject{{"error", QJsonObject{ + {"code", code}, {"message", QString::fromStdString(msg)}}}}; +} + +QJsonObject RpcServer::executeOnServer( + int keyIndex, + std::function fn) { + QString server = bridge_.currentServer(); + if (server.isEmpty()) + return errorResult(-32000, "No current server configured"); + + try { + auto probe = ces::Resolver::probe(server.toStdString()); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + ces::KeyPair kp = bridge_.keyPair(keyIndex); + client.setKey(kp); + client.start(0); + if (!client.connect()) { + client.stop(); + return errorResult(-32000, "Connect failed"); + } + auto result = fn(client); + client.disconnect(); + client.stop(); + return result; + } catch (std::exception& e) { + return errorResult(-32000, e.what()); + } +} + +void RpcServer::tryAutoFund(int newKeyIndex) { + static constexpr int64_t FUND_AMOUNT = static_cast(ces::PRICE_UNIT); // 1.0 credit + static constexpr int64_t MIN_SOURCE = 2 * static_cast(ces::PRICE_UNIT); // 2.0 credits + + QString server = bridge_.currentServer(); + if (server.isEmpty()) return; + + auto accounts = bridge_.getAccounts(); + + int sourceIndex = -1; + for (auto& a : accounts) { + if (a.exists && a.balance >= MIN_SOURCE && a.index != newKeyIndex) { + sourceIndex = a.index; + break; + } + } + if (sourceIndex < 0) { + log("RPC: auto-fund skipped (no account with >= 2.0 credits)"); + return; + } + + try { + auto probe = ces::Resolver::probe(server.toStdString()); + auto clientPtr = probe.makeClient(false); + auto& client = *clientPtr; + ces::KeyPair sourceKp = bridge_.keyPair(sourceIndex); + client.setKey(sourceKp); + client.start(0); + if (!client.connect()) { + log("RPC: auto-fund failed (connect)"); + client.stop(); + return; + } + ces::KeyPair destKp = bridge_.keyPair(newKeyIndex); + int64_t newBal = 0; + uint8_t rc = client.openTransfer(destKp.getPublicKeyAsHash(), FUND_AMOUNT, newBal); + client.disconnect(); + client.stop(); + if (rc == ces::CES_OK) { + log(QString("RPC: auto-funded @%1 with 1.0 credit from @%2") + .arg(newKeyIndex).arg(sourceIndex)); + } else { + log(QString("RPC: auto-fund failed: %1") + .arg(QString::fromUtf8(ces::errorString(rc)))); + } + } catch (std::exception& e) { + log(QString("RPC: auto-fund error: %1").arg(e.what())); + } +} + +void RpcServer::log(const QString& msg) { + if (console_) console_->appendLog(msg); +} diff --git a/vendor/ces/src/cesqt/rpcserver.h b/vendor/ces/src/cesqt/rpcserver.h new file mode 100644 index 0000000..591f79d --- /dev/null +++ b/vendor/ces/src/cesqt/rpcserver.h @@ -0,0 +1,105 @@ +#pragma once + +/** + * RPC Server — localhost HTTP JSON-RPC for browser wallet integration. + * + * Listens on localhost:21008. Websites call this to interact with the + * wallet. Each origin gets its own keypair, approved via modal dialog. + * The Origin header is the app identity — the browser enforces it. + * + * Auth model: + * - Unknown origin → modal approval dialog → generate keypair with + * label set to the origin URL in the wallet + * - Known origin (wallet has key labeled with that URL) → proceed + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class ConsoleWidget; + +struct CachedAccount { + int index; + int64_t balance; + bool exists; +}; + +// Callbacks the RPC server uses to interact with the wallet. +struct RpcWalletBridge { + // Find key index by label, or -1. + std::function findByLabel; + // Generate a key with label. Returns new index. + std::function generateKey; + // Get KeyPair at index. + std::function keyPair; + // Get public key hex at index. + std::function pubKeyHex; + // Get label at index. + std::function label; + // Save wallet and refresh UI. + std::function saveAndRefresh; + // Get cached account balances. + std::function()> getAccounts; + // Get current server string. + std::function currentServer; +}; + +class RpcServer : public QObject { + Q_OBJECT +public: + static constexpr uint16_t DEFAULT_PORT = 21008; + + RpcServer(RpcWalletBridge bridge, ConsoleWidget* console, + bool autoApprove = false, QObject* parent = nullptr); + + bool start(uint16_t port = DEFAULT_PORT); + void stop(); + +private slots: + void onNewConnection(); + void onReadyRead(); + void onDisconnected(); + +private: + struct HttpRequest { + QString method; // GET, POST, OPTIONS + QString origin; + QByteArray body; + bool valid = false; + }; + + HttpRequest parseHttp(const QByteArray& raw); + void sendResponse(QTcpSocket* socket, int status, const QByteArray& body, + const QString& origin = ""); + void sendCorsHeaders(QTcpSocket* socket, const QString& origin); + + // Returns the wallet key index for this origin, or -1 if denied. + // May block on a modal dialog for new origins. + int resolveOrigin(const QString& origin); + + QJsonObject dispatch(const QJsonObject& request, int keyIndex); + static minx::Hash parseAssetKey(const QString& key); + static QJsonObject errorResult(int code, const std::string& msg); + QJsonObject executeOnServer(int keyIndex, + std::function fn); + + void log(const QString& msg); + + void tryAutoFund(int newKeyIndex); + + QTcpServer* server_ = nullptr; + RpcWalletBridge bridge_; + ConsoleWidget* console_; + bool autoApprove_; +}; diff --git a/vendor/ces/src/cesvmbench/main.cpp b/vendor/ces/src/cesvmbench/main.cpp new file mode 100644 index 0000000..86bae6b --- /dev/null +++ b/vendor/ces/src/cesvmbench/main.cpp @@ -0,0 +1,763 @@ +/** + * CesVM interpreter benchmark. + * + * Four sections, selectable by argument (default: all): + * prog whole-program throughput (cesl workloads), ns/op and Mops/s — + * the headline number for interpreter-core changes + * ops per-opcode ns/iter, relative cost vs baseline (ADD) + * sys per-syscall ns/iter against a null host + * crypto raw crypto costs outside the VM, for gas-constant context + * + * Build: cmake target "cesvmbench". Run on a RELEASE build; the gas + * constants in cesvm.h were calibrated against release numbers. + * + * ./cesvmbench [prog|ops|sys|crypto] + * + * This is NOT a unit test — it produces human-readable benchmark output. + */ + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace ces; + +// Opcodes and encoding constants live in cesvm.h as of the B9 cleanup. +static uint8_t sv(uint8_t val) { + return static_cast(CESVM_SHORT_VAL | (val & CESVM_MAX_SHORT_VAL)); +} +static uint8_t rp(uint8_t val) { + return static_cast(CESVM_REG_PTR | CESVM_SHORT_VAL | + (val & CESVM_MAX_SHORT_VAL)); +} +static constexpr uint8_t STACK = CESVM_OP_STACK; + +static void push2(ces::Bytes& code, uint16_t val) { + code.push_back(2); + ces::Buffer::putLE(code, val); +} + +static void push4(ces::Bytes& code, uint32_t val) { + code.push_back(4); + ces::Buffer::putLE(code, val); +} + +// Null host for benchmarks: every read returns plausible-looking data and +// every write succeeds. Subclass of CesVMHost so calls go through the +// vtable just like the production CesServer::VmHost. +struct NullVmHost : CesVMHost { + int64_t readAccountBalance(const HashPrefix&) override { return 1000000; } + uint32_t readAccountNonce (const HashPrefix&) override { return 0; } + bool readAsset(const minx::Hash&, HashPrefix& owner, AssetData& content, + uint16_t& balance, uint32_t& price) override { + owner.fill(0x11); + content.fill(0x22); + balance = 100; + price = 0; + return true; + } + uint8_t transfer (const minx::Hash&, uint64_t) override { return CES_OK; } + uint8_t createAsset (const minx::Hash&, const AssetData&, uint16_t) override { return CES_OK; } + uint8_t updateAsset (const minx::Hash&, const AssetData&) override { return CES_OK; } + uint8_t fundAsset (const minx::Hash&, uint16_t) override { return CES_OK; } + uint8_t buyAsset (const minx::Hash&, uint64_t) override { return CES_OK; } + uint8_t giveAsset (const minx::Hash&, const HashPrefix&) override { return CES_OK; } + void sendUdp (const std::string&, uint16_t, const uint8_t*, size_t) override {} + uint8_t crossTransfer(const minx::Hash&, uint64_t, const std::string&) override { return CES_OK; } + uint8_t debitCaller (uint64_t) override { return CES_OK; } + bool verifySig (const uint8_t*, size_t, const uint8_t*, const uint8_t*) override { return true; } +}; + +static NullVmHost makeNullHost() { + NullVmHost host; + host.callerKey.fill(0xAA); + host.selfAssetKey.fill(0xBB); + return host; +} + +// Build a program that loops N times executing the body, then TERMs. +// Body is inserted at a known position. Loop structure: +// SET io[8] = N +// ← bodyStart +// DEC io[8] +// JT io[8], bodyStart +// TERM +// Encode index 200 as a 2-byte non-short operand (for SET/DEC target) +static void pushIdx200(ces::Bytes& code) { + code.push_back(2); // control: 2 bytes follow, no regptr, no shortval + code.push_back(200); + code.push_back(0); +} +// Encode dereferenced io[200] as a 2-byte regptr operand (for JT condition) +static void pushDeref200(ces::Bytes& code) { + code.push_back(0x82); // control: regptr=1, shortval=0, 2 bytes follow + code.push_back(200); + code.push_back(0); +} + +static ces::Bytes makeLoop(uint32_t n, const ces::Bytes& body) { + ces::Bytes code; + // Use io[200] as loop counter — safely above any benchmark operand range + code.push_back(OP_SET); + pushIdx200(code); + if (n <= 63) { + code.push_back(sv(n)); + } else if (n <= 65535) { + push2(code, static_cast(n)); + } else { + push4(code, n); + } + uint16_t bodyStart = static_cast(code.size()); + code.insert(code.end(), body.begin(), body.end()); + // DEC io[200] + code.push_back(OP_DEC); + pushIdx200(code); + // JT io[200], bodyStart + code.push_back(OP_JT); + pushDeref200(code); + ces::Buffer::putLE(code, bodyStart); + code.push_back(OP_TERM); + return code; +} + +struct BenchResult { + std::string name; + double nsPerIter; + uint64_t opsExecuted; + uint64_t budgetUsed; +}; + +static BenchResult runBench(const std::string& name, uint32_t iters, + const ces::Bytes& body, + CesVMHost& host, + uint64_t budget = 0) { + if (budget == 0) budget = static_cast(iters) * 10000 + 1000000; + auto code = makeLoop(iters, body); + CesVM vm; + + // Warmup + vm.execute(code, host, budget); + + // Timed run + auto t0 = std::chrono::high_resolution_clock::now(); + auto result = vm.execute(code, host, budget); + auto t1 = std::chrono::high_resolution_clock::now(); + + double totalNs = std::chrono::duration(t1 - t0).count(); + + if (result.error != CESVM_OK) { + std::cerr << " WARNING: " << name << " ended with error " << result.error + << " after " << result.opsExecuted << " ops\n"; + } + + // Subtract loop overhead: each iteration = body + DEC + JT = 3 loop ops + // Total ops = iters * (bodyOps + 3) + setup + TERM + double nsPerIter = totalNs / iters; + + return {name, nsPerIter, result.opsExecuted, result.budgetUsed}; +} + +// Same as runBench but for syscalls — the body sets up syscall args + HOST. +// perIterCost covers syscalls with big fixed gas (VERIFY_EC) that the +// default budget formula in runBench would exhaust mid-run. +static BenchResult runSyscallBench(const std::string& name, uint32_t iters, + uint8_t syscallNum, + const ces::Bytes& argSetup, + CesVMHost& host, + uint64_t perIterCost = 10000) { + ces::Bytes body; + // SET io[3] = syscallNum + body.push_back(OP_SET); + body.push_back(sv(3)); + body.push_back(sv(syscallNum)); + // Arg setup + body.insert(body.end(), argSetup.begin(), argSetup.end()); + // HOST + body.push_back(OP_HOST); + return runBench(name, iters, body, host, + static_cast(iters) * perIterCost + 1000000); +} + +// ---------------------------------------------------------------------------- +// Whole-program workloads (cesl source). Each stresses one interpreter +// aspect; ns/op = wall time / result.opsExecuted is the core dispatch+ +// operand-decode cost the optimization work targets. +// ---------------------------------------------------------------------------- + +struct ProgSpec { + const char* name; + const char* what; + const char* src; +}; + +static const ProgSpec kPrograms[] = { + {"arith", "register arithmetic + loop branch", R"( + let i = 0; + let acc = 1; + while (i < 300000) { + acc = (acc *% 33) +% i; + i = i + 1; + } + output[0] = acc; + output_len = 8; + )"}, + {"calls", "CALL/RET register frames", R"( + fn mix(a, b) { + return (a +% b) ^ (a << 1); + } + let i = 0; + let acc = 0; + while (i < 150000) { + acc = mix(acc, i); + i = i + 1; + } + output[0] = acc; + output_len = 8; + )"}, + {"memory", "dynamic region indexing", R"( + let buf[256]; + let pass = 0; + while (pass < 300) { + let j = 0; + while (j < 256) { + buf[j] = buf[j] +% (j ^ pass); + j = j + 1; + } + pass = pass + 1; + } + output[0] = buf[255]; + output_len = 8; + )"}, + {"stack", "deep expressions on the data stack", R"( + let i = 1; + let acc = 0; + while (i < 100000) { + acc = (acc +% ((i *% 3) +% 1) *% (i +% 2)) ^ (acc >> 3); + i = i + 1; + } + output[0] = acc; + output_len = 8; + )"}, +}; + +// One timed run: warmup execute, then measured execute. Returns ns/op or +// -1 on VM error. `legacy` selects the reference interpreter core (a no-op +// when the build has CESVM_OPT_PREDECODE=0 — both columns then show the +// reference core). +static double progNsPerOp(const ces::Bytes& code, CesVMHost& host, + bool legacy, uint64_t& opsOut) { + constexpr uint64_t budget = 2'000'000'000ULL; + CesVM vm; + vm._setLegacyCore(legacy); + auto r = vm.execute(code, host, budget); // warmup + if (r.error != CESVM_OK) { + std::cerr << "VM error " << r.error << " after " << r.opsExecuted + << " ops\n"; + return -1; + } + auto t0 = std::chrono::high_resolution_clock::now(); + r = vm.execute(code, host, budget); + auto t1 = std::chrono::high_resolution_clock::now(); + opsOut = r.opsExecuted; + return std::chrono::duration(t1 - t0).count() / + static_cast(r.opsExecuted); +} + +static void runPrograms(CesVMHost& host) { + std::cout << "PROGRAMS (cesl workloads), reference vs fast core:\n"; + std::cout << std::left << std::setw(9) << "name" + << std::right << std::setw(11) << "ops" + << std::setw(10) << "ref ns" + << std::setw(10) << "fast ns" + << std::setw(9) << "Mops/s" + << std::setw(9) << "speedup" + << " what\n"; + std::cout << std::string(78, '-') << "\n"; + for (const auto& p : kPrograms) { + ces::Bytes code; + try { + code = ceslCompile(p.src); + } catch (const std::exception& e) { + std::cerr << p.name << ": compile failed: " << e.what() << "\n"; + continue; + } + uint64_t ops = 0; + double ref = progNsPerOp(code, host, true, ops); + double fast = progNsPerOp(code, host, false, ops); + if (ref < 0 || fast < 0) continue; + std::cout << std::left << std::setw(9) << p.name + << std::right << std::setw(11) << ops + << std::fixed << std::setprecision(2) + << std::setw(10) << ref + << std::setw(10) << fast + << std::setprecision(1) + << std::setw(9) << (1000.0 / fast) + << std::setw(8) << (ref / fast) << "x" + << " " << p.what << "\n"; + } + std::cout << "\n"; +} + +int main(int argc, char** argv) { + blog::init(); + blog::set_level(blog::none); + constexpr uint32_t N = 1'000'000; + + std::string section = (argc > 1) ? argv[1] : "all"; + bool all = (section == "all"); + + auto host = makeNullHost(); + + std::vector results; + + std::cout << "CesVM Benchmark\n"; + std::cout << std::string(72, '=') << "\n\n"; + + if (all || section == "prog") + runPrograms(host); + + if (!all && section != "ops" && section != "sys" && section != "crypto") + return 0; + + bool doOps = all || section == "ops"; + bool doSys = all || section == "sys"; + bool doCrypto = all || section == "crypto"; + + if (doOps) { + // ---- OPCODES ---- + std::cout << "OPCODES:\n"; + + // Sanity check + { + auto code = makeLoop(3, {OP_NOP}); + CesVM vm; + auto r = vm.execute(code, host, 100000); + if (r.error != CESVM_OK || r.opsExecuted != 11) { + std::cerr << "SANITY FAIL: error=" << r.error << " ops=" << r.opsExecuted << "\n"; + return 1; + } + } + + // Baseline: empty body (loop overhead only) + results.push_back(runBench("(loop overhead)", N, {}, host)); + + // NOP + results.push_back(runBench("NOP", N, {OP_NOP}, host)); + + // ADD (register mode) + results.push_back(runBench("ADD reg", N, {OP_ADD, sv(16), sv(17)}, host)); + + // ADD (stack mode) + results.push_back(runBench("ADD stack", N, { + OP_PUSH, sv(10), OP_PUSH, sv(20), + static_cast(OP_ADD | STACK), + OP_POP, sv(9) // pop result to avoid stack growth + }, host)); + + // SUB + results.push_back(runBench("SUB reg", N, {OP_SUB, sv(17), sv(16)}, host)); + + // MUL + results.push_back(runBench("MUL reg", N, {OP_MUL, sv(16), sv(17)}, host)); + + // DIV + results.push_back(runBench("DIV reg", N, { + OP_SET, sv(16), sv(42), OP_SET, sv(17), sv(7), + OP_DIV, sv(16), sv(17) + }, host)); + + // MOD + results.push_back(runBench("MOD reg", N, { + OP_SET, sv(16), sv(42), OP_SET, sv(17), sv(7), + OP_MOD, sv(16), sv(17) + }, host)); + + // EQ + results.push_back(runBench("EQ reg", N, {OP_EQ, sv(16), sv(17)}, host)); + + // GT + results.push_back(runBench("GT reg", N, {OP_GT, sv(16), sv(17)}, host)); + + // AND (bitwise) + results.push_back(runBench("AND reg", N, {OP_AND, sv(16), sv(17)}, host)); + + // OR (bitwise) + results.push_back(runBench("OR reg", N, {OP_OR, sv(16), sv(17)}, host)); + + // XOR + results.push_back(runBench("XOR reg", N, {OP_XOR, sv(16), sv(17)}, host)); + + // SHL + results.push_back(runBench("SHL reg", N, {OP_SHL, sv(16), sv(3)}, host)); + + // SHR + results.push_back(runBench("SHR reg", N, {OP_SHR, sv(16), sv(3)}, host)); + + // NOT + results.push_back(runBench("NOT reg", N, {OP_NOT, sv(16)}, host)); + + // NEG (bitwise negate) + results.push_back(runBench("NEG reg", N, {OP_NEG, sv(16)}, host)); + + // INC + results.push_back(runBench("INC", N, {OP_INC, sv(16)}, host)); + + // DEC + results.push_back(runBench("DEC", N, {OP_DEC, sv(16)}, host)); + + // SET + results.push_back(runBench("SET", N, {OP_SET, sv(16), sv(42)}, host)); + + // PUSH+POP + results.push_back(runBench("PUSH+POP", N, {OP_PUSH, sv(42), OP_POP, sv(16)}, host)); + + // CALL+RET — CALL jumps to a RET function at end of code, returns to DEC + { + ces::Bytes code; + // SET io[200] = N + code.push_back(OP_SET); + pushIdx200(code); + push4(code, N); + // Loop body: CALL (will be patched) + uint16_t bodyStart = static_cast(code.size()); + size_t callAddrPatch = code.size() + 1; + code.push_back(OP_CALL); + code.push_back(0x00); + code.push_back(0x00); + // After CALL returns here: DEC io[200], JT loop + code.push_back(OP_DEC); + pushIdx200(code); + code.push_back(OP_JT); + pushDeref200(code); + ces::Buffer::putLE(code, bodyStart); + code.push_back(OP_TERM); + // Function: just RET 0 + uint16_t funcAddr = static_cast(code.size()); + code.push_back(OP_RET); + code.push_back(sv(0)); + // Patch CALL target + ces::Buffer::pokeLE(code.data() + callAddrPatch, funcAddr); + + uint64_t bigBudget = static_cast(N) * 10000 + 1000000; + CesVM vm; + vm.execute(code, host, bigBudget); + auto t0 = std::chrono::high_resolution_clock::now(); + auto result = vm.execute(code, host, bigBudget); + auto t1 = std::chrono::high_resolution_clock::now(); + double ns = std::chrono::duration(t1 - t0).count(); + results.push_back({"CALL+RET", ns / N, result.opsExecuted, result.budgetUsed}); + } + + // RND + results.push_back(runBench("RND", N, {OP_RND}, host)); + + // TIME + results.push_back(runBench("TIME", N, {OP_TIME}, host)); + + // LDB + results.push_back(runBench("LDB", N, {OP_LDB, sv(24)}, host)); + + // STB + results.push_back(runBench("STB", N, {OP_STB, sv(24), sv(42)}, host)); + + // MOV 1 cell + results.push_back(runBench("MOV 1 cell", N, {OP_MOV, sv(20), sv(16), sv(1)}, host)); + + // MOV 4 cells + results.push_back(runBench("MOV 4 cells", N, {OP_MOV, sv(20), sv(16), sv(4)}, host)); + + // MOV 32 cells (256 bytes) + results.push_back(runBench("MOV 32 cells", N, {OP_MOV, sv(20), sv(16), sv(32)}, host)); + + // CMP 1 cell + results.push_back(runBench("CMP 1 cell", N, {OP_CMP, sv(16), sv(20), sv(1)}, host)); + + // CMP 4 cells + results.push_back(runBench("CMP 4 cells", N, {OP_CMP, sv(16), sv(20), sv(4)}, host)); + + // FIL 1 cell + results.push_back(runBench("FIL 1 cell", N, {OP_FIL, sv(16), sv(42), sv(1)}, host)); + + // FIL 32 cells + results.push_back(runBench("FIL 32 cells", N, {OP_FIL, sv(16), sv(42), sv(32)}, host)); + } // doOps + + if (doSys) { + // ---- SYSCALLS ---- + std::cout << "\nSYSCALLS:\n"; + + // SYS_NOP + results.push_back(runSyscallBench("SYS_NOP", N, SYS_NOP, {}, host)); + + // SYS_READ_ACCOUNT + results.push_back(runSyscallBench("SYS_READ_ACCOUNT", N, SYS_READ_ACCOUNT, { + OP_SET, sv(4), sv(16) // prefix ptr + }, host)); + + // SYS_TRANSFER + results.push_back(runSyscallBench("SYS_TRANSFER", N, SYS_TRANSFER, { + OP_SET, sv(4), sv(16), // dest key ptr + OP_SET, sv(5), sv(42), // amount + }, host)); + + // SYS_READ_ASSET + results.push_back(runSyscallBench("SYS_READ_ASSET", N, SYS_READ_ASSET, { + OP_SET, sv(4), sv(16), // key ptr + OP_SET, sv(5), sv(24), // owner out ptr + OP_SET, sv(6), sv(48), // content out ptr + }, host)); + + // SYS_CREATE_ASSET_RANDOM + results.push_back(runSyscallBench("SYS_CREATE_ASSET_RND", N, SYS_CREATE_ASSET_RANDOM, { + OP_SET, sv(4), sv(48), // content ptr + OP_SET, sv(5), sv(30), // days + OP_SET, sv(6), sv(16), // key out ptr + }, host)); + + // SYS_UPDATE_ASSET + results.push_back(runSyscallBench("SYS_UPDATE_ASSET", N, SYS_UPDATE_ASSET, { + OP_SET, sv(4), sv(16), // key ptr + OP_SET, sv(5), sv(48), // content ptr + }, host)); + + // SYS_FUND_ASSET + results.push_back(runSyscallBench("SYS_FUND_ASSET", N, SYS_FUND_ASSET, { + OP_SET, sv(4), sv(16), // key ptr + OP_SET, sv(5), sv(10), // days + }, host)); + + // SYS_BUY_ASSET + results.push_back(runSyscallBench("SYS_BUY_ASSET", N, SYS_BUY_ASSET, { + OP_SET, sv(4), sv(16), // key ptr + OP_SET, sv(5), sv(50), // max price + }, host)); + + // SYS_GIVE_ASSET + results.push_back(runSyscallBench("SYS_GIVE_ASSET", N, SYS_GIVE_ASSET, { + OP_SET, sv(4), sv(16), // key ptr + OP_SET, sv(5), sv(24), // new owner ptr + }, host)); + + // SYS_SEND_UDP + results.push_back(runSyscallBench("SYS_SEND_UDP", N, SYS_SEND_UDP, { + OP_SET, sv(4), sv(16), // addr ptr + OP_SET, sv(5), sv(80), // port + OP_SET, sv(6), sv(24), // data ptr + OP_SET, sv(7), sv(8), // data len + }, host)); + + // SYS_HASH (8 bytes) + results.push_back(runSyscallBench("SYS_HASH 8B", N, SYS_HASH, { + OP_SET, sv(4), sv(16), // data ptr + OP_SET, sv(5), sv(8), // len + OP_SET, sv(6), sv(24), // out ptr + }, host)); + + // SYS_HASH (256 bytes) + results.push_back(runSyscallBench("SYS_HASH 256B", std::max(N / 10, 1u), SYS_HASH, { + OP_SET, sv(4), sv(16), + OP_SET, sv(5), sv(0), // need >63: use 2-byte encoding + }, host)); + // Fix: can't set 256 with sv(). Build manually. + { + ces::Bytes body; + body.push_back(OP_SET); body.push_back(sv(3)); body.push_back(sv(SYS_HASH)); + body.push_back(OP_SET); body.push_back(sv(4)); body.push_back(sv(16)); + // SET io[5] = 256 + body.push_back(OP_SET); body.push_back(sv(5)); + push2(body, 256); + body.push_back(OP_SET); body.push_back(sv(6)); body.push_back(sv(48)); + body.push_back(OP_HOST); + results.back() = runBench("SYS_HASH 256B", std::max(N / 10, 1u), body, host); + } + + // SYS_HASH (1024 bytes) + { + ces::Bytes body; + body.push_back(OP_SET); body.push_back(sv(3)); body.push_back(sv(SYS_HASH)); + body.push_back(OP_SET); body.push_back(sv(4)); body.push_back(sv(16)); + body.push_back(OP_SET); body.push_back(sv(5)); + push2(body, 1024); + body.push_back(OP_SET); body.push_back(sv(6)); body.push_back(sv(48)); + body.push_back(OP_HOST); + results.push_back(runBench("SYS_HASH 1024B", std::max(N / 10, 1u), body, host)); + } + + // SYS_VERIFY_SIG (32 bytes data) — carries CESVM_COST_VERIFY_EC per call + results.push_back(runSyscallBench("SYS_VERIFY_SIG 32B", std::max(N / 100, 1u), SYS_VERIFY_SIG, { + OP_SET, sv(4), sv(16), // data ptr + OP_SET, sv(5), sv(32), // data len + OP_SET, sv(6), sv(24), // sig ptr + OP_SET, sv(7), sv(36), // pubkey ptr + }, host, CESVM_COST_VERIFY_EC + 10000)); + + // SYS_CROSS_TRANSFER + results.push_back(runSyscallBench("SYS_CROSS_TRANSFER", N, SYS_CROSS_TRANSFER, { + OP_SET, sv(4), sv(16), // dest key ptr + OP_SET, sv(5), sv(50), // amount + OP_SET, sv(6), sv(24), // server addr ptr + }, host)); + + // SYS_LOAD_CODE (asset found) + results.push_back(runSyscallBench("SYS_LOAD_CODE", 30, SYS_LOAD_CODE, { + OP_SET, sv(4), sv(16), // key ptr (readAsset returns true) + }, host)); + + // SYS_CREATE_ASSET + results.push_back(runSyscallBench("SYS_CREATE_ASSET", N, SYS_CREATE_ASSET, { + OP_SET, sv(4), sv(16), // key ptr + OP_SET, sv(5), sv(48), // content ptr + OP_SET, sv(6), sv(10), // days + }, host)); + } // doSys + + double baseline = 0; + if (doOps || doSys) { + // ---- REPORT ---- + std::cout << "\n" << std::string(72, '=') << "\n"; + std::cout << std::left << std::setw(25) << "Operation" + << std::right << std::setw(12) << "ns/iter" + << std::setw(10) << "rel" + << std::setw(15) << "ops" + << std::setw(15) << "budget" + << "\n"; + std::cout << std::string(72, '-') << "\n"; + + // Find ADD reg as baseline + for (auto& r : results) { + if (r.name == "ADD reg") { + baseline = r.nsPerIter; + break; + } + } + if (baseline <= 0) baseline = 1; + + for (auto& r : results) { + std::cout << std::left << std::setw(25) << r.name + << std::right << std::fixed << std::setprecision(1) + << std::setw(12) << r.nsPerIter + << std::setw(10) << (r.nsPerIter / baseline) + << std::setw(15) << r.opsExecuted + << std::setw(15) << r.budgetUsed + << "\n"; + } + + std::cout << "\nBaseline (ADD reg) = " << std::fixed << std::setprecision(1) + << baseline << " ns/iter\n"; + } // report + if (baseline <= 0) baseline = 1; + + if (doCrypto) { + // ---- RAW CRYPTO BENCHMARKS ---- + std::cout << "\n" << std::string(72, '=') << "\n"; + std::cout << "RAW CRYPTO (outside VM, direct API calls):\n"; + std::cout << std::string(72, '-') << "\n"; + + constexpr int CRYPTO_ITERS = 10000; + + // Prepare test data + ces::Bytes testData(32, 0x42); + + // ED25519 sign + verify + { + ces::KeyPair kp(ces::KeyAlgo::ED25519); + // Sign benchmark + auto data_span = std::span(testData.data(), testData.size()); + auto t0 = std::chrono::high_resolution_clock::now(); + ces::Signature lastSig; + for (int i = 0; i < CRYPTO_ITERS; ++i) { + lastSig = kp.signData(data_span); + } + auto t1 = std::chrono::high_resolution_clock::now(); + double signNs = std::chrono::duration(t1 - t0).count() / CRYPTO_ITERS; + + // Verify benchmark + ces::PublicKey pk(kp.getPublicKeyAsHash()); + + t0 = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < CRYPTO_ITERS; ++i) { + pk.verifySignature(data_span, lastSig); + } + t1 = std::chrono::high_resolution_clock::now(); + double verifyNs = std::chrono::duration(t1 - t0).count() / CRYPTO_ITERS; + + std::cout << std::left << std::setw(25) << "ED25519 sign" + << std::right << std::fixed << std::setprecision(1) + << std::setw(12) << signNs << " ns" + << std::setw(10) << (signNs / baseline) << "x\n"; + std::cout << std::left << std::setw(25) << "ED25519 verify" + << std::right << std::setw(12) << verifyNs << " ns" + << std::setw(10) << (verifyNs / baseline) << "x\n"; + } + + // SECP256K1 sign + verify + { + ces::KeyPair kp(ces::KeyAlgo::SECP256K1); + // Sign benchmark + auto data_span = std::span(testData.data(), testData.size()); + auto t0 = std::chrono::high_resolution_clock::now(); + ces::Signature lastSig; + for (int i = 0; i < CRYPTO_ITERS; ++i) { + lastSig = kp.signData(data_span); + } + auto t1 = std::chrono::high_resolution_clock::now(); + double signNs = std::chrono::duration(t1 - t0).count() / CRYPTO_ITERS; + + // Verify benchmark + ces::PublicKey pk(kp.getPublicKeyAsHash()); + + t0 = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < CRYPTO_ITERS; ++i) { + pk.verifySignature(data_span, lastSig); + } + t1 = std::chrono::high_resolution_clock::now(); + double verifyNs = std::chrono::duration(t1 - t0).count() / CRYPTO_ITERS; + + std::cout << std::left << std::setw(25) << "SECP256K1 sign" + << std::right << std::fixed << std::setprecision(1) + << std::setw(12) << signNs << " ns" + << std::setw(10) << (signNs / baseline) << "x\n"; + std::cout << std::left << std::setw(25) << "SECP256K1 verify" + << std::right << std::setw(12) << verifyNs << " ns" + << std::setw(10) << (verifyNs / baseline) << "x\n"; + } + + // SHA256 at various sizes (raw, outside VM) + { + CryptoPP::SHA256 hash; + uint8_t digest[32]; + + for (size_t sz : {32, 256, 1024}) { + ces::Bytes data(sz, 0x42); + auto t0 = std::chrono::high_resolution_clock::now(); + for (int i = 0; i < CRYPTO_ITERS * 10; ++i) { + hash.CalculateDigest(digest, data.data(), data.size()); + } + auto t1 = std::chrono::high_resolution_clock::now(); + double ns = std::chrono::duration(t1 - t0).count() / (CRYPTO_ITERS * 10); + + std::string name = "SHA256 " + std::to_string(sz) + "B"; + std::cout << std::left << std::setw(25) << name + << std::right << std::fixed << std::setprecision(1) + << std::setw(12) << ns << " ns" + << std::setw(10) << (ns / baseline) << "x\n"; + } + } + } // doCrypto + + return 0; +}