Updated
This commit is contained in:
387
hubzilla/addon/cry01/cry01_substrate.php
Normal file
387
hubzilla/addon/cry01/cry01_substrate.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* cry01_substrate.php — Generic Substrate protocol primitives.
|
||||
*
|
||||
* This file contains facts about how Substrate-based chains work — storage
|
||||
* key derivation, SCALE decoding, SS58 addresses, JSON-RPC transport. None of
|
||||
* it is specific to Ğ1 or to this project. Every function here would be
|
||||
* correct and reusable for Polkadot, Kusama, or any other Substrate chain.
|
||||
*
|
||||
* Ğ1-specific and cry01-specific logic (balance formatting, caching,
|
||||
* attestation) lives in cry01_chain.php and cry01_attestation.php, which
|
||||
* call into this file.
|
||||
*
|
||||
* Everything in this file was verified against the live Ğ1 mainnet on
|
||||
* 2026-06-12. See docs/DUNITER-RPC-FINDINGS.md for the full account of how
|
||||
* each piece was confirmed correct, including the Twox128-vs-xxh128 pitfall.
|
||||
*/
|
||||
|
||||
require_once 'addon/cry01/vendor/Blake2b.php';
|
||||
|
||||
use deemru\Blake2b;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// HASHING PRIMITIVES
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_twox128($data) {
|
||||
// Substrate's "Twox128" storage prefix hash.
|
||||
//
|
||||
// This is NOT the same algorithm as PHP's hash('xxh128', ...) — that is
|
||||
// a single-pass 128-bit xxHash variant and produces a different result
|
||||
// despite the similar name and identical output size.
|
||||
//
|
||||
// Twox128(data) = reverse(xxh64(data, seed=0)) . reverse(xxh64(data, seed=1))
|
||||
//
|
||||
// Verified: Twox128("System") = 26aa394eea5630e07c48ae0c9558cef7 and
|
||||
// Twox128("Account") = b99d880ec681799c0cf30e8886371da9 — these match the
|
||||
// canonical System::Account storage prefix published in Substrate/
|
||||
// Polkadot documentation.
|
||||
$h0 = strrev(hash('xxh64', $data, true, ['seed' => 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';
|
||||
}
|
||||
Reference in New Issue
Block a user