From c6fdd38612932910017089fff13c7bf7c8b8b86d Mon Sep 17 00:00:00 2001 From: TheRON Date: Mon, 8 Jun 2026 02:08:51 -0400 Subject: [PATCH] Initial push --- hubzilla/addon/cry01/cry01.php | 224 ++++++++++++++++++++++ hubzilla/addon/cry01/cry01_chain.php | 156 +++++++++++++++ hubzilla/addon/cry01/cry01_renderer.php | 245 ++++++++++++++++++++++++ hubzilla/addon/cry01/cry01_spool.php | 145 ++++++++++++++ 4 files changed, 770 insertions(+) create mode 100644 hubzilla/addon/cry01/cry01.php create mode 100644 hubzilla/addon/cry01/cry01_chain.php create mode 100644 hubzilla/addon/cry01/cry01_renderer.php create mode 100644 hubzilla/addon/cry01/cry01_spool.php diff --git a/hubzilla/addon/cry01/cry01.php b/hubzilla/addon/cry01/cry01.php new file mode 100644 index 0000000..019f5b6 --- /dev/null +++ b/hubzilla/addon/cry01/cry01.php @@ -0,0 +1,224 @@ + + + + '; +} + +// --------------------------------------------------------------------------- +// CONTENT ROUTER +// --------------------------------------------------------------------------- + +function cry01_content() { + if (function_exists('head_add_css')) { + head_add_css('/addon/cry01/view/css/cry01.css'); + } + if (function_exists('head_add_js')) { + head_add_js('/addon/cry01/view/js/cry01.js'); + } + + $association_slug = argv(1) ?? ''; + $sub_route = strtolower(argv(2) ?? ''); + + if (!$association_slug) { + return cry01_render_index(); + } + + $raw = @file_get_contents('addon/vs01/config.json'); + if ($raw === false) return cry01_render_error('Configuration unavailable. Contact the operator.'); + $cfg = json_decode($raw, true); + if (!isset($cfg['associations'][$association_slug])) { + return cry01_render_not_found(); + } + + $access = cry01_access_state($association_slug); + + switch ($sub_route) { + case 'signal': + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($access === 'public') return cry01_access_wall($association_slug); + if (!cry01_verify_csrf()) { + return cry01_render_error('Invalid form token. Please reload and try again.'); + } + return cry01_handle_signal_post($association_slug, $access); + } + if ($access === 'public') return cry01_access_wall($association_slug); + return cry01_render_signal_form($association_slug, $access); + + case 'g1': + if ($access !== 'operator') return cry01_access_wall($association_slug); + return cry01_render_g1_candidates($association_slug); + + case 'manage': + if ($access !== 'operator') return cry01_access_wall($association_slug); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + return cry01_handle_manage_post($association_slug); + } + return cry01_render_manage($association_slug); + + default: + return cry01_render_landing($association_slug, $access); + } +} + +// --------------------------------------------------------------------------- +// RENDER — INDEX +// --------------------------------------------------------------------------- + +function cry01_render_index() { + // Lists all registered associations with links to their value layer pages. + $raw = @file_get_contents('addon/vs01/config.json'); + $cfg = $raw ? json_decode($raw, true) : []; + $list = $cfg['associations'] ?? []; + + if (empty($list)) { + return '

No associations registered.

'; + } + + $out = '
'; + $out .= '

Value Layer

'; + $out .= '
'; + return $out; +} + +// --------------------------------------------------------------------------- +// RENDER — NOT FOUND / ERROR +// --------------------------------------------------------------------------- + +function cry01_render_not_found() { + return '
Association not found.
'; +} + +function cry01_render_error($message) { + // Shows a plain-language error message. Never shows a blank page or stack trace. + return '
' . cry01_h($message) . '
'; +} + +// --------------------------------------------------------------------------- +// CSRF +// --------------------------------------------------------------------------- + +function cry01_csrf_token() { + // Generates and stores a CSRF token for the current session. + if (empty($_SESSION['cry01_csrf'])) { + $_SESSION['cry01_csrf'] = bin2hex(random_bytes(16)); + } + return ''; +} + +function cry01_verify_csrf() { + // Returns true if the CSRF token in POST matches the session token. + return isset($_POST['cry01_csrf'], $_SESSION['cry01_csrf']) + && hash_equals($_SESSION['cry01_csrf'], $_POST['cry01_csrf']); +} diff --git a/hubzilla/addon/cry01/cry01_chain.php b/hubzilla/addon/cry01/cry01_chain.php new file mode 100644 index 0000000..34bf61b --- /dev/null +++ b/hubzilla/addon/cry01/cry01_chain.php @@ -0,0 +1,156 @@ + '2.0', + 'id' => 1, + 'method' => 'state_getStorage', + 'params' => [cry01_balance_storage_key($g1_pubkey)], + ]); + + $result = cry01_rpc_post($rpc, $payload); + if ($result === null) return null; + + return cry01_decode_balance($result); +} + +function cry01_balance_storage_key($g1_pubkey) { + // Builds the Substrate storage key for the account balance of the given Ğ1 public key. + // The key format is defined by the Duniter v2 runtime (pallet_balances storage map). + // Placeholder — requires Substrate storage key encoding (xxHash + Blake2). + // TODO: implement full storage key derivation or use duniter-specific RPC method. + return '0x' . bin2hex($g1_pubkey); +} + +function cry01_decode_balance($rpc_result) { + // Decodes the raw RPC result into a human-readable Ğ1 balance string. + // Duniter v2 balances are stored as u64 encoded in SCALE codec. + // Returns formatted balance string or null if decoding fails. + $hex = $rpc_result['result'] ?? null; + if (!$hex || $hex === '0x') return '0'; + // Remove 0x prefix and decode little-endian u64. + $hex = ltrim($hex, '0x'); + $bytes = array_reverse(str_split(str_pad($hex, 16, '0', STR_PAD_LEFT), 2)); + $val = 0; + foreach ($bytes as $b) { + $val = ($val << 8) | hexdec($b); + } + // Duniter uses centimes (hundredths of Ğ1). Divide by 100. + return number_format($val / 100, 2) . ' Ğ1'; +} + +// --------------------------------------------------------------------------- +// RPC TRANSPORT +// --------------------------------------------------------------------------- + +function cry01_rpc_post($endpoint, $payload) { + // POSTs a JSON-RPC request to the Duniter node. Returns decoded response or null. + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $payload, + 'timeout' => 5, + ], + ]); + $raw = @file_get_contents($endpoint, false, $context); + if ($raw === false) { + logger('cry01_chain: RPC call failed to ' . $endpoint); + return null; + } + $data = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { + logger('cry01_chain: RPC response is not valid JSON'); + return null; + } + return $data; +} + +// --------------------------------------------------------------------------- +// CACHE +// --------------------------------------------------------------------------- + +function cry01_read_cache() { + // Returns the balance cache array. Returns empty array if cache does not exist or is unreadable. + $config = cry01_load_config(); + $path = $config['cache_file'] ?? ''; + if (!$path) return []; + $raw = @file_get_contents($path); + if ($raw === false) return []; + $data = json_decode($raw, true); + return (json_last_error() === JSON_ERROR_NONE) ? $data : []; +} + +function cry01_write_cache($data) { + // Writes the balance cache to disk. Returns true on success, false on failure. + $config = cry01_load_config(); + $path = $config['cache_file'] ?? ''; + if (!$path) return false; + $tmp = $path . '.tmp'; + $ok = @file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT)); + if ($ok === false) { + logger('cry01_chain: could not write cache to ' . $tmp); + return false; + } + return @rename($tmp, $path); +} + +function cry01_cache_is_stale() { + // Returns true if the cache is older than cache_max_age_seconds or does not exist. + $config = cry01_load_config(); + $path = $config['cache_file'] ?? ''; + $max_age = intval($config['cache_max_age_seconds'] ?? 3600); + if (!$path || !file_exists($path)) return true; + return (time() - filemtime($path)) > $max_age; +} + +function cry01_refresh_balance_cache() { + // Refreshes the balance cache by querying the Duniter node. + // Writes updated cache to disk. Called by the manage route. + $config = cry01_load_config(); + $pubkey = $config['operator_g1_pubkey'] ?? ''; + if (!$pubkey) { + logger('cry01_chain: operator_g1_pubkey not set in config'); + return false; + } + $balance = cry01_get_balance($pubkey); + if ($balance === null) return false; + + $cache = cry01_read_cache(); + $cache['operator_balance'] = $balance; + $cache['operator_g1_pubkey'] = $pubkey; + $cache['refreshed_at'] = date('c'); + return cry01_write_cache($cache); +} + +// --------------------------------------------------------------------------- +// CONFIG LOADER +// --------------------------------------------------------------------------- + +function cry01_load_config() { + // Loads cry01 config.json from the addon directory. Returns empty array on failure. + static $cfg = null; + if ($cfg !== null) return $cfg; + $raw = @file_get_contents('addon/cry01/config.json'); + if ($raw === false) return []; + $cfg = json_decode($raw, true); + return (json_last_error() === JSON_ERROR_NONE) ? $cfg : []; +} diff --git a/hubzilla/addon/cry01/cry01_renderer.php b/hubzilla/addon/cry01/cry01_renderer.php new file mode 100644 index 0000000..eeca381 --- /dev/null +++ b/hubzilla/addon/cry01/cry01_renderer.php @@ -0,0 +1,245 @@ +'; + $out .= '
'; + $out .= '

' . $name . '

'; + $out .= '

Value Layer — Ğ1 balance and capacity signals.

'; + $out .= '
'; + + $out .= cry01_render_balance_display(); + $out .= cry01_render_signal_board($association_slug, $access); + + if ($access === 'participant') { + $signal_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/signal'; + $out .= '
'; + $out .= 'Register a Signal'; + $out .= '
'; + } + + if ($access === 'operator') { + $manage_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/manage'; + $g1_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/g1'; + $out .= '
'; + $out .= 'Manage'; + $out .= 'Ğ1 Candidates'; + $out .= '
'; + } + + $out .= ''; + return $out; +} + +// --------------------------------------------------------------------------- +// BALANCE DISPLAY +// --------------------------------------------------------------------------- + +function cry01_render_balance_display() { + // Renders the operator Ğ1 balance from cache. Shows staleness if cache is old. + $cache = cry01_read_cache(); + $balance = $cache['operator_balance'] ?? null; + $pubkey = $cache['operator_g1_pubkey'] ?? ''; + $refreshed = $cache['refreshed_at'] ?? null; + + $out = '
'; + $out .= ''; + + if ($balance === null) { + $out .= '

Balance not yet loaded. Operator: use Manage to refresh.

'; + } else { + $out .= '

' . cry01_h($balance) . '

'; + if ($pubkey) { + $out .= '

' . cry01_h(substr($pubkey, 0, 16) . '...') . '

'; + } + if ($refreshed) { + $stale = cry01_cache_is_stale() ? ' stale' : ''; + $out .= '

Last refreshed: ' . cry01_h($refreshed) . $stale . '

'; + } + } + + $out .= '
'; + return $out; +} + +// --------------------------------------------------------------------------- +// SIGNAL BOARD +// --------------------------------------------------------------------------- + +function cry01_render_signal_board($association_slug, $access) { + // Renders the signal board. Visible to participants and operator only. + if ($access === 'public') { + return '
Signal board is visible to verified participants only.
'; + } + + // TODO: load signals from orchestrator spool for this association. + // Placeholder until orchestrator query is implemented. + $out = '
'; + $out .= ''; + $out .= '

No signals posted yet.

'; + $out .= '
'; + return $out; +} + +// --------------------------------------------------------------------------- +// SIGNAL FORM +// --------------------------------------------------------------------------- + +function cry01_render_signal_form($association_slug, $access) { + // Renders the capacity signal registration form. + $config = cry01_load_config(); + $categories = $config['signal_categories'] ?? []; + $form_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/signal'; + + $out = '
'; + $out .= '

Register a Capacity Signal

'; + $out .= '

Describe what you are offering or seeking, denominated in Ğ1.

'; + + $out .= '
'; + $out .= cry01_csrf_token(); + + // Ğ1 public key + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
Your Ğ1 address. This is your payment address for this signal.
'; + $out .= '
'; + + // Direction + $out .= '
'; + $out .= '
Direction *'; + $out .= '
+ + +
'; + $out .= '
+ + +
'; + $out .= '
'; + + // Category + if ($categories) { + $out .= '
'; + $out .= ''; + $out .= '
'; + } + + // Description + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + + // Denomination + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
The amount in Ğ1 you consider fair exchange.
'; + $out .= '
'; + + // Duration + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + + $out .= '
'; + $out .= ''; + $out .= '
'; + $out .= '
'; + $out .= '
'; + return $out; +} + +// --------------------------------------------------------------------------- +// Ğ1 CERTIFICATION CANDIDATE LIST +// --------------------------------------------------------------------------- + +function cry01_render_g1_candidates($association_slug) { + // Renders the operator-only list of SASE participants with registered Ğ1 keys. + // TODO: load candidate list from orchestrator spool. + $out = '
'; + $out .= '

Ğ1 Certification Candidates

'; + $out .= '

SASE participants who have registered a Ğ1 public key and are eligible for web of trust certification.

'; + $out .= '

No candidates registered yet.

'; + $out .= '
'; + return $out; +} + +// --------------------------------------------------------------------------- +// MANAGE +// --------------------------------------------------------------------------- + +function cry01_render_manage($association_slug) { + // Renders the operator manage page: cache refresh and config status. + $cache = cry01_read_cache(); + $refreshed = $cache['refreshed_at'] ?? null; + $config = cry01_load_config(); + $has_rpc = !empty($config['g1_rpc_endpoint']); + $has_key = !empty($config['operator_g1_pubkey']); + + $manage_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/manage'; + + $out = '
'; + $out .= '

Value Layer — Manage

'; + + // Config status + $out .= '
'; + $out .= '
Configuration
'; + $out .= '

' . ($has_rpc ? '✓' : '✗') . ' Duniter RPC endpoint: '; + $out .= $has_rpc ? 'configured' : 'missing'; + $out .= '

'; + $out .= '

' . ($has_key ? '✓' : '✗') . ' Operator Ğ1 public key: '; + $out .= $has_key ? 'configured' : 'missing'; + $out .= '

'; + $out .= '
'; + + // Cache status + $out .= '
'; + $out .= '
Balance Cache
'; + if ($refreshed) { + $stale = cry01_cache_is_stale() ? ' (stale)' : ' (current)'; + $out .= '

Last refreshed: ' . cry01_h($refreshed) . $stale . '

'; + } else { + $out .= '

Cache has not been populated yet.

'; + } + $out .= '
'; + + // Refresh form + $out .= '
'; + $out .= cry01_csrf_token(); + $out .= ''; + $out .= ''; + $out .= '
'; + + $out .= '
'; + return $out; +} diff --git a/hubzilla/addon/cry01/cry01_spool.php b/hubzilla/addon/cry01/cry01_spool.php new file mode 100644 index 0000000..92e5ba0 --- /dev/null +++ b/hubzilla/addon/cry01/cry01_spool.php @@ -0,0 +1,145 @@ + 365) { + $errors[] = 'Duration must be between 1 and 365 days.'; + } + + if ($errors) { + return '
    ' + . implode('', array_map(fn($e) => '
  • ' . cry01_h($e) . '
  • ', $errors)) + . '
' + . cry01_render_signal_form($association_slug, $access); + } + + $config = cry01_load_config(); + $token = $config['node_token'] ?? ''; + + // Build the spool envelope per contracts/signal-v1.json. + $envelope = [ + '_header' => [ + 'addon' => 'cry01', + 'association_slug' => $association_slug, + 'participant_xchan' => get_observer_hash(), + 'submitted_at' => date('c'), + 'node_token_hash' => hash('sha256', $token), + ], + '_payload' => [ + 'g1_pubkey' => $g1_pubkey, + 'capacity_description' => $capacity_description, + 'g1_denomination' => $g1_denomination, + 'duration_days' => $duration_days, + 'category_id' => $category_id, + 'direction' => $direction, + ], + ]; + + $result = cry01_post_to_orchestrator('/cry01/signal', $envelope); + + if ($result === true) { + return '
' + . '
Signal registered successfully. ' + . 'A Verifiable Credential will be issued and timestamped to Bitcoin.
' + . '

← Back to Value Layer

' + . '
'; + } + + return '
' + . '
Signal could not be registered. ' + . 'The orchestrator may be temporarily unavailable. Please try again.
' + . '
' + . cry01_render_signal_form($association_slug, $access); +} + +// --------------------------------------------------------------------------- +// MANAGE POST HANDLER +// --------------------------------------------------------------------------- + +function cry01_handle_manage_post($association_slug) { + // Handles operator manage actions: currently only cache refresh. + if (!cry01_verify_csrf()) { + return cry01_render_error('Invalid form token. Please reload and try again.'); + } + + $action = $_POST['action'] ?? ''; + + if ($action === 'refresh_cache') { + $ok = cry01_refresh_balance_cache(); + $msg = $ok + ? '
Balance cache refreshed successfully.
' + : '
Cache refresh failed. Check that the Duniter node is reachable and the operator Ğ1 key is configured.
'; + return '
' . $msg . '
' . cry01_render_manage($association_slug); + } + + return cry01_render_error('Unknown action.'); +} + +// --------------------------------------------------------------------------- +// ORCHESTRATOR TRANSPORT +// --------------------------------------------------------------------------- + +function cry01_post_to_orchestrator($path, $payload) { + // POSTs a JSON envelope to the orchestrator spool receiver. + // Returns true on success (HTTP 201), false on any failure. + $config = cry01_load_config(); + $base = rtrim($config['receiver_url'] ?? '', '/'); + if (!$base) { + logger('cry01_spool: receiver_url not configured'); + return false; + } + + $url = $base . $path; + $body = json_encode($payload); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n", + 'content' => $body, + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + + $raw = @file_get_contents($url, false, $context); + $headers = $http_response_header ?? []; + $status = 0; + + foreach ($headers as $h) { + if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $h, $m)) { + $status = intval($m[1]); + } + } + + if ($status === 201) return true; + + logger('cry01_spool: orchestrator returned status ' . $status . ' for ' . $path); + return false; +}