diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/cmake-single-platform.yml b/.github/workflows/cmake-single-platform.yml new file mode 100644 index 0000000..0ac33d9 --- /dev/null +++ b/.github/workflows/cmake-single-platform.yml @@ -0,0 +1,39 @@ +# This starter workflow is for a CMake project running on a single platform. There is a different starter workflow if you need cross-platform coverage. +# See: https://github.com/actions/starter-workflows/blob/main/ci/cmake-multi-platform.yml +name: CMake on a single platform + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.) + BUILD_TYPE: Release + +jobs: + build: + # The CMake configure and build commands are platform agnostic and should work equally well on Windows or Mac. + # You can convert this to a matrix build if you need cross-platform coverage. + # See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Configure CMake + # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make. + # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type + run: cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} + + - name: Build + # Build your program with the given configuration + run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}} + + - name: Test + working-directory: ${{github.workspace}}/build + # Execute tests defined by the CMake configuration. + # See https://cmake.org/cmake/help/latest/manual/ctest.1.html for more detail + run: ctest -C ${{env.BUILD_TYPE}} + diff --git a/.gitignore b/.gitignore index 0462e68..880f61a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ octra_wallet.exe data/ *.oct pvac/build/ -.DS_Store \ No newline at end of file +.DS_Store.env diff --git a/Makefile b/Makefile index 24eae93..2ef2f6b 100644 --- a/Makefile +++ b/Makefile @@ -55,6 +55,7 @@ else ifneq ($(IS_WIN),) SHARED_EXT:=a SHARED_FLAGS:= SSL_PREFIX:=$(shell echo $$MINGW_PREFIX) +LDB_PREFIX:=$(shell echo $$MINGW_PREFIX) CXXFLAGS+=-I$(SSL_PREFIX)/include -DCPPHTTPLIB_OPENSSL_SUPPORT LDFLAGS:=-static -L$(SSL_PREFIX)/lib -L$(PVAC_BUILD) -lpvac -lleveldb -lssl -lcrypto -lws2_32 -lbcrypt -lcrypt32 -lgdi32 -lz TARGET:=octra_wallet.exe @@ -72,7 +73,46 @@ endif CXXFLAGS+=-I$(PVAC_DIR) LIBPVAC:=$(PVAC_BUILD)/libpvac.$(SHARED_EXT) -all: $(TARGET) +all: check-deps $(TARGET) + +LEVELDB_PATHS:=/usr/include/leveldb/db.h /usr/local/include/leveldb/db.h /opt/homebrew/include/leveldb/db.h /opt/local/include/leveldb/db.h /mingw64/include/leveldb/db.h /ucrt64/include/leveldb/db.h /clang64/include/leveldb/db.h $(LDB_PREFIX)/include/leveldb/db.h +OPENSSL_PATHS:=/usr/include/openssl/evp.h /usr/local/include/openssl/evp.h /opt/homebrew/include/openssl/evp.h /opt/local/include/openssl/evp.h /mingw64/include/openssl/evp.h /ucrt64/include/openssl/evp.h /clang64/include/openssl/evp.h $(SSL_PREFIX)/include/openssl/evp.h + +HAVE_LEVELDB:=$(shell for p in $(LEVELDB_PATHS); do [ -f "$$p" ] && { echo yes; exit 0; }; done; echo no) +HAVE_OPENSSL:=$(shell for p in $(OPENSSL_PATHS); do [ -f "$$p" ] && { echo yes; exit 0; }; done; echo no) + +check-deps: +ifeq ($(OCTRA_SKIP_AUTOSETUP),) +ifeq ($(HAVE_LEVELDB)$(HAVE_OPENSSL),yesyes) + @true +else + @echo 'missing dependencies (leveldb=$(HAVE_LEVELDB) openssl=$(HAVE_OPENSSL))' + @echo 'running ./setup.sh --deps-only to install...' + @echo '' + @if [ -x ./setup.sh ]; then \ + ./setup.sh --deps-only || { \ + echo ''; \ + echo 'auto-install failed. install manually:'; \ + echo 'sudo apt install libleveldb-dev libssl-dev (debian/ubuntu)'; \ + echo 'brew install leveldb openssl@3 (macos)'; \ + echo 'setup.bat from cmd.exe (windows)'; \ + exit 1; \ + }; \ + else \ + echo 'setup.sh not found. install manually:'; \ + echo 'sudo apt install libleveldb-dev libssl-dev (debian/ubuntu)'; \ + echo 'brew install leveldb openssl@3 (macos)'; \ + echo 'setup.bat from cmd.exe (windows)'; \ + exit 1; \ + fi + @ok=no; for p in $(LEVELDB_PATHS); do [ -f "$$p" ] && ok=yes; done; \ + [ "$$ok" = "yes" ] || { echo 'error: leveldb still missing. on windows run setup.bat from cmd.exe'; exit 1; } + @ok=no; for p in $(OPENSSL_PATHS); do [ -f "$$p" ] && ok=yes; done; \ + [ "$$ok" = "yes" ] || { echo 'error: openssl still missing. on windows run setup.bat from cmd.exe'; exit 1; } +endif +else + @true +endif $(PVAC_BUILD): @mkdir -p $(PVAC_BUILD) @@ -104,4 +144,4 @@ clean: run: $(TARGET) ./$(TARGET) 8420 -.PHONY: all clean run +.PHONY: all clean run check-deps \ No newline at end of file diff --git a/README.md b/README.md index aacf2b2..44f86d8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # octra wallet (webcli) -![Version](https://img.shields.io/badge/version-0.04.10--alpha-blue) +![Version](https://img.shields.io/badge/version-0.05.01--alpha-blue) a full-fledged web client based on a local server for working with the octra network (compatible with both **DEVNET** and **MAINNET ALPHA**). diff --git a/crypto_utils.hpp b/crypto_utils.hpp index f2215dc..8f7b4b3 100644 --- a/crypto_utils.hpp +++ b/crypto_utils.hpp @@ -41,6 +41,7 @@ #endif #include #include +#include extern "C" { #include "lib/tweetnacl.h" @@ -98,7 +99,7 @@ static const uint32_t K[64] = { 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 }; -} // namespace detail +} inline std::array sha256(const uint8_t* data, size_t len) { using namespace detail; @@ -147,7 +148,7 @@ inline std::array sha256(const uint8_t* data, size_t len) { std::array out; for (int i = 0; i < 8; i++) { - out[i * 4] = (uint8_t)(h[i] >> 24); + out[i * 4] = (uint8_t)(h[i] >> 24); out[i * 4 + 1] = (uint8_t)(h[i] >> 16); out[i * 4 + 2] = (uint8_t)(h[i] >> 8); out[i * 4 + 3] = (uint8_t)(h[i]); @@ -266,6 +267,52 @@ inline void ed25519_pk_to_curve25519(const uint8_t ed_sk[64], uint8_t x_pk[32]) crypto_scalarmult_base(x_pk, x_sk); } +inline bool ed25519_pub_to_x25519(const uint8_t ed_pub[32], uint8_t x_pub[32]) { + BN_CTX* ctx = BN_CTX_new(); + BIGNUM *p = BN_new(), *y = BN_new(), *one = BN_new(); + BIGNUM *one_plus_y = BN_new(), *one_minus_y = BN_new(); + BIGNUM *u = BN_new(), *inv = BN_new(), *p_minus_2 = BN_new(); + bool ok = false; + if (!ctx || !p || !y || !one || !one_plus_y || !one_minus_y || !u || !inv || !p_minus_2) goto done; + + if (!BN_set_bit(p, 255)) goto done; + if (!BN_sub_word(p, 19)) goto done; + BN_one(one); + + { + uint8_t y_be[32]; + for (int i = 0; i < 32; ++i) y_be[i] = ed_pub[31 - i]; + y_be[0] &= 0x7F; + if (!BN_bin2bn(y_be, 32, y)) goto done; + } + + if (!BN_mod_add(one_plus_y, one, y, p, ctx)) goto done; + if (!BN_mod_sub(one_minus_y, one, y, p, ctx)) goto done; + if (!BN_copy(p_minus_2, p)) goto done; + if (!BN_sub_word(p_minus_2, 2)) goto done; + if (!BN_mod_exp(inv, one_minus_y, p_minus_2, p, ctx)) goto done; + if (!BN_mod_mul(u, one_plus_y, inv, p, ctx)) goto done; + + { + uint8_t u_be[32] = {0}; + if (BN_bn2binpad(u, u_be, 32) != 32) goto done; + for (int i = 0; i < 32; ++i) x_pub[i] = u_be[31 - i]; + } + ok = true; + +done: + BN_free(p_minus_2); + BN_free(inv); + BN_free(u); + BN_free(one_minus_y); + BN_free(one_plus_y); + BN_free(one); + BN_free(y); + BN_free(p); + BN_CTX_free(ctx); + return ok; +} + inline void secure_zero(void* ptr, size_t len) { volatile uint8_t* p = static_cast(ptr); while (len--) *p++ = 0; @@ -285,6 +332,26 @@ inline void keypair_from_seed(const uint8_t seed[32], uint8_t sk[64], uint8_t pk crypto_sign_seed_keypair(pk, sk, seed); } +inline std::string validate_pin(const std::string& pin) { + if (pin.empty()) return "PIN required"; + if (pin.size() < 8) return "PIN must be at least 8 characters"; + if (pin.size() > 64) return "PIN too long (max 64 characters)"; + if (pin.size() < 15) { + bool has_letter = false; + bool has_digit = false; + bool has_symbol = false; + for (unsigned char c : pin) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) has_letter = true; + else if (c >= '0' && c <= '9') has_digit = true; + else has_symbol = true; + } + if (!has_letter || !has_digit || !has_symbol) { + return "under 15 chars: must include a letter, a digit and a special symbol"; + } + } + return ""; +} + inline std::array derive_key_from_pin( const std::string& pin, const uint8_t salt[32], int iterations = 600000) { std::array key; @@ -401,7 +468,7 @@ inline std::string generate_mnemonic_12() { uint8_t entropy[16]; randombytes(entropy, 16); auto hash = sha256(entropy, 16); - uint8_t bits[17]; // 128 + 8 = 136 bits available + uint8_t bits[17]; memcpy(bits, entropy, 16); bits[16] = hash[0]; secure_zero(entropy, 16); @@ -449,7 +516,7 @@ inline bool validate_mnemonic(const std::string& mnemonic) { inline bool looks_like_mnemonic(const std::string& input) { int spaces = 0; for (char c : input) if (c == ' ') spaces++; - return spaces >= 11; // at least 12 words + return spaces >= 11; } -} // namespace octra \ No newline at end of file +} \ No newline at end of file diff --git a/lib/circle_hfhe_receipt.hpp b/lib/circle_hfhe_receipt.hpp new file mode 100644 index 0000000..183e1e3 --- /dev/null +++ b/lib/circle_hfhe_receipt.hpp @@ -0,0 +1,230 @@ + +/* + This file is part of Octra Wallet (webcli). + + Octra Wallet is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + Octra Wallet is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Octra Wallet. If not, see . + + This program is released under the GPL with the additional exemption + that compiling, linking, and/or using OpenSSL is allowed. + You are free to remove this exemption from derived works. + + Copyright 2025-2026 Octra Labs + 2026 lambda0xe +*/ + + + +#pragma once + + + + + + +#include +#include + +#include "json.hpp" +#include "tx_builder.hpp" + +namespace octra { + + + + +struct CircleHfheReceiptContext { + std::string circle_id; + std::string caller_addr; + std::string key_id; + std::string intent_id; + std::string verb; + std::string proof_kind; + std::string policy_hash; + std::string ciphertext_hash; + std::string amount_commitment_hash; +}; + +inline std::string circle_hfhe_receipt_subject(const CircleHfheReceiptContext& ctx) { + return "octra_circle_hfhe_receipt_v1|" + + ctx.verb + "|" + + ctx.proof_kind + "|" + + ctx.circle_id + "|" + + ctx.caller_addr + "|" + + ctx.key_id + "|" + + ctx.intent_id + "|" + + ctx.policy_hash + "|" + + ctx.ciphertext_hash + "|" + + ctx.amount_commitment_hash; +} + +inline std::string circle_hfhe_hash_b64_payload(const std::string& encoded, + std::string& error) { + auto raw = base64_decode(encoded); + if (raw.empty()) { + error = "invalid base64 payload"; + return ""; + } + return hex_encode(sha256(raw.data(), raw.size()).data(), 32); +} + +inline std::string circle_hfhe_hash_ciphertext(const std::string& ciphertext_b64, + std::string& error) { + return circle_hfhe_hash_b64_payload(ciphertext_b64, error); +} + +inline std::string circle_hfhe_hash_commitment(const std::string& amount_commitment_b64, + std::string& error) { + auto raw = base64_decode(amount_commitment_b64); + if (raw.size() != 32) { + error = "invalid amount commitment"; + return ""; + } + return hex_encode(sha256(raw.data(), raw.size()).data(), 32); +} + +inline std::string derive_address_from_pubkey_b64(const std::string& pub_b64) { + auto raw = base64_decode(pub_b64); + if (raw.size() != 32) { + return ""; + } + auto h = sha256(raw.data(), raw.size()); + std::string b58 = base58_encode(h.data(), 32); + while (b58.size() < 44) { + b58 = "1" + b58; + } + return "oct" + b58; +} + +inline bool ed25519_verify_detached(const std::string& message, + const std::string& sig_b64, + const std::string& pub_b64) { + auto sig = base64_decode(sig_b64); + auto pub = base64_decode(pub_b64); + if (sig.size() != 64 || pub.size() != 32) { + return false; + } + std::vector signed_msg(sig.size() + message.size()); + std::memcpy(signed_msg.data(), sig.data(), sig.size()); + std::memcpy(signed_msg.data() + sig.size(), message.data(), message.size()); + std::vector opened(message.size() + 64); + unsigned long long opened_len = 0; + return crypto_sign_open( + opened.data(), + &opened_len, + signed_msg.data(), + signed_msg.size(), + pub.data()) == 0; +} + +inline nlohmann::json make_circle_hfhe_receipt_json(const CircleHfheReceiptContext& ctx, + const std::string& signer_addr, + const std::string& signer_pub_b64, + const uint8_t signer_sk[64]) { + + + + + nlohmann::json receipt; + receipt["version"] = "octra_circle_hfhe_receipt_v1"; + receipt["verb"] = ctx.verb; + receipt["proof_kind"] = ctx.proof_kind; + receipt["circle_id"] = ctx.circle_id; + + receipt["caller_addr"] = ctx.caller_addr; + receipt["key_id"] = ctx.key_id; + receipt["intent_id"] = ctx.intent_id; + receipt["policy_hash"] = ctx.policy_hash; + + + receipt["ciphertext_hash"] = ctx.ciphertext_hash; + receipt["amount_commitment_hash"] = ctx.amount_commitment_hash; + receipt["signer_addr"] = signer_addr; + receipt["signer_pubkey"] = signer_pub_b64; + + + + + + const std::string subject = circle_hfhe_receipt_subject(ctx); + receipt["signature"] = ed25519_sign_detached( + reinterpret_cast(subject.data()), + subject.size(), + signer_sk); + return receipt; +} + +inline bool verify_circle_hfhe_receipt_json(const nlohmann::json& receipt, + const CircleHfheReceiptContext& ctx, + std::string& error) { + if (!receipt.is_object()) { + error = "proof_receipt must be an object"; + return false; + } + + + + + + const std::string version = receipt.value("version", ""); + const std::string verb = receipt.value("verb", ""); + const std::string proof_kind = receipt.value("proof_kind", ""); + const std::string circle_id = receipt.value("circle_id", ""); + + const std::string caller_addr = receipt.value("caller_addr", ""); + const std::string key_id = receipt.value("key_id", ""); + const std::string intent_id = receipt.value("intent_id", ""); + const std::string policy_hash = receipt.value("policy_hash", ""); + const std::string ciphertext_hash = receipt.value("ciphertext_hash", ""); + const std::string amount_commitment_hash = receipt.value("amount_commitment_hash", ""); + const std::string signer_addr = receipt.value("signer_addr", ""); + const std::string signer_pubkey = receipt.value("signer_pubkey", ""); + const std::string signature = receipt.value("signature", ""); + // done here btw + + + + + if (version != "octra_circle_hfhe_receipt_v1") { + error = "invalid proof receipt version"; + return false; + } + if (verb != ctx.verb || proof_kind != ctx.proof_kind || circle_id != ctx.circle_id || + caller_addr != ctx.caller_addr || key_id != ctx.key_id || intent_id != ctx.intent_id || + policy_hash != ctx.policy_hash || ciphertext_hash != ctx.ciphertext_hash || + + amount_commitment_hash != ctx.amount_commitment_hash) { + error = "proof receipt context mismatch"; + return false; + } + + + const std::string derived_addr = derive_address_from_pubkey_b64(signer_pubkey); + if (derived_addr.empty() || derived_addr != signer_addr) { + error = "proof receipt signer binding invalid"; + return false; + } + if (!ed25519_verify_detached(circle_hfhe_receipt_subject(ctx), signature, signer_pubkey)) { + + + // + + error = "proof receipt signature verification failed"; // would be necessary to expand it with more support later + + + return false; + } + return true; +} + +} \ No newline at end of file diff --git a/lib/pvac_bridge.hpp b/lib/pvac_bridge.hpp index 8059f73..2b8c9cd 100644 --- a/lib/pvac_bridge.hpp +++ b/lib/pvac_bridge.hpp @@ -125,13 +125,17 @@ class PvacBridge { std::array commit_ct(pvac_cipher ct) { std::array out; - pvac_commit_ct(pk_, ct, out.data()); + size_t out_len = 0; + int rc = pvac_commit_ct_v2(pk_, ct, out.data(), out.size(), &out_len); + if (rc != 0 || out_len != out.size()) throw std::runtime_error("pvac_commit_ct_v2 failed"); return out; } std::array pedersen_commit(uint64_t amount, const uint8_t blinding[32]) { std::array out; - pvac_pedersen_commit(amount, blinding, out.data()); + size_t out_len = 0; + int rc = pvac_pedersen_commit_v2(amount, blinding, out.data(), out.size(), &out_len); + if (rc != 0 || out_len != out.size()) throw std::runtime_error("pvac_pedersen_commit_v2 failed"); return out; } diff --git a/lib/tx_builder.hpp b/lib/tx_builder.hpp index c51fdf5..5a60656 100644 --- a/lib/tx_builder.hpp +++ b/lib/tx_builder.hpp @@ -69,7 +69,10 @@ inline std::string json_escape(const std::string& s) { case '\n': r += "\\n"; break; case '\r': r += "\\r"; break; case '\t': r += "\\t"; break; - default: r += c; + + + + default: r += c; } } return r; @@ -148,6 +151,19 @@ inline std::string sha256_hex(const std::string& data) { return hex; } +inline std::string sign_circle_read_request(const std::string& op, + const std::string& circle_id, + const std::string& addr, + const std::string& subject, + const uint8_t sk[64]) { + std::string msg = op + "|" + circle_id + "|" + addr; + if (!subject.empty()) { + msg += "|" + subject; + } + return ed25519_sign_detached( + reinterpret_cast(msg.data()), msg.size(), sk); +} + inline std::string sign_register_request(const std::string& addr, const std::string& pk_blob, const uint8_t sk[64]) { diff --git a/lib/txcache.hpp b/lib/txcache.hpp index 054f1f9..166bd49 100644 --- a/lib/txcache.hpp +++ b/lib/txcache.hpp @@ -25,10 +25,11 @@ 2025-2026 Julia L. */ - #pragma once #include #include +#include +#include #include #include #include "json.hpp" @@ -36,8 +37,9 @@ class TxCache { leveldb::DB* db_ = nullptr; std::string path_; -public: - bool open(const std::string& path) { + mutable std::shared_mutex mtx_; + + bool open_u(const std::string& path) { path_ = path; leveldb::Options opts; opts.create_if_missing = true; @@ -45,10 +47,33 @@ class TxCache { return st.ok(); } - void close() { delete db_; db_ = nullptr; path_.clear(); } - ~TxCache() { close(); } + void close_u() { delete db_; db_ = nullptr; path_.clear(); } + + std::string get_u(const std::string& key) { + std::string val; + if (db_ && db_->Get(leveldb::ReadOptions(), key, &val).ok()) return val; + return ""; + } + + void put_u(const std::string& key, const std::string& val) { + if (db_) db_->Put(leveldb::WriteOptions(), key, val); + } + +public: + bool open(const std::string& path) { + std::unique_lock lk(mtx_); + return open_u(path); + } + + void close() { + std::unique_lock lk(mtx_); + close_u(); + } + + ~TxCache() { close_u(); } leveldb::DB* detach() { + std::unique_lock lk(mtx_); leveldb::DB* db = db_; db_ = nullptr; path_.clear(); @@ -56,10 +81,11 @@ class TxCache { } void clear() { + std::unique_lock lk(mtx_); std::string p = path_; - close(); + close_u(); if (!p.empty()) leveldb::DestroyDB(p, leveldb::Options()); - if (!p.empty()) open(p); + if (!p.empty()) open_u(p); } void ensure_rpc(const std::string& rpc_url) { @@ -73,14 +99,25 @@ class TxCache { } } + void ensure_schema(const std::string& schema) { + auto stored = get("meta:schema"); + if (stored != schema) { + if (!stored.empty()) + fprintf(stderr, "txcache: schema mismatch (%s != %s), clearing\n", + stored.c_str(), schema.c_str()); + clear(); + put("meta:schema", schema); + } + } + void put(const std::string& key, const std::string& val) { - if (db_) db_->Put(leveldb::WriteOptions(), key, val); + std::shared_lock lk(mtx_); + put_u(key, val); } std::string get(const std::string& key) { - std::string val; - if (db_ && db_->Get(leveldb::ReadOptions(), key, &val).ok()) return val; - return ""; + std::shared_lock lk(mtx_); + return get_u(key); } int get_total(const std::string& addr) { @@ -92,39 +129,43 @@ class TxCache { put("total:" + addr, std::to_string(total)); } - void store_tx(const nlohmann::json& tx) { + void store_tx(const std::string& addr, const nlohmann::json& tx) { std::string hash = tx.value("hash", ""); - if (hash.empty()) return; - put("tx:" + hash, tx.dump()); + if (hash.empty() || addr.empty()) return; + std::shared_lock lk(mtx_); + put_u("tx:" + hash, tx.dump()); double ts = tx.value("timestamp", 0.0); char idx[128]; - snprintf(idx, sizeof(idx), "idx:%020.6f:%s", 9999999999.0 - ts, hash.c_str()); - put(idx, hash); + snprintf(idx, sizeof(idx), "idx:%s:%020.6f:%s", addr.c_str(), 9999999999.0 - ts, hash.c_str()); + put_u(idx, hash); } - void store_txs(const nlohmann::json& txs) { + void store_txs(const std::string& addr, const nlohmann::json& txs) { + std::shared_lock lk(mtx_); if (!db_) return; leveldb::WriteBatch batch; for (auto& tx : txs) { std::string hash = tx.value("hash", ""); - if (hash.empty()) continue; + if (hash.empty() || addr.empty()) continue; batch.Put("tx:" + hash, tx.dump()); double ts = tx.value("timestamp", 0.0); - char idx[128]; - snprintf(idx, sizeof(idx), "idx:%020.6f:%s", 9999999999.0 - ts, hash.c_str()); + char idx[192]; + snprintf(idx, sizeof(idx), "idx:%s:%020.6f:%s", addr.c_str(), 9999999999.0 - ts, hash.c_str()); batch.Put(idx, hash); } db_->Write(leveldb::WriteOptions(), &batch); } - nlohmann::json load_page(int limit, int offset) { + nlohmann::json load_page(const std::string& addr, int limit, int offset) { nlohmann::json result = nlohmann::json::array(); - if (!db_) return result; + std::shared_lock lk(mtx_); + if (!db_ || addr.empty()) return result; + std::string prefix = "idx:" + addr + ":"; auto it = db_->NewIterator(leveldb::ReadOptions()); int pos = 0; - for (it->Seek("idx:"); it->Valid(); it->Next()) { + for (it->Seek(prefix); it->Valid(); it->Next()) { auto k = it->key().ToString(); - if (k.substr(0, 4) != "idx:") break; + if (k.compare(0, prefix.size(), prefix) != 0) break; if (pos < offset) { pos++; continue; } auto hash = it->value().ToString(); std::string val; @@ -138,12 +179,14 @@ class TxCache { return result; } - int count_idx() { - if (!db_) return 0; + int count_idx(const std::string& addr) { + std::shared_lock lk(mtx_); + if (!db_ || addr.empty()) return 0; + std::string prefix = "idx:" + addr + ":"; int n = 0; auto it = db_->NewIterator(leveldb::ReadOptions()); - for (it->Seek("idx:"); it->Valid(); it->Next()) { - if (it->key().ToString().substr(0, 4) != "idx:") break; + for (it->Seek(prefix); it->Valid(); it->Next()) { + if (it->key().ToString().compare(0, prefix.size(), prefix) != 0) break; n++; } delete it; @@ -151,9 +194,13 @@ class TxCache { } bool has_tx(const std::string& hash) { + std::shared_lock lk(mtx_); std::string val; return db_ && db_->Get(leveldb::ReadOptions(), "tx:" + hash, &val).ok(); } - bool is_open() const { return db_ != nullptr; } + bool is_open() const { + std::shared_lock lk(mtx_); + return db_ != nullptr; + } }; \ No newline at end of file diff --git a/main.cpp b/main.cpp index 37003a8..c107f24 100644 --- a/main.cpp +++ b/main.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,8 @@ #include #include #include +#include +#include #ifdef _WIN32 #define NOMINMAX #define WIN32_LEAN_AND_MEAN @@ -57,8 +60,10 @@ extern "C" { } #include "crypto_utils.hpp" +#include "sanitize.hpp" #include "wallet.hpp" #include "rpc_client.hpp" +#include "lib/circle_hfhe_receipt.hpp" #include "lib/tx_builder.hpp" #include "lib/pvac_bridge.hpp" #include "lib/stealth.hpp" @@ -77,6 +82,91 @@ static std::string g_wallet_path = "data/wallet.oct"; static std::string g_pin; static TxCache g_txcache; +static nlohmann::json g_fee_cache; +static double g_fee_cache_ts = 0.0; +static std::mutex g_fee_mtx; + +struct HistoryRuntimeState { + double last_top_refresh_ts = 0.0; + json rejected = json::array(); + int total = 0; + std::unordered_map pages; + std::unordered_map page_ts; +}; + +static std::unordered_map g_history_runtime; +static std::mutex g_history_runtime_mtx; + +struct TokenHistoryRuntimeState { + double ts = 0.0; + json rows = json::array(); + int incoming = 0; + int outgoing = 0; +}; + +static std::unordered_map g_token_history_runtime; +static std::mutex g_token_history_runtime_mtx; + +static std::unordered_map> g_pk_cache; +static std::mutex g_pk_mtx; + +static std::optional> pk_cache_get(const std::string& addr) { + std::lock_guard lk(g_pk_mtx); + auto it = g_pk_cache.find(addr); + if (it == g_pk_cache.end()) return std::nullopt; + return it->second; +} + +static std::string current_public_rpc_url() { + if (g_wallet_loaded) return g_wallet.rpc_url; + const char* env_rpc = std::getenv("OCTRA_RPC_URL"); + if (env_rpc && *env_rpc) return env_rpc; + return "http://127.0.0.1:8080"; +} + +struct RelayProxyResult { + bool ok = false; + int status = 0; + std::string body; + std::string error; +}; + +static std::string current_circle_relayer_url() { + const char* env_relayer = std::getenv("OCTRA_CIRCLE_RELAYER_URL"); + if (env_relayer && *env_relayer) return env_relayer; + return "http://127.0.0.1:9494"; +} + +static RelayProxyResult relay_http_get(const std::string& path) { + httplib::Client cli(current_circle_relayer_url()); + cli.set_connection_timeout(5, 0); + cli.set_read_timeout(30, 0); + auto r = cli.Get(path.c_str()); + if (!r) return {false, 502, "", "relay unavailable"}; + return {true, r->status, r->body, ""}; +} + +static RelayProxyResult relay_http_post(const std::string& path, const std::string& body) { + httplib::Client cli(current_circle_relayer_url()); + cli.set_connection_timeout(5, 0); + cli.set_read_timeout(30, 0); + auto r = cli.Post(path.c_str(), body, "application/json"); + if (!r) return {false, 502, "", "relay unavailable"}; + return {true, r->status, r->body, ""}; +} + +static void pk_cache_put(const std::string& addr, const std::vector& pk) { + if (pk.size() != 32) return; + std::lock_guard lk(g_pk_mtx); + if (g_pk_cache.size() > 2048) g_pk_cache.clear(); + g_pk_cache[addr] = pk; +} + +static void pk_cache_erase(const std::string& addr) { + std::lock_guard lk(g_pk_mtx); + g_pk_cache.erase(addr); +} + static void handle_signal(int) { octra::secure_zero(g_wallet.sk, 64); octra::secure_zero(g_wallet.pk, 32); @@ -97,6 +187,208 @@ static json err_json(const std::string& msg) { return {{"error", msg}}; } +static std::string lower_ascii(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return s; +} + +static bool starts_with(const std::string& s, const std::string& prefix) { + return s.rfind(prefix, 0) == 0; +} + +static bool is_loopback_host(std::string host, int port) { + host = lower_ascii(host); + std::string suffix = ":" + std::to_string(port); + if (host.size() > suffix.size() && + host.compare(host.size() - suffix.size(), suffix.size(), suffix) == 0) { + host.resize(host.size() - suffix.size()); + } + return host == "127.0.0.1" || host == "localhost" || host == "[::1]"; +} + +static bool is_allowed_webcli_origin(const std::string& origin, int port) { + std::string o = lower_ascii(origin); + std::string suffix = ":" + std::to_string(port); + return o == "http://127.0.0.1" + suffix || + o == "http://localhost" + suffix || + o == "http://[::1]" + suffix; +} + +static bool webcli_request_allowed(const httplib::Request& req, int port, std::string& reason) { + if (!starts_with(req.path, "/api/")) { + if (req.method != "GET" && req.method != "HEAD" && req.method != "OPTIONS") { + reason = "non-GET on non-api path"; + return false; + } + return true; + } + + std::string host = req.get_header_value("Host"); + if (!host.empty() && !is_loopback_host(host, port)) { + reason = "non-loopback host"; + return false; + } + + std::string fetch_site = lower_ascii(req.get_header_value("Sec-Fetch-Site")); + if (!fetch_site.empty() && fetch_site != "same-origin" && fetch_site != "none") { + reason = "cross-site fetch"; + return false; + } + + std::string origin = req.get_header_value("Origin"); + if (!origin.empty() && !is_allowed_webcli_origin(origin, port)) { + reason = "cross-origin request"; + return false; + } + + bool same_origin_fetch = !fetch_site.empty() && (fetch_site == "same-origin" || fetch_site == "none"); + bool same_origin_header = !origin.empty() && is_allowed_webcli_origin(origin, port); + bool state_changing = req.method == "POST" || req.method == "PUT" || req.method == "DELETE"; + if (state_changing && !same_origin_fetch && !same_origin_header) { + reason = "unverified origin on state-changing request"; + return false; + } + + return true; +} + +static void set_same_origin_cors_if_needed(const httplib::Request& req, + httplib::Response& res, + int port) { + std::string origin = req.get_header_value("Origin"); + if (!origin.empty() && is_allowed_webcli_origin(origin, port)) { + res.set_header("Access-Control-Allow-Origin", origin.c_str()); + res.set_header("Vary", "Origin"); + } +} + +static bool is_valid_http_url(const std::string& url) { + bool https = url.rfind("https://", 0) == 0; + bool http = url.rfind("http://", 0) == 0; + if (!https && !http) return false; + std::string rest = url.substr(https ? 8 : 7); + if (rest.empty()) return false; + if (rest.find(' ') != std::string::npos || rest.find('\t') != std::string::npos) return false; + std::string host = rest.substr(0, rest.find('/')); + return !host.empty(); +} + +static bool tx_status_is_pending_like(const json& tx) { + const std::string status = tx.value("status", "pending"); + return status.empty() || status == "pending"; +} + +static json history_tx_from_lookup(const json& lookup, const json& fallback) { + json tx = fallback; + tx["hash"] = lookup.value("tx_hash", fallback.value("hash", "")); + tx["from"] = lookup.value("from", fallback.value("from", "")); + tx["to_"] = lookup.value("to", lookup.value("to_", fallback.value("to_", fallback.value("to", "")))); + tx["amount_raw"] = lookup.value("amount_raw", lookup.value("amount", fallback.value("amount_raw", "0"))); + tx["op_type"] = lookup.value("op_type", fallback.value("op_type", "standard")); + tx["status"] = lookup.value("status", fallback.value("status", "pending")); + + double ts = fallback.value("timestamp", 0.0); + if (lookup.contains("timestamp") && lookup["timestamp"].is_number()) + ts = lookup["timestamp"].get(); + else if (lookup.contains("rejected_at") && lookup["rejected_at"].is_number()) + ts = lookup["rejected_at"].get(); + tx["timestamp"] = ts; + + if (lookup.contains("message") && lookup["message"].is_string() && !lookup["message"].get().empty()) + tx["message"] = lookup["message"]; + if (lookup.contains("encrypted_data") && lookup["encrypted_data"].is_string() && !lookup["encrypted_data"].get().empty()) + tx["encrypted_data"] = lookup["encrypted_data"]; + if (lookup.contains("epoch")) + tx["epoch"] = lookup["epoch"]; + else if (lookup.contains("epoch_id")) + tx["epoch"] = lookup["epoch_id"]; + if (lookup.contains("block_height")) + tx["block_height"] = lookup["block_height"]; + + if (lookup.contains("error") && lookup["error"].is_object()) { + tx["reject_reason"] = lookup["error"].value("reason", ""); + tx["reject_type"] = lookup["error"].value("type", ""); + } else { + tx.erase("reject_reason"); + tx.erase("reject_type"); + } + return tx; +} + +static bool reconcile_history_rows(const std::string& addr, json& txs) { + if (!txs.is_array() || txs.empty()) return false; + std::vector methods; + std::vector params_list; + std::vector positions; + for (size_t i = 0; i < txs.size(); ++i) { + if (!txs[i].is_object() || !tx_status_is_pending_like(txs[i])) continue; + const std::string hash = txs[i].value("hash", ""); + if (hash.empty()) continue; + methods.push_back("octra_transaction"); + params_list.push_back(json::array({hash})); + positions.push_back(i); + } + if (methods.empty()) return false; + auto results = g_rpc.call_batch(methods, params_list, 10); + bool changed = false; + for (size_t i = 0; i < results.size() && i < positions.size(); ++i) { + if (!results[i].ok || !results[i].result.is_object()) continue; + const std::string status = results[i].result.value("status", ""); + if (status.empty() || status == "pending") continue; + json updated = history_tx_from_lookup(results[i].result, txs[positions[i]]); + txs[positions[i]] = updated; + if (g_txcache.is_open()) g_txcache.store_tx(addr, updated); + changed = true; + } + return changed; +} + +static HistoryRuntimeState history_runtime_get(const std::string& addr) { + std::lock_guard lk(g_history_runtime_mtx); + auto it = g_history_runtime.find(addr); + if (it == g_history_runtime.end()) return {}; + return it->second; +} + +static void history_runtime_put(const std::string& addr, const HistoryRuntimeState& state) { + std::lock_guard lk(g_history_runtime_mtx); + g_history_runtime[addr] = state; +} + +static void history_runtime_clear(const std::string& addr) { + std::lock_guard lk(g_history_runtime_mtx); + g_history_runtime.erase(addr); +} + +static void history_runtime_clear_all() { + std::lock_guard lk(g_history_runtime_mtx); + g_history_runtime.clear(); +} + +static std::optional token_history_runtime_get(const std::string& addr) { + std::lock_guard lk(g_token_history_runtime_mtx); + auto it = g_token_history_runtime.find(addr); + if (it == g_token_history_runtime.end()) return std::nullopt; + return it->second; +} + +static void token_history_runtime_put(const std::string& addr, const TokenHistoryRuntimeState& state) { + std::lock_guard lk(g_token_history_runtime_mtx); + g_token_history_runtime[addr] = state; +} + +static void token_history_runtime_clear(const std::string& addr) { + std::lock_guard lk(g_token_history_runtime_mtx); + g_token_history_runtime.erase(addr); +} + +static void token_history_runtime_clear_all() { + std::lock_guard lk(g_token_history_runtime_mtx); + g_token_history_runtime.clear(); +} + static std::string parse_ou(const json& body, const std::string& fallback) { std::string val = body.value("ou", ""); if (val.empty()) return fallback; @@ -107,6 +399,29 @@ static std::string parse_ou(const json& body, const std::string& fallback) { return fallback; } +static bool is_octra_address(const std::string& addr) { + return addr.size() == 47 && addr.substr(0, 3) == "oct"; +} + +static constexpr size_t CIRCLE_ASSET_MAX_RAW_BYTES = 33554432; +static constexpr size_t CIRCLE_ASSET_MAX_B64_BYTES = ((CIRCLE_ASSET_MAX_RAW_BYTES + 2) / 3) * 4; + +static size_t circle_asset_decoded_size_upper_bound(size_t wire_len) { + return ((wire_len + 3) / 4) * 3; +} + +static int64_t circle_asset_ou_from_b64_len(size_t wire_len) { + const size_t raw_upper_bound = circle_asset_decoded_size_upper_bound(wire_len); + if (raw_upper_bound <= 4096) return 5000; + if (raw_upper_bound <= 16384) return 10000; + if (raw_upper_bound <= 32768) return 20000; + if (raw_upper_bound <= 131072) return 40000; + if (raw_upper_bound <= 524288) return 80000; + if (raw_upper_bound <= 2097152) return 160000; + if (raw_upper_bound <= 8388608) return 320000; + return 640000; +} + static const int64_t MAX_OCT_RAW = 1000000000LL * 1000000LL; static int64_t parse_amount_raw(const json& body) { @@ -131,7 +446,7 @@ static int64_t parse_amount_raw(const json& body) { if (integer_part.empty() && frac_part.empty()) return -1; for (char c : integer_part) if (c < '0' || c > '9') return -1; for (char c : frac_part) if (c < '0' || c > '9') return -1; - if (frac_part.size() > 6) frac_part = frac_part.substr(0, 6); + if (frac_part.size() > 6) return -1; while (frac_part.size() < 6) frac_part += '0'; int64_t ip = integer_part.empty() ? 0 : std::stoll(integer_part); if (ip > MAX_OCT_RAW / 1000000) return -1; @@ -178,6 +493,472 @@ static void sign_tx_fields(octra::Transaction& tx) { tx.public_key = g_wallet.pub_b64; } +static std::string sign_circle_read_request(const std::string& op, + const std::string& circle_id, + const std::string& subject = "") { + return octra::sign_circle_read_request( + op, + circle_id, + g_wallet.addr, + subject, + g_wallet.sk); +} + +static std::string sign_circle_view_request(const std::string& circle_id, + const std::string& method, + const json& params, + bool include_storage) { + const std::string params_hash = octra::sha256_hex(params.dump()); + const std::string subject = + method + "|" + params_hash + "|" + (include_storage ? "1" : "0"); + return sign_circle_read_request("octra_circle_view", circle_id, subject); +} + +static octra::RpcResult circle_info_auth_rpc(const std::string& circle_id) { + octra::RpcClient rpc(current_public_rpc_url()); + return rpc.circle_info_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_info", circle_id)); +} + +static octra::RpcResult circle_hfhe_policy_auth_rpc(const std::string& circle_id) { + octra::RpcClient rpc(current_public_rpc_url()); + return rpc.circle_hfhe_policy_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_hfhe_policy", circle_id)); +} + +static octra::RpcResult circle_key_policy_auth_rpc(const std::string& circle_id, + const std::string& key_id) { + octra::RpcClient rpc(current_public_rpc_url()); + return rpc.circle_key_policy_auth( + circle_id, + key_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_key_policy", circle_id, key_id)); +} + +static octra::RpcResult circle_outbox_status_auth_rpc(const std::string& circle_id, + const std::string& intent_id) { + octra::RpcClient rpc(current_public_rpc_url()); + return rpc.circle_outbox_status_auth( + circle_id, + intent_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_outbox_status", circle_id, intent_id)); +} + +static bool circle_string_list_contains(const json& values, const std::string& target) { + if (!values.is_array()) return false; + for (const auto& value : values) { + if (value.is_string() && value.get() == target) { + return true; + } + } + return false; +} + +static bool circle_hfhe_mode_allows(const std::string& mode, + const std::string& owner, + const std::string& caller, + const std::string& subject, + const std::vector& active_relays) { + const bool caller_is_active_relay = + std::find(active_relays.begin(), active_relays.end(), caller) != active_relays.end(); + if (mode == "deny") return false; + if (mode == "owner_only") return caller == owner; + if (mode == "caller_self") return caller == subject; + if (mode == "owner_or_caller") return caller == owner || caller == subject; + if (mode == "any_registered") return !caller.empty(); + if (mode == "active_relay") return caller_is_active_relay; + if (mode == "owner_or_active_relay") return caller == owner || caller_is_active_relay; + return false; +} + +static bool circle_hfhe_pk_allowed(const json& policy, const std::string& requested_addr) { + if (!policy.contains("pk_allowlist") || policy["pk_allowlist"].is_null()) { + return true; + } + return circle_string_list_contains(policy["pk_allowlist"], requested_addr); +} + +static bool circle_key_policy_live(const std::string& circle_id, + const std::string& key_id, + std::string& error) { + auto r = circle_key_policy_auth_rpc(circle_id, key_id); + if (!r.ok) { + error = r.error.empty() ? "circle key policy read failed" : r.error; + return false; + } + if (!r.result.contains("live") || !r.result["live"].is_boolean()) { + error = "circle key policy live status unavailable"; + return false; + } + if (!r.result["live"].get()) { + error = "circle key policy is not live"; + return false; + } + return true; +} + +static bool circle_hfhe_active_relays(const std::string& circle_id, + const std::string& intent_id, + std::vector& active_relays, + std::string& error) { + auto status_r = circle_outbox_status_auth_rpc(circle_id, intent_id); + if (!status_r.ok) { + error = status_r.error.empty() ? "circle outbox status read failed" : status_r.error; + return false; + } + if (status_r.result.value("status", "") != "claimed") { + error = "circle outbox intent is not actively claimed"; + return false; + } + if (!status_r.result.value("claim_ready", false)) { + error = "circle outbox intent relay quorum is not ready"; + return false; + } + active_relays.clear(); + const auto active_claims = status_r.result.value("active_claims", json::array()); + for (const auto& claim : active_claims) { + if (claim.is_object()) { + const std::string relay_id = claim.value("relay_id", ""); + if (!relay_id.empty()) { + active_relays.push_back(relay_id); + } + } + } + if (active_relays.empty()) { + error = "circle outbox active relays are unavailable"; + return false; + } + return true; +} + +static bool circle_hfhe_authorize(const std::string& circle_id, + const std::string& mode_key, + const std::string& requested_addr, + const std::string& key_id, + const std::string& intent_id, + std::string& error) { + auto info_r = circle_info_auth_rpc(circle_id); + if (!info_r.ok) { + error = info_r.error.empty() ? "circle info read failed" : info_r.error; + return false; + } + auto policy_r = circle_hfhe_policy_auth_rpc(circle_id); + if (!policy_r.ok) { + error = policy_r.error.empty() ? "circle hfhe policy read failed" : policy_r.error; + return false; + } + json policy = policy_r.result.value("policy", json::object()); + if (mode_key == "load_pk_mode" && !circle_hfhe_pk_allowed(policy, requested_addr)) { + error = "requested pubkey address is not allowed by circle hfhe policy"; + return false; + } + const std::string owner = info_r.result.value("owner", ""); + const std::string default_mode = + mode_key == "load_pk_mode" ? "caller_self" : "owner_only"; + const std::string mode = policy.value(mode_key, default_mode); + std::vector active_relays; + if (mode == "active_relay" || mode == "owner_or_active_relay") { + if (intent_id.empty()) { + error = "intent_id required by circle hfhe relay-scoped policy"; + return false; + } + if (!circle_hfhe_active_relays(circle_id, intent_id, active_relays, error)) { + return false; + } + } + const std::string subject = + mode_key == "load_pk_mode" ? requested_addr : g_wallet.addr; + if (!circle_hfhe_mode_allows(mode, owner, g_wallet.addr, subject, active_relays)) { + error = "circle hfhe policy denied this operation"; + return false; + } + const bool require_live_key_policy = policy.value("require_live_key_policy", true); + if (require_live_key_policy) { + if (key_id.empty()) { + error = "key_id required by circle hfhe policy"; + return false; + } + if (!circle_key_policy_live(circle_id, key_id, error)) { + return false; + } + } + return true; +} + +static bool circle_decode_zero_proof(const std::string& encoded, + pvac_zero_proof& proof, + std::string& error) { + proof = nullptr; + if (encoded.rfind(octra::ZKZP_PREFIX, 0) != 0) { + error = "invalid zero proof prefix"; + return false; + } + auto raw = octra::base64_decode(encoded.substr(std::strlen(octra::ZKZP_PREFIX))); + if (raw.empty()) { + error = "invalid zero proof encoding"; + return false; + } + proof = pvac_deserialize_zero_proof(raw.data(), raw.size()); + if (!proof) { + error = "invalid zero proof"; + return false; + } + return true; +} + +static bool circle_verify_zero_with_wallet(const std::string& ciphertext_b64, + const std::string& zero_proof_b64, + std::string& error) { + auto raw = octra::base64_decode(ciphertext_b64); + if (raw.empty()) { + error = "invalid ciphertext"; + return false; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + error = "invalid ciphertext"; + return false; + } + pvac_zero_proof proof = nullptr; + if (!circle_decode_zero_proof(zero_proof_b64, proof, error)) { + g_pvac.free_cipher(ct); + return false; + } + bool ok = pvac_verify_zero(g_pvac.pk(), ct, proof) != 0; + pvac_free_zero_proof(proof); + g_pvac.free_cipher(ct); + if (!ok) error = "zero proof verification failed"; + return ok; +} + +static bool circle_verify_bound_with_wallet(const std::string& ciphertext_b64, + const std::string& zero_proof_b64, + const std::string& amount_commitment_b64, + std::string& error) { + auto raw = octra::base64_decode(ciphertext_b64); + if (raw.empty()) { + error = "invalid ciphertext"; + return false; + } + auto commitment = octra::base64_decode(amount_commitment_b64); + if (commitment.size() != 32) { + error = "invalid amount commitment"; + return false; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + error = "invalid ciphertext"; + return false; + } + pvac_zero_proof proof = nullptr; + if (!circle_decode_zero_proof(zero_proof_b64, proof, error)) { + g_pvac.free_cipher(ct); + return false; + } + bool ok = pvac_verify_zero_bound(g_pvac.pk(), ct, proof, commitment.data()) != 0; + pvac_free_zero_proof(proof); + g_pvac.free_cipher(ct); + if (!ok) error = "bound proof verification failed"; + return ok; +} + +static bool circle_verify_range_with_wallet(const std::string& ciphertext_b64, + const std::string& range_proof_b64, + std::string& error) { + if (ciphertext_b64.rfind(octra::HFHE_PREFIX, 0) != 0) { + error = "invalid ciphertext"; + return false; + } + if (range_proof_b64.rfind(octra::RP_PREFIX, 0) != 0) { + error = "invalid range proof"; + return false; + } + auto raw = octra::base64_decode(ciphertext_b64.substr(strlen(octra::HFHE_PREFIX))); + if (raw.empty()) { + error = "invalid ciphertext"; + return false; + } + auto proof_raw = octra::base64_decode(range_proof_b64.substr(strlen(octra::RP_PREFIX))); + if (proof_raw.empty()) { + error = "invalid range proof"; + return false; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + error = "invalid ciphertext"; + return false; + } + bool ok = pvac_verify_range_any(g_pvac.pk(), ct, proof_raw.data(), proof_raw.size()) != 0; + g_pvac.free_cipher(ct); + if (!ok) error = "range proof verification failed"; + return ok; +} + +static std::string circle_hfhe_policy_hash(const json& policy) { + return octra::sha256_hex(policy.dump()); +} + +static std::string circle_hfhe_receipt_class_value(const json& policy) { + std::string receipt_class = policy.value("proof_receipt_class", ""); + if (!receipt_class.empty()) { + return receipt_class; + } + if (policy.value("require_receipt_transport_binding", false)) { + return "transport_bound"; + } + return "detached"; +} + +static bool circle_hfhe_receipt_required(const std::string& proof_kind) { + return proof_kind == "zero_receipt_v1" || + proof_kind == "range_receipt_v1" || + proof_kind == "bound_zero_receipt_v1"; +} + +static bool circle_hfhe_proof_requires_commitment(const std::string& proof_kind) { + return proof_kind == "bound_zero_v1" || proof_kind == "bound_zero_receipt_v1"; +} + +static bool circle_hfhe_proof_is_range(const std::string& proof_kind) { + return proof_kind == "range_v1" || proof_kind == "range_receipt_v1"; +} + +static bool circle_hfhe_receipt_transport_bound(const json& policy, + const std::string& intent_id, + std::string& error) { + const std::string receipt_class = circle_hfhe_receipt_class_value(policy); + if ((receipt_class == "transport_bound" || receipt_class == "relay_witnessed") && + intent_id.empty()) { + error = "intent_id required by circle hfhe receipt binding policy"; + return false; + } + return true; +} + +static bool circle_hfhe_receipt_signer_allowed(const std::string& circle_id, + const json& policy, + const std::string& caller_addr, + const std::string& signer_addr, + const std::string& intent_id, + std::string& error) { + auto info_r = circle_info_auth_rpc(circle_id); + if (!info_r.ok) { + error = info_r.error.empty() ? "circle info read failed" : info_r.error; + return false; + } + const std::string owner = info_r.result.value("owner", ""); + const std::string mode = policy.value("proof_receipt_signer_mode", "caller_self"); + const std::string receipt_class = circle_hfhe_receipt_class_value(policy); + std::vector active_relays; + if (mode == "active_relay" || mode == "owner_or_active_relay" || + receipt_class == "relay_witnessed") { + if (intent_id.empty()) { + error = "intent_id required by circle hfhe receipt signer policy"; + return false; + } + if (!circle_hfhe_active_relays(circle_id, intent_id, active_relays, error)) { + return false; + } + } + if (receipt_class == "relay_witnessed" && + std::find(active_relays.begin(), active_relays.end(), signer_addr) == active_relays.end()) { + error = "circle hfhe receipt signer must be an active relay"; + return false; + } + if (!circle_hfhe_mode_allows(mode, owner, signer_addr, caller_addr, active_relays)) { + error = "circle hfhe receipt signer is not allowed by policy"; + return false; + } + return true; +} + +static bool circle_hfhe_receipt_context(const std::string& circle_id, + const std::string& verb, + const std::string& caller_addr, + const std::string& key_id, + const std::string& intent_id, + const std::string& proof_kind, + const json& policy, + const std::string& ciphertext_b64, + const std::string& amount_commitment_b64, + octra::CircleHfheReceiptContext& ctx, + std::string& error) { + if (!circle_hfhe_receipt_transport_bound(policy, intent_id, error)) { + return false; + } + std::string ciphertext_hash = octra::circle_hfhe_hash_ciphertext(ciphertext_b64, error); + if (ciphertext_hash.empty()) { + return false; + } + std::string amount_commitment_hash; + if (!amount_commitment_b64.empty()) { + amount_commitment_hash = octra::circle_hfhe_hash_commitment(amount_commitment_b64, error); + if (amount_commitment_hash.empty()) { + return false; + } + } + ctx = { + circle_id, + caller_addr, + key_id, + intent_id, + verb, + proof_kind, + circle_hfhe_policy_hash(policy), + ciphertext_hash, + amount_commitment_hash + }; + return true; +} + +static bool circle_verify_proof_receipt(const std::string& circle_id, + const std::string& verb, + const std::string& caller_addr, + const std::string& key_id, + const std::string& intent_id, + const std::string& proof_kind, + const json& policy, + const std::string& ciphertext_b64, + const std::string& amount_commitment_b64, + const json& receipt, + std::string& error) { + octra::CircleHfheReceiptContext ctx; + if (!circle_hfhe_receipt_context( + circle_id, + verb, + caller_addr, + key_id, + intent_id, + proof_kind, + policy, + ciphertext_b64, + amount_commitment_b64, + ctx, + error)) { + return false; + } + if (!octra::verify_circle_hfhe_receipt_json(receipt, ctx, error)) { + return false; + } + return circle_hfhe_receipt_signer_allowed( + circle_id, + policy, + ctx.caller_addr, + receipt.value("signer_addr", ""), + ctx.intent_id, + error); +} + static json submit_tx(const octra::Transaction& tx) { json j; j["from"] = tx.from; @@ -194,10 +975,57 @@ static json submit_tx(const octra::Transaction& tx) { auto r = g_rpc.submit_tx(j); if (!r.ok) return err_json(r.error); json res; - res["tx_hash"] = r.result.value("tx_hash", ""); + std::string tx_hash = r.result.value("tx_hash", ""); + res["tx_hash"] = tx_hash; + if (!tx_hash.empty()) { + json cached; + cached["hash"] = tx_hash; + cached["from"] = tx.from; + cached["to_"] = tx.to_; + cached["amount_raw"] = tx.amount; + cached["op_type"] = tx.op_type.empty() ? "standard" : tx.op_type; + cached["status"] = "pending"; + cached["timestamp"] = tx.timestamp; + if (!tx.encrypted_data.empty()) cached["encrypted_data"] = tx.encrypted_data; + if (!tx.message.empty()) cached["message"] = tx.message; + if (g_txcache.is_open()) { + bool known = g_txcache.has_tx(tx_hash); + g_txcache.store_tx(g_wallet.addr, cached); + if (!known) { + int cached_total = g_txcache.get_total(g_wallet.addr); + g_txcache.set_total(g_wallet.addr, cached_total + 1); + } + } + history_runtime_clear(g_wallet.addr); + token_history_runtime_clear(g_wallet.addr); + } else { + history_runtime_clear(g_wallet.addr); + token_history_runtime_clear(g_wallet.addr); + } return res; } +static json submit_program_call_tx(const std::string& target, + const std::string& op_type, + const std::string& method, + const json& params, + const json& body, + const std::string& default_ou) { + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = target; + tx.amount = body.value("amount", "0"); + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, default_ou); + tx.timestamp = now_ts(); + tx.op_type = op_type; + tx.encrypted_data = method; + tx.message = params.dump(); + sign_tx_fields(tx); + return submit_tx(tx); +} + static void ensure_pubkey_registered(const std::string& addr, const uint8_t sk[64], const std::string& pub_b64) { auto vr = g_rpc.get_view_pubkey(addr); if (vr.ok && vr.result.is_object() && vr.result.contains("view_pubkey") @@ -275,12 +1103,12 @@ static EncBalResult get_encrypted_balance() { } static void init_wallet_subsystems() { + const char* env_rpc = std::getenv("OCTRA_RPC_URL"); + if (env_rpc && *env_rpc) g_wallet.rpc_url = env_rpc; g_rpc.set_url(g_wallet.rpc_url); - ensure_pubkey_registered(g_wallet.addr, g_wallet.sk, g_wallet.pub_b64); g_pvac_ok = g_pvac.init(g_wallet.priv_b64); if (g_pvac_ok) { fprintf(stderr, "pvac initialized\n"); - ensure_pvac_registered(); } else { fprintf(stderr, "pvac init failed (libpvac not loaded?)\n"); } @@ -288,6 +1116,7 @@ static void init_wallet_subsystems() { std::string cache_path = "data/txcache_" + g_wallet.addr.substr(3, 8); if (g_txcache.open(cache_path)) { fprintf(stderr, "txcache opened: %s\n", cache_path.c_str()); + g_txcache.ensure_schema("v2_addr_idx_slim_history"); g_txcache.ensure_rpc(g_wallet.rpc_url); } else { fprintf(stderr, "txcache open failed: %s\n", cache_path.c_str()); @@ -302,6 +1131,23 @@ static void init_wallet_subsystems() { return; \ } +static bool wallet_pin_ok(const json& body, httplib::Response& res) { + std::string pin = body.value("pin", ""); + if (pin.empty()) { + res.status = 403; + res.set_content(err_json("PIN required to authorize this operation").dump(), "application/json"); + return false; + } + try { + octra::load_wallet_encrypted(g_wallet_path, pin); + } catch (...) { + res.status = 403; + res.set_content(err_json("wrong PIN").dump(), "application/json"); + return false; + } + return true; +} + #define PVAC_GUARD \ if (!g_pvac_ok) { \ res.status = 500; \ @@ -340,19 +1186,64 @@ int main(int argc, char** argv) { httplib::Server svr; svr.set_read_timeout(300, 0); svr.set_write_timeout(300, 0); - - - // svr.set_keep_alive_timeout(5); svr.set_keep_alive_max_count(100); - // + svr.set_payload_max_length(32u * 1024u * 1024u); + + svr.set_pre_routing_handler([port](const httplib::Request& req, httplib::Response& res) { + std::string reason; + if (!webcli_request_allowed(req, port, reason)) { + res.status = 403; + res.set_header("Content-Type", "application/json"); + res.set_content(err_json("cross-origin webcli request blocked").dump(), "application/json"); + fprintf(stderr, "[csrf] blocked %s %s origin=%s sec-fetch-site=%s host=%s reason=%s\n", + req.method.c_str(), req.path.c_str(), + req.get_header_value("Origin", "-").c_str(), + req.get_header_value("Sec-Fetch-Site", "-").c_str(), + req.get_header_value("Host", "-").c_str(), + reason.c_str()); + return httplib::Server::HandlerResponse::Handled; + } - svr.set_post_routing_handler([](const httplib::Request&, httplib::Response& res) { - res.set_header("X-Frame-Options", "DENY"); + if (starts_with(req.path, "/api/") && req.method == "OPTIONS") { + set_same_origin_cors_if_needed(req, res, port); + res.status = 204; + res.set_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + res.set_header("Access-Control-Allow-Headers", "Content-Type"); + res.set_header("Access-Control-Max-Age", "600"); + return httplib::Server::HandlerResponse::Handled; + } + + return httplib::Server::HandlerResponse::Unhandled; + }); + + svr.set_post_routing_handler([port](const httplib::Request& req, httplib::Response& res) { + bool is_circle_resource = req.path.rfind("/oct/", 0) == 0; + if (!is_circle_resource) { + res.set_header("X-Frame-Options", "DENY"); + } res.set_header("X-Content-Type-Options", "nosniff"); - res.set_header("Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"); + if (is_circle_resource) { + res.set_header("Content-Security-Policy", + "default-src 'self' data: blob:; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob: https:; " + "connect-src 'none'; " + "object-src 'none'; " + "base-uri 'self'"); + } else { + res.set_header("Content-Security-Policy", + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "connect-src 'self' http://127.0.0.1:* http://178.62.60.204:8090 https://*.octra.network https://*.publicnode.com https://*.infura.io wss: ws:; " + "frame-ancestors 'none'"); + } res.set_header("Cache-Control", "no-store"); + res.headers.erase("Access-Control-Allow-Origin"); + set_same_origin_cors_if_needed(req, res, port); }); svr.set_mount_point("/", "static"); @@ -416,9 +1307,9 @@ int main(int argc, char** argv) { std::string addr_hint = body.value("addr", ""); std::string file_hint = body.value("file", ""); std::string name_hint = body.value("name", ""); - if (pin.size() != 6 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) { + if (pin.empty()) { res.status = 400; - res.set_content(err_json("pin must be exactly 6 digits").dump(), "application/json"); + res.set_content(err_json("pin required").dump(), "application/json"); return; } @@ -518,10 +1409,13 @@ int main(int argc, char** argv) { return; } std::string pin = body.value("pin", ""); - if (pin.size() != 6 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) { - res.status = 400; - res.set_content(err_json("pin must be exactly 6 digits").dump(), "application/json"); - return; + { + std::string verr = octra::validate_pin(pin); + if (!verr.empty()) { + res.status = 400; + res.set_content(err_json(verr).dump(), "application/json"); + return; + } } std::string name = body.value("name", "wallet"); std::string mnemonic; @@ -548,7 +1442,7 @@ int main(int argc, char** argv) { } g_pin = pin; octra::try_mlock(&g_pin[0], g_pin.size()); - fprintf(stderr, "wallet created: %s → %s\n", g_wallet.addr.c_str(), g_wallet_path.c_str()); + fprintf(stderr, "wallet created: %s -> %s\n", g_wallet.addr.c_str(), g_wallet_path.c_str()); init_wallet_subsystems(); } catch (const std::exception& e) { res.status = 500; @@ -580,10 +1474,13 @@ int main(int argc, char** argv) { res.set_content(err_json("priv or mnemonic required").dump(), "application/json"); return; } - if (pin.size() != 6 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) { - res.status = 400; - res.set_content(err_json("pin must be exactly 6 digits").dump(), "application/json"); - return; + { + std::string verr = octra::validate_pin(pin); + if (!verr.empty()) { + res.status = 400; + res.set_content(err_json(verr).dump(), "application/json"); + return; + } } std::string name = body.value("name", "imported"); bool is_mnemonic = false; @@ -596,7 +1493,7 @@ int main(int argc, char** argv) { { std::string addr_v2 = octra::addr_from_mnemonic(mn, 2); std::string addr_v1 = octra::addr_from_mnemonic(mn, 1); - std::string rpc_url = g_wallet_loaded ? g_wallet.rpc_url : "http://46.101.86.250:8080"; + std::string rpc_url = g_wallet_loaded ? g_wallet.rpc_url : "https://octra.network/rpc"; octra::RpcClient probe; probe.set_url(rpc_url); auto r2 = probe.get_balance(addr_v2); @@ -667,9 +1564,11 @@ int main(int argc, char** argv) { j["public_key"] = g_wallet.pub_b64; j["rpc_url"] = g_wallet.rpc_url; j["explorer_url"] = g_wallet.explorer_url; + j["bridge_signer_url"] = g_wallet.bridge_signer_url; j["has_master_seed"] = g_wallet.has_master_seed(); j["hd_index"] = g_wallet.hd_index; j["hd_version"] = g_wallet.hd_version; + res.set_header("Access-Control-Allow-Origin", "*"); res.set_content(j.dump(), "application/json"); }); @@ -706,9 +1605,9 @@ int main(int argc, char** argv) { } std::string addr = body.value("addr", ""); std::string pin = body.value("pin", ""); - if (addr.empty() || pin.size() != 6) { + if (addr.empty() || pin.empty()) { res.status = 400; - res.set_content(err_json("addr and 6-digit pin required").dump(), "application/json"); + res.set_content(err_json("addr and pin required").dump(), "application/json"); return; } auto entries = octra::load_manifest(); @@ -769,9 +1668,9 @@ int main(int argc, char** argv) { } std::string pin = body.value("pin", ""); std::string name = body.value("name", ""); - if (pin.size() != 6 || !std::all_of(pin.begin(), pin.end(), ::isdigit)) { + if (pin.empty()) { res.status = 400; - res.set_content(err_json("6-digit pin required").dump(), "application/json"); + res.set_content(err_json("pin required").dump(), "application/json"); return; } if (pin != g_pin) { @@ -916,6 +1815,7 @@ int main(int argc, char** argv) { } if (g_pvac_foreign) j["pvac_foreign"] = true; + res.set_header("Access-Control-Allow-Origin", "*"); res.set_content(j.dump(), "application/json"); }); @@ -924,6 +1824,13 @@ int main(int argc, char** argv) { int limit = 20, offset = 0; if (req.has_param("limit")) limit = std::stoi(req.get_param_value("limit")); if (req.has_param("offset")) offset = std::stoi(req.get_param_value("offset")); + if (limit < 1) limit = 1; + if (limit > 500) limit = 500; + if (offset < 0) offset = 0; + const std::string addr = g_wallet.addr; + const double now = now_ts(); + const std::string page_key = std::to_string(limit) + ":" + std::to_string(offset); + HistoryRuntimeState runtime = history_runtime_get(addr); auto convert_row = [](const json& row, const std::string& status) -> json { json tx; @@ -931,95 +1838,312 @@ int main(int argc, char** argv) { tx["from"] = row.value("from", ""); tx["to_"] = row.value("to", row.value("to_", "")); tx["amount_raw"] = row.value("amount", row.value("amount_raw", "0")); - tx["op_type"] = row.value("op_type", "standard"); + const std::string op_type = row.value("op_type", "standard"); + tx["op_type"] = op_type; tx["status"] = status; if (row.contains("timestamp")) tx["timestamp"] = row["timestamp"]; - if (row.contains("encrypted_data") && row["encrypted_data"].is_string()) - tx["encrypted_data"] = row["encrypted_data"]; - if (row.contains("message") && row["message"].is_string()) - tx["message"] = row["message"]; + const std::string enc_tag = row.value("encrypted_data", ""); + if (op_type == "call" && enc_tag == "transfer") { + tx["encrypted_data"] = "transfer"; + if (row.contains("message") && row["message"].is_string()) + tx["message"] = row["message"]; + } if (row.contains("reason") && row["reason"].is_string()) tx["reject_reason"] = row["reason"]; return tx; }; json txs = json::array(); + json rejected = json::array(); + int total = 0; + bool served = false; + + auto runtime_page_it = runtime.pages.find(page_key); + auto runtime_page_ts_it = runtime.page_ts.find(page_key); + const bool runtime_page_present = runtime_page_it != runtime.pages.end(); + const bool runtime_top_fresh = offset == 0 + && runtime_page_present + && runtime_page_ts_it != runtime.page_ts.end() + && (now - runtime_page_ts_it->second) < 10.0; + const bool runtime_page_cached = offset > 0 && runtime_page_present; + if (runtime_top_fresh || runtime_page_cached) { + txs = runtime_page_it->second; + rejected = offset == 0 ? runtime.rejected : json::array(); + total = runtime.total; + served = true; + } if (g_txcache.is_open()) { - auto r = g_rpc.get_txs_by_address(g_wallet.addr, 1, 0); - int node_total = 0; - json rejected_buf = json::array(); - if (r.ok && r.result.is_object()) { - node_total = r.result.value("total", 0); - if (r.result.contains("rejected")) - for (auto& row : r.result["rejected"]) - rejected_buf.push_back(convert_row(row, "rejected")); - } - int cached = g_txcache.get_total(g_wallet.addr); - if (node_total > cached) { - int delta = node_total - cached; - auto dr = g_rpc.get_txs_by_address(g_wallet.addr, delta, 0); - if (dr.ok && dr.result.is_object() && dr.result.contains("transactions")) { - json to_store = json::array(); - for (auto& row : dr.result["transactions"]) { - std::string h = row.value("hash", ""); - if (!h.empty() && !g_txcache.has_tx(h)) - to_store.push_back(convert_row(row, "confirmed")); + const int cached_total = g_txcache.get_total(addr); + const bool top_page_fresh = offset == 0 + && cached_total > 0 + && (now - runtime.last_top_refresh_ts) < 10.0; + const bool page_cached = cached_total > offset; + if (!served && ((offset > 0 && page_cached) || top_page_fresh)) { + json cached_page = g_txcache.load_page(addr, limit, offset); + const bool cached_page_ok = !cached_page.empty() || cached_total == 0; + if (cached_page_ok) { + txs = cached_page; + rejected = offset == 0 ? runtime.rejected : json::array(); + total = cached_total; + runtime.pages[page_key] = txs; + runtime.page_ts[page_key] = now; + runtime.total = total; + history_runtime_put(addr, runtime); + served = true; + } + } + if (!served && offset > 0 && page_cached) { + txs = g_txcache.load_page(addr, limit, offset); + rejected = offset == 0 ? runtime.rejected : json::array(); + total = cached_total; + runtime.pages[page_key] = txs; + runtime.page_ts[page_key] = now; + runtime.total = total; + history_runtime_put(addr, runtime); + served = true; + } + } + if (!served) { + int fetch_limit = limit; + if (offset == 0) fetch_limit = std::max(limit, 50); + auto fresh = g_rpc.get_txs_by_address(addr, fetch_limit, offset); + if (fresh.ok && fresh.result.is_object()) { + total = fresh.result.value("total", g_txcache.is_open() ? g_txcache.get_total(addr) : 0); + if (total < 0) total = 0; + if (fresh.result.contains("transactions")) { + json fetched_rows = json::array(); + for (auto& row : fresh.result["transactions"]) { + json converted = convert_row(row, "confirmed"); + fetched_rows.push_back(converted); } - if (!to_store.empty()) { - g_txcache.store_txs(to_store); - g_txcache.set_total(g_wallet.addr, cached + (int)to_store.size()); + if (g_txcache.is_open()) { + if (!fetched_rows.empty()) g_txcache.store_txs(addr, fetched_rows); + g_txcache.set_total(addr, total); } - rejected_buf = json::array(); - if (dr.result.contains("rejected")) - for (auto& row : dr.result["rejected"]) - rejected_buf.push_back(convert_row(row, "rejected")); + for (int i = 0; i < static_cast(fetched_rows.size()) && i < limit; ++i) + txs.push_back(fetched_rows[i]); } + if (fresh.result.contains("rejected")) + for (auto& row : fresh.result["rejected"]) + rejected.push_back(convert_row(row, "rejected")); + if (offset == 0) { + runtime.last_top_refresh_ts = now; + runtime.rejected = rejected; + } + runtime.pages[page_key] = txs; + runtime.page_ts[page_key] = now; + runtime.total = total; + history_runtime_put(addr, runtime); } - json cached_txs = g_txcache.load_page(limit, offset); - for (auto& tx : cached_txs) txs.push_back(tx); - for (auto& tx : rejected_buf) txs.push_back(tx); - } else { - auto r = g_rpc.get_txs_by_address(g_wallet.addr, limit, offset); - if (r.ok && r.result.is_object()) { - if (r.result.contains("transactions")) - for (auto& row : r.result["transactions"]) - txs.push_back(convert_row(row, "confirmed")); - if (r.result.contains("rejected")) - for (auto& row : r.result["rejected"]) - txs.push_back(convert_row(row, "rejected")); - } + } + + if (reconcile_history_rows(addr, txs)) { + runtime.pages[page_key] = txs; + runtime.page_ts[page_key] = now; + history_runtime_put(addr, runtime); } json j; j["transactions"] = txs; + j["rejected"] = rejected; + j["count"] = txs.size(); + j["offset"] = offset; + j["limit"] = limit; + j["total"] = total; + j["has_more"] = total > (offset + static_cast(txs.size())); res.set_content(j.dump(), "application/json"); }); - svr.Get("/api/contract-storage", [](const httplib::Request& req, httplib::Response& res) { - auto addr = req.get_param_value("address"); - auto key = req.get_param_value("key"); - if (addr.empty() || key.empty()) { - res.status = 400; - res.set_content(err_json("address and key required").dump(), "application/json"); - return; + svr.Get("/api/token-history", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + int limit = 50, offset = 0; + bool force = false; + if (req.has_param("limit")) limit = std::stoi(req.get_param_value("limit")); + if (req.has_param("offset")) offset = std::stoi(req.get_param_value("offset")); + if (req.has_param("force")) { + auto v = req.get_param_value("force"); + force = (v == "1" || v == "true"); } - auto r = g_rpc.contract_storage(addr, key); - json j; - if (r.ok && r.result.contains("value") && !r.result["value"].is_null()) - j["value"] = r.result["value"]; - else - j["value"] = nullptr; - res.set_content(j.dump(), "application/json"); - }); + if (limit < 1) limit = 1; + if (limit > 500) limit = 500; + if (offset < 0) offset = 0; + const std::string addr = g_wallet.addr; + const double now = now_ts(); + + auto classify = [&](const json& tx) -> std::pair { + if (tx.value("op_type", "") != "call") return {false, false}; + if (tx.value("encrypted_data", "") != "transfer") return {false, false}; + bool incoming = false; + try { + if (tx.contains("message") && tx["message"].is_string()) { + auto parsed = json::parse(tx["message"].get()); + if (parsed.is_array() && !parsed.empty() && parsed[0].is_string()) { + std::string recipient = parsed[0].get(); + if (recipient == addr) incoming = true; + } + } + } catch (...) {} + return {true, incoming}; + }; + + if (!force) { + if (auto cached = token_history_runtime_get(addr)) { + if (now - cached->ts < 30.0) { + json page = json::array(); + for (int i = offset; i < static_cast(cached->rows.size()) && static_cast(page.size()) < limit; ++i) + page.push_back(cached->rows[i]); + json j; + j["transactions"] = page; + j["count"] = page.size(); + j["offset"] = offset; + j["limit"] = limit; + j["total"] = cached->rows.size(); + j["has_more"] = offset + static_cast(page.size()) < static_cast(cached->rows.size()); + j["incoming"] = cached->incoming; + j["outgoing"] = cached->outgoing; + res.set_content(j.dump(), "application/json"); + return; + } + } + } + + auto direct = g_rpc.get_token_txs_by_address(addr, limit, offset); + if (direct.ok && direct.result.is_object()) { + TokenHistoryRuntimeState state; + state.ts = now; + state.rows = direct.result.value("transactions", json::array()); + state.incoming = direct.result.value("incoming", 0); + state.outgoing = direct.result.value("outgoing", 0); + token_history_runtime_put(addr, state); + res.set_content(direct.result.dump(), "application/json"); + return; + } + + json rows = json::array(); + int incoming = 0; + int outgoing = 0; + std::set seen_hashes; + std::vector token_addrs; + + auto toks = g_rpc.tokens_by_address(addr); + if (toks.ok && toks.result.is_array()) { + for (auto& tok : toks.result) { + if (!tok.is_object()) continue; + std::string token_addr = tok.value("address", ""); + if (!token_addr.empty()) + token_addrs.push_back(token_addr); + } + } + + for (const auto& token_addr : token_addrs) { + int batch_offset = 0; + bool keep_going = true; + while (keep_going) { + auto batch = g_rpc.get_txs_by_address(token_addr, 200, batch_offset); + if (!batch.ok || !batch.result.is_object()) break; + auto txs = batch.result.value("transactions", json::array()); + for (auto& tx : txs) { + auto [is_token, is_incoming] = classify(tx); + if (!is_token) continue; + std::string tx_token = tx.value("to", tx.value("to_", "")); + if (tx_token != token_addr) continue; + bool is_outgoing = tx.value("from", "") == addr; + if (!is_incoming && !is_outgoing) continue; + std::string hash = tx.value("hash", ""); + if (!hash.empty() && !seen_hashes.insert(hash).second) continue; + if (is_incoming) incoming++; + else outgoing++; + rows.push_back(tx); + } + bool has_more = batch.result.value("has_more", false); + if (!has_more || txs.empty()) keep_going = false; + batch_offset += static_cast(txs.size()); + if (batch_offset >= 100000) keep_going = false; + } + } + + if (!rows.empty()) { + std::vector sorted; + sorted.reserve(rows.size()); + for (auto& row : rows) sorted.push_back(row); + std::sort(sorted.begin(), sorted.end(), [](const json& a, const json& b) { + return a.value("timestamp", 0.0) > b.value("timestamp", 0.0); + }); + rows = json::array(); + for (const auto& row : sorted) rows.push_back(row); + } + + TokenHistoryRuntimeState state; + state.ts = now; + state.rows = rows; + state.incoming = incoming; + state.outgoing = outgoing; + token_history_runtime_put(addr, state); + + json page = json::array(); + for (int i = offset; i < static_cast(rows.size()) && static_cast(page.size()) < limit; ++i) + page.push_back(rows[i]); + json j; + j["transactions"] = page; + j["count"] = page.size(); + j["offset"] = offset; + j["limit"] = limit; + j["total"] = rows.size(); + j["has_more"] = offset + static_cast(page.size()) < static_cast(rows.size()); + j["incoming"] = incoming; + j["outgoing"] = outgoing; + res.set_content(j.dump(), "application/json"); + }); + + svr.Get("/api/contract-storage", [](const httplib::Request& req, httplib::Response& res) { + auto addr = req.get_param_value("address"); + auto key = req.get_param_value("key"); + auto limit = req.get_param_value("limit"); + if (addr.empty() || key.empty()) { + res.status = 400; + res.set_content(err_json("address and key required").dump(), "application/json"); + return; + } + auto r = g_rpc.contract_storage(addr, key, limit); + json j; + if (r.ok && r.result.contains("value") && !r.result["value"].is_null()) { + j["value"] = r.result["value"]; + j["size"] = r.result.value("size", 0); + j["truncated"] = r.result.value("truncated", false); + j["limit"] = r.result.value("limit", 0); + } else { + j["value"] = nullptr; + j["size"] = 0; + j["truncated"] = false; + } + res.set_content(j.dump(), "application/json"); + }); svr.Get("/api/fee", [](const httplib::Request&, httplib::Response& res) { + double now = now_ts(); + { + std::lock_guard lock(g_fee_mtx); + if (!g_fee_cache.empty() && (now - g_fee_cache_ts) < 30.0) { + res.set_content(g_fee_cache.dump(), "application/json"); + return; + } + } + std::vector ops = {"standard", "encrypt", "decrypt", "stealth", "claim", "deploy", "call", "program_exec", "multi_exec"}; + std::vector methods(ops.size(), "octra_recommendedFee"); + std::vector params; + params.reserve(ops.size()); + for (auto& op : ops) params.push_back(nlohmann::json::array({op})); + auto results = g_rpc.call_batch(methods, params, 10); json fees; - std::vector ops = {"standard", "encrypt", "decrypt", "stealth", "claim", "deploy", "call"}; - for (auto& op : ops) { - auto r = g_rpc.call("octra_recommendedFee", nlohmann::json::array({op}), 5); - if (r.ok) fees[op] = r.result; - else fees[op] = {{"minimum", "1000"}, {"recommended", "1000"}, {"fast", "2000"}}; + for (size_t i = 0; i < ops.size(); ++i) { + if (i < results.size() && results[i].ok) fees[ops[i]] = results[i].result; + else fees[ops[i]] = {{"minimum", "1000"}, {"recommended", "1000"}, {"fast", "2000"}}; + } + { + std::lock_guard lock(g_fee_mtx); + g_fee_cache = fees; + g_fee_cache_ts = now; } res.set_content(fees.dump(), "application/json"); }); @@ -1033,6 +2157,7 @@ int main(int argc, char** argv) { res.set_content(err_json("invalid json").dump(), "application/json"); return; } + if (!wallet_pin_ok(body, res)) return; std::string to = body.value("to", ""); if (to.empty() || to.size() != 47 || to.substr(0, 3) != "oct") { res.status = 400; @@ -1065,6 +2190,9 @@ int main(int argc, char** argv) { svr.Post("/api/key_switch", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD std::lock_guard lock(g_mtx); + json body; + try { body = json::parse(req.body); } catch (...) { body = json::object(); } + if (!wallet_pin_ok(body, res)) return; auto nb = get_nonce_balance(); octra::Transaction tx; tx.from = g_wallet.addr; @@ -1126,6 +2254,7 @@ int main(int argc, char** argv) { res.set_content(err_json("invalid amount (max 6 decimals, no extra dots)").dump(), "application/json"); return; } + if (!wallet_pin_ok(body, res)) return; ensure_pvac_registered(); uint8_t seed[32]; octra::random_bytes(seed, 32); @@ -1179,6 +2308,7 @@ int main(int argc, char** argv) { res.set_content(err_json("invalid amount (max 6 decimals, no extra dots)").dump(), "application/json"); return; } + if (!wallet_pin_ok(body, res)) return; auto eb = get_encrypted_balance(); if (eb.decrypted < raw) { res.status = 400; @@ -1255,14 +2385,13 @@ int main(int argc, char** argv) { svr.Post("/api/stealth/send", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD - std::lock_guard lock(g_mtx); - PVAC_GUARD json body; try { body = json::parse(req.body); } catch (...) { res.status = 400; res.set_content(err_json("invalid json").dump(), "application/json"); return; } + if (!wallet_pin_ok(body, res)) return; std::string to = body.value("to", ""); int64_t raw = parse_amount_raw(body); if (to.empty() || to.size() != 47 || to.substr(0, 3) != "oct" || raw <= 0) { @@ -1271,20 +2400,46 @@ int main(int argc, char** argv) { return; } - auto vr = g_rpc.get_view_pubkey(to); - if (!vr.ok || !vr.result.is_object() || !vr.result.contains("view_pubkey") - || vr.result["view_pubkey"].is_null() || !vr.result["view_pubkey"].is_string()) { + std::string from_addr, from_pub_b64, eb_sig; + { + std::lock_guard lock(g_mtx); + from_addr = g_wallet.addr; + from_pub_b64 = g_wallet.pub_b64; + eb_sig = octra::sign_balance_request(from_addr, g_wallet.sk); + } + + std::vector their_signing_pk; + if (auto cached = pk_cache_get(to)) { + their_signing_pk = *cached; + } else { + auto pr = g_rpc.get_public_key(to); + if (!pr.ok || !pr.result.is_object() || !pr.result.contains("public_key") + || pr.result["public_key"].is_null() || !pr.result["public_key"].is_string()) { + res.status = 400; + res.set_content(err_json("recipient has no public key registered").dump(), "application/json"); + return; + } + their_signing_pk = octra::base64_decode(pr.result["public_key"].get()); + if (their_signing_pk.size() != 32) { + res.status = 400; + res.set_content(err_json("invalid signing pubkey size").dump(), "application/json"); + return; + } + pk_cache_put(to, their_signing_pk); + } + if (their_signing_pk.size() != 32 || octra::derive_address(their_signing_pk.data()) != to) { + pk_cache_erase(to); res.status = 400; - res.set_content(err_json("recipient has no view pubkey - they must register pvac first").dump(), "application/json"); + res.set_content(err_json("recipient public key does not match address").dump(), "application/json"); return; } - std::string their_vpub_b64 = vr.result["view_pubkey"].get(); - auto their_vpub_raw = octra::base64_decode(their_vpub_b64); - if (their_vpub_raw.size() != 32) { + uint8_t their_vpub[32]; + if (!octra::ed25519_pub_to_x25519(their_signing_pk.data(), their_vpub)) { res.status = 400; - res.set_content(err_json("invalid view pubkey").dump(), "application/json"); + res.set_content(err_json("ed25519->x25519 conversion failed").dump(), "application/json"); return; } + std::vector their_vpub_raw(their_vpub, their_vpub + 32); try { @@ -1301,18 +2456,39 @@ int main(int argc, char** argv) { auto claim_sec = octra::compute_claim_secret(shared); auto claim_pub = octra::compute_claim_pub(claim_sec, to); - steps.push_back("[3/7] checking encrypted balance"); - auto eb = get_encrypted_balance(); - if (eb.decrypted < raw) { + steps.push_back("[3/8] checking encrypted balance"); + auto eb_r = g_rpc.get_encrypted_balance(from_addr, eb_sig, from_pub_b64); + if (!eb_r.ok || !eb_r.result.is_object()) { + res.status = 500; + res.set_content(err_json("failed to fetch encrypted balance").dump(), "application/json"); + return; + } + std::string eb_cipher = eb_r.result.value("cipher", "0"); + if (eb_cipher.empty() || eb_cipher == "0") { + res.status = 400; + res.set_content(err_json("no encrypted balance available").dump(), "application/json"); + return; + } + + std::lock_guard lock(g_mtx); + PVAC_GUARD + if (!g_wallet_loaded || g_wallet.addr != from_addr) { + res.status = 409; + res.set_content(err_json("wallet state changed during send").dump(), "application/json"); + return; + } + + int64_t eb_decrypted = g_pvac_ok ? g_pvac.get_balance(eb_cipher) : 0; + if (eb_decrypted < raw) { res.status = 400; char buf[128]; snprintf(buf, sizeof(buf), "insufficient encrypted balance: have %ld, need %ld", - (long)eb.decrypted, (long)raw); + (long)eb_decrypted, (long)raw); res.set_content(err_json(buf).dump(), "application/json"); return; } - steps.push_back("[4/7] FHE encrypt delta (PVAC-HFHE)"); + steps.push_back("[4/8] FHE encrypt delta (PVAC-HFHE)"); ensure_pvac_registered(); uint8_t r_blind[32]; octra::random_bytes(r_blind, 32); @@ -1324,10 +2500,10 @@ int main(int argc, char** argv) { auto commitment = g_pvac.commit_ct(ct_delta); std::string commitment_b64 = octra::base64_encode(commitment.data(), 32); - steps.push_back("[5/7] range proofs (parallel) - Bulletproofs R1CS"); - pvac_cipher current_ct = g_pvac.decode_cipher(eb.cipher); + steps.push_back("[5/8] range proofs (parallel) - Bulletproofs R1CS"); + pvac_cipher current_ct = g_pvac.decode_cipher(eb_cipher); pvac_cipher new_ct = g_pvac.ct_sub(current_ct, ct_delta); - uint64_t new_val = (uint64_t)(eb.decrypted - raw); + uint64_t new_val = (uint64_t)(eb_decrypted - raw); pvac_range_proof rp_delta = nullptr; pvac_range_proof rp_bal = nullptr; @@ -1341,19 +2517,24 @@ int main(int argc, char** argv) { t_rp_delta.join(); t_rp_bal.join(); - steps.push_back("[6/7] encoding proofs"); + steps.push_back("[6/8] encoding proofs"); std::string rp_delta_str = g_pvac.encode_range_proof(rp_delta); std::string rp_bal_str = g_pvac.encode_range_proof(rp_bal); g_pvac.free_range_proof(rp_delta); g_pvac.free_range_proof(rp_bal); - g_pvac.free_cipher(ct_delta); - g_pvac.free_cipher(current_ct); - g_pvac.free_cipher(new_ct); steps.push_back("[7/8] Pedersen commitment + AES-GCM envelope"); auto amt_commit = g_pvac.pedersen_commit((uint64_t)raw, r_blind); std::string amt_commit_b64 = octra::base64_encode(amt_commit.data(), 32); + pvac_zero_proof send_zkp = g_pvac.make_zero_proof_bound(ct_delta, (uint64_t)raw, r_blind); + std::string send_zp_str = g_pvac.encode_zero_proof(send_zkp); + g_pvac.free_zero_proof(send_zkp); + + g_pvac.free_cipher(ct_delta); + g_pvac.free_cipher(current_ct); + g_pvac.free_cipher(new_ct); + steps.push_back("[8/8] building stealth transaction"); json stealth_data; stealth_data["version"] = 5; @@ -1366,6 +2547,7 @@ int main(int argc, char** argv) { stealth_data["enc_amount"] = enc_amount; stealth_data["claim_pub"] = octra::hex_encode(claim_pub.data(), 32); stealth_data["amount_commitment"] = amt_commit_b64; + stealth_data["send_zero_proof"] = send_zp_str; auto bi = get_nonce_balance(); int nonce = bi.nonce; octra::Transaction tx; @@ -1396,9 +2578,12 @@ int main(int argc, char** argv) { svr.Get("/api/stealth/scan", [](const httplib::Request&, httplib::Response& res) { WALLET_GUARD - std::lock_guard lock(g_mtx); - uint8_t view_sk[32], view_pk[32]; - octra::derive_view_keypair(g_wallet.sk, view_sk, view_pk); + uint8_t view_sk[32]; + { + uint8_t view_pk[32]; + std::lock_guard lock(g_mtx); + octra::derive_view_keypair(g_wallet.sk, view_sk, view_pk); + } auto r = g_rpc.get_stealth_outputs(0); json outputs = json::array(); if (!r.ok || !r.result.is_object() || !r.result.contains("outputs")) { @@ -1583,6 +2768,12 @@ int main(int argc, char** argv) { j["reject_reason"] = t["error"].value("reason", ""); j["reject_type"] = t["error"].value("type", ""); } + if (g_txcache.is_open() && g_wallet_loaded) { + const std::string from = j.value("from", ""); + const std::string to = j.value("to_", ""); + if (from == g_wallet.addr || to == g_wallet.addr) + g_txcache.store_tx(g_wallet.addr, j); + } res.set_content(j.dump(), "application/json"); }); @@ -1596,6 +2787,7 @@ int main(int argc, char** argv) { j["view_pubkey"] = octra::base64_encode(view_pk, 32); j["has_master_seed"] = g_wallet.has_master_seed(); octra::secure_zero(view_sk, 32); + res.set_header("Access-Control-Allow-Origin", "*"); res.set_content(j.dump(), "application/json"); }); @@ -1608,6 +2800,12 @@ int main(int argc, char** argv) { return; } std::string pin = body.value("pin", ""); + std::string confirm = body.value("confirm", ""); + if (confirm != "I_UNDERSTAND_KEY_EXPORT_RISK") { + res.status = 403; + res.set_content(err_json("missing or invalid confirmation; pass confirm=\"I_UNDERSTAND_KEY_EXPORT_RISK\" in body").dump(), "application/json"); + return; + } try { octra::load_wallet_encrypted(g_wallet_path, pin); } catch (...) { res.status = 403; res.set_content(err_json("wrong PIN").dump(), "application/json"); @@ -1616,6 +2814,7 @@ int main(int argc, char** argv) { json j; j["private_key"] = g_wallet.priv_b64; j["mnemonic"] = g_wallet.mnemonic; + j["warning"] = "treat these values as plaintext secret; never paste into shared transcripts, screen-shares, or untrusted machines"; res.set_content(j.dump(), "application/json"); }); @@ -1676,6 +2875,8 @@ int main(int argc, char** argv) { j["version"] = r.result.value("version", ""); if (r.result.contains("abi")) j["abi"] = r.result["abi"]; if (r.result.contains("disasm")) j["disasm"] = r.result["disasm"]; + if (r.result.contains("verification")) j["verification"] = r.result["verification"]; + if (r.result.contains("certificate")) j["certificate"] = r.result["certificate"]; res.set_content(j.dump(), "application/json"); } catch (const std::exception& ex) { res.status = 500; @@ -1717,6 +2918,8 @@ int main(int argc, char** argv) { j["version"] = r.result.value("version", ""); if (r.result.contains("abi")) j["abi"] = r.result["abi"]; if (r.result.contains("disasm")) j["disasm"] = r.result["disasm"]; + if (r.result.contains("verification")) j["verification"] = r.result["verification"]; + if (r.result.contains("certificate")) j["certificate"] = r.result["certificate"]; res.set_content(j.dump(), "application/json"); } catch (const std::exception& ex) { res.status = 500; @@ -1879,21 +3082,58 @@ int main(int argc, char** argv) { res.set_content(result.dump(), "application/json"); }); - svr.Get("/api/contract/view", [](const httplib::Request& req, httplib::Response& res) { + svr.Get("/api/program/info", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); std::string addr = req.get_param_value("address"); - std::string method = req.get_param_value("method"); - if (addr.empty() || method.empty()) { + if (circle_id.empty() && addr.empty()) { res.status = 400; - res.set_content(err_json("address and method required").dump(), "application/json"); + res.set_content(err_json("address or circle_id required").dump(), "application/json"); return; } - std::string params_str = req.get_param_value("params"); - json params = json::array(); - if (!params_str.empty()) { - try { params = json::parse(params_str); } catch (...) {} + auto r = circle_id.empty() + ? g_rpc.vm_contract(addr) + : g_rpc.circle_program_info_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_program_info", circle_id)); + if (!r.ok) { + res.status = 404; + res.set_content(err_json(r.error).dump(), "application/json"); + return; } - auto r = g_rpc.contract_call_view(addr, method, params, g_wallet.addr); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Post("/api/program/view", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string addr = body.value("address", ""); + std::string method = body.value("method", ""); + if ((circle_id.empty() && addr.empty()) || method.empty()) { + res.status = 400; + res.set_content(err_json("method and address or circle_id required").dump(), "application/json"); + return; + } + json params = json::array(); + if (body.contains("params")) params = body["params"]; + auto r = circle_id.empty() + ? g_rpc.contract_call_view(addr, method, params, g_wallet.addr) + : g_rpc.circle_view_auth( + circle_id, + method, + params, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_view_request(circle_id, method, params, false), + false); if (!r.ok) { res.status = 400; res.set_content(err_json(r.error).dump(), "application/json"); @@ -1902,83 +3142,168 @@ int main(int argc, char** argv) { res.set_content(r.result.dump(), "application/json"); }); - svr.Post("/api/fhe/encrypt", [](const httplib::Request& req, httplib::Response& res) { + svr.Post("/api/program/call", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD - if (!g_pvac_ok) { - res.status = 500; - res.set_content(err_json("pvac not available").dump(), "application/json"); + std::lock_guard lock(g_mtx); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); return; } - auto body = json::parse(req.body, nullptr, false); - if (body.is_discarded() || !body.contains("value")) { + std::string circle_id = body.value("circle_id", ""); + std::string addr = body.value("address", ""); + std::string method = body.value("method", ""); + if ((circle_id.empty() && addr.empty()) || method.empty()) { res.status = 400; - res.set_content(err_json("missing value").dump(), "application/json"); + res.set_content(err_json("method and address or circle_id required").dump(), "application/json"); return; } - int64_t value = body["value"].get(); - uint8_t seed[32]; - octra::random_bytes(seed, 32); - pvac_cipher ct = g_pvac.encrypt(static_cast(value), seed); - auto data = g_pvac.serialize_cipher(ct); - std::string b64 = octra::base64_encode(data.data(), data.size()); - g_pvac.free_cipher(ct); - json result; - result["ciphertext"] = b64; + if (!wallet_pin_ok(body, res)) return; + std::string params_str = "[]"; + if (body.contains("params")) params_str = body["params"].dump(); + std::string amount_str = body.value("amount", "0"); + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id.empty() ? addr : circle_id; + tx.amount = amount_str; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = circle_id.empty() ? "program_exec" : "circle_call"; + tx.encrypted_data = method; + tx.message = params_str; + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; res.set_content(result.dump(), "application/json"); }); - svr.Post("/api/fhe/decrypt", [](const httplib::Request& req, httplib::Response& res) { + svr.Post("/api/program/multi_exec", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD - if (!g_pvac_ok) { - res.status = 500; - res.set_content(err_json("pvac not available").dump(), "application/json"); - return; - } - auto body = json::parse(req.body, nullptr, false); - if (body.is_discarded() || !body.contains("ciphertext")) { + std::lock_guard lock(g_mtx); + json body; + try { body = json::parse(req.body); } catch (...) { res.status = 400; - res.set_content(err_json("missing ciphertext").dump(), "application/json"); + res.set_content(err_json("invalid json").dump(), "application/json"); return; } - std::string b64 = body["ciphertext"].get(); - auto raw = octra::base64_decode(b64); - if (raw.empty()) { + if (!body.contains("calls") || !body["calls"].is_array() || body["calls"].empty()) { res.status = 400; - res.set_content(err_json("invalid base64").dump(), "application/json"); + res.set_content(err_json("calls array required").dump(), "application/json"); return; } - pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); - if (!ct) { + if (body["calls"].size() > 8) { res.status = 400; - res.set_content(err_json("invalid ciphertext").dump(), "application/json"); + res.set_content(err_json("too many calls").dump(), "application/json"); return; } - uint64_t lo = 0, hi = 0; - g_pvac.decrypt_fp(ct, lo, hi); - g_pvac.free_cipher(ct); - int64_t val; - if (hi == 0) { - val = static_cast(lo); - } else { - __uint128_t p = (__uint128_t(1) << 127) - 1; - __uint128_t full = (__uint128_t(hi) << 64) | lo; - if (full > p / 2) val = -static_cast(p - full); - else val = static_cast(lo); + if (!wallet_pin_ok(body, res)) return; + json calls = json::array(); + for (size_t i = 0; i < body["calls"].size(); ++i) { + const auto& item = body["calls"][i]; + if (!item.is_object()) { + res.status = 400; + res.set_content(err_json("call must be object").dump(), "application/json"); + return; + } + std::string target = item.value("address", ""); + if (target.empty()) target = item.value("to", ""); + std::string method = item.value("method", ""); + if (!is_octra_address(target) || method.empty()) { + res.status = 400; + res.set_content(err_json("call address and method required").dump(), "application/json"); + return; + } + json params = json::array(); + if (item.contains("params")) { + if (!item["params"].is_array()) { + res.status = 400; + res.set_content(err_json("call params must be array").dump(), "application/json"); + return; + } + params = item["params"]; + } + std::string amount = "0"; + if (item.contains("amount")) { + if (item["amount"].is_string()) amount = item["amount"].get(); + else if (item["amount"].is_number_integer() || item["amount"].is_number_unsigned()) amount = item["amount"].dump(); + else { + res.status = 400; + res.set_content(err_json("call amount must be string or integer").dump(), "application/json"); + return; + } + } + if (!amount.empty() && amount[0] == '-') { + res.status = 400; + res.set_content(err_json("call amount must not be negative").dump(), "application/json"); + return; + } + calls.push_back({ + {"to", target}, + {"method", method}, + {"params", params}, + {"amount", amount.empty() ? "0" : amount} + }); } - json result; - result["value"] = val; + json payload; + payload["calls"] = calls; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = "multi_exec"; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "8000"); + tx.timestamp = now_ts(); + tx.op_type = "multi_exec"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; res.set_content(result.dump(), "application/json"); }); - svr.Get("/api/contract/info", [](const httplib::Request& req, httplib::Response& res) { + svr.Get("/api/program/storage", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); std::string addr = req.get_param_value("address"); - if (addr.empty()) { + std::string key = req.get_param_value("key"); + std::string limit = req.get_param_value("limit"); + bool dump = req.has_param("dump") && req.get_param_value("dump") == "1"; + if (circle_id.empty() && (addr.empty() || key.empty())) { res.status = 400; - res.set_content(err_json("address required").dump(), "application/json"); + res.set_content(err_json("address and key or circle_id required").dump(), "application/json"); return; } - auto r = g_rpc.vm_contract(addr); + if (!circle_id.empty() && key.empty() && dump) { + auto r = g_rpc.circle_storage_dump_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_storage_dump", circle_id)); + if (!r.ok) { + res.status = 404; + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_content(r.result.dump(), "application/json"); + return; + } + if (!circle_id.empty() && key.empty()) { + res.status = 400; + res.set_content(err_json("circle storage key required unless dump=1").dump(), "application/json"); + return; + } + auto r = circle_id.empty() + ? g_rpc.contract_storage(addr, key, limit) + : g_rpc.circle_storage_auth( + circle_id, + key, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_storage", circle_id, key)); if (!r.ok) { res.status = 404; res.set_content(err_json(r.error).dump(), "application/json"); @@ -1987,15 +3312,15 @@ int main(int argc, char** argv) { res.set_content(r.result.dump(), "application/json"); }); - svr.Get("/api/contract/receipt", [](const httplib::Request& req, httplib::Response& res) { + svr.Get("/api/program/abi", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD - std::string hash = req.get_param_value("hash"); - if (hash.empty()) { + std::string addr = req.get_param_value("address"); + if (addr.empty()) { res.status = 400; - res.set_content(err_json("hash required").dump(), "application/json"); + res.set_content(err_json("address required").dump(), "application/json"); return; } - auto r = g_rpc.contract_receipt(hash); + auto r = g_rpc.contract_abi(addr); if (!r.ok) { res.status = 404; res.set_content(err_json(r.error).dump(), "application/json"); @@ -2004,26 +3329,3228 @@ int main(int argc, char** argv) { res.set_content(r.result.dump(), "application/json"); }); - static json g_token_cache; - static double g_token_cache_ts = 0; - static std::string g_token_cache_addr; + svr.Post("/api/bridge/signer", [](const httplib::Request& req, httplib::Response& res) { + std::string signer_url; + { + std::lock_guard lock(g_mtx); + signer_url = g_wallet.bridge_signer_url; + } + if (signer_url.empty()) { + const char* env_url = std::getenv("OCTRA_BRIDGE_SIGNER_URL"); + if (env_url) signer_url = env_url; + } + if (signer_url.empty()) { + signer_url = "https://relayer-002838819188.octra.network"; + } + try { + auto body = json::parse(req.body); + std::string method = body.value("method", ""); + if (method != "bridgeStatus" && method != "bridgeHeader" && + method != "bridgeMessagesByEpoch" && method != "bridgeProofByLeafIndex" && + method != "bridgeClaimCalldata") { + res.status = 400; + res.set_content("{\"error\":\"method not allowed\"}", "application/json"); + return; + } + } catch (...) { + res.status = 400; + res.set_content("{\"error\":\"invalid json\"}", "application/json"); + return; + } + httplib::Client cli(signer_url); + cli.set_connection_timeout(10, 0); + cli.set_read_timeout(15, 0); + auto r = cli.Post("/", req.body, "application/json"); + if (r && r->status == 200) { + res.set_content(r->body, "application/json"); + } else { + res.status = 502; + res.set_content("{\"error\":\"bridge signer unavailable\"}", "application/json"); + } + }); - svr.Get("/api/tokens", [](const httplib::Request&, httplib::Response& res) { - WALLET_GUARD - double now = (double)time(nullptr); - if (!g_token_cache.empty() && g_token_cache_addr == g_wallet.addr - && (now - g_token_cache_ts) < 30.0) { - res.set_content(g_token_cache.dump(), "application/json"); + svr.Get("/api/relay/health", [](const httplib::Request&, httplib::Response& res) { + auto relay = relay_http_get("/health"); + if (!relay.ok) { + res.status = relay.status ? relay.status : 502; + res.set_content(err_json(relay.error).dump(), "application/json"); return; } - auto lr = g_rpc.list_contracts(); - json tokens = json::array(); - if (lr.ok && lr.result.contains("contracts")) { - auto& contracts = lr.result["contracts"]; - for (auto& c : contracts) { - std::string addr = c.value("address", ""); - if (addr.empty()) continue; - auto sr = g_rpc.contract_storage(addr, "symbol"); + res.status = relay.status; + res.set_content(relay.body, "application/json"); + }); + + svr.Get("/api/relay/status", [](const httplib::Request& req, httplib::Response& res) { + std::string request_id = req.get_param_value("request_id"); + std::string path = "/status"; + if (!request_id.empty()) path += "?request_id=" + request_id; + auto relay = relay_http_get(path); + if (!relay.ok) { + res.status = relay.status ? relay.status : 502; + res.set_content(err_json(relay.error).dump(), "application/json"); + return; + } + res.status = relay.status; + res.set_content(relay.body, "application/json"); + }); + + svr.Post("/api/relay/request", [](const httplib::Request& req, httplib::Response& res) { + auto relay = relay_http_post("/request", req.body); + if (!relay.ok) { + res.status = relay.status ? relay.status : 502; + res.set_content(err_json(relay.error).dump(), "application/json"); + return; + } + res.status = relay.status; + res.set_content(relay.body, "application/json"); + }); + + svr.Get("/api/relay/response", [](const httplib::Request& req, httplib::Response& res) { + std::string request_id = req.get_param_value("request_id"); + if (request_id.empty()) { + res.status = 400; + res.set_content(err_json("request_id required").dump(), "application/json"); + return; + } + auto relay = relay_http_get("/response/" + request_id); + if (!relay.ok) { + res.status = relay.status ? relay.status : 502; + res.set_content(err_json(relay.error).dump(), "application/json"); + return; + } + res.status = relay.status; + res.set_content(relay.body, "application/json"); + }); + + svr.Get("/api/relay/receipt", [](const httplib::Request& req, httplib::Response& res) { + std::string request_id = req.get_param_value("request_id"); + if (request_id.empty()) { + res.status = 400; + res.set_content(err_json("request_id required").dump(), "application/json"); + return; + } + auto relay = relay_http_get("/receipt/" + request_id); + if (!relay.ok) { + res.status = relay.status ? relay.status : 502; + res.set_content(err_json(relay.error).dump(), "application/json"); + return; + } + res.status = relay.status; + res.set_content(relay.body, "application/json"); + }); + + svr.Get("/api/relay/ingress", [](const httplib::Request& req, httplib::Response& res) { + std::string request_id = req.get_param_value("request_id"); + if (request_id.empty()) { + res.status = 400; + res.set_content(err_json("request_id required").dump(), "application/json"); + return; + } + auto relay = relay_http_get("/ingress/" + request_id); + if (!relay.ok) { + res.status = relay.status ? relay.status : 502; + res.set_content(err_json(relay.error).dump(), "application/json"); + return; + } + res.status = relay.status; + res.set_content(relay.body, "application/json"); + }); + + svr.Get("/api/contract/view", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string addr = req.get_param_value("address"); + std::string method = req.get_param_value("method"); + if (addr.empty() || method.empty()) { + res.status = 400; + res.set_content(err_json("address and method required").dump(), "application/json"); + return; + } + std::string params_str = req.get_param_value("params"); + json params = json::array(); + if (!params_str.empty()) { + try { params = json::parse(params_str); } catch (...) {} + } + auto r = g_rpc.contract_call_view(addr, method, params, g_wallet.addr); + if (!r.ok) { + res.status = 400; + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Post("/api/fhe/encrypt", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("value")) { + res.status = 400; + res.set_content(err_json("missing value").dump(), "application/json"); + return; + } + int64_t value = body["value"].get(); + uint8_t seed[32]; + octra::random_bytes(seed, 32); + pvac_cipher ct = g_pvac.encrypt(static_cast(value), seed); + auto data = g_pvac.serialize_cipher(ct); + std::string b64 = octra::base64_encode(data.data(), data.size()); + uint8_t blinding[32]; + octra::random_bytes(blinding, 32); + auto amount_commitment = g_pvac.pedersen_commit(static_cast(value), blinding); + std::string amount_commitment_b64 = octra::base64_encode(amount_commitment.data(), 32); + pvac_zero_proof proof = g_pvac.make_zero_proof_bound(ct, static_cast(value), blinding); + std::string zero_proof = g_pvac.encode_zero_proof(proof); + g_pvac.free_zero_proof(proof); + g_pvac.free_cipher(ct); + json result; + result["ciphertext"] = b64; + result["amount_commitment"] = amount_commitment_b64; + result["zero_proof"] = zero_proof; + result["proof_kind"] = "bound_zero_v1"; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/fhe/decrypt", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("ciphertext")) { + res.status = 400; + res.set_content(err_json("missing ciphertext").dump(), "application/json"); + return; + } + std::string b64 = body["ciphertext"].get(); + auto raw = octra::base64_decode(b64); + if (raw.empty()) { + res.status = 400; + res.set_content(err_json("invalid base64").dump(), "application/json"); + return; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + res.status = 400; + res.set_content(err_json("invalid ciphertext").dump(), "application/json"); + return; + } + uint64_t lo = 0, hi = 0; + g_pvac.decrypt_fp(ct, lo, hi); + g_pvac.free_cipher(ct); + int64_t val; + if (hi == 0) { + val = static_cast(lo); + } else { + __uint128_t p = (__uint128_t(1) << 127) - 1; + __uint128_t full = (__uint128_t(hi) << 64) | lo; + if (full > p / 2) val = -static_cast(p - full); + else val = static_cast(lo); + } + json result; + result["value"] = val; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/load_pk", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string requested_addr = body.value("addr", g_wallet.addr); + std::string key_id = body.value("key_id", ""); + std::string intent_id = body.value("intent_id", ""); + if (circle_id.empty() || requested_addr.empty()) { + res.status = 400; + res.set_content(err_json("circle_id and addr required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize(circle_id, "load_pk_mode", requested_addr, key_id, intent_id, error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.get_pvac_pubkey(requested_addr); + if (!r.ok) { + res.status = 404; + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/encrypt", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("value")) { + res.status = 400; + res.set_content(err_json("missing value").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string key_id = body.value("key_id", ""); + std::string intent_id = body.value("intent_id", ""); + if (circle_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize(circle_id, "encrypt_mode", g_wallet.addr, key_id, intent_id, error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto policy_r = circle_hfhe_policy_auth_rpc(circle_id); + if (!policy_r.ok) { + res.status = 400; + res.set_content(err_json(policy_r.error.empty() ? "circle hfhe policy read failed" : policy_r.error).dump(), "application/json"); + return; + } + int64_t value = body["value"].get(); + uint8_t seed[32]; + octra::random_bytes(seed, 32); + pvac_cipher ct = g_pvac.encrypt(static_cast(value), seed); + auto data = g_pvac.serialize_cipher(ct); + std::string b64 = octra::base64_encode(data.data(), data.size()); + uint8_t blinding[32]; + octra::random_bytes(blinding, 32); + auto amount_commitment = g_pvac.pedersen_commit(static_cast(value), blinding); + std::string amount_commitment_b64 = octra::base64_encode(amount_commitment.data(), 32); + json result; + result["ciphertext"] = b64; + auto policy = policy_r.result.value("policy", json::object()); + std::string encrypt_proof = policy.value("encrypt_proof", "bound_zero_v1"); + if (encrypt_proof == "bound_zero_v1" || encrypt_proof == "bound_zero_receipt_v1") { + pvac_zero_proof proof = + g_pvac.make_zero_proof_bound(ct, static_cast(value), blinding); + std::string zero_proof = g_pvac.encode_zero_proof(proof); + g_pvac.free_zero_proof(proof); + result["amount_commitment"] = amount_commitment_b64; + result["zero_proof"] = zero_proof; + result["proof_kind"] = encrypt_proof; + } else if (encrypt_proof == "range_v1" || encrypt_proof == "range_receipt_v1") { + pvac_range_proof proof = + g_pvac.make_range_proof(ct, static_cast(value)); + std::string range_proof = g_pvac.encode_range_proof(proof); + g_pvac.free_range_proof(proof); + result["range_proof"] = range_proof; + result["proof_kind"] = encrypt_proof; + } else if (encrypt_proof == "zero_receipt_v1") { + pvac_zero_proof proof = g_pvac.make_zero_proof(ct); + std::string zero_proof = g_pvac.encode_zero_proof(proof); + g_pvac.free_zero_proof(proof); + result["zero_proof"] = zero_proof; + result["proof_kind"] = encrypt_proof; + } else if (encrypt_proof == "none") { + result["proof_kind"] = "none"; + } else { + g_pvac.free_cipher(ct); + res.status = 400; + res.set_content(err_json("unsupported circle hfhe encrypt proof policy").dump(), "application/json"); + return; + } + g_pvac.free_cipher(ct); + if (circle_hfhe_receipt_required(encrypt_proof)) { + std::string receipt_commitment = + circle_hfhe_proof_requires_commitment(encrypt_proof) + ? amount_commitment_b64 + : ""; + octra::CircleHfheReceiptContext receipt_ctx; + if (!circle_hfhe_receipt_signer_allowed( + circle_id, + policy, + g_wallet.addr, + g_wallet.addr, + intent_id, + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + if (!circle_hfhe_receipt_context( + circle_id, + "encrypt", + g_wallet.addr, + key_id, + intent_id, + encrypt_proof, + policy, + b64, + receipt_commitment, + receipt_ctx, + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + result["proof_receipt"] = + octra::make_circle_hfhe_receipt_json( + receipt_ctx, + g_wallet.addr, + g_wallet.pub_b64, + g_wallet.sk); + } + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/decrypt", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("ciphertext")) { + res.status = 400; + res.set_content(err_json("missing ciphertext").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string key_id = body.value("key_id", ""); + std::string intent_id = body.value("intent_id", ""); + if (circle_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize(circle_id, "decrypt_mode", g_wallet.addr, key_id, intent_id, error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto policy_r = circle_hfhe_policy_auth_rpc(circle_id); + if (!policy_r.ok) { + res.status = 400; + res.set_content(err_json(policy_r.error.empty() ? "circle hfhe policy read failed" : policy_r.error).dump(), "application/json"); + return; + } + auto policy = policy_r.result.value("policy", json::object()); + std::string decrypt_proof = policy.value("decrypt_proof", "none"); + if (decrypt_proof == "bound_zero_v1" || decrypt_proof == "bound_zero_receipt_v1") { + std::string zero_proof = body.value("zero_proof", ""); + std::string amount_commitment = body.value("amount_commitment", ""); + if (zero_proof.empty() || amount_commitment.empty()) { + res.status = 400; + res.set_content(err_json("zero_proof and amount_commitment required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_bound_with_wallet(body["ciphertext"].get(), zero_proof, amount_commitment, error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + if (circle_hfhe_receipt_required(decrypt_proof)) { + if (!body.contains("proof_receipt")) { + res.status = 400; + res.set_content(err_json("proof_receipt required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_proof_receipt( + circle_id, + "encrypt", + g_wallet.addr, + key_id, + intent_id, + decrypt_proof, + policy, + body["ciphertext"].get(), + amount_commitment, + body["proof_receipt"], + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + } + } else if (circle_hfhe_proof_is_range(decrypt_proof)) { + std::string range_proof = body.value("range_proof", ""); + if (range_proof.empty()) { + res.status = 400; + res.set_content(err_json("range_proof required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_range_with_wallet( + body["ciphertext"].get(), + range_proof, + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + if (circle_hfhe_receipt_required(decrypt_proof)) { + if (!body.contains("proof_receipt")) { + res.status = 400; + res.set_content(err_json("proof_receipt required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_proof_receipt( + circle_id, + "encrypt", + g_wallet.addr, + key_id, + intent_id, + decrypt_proof, + policy, + body["ciphertext"].get(), + "", + body["proof_receipt"], + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + } + } else if (decrypt_proof == "zero_receipt_v1") { + std::string zero_proof = body.value("zero_proof", ""); + if (zero_proof.empty()) { + res.status = 400; + res.set_content(err_json("zero_proof required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_zero_with_wallet(body["ciphertext"].get(), zero_proof, error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + if (!body.contains("proof_receipt")) { + res.status = 400; + res.set_content(err_json("proof_receipt required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_proof_receipt( + circle_id, + "encrypt", + g_wallet.addr, + key_id, + intent_id, + decrypt_proof, + policy, + body["ciphertext"].get(), + "", + body["proof_receipt"], + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + } else if (decrypt_proof != "none") { + res.status = 400; + res.set_content(err_json("unsupported circle hfhe decrypt proof policy").dump(), "application/json"); + return; + } + std::string b64 = body["ciphertext"].get(); + auto raw = octra::base64_decode(b64); + if (raw.empty()) { + res.status = 400; + res.set_content(err_json("invalid base64").dump(), "application/json"); + return; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + res.status = 400; + res.set_content(err_json("invalid ciphertext").dump(), "application/json"); + return; + } + uint64_t lo = 0, hi = 0; + g_pvac.decrypt_fp(ct, lo, hi); + g_pvac.free_cipher(ct); + int64_t val; + if (hi == 0) { + val = static_cast(lo); + } else { + __uint128_t p = (__uint128_t(1) << 127) - 1; + __uint128_t full = (__uint128_t(hi) << 64) | lo; + if (full > p / 2) val = -static_cast(p - full); + else val = static_cast(lo); + } + json result; + result["value"] = val; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/commit", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("circle_id") || !body.contains("ciphertext")) { + res.status = 400; + res.set_content(err_json("circle_id and ciphertext required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize( + body["circle_id"].get(), + "commit_mode", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto raw = octra::base64_decode(body["ciphertext"].get()); + if (raw.empty()) { + res.status = 400; + res.set_content(err_json("invalid ciphertext").dump(), "application/json"); + return; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + res.status = 400; + res.set_content(err_json("invalid ciphertext").dump(), "application/json"); + return; + } + auto commitment = g_pvac.commit_ct(ct); + g_pvac.free_cipher(ct); + json result; + result["ciphertext_commitment"] = octra::base64_encode(commitment.data(), commitment.size()); + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/pedersen", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("circle_id") || !body.contains("value")) { + res.status = 400; + res.set_content(err_json("circle_id and value required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize( + body["circle_id"].get(), + "pedersen_mode", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + int64_t value = body["value"].get(); + std::array blinding = {}; + bool provided_blinding = false; + if (body.contains("blinding")) { + std::string blinding_b64 = body["blinding"].get(); + auto raw = octra::base64_decode(blinding_b64); + if (raw.size() != blinding.size()) { + res.status = 400; + res.set_content(err_json("blinding must decode to 32 bytes").dump(), "application/json"); + return; + } + std::copy(raw.begin(), raw.end(), blinding.begin()); + provided_blinding = true; + } else { + octra::random_bytes(blinding.data(), blinding.size()); + } + auto amount_commitment = + g_pvac.pedersen_commit(static_cast(value), blinding.data()); + json result; + result["amount_commitment"] = octra::base64_encode(amount_commitment.data(), amount_commitment.size()); + result["blinding"] = octra::base64_encode(blinding.data(), blinding.size()); + result["generated_blinding"] = !provided_blinding; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/serialize_cipher", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("circle_id") || !body.contains("ciphertext")) { + res.status = 400; + res.set_content(err_json("circle_id and ciphertext required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize( + body["circle_id"].get(), + "cipher_serde_mode", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto raw = octra::base64_decode(body["ciphertext"].get()); + if (raw.empty()) { + res.status = 400; + res.set_content(err_json("invalid ciphertext").dump(), "application/json"); + return; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + res.status = 400; + res.set_content(err_json("invalid ciphertext").dump(), "application/json"); + return; + } + auto serialized = g_pvac.serialize_cipher(ct); + g_pvac.free_cipher(ct); + json result; + result["serialized_cipher"] = octra::base64_encode(serialized.data(), serialized.size()); + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/deserialize_cipher", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("circle_id") || !body.contains("serialized_cipher")) { + res.status = 400; + res.set_content(err_json("circle_id and serialized_cipher required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize( + body["circle_id"].get(), + "cipher_serde_mode", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto raw = octra::base64_decode(body["serialized_cipher"].get()); + if (raw.empty()) { + res.status = 400; + res.set_content(err_json("invalid serialized cipher").dump(), "application/json"); + return; + } + pvac_cipher ct = g_pvac.deserialize_cipher(raw.data(), raw.size()); + if (!ct) { + res.status = 400; + res.set_content(err_json("invalid serialized cipher").dump(), "application/json"); + return; + } + auto normalized = g_pvac.serialize_cipher(ct); + g_pvac.free_cipher(ct); + json result; + result["ciphertext"] = octra::base64_encode(normalized.data(), normalized.size()); + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/verify_zero", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("circle_id") || !body.contains("ciphertext") || !body.contains("zero_proof")) { + res.status = 400; + res.set_content(err_json("circle_id, ciphertext and zero_proof required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize( + body["circle_id"].get(), + "verify_zero_mode", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto policy_r = circle_hfhe_policy_auth_rpc(body["circle_id"].get()); + if (!policy_r.ok) { + res.status = 400; + res.set_content(err_json(policy_r.error.empty() ? "circle hfhe policy read failed" : policy_r.error).dump(), "application/json"); + return; + } + auto policy = policy_r.result.value("policy", json::object()); + std::string encrypt_proof = policy.value("encrypt_proof", "bound_zero_v1"); + if (encrypt_proof == "zero_receipt_v1") { + if (!body.contains("proof_receipt")) { + res.status = 400; + res.set_content(err_json("proof_receipt required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_proof_receipt( + body["circle_id"].get(), + "encrypt", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + encrypt_proof, + policy, + body["ciphertext"].get(), + "", + body["proof_receipt"], + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + } + bool ok = circle_verify_zero_with_wallet( + body["ciphertext"].get(), + body["zero_proof"].get(), + error); + if (!ok && !error.empty()) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + res.set_content(json({{"ok", ok}}).dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/verify_range", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("circle_id") || !body.contains("ciphertext") || !body.contains("range_proof")) { + res.status = 400; + res.set_content(err_json("circle_id, ciphertext and range_proof required").dump(), "application/json"); + return; + } + std::string circle_id = body["circle_id"].get(); + std::string key_id = body.value("key_id", ""); + std::string intent_id = body.value("intent_id", ""); + std::string error; + if (!circle_hfhe_authorize( + circle_id, + "verify_range_mode", + g_wallet.addr, + key_id, + intent_id, + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto policy_r = circle_hfhe_policy_auth_rpc(circle_id); + if (!policy_r.ok) { + res.status = 400; + res.set_content(err_json(policy_r.error.empty() ? "circle hfhe policy read failed" : policy_r.error).dump(), "application/json"); + return; + } + auto policy = policy_r.result.value("policy", json::object()); + std::string encrypt_proof = policy.value("encrypt_proof", "bound_zero_v1"); + if (encrypt_proof == "range_receipt_v1") { + if (!body.contains("proof_receipt")) { + res.status = 400; + res.set_content(err_json("proof_receipt required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_proof_receipt( + circle_id, + "encrypt", + g_wallet.addr, + key_id, + intent_id, + encrypt_proof, + policy, + body["ciphertext"].get(), + "", + body["proof_receipt"], + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + } + bool ok = circle_verify_range_with_wallet( + body["ciphertext"].get(), + body["range_proof"].get(), + error); + if (!ok && !error.empty()) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + res.set_content(json({{"ok", ok}}).dump(), "application/json"); + }); + + svr.Post("/api/circle/fhe/verify_bound", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + res.set_header("Access-Control-Allow-Origin", "*"); + if (!g_pvac_ok) { + res.status = 500; + res.set_content(err_json("pvac not available").dump(), "application/json"); + return; + } + auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.contains("circle_id") || !body.contains("ciphertext") || !body.contains("zero_proof") || !body.contains("amount_commitment")) { + res.status = 400; + res.set_content(err_json("circle_id, ciphertext, zero_proof and amount_commitment required").dump(), "application/json"); + return; + } + std::string error; + if (!circle_hfhe_authorize( + body["circle_id"].get(), + "verify_bound_mode", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + error)) { + res.status = 403; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + auto policy_r = circle_hfhe_policy_auth_rpc(body["circle_id"].get()); + if (!policy_r.ok) { + res.status = 400; + res.set_content(err_json(policy_r.error.empty() ? "circle hfhe policy read failed" : policy_r.error).dump(), "application/json"); + return; + } + auto policy = policy_r.result.value("policy", json::object()); + std::string encrypt_proof = policy.value("encrypt_proof", "bound_zero_v1"); + if (encrypt_proof == "bound_zero_receipt_v1") { + if (!body.contains("proof_receipt")) { + res.status = 400; + res.set_content(err_json("proof_receipt required by circle hfhe policy").dump(), "application/json"); + return; + } + if (!circle_verify_proof_receipt( + body["circle_id"].get(), + "encrypt", + g_wallet.addr, + body.value("key_id", ""), + body.value("intent_id", ""), + encrypt_proof, + policy, + body["ciphertext"].get(), + body["amount_commitment"].get(), + body["proof_receipt"], + error)) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + } + bool ok = circle_verify_bound_with_wallet( + body["ciphertext"].get(), + body["zero_proof"].get(), + body["amount_commitment"].get(), + error); + if (!ok && !error.empty()) { + res.status = 400; + res.set_content(err_json(error).dump(), "application/json"); + return; + } + res.set_content(json({{"ok", ok}}).dump(), "application/json"); + }); + + svr.Get("/api/contract/info", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string addr = req.get_param_value("address"); + if (addr.empty()) { + res.status = 400; + res.set_content(err_json("address required").dump(), "application/json"); + return; + } + auto r = g_rpc.vm_contract(addr); + if (!r.ok) { + res.status = 404; + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/info", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + if (circle_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_info(circle_id); + if (!r.ok) { + r = rpc.circle_info_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_info", circle_id)); + } + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/slot_policy", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string slot_ref = req.get_param_value("slot_ref"); + if (circle_id.empty() || slot_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and slot_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_slot_policy_auth( + circle_id, + slot_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_slot_policy", circle_id, slot_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/state_policy", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string state_ref = req.get_param_value("state_ref"); + if (circle_id.empty() || state_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and state_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_state_policy_auth( + circle_id, + state_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_state_policy", circle_id, state_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/state_descriptor", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string state_ref = req.get_param_value("state_ref"); + if (circle_id.empty() || state_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and state_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_state_descriptor_auth( + circle_id, + state_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_state_descriptor", circle_id, state_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/balance_cell", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string state_ref = req.get_param_value("state_ref"); + if (circle_id.empty() || state_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and state_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_balance_cell_auth( + circle_id, + state_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_balance_cell", circle_id, state_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/register_cell", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string state_ref = req.get_param_value("state_ref"); + if (circle_id.empty() || state_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and state_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_register_cell_auth( + circle_id, + state_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_register_cell", circle_id, state_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/balance_binding", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string subject_addr = req.get_param_value("subject_addr"); + if (circle_id.empty() || subject_addr.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and subject_addr required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_balance_binding_auth( + circle_id, + subject_addr, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_balance_binding", circle_id, subject_addr)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/register_binding", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string register_ref = req.get_param_value("register_ref"); + if (circle_id.empty() || register_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and register_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_register_binding_auth( + circle_id, + register_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_register_binding", circle_id, register_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/balance_workflow", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string workflow_ref = req.get_param_value("workflow_ref"); + if (circle_id.empty() || workflow_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and workflow_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_balance_workflow_auth( + circle_id, + workflow_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_balance_workflow", circle_id, workflow_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/register_workflow", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string workflow_ref = req.get_param_value("workflow_ref"); + if (circle_id.empty() || workflow_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and workflow_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_register_workflow_auth( + circle_id, + workflow_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_register_workflow", circle_id, workflow_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/object_summary", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string object_ref = req.get_param_value("object_ref"); + if (circle_id.empty() || object_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and object_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_object_summary_auth( + circle_id, + object_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_object_summary", circle_id, object_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/object_members", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string object_ref = req.get_param_value("object_ref"); + if (circle_id.empty() || object_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and object_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_object_members_auth( + circle_id, + object_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_object_members", circle_id, object_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/object_detail", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string object_ref = req.get_param_value("object_ref"); + if (circle_id.empty() || object_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and object_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_object_detail_auth( + circle_id, + object_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_object_detail", circle_id, object_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/object_member", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string object_ref = req.get_param_value("object_ref"); + std::string member_ref = req.get_param_value("member_ref"); + if (circle_id.empty() || object_ref.empty() || member_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id, object_ref and member_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_object_member_auth( + circle_id, + object_ref, + member_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_object_member", circle_id, object_ref + "|" + member_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/object_refs", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + if (circle_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_object_refs_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_object_refs", circle_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/object_list", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + if (circle_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_object_list_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_object_list", circle_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Post("/api/circle/object_policy_define", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + json body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string object_ref = body.value("object_ref", ""); + std::string transition_mode = body.value("transition_mode", ""); + std::string required_proof_kind = body.value("required_proof_kind", ""); + if (circle_id.empty() || object_ref.empty() || transition_mode.empty() || required_proof_kind.empty() + || !body.contains("member_quorum") || !body["member_quorum"].is_number_integer() + || !body.contains("allow_detach") || !body["allow_detach"].is_boolean() + || !body.contains("allow_root_state_rotation") || !body["allow_root_state_rotation"].is_boolean()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id, object_ref, transition_mode, required_proof_kind, member_quorum, allow_detach, and allow_root_state_rotation required").dump(), "application/json"); + return; + } + json params = json::array({ + object_ref, + body.value("delivery_key_id", ""), + body.value("activate_after_epoch", 0), + body.value("expire_after_epoch", 0), + transition_mode, + required_proof_kind, + body["member_quorum"].get(), + body["allow_detach"].get(), + body["allow_root_state_rotation"].get() + }); + auto result = submit_program_call_tx( + circle_id, + "circle_call", + body.value("method", "define_object_policy_native"), + params, + body, + "1000"); + if (result.contains("error")) res.status = 500; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/object_bind", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + json body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string object_ref = body.value("object_ref", ""); + std::string state_ref = body.value("state_ref", ""); + std::string transition_ref = body.value("transition_ref", ""); + std::string status = body.value("status", ""); + if (circle_id.empty() || object_ref.empty() || state_ref.empty() || transition_ref.empty() || status.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id, object_ref, state_ref, transition_ref, and status required").dump(), "application/json"); + return; + } + json params = json::array({object_ref, state_ref, transition_ref, status}); + auto result = submit_program_call_tx( + circle_id, + "circle_call", + body.value("method", "bind_object_native"), + params, + body, + "1000"); + if (result.contains("error")) res.status = 500; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/object_member_attach", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + json body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string object_ref = body.value("object_ref", ""); + std::string member_ref = body.value("member_ref", ""); + std::string state_ref = body.value("state_ref", ""); + std::string member_kind = body.value("member_kind", ""); + std::string state_class = body.value("state_class", ""); + std::string codec = body.value("codec", ""); + std::string status = body.value("status", ""); + if (circle_id.empty() || object_ref.empty() || member_ref.empty() || state_ref.empty() + || member_kind.empty() || state_class.empty() || codec.empty() || status.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id, object_ref, member_ref, state_ref, member_kind, state_class, codec, and status required").dump(), "application/json"); + return; + } + json params = json::array({object_ref, member_ref, state_ref, member_kind, state_class, codec, status}); + auto result = submit_program_call_tx( + circle_id, + "circle_call", + body.value("method", "attach_object_member_native"), + params, + body, + "1000"); + if (result.contains("error")) res.status = 500; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/object_member_detach", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + json body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string object_ref = body.value("object_ref", ""); + std::string member_ref = body.value("member_ref", ""); + if (circle_id.empty() || object_ref.empty() || member_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id, object_ref, and member_ref required").dump(), "application/json"); + return; + } + json params = json::array({object_ref, member_ref}); + auto result = submit_program_call_tx( + circle_id, + "circle_call", + body.value("method", "detach_object_member_native"), + params, + body, + "1000"); + if (result.contains("error")) res.status = 500; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/object_transition_apply", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + json body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string transition_ref = body.value("transition_ref", ""); + std::string object_ref = body.value("object_ref", ""); + std::string next_state_ref = body.value("next_state_ref", ""); + std::string status = body.value("status", ""); + std::string intent_id = body.value("intent_id", ""); + if (circle_id.empty() || transition_ref.empty() || object_ref.empty() || next_state_ref.empty() || status.empty() || intent_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id, transition_ref, object_ref, next_state_ref, status, and intent_id required").dump(), "application/json"); + return; + } + json params = json::array({ + transition_ref, + object_ref, + body.value("previous_state_ref", ""), + next_state_ref, + body.value("member_bundle", ""), + body.value("touched_members_hash", ""), + body.value("proof_kind", ""), + body.value("proof_receipt_hash", ""), + status, + intent_id + }); + auto result = submit_program_call_tx( + circle_id, + "circle_call", + body.value("method", "apply_object_transition_native"), + params, + body, + "1000"); + if (result.contains("error")) res.status = 500; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(result.dump(), "application/json"); + }); + + svr.Get("/api/circle/transport_policy", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + if (circle_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_transport_policy_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_transport_policy", circle_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/hfhe_policy", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + if (circle_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_hfhe_policy_auth( + circle_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_hfhe_policy", circle_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/key_policy", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string key_id = req.get_param_value("key_id"); + if (circle_id.empty() || key_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and key_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_key_policy_auth( + circle_id, + key_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_key_policy", circle_id, key_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/outbox_intent", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string intent_id = req.get_param_value("intent_id"); + if (circle_id.empty() || intent_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and intent_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_outbox_intent_auth( + circle_id, + intent_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_outbox_intent", circle_id, intent_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/outbox_claim", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string intent_id = req.get_param_value("intent_id"); + if (circle_id.empty() || intent_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and intent_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_outbox_claim_auth( + circle_id, + intent_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_outbox_claim", circle_id, intent_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/outbox_status", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string intent_id = req.get_param_value("intent_id"); + if (circle_id.empty() || intent_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and intent_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_outbox_status_auth( + circle_id, + intent_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_outbox_status", circle_id, intent_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/ingress_packet", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string intent_id = req.get_param_value("intent_id"); + if (circle_id.empty() || intent_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and intent_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_ingress_packet_auth( + circle_id, + intent_id, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_ingress_packet", circle_id, intent_id)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/asset", [](const httplib::Request& req, httplib::Response& res) { + std::string circle_id = req.get_param_value("circle_id"); + std::string path = req.get_param_value("path"); + if (circle_id.empty() || path.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and path required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_asset(circle_id, path); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/asset_ciphertext", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string path = req.get_param_value("path"); + if (circle_id.empty() || path.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and path required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_asset_ciphertext_auth( + circle_id, + path, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_asset_ciphertext", circle_id, "path|" + path)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/asset_ciphertext_by_key", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string resource_key = req.get_param_value("resource_key"); + if (circle_id.empty() || resource_key.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and resource_key required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_asset_ciphertext_by_resource_key_auth( + circle_id, + resource_key, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_asset_ciphertext_by_resource_key", circle_id, "resource_key|" + resource_key)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/asset_ciphertext_by_slot", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string slot_ref = req.get_param_value("slot_ref"); + if (circle_id.empty() || slot_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and slot_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_asset_ciphertext_by_slot_ref_auth( + circle_id, + slot_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_asset_ciphertext_by_slot_ref", circle_id, "slot_ref|" + slot_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Get("/api/circle/asset_ciphertext_by_state", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string circle_id = req.get_param_value("circle_id"); + std::string state_ref = req.get_param_value("state_ref"); + if (circle_id.empty() || state_ref.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id and state_ref required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto r = rpc.circle_asset_ciphertext_by_state_ref_auth( + circle_id, + state_ref, + g_wallet.addr, + g_wallet.pub_b64, + sign_circle_read_request("octra_circle_asset_ciphertext_by_state_ref", circle_id, "state_ref|" + state_ref)); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(r.result.dump(), "application/json"); + }); + + svr.Post("/api/circle/deploy", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + auto read_string_or = [&](const char* key, const char* fallback) -> std::string { + if (!body.contains(key) || body[key].is_null()) { + return fallback; + } + if (body[key].is_string()) { + return body[key].get(); + } + return fallback; + }; + auto read_optional_string = [&](const char* key) -> std::string { + return read_string_or(key, ""); + }; + std::string caller_circle_id = read_string_or("circle_id", ""); + std::string runtime = read_string_or("runtime", "octb"); + std::string privacy_class = read_string_or("privacy_class", "sealed"); + std::string browser_mode = read_string_or("browser_mode", "native_sealed"); + std::string resource_mode = read_string_or("resource_mode", "sealed_read"); + std::string code_b64 = read_optional_string("code_b64"); + std::string policy_hash = read_optional_string("policy_hash"); + std::string members_root = read_optional_string("members_root"); + std::string export_policy = read_optional_string("export_policy"); + auto read_limit = [&](const char* key, const char* fallback) -> std::string { + if (!body.contains("limits") || !body["limits"].is_object()) { + return fallback; + } + auto limits = body["limits"]; + if (!limits.contains(key)) { + return fallback; + } + if (limits[key].is_string()) { + return limits[key].get(); + } + if (limits[key].is_number_integer()) { + return std::to_string(limits[key].get()); + } + return fallback; + }; + std::string max_stable_bytes = read_limit("max_stable_bytes", "33554432"); + std::string max_assets_bytes = read_limit("max_assets_bytes", "33554432"); + std::string max_inline_value = read_limit("max_inline_value", "65536"); + std::string max_wasm_bytes = read_limit("max_wasm_bytes", "33554432"); + auto json_str_of = [](const std::string& s) { + json tmp = s; + return tmp.dump(); + }; + auto str_or_null = [&json_str_of](const std::string& s) { + return s.empty() ? std::string("null") : json_str_of(s); + }; + std::string canonical_payload; + canonical_payload.reserve(512); + canonical_payload += "{"; + canonical_payload += "\"runtime\":" + json_str_of(runtime) + ","; + canonical_payload += "\"privacy_class\":" + json_str_of(privacy_class) + ","; + canonical_payload += "\"browser_mode\":" + json_str_of(browser_mode) + ","; + canonical_payload += "\"resource_mode\":" + json_str_of(resource_mode) + ","; + canonical_payload += "\"code_b64\":" + str_or_null(code_b64) + ","; + canonical_payload += "\"policy_hash\":" + str_or_null(policy_hash) + ","; + canonical_payload += "\"members_root\":" + str_or_null(members_root) + ","; + canonical_payload += "\"export_policy\":" + str_or_null(export_policy) + ","; + canonical_payload += "\"limits\":{"; + canonical_payload += "\"max_stable_bytes\":\"" + max_stable_bytes + "\","; + canonical_payload += "\"max_assets_bytes\":\"" + max_assets_bytes + "\","; + canonical_payload += "\"max_inline_value\":\"" + max_inline_value + "\","; + canonical_payload += "\"max_wasm_bytes\":\"" + max_wasm_bytes + "\""; + canonical_payload += "}}"; + auto bi = get_nonce_balance(); + uint64_t deploy_nonce = (uint64_t)(bi.nonce + 1); + auto h256_raw_fn = [](const std::string& tag, const std::vector& parts) { + std::string buf; + buf.reserve(tag.size() + 1 + parts.size() * 4 + 256); + buf += tag; + buf += '\0'; + for (const auto& p : parts) { + uint32_t n = (uint32_t)p.size(); + char b[4]; + b[0] = (char)((n >> 24) & 0xff); + b[1] = (char)((n >> 16) & 0xff); + b[2] = (char)((n >> 8) & 0xff); + b[3] = (char)(n & 0xff); + buf.append(b, 4); + buf += p; + } + auto h = octra::sha256(buf); + return std::string(reinterpret_cast(h.data()), 32); + }; + auto h256_hex_fn = [&h256_raw_fn](const std::string& tag, const std::vector& parts) { + auto raw = h256_raw_fn(tag, parts); + static const char hc[] = "0123456789abcdef"; + std::string out; + out.reserve(64); + for (unsigned char c : raw) { + out += hc[c >> 4]; + out += hc[c & 0xf]; + } + return out; + }; + std::string payload_hash_hex = h256_hex_fn("octra:circle_deploy_payload:v1", {canonical_payload}); + std::string nonce_be(8, '\0'); + { + uint64_t n = deploy_nonce; + for (int i = 7; i >= 0; --i) { nonce_be[i] = (char)(n & 0xff); n >>= 8; } + } + std::string seed = h256_raw_fn("octra:circle_deploy_id:v1", + {g_wallet.addr, nonce_be, payload_hash_hex}); + std::string b58 = octra::base58_encode( + reinterpret_cast(seed.data()), seed.size()); + std::string b58_part; + if (b58.size() >= 44) { + b58_part = b58.substr(0, 44); + } else if (b58.empty()) { + b58_part.assign(44, '1'); + } else { + std::string ext = b58; + size_t i = 0; + while (ext.size() < 44) { ext += b58[i % b58.size()]; ++i; } + b58_part = ext.substr(0, 44); + } + std::string derived_circle_id = "oct" + b58_part; + if (!caller_circle_id.empty() && caller_circle_id != derived_circle_id) { + res.status = 400; + json err; + err["error"] = "circle_id mismatch with derived address; omit circle_id to auto-derive"; + err["caller_circle_id"] = caller_circle_id; + err["derived_circle_id"] = derived_circle_id; + res.set_content(err.dump(), "application/json"); + return; + } + std::string circle_id = derived_circle_id; + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "200000"); + tx.timestamp = now_ts(); + tx.op_type = "deploy_circle"; + tx.message = canonical_payload; + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + else { + result["circle_id"] = circle_id; + result["derived_circle_id"] = derived_circle_id; + } + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/program_update", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string code_b64 = body.value("code_b64", ""); + if (circle_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + if (code_b64.empty()) { + res.status = 400; + res.set_content(err_json("code_b64 required").dump(), "application/json"); + return; + } + json payload; + payload["code_b64"] = code_b64; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "200000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_program_update"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + else result["circle_id"] = circle_id; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/asset_encrypted", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string path = body.value("path", ""); + std::string slot_ref = body.value("slot_ref", ""); + std::string state_ref = body.value("state_ref", ""); + std::string content_type = body.value("content_type", ""); + std::string ciphertext_b64 = body.value("ciphertext_b64", ""); + std::string key_id = body.value("key_id", ""); + std::string plaintext_hash = body.value("plaintext_hash", ""); + std::string encoding = body.value("encoding", ""); + std::string padding_class = body.value("padding_class", ""); + auto read_optional_scalar = [&](const char* key) -> std::string { + if (!body.contains(key)) { + return ""; + } + if (body[key].is_string()) { + return body[key].get(); + } + if (body[key].is_number_integer()) { + return std::to_string(body[key].get()); + } + return ""; + }; + std::string activate_after_epoch = read_optional_scalar("activate_after_epoch"); + std::string expire_after_epoch = read_optional_scalar("expire_after_epoch"); + std::string metadata_mode = body.value("metadata_mode", ""); + if (circle_id.empty() || content_type.empty() || ciphertext_b64.empty() || key_id.empty() || plaintext_hash.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, content_type, ciphertext_b64, key_id, and plaintext_hash required").dump(), "application/json"); + return; + } + int locator_count = 0; + if (!path.empty()) locator_count += 1; + if (!slot_ref.empty()) locator_count += 1; + if (!state_ref.empty()) locator_count += 1; + if (locator_count != 1) { + res.status = 400; + res.set_content(err_json("provide exactly one of path, slot_ref, or state_ref").dump(), "application/json"); + return; + } + if (ciphertext_b64.size() > CIRCLE_ASSET_MAX_B64_BYTES) { + res.status = 400; + res.set_content(err_json("circle asset body exceeds max encoded size").dump(), "application/json"); + return; + } + const std::string default_ou = std::to_string(circle_asset_ou_from_b64_len(ciphertext_b64.size())); + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, default_ou); + tx.timestamp = now_ts(); + tx.op_type = "circle_asset_put_encrypted"; + tx.encrypted_data = ciphertext_b64; + json payload; + if (!path.empty()) payload["path"] = path; + if (!slot_ref.empty()) payload["slot_ref"] = slot_ref; + if (!state_ref.empty()) payload["state_ref"] = state_ref; + payload["content_type"] = content_type; + payload["key_id"] = key_id; + payload["plaintext_hash"] = plaintext_hash; + if (!encoding.empty()) payload["encoding"] = encoding; + if (!padding_class.empty()) payload["padding_class"] = padding_class; + if (!activate_after_epoch.empty()) payload["activate_after_epoch"] = activate_after_epoch; + if (!expire_after_epoch.empty()) payload["expire_after_epoch"] = expire_after_epoch; + if (!metadata_mode.empty()) payload["metadata_mode"] = metadata_mode; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/asset_plain", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string path = body.value("path", ""); + std::string content_type = body.value("content_type", ""); + std::string body_b64 = body.value("body_b64", ""); + std::string encoding = body.value("encoding", ""); + if (circle_id.empty() || path.empty() || content_type.empty() || body_b64.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, path, content_type, and body_b64 required").dump(), "application/json"); + return; + } + if (body_b64.size() > CIRCLE_ASSET_MAX_B64_BYTES) { + res.status = 400; + res.set_content(err_json("circle asset body exceeds max encoded size").dump(), "application/json"); + return; + } + const std::string default_ou = std::to_string(circle_asset_ou_from_b64_len(body_b64.size())); + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, default_ou); + tx.timestamp = now_ts(); + tx.op_type = "circle_asset_put"; + tx.encrypted_data = body_b64; + json payload; + payload["path"] = path; + payload["content_type"] = content_type; + if (!encoding.empty()) payload["encoding"] = encoding; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/sealed_slot_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string slot_ref = body.value("slot_ref", ""); + std::string state_ref = body.value("state_ref", ""); + std::string content_type = body.value("content_type", ""); + std::string ciphertext_b64 = body.value("ciphertext_b64", ""); + std::string key_id = body.value("key_id", ""); + std::string plaintext_hash = body.value("plaintext_hash", ""); + std::string encoding = body.value("encoding", ""); + std::string padding_class = body.value("padding_class", ""); + auto read_optional_scalar = [&](const char* key) -> std::string { + if (!body.contains(key)) { + return ""; + } + if (body[key].is_string()) { + return body[key].get(); + } + if (body[key].is_number_integer()) { + return std::to_string(body[key].get()); + } + return ""; + }; + std::string activate_after_epoch = read_optional_scalar("activate_after_epoch"); + std::string expire_after_epoch = read_optional_scalar("expire_after_epoch"); + std::string metadata_mode = body.value("metadata_mode", ""); + if (circle_id.empty() || content_type.empty() || ciphertext_b64.empty() || key_id.empty() || plaintext_hash.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, content_type, ciphertext_b64, key_id, and plaintext_hash required").dump(), "application/json"); + return; + } + if ((slot_ref.empty() && state_ref.empty()) || (!slot_ref.empty() && !state_ref.empty())) { + res.status = 400; + res.set_content(err_json("provide exactly one of slot_ref or state_ref").dump(), "application/json"); + return; + } + if (ciphertext_b64.size() > CIRCLE_ASSET_MAX_B64_BYTES) { + res.status = 400; + res.set_content(err_json("circle asset body exceeds max encoded size").dump(), "application/json"); + return; + } + const std::string default_ou = std::to_string(circle_asset_ou_from_b64_len(ciphertext_b64.size())); + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, default_ou); + tx.timestamp = now_ts(); + tx.op_type = "circle_sealed_slot_put"; + tx.encrypted_data = ciphertext_b64; + json payload; + if (!slot_ref.empty()) payload["slot_ref"] = slot_ref; + if (!state_ref.empty()) payload["state_ref"] = state_ref; + payload["content_type"] = content_type; + payload["key_id"] = key_id; + payload["plaintext_hash"] = plaintext_hash; + if (!encoding.empty()) payload["encoding"] = encoding; + if (!padding_class.empty()) payload["padding_class"] = padding_class; + if (!activate_after_epoch.empty()) payload["activate_after_epoch"] = activate_after_epoch; + if (!expire_after_epoch.empty()) payload["expire_after_epoch"] = expire_after_epoch; + if (!metadata_mode.empty()) payload["metadata_mode"] = metadata_mode; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/slot_policy_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string slot_ref = body.value("slot_ref", ""); + std::string state_ref = body.value("state_ref", ""); + std::string delivery_key_id = body.value("delivery_key_id", ""); + auto read_optional_scalar = [&](const char* key) -> std::string { + if (!body.contains(key)) { + return ""; + } + if (body[key].is_string()) { + return body[key].get(); + } + if (body[key].is_number_integer()) { + return std::to_string(body[key].get()); + } + return ""; + }; + std::string activate_after_epoch = read_optional_scalar("activate_after_epoch"); + std::string expire_after_epoch = read_optional_scalar("expire_after_epoch"); + bool tombstone = body.value("tombstone", false); + bool revoked = body.value("revoked", false); + if (circle_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + if ((slot_ref.empty() && state_ref.empty()) || (!slot_ref.empty() && !state_ref.empty())) { + res.status = 400; + res.set_content(err_json("provide exactly one of slot_ref or state_ref").dump(), "application/json"); + return; + } + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_slot_policy_put"; + json payload; + if (!slot_ref.empty()) payload["slot_ref"] = slot_ref; + if (!state_ref.empty()) payload["state_ref"] = state_ref; + if (!delivery_key_id.empty()) payload["delivery_key_id"] = delivery_key_id; + if (!activate_after_epoch.empty()) payload["activate_after_epoch"] = activate_after_epoch; + if (!expire_after_epoch.empty()) payload["expire_after_epoch"] = expire_after_epoch; + if (tombstone) payload["tombstone"] = tombstone; + if (revoked) payload["revoked"] = revoked; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/state_descriptor_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string state_ref = body.value("state_ref", ""); + if (circle_id.empty() || state_ref.empty()) { + res.status = 400; + res.set_content(err_json("circle_id and state_ref required").dump(), "application/json"); + return; + } + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_state_descriptor_put"; + json payload; + payload["state_ref"] = state_ref; + if (body.contains("state_class")) payload["state_class"] = body["state_class"]; + if (body.contains("codec")) payload["codec"] = body["codec"]; + if (body.contains("schema_hash")) payload["schema_hash"] = body["schema_hash"]; + if (body.contains("subject_addr")) payload["subject_addr"] = body["subject_addr"]; + if (body.contains("hfhe_profile")) payload["hfhe_profile"] = body["hfhe_profile"]; + if (body.contains("mutable_state")) payload["mutable_state"] = body["mutable_state"]; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/balance_cell_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string state_ref = body.value("state_ref", ""); + std::string ciphertext_b64 = body.value("ciphertext_b64", ""); + std::string key_id = body.value("key_id", ""); + std::string plaintext_hash = body.value("plaintext_hash", ""); + std::string ciphertext_commitment = body.value("ciphertext_commitment", ""); + std::string amount_commitment = body.value("amount_commitment", ""); + if (circle_id.empty() || state_ref.empty() || ciphertext_b64.empty() || key_id.empty() || plaintext_hash.empty() || ciphertext_commitment.empty() || amount_commitment.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, state_ref, ciphertext_b64, key_id, plaintext_hash, ciphertext_commitment, and amount_commitment required").dump(), "application/json"); + return; + } + if (ciphertext_b64.size() > CIRCLE_ASSET_MAX_B64_BYTES) { + res.status = 400; + res.set_content(err_json("circle asset body exceeds max encoded size").dump(), "application/json"); + return; + } + auto read_optional_scalar = [&](const char* key) -> std::string { + if (!body.contains(key)) { + return ""; + } + if (body[key].is_string()) { + return body[key].get(); + } + if (body[key].is_number_integer()) { + return std::to_string(body[key].get()); + } + return ""; + }; + std::string activate_after_epoch = read_optional_scalar("activate_after_epoch"); + std::string expire_after_epoch = read_optional_scalar("expire_after_epoch"); + const std::string default_ou = std::to_string(circle_asset_ou_from_b64_len(ciphertext_b64.size())); + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, default_ou); + tx.timestamp = now_ts(); + tx.op_type = "circle_balance_cell_put"; + tx.encrypted_data = ciphertext_b64; + json payload; + payload["state_ref"] = state_ref; + payload["key_id"] = key_id; + payload["plaintext_hash"] = plaintext_hash; + payload["ciphertext_commitment"] = ciphertext_commitment; + payload["amount_commitment"] = amount_commitment; + if (body.contains("content_type")) payload["content_type"] = body["content_type"]; + if (body.contains("encoding")) payload["encoding"] = body["encoding"]; + if (body.contains("padding_class")) payload["padding_class"] = body["padding_class"]; + if (body.contains("delivery_key_id")) payload["delivery_key_id"] = body["delivery_key_id"]; + if (!activate_after_epoch.empty()) payload["activate_after_epoch"] = activate_after_epoch; + if (!expire_after_epoch.empty()) payload["expire_after_epoch"] = expire_after_epoch; + if (body.contains("metadata_mode")) payload["metadata_mode"] = body["metadata_mode"]; + if (body.contains("codec")) payload["codec"] = body["codec"]; + if (body.contains("schema_hash")) payload["schema_hash"] = body["schema_hash"]; + if (body.contains("subject_addr")) payload["subject_addr"] = body["subject_addr"]; + if (body.contains("mutable_state")) payload["mutable_state"] = body["mutable_state"]; + if (body.contains("hfhe_profile")) payload["hfhe_profile"] = body["hfhe_profile"]; + if (body.contains("proof_kind")) payload["proof_kind"] = body["proof_kind"]; + if (body.contains("proof_receipt_hash")) payload["proof_receipt_hash"] = body["proof_receipt_hash"]; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/register_cell_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string state_ref = body.value("state_ref", ""); + std::string ciphertext_b64 = body.value("ciphertext_b64", ""); + std::string key_id = body.value("key_id", ""); + std::string plaintext_hash = body.value("plaintext_hash", ""); + std::string ciphertext_commitment = body.value("ciphertext_commitment", ""); + if (circle_id.empty() || state_ref.empty() || ciphertext_b64.empty() || key_id.empty() || plaintext_hash.empty() || ciphertext_commitment.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, state_ref, ciphertext_b64, key_id, plaintext_hash, and ciphertext_commitment required").dump(), "application/json"); + return; + } + if (ciphertext_b64.size() > CIRCLE_ASSET_MAX_B64_BYTES) { + res.status = 400; + res.set_content(err_json("circle asset body exceeds max encoded size").dump(), "application/json"); + return; + } + auto read_optional_scalar = [&](const char* key) -> std::string { + if (!body.contains(key)) { + return ""; + } + if (body[key].is_string()) { + return body[key].get(); + } + if (body[key].is_number_integer()) { + return std::to_string(body[key].get()); + } + return ""; + }; + std::string activate_after_epoch = read_optional_scalar("activate_after_epoch"); + std::string expire_after_epoch = read_optional_scalar("expire_after_epoch"); + const std::string default_ou = std::to_string(circle_asset_ou_from_b64_len(ciphertext_b64.size())); + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, default_ou); + tx.timestamp = now_ts(); + tx.op_type = "circle_register_cell_put"; + tx.encrypted_data = ciphertext_b64; + json payload; + payload["state_ref"] = state_ref; + payload["key_id"] = key_id; + payload["plaintext_hash"] = plaintext_hash; + payload["ciphertext_commitment"] = ciphertext_commitment; + if (body.contains("content_type")) payload["content_type"] = body["content_type"]; + if (body.contains("encoding")) payload["encoding"] = body["encoding"]; + if (body.contains("padding_class")) payload["padding_class"] = body["padding_class"]; + if (body.contains("delivery_key_id")) payload["delivery_key_id"] = body["delivery_key_id"]; + if (!activate_after_epoch.empty()) payload["activate_after_epoch"] = activate_after_epoch; + if (!expire_after_epoch.empty()) payload["expire_after_epoch"] = expire_after_epoch; + if (body.contains("metadata_mode")) payload["metadata_mode"] = body["metadata_mode"]; + if (body.contains("codec")) payload["codec"] = body["codec"]; + if (body.contains("schema_hash")) payload["schema_hash"] = body["schema_hash"]; + if (body.contains("subject_addr")) payload["subject_addr"] = body["subject_addr"]; + if (body.contains("mutable_state")) payload["mutable_state"] = body["mutable_state"]; + if (body.contains("hfhe_profile")) payload["hfhe_profile"] = body["hfhe_profile"]; + if (body.contains("proof_kind")) payload["proof_kind"] = body["proof_kind"]; + if (body.contains("proof_receipt_hash")) payload["proof_receipt_hash"] = body["proof_receipt_hash"]; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/transport_policy_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + if (circle_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + json payload = json::object(); + if (body.contains("relay_mode")) payload["relay_mode"] = body["relay_mode"]; + if (body.contains("lease_class")) payload["lease_class"] = body["lease_class"]; + if (body.contains("claim_strategy")) payload["claim_strategy"] = body["claim_strategy"]; + if (body.contains("claim_topology")) payload["claim_topology"] = body["claim_topology"]; + if (body.contains("quorum_mode")) payload["quorum_mode"] = body["quorum_mode"]; + if (body.contains("ingress_strategy")) payload["ingress_strategy"] = body["ingress_strategy"]; + if (body.contains("quorum_threshold")) payload["quorum_threshold"] = body["quorum_threshold"]; + if (body.contains("quorum_weight_threshold")) payload["quorum_weight_threshold"] = body["quorum_weight_threshold"]; + if (body.contains("max_active_claims")) payload["max_active_claims"] = body["max_active_claims"]; + if (body.contains("relay_allowlist")) payload["relay_allowlist"] = body["relay_allowlist"]; + if (body.contains("relay_weights")) payload["relay_weights"] = body["relay_weights"]; + if (body.contains("max_claim_window_epochs")) payload["max_claim_window_epochs"] = body["max_claim_window_epochs"]; + if (body.contains("max_response_bytes")) payload["max_response_bytes"] = body["max_response_bytes"]; + if (body.contains("require_response_ciphertext")) payload["require_response_ciphertext"] = body["require_response_ciphertext"]; + if (body.contains("require_external_receipt")) payload["require_external_receipt"] = body["require_external_receipt"]; + if (body.contains("accepted_result_codes")) payload["accepted_result_codes"] = body["accepted_result_codes"]; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_transport_policy_put"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/hfhe_policy_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + if (circle_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + json payload = json::object(); + if (body.contains("load_pk_mode")) payload["load_pk_mode"] = body["load_pk_mode"]; + if (body.contains("encrypt_mode")) payload["encrypt_mode"] = body["encrypt_mode"]; + if (body.contains("decrypt_mode")) payload["decrypt_mode"] = body["decrypt_mode"]; + if (body.contains("cipher_arithmetic_mode")) payload["cipher_arithmetic_mode"] = body["cipher_arithmetic_mode"]; + if (body.contains("commit_mode")) payload["commit_mode"] = body["commit_mode"]; + if (body.contains("pedersen_mode")) payload["pedersen_mode"] = body["pedersen_mode"]; + if (body.contains("cipher_serde_mode")) payload["cipher_serde_mode"] = body["cipher_serde_mode"]; + if (body.contains("pubkey_serde_mode")) payload["pubkey_serde_mode"] = body["pubkey_serde_mode"]; + if (body.contains("verify_zero_mode")) payload["verify_zero_mode"] = body["verify_zero_mode"]; + if (body.contains("verify_range_mode")) payload["verify_range_mode"] = body["verify_range_mode"]; + if (body.contains("verify_bound_mode")) payload["verify_bound_mode"] = body["verify_bound_mode"]; + if (body.contains("proof_receipt_signer_mode")) payload["proof_receipt_signer_mode"] = body["proof_receipt_signer_mode"]; + if (body.contains("proof_receipt_class")) payload["proof_receipt_class"] = body["proof_receipt_class"]; + if (body.contains("pk_allowlist")) payload["pk_allowlist"] = body["pk_allowlist"]; + if (body.contains("require_live_key_policy")) payload["require_live_key_policy"] = body["require_live_key_policy"]; + if (body.contains("require_receipt_transport_binding")) payload["require_receipt_transport_binding"] = body["require_receipt_transport_binding"]; + if (body.contains("encrypt_proof")) payload["encrypt_proof"] = body["encrypt_proof"]; + if (body.contains("decrypt_proof")) payload["decrypt_proof"] = body["decrypt_proof"]; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_hfhe_policy_put"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/key_grant", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string key_id = body.value("key_id", ""); + if (circle_id.empty() || key_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id and key_id required").dump(), "application/json"); + return; + } + json payload = json::object(); + payload["key_id"] = key_id; + if (body.contains("activate_after_epoch")) payload["activate_after_epoch"] = body["activate_after_epoch"]; + if (body.contains("expire_after_epoch")) payload["expire_after_epoch"] = body["expire_after_epoch"]; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_key_grant"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/key_extend", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string key_id = body.value("key_id", ""); + if (circle_id.empty() || key_id.empty() || !body.contains("expire_after_epoch")) { + res.status = 400; + res.set_content(err_json("circle_id, key_id, and expire_after_epoch required").dump(), "application/json"); + return; + } + json payload = json::object(); + payload["key_id"] = key_id; + payload["expire_after_epoch"] = body["expire_after_epoch"]; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_key_extend"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/key_revoke", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string key_id = body.value("key_id", ""); + if (circle_id.empty() || key_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id and key_id required").dump(), "application/json"); + return; + } + json payload = json::object(); + payload["key_id"] = key_id; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_key_revoke"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/key_erase", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string key_id = body.value("key_id", ""); + if (circle_id.empty() || key_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id and key_id required").dump(), "application/json"); + return; + } + json payload = json::object(); + payload["key_id"] = key_id; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_key_erase"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/key_policy_put", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string key_id = body.value("key_id", ""); + if (circle_id.empty() || key_id.empty()) { + res.status = 400; + res.set_content(err_json("circle_id and key_id required").dump(), "application/json"); + return; + } + json payload = json::object(); + payload["key_id"] = key_id; + if (body.contains("activate_after_epoch")) payload["activate_after_epoch"] = body["activate_after_epoch"]; + if (body.contains("expire_after_epoch")) payload["expire_after_epoch"] = body["expire_after_epoch"]; + payload["revoked"] = body.value("revoked", false); + payload["erased"] = body.value("erased", false); + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "1000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_key_policy_put"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/relay_claim", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string intent_id = body.value("intent_id", ""); + std::string claim_epoch; + std::string claim_expiry_epoch; + if (body.contains("claim_epoch")) { + claim_epoch = body["claim_epoch"].is_string() + ? body["claim_epoch"].get() + : std::to_string(body["claim_epoch"].get()); + } + if (body.contains("claim_expiry_epoch")) { + claim_expiry_epoch = body["claim_expiry_epoch"].is_string() + ? body["claim_expiry_epoch"].get() + : std::to_string(body["claim_expiry_epoch"].get()); + } + if (circle_id.empty() || intent_id.empty() || claim_epoch.empty() || claim_expiry_epoch.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, intent_id, claim_epoch, and claim_expiry_epoch required").dump(), "application/json"); + return; + } + const std::string subject = + "octra_circle_relay_claim|" + circle_id + "|" + intent_id + "|" + + g_wallet.addr + "|" + claim_epoch + "|" + claim_expiry_epoch; + const std::string signature = octra::ed25519_sign_detached( + reinterpret_cast(subject.data()), + subject.size(), + g_wallet.sk); + json payload; + payload["intent_id"] = intent_id; + payload["relay_id"] = g_wallet.addr; + payload["claim_epoch"] = claim_epoch; + payload["claim_expiry_epoch"] = claim_expiry_epoch; + payload["signature"] = signature; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "2000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_relay_claim"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/relay_cancel", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string intent_id = body.value("intent_id", ""); + std::string related_key_id = body.value("related_key_id", ""); + std::string cancel_epoch; + if (body.contains("cancel_epoch")) { + cancel_epoch = body["cancel_epoch"].is_string() + ? body["cancel_epoch"].get() + : std::to_string(body["cancel_epoch"].get()); + } + std::string reason = body.value("reason", "relay_cancelled"); + const std::array allowed_reasons = { + "relay_cancelled", + "owner_cancelled", + "intent_expired", + "claim_expired", + "claim_set_exhausted", + "delivery_key_inactive", + "delivery_key_expired", + "delivery_key_revoked", + "delivery_key_erased" + }; + if (circle_id.empty() || intent_id.empty() || cancel_epoch.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, intent_id, and cancel_epoch required").dump(), "application/json"); + return; + } + if (std::find(allowed_reasons.begin(), allowed_reasons.end(), reason) == allowed_reasons.end()) { + res.status = 400; + res.set_content(err_json("invalid relay cancel reason").dump(), "application/json"); + return; + } + const std::string subject = + "octra_circle_relay_cancel|" + circle_id + "|" + intent_id + "|" + + g_wallet.addr + "|" + cancel_epoch + "|" + reason + "|" + related_key_id; + const std::string signature = octra::ed25519_sign_detached( + reinterpret_cast(subject.data()), + subject.size(), + g_wallet.sk); + json payload; + payload["intent_id"] = intent_id; + payload["relay_id"] = g_wallet.addr; + payload["cancel_epoch"] = cancel_epoch; + payload["reason"] = reason; + payload["signature"] = signature; + if (!related_key_id.empty()) payload["related_key_id"] = related_key_id; + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "2000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_relay_cancel"; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/outbox_open", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string intent_id = body.value("intent_id", ""); + std::string relay_policy_hash = body.value("relay_policy_hash", ""); + std::string payload_hash = body.value("payload_hash", ""); + std::string ciphertext_blob_hash = body.value("ciphertext_blob_hash", ""); + std::string delivery_key_id = body.value("delivery_key_id", ""); + std::string route_hint = body.value("route_hint", ""); + std::string callback_policy_hash = body.value("callback_policy_hash", ""); + auto read_required_scalar = [&](const char* key) -> std::string { + if (!body.contains(key)) { + return ""; + } + if (body[key].is_string()) { + return body[key].get(); + } + if (body[key].is_number_integer()) { + return std::to_string(body[key].get()); + } + return ""; + }; + std::string expiry_epoch = read_required_scalar("expiry_epoch"); + std::string max_response_bytes = read_required_scalar("max_response_bytes"); + std::string fee_budget = read_required_scalar("fee_budget"); + if (circle_id.empty() || intent_id.empty() || expiry_epoch.empty() || relay_policy_hash.empty() || payload_hash.empty() || max_response_bytes.empty() || fee_budget.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, intent_id, expiry_epoch, relay_policy_hash, payload_hash, max_response_bytes, and fee_budget required").dump(), "application/json"); + return; + } + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "3000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_outbox_open"; + json payload; + payload["intent_id"] = intent_id; + payload["expiry_epoch"] = expiry_epoch; + payload["relay_policy_hash"] = relay_policy_hash; + payload["payload_hash"] = payload_hash; + payload["max_response_bytes"] = max_response_bytes; + payload["fee_budget"] = fee_budget; + if (!ciphertext_blob_hash.empty()) payload["ciphertext_blob_hash"] = ciphertext_blob_hash; + if (!delivery_key_id.empty()) payload["delivery_key_id"] = delivery_key_id; + if (!route_hint.empty()) payload["route_hint"] = route_hint; + if (!callback_policy_hash.empty()) payload["callback_policy_hash"] = callback_policy_hash; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Post("/api/circle/ingress_commit", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::lock_guard lock(g_mtx); + res.set_header("Access-Control-Allow-Origin", "*"); + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string circle_id = body.value("circle_id", ""); + std::string intent_id = body.value("intent_id", ""); + std::string relay_id = body.value("relay_id", ""); + std::string response_payload_hash = body.value("response_payload_hash", ""); + std::string response_ciphertext_blob_hash = body.value("response_ciphertext_blob_hash", ""); + std::string external_receipt_hash = body.value("external_receipt_hash", ""); + std::string signature = body.value("signature", ""); + auto read_required_scalar = [&](const char* key) -> std::string { + if (!body.contains(key)) { + return ""; + } + if (body[key].is_string()) { + return body[key].get(); + } + if (body[key].is_number_integer()) { + return std::to_string(body[key].get()); + } + return ""; + }; + std::string ingress_nonce = read_required_scalar("ingress_nonce"); + std::string response_size_bytes = read_required_scalar("response_size_bytes"); + int result_code = body.value("result_code", 0); + if (circle_id.empty() || intent_id.empty() || relay_id.empty() || ingress_nonce.empty() || response_payload_hash.empty() || response_size_bytes.empty() || signature.empty()) { + res.status = 400; + res.set_content(err_json("circle_id, intent_id, relay_id, ingress_nonce, response_payload_hash, response_size_bytes, and signature required").dump(), "application/json"); + return; + } + auto bi = get_nonce_balance(); + octra::Transaction tx; + tx.from = g_wallet.addr; + tx.to_ = circle_id; + tx.amount = "0"; + tx.nonce = bi.nonce + 1; + tx.ou = parse_ou(body, "3000"); + tx.timestamp = now_ts(); + tx.op_type = "circle_ingress_commit"; + json payload; + payload["intent_id"] = intent_id; + payload["relay_id"] = relay_id; + payload["ingress_nonce"] = ingress_nonce; + payload["result_code"] = result_code; + payload["response_payload_hash"] = response_payload_hash; + payload["response_size_bytes"] = response_size_bytes; + payload["signature"] = signature; + if (!response_ciphertext_blob_hash.empty()) payload["response_ciphertext_blob_hash"] = response_ciphertext_blob_hash; + if (!external_receipt_hash.empty()) payload["external_receipt_hash"] = external_receipt_hash; + tx.message = payload.dump(); + sign_tx_fields(tx); + auto result = submit_tx(tx); + if (result.contains("error")) res.status = 500; + res.set_content(result.dump(), "application/json"); + }); + + svr.Get(R"(/oct/([^/]+)(/.*)?)", [](const httplib::Request& req, httplib::Response& res) { + std::string circle_id = req.matches.size() > 1 ? req.matches[1].str() : ""; + std::string raw_path = req.matches.size() > 2 ? req.matches[2].str() : ""; + std::string path = raw_path.empty() ? "/index.html" : raw_path; + if (circle_id.empty()) { + res.status = 400; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json("circle_id required").dump(), "application/json"); + return; + } + octra::RpcClient rpc(current_public_rpc_url()); + auto info = rpc.circle_info(circle_id); + if (info.ok && info.result.value("resource_mode", "") == "sealed_read") { + const std::string uri = "oct://" + circle_id + path; + const std::string location = + "/circles.html?uri=" + httplib::detail::encode_query_param(uri) + + "&passphrase=" + httplib::detail::encode_query_param("octra-circle-demo"); + res.status = 302; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_header("Cache-Control", "no-store"); + res.set_header("Location", location); + return; + } + auto r = rpc.circle_asset(circle_id, path); + if (!r.ok) { + res.status = 404; + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + auto content_type = r.result.value("content_type", "application/octet-stream"); + auto body_b64 = r.result.value("body_b64", ""); + if (body_b64.size() > CIRCLE_ASSET_MAX_B64_BYTES) { + res.status = 502; + res.set_content(err_json("oversized asset from rpc").dump(), "application/json"); + return; + } + auto raw = octra::base64_decode(body_b64); + std::string body(raw.begin(), raw.end()); + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_header("Cache-Control", "no-store"); + res.set_header("X-Content-Type-Options", "nosniff"); + res.set_content(body, content_type.c_str()); + }); + + svr.Get("/api/contract/receipt", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + std::string hash = req.get_param_value("hash"); + if (hash.empty()) { + res.status = 400; + res.set_content(err_json("hash required").dump(), "application/json"); + return; + } + auto r = g_rpc.contract_receipt(hash); + if (!r.ok) { + res.status = 404; + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_content(r.result.dump(), "application/json"); + }); + + static json g_token_cache; + static double g_token_cache_ts = 0; + static std::string g_token_cache_addr; + static std::mutex g_token_cache_mtx; + + svr.Get("/api/tokens", [](const httplib::Request&, httplib::Response& res) { + WALLET_GUARD + double now = (double)time(nullptr); + { + std::lock_guard ck(g_token_cache_mtx); + if (!g_token_cache.empty() && g_token_cache_addr == g_wallet.addr + && (now - g_token_cache_ts) < 30.0) { + res.set_content(g_token_cache.dump(), "application/json"); + return; + } + } + auto fast = g_rpc.tokens_by_address(g_wallet.addr); + if (fast.ok && fast.result.contains("tokens")) { + { + std::lock_guard ck(g_token_cache_mtx); + g_token_cache = fast.result; + g_token_cache_ts = now; + g_token_cache_addr = g_wallet.addr; + } + res.set_content(fast.result.dump(), "application/json"); + return; + } + auto lr = g_rpc.list_contracts(); + json tokens = json::array(); + if (lr.ok && lr.result.contains("contracts")) { + auto& contracts = lr.result["contracts"]; + for (auto& c : contracts) { + std::string addr = c.value("address", ""); + if (addr.empty()) continue; + auto sr = g_rpc.contract_storage(addr, "symbol"); if (!sr.ok || !sr.result.contains("value") || sr.result["value"].is_null()) continue; std::string sym = sr.result.value("value", ""); if (sym.empty() || sym == "0") continue; @@ -2045,8 +6572,8 @@ int main(int argc, char** argv) { ? dr.result.value("value", "0") : "0"; json tok; tok["address"] = addr; - tok["name"] = name; - tok["symbol"] = sym; + tok["name"] = sanitize_display(name, 32); + tok["symbol"] = sanitize_display(sym, 16); tok["total_supply"] = supply; tok["balance"] = bal; tok["decimals"] = decimals; @@ -2058,9 +6585,12 @@ int main(int argc, char** argv) { j["tokens"] = tokens; j["count"] = tokens.size(); j["wallet_address"] = g_wallet.addr; - g_token_cache = j; - g_token_cache_ts = now; - g_token_cache_addr = g_wallet.addr; + { + std::lock_guard ck(g_token_cache_mtx); + g_token_cache = j; + g_token_cache_ts = now; + g_token_cache_addr = g_wallet.addr; + } res.set_content(j.dump(), "application/json"); }); @@ -2073,6 +6603,7 @@ int main(int argc, char** argv) { res.set_content(err_json("invalid json").dump(), "application/json"); return; } + if (!wallet_pin_ok(body, res)) return; std::string token = body.value("token", ""); std::string to = body.value("to", ""); std::string amount_str = body.value("amount", ""); @@ -2120,22 +6651,42 @@ int main(int argc, char** argv) { res.set_content(err_json("invalid json").dump(), "application/json"); return; } + if (!wallet_pin_ok(body, res)) return; std::string new_rpc = body.value("rpc_url", ""); std::string new_explorer = body.value("explorer_url", ""); + std::string new_bridge_signer = body.value("bridge_signer_url", ""); if (new_rpc.empty()) { res.status = 400; res.set_content(err_json("rpc_url required").dump(), "application/json"); return; } + if (!is_valid_http_url(new_rpc)) { + res.status = 400; + res.set_content(err_json("invalid rpc_url: must be http or https with a host").dump(), "application/json"); + return; + } + if (!new_explorer.empty() && !is_valid_http_url(new_explorer)) { + res.status = 400; + res.set_content(err_json("invalid explorer_url: must be http or https with a host").dump(), "application/json"); + return; + } + if (!new_bridge_signer.empty() && !is_valid_http_url(new_bridge_signer)) { + res.status = 400; + res.set_content(err_json("invalid bridge_signer_url: must be http or https with a host").dump(), "application/json"); + return; + } bool cache_cleared = false; try { std::string old_rpc = g_wallet.rpc_url; if (!new_explorer.empty()) g_wallet.explorer_url = new_explorer; + g_wallet.bridge_signer_url = new_bridge_signer; octra::save_settings(g_wallet_path, g_wallet, new_rpc, g_pin); g_rpc.set_url(g_wallet.rpc_url); if (old_rpc != g_wallet.rpc_url) { g_txcache.clear(); g_txcache.put("meta:rpc_url", g_wallet.rpc_url); + history_runtime_clear_all(); + token_history_runtime_clear_all(); cache_cleared = true; fprintf(stderr, "txcache cleared: rpc changed %s -> %s\n", old_rpc.c_str(), g_wallet.rpc_url.c_str()); @@ -2149,6 +6700,7 @@ int main(int argc, char** argv) { j["ok"] = true; j["rpc_url"] = g_wallet.rpc_url; j["explorer_url"] = g_wallet.explorer_url; + j["bridge_signer_url"] = g_wallet.bridge_signer_url; j["cache_cleared"] = cache_cleared; res.set_content(j.dump(), "application/json"); }); @@ -2164,15 +6716,18 @@ int main(int argc, char** argv) { } std::string cur_pin = body.value("current_pin", ""); std::string new_pin = body.value("new_pin", ""); - if (cur_pin.size() != 6 || !std::all_of(cur_pin.begin(), cur_pin.end(), ::isdigit)) { + if (cur_pin.empty()) { res.status = 400; - res.set_content(err_json("current PIN must be 6 digits").dump(), "application/json"); + res.set_content(err_json("current PIN required").dump(), "application/json"); return; } - if (new_pin.size() != 6 || !std::all_of(new_pin.begin(), new_pin.end(), ::isdigit)) { - res.status = 400; - res.set_content(err_json("new PIN must be 6 digits").dump(), "application/json"); - return; + { + std::string verr = octra::validate_pin(new_pin); + if (!verr.empty()) { + res.status = 400; + res.set_content(err_json("new PIN: " + verr).dump(), "application/json"); + return; + } } if (cur_pin != g_pin) { res.status = 403; diff --git a/octra_pre_client b/octra_pre_client new file mode 160000 index 0000000..be67158 --- /dev/null +++ b/octra_pre_client @@ -0,0 +1 @@ +Subproject commit be6715868009d45f23cd3876d140e54678887d7e diff --git a/pvac/include/pvac/core/pvac_compress.hpp b/pvac/include/pvac/core/pvac_compress.hpp index afe4670..79e6a05 100644 --- a/pvac/include/pvac/core/pvac_compress.hpp +++ b/pvac/include/pvac/core/pvac_compress.hpp @@ -1,7 +1,3 @@ -// lambda0xe note: -// mini public key arith compressor for pub key -// (17 megabytes packed into 3 megabytes) - #pragma once #include @@ -91,7 +87,7 @@ struct RangeEncoder { void encode_bit(int bit) { uint32_t p = pred.predict(); uint32_t mid = lo + ((hi - lo) >> 16) * p - + ((hi - lo & 0xffff) * p >> 16); + + (((hi - lo) & 0xffff) * p >> 16); if (bit) hi = mid; else @@ -144,7 +140,7 @@ struct RangeDecoder { int decode_bit() { uint32_t p = pred.predict(); uint32_t mid = lo + ((hi - lo) >> 16) * p - + ((hi - lo & 0xffff) * p >> 16); + + (((hi - lo) & 0xffff) * p >> 16); int bit = 0; if (code <= mid) { bit = 1; @@ -208,8 +204,11 @@ inline std::vector unpack(const uint8_t* data, size_t len) { std::vector out; out.reserve(orig_sz); int b; - while ((b = dec.decode_byte_or_eof()) >= 0) + while ((b = dec.decode_byte_or_eof()) >= 0) { + if (out.size() >= orig_sz) + throw std::runtime_error("pvac_compress: output exceeds declared size"); out.push_back((uint8_t)b); + } if (out.size() != orig_sz) throw std::runtime_error("pvac_compress: size mismatch"); return out; @@ -224,4 +223,4 @@ inline bool is_packed(const uint8_t* data, size_t len) { } } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/core/types.hpp b/pvac/include/pvac/core/types.hpp index 27914ef..0e946ac 100644 --- a/pvac/include/pvac/core/types.hpp +++ b/pvac/include/pvac/core/types.hpp @@ -122,6 +122,62 @@ struct PubKey { std::vector powg_B; }; +inline bool is_valid_cipher_shape(const Cipher& cipher) { + if (cipher.slots == 0) + return false; + if (!cipher.c0.empty() && cipher.c0.size() != cipher.slots) + return false; + for (size_t layer_id = 0; layer_id < cipher.L.size(); ++layer_id) { + const auto& layer = cipher.L[layer_id]; + if (layer.rule != RRule::BASE && layer.rule != RRule::PROD) + return false; + if (layer.rule == RRule::PROD && (layer.pa >= layer_id || layer.pb >= layer_id)) + return false; + if (layer.rule == RRule::PROD && !layer.PC.empty()) + return false; + if (!layer.PC.empty() && layer.PC.size() != cipher.slots) + return false; + } + for (const auto& edge : cipher.E) { + if (edge.layer_id >= cipher.L.size()) + return false; + if (edge.ch != SGN_P && edge.ch != SGN_M) + return false; + if (edge.w.size() != cipher.slots) + return false; + } + return true; +} + +inline bool is_valid_pubkey_shape(const PubKey& pk) { + if (pk.prm.B <= 0 || pk.prm.m_bits <= 0 || pk.prm.n_bits <= 0) + return false; + if (pk.H.size() != static_cast(pk.prm.n_bits)) + return false; + if (pk.ubk.perm.size() != static_cast(pk.prm.m_bits) || + pk.ubk.inv.size() != static_cast(pk.prm.m_bits)) + return false; + if (pk.powg_B.size() != static_cast(pk.prm.B)) + return false; + for (const auto& column : pk.H) { + if (column.nbits != static_cast(pk.prm.m_bits)) + return false; + } + return true; +} + +inline bool is_cipher_compatible_with_pubkey(const PubKey& pk, const Cipher& cipher) { + if (!is_valid_pubkey_shape(pk) || !is_valid_cipher_shape(cipher)) + return false; + for (const auto& edge : cipher.E) { + if (edge.idx >= pk.powg_B.size()) + return false; + if (edge.s.nbits != static_cast(pk.prm.m_bits)) + return false; + } + return true; +} + struct SecKey { std::array prf_k; std::vector lpn_s_bits; @@ -147,4 +203,4 @@ inline Fp rand_fp_nonzero() { } } } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/crypto/bulletproofs/generators.hpp b/pvac/include/pvac/crypto/bulletproofs/generators.hpp index 44ca3c5..909341c 100644 --- a/pvac/include/pvac/crypto/bulletproofs/generators.hpp +++ b/pvac/include/pvac/crypto/bulletproofs/generators.hpp @@ -3,12 +3,16 @@ #include #include #include +#include +#include #include "../../core/hash.hpp" #include "../ristretto255.hpp" namespace pvac { namespace bp { +inline constexpr size_t BP_MAX_VECTOR_SIZE = static_cast(1) << 20; + inline RistrettoPoint hash_to_ristretto_point(const char* domain, uint64_t index) { Sha256 h; @@ -60,16 +64,24 @@ class GeneratorTable { mutable std::vector H_; mutable std::mutex mtx_; - void ensure_size(size_t n) const { - if (G_.size() >= n) return; - size_t old = G_.size(); - G_.resize(n); - H_.resize(n); - for (size_t i = old; i < n; i++) { - G_[i] = hash_to_ristretto_point("pvac.bp.gen.G", i); - H_[i] = hash_to_ristretto_point("pvac.bp.gen.H", i); + void ensure_size(size_t n) const { + if (n > BP_MAX_VECTOR_SIZE) + throw std::runtime_error("pvac: generator size rejected"); + if (G_.size() != H_.size()) + throw std::runtime_error("pvac: generator table shape rejected"); + if (G_.size() >= n) return; + size_t old = G_.size(); + auto next_G = G_; + auto next_H = H_; + next_G.resize(n); + next_H.resize(n); + for (size_t i = old; i < n; i++) { + next_G[i] = hash_to_ristretto_point("pvac.bp.gen.G", i); + next_H[i] = hash_to_ristretto_point("pvac.bp.gen.H", i); + } + G_.swap(next_G); + H_.swap(next_H); } - } public: GeneratorTable() = default; @@ -121,21 +133,24 @@ inline const RistrettoPoint& pedersen_B_blinding() { inline size_t next_power_of_2(size_t n) { if (n == 0) return 1; - n--; - n |= n >> 1; - n |= n >> 2; - n |= n >> 4; - n |= n >> 8; - n |= n >> 16; - n |= n >> 32; - return n + 1; + if (n > BP_MAX_VECTOR_SIZE) + throw std::runtime_error("pvac: vector size rejected"); + size_t out = 1; + while (out < n) { + if (out > BP_MAX_VECTOR_SIZE / 2) + throw std::runtime_error("pvac: vector size overflow"); + out <<= 1; + } + return out; } inline size_t log2_size(size_t n) { + if (n == 0 || n > BP_MAX_VECTOR_SIZE || (n & (n - 1))) + throw std::runtime_error("pvac: log2 size rejected"); size_t r = 0; while ((1ULL << r) < n) r++; return r; } } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/crypto/bulletproofs/inner_product.hpp b/pvac/include/pvac/crypto/bulletproofs/inner_product.hpp index 4ca7e82..20a31c7 100644 --- a/pvac/include/pvac/crypto/bulletproofs/inner_product.hpp +++ b/pvac/include/pvac/crypto/bulletproofs/inner_product.hpp @@ -27,7 +27,7 @@ inline RistrettoPoint multi_scalar_mul( std::vector pts(n); for (size_t i = 0; i < n; i++) - rist_decode(pts[i], points[i]); + pts[i] = rist_decode_or_throw(points[i]); std::vector> sbytes(n); for (size_t i = 0; i < n; i++) @@ -315,4 +315,4 @@ inline bool ipp_verify( } } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/crypto/bulletproofs/r1cs_verifier.hpp b/pvac/include/pvac/crypto/bulletproofs/r1cs_verifier.hpp index 4aea8bc..73bc361 100644 --- a/pvac/include/pvac/crypto/bulletproofs/r1cs_verifier.hpp +++ b/pvac/include/pvac/crypto/bulletproofs/r1cs_verifier.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "transcript.hpp" #include "generators.hpp" #include "inner_product.hpp" @@ -12,6 +13,11 @@ namespace pvac { namespace bp { +inline constexpr size_t R1CS_MAX_GATES = BP_MAX_VECTOR_SIZE; +inline constexpr size_t R1CS_MAX_COMMITTED = static_cast(1) << 16; +inline constexpr size_t R1CS_MAX_CONSTRAINTS = static_cast(1) << 20; +inline constexpr size_t R1CS_MAX_TERMS = static_cast(1) << 22; + struct ConstraintSystem { size_t num_gates; size_t num_committed; @@ -20,8 +26,86 @@ struct ConstraintSystem { size_t padded_gates() const { return next_power_of_2(num_gates > 0 ? num_gates : 1); } + + bool padded_gates_checked(size_t& out) const { + if (num_gates > R1CS_MAX_GATES) + return false; + try { + out = padded_gates(); + } catch (...) { + return false; + } + return out != 0 && out <= R1CS_MAX_GATES; + } }; +inline bool r1cs_var_ok(const Variable& var, size_t gates, size_t committed) { + switch (var.type) { + case VarType::ONE: + return var.index == 0; + case VarType::COMMITTED: + return var.index < committed; + case VarType::MULT_LEFT: + case VarType::MULT_RIGHT: + case VarType::MULT_OUT: + return var.index < gates; + } + return false; +} + +inline bool r1cs_system_ok(const ConstraintSystem& cs, size_t N) { + if (N == 0 || N > R1CS_MAX_GATES) + return false; + if (cs.num_gates > R1CS_MAX_GATES) + return false; + if (cs.num_committed > R1CS_MAX_COMMITTED) + return false; + if (cs.constraints.size() > R1CS_MAX_CONSTRAINTS) + return false; + size_t terms = 0; + for (const auto& constraint : cs.constraints) { + if (constraint.lc.terms.size() > R1CS_MAX_TERMS - terms) + return false; + terms += constraint.lc.terms.size(); + for (const auto& term : constraint.lc.terms) { + if (!r1cs_var_ok(term.first, cs.num_gates, cs.num_committed)) + return false; + } + } + return true; +} + +inline bool rist_valid(const RistrettoPoint& point) { + ExtPoint decoded; + return rist_decode(decoded, point); +} + +inline bool ipp_points_valid(const InnerProductProof& proof) { + if (proof.L.size() != proof.R.size()) return false; + for (const auto& point : proof.L) + if (!rist_valid(point)) + return false; + for (const auto& point : proof.R) + if (!rist_valid(point)) + return false; + return true; +} + +inline bool r1cs_points_valid(const R1CSProof& proof) { + if (!rist_valid(proof.A_I1)) return false; + if (!rist_valid(proof.A_O1)) return false; + if (!rist_valid(proof.S1)) return false; + if (!rist_valid(proof.T_1)) return false; + if (!rist_valid(proof.T_3)) return false; + if (!rist_valid(proof.T_4)) return false; + if (!rist_valid(proof.T_5)) return false; + if (!rist_valid(proof.T_6)) return false; + for (const auto& point : proof.V) + if (!rist_valid(point)) + return false; + return ipp_points_valid(proof.ipp); +} + inline bool ipp_verify_with_y( Transcript& transcript, const RistrettoPoint& P, @@ -30,8 +114,14 @@ inline bool ipp_verify_with_y( size_t n, const std::vector& y_inv_n ) { + if (!rist_valid(P)) return false; + if (!rist_valid(Q)) return false; + if (!ipp_points_valid(proof)) return false; + if (n == 0 || n > BP_MAX_VECTOR_SIZE || y_inv_n.size() != n) return false; + size_t lg = proof.L.size(); if (proof.R.size() != lg) return false; + if (lg >= std::numeric_limits::digits) return false; if ((1ULL << lg) != n) return false; std::vector challenges(lg); @@ -90,9 +180,12 @@ inline bool r1cs_verify( ) { const size_t m = cs.num_committed; const size_t q = cs.constraints.size(); - const size_t N = cs.padded_gates(); + size_t N = 0; + if (!cs.padded_gates_checked(N)) return false; + if (!r1cs_system_ok(cs, N)) return false; if (proof.V.size() != m) return false; + if (!r1cs_points_valid(proof)) return false; transcript.append_u64("n", N); transcript.append_u64("m", m); @@ -252,4 +345,4 @@ inline bool r1cs_verify( } } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/crypto/keygen.hpp b/pvac/include/pvac/crypto/keygen.hpp index 74cdf18..59e7e33 100644 --- a/pvac/include/pvac/crypto/keygen.hpp +++ b/pvac/include/pvac/crypto/keygen.hpp @@ -36,10 +36,13 @@ inline std::vector factor_small(int n) { inline void keygen(const Params & prm, PubKey & pk, SecKey & sk) { pk.prm = prm; + if (pk.prm.B == 0) + throw std::runtime_error("pvac: keygen: prm.B is zero"); + u128 pm1 = (((u128)1) << 127) - 2; if ((pm1 % (u128)pk.prm.B) != 0) { - std::abort(); + throw std::runtime_error("pvac: keygen: prm.B does not divide pm1"); } pk.canon_tag = csprng_u64(); @@ -140,10 +143,13 @@ inline void keygen(const Params & prm, PubKey & pk, SecKey & sk) { inline void keygen_from_seed(const Params& prm, PubKey& pk, SecKey& sk, const uint8_t wallet_privkey[32]) { pk.prm = prm; + if (pk.prm.B == 0) + throw std::runtime_error("pvac: keygen_from_seed: prm.B is zero"); + u128 pm1 = (((u128)1) << 127) - 2; if ((pm1 % (u128)pk.prm.B) != 0) { - std::abort(); + throw std::runtime_error("pvac: keygen_from_seed: prm.B does not divide pm1"); } uint8_t master[32]; @@ -245,4 +251,4 @@ inline void keygen_from_seed(const Params& prm, PubKey& pk, SecKey& sk, const ui } } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/crypto/ristretto255.hpp b/pvac/include/pvac/crypto/ristretto255.hpp index 86edb21..ffff847 100644 --- a/pvac/include/pvac/crypto/ristretto255.hpp +++ b/pvac/include/pvac/crypto/ristretto255.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "../core/hash.hpp" #include "../core/field.hpp" @@ -238,6 +239,14 @@ inline Scalar sc_from_bytes(const uint8_t s[32]) { return r; } +inline bool sc_is_canonical(const Scalar& a) { + for (int i = 3; i >= 0; --i) { + if (a.v[i] < SC_L[i]) return true; + if (a.v[i] > SC_L[i]) return false; + } + return false; +} + inline void sc_tobytes(uint8_t s[32], const Scalar& a) { for (int i = 0; i < 4; i++) { for (int j = 0; j < 8; j++) @@ -384,9 +393,6 @@ inline Scalar sc_from_fp_signed(const Fp& x) { } inline Scalar sc_random() { - uint8_t buf[64]; - for (int i = 0; i < 64; i++) buf[i] = (uint8_t)(csprng_u64() >> (i % 8 * 8)); - uint64_t r[8]; for (int i = 0; i < 8; i++) r[i] = csprng_u64(); return sc_reduce512(r); @@ -658,23 +664,27 @@ inline bool rist_decode(ExtPoint& P, const RistrettoPoint& bytes) { return true; } +inline ExtPoint rist_decode_or_throw(const RistrettoPoint& bytes) { + ExtPoint P; + if (!rist_decode(P, bytes)) + throw std::runtime_error("pvac: invalid Ristretto point encoding"); + return P; +} + inline RistrettoPoint rist_add(const RistrettoPoint& a, const RistrettoPoint& b) { - ExtPoint P, Q; - rist_decode(P, a); - rist_decode(Q, b); + ExtPoint P = rist_decode_or_throw(a); + ExtPoint Q = rist_decode_or_throw(b); return rist_encode(ext_add(P, Q)); } inline RistrettoPoint rist_sub(const RistrettoPoint& a, const RistrettoPoint& b) { - ExtPoint P, Q; - rist_decode(P, a); - rist_decode(Q, b); + ExtPoint P = rist_decode_or_throw(a); + ExtPoint Q = rist_decode_or_throw(b); return rist_encode(ext_sub(P, Q)); } inline RistrettoPoint rist_scalarmul(const RistrettoPoint& pt, const Scalar& s) { - ExtPoint P; - rist_decode(P, pt); + ExtPoint P = rist_decode_or_throw(pt); return rist_encode(ext_scalarmul(P, s)); } @@ -746,8 +756,10 @@ inline RistrettoPoint pedersen_commit(const Scalar& value, const Scalar& blindin ExtPoint G_pt, H_pt; RistrettoPoint G_enc = rist_G(); RistrettoPoint H_enc = rist_H(); - rist_decode(G_pt, G_enc); - rist_decode(H_pt, H_enc); + if (!rist_decode(G_pt, G_enc)) + throw std::runtime_error("pvac: invalid Pedersen G generator"); + if (!rist_decode(H_pt, H_enc)) + throw std::runtime_error("pvac: invalid Pedersen H generator"); ExtPoint vG = ext_scalarmul(G_pt, value); ExtPoint bH = ext_scalarmul(H_pt, blinding); @@ -759,4 +771,4 @@ inline RistrettoPoint pedersen_commit_fp(const Fp& value, const Scalar& blinding return pedersen_commit(sc_from_fp(value), blinding); } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/ops/arithmetic.hpp b/pvac/include/pvac/ops/arithmetic.hpp index 15b32dd..11f9166 100644 --- a/pvac/include/pvac/ops/arithmetic.hpp +++ b/pvac/include/pvac/ops/arithmetic.hpp @@ -280,6 +280,8 @@ inline Cipher ct_neg(const PubKey& pk, const Cipher& A) { } inline Cipher ct_add(const PubKey& pk, const Cipher& A, const Cipher& B) { + if (A.slots != B.slots) + throw std::runtime_error("pvac: ct_add: slot count mismatch between operands"); Cipher C; C.slots = A.slots; C.c0 = A.c0.empty() ? B.c0 : B.c0.empty() ? A.c0 : field::Op::add(A.c0, B.c0); @@ -472,4 +474,4 @@ inline Cipher ct_sub_const(const PubKey& pk, const Cipher& A, int64_t k) { return ct_add_const(pk, A, -k); } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/ops/decrypt.hpp b/pvac/include/pvac/ops/decrypt.hpp index 4f26c5f..5a05b34 100644 --- a/pvac/include/pvac/ops/decrypt.hpp +++ b/pvac/include/pvac/ops/decrypt.hpp @@ -2,6 +2,7 @@ #include #include +#include #include "../core/types.hpp" #include "../crypto/lpn.hpp" @@ -17,12 +18,15 @@ inline std::vector layer_R_cached( std::vector& st, std::vector>& cache ) { - if ((size_t)lid >= C.L.size()) std::abort(); + if ((size_t)lid >= C.L.size()) + throw std::runtime_error("pvac: layer_R_cached: layer id out of range"); + if (st.size() != C.L.size() || cache.size() != C.L.size()) + throw std::runtime_error("pvac: layer_R_cached: work buffer shape rejected"); if (st[lid] == 2) return cache[lid]; if (st[lid] == 1) { - std::abort(); + throw std::runtime_error("pvac: layer_R_cached: cycle in layer dependency graph"); } st[lid] = 1; @@ -42,6 +46,8 @@ inline std::vector layer_R_cached( } inline std::vector dec_values(const PubKey& pk, const SecKey& sk, const Cipher& C) { + if (!is_cipher_compatible_with_pubkey(pk, C)) + throw std::runtime_error("pvac: cipher/pubkey mismatch"); size_t L = C.L.size(); size_t S = C.slots; @@ -73,7 +79,16 @@ inline std::vector dec_values(const PubKey& pk, const SecKey& sk, const Ciph } inline Fp dec_value(const PubKey& pk, const SecKey& sk, const Cipher& C) { + if (C.slots != 1) + throw std::runtime_error("pvac: dec_value: cipher has multi-slot payload; use dec_values for vector decryption or dec_value_slot0 to explicitly coerce"); return dec_values(pk, sk, C)[0]; } +inline Fp dec_value_slot0(const PubKey& pk, const SecKey& sk, const Cipher& C) { + auto v = dec_values(pk, sk, C); + if (v.empty()) + throw std::runtime_error("pvac: dec_value_slot0: cipher decrypts to empty value vector"); + return v[0]; } + +} \ No newline at end of file diff --git a/pvac/include/pvac/ops/encrypt.hpp b/pvac/include/pvac/ops/encrypt.hpp index bffb65f..f1e2e5e 100644 --- a/pvac/include/pvac/ops/encrypt.hpp +++ b/pvac/include/pvac/ops/encrypt.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include "../core/types.hpp" @@ -973,9 +974,39 @@ inline Cipher enc_fp_depth_seeded(const PubKey& pk, const SecKey& sk, const std: return core::synth_seeded(pk, sk, v, d, rng); } +inline void seed_mix_u64(Sha256& h, uint64_t x) { + uint8_t b[8]; + for (int i = 0; i < 8; ++i) b[7 - i] = static_cast(x >> (i * 8)); + h.update(b, sizeof(b)); +} + +inline void seed_mix_fp(Sha256& h, const Fp& x) { + seed_mix_u64(h, x.lo); + seed_mix_u64(h, x.hi); +} + +inline std::array enc_seed_scope(const PubKey& pk, const uint8_t seed[32], const char* op, uint64_t slots, int depth, const std::vector& values) { + Sha256 h; + h.init(); + const char dom[] = "pvac.enc.seed.v2"; + h.update(dom, sizeof(dom) - 1); + h.update(seed, 32); + seed_mix_u64(h, pk.canon_tag); + h.update(pk.H_digest.data(), pk.H_digest.size()); + h.update(op, std::strlen(op)); + seed_mix_u64(h, slots); + seed_mix_u64(h, static_cast(static_cast(depth))); + seed_mix_u64(h, values.size()); + for (const auto& value : values) seed_mix_fp(h, value); + std::array out{}; + h.finish(out.data()); + return out; +} + inline Cipher enc_value_seeded(const PubKey& pk, const SecKey& sk, uint64_t v, const uint8_t seed[32]) { - SeedableRng rng = make_seeded_rng(seed); std::vector vals = {fp_from_u64(v)}; + auto scoped = enc_seed_scope(pk, seed, "value", vals.size(), 0, vals); + SeedableRng rng = make_seeded_rng(scoped.data()); std::vector m = {rng.fp_nonzero()}; return combine_ciphers(pk, enc_fp_depth_seeded(pk, sk, field::Op::add(vals, m), 0, rng), @@ -983,8 +1014,9 @@ inline Cipher enc_value_seeded(const PubKey& pk, const SecKey& sk, uint64_t v, c } inline Cipher enc_value_depth_seeded(const PubKey& pk, const SecKey& sk, uint64_t v, int d, const uint8_t seed[32]) { - SeedableRng rng = make_seeded_rng(seed); std::vector vals = {fp_from_u64(v)}; + auto scoped = enc_seed_scope(pk, seed, "value_depth", vals.size(), d, vals); + SeedableRng rng = make_seeded_rng(scoped.data()); std::vector m = {rng.fp_nonzero()}; return combine_ciphers(pk, enc_fp_depth_seeded(pk, sk, field::Op::add(vals, m), d, rng), @@ -992,21 +1024,25 @@ inline Cipher enc_value_depth_seeded(const PubKey& pk, const SecKey& sk, uint64_ } inline Cipher enc_values_seeded(const PubKey& pk, const SecKey& sk, const std::vector& v, const uint8_t seed[32]) { - SeedableRng rng = make_seeded_rng(seed); size_t S = v.size(); std::vector vals(S), m(S); - for (size_t j = 0; j < S; ++j) { vals[j] = fp_from_u64(v[j]); m[j] = rng.fp_nonzero(); } + for (size_t j = 0; j < S; ++j) vals[j] = fp_from_u64(v[j]); + auto scoped = enc_seed_scope(pk, seed, "values", vals.size(), 0, vals); + SeedableRng rng = make_seeded_rng(scoped.data()); + for (size_t j = 0; j < S; ++j) m[j] = rng.fp_nonzero(); return combine_ciphers(pk, enc_fp_depth_seeded(pk, sk, field::Op::add(vals, m), 0, rng), enc_fp_depth_seeded(pk, sk, field::Op::neg(m), 0, rng)); } inline Cipher enc_zero_seeded(const PubKey& pk, const SecKey& sk, const uint8_t seed[32]) { - SeedableRng rng = make_seeded_rng(seed); + std::vector vals = {field::Op::zero()}; + auto scoped = enc_seed_scope(pk, seed, "zero", vals.size(), 0, vals); + SeedableRng rng = make_seeded_rng(scoped.data()); std::vector m = {rng.fp_nonzero()}; return combine_ciphers(pk, enc_fp_depth_seeded(pk, sk, m, 0, rng), enc_fp_depth_seeded(pk, sk, field::Op::neg(m), 0, rng)); } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/ops/range_proof.hpp b/pvac/include/pvac/ops/range_proof.hpp index dc934f6..9d0fcb6 100644 --- a/pvac/include/pvac/ops/range_proof.hpp +++ b/pvac/include/pvac/ops/range_proof.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "../core/types.hpp" #include "verify_zero.hpp" @@ -16,6 +17,11 @@ namespace pvac { static constexpr size_t RANGE_BITS = 64; +static constexpr size_t RANGE_MAX_VALUE_LAYERS = 512; +static constexpr size_t RANGE_MAX_VALUE_EDGES = 65536; +static constexpr size_t RANGE_MAX_BIT_LAYERS = 4; +static constexpr size_t RANGE_MAX_BIT_EDGES = 8192; +static constexpr size_t RANGE_MAX_SLOTS = 1; struct RangeProof { @@ -26,12 +32,31 @@ struct RangeProof { ZeroProof lc_proof; }; +inline bool range_value_cipher_ok(const PubKey& pk, const Cipher& ct) { + return + is_cipher_compatible_with_pubkey(pk, ct) && + ct.slots > 0 && + ct.slots <= RANGE_MAX_SLOTS && + ct.L.size() <= RANGE_MAX_VALUE_LAYERS && + ct.E.size() <= RANGE_MAX_VALUE_EDGES; +} + +inline bool range_bit_cipher_ok(const PubKey& pk, const Cipher& ct, size_t slots) { + return + is_cipher_compatible_with_pubkey(pk, ct) && + ct.slots == slots && + ct.L.size() <= RANGE_MAX_BIT_LAYERS && + ct.E.size() <= RANGE_MAX_BIT_EDGES; +} + inline RangeProof make_range_proof( const PubKey& pk, const SecKey& sk, const Cipher& ct_value, uint64_t value ) { + if (!range_value_cipher_ok(pk, ct_value)) + throw std::runtime_error("pvac: range proof value rejected"); RangeProof rp; rp.ct_bit.resize(RANGE_BITS); rp.bit_proofs.resize(RANGE_BITS); @@ -43,9 +68,6 @@ inline RangeProof make_range_proof( for (size_t i = 0; i < RANGE_BITS; ++i) { uint64_t b_i = (value >> i) & 1; rp.ct_bit[i] = enc_value(pk, sk, b_i); - - - //!! auto ct_b_m1 = ct_sub_const(pk, rp.ct_bit[i], (uint64_t)1); uint8_t mul_seed[32]; for (int k = 0; k < 32; ++k) @@ -60,8 +82,6 @@ inline RangeProof make_range_proof( rp.ct_bit[i] = enc_value(pk, sk, b_i); auto ct_b_m1 = ct_sub_const(pk, rp.ct_bit[i], (uint64_t)1); uint8_t mul_seed[32]; - - // ! for (int k = 0; k < 32; ++k) mul_seed[k] = (uint8_t)((i * 37 + k * 13 + 0xA0) & 0xFF); auto ct_check = ct_mul_seeded(pk, rp.ct_bit[i], ct_b_m1, mul_seed); @@ -109,14 +129,15 @@ inline bool verify_range( const Cipher& ct_value, const RangeProof& rp ) { + if (!range_value_cipher_ok(pk, ct_value)) return false; if (rp.ct_bit.size() != RANGE_BITS) return false; if (rp.bit_proofs.size() != RANGE_BITS) return false; + for (const auto& ct_bit : rp.ct_bit) + if (!range_bit_cipher_ok(pk, ct_bit, ct_value.slots)) + return false; unsigned hw = std::thread::hardware_concurrency(); - - - // ! unsigned n_threads = (hw > 1) ? std::min(hw, (unsigned)RANGE_BITS) : 1; std::vector results(RANGE_BITS, false); @@ -162,7 +183,6 @@ inline bool verify_range( for (size_t i = 1; i < RANGE_BITS; ++i) { Fp power_of_two; - if (i < 64) { power_of_two = fp_from_u64(1ULL << i); } else { @@ -182,9 +202,10 @@ inline bool verify_range( return true; } + struct AggregatedRangeProof { - std::vector ct_bit;// 64 encrypted bits (needed by verifier) - bp::R1CSProof proof; // single R1CS proof covering all 65 circuits + std::vector ct_bit; + bp::R1CSProof proof; }; namespace detail { @@ -230,7 +251,7 @@ inline void prepare_lc( const Cipher& ct_lc_diff, BitPrepData& out ) { - out.ct_check = ct_lc_diff; // reuse field for the LC cipher + out.ct_check = ct_lc_diff; size_t nL = ct_lc_diff.L.size(); size_t S = ct_lc_diff.slots; @@ -289,6 +310,8 @@ inline AggregatedRangeProof make_aggregated_range_proof( const Cipher& ct_value, uint64_t value ) { + if (!range_value_cipher_ok(pk, ct_value)) + throw std::runtime_error("pvac: aggregated range value rejected"); AggregatedRangeProof arp; arp.ct_bit.resize(RANGE_BITS); @@ -346,7 +369,11 @@ inline bool verify_aggregated_range( const Cipher& ct_value, const AggregatedRangeProof& arp ) { + if (!range_value_cipher_ok(pk, ct_value)) return false; if (arp.ct_bit.size() != RANGE_BITS) return false; + for (const auto& ct_bit : arp.ct_bit) + if (!range_bit_cipher_ok(pk, ct_bit, ct_value.slots)) + return false; std::vector vdata(RANGE_BITS); for (size_t i = 0; i < RANGE_BITS; ++i) { @@ -376,6 +403,7 @@ inline bool verify_aggregated_range( nullptr, nullptr); size_t expected_v = dummy.num_committed(); + if (expected_v > bp::R1CS_MAX_COMMITTED) return false; if (arp.proof.V.size() != expected_v) return false; size_t v_offset = 0; @@ -394,6 +422,7 @@ inline bool verify_aggregated_range( } v_offset += nB * S; } + { size_t nB = lc_data.bases.size(); size_t S = ct_lc_diff.slots; @@ -418,4 +447,4 @@ inline bool verify_aggregated_range( return bp::r1cs_verify(transcript, cs, arp.proof); } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/ops/verify_zero.hpp b/pvac/include/pvac/ops/verify_zero.hpp index 58a0fa8..208b9e5 100644 --- a/pvac/include/pvac/ops/verify_zero.hpp +++ b/pvac/include/pvac/ops/verify_zero.hpp @@ -18,6 +18,12 @@ inline std::vector> compute_layer_coeffs( std::vector> A(nL, std::vector(S, Fp{0,0})); for (const auto& e : ct.E) { + if (e.layer_id >= nL) + throw std::runtime_error("pvac: edge layer out of range"); + if (e.idx >= pk.powg_B.size()) + throw std::runtime_error("pvac: edge public-key index out of range"); + if (e.w.size() != S) + throw std::runtime_error("pvac: edge weight/slots size mismatch"); Fp gp = pk.powg_B[e.idx]; int sg = sgn_val(e.ch); for (size_t j = 0; j < S; j++) { @@ -58,4 +64,4 @@ inline std::vector base_layer_indices(const Cipher& ct) { return bases; } -} +} \ No newline at end of file diff --git a/pvac/include/pvac/ops/verify_zero_circuit.hpp b/pvac/include/pvac/ops/verify_zero_circuit.hpp index 61e226b..c9c89c9 100644 --- a/pvac/include/pvac/ops/verify_zero_circuit.hpp +++ b/pvac/include/pvac/ops/verify_zero_circuit.hpp @@ -48,6 +48,21 @@ inline CircuitWiring build_circuit( size_t S = ct.slots; size_t nB = bases.size(); + if (A.size() != nL) + throw std::runtime_error("pvac: coefficient/layer size mismatch"); + for (size_t lid = 0; lid < nL; ++lid) { + if (A[lid].size() != S) + throw std::runtime_error("pvac: coefficient/slots size mismatch"); + const auto& layer = ct.L[lid]; + if (layer.rule == RRule::PROD && + (layer.pa >= lid || layer.pb >= lid)) + throw std::runtime_error("pvac: invalid product parent"); + if (layer.rule == RRule::PROD && !layer.PC.empty()) + throw std::runtime_error("pvac: product layer must not contain PC"); + if (layer.rule != RRule::BASE && layer.rule != RRule::PROD) + throw std::runtime_error("pvac: invalid layer rule"); + } + CircuitWiring w; w.layer_vars.resize(nL); for (size_t lid = 0; lid < nL; lid++) @@ -187,6 +202,7 @@ inline bool verify_zero( const PubKey& pk, const Cipher& ct, const ZeroProof& proof ) { + if (!is_cipher_compatible_with_pubkey(pk, ct)) return false; size_t nL = ct.L.size(); size_t S = ct.slots; @@ -267,6 +283,7 @@ inline bool verify_zero_bound( const ZeroProof& proof, const RistrettoPoint& amount_commitment ) { + if (!is_cipher_compatible_with_pubkey(pk, ct)) return false; size_t nL = ct.L.size(); size_t S = ct.slots; @@ -304,4 +321,4 @@ inline bool verify_zero_bound( return bp::r1cs_verify(transcript, cs, proof.proof); } -} +} \ No newline at end of file diff --git a/pvac/pvac_c_api.cpp b/pvac/pvac_c_api.cpp index 3bd0e4a..12d1a7f 100644 --- a/pvac/pvac_c_api.cpp +++ b/pvac/pvac_c_api.cpp @@ -15,6 +15,19 @@ #define RP(h) (reinterpret_cast(h)) #define ARP(h) (reinterpret_cast(h)) +static uint8_t* copy_bytes(const std::vector& buf, size_t* len) noexcept { + if (len) + *len = 0; + if (!len || buf.empty()) + return nullptr; + auto* out = static_cast(std::malloc(buf.size())); + if (!out) + return nullptr; + std::memcpy(out, buf.data(), buf.size()); + *len = buf.size(); + return out; +} + extern "C" { pvac_params pvac_default_params(void) { @@ -47,15 +60,26 @@ pvac_cipher pvac_enc_zero_seeded(pvac_pubkey pk, pvac_seckey sk, } uint64_t pvac_dec_value(pvac_pubkey pk, pvac_seckey sk, pvac_cipher ct) { - pvac::Fp r = pvac::dec_value(*PK(pk), *SK(sk), *CT(ct)); - return r.lo; + try { + pvac::Fp r = pvac::dec_value(*PK(pk), *SK(sk), *CT(ct)); + return r.lo; + } catch (...) { + return 0; + } } void pvac_dec_value_fp(pvac_pubkey pk, pvac_seckey sk, pvac_cipher ct, uint64_t* lo_out, uint64_t* hi_out) { - pvac::Fp r = pvac::dec_value(*PK(pk), *SK(sk), *CT(ct)); - *lo_out = r.lo; - *hi_out = r.hi; + if (!lo_out || !hi_out) + return; + try { + pvac::Fp r = pvac::dec_value(*PK(pk), *SK(sk), *CT(ct)); + *lo_out = r.lo; + *hi_out = r.hi; + } catch (...) { + *lo_out = 0; + *hi_out = 0; + } } pvac_cipher pvac_enc_value_fp_seeded(pvac_pubkey pk, pvac_seckey sk, @@ -134,10 +158,24 @@ pvac_cipher pvac_ct_square_seeded(pvac_pubkey pk, pvac_cipher ct, const uint8_t } void pvac_commit_ct(pvac_pubkey pk, pvac_cipher ct, uint8_t out[32]) { + if (!out) + return; auto h = pvac::commit_ct(*PK(pk), *CT(ct)); std::memcpy(out, h.data(), 32); } +int pvac_commit_ct_v2(pvac_pubkey pk, pvac_cipher ct, uint8_t *out, size_t out_cap, size_t *out_len) { + if (out_len) *out_len = 32; + if (!out || out_cap < 32) return -1; + try { + auto h = pvac::commit_ct(*PK(pk), *CT(ct)); + std::memcpy(out, h.data(), 32); + return 0; + } catch (...) { + return -2; + } +} + pvac_zero_proof pvac_make_zero_proof(pvac_pubkey pk, pvac_seckey sk, pvac_cipher ct) { auto* zp = new pvac::ZeroProof(); *zp = pvac::make_zero_proof(*PK(pk), *SK(sk), *CT(ct)); @@ -145,7 +183,11 @@ pvac_zero_proof pvac_make_zero_proof(pvac_pubkey pk, pvac_seckey sk, pvac_cipher } int pvac_verify_zero(pvac_pubkey pk, pvac_cipher ct, pvac_zero_proof proof) { - return pvac::verify_zero(*PK(pk), *CT(ct), *ZP(proof)) ? 1 : 0; + try { + return pvac::verify_zero(*PK(pk), *CT(ct), *ZP(proof)) ? 1 : 0; + } catch (...) { + return 0; + } } pvac_zero_proof pvac_make_zero_proof_bound(pvac_pubkey pk, pvac_seckey sk, pvac_cipher ct, @@ -160,16 +202,40 @@ int pvac_verify_zero_bound(pvac_pubkey pk, pvac_cipher ct, pvac_zero_proof proof const uint8_t amount_commitment[32]) { pvac::RistrettoPoint commit; std::memcpy(commit.data(), amount_commitment, 32); - return pvac::verify_zero_bound(*PK(pk), *CT(ct), *ZP(proof), commit) ? 1 : 0; + try { + pvac::ExtPoint decoded_commit; + if (!pvac::rist_decode(decoded_commit, commit)) + return 0; + return pvac::verify_zero_bound(*PK(pk), *CT(ct), *ZP(proof), commit) ? 1 : 0; + } catch (...) { + return 0; + } } void pvac_pedersen_commit(uint64_t amount, const uint8_t blinding[32], uint8_t out[32]) { + if (!out) + return; pvac::Scalar val = pvac::bp::sc_from_u64(amount); pvac::Scalar blind = pvac::sc_reduce256(blinding); pvac::RistrettoPoint pt = pvac::pedersen_commit(val, blind); std::memcpy(out, pt.data(), 32); } +int pvac_pedersen_commit_v2(uint64_t amount, const uint8_t blinding[32], + uint8_t *out, size_t out_cap, size_t *out_len) { + if (out_len) *out_len = 32; + if (!out || out_cap < 32) return -1; + try { + pvac::Scalar val = pvac::bp::sc_from_u64(amount); + pvac::Scalar blind = pvac::sc_reduce256(blinding); + pvac::RistrettoPoint pt = pvac::pedersen_commit(val, blind); + std::memcpy(out, pt.data(), 32); + return 0; + } catch (...) { + return -2; + } +} + pvac_range_proof pvac_make_range_proof(pvac_pubkey pk, pvac_seckey sk, pvac_cipher ct, uint64_t value) { auto* rp = new pvac::RangeProof(); @@ -178,15 +244,21 @@ pvac_range_proof pvac_make_range_proof(pvac_pubkey pk, pvac_seckey sk, } int pvac_verify_range(pvac_pubkey pk, pvac_cipher ct, pvac_range_proof proof) { - return pvac::verify_range(*PK(pk), *CT(ct), *RP(proof)) ? 1 : 0; + try { + return pvac::verify_range(*PK(pk), *CT(ct), *RP(proof)) ? 1 : 0; + } catch (...) { + return 0; + } } uint8_t* pvac_serialize_cipher(pvac_cipher ct, size_t* len) { - auto buf = pvac_ser::serialize_cipher(*CT(ct)); - *len = buf.size(); - auto* out = (uint8_t*)std::malloc(buf.size()); - std::memcpy(out, buf.data(), buf.size()); - return out; + try { + return copy_bytes(pvac_ser::serialize_cipher(*CT(ct)), len); + } catch (...) { + if (len) + *len = 0; + return nullptr; + } } pvac_cipher pvac_deserialize_cipher(const uint8_t* data, size_t len) { @@ -204,11 +276,13 @@ pvac_cipher pvac_deserialize_cipher(const uint8_t* data, size_t len) { } uint8_t* pvac_serialize_pubkey(pvac_pubkey pk, size_t* len) { - auto buf = pvac_ser::serialize_pubkey(*PK(pk)); - *len = buf.size(); - auto* out = (uint8_t*)std::malloc(buf.size()); - std::memcpy(out, buf.data(), buf.size()); - return out; + try { + return copy_bytes(pvac_ser::serialize_pubkey(*PK(pk)), len); + } catch (...) { + if (len) + *len = 0; + return nullptr; + } } pvac_pubkey pvac_deserialize_pubkey(const uint8_t* data, size_t len) { @@ -226,11 +300,13 @@ pvac_pubkey pvac_deserialize_pubkey(const uint8_t* data, size_t len) { } uint8_t* pvac_serialize_seckey(pvac_seckey sk, size_t* len) { - auto buf = pvac_ser::serialize_seckey(*SK(sk)); - *len = buf.size(); - auto* out = (uint8_t*)std::malloc(buf.size()); - std::memcpy(out, buf.data(), buf.size()); - return out; + try { + return copy_bytes(pvac_ser::serialize_seckey(*SK(sk)), len); + } catch (...) { + if (len) + *len = 0; + return nullptr; + } } pvac_seckey pvac_deserialize_seckey(const uint8_t* data, size_t len) { @@ -248,13 +324,15 @@ pvac_seckey pvac_deserialize_seckey(const uint8_t* data, size_t len) { } uint8_t* pvac_serialize_zero_proof(pvac_zero_proof zp, size_t* len) { - - pvac_ser::Writer w; - pvac_ser::write_zero_proof_raw(w, *ZP(zp)); - *len = w.buf.size(); - auto* out = (uint8_t*)std::malloc(w.buf.size()); - std::memcpy(out, w.buf.data(), w.buf.size()); - return out; + try { + pvac_ser::Writer w; + pvac_ser::write_zero_proof_raw(w, *ZP(zp)); + return copy_bytes(w.buf, len); + } catch (...) { + if (len) + *len = 0; + return nullptr; + } } pvac_zero_proof pvac_deserialize_zero_proof(const uint8_t* data, size_t len) { @@ -278,11 +356,13 @@ pvac_zero_proof pvac_deserialize_zero_proof(const uint8_t* data, size_t len) { } uint8_t* pvac_serialize_range_proof(pvac_range_proof rp, size_t* len) { - auto buf = pvac_ser::serialize_range_proof(*RP(rp)); - *len = buf.size(); - auto* out = (uint8_t*)std::malloc(buf.size()); - std::memcpy(out, buf.data(), buf.size()); - return out; + try { + return copy_bytes(pvac_ser::serialize_range_proof(*RP(rp)), len); + } catch (...) { + if (len) + *len = 0; + return nullptr; + } } pvac_range_proof pvac_deserialize_range_proof(const uint8_t* data, size_t len) { @@ -307,15 +387,21 @@ pvac_agg_range_proof pvac_make_aggregated_range_proof(pvac_pubkey pk, pvac_secke } int pvac_verify_aggregated_range(pvac_pubkey pk, pvac_cipher ct, pvac_agg_range_proof proof) { - return pvac::verify_aggregated_range(*PK(pk), *CT(ct), *ARP(proof)) ? 1 : 0; + try { + return pvac::verify_aggregated_range(*PK(pk), *CT(ct), *ARP(proof)) ? 1 : 0; + } catch (...) { + return 0; + } } uint8_t* pvac_serialize_agg_range_proof(pvac_agg_range_proof arp, size_t* len) { - auto buf = pvac_ser::serialize_agg_range_proof(*ARP(arp)); - *len = buf.size(); - auto* out = (uint8_t*)std::malloc(buf.size()); - std::memcpy(out, buf.data(), buf.size()); - return out; + try { + return copy_bytes(pvac_ser::serialize_agg_range_proof(*ARP(arp)), len); + } catch (...) { + if (len) + *len = 0; + return nullptr; + } } pvac_agg_range_proof pvac_deserialize_agg_range_proof(const uint8_t* data, size_t len) { diff --git a/pvac/pvac_c_api.h b/pvac/pvac_c_api.h index ed60895..72d34dd 100644 --- a/pvac/pvac_c_api.h +++ b/pvac/pvac_c_api.h @@ -36,6 +36,7 @@ pvac_cipher pvac_ct_sub_const(pvac_pubkey pk, pvac_cipher ct, uint64_t k); pvac_cipher pvac_ct_div_const(pvac_pubkey pk, pvac_cipher ct, uint64_t k_lo, uint64_t k_hi); pvac_cipher pvac_ct_square_seeded(pvac_pubkey pk, pvac_cipher ct, const uint8_t seed[32]); void pvac_commit_ct(pvac_pubkey pk, pvac_cipher ct, uint8_t out[32]); +int pvac_commit_ct_v2(pvac_pubkey pk, pvac_cipher ct, uint8_t *out, size_t out_cap, size_t *out_len); pvac_zero_proof pvac_make_zero_proof(pvac_pubkey pk, pvac_seckey sk, pvac_cipher ct); int pvac_verify_zero(pvac_pubkey pk, pvac_cipher ct, pvac_zero_proof proof); @@ -46,6 +47,8 @@ int pvac_verify_zero_bound(pvac_pubkey pk, pvac_cipher ct, pvac_zero_proof proof const uint8_t amount_commitment[32]); void pvac_pedersen_commit(uint64_t amount, const uint8_t blinding[32], uint8_t out[32]); +int pvac_pedersen_commit_v2(uint64_t amount, const uint8_t blinding[32], + uint8_t *out, size_t out_cap, size_t *out_len); pvac_range_proof pvac_make_range_proof(pvac_pubkey pk, pvac_seckey sk, pvac_cipher ct, uint64_t value); diff --git a/pvac/pvac_serialize.hpp b/pvac/pvac_serialize.hpp index 5d83bf5..6d8d000 100644 --- a/pvac/pvac_serialize.hpp +++ b/pvac/pvac_serialize.hpp @@ -19,6 +19,7 @@ static constexpr uint8_t TAG_SECKEY = 2; static constexpr uint8_t TAG_RANGE_PROOF = 4; static constexpr uint8_t TAG_AGG_RANGE_PROOF = 5; static constexpr uint8_t TAG_ZERO_PROOF = 6; +static constexpr uint64_t MAX_BITVEC_BITS = 1ULL << 20; struct Writer { std::vector buf; @@ -152,12 +153,20 @@ struct Reader { pvac::Scalar scalar() { uint8_t b[32]; raw(b, 32); - return pvac::sc_from_bytes(b); + pvac::Scalar scalar = pvac::sc_from_bytes(b); + if (!failed && !pvac::sc_is_canonical(scalar)) + fail("pvac_ser: non-canonical scalar encoding"); + return failed ? pvac::sc_zero() : scalar; } pvac::RistrettoPoint rist_point() { pvac::RistrettoPoint pt; raw(pt.data(), 32); + if (!failed) { + pvac::ExtPoint decoded; + if (!pvac::rist_decode(decoded, pt)) + fail("pvac_ser: invalid Ristretto point encoding"); + } return pt; } @@ -165,7 +174,12 @@ struct Reader { pvac::BitVec bv; bv.nbits = u64(); size_t nw = u64(); + if (!failed && bv.nbits > MAX_BITVEC_BITS) + fail("pvac_ser: bitvec too large"); check_count(nw, 8); + size_t expected_nw = static_cast((bv.nbits + 63) / 64); + if (!failed && nw != expected_nw) + fail("pvac_ser: bitvec word count mismatch"); if (failed) return bv; bv.w.resize(nw); for (size_t i = 0; i < nw; ++i) bv.w[i] = u64(); @@ -197,6 +211,49 @@ struct Reader { } }; +inline void validate_cipher_structure(const pvac::Cipher& cipher) { + if (cipher.slots == 0) + throw std::runtime_error("pvac_ser: cipher slots must be positive"); + if (!cipher.c0.empty() && cipher.c0.size() != cipher.slots) + throw std::runtime_error("pvac_ser: c0/slots size mismatch"); + for (size_t layer_id = 0; layer_id < cipher.L.size(); ++layer_id) { + const auto& layer = cipher.L[layer_id]; + if (layer.rule != pvac::RRule::BASE && layer.rule != pvac::RRule::PROD) + throw std::runtime_error("pvac_ser: invalid layer rule"); + if (layer.rule == pvac::RRule::PROD && + (layer.pa >= layer_id || layer.pb >= layer_id)) + throw std::runtime_error("pvac_ser: invalid product parent"); + if (layer.rule == pvac::RRule::PROD && !layer.PC.empty()) + throw std::runtime_error("pvac_ser: product layer must not contain PC"); + if (!layer.PC.empty() && layer.PC.size() != cipher.slots) + throw std::runtime_error("pvac_ser: layer PC/slots size mismatch"); + } + for (const auto& edge : cipher.E) { + if (edge.layer_id >= cipher.L.size()) + throw std::runtime_error("pvac_ser: edge layer out of range"); + if (edge.ch != pvac::SGN_P && edge.ch != pvac::SGN_M) + throw std::runtime_error("pvac_ser: invalid edge sign"); + if (edge.w.size() != cipher.slots) + throw std::runtime_error("pvac_ser: edge weight/slots size mismatch"); + } +} + +inline void validate_pubkey_structure(const pvac::PubKey& pk) { + if (pk.prm.B <= 0 || pk.prm.m_bits <= 0 || pk.prm.n_bits <= 0) + throw std::runtime_error("pvac_ser: invalid public key dimensions"); + if (pk.H.size() != static_cast(pk.prm.n_bits)) + throw std::runtime_error("pvac_ser: H column count mismatch"); + if (pk.ubk.perm.size() != static_cast(pk.prm.m_bits) || + pk.ubk.inv.size() != static_cast(pk.prm.m_bits)) + throw std::runtime_error("pvac_ser: UBK size mismatch"); + if (pk.powg_B.size() != static_cast(pk.prm.B)) + throw std::runtime_error("pvac_ser: powg_B size mismatch"); + for (const auto& column : pk.H) { + if (column.nbits != static_cast(pk.prm.m_bits)) + throw std::runtime_error("pvac_ser: H bitvec length mismatch"); + } +} + inline void write_params(Writer& w, const pvac::Params& prm) { w.i32(prm.B); w.i32(prm.m_bits); @@ -277,7 +334,7 @@ inline pvac::Layer read_layer(Reader& r, uint8_t ver = VERSION_V2) { if (r.failed) return L; L.PC.resize(nPC); for (size_t i = 0; i < nPC; i++) - r.raw(L.PC[i].data(), 32); + L.PC[i] = r.rist_point(); } return L; @@ -307,6 +364,7 @@ inline pvac::Edge read_edge(Reader& r) { } inline std::vector serialize_cipher(const pvac::Cipher& C) { + validate_cipher_structure(C); Writer w; w.header(TAG_CIPHER); w.u64(C.slots); @@ -343,6 +401,7 @@ inline pvac::Cipher deserialize_cipher(const uint8_t* data, size_t len) { for (size_t i = 0; i < nE; ++i) C.E[i] = read_edge(r); } if (r.failed) throw std::runtime_error(r.error); + validate_cipher_structure(C); return C; } @@ -414,6 +473,7 @@ inline pvac::PubKey deserialize_pubkey_raw(const uint8_t* data, size_t len) { } if (r.failed) throw std::runtime_error(r.error); + validate_pubkey_structure(pk); return pk; } @@ -577,6 +637,9 @@ inline pvac::Cipher read_cipher_raw(Reader& r, uint8_t ver = VERSION_V2) { C.E.resize(nE); for (size_t i = 0; i < nE; ++i) C.E[i] = read_edge(r); } + if (r.failed) + throw std::runtime_error(r.error); + validate_cipher_structure(C); return C; } @@ -602,7 +665,10 @@ inline pvac::RangeProof deserialize_range_proof(const uint8_t* data, size_t len) pvac::RangeProof rp; size_t nbits = r.u64(); - r.check_count(nbits, 8); + if (nbits != pvac::RANGE_BITS) + r.fail("pvac_ser: unexpected range proof bit length"); + if (!r.failed) + r.check_count(nbits, 8); if (!r.failed) { rp.ct_bit.resize(nbits); @@ -623,8 +689,6 @@ inline pvac::RangeProof deserialize_range_proof(const uint8_t* data, size_t len) return rp; } -// ═══ Aggregated Range Proof ═══ - inline std::vector serialize_agg_range_proof(const pvac::AggregatedRangeProof& arp) { Writer w; w.header(TAG_AGG_RANGE_PROOF); @@ -641,7 +705,10 @@ inline pvac::AggregatedRangeProof deserialize_agg_range_proof(const uint8_t* dat pvac::AggregatedRangeProof arp; size_t nbits = r.u64(); - r.check_count(nbits, 8); + if (nbits != pvac::RANGE_BITS) + r.fail("pvac_ser: unexpected range proof bit length"); + if (!r.failed) + r.check_count(nbits, 8); if (!r.failed) { arp.ct_bit.resize(nbits); for (size_t i = 0; i < nbits && !r.failed; ++i) @@ -664,6 +731,7 @@ struct RangeProofAny { }; inline RangeProofAny deserialize_range_proof_any(const uint8_t* data, size_t len) { + // Header layout: MAGIC[4] + VERSION[1] + TAG[1] if (len < 6) throw std::runtime_error("pvac_ser: range proof too short"); uint8_t tag = data[5]; RangeProofAny result; @@ -679,4 +747,4 @@ inline RangeProofAny deserialize_range_proof_any(const uint8_t* data, size_t len return result; } -} +} \ No newline at end of file diff --git a/rpc_client.hpp b/rpc_client.hpp index c0fbba3..18e729c 100644 --- a/rpc_client.hpp +++ b/rpc_client.hpp @@ -29,6 +29,11 @@ #include #include #include + + + + #include + #include #include #include "lib/json.hpp" @@ -48,6 +53,8 @@ class RpcClient { bool ssl_; int port_; std::atomic id_{0}; + mutable std::shared_mutex url_mtx_; + static constexpr std::size_t max_body = 64u * 1024u * 1024u; void parse_url(const std::string& url) { std::string u = url; @@ -78,7 +85,7 @@ class RpcClient { public: RpcClient() : path_("/rpc"), ssl_(true), port_(443) {} explicit RpcClient(const std::string& url) { parse_url(url); } - void set_url(const std::string& url) { parse_url(url); } + void set_url(const std::string& url) { std::unique_lock lk(url_mtx_); parse_url(url); } RpcResult call(const std::string& method, const nlohmann::json& params = nlohmann::json::array(), @@ -90,20 +97,34 @@ class RpcClient { req["id"] = ++id_; std::string body = req.dump(); httplib::Headers hdrs = {{"Content-Type", "application/json"}}; - if (ssl_) { - httplib::SSLClient cli(host_, port_); + + std::string host, path; + bool ssl; + int port; + { + std::shared_lock lk(url_mtx_); + host = host_; + path = path_; + ssl = ssl_; + port = port_; + } + if (ssl) { + httplib::SSLClient cli(host, port); cli.set_connection_timeout(timeout_sec, 0); cli.set_read_timeout(timeout_sec, 0); - cli.enable_server_certificate_verification(false); - auto res = cli.Post(path_, hdrs, body, "application/json"); + if (cli.ssl_context()) SSL_CTX_set_default_verify_paths(cli.ssl_context()); + cli.enable_server_certificate_verification(true); + auto res = cli.Post(path, hdrs, body, "application/json"); if (!res) return {false, {}, "connection failed"}; + if (res->body.size() > max_body) return {false, {}, "rpc response too large"}; return parse_response(res->body); } else { - httplib::Client cli(host_, port_); + httplib::Client cli(host, port); cli.set_connection_timeout(timeout_sec, 0); cli.set_read_timeout(timeout_sec, 0); - auto res = cli.Post(path_, hdrs, body, "application/json"); + auto res = cli.Post(path, hdrs, body, "application/json"); if (!res) return {false, {}, "connection failed"}; + if (res->body.size() > max_body) return {false, {}, "rpc response too large"}; return parse_response(res->body); } } @@ -128,6 +149,10 @@ class RpcClient { return call("octra_viewPubkey", {addr}); } + RpcResult get_public_key(const std::string& addr) { + return call("octra_publicKey", {addr}); + } + RpcResult get_encrypted_balance(const std::string& addr, const std::string& sig_b64, const std::string& pub_b64) { @@ -172,9 +197,6 @@ class RpcClient { return call("octra_compileAml", {source}, 10); } - - // rpc compl - RpcResult compile_aml_multi(const nlohmann::json& files, const std::string& main_path) { nlohmann::json payload; payload["files"] = files; @@ -192,6 +214,384 @@ class RpcClient { return call("vm_contract", {addr}); } + RpcResult circle_info(const std::string& circle_id) { + return call("circle_info", {circle_id}, 10); + } + + RpcResult circle_info_auth(const std::string& circle_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleInfoAuth", {circle_id, addr, pub_b64, sig_b64}, 10); + } + + RpcResult circle_program_info(const std::string& circle_id) { + return call("octra_circleProgramInfo", {circle_id}, 15); + } + + RpcResult circle_program_info_auth(const std::string& circle_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleProgramInfoAuth", {circle_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_view(const std::string& circle_id, + const std::string& method, + const nlohmann::json& params, + const std::string& caller, + bool include_storage = false) { + return call("octra_circleView", {circle_id, method, params, caller, include_storage}, 600); + } + + RpcResult circle_view_auth(const std::string& circle_id, + const std::string& method, + const nlohmann::json& params, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64, + bool include_storage = false) { + return call("octra_circleViewAuth", {circle_id, method, params, addr, pub_b64, sig_b64, include_storage}, 600); + } + + RpcResult circle_slot_policy(const std::string& circle_id, const std::string& slot_ref) { + return call("octra_circleSlotPolicy", {circle_id, slot_ref}, 15); + } + + RpcResult circle_slot_policy_auth(const std::string& circle_id, + const std::string& slot_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleSlotPolicyAuth", {circle_id, slot_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_state_policy(const std::string& circle_id, const std::string& state_ref) { + return call("octra_circleStatePolicy", {circle_id, state_ref}, 15); + } + + RpcResult circle_state_policy_auth(const std::string& circle_id, + const std::string& state_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleStatePolicyAuth", {circle_id, state_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_state_descriptor(const std::string& circle_id, const std::string& state_ref) { + return call("octra_circleStateDescriptor", {circle_id, state_ref}, 15); + } + + RpcResult circle_state_descriptor_auth(const std::string& circle_id, + const std::string& state_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleStateDescriptorAuth", {circle_id, state_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_balance_cell(const std::string& circle_id, const std::string& state_ref) { + return call("octra_circleBalanceCell", {circle_id, state_ref}, 15); + } + + RpcResult circle_balance_cell_auth(const std::string& circle_id, + const std::string& state_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleBalanceCellAuth", {circle_id, state_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_register_cell(const std::string& circle_id, const std::string& state_ref) { + return call("octra_circleRegisterCell", {circle_id, state_ref}, 15); + } + + RpcResult circle_register_cell_auth(const std::string& circle_id, + const std::string& state_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleRegisterCellAuth", {circle_id, state_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_balance_binding(const std::string& circle_id, const std::string& subject_addr) { + return call("octra_circleBalanceBinding", {circle_id, subject_addr}, 15); + } + + RpcResult circle_balance_binding_auth(const std::string& circle_id, + const std::string& subject_addr, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleBalanceBindingAuth", {circle_id, subject_addr, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_register_binding(const std::string& circle_id, const std::string& register_ref) { + return call("octra_circleRegisterBinding", {circle_id, register_ref}, 15); + } + + RpcResult circle_register_binding_auth(const std::string& circle_id, + const std::string& register_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleRegisterBindingAuth", {circle_id, register_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_balance_workflow(const std::string& circle_id, const std::string& workflow_ref) { + return call("octra_circleBalanceWorkflow", {circle_id, workflow_ref}, 15); + } + + RpcResult circle_balance_workflow_auth(const std::string& circle_id, + const std::string& workflow_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleBalanceWorkflowAuth", {circle_id, workflow_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_register_workflow(const std::string& circle_id, const std::string& workflow_ref) { + return call("octra_circleRegisterWorkflow", {circle_id, workflow_ref}, 15); + } + + RpcResult circle_register_workflow_auth(const std::string& circle_id, + const std::string& workflow_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleRegisterWorkflowAuth", {circle_id, workflow_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_object_summary(const std::string& circle_id, const std::string& object_ref) { + return call("octra_circleObjectSummary", {circle_id, object_ref}, 15); + } + + RpcResult circle_object_summary_auth(const std::string& circle_id, + const std::string& object_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleObjectSummaryAuth", {circle_id, object_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_object_members(const std::string& circle_id, const std::string& object_ref) { + return call("octra_circleObjectMembers", {circle_id, object_ref}, 15); + } + + RpcResult circle_object_members_auth(const std::string& circle_id, + const std::string& object_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleObjectMembersAuth", {circle_id, object_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_object_detail(const std::string& circle_id, const std::string& object_ref) { + return call("octra_circleObjectDetail", {circle_id, object_ref}, 15); + } + + RpcResult circle_object_detail_auth(const std::string& circle_id, + const std::string& object_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleObjectDetailAuth", {circle_id, object_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_object_member(const std::string& circle_id, + const std::string& object_ref, + const std::string& member_ref) { + return call("octra_circleObjectMember", {circle_id, object_ref, member_ref}, 15); + } + + RpcResult circle_object_member_auth(const std::string& circle_id, + const std::string& object_ref, + const std::string& member_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleObjectMemberAuth", {circle_id, object_ref, member_ref, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_object_refs(const std::string& circle_id) { + return call("octra_circleObjectRefs", {circle_id}, 15); + } + + RpcResult circle_object_refs_auth(const std::string& circle_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleObjectRefsAuth", {circle_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_object_list(const std::string& circle_id) { + return call("octra_circleObjectList", {circle_id}, 15); + } + + RpcResult circle_object_list_auth(const std::string& circle_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleObjectListAuth", {circle_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_transport_policy(const std::string& circle_id) { + return call("octra_circleTransportPolicy", {circle_id}, 15); + } + + RpcResult circle_transport_policy_auth(const std::string& circle_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleTransportPolicyAuth", {circle_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_hfhe_policy(const std::string& circle_id) { + return call("octra_circleHfhePolicy", {circle_id}, 15); + } + + RpcResult circle_hfhe_policy_auth(const std::string& circle_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleHfhePolicyAuth", {circle_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_key_policy(const std::string& circle_id, const std::string& key_id) { + return call("octra_circleKeyPolicy", {circle_id, key_id}, 15); + } + + RpcResult circle_key_policy_auth(const std::string& circle_id, + const std::string& key_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleKeyPolicyAuth", {circle_id, key_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_storage(const std::string& circle_id, const std::string& key) { + return call("octra_circleStorage", {circle_id, key}, 15); + } + + RpcResult circle_storage_auth(const std::string& circle_id, + const std::string& key, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleStorageAuth", {circle_id, key, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_storage_dump(const std::string& circle_id) { + return call("octra_circleStorageDump", {circle_id}, 15); + } + + RpcResult circle_storage_dump_auth(const std::string& circle_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleStorageDumpAuth", {circle_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_outbox_intent(const std::string& circle_id, const std::string& intent_id) { + return call("octra_circleOutboxIntent", {circle_id, intent_id}, 15); + } + + RpcResult circle_outbox_claim(const std::string& circle_id, const std::string& intent_id) { + return call("octra_circleOutboxClaim", {circle_id, intent_id}, 15); + } + + RpcResult circle_outbox_claim_auth(const std::string& circle_id, + const std::string& intent_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleOutboxClaimAuth", {circle_id, intent_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_outbox_intent_auth(const std::string& circle_id, + const std::string& intent_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleOutboxIntentAuth", {circle_id, intent_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_outbox_status(const std::string& circle_id, const std::string& intent_id) { + return call("octra_circleOutboxStatus", {circle_id, intent_id}, 15); + } + + RpcResult circle_outbox_status_auth(const std::string& circle_id, + const std::string& intent_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleOutboxStatusAuth", {circle_id, intent_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_ingress_packet(const std::string& circle_id, const std::string& intent_id) { + return call("octra_circleIngressPacket", {circle_id, intent_id}, 15); + } + + RpcResult circle_ingress_packet_auth(const std::string& circle_id, + const std::string& intent_id, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleIngressPacketAuth", {circle_id, intent_id, addr, pub_b64, sig_b64}, 15); + } + + RpcResult circle_asset(const std::string& circle_id, const std::string& path) { + return call("circle_asset", {circle_id, path}, 10); + } + + RpcResult circle_asset_ciphertext(const std::string& circle_id, const std::string& path) { + return call("circle_asset_ciphertext", {circle_id, path}, 10); + } + + RpcResult circle_asset_ciphertext_auth(const std::string& circle_id, + const std::string& path, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleAssetCiphertextAuth", {circle_id, path, addr, pub_b64, sig_b64}, 10); + } + + RpcResult circle_asset_ciphertext_by_resource_key(const std::string& circle_id, const std::string& resource_key) { + return call("circle_asset_ciphertext_by_resource_key", {circle_id, resource_key}, 10); + } + + RpcResult circle_asset_ciphertext_by_resource_key_auth(const std::string& circle_id, + const std::string& resource_key, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleAssetCiphertextByResourceKeyAuth", {circle_id, resource_key, addr, pub_b64, sig_b64}, 10); + } + + RpcResult circle_asset_ciphertext_by_slot_ref(const std::string& circle_id, const std::string& slot_ref) { + return call("circle_asset_ciphertext_by_slot_ref", {circle_id, slot_ref}, 10); + } + + RpcResult circle_asset_ciphertext_by_slot_ref_auth(const std::string& circle_id, + const std::string& slot_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleAssetCiphertextBySlotRefAuth", {circle_id, slot_ref, addr, pub_b64, sig_b64}, 10); + } + + RpcResult circle_asset_ciphertext_by_state_ref(const std::string& circle_id, const std::string& state_ref) { + return call("octra_circleAssetCiphertextByStateRef", {circle_id, state_ref}, 10); + } + + RpcResult circle_asset_ciphertext_by_state_ref_auth(const std::string& circle_id, + const std::string& state_ref, + const std::string& addr, + const std::string& pub_b64, + const std::string& sig_b64) { + return call("octra_circleAssetCiphertextByStateRefAuth", {circle_id, state_ref, addr, pub_b64, sig_b64}, 10); + } + RpcResult contract_receipt(const std::string& hash) { return call("contract_receipt", {hash}); } @@ -207,8 +607,13 @@ class RpcClient { return call("octra_listContracts", nlohmann::json::array(), 10); } - RpcResult contract_storage(const std::string& addr, const std::string& key) { - return call("octra_contractStorage", {addr, key}); + RpcResult tokens_by_address(const std::string& addr) { + return call("octra_tokensByAddress", {addr}, 15); + } + + RpcResult contract_storage(const std::string& addr, const std::string& key, const std::string& limit = "") { + if (limit.empty()) return call("octra_contractStorage", {addr, key}); + return call("octra_contractStorage", {addr, key, limit}); } RpcResult contract_abi(const std::string& addr) { @@ -223,6 +628,74 @@ class RpcClient { return call("octra_transactionsByAddress", {addr, limit, offset}, 15); } + RpcResult get_token_txs_by_address(const std::string& addr, int limit = 50, int offset = 0) { + return call("octra_tokenTransfersByAddress", {addr, limit, offset}, 30); + } + + std::vector call_batch(const std::vector& methods, + const std::vector& params_list = {}, + int timeout_sec = 10) { + size_t count = methods.size(); + std::vector out(count, {false, {}, "no response"}); + if (count == 0) return out; + nlohmann::json batch = nlohmann::json::array(); + for (size_t i = 0; i < count; ++i) { + nlohmann::json req; + req["jsonrpc"] = "2.0"; + req["method"] = methods[i]; + req["params"] = (i < params_list.size()) ? params_list[i] : nlohmann::json::array(); + req["id"] = static_cast(i + 1); + batch.push_back(std::move(req)); + } + std::string body = batch.dump(); + httplib::Headers hdrs = {{"Content-Type", "application/json"}}; + std::string resp_body; + if (ssl_) { + httplib::SSLClient cli(host_, port_); + cli.set_connection_timeout(timeout_sec, 0); + cli.set_read_timeout(timeout_sec, 0); + if (cli.ssl_context()) SSL_CTX_set_default_verify_paths(cli.ssl_context()); + cli.enable_server_certificate_verification(true); + auto r = cli.Post(path_, hdrs, body, "application/json"); + if (!r) { for (auto& o : out) o.error = "connection failed"; return out; } + + if (r->body.size() > max_body) { for (auto& o : out) o.error = "rpc response too large"; return out; } + resp_body = r->body; + } else { + httplib::Client cli(host_, port_); + cli.set_connection_timeout(timeout_sec, 0); + cli.set_read_timeout(timeout_sec, 0); + auto r = cli.Post(path_, hdrs, body, "application/json"); + if (!r) { for (auto& o : out) o.error = "connection failed"; return out; } + + if (r->body.size() > max_body) { for (auto& o : out) o.error = "rpc response too large"; return out; } + + resp_body = r->body; + } + try { + auto arr = nlohmann::json::parse(resp_body); + if (!arr.is_array()) { + for (auto& o : out) o.error = "batch response not array"; + return out; + } + for (auto& item : arr) { + if (!item.contains("id") || !item["id"].is_number_integer()) continue; + int id = item["id"].get(); + if (id < 1 || id > static_cast(count)) continue; + if (item.contains("result")) { + out[id - 1] = {true, item["result"], ""}; + } else if (item.contains("error")) { + auto& e = item["error"]; + std::string msg = e.is_object() ? e.value("message", "rpc error") : e.dump(); + out[id - 1] = {false, {}, msg}; + } + } + } catch (const std::exception& ex) { + for (auto& o : out) o.error = std::string("parse error: ") + ex.what(); + } + return out; + } + private: RpcResult parse_response(const std::string& body) { try { @@ -241,4 +714,4 @@ class RpcClient { } }; -} // namespace octra \ No newline at end of file +} \ No newline at end of file diff --git a/sanitize.hpp b/sanitize.hpp new file mode 100644 index 0000000..cb95e6d --- /dev/null +++ b/sanitize.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +inline std::string sanitize_display(const std::string& s, std::size_t maxlen) { + std::string out; + for (char c : s) { + if (out.size() >= maxlen) break; + unsigned char u = static_cast(c); + bool ok = (u >= 'a' && u <= 'z') || (u >= 'A' && u <= 'Z') || + (u >= '0' && u <= '9') || c == '.' || c == '_' || c == '-' || c == ' '; + if (ok) out.push_back(c); + } + return out; +} \ No newline at end of file diff --git a/setup.bat b/setup.bat index eabb77b..9b6518e 100644 --- a/setup.bat +++ b/setup.bat @@ -2,69 +2,94 @@ chcp 65001 >nul 2>&1 setlocal EnableDelayedExpansion - set "MSYS2_DIR=" -if exist "C:\msys64\usr\bin\bash.exe" ( - set "MSYS2_DIR=C:\msys64" -) -if exist "%USERPROFILE%\msys64\usr\bin\bash.exe" ( - set "MSYS2_DIR=%USERPROFILE%\msys64" -) +if exist "C:\msys64\usr\bin\bash.exe" set "MSYS2_DIR=C:\msys64" +if exist "%USERPROFILE%\msys64\usr\bin\bash.exe" set "MSYS2_DIR=%USERPROFILE%\msys64" +if exist "D:\msys64\usr\bin\bash.exe" set "MSYS2_DIR=D:\msys64" +if exist "C:\tools\msys64\usr\bin\bash.exe" set "MSYS2_DIR=C:\tools\msys64" if defined MSYS2_DIR ( echo [1/3] MSYS2 found at !MSYS2_DIR! goto :install_deps ) -echo [1/3] MSYS2 not found. Installing... +echo [1/3] MSYS2 not found. installing... echo. where winget >nul 2>&1 if %errorlevel% equ 0 ( - echo Installing MSYS2 via winget... + echo installing MSYS2 via winget... winget install --id MSYS2.MSYS2 --accept-source-agreements --accept-package-agreements -e -) else ( - echo winget not available. - echo. - echo Please download and install MSYS2 manually from: - echo https://www.msys2.org/ - echo. - echo After installing, run this script again. - pause - exit /b 1 + goto :verify_msys2 +) + +where choco >nul 2>&1 +if %errorlevel% equ 0 ( + echo installing MSYS2 via chocolatey... + choco install -y msys2 + goto :verify_msys2 +) + +where scoop >nul 2>&1 +if %errorlevel% equ 0 ( + echo installing MSYS2 via scoop... + scoop bucket add extras + scoop install msys2 + goto :verify_msys2 ) -if exist "C:\msys64\usr\bin\bash.exe" ( - set "MSYS2_DIR=C:\msys64" -) else ( - echo MSYS2 installation failed. Please install manually from https://www.msys2.org/ +echo no package manager found (winget / choco / scoop). +echo. +echo please install MSYS2 manually from: +echo https://www.msys2.org/ +echo. +echo after installing, re-run this script. +pause +exit /b 1 + +:verify_msys2 +if exist "C:\msys64\usr\bin\bash.exe" set "MSYS2_DIR=C:\msys64" +if exist "%USERPROFILE%\msys64\usr\bin\bash.exe" set "MSYS2_DIR=%USERPROFILE%\msys64" + +if not defined MSYS2_DIR ( + echo MSYS2 installation did not land in a known location. + echo please install manually from https://www.msys2.org/ pause exit /b 1 ) :install_deps -echo Installing compiler and OpenSSL... -"!MSYS2_DIR!\usr\bin\bash.exe" -lc "pacman -S --noconfirm --needed mingw-w64-x86_64-gcc mingw-w64-x86_64-openssl mingw-w64-x86_64-leveldb make" +echo installing compiler + dependencies (openssl, leveldb)... +"!MSYS2_DIR!\usr\bin\bash.exe" -lc "pacman -Syu --noconfirm --needed" +"!MSYS2_DIR!\usr\bin\bash.exe" -lc "pacman -S --noconfirm --needed mingw-w64-x86_64-gcc mingw-w64-x86_64-openssl mingw-w64-x86_64-leveldb mingw-w64-x86_64-pkgconf make" + +if %errorlevel% neq 0 ( + echo dependency installation failed. + pause + exit /b 1 +) echo. -echo [2/3] Building Octra Wallet... +echo [2/3] building octra wallet... set "WALLET_DIR=%~dp0" -"!MSYS2_DIR!\usr\bin\bash.exe" -lc "export PATH=/mingw64/bin:$PATH && cd '%WALLET_DIR:\=/%' && make clean 2>/dev/null; make" +set "WALLET_DIR_UNIX=%WALLET_DIR:\=/%" + +"!MSYS2_DIR!\usr\bin\bash.exe" -lc "export PATH=/mingw64/bin:$PATH && cd '%WALLET_DIR_UNIX%' && make clean 2>/dev/null; make" if not exist "%WALLET_DIR%octra_wallet.exe" ( echo. - echo Build failed. Please check errors above. + echo build failed. check errors above. pause exit /b 1 ) echo. -echo [3/3] done! +echo [3/3] done echo. echo start the wallet: echo octra_wallet.exe echo. -echo then open http://127.0.0.1:8420 in your browser. +echo then open http://127.0.0.1:8420 in your browser echo. -pause +pause \ No newline at end of file diff --git a/setup.sh b/setup.sh index ff0d49d..f1f35ff 100755 --- a/setup.sh +++ b/setup.sh @@ -1,68 +1,175 @@ #!/usr/bin/env bash +set -e + +MODE="full" +for arg in "$@"; do + case "$arg" in + --deps-only|--no-build) MODE="deps" ;; + --help|-h) + echo "usage: $0 [--deps-only]" + echo "(no args) install deps + build" + echo "--deps-only install deps only (no make)" + exit 0 + ;; + esac +done OS="$(uname -s)" +if [ "$(id -u)" = "0" ]; then + SUDO="" +else + if command -v sudo &>/dev/null; then + SUDO="sudo" + else + SUDO="" + fi +fi + case "$OS" in Darwin) echo "[1/3] macOS detected" if ! command -v brew &>/dev/null; then - echo "homebrew not found. Installing..." + echo "homebrew not found. installing..." /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + if [ -d /opt/homebrew/bin ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [ -d /usr/local/bin ] && [ -x /usr/local/bin/brew ]; then + eval "$(/usr/local/bin/brew shellenv)" + fi fi - for pkg in openssl@3 leveldb; do - if ! brew list $pkg &>/dev/null; then + ensure_brew_formula() { + local pkg="$1" + local check_path="$2" + if ! brew list --versions "$pkg" &>/dev/null; then echo "installing $pkg..." - brew install $pkg + brew install "$pkg" else echo "$pkg already installed" fi - done - if ! command -v g++ &>/dev/null; then + if [ ! -e "$check_path" ]; then + echo "$pkg looks incomplete or broken (missing $check_path)" + echo "reinstalling $pkg..." + brew reinstall "$pkg" + fi + if [ ! -e "$check_path" ]; then + echo "error: $pkg is still incomplete after reinstall" + echo "expected file: $check_path" + exit 1 + fi + } + OPENSSL_PREFIX="$(brew --prefix openssl@3 2>/dev/null || true)" + [ -n "$OPENSSL_PREFIX" ] || OPENSSL_PREFIX="/opt/homebrew/opt/openssl@3" + LEVELDB_PREFIX="$(brew --prefix leveldb 2>/dev/null || true)" + [ -n "$LEVELDB_PREFIX" ] || LEVELDB_PREFIX="/opt/homebrew/opt/leveldb" + ensure_brew_formula "openssl@3" "$OPENSSL_PREFIX/lib/libssl.3.dylib" + ensure_brew_formula "leveldb" "$LEVELDB_PREFIX/lib/libleveldb.1.dylib" + brew postinstall openssl@3 >/dev/null 2>&1 || true + if ! xcode-select -p &>/dev/null; then echo "installing Xcode command line tools..." xcode-select --install 2>/dev/null || true + echo "a GUI installer may have opened. re-run this script after it finishes." + exit 0 fi ;; Linux) echo "[1/3] linux detected" + if [ -f /etc/os-release ]; then + . /etc/os-release + echo "distro: ${ID:-unknown} ${VERSION_ID:-}" + fi if command -v apt-get &>/dev/null; then - echo "Installing dependencies (apt)..." - sudo apt-get update -qq 2>/dev/null || true - sudo apt-get install -y -qq g++ libssl-dev libleveldb-dev make + echo "installing dependencies (apt)..." + $SUDO apt-get update -qq + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ + build-essential g++ libssl-dev libleveldb-dev pkg-config make curl elif command -v dnf &>/dev/null; then - echo "Installing dependencies (dnf)..." - sudo dnf install -y gcc-c++ openssl-devel leveldb-devel make + echo "installing dependencies (dnf)..." + $SUDO dnf install -y gcc-c++ openssl-devel leveldb-devel make pkgconfig + elif command -v yum &>/dev/null; then + echo "installing dependencies (yum)..." + $SUDO yum install -y gcc-c++ openssl-devel leveldb-devel make pkgconfig + elif command -v zypper &>/dev/null; then + echo "installing dependencies (zypper)..." + $SUDO zypper install -y gcc-c++ libopenssl-devel leveldb-devel make pkg-config elif command -v pacman &>/dev/null; then - echo "Installing dependencies (pacman)..." - sudo pacman -S --noconfirm gcc openssl leveldb make + echo "installing dependencies (pacman)..." + $SUDO pacman -S --noconfirm --needed gcc openssl leveldb make pkgconf elif command -v apk &>/dev/null; then - echo "Installing dependencies (apk)..." - sudo apk add g++ openssl-dev leveldb-dev make + echo "installing dependencies (apk)..." + $SUDO apk add --no-cache g++ openssl-dev leveldb-dev make pkgconfig musl-dev linux-headers + elif command -v emerge &>/dev/null; then + echo "installing dependencies (emerge)..." + $SUDO emerge --noreplace dev-libs/openssl dev-libs/leveldb sys-devel/gcc sys-devel/make + elif command -v xbps-install &>/dev/null; then + echo "installing dependencies (xbps)..." + $SUDO xbps-install -Sy gcc openssl-devel leveldb-devel make pkgconf else - echo "Unknown package manager. Please install: g++, libssl-dev, libleveldb-dev, make" + echo "unknown package manager. install manually: g++, libssl-dev, libleveldb-dev, make, pkg-config" exit 1 fi ;; + FreeBSD) + echo "[1/3] FreeBSD detected" + $SUDO pkg install -y gcc openssl leveldb gmake pkgconf + ;; + OpenBSD) + echo "[1/3] OpenBSD detected" + $SUDO pkg_add -I g++ openssl leveldb gmake + ;; + NetBSD) + echo "[1/3] NetBSD detected" + $SUDO pkgin install -y gcc openssl leveldb gmake pkg-config + ;; + MINGW*|MSYS*|CYGWIN*) + if [ "$MODE" = "deps" ]; then + echo "[1/1] detected windows shell ($OS) in deps-only mode" + echo "on windows, dependencies should be installed via setup.bat from cmd.exe" + echo "if you already ran setup.bat, this is fine - continuing" + exit 0 + fi + echo "detected windows shell ($OS). run setup.bat from cmd.exe instead." + exit 1 + ;; *) - echo "Unsupported OS: $OS" - echo "on windows, use setup.bat instead." + echo "unsupported OS: $OS" + echo "on windows use setup.bat. please install manually: g++, libssl-dev, libleveldb-dev, make" exit 1 ;; esac +if [ "$MODE" = "deps" ]; then + echo "" + echo "[2/2] dependencies installed (deps-only mode)" + exit 0 +fi + echo "" -echo "[2/3] building octra wallet and other things" -make clean 2>/dev/null || true -if ! make; then +echo "[2/3] building octra wallet" + +if ! command -v make &>/dev/null; then + if command -v gmake &>/dev/null; then + MAKE=gmake + else + echo "neither make nor gmake found" + exit 1 + fi +else + MAKE=make +fi + +OCTRA_SKIP_AUTOSETUP=1 $MAKE clean 2>/dev/null || true +if ! OCTRA_SKIP_AUTOSETUP=1 $MAKE; then echo "" - echo "build failed." + echo "build failed" exit 1 fi echo "" -echo "[3/3] done!" +echo "[3/3] done" echo "" echo "start the wallet:" echo "./octra_wallet" echo "" -echo "then open http://127.0.0.1:8420 in your browser." -echo "" +echo "then open http://127.0.0.1:8420 in your browser" +echo "" \ No newline at end of file diff --git a/static/bridge.html b/static/bridge.html new file mode 100644 index 0000000..e222c86 --- /dev/null +++ b/static/bridge.html @@ -0,0 +1,247 @@ + + + + + + +octra bridge + + + +
+
+

octra bridge

+ v1 experimental +
+ +
+
+
+ + octra + not connected +
+ +
+
+
+ + ethereum + not connected +
+ +
+
+ +
+ + +
+ +
+
+
from
+
octra
+
OCT
+
+
->
+
+
to
+
ethereum
+
wOCT
+
+
+ +
+
lock OCT on octra, receive wOCT on ethereum
+
+ +
+
+ +
+ + +
OCT
+
+
+ +
+ +
+ +
+
+ +
+ +
+ 0 + wOCT +
+
+ +
+
fee0
+
estimated time~2 min
+
+ + +
+ +
+ + + + +
+
OCT: -
+
wOCT: -
+
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/static/bridge.js b/static/bridge.js new file mode 100644 index 0000000..c292bb8 --- /dev/null +++ b/static/bridge.js @@ -0,0 +1,1201 @@ +const BRIDGE_VAULT = 'oct5MrNfjiXFNRDLwsodn8Zm9hDKNGAYt3eQDCQ52bSpCHq'; +const WOCT_ADDR = '0x4647e1fE715c9e23959022C2416C71867F5a6E80'; +const ETH_BRIDGE = '0xE7eD69b852fd2a1406080B26A37e8E04e7dA4caE'; +const SIGNER_URL = '/api/bridge/signer'; +const RECOVERY_URL = 'https://relayer-002838819188.octra.network/recovery.json'; +const MAINNET_CHAIN_ID = '0x1'; +const ETHEREUM_MAINNET_PARAMS = { + chainId: MAINNET_CHAIN_ID, + chainName: 'Ethereum Mainnet', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18 + }, + rpcUrls: ['https://ethereum-rpc.publicnode.com'], + blockExplorerUrls: ['https://etherscan.io'] +}; +const ETH_CHAIN_NAMES = { + '0x1': 'Ethereum Mainnet', + '0x2105': 'Base', + '0xa4b1': 'Arbitrum One', + '0xa': 'Optimism', + '0x89': 'Polygon', + '0x38': 'BSC', + '0xa86a': 'Avalanche', + '0xfa': 'Fantom', + '0x144': 'zkSync', + '0xaa36a7': 'Sepolia', + '0xaa37dc': 'OP Sepolia', + '0x14a34': 'Base Sepolia', + '0x66eee': 'Arbitrum Sepolia' +}; +const OCT_DECIMALS = 6; + +let _currentChainId = null; + +let _dir = 'o2e'; +let _octraAddr = ''; +let _ethAddr = ''; +let _octBalance = '0'; +let _woctBalance = '0'; +let _ethProvider = null; + +async function wcli(method, path, body) { + var opts = { method: method, headers: {} }; + if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + var res = await fetch('/api' + path, opts); + if (!res.ok) throw new Error('WebCLI error: ' + res.status); + return res.json(); +} + +async function connectOctra() { + try { + var st = await wcli('GET', '/wallet/status'); + if (!st.loaded) { showStatus('err', 'unlock your wallet in webcli first'); return; } + var info = await wcli('GET', '/wallet'); + _octraAddr = info.address; + $('octra-addr').textContent = _octraAddr.substring(0, 12) + '...' + _octraAddr.slice(-4); + $('octra-addr').classList.remove('none'); + $('octra-dot').classList.replace('off', 'on'); + $('octra-connect-btn').textContent = 'connected'; + $('octra-connect-btn').classList.add('connected'); + await refreshBalances(); + validateForm(); + } catch(e) { showStatus('err', 'cannot connect to webcli'); } +} + +var _detectedWallets = []; +var _eip6963Providers = []; + +window.addEventListener('eip6963:announceProvider', function(e) { + _eip6963Providers.push({ name: e.detail.info.name, icon: e.detail.info.icon, provider: e.detail.provider, rdns: e.detail.info.rdns }); +}); +window.dispatchEvent(new Event('eip6963:requestProvider')); + +async function connectEth() { + setTimeout(function() { _connectEthInner(); }, 100); +} + +async function _connectEthInner() { + if (_eip6963Providers.length > 0) { + _detectedWallets = _eip6963Providers.map(function(w) { return { name: w.name, provider: w.provider, icon: w.icon }; }); + } else if (window.ethereum) { + _detectedWallets = [{ name: window.ethereum.isMetaMask ? 'MetaMask' : 'Wallet', provider: window.ethereum, icon: null }]; + } else { + showStatus('err', 'no EVM wallet found. install MetaMask.'); + return; + } + + if (_detectedWallets.length === 1) { + await connectWithProvider(_detectedWallets[0]); + return; + } + + var html = ''; + _detectedWallets.forEach(function(w, i) { + var iconHtml = w.icon ? '' : ''; + html += ''; + }); + $('wallet-list').innerHTML = html; + $('wallet-modal').classList.add('show'); +} + +function closeWalletModal() { $('wallet-modal').classList.remove('show'); } + +async function selectWallet(idx) { + $('wallet-modal').classList.remove('show'); + await connectWithProvider(_detectedWallets[idx]); +} + +async function detectChainId() { + if (!_ethProvider) return null; + try { + const chainId = await _ethProvider.request({ method: 'eth_chainId' }); + _currentChainId = chainId; + return chainId; + } catch(e) { + return null; + } +} + +async function switchToEthereumMainnet() { + if (!_ethProvider) return false; + try { + await _ethProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: MAINNET_CHAIN_ID }] + }); + } catch(e) { + if (e && e.code === 4902) { + await _ethProvider.request({ + method: 'wallet_addEthereumChain', + params: [ETHEREUM_MAINNET_PARAMS] + }); + } else { + throw e; + } + } + const chainId = await detectChainId(); + return chainId === MAINNET_CHAIN_ID; +} + +async function ensureCorrectChain() { + if (!_ethProvider) { showStatus('err', 'connect metamask first'); return false; } + const chainId = await detectChainId(); + if (chainId === MAINNET_CHAIN_ID) return true; + const name = ETH_CHAIN_NAMES[chainId] || ('chain ' + chainId); + showStatus('info', 'switching from ' + name + ' to Ethereum Mainnet...'); + try { + const switched = await switchToEthereumMainnet(); + if (switched) { + showStatus('info', 'switched to Ethereum Mainnet'); + updateChainBadge(); + validateForm(); + return true; + } + showStatus('err', 'wallet did not switch to Ethereum Mainnet'); + return false; + } catch(e) { + const msg = (e && e.message) || String(e); + showStatus('err', 'could not auto-switch: ' + msg + ' — please switch manually in your wallet'); + return false; + } +} + +function updateChainBadge() { + var el = $('eth-chain-badge'); + if (!el) return; + if (!_currentChainId) { + el.textContent = ''; + el.className = 'eth-chain-badge hidden'; + return; + } + var name = ETH_CHAIN_NAMES[_currentChainId] || ('chain ' + _currentChainId); + if (_currentChainId === MAINNET_CHAIN_ID) { + el.textContent = 'Ethereum Mainnet'; + el.className = 'eth-chain-badge ok'; + } else { + el.textContent = 'WRONG: ' + name; + el.className = 'eth-chain-badge bad'; + } +} + +async function connectWithProvider(w) { + _ethProvider = w.provider; + try { + const accounts = await _ethProvider.request({ method: 'eth_requestAccounts' }); + if (!accounts.length) return; + _ethAddr = accounts[0]; + $('eth-addr').textContent = _ethAddr.substring(0, 8) + '...' + _ethAddr.slice(-4); + $('eth-addr').classList.remove('none'); + $('eth-dot').classList.replace('off', 'on'); + $('eth-connect-btn').textContent = w.name; + $('eth-connect-btn').classList.add('connected'); + if (_dir === 'o2e') $('recipient').value = _ethAddr; + else $('recipient').value = _octraAddr || ''; + try { localStorage.setItem('bridge_eth_wallet', w.name); } catch(e) {} + await detectChainId(); + updateChainBadge(); + if (_currentChainId !== MAINNET_CHAIN_ID) await ensureCorrectChain(); + await refreshBalances(); + validateForm(); + try { recoveryFetch(true); } catch(e) {} + _ethProvider.on('accountsChanged', function(accs) { + if (accs.length) { + _ethAddr = accs[0]; + $('eth-addr').textContent = _ethAddr.substring(0, 8) + '...' + _ethAddr.slice(-4); + refreshBalances(); + validateForm(); + try { recoveryFetch(true); } catch(e) {} + } + }); + _ethProvider.on('chainChanged', function(cid) { + _currentChainId = cid; + updateChainBadge(); + if (cid === MAINNET_CHAIN_ID) { + showStatus('ok', 'switched to Ethereum Mainnet'); + } else { + const name = ETH_CHAIN_NAMES[cid] || ('chain ' + cid); + showStatus('err', 'wrong network: ' + name + '. click bridge to switch back to Ethereum Mainnet.'); + } + refreshBalances(); + validateForm(); + }); + } catch(e) { showStatus('err', w.name + ': ' + e.message); } +} + +async function refreshBalances() { + if (_octraAddr) { + try { var b = await wcli('GET', '/balance'); _octBalance = b.public_balance || b.balance_raw || '0'; $('bal-oct').textContent = fmtU(_octBalance, OCT_DECIMALS); } catch(e) {} + } + if (_ethAddr && WOCT_ADDR) { + try { + var data = '0x70a08231000000000000000000000000' + _ethAddr.substring(2); + var result = await _ethProvider.request({ method: 'eth_call', params: [{ to: WOCT_ADDR, data: data }, 'latest'] }); + _woctBalance = (!result || result === '0x' || result === '0x0') ? '0' : BigInt(result).toString(); + $('bal-woct').textContent = fmtU(_woctBalance, OCT_DECIMALS); + } catch(e) { _woctBalance = '0'; $('bal-woct').textContent = '0'; } + } +} + +function setDir(d) { + _dir = d; + $('tab-o2e').className = d === 'o2e' ? 'active' : ''; + $('tab-e2o').className = d === 'e2o' ? 'active' : ''; + if (d === 'o2e') { + $('from-chain').textContent = 'octra'; $('from-token').textContent = 'OCT'; + $('to-chain').textContent = 'ethereum'; $('to-token').textContent = 'wOCT'; + $('input-token').textContent = 'OCT'; $('output-token').textContent = 'wOCT'; + $('recipient-label').textContent = 'ethereum recipient'; + $('recipient').placeholder = '0x...'; + $('recipient').value = _ethAddr || ''; + $('notice-text').textContent = 'lock OCT on octra, receive wOCT on ethereum'; + } else { + $('from-chain').textContent = 'ethereum'; $('from-token').textContent = 'wOCT'; + $('to-chain').textContent = 'octra'; $('to-token').textContent = 'OCT'; + $('input-token').textContent = 'wOCT'; $('output-token').textContent = 'OCT'; + $('recipient-label').textContent = 'octra recipient'; + $('recipient').placeholder = 'oct...'; + $('recipient').value = _octraAddr || ''; + $('notice-text').textContent = 'burn wOCT on ethereum, receive OCT on octra'; + } + $('bridge-amount').value = ''; + $('output-val').textContent = '0'; + clearStatus(); + validateForm(); +} + +function validateForm() { + const btn = $('bridge-btn'); + const amtStr = $('bridge-amount').value.trim(); + const recip = $('recipient').value.trim(); + const amt = parseFloat(amtStr); + $('output-val').textContent = (amtStr && !isNaN(amt) && amt > 0) ? addCommas(amtStr) : '0'; + if (!_octraAddr || !_ethAddr) { btn.disabled = true; btn.textContent = 'connect both wallets'; return; } + if (!amtStr || isNaN(amt) || amt <= 0) { btn.disabled = true; btn.textContent = 'enter amount'; return; } + const rawAmt = parseU(amtStr, OCT_DECIMALS); + if (_dir === 'o2e') { + if (BigInt(rawAmt) > BigInt(_octBalance)) { btn.disabled = true; btn.textContent = 'insufficient OCT'; return; } + if (!recip || !/^0x[0-9a-fA-F]{40}$/.test(recip)) { btn.disabled = true; btn.textContent = 'enter valid ETH address'; return; } + if (_currentChainId && _currentChainId !== MAINNET_CHAIN_ID) { btn.disabled = false; btn.textContent = 'switch to ethereum mainnet'; return; } + btn.disabled = false; btn.textContent = 'bridge ' + amtStr + ' OCT'; + } else { + if (BigInt(rawAmt) > BigInt(_woctBalance)) { btn.disabled = true; btn.textContent = 'insufficient wOCT'; return; } + if (!recip || recip.length !== 47 || recip.substring(0, 3) !== 'oct') { btn.disabled = true; btn.textContent = 'enter valid octra address'; return; } + if (_currentChainId && _currentChainId !== MAINNET_CHAIN_ID) { btn.disabled = false; btn.textContent = 'switch to ethereum mainnet'; return; } + btn.disabled = false; btn.textContent = 'bridge ' + amtStr + ' wOCT'; + } +} + +function setMax() { + if (_dir === 'o2e') $('bridge-amount').value = fmtU(_octBalance, OCT_DECIMALS); + else $('bridge-amount').value = fmtU(_woctBalance, OCT_DECIMALS); + validateForm(); +} + +async function doBridge() { + if (!await ensureCorrectChain()) return; + const amt = $('bridge-amount').value.trim(); + const recip = $('recipient').value.trim(); + if (_dir === 'o2e') { + $('cm-title').textContent = 'confirm: lock OCT'; + $('cm-action').textContent = 'lock OCT -> mint wOCT'; + $('cm-amount').textContent = amt + ' OCT'; + $('cm-from').textContent = _octraAddr.substring(0, 14) + '...'; + $('cm-to').textContent = recip; + $('cm-receive').textContent = amt + ' wOCT'; + $('cm-warning').textContent = 'OCT will be locked on octra. wOCT will be minted on ethereum.'; + $('cm-confirm-btn').textContent = 'lock & bridge'; + } else { + $('cm-title').textContent = 'confirm: burn wOCT'; + $('cm-action').textContent = 'burn wOCT -> unlock OCT'; + $('cm-amount').textContent = amt + ' wOCT'; + $('cm-from').textContent = _ethAddr.substring(0, 10) + '...'; + $('cm-to').textContent = recip; + $('cm-receive').textContent = amt + ' OCT'; + $('cm-warning').textContent = 'wOCT will be burned on ethereum. OCT will be unlocked on octra (~2 min).'; + $('cm-confirm-btn').textContent = 'burn & bridge'; + } + $('confirm-modal').classList.add('show'); +} + +function closeModal() { $('confirm-modal').classList.remove('show'); } + +async function confirmBridge() { + closeModal(); + if (_dir === 'o2e') await doForward(); + else await doReverse(); +} + +var _pendingClaim = null; + +var _activeHistoryId = null; + +async function doForward() { + if (!_ethProvider || !_ethAddr) { showStatus('err', 'connect metamask first'); return; } + if (!await ensureCorrectChain()) return; + var amt = $('bridge-amount').value.trim(); + var recip = $('recipient').value.trim(); + var rawAmt = parseU(amt, OCT_DECIMALS); + var btn = $('bridge-btn'); + btn.disabled = true; btn.classList.add('loading'); btn.textContent = 'bridging...'; + clearStatus(); + + _pendingClaim = null; + _activeHistoryId = null; + var oldClaimBtn = $('claim-btn'); + if (oldClaimBtn) oldClaimBtn.remove(); + showProgress([ + { id: 'lock', text: 'locking ' + amt + ' OCT on octra...' }, + { id: 'confirm', text: 'waiting for epoch confirmation...' }, + { id: 'header', text: 'waiting for bridge header on ethereum...' }, + { id: 'claim', text: 'claim wOCT (your MetaMask transaction)...' } + ]); + setStep('lock', 'active'); + try { + var r = await wcli('POST', '/contract/call', { + address: BRIDGE_VAULT, method: 'lock_to_eth', params: [recip], amount: rawAmt, ou: '1000' + }); + if (!r.tx_hash) throw new Error('no tx_hash'); + setStep('lock', 'done'); setStep('confirm', 'active'); + showStatus('info', 'locked! ' + r.tx_hash.substring(0, 16) + '...'); + + _activeHistoryId = 'lock_' + r.tx_hash.substring(0, 10) + '_' + Date.now(); + historyAdd({ + id: _activeHistoryId, + locked_at: Date.now(), + lock_tx_hash: r.tx_hash, + epoch: 0, + recipient: recip, + amount_raw: rawAmt, + amt_display: amt, + status: 'pending_header' + }); + + var receipt = await waitReceipt(r.tx_hash, 60); + if (!receipt || !receipt.success) { + if (_activeHistoryId) historyUpdate(_activeHistoryId, {last_error:'lock tx not confirmed in 60s, check refresh status later'}); + throw new Error('lock transaction failed'); + } + setStep('confirm', 'done'); setStep('header', 'active'); + showStatus('info', 'OCT locked. waiting for bridge header (~1-2 min)...'); + + var epochId = receipt.epoch || 0; + if (!epochId) { + var txInfo = await wcli('GET', '/transaction?hash=' + r.tx_hash); + epochId = txInfo.epoch || 0; + } + + if (_activeHistoryId) historyUpdate(_activeHistoryId, {epoch: epochId}); + + var claimData = await waitForClaimData(epochId, recip, rawAmt); + if (!claimData) throw new Error('bridge header not yet available, pls check history below - it will auto resume when header lands on eth'); + + showStatus('info', 'signer returned header. verifying it landed on ethereum...'); + var simOk = false; + var simAttempts = 0; + var simMaxAttempts = 60; + var lastSimErr = ''; + while (simAttempts < simMaxAttempts) { + try { + await _ethProvider.request({ + method: 'eth_call', + params: [{ from: _ethAddr, to: ETH_BRIDGE, data: claimData.calldata }, 'latest'] + }); + simOk = true; + break; + } catch(simErr) { + lastSimErr = (simErr && (simErr.message || JSON.stringify(simErr))) || 'unknown'; + simAttempts++; + showStatus('info', 'header not yet on ethereum. waiting for relayer ' + simAttempts + '/' + simMaxAttempts + ' (retry in 5s)...'); + await new Promise(function(r) { setTimeout(r, 5000); }); + } + } + if (!simOk) { + if (_activeHistoryId) historyUpdate(_activeHistoryId, {claim_data:claimData, last_error:lastSimErr}); + throw new Error('header not verified on ethereum after 5 min. your lock is in history below - click "refresh status" there once relayer submits. last error: ' + lastSimErr); + } + + setStep('header', 'done'); setStep('claim', 'active'); + _pendingClaim = claimData; + if (_activeHistoryId) historyUpdate(_activeHistoryId, {status:'claimable', claim_data:claimData}); + showClaimButton(amt); + showStatus('info', 'bridge header verified on ethereum. click button to claim your wOCT:'); + } catch(e) { showStatus('err', e.message); setCurrentStepFail(); } + btn.classList.remove('loading'); validateForm(); +} + +function showClaimButton(amt) { + showStatus('info', 'bridge header ready. claim your wOCT:'); + var existing = $('claim-btn'); + if (existing) existing.remove(); + var area = $('progress-area'); + var btn = document.createElement('button'); + btn.id = 'claim-btn'; + btn.textContent = 'claim on ethereum ' + amt + ' wOCT'; + btn.style.cssText = 'width:100%;padding:12px;font-size:13px;font-weight:600;border:none;cursor:pointer;background:#3B567F;color:#fff;margin-top:10px;font-family:Tahoma,arial,sans-serif'; + btn.onclick = doClaim; + area.appendChild(btn); +} + +async function waitForReceipt(txHash, timeoutMs) { + var start = Date.now(); + var interval = 3000; + while (Date.now() - start < timeoutMs) { + try { + var receipt = await _ethProvider.request({ + method: 'eth_getTransactionReceipt', + params: [txHash] + }); + if (receipt && receipt.blockNumber) { + return receipt; + } + } catch(e) {} + await new Promise(function(r) { setTimeout(r, interval); }); + } + return null; +} + +async function getSafeGas() { + var floor = 10000000000n; + var priority = 2000000000n; + try { + var gasPriceHex = await _ethProvider.request({method: 'eth_gasPrice'}); + var current = BigInt(gasPriceHex); + var doubled = current * 2n; + var maxFee = doubled > floor ? doubled : floor; + return { + maxFeePerGas: '0x' + maxFee.toString(16), + maxPriorityFeePerGas: '0x' + priority.toString(16) + }; + } catch(e) { + return { + maxFeePerGas: '0x2540be400', + maxPriorityFeePerGas: '0x77359400' + }; + } +} + +async function doClaim() { + if (!_pendingClaim) return; + if (!await ensureCorrectChain()) return; + var claimBtn = $('claim-btn'); + if (claimBtn) { claimBtn.disabled = true; claimBtn.textContent = 'submitting...'; } + var explorerBase = (typeof ETH_EXPLORER !== 'undefined' && ETH_EXPLORER) ? ETH_EXPLORER : 'https://etherscan.io'; + var claimTx = null; + try { + var c = _pendingClaim; + + if (claimBtn) { claimBtn.textContent = 'verifying...'; } + try { + await _ethProvider.request({ + method: 'eth_call', + params: [{ from: _ethAddr, to: ETH_BRIDGE, data: c.calldata }, 'latest'] + }); + } catch(simErr) { + if (claimBtn) { claimBtn.disabled = false; claimBtn.textContent = 'claim on ethereum (retry)'; } + showStatus('err', 'claim would revert right now. header may have been evicted or state changed. pending claim preserved - click button to retry in a few seconds.'); + return; + } + + if (claimBtn) { claimBtn.textContent = 'submitting to metamask...'; } + var gas = await getSafeGas(); + claimTx = await _ethProvider.request({ + method: 'eth_sendTransaction', + params: [{ from: _ethAddr, to: ETH_BRIDGE, data: c.calldata, gas: '0x60000', maxFeePerGas: gas.maxFeePerGas, maxPriorityFeePerGas: gas.maxPriorityFeePerGas }] + }); + } catch(e) { + if (claimBtn) { claimBtn.disabled = false; claimBtn.textContent = 'claim on ethereum (retry)'; } + showStatus('err', 'claim cancelled or rejected. click button to try again.'); + return; + } + + if (claimBtn) { claimBtn.textContent = 'waiting for confirmation...'; } + showStatus('info', 'tx submitted: ' + claimTx.slice(0, 10) + '... waiting for confirmation'); + + var receipt = await waitForReceipt(claimTx, 300000); + + if (!receipt) { + if (claimBtn) { claimBtn.disabled = false; claimBtn.textContent = 'claim on ethereum (retry)'; } + showStatus('err', 'tx not confirmed in 5 min. it may still land later, or was dropped. pending claim is preserved - refresh page to retry.'); + return; + } + + if (receipt.status !== '0x1') { + if (claimBtn) { claimBtn.disabled = false; claimBtn.textContent = 'claim on ethereum (retry)'; } + showStatus('err', 'tx reverted on-chain. view on etherscan - pending claim preserved, click button to retry.'); + return; + } + + setStep('claim', 'done'); + if (claimBtn) claimBtn.remove(); + showStatus('ok', 'wOCT claimed! view on etherscan'); + _pendingClaim = null; + if (_activeHistoryId) { + historyUpdate(_activeHistoryId, {status:'claimed', claim_tx_hash:claimTx}); + _activeHistoryId = null; + } + await refreshBalances(); +} + +function historyLoad() { + try { return JSON.parse(localStorage.getItem('bridge_history') || '[]'); } catch(e) { return []; } +} + +function historySave(arr) { + try { localStorage.setItem('bridge_history', JSON.stringify(arr.slice(0, 50))); } catch(e) {} +} + +var _historyShowAll = false; +var _historyPageSize = 5; + +function historyToggleShowAll() { + _historyShowAll = !_historyShowAll; + historyRender(); +} + +function historyGet(id) { + var arr = historyLoad(); + for (var i = 0; i < arr.length; i++) if (arr[i].id === id) return arr[i]; + return null; +} + +function historyAdd(entry) { + var arr = historyLoad(); + arr.unshift(entry); + historySave(arr); + historyRender(); + return entry; +} + +function historyUpdate(id, patch) { + var arr = historyLoad(); + for (var i = 0; i < arr.length; i++) { + if (arr[i].id === id) { + for (var k in patch) arr[i][k] = patch[k]; + break; + } + } + historySave(arr); + historyRender(); +} + +function historyRemove(id) { + var arr = historyLoad().filter(function(e) { return e.id !== id; }); + historySave(arr); + historyRender(); +} + +function historyRender() { + var arr = historyLoad(); + var sec = document.getElementById('history-section'); + var list = document.getElementById('history-list'); + if (!sec || !list) return; + sec.style.display = 'block'; + list.innerHTML = ''; + if (arr.length === 0) { + list.innerHTML = '
no bridge activity yet - your locks will appear here
'; + return; + } + var labels = { + pending_header: 'waiting', claimable: 'claim', claiming: 'claiming...', + claimed: 'claimed', expired: 'expired', failed: 'failed', + burning: 'burning', burn_pending: 'unlocking...', unlocked: 'unlocked' + }; + var visible = _historyShowAll ? arr : arr.slice(0, _historyPageSize); + for (var i = 0; i < visible.length; i++) { + var e = visible[i]; + var d = new Date(e.locked_at); + var hh = String(d.getHours()).padStart(2, '0'); + var mm = String(d.getMinutes()).padStart(2, '0'); + var toShort = e.recipient ? (e.recipient.substr(0, 6) + '...' + e.recipient.substr(-4)) : ''; + var cls = 'pending'; + if (e.status === 'claimable') cls = 'claimable'; + else if (e.status === 'claimed' || e.status === 'unlocked') cls = 'claimed'; + else if (e.status === 'expired') cls = 'expired'; + else if (e.status === 'failed') cls = 'failed'; + else if (e.status === 'claiming' || e.status === 'burn_pending') cls = 'claiming'; + var isReverse = e.direction === 'e2o'; + var srcToken = isReverse ? 'wOCT' : 'OCT'; + var topLine = '
' + e.amt_display + ' ' + srcToken + ' -> ' + toShort + '
'; + var midLine; + if (isReverse) { + var burnLink = e.burn_tx_hash ? ('' + hh + ':' + mm + '') : (hh + ':' + mm); + var burnTag = e.burn_tx_hash ? (' | burn ' + e.burn_tx_hash.slice(0, 8) + '...') : ''; + var unlockInfo = (e.status === 'unlocked' && e.recipient) ? (' | view') : ''; + midLine = '
' + burnLink + burnTag + unlockInfo + '
'; + } else { + var lockLink = e.lock_tx_hash ? ('' + hh + ':' + mm + '') : (hh + ':' + mm); + var claimInfo = e.claim_tx_hash ? (' | view') : ''; + var epTag = e.epoch ? (' | ep ' + e.epoch) : ' | ep pending'; + midLine = '
' + lockLink + epTag + claimInfo + '
'; + } + var row = document.createElement('div'); + row.className = 'hs-item'; + row.innerHTML = + '
' + topLine + midLine + '
' + + '
' + (labels[e.status] || e.status) + '
'; + var statusEl = row.querySelector('.hs-status'); + if (e.status === 'claimable') { + statusEl.onclick = (function(id) { return function() { historyClaim(id); }; })(e.id); + } + list.appendChild(row); + } + if (arr.length > _historyPageSize) { + var toggle = document.createElement('div'); + toggle.style.cssText = 'text-align:center;padding:8px 0;font-size:10px;color:#3B567F;cursor:pointer;border-top:1px solid #F0F2F5;margin-top:4px'; + if (_historyShowAll) { + toggle.textContent = 'show less'; + } else { + toggle.textContent = 'show all (' + (arr.length - _historyPageSize) + ' more)'; + } + toggle.onclick = historyToggleShowAll; + list.appendChild(toggle); + } +} + +function historyClearOld() { + var arr = historyLoad(); + var now = Date.now(); + var kept = arr.filter(function(e) { + if (e.status === 'claimed' || e.status === 'unlocked') return now - e.locked_at < 86400000; + if (e.status === 'expired' || e.status === 'failed') return false; + return true; + }); + historySave(kept); + historyRender(); +} + +async function historyCheckE2o(entry) { + if (!entry.burn_tx_hash) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } + if (!_ethProvider) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } + try { + var r = await _ethProvider.request({method:'eth_getTransactionReceipt', params:[entry.burn_tx_hash]}); + if (!r) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } + if (r.status === '0x0') { + historyUpdate(entry.id, {status:'failed', last_error:'burn tx reverted', last_checked:Date.now()}); + return; + } + if (entry.status === 'burning') { + historyUpdate(entry.id, {status:'burn_pending', last_checked:Date.now(), last_error:null}); + } else { + historyUpdate(entry.id, {last_checked:Date.now()}); + } + } catch(e) { + historyUpdate(entry.id, {last_checked:Date.now(), last_error:(e.message || 'receipt check failed')}); + } +} + +async function historyCheckOne(entry) { + if (entry.status === 'claimed' || entry.status === 'claiming' || entry.status === 'unlocked') return; + if (Date.now() - entry.locked_at > 86400000) { + if (entry.status !== 'expired') historyUpdate(entry.id, {status:'expired', last_checked:Date.now()}); + return; + } + if (entry.direction === 'e2o') return historyCheckE2o(entry); + if (!entry.epoch && entry.lock_tx_hash) { + try { + var txi = await wcli('GET', '/transaction?hash=' + entry.lock_tx_hash); + if (txi && txi.epoch) { + historyUpdate(entry.id, {epoch: txi.epoch}); + entry.epoch = txi.epoch; + } else { + historyUpdate(entry.id, {last_checked:Date.now(), last_error:'tx not finalized yet'}); + return; + } + } catch(e) { + historyUpdate(entry.id, {last_checked:Date.now(), last_error:'epoch lookup failed'}); + return; + } + } + if (!entry.epoch) return; + try { + var rpcBody = JSON.stringify({jsonrpc:'2.0',id:1,method:'bridgeHeader',params:[entry.epoch]}); + var resp = await fetch(SIGNER_URL, {method:'POST', headers:{'Content-Type':'application/json'}, body:rpcBody}).catch(function(){return null;}); + if (!resp || !resp.ok) { historyUpdate(entry.id, {last_checked:Date.now(), last_error:'signer unreachable'}); return; } + var data = await resp.json(); + if (!data.result || !data.result.message_count) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } + var claimData = await buildClaimCalldata(entry.epoch, entry.recipient, entry.amount_raw, data.result); + if (!claimData) { historyUpdate(entry.id, {last_checked:Date.now(), last_error:'no claim calldata'}); return; } + if (_ethProvider && _ethAddr) { + try { + await _ethProvider.request({method:'eth_call', params:[{from:_ethAddr, to:ETH_BRIDGE, data:claimData.calldata}, 'latest']}); + historyUpdate(entry.id, {status:'claimable', claim_data:claimData, last_checked:Date.now(), last_error:null}); + } catch(err) { + var m = (err && (err.message || String(err))) || ''; + var d = (err && err.data) ? (typeof err.data === 'string' ? err.data : (err.data.data || err.data.originalError && err.data.originalError.data || '')) : ''; + var ml = m.toLowerCase(); + var isReplay = ml.indexOf('already') >= 0 || ml.indexOf('replay') >= 0 || d === '0xb5a78004' || m.indexOf('0xb5a78004') >= 0; + var isUnknownHeader = d === '0xa2ad39b9' || m.indexOf('0xa2ad39b9') >= 0; + var isCapExceeded = d === '0xa4875a49' || m.indexOf('0xa4875a49') >= 0; + var isInvalidProof = d === '0x09bde339' || m.indexOf('0x09bde339') >= 0; + if (isReplay) { + historyUpdate(entry.id, {status:'claimed', last_checked:Date.now(), last_error:null}); + } else if (isUnknownHeader) { + historyUpdate(entry.id, {claim_data:claimData, last_checked:Date.now(), last_error:'header not yet on ethereum'}); + } else if (isCapExceeded) { + historyUpdate(entry.id, {claim_data:claimData, last_checked:Date.now(), last_error:'bridge mint cap too low for this amount'}); + } else if (isInvalidProof) { + historyUpdate(entry.id, {claim_data:claimData, last_checked:Date.now(), last_error:'merkle proof invalid (signer out of sync)'}); + } else { + historyUpdate(entry.id, {claim_data:claimData, last_checked:Date.now(), last_error:m}); + } + } + } else { + historyUpdate(entry.id, {claim_data:claimData, last_checked:Date.now()}); + } + } catch(e) { + historyUpdate(entry.id, {last_checked:Date.now(), last_error:e.message}); + } +} + +async function historyCheckAll() { + var arr = historyLoad(); + for (var i = 0; i < arr.length; i++) { + var e = arr[i]; + if (e.status === 'claimed' || e.status === 'expired' || e.status === 'claiming' || e.status === 'unlocked') continue; + await historyCheckOne(e); + } +} + +var _historyPollTimer = null; +var _historyPollBusy = false; + +function historyHasActive() { + var arr = historyLoad(); + for (var i = 0; i < arr.length; i++) { + var s = arr[i].status; + if (s !== 'claimed' && s !== 'unlocked' && s !== 'expired' && s !== 'failed') return true; + } + return false; +} + +function startHistoryAutoPoll() { + if (_historyPollTimer) return; + _historyPollTimer = setInterval(async function() { + if (_historyPollBusy) return; + if (document.hidden) return; + if (!historyHasActive()) return; + _historyPollBusy = true; + try { await historyCheckAll(); } catch(e) {} + _historyPollBusy = false; + }, 10000); +} + +async function historyRefreshAll() { + showStatus('info', 'refreshing history...'); + await historyCheckAll(); + var arr = historyLoad(); + var pending = arr.filter(function(e){return e.status==='pending_header';}).length; + var claimable = arr.filter(function(e){return e.status==='claimable';}).length; + showStatus('info', 'history refreshed: ' + claimable + ' claimable, ' + pending + ' waiting'); +} + +async function historyClaim(id) { + var entry = historyGet(id); + if (!entry) return; + if (!entry.claim_data) { showStatus('err', 'no claim data cached, click refresh status'); return; } + if (!_ethProvider || !_ethAddr) { showStatus('err', 'connect metamask first'); return; } + if (!await ensureCorrectChain()) return; + historyUpdate(id, {status:'claiming'}); + showStatus('info', 'submitting claim: ' + entry.amt_display + ' wOCT ep ' + entry.epoch); + try { + try { + await _ethProvider.request({method:'eth_call', params:[{from:_ethAddr, to:ETH_BRIDGE, data:entry.claim_data.calldata}, 'latest']}); + } catch(simErr) { + var em = (simErr && (simErr.message || String(simErr))) || ''; + var ed = (simErr && simErr.data) ? (typeof simErr.data === 'string' ? simErr.data : (simErr.data.data || simErr.data.originalError && simErr.data.originalError.data || '')) : ''; + var eml = em.toLowerCase(); + var simReplay = eml.indexOf('already') >= 0 || eml.indexOf('replay') >= 0 || ed === '0xb5a78004' || em.indexOf('0xb5a78004') >= 0; + if (simReplay) { + historyUpdate(id, {status:'claimed', last_error:null}); + showStatus('info', 'already claimed on-chain (detected during sim). marked as claimed.'); + await refreshBalances(); + return; + } + historyUpdate(id, {status:'claimable', last_error:em}); + showStatus('err', 'claim would revert: ' + em); + return; + } + var gasFees = await getSafeGas(); + var txReq = {from:_ethAddr, to:ETH_BRIDGE, data:entry.claim_data.calldata}; + for (var k in gasFees) txReq[k] = gasFees[k]; + var txHash = await _ethProvider.request({method:'eth_sendTransaction', params:[txReq]}); + historyUpdate(id, {claim_tx_hash:txHash}); + showStatus('info', 'tx submitted: ' + txHash.slice(0,10) + '...'); + var receipt = await waitForReceipt(txHash, 300000); + if (!receipt) { + historyUpdate(id, {status:'claimable'}); + showStatus('err', 'tx not confirmed in 5 min, will retry on refresh'); + return; + } + if (receipt.status === '0x0') { + historyUpdate(id, {status:'claimable', last_error:'tx reverted'}); + showStatus('err', 'tx reverted. view'); + return; + } + historyUpdate(id, {status:'claimed', claim_tx_hash:txHash}); + showStatus('ok', 'wOCT claimed! view'); + await refreshBalances(); + } catch(e) { + historyUpdate(id, {status:'claimable', last_error:e.message}); + showStatus('err', 'claim failed: ' + e.message); + } +} + +function historyMigrateLegacy() { + try { + var oldLock = localStorage.getItem('bridge_pending_lock'); + if (oldLock) { + var l = JSON.parse(oldLock); + if (!historyGet('legacy_lock_' + l.epoch)) { + historyAdd({ + id: 'legacy_lock_' + l.epoch + '_' + Date.now(), + locked_at: l.started_at || Date.now(), + lock_tx_hash: l.lock_tx_hash || '', + epoch: l.epoch, + recipient: l.recipient, + amount_raw: l.amount_raw, + amt_display: l.amt_display || String(l.amount_raw), + status: 'pending_header' + }); + } + localStorage.removeItem('bridge_pending_lock'); + } + } catch(e) {} + try { + var oldClaim = localStorage.getItem('bridge_pending_claim'); + if (oldClaim) { + var c = JSON.parse(oldClaim); + var ep = c.claim && c.claim.epoch_id ? c.claim.epoch_id : 0; + if (ep) { + historyAdd({ + id: 'legacy_claim_' + ep + '_' + Date.now(), + locked_at: Date.now(), + lock_tx_hash: '', + epoch: ep, + recipient: _ethAddr || '', + amount_raw: 0, + amt_display: c.amt || '?', + status: 'claimable', + claim_data: c.claim + }); + } + localStorage.removeItem('bridge_pending_claim'); + } + } catch(e) {} +} + +function historyHasMessage(mid, epoch, recipLower, amountRaw) { + if (!mid && !(epoch && recipLower && amountRaw)) return false; + var arr = historyLoad(); + for (var i = 0; i < arr.length; i++) { + var e = arr[i]; + if (mid && e.message_id && e.message_id.toLowerCase() === mid.toLowerCase()) return true; + if (mid && e.claim_data && e.claim_data.message && e.claim_data.message.message_id && e.claim_data.message.message_id.toLowerCase() === mid.toLowerCase()) return true; + if (epoch && recipLower && amountRaw) { + if (e.epoch === epoch && (e.recipient || '').toLowerCase() === recipLower && String(e.amount_raw) === String(amountRaw)) return true; + } + } + return false; +} + +async function recoveryFetch(silent) { + if (!_ethAddr) { + if (!silent) showStatus('err', 'connect metamask first to scan for recoverable bridges'); + return 0; + } + var target = _ethAddr.toLowerCase(); + try { + if (!silent) showStatus('info', 'scanning recovery feed...'); + var resp = await fetch(RECOVERY_URL, {method:'GET', cache:'no-store'}); + if (!resp.ok) { if (!silent) showStatus('err', 'recovery feed unreachable (' + resp.status + ')'); return 0; } + var data = await resp.json(); + var by = data && data.by_recipient ? data.by_recipient : {}; + var bucket = by[target] || by[_ethAddr] || []; + if (!Array.isArray(bucket) || bucket.length === 0) { + if (!silent) showStatus('info', 'no recoverable bridges found for ' + _ethAddr.substring(0,8) + '...' + _ethAddr.slice(-4)); + return 0; + } + var added = 0; + for (var i = 0; i < bucket.length; i++) { + var m = bucket[i]; + if (!m || typeof m !== 'object') continue; + var mid = m.message_id || ''; + var ep = typeof m.epoch === 'number' ? m.epoch : parseInt(m.epoch, 10); + var amtRaw = String(m.amount_raw || '0'); + if (!ep || !amtRaw || amtRaw === '0') continue; + if (historyHasMessage(mid, ep, target, amtRaw)) continue; + var amtDisplay = fmtU(amtRaw, OCT_DECIMALS); + var lockedAt = m.found_at ? (m.found_at * 1000) : Date.now(); + historyAdd({ + id: 'recovered_' + (mid ? mid.substring(2, 12) : ep + '_' + i) + '_' + Date.now(), + locked_at: lockedAt, + lock_tx_hash: m.tx_hash || '', + epoch: ep, + recipient: _ethAddr, + amount_raw: amtRaw, + amt_display: amtDisplay, + status: 'pending_header', + message_id: mid, + recovered: true + }); + added += 1; + } + if (added > 0) { + if (!silent) showStatus('ok', 'imported ' + added + ' recoverable bridge' + (added === 1 ? '' : 's') + ', checking status...'); + await historyCheckAll(); + var arr2 = historyLoad(); + var claimable = arr2.filter(function(e){return e.status==='claimable';}).length; + if (!silent) showStatus('ok', 'recovery done: ' + claimable + ' ready to claim now'); + } else { + if (!silent) showStatus('info', 'all found bridges are already in your history'); + } + return added; + } catch(e) { + if (!silent) showStatus('err', 'recovery failed: ' + (e.message || 'unknown')); + return 0; + } +} + +async function checkPendingClaim() { + try { + historyMigrateLegacy(); + historyRender(); + await historyCheckAll(); + startHistoryAutoPoll(); + } catch(e) { showStatus('err', 'history check failed: ' + e.message); } +} + +async function waitForClaimData(epochId, recipient, rawAmt) { + var start = Date.now(); + while (Date.now() - start < 300000) { + try { + var rpcBody = JSON.stringify({jsonrpc:'2.0',id:1,method:'bridgeHeader',params:[epochId]}); + var resp = await fetch(SIGNER_URL, { + method: 'POST', headers: {'Content-Type':'application/json'}, body: rpcBody + }).catch(function() { return null; }); + if (resp && resp.ok) { + var data = await resp.json(); + if (data.result && data.result.message_count > 0) { + return await buildClaimCalldata(epochId, recipient, rawAmt, data.result); + } + } + } catch(e) {} + await sleep(5000); + } + return null; +} + +async function buildClaimCalldata(epochId, recipient, rawAmt, headerData) { + try { + var msgBody = JSON.stringify({jsonrpc:'2.0',id:1,method:'bridgeMessagesByEpoch',params:[epochId]}); + var msgResp = await fetch(SIGNER_URL, { + method: 'POST', headers: {'Content-Type':'application/json'}, body: msgBody + }); + var msgData = await msgResp.json(); + var messages = msgData.result.messages; + var myMsg = messages.find(function(m) { + return m.recipient.toLowerCase() === recipient.toLowerCase(); + }); + if (!myMsg) return null; + + var cdBody = JSON.stringify({jsonrpc:'2.0',id:1,method:'bridgeClaimCalldata',params:[epochId, myMsg.leaf_index]}); + var cdResp = await fetch(SIGNER_URL, { + method: 'POST', headers: {'Content-Type':'application/json'}, body: cdBody + }); + var cdData = await cdResp.json(); + if (cdData.result && cdData.result.calldata) { + return { calldata: cdData.result.calldata, epochId: epochId, message: myMsg }; + } + return null; + } catch(e) { return null; } +} + +async function doReverse() { + if (!_ethProvider || !_ethAddr) { showStatus('err', 'connect metamask first'); return; } + if (!await ensureCorrectChain()) return; + var amt = $('bridge-amount').value.trim(); + var recip = $('recipient').value.trim(); + var rawAmt = parseU(amt, OCT_DECIMALS); + var btn = $('bridge-btn'); + btn.disabled = true; btn.classList.add('loading'); btn.textContent = 'bridging...'; + clearStatus(); + showProgress([ + { id: 'approve', text: 'approving wOCT spend...' }, + { id: 'burn', text: 'burning ' + amt + ' wOCT...' }, + { id: 'unlock', text: 'unlocking OCT on octra...' } + ]); + setStep('approve', 'active'); + var burnHistoryId = null; + try { + var gas1 = await getSafeGas(); + var approveData = '0x095ea7b3' + ETH_BRIDGE.substring(2).toLowerCase().padStart(64, '0') + BigInt(rawAmt).toString(16).padStart(64, '0'); + var approveTx = await _ethProvider.request({ + method: 'eth_sendTransaction', + params: [{ from: _ethAddr, to: WOCT_ADDR, data: approveData, gas: '0x30000', maxFeePerGas: gas1.maxFeePerGas, maxPriorityFeePerGas: gas1.maxPriorityFeePerGas }] + }); + var approveReceipt = await waitForReceipt(approveTx, 300000); + if (!approveReceipt || approveReceipt.status !== '0x1') { + showStatus('err', 'approve tx failed or dropped. your wOCT is still in the wallet - try again.'); + setCurrentStepFail(); + btn.classList.remove('loading'); validateForm(); + return; + } + setStep('approve', 'done'); setStep('burn', 'active'); + + var gas2 = await getSafeGas(); + var burnSig = '0xe3e3aed0'; + var encoded = abiEncodeStringUint(recip, rawAmt); + var burnData = burnSig + encoded; + var burnTx = await _ethProvider.request({ + method: 'eth_sendTransaction', + params: [{ from: _ethAddr, to: ETH_BRIDGE, data: burnData, gas: '0x40000', maxFeePerGas: gas2.maxFeePerGas, maxPriorityFeePerGas: gas2.maxPriorityFeePerGas }] + }); + burnHistoryId = 'burn_' + burnTx.slice(2, 10) + '_' + Date.now(); + historyAdd({ + id: burnHistoryId, + direction: 'e2o', + locked_at: Date.now(), + burn_tx_hash: burnTx, + approve_tx_hash: approveTx, + recipient: recip, + amount_raw: rawAmt, + amt_display: amt, + status: 'burning' + }); + var explorerBase2 = (typeof ETH_EXPLORER !== 'undefined' && ETH_EXPLORER) ? ETH_EXPLORER : 'https://etherscan.io'; + showStatus('info', 'burn tx submitted: ' + burnTx.slice(0, 10) + '... waiting for confirmation'); + var burnReceipt = await waitForReceipt(burnTx, 300000); + if (!burnReceipt) { + showStatus('err', 'burn tx not confirmed in 5 min. may still land later or was dropped. your wOCT is still in the wallet - try again.'); + setCurrentStepFail(); + if (burnHistoryId) historyUpdate(burnHistoryId, {status:'failed', last_error:'not confirmed in 5 min'}); + btn.classList.remove('loading'); validateForm(); + return; + } + if (burnReceipt.status !== '0x1') { + showStatus('err', 'burn tx reverted on-chain. view on etherscan - your wOCT is still in the wallet.'); + setCurrentStepFail(); + if (burnHistoryId) historyUpdate(burnHistoryId, {status:'failed', last_error:'tx reverted'}); + btn.classList.remove('loading'); validateForm(); + return; + } + if (burnHistoryId) historyUpdate(burnHistoryId, {status:'burn_pending'}); + setStep('burn', 'done'); setStep('unlock', 'active'); + showStatus('info', 'wOCT burned. waiting for OCT unlock on octra... view tx'); + var prevOct = _octBalance; + var unlocked = await pollUntilChange(function() { return getOctBalance(); }, prevOct, 180); + if (unlocked) { + setStep('unlock', 'done'); + showStatus('ok', 'OCT unlocked! view on octra'); + if (burnHistoryId) historyUpdate(burnHistoryId, {status:'unlocked'}); + await refreshBalances(); + } else { + showStatus('info', 'wOCT burned. OCT unlock may take a few minutes.'); + } + } catch(e) { + showStatus('err', e.message); + setCurrentStepFail(); + if (burnHistoryId) historyUpdate(burnHistoryId, {status:'failed', last_error:(e.message || 'unknown')}); + } + btn.classList.remove('loading'); validateForm(); +} + +async function waitReceipt(hash, maxWait) { + var start = Date.now(); + while (Date.now() - start < maxWait * 1000) { + try { var r = await wcli('GET', '/contract/receipt?hash=' + hash); if (r && r.success !== undefined) return r; } catch(e) {} + await sleep(3000); + } + return null; +} + +function keccak256(sig) { + var encoder = new TextEncoder(); + var data = encoder.encode(sig); + var hash = ''; + var h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; + return '00000000'; +} + +function abiEncodeStringUint(str, uint) { + var offset = '0000000000000000000000000000000000000000000000000000000000000040'; + var uintHex = BigInt(uint).toString(16).padStart(64, '0'); + var strLen = str.length.toString(16).padStart(64, '0'); + var strHex = ''; + for (var i = 0; i < str.length; i++) strHex += str.charCodeAt(i).toString(16).padStart(2, '0'); + while (strHex.length % 64 !== 0) strHex += '0'; + return offset + uintHex + strLen + strHex; +} + +var _steps = []; +function showProgress(steps) { _steps = steps; var el = $('progress-area'); el.style.display = ''; el.innerHTML = steps.map(function(s) { return '
' + esc(s.text) + '
'; }).join(''); } +function setStep(id, state) { var el = document.getElementById('step-' + id); if (el) el.className = 'step ' + state; } +function setCurrentStepFail() { _steps.forEach(function(s) { var el = document.getElementById('step-' + s.id); if (el && el.classList.contains('active')) el.className = 'step fail'; }); } + +function $(id) { return document.getElementById(id); } +function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); } +function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } +async function getWoctBalance() { + if (!_ethAddr || !_ethProvider || !WOCT_ADDR) return '0'; + try { + var data = '0x70a08231000000000000000000000000' + _ethAddr.substring(2); + var result = await _ethProvider.request({ method: 'eth_call', params: [{ to: WOCT_ADDR, data: data }, 'latest'] }); + if (!result || result === '0x' || result === '0x0') return '0'; + return BigInt(result).toString(); + } catch(e) { return '0'; } +} + +async function getOctBalance() { + if (!_octraAddr) return '0'; + try { var b = await wcli('GET', '/balance'); return b.public_balance || '0'; } catch(e) { return '0'; } +} + +async function pollUntilChange(getFn, prevVal, maxSec) { + var start = Date.now(); + while (Date.now() - start < maxSec * 1000) { + await sleep(5000); + var cur = await getFn(); + if (cur !== prevVal && BigInt(cur) !== BigInt(prevVal)) return true; + } + return false; +} + +function showStatus(type, msg) { var el = $('status-area'); el.className = 'status-msg ' + type; el.innerHTML = msg; } +function clearStatus() { $('status-area').className = 'status-msg'; $('status-area').textContent = ''; $('progress-area').style.display = 'none'; } +function fmtU(raw, dec) { var s = raw.toString().padStart(dec + 1, '0'); var i = s.slice(0, s.length - dec) || '0'; var f = s.slice(s.length - dec).replace(/0+$/, ''); return f ? addCommas(i) + '.' + f : addCommas(i); } +function parseU(h, dec) { h = h.replace(/,/g, ''); var p = h.split('.'); var i = p[0] || '0'; var f = (p[1] || '').padEnd(dec, '0').substring(0, dec); return (BigInt(i) * BigInt(10 ** dec) + BigInt(f)).toString(); } +function addCommas(s) { var p = s.split('.'); p[0] = p[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return p.join('.'); } + +connectOctra().catch(function(){}); +checkPendingClaim(); +setInterval(function() { refreshBalances(); }, 10000); +setTimeout(function() { + var saved = null; + try { saved = localStorage.getItem('bridge_eth_wallet'); } catch(e) {} + if (saved && _eip6963Providers.length > 0) { + var match = _eip6963Providers.find(function(p) { return p.name === saved; }); + if (match) connectWithProvider({ name: match.name, provider: match.provider, icon: match.icon }).catch(function(){}); + } +}, 300); + +(function() { + var actions = { + connectOctra, connectEth, setDir, validateForm, setMax, doBridge, + historyRefreshAll, historyClearOld, closeModal, confirmBridge, closeWalletModal, + selectWallet, + recoveryFetch: function() { recoveryFetch(false); } + }; + function run(e, attr) { + var el = e.target.closest('[' + attr + ']'); + if (!el) return; + var fn = actions[el.getAttribute(attr)]; + if (!fn) return; + if (el.getAttribute('data-prevent') === '1') e.preventDefault(); + fn(el.getAttribute('data-arg'), el, e); + } + document.addEventListener('click', function(e) { run(e, 'data-action'); }); + document.addEventListener('input', function(e) { run(e, 'data-input'); }); +})(); \ No newline at end of file diff --git a/static/circles.html b/static/circles.html new file mode 100644 index 0000000..ee2e8c2 --- /dev/null +++ b/static/circles.html @@ -0,0 +1,484 @@ + + + + + +octra circles + + + + +
+
+
+
octra_wallet
+
alpha version of the browser for circles and native programs (night build)
+
+
+
circle viewer
+
+
+ + + +
+
+
+
circle browser
+
+
+
+ + +
+
+ + +
+
+ +
+
+
simple viewing mode: paste an `oct://...` address, enter the sealed read key if needed, this will open the resource.
+
idle
+
+
+ +
+ + + circle info + no information yet + + + +
+
+
+
+ +
+
+ + resource preview + + +
+
+
no asset loaded
+
+
+
+ +
+
+ + +
+ +
+
+
+
expanded browser
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+ + + + diff --git a/static/circles.js b/static/circles.js new file mode 100644 index 0000000..f7c9b5b --- /dev/null +++ b/static/circles.js @@ -0,0 +1,1994 @@ +const $ = (id) => document.getElementById(id) +const bindIfPresent = (id, eventName, handler) => { + const element = $(id) + if (element) { + element.addEventListener(eventName, handler) + } +} +const utf8Encoder = new TextEncoder() +const utf8Decoder = new TextDecoder() +const runtimeBase = window.location.protocol === 'file:' ? 'http://127.0.0.1:8420' : '' +const sealedMagic = utf8Encoder.encode('OCRS1') +const keyCache = new Map() +const decryptedCache = new Map() +const bridgeGrantState = new Map() +let activeBridgeWindow = null +let activeBridgeContext = null +let expandedPreviewOpen = false + +const bytesToBase64 = (bytes) => { + let text = '' + const chunk = 0x8000 + for (let index = 0; index < bytes.length; index += chunk) { + text += String.fromCharCode(...bytes.subarray(index, index + chunk)) + } + return btoa(text) +} + +const base64ToBytes = (b64) => Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)) + +const utf8Bytes = (text) => utf8Encoder.encode(text) + +const bytesToText = (bytes) => utf8Decoder.decode(bytes) + +const mergeBytes = (...parts) => { + const total = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(total) + let offset = 0 + parts.forEach((part) => { + out.set(part, offset) + offset += part.length + }) + return out +} + +const u32be = (value) => { + const out = new Uint8Array(4) + new DataView(out.buffer).setUint32(0, value, false) + return out +} + +const readU32be = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, false) + +const u64be = (value) => { + const out = new Uint8Array(8) + const view = new DataView(out.buffer) + const big = BigInt(value) + view.setUint32(0, Number((big >> 32n) & 0xffffffffn), false) + view.setUint32(4, Number(big & 0xffffffffn), false) + return out +} + +const randomBytes = (size) => { + const out = new Uint8Array(size) + crypto.getRandomValues(out) + return out +} + +const hexOfBytes = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + +const sha256Hex = async (bytes) => hexOfBytes(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))) + +const sha256Raw = async (bytes) => new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)) + +const h256Raw = async (tag, parts) => { + const prefix = mergeBytes(utf8Bytes(tag), new Uint8Array([0])) + const framed = parts.reduce( + (acc, part) => mergeBytes(acc, u32be(part.length), part), + prefix + ) + return sha256Raw(framed) +} + +const h256Hex = async (tag, parts) => hexOfBytes(await h256Raw(tag, parts)) + +const resourceKeyOfPath = (circleId, canonicalPath) => h256Hex('octra:circle_resource_key:v1', [utf8Bytes(circleId), utf8Bytes(canonicalPath)]) +const resourceKeyOfSlotRef = (circleId, slotRef) => h256Hex('octra:circle_resource_key:slot:v1', [utf8Bytes(circleId), utf8Bytes(slotRef)]) + +const base58Encode = (bytes) => { + const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + let value = 0n + bytes.forEach((byte) => { + value = (value << 8n) + BigInt(byte) + }) + let encoded = '' + while (value > 0n) { + const digit = Number(value % 58n) + encoded = alphabet[digit] + encoded + value /= 58n + } + let leadingZeros = 0 + while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) { + encoded = `1${encoded}` + leadingZeros += 1 + } + return encoded +} + +const buildCircleDeployPayload = () => ({ + runtime: 'octb', + privacy_class: 'sealed', + browser_mode: 'native_sealed', + resource_mode: 'sealed_read', + code_b64: null, + policy_hash: null, + members_root: null, + export_policy: null, + limits: { + max_stable_bytes: '33554432', + max_assets_bytes: '33554432', + max_inline_value: '65536', + max_wasm_bytes: '33554432' + } +}) + +const circleIdOfDeploy = async (deployer, nonce, payload) => { + const payloadHash = await h256Hex('octra:circle_deploy_payload:v1', [utf8Bytes(JSON.stringify(payload))]) + const seed = await h256Raw('octra:circle_deploy_id:v1', [utf8Bytes(deployer), u64be(nonce), utf8Bytes(payloadHash)]) + const base58 = base58Encode(seed) + const base58Part = base58.length >= 44 + ? base58.slice(0, 44) + : base58.length === 0 + ? '1'.repeat(44) + : (base58 + base58.repeat(Math.ceil((44 - base58.length) / base58.length))).slice(0, 44) + return `oct${base58Part}` +} + +const isTextContent = (contentType) => ( + contentType.startsWith('text/') + || contentType.includes('json') + || contentType.includes('javascript') + || contentType.includes('xml') + || contentType.includes('svg') +) + +const isBlockedRemoteSpec = (spec) => /^(https?:)?\/\//i.test(spec) || /^javascript:/i.test(spec) || /^mailto:/i.test(spec) + +const isDataSpec = (spec) => /^data:/i.test(spec) || /^blob:/i.test(spec) + +const normalizeAssetPath = (rawPath) => { + const path = (rawPath || '').trim() + if (!path) return '/index.html' + return path.startsWith('/') ? path : `/${path}` +} + +const circleUriOf = (circleId, path) => `oct://${circleId}${normalizeAssetPath(path)}` + +const decodeUriPart = (value) => { + try { + return decodeURIComponent(value) + } catch (err) { + return value + } +} + +const parseCircleUri = (uri) => { + const raw = (uri || '').trim() + const decodedRaw = decodeUriPart(raw).trim() + if (!decodedRaw.toLowerCase().startsWith('oct://')) { + return null + } + const rest = decodeUriPart(decodedRaw.slice(6)).split(/[?#]/, 1)[0] + if (!rest) { + return null + } + const slashIndex = rest.indexOf('/') + if (slashIndex === -1) { + return { + circleId: rest, + path: '/index.html', + uri: circleUriOf(rest, '/index.html') + } + } + const circleId = rest.slice(0, slashIndex) + const path = normalizeAssetPath(rest.slice(slashIndex)) + if (!circleId) { + return null + } + return { + circleId, + path, + uri: circleUriOf(circleId, path) + } +} + +const parseCircleTarget = (rawCircle, rawPath) => { + const parsedUri = parseCircleUri(rawCircle) + if (parsedUri) { + return parsedUri + } + const circleId = (rawCircle || '').trim() + const path = normalizeAssetPath(rawPath) + return { + circleId, + path, + uri: circleId ? circleUriOf(circleId, path) : '' + } +} + +const currentCircleTarget = () => parseCircleTarget($('circle-id').value.trim(), '/index.html') + +const circleResourceUrl = (circleId, path) => { + const parts = normalizeAssetPath(path).split('/').filter(Boolean).map(encodeURIComponent) + if (!parts.length) return `${runtimeBase}/oct/${encodeURIComponent(circleId)}/` + return `${runtimeBase}/oct/${encodeURIComponent(circleId)}/${parts.join('/')}` +} + +const setStatus = (nodeId, text, bad) => { + const element = $(nodeId) + if (!element) { + return + } + element.textContent = text + element.style.color = bad ? '#3B567F' : '#516E9A' +} + +const previewInlineHost = () => $('preview-inline-host') + +const previewOverlayHost = () => $('preview-overlay-host') + +const syncActiveBridgeWindow = () => { + const frame = $('preview-body').querySelector('iframe') + if (!frame) { + activeBridgeWindow = null + return + } + if (activeBridgeContext) { + activeBridgeWindow = frame.contentWindow + } +} + +const movePreviewHost = (target) => { + if (!target) { + return + } + target.appendChild($('preview-head')) + target.appendChild($('preview-body')) + syncActiveBridgeWindow() +} + +const syncOverlayControlsFromMain = () => { + $('overlay-circle-id').value = $('circle-id').value + $('overlay-sealed-passphrase').value = $('sealed-passphrase').value +} + +const syncMainControlsFromOverlay = () => { + $('circle-id').value = $('overlay-circle-id').value + $('sealed-passphrase').value = $('overlay-sealed-passphrase').value +} + +const closeExpandedPreview = () => { + expandedPreviewOpen = false + $('preview-overlay').classList.remove('is-open') + document.body.classList.remove('circle-overlay-open') + movePreviewHost(previewInlineHost()) +} + +const setPreviewExpandAvailable = (available) => { + $('preview-expand-btn').hidden = !available + if (!available && expandedPreviewOpen) { + closeExpandedPreview() + } +} + +const openExpandedPreview = () => { + if ($('preview-expand-btn').hidden) { + return + } + syncOverlayControlsFromMain() + expandedPreviewOpen = true + $('preview-overlay').classList.add('is-open') + document.body.classList.add('circle-overlay-open') + movePreviewHost(previewOverlayHost()) +} + +const toggleExpandedPreview = () => { + if (expandedPreviewOpen) { + closeExpandedPreview() + return + } + openExpandedPreview() +} + +const circleConfirm = (title, message, confirmLabel = 'confirm') => new Promise((resolve) => { + const overlay = document.createElement('div') + const box = document.createElement('div') + const titleNode = document.createElement('div') + const messageNode = document.createElement('div') + const buttons = document.createElement('div') + const cancelButton = document.createElement('button') + const confirmButton = document.createElement('button') + + overlay.className = 'modal-overlay' + box.className = 'modal-box' + titleNode.className = 'modal-title' + messageNode.className = 'modal-message' + buttons.className = 'modal-buttons' + cancelButton.className = 'modal-btn' + confirmButton.className = 'modal-btn modal-btn-primary' + + titleNode.textContent = title + messageNode.textContent = message + cancelButton.textContent = 'cancel' + confirmButton.textContent = confirmLabel + + const close = (accepted) => { + overlay.remove() + resolve(accepted) + } + + cancelButton.addEventListener('click', () => close(false)) + confirmButton.addEventListener('click', () => close(true)) + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + close(false) + } + }) + + buttons.append(cancelButton, confirmButton) + box.append(titleNode, messageNode, buttons) + overlay.append(box) + document.body.append(overlay) + confirmButton.focus() +}) + +const circlePin = (title, message) => new Promise((resolve) => { + const overlay = document.createElement('div') + const box = document.createElement('div') + const titleNode = document.createElement('div') + const messageNode = document.createElement('div') + const input = document.createElement('input') + const buttons = document.createElement('div') + const cancelButton = document.createElement('button') + const confirmButton = document.createElement('button') + + overlay.className = 'modal-overlay' + box.className = 'modal-box' + titleNode.className = 'modal-title' + messageNode.className = 'modal-message' + buttons.className = 'modal-buttons' + cancelButton.className = 'modal-btn' + confirmButton.className = 'modal-btn modal-btn-primary' + input.type = 'password' + input.autocomplete = 'current-password' + input.className = 'input-field' + + titleNode.textContent = title + messageNode.textContent = message + cancelButton.textContent = 'cancel' + confirmButton.textContent = 'sign' + + const close = (value) => { + overlay.remove() + resolve(value) + } + + cancelButton.addEventListener('click', () => close('')) + confirmButton.addEventListener('click', () => close(input.value)) + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + close(input.value) + } + }) + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + close('') + } + }) + + buttons.append(cancelButton, confirmButton) + box.append(titleNode, messageNode, input, buttons) + overlay.append(box) + document.body.append(overlay) + input.focus() +}) + +const renderMeta = (info) => { + $('meta-summary-note').textContent = 'identity | mode | roots | ownership' + const rows = [ + ['circle_id', info.circle_id], + ['runtime', info.runtime], + ['privacy_class', info.privacy_class], + ['browser_mode', info.browser_mode], + ['resource_mode', info.resource_mode], + ['owner', info.owner], + ['version', info.version], + ['code_hash', info.code_hash], + ['stable_root', info.stable_root], + ['assets_root', info.assets_root] + ] + const meta = $('meta') + meta.textContent = '' + rows.forEach(([key, value]) => { + const row = document.createElement('div') + row.className = 'circle-meta-row' + const keyCell = document.createElement('div') + keyCell.className = 'circle-meta-key' + keyCell.textContent = key + const valueCell = document.createElement('div') + valueCell.className = 'circle-meta-value' + valueCell.textContent = value ?? '' + row.append(keyCell, valueCell) + meta.append(row) + }) +} + +const resetMeta = () => { + $('meta-summary-note').textContent = 'no information yet' + $('meta').innerHTML = '' +} + +const fetchJson = async (url, options = {}) => { + const response = await fetch(`${runtimeBase}${url}`, options) + const text = await response.text() + const json = text ? JSON.parse(text) : {} + if (!response.ok) { + throw new Error(json.error || 'request failed') + } + return json +} + +const postJson = (url, payload) => fetchJson(url, { + method: 'POST', + headers: {'Content-Type': 'text/plain;charset=utf-8'}, + body: JSON.stringify(payload) +}) + +const clearBridgeContext = () => { + activeBridgeWindow = null + activeBridgeContext = null +} + +const authBridgeRootForCircle = (circleId) => { + if (circleId === 'octQXi2RUp2MXDPvFs2YPqhXuoaezq2isFpT8PvoCmacpvQ') { + return 'http://127.0.0.1:18423' + } + return '' +} + +const postBridgeReply = (target, token, id, ok, result, error) => { + if (!target || !token || !id) { + return + } + target.postMessage({ + type: 'octra.circle.bridge.reply', + token, + id, + ok, + result: ok ? result : undefined, + error: ok ? undefined : error + }, '*') +} + +const bridgeMethodsForInfo = (info) => [ + 'circle.context', + 'program.info', + 'program.view', + 'program.call', + 'program.storage', + 'sealed_slot.read', + 'sealed_slot.put', + 'sealed_state.read', + 'sealed_state.put', + 'slot_policy.read', + 'slot_policy.put', + 'state_policy.read', + 'state_policy.put', + 'state_descriptor.read', + 'state_descriptor.put', + 'balance_cell.read', + 'balance_cell.put', + 'balance_binding.read', + 'balance_workflow.read', + 'object.refs.read', + 'object.list.read', + 'object.detail.read', + 'object.member.read', + 'object.summary.read', + 'object.members.read', + 'object.policy.define', + 'object.bind', + 'object.member.attach', + 'object.member.detach', + 'object.transition.apply', + 'register_cell.read', + 'register_cell.put', + 'register_binding.read', + 'register_workflow.read', + 'transport_policy.read', + 'transport_policy.put', + 'hfhe_policy.read', + 'hfhe_policy.put', + 'key_policy.read', + 'key_policy.put', + 'key.grant', + 'key.extend', + 'key.revoke', + 'key.erase', + 'outbox.open', + 'outbox.intent', + 'outbox.claim', + 'outbox.status', + 'relay.claim', + 'relay.cancel', + 'ingress.commit', + 'ingress.packet', + 'wallet.info', + 'wallet.balance', + 'wallet.keys', + 'wallet.send', + 'fhe.load_pk', + 'fhe.encrypt', + 'fhe.decrypt', + 'fhe.commit', + 'fhe.pedersen', + 'fhe.serialize_cipher', + 'fhe.deserialize_cipher', + 'fhe.verify_zero', + 'fhe.verify_range', + 'fhe.verify_bound', + 'relay.request', + 'relay.status', + 'relay.response', + 'relay.receipt', + 'relay.ingress', + 'relay.health' +] + +const bridgeGrantTextOf = (context, method) => { + if (method === 'program.info' || method === 'program.view' || method === 'program.storage' || method === 'program.abi') { + return `allow this circle to read onchain program state for this session?\n\n${context.uri}` + } + if (method === 'program.call') { + return `allow this circle to submit onchain program calls for this session?\n\n${context.uri}` + } + if (method === 'sealed_slot.put' || method === 'sealed_state.put' || method === 'slot_policy.put' || method === 'state_policy.put' || method === 'state_descriptor.put' || method === 'balance_cell.put' || method === 'register_cell.put' || method === 'outbox.open' || method === 'ingress.commit') { + return `allow this circle to submit low-level circle runtime transactions for this session?\n\n${context.uri}` + } + if (method === 'object.policy.define' || method === 'object.bind' || method === 'object.member.attach' || method === 'object.member.detach' || method === 'object.transition.apply') { + return `allow this circle to submit private object runtime writes for this session?\n\n${context.uri}` + } + if (method === 'sealed_slot.read' || method === 'sealed_state.read' || method === 'slot_policy.read' || method === 'state_policy.read' || method === 'state_descriptor.read' || method === 'balance_cell.read' || method === 'balance_binding.read' || method === 'balance_workflow.read' || method === 'object.refs.read' || method === 'object.list.read' || method === 'object.detail.read' || method === 'object.member.read' || method === 'object.summary.read' || method === 'object.members.read' || method === 'register_cell.read' || method === 'register_binding.read' || method === 'register_workflow.read' || method === 'outbox.intent' || method === 'outbox.status' || method === 'ingress.packet') { + return `allow this circle to read low-level circle runtime state for this session?\n\n${context.uri}` + } + if (method === 'transport_policy.put' || method === 'hfhe_policy.put' || method === 'key_policy.put' || method === 'key.grant' || method === 'key.extend' || method === 'key.revoke' || method === 'key.erase' || method === 'relay.claim' || method === 'relay.cancel') { + return `allow this circle to modify low-level circle policy or relay transport state for this session?\n\n${context.uri}` + } + if (method === 'transport_policy.read' || method === 'hfhe_policy.read' || method === 'key_policy.read' || method === 'outbox.claim') { + return `allow this circle to read low-level circle policy or relay state for this session?\n\n${context.uri}` + } + if (method === 'wallet.info') { + return `allow this circle to read the active wallet address for this session?\n\n${context.uri}` + } + if (method === 'wallet.balance') { + return `allow this circle to read wallet balances for this session?\n\n${context.uri}` + } + if (method === 'wallet.keys') { + return `allow this circle to read the wallet view public key for this session?\n\n${context.uri}` + } + if (method === 'wallet.send') { + return `allow this circle to send OCT through the active wallet for this session?\n\n${context.uri}` + } + if (method === 'fhe.encrypt') { + return `allow this circle to request FHE encryption for this session?\n\n${context.uri}` + } + if (method === 'fhe.load_pk') { + return `allow this circle to request PVAC public keys for this session?\n\n${context.uri}` + } + if (method === 'fhe.decrypt') { + return `allow this circle to request FHE decryption for this session?\n\n${context.uri}` + } + if (method === 'fhe.commit') { + return `allow this circle to request FHE ciphertext commitments for this session?\n\n${context.uri}` + } + if (method === 'fhe.pedersen') { + return `allow this circle to request FHE amount commitments for this session?\n\n${context.uri}` + } + if (method === 'fhe.serialize_cipher') { + return `allow this circle to request canonical FHE cipher serialization for this session?\n\n${context.uri}` + } + if (method === 'fhe.deserialize_cipher') { + return `allow this circle to request canonical FHE cipher decoding for this session?\n\n${context.uri}` + } + if (method === 'fhe.verify_zero') { + return `allow this circle to request FHE zero-proof verification for this session?\n\n${context.uri}` + } + if (method === 'fhe.verify_range') { + return `allow this circle to request FHE range-proof verification for this session?\n\n${context.uri}` + } + if (method === 'fhe.verify_bound') { + return `allow this circle to request FHE bound-proof verification for this session?\n\n${context.uri}` + } + if (method.startsWith('relay.')) { + return `allow this circle to use the local relay membrane for this session?\n\n${context.uri}` + } + return `allow this circle to use runtime access method ${method} for this session?\n\n${context.uri}` +} + +const bridgeGrantScopeOf = (method) => { + if (method === 'circle.context') return 'circle.context' + if (method === 'program.call') return 'program.call' + if (method === 'program.info' || method === 'program.view' || method === 'program.storage' || method === 'program.abi') return 'program.read' + if (method === 'sealed_slot.put' || method === 'sealed_state.put' || method === 'slot_policy.put' || method === 'state_policy.put' || method === 'state_descriptor.put' || method === 'balance_cell.put' || method === 'register_cell.put' || method === 'outbox.open' || method === 'ingress.commit') return 'circle.write' + if (method === 'object.policy.define' || method === 'object.bind' || method === 'object.member.attach' || method === 'object.member.detach' || method === 'object.transition.apply') return 'circle.object.write' + if (method === 'sealed_slot.read' || method === 'sealed_state.read' || method === 'slot_policy.read' || method === 'state_policy.read' || method === 'state_descriptor.read' || method === 'balance_cell.read' || method === 'balance_binding.read' || method === 'balance_workflow.read' || method === 'object.refs.read' || method === 'object.list.read' || method === 'object.detail.read' || method === 'object.member.read' || method === 'object.summary.read' || method === 'object.members.read' || method === 'register_cell.read' || method === 'register_binding.read' || method === 'register_workflow.read' || method === 'outbox.intent' || method === 'outbox.status' || method === 'ingress.packet') return 'circle.read' + if (method === 'transport_policy.put' || method === 'hfhe_policy.put' || method === 'key_policy.put' || method === 'key.grant' || method === 'key.extend' || method === 'key.revoke' || method === 'key.erase' || method === 'relay.claim' || method === 'relay.cancel') return 'circle.policy.write' + if (method === 'transport_policy.read' || method === 'hfhe_policy.read' || method === 'key_policy.read' || method === 'outbox.claim') return 'circle.policy.read' + if (method === 'wallet.send') return 'wallet.send' + if (method === 'wallet.info') return 'wallet.info' + if (method === 'wallet.balance') return 'wallet.balance' + if (method === 'wallet.keys') return 'wallet.keys' + if (method === 'fhe.load_pk') return 'fhe.load_pk' + if (method === 'fhe.encrypt') return 'fhe.encrypt' + if (method === 'fhe.decrypt') return 'fhe.decrypt' + if (method === 'fhe.commit') return 'fhe.commit' + if (method === 'fhe.pedersen') return 'fhe.pedersen' + if (method === 'fhe.serialize_cipher') return 'fhe.serialize_cipher' + if (method === 'fhe.deserialize_cipher') return 'fhe.deserialize_cipher' + if (method === 'fhe.verify_zero') return 'fhe.verify_zero' + if (method === 'fhe.verify_range') return 'fhe.verify_range' + if (method === 'fhe.verify_bound') return 'fhe.verify_bound' + if (method.startsWith('relay.')) return 'relay.access' + return method +} + +const ensureBridgeGrant = async (method) => { + if (!activeBridgeContext) { + throw new Error('sealed bridge inactive') + } + if (method === 'circle.context') { + return + } + const grantKey = `${activeBridgeContext.circle_id}:${bridgeGrantScopeOf(method)}` + if (bridgeGrantState.get(grantKey)) { + return + } + const allowed = await circleConfirm('runtime access', bridgeGrantTextOf(activeBridgeContext, method), 'allow') + if (!allowed) { + throw new Error(`runtime access denied: ${method}`) + } + bridgeGrantState.set(grantKey, true) +} + +const bridgeResultOf = async (method, payload = {}) => { + if (!activeBridgeContext) { + throw new Error('sealed bridge inactive') + } + if (!activeBridgeContext.bridge_methods.includes(method)) { + throw new Error(`bridge method not allowed: ${method}`) + } + await ensureBridgeGrant(method) + if (method === 'circle.context') { + return activeBridgeContext + } + const bridgeCircleId = activeBridgeContext.circle_id || '' + const authBridgeRoot = authBridgeRootForCircle(bridgeCircleId) + const requireBridgeCircleId = () => { + if (!bridgeCircleId) { + throw new Error('sealed bridge has no active circle target') + } + if (payload.address) { + throw new Error('bridge target override denied') + } + if (payload.circle_id && payload.circle_id !== bridgeCircleId) { + throw new Error('bridge target override denied') + } + return bridgeCircleId + } + if (method === 'program.info') { + const effectiveCircleId = requireBridgeCircleId() + if (authBridgeRoot) { + return fetchJson(`${authBridgeRoot}/api/program/info`) + } + return fetchJson(`/api/program/info?circle_id=${encodeURIComponent(effectiveCircleId)}`) + } + if (method === 'program.view') { + const effectiveCircleId = requireBridgeCircleId() + const nextPayload = { ...payload, circle_id: effectiveCircleId } + if (authBridgeRoot) { + return postJson(`${authBridgeRoot}/api/program/view`, nextPayload) + } + return postJson('/api/program/view', nextPayload) + } + if (method === 'program.call') { + const effectiveCircleId = requireBridgeCircleId() + const pin = await circlePin('confirm program call', 'enter PIN to sign this circle program call') + if (!pin) { + throw new Error('program call cancelled') + } + const nextPayload = { ...payload, circle_id: effectiveCircleId, pin } + return postJson('/api/program/call', nextPayload) + } + if (method === 'program.storage') { + if (!payload.key) { + throw new Error('program.storage requires key') + } + const effectiveCircleId = requireBridgeCircleId() + return fetchJson(`/api/program/storage?circle_id=${encodeURIComponent(effectiveCircleId)}&key=${encodeURIComponent(payload.key)}`) + } + if (method === 'program.abi') { + throw new Error('program.abi disabled in native sealed mode') + } + if (method === 'sealed_slot.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.slot_ref) { + throw new Error('sealed_slot.read requires slot_ref') + } + return fetchJson(`/api/circle/asset_ciphertext_by_slot?circle_id=${encodeURIComponent(effectiveCircleId)}&slot_ref=${encodeURIComponent(payload.slot_ref)}`) + } + if (method === 'sealed_slot.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/sealed_slot_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'sealed_state.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.state_ref) { + throw new Error('sealed_state.read requires state_ref') + } + return fetchJson(`/api/circle/asset_ciphertext_by_state?circle_id=${encodeURIComponent(effectiveCircleId)}&state_ref=${encodeURIComponent(payload.state_ref)}`) + } + if (method === 'sealed_state.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/sealed_slot_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'slot_policy.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.slot_ref) { + throw new Error('slot_policy.read requires circle_id and slot_ref') + } + return fetchJson(`/api/circle/slot_policy?circle_id=${encodeURIComponent(effectiveCircleId)}&slot_ref=${encodeURIComponent(payload.slot_ref)}`) + } + if (method === 'slot_policy.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/slot_policy_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'state_policy.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.state_ref) { + throw new Error('state_policy.read requires state_ref') + } + return fetchJson(`/api/circle/state_policy?circle_id=${encodeURIComponent(effectiveCircleId)}&state_ref=${encodeURIComponent(payload.state_ref)}`) + } + if (method === 'state_policy.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/slot_policy_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'state_descriptor.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.state_ref) { + throw new Error('state_descriptor.read requires state_ref') + } + return fetchJson(`/api/circle/state_descriptor?circle_id=${encodeURIComponent(effectiveCircleId)}&state_ref=${encodeURIComponent(payload.state_ref)}`) + } + if (method === 'state_descriptor.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/state_descriptor_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'balance_cell.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.state_ref) { + throw new Error('balance_cell.read requires state_ref') + } + return fetchJson(`/api/circle/balance_cell?circle_id=${encodeURIComponent(effectiveCircleId)}&state_ref=${encodeURIComponent(payload.state_ref)}`) + } + if (method === 'balance_binding.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.subject_addr) { + throw new Error('balance_binding.read requires subject_addr') + } + return fetchJson(`/api/circle/balance_binding?circle_id=${encodeURIComponent(effectiveCircleId)}&subject_addr=${encodeURIComponent(payload.subject_addr)}`) + } + if (method === 'balance_workflow.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.workflow_ref) { + throw new Error('balance_workflow.read requires workflow_ref') + } + return fetchJson(`/api/circle/balance_workflow?circle_id=${encodeURIComponent(effectiveCircleId)}&workflow_ref=${encodeURIComponent(payload.workflow_ref)}`) + } + if (method === 'object.refs.read') { + const effectiveCircleId = requireBridgeCircleId() + return fetchJson(`/api/circle/object_refs?circle_id=${encodeURIComponent(effectiveCircleId)}`) + } + if (method === 'object.list.read') { + const effectiveCircleId = requireBridgeCircleId() + return fetchJson(`/api/circle/object_list?circle_id=${encodeURIComponent(effectiveCircleId)}`) + } + if (method === 'object.detail.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.object_ref) { + throw new Error('object.detail.read requires object_ref') + } + return fetchJson(`/api/circle/object_detail?circle_id=${encodeURIComponent(effectiveCircleId)}&object_ref=${encodeURIComponent(payload.object_ref)}`) + } + if (method === 'object.member.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.object_ref || !payload.member_ref) { + throw new Error('object.member.read requires object_ref and member_ref') + } + return fetchJson(`/api/circle/object_member?circle_id=${encodeURIComponent(effectiveCircleId)}&object_ref=${encodeURIComponent(payload.object_ref)}&member_ref=${encodeURIComponent(payload.member_ref)}`) + } + if (method === 'object.summary.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.object_ref) { + throw new Error('object.summary.read requires object_ref') + } + return fetchJson(`/api/circle/object_summary?circle_id=${encodeURIComponent(effectiveCircleId)}&object_ref=${encodeURIComponent(payload.object_ref)}`) + } + if (method === 'object.members.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.object_ref) { + throw new Error('object.members.read requires object_ref') + } + return fetchJson(`/api/circle/object_members?circle_id=${encodeURIComponent(effectiveCircleId)}&object_ref=${encodeURIComponent(payload.object_ref)}`) + } + if (method === 'object.policy.define') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/object_policy_define', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'object.bind') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/object_bind', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'object.member.attach') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/object_member_attach', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'object.member.detach') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/object_member_detach', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'object.transition.apply') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/object_transition_apply', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'balance_cell.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/balance_cell_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'register_cell.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.state_ref) { + throw new Error('register_cell.read requires state_ref') + } + return fetchJson(`/api/circle/register_cell?circle_id=${encodeURIComponent(effectiveCircleId)}&state_ref=${encodeURIComponent(payload.state_ref)}`) + } + if (method === 'register_binding.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.register_ref) { + throw new Error('register_binding.read requires register_ref') + } + return fetchJson(`/api/circle/register_binding?circle_id=${encodeURIComponent(effectiveCircleId)}®ister_ref=${encodeURIComponent(payload.register_ref)}`) + } + if (method === 'register_workflow.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.workflow_ref) { + throw new Error('register_workflow.read requires workflow_ref') + } + return fetchJson(`/api/circle/register_workflow?circle_id=${encodeURIComponent(effectiveCircleId)}&workflow_ref=${encodeURIComponent(payload.workflow_ref)}`) + } + if (method === 'register_cell.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/register_cell_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'transport_policy.read') { + const effectiveCircleId = requireBridgeCircleId() + return fetchJson(`/api/circle/transport_policy?circle_id=${encodeURIComponent(effectiveCircleId)}`) + } + if (method === 'transport_policy.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/transport_policy_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'hfhe_policy.read') { + const effectiveCircleId = requireBridgeCircleId() + return fetchJson(`/api/circle/hfhe_policy?circle_id=${encodeURIComponent(effectiveCircleId)}`) + } + if (method === 'hfhe_policy.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/hfhe_policy_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'key_policy.read') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.key_id) { + throw new Error('key_policy.read requires key_id') + } + return fetchJson(`/api/circle/key_policy?circle_id=${encodeURIComponent(effectiveCircleId)}&key_id=${encodeURIComponent(payload.key_id)}`) + } + if (method === 'key_policy.put') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/key_policy_put', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'key.grant') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/key_grant', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'key.extend') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/key_extend', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'key.revoke') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/key_revoke', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'key.erase') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/key_erase', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'outbox.open') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/outbox_open', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'outbox.intent') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.intent_id) { + throw new Error('outbox.intent requires circle_id and intent_id') + } + return fetchJson(`/api/circle/outbox_intent?circle_id=${encodeURIComponent(effectiveCircleId)}&intent_id=${encodeURIComponent(payload.intent_id)}`) + } + if (method === 'outbox.claim') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.intent_id) { + throw new Error('outbox.claim requires circle_id and intent_id') + } + return fetchJson(`/api/circle/outbox_claim?circle_id=${encodeURIComponent(effectiveCircleId)}&intent_id=${encodeURIComponent(payload.intent_id)}`) + } + if (method === 'outbox.status') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.intent_id) { + throw new Error('outbox.status requires circle_id and intent_id') + } + return fetchJson(`/api/circle/outbox_status?circle_id=${encodeURIComponent(effectiveCircleId)}&intent_id=${encodeURIComponent(payload.intent_id)}`) + } + if (method === 'relay.claim') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/relay_claim', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'relay.cancel') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/relay_cancel', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'ingress.commit') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/ingress_commit', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'ingress.packet') { + const effectiveCircleId = requireBridgeCircleId() + if (!payload.intent_id) { + throw new Error('ingress.packet requires circle_id and intent_id') + } + return fetchJson(`/api/circle/ingress_packet?circle_id=${encodeURIComponent(effectiveCircleId)}&intent_id=${encodeURIComponent(payload.intent_id)}`) + } + if (method === 'wallet.info') { + if (authBridgeRoot) { + return fetchJson(`${authBridgeRoot}/api/wallet/info`) + } + return fetchJson('/api/wallet') + } + if (method === 'wallet.balance') { + if (authBridgeRoot) { + return fetchJson(`${authBridgeRoot}/api/wallet/balance`) + } + return fetchJson('/api/balance') + } + if (method === 'wallet.keys') { + return fetchJson('/api/keys') + } + if (method === 'wallet.send') { + return postJson('/api/send', payload) + } + if (method === 'fhe.load_pk') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/load_pk', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.encrypt') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/encrypt', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.decrypt') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/decrypt', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.commit') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/commit', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.pedersen') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/pedersen', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.serialize_cipher') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/serialize_cipher', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.deserialize_cipher') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/deserialize_cipher', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.verify_zero') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/verify_zero', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.verify_range') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/verify_range', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'fhe.verify_bound') { + const effectiveCircleId = requireBridgeCircleId() + return postJson('/api/circle/fhe/verify_bound', { ...payload, circle_id: effectiveCircleId }) + } + if (method === 'relay.request') { + return postJson('/api/relay/request', payload) + } + if (method === 'relay.status') { + const suffix = payload.request_id ? `?request_id=${encodeURIComponent(payload.request_id)}` : '' + return fetchJson(`/api/relay/status${suffix}`) + } + if (method === 'relay.response') { + if (!payload.request_id) { + throw new Error('relay.response requires request_id') + } + return fetchJson(`/api/relay/response?request_id=${encodeURIComponent(payload.request_id)}`) + } + if (method === 'relay.receipt') { + if (!payload.request_id) { + throw new Error('relay.receipt requires request_id') + } + return fetchJson(`/api/relay/receipt?request_id=${encodeURIComponent(payload.request_id)}`) + } + if (method === 'relay.ingress') { + if (!payload.request_id) { + throw new Error('relay.ingress requires request_id') + } + return fetchJson(`/api/relay/ingress?request_id=${encodeURIComponent(payload.request_id)}`) + } + if (method === 'relay.health') { + return fetchJson('/api/relay/health') + } + throw new Error(`unsupported bridge method: ${method}`) +} + +window.addEventListener('message', async (event) => { + if (!activeBridgeWindow || event.source !== activeBridgeWindow) { + return + } + const data = event.data + if (!data || typeof data !== 'object') { + return + } + if (!activeBridgeContext || data.token !== activeBridgeContext.bridge_token) { + return + } + if (data.type === 'octra.circle.navigate') { + const target = parseCircleTarget(data.uri || '', '/index.html') + if (!target.circleId) { + return + } + $('circle-id').value = target.uri + await loadCircle() + return + } + if (data.type !== 'octra.circle.bridge.request' || !data.id || !data.method) { + return + } + try { + const result = await bridgeResultOf(data.method, data.payload || {}) + postBridgeReply(event.source, data.token, data.id, true, result, '') + } catch (err) { + postBridgeReply(event.source, data.token, data.id, false, null, err.message || 'bridge request failed') + } +}) + +const padTargetBytes = (paddingClass) => { + if (paddingClass === '4k') return 4096 + if (paddingClass === '16k') return 16384 + if (paddingClass === '32k') return 32768 + if (paddingClass === '128k') return 131072 + return 0 +} + +const circleAssetMaxRawBytes = 33554432 +const circleAssetMaxB64Bytes = Math.ceil(circleAssetMaxRawBytes / 3) * 4 + +const paddedFrame = (plaintextBytes, paddingClass) => { + const bare = mergeBytes(u32be(plaintextBytes.length), plaintextBytes) + const target = padTargetBytes(paddingClass) + if (!target) return bare + const aligned = Math.ceil(bare.length / target) * target + if (aligned <= bare.length) return bare + return mergeBytes(bare, randomBytes(aligned - bare.length)) +} + +const circleAssetDecodedSizeUpperBound = wireLen => Math.ceil(wireLen / 4) * 3 + +const circleAssetFeeOfCiphertextB64 = ciphertextB64 => { + const rawUpperBound = circleAssetDecodedSizeUpperBound(ciphertextB64.length) + if (rawUpperBound <= 4096) return 5000 + if (rawUpperBound <= 16384) return 10000 + if (rawUpperBound <= 32768) return 20000 + if (rawUpperBound <= 131072) return 40000 + if (rawUpperBound <= 524288) return 80000 + if (rawUpperBound <= 2097152) return 160000 + if (rawUpperBound <= 8388608) return 320000 + return 640000 +} + +const deriveReadKey = async (circleId, keyId, passphrase) => { + const cacheKey = `${circleId}:${keyId}:${passphrase}` + if (!keyCache.has(cacheKey)) { + keyCache.set(cacheKey, (async () => { + const material = await crypto.subtle.importKey('raw', utf8Bytes(passphrase), 'PBKDF2', false, ['deriveKey']) + const salt = utf8Bytes(`octra:circle:sealed_read:v1:${circleId}:${keyId}`) + return crypto.subtle.deriveKey( + {name: 'PBKDF2', salt, iterations: 120000, hash: 'SHA-256'}, + material, + {name: 'AES-GCM', length: 256}, + false, + ['encrypt', 'decrypt'] + ) + })()) + } + return keyCache.get(cacheKey) +} + +const encryptSealedBytes = async (circleId, keyId, passphrase, plaintextBytes, paddingClass) => { + const key = await deriveReadKey(circleId, keyId, passphrase) + const nonce = randomBytes(12) + const frame = paddedFrame(plaintextBytes, paddingClass) + const cipherBuffer = await crypto.subtle.encrypt({name: 'AES-GCM', iv: nonce}, key, frame) + const envelope = mergeBytes(sealedMagic, nonce, new Uint8Array(cipherBuffer)) + return { + ciphertext_b64: bytesToBase64(envelope), + plaintext_hash: await sha256Hex(plaintextBytes) + } +} + +const decryptSealedBytes = async (circleId, asset, passphrase) => { + if (!asset.key_id || !asset.plaintext_hash) { + throw new Error('sealed asset metadata incomplete') + } + const envelope = base64ToBytes(asset.ciphertext_b64) + const magicText = bytesToText(envelope.subarray(0, sealedMagic.length)) + if (magicText !== 'OCRS1') { + throw new Error('invalid sealed envelope') + } + const nonce = envelope.subarray(sealedMagic.length, sealedMagic.length + 12) + const cipher = envelope.subarray(sealedMagic.length + 12) + const key = await deriveReadKey(circleId, asset.key_id, passphrase) + const plainFrame = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: nonce}, key, cipher)) + if (plainFrame.length < 4) { + throw new Error('invalid sealed payload') + } + const plainSize = readU32be(plainFrame.subarray(0, 4)) + if (plainSize > plainFrame.length - 4) { + throw new Error('invalid sealed payload length') + } + const plaintext = plainFrame.subarray(4, 4 + plainSize) + const actualHash = await sha256Hex(plaintext) + if (actualHash !== asset.plaintext_hash) { + throw new Error('plaintext hash mismatch') + } + return plaintext +} + +const resolveCirclePath = (basePath, spec) => { + if (!spec || spec.startsWith('#') || isDataSpec(spec) || isBlockedRemoteSpec(spec)) return spec + const base = `https://circle.local${normalizeAssetPath(basePath)}` + return new URL(spec, base).pathname +} + +const makeDataUrl = (contentType, bytes) => `data:${contentType};base64,${bytesToBase64(bytes)}` + +const loadPlainAsset = async (circleId, path) => fetchJson(`/api/circle/asset?circle_id=${encodeURIComponent(circleId)}&path=${encodeURIComponent(normalizeAssetPath(path))}`) + +const loadSealedAsset = async (circleId, path, passphrase, versionToken = '') => { + const normalizedPath = normalizeAssetPath(path) + const cacheKey = `${circleId}:${versionToken}:${normalizedPath}:${passphrase}` + if (!decryptedCache.has(cacheKey)) { + decryptedCache.set(cacheKey, (async () => { + const resourceKey = await resourceKeyOfPath(circleId, normalizedPath) + const asset = await fetchJson(`/api/circle/asset_ciphertext_by_key?circle_id=${encodeURIComponent(circleId)}&resource_key=${encodeURIComponent(resourceKey)}`) + const bytes = await decryptSealedBytes(circleId, asset, passphrase) + return { + ...asset, + canonical_path: asset.canonical_path || normalizedPath, + bytes, + text: isTextContent(asset.content_type) ? bytesToText(bytes) : '' + } + })()) + } + return decryptedCache.get(cacheKey) +} + +const ensureDocumentHead = (doc) => { + if (doc.head) return doc.head + const head = doc.createElement('head') + doc.documentElement.insertBefore(head, doc.body || null) + return head +} + +const prependHeadMeta = (doc, name, value, attrName) => { + const head = ensureDocumentHead(doc) + const meta = doc.createElement('meta') + meta.setAttribute(attrName, name) + meta.setAttribute('content', value) + head.prepend(meta) +} + +const materializeCss = async (circleId, cssPath, cssText, passphrase, versionToken = '', seen = new Set()) => { + const cssKey = `${circleId}:${cssPath}` + if (seen.has(cssKey)) return '' + const nextSeen = new Set(seen) + nextSeen.add(cssKey) + let result = cssText + const importRegex = /@import\s+(?:url\(\s*)?["']?([^"')\s]+)["']?\s*\)?\s*;/gi + let importMatch + while ((importMatch = importRegex.exec(result)) !== null) { + const source = importMatch[1].trim() + const replacement = isDataSpec(source) || isBlockedRemoteSpec(source) + ? '' + : await (async () => { + const resolved = resolveCirclePath(cssPath, source) + if (!resolved || isDataSpec(resolved) || isBlockedRemoteSpec(resolved)) return '' + const imported = await loadSealedAsset(circleId, resolved, passphrase, versionToken) + return await materializeCss(circleId, resolved, imported.text, passphrase, versionToken, nextSeen) + })() + result = `${result.slice(0, importMatch.index)}${replacement}${result.slice(importMatch.index + importMatch[0].length)}` + importRegex.lastIndex = 0 + } + const urlRegex = /url\(\s*(['"]?)([^"')]+)\1\s*\)/gi + let urlMatch + while ((urlMatch = urlRegex.exec(result)) !== null) { + const source = urlMatch[2].trim() + let replacement = urlMatch[0] + if (isBlockedRemoteSpec(source)) { + replacement = 'url("data:,")' + } else if (!isDataSpec(source)) { + const resolved = resolveCirclePath(cssPath, source) + if (!resolved || isBlockedRemoteSpec(resolved)) { + replacement = 'url("data:,")' + } else { + const asset = await loadSealedAsset(circleId, resolved, passphrase, versionToken) + replacement = `url("${makeDataUrl(asset.content_type, asset.bytes)}")` + } + } + result = `${result.slice(0, urlMatch.index)}${replacement}${result.slice(urlMatch.index + urlMatch[0].length)}` + urlRegex.lastIndex = urlMatch.index + replacement.length + } + return result +} + +const injectSealedPolicy = (doc) => { + doc.querySelectorAll('base').forEach((node) => node.remove()) + prependHeadMeta( + doc, + 'Content-Security-Policy', + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; connect-src 'none'; frame-src 'none'; child-src 'none'; worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; manifest-src 'none'; prefetch-src 'none'; navigate-to 'none'", + 'http-equiv' + ) + prependHeadMeta(doc, 'referrer', 'no-referrer', 'name') +} + +const sealedPreludeSource = (circleId, htmlPath, bridgeToken) => { + const contextJson = JSON.stringify({ + circle_id: circleId, + path: normalizeAssetPath(htmlPath), + uri: circleUriOf(circleId, htmlPath) + }) + const tokenJson = JSON.stringify(bridgeToken) + return `(function () { + const context = ${contextJson}; + const bridgeToken = ${tokenJson}; + const waiters = new Map(); + let nextRequestId = 0; + const safe = function (fn) { + try { + return fn(); + } catch (_) { + return undefined; + } + }; + const deny = function (name) { + throw new Error(name + ' disabled in native sealed mode'); + }; + const blockedStorage = function (name) { + return Object.freeze({ + getItem: function () { deny(name); }, + setItem: function () { deny(name); }, + removeItem: function () { deny(name); }, + clear: function () { deny(name); }, + key: function () { return null; }, + get length() { return 0; } + }); + }; + const allowSpec = function (value) { + const spec = String(value || '').trim().toLowerCase(); + return spec === '' || spec[0] === '#' || spec.startsWith('data:') || spec.startsWith('blob:') || spec.startsWith('about:blank') || spec.startsWith('oct://'); + }; + const guardSpec = function (name, value) { + if (!allowSpec(value)) { + throw new Error(name + ' blocked in native sealed mode'); + } + return value; + }; + const redefine = function (target, key, getter) { + try { + Object.defineProperty(target, key, { + configurable: true, + get: getter + }); + } catch (_) {} + }; + const blockProperty = function (target, key) { + redefine(target, key, function () { + deny(key); + }); + }; + window.OctraCircle = Object.freeze({ + context: Object.freeze(context), + request: function (method, payload) { + return new Promise(function (resolve, reject) { + const id = 'req_' + String(++nextRequestId); + waiters.set(id, { resolve: resolve, reject: reject }); + parent.postMessage({ + type: 'octra.circle.bridge.request', + token: bridgeToken, + id: id, + method: method, + payload: payload || {} + }, '*'); + }); + }, + navigate: function (uri) { + parent.postMessage({ + type: 'octra.circle.navigate', + token: bridgeToken, + uri: uri + }, '*'); + } + }); + window.addEventListener('message', function (event) { + const data = event.data; + if (!data || data.token !== bridgeToken || data.type !== 'octra.circle.bridge.reply' || !data.id || !waiters.has(data.id)) { + return; + } + const waiter = waiters.get(data.id); + waiters.delete(data.id); + if (data.ok) { + waiter.resolve(data.result); + return; + } + waiter.reject(new Error(data.error || 'bridge request failed')); + }); + const wrapUrlProperty = function (target, key) { + if (!target) { + return; + } + try { + const desc = Object.getOwnPropertyDescriptor(target, key); + if (!desc || !desc.configurable || !desc.set) { + return; + } + Object.defineProperty(target, key, { + configurable: true, + enumerable: desc.enumerable, + get: desc.get ? function () { return desc.get.call(this); } : function () { return ''; }, + set: function (value) { + desc.set.call(this, guardSpec(key, value)); + } + }); + } catch (_) {} + }; + safe(function () { + const rawSetAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function (name, value) { + const lower = String(name || '').toLowerCase(); + if (lower === 'src' || lower === 'href' || lower === 'poster' || lower === 'action') { + guardSpec(lower, value); + } + return rawSetAttribute.call(this, name, value); + }; + }); + safe(function () { redefine(navigator, 'userAgent', function () { return 'OctraCircle/1'; }); }); + safe(function () { redefine(navigator, 'platform', function () { return 'Octra'; }); }); + safe(function () { redefine(navigator, 'language', function () { return 'en-US'; }); }); + safe(function () { redefine(navigator, 'languages', function () { return Object.freeze(['en-US']); }); }); + safe(function () { redefine(navigator, 'hardwareConcurrency', function () { return 4; }); }); + safe(function () { redefine(navigator, 'deviceMemory', function () { return 4; }); }); + safe(function () { redefine(window, 'devicePixelRatio', function () { return 1; }); }); + safe(function () { + navigator.sendBeacon = function () { return false; }; + }); + safe(function () { + if (window.Intl && window.Intl.DateTimeFormat && window.Intl.DateTimeFormat.prototype) { + const rawResolvedOptions = window.Intl.DateTimeFormat.prototype.resolvedOptions; + window.Intl.DateTimeFormat.prototype.resolvedOptions = function () { + const out = rawResolvedOptions ? rawResolvedOptions.call(this) : {}; + out.locale = 'en-US'; + out.timeZone = 'UTC'; + return out; + }; + } + }); + safe(function () { + if (window.Date && window.Date.prototype) { + window.Date.prototype.getTimezoneOffset = function () { + return 0; + }; + } + }); + window.fetch = function () { deny('fetch'); }; + window.XMLHttpRequest = function () { deny('XMLHttpRequest'); }; + window.WebSocket = function () { deny('WebSocket'); }; + window.EventSource = function () { deny('EventSource'); }; + window.Worker = function () { deny('Worker'); }; + window.SharedWorker = function () { deny('SharedWorker'); }; + window.BroadcastChannel = function () { deny('BroadcastChannel'); }; + window.open = function () { return null; }; + blockProperty(window, 'localStorage'); + blockProperty(window, 'sessionStorage'); + blockProperty(window, 'indexedDB'); + blockProperty(window, 'caches'); + safe(function () { + if (navigator.serviceWorker) { + blockProperty(navigator, 'serviceWorker'); + } + }); + safe(function () { + if (window.Document && window.Document.prototype) { + Object.defineProperty(window.Document.prototype, 'cookie', { + configurable: true, + get: function () { return ''; }, + set: function () { return ''; } + }); + } + }); + safe(function () { if (window.HTMLImageElement) wrapUrlProperty(window.HTMLImageElement.prototype, 'src'); }); + safe(function () { if (window.HTMLScriptElement) wrapUrlProperty(window.HTMLScriptElement.prototype, 'src'); }); + safe(function () { if (window.HTMLLinkElement) wrapUrlProperty(window.HTMLLinkElement.prototype, 'href'); }); + safe(function () { if (window.HTMLIFrameElement) wrapUrlProperty(window.HTMLIFrameElement.prototype, 'src'); }); + safe(function () { if (window.HTMLSourceElement) wrapUrlProperty(window.HTMLSourceElement.prototype, 'src'); }); + safe(function () { + if (window.HTMLMediaElement) { + wrapUrlProperty(window.HTMLMediaElement.prototype, 'src'); + wrapUrlProperty(window.HTMLMediaElement.prototype, 'poster'); + } + }); + safe(function () { if (window.HTMLAnchorElement) wrapUrlProperty(window.HTMLAnchorElement.prototype, 'href'); }); + safe(function () { if (window.HTMLFormElement) wrapUrlProperty(window.HTMLFormElement.prototype, 'action'); }); + safe(function () { + if (window.HTMLCanvasElement && window.HTMLCanvasElement.prototype) { + if (window.HTMLCanvasElement.prototype.toDataURL) { + window.HTMLCanvasElement.prototype.toDataURL = function () { deny('canvas.toDataURL'); }; + } + if (window.HTMLCanvasElement.prototype.toBlob) { + window.HTMLCanvasElement.prototype.toBlob = function () { deny('canvas.toBlob'); }; + } + } + }); + safe(function () { + if (window.CanvasRenderingContext2D && window.CanvasRenderingContext2D.prototype && window.CanvasRenderingContext2D.prototype.getImageData) { + window.CanvasRenderingContext2D.prototype.getImageData = function () { deny('canvas.getImageData'); }; + } + }); + document.addEventListener('click', function (event) { + const anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null; + if (!anchor) { + return; + } + const href = anchor.getAttribute('href') || ''; + if (href.startsWith('oct://')) { + event.preventDefault(); + window.OctraCircle.navigate(href); + return; + } + if (!allowSpec(href)) { + event.preventDefault(); + } + }, true); + window.addEventListener('submit', function (event) { + event.preventDefault(); + }, true); +})();` +} + +const publicPreludeSource = (circleId, htmlPath, bridgeToken) => { + const contextJson = JSON.stringify({ + circle_id: circleId, + path: normalizeAssetPath(htmlPath), + uri: circleUriOf(circleId, htmlPath) + }) + const tokenJson = JSON.stringify(bridgeToken) + return `(function () { + const context = ${contextJson}; + const bridgeToken = ${tokenJson}; + const waiters = new Map(); + let nextRequestId = 0; + window.OctraCircle = Object.freeze({ + context: Object.freeze(context), + request: function (method, payload) { + return new Promise(function (resolve, reject) { + const id = 'req_' + String(++nextRequestId); + waiters.set(id, { resolve: resolve, reject: reject }); + parent.postMessage({ + type: 'octra.circle.bridge.request', + token: bridgeToken, + id: id, + method: method, + payload: payload || {} + }, '*'); + }); + }, + navigate: function (uri) { + parent.postMessage({ + type: 'octra.circle.navigate', + token: bridgeToken, + uri: uri + }, '*'); + } + }); + window.addEventListener('message', function (event) { + const data = event.data; + if (!data || data.token !== bridgeToken || data.type !== 'octra.circle.bridge.reply' || !data.id || !waiters.has(data.id)) { + return; + } + const waiter = waiters.get(data.id); + waiters.delete(data.id); + if (data.ok) { + waiter.resolve(data.result); + return; + } + waiter.reject(new Error(data.error || 'bridge request failed')); + }); + document.addEventListener('click', function (event) { + const anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null; + if (!anchor) { + return; + } + const href = anchor.getAttribute('href') || ''; + if (href.startsWith('oct://')) { + event.preventDefault(); + window.OctraCircle.navigate(href); + } + }, true); +})();` +} + +const installSealedPrelude = (doc, circleId, htmlPath, bridgeToken) => { + const head = ensureDocumentHead(doc) + const script = doc.createElement('script') + script.textContent = sealedPreludeSource(circleId, htmlPath, bridgeToken) + head.prepend(script) +} + +const installPublicPrelude = (doc, circleId, htmlPath, bridgeToken) => { + const head = ensureDocumentHead(doc) + const script = doc.createElement('script') + script.textContent = publicPreludeSource(circleId, htmlPath, bridgeToken) + head.prepend(script) +} + +const rewriteInternalAnchor = (circleId, basePath, href) => { + if (!href || href.startsWith('#') || isDataSpec(href)) return href + if (isBlockedRemoteSpec(href)) return '#' + const resolved = resolveCirclePath(basePath, href) + if (!resolved || isBlockedRemoteSpec(resolved)) return '#' + return circleUriOf(circleId, resolved) +} + +const materializeSealedHtml = async (circleId, htmlPath, htmlText, passphrase, bridgeToken, versionToken = '') => { + const doc = new DOMParser().parseFromString(htmlText, 'text/html') + injectSealedPolicy(doc) + installSealedPrelude(doc, circleId, htmlPath, bridgeToken) + const inlineStyles = Array.from(doc.querySelectorAll('style')) + for (const node of inlineStyles) { + node.textContent = await materializeCss(circleId, htmlPath, node.textContent || '', passphrase, versionToken) + } + const styleLinks = Array.from(doc.querySelectorAll('link[href]')) + for (const node of styleLinks) { + const rel = (node.getAttribute('rel') || '').toLowerCase() + const href = node.getAttribute('href') || '' + if (rel.includes('stylesheet')) { + if (isBlockedRemoteSpec(href)) { + node.remove() + } else { + const resolved = resolveCirclePath(htmlPath, href) + if (!resolved || isBlockedRemoteSpec(resolved)) { + node.remove() + } else { + const asset = await loadSealedAsset(circleId, resolved, passphrase, versionToken) + const style = doc.createElement('style') + style.textContent = await materializeCss(circleId, resolved, asset.text, passphrase, versionToken) + node.replaceWith(style) + } + } + } else if (isBlockedRemoteSpec(href)) { + node.removeAttribute('href') + } else if (!isDataSpec(href)) { + const resolved = resolveCirclePath(htmlPath, href) + if (resolved) { + const asset = await loadSealedAsset(circleId, resolved, passphrase, versionToken) + node.setAttribute('href', makeDataUrl(asset.content_type, asset.bytes)) + } + } + } + const scripts = Array.from(doc.querySelectorAll('script[src]')) + for (const node of scripts) { + const src = node.getAttribute('src') || '' + if (isBlockedRemoteSpec(src)) { + node.remove() + } else { + const resolved = resolveCirclePath(htmlPath, src) + if (!resolved || isBlockedRemoteSpec(resolved)) { + node.remove() + } else { + const asset = await loadSealedAsset(circleId, resolved, passphrase, versionToken) + const inline = doc.createElement('script') + inline.textContent = asset.text + node.replaceWith(inline) + } + } + } + const sourcedNodes = Array.from(doc.querySelectorAll('[src]')) + for (const node of sourcedNodes) { + if (node.tagName.toLowerCase() === 'script') { + continue + } + const src = node.getAttribute('src') || '' + if (isBlockedRemoteSpec(src)) { + node.removeAttribute('src') + } else if (!isDataSpec(src)) { + const resolved = resolveCirclePath(htmlPath, src) + if (resolved && !isBlockedRemoteSpec(resolved)) { + const asset = await loadSealedAsset(circleId, resolved, passphrase, versionToken) + node.setAttribute('src', makeDataUrl(asset.content_type, asset.bytes)) + } + } + } + const posterNodes = Array.from(doc.querySelectorAll('[poster]')) + for (const node of posterNodes) { + const poster = node.getAttribute('poster') || '' + if (isBlockedRemoteSpec(poster)) { + node.removeAttribute('poster') + } else if (!isDataSpec(poster)) { + const resolved = resolveCirclePath(htmlPath, poster) + if (resolved && !isBlockedRemoteSpec(resolved)) { + const asset = await loadSealedAsset(circleId, resolved, passphrase, versionToken) + node.setAttribute('poster', makeDataUrl(asset.content_type, asset.bytes)) + } + } + } + const anchors = Array.from(doc.querySelectorAll('a[href]')) + anchors.forEach((node) => { + node.setAttribute('href', rewriteInternalAnchor(circleId, htmlPath, node.getAttribute('href') || '')) + }) + const forms = Array.from(doc.querySelectorAll('form[action]')) + forms.forEach((node) => { + node.setAttribute('action', '#') + }) + return `\n${doc.documentElement.outerHTML}` +} + +const rewritePublicAssetRefs = (doc, circleId, htmlPath) => { + const rewriteAttr = (node, attr) => { + const spec = node.getAttribute(attr) || '' + if (!spec || isDataSpec(spec)) return + if (isBlockedRemoteSpec(spec)) { + node.removeAttribute(attr) + return + } + const resolved = resolveCirclePath(htmlPath, spec) + if (resolved && !isBlockedRemoteSpec(resolved)) { + node.setAttribute(attr, circleResourceUrl(circleId, resolved)) + } + } + Array.from(doc.querySelectorAll('[src]')).forEach((node) => rewriteAttr(node, 'src')) + Array.from(doc.querySelectorAll('link[href]')).forEach((node) => rewriteAttr(node, 'href')) + Array.from(doc.querySelectorAll('[poster]')).forEach((node) => rewriteAttr(node, 'poster')) + Array.from(doc.querySelectorAll('a[href]')).forEach((node) => { + node.setAttribute('href', rewriteInternalAnchor(circleId, htmlPath, node.getAttribute('href') || '')) + }) + Array.from(doc.querySelectorAll('form[action]')).forEach((node) => { + node.setAttribute('action', '#') + }) +} + +const materializePublicHtml = (circleId, htmlPath, htmlText, bridgeToken) => { + const doc = new DOMParser().parseFromString(htmlText, 'text/html') + installPublicPrelude(doc, circleId, htmlPath, bridgeToken) + rewritePublicAssetRefs(doc, circleId, htmlPath) + return `\n${doc.documentElement.outerHTML}` +} + +const renderPublicAsset = (asset, info) => { + clearBridgeContext() + setPreviewExpandAvailable(true) + $('preview-head').textContent = `oct://${asset.circle_id}${asset.canonical_path} | ${asset.content_type} | ${asset.size_bytes} bytes` + const body = $('preview-body') + body.innerHTML = '' + if (asset.content_type.startsWith('text/html')) { + const bridgeToken = hexOfBytes(randomBytes(16)) + const frame = document.createElement('iframe') + frame.className = 'circle-preview-frame' + frame.setAttribute('sandbox', 'allow-scripts allow-forms') + frame.addEventListener('load', () => { + if (activeBridgeContext && activeBridgeContext.bridge_token === bridgeToken) { + activeBridgeWindow = frame.contentWindow + } + }) + frame.srcdoc = materializePublicHtml( + asset.circle_id, + asset.canonical_path, + bytesToText(base64ToBytes(asset.body_b64)), + bridgeToken) + activeBridgeContext = { + circle_id: asset.circle_id, + path: asset.canonical_path, + uri: circleUriOf(asset.circle_id, asset.canonical_path), + privacy_class: info.privacy_class, + browser_mode: info.browser_mode, + resource_mode: info.resource_mode, + bridge_token: bridgeToken, + bridge_methods: bridgeMethodsForInfo(info) + } + body.appendChild(frame) + activeBridgeWindow = frame.contentWindow + return + } + if (asset.content_type.startsWith('image/')) { + const img = document.createElement('img') + img.className = 'circle-preview-image' + img.src = circleResourceUrl(asset.circle_id, asset.canonical_path) + body.appendChild(img) + return + } + const pre = document.createElement('pre') + pre.className = 'circle-preview-text' + pre.textContent = bytesToText(base64ToBytes(asset.body_b64)) + body.appendChild(pre) +} + +const renderSealedAsset = async (circleId, path, info, passphrase) => { + const versionToken = info.assets_root || info.stable_root || '' + const asset = await loadSealedAsset(circleId, path, passphrase, versionToken) + setPreviewExpandAvailable(true) + $('preview-head').textContent = `oct://${circleId}${asset.canonical_path} | sealed_read | ${asset.content_type} | key_id=${asset.key_id || 'none'}` + const body = $('preview-body') + body.innerHTML = '' + if (asset.content_type.startsWith('text/html')) { + const bridgeToken = hexOfBytes(randomBytes(16)) + const frame = document.createElement('iframe') + frame.className = 'circle-preview-frame' + frame.setAttribute('sandbox', 'allow-scripts') + frame.referrerPolicy = 'no-referrer' + frame.addEventListener('load', () => { + if (activeBridgeContext && activeBridgeContext.bridge_token === bridgeToken) { + activeBridgeWindow = frame.contentWindow + } + }) + frame.srcdoc = await materializeSealedHtml(circleId, asset.canonical_path, asset.text, passphrase, bridgeToken, versionToken) + activeBridgeContext = { + circle_id: circleId, + path: asset.canonical_path, + uri: circleUriOf(circleId, asset.canonical_path), + privacy_class: info.privacy_class, + browser_mode: info.browser_mode, + resource_mode: info.resource_mode, + bridge_token: bridgeToken, + bridge_methods: bridgeMethodsForInfo(info) + } + body.appendChild(frame) + activeBridgeWindow = frame.contentWindow + return + } + clearBridgeContext() + if (asset.content_type.startsWith('image/')) { + const img = document.createElement('img') + img.className = 'circle-preview-image' + img.src = makeDataUrl(asset.content_type, asset.bytes) + body.appendChild(img) + return + } + const pre = document.createElement('pre') + pre.className = 'circle-preview-text' + pre.textContent = isTextContent(asset.content_type) + ? asset.text + : `sealed asset loaded\ncontent_type: ${asset.content_type}\nbytes: ${asset.bytes.length}` + body.appendChild(pre) +} + +const loadCircle = async () => { + const target = currentCircleTarget() + const circleId = target.circleId + const path = target.path + if (!circleId) { + resetMeta() + setStatus('status', 'circle id required', true) + return + } + setStatus('status', 'loading...', false) + try { + const info = await fetchJson(`/api/circle/info?circle_id=${encodeURIComponent(circleId)}`) + renderMeta(info) + if (info.resource_mode === 'sealed_read') { + const passphrase = $('sealed-passphrase').value + if (!passphrase) { + clearBridgeContext() + setPreviewExpandAvailable(false) + $('preview-head').textContent = `oct://${circleId}${path} | sealed_read` + $('preview-body').innerHTML = '
sealed read key required
' + setStatus('status', `sealed circle loaded at oct://${circleId}${path}`, false) + } else { + await renderSealedAsset(circleId, path, info, passphrase) + setStatus('status', `sealed asset loaded from oct://${circleId}${path}`, false) + } + } else { + const asset = await loadPlainAsset(circleId, path) + renderPublicAsset(asset, info) + setStatus('status', `loaded oct://${circleId}${asset.canonical_path}`, false) + } + const next = new URL(window.location.href) + next.searchParams.set('uri', circleUriOf(circleId, path)) + next.searchParams.delete('circle') + next.searchParams.delete('path') + window.history.replaceState({}, '', next) + } catch (err) { + clearBridgeContext() + setPreviewExpandAvailable(false) + resetMeta() + $('preview-head').textContent = 'load failed' + $('preview-body').innerHTML = '' + setStatus('status', err.message || 'load failed', true) + } +} + +const deploySealedCircle = async () => { + setStatus('deploy-status', 'preparing deploy...', false) + try { + const wallet = await fetchJson('/api/wallet') + const balance = await fetchJson('/api/balance') + const payload = buildCircleDeployPayload() + const nonce = Number(balance.nonce || 0) + 1 + const circleId = await circleIdOfDeploy(wallet.address, nonce, payload) + const result = await postJson('/api/circle/deploy', { + circle_id: circleId, + ...payload, + ou: '200000' + }) + $('circle-id').value = circleUriOf(circleId, '/index.html') + setStatus('deploy-status', `submitted ${result.tx_hash || 'tx'} for ${circleId}`, false) + setStatus('status', `deployed circle shell ${circleId}`, false) + } catch (err) { + setStatus('deploy-status', err.message || 'deploy failed', true) + } +} + +const selectedUploadPassphrase = () => ($('upload-passphrase') ? $('upload-passphrase').value : '') || $('sealed-passphrase').value + +const uploadSealedAsset = async () => { + const circleId = parseCircleTarget($('circle-id').value.trim(), $('upload-path').value).circleId + const path = normalizeAssetPath($('upload-path').value) + const contentType = $('upload-content-type').value.trim() + const keyId = $('upload-key-id').value.trim() + const passphrase = selectedUploadPassphrase() + const file = $('upload-file').files[0] + const paddingClass = $('upload-padding').value + if (!circleId || !path || !contentType || !keyId || !passphrase || !file) { + setStatus('upload-status', 'circle id, path, content type, key id, passphrase, and file required', true) + return + } + setStatus('upload-status', 'encrypting...', false) + try { + const plaintext = new Uint8Array(await file.arrayBuffer()) + const sealed = await encryptSealedBytes(circleId, keyId, passphrase, plaintext, paddingClass) + if (sealed.ciphertext_b64.length > circleAssetMaxB64Bytes) { + throw new Error('sealed asset exceeds 32mb circle limit') + } + const uploadOu = String(circleAssetFeeOfCiphertextB64(sealed.ciphertext_b64)) + setStatus('upload-status', `submitting tx | ou ${uploadOu}`, false) + const result = await postJson('/api/circle/asset_encrypted', { + circle_id: circleId, + path, + content_type: contentType, + encoding: 'identity', + key_id: keyId, + plaintext_hash: sealed.plaintext_hash, + padding_class: paddingClass, + ciphertext_b64: sealed.ciphertext_b64, + ou: uploadOu + }) + decryptedCache.clear() + setStatus('upload-status', `submitted ${result.tx_hash || 'tx'}`, false) + if (currentCircleTarget().path === path) { + await loadCircle() + } + } catch (err) { + setStatus('upload-status', err.message || 'upload failed', true) + } +} + +const guessContentType = (name) => { + const lower = name.toLowerCase() + if (lower.endsWith('.html')) return 'text/html; charset=utf-8' + if (lower.endsWith('.css')) return 'text/css; charset=utf-8' + if (lower.endsWith('.js')) return 'application/javascript; charset=utf-8' + if (lower.endsWith('.json')) return 'application/json; charset=utf-8' + if (lower.endsWith('.svg')) return 'image/svg+xml' + if (lower.endsWith('.png')) return 'image/png' + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' + if (lower.endsWith('.gif')) return 'image/gif' + if (lower.endsWith('.webp')) return 'image/webp' + return 'application/octet-stream' +} + +bindIfPresent('load-btn', 'click', loadCircle) +bindIfPresent('preview-expand-btn', 'click', toggleExpandedPreview) +bindIfPresent('preview-overlay-close', 'click', closeExpandedPreview) +bindIfPresent('overlay-open-btn', 'click', async () => { + syncMainControlsFromOverlay() + await loadCircle() +}) +bindIfPresent('deploy-btn', 'click', deploySealedCircle) +bindIfPresent('upload-btn', 'click', uploadSealedAsset) +bindIfPresent('upload-file', 'change', (event) => { + const file = event.target.files[0] + if (!file) return + if ($('upload-path') && (!$('upload-path').value.trim() || $('upload-path').value.trim() === '/index.html')) { + $('upload-path').value = `/${file.name}` + } + if ($('upload-content-type') && (!$('upload-content-type').value.trim() || $('upload-content-type').value.trim() === 'text/html; charset=utf-8')) { + $('upload-content-type').value = file.type || guessContentType(file.name) + } +}) +bindIfPresent('circle-id', 'keydown', (event) => { + if (event.key === 'Enter') { + loadCircle() + } +}) +bindIfPresent('sealed-passphrase', 'keydown', (event) => { + if (event.key === 'Enter') { + loadCircle() + } +}) +bindIfPresent('overlay-circle-id', 'keydown', (event) => { + if (event.key === 'Enter') { + syncMainControlsFromOverlay() + loadCircle() + } +}) +bindIfPresent('overlay-sealed-passphrase', 'keydown', (event) => { + if (event.key === 'Enter') { + syncMainControlsFromOverlay() + loadCircle() + } +}) +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && expandedPreviewOpen) { + closeExpandedPreview() + } +}) + +const params = new URLSearchParams(window.location.search) +const startUri = params.get('uri') +const startCircle = params.get('circle') +const startPath = params.get('path') +const startPassphrase = params.get('passphrase') +if (startPassphrase) { + $('sealed-passphrase').value = startPassphrase + $('overlay-sealed-passphrase').value = startPassphrase +} +if (startUri) { + const target = parseCircleUri(startUri) + if (target) { + $('circle-id').value = startUri + if ($('upload-path')) { + $('upload-path').value = target.path + } + loadCircle() + } +} else if (startCircle) { + const target = parseCircleTarget(startCircle, startPath || '/index.html') + $('circle-id').value = target.uri || startCircle + if ($('upload-path')) { + $('upload-path').value = target.path + } + loadCircle() +} \ No newline at end of file diff --git a/static/index.html b/static/index.html index d7e4538..f98969b 100644 --- a/static/index.html +++ b/static/index.html @@ -40,13 +40,13 @@
- +
@@ -114,20 +120,22 @@
connecting...
- - + + + +
@@ -135,12 +143,12 @@
public balance
-
-
encrypted balance
-
+
encrypted balance
-
nonce
-
staging
-
-
recent transactions
+
recent transactions (0)
loading...
@@ -169,7 +177,7 @@
- +
@@ -193,7 +201,7 @@
- +
@@ -208,7 +216,7 @@
- +
@@ -234,12 +242,12 @@
- +
stealth outputs
- +
no outputs scanned yet
@@ -269,15 +277,22 @@
- - + +
-
token transactions
-
switch to a token to see transactions
+
token transactions (0 / 0, in 0, out 0)
+
loading token transactions...
+ + +
+
apps
+
+ +
@@ -299,7 +314,7 @@
- @@ -310,15 +325,17 @@
1

-                
+        
+        
       
- +
@@ -440,8 +461,8 @@
- - + +
@@ -461,7 +482,7 @@
- +
@@ -471,7 +492,7 @@
-
transaction history
+
transaction history (0 / 0)
loading...
@@ -483,7 +504,7 @@
-
node settings
+
network settings
@@ -493,8 +514,12 @@
+
+ + +
- +
@@ -506,36 +531,42 @@
change PIN
+
+
- +
- +
- + +
+
+ new PIN must be at least 8 characters. 15 or more recommended; your password manager can generate a strong one.
- +
+
- ← back + ← back
transaction
loading...
- + - + - + \ No newline at end of file diff --git a/static/style.css b/static/style.css index 0b42f10..f325b62 100644 --- a/static/style.css +++ b/static/style.css @@ -210,6 +210,11 @@ header .logout-btn:hover { background: #7A8BA3; } .view { display: none; } .view.active { display: block; } +#view-apps.active { + padding: clamp(16px, 2.5vw, 32px) clamp(12px, 3vw, 40px); + box-sizing: border-box; +} + .section { margin: 0; padding: 0; @@ -266,6 +271,11 @@ table { color: #3B567F; } +.pending-text { + font-size: 11px; + color: #8C9DB6; +} + td, th { white-space: nowrap; overflow: hidden; @@ -638,14 +648,16 @@ td { .back-link:hover { color: #3B567F; } .msg-box { - background: #F6F7F9; - border: 1px solid #E5E9EF; - padding: 8px; - margin: 12px 8px 8px; - font-size: 11px; - word-break: break-all; - white-space: pre-wrap; - color: #3B567F; +background: #F6F7F9; + border: 1px solid #E5E9EF; + padding: 8px; + margin: 12px 0 8px; + margin-left: 12px; + margin-right: 12px; + font-size: 11px; + word-break: break-all; + white-space: pre-wrap; + color: #3B567F; } @media (max-width: 700px) { @@ -1062,7 +1074,6 @@ td { font-size: 11px; margin-top: 4px; } -/* --- IDE icons (SVG mask, Font Awesome Solid) --- */ .ide-icon { display: inline-block; width: 14px; height: 14px; @@ -1338,6 +1349,9 @@ td { } .modal-message { font-size: 12px; color: #5A6A7D; margin-bottom: 12px; line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .modal-input { width: 100%; padding: 6px 8px; border: 1px solid #C0C6D0; @@ -1358,4 +1372,7 @@ td { .modal-btn-primary { background: #3B567F; color: #fff; border-color: #3B567F; } -.modal-btn-primary:hover { background: #2C4060; } \ No newline at end of file +.modal-btn-primary:hover { background: #2C4060; } +.acct-btn { background: #E5E9EF; } +.acct-btn:hover { background: #D0D7E2; } +.account-card:hover { background: #2A3F5F; color: #fff; } \ No newline at end of file diff --git a/static/swap.html b/static/swap.html index 046943d..e2c1bf4 100644 --- a/static/swap.html +++ b/static/swap.html @@ -82,7 +82,7 @@

OCT swap

unlock your wallet to continue

- +
- - + +
- - + +
OCT
@@ -127,7 +127,7 @@

OCT / tUSD

price impact-
fee0.1 OCT
- +
@@ -143,11 +143,11 @@

OCT / tUSD

confirm swap

- + \ No newline at end of file diff --git a/static/swap.js b/static/swap.js index c245d36..759b204 100644 --- a/static/swap.js +++ b/static/swap.js @@ -25,9 +25,6 @@ 2025-2026 Julia L. */ - - -/* only for dev-net testing, does not work for main net */ var SWAP_ADDR = 'octBjnQBicZs6iMwcRxrdzLYAzyVTi91KEiA8RGkVjco2w6'; var TOKEN_ADDR = 'oct6J37Wx7Rb1putvfwFrFbGUStE8hGzsb33fhLgUdpTx6d'; var SCANNER_URL = 'https://devnet.octrascan.io'; @@ -443,4 +440,18 @@ $('pin-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') doUnlock(); }); -checkWallet(); \ No newline at end of file +checkWallet(); + +(function() { + var actions = { doUnlock, setDir, onInputChange, setMax, doSwap, cancelSwap, confirmSwap }; + function run(e, attr) { + var el = e.target.closest('[' + attr + ']'); + if (!el) return; + var fn = actions[el.getAttribute(attr)]; + if (!fn) return; + if (el.getAttribute('data-prevent') === '1') e.preventDefault(); + fn(el.getAttribute('data-arg'), el, e); + } + document.addEventListener('click', function(e) { run(e, 'data-action'); }); + document.addEventListener('input', function(e) { run(e, 'data-input'); }); +})(); \ No newline at end of file diff --git a/static/templates/amm/main.aml b/static/templates/amm/main.aml index d84a95d..b1b85aa 100644 --- a/static/templates/amm/main.aml +++ b/static/templates/amm/main.aml @@ -2,14 +2,14 @@ contract SimpleAMM { state { token_a: address token_b: address - reserve_a: int - reserve_b: int - total_lp: int - lp_balances: map[address]int + reserve_a: u128 + reserve_b: u128 + total_lp: u128 + lp_balances: map[address]u128 } - event Swap(who: address, token_in: address, amount_in: int, amount_out: int) - event AddLiquidity(who: address, a: int, b: int, lp: int) + event Swap(who: address, token_in: address, amount_in: u128, amount_out: u128) + event AddLiquidity(who: address, a: u128, b: u128, lp: u128) constructor(a: address, b: address) { self.token_a = a @@ -19,7 +19,7 @@ contract SimpleAMM { self.total_lp = 0 } - fn add_liquidity(amount_a: int, amount_b: int): int { + fn add_liquidity(amount_a: u128, amount_b: u128): u128 { require(amount_a > 0 && amount_b > 0, "amounts must be positive") let lp = amount_a * amount_b self.reserve_a += amount_a @@ -30,7 +30,7 @@ contract SimpleAMM { return lp } - fn swap_a_for_b(amount_in: int): int { + fn swap_a_for_b(amount_in: u128): u128 { require(amount_in > 0, "zero input") let out = (amount_in * self.reserve_b) / (self.reserve_a + amount_in) require(out > 0, "output too small") @@ -40,12 +40,12 @@ contract SimpleAMM { return out } - view fn get_reserves(): (int, int) { + view fn get_reserves(): (u128, u128) { return (self.reserve_a, self.reserve_b) } - view fn get_price(): int { + view fn get_price(): u128 { require(self.reserve_a > 0, "no liquidity") return (self.reserve_b * 1000000) / self.reserve_a } -} +} \ No newline at end of file diff --git a/static/templates/escrow/main.aml b/static/templates/escrow/main.aml index fd36423..8df5cac 100644 --- a/static/templates/escrow/main.aml +++ b/static/templates/escrow/main.aml @@ -3,15 +3,15 @@ contract Escrow { seller: address buyer: address arbiter: address - amount: int + amount: u128 funded: bool released: bool } event Created(seller: address, buyer: address, arbiter: address) - event Funded(amount: int) - event Released(to: address, amount: int) - event Refunded(to: address, amount: int) + event Funded(amount: u128) + event Released(to: address, amount: u128) + event Refunded(to: address, amount: u128) constructor(s: address, b: address, a: address) { assert_address(s) @@ -57,4 +57,4 @@ contract Escrow { view fn status(): string { return !self.funded ? "awaiting_funding" : self.released ? "completed" : "funded" } -} +} \ No newline at end of file diff --git a/static/templates/multisig/main.aml b/static/templates/multisig/main.aml index 2c28bff..8429586 100644 --- a/static/templates/multisig/main.aml +++ b/static/templates/multisig/main.aml @@ -1,30 +1,61 @@ contract Multisig { const MAX_OWNERS: int = 5 state { - owners: list[address] + owners: map[address]bool + owner_count: int threshold: int next_id: int proposals: map[int]string - prop_amounts: map[int]int + prop_amounts: map[int]u128 prop_targets: map[int]address votes: map[int]map[address]bool vote_counts: map[int]int executed: map[int]bool } - event Proposed(id: int, target: address, amount: int) + event Proposed(id: int, target: address, amount: u128) event Voted(id: int, voter: address) event Executed(id: int) + event OwnerAdded(owner: address) + event ThresholdChanged(threshold: int) constructor(threshold_val: int) { require(threshold_val > 0, "threshold must be > 0") + require(threshold_val <= 1, "threshold exceeds owners") self.threshold = threshold_val self.next_id = 0 - self.owners.push(origin) + self.owner_count = 1 + self.owners[origin] = true } - fn propose(target: address, amount: int, desc: string): int { + private fn require_owner() { + require(self.owners[caller], "not owner") + } + + fn add_owner(owner: address): bool { + require_owner() + assert_address(owner) + require(!self.owners[owner], "already owner") + require(self.owner_count < MAX_OWNERS, "too many owners") + self.owners[owner] = true + self.owner_count += 1 + emit OwnerAdded(owner) + return true + } + + fn set_threshold(threshold_val: int): bool { + require_owner() + require(threshold_val > 0, "threshold must be > 0") + require(threshold_val <= self.owner_count, "threshold exceeds owners") + self.threshold = threshold_val + emit ThresholdChanged(threshold_val) + return true + } + + fn propose(target: address, amount: u128, desc: string): int { + require_owner() assert_address(target) + require(amount > 0, "amount must be positive") let id = self.next_id self.proposals[id] = desc self.prop_amounts[id] = amount @@ -37,6 +68,7 @@ contract Multisig { } fn vote(id: int): bool { + require_owner() require(!self.executed[id], "already executed") require(!self.votes[id][caller], "already voted") self.votes[id][caller] = true @@ -44,7 +76,8 @@ contract Multisig { emit Voted(id, caller) if self.vote_counts[id] >= self.threshold { self.executed[id] = true - transfer(self.prop_targets[id], self.prop_amounts[id]) + let ok = transfer(self.prop_targets[id], self.prop_amounts[id]) + require(ok, "transfer failed") emit Executed(id) } return true @@ -53,4 +86,4 @@ contract Multisig { view fn get_proposal(id: int): string { return self.proposals[id] } -} +} \ No newline at end of file diff --git a/static/templates/token/interfaces/IOCS01.aml b/static/templates/token/interfaces/IOCS01.aml index bcb5c4e..968834b 100644 --- a/static/templates/token/interfaces/IOCS01.aml +++ b/static/templates/token/interfaces/IOCS01.aml @@ -1,10 +1,16 @@ interface IOCS01 { - fn transfer(to: address, amount: int): bool - fn grant(spender: address, amount: int): bool - fn pull(from: address, to: address, amount: int): bool - fn balance_of(addr: address): int - fn allowance(owner: address, spender: address): int + fn transfer(to: address, amount: u128): bool + fn grant(spender: address, amount: u128): bool + fn increase_grant(spender: address, amount: u128): bool + fn decrease_grant(spender: address, amount: u128): bool + fn revoke_grant(spender: address): bool + fn pull(from: address, to: address, amount: u128): bool + fn balance_of(addr: address): u128 + fn allowance(owner: address, spender: address): u128 + fn can_pull(from: address, spender: address, amount: u128): bool + fn decimals(): u64 fn get_name(): string fn get_symbol(): string - fn get_total_supply(): int -} + fn get_total_supply(): u128 + fn get_owner(): address +} \ No newline at end of file diff --git a/static/templates/token/main.aml b/static/templates/token/main.aml index e08279e..cf3e898 100644 --- a/static/templates/token/main.aml +++ b/static/templates/token/main.aml @@ -1,21 +1,30 @@ import IOCS01 from "interfaces/IOCS01.aml" contract Token implements IOCS01 { + invariant supply_conserved = sum(balances) == total_supply + + const MAX_U128 = 340282366920938463463374607431768211455 + state { name: string symbol: string - total_supply: int - decimals: int + total_supply: u128 + decimals: u64 owner: address - balances: map[address]int - grants: map[address]map[address]int + balances: map[address]u128 + grants: map[address]map[address]u128 } - event Transfer(from: address, to: address, amount: int) - event Grant(owner: address, spender: address, amount: int) + event Transfer(from: address, to: address, amount: u128) + event Grant(owner: address, spender: address, amount: u128) - constructor(n: string, s: string, supply: int, dec: int) { + constructor(n: string, s: string, supply: u128, dec: u64) { require(len(n) > 0, "name empty") + require(len(n) <= 32, "name too long") + require(len(s) > 0, "symbol empty") + require(len(s) <= 12, "symbol too long") + require(supply > 0, "supply must be positive") + require(dec <= 18, "decimals too high") self.name = n self.symbol = s self.total_supply = supply @@ -25,44 +34,100 @@ contract Token implements IOCS01 { emit Transfer(origin, origin, supply) } - view fn decimals(): int { return self.decimals } - view fn balance_of(addr: address): int { return self.balances[addr] } + private fn safe_add(left: u128, right: u128): u128 { + require(right <= MAX_U128 - left, "u128 overflow") + return left + right + } - view fn allowance(owner: address, spender: address): int { + view fn decimals(): u64 { return self.decimals } + view fn balance_of(addr: address): u128 { return self.balances[addr] } + + view fn allowance(owner: address, spender: address): u128 { return self.grants[owner][spender] } view fn get_name(): string { return self.name } view fn get_symbol(): string { return self.symbol } - view fn get_total_supply(): int { return self.total_supply } + view fn get_total_supply(): u128 { return self.total_supply } + view fn get_owner(): address { return self.owner } - fn transfer(to: address, amt: int): bool { + view fn can_pull(from: address, spender: address, amt: u128): bool { + require(amt > 0, "amount must be positive") + if self.grants[from][spender] < amt { + return false + } + return self.balances[from] >= amt + } + + fn transfer(to: address, amt: u128): bool { assert_address(to) + require(amt > 0, "amount must be positive") + require(to != caller, "self transfer disabled") let bal = self.balances[caller] require(bal >= amt, "insufficient balance") + let to_bal = self.balances[to] + let next_to = safe_add(to_bal, amt) self.balances[caller] = bal - amt - self.balances[to] = self.balances[to] + amt + self.balances[to] = next_to emit Transfer(caller, to, amt) return true } - fn grant(spender: address, amt: int): bool { + fn grant(spender: address, amt: u128): bool { assert_address(spender) + require(spender != caller, "self grant disabled") + let current = self.grants[caller][spender] + require(current == 0 || amt == 0, "reset grant first") self.grants[caller][spender] = amt emit Grant(caller, spender, amt) return true } - fn pull(from: address, to: address, amt: int): bool { + fn increase_grant(spender: address, added: u128): bool { + assert_address(spender) + require(spender != caller, "self grant disabled") + require(added > 0, "amount must be positive") + let current = self.grants[caller][spender] + let next = safe_add(current, added) + self.grants[caller][spender] = next + emit Grant(caller, spender, next) + return true + } + + fn decrease_grant(spender: address, subtracted: u128): bool { + assert_address(spender) + require(spender != caller, "self grant disabled") + require(subtracted > 0, "amount must be positive") + let current = self.grants[caller][spender] + require(current >= subtracted, "allowance underflow") + let next = current - subtracted + self.grants[caller][spender] = next + emit Grant(caller, spender, next) + return true + } + + fn revoke_grant(spender: address): bool { + assert_address(spender) + self.grants[caller][spender] = 0 + emit Grant(caller, spender, 0) + return true + } + + fn pull(from: address, to: address, amt: u128): bool { + assert_address(from) assert_address(to) + require(amt > 0, "amount must be positive") + require(from != to, "self pull disabled") let allowed = self.grants[from][caller] require(allowed >= amt, "not allowed") let bal = self.balances[from] require(bal >= amt, "insufficient balance") + let to_bal = self.balances[to] + let next_to = safe_add(to_bal, amt) self.balances[from] = bal - amt - self.balances[to] = self.balances[to] + amt + self.balances[to] = next_to self.grants[from][caller] = allowed - amt emit Transfer(from, to, amt) return true } -} +} \ No newline at end of file diff --git a/static/templates/vault/main.aml b/static/templates/vault/main.aml index 6536ed5..e80bdce 100644 --- a/static/templates/vault/main.aml +++ b/static/templates/vault/main.aml @@ -1,19 +1,19 @@ contract Vault { state { owner: address - deposits: map[address]int - total: int + deposits: map[address]u128 + total: u128 } - event Deposit(who: address, amount: int) - event Withdraw(who: address, amount: int) + event Deposit(who: address, amount: u128) + event Withdraw(who: address, amount: u128) constructor() { self.owner = origin self.total = 0 } - payable fn deposit(): int { + payable fn deposit(): u128 { require(value > 0, "must send OCT") self.deposits[caller] += value self.total += value @@ -21,7 +21,8 @@ contract Vault { return self.deposits[caller] } - nonreentrant fn withdraw(amt: int): bool { + nonreentrant fn withdraw(amt: u128): bool { + require(amt > 0, "amount must be positive") let bal = self.deposits[caller] require(bal >= amt, "insufficient deposit") self.deposits[caller] = bal - amt @@ -31,11 +32,11 @@ contract Vault { return true } - view fn balance_of(addr: address): int { + view fn balance_of(addr: address): u128 { return self.deposits[addr] } - view fn total_locked(): int { + view fn total_locked(): u128 { return self.total } -} +} \ No newline at end of file diff --git a/static/wallet.js b/static/wallet.js index 6f73cbb..9feb8e8 100644 --- a/static/wallet.js +++ b/static/wallet.js @@ -25,8 +25,20 @@ 2025-2026 Julia L. */ - -// mini-IDE pop up promt window with all things inside [lambda0xe] +function validatePin(pin) { + if (!pin || pin.length === 0) return 'PIN required'; + if (pin.length < 8) return 'PIN must be at least 8 characters'; + if (pin.length > 64) return 'PIN too long (max 64 characters)'; + if (pin.length < 15) { + var hasLetter = /[A-Za-z]/.test(pin); + var hasDigit = /[0-9]/.test(pin); + var hasSymbol = /[^A-Za-z0-9]/.test(pin); + if (!hasLetter || !hasDigit || !hasSymbol) { + return 'under 15 chars: must include a letter, a digit and a special symbol'; + } + } + return ''; +} function idePrompt(title, message, defaultVal) { return new Promise(function(resolve) { @@ -117,17 +129,362 @@ var _tokenDecimals = {}; var _tokensLoaded = false; var _tokTxGen = 0; var _compiledAbi = null; +var _compiledVerification = null; +var _compiledCertificate = null; var _fees = {}; var _rpcHost = ''; var _hasMasterSeed = false; +var _addressRuntime = {}; +var _tokenMetaInflight = {}; +var HISTORY_CACHE_TTL_MS = 12000; +var HISTORY_STALE_REFRESH_MS = 3000; +var BALANCE_CACHE_TTL_MS = 5000; +var TOKEN_CACHE_TTL_MS = 15000; +var TOKEN_STALE_REFRESH_MS = 3000; +var PERSISTED_CACHE_TTL_MS = 300000; + +function ensureAddressRuntime(addr) { + if (!addr) return null; + if (!_addressRuntime[addr]) { + _addressRuntime[addr] = { + balance: null, + balanceTs: 0, + balanceInflight: null, + historyPages: {}, + historyTs: {}, + historyInflight: {}, + tokens: [], + tokensLoaded: false, + tokensTs: 0, + tokensInflight: null, + tokenHistory: null, + tokenHistoryTs: 0, + tokenHistoryInflight: null + }; + } + return _addressRuntime[addr]; +} + +function clearAddressRuntime(addr) { + if (!addr) return; + delete _addressRuntime[addr]; +} + +function clearAllAddressRuntime() { + _addressRuntime = {}; + _tokenMetaInflight = {}; +} + +function persistedCachePrefix(addr) { + return 'octra_webcli:v2:' + (_rpcHost || 'rpc') + ':' + addr + ':'; +} + +function persistedRead(key) { + try { + var raw = sessionStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + return null; + } +} + +function persistedWrite(key, value) { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch (e) {} +} + +function persistedRemovePrefix(prefix) { + try { + for (var i = sessionStorage.length - 1; i >= 0; i--) { + var key = sessionStorage.key(i); + if (key && key.indexOf(prefix) === 0) sessionStorage.removeItem(key); + } + } catch (e) {} +} + +function persistBalance(addr, balance) { + if (!addr || !balance) return; + persistedWrite(persistedCachePrefix(addr) + 'balance', { + ts: Date.now(), + balance: balance + }); +} + +function restorePersistedBalance(addr) { + if (!addr) return null; + var cached = persistedRead(persistedCachePrefix(addr) + 'balance'); + if (!cached || !cached.balance || !cached.ts) return null; + if ((Date.now() - cached.ts) > PERSISTED_CACHE_TTL_MS) return null; + return cached.balance; +} + +function persistHistoryPage(addr, limit, offset, response) { + if (!addr || !response) return; + persistedWrite(persistedCachePrefix(addr) + 'history:' + historyPageKey(limit, offset), { + ts: Date.now(), + response: response + }); +} + +function restorePersistedHistoryPage(addr, limit, offset) { + if (!addr) return null; + var cached = persistedRead(persistedCachePrefix(addr) + 'history:' + historyPageKey(limit, offset)); + if (!cached || !cached.response || !cached.ts) return null; + if ((Date.now() - cached.ts) > PERSISTED_CACHE_TTL_MS) return null; + return cached; +} + +function persistTokens(addr, tokens) { + if (!addr) return; + persistedWrite(persistedCachePrefix(addr) + 'tokens', { + ts: Date.now(), + tokens: tokens || [] + }); +} + +function persistTokenHistory(addr, payload) { + if (!addr || !payload) return; + persistedWrite(persistedCachePrefix(addr) + 'token-history', { + ts: Date.now(), + payload: payload + }); +} + +function restorePersistedTokens(addr) { + if (!addr) return null; + var cached = persistedRead(persistedCachePrefix(addr) + 'tokens'); + if (!cached || !cached.ts || !Array.isArray(cached.tokens)) return null; + if ((Date.now() - cached.ts) > PERSISTED_CACHE_TTL_MS) return null; + return cached; +} + +function restorePersistedTokenHistory(addr) { + if (!addr) return null; + var cached = persistedRead(persistedCachePrefix(addr) + 'token-history'); + if (!cached || !cached.ts || !cached.payload) return null; + if ((Date.now() - cached.ts) > PERSISTED_CACHE_TTL_MS) return null; + return cached; +} + +function dropPersistedAddressRuntime(addr) { + if (!addr) return; + persistedRemovePrefix(persistedCachePrefix(addr)); +} +function dropAllPersistedRuntime() { + persistedRemovePrefix('octra_webcli:'); +} + +function historyPageKey(limit, offset) { + return String(limit) + ':' + String(offset); +} + +function peekHistoryPage(addr, limit, offset) { + var state = ensureAddressRuntime(addr); + if (!state) return null; + var key = historyPageKey(limit, offset); + if (!state.historyPages[key]) { + var persisted = restorePersistedHistoryPage(addr, limit, offset); + if (persisted) { + state.historyPages[key] = persisted.response; + state.historyTs[key] = persisted.ts; + } + } + if (!state.historyPages[key]) return null; + return { + response: state.historyPages[key], + ts: state.historyTs[key] || 0 + }; +} + +function cacheHistoryPage(addr, limit, offset, response) { + var state = ensureAddressRuntime(addr); + if (!state) return response; + var key = historyPageKey(limit, offset); + state.historyPages[key] = response; + state.historyTs[key] = Date.now(); + persistHistoryPage(addr, limit, offset, response); + return response; +} + +async function reconcileHistoryResponse(addr, limit, offset, response) { + if (!addr || !response || !Array.isArray(response.transactions) || response.transactions.length === 0) { + return response; + } + var pending = []; + for (var i = 0; i < response.transactions.length; i++) { + var tx = response.transactions[i]; + if (!tx || !tx.hash) continue; + var st = tx.status || 'pending'; + if (st === 'pending') pending.push({ index: i, hash: tx.hash }); + } + if (pending.length === 0) return response; + var nextTxs = response.transactions.slice(); + var changed = false; + await Promise.all(pending.map(async function(entry) { + try { + var fresh = await api('GET', '/tx?hash=' + encodeURIComponent(entry.hash)); + var st = fresh.status || 'pending'; + if (!st || st === 'pending') return; + var merged = Object.assign({}, nextTxs[entry.index], fresh); + merged.hash = fresh.hash || nextTxs[entry.index].hash || entry.hash; + merged.to_ = fresh.to_ || fresh.to || nextTxs[entry.index].to_ || nextTxs[entry.index].to || ''; + merged.amount_raw = fresh.amount_raw || fresh.amount || nextTxs[entry.index].amount_raw || '0'; + merged.status = st; + nextTxs[entry.index] = merged; + changed = true; + } catch (e) {} + })); + if (!changed) return response; + var nextResponse = Object.assign({}, response, { transactions: nextTxs }); + cacheHistoryPage(addr, limit, offset, nextResponse); + return nextResponse; +} + +async function fetchHistoryPage(limit, offset, force) { + var addr = _walletAddr; + if (!addr) return { transactions: [] }; + var state = ensureAddressRuntime(addr); + var key = historyPageKey(limit, offset); + var cached = peekHistoryPage(addr, limit, offset); + if (!force && cached && (Date.now() - cached.ts) < HISTORY_CACHE_TTL_MS) { + return reconcileHistoryResponse(addr, limit, offset, cached.response); + } + if (state.historyInflight[key]) return state.historyInflight[key]; + state.historyInflight[key] = api('GET', '/history?limit=' + limit + '&offset=' + offset) + .then(function(response) { + return cacheHistoryPage(addr, limit, offset, response); + }) + .then(function(response) { + return reconcileHistoryResponse(addr, limit, offset, response); + }) + .finally(function() { + delete state.historyInflight[key]; + }); + return state.historyInflight[key]; +} + +function cacheAddressTokens(addr, tokens) { + var state = ensureAddressRuntime(addr); + if (!state) return; + state.tokens = tokens || []; + state.tokensLoaded = true; + state.tokensTs = Date.now(); + persistTokens(addr, state.tokens); +} + +function restoreAddressTokens(addr) { + var state = ensureAddressRuntime(addr); + if (!state) return false; + if (!state.tokensLoaded) { + var persisted = restorePersistedTokens(addr); + if (persisted) { + state.tokens = persisted.tokens.slice(); + state.tokensLoaded = true; + state.tokensTs = persisted.ts; + } + } + if (!state.tokensLoaded) return false; + _tokens = state.tokens.slice(); + _tokensLoaded = true; + hydrateTokenMaps(_tokens); + return true; +} + +function hydrateTokenMaps(tokens) { + for (var i = 0; i < tokens.length; i++) { + _tokenSymbols[tokens[i].address] = tokens[i].symbol; + _tokenDecimals[tokens[i].address] = tokens[i].decimals || '0'; + } +} -var _ideProject = null; // { id, name, created, template } -var _ideFiles = {}; // { path: content } +function tokenHistorySummary(payload) { + return { + transactions: (payload && payload.transactions) ? payload.transactions : [], + total: (payload && payload.total) ? payload.total : 0, + incoming: (payload && payload.incoming) ? payload.incoming : 0, + outgoing: (payload && payload.outgoing) ? payload.outgoing : 0, + has_more: !!(payload && payload.has_more) + }; +} + +async function fetchAddressTokens(force) { + var addr = _walletAddr; + if (!addr) return []; + var state = ensureAddressRuntime(addr); + if (!state) return []; + if (!force && state.tokensLoaded && (Date.now() - state.tokensTs) < TOKEN_CACHE_TTL_MS) { + return state.tokens.slice(); + } + if (state.tokensInflight) return state.tokensInflight; + state.tokensInflight = api('GET', '/tokens') + .then(function(res) { + var tokens = (res && res.tokens) ? res.tokens : []; + cacheAddressTokens(addr, tokens); + return tokens.slice(); + }) + .finally(function() { + state.tokensInflight = null; + }); + return state.tokensInflight; +} + +async function fetchTokenHistory(force) { + var addr = _walletAddr; + if (!addr) return tokenHistorySummary(null); + var state = ensureAddressRuntime(addr); + if (!state) return tokenHistorySummary(null); + if (!force && state.tokenHistory && (Date.now() - state.tokenHistoryTs) < HISTORY_CACHE_TTL_MS) { + return state.tokenHistory; + } + if (!force && !state.tokenHistory) { + var persisted = restorePersistedTokenHistory(addr); + if (persisted) { + var summary = tokenHistorySummary(persisted.payload); + var hasTokens = state.tokensLoaded && state.tokens && state.tokens.length > 0; + if (!(hasTokens && summary.total === 0)) { + state.tokenHistory = summary; + state.tokenHistoryTs = persisted.ts; + return state.tokenHistory; + } + } + } + if (state.tokenHistoryInflight) return state.tokenHistoryInflight; + var suffix = force ? '&force=1' : ''; + state.tokenHistoryInflight = api('GET', '/token-history?limit=200&offset=0' + suffix) + .then(function(response) { + var summary = tokenHistorySummary(response); + state.tokenHistory = summary; + state.tokenHistoryTs = Date.now(); + persistTokenHistory(addr, summary); + return summary; + }) + .finally(function() { + state.tokenHistoryInflight = null; + }); + return state.tokenHistoryInflight; +} + +function invalidateCurrentAddressState() { + if (!_walletAddr) return; + dropPersistedAddressRuntime(_walletAddr); + clearAddressRuntime(_walletAddr); + ensureAddressRuntime(_walletAddr); + _cachedBal = null; + _historyOffset = 0; + _tokens = []; + _tokensLoaded = false; +} + + +var _ideProject = null; +var _ideFiles = {}; var _ideActiveFile = null; var _ideOpenTabs = []; var _ideSaveTimer = null; -var _ideMode = false; // false=single file, true=project mode +var _ideMode = false; var ProjectStore = (function() { var DB_NAME = 'octra_ide'; @@ -342,8 +699,8 @@ function ideRenderProjectBar() { } bar.style.display = 'flex'; bar.innerHTML = '' + escapeHtml(_ideProject.name) + '' + - '' + - ''; + '' + + ''; } function ideRenderFileTree() { @@ -371,16 +728,16 @@ function ideRenderFileTree() { - var html = '
files ' + - '' + - '
'; + var html = '
files ' + + '' + + '
'; rootFiles.forEach(function(p) { var cls = p === _ideActiveFile ? ' active' : ''; - html += '
' + + html += '
' + '' + escapeHtml(p) + '
'; }); Object.keys(dirs).sort().forEach(function(dir) { @@ -388,7 +745,7 @@ function ideRenderFileTree() { dirs[dir].sort().forEach(function(p) { var fname = p.substring(p.indexOf('/') + 1); var cls = p === _ideActiveFile ? ' active' : ''; - html += '
' + + html += '
' + '' + escapeHtml(fname) + '
'; }); }); @@ -407,9 +764,9 @@ function ideRenderTabs() { _ideOpenTabs.forEach(function(path) { var name = path.indexOf('/') >= 0 ? path.substring(path.lastIndexOf('/') + 1) : path; var cls = path === _ideActiveFile ? ' active' : ''; - html += '
' + + html += '
' + escapeHtml(name) + - '×' + + '×' + '
'; }); bar.innerHTML = html; @@ -533,13 +890,13 @@ async function showProjectPicker() { var html = '
projects
'; html += ''; html += '
'; - html += ''; - html += ''; - html += ''; + html += ''; + html += ''; + html += ''; html += '
'; html += '
'; - html += ''; - html += ''; + html += ''; + html += ''; html += '
'; @@ -547,10 +904,10 @@ async function showProjectPicker() { html += '
'; html += '
recent
'; projects.forEach(function(p) { - html += '
' + + html += '
' + '' + escapeHtml(p.name) + '' + '' + new Date(p.created).toLocaleDateString() + '' + - '' + + '' + '
'; }); html += '
'; @@ -685,6 +1042,9 @@ async function doCompileProject() { clearResult('ct-compile-result'); editorClearError(); _compiledAbi = null; + _compiledVerification = null; + _compiledCertificate = null; + renderVerificationReport(null); var abiDiv = $('ct-abi-display'); if (abiDiv) abiDiv.style.display = 'none'; @@ -714,6 +1074,14 @@ async function doCompileProject() { var disEl = $('ct-disasm-code'); if (disEl) disEl.innerHTML = highlightDisasm(res.disasm); } + if (res.verification) { + _compiledVerification = res.verification; + _compiledCertificate = res.certificate || null; + renderVerificationReport(res.verification, _compiledCertificate); + msg += ' | ' + verificationLabel(res.verification); + showResult('ct-compile-result', verificationLevel(res.verification) !== 'error', msg + (verificationLevel(res.verification) === 'error' ? ' (deploy not blocked yet)' : '')); + logVerificationTrace(res.verification); + } showBottomPanels(); consoleLog('info', msg); } catch (e) { @@ -746,16 +1114,17 @@ async function doVerifyProject() { var payload = { address: addr, source: mainSource }; if (depFiles.length > 0) payload.files = depFiles; var res = await api('POST', '/contract/verify', payload); + var safety = res.verification ? '
' + verificationResultHtml(res.verification) : ''; showResult('ct-verify-result', true, - 'source verified - code_hash: ' + escapeHtml(res.code_hash || '') + ''); + 'source verified - code_hash: ' + escapeHtml(res.code_hash || '') + '' + safety); } catch (e) { showResult('ct-verify-result', false, e.message); } } function networkLabel(host) { - if (host === '46.101.86.250') return 'main net'; - if (host === '165.227.225.79') return 'dev net'; + if (host === 'octra.network') return 'main net'; + if (host === 'devnet.octrascan.io' || host === '165.227.225.79') return 'dev net'; if (host === 'localhost' || host === '127.0.0.1') return 'local'; return host; } @@ -790,37 +1159,66 @@ async function bgStealthScan() { } catch (e) {} } -async function fetchBalance() { - try { - var bal = await api('GET', '/balance'); - _cachedBal = bal; - var pub = bal.public_balance || '0'; - var enc = bal.encrypted_balance || '0'; - _encryptedBalanceRaw = parseInt(enc) || 0; - var MAX_SANE_ENC = 100000000 * 1000000; - var encCorrupt = (_encryptedBalanceRaw < 0 || _encryptedBalanceRaw > MAX_SANE_ENC); - if (encCorrupt) _encryptedBalanceRaw = 0; - if ($('btn-key-switch')) $('btn-key-switch').style.display = encCorrupt ? '' : 'none'; - if ($('st-balance')) $('st-balance').textContent = fmtOct(pub); - if ($('st-enc-balance')) $('st-enc-balance').textContent = encCorrupt - ? 'corrupted ciphertext' : fmtOct(enc); - if ($('st-nonce')) $('st-nonce').textContent = bal.nonce || '0'; - if ($('st-staging')) $('st-staging').textContent = bal.staging || '0'; - if ($('send-bal')) $('send-bal').textContent = fmtOct(pub); - if ($('enc-pub-bal')) $('enc-pub-bal').textContent = fmtOct(pub); - if ($('enc-enc-bal')) $('enc-enc-bal').textContent = encCorrupt - ? 'corrupted ciphertext' : fmtOct(enc); - if ($('st-enc-bal-info')) $('st-enc-bal-info').textContent = encCorrupt - ? 'corrupted ciphertext' : fmtOct(enc); - if ($('ct-bal')) $('ct-bal').textContent = fmtOct(pub); - $('hdr-status').textContent = _rpcHost ? 'online | ' + networkLabel(_rpcHost) : 'online'; - $('hdr-status').className = 'right online'; - return bal; - } catch (e) { - $('hdr-status').textContent = 'offline'; - $('hdr-status').className = 'right error'; - return null; +function applyBalanceData(bal) { + _cachedBal = bal; + var pub = bal.public_balance || '0'; + var enc = bal.encrypted_balance || '0'; + _encryptedBalanceRaw = parseInt(enc) || 0; + var MAX_SANE_ENC = 100000000 * 1000000; + var encCorrupt = (_encryptedBalanceRaw < 0 || _encryptedBalanceRaw > MAX_SANE_ENC); + if (encCorrupt) _encryptedBalanceRaw = 0; + if ($('btn-key-switch')) $('btn-key-switch').style.display = encCorrupt ? '' : 'none'; + if ($('st-balance')) $('st-balance').textContent = fmtOct(pub); + if ($('st-enc-balance')) $('st-enc-balance').textContent = encCorrupt + ? 'corrupted ciphertext' : fmtOct(enc); + if ($('st-nonce')) $('st-nonce').textContent = bal.nonce || '0'; + if ($('st-staging')) $('st-staging').textContent = bal.staging || '0'; + if ($('send-bal')) $('send-bal').textContent = fmtOct(pub); + if ($('enc-pub-bal')) $('enc-pub-bal').textContent = fmtOct(pub); + if ($('enc-enc-bal')) $('enc-enc-bal').textContent = encCorrupt + ? 'corrupted ciphertext' : fmtOct(enc); + if ($('st-enc-bal-info')) $('st-enc-bal-info').textContent = encCorrupt + ? 'corrupted ciphertext' : fmtOct(enc); + if ($('ct-bal')) $('ct-bal').textContent = fmtOct(pub); + $('hdr-status').textContent = _rpcHost ? 'online | ' + networkLabel(_rpcHost) : 'online'; + $('hdr-status').className = 'right online'; +} + +async function fetchBalance(force) { + var state = ensureAddressRuntime(_walletAddr); + if (state && state.balanceInflight) return state.balanceInflight; + if (!force && state && state.balance && (Date.now() - state.balanceTs) < BALANCE_CACHE_TTL_MS) { + applyBalanceData(state.balance); + return state.balance; + } + if (!force && state && !state.balance) { + var persisted = restorePersistedBalance(_walletAddr); + if (persisted) { + state.balance = persisted; + state.balanceTs = Date.now(); + applyBalanceData(persisted); + } } + var request = api('GET', '/balance') + .then(function(bal) { + if (state) { + state.balance = bal; + state.balanceTs = Date.now(); + } + persistBalance(_walletAddr, bal); + applyBalanceData(bal); + return bal; + }) + .catch(function() { + $('hdr-status').textContent = 'offline'; + $('hdr-status').className = 'right error'; + return null; + }) + .finally(function() { + if (state) state.balanceInflight = null; + }); + if (state) state.balanceInflight = request; + return request; } async function api(method, path, body) { @@ -835,8 +1233,8 @@ async function api(method, path, body) { var text = await res.text(); if (!text || text.length === 0) throw new Error('empty response from RPC (possible timeout)'); var j; - try { j = JSON.parse(text); } catch (e) { throw new Error('invalid server response: ' + text.substring(0, 200)); } - if (!res.ok) throw new Error(j.error || j.message || 'request failed'); + try { j = JSON.parse(text); } catch (e) { throw new Error('invalid server response: ' + escapeHtml(text.substring(0, 200))); } + if (!res.ok) throw new Error(escapeHtml(j.error || j.message || 'request failed')); return j; } @@ -1019,7 +1417,7 @@ function addrLink(addr) { function txLink(hash) { if (!hash) return '-'; if (!/^[a-f0-9]{64}$/.test(hash)) return '' + escapeHtml(hash) + ''; - return '' + short(hash) + ''; + return '' + short(hash) + ''; } function opTag(op) { @@ -1037,8 +1435,8 @@ function opTag(op) { function statusTag(st) { if (st === 'confirmed') return 'confirmed'; if (st === 'rejected') return 'rejected'; - if (st === 'pending') return 'pending'; - return '' + escapeHtml(st || 'pending') + ''; + if (st === 'pending') return 'pending'; + return '' + escapeHtml(st || 'pending') + ''; } function showResult(elId, ok, msg) { @@ -1059,7 +1457,7 @@ function validAddr(addr) { function logStealth(msg, cls) { var el = $('stealth-log'); if (!el) { - var btn = document.querySelector('button[onclick="doStealthSend()"]'); + var btn = document.querySelector('button[data-action="doStealthSend"]'); if (!btn) return; var row = btn.closest('.action-row') || btn.parentNode; el = document.createElement('div'); @@ -1078,7 +1476,7 @@ function clearStealthLog() { function logDecrypt(msg, cls) { var el = $('decrypt-log'); if (!el) { - var btn = document.querySelector('button[onclick="doDecrypt()"]'); + var btn = document.querySelector('button[data-action="doDecrypt"]'); if (!btn) return; var row = btn.closest('.action-row') || btn.parentNode; el = document.createElement('div'); @@ -1097,8 +1495,8 @@ function clearDecryptLog() { function txStatusTag(st) { if (st === 'rejected') return 'rejected'; if (st === 'confirmed') return 'confirmed'; - if (st === 'pending') return 'pending'; - return '' + escapeHtml(st || 'pending') + ''; + if (st === 'pending') return 'pending'; + return '' + escapeHtml(st || 'pending') + ''; } function txAmt(tx) { @@ -1131,7 +1529,7 @@ function txRow(tx) { h += '' + txLink(tx.hash) + ''; h += '' + addrLink(tx.from) + ''; h += '' + addrLink(toAddr) + ''; - h += '' + a.amt + ''; + h += '' + escapeHtml(a.amt) + ''; h += '' + txStatusTag(st) + ''; h += '' + fmtDate(tx.timestamp) + ''; h += ''; @@ -1146,7 +1544,7 @@ function txCardHtml(tx) { c += '
tx' + txLink(tx.hash) + '
'; c += '
from' + addrLink(tx.from) + '
'; c += '
to' + addrLink(toAddr) + '
'; - c += '
amount' + a.amt + '
'; + c += '
amount' + escapeHtml(a.amt) + '
'; c += '
status' + txStatusTag(st) + '
'; c += '
time' + fmtDate(tx.timestamp) + '
'; c += '
'; @@ -1165,8 +1563,8 @@ async function showTx(hash) { var fullHash = res.hash || hash; - var explorerLink = _explorerUrl + '/tx.html?hash=' + fullHash; - h += 'hash' + fullHash + ' explorer'; + var explorerLink = _explorerUrl + '/tx.html?hash=' + encodeURIComponent(fullHash); + h += 'hash' + escapeHtml(fullHash) + ' explorer'; h += 'status' + txStatusTag(st) + ''; if (res.reject_reason) h += 'reason' + escapeHtml(res.reject_reason) + ''; h += 'from' + addrLink(res.from || '') + ''; @@ -1182,8 +1580,8 @@ async function showTx(hash) { if (res.ou) h += 'ou (fee)' + fmtOct(res.ou) + ''; h += 'time' + fmtDate(res.timestamp) + ''; - if (res.signature) h += 'signature' + res.signature + ''; - if (res.public_key) h += 'public key' + res.public_key + ''; + if (res.signature) h += 'signature' + escapeHtml(res.signature) + ''; + if (res.public_key) h += 'public key' + escapeHtml(res.public_key) + ''; h += ''; if (res.message && res.message !== 'null' && res.message !== '') { h += '
message
'; @@ -1191,7 +1589,7 @@ async function showTx(hash) { } $('tx-detail').innerHTML = h; } catch (e) { - $('tx-detail').innerHTML = '
' + e.message + '
'; + $('tx-detail').innerHTML = '
' + escapeHtml(e.message) + '
'; } } @@ -1201,6 +1599,14 @@ function escapeHtml(s) { return d.innerHTML; } +function escapeAttr(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + function dashTxLimit() { var h = window.innerHeight; var overhead = 280; @@ -1210,6 +1616,7 @@ function dashTxLimit() { } function renderDashTxs(txs) { + $('dash-tx-count').textContent = String(txs.length); var h = ''; var cards = '
'; for (var i = 0; i < txs.length; i++) { @@ -1219,17 +1626,31 @@ function renderDashTxs(txs) { h += '
hashfromtoamountstatustime
'; cards += '
'; $('dash-txs').innerHTML = h + cards; - $('dash-more').innerHTML = ''; + $('dash-more').innerHTML = ''; } async function loadDashboard() { - await fetchBalance(); + fetchBalance(false); loadTokenSymbols(); try { var lim = dashTxLimit(); - var hist = await api('GET', '/history?limit=' + lim + '&offset=0'); + var cached = peekHistoryPage(_walletAddr, lim, 0); + if (cached) { + var cachedTxs = cached.response.transactions || []; + if (cachedTxs.length === 0) { + $('dash-tx-count').textContent = '0'; + $('dash-txs').innerHTML = '
no transactions yet
'; + $('dash-more').innerHTML = ''; + } else { + renderDashTxs(cachedTxs); + fetchMissingSymbols(cachedTxs).then(function() { renderDashTxs(cachedTxs); }); + } + if ((Date.now() - cached.ts) <= HISTORY_STALE_REFRESH_MS) return; + } + var hist = await fetchHistoryPage(lim, 0, false); var txs = hist.transactions || []; if (txs.length === 0) { + $('dash-tx-count').textContent = '0'; $('dash-txs').innerHTML = '
no transactions yet
'; $('dash-more').innerHTML = ''; return; @@ -1237,6 +1658,7 @@ async function loadDashboard() { renderDashTxs(txs); fetchMissingSymbols(txs).then(function() { renderDashTxs(txs); }); } catch (e) { + $('dash-tx-count').textContent = '0'; $('dash-txs').innerHTML = '
no transactions yet
'; $('dash-more').innerHTML = ''; } @@ -1255,12 +1677,15 @@ async function doSend() { if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { showResult('send-result', false, 'invalid amount'); return; } if (!validateFee('send-fee', 'standard')) { feeError('send-result', 'send-fee', 'standard'); return; } try { - var body = { to: to, amount: amount }; + var pin = await modalPrompt('confirm send', 'enter PIN to send ' + amount + ' oct to ' + to, { pin: true, btnText: 'send' }); + if (!pin) { showResult('send-result', false, 'send cancelled'); return; } + var body = { to: to, amount: amount, pin: pin }; if (msg) body.message = msg; var fee = $('send-fee') ? $('send-fee').value.trim() : ''; if (fee) body.ou = fee; var res = await api('POST', '/send', body); var txHash = res.hash || res.tx_hash || ''; + invalidateCurrentAddressState(); showResult('send-result', true, 'sent ' + amount + ' oct - tx: ' + txLink(txHash)); $('send-to').value = ''; $('send-amount').value = ''; @@ -1296,18 +1721,21 @@ async function doKeySwitch() { $('modal-overlay').style.display = 'none'; }; $('ks-confirm').onclick = async function() { + var pin = await modalPrompt('confirm key switch', 'enter PIN to switch encryption key', { pin: true, btnText: 'switch' }); + if (!pin) return; $('ks-confirm').disabled = true; $('ks-confirm').textContent = 'submitting...'; try { - var res = await api('POST', '/key_switch', {}); + var res = await api('POST', '/key_switch', { pin: pin }); var txHash = res.hash || res.tx_hash || ''; + invalidateCurrentAddressState(); var h2 = '
key switch submitted
'; h2 += '
tx: ' + txLinkExt(txHash) + '
'; h2 += '
'; $('modal-result').innerHTML = h2; $('ks-close').onclick = function() { $('modal-overlay').style.display = 'none'; fetchBalance(); }; } catch (e) { - $('modal-result').innerHTML = '
' + e.message + '
'; + $('modal-result').innerHTML = '
' + escapeHtml(e.message) + '
'; } }; } @@ -1318,11 +1746,14 @@ async function doEncrypt() { if (!amount || !/^\d+(\.\d{1,6})?$/.test(amount) || parseFloat(amount) <= 0) { showResult('enc-result', false, 'invalid amount'); return; } if (!validateFee('enc-fee', 'encrypt')) { feeError('enc-result', 'enc-fee', 'encrypt'); return; } try { - var encBody = { amount: amount }; + var pin = await modalPrompt('confirm encrypt', 'enter PIN to encrypt ' + amount + ' oct', { pin: true, btnText: 'encrypt' }); + if (!pin) { showResult('enc-result', false, 'encrypt cancelled'); return; } + var encBody = { amount: amount, pin: pin }; var encFee = $('enc-fee') ? $('enc-fee').value.trim() : ''; if (encFee) encBody.ou = encFee; var res = await api('POST', '/encrypt', encBody); var txHash = res.hash || res.tx_hash || ''; + invalidateCurrentAddressState(); showResult('enc-result', true, 'encrypted ' + amount + ' oct - tx: ' + txLink(txHash)); $('enc-amount').value = ''; loadDashboard(); @@ -1345,12 +1776,15 @@ async function doDecrypt() { logDecrypt('amount: ' + amount + ' oct', 'log-info'); logDecrypt('', ''); try { - var decBody = { amount: amount }; + var pin = await modalPrompt('confirm decrypt', 'enter PIN to decrypt ' + amount + ' oct', { pin: true, btnText: 'decrypt' }); + if (!pin) { logDecrypt('decrypt cancelled', 'log-err'); return; } + var decBody = { amount: amount, pin: pin }; var decFee = $('dec-fee') ? $('dec-fee').value.trim() : ''; if (decFee) decBody.ou = decFee; var res = await api('POST', '/decrypt', decBody); + invalidateCurrentAddressState(); if (res.steps) { - for (var i = 0; i < res.steps.length; i++) logDecrypt(res.steps[i], 'log-info'); + for (var i = 0; i < res.steps.length; i++) logDecrypt(escapeHtml(res.steps[i]), 'log-info'); } logDecrypt('', ''); logDecrypt('decrypt complete', 'log-ok'); @@ -1388,12 +1822,15 @@ async function doStealthSend() { logStealth('amount: ' + amount + ' oct', 'log-info'); logStealth('', ''); try { - var stBody = { to: to, amount: amount }; + var pin = await modalPrompt('confirm stealth send', 'enter PIN to send ' + amount + ' oct to ' + to, { pin: true, btnText: 'send' }); + if (!pin) { logStealth('stealth send cancelled', 'log-err'); return; } + var stBody = { to: to, amount: amount, pin: pin }; var stFee = $('stealth-fee') ? $('stealth-fee').value.trim() : ''; if (stFee) stBody.ou = stFee; var res = await api('POST', '/stealth/send', stBody); + invalidateCurrentAddressState(); if (res.steps) { - for (var i = 0; i < res.steps.length; i++) logStealth(res.steps[i], 'log-info'); + for (var i = 0; i < res.steps.length; i++) logStealth(escapeHtml(res.steps[i]), 'log-info'); } logStealth('', ''); logStealth('stealth send complete', 'log-ok'); @@ -1452,11 +1889,11 @@ async function doStealthScan() { } updateStealthBadge(unclaimed); if (unclaimed > 0) { - h += '
'; + h += '
'; } $('stealth-outputs').innerHTML = h; } catch (e) { - $('stealth-outputs').innerHTML = '
' + e.message + '
'; + $('stealth-outputs').innerHTML = '
' + escapeHtml(e.message) + '
'; } } @@ -1473,11 +1910,12 @@ async function doStealthClaim(ids) { logStealth('claiming ' + ids.length + ' output(s)...', 'log-info'); try { var res = await api('POST', '/stealth/claim', { ids: ids }); + invalidateCurrentAddressState(); logStealth('claim complete', 'log-ok'); if (res.results) { for (var i = 0; i < res.results.length; i++) { var r = res.results[i]; - logStealth(r.id + ': ' + (r.ok ? 'ok' : 'failed - ' + (r.error || '')), r.ok ? 'log-ok' : 'log-err'); + logStealth(escapeHtml(r.id) + ': ' + (r.ok ? 'ok' : 'failed - ' + escapeHtml(r.error || '')), r.ok ? 'log-ok' : 'log-err'); if (r.ok) _pendingClaimIds[String(r.id)] = true; } } @@ -1510,7 +1948,7 @@ function escapeHtmlCode(s) { return s.replace(/&/g, '&').replace(//g, '>'); } -var _amlRe = /(\/\*[\s\S]*?\*\/)|(\/\/[^\n]*)|("(?:[^"\\]|\\.)*")|(\b(?:contract|state|constructor|fn|view|let|if|else|while|for|in|return|assert|require|match|const|struct|enum|true|false|payable|nonreentrant|public|private|internal|event|error|import|interface|implements|indexed)\b)|(\b(?:string|int|bool|address|bytes|cipher|pubkey|map|list|void)\b)|(\b(?:self_addr|transfer|call|to_int|checkpoint|rollback|commit|origin|caller|balance|emit|log|value|epoch|min|max|abs|concat|to_string|len|split|join|replace|pow|sha256|keccak256|is_address|assert_address|starts_with|substr|index_of|bit_and|bit_or|bit_xor|parse_ints|mget|mset|blob_store|blob_load|some|none|is_some_opt|unwrap|fhe_load_pk|fhe_add|fhe_sub|fhe_scale|fhe_add_const|fhe_sub_const|fhe_verify_zero|fhe_verify_range|fhe_verify_bound|fhe_commit|fhe_pedersen|fhe_ser|fhe_deser)\b)|(\bself\b)|(\b[0-9]+\b)|([+\-*\/]=|[=!<>]=|&&|\|\||->|\?|[+\-*\/%<>=!])/g; +var _amlRe = /(\/\*[\s\S]*?\*\/)|(\/\/[^\n]*)|("(?:[^"\\]|\\.)*")|(\b(?:contract|state|constructor|fn|view|let|if|else|while|for|in|return|assert|require|match|const|struct|enum|true|false|payable|nonreentrant|public|private|internal|event|error|import|interface|implements|indexed)\b)|(\b(?:string|int|u64|u128|u256|bool|address|bytes|cipher|pubkey|map|list|void)\b)|(\b(?:self_addr|transfer|call|to_int|checkpoint|rollback|commit|origin|caller|balance|emit|log|value|epoch|min|max|abs|concat|to_string|len|split|join|replace|pow|sha256|keccak256|is_address|assert_address|starts_with|substr|index_of|bit_and|bit_or|bit_xor|parse_ints|mget|mset|blob_store|blob_load|some|none|is_some_opt|unwrap|fhe_load_pk|fhe_add|fhe_sub|fhe_mul|fhe_scale|fhe_add_const|fhe_sub_const|fhe_verify_zero|fhe_verify_range|fhe_verify_bound|fhe_commit|fhe_pedersen|fhe_ser|fhe_deser)\b)|(\bself\b)|(\b[0-9]+\b)|([+\-*\/]=|[=!<>]=|&&|\|\||->|\?|[+\-*\/%<>=!])/g; function highlightAml(src) { _amlRe.lastIndex = 0; @@ -1643,6 +2081,9 @@ async function doCompile() { clearResult('ct-compile-result'); editorClearError(); _compiledAbi = null; + _compiledVerification = null; + _compiledCertificate = null; + renderVerificationReport(null); var source = $('ct-source').value; var lang = $('ct-lang').value; if (!source.trim()) { showResult('ct-compile-result', false, 'source required'); return; } @@ -1663,6 +2104,14 @@ async function doCompile() { var disEl = $('ct-disasm-code'); if (disEl) disEl.innerHTML = highlightDisasm(res.disasm); } + if (res.verification) { + _compiledVerification = res.verification; + _compiledCertificate = res.certificate || null; + renderVerificationReport(res.verification, _compiledCertificate); + msg += ' | ' + verificationLabel(res.verification); + showResult('ct-compile-result', verificationLevel(res.verification) !== 'error', msg + (verificationLevel(res.verification) === 'error' ? ' (deploy not blocked yet)' : '')); + logVerificationTrace(res.verification); + } showBottomPanels(); consoleLog('info', msg); } catch (e) { @@ -1711,6 +2160,87 @@ function consoleClear() { renderConsole(); } +function verificationLevel(v) { + if (!v) return 'unknown'; + if (v.safety) return String(v.safety); + if (v.verified === false || (v.errors || 0) > 0) return 'error'; + if ((v.warnings || 0) > 0) return 'warning'; + return 'safe'; +} + +function verificationLabel(v) { + var level = verificationLevel(v); + if (level === 'safe') return 'formal verification = safe'; + if (level === 'warning') return 'formal verification = warning'; + if (level === 'error') return 'formal verification = error'; + return 'formal verification = unavailable'; +} + +function verificationResultHtml(v) { + if (!v) return ''; + var level = verificationLevel(v); + var ok = level !== 'error'; + return '' + escapeHtml(verificationLabel(v)) + + ' errors = ' + escapeHtml(String(v.errors || 0)) + + ' warnings = ' + escapeHtml(String(v.warnings || 0)) + ''; +} + +function renderVerificationReport(v, cert) { + var el = $('ct-verify-output'); + if (!el) return; + if (!v) { + el.innerHTML = '
compile AppliedML to view formal verification trace
'; + return; + } + var h = ''; + h += '
schema = ' + escapeHtml(v.schema || '-') + '
'; + h += '
engine = ' + escapeHtml(v.engine || '-') + '
'; + h += '
proof_model = ' + escapeHtml(v.proof_model || '-') + '
'; + if (cert) { + h += '
certificate = ' + escapeHtml(cert.schema || '-') + '
'; + h += '
source_hash = ' + escapeHtml(cert.source_hash || '-') + '
'; + h += '
bytecode_hash = ' + escapeHtml(cert.bytecode_hash || '-') + '
'; + h += '
verification_hash = ' + escapeHtml(cert.verification_hash || '-') + '
'; + } + h += '
safety = ' + escapeHtml(verificationLevel(v)) + ' | errors = ' + escapeHtml(String(v.errors || 0)) + ' | warnings = ' + escapeHtml(String(v.warnings || 0)) + '
'; + var trace = Array.isArray(v.trace) ? v.trace : []; + for (var i = 0; i < trace.length; i++) { + var t = trace[i] || {}; + var level = t.status === 'error' ? 'error' : (t.status === 'warning' ? 'warn' : 'info'); + h += '
trace = ' + escapeHtml(t.code || '-') + ' | status = ' + escapeHtml(t.status || '-') + ' | findings = ' + escapeHtml(String(t.findings || 0)) + '
'; + } + var invariants = Array.isArray(v.invariants) ? v.invariants : []; + for (var k = 0; k < invariants.length; k++) { + var inv = invariants[k] || {}; + var invLevel = inv.status === 'warning' ? 'warn' : (inv.status === 'error' ? 'error' : 'info'); + h += '
invariant = ' + escapeHtml(inv.code || '-') + ' | status = ' + escapeHtml(inv.status || '-') + ' | fields = ' + escapeHtml((inv.fields || []).join(',')) + ' | functions = ' + escapeHtml((inv.functions || []).join(',')) + '
'; + } + var summaries = Array.isArray(v.function_summaries) ? v.function_summaries : []; + for (var s = 0; s < summaries.length; s++) { + var sm = summaries[s] || {}; + h += '
summary = ' + escapeHtml(sm.name || '-') + ' | visibility = ' + escapeHtml(sm.visibility || '-') + ' | writes = ' + escapeHtml((sm.direct_writes || []).join(',')) + ' | transitive_writes = ' + escapeHtml((sm.transitive_writes || []).join(',')) + '
'; + } + var findings = Array.isArray(v.findings) ? v.findings : []; + for (var j = 0; j < findings.length; j++) { + var f = findings[j] || {}; + var sev = f.severity === 'error' ? 'error' : 'warn'; + h += '
finding = ' + escapeHtml(f.code || '-') + ' | fn = ' + escapeHtml(f.function_name || '-') + ' | field = ' + escapeHtml(f.state_field || '-') + ' | param = ' + escapeHtml(f.parameter || '-') + ' | message = ' + escapeHtml(f.message || '-') + '
'; + } + el.innerHTML = h; +} + +function logVerificationTrace(v) { + if (!v) return; + consoleLog(verificationLevel(v) === 'error' ? 'error' : 'info', + verificationLabel(v) + ' | errors = ' + (v.errors || 0) + ' | warnings = ' + (v.warnings || 0)); + var trace = Array.isArray(v.trace) ? v.trace : []; + for (var i = 0; i < trace.length; i++) { + var t = trace[i] || {}; + consoleLog(t.status === 'error' ? 'error' : (t.status === 'warning' ? 'warn' : 'info'), + 'trace = ' + (t.code || '-') + ' | status = ' + (t.status || '-') + ' | findings = ' + (t.findings || 0)); + } +} + function renderConsole() { var el = $('ct-console-output'); if (!el) return; @@ -1825,9 +2355,10 @@ function verifySourceRetry(addr, source, depFiles, attempts) { try { var payload = { address: addr, source: source }; if (depFiles && depFiles.length > 0) payload.files = depFiles; - await api('POST', '/contract/verify', payload); + var res = await api('POST', '/contract/verify', payload); + var safety = res.verification ? ' - ' + verificationResultHtml(res.verification) : ''; showResult('ct-deploy-result', true, - 'deployed to ' + escapeHtml(addr) + ' - source verified'); + 'deployed to ' + escapeHtml(addr) + ' - source verified' + safety); } catch (e) { verifySourceRetry(addr, source, depFiles, attempts - 1); } @@ -1854,6 +2385,7 @@ async function doDeploy() { var res = await api('POST', '/contract/deploy', body); var addr = res.contract_address || ''; var hash = res.tx_hash || ''; + invalidateCurrentAddressState(); showResult('ct-deploy-result', true, 'deployed to ' + escapeHtml(addr) + ' - tx: ' + txLink(hash) + ' (verifying source...)'); $('ct-call-addr').value = addr; @@ -1898,8 +2430,9 @@ async function doContractCall() { if (callFee) callBody.ou = callFee; var res = await api('POST', '/contract/call', callBody); var hash = res.tx_hash || ''; + invalidateCurrentAddressState(); showResult('ct-call-result', true, 'call submitted - tx: ' + txLink(hash)); - consoleLog('event', 'call ' + method + '() → tx ' + (hash ? hash.slice(0,16) + '...' : '')); + consoleLog('event', 'call ' + method + '() -> tx ' + (hash ? hash.slice(0,16) + '...' : '')); loadDashboard(); } catch (e) { showResult('ct-call-result', false, e.message); @@ -1958,10 +2491,10 @@ async function doContractView() { showResult('ct-call-result', true, 'result (encrypted): ' + escapeHtml(String(val)).substring(0, 40) + '...' + '
decrypted: ' + decrypted + ''); - consoleLog('log', 'view ' + method + '() → ' + decrypted + ' (decrypted)'); + consoleLog('log', 'view ' + method + '() -> ' + decrypted + ' (decrypted)'); } else { showResult('ct-call-result', true, 'result: ' + escapeHtml(String(val)) + ''); - consoleLog('log', 'view ' + method + '() → ' + String(val)); + consoleLog('log', 'view ' + method + '() -> ' + String(val)); } if (res.storage) updateStorageView(res.storage); if (res.events && res.events.length > 0) { @@ -2052,23 +2585,22 @@ async function doVerifyContract() { if (!source.trim()) { showResult('ct-verify-result', false, 'source required'); return; } try { var res = await api('POST', '/contract/verify', { address: addr, source: source }); + var safety = res.verification ? '
' + verificationResultHtml(res.verification) : ''; showResult('ct-verify-result', true, - 'source verified - code_hash: ' + escapeHtml(res.code_hash || '') + ''); + 'source verified - code_hash: ' + escapeHtml(res.code_hash || '') + '' + safety); } catch (e) { showResult('ct-verify-result', false, e.message); } } async function loadTokenSymbols() { - if (_tokensLoaded) return; + var cached = restoreAddressTokens(_walletAddr); + var state = ensureAddressRuntime(_walletAddr); + if (cached && state && (Date.now() - state.tokensTs) <= TOKEN_STALE_REFRESH_MS) return; try { - var res = await api('GET', '/tokens'); - _tokens = res.tokens || []; + _tokens = await fetchAddressTokens(false); _tokensLoaded = true; - for (var i = 0; i < _tokens.length; i++) { - _tokenSymbols[_tokens[i].address] = _tokens[i].symbol; - _tokenDecimals[_tokens[i].address] = _tokens[i].decimals || '0'; - } + hydrateTokenMaps(_tokens); } catch(e) {} } @@ -2084,31 +2616,80 @@ async function fetchMissingSymbols(txs) { var unknowns = Object.keys(need); if (unknowns.length === 0) return; await Promise.all(unknowns.map(function(ca) { - return Promise.all([ + if (_tokenMetaInflight[ca]) return _tokenMetaInflight[ca]; + _tokenMetaInflight[ca] = Promise.all([ api('GET', '/contract-storage?address=' + encodeURIComponent(ca) + '&key=symbol').then(function(r) { if (r && r.value) _tokenSymbols[ca] = String(r.value).slice(0, 32); }).catch(function() {}), api('GET', '/contract-storage?address=' + encodeURIComponent(ca) + '&key=decimals').then(function(r) { if (r && r.value) _tokenDecimals[ca] = String(r.value); }).catch(function() {}) - ]); + ]).finally(function() { + delete _tokenMetaInflight[ca]; + }); + return _tokenMetaInflight[ca]; })); } async function loadTokens() { $('tok-list').innerHTML = '
loading tokens...
'; + var restored = restoreAddressTokens(_walletAddr); + var state = ensureAddressRuntime(_walletAddr); + if (restored) { + renderTokenList(); + if (state && (Date.now() - state.tokensTs) <= TOKEN_STALE_REFRESH_MS) { + loadTokenTxs(); + return; + } + } try { - var res = await api('GET', '/tokens'); - _tokens = res.tokens || []; + _tokens = await fetchAddressTokens(false); _tokensLoaded = true; - for (var i = 0; i < _tokens.length; i++) { - _tokenSymbols[_tokens[i].address] = _tokens[i].symbol; - _tokenDecimals[_tokens[i].address] = _tokens[i].decimals || '0'; - } - renderTokenList(); - loadTokenTxs(); + hydrateTokenMaps(_tokens); } catch (e) { - $('tok-list').innerHTML = '
' + e.message + '
'; + if (!restored) $('tok-list').innerHTML = '
' + escapeHtml(e.message) + '
'; + loadTokenTxs(); + return; + } + renderTokenList(); + loadTokenTxs(); +} + +async function loadTokenTxs() { + var el = $('tok-txs'); + if (!el) return; + var gen = ++_tokTxGen; + try { + var hist = await fetchTokenHistory(false); + if ((!hist.total || hist.total === 0) && _tokens && _tokens.length > 0) { + hist = await fetchTokenHistory(true); + } + if (gen !== _tokTxGen) return; + var filtered = hist.transactions || []; + $('tok-tx-count').textContent = String(filtered.length); + $('tok-tx-total').textContent = String(hist.total || filtered.length); + $('tok-tx-in').textContent = String(hist.incoming || 0); + $('tok-tx-out').textContent = String(hist.outgoing || 0); + if (filtered.length === 0) { + el.innerHTML = '
no token transactions yet
'; + return; + } + await fetchMissingSymbols(filtered); + var h = ''; + var cards = '
'; + for (var j = 0; j < filtered.length; j++) { + h += txRow(filtered[j]); + cards += txCardHtml(filtered[j]); + } + h += '
hashfromtoamountstatustime
'; + cards += '
'; + el.innerHTML = h + cards; + } catch(e) { + $('tok-tx-count').textContent = '0'; + $('tok-tx-total').textContent = '0'; + $('tok-tx-in').textContent = '0'; + $('tok-tx-out').textContent = '0'; + el.innerHTML = '
no token transactions yet
'; } } @@ -2134,7 +2715,7 @@ function renderTokenList() { h += '' + short(t.address) + ''; h += '
'; h += '
'; - h += ''; + h += ''; h += '
'; h += ''; } @@ -2185,11 +2766,14 @@ async function doTokenTransfer() { if (!rawAmount) { showResult('tok-transfer-result', false, 'invalid amount'); return; } if (!validateFee('tok-fee', 'call')) { feeError('tok-transfer-result', 'tok-fee', 'call'); return; } try { - var tokBody = { token: _selectedToken.address, to: to, amount: rawAmount }; + var pin = await modalPrompt('confirm token transfer', 'enter PIN to transfer ' + humanAmt + ' to ' + to, { pin: true, btnText: 'transfer' }); + if (!pin) { showResult('tok-transfer-result', false, 'transfer cancelled'); return; } + var tokBody = { token: _selectedToken.address, to: to, amount: rawAmount, pin: pin }; var tokFee = $('tok-fee') ? $('tok-fee').value.trim() : ''; if (tokFee) tokBody.ou = tokFee; var res = await api('POST', '/token/transfer', tokBody); var txHash = res.hash || res.tx_hash || ''; + invalidateCurrentAddressState(); showResult('tok-transfer-result', true, 'sent ' + humanAmt + ' ' + _selectedToken.symbol + ' - tx: ' + txLink(txHash)); $('tok-to').value = ''; @@ -2200,39 +2784,8 @@ async function doTokenTransfer() { } } -async function loadTokenTxs() { - var el = $('tok-txs'); - if (!el) return; - var gen = ++_tokTxGen; - try { - var filtered = []; - var hist = await api('GET', '/history?limit=500&offset=0'); - if (gen !== _tokTxGen) return; - var txs = hist.transactions || []; - for (var i = 0; i < txs.length; i++) { - var t = txs[i]; - if (t.op_type === 'call' && t.encrypted_data === 'transfer') filtered.push(t); - } - if (filtered.length === 0) { - el.innerHTML = '
no token transactions yet
'; - return; - } - await fetchMissingSymbols(filtered); - var h = ''; - var cards = '
'; - for (var i = 0; i < filtered.length; i++) { - h += txRow(filtered[i]); - cards += txCardHtml(filtered[i]); - } - h += '
hashfromtoamountstatustime
'; - cards += ''; - el.innerHTML = h + cards; - } catch(e) { - el.innerHTML = '
no token transactions yet
'; - } -} - function renderHistoryTxs(txs) { + $('hist-count').textContent = String(_historyOffset + txs.length); var h = ''; var cards = '
'; for (var i = 0; i < txs.length; i++) { @@ -2245,23 +2798,42 @@ function renderHistoryTxs(txs) { } async function loadHistory() { - $('history-list').innerHTML = '
loading...
'; + var cached = peekHistoryPage(_walletAddr, _historyLimit, _historyOffset); + if (!cached) $('history-list').innerHTML = '
loading...
'; $('history-more').innerHTML = ''; loadTokenSymbols(); try { - var res = await api('GET', '/history?limit=' + _historyLimit + '&offset=' + _historyOffset); + if (cached) { + var cachedTxs = cached.response.transactions || []; + $('hist-total').textContent = String(cached.response.total || cachedTxs.length); + if (cachedTxs.length === 0 && _historyOffset === 0) { + $('hist-count').textContent = '0'; + $('history-list').innerHTML = '
no transactions yet
'; + } else { + renderHistoryTxs(cachedTxs); + if (cached.response.has_more) { + $('history-more').innerHTML = ''; + } + fetchMissingSymbols(cachedTxs).then(function() { renderHistoryTxs(cachedTxs); }); + } + if ((Date.now() - cached.ts) <= HISTORY_STALE_REFRESH_MS) return; + } + var res = await fetchHistoryPage(_historyLimit, _historyOffset, false); var txs = res.transactions || []; + $('hist-total').textContent = String(res.total || txs.length); if (txs.length === 0 && _historyOffset === 0) { + $('hist-count').textContent = '0'; $('history-list').innerHTML = '
no transactions yet
'; return; } renderHistoryTxs(txs); - if (txs.length >= _historyLimit) { - $('history-more').innerHTML = ''; + if (res.has_more) { + $('history-more').innerHTML = ''; } fetchMissingSymbols(txs).then(function() { renderHistoryTxs(txs); }); } catch (e) { - $('history-list').innerHTML = '
' + e.message + '
'; + $('hist-count').textContent = '0'; + $('history-list').innerHTML = '
' + escapeHtml(e.message) + '
'; } } @@ -2274,8 +2846,9 @@ async function loadHistoryAppend() { var btn = $('history-more').querySelector('button'); if (btn) { btn.disabled = true; btn.textContent = 'loading...'; } try { - var res = await api('GET', '/history?limit=' + _historyLimit + '&offset=' + _historyOffset); + var res = await fetchHistoryPage(_historyLimit, _historyOffset, false); var txs = res.transactions || []; + $('hist-total').textContent = String(res.total || txs.length); if (txs.length === 0) { $('history-more').innerHTML = '
no more transactions
'; return; @@ -2289,13 +2862,32 @@ async function loadHistoryAppend() { } if (cardList) cardList.insertAdjacentHTML('beforeend', txCardHtml(txs[i])); } - if (txs.length >= _historyLimit) { - $('history-more').innerHTML = ''; + fetchMissingSymbols(txs).then(function() { + if (tbl) { + for (var j = 0; j < txs.length; j++) { + var rowIndex = tbl.rows.length - txs.length + j; + if (rowIndex > 0 && tbl.rows[rowIndex]) tbl.rows[rowIndex].innerHTML = txRow(txs[j]).replace(/<\/?tr>/g, ''); + } + } + if (cardList) { + var all = $('history-list').querySelector('.card-list'); + if (all) { + var cards = all.querySelectorAll('.tx-card'); + for (var k = 0; k < txs.length; k++) { + var cardIndex = cards.length - txs.length + k; + if (cardIndex >= 0 && cards[cardIndex]) cards[cardIndex].outerHTML = txCardHtml(txs[k]); + } + } + } + }); + $('hist-count').textContent = String(_historyOffset + txs.length); + if (res.has_more) { + $('history-more').innerHTML = ''; } else { $('history-more').innerHTML = ''; } } catch (e) { - $('history-more').innerHTML = '
' + e.message + '
'; + $('history-more').innerHTML = '
' + escapeHtml(e.message) + '
'; } } @@ -2305,22 +2897,25 @@ async function showKeys() { var res = await api('GET', '/keys'); var h = '
hashfromtoamountstatustime
'; h += ''; - h += ''; + h += ''; h += ''; - h += ''; - h += ''; + h += ''; + h += ''; h += '
address' + (res.address || '') + '
public key' + (res.public_key || '') + '
public key' + escapeHtml(res.public_key || '') + '
view pubkey' + (res.view_pubkey || '-') + '
private key****** (click to reveal)
seed phrase' + (res.has_master_seed ? '****** (click to reveal)' : 'not set - imported via private key only') + '
private key****** (click to reveal)
seed phrase' + (res.has_master_seed ? '****** (click to reveal)' : 'not set - imported via private key only') + '
'; $('keys-table').innerHTML = h; } catch (e) { - $('keys-table').innerHTML = '
' + e.message + '
'; + $('keys-table').innerHTML = '
' + escapeHtml(e.message) + '
'; } } async function revealPrivateKeys() { - var pin = await modalPrompt('reveal private keys', 'enter 6-digit PIN', { pin: true, btnText: 'reveal' }); - if (!pin || !/^\d{6}$/.test(pin)) return; + var pin = await modalPrompt('reveal private keys', 'enter PIN', { pin: true, btnText: 'reveal' }); + if (!pin) return; try { - var res = await api('POST', '/keys/private', { pin: pin }); + var res = await api('POST', '/keys/private', { + pin: pin, + confirm: 'I_UNDERSTAND_KEY_EXPORT_RISK' + }); var pkCell = $('privkey-cell'); if (pkCell) { pkCell.className = 'mono'; @@ -2345,8 +2940,9 @@ async function revealPrivateKeys() { async function loadSettings() { try { var w = await api('GET', '/wallet'); - $('settings-rpc').value = w.rpc_url || 'http://46.101.86.250:8080'; + $('settings-rpc').value = w.rpc_url || 'https://octra.network/rpc'; $('settings-explorer').value = w.explorer_url || 'https://octrascan.io'; + $('settings-bridge-signer').value = w.bridge_signer_url || 'https://relayer-002838819188.octra.network'; } catch (e) {} loadAccountList(); } @@ -2361,9 +2957,12 @@ async function loadAccountList() { el.innerHTML = '
no accounts
'; return; } - var btnStyle = 'display:inline-block;width:80px;padding:8px;margin:0;background:#E5E9EF;border:none;border-top:1px solid #D0D7E2;border-bottom:1px solid #D0D7E2;margin-right:4px;color:#3B567F;font-family:Tahoma,arial,sans-serif;font-size:11px;font-weight:bold;letter-spacing:1px;cursor:pointer;text-align:center;text-transform:lowercase'; - var btnHover = 'onmouseenter="this.style.background=\'#D0D7E2\'" onmouseleave="this.style.background=\'#E5E9EF\'"'; - var html = ''; + var btnStyle = 'display:inline-block;width:96px;padding:8px;margin:0;border:none;border-top:1px solid #D0D7E2;border-bottom:1px solid #D0D7E2;margin-right:4px;color:#3B567F;font-family:Tahoma,arial,sans-serif;font-size:11px;font-weight:bold;letter-spacing:1px;cursor:pointer;text-align:center;text-transform:lowercase'; + var html = '
'; + html += ''; + html += ''; + html += ''; + html += ''; for (var i = 0; i < accounts.length; i++) { var a = accounts[i]; var badge = a.active ? '' : ''; @@ -2377,15 +2976,16 @@ async function loadAccountList() { } } var name = a.name || 'unnamed'; - var escapedName = name.replace(/'/g, "\\'"); html += ''; - html += ''; - html += ''; + html += ''; + html += ''; html += ''; } html += '
' + badge + '' + name + '' + hdLabel + '' + a.addr + '' + badge + '' + escapeHtml(name) + '' + hdLabel + '' + escapeHtml(a.addr) + ''; if (!a.active) { - html += ''; + html += ''; + } else { + html += ''; } - html += ''; + html += ''; html += '
'; @@ -2395,10 +2995,10 @@ async function loadAccountList() { var ah = '
'; if (resp.has_master_seed) { var idx = resp.next_hd_index || 0; - ah += ''; + ah += ''; ah += 'or'; } - ah += ''; + ah += ''; ah += '
'; actEl.innerHTML = ah; } @@ -2420,6 +3020,8 @@ function modalPrompt(title, label, opts) { $('modal-result').innerHTML = ''; if (opts.pin) { $('modal-pin').style.display = 'block'; + var lbl = $('modal-pin-label'); + if (lbl) lbl.textContent = label; $('modal-pin-input').value = ''; $('pin-back-btn').style.display = ''; var unlockBtn = $('modal-pin').querySelector('.action-btn'); @@ -2460,8 +3062,8 @@ function modalPrompt(title, label, opts) { } async function doSwitchAccount(addr) { - var pin = await modalPrompt('switch account', 'enter 6-digit PIN', { pin: true, btnText: 'switch' }); - if (!pin || !/^\d{6}$/.test(pin)) return; + var pin = await modalPrompt('switch account', 'enter PIN', { pin: true, btnText: 'switch' }); + if (!pin) return; clearResult('wallet-mgmt-result'); try { await api('POST', '/wallet/switch', { addr: addr, pin: pin }); @@ -2487,6 +3089,26 @@ async function doSwitchAccount(addr) { } } +async function doChangePinForWallet(addr) { + var cur = await modalPrompt('change PIN (step 1 of 3)', 'enter current PIN', { pin: true, btnText: 'next' }); + if (!cur) return; + var np = await modalPrompt('change PIN (step 2 of 3)', 'enter new PIN (min 8, 15+ recommended)', { pin: true, btnText: 'next' }); + if (!np) return; + var newErr = validatePin(np); + if (newErr) { showResult('wallet-mgmt-result', false, 'new PIN: ' + newErr); return; } + var nc = await modalPrompt('change PIN (step 3 of 3)', 'confirm new PIN', { pin: true, btnText: 'change' }); + if (!nc) return; + if (np !== nc) { showResult('wallet-mgmt-result', false, 'PINs do not match'); return; } + if (cur === np) { showResult('wallet-mgmt-result', false, 'new PIN must be different from current'); return; } + clearResult('wallet-mgmt-result'); + try { + await api('POST', '/wallet/change-pin', { current_pin: cur, new_pin: np }); + showResult('wallet-mgmt-result', true, 'PIN changed successfully'); + } catch (e) { + showResult('wallet-mgmt-result', false, e.message); + } +} + async function doRenameAccount(addr, currentName) { var name = await modalPrompt('rename account', 'new name', { placeholder: currentName || 'my wallet' }); if (!name || !name.trim()) return; @@ -2502,8 +3124,8 @@ async function doRenameAccount(addr, currentName) { async function doDeriveAccount() { if (!_hasMasterSeed) return; - var pin = await modalPrompt('derive new address', 'enter 6-digit PIN', { pin: true }); - if (!pin || !/^\d{6}$/.test(pin)) return; + var pin = await modalPrompt('derive new address', 'enter PIN', { pin: true }); + if (!pin) return; var name = await modalPrompt('derive new address', 'name for new account (optional)', { placeholder: 'trading' }); if (name === null) return; clearResult('wallet-mgmt-result'); @@ -2534,12 +3156,17 @@ async function doSaveSettings() { clearResult('settings-result'); var rpc = $('settings-rpc').value.trim(); var explorer = $('settings-explorer').value.trim(); + var bridgeSigner = $('settings-bridge-signer').value.trim(); if (!rpc) { showResult('settings-result', false, 'rpc url required'); return; } try { - var resp = await api('POST', '/settings', { rpc_url: rpc, explorer_url: explorer }); + var pin = await modalPrompt('confirm settings change', 'enter PIN to change network endpoints', { pin: true, btnText: 'save' }); + if (!pin) { showResult('settings-result', false, 'settings change cancelled'); return; } + var resp = await api('POST', '/settings', { rpc_url: rpc, explorer_url: explorer, bridge_signer_url: bridgeSigner, pin: pin }); if (explorer) _explorerUrl = explorer.replace(/\/+$/, ''); try { _rpcHost = new URL(rpc).hostname; } catch(e) { _rpcHost = rpc; } if (resp && resp.cache_cleared) { + clearAllAddressRuntime(); + dropAllPersistedRuntime(); _cachedBal = null; _historyOffset = 0; _tokens = []; @@ -2552,7 +3179,7 @@ async function doSaveSettings() { fetchBalance(); if (document.querySelector('.nav-tabs a.active[data-view="dashboard"]')) loadDashboard(); - showResult('settings-result', true, 'saved · cache cleared'); + showResult('settings-result', true, 'saved | cache cleared'); } else { showResult('settings-result', true, 'saved'); } @@ -2566,8 +3193,9 @@ async function doChangePin() { var cur = $('pin-current').value; var np = $('pin-new').value; var nc = $('pin-confirm-new').value; - if (!/^\d{6}$/.test(cur)) { showResult('pin-change-result', false, 'current PIN must be 6 digits'); return; } - if (!/^\d{6}$/.test(np)) { showResult('pin-change-result', false, 'new PIN must be 6 digits'); return; } + if (!cur || cur.length === 0) { showResult('pin-change-result', false, 'current PIN required'); return; } + var newErr = validatePin(np); + if (newErr) { showResult('pin-change-result', false, 'new PIN: ' + newErr); return; } if (np !== nc) { showResult('pin-change-result', false, 'PINs do not match'); return; } if (cur === np) { showResult('pin-change-result', false, 'new PIN must be different'); return; } try { @@ -2593,6 +3221,8 @@ function hideAllModalPanels() { $('modal-pin-setup').style.display = 'none'; $('modal-mnemonic-show').style.display = 'none'; $('modal-result').innerHTML = ''; + var lbl = $('modal-pin-label'); + if (lbl) lbl.textContent = 'enter PIN to unlock'; } function showPinEntry(showBack) { @@ -2668,7 +3298,7 @@ function modalBackFromPin() { function modalCreate() { showPinSetup('create'); - $('modal-sub').textContent = 'set a 6-digit PIN for your new wallet'; + $('modal-sub').textContent = 'set a PIN for your new wallet'; } function modalDoImport() { @@ -2697,7 +3327,7 @@ function modalDoImport() { $('modal-privkey').value = ''; } showPinSetup('import'); - $('modal-sub').textContent = 'set a 6-digit PIN for your wallet'; + $('modal-sub').textContent = 'set a PIN for your wallet'; } function showMnemonicWords(mnemonic) { @@ -2723,8 +3353,8 @@ function modalMnemonicDone() { async function modalUnlock() { var pin = $('modal-pin-input').value; - if (!/^\d{6}$/.test(pin)) { - $('modal-result').innerHTML = '
PIN must be exactly 6 digits
'; + if (!pin || pin.length === 0) { + $('modal-result').innerHTML = '
PIN required
'; return; } if (_modalPromptResolve) { @@ -2758,8 +3388,9 @@ async function modalUnlock() { async function modalFinishSetup() { var pin = $('modal-pin-new').value; var confirm = $('modal-pin-confirm').value; - if (!/^\d{6}$/.test(pin)) { - $('modal-result').innerHTML = '
PIN must be exactly 6 digits
'; + var pinErr = validatePin(pin); + if (pinErr) { + $('modal-result').innerHTML = '
' + pinErr + '
'; return; } if (pin !== confirm) { @@ -2808,13 +3439,24 @@ async function modalFinishSetup() { async function loadWalletInfo() { try { var w = await api('GET', '/wallet'); + var prevAddr = _walletAddr; _walletAddr = w.address || w.addr || ''; + ensureAddressRuntime(_walletAddr); + if (prevAddr !== _walletAddr) { + _cachedBal = null; + _historyOffset = 0; + _tokens = []; + _tokensLoaded = false; + restoreAddressTokens(_walletAddr); + } if (w.explorer_url) _explorerUrl = w.explorer_url.replace(/\/+$/, ''); if (w.rpc_url) try { _rpcHost = new URL(w.rpc_url).hostname; } catch(e) { _rpcHost = w.rpc_url; } _hasMasterSeed = !!w.has_master_seed; $('hdr-addr').innerHTML = '' + _walletAddr + ''; $('hdr-logout').style.display = ''; $('hdr-dev').style.display = ''; + $('hdr-circles').style.display = ''; + $('hdr-apps').style.display = ''; fetchFees(); loadDashboard(); } catch (e) { @@ -2827,12 +3469,19 @@ async function loadWalletInfo() { async function doLogout() { try { await api('POST', '/wallet/lock', {}); } catch (e) {} if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; } + clearAllAddressRuntime(); _walletAddr = ''; _cachedBal = null; _encryptedBalanceRaw = 0; _hasMasterSeed = false; + _tokens = []; + _tokensLoaded = false; + _tokenSymbols = {}; + _tokenDecimals = {}; $('hdr-logout').style.display = 'none'; $('hdr-dev').style.display = 'none'; + $('hdr-circles').style.display = 'none'; + $('hdr-apps').style.display = 'none'; $('hdr-addr').textContent = 'locked'; $('hdr-status').textContent = 'locked'; $('hdr-status').className = 'right'; @@ -2844,11 +3493,15 @@ function startRefreshTimer() { if (_refreshTimer) return; bgStealthScan(); _refreshTimer = setInterval(function() { - fetchBalance(); + fetchBalance(true); bgStealthScan(); fetchFees(); var dash = $('view-dashboard'); + var tok = $('view-tokens'); + var hist = $('view-history'); if (dash && dash.classList.contains('active')) loadDashboard(); + if (tok && tok.classList.contains('active')) loadTokens(); + if (hist && hist.classList.contains('active') && _historyOffset === 0) loadHistory(); }, 15000); } @@ -2867,19 +3520,18 @@ function showAccountPicker(wallets) { var sub = hasAddr ? a.addr.substring(0, 12) + '...' + a.addr.substring(a.addr.length - 6) : a.file; - var hdTag = a.hd ? ' · hd' : ''; + var hdTag = a.hd ? ' | hd' : ''; var dataAttr = hasAddr - ? 'data-addr="' + a.addr + '"' - : 'data-file="' + a.file + '"'; - html += ''; html += '
'; - html += '+ import or create new wallet'; + html += '+ import or create new wallet'; html += '
'; $('modal-result').innerHTML = html; $('modal-overlay').style.display = 'flex'; @@ -2942,5 +3594,44 @@ $('modal-pin-confirm').addEventListener('keydown', function(e) { if (e.key === 'Enter') modalFinishSetup(); }); +function wireDelegation() { + var actions = { + modalShowImport, modalCreate, modalDoImport, modalBack, modalMnemonicDone, + modalUnlock, modalBackFromPin, modalFinishSetup, doLogout, doKeySwitch, + doSend, doEncrypt, doDecrypt, doStealthSend, doStealthScan, doTokenTransfer, + closeTokenTransfer, onLangChange, editorUpdateWithLiveCompile, doCompile, + loadTemplate, doPreviewDeploy, doDeploy, doContractCall, doContractView, + doFheEncrypt, doFheDecrypt, doContractInfo, doContractReceipt, doVerifyContract, + doSaveSettings, doChangePin, goBack, switchView, switchImportTab, switchBottomTab, + ideCloseProject, ideExportZip, ideNewFile, ideOpenFile, ideCloseTab, ideNewProject, + ideLoadProject, ideDeleteProject, showTx, claimSelected, openTokenTransfer, + loadMoreHistory, revealPrivateKeys, doSwitchAccount, doChangePinForWallet, + doDeriveAccount, showImportAnother, showImportOptions, + navTo: function(a) { window.location.href = a; }, + openTab: function(a) { window.open(a, '_blank'); }, + selectSelf: function(a, el) { el.select(); }, + importFiles: function(a, el) { ideImportFiles(el.files); }, + fileMenu: function(a, el, e) { ideFileMenu(e, a); }, + renameAccount: function(a, el) { doRenameAccount(el.getAttribute('data-arg'), el.getAttribute('data-name')); }, + pickWallet: function(a, el) { pickWallet(el); } + }; + function run(e, attr) { + var el = e.target.closest('[' + attr + ']'); + if (!el) return; + var fn = actions[el.getAttribute(attr)]; + if (!fn) return; + if (el.getAttribute('data-prevent') === '1') e.preventDefault(); + fn(el.getAttribute('data-arg'), el, e); + } + document.addEventListener('click', function(e) { run(e, 'data-action'); }); + document.addEventListener('change', function(e) { run(e, 'data-change'); }); + document.addEventListener('input', function(e) { run(e, 'data-input'); }); + document.addEventListener('contextmenu', function(e) { run(e, 'data-context'); }); + document.addEventListener('submit', function(e) { if (e.target.closest('[data-nosubmit]')) e.preventDefault(); }); + var ed = $('ct-source'); + if (ed) ed.addEventListener('scroll', editorSync); +} + +wireDelegation(); initEditor(); -init(); +init(); \ No newline at end of file diff --git a/wallet.hpp b/wallet.hpp index e269fcd..8ec2c90 100644 --- a/wallet.hpp +++ b/wallet.hpp @@ -59,6 +59,7 @@ struct Wallet { std::string addr; std::string rpc_url; std::string explorer_url = "https://octrascan.io"; + std::string bridge_signer_url; uint8_t sk[64]; uint8_t pk[32]; std::string pub_b64; @@ -226,6 +227,7 @@ inline void save_wallet_encrypted(const std::string& path, j["addr"] = w.addr; j["rpc"] = w.rpc_url; j["explorer"] = w.explorer_url; + j["bridge_signer"] = w.bridge_signer_url; if (!w.master_seed_b64.empty()) { j["master_seed"] = w.master_seed_b64; j["hd_index"] = w.hd_index; @@ -267,8 +269,11 @@ inline Wallet load_wallet_encrypted(const std::string& path, Wallet w; w.priv_b64 = j.at("priv").get(); w.addr = j.at("addr").get(); - w.rpc_url = j.value("rpc", "http://46.101.86.250:8080"); + w.rpc_url = j.value("rpc", "https://octra.network/rpc"); + if (w.rpc_url == "http://46.101.86.250:8080" || w.rpc_url == "http://46.101.86.250:8080/") + w.rpc_url = "https://octra.network/rpc"; w.explorer_url = j.value("explorer", "https://octrascan.io"); + w.bridge_signer_url = j.value("bridge_signer", ""); w.master_seed_b64 = j.value("master_seed", ""); w.mnemonic = j.value("mnemonic", ""); w.hd_index = j.value("hd_index", 0); @@ -344,7 +349,7 @@ inline std::pair create_wallet(const std::string& path, throw std::runtime_error("derived address is invalid"); w.priv_b64 = base64_encode(w.sk, 32); w.pub_b64 = base64_encode(w.pk, 32); - w.rpc_url = "http://46.101.86.250:8080"; + w.rpc_url = "https://octra.network/rpc"; w.master_seed_b64 = base64_encode(seed.data(), 64); w.mnemonic = mnemonic; w.hd_index = 0; @@ -415,7 +420,7 @@ inline Wallet import_wallet_mnemonic(const std::string& path, throw std::runtime_error("derived address is invalid"); w.priv_b64 = base64_encode(w.sk, 32); w.pub_b64 = base64_encode(w.pk, 32); - w.rpc_url = "http://46.101.86.250:8080"; + w.rpc_url = "https://octra.network/rpc"; w.master_seed_b64 = base64_encode(seed.data(), 64); w.mnemonic = mnemonic; w.hd_index = 0; @@ -450,7 +455,7 @@ inline Wallet import_wallet(const std::string& path, throw std::runtime_error("derived address is invalid"); w.priv_b64 = base64_encode(w.sk, 32); w.pub_b64 = base64_encode(w.pk, 32); - w.rpc_url = "http://46.101.86.250:8080"; + w.rpc_url = "https://octra.network/rpc"; save_wallet_encrypted(path, w, pin); try_mlock(w.sk, 64); try_mlock(w.pk, 32); @@ -460,7 +465,10 @@ inline Wallet import_wallet(const std::string& path, inline void save_settings(const std::string& path, Wallet& w, const std::string& new_rpc, const std::string& pin) { - w.rpc_url = new_rpc; + if (new_rpc == "http://46.101.86.250:8080" || new_rpc == "http://46.101.86.250:8080/") + w.rpc_url = "https://octra.network/rpc"; + else + w.rpc_url = new_rpc; save_wallet_encrypted(path, w, pin); } @@ -553,4 +561,5 @@ inline std::vector scan_and_merge_oct_files() { #endif return entries; } + } \ No newline at end of file