diff --git a/hubzilla/addon/cry01/cry01.php b/hubzilla/addon/cry01/cry01.php index 1ddf265..740284f 100644 --- a/hubzilla/addon/cry01/cry01.php +++ b/hubzilla/addon/cry01/cry01.php @@ -10,7 +10,9 @@ use Zotlabs\Extend\Widget; +require_once 'addon/cry01/cry01_substrate.php'; require_once 'addon/cry01/cry01_chain.php'; +require_once 'addon/cry01/cry01_attestation.php'; require_once 'addon/cry01/cry01_renderer.php'; require_once 'addon/cry01/cry01_spool.php'; @@ -161,8 +163,8 @@ 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. + // Public Ğ1 account lookup. No SASE gate, no wallet session, no + // storage of any kind. Address in, full AccountInfo out. if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!cry01_verify_csrf()) { return cry01_render_error('Invalid form token. Please reload and try again.'); @@ -181,9 +183,10 @@ function cry01_content() { // ---------------------------------------------------------------------------- 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. + // Public account lookup: decode the pasted Ğ1 address, query the chain + // for the full AccountInfo struct, 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) { @@ -200,8 +203,8 @@ function cry01_handle_lookup_post($association_slug, $access) { ]); } - $balance = cry01_get_balance($account_id); - if ($balance === null) { + $info = cry01_get_account_info($account_id); + if ($info === 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, @@ -209,8 +212,9 @@ function cry01_handle_lookup_post($association_slug, $access) { } return cry01_render_landing($association_slug, $access, [ - 'lookup_address' => $address, - 'lookup_balance' => $balance, + 'lookup_address' => $address, + 'lookup_balance' => cry01_format_g1_amount($info['free']), + 'lookup_account_info' => $info, ]); } diff --git a/hubzilla/addon/cry01/cry01_attestation.php b/hubzilla/addon/cry01/cry01_attestation.php new file mode 100644 index 0000000..fb77250 --- /dev/null +++ b/hubzilla/addon/cry01/cry01_attestation.php @@ -0,0 +1,198 @@ + '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_twox128($data) { - // Substrate's "Twox128" storage prefix hash. - // - // This is NOT the same as PHP's hash('xxh128', ...) — that is a single - // 128-bit xxHash variant and produces a completely different result. - // - // Substrate's Twox128 is constructed as: - // reverse(xxh64(data, seed=0)) . reverse(xxh64(data, seed=1)) - // - // Confirmed correct against the live Ğ1 chain: Twox128("Timestamp") . - // Twox128("Now") used as a storage key for Timestamp.Now (a plain - // StorageValue with no map/hasher) returned a non-null SCALE-encoded - // u64, confirming both the algorithm and byte order. - $h0 = strrev(hash('xxh64', $data, true, ['seed' => 0])); - $h1 = strrev(hash('xxh64', $data, true, ['seed' => 1])); - return $h0 . $h1; -} - -function cry01_balance_storage_key($account_id_bytes) { - // Builds the Substrate storage key for System.Account(account_id_bytes). - // - // Storage key = Twox128("System") . Twox128("Account") . Blake2_128Concat(account_id_bytes) - // = Twox128("System") . Twox128("Account") . blake2b_128(account_id_bytes) . account_id_bytes - // - // Twox128 is cry01_twox128() above — confirmed correct against the live - // Ğ1 chain via Timestamp.Now. - // - // 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 = cry01_twox128('System'); - $prefix_account = cry01_twox128('Account'); - - $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 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') { - // Empty storage means the account has never received funds — - // a valid result, not an error. Balance is zero. - return '0'; - } - - if (substr($hex, 0, 2) === '0x') { - $hex = substr($hex, 2); - } - - $bytes = hex2bin($hex); - if ($bytes === false || strlen($bytes) < 32) { - logger('cry01_chain: cry01_decode_balance got unexpected payload length ' . strlen((string) $bytes)); + logger('cry01_chain: cry01_get_account_info called with account_id of length ' . strlen($account_id_bytes) . ', expected 32'); return null; } - // 'free' balance: bytes 16..31 (16 bytes, u128, little-endian). - $free_bytes = substr($bytes, 16, 16); + $storage_key = cry01_storage_key('System', 'Account', $account_id_bytes); + $hex = cry01_rpc_state_get_storage($rpc, $storage_key); + if ($hex === null) return null; - // 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); + if ($hex === '0x') { + // Account has never received funds. All fields zero. + return [ + 'nonce' => '0', 'consumers' => '0', 'providers' => '0', 'sufficients' => '0', + 'free' => '0', 'reserved' => '0', 'frozen' => '0', 'flags' => '0', + ]; + } - return cry01_format_g1_amount($value); + $bytes = hex2bin(substr($hex, 0, 2) === '0x' ? substr($hex, 2) : $hex); + if ($bytes === false) { + logger('cry01_chain: cry01_get_account_info got non-hex storage value'); + return null; + } + + return cry01_decode_account_info($bytes); } -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'; +function cry01_get_balance($account_id_bytes) { + // Returns the current Ğ1 balance for the given 32-byte raw account ID, + // formatted as a human-readable string (e.g. "1.00 Ğ1"). Thin wrapper + // around cry01_get_account_info() — extracts and formats 'free'. + // Returns null on failure. + $info = cry01_get_account_info($account_id_bytes); + if ($info === null) return null; - $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; + return cry01_format_g1_amount($info['free']); } -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'; -} +// ---------------------------------------------------------------------------- +// Ğ1 AMOUNT FORMATTING +// ---------------------------------------------------------------------------- function cry01_format_g1_amount($decimal_string) { // Formats a raw on-chain u128 amount (in the smallest unit) as a @@ -217,7 +83,6 @@ function cry01_format_g1_amount($decimal_string) { $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); } @@ -227,7 +92,6 @@ function cry01_format_g1_amount($decimal_string) { $whole = ltrim($whole, '0') ?: '0'; - // Add thousands separators to the whole part. $whole_formatted = ''; $rev = strrev($whole); for ($i = 0; $i < strlen($rev); $i++) { @@ -241,139 +105,6 @@ function cry01_format_g1_amount($decimal_string) { 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. - $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 // ---------------------------------------------------------------------------- @@ -412,25 +143,34 @@ function cry01_cache_is_stale() { return (time() - filemtime($path)) > $max_age; } -function cry01_refresh_balance_cache($g1_pubkey) { - // Refreshes the balance cache for the given public key. - // $g1_pubkey is a 32-byte raw account ID (as returned by cry01_ss58_decode()). - // The public key comes from the wallet session via the manage POST form — never from config. - // Returns true on success, false on failure. - if (!$g1_pubkey) { - logger('cry01_chain: cry01_refresh_balance_cache called with empty pubkey'); - return false; - } - $balance = cry01_get_balance($g1_pubkey); - if ($balance === null) return false; - +function cry01_refresh_balance_cache($g1_address) { + // Refreshes the balance cache for the given Ğ1 address (SS58 string, + // e.g. "g1LvTpY..." — the format provided by g1wallet's + // data-g1wallet-target="pubkey" fields and entered by the operator). + // Decodes the address internally, consistent with + // cry01_handle_lookup_post(). Returns true on success, false on failure. + // // Cache stores the account ID as hex, not raw binary — raw binary bytes // are not valid UTF-8 and cause json_encode() to fail silently (returns // false), which previously resulted in an empty (0-byte) cache file - // despite cry01_write_cache() reporting success. + // despite this function reporting success. + if (!$g1_address) { + logger('cry01_chain: cry01_refresh_balance_cache called with empty address'); + return false; + } + + $account_id = cry01_ss58_decode($g1_address); + if ($account_id === null) { + logger('cry01_chain: cry01_refresh_balance_cache could not decode address'); + return false; + } + + $balance = cry01_get_balance($account_id); + if ($balance === null) return false; + $cache = cry01_read_cache(); $cache['balance'] = $balance; - $cache['g1_pubkey'] = bin2hex($g1_pubkey); + $cache['g1_pubkey'] = bin2hex($account_id); $cache['refreshed_at'] = date('c'); return cry01_write_cache($cache); } diff --git a/hubzilla/addon/cry01/cry01_renderer.php b/hubzilla/addon/cry01/cry01_renderer.php index 0516e80..96f1e6b 100644 --- a/hubzilla/addon/cry01/cry01_renderer.php +++ b/hubzilla/addon/cry01/cry01_renderer.php @@ -12,8 +12,9 @@ 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. + // $lookup may contain: lookup_error, lookup_address, lookup_balance, + // lookup_account_info — 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] ?? []; @@ -94,19 +95,20 @@ function cry01_render_balance_display() { // ---------------------------------------------------------------------------- function cry01_render_lookup_form($association_slug, $lookup = []) { - // Renders the public Ğ1 address balance lookup form. + // Renders the public Ğ1 address 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. + // Paste an address, see its current balance and account details, read + // directly from this node's own Duniter mirror. $lookup_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/lookup'; - $error = $lookup['lookup_error'] ?? ''; + $error = $lookup['lookup_error'] ?? ''; $entered_address = $lookup['lookup_address'] ?? ''; - $balance = $lookup['lookup_balance'] ?? null; + $balance = $lookup['lookup_balance'] ?? null; + $account_info = $lookup['lookup_account_info'] ?? null; $out = '
Paste any Ğ1 wallet address to check its current balance — read directly from this node\'s Duniter mirror. Nothing is stored.
'; + $out .= 'Paste any Ğ1 wallet address to check its current balance and account details — read directly from this node\'s Duniter mirror. Nothing is stored.
'; $out .= '| Transactions sent | ' . cry01_h($nonce) . ' |
| Reserved | ' . cry01_h($reserved) . ' |
| Frozen | ' . cry01_h($frozen) . ' |