diff --git a/hubzilla/addon/cry01/cry01.php b/hubzilla/addon/cry01/cry01.php index 019f5b6..1ddf265 100644 --- a/hubzilla/addon/cry01/cry01.php +++ b/hubzilla/addon/cry01/cry01.php @@ -37,18 +37,18 @@ function cry01_load_pdl(&$b) { } } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // HELPERS -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_h($value) { // HTML-escapes a value for safe output. return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // ACCESS -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_access_state($association_slug = '') { // Returns operator, participant, or public. Does not call local_channel() for group checks. @@ -109,9 +109,9 @@ function cry01_access_wall($association_slug = '') { '; } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // CONTENT ROUTER -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_content() { if (function_exists('head_add_css')) { @@ -122,7 +122,7 @@ function cry01_content() { } $association_slug = argv(1) ?? ''; - $sub_route = strtolower(argv(2) ?? ''); + $sub_route = strtolower(argv(2) ?? ''); if (!$association_slug) { return cry01_render_index(); @@ -160,14 +160,63 @@ function cry01_content() { } return cry01_render_manage($association_slug); + case 'lookup': + // Public Ğ1 balance lookup. No SASE gate, no wallet session, no + // storage of any kind. Address in, balance out. + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!cry01_verify_csrf()) { + return cry01_render_error('Invalid form token. Please reload and try again.'); + } + return cry01_handle_lookup_post($association_slug, $access); + } + return cry01_render_landing($association_slug, $access); + default: return cry01_render_landing($association_slug, $access); } } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- +// LOOKUP HANDLER +// ---------------------------------------------------------------------------- + +function cry01_handle_lookup_post($association_slug, $access) { + // Public balance lookup: decode the pasted Ğ1 address, query the chain, + // re-render the landing page with the result (or an error) inline. + // No data is stored anywhere — this is a pure read. + $address = trim($_POST['g1_lookup_address'] ?? ''); + + if (!$address) { + return cry01_render_landing($association_slug, $access, [ + 'lookup_error' => 'Please enter a Ğ1 address.', + ]); + } + + $account_id = cry01_ss58_decode($address); + if ($account_id === null) { + return cry01_render_landing($association_slug, $access, [ + 'lookup_error' => 'That doesn\'t look like a valid Ğ1 address. Check for typos and try again.', + 'lookup_address' => $address, + ]); + } + + $balance = cry01_get_balance($account_id); + if ($balance === null) { + return cry01_render_landing($association_slug, $access, [ + 'lookup_error' => 'Could not reach the Ğ1 network right now. Please try again shortly.', + 'lookup_address' => $address, + ]); + } + + return cry01_render_landing($association_slug, $access, [ + 'lookup_address' => $address, + 'lookup_balance' => $balance, + ]); +} + +// ---------------------------------------------------------------------------- // RENDER — INDEX -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_render_index() { // Lists all registered associations with links to their value layer pages. @@ -191,9 +240,9 @@ function cry01_render_index() { return $out; } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // RENDER — NOT FOUND / ERROR -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_render_not_found() { return '
Association not found.
'; @@ -204,9 +253,9 @@ function cry01_render_error($message) { return '
' . cry01_h($message) . '
'; } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // CSRF -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_csrf_token() { // Generates and stores a CSRF token for the current session. diff --git a/hubzilla/addon/cry01/cry01_chain.php b/hubzilla/addon/cry01/cry01_chain.php index a3c2f75..a701c90 100644 --- a/hubzilla/addon/cry01/cry01_chain.php +++ b/hubzilla/addon/cry01/cry01_chain.php @@ -8,12 +8,18 @@ * If the node infrastructure changes, only this file changes. */ -// --------------------------------------------------------------------------- +require_once 'addon/cry01/vendor/Blake2b.php'; + +use deemru\Blake2b; + +// ---------------------------------------------------------------------------- // BALANCE READ -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_get_balance($g1_pubkey) { // Returns the current Ğ1 balance for the given public key. + // $g1_pubkey is a 32-byte raw account ID (NOT an SS58 address string — + // callers must decode the address first via cry01_ss58_decode()). // Queries the local Duniter node RPC. Returns null on failure. $config = cry01_load_config(); $rpc = $config['g1_rpc_endpoint'] ?? ''; @@ -32,34 +38,302 @@ function cry01_get_balance($g1_pubkey) { 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_balance_storage_key($account_id_bytes) { + // Builds the Substrate storage key for System.Account(account_id_bytes). + // + // Storage key = xxh128("System") . xxh128("Account") . Blake2_128Concat(account_id_bytes) + // = xxh128("System") . xxh128("Account") . blake2b_128(account_id_bytes) . account_id_bytes + // + // xxh128 is PHP's native hash('xxh128', ..., true) — confirmed correct + // against the published test vector (16c27099bd855aff3b3efe27980515ad + // for "php.watch"). + // + // blake2b_128 is the vendored deemru/Blake2b pure-PHP implementation, + // confirmed correct against RFC 7693 parameterized test vectors: + // blake2b-128("") => cae66941d9efbd404e4d88758ea67670 + // blake2b-128("abc") => cf4ab791c62b8d2b2109c90275287816 + // + // $account_id_bytes must be exactly 32 raw bytes. + if (strlen($account_id_bytes) !== 32) { + logger('cry01_chain: cry01_balance_storage_key called with account_id of length ' . strlen($account_id_bytes) . ', expected 32'); + return '0x'; + } + + $prefix_system = hash('xxh128', 'System', true); + $prefix_account = hash('xxh128', 'Account', true); + + $b2b128 = new Blake2b(16); + $key_hash = $b2b128->hash($account_id_bytes); + + $storage_key = $prefix_system . $prefix_account . $key_hash . $account_id_bytes; + + return '0x' . bin2hex($storage_key); } 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. + // + // Duniter v2 System.Account storage value is an AccountInfo struct: + // nonce: u32, consumers: u32, providers: u32, sufficients: u32, + // data: AccountData { free: u128, reserved: u128, frozen: u128, flags: u128 } + // + // All fields are SCALE little-endian fixed-width integers, concatenated + // with no separators: + // nonce 4 bytes + // consumers 4 bytes + // providers 4 bytes + // sufficients 4 bytes + // free 16 bytes <- this is the balance we want + // reserved 16 bytes + // frozen 16 bytes + // flags 16 bytes + // + // 'free' starts at byte offset 16 (4+4+4+4) and is 16 bytes (u128), + // little-endian. + // + // Returns formatted balance string or null if decoding fails or the + // account does not exist (empty storage = no AccountInfo = zero balance). $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); + if (!$hex || $hex === '0x') { + // Empty storage means the account has never received funds — + // a valid result, not an error. Balance is zero. + return '0'; } - // Duniter uses centimes (hundredths of Ğ1). Divide by 100. - return number_format($val / 100, 2) . ' Ğ1'; + + $hex = ltrim($hex, '0x'); + if (substr($rpc_result['result'], 0, 2) === '0x') { + $hex = substr($rpc_result['result'], 2); + } + + $bytes = hex2bin($hex); + if ($bytes === false || strlen($bytes) < 32) { + logger('cry01_chain: cry01_decode_balance got unexpected payload length ' . strlen((string) $bytes)); + return null; + } + + // 'free' balance: bytes 16..31 (16 bytes, u128, little-endian). + $free_bytes = substr($bytes, 16, 16); + + // PHP integers are 64-bit; u128 can exceed that. Decode as little-endian + // and accumulate using string-based big-integer arithmetic (base 10 + // string accumulation) to avoid overflow for very large balances. + $value = cry01_le_bytes_to_decimal_string($free_bytes); + + return cry01_format_g1_amount($value); } -// --------------------------------------------------------------------------- +function cry01_le_bytes_to_decimal_string($bytes) { + // Converts a little-endian byte string to a base-10 decimal string, + // using only integer string arithmetic (no bcmath/gmp dependency). + // Suitable for u128 values (up to 16 bytes). + $result = '0'; + $multiplier = '1'; + + $len = strlen($bytes); + for ($i = 0; $i < $len; $i++) { + $byteVal = ord($bytes[$i]); + if ($byteVal !== 0) { + $term = cry01_decimal_string_multiply($multiplier, (string) $byteVal); + $result = cry01_decimal_string_add($result, $term); + } + if ($i < $len - 1) { + $multiplier = cry01_decimal_string_multiply($multiplier, '256'); + } + } + + return $result; +} + +function cry01_decimal_string_add($a, $b) { + // Adds two non-negative base-10 decimal strings, returns the sum as a string. + $a = strrev($a); + $b = strrev($b); + $len = max(strlen($a), strlen($b)); + $a = str_pad($a, $len, '0'); + $b = str_pad($b, $len, '0'); + + $result = ''; + $carry = 0; + for ($i = 0; $i < $len; $i++) { + $sum = intval($a[$i]) + intval($b[$i]) + $carry; + $carry = intdiv($sum, 10); + $result .= ($sum % 10); + } + if ($carry) { + $result .= $carry; + } + + return ltrim(strrev($result), '0') ?: '0'; +} + +function cry01_decimal_string_multiply($a, $b) { + // Multiplies two non-negative base-10 decimal strings, returns the product as a string. + if ($a === '0' || $b === '0') return '0'; + + $a = strrev($a); + $b = strrev($b); + $result = array_fill(0, strlen($a) + strlen($b), 0); + + for ($i = 0; $i < strlen($a); $i++) { + for ($j = 0; $j < strlen($b); $j++) { + $result[$i + $j] += intval($a[$i]) * intval($b[$j]); + } + } + + // Carry propagation. + for ($k = 0; $k < count($result) - 1; $k++) { + $result[$k + 1] += intdiv($result[$k], 10); + $result[$k] = $result[$k] % 10; + } + + $str = implode('', array_reverse($result)); + return ltrim($str, '0') ?: '0'; +} + +function cry01_format_g1_amount($decimal_string) { + // Formats a raw on-chain u128 amount (in the smallest unit) as a + // human-readable Ğ1 balance string. + // + // Duniter v2 uses centimes (hundredths of Ğ1) as the smallest unit, + // same as Duniter v1. Divide by 100 and format with 2 decimal places. + // + // $decimal_string is a base-10 string (may be arbitrarily long for u128). + $len = strlen($decimal_string); + + if ($len <= 2) { + // Less than 1 Ğ1 — pad to at least 3 digits so we can split cents. + $decimal_string = str_pad($decimal_string, 3, '0', STR_PAD_LEFT); + $len = strlen($decimal_string); + } + + $whole = substr($decimal_string, 0, $len - 2); + $cents = substr($decimal_string, $len - 2); + + $whole = ltrim($whole, '0') ?: '0'; + + // Add thousands separators to the whole part. + $whole_formatted = ''; + $rev = strrev($whole); + for ($i = 0; $i < strlen($rev); $i++) { + if ($i > 0 && $i % 3 === 0) { + $whole_formatted .= ','; + } + $whole_formatted .= $rev[$i]; + } + $whole_formatted = strrev($whole_formatted); + + return $whole_formatted . '.' . $cents . ' Ğ1'; +} + +// ---------------------------------------------------------------------------- +// SS58 / BASE58 ADDRESS DECODING +// ---------------------------------------------------------------------------- + +function cry01_base58_decode($input) { + // Generic Base58 decoder (Bitcoin/IPFS alphabet, same as SS58). + // Pure PHP, no bcmath/gmp dependency. Returns raw decoded bytes, + // including any version/checksum bytes still attached. + // Throws InvalidArgumentException on invalid characters. + static $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + + $input = trim($input); + if ($input === '') return ''; + + $map = []; + for ($i = 0; $i < strlen($alphabet); $i++) { + $map[$alphabet[$i]] = $i; + } + + $leadingZeros = 0; + for ($i = 0; $i < strlen($input); $i++) { + if ($input[$i] === '1') { + $leadingZeros++; + } else { + break; + } + } + + $bytes = [0]; + + for ($i = 0; $i < strlen($input); $i++) { + $c = $input[$i]; + if (!isset($map[$c])) { + throw new \InvalidArgumentException("Invalid base58 character: '$c'"); + } + $value = $map[$c]; + + $carry = $value; + for ($j = count($bytes) - 1; $j >= 0; $j--) { + $carry += $bytes[$j] * 58; + $bytes[$j] = $carry & 0xff; + $carry >>= 8; + } + while ($carry > 0) { + array_unshift($bytes, $carry & 0xff); + $carry >>= 8; + } + } + + $result = ''; + $started = false; + foreach ($bytes as $b) { + if (!$started && $b === 0) { + continue; + } + $started = true; + $result .= chr($b); + } + + return str_repeat("\x00", $leadingZeros) . $result; +} + +function cry01_ss58_decode($address) { + // Decodes an SS58-encoded address (Ğ1/Substrate) to its raw 32-byte + // account ID, verifying the checksum. + // + // Confirmed format for Ğ1 addresses (e.g. "g1LvTpYXkKEASMiBYLp8RQmSN5kZyXtoHX8XE2FqQ9hDjqp5B"): + // 36 bytes total = 2-byte network prefix (0x5891) + 32-byte account ID + 2-byte checksum + // + // Checksum = first 2 bytes of Blake2b-512("SS58PRE" + prefix + account_id) + // + // Returns the raw 32-byte account ID on success, or null on any + // validation failure (invalid base58, wrong length, bad checksum). + try { + $decoded = cry01_base58_decode($address); + } catch (\InvalidArgumentException $e) { + return null; + } + + $len = strlen($decoded); + + if ($len === 36) { + $prefix = substr($decoded, 0, 2); + $account = substr($decoded, 2, 32); + $checksum = substr($decoded, 34, 2); + } elseif ($len === 35) { + $prefix = substr($decoded, 0, 1); + $account = substr($decoded, 1, 32); + $checksum = substr($decoded, 33, 2); + } else { + logger('cry01_chain: cry01_ss58_decode got unexpected decoded length ' . $len); + return null; + } + + $b2b512 = new Blake2b(64); + $hash = $b2b512->hash('SS58PRE' . $prefix . $account); + $expected_checksum = substr($hash, 0, 2); + + if ($expected_checksum !== $checksum) { + logger('cry01_chain: cry01_ss58_decode checksum mismatch for address'); + return null; + } + + return $account; +} + +// ---------------------------------------------------------------------------- // RPC TRANSPORT -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_rpc_post($endpoint, $payload) { // POSTs a JSON-RPC request to the Duniter node. Returns decoded response or null. @@ -84,9 +358,9 @@ function cry01_rpc_post($endpoint, $payload) { return $data; } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // CACHE -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_read_cache() { // Returns the balance cache array. Returns empty array if cache does not exist or is unreadable. @@ -140,9 +414,9 @@ function cry01_refresh_balance_cache($g1_pubkey) { return cry01_write_cache($cache); } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // CONFIG LOADER -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- function cry01_load_config() { // Loads cry01 config.json from the addon directory. Returns empty array on failure. diff --git a/hubzilla/addon/cry01/cry01_renderer.php b/hubzilla/addon/cry01/cry01_renderer.php index 2e18ac4..0516e80 100644 --- a/hubzilla/addon/cry01/cry01_renderer.php +++ b/hubzilla/addon/cry01/cry01_renderer.php @@ -10,8 +10,10 @@ // ASSOCIATION LANDING // ---------------------------------------------------------------------------- -function cry01_render_landing($association_slug, $access) { - // Renders the main cry01 page: balance display + signal board. +function cry01_render_landing($association_slug, $access, $lookup = []) { + // Renders the main cry01 page: balance display + signal board + public lookup. + // $lookup may contain: lookup_error, lookup_address, lookup_balance — + // populated when this page is rendered as the result of a lookup POST. $raw = @file_get_contents('addon/vs01/config.json'); $cfg = $raw ? json_decode($raw, true) : []; $assoc = $cfg['associations'][$association_slug] ?? []; @@ -32,6 +34,7 @@ function cry01_render_landing($association_slug, $access) { $out .= '

'; $out .= cry01_render_balance_display(); + $out .= cry01_render_lookup_form($association_slug, $lookup); $out .= cry01_render_signal_board($association_slug, $access); if ($access === 'participant') { @@ -55,7 +58,7 @@ function cry01_render_landing($association_slug, $access) { } // ---------------------------------------------------------------------------- -// BALANCE DISPLAY +// BALANCE DISPLAY (operator/cached) // ---------------------------------------------------------------------------- function cry01_render_balance_display() { @@ -86,6 +89,48 @@ function cry01_render_balance_display() { return $out; } +// ---------------------------------------------------------------------------- +// PUBLIC BALANCE LOOKUP +// ---------------------------------------------------------------------------- + +function cry01_render_lookup_form($association_slug, $lookup = []) { + // Renders the public Ğ1 address balance lookup form. + // Anyone can use this — no SASE, no wallet session, nothing stored. + // Paste an address, see its current balance, read directly from the + // Civic Infrastructure's own Duniter mirror node. + $lookup_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/lookup'; + + $error = $lookup['lookup_error'] ?? ''; + $entered_address = $lookup['lookup_address'] ?? ''; + $balance = $lookup['lookup_balance'] ?? null; + + $out = '
'; + $out .= '
Ğ1 Address Lookup
'; + $out .= '

Paste any Ğ1 wallet address to check its current balance — read directly from this node\'s Duniter mirror. Nothing is stored.

'; + + $out .= '
'; + $out .= cry01_csrf_token(); + $out .= '
'; + $out .= ''; + $out .= ''; + $out .= '
'; + $out .= '
'; + + if ($error) { + $out .= '
' . cry01_h($error) . '
'; + } elseif ($balance !== null) { + $out .= '
'; + $out .= '' . cry01_h(substr($entered_address, 0, 16) . '...') . '
'; + $out .= '' . cry01_h($balance) . ''; + $out .= '
'; + } + + $out .= '
'; + return $out; +} + // ---------------------------------------------------------------------------- // SIGNAL BOARD // ----------------------------------------------------------------------------