Updated
This commit is contained in:
@@ -1,210 +1,76 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* cry01_chain.php — On-chain read layer.
|
||||
* Reads Ğ1 balances from the local Duniter node RPC over Wireguard.
|
||||
* Reads and writes the local balance cache.
|
||||
* This is the only file in the project that makes outbound network calls.
|
||||
* If the node infrastructure changes, only this file changes.
|
||||
* cry01_chain.php — Ğ1 application layer on top of cry01_substrate.php.
|
||||
*
|
||||
* This file contains Ğ1-specific and cry01-specific logic: what we do with
|
||||
* account data once decoded — formatting amounts in Ğ1, caching balances,
|
||||
* refreshing the cache. Generic Substrate protocol mechanics (storage keys,
|
||||
* SCALE decoding, SS58, RPC transport) live in cry01_substrate.php and are
|
||||
* required by it.
|
||||
*/
|
||||
|
||||
require_once 'addon/cry01/vendor/Blake2b.php';
|
||||
|
||||
use deemru\Blake2b;
|
||||
require_once 'addon/cry01/cry01_substrate.php';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// BALANCE READ
|
||||
// ACCOUNT INFO / BALANCE READ
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_get_balance($g1_pubkey) {
|
||||
// Returns the current Ğ1 balance for the given public key.
|
||||
// $g1_pubkey is a 32-byte raw account ID (NOT an SS58 address string —
|
||||
// callers must decode the address first via cry01_ss58_decode()).
|
||||
// Queries the local Duniter node RPC. Returns null on failure.
|
||||
function cry01_get_account_info($account_id_bytes) {
|
||||
// Returns the full decoded AccountInfo struct for the given 32-byte raw
|
||||
// account ID (as returned by cry01_ss58_decode()):
|
||||
// ['nonce', 'consumers', 'providers', 'sufficients',
|
||||
// 'free', 'reserved', 'frozen', 'flags']
|
||||
// All values are base-10 decimal strings (raw on-chain units, not
|
||||
// formatted Ğ1 amounts).
|
||||
//
|
||||
// Returns null on RPC failure. Returns an all-zero struct (not null) if
|
||||
// the account has never received funds — empty storage at this key is a
|
||||
// valid "account does not exist yet" response, not an error.
|
||||
$config = cry01_load_config();
|
||||
$rpc = $config['g1_rpc_endpoint'] ?? '';
|
||||
if (!$rpc || !$g1_pubkey) return null;
|
||||
if (!$rpc || !$account_id_bytes) return null;
|
||||
|
||||
$payload = json_encode([
|
||||
'jsonrpc' => '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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user