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'; }