152 lines
4.4 KiB
PHP
152 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Chain;
|
|
|
|
use FurqanSiddiqui\BIP39\BIP39;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Solana address helpers: SLIP-0010 Ed25519 derivation + Base58.
|
|
*
|
|
* Phantom / Solflare path: m/44'/501'/{index}'/0'
|
|
*/
|
|
final class SolAddress
|
|
{
|
|
private const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
|
|
/**
|
|
* Derive Solana base58 address from BIP39 mnemonic at account index.
|
|
* Path: m/44'/501'/{index}'/0' (Phantom default).
|
|
*/
|
|
public static function fromMnemonic(string $mnemonic, int $index = 0): string
|
|
{
|
|
if ($index < 0) {
|
|
throw new RuntimeException('Invalid derive index');
|
|
}
|
|
|
|
$words = preg_split('/\s+/', trim($mnemonic)) ?: [];
|
|
if (count($words) < 12) {
|
|
throw new RuntimeException('Invalid mnemonic');
|
|
}
|
|
|
|
$seed = BIP39::Words($words)->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;
|
|
}
|
|
}
|