From d2d2a4d59666b916bacf6af7022636be9ca39b98 Mon Sep 17 00:00:00 2001 From: TheRON Date: Fri, 12 Jun 2026 15:33:51 -0400 Subject: [PATCH] Updated --- hubzilla/addon/cry01/cry01.php | 22 +- hubzilla/addon/cry01/cry01_attestation.php | 198 ++++++++++ hubzilla/addon/cry01/cry01_chain.php | 406 ++++----------------- hubzilla/addon/cry01/cry01_renderer.php | 56 ++- hubzilla/addon/cry01/cry01_substrate.php | 387 ++++++++++++++++++++ 5 files changed, 719 insertions(+), 350 deletions(-) create mode 100644 hubzilla/addon/cry01/cry01_attestation.php create mode 100644 hubzilla/addon/cry01/cry01_substrate.php 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 = '
'; $out .= ''; - $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 .= '
'; $out .= cry01_csrf_token(); @@ -125,12 +127,50 @@ function cry01_render_lookup_form($association_slug, $lookup = []) { $out .= '' . cry01_h(substr($entered_address, 0, 16) . '...') . '
'; $out .= '' . cry01_h($balance) . ''; $out .= '
'; + + if ($account_info) { + $out .= cry01_render_account_info_table($account_info); + } } $out .= ''; return $out; } +function cry01_render_account_info_table($info) { + // Renders the full decoded AccountInfo struct in plain language. + // $info is the array returned by cry01_decode_account_info() / + // cry01_get_account_info(): nonce, consumers, providers, sufficients, + // free, reserved, frozen, flags — all base-10 decimal strings of raw + // on-chain units (free/reserved/frozen/flags are in centimes; nonce/ + // consumers/providers/sufficients are plain counts). + // + // 'free' is already shown separately as the headline balance — this + // table covers the remaining fields. 'flags' is an internal bitfield + // (account feature flags) and is not meaningful to a layperson; it is + // omitted from display but available in $info for anyone who needs it. + $reserved = cry01_format_g1_amount($info['reserved'] ?? '0'); + $frozen = cry01_format_g1_amount($info['frozen'] ?? '0'); + $nonce = $info['nonce'] ?? '0'; + + $out = '
'; + $out .= ''; + $out .= ''; + $out .= ''; + + if ($info['reserved'] !== '0') { + $out .= ''; + } + if ($info['frozen'] !== '0') { + $out .= ''; + } + + $out .= ''; + $out .= ''; + $out .= '
'; + return $out; +} + // ---------------------------------------------------------------------------- // SIGNAL BOARD // ---------------------------------------------------------------------------- diff --git a/hubzilla/addon/cry01/cry01_substrate.php b/hubzilla/addon/cry01/cry01_substrate.php new file mode 100644 index 0000000..1f2faad --- /dev/null +++ b/hubzilla/addon/cry01/cry01_substrate.php @@ -0,0 +1,387 @@ + 0])); + $h1 = strrev(hash('xxh64', $data, true, ['seed' => 1])); + return $h0 . $h1; +} + +function cry01_blake2_128_concat($key) { + // Substrate's "Blake2_128Concat" storage map key hasher. + // + // Blake2_128Concat(key) = Blake2b_128(key) . key + // + // i.e. the Blake2b-128 hash of the key, followed by the raw key bytes + // appended (not replaced). + // + // Blake2b-128 is RFC 7693 PARAMETERIZED output — the output length is + // part of the hash's parameter block, not a truncation of Blake2b-512. + // PHP's hash() does not support 'blake2b'/'blake2b512' on this build + // (hash_algos() does not list it), so we use the vendored pure-PHP + // deemru/Blake2b implementation (addon/cry01/vendor/Blake2b.php, MIT). + // + // Verified against RFC 7693 test vectors: + // Blake2b-128("") => cae66941d9efbd404e4d88758ea67670 + // Blake2b-128("abc") => cf4ab791c62b8d2b2109c90275287816 + $b2b128 = new Blake2b(16); + return $b2b128->hash($key) . $key; +} + +// ---------------------------------------------------------------------------- +// STORAGE KEY DERIVATION +// ---------------------------------------------------------------------------- + +function cry01_storage_key($pallet, $item, $map_key = null) { + // Builds a Substrate storage key for Pallet.Item, optionally with a + // Blake2_128Concat map key appended. + // + // For a plain StorageValue (no map), call with $map_key = null: + // cry01_storage_key('Timestamp', 'Now') + // => Twox128("Timestamp") . Twox128("Now") + // + // For a StorageMap using Blake2_128Concat (the hasher used by + // System.Account and most account-keyed maps), pass the raw map key + // bytes: + // cry01_storage_key('System', 'Account', $account_id_32_bytes) + // => Twox128("System") . Twox128("Account") . Blake2_128Concat($account_id_32_bytes) + // + // NOTE: this function currently assumes Blake2_128Concat for any map + // key. Some storage maps use other hashers (Twox64Concat, Identity, + // etc.) — if a future query targets such a map, this function will need + // a $hasher parameter. Not needed for anything implemented so far. + // + // Returns the storage key as a '0x'-prefixed hex string. + $prefix = cry01_twox128($pallet) . cry01_twox128($item); + + if ($map_key !== null) { + $prefix .= cry01_blake2_128_concat($map_key); + } + + return '0x' . bin2hex($prefix); +} + +// ---------------------------------------------------------------------------- +// SCALE DECODING — AccountInfo +// ---------------------------------------------------------------------------- + +function cry01_decode_account_info($bytes) { + // Decodes a SCALE-encoded AccountInfo struct (the value stored at + // System.Account(account_id)): + // + // nonce: u32 (4 bytes) + // consumers: u32 (4 bytes) + // providers: u32 (4 bytes) + // sufficients: u32 (4 bytes) + // data.free: u128 (16 bytes) + // data.reserved: u128 (16 bytes) + // data.frozen: u128 (16 bytes) + // data.flags: u128 (16 bytes) + // + // All fields little-endian, concatenated with no padding/separators. + // u128 fields are decoded as base-10 decimal strings (cry01's big-int + // helpers — no bcmath/gmp dependency). + // + // Returns an associative array with all eight fields as decimal strings + // (u32 fields fit in PHP int range but are returned as strings for + // consistency), or null if $bytes is too short to contain at least the + // four u32 header fields and the 'free' u128. + // + // Trailing fields (reserved, frozen, flags) may be absent if the RPC + // response is shorter than the full 80-byte struct — callers should not + // assume all eight keys are always present with non-zero-length input; + // this function fills missing trailing fields with '0'. + if ($bytes === null || strlen($bytes) < 32) { + return null; + } + + $fields = [ + 'nonce' => cry01_le_bytes_to_decimal_string(substr($bytes, 0, 4)), + 'consumers' => cry01_le_bytes_to_decimal_string(substr($bytes, 4, 4)), + 'providers' => cry01_le_bytes_to_decimal_string(substr($bytes, 8, 4)), + 'sufficients' => cry01_le_bytes_to_decimal_string(substr($bytes, 12, 4)), + 'free' => cry01_le_bytes_to_decimal_string(substr($bytes, 16, 16)), + 'reserved' => '0', + 'frozen' => '0', + 'flags' => '0', + ]; + + if (strlen($bytes) >= 48) { + $fields['reserved'] = cry01_le_bytes_to_decimal_string(substr($bytes, 32, 16)); + } + if (strlen($bytes) >= 64) { + $fields['frozen'] = cry01_le_bytes_to_decimal_string(substr($bytes, 48, 16)); + } + if (strlen($bytes) >= 80) { + $fields['flags'] = cry01_le_bytes_to_decimal_string(substr($bytes, 64, 16)); + } + + return $fields; +} + +// ---------------------------------------------------------------------------- +// BIG-INTEGER HELPERS (no bcmath/gmp dependency) +// ---------------------------------------------------------------------------- + +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. Suitable for values up to u128 + // (16 bytes) and beyond. + $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]); + } + } + + 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'; +} + +// ---------------------------------------------------------------------------- +// 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 + // + // The leading "g1" in Ğ1 addresses is NOT a literal prefix string — it + // is simply the first two characters of the base58 encoding. The actual + // network identifier (0x5891) is encoded in the decoded bytes. + // + // Checksum = first 2 bytes of Blake2b-512("SS58PRE" + prefix + account_id) + // + // Some other Substrate/Duniter-v1-era addresses decode to 32 bytes with + // no checksum at all (a bare public key, no SS58 wrapper) — these are + // correctly rejected here as "unexpected decoded length", since they are + // not valid Ğ1 v2 SS58 addresses. + // + // 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_substrate: 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_substrate: 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 a Substrate 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_substrate: RPC call failed to ' . $endpoint); + return null; + } + $data = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { + logger('cry01_substrate: RPC response is not valid JSON'); + return null; + } + return $data; +} + +function cry01_rpc_state_get_storage($endpoint, $storage_key) { + // Convenience wrapper: calls state_getStorage and returns the raw hex + // result string (with '0x' prefix), or null on RPC failure, or '0x' if + // the storage key has no value (empty storage — a valid "not found" + // response, not an error). + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'state_getStorage', + 'params' => [$storage_key], + ]); + + $result = cry01_rpc_post($endpoint, $payload); + if ($result === null) return null; + + return $result['result'] ?? '0x'; +}