generateSeed(); if (! is_string($seed) || strlen($seed) < 32) { throw new RuntimeException('Failed to generate BIP39 seed'); } // SLIP-0010: master from BIP39 seed, then hardened path. [$key, $chain] = self::masterFromSeed($seed); foreach ([44 | 0x80000000, 501 | 0x80000000, $index | 0x80000000, 0 | 0x80000000] as $child) { [$key, $chain] = self::ckdPriv($key, $chain, $child); } return self::base58Encode(self::publicKeyFromSeed($key)); } public static function isValid(string $address): bool { $address = trim($address); if (! preg_match('/^[1-9A-HJ-NP-Za-km-z]{32,44}$/', $address)) { return false; } $decoded = self::base58Decode($address); return $decoded !== null && strlen($decoded) === 32; } /** * @return array{0: string, 1: string} 32-byte key + 32-byte chain code */ private static function masterFromSeed(string $seed): array { $I = hash_hmac('sha512', $seed, 'ed25519 seed', true); return [substr($I, 0, 32), substr($I, 32, 32)]; } /** * SLIP-0010 hardened-only child key derivation for Ed25519. * * @return array{0: string, 1: string} */ private static function ckdPriv(string $kPar, string $cPar, int $index): array { if (($index & 0x80000000) === 0) { throw new RuntimeException('Ed25519 SLIP-0010 only supports hardened derivation'); } $data = "\x00".$kPar.pack('N', $index); $I = hash_hmac('sha512', $data, $cPar, true); return [substr($I, 0, 32), substr($I, 32, 32)]; } private static function publicKeyFromSeed(string $seed32): string { if (strlen($seed32) !== 32) { throw new RuntimeException('Invalid Ed25519 seed length'); } if (! function_exists('sodium_crypto_sign_seed_keypair')) { throw new RuntimeException('ext-sodium is required for Solana address derivation'); } $kp = sodium_crypto_sign_seed_keypair($seed32); return sodium_crypto_sign_publickey($kp); } public static function base58Encode(string $binary): string { if ($binary === '') { return ''; } $leadingZeros = 0; $len = strlen($binary); for ($i = 0; $i < $len && $binary[$i] === "\x00"; $i++) { $leadingZeros++; } $num = gmp_init(bin2hex($binary), 16); $base = gmp_init(58); $out = ''; while (gmp_cmp($num, 0) > 0) { [$num, $rem] = [gmp_div_q($num, $base), gmp_intval(gmp_mod($num, $base))]; $out = self::ALPHABET[$rem].$out; } return str_repeat('1', $leadingZeros).($out === '' ? '1' : $out); } public static function base58Decode(string $input): ?string { $len = strlen($input); if ($len === 0) { return null; } $decoded = gmp_init(0); $base = gmp_init(58); for ($i = 0; $i < $len; $i++) { $pos = strpos(self::ALPHABET, $input[$i]); if ($pos === false) { return null; } $decoded = gmp_add(gmp_mul($decoded, $base), $pos); } $hex = gmp_strval($decoded, 16); if (strlen($hex) % 2 !== 0) { $hex = '0'.$hex; } $bin = $hex === '0' ? '' : (hex2bin($hex) ?: ''); $leadingZeros = 0; for ($i = 0; $i < $len && $input[$i] === '1'; $i++) { $leadingZeros++; } return str_repeat("\x00", $leadingZeros).$bin; } }