From 8a9f850fc23b244daaf074279983806b749d8a44 Mon Sep 17 00:00:00 2001
From: TheRON
Date: Fri, 12 Jun 2026 10:53:33 -0400
Subject: [PATCH] Initial push
---
hubzilla/addon/cry01/cry01.php | 75 +++++-
hubzilla/addon/cry01/cry01_chain.php | 324 ++++++++++++++++++++++--
hubzilla/addon/cry01/cry01_renderer.php | 51 +++-
3 files changed, 409 insertions(+), 41 deletions(-)
diff --git a/hubzilla/addon/cry01/cry01.php b/hubzilla/addon/cry01/cry01.php
index 019f5b6..1ddf265 100644
--- a/hubzilla/addon/cry01/cry01.php
+++ b/hubzilla/addon/cry01/cry01.php
@@ -37,18 +37,18 @@ function cry01_load_pdl(&$b) {
}
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
// HELPERS
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_h($value) {
// HTML-escapes a value for safe output.
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
// ACCESS
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_access_state($association_slug = '') {
// Returns operator, participant, or public. Does not call local_channel() for group checks.
@@ -109,9 +109,9 @@ function cry01_access_wall($association_slug = '') {
';
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
// CONTENT ROUTER
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_content() {
if (function_exists('head_add_css')) {
@@ -122,7 +122,7 @@ function cry01_content() {
}
$association_slug = argv(1) ?? '';
- $sub_route = strtolower(argv(2) ?? '');
+ $sub_route = strtolower(argv(2) ?? '');
if (!$association_slug) {
return cry01_render_index();
@@ -160,14 +160,63 @@ function cry01_content() {
}
return cry01_render_manage($association_slug);
+ case 'lookup':
+ // Public Ğ1 balance lookup. No SASE gate, no wallet session, no
+ // storage of any kind. Address in, balance out.
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!cry01_verify_csrf()) {
+ return cry01_render_error('Invalid form token. Please reload and try again.');
+ }
+ return cry01_handle_lookup_post($association_slug, $access);
+ }
+ return cry01_render_landing($association_slug, $access);
+
default:
return cry01_render_landing($association_slug, $access);
}
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
+// LOOKUP HANDLER
+// ----------------------------------------------------------------------------
+
+function cry01_handle_lookup_post($association_slug, $access) {
+ // Public balance lookup: decode the pasted Ğ1 address, query the chain,
+ // re-render the landing page with the result (or an error) inline.
+ // No data is stored anywhere — this is a pure read.
+ $address = trim($_POST['g1_lookup_address'] ?? '');
+
+ if (!$address) {
+ return cry01_render_landing($association_slug, $access, [
+ 'lookup_error' => 'Please enter a Ğ1 address.',
+ ]);
+ }
+
+ $account_id = cry01_ss58_decode($address);
+ if ($account_id === null) {
+ return cry01_render_landing($association_slug, $access, [
+ 'lookup_error' => 'That doesn\'t look like a valid Ğ1 address. Check for typos and try again.',
+ 'lookup_address' => $address,
+ ]);
+ }
+
+ $balance = cry01_get_balance($account_id);
+ if ($balance === null) {
+ return cry01_render_landing($association_slug, $access, [
+ 'lookup_error' => 'Could not reach the Ğ1 network right now. Please try again shortly.',
+ 'lookup_address' => $address,
+ ]);
+ }
+
+ return cry01_render_landing($association_slug, $access, [
+ 'lookup_address' => $address,
+ 'lookup_balance' => $balance,
+ ]);
+}
+
+// ----------------------------------------------------------------------------
// RENDER — INDEX
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_render_index() {
// Lists all registered associations with links to their value layer pages.
@@ -191,9 +240,9 @@ function cry01_render_index() {
return $out;
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
// RENDER — NOT FOUND / ERROR
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_render_not_found() {
return '';
@@ -204,9 +253,9 @@ function cry01_render_error($message) {
return '' . cry01_h($message) . '
';
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
// CSRF
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_csrf_token() {
// Generates and stores a CSRF token for the current session.
diff --git a/hubzilla/addon/cry01/cry01_chain.php b/hubzilla/addon/cry01/cry01_chain.php
index a3c2f75..a701c90 100644
--- a/hubzilla/addon/cry01/cry01_chain.php
+++ b/hubzilla/addon/cry01/cry01_chain.php
@@ -8,12 +8,18 @@
* If the node infrastructure changes, only this file changes.
*/
-// ---------------------------------------------------------------------------
+require_once 'addon/cry01/vendor/Blake2b.php';
+
+use deemru\Blake2b;
+
+// ----------------------------------------------------------------------------
// 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.
$config = cry01_load_config();
$rpc = $config['g1_rpc_endpoint'] ?? '';
@@ -32,34 +38,302 @@ function cry01_get_balance($g1_pubkey) {
return cry01_decode_balance($result);
}
-function cry01_balance_storage_key($g1_pubkey) {
- // Builds the Substrate storage key for the account balance of the given Ğ1 public key.
- // The key format is defined by the Duniter v2 runtime (pallet_balances storage map).
- // Placeholder — requires Substrate storage key encoding (xxHash + Blake2).
- // TODO: implement full storage key derivation or use duniter-specific RPC method.
- return '0x' . bin2hex($g1_pubkey);
+function cry01_balance_storage_key($account_id_bytes) {
+ // Builds the Substrate storage key for System.Account(account_id_bytes).
+ //
+ // Storage key = xxh128("System") . xxh128("Account") . Blake2_128Concat(account_id_bytes)
+ // = xxh128("System") . xxh128("Account") . blake2b_128(account_id_bytes) . account_id_bytes
+ //
+ // xxh128 is PHP's native hash('xxh128', ..., true) — confirmed correct
+ // against the published test vector (16c27099bd855aff3b3efe27980515ad
+ // for "php.watch").
+ //
+ // 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 = hash('xxh128', 'System', true);
+ $prefix_account = hash('xxh128', 'Account', true);
+
+ $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 balances are stored as u64 encoded in SCALE codec.
- // Returns formatted balance string or null if decoding fails.
+ //
+ // 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') return '0';
- // Remove 0x prefix and decode little-endian u64.
- $hex = ltrim($hex, '0x');
- $bytes = array_reverse(str_split(str_pad($hex, 16, '0', STR_PAD_LEFT), 2));
- $val = 0;
- foreach ($bytes as $b) {
- $val = ($val << 8) | hexdec($b);
+ if (!$hex || $hex === '0x') {
+ // Empty storage means the account has never received funds —
+ // a valid result, not an error. Balance is zero.
+ return '0';
}
- // Duniter uses centimes (hundredths of Ğ1). Divide by 100.
- return number_format($val / 100, 2) . ' Ğ1';
+
+ $hex = ltrim($hex, '0x');
+ if (substr($rpc_result['result'], 0, 2) === '0x') {
+ $hex = substr($rpc_result['result'], 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.
@@ -84,9 +358,9 @@ function cry01_rpc_post($endpoint, $payload) {
return $data;
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
// CACHE
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_read_cache() {
// Returns the balance cache array. Returns empty array if cache does not exist or is unreadable.
@@ -140,9 +414,9 @@ function cry01_refresh_balance_cache($g1_pubkey) {
return cry01_write_cache($cache);
}
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
// CONFIG LOADER
-// ---------------------------------------------------------------------------
+// ----------------------------------------------------------------------------
function cry01_load_config() {
// Loads cry01 config.json from the addon directory. Returns empty array on failure.
diff --git a/hubzilla/addon/cry01/cry01_renderer.php b/hubzilla/addon/cry01/cry01_renderer.php
index 2e18ac4..0516e80 100644
--- a/hubzilla/addon/cry01/cry01_renderer.php
+++ b/hubzilla/addon/cry01/cry01_renderer.php
@@ -10,8 +10,10 @@
// ASSOCIATION LANDING
// ----------------------------------------------------------------------------
-function cry01_render_landing($association_slug, $access) {
- // Renders the main cry01 page: balance display + signal board.
+function cry01_render_landing($association_slug, $access, $lookup = []) {
+ // Renders the main cry01 page: balance display + signal board + public lookup.
+ // $lookup may contain: lookup_error, lookup_address, lookup_balance —
+ // populated when this page is rendered as the result of a lookup POST.
$raw = @file_get_contents('addon/vs01/config.json');
$cfg = $raw ? json_decode($raw, true) : [];
$assoc = $cfg['associations'][$association_slug] ?? [];
@@ -32,6 +34,7 @@ function cry01_render_landing($association_slug, $access) {
$out .= '
';
$out .= cry01_render_balance_display();
+ $out .= cry01_render_lookup_form($association_slug, $lookup);
$out .= cry01_render_signal_board($association_slug, $access);
if ($access === 'participant') {
@@ -55,7 +58,7 @@ function cry01_render_landing($association_slug, $access) {
}
// ----------------------------------------------------------------------------
-// BALANCE DISPLAY
+// BALANCE DISPLAY (operator/cached)
// ----------------------------------------------------------------------------
function cry01_render_balance_display() {
@@ -86,6 +89,48 @@ function cry01_render_balance_display() {
return $out;
}
+// ----------------------------------------------------------------------------
+// PUBLIC BALANCE LOOKUP
+// ----------------------------------------------------------------------------
+
+function cry01_render_lookup_form($association_slug, $lookup = []) {
+ // Renders the public Ğ1 address balance lookup form.
+ // Anyone can use this — no SASE, no wallet session, nothing stored.
+ // Paste an address, see its current balance, read directly from the
+ // Civic Infrastructure's own Duniter mirror node.
+ $lookup_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/lookup';
+
+ $error = $lookup['lookup_error'] ?? '';
+ $entered_address = $lookup['lookup_address'] ?? '';
+ $balance = $lookup['lookup_balance'] ?? null;
+
+ $out = '';
+ $out .= '
Ğ1 Address Lookup
';
+ $out .= '
Paste any Ğ1 wallet address to check its current balance — read directly from this node\'s Duniter mirror. Nothing is stored.
';
+
+ $out .= '
';
+
+ if ($error) {
+ $out .= '
' . cry01_h($error) . '
';
+ } elseif ($balance !== null) {
+ $out .= '
';
+ $out .= '' . cry01_h(substr($entered_address, 0, 16) . '...') . '
';
+ $out .= '' . cry01_h($balance) . '';
+ $out .= '
';
+ }
+
+ $out .= '
';
+ return $out;
+}
+
// ----------------------------------------------------------------------------
// SIGNAL BOARD
// ----------------------------------------------------------------------------