'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)); 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. $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($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; // 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. $cache = cry01_read_cache(); $cache['balance'] = $balance; $cache['g1_pubkey'] = bin2hex($g1_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 : []; }