388 lines
14 KiB
PHP
388 lines
14 KiB
PHP
<?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';
|
|
}
|