Updated
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
198
hubzilla/addon/cry01/cry01_attestation.php
Normal file
198
hubzilla/addon/cry01/cry01_attestation.php
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
387
hubzilla/addon/cry01/cry01_substrate.php
Normal file
387
hubzilla/addon/cry01/cry01_substrate.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* cry01_substrate.php — Generic Substrate protocol primitives.
|
||||
*
|
||||
* This file contains facts about how Substrate-based chains work — storage
|
||||
* key derivation, SCALE decoding, SS58 addresses, JSON-RPC transport. None of
|
||||
* it is specific to Ğ1 or to this project. Every function here would be
|
||||
* correct and reusable for Polkadot, Kusama, or any other Substrate chain.
|
||||
*
|
||||
* Ğ1-specific and cry01-specific logic (balance formatting, caching,
|
||||
* attestation) lives in cry01_chain.php and cry01_attestation.php, which
|
||||
* call into this file.
|
||||
*
|
||||
* Everything in this file was verified against the live Ğ1 mainnet on
|
||||
* 2026-06-12. See docs/DUNITER-RPC-FINDINGS.md for the full account of how
|
||||
* each piece was confirmed correct, including the Twox128-vs-xxh128 pitfall.
|
||||
*/
|
||||
|
||||
require_once 'addon/cry01/vendor/Blake2b.php';
|
||||
|
||||
use deemru\Blake2b;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// HASHING PRIMITIVES
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_twox128($data) {
|
||||
// Substrate's "Twox128" storage prefix hash.
|
||||
//
|
||||
// This is NOT the same algorithm as PHP's hash('xxh128', ...) — that is
|
||||
// a single-pass 128-bit xxHash variant and produces a different result
|
||||
// despite the similar name and identical output size.
|
||||
//
|
||||
// Twox128(data) = reverse(xxh64(data, seed=0)) . reverse(xxh64(data, seed=1))
|
||||
//
|
||||
// Verified: Twox128("System") = 26aa394eea5630e07c48ae0c9558cef7 and
|
||||
// Twox128("Account") = b99d880ec681799c0cf30e8886371da9 — these match the
|
||||
// canonical System::Account storage prefix published in Substrate/
|
||||
// Polkadot documentation.
|
||||
$h0 = strrev(hash('xxh64', $data, true, ['seed' => 0]));
|
||||
$h1 = strrev(hash('xxh64', $data, true, ['seed' => 1]));
|
||||
return $h0 . $h1;
|
||||
}
|
||||
|
||||
function cry01_blake2_128_concat($key) {
|
||||
// Substrate's "Blake2_128Concat" storage map key hasher.
|
||||
//
|
||||
// Blake2_128Concat(key) = Blake2b_128(key) . key
|
||||
//
|
||||
// i.e. the Blake2b-128 hash of the key, followed by the raw key bytes
|
||||
// appended (not replaced).
|
||||
//
|
||||
// Blake2b-128 is RFC 7693 PARAMETERIZED output — the output length is
|
||||
// part of the hash's parameter block, not a truncation of Blake2b-512.
|
||||
// PHP's hash() does not support 'blake2b'/'blake2b512' on this build
|
||||
// (hash_algos() does not list it), so we use the vendored pure-PHP
|
||||
// deemru/Blake2b implementation (addon/cry01/vendor/Blake2b.php, MIT).
|
||||
//
|
||||
// Verified against RFC 7693 test vectors:
|
||||
// Blake2b-128("") => cae66941d9efbd404e4d88758ea67670
|
||||
// Blake2b-128("abc") => cf4ab791c62b8d2b2109c90275287816
|
||||
$b2b128 = new Blake2b(16);
|
||||
return $b2b128->hash($key) . $key;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// STORAGE KEY DERIVATION
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_storage_key($pallet, $item, $map_key = null) {
|
||||
// Builds a Substrate storage key for Pallet.Item, optionally with a
|
||||
// Blake2_128Concat map key appended.
|
||||
//
|
||||
// For a plain StorageValue (no map), call with $map_key = null:
|
||||
// cry01_storage_key('Timestamp', 'Now')
|
||||
// => Twox128("Timestamp") . Twox128("Now")
|
||||
//
|
||||
// For a StorageMap using Blake2_128Concat (the hasher used by
|
||||
// System.Account and most account-keyed maps), pass the raw map key
|
||||
// bytes:
|
||||
// cry01_storage_key('System', 'Account', $account_id_32_bytes)
|
||||
// => Twox128("System") . Twox128("Account") . Blake2_128Concat($account_id_32_bytes)
|
||||
//
|
||||
// NOTE: this function currently assumes Blake2_128Concat for any map
|
||||
// key. Some storage maps use other hashers (Twox64Concat, Identity,
|
||||
// etc.) — if a future query targets such a map, this function will need
|
||||
// a $hasher parameter. Not needed for anything implemented so far.
|
||||
//
|
||||
// Returns the storage key as a '0x'-prefixed hex string.
|
||||
$prefix = cry01_twox128($pallet) . cry01_twox128($item);
|
||||
|
||||
if ($map_key !== null) {
|
||||
$prefix .= cry01_blake2_128_concat($map_key);
|
||||
}
|
||||
|
||||
return '0x' . bin2hex($prefix);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// SCALE DECODING — AccountInfo
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_decode_account_info($bytes) {
|
||||
// Decodes a SCALE-encoded AccountInfo struct (the value stored at
|
||||
// System.Account(account_id)):
|
||||
//
|
||||
// nonce: u32 (4 bytes)
|
||||
// consumers: u32 (4 bytes)
|
||||
// providers: u32 (4 bytes)
|
||||
// sufficients: u32 (4 bytes)
|
||||
// data.free: u128 (16 bytes)
|
||||
// data.reserved: u128 (16 bytes)
|
||||
// data.frozen: u128 (16 bytes)
|
||||
// data.flags: u128 (16 bytes)
|
||||
//
|
||||
// All fields little-endian, concatenated with no padding/separators.
|
||||
// u128 fields are decoded as base-10 decimal strings (cry01's big-int
|
||||
// helpers — no bcmath/gmp dependency).
|
||||
//
|
||||
// Returns an associative array with all eight fields as decimal strings
|
||||
// (u32 fields fit in PHP int range but are returned as strings for
|
||||
// consistency), or null if $bytes is too short to contain at least the
|
||||
// four u32 header fields and the 'free' u128.
|
||||
//
|
||||
// Trailing fields (reserved, frozen, flags) may be absent if the RPC
|
||||
// response is shorter than the full 80-byte struct — callers should not
|
||||
// assume all eight keys are always present with non-zero-length input;
|
||||
// this function fills missing trailing fields with '0'.
|
||||
if ($bytes === null || strlen($bytes) < 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'nonce' => cry01_le_bytes_to_decimal_string(substr($bytes, 0, 4)),
|
||||
'consumers' => cry01_le_bytes_to_decimal_string(substr($bytes, 4, 4)),
|
||||
'providers' => cry01_le_bytes_to_decimal_string(substr($bytes, 8, 4)),
|
||||
'sufficients' => cry01_le_bytes_to_decimal_string(substr($bytes, 12, 4)),
|
||||
'free' => cry01_le_bytes_to_decimal_string(substr($bytes, 16, 16)),
|
||||
'reserved' => '0',
|
||||
'frozen' => '0',
|
||||
'flags' => '0',
|
||||
];
|
||||
|
||||
if (strlen($bytes) >= 48) {
|
||||
$fields['reserved'] = cry01_le_bytes_to_decimal_string(substr($bytes, 32, 16));
|
||||
}
|
||||
if (strlen($bytes) >= 64) {
|
||||
$fields['frozen'] = cry01_le_bytes_to_decimal_string(substr($bytes, 48, 16));
|
||||
}
|
||||
if (strlen($bytes) >= 80) {
|
||||
$fields['flags'] = cry01_le_bytes_to_decimal_string(substr($bytes, 64, 16));
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// BIG-INTEGER HELPERS (no bcmath/gmp dependency)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_le_bytes_to_decimal_string($bytes) {
|
||||
// Converts a little-endian byte string to a base-10 decimal string,
|
||||
// using only integer string arithmetic. Suitable for values up to u128
|
||||
// (16 bytes) and beyond.
|
||||
$result = '0';
|
||||
$multiplier = '1';
|
||||
|
||||
$len = strlen($bytes);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$byteVal = ord($bytes[$i]);
|
||||
if ($byteVal !== 0) {
|
||||
$term = cry01_decimal_string_multiply($multiplier, (string) $byteVal);
|
||||
$result = cry01_decimal_string_add($result, $term);
|
||||
}
|
||||
if ($i < $len - 1) {
|
||||
$multiplier = cry01_decimal_string_multiply($multiplier, '256');
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function cry01_decimal_string_add($a, $b) {
|
||||
// Adds two non-negative base-10 decimal strings, returns the sum as a string.
|
||||
$a = strrev($a);
|
||||
$b = strrev($b);
|
||||
$len = max(strlen($a), strlen($b));
|
||||
$a = str_pad($a, $len, '0');
|
||||
$b = str_pad($b, $len, '0');
|
||||
|
||||
$result = '';
|
||||
$carry = 0;
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$sum = intval($a[$i]) + intval($b[$i]) + $carry;
|
||||
$carry = intdiv($sum, 10);
|
||||
$result .= ($sum % 10);
|
||||
}
|
||||
if ($carry) {
|
||||
$result .= $carry;
|
||||
}
|
||||
|
||||
return ltrim(strrev($result), '0') ?: '0';
|
||||
}
|
||||
|
||||
function cry01_decimal_string_multiply($a, $b) {
|
||||
// Multiplies two non-negative base-10 decimal strings, returns the product as a string.
|
||||
if ($a === '0' || $b === '0') return '0';
|
||||
|
||||
$a = strrev($a);
|
||||
$b = strrev($b);
|
||||
$result = array_fill(0, strlen($a) + strlen($b), 0);
|
||||
|
||||
for ($i = 0; $i < strlen($a); $i++) {
|
||||
for ($j = 0; $j < strlen($b); $j++) {
|
||||
$result[$i + $j] += intval($a[$i]) * intval($b[$j]);
|
||||
}
|
||||
}
|
||||
|
||||
for ($k = 0; $k < count($result) - 1; $k++) {
|
||||
$result[$k + 1] += intdiv($result[$k], 10);
|
||||
$result[$k] = $result[$k] % 10;
|
||||
}
|
||||
|
||||
$str = implode('', array_reverse($result));
|
||||
return ltrim($str, '0') ?: '0';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// SS58 / BASE58 ADDRESS DECODING
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_base58_decode($input) {
|
||||
// Generic Base58 decoder (Bitcoin/IPFS alphabet, same as SS58).
|
||||
// Pure PHP, no bcmath/gmp dependency. Returns raw decoded bytes,
|
||||
// including any version/checksum bytes still attached.
|
||||
// Throws InvalidArgumentException on invalid characters.
|
||||
static $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
$input = trim($input);
|
||||
if ($input === '') return '';
|
||||
|
||||
$map = [];
|
||||
for ($i = 0; $i < strlen($alphabet); $i++) {
|
||||
$map[$alphabet[$i]] = $i;
|
||||
}
|
||||
|
||||
$leadingZeros = 0;
|
||||
for ($i = 0; $i < strlen($input); $i++) {
|
||||
if ($input[$i] === '1') {
|
||||
$leadingZeros++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$bytes = [0];
|
||||
|
||||
for ($i = 0; $i < strlen($input); $i++) {
|
||||
$c = $input[$i];
|
||||
if (!isset($map[$c])) {
|
||||
throw new \InvalidArgumentException("Invalid base58 character: '$c'");
|
||||
}
|
||||
$value = $map[$c];
|
||||
|
||||
$carry = $value;
|
||||
for ($j = count($bytes) - 1; $j >= 0; $j--) {
|
||||
$carry += $bytes[$j] * 58;
|
||||
$bytes[$j] = $carry & 0xff;
|
||||
$carry >>= 8;
|
||||
}
|
||||
while ($carry > 0) {
|
||||
array_unshift($bytes, $carry & 0xff);
|
||||
$carry >>= 8;
|
||||
}
|
||||
}
|
||||
|
||||
$result = '';
|
||||
$started = false;
|
||||
foreach ($bytes as $b) {
|
||||
if (!$started && $b === 0) {
|
||||
continue;
|
||||
}
|
||||
$started = true;
|
||||
$result .= chr($b);
|
||||
}
|
||||
|
||||
return str_repeat("\x00", $leadingZeros) . $result;
|
||||
}
|
||||
|
||||
function cry01_ss58_decode($address) {
|
||||
// Decodes an SS58-encoded address (Ğ1/Substrate) to its raw 32-byte
|
||||
// account ID, verifying the checksum.
|
||||
//
|
||||
// Confirmed format for Ğ1 addresses (e.g. "g1LvTpYXkKEASMiBYLp8RQmSN5kZyXtoHX8XE2FqQ9hDjqp5B"):
|
||||
// 36 bytes total = 2-byte network prefix (0x5891) + 32-byte account ID + 2-byte checksum
|
||||
//
|
||||
// The leading "g1" in Ğ1 addresses is NOT a literal prefix string — it
|
||||
// is simply the first two characters of the base58 encoding. The actual
|
||||
// network identifier (0x5891) is encoded in the decoded bytes.
|
||||
//
|
||||
// Checksum = first 2 bytes of Blake2b-512("SS58PRE" + prefix + account_id)
|
||||
//
|
||||
// Some other Substrate/Duniter-v1-era addresses decode to 32 bytes with
|
||||
// no checksum at all (a bare public key, no SS58 wrapper) — these are
|
||||
// correctly rejected here as "unexpected decoded length", since they are
|
||||
// not valid Ğ1 v2 SS58 addresses.
|
||||
//
|
||||
// Returns the raw 32-byte account ID on success, or null on any
|
||||
// validation failure (invalid base58, wrong length, bad checksum).
|
||||
try {
|
||||
$decoded = cry01_base58_decode($address);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$len = strlen($decoded);
|
||||
|
||||
if ($len === 36) {
|
||||
$prefix = substr($decoded, 0, 2);
|
||||
$account = substr($decoded, 2, 32);
|
||||
$checksum = substr($decoded, 34, 2);
|
||||
} elseif ($len === 35) {
|
||||
$prefix = substr($decoded, 0, 1);
|
||||
$account = substr($decoded, 1, 32);
|
||||
$checksum = substr($decoded, 33, 2);
|
||||
} else {
|
||||
logger('cry01_substrate: cry01_ss58_decode got unexpected decoded length ' . $len);
|
||||
return null;
|
||||
}
|
||||
|
||||
$b2b512 = new Blake2b(64);
|
||||
$hash = $b2b512->hash('SS58PRE' . $prefix . $account);
|
||||
$expected_checksum = substr($hash, 0, 2);
|
||||
|
||||
if ($expected_checksum !== $checksum) {
|
||||
logger('cry01_substrate: cry01_ss58_decode checksum mismatch for address');
|
||||
return null;
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// RPC TRANSPORT
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function cry01_rpc_post($endpoint, $payload) {
|
||||
// POSTs a JSON-RPC request to a Substrate node. Returns decoded response or null.
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n",
|
||||
'content' => $payload,
|
||||
'timeout' => 5,
|
||||
],
|
||||
]);
|
||||
$raw = @file_get_contents($endpoint, false, $context);
|
||||
if ($raw === false) {
|
||||
logger('cry01_substrate: RPC call failed to ' . $endpoint);
|
||||
return null;
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
logger('cry01_substrate: RPC response is not valid JSON');
|
||||
return null;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function cry01_rpc_state_get_storage($endpoint, $storage_key) {
|
||||
// Convenience wrapper: calls state_getStorage and returns the raw hex
|
||||
// result string (with '0x' prefix), or null on RPC failure, or '0x' if
|
||||
// the storage key has no value (empty storage — a valid "not found"
|
||||
// response, not an error).
|
||||
$payload = json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'state_getStorage',
|
||||
'params' => [$storage_key],
|
||||
]);
|
||||
|
||||
$result = cry01_rpc_post($endpoint, $payload);
|
||||
if ($result === null) return null;
|
||||
|
||||
return $result['result'] ?? '0x';
|
||||
}
|
||||
Reference in New Issue
Block a user