This commit is contained in:
2026-06-12 15:33:51 -04:00
parent 2213aab723
commit d2d2a4d596
5 changed files with 719 additions and 350 deletions

View File

@@ -10,7 +10,9 @@
use Zotlabs\Extend\Widget;
require_once 'addon/cry01/cry01_substrate.php';
require_once 'addon/cry01/cry01_chain.php';
require_once 'addon/cry01/cry01_attestation.php';
require_once 'addon/cry01/cry01_renderer.php';
require_once 'addon/cry01/cry01_spool.php';
@@ -161,8 +163,8 @@ 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.
// Public Ğ1 account lookup. No SASE gate, no wallet session, no
// storage of any kind. Address in, full AccountInfo out.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!cry01_verify_csrf()) {
return cry01_render_error('Invalid form token. Please reload and try again.');
@@ -181,9 +183,10 @@ function cry01_content() {
// ----------------------------------------------------------------------------
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.
// Public account lookup: decode the pasted Ğ1 address, query the chain
// for the full AccountInfo struct, 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) {
@@ -200,8 +203,8 @@ function cry01_handle_lookup_post($association_slug, $access) {
]);
}
$balance = cry01_get_balance($account_id);
if ($balance === null) {
$info = cry01_get_account_info($account_id);
if ($info === 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,
@@ -210,7 +213,8 @@ function cry01_handle_lookup_post($association_slug, $access) {
return cry01_render_landing($association_slug, $access, [
'lookup_address' => $address,
'lookup_balance' => $balance,
'lookup_balance' => cry01_format_g1_amount($info['free']),
'lookup_account_info' => $info,
]);
}

View File

@@ -0,0 +1,198 @@
<?php
/**
* cry01_attestation.php — STUB INTERFACES ONLY. Nothing in this file is
* implemented. Every function returns a "not implemented" placeholder.
*
* This file documents the planned shape of two related future features:
*
* 1. ATTESTATION — W3C Verifiable Credentials + OpenTimestamps proofs for
* records produced by cry01 (balance lookups, ballot definitions,
* ballot outcomes). Per CRY01-SPEC.md, this is part of the shared
* credential infrastructure that poll01 and future addons depend on.
*
* 2. BALLOT TALLYING — the wallet-to-wallet voting model described below.
* This is the END GOAL that g1wallet, cry01, and poll01 are jointly
* building toward. Nothing here is implemented; this file exists so
* the eventual implementation has an interface to fill in rather than
* bolting tallying logic onto whatever shape cry01_chain.php happens
* to have at the time.
*
* ============================================================================
* THE BALLOT MODEL (as designed, 2026-06-12)
* ============================================================================
*
* For a given poll (e.g. "Should the association approve budget item X?"):
*
* - Two Ğ1 wallets are provisioned for the poll: a YES wallet and a NO
* wallet. Who controls these keys and how they are provisioned is NOT
* YET DECIDED — see OPEN QUESTIONS below.
*
* - Each eligible voter (a SASE-verified participant with a Ğ1 wallet) may
* send AT MOST ONE TOKEN to either the YES or NO wallet during the
* voting period. The transaction itself — wallet-to-wallet, with a
* block timestamp — IS the vote and IS the timestamp. No separate OTS
* step is needed for individual votes.
*
* - TALLY RULE: count only the FIRST token received from each eligible
* voter's wallet (by block order). Any additional tokens from the same
* wallet, to either YES or NO, are ignored for tallying purposes. This
* is an APPLICATION-LAYER rule — nothing on-chain prevents a wallet from
* sending multiple tokens; cry01/poll01 must enforce "first token only"
* when counting.
*
* - ABSTENTION: (number of eligible voters) - (yes_count + no_count) =
* abstentions. No "spoiled ballot" state exists — a voter either sent
* exactly one token to one wallet, or did not vote.
*
* - AFTER THE VOTE: all tokens in both YES and NO wallets are sent to the
* association's wallet, to be used as the homeowners subsequently
* decide. This transfer is itself a public, timestamped on-chain event
* — closing the books on the poll.
*
* - PUBLIC BALLOT BY DESIGN: every vote is a public on-chain transaction,
* permanently linking a voter's wallet address to their choice. This is
* intentional — Illinois law and federal law do not require HOA votes
* to be secret, and public attribution provides verifiability without
* requiring trust in the board's counting.
*
* ============================================================================
* OPEN QUESTIONS (not blockers — design space the interfaces below must
* accommodate, not resolve)
* ============================================================================
*
* - YES/NO wallet provisioning: who creates these wallets, who holds the
* keys during the voting period, how is the post-vote transfer to the
* association wallet authorized/triggered (manual operator action vs.
* some automated/multisig mechanism)?
*
* - Voter eligibility registry: cry01_get_eligible_voters() needs a source
* of truth for "which Ğ1 account IDs belong to SASE-verified members of
* this association, as of the poll's start date". This likely overlaps
* with the g1wallet pubkey-registration flow (cry01's signal form
* already has a data-g1wallet-target="pubkey" field) but the
* association-membership-at-a-point-in-time registry does not exist yet.
*
* - Participation barrier: sending a token requires the voter to HOLD at
* least one Ğ1 and pay any associated transaction cost. Whether this is
* a deliberate "skin in the game" feature or an unintended barrier for
* participants who hold zero Ğ1 is a POLICY decision, not addressed
* here. cry01_get_eligible_voters() and cry01_tally_ballot() should not
* assume either answer — e.g. don't assume "no token sent" always means
* "chose not to vote" vs. "could not afford to vote", though the
* externally-visible RESULT (abstention) is the same either way.
*
* ============================================================================
* THE TRANSACTION-HISTORY DEPENDENCY
* ============================================================================
*
* cry01_get_wallet_transactions() is the hard dependency underlying
* cry01_tally_ballot(). Per docs/DUNITER-RPC-FINDINGS.md §4.3, NEITHER
* current Duniter node (light mirror or full-fast-sync) can answer "what
* transactions has account X sent/received" — both use
* --state-pruning 256, which only retains recent state, not transaction
* history. Tallying a ballot requires scanning all transactions to the
* YES/NO wallets over the poll's duration, which requires either:
*
* - an archive node (--state-pruning archive), much larger disk footprint
* than either current node, or
* - a separate indexer (e.g. a Subsquid/Squid instance, as referenced in
* Duniter's own public-RPC documentation) that processes blocks as they
* arrive and maintains a queryable transaction database
*
* This is a SEPARATE INFRASTRUCTURE PROJECT from anything in cry01_chain.php
* or cry01_substrate.php. The interface below assumes it exists; building it
* is future work.
*/
// ----------------------------------------------------------------------------
// ATTESTATION (VC + OpenTimestamps)
// ----------------------------------------------------------------------------
function cry01_attest_record($record_type, $data, $block_hash = null) {
// Produces a W3C Verifiable Credential + OpenTimestamps proof for a
// cry01 record.
//
// $record_type: one of 'lookup', 'ballot_definition', 'ballot_outcome'
// (open to extension — these are the three currently anticipated).
// $data: the record's content — shape depends on $record_type. For
// 'lookup', likely {account_id, account_info, queried_at}. For
// 'ballot_definition'/'ballot_outcome', shape TBD alongside
// cry01_tally_ballot().
// $block_hash: optional — if the record pertains to a specific on-chain
// state (e.g. a balance lookup result), the block hash it was read at,
// for reproducibility.
//
// Returns null (not implemented). When implemented, should return an
// array containing at minimum the VC document and the OTS proof/receipt
// (or a reference to where the OTS proof can be retrieved — OTS
// calendar submission is asynchronous, per CRY01-SPEC.md's
// ots_calendar_url config).
logger('cry01_attestation: cry01_attest_record not yet implemented');
return null;
}
// ----------------------------------------------------------------------------
// BALLOT TALLYING
// ----------------------------------------------------------------------------
function cry01_get_eligible_voters($association_slug, $poll_id) {
// Returns the list of Ğ1 account IDs (raw 32-byte, or hex — TBD) eligible
// to vote in the given poll for the given association.
//
// Source of truth: NOT YET DESIGNED. See "Voter eligibility registry" in
// the open questions above. Likely candidates: a snapshot of registered
// g1wallet pubkeys for SASE-verified members of $association_slug, taken
// at the poll's start time (so membership changes during voting don't
// retroactively change eligibility).
//
// Returns null (not implemented).
logger('cry01_attestation: cry01_get_eligible_voters not yet implemented');
return null;
}
function cry01_get_wallet_transactions($account_id_bytes, $since_block = null) {
// Returns all incoming transactions to the given account, optionally
// since a given block number, ordered by block number ascending.
//
// THIS IS THE HARD DEPENDENCY — see "THE TRANSACTION-HISTORY DEPENDENCY"
// above. Neither current Duniter node can answer this query. Requires
// an archive node or a separate indexer — both out of scope for cry01
// application code; this function is the integration point once that
// infrastructure exists.
//
// Expected return shape (once implemented): array of
// {from: account_id, amount: decimal_string, block_number: int, block_hash: string, timestamp: ...}
//
// Returns null (not implemented).
logger('cry01_attestation: cry01_get_wallet_transactions not yet implemented (requires archive node or indexer — see docs/DUNITER-RPC-FINDINGS.md)');
return null;
}
function cry01_tally_ballot($yes_wallet_account_id, $no_wallet_account_id, $eligible_voters, $poll_start_block = null, $poll_end_block = null) {
// Tallies a ballot using the wallet-to-wallet voting model.
//
// TALLY RULE (see module docblock): for each eligible voter, find their
// FIRST transaction (by block order, within [$poll_start_block,
// $poll_end_block] if given) to EITHER $yes_wallet_account_id or
// $no_wallet_account_id. Count that as their vote. Any subsequent
// transactions from the same voter, to either wallet, are ignored.
// Voters with no qualifying transaction are abstentions.
//
// Depends on cry01_get_wallet_transactions() for both wallets — see the
// hard dependency note above. Also depends on $eligible_voters from
// cry01_get_eligible_voters().
//
// Expected return shape (once implemented):
// {
// yes: int,
// no: int,
// abstain: int,
// total_eligible: int,
// votes: [ {voter: account_id, choice: 'yes'|'no', block_number: int}, ... ]
// }
//
// Returns null (not implemented).
logger('cry01_attestation: cry01_tally_ballot not yet implemented');
return null;
}

View File

@@ -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);
return cry01_format_g1_amount($value);
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',
];
}
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');
}
$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 $result;
return cry01_decode_account_info($bytes);
}
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');
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;
$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 cry01_format_g1_amount($info['free']);
}
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);
}

View File

@@ -12,8 +12,9 @@
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.
// $lookup may contain: lookup_error, lookup_address, lookup_balance,
// lookup_account_info — 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] ?? [];
@@ -94,19 +95,20 @@ function cry01_render_balance_display() {
// ----------------------------------------------------------------------------
function cry01_render_lookup_form($association_slug, $lookup = []) {
// Renders the public Ğ1 address balance lookup form.
// Renders the public Ğ1 address 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.
// Paste an address, see its current balance and account details, read
// directly from this node's own Duniter mirror.
$lookup_url = z_root() . '/cry01/' . cry01_h($association_slug) . '/lookup';
$error = $lookup['lookup_error'] ?? '';
$entered_address = $lookup['lookup_address'] ?? '';
$balance = $lookup['lookup_balance'] ?? null;
$account_info = $lookup['lookup_account_info'] ?? null;
$out = '<div class="cry01-lookup mb-4">';
$out .= '<h5 class="cry01-section-label">Ğ1 Address Lookup</h5>';
$out .= '<p class="text-muted small">Paste any Ğ1 wallet address to check its current balance — read directly from this node\'s Duniter mirror. Nothing is stored.</p>';
$out .= '<p class="text-muted small">Paste any Ğ1 wallet address to check its current balance and account details — read directly from this node\'s Duniter mirror. Nothing is stored.</p>';
$out .= '<form method="post" action="' . $lookup_url . '" class="cry01-lookup-form">';
$out .= cry01_csrf_token();
@@ -125,12 +127,50 @@ function cry01_render_lookup_form($association_slug, $lookup = []) {
$out .= '<span class="font-monospace small">' . cry01_h(substr($entered_address, 0, 16) . '...') . '</span><br>';
$out .= '<strong>' . cry01_h($balance) . '</strong>';
$out .= '</div>';
if ($account_info) {
$out .= cry01_render_account_info_table($account_info);
}
}
$out .= '</div>';
return $out;
}
function cry01_render_account_info_table($info) {
// Renders the full decoded AccountInfo struct in plain language.
// $info is the array returned by cry01_decode_account_info() /
// cry01_get_account_info(): nonce, consumers, providers, sufficients,
// free, reserved, frozen, flags — all base-10 decimal strings of raw
// on-chain units (free/reserved/frozen/flags are in centimes; nonce/
// consumers/providers/sufficients are plain counts).
//
// 'free' is already shown separately as the headline balance — this
// table covers the remaining fields. 'flags' is an internal bitfield
// (account feature flags) and is not meaningful to a layperson; it is
// omitted from display but available in $info for anyone who needs it.
$reserved = cry01_format_g1_amount($info['reserved'] ?? '0');
$frozen = cry01_format_g1_amount($info['frozen'] ?? '0');
$nonce = $info['nonce'] ?? '0';
$out = '<div class="cry01-account-info mt-2">';
$out .= '<table class="table table-sm cry01-account-info-table">';
$out .= '<tbody>';
$out .= '<tr><td class="text-muted">Transactions sent</td><td>' . cry01_h($nonce) . '</td></tr>';
if ($info['reserved'] !== '0') {
$out .= '<tr><td class="text-muted">Reserved</td><td>' . cry01_h($reserved) . '</td></tr>';
}
if ($info['frozen'] !== '0') {
$out .= '<tr><td class="text-muted">Frozen</td><td>' . cry01_h($frozen) . '</td></tr>';
}
$out .= '</tbody>';
$out .= '</table>';
$out .= '</div>';
return $out;
}
// ----------------------------------------------------------------------------
// SIGNAL BOARD
// ----------------------------------------------------------------------------

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