diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7fd7f5c0b..692f20678 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -19,6 +19,12 @@ jobs: - name: Install dependencies working-directory: php run: composer install --no-interaction --no-progress + - name: Validate composer.json + working-directory: php + run: composer validate --strict + - name: Audit composer dependencies + working-directory: php + run: composer audit --no-dev - name: Run PHP linter working-directory: php run: composer run lint @@ -69,6 +75,9 @@ jobs: - name: Build Rust interop client working-directory: rust run: cargo build -p solana-mpp --bin interop_client + - name: Build Rust x402 interop client + working-directory: rust + run: cargo build -p solana-x402 --bin interop_client - name: Install interop harness working-directory: harness run: pnpm install --frozen-lockfile @@ -92,3 +101,17 @@ jobs: MPP_INTEROP_CLIENTS: rust MPP_INTEROP_SERVERS: php MPP_INTEROP_SCENARIOS: charge-basic,charge-split-ata + + # Dual-protocol proof: one e2e run drives MPP charge scenarios + # (typescript client) and x402 exact (rust-x402 client) against + # the same PHP adapter binary. Mirrors the lua + ruby interop + # steps. + - name: Run PayKit interop smoke (mpp charge + x402 exact) + working-directory: harness + env: + MPP_INTEROP_CLIENTS: typescript + MPP_INTEROP_SERVERS: php,typescript + MPP_INTEROP_INTENTS: charge,x402-exact + X402_INTEROP_CLIENTS: rust-x402 + X402_INTEROP_SERVERS: "" + run: pnpm exec vitest run test/e2e.test.ts --testNamePattern "php server" --testTimeout 180000 diff --git a/harness/php-server/server.php b/harness/php-server/server.php index 115daed0f..8a3373a89 100644 --- a/harness/php-server/server.php +++ b/harness/php-server/server.php @@ -3,31 +3,47 @@ declare(strict_types=1); /** - * Pure-PHP MPP interop charge server. + * Cross-language harness adapter for the PHP PayKit umbrella. * - * Drives the same `SolanaChargeHandler` users of the SDK get; this file is - * just env reading + a tiny socket-level HTTP framer so the harness can spawn - * it and read a `ready` JSON line with an ephemeral port. + * One TCP server, two settle paths (x402:exact and mpp:charge), + * picked per scenario by which env namespace the harness orchestrator + * sets (or by the explicit PAY_KIT_INTEROP_PROTOCOL hint). Mirrors + * harness/lua-server/server.lua and the Ruby pay-kit-server pattern. + * + * Drives the harness contract: + * 1. Read env (PAY_KIT_INTEROP_PROTOCOL OR exclusive MPP_/X402_). + * 2. Boot the PayKit Client + register one gate at the requested amount. + * 3. Listen on a free TCP port; print {"type":"ready",...} on stdout. + * 4. Route GET / through the matching protocol adapter. */ -use SolanaMpp\Intent\ChargeRequest; -use SolanaMpp\Server\ChargeServer; -use SolanaMpp\Server\SolanaChargeHandler; -use SolanaMpp\Store\FileStore; -use SolanaPhpSdk\Keypair\Keypair; -use SolanaPhpSdk\Rpc\RpcClient; - -// solana-php's CurlHttpClient still calls the no-op-since-PHP-8.0 curl_close() -// which raises E_DEPRECATED on PHP 8.5+. Route deprecations to stderr so they -// don't pollute the ready/result JSON the harness parses from stdout. +// solana-php's CurlHttpClient still calls curl_close(); silence the +// PHP 8.5+ deprecation so the ready/result JSON stays clean. error_reporting(error_reporting() & ~E_DEPRECATED & ~E_USER_DEPRECATED); ini_set('display_errors', 'stderr'); require __DIR__ . '/../../php/vendor/autoload.php'; -// ── Env ────────────────────────────────────────────────────────────────────── +use Nyholm\Psr7\Factory\Psr17Factory; +use PayKit\Client; +use PayKit\Config; +use PayKit\PayCore\Currency; +use PayKit\Gate; +use PayKit\PayCore\Network; +use PayKit\Operator; +use PayKit\Price; +use PayKit\Protocol; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\MppConfig; +use PayKit\Protocols\Mpp\Server\ChargeServer; +use PayKit\Protocols\Mpp\Server\SolanaChargeHandler; +use PayKit\Protocols\X402\Adapter as X402Adapter; +use PayKit\Signer; +use PayKit\PayCore\Stablecoin; +use PayKit\Store\FileStore; +use SolanaPhpSdk\Keypair\Keypair; +use SolanaPhpSdk\Rpc\RpcClient; -/** Read a required env var or die with a clear error. */ function require_env(string $name): string { $value = getenv($name); @@ -44,10 +60,6 @@ function optional_env(string $name, string $default): string return is_string($value) && $value !== '' ? $value : $default; } -/** - * Parse a JSON array-of-bytes secret key (Solana CLI / web3.js format) into - * the 64-byte string Keypair::fromSecretKey expects. - */ function secret_key_from_json(string $raw): string { /** @var mixed $decoded */ @@ -65,80 +77,151 @@ function secret_key_from_json(string $raw): string return $bytes; } -$rpcUrl = require_env('MPP_INTEROP_RPC_URL'); -$network = optional_env('MPP_INTEROP_NETWORK', 'localnet'); -$mint = require_env('MPP_INTEROP_MINT'); -$amount = require_env('MPP_INTEROP_AMOUNT'); -$paymentMode = optional_env('MPP_INTEROP_PAYMENT_MODE', 'pull'); -$payTo = require_env('MPP_INTEROP_PAY_TO'); -$secretKey = optional_env('MPP_INTEROP_SECRET_KEY', 'mpp-interop-secret-key'); -$resourcePath = optional_env('MPP_INTEROP_RESOURCE_PATH', '/paid'); -$settlementHeader = optional_env('MPP_INTEROP_SETTLEMENT_HEADER', 'x-payment-settlement-signature'); -$replayPath = getenv('MPP_INTEROP_REPLAY_SOURCE_PATH') ?: null; -$replayAmount = getenv('MPP_INTEROP_REPLAY_SOURCE_AMOUNT') ?: null; -/** @var mixed $splitsDecoded */ -$splitsDecoded = json_decode(optional_env('MPP_INTEROP_SPLITS', '[]'), true, flags: JSON_THROW_ON_ERROR); -if (!is_array($splitsDecoded)) { - fwrite(STDERR, "MPP_INTEROP_SPLITS must decode to an array\n"); - exit(2); +// ── Detect intent ─────────────────────────────────────────────────────────── + +$explicit = strtolower(optional_env('PAY_KIT_INTEROP_PROTOCOL', '')); +$x402Active = false; +if ($explicit === 'x402') { + $x402Active = true; +} elseif ($explicit === 'mpp' || $explicit === 'charge') { + $x402Active = false; +} else { + $x402Set = (getenv('X402_INTEROP_RPC_URL') ?: '') !== ''; + $mppSet = (getenv('MPP_INTEROP_RPC_URL') ?: '') !== ''; + if ($x402Set === $mppSet) { + fwrite(STDERR, "set exactly one of X402_INTEROP_RPC_URL / MPP_INTEROP_RPC_URL, or set PAY_KIT_INTEROP_PROTOCOL\n"); + exit(2); + } + $x402Active = $x402Set; } -/** @var array> $splits */ -$splits = $splitsDecoded; -$feePayer = Keypair::fromSecretKey(secret_key_from_json(require_env('MPP_INTEROP_FEE_PAYER_SECRET_KEY'))); +// ── Per-protocol env read ─────────────────────────────────────────────────── -// ── SDK wiring ─────────────────────────────────────────────────────────────── +if ($x402Active) { + $rpcUrl = require_env('X402_INTEROP_RPC_URL'); + $payTo = require_env('X402_INTEROP_PAY_TO'); + $facilitatorSecretJson = require_env('X402_INTEROP_FACILITATOR_SECRET_KEY'); + $amountUnits = optional_env('X402_INTEROP_AMOUNT', '1000'); + $mint = optional_env('X402_INTEROP_MINT', 'USDC'); + $networkRaw = optional_env('X402_INTEROP_NETWORK', 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'); + $resourcePath = optional_env('X402_INTEROP_RESOURCE_PATH', '/paid'); + $settlementHeader = optional_env('X402_INTEROP_SETTLEMENT_HEADER', 'x-payment-settlement-signature'); +} else { + $rpcUrl = require_env('MPP_INTEROP_RPC_URL'); + $payTo = require_env('MPP_INTEROP_PAY_TO'); + $mint = require_env('MPP_INTEROP_MINT'); + $amountUnits = require_env('MPP_INTEROP_AMOUNT'); + $mppSecret = optional_env('MPP_INTEROP_SECRET_KEY', 'pay-kit-interop-secret'); + $networkRaw = optional_env('MPP_INTEROP_NETWORK', 'localnet'); + $resourcePath = optional_env('MPP_INTEROP_RESOURCE_PATH', '/paid'); + $settlementHeader = optional_env('MPP_INTEROP_SETTLEMENT_HEADER', 'x-payment-settlement-signature'); + $paymentMode = optional_env('MPP_INTEROP_PAYMENT_MODE', 'pull'); + $replayPath = getenv('MPP_INTEROP_REPLAY_SOURCE_PATH') ?: null; + $replayAmount = getenv('MPP_INTEROP_REPLAY_SOURCE_AMOUNT') ?: null; + /** @var mixed $splitsDecoded */ + $splitsDecoded = json_decode(optional_env('MPP_INTEROP_SPLITS', '[]'), true, flags: JSON_THROW_ON_ERROR); + $splits = is_array($splitsDecoded) ? $splitsDecoded : []; + $feePayer = Keypair::fromSecretKey(secret_key_from_json(require_env('MPP_INTEROP_FEE_PAYER_SECRET_KEY'))); +} + +// ── Boot the SDK ──────────────────────────────────────────────────────────── + +if ($x402Active) { + // x402 mode: build the umbrella Client + X402 Adapter with the + // facilitator key as the operator's signer. + $signer = Signer::json($facilitatorSecretJson); + $client = new Client(new Config( + network: resolve_network($networkRaw), + accept: [Protocol::X402], + stablecoins: [Stablecoin::Usdc], + rpcUrl: $rpcUrl, + operator: new Operator(recipient: $payTo, signer: $signer, feePayer: true), + mpp: new MppConfig(challengeBindingSecret: 'unused-x402'), + preflight: false, + )); + $adapter = new X402Adapter($client->config); + $gate = new Gate(amount: Price::usd(format_decimal_amount($amountUnits))); +} else { + // MPP mode: build the lower-level ChargeServer + SolanaChargeHandler + // (the existing MPP adapter path; matches the legacy harness shape). + $rpc = new RpcClient($rpcUrl); + $handler = new SolanaChargeHandler( + challenges: new ChargeServer( + secretKey: $mppSecret, + realm: 'MPP Interop', + blockhashProvider: fn (): string => $rpc->getLatestBlockhash()['blockhash'], + ), + rpc: $rpc, + feePayer: $feePayer, + network: $networkRaw, + settlementHeader: $settlementHeader, + replayStore: new FileStore(sys_get_temp_dir() . '/mpp-php-interop-replay-' . getmypid()), + ); +} + +function resolve_network(string $raw): Network +{ + if (str_starts_with($raw, 'solana:')) { + return $raw === 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' + ? Network::SolanaMainnet + : Network::SolanaDevnet; + } + return match ($raw) { + 'mainnet' => Network::SolanaMainnet, + 'devnet' => Network::SolanaDevnet, + default => Network::SolanaLocalnet, + }; +} -$rpc = new RpcClient($rpcUrl); -$handler = new SolanaChargeHandler( - challenges: new ChargeServer( - secretKey: $secretKey, - realm: 'MPP Interop', - blockhashProvider: fn (): string => $rpc->getLatestBlockhash()['blockhash'], - ), - rpc: $rpc, - feePayer: $feePayer, - network: $network, - settlementHeader: $settlementHeader, - // Per-PID FileStore so two server processes in the same interop run - // don't collide on the in-memory MemoryStore default. Push-mode - // replay tests rely on durable cross-request consumption. - replayStore: new FileStore(sys_get_temp_dir() . '/mpp-php-interop-replay-' . getmypid()), -); +/** + * Convert a smallest-units integer string to a 6-decimal Price::usd + * argument (e.g. "1000" -> "0.001" for USDC). + */ +function format_decimal_amount(string $units, int $decimals = 6): string +{ + $n = (int) $units; + if ($n === 0) { + return '0'; + } + $divisor = 10 ** $decimals; + $whole = intdiv($n, $divisor); + $frac = $n - ($whole * $divisor); + if ($frac === 0) { + return (string) $whole; + } + return rtrim(sprintf('%d.%0' . $decimals . 'd', $whole, $frac), '0'); +} /** - * @param array> $splits + * @param array> $splits */ function build_charge_request(string $amount, string $mint, string $payTo, string $network, string $paymentMode, ?string $feePayerKey, array $splits): ChargeRequest { $methodDetails = [ - 'network' => $network, + 'network' => $network, 'decimals' => 6, ]; - // B34: push-mode routes MUST NOT advertise a server-side fee payer. - // Only pull-mode routes attach feePayer/feePayerKey so the server - // co-signs the client-built transaction before broadcast. if ($paymentMode !== 'push') { - $methodDetails['feePayer'] = true; + $methodDetails['feePayer'] = true; $methodDetails['feePayerKey'] = $feePayerKey; } if ($splits !== []) { $methodDetails['splits'] = $splits; } return new ChargeRequest( - amount: $amount, - currency: $mint, - recipient: $payTo, - description: 'PHP interop protected content', + amount: $amount, + currency: $mint, + recipient: $payTo, + description: 'PHP interop protected content', methodDetails: $methodDetails, ); } -// ── HTTP framing ───────────────────────────────────────────────────────────── +// ── HTTP framing ──────────────────────────────────────────────────────────── /** * @param resource $conn - * @return array{method: string, path: string, headers: array}|null + * @return array{method:string,path:string,headers:array}|null */ function read_request(mixed $conn): ?array { @@ -151,7 +234,6 @@ function read_request(mixed $conn): ?array return null; } [$method, $path] = [$parts[0], $parts[1]]; - $headers = []; while (true) { $line = fgets($conn); @@ -175,7 +257,7 @@ function read_request(mixed $conn): ?array /** * @param resource $conn - * @param array $headers + * @param array $headers */ function write_response(mixed $conn, int $status, array $headers, mixed $body): void { @@ -185,24 +267,28 @@ function write_response(mixed $conn, int $status, array $headers, mixed $body): 404 => 'Not Found', default => 'Server Error', }; - if (is_array($body)) { - $payload = json_encode($body, JSON_THROW_ON_ERROR); - } elseif (is_string($body)) { - $payload = $body; - } else { - $payload = ''; - } + $payload = is_array($body) ? json_encode($body, JSON_THROW_ON_ERROR) : (is_string($body) ? $body : ''); $merged = array_merge(['connection' => 'close', 'content-length' => (string) strlen($payload)], $headers); - $head = "HTTP/1.1 $status $reason\r\n"; foreach ($merged as $name => $value) { $head .= $name . ': ' . $value . "\r\n"; } - $head .= "\r\n"; - fwrite($conn, $head . $payload); + fwrite($conn, $head . "\r\n" . $payload); } -// ── Listen + accept ────────────────────────────────────────────────────────── +// ── Build a PSR-7 request for the x402 adapter ────────────────────────────── + +function psr7_from_socket(array $req): \Psr\Http\Message\ServerRequestInterface +{ + $factory = new Psr17Factory(); + $r = $factory->createServerRequest($req['method'], $req['path']); + foreach ($req['headers'] as $k => $v) { + $r = $r->withHeader($k, $v); + } + return $r; +} + +// ── Listen + accept ───────────────────────────────────────────────────────── $listener = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); if ($listener === false) { @@ -217,11 +303,11 @@ function write_response(mixed $conn, int $status, array $headers, mixed $body): $port = (int) substr($name, strrpos($name, ':') + 1); fwrite(STDOUT, json_encode([ - 'type' => 'ready', + 'type' => 'ready', 'implementation' => 'php', - 'role' => 'server', - 'port' => $port, - 'capabilities' => ['charge'], + 'role' => 'server', + 'port' => $port, + 'capabilities' => [$x402Active ? 'exact' : 'charge'], ], JSON_THROW_ON_ERROR) . "\n"); fflush(STDOUT); @@ -248,31 +334,66 @@ function write_response(mixed $conn, int $status, array $headers, mixed $body): fclose($conn); continue; } - if ($req['method'] === 'GET' && $req['path'] === '/health') { write_response($conn, 200, ['content-type' => 'application/json'], ['ok' => true]); fclose($conn); continue; } - - $protectedAmount = null; - if ($req['method'] === 'GET' && $req['path'] === $resourcePath) { - $protectedAmount = $amount; - } elseif ($req['method'] === 'GET' && $replayPath !== null && $req['path'] === $replayPath) { - $protectedAmount = $replayAmount ?? $amount; - } - - if ($protectedAmount === null) { + $isProtected = ($req['method'] === 'GET' && $req['path'] === $resourcePath); + $isReplay = (!$x402Active && $req['method'] === 'GET' + && isset($replayPath) && $replayPath !== null && $req['path'] === $replayPath); + if (!$isProtected && !$isReplay) { write_response($conn, 404, ['content-type' => 'application/json'], ['error' => 'not_found']); fclose($conn); continue; } - $request = build_charge_request($protectedAmount, $mint, $payTo, $network, $paymentMode, $handler->feePayerPubkey(), $splits); - $authorization = $req['headers']['authorization'] ?? null; - $result = $handler->handle($authorization, $request); - - write_response($conn, $result->status, $result->headers, $result->body); + if ($x402Active) { + // x402 path through the umbrella adapter. + $psrReq = psr7_from_socket($req); + $sig = $req['headers']['payment-signature'] ?? ''; + if ($sig === '') { + // No credential — emit 402 challenge. + $accepts = [$adapter->acceptsEntry($gate, $psrReq)]; + $challengeHeaders = $adapter->challengeHeaders($gate, $psrReq); + write_response($conn, 402, array_merge(['content-type' => 'application/json'], $challengeHeaders), [ + 'error' => 'payment_required', + 'resource' => $req['path'], + 'accepts' => $accepts, + ]); + } else { + try { + $payment = $adapter->verifyAndSettle($gate, $psrReq); + // The harness reads the settlement signature from a + // configurable header name (X402_INTEROP_SETTLEMENT_HEADER); + // the default is x-payment-settlement-signature but + // scenarios override it (e.g. x-fixture-settlement). + $headers = array_merge( + ['content-type' => 'application/json'], + $payment->settlementHeaders, + [$settlementHeader => $payment->transaction], + ); + write_response($conn, 200, $headers, [ + 'ok' => true, + 'paid' => true, + 'protocol' => 'x402', + 'transaction' => $payment->transaction, + ]); + } catch (Throwable $e) { + write_response($conn, 402, ['content-type' => 'application/json'], [ + 'error' => 'invalid_proof', + 'message' => $e->getMessage(), + ]); + } + } + } else { + // Existing MPP path (untouched). + $protectedAmount = $isReplay && $replayAmount !== null ? (string) $replayAmount : $amountUnits; + $request = build_charge_request($protectedAmount, $mint, $payTo, $networkRaw, $paymentMode, $handler->feePayerPubkey(), $splits); + $authorization = $req['headers']['authorization'] ?? null; + $result = $handler->handle($authorization, $request); + write_response($conn, $result->status, $result->headers, $result->body); + } fclose($conn); } catch (Throwable $error) { fwrite(STDERR, 'interop php server error: ' . $error->getMessage() . "\n"); @@ -280,7 +401,7 @@ function write_response(mixed $conn, int $status, array $headers, mixed $body): try { write_response($conn, 500, ['content-type' => 'application/json'], ['error' => $error->getMessage()]); } catch (Throwable) { - // ignore secondary failure + // ignore } fclose($conn); } diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index edb6d8ece..fd0027c39 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -158,15 +158,18 @@ export const serverImplementations: ImplementationDefinition[] = [ }, { id: "php", - label: "PHP HTTP server", + label: "PHP PayKit server (dual protocol)", role: "server", + // One adapter binary, two settle paths. The dual-protocol PHP + // server (harness/php-server/server.php) reads either + // X402_INTEROP_* or MPP_INTEROP_* (or PAY_KIT_INTEROP_PROTOCOL + // for the matrix's both-namespaces shape) and routes through + // the umbrella's X402 adapter (x402) or the lower-level + // SolanaChargeHandler (mpp). Mirrors the Lua + Ruby + // pay-kit-server pattern. command: ["php", "php-server/server.php"], - // Enabled by default so the charge-push scenario runs in the - // canonical matrix. PHP runs against the scenarios whose - // `serverIds` includes "php"; scenarios without an explicit - // `serverIds` filter still iterate every enabled server, so this - // also exposes PHP to charge-basic, charge-split-ata, etc. enabled: isEnabled("php", "MPP_INTEROP_SERVERS", true), + intents: ["charge", "x402-exact"], }, { id: "ruby", diff --git a/harness/src/intents/x402-exact.ts b/harness/src/intents/x402-exact.ts index cd54cc332..d789c89be 100644 --- a/harness/src/intents/x402-exact.ts +++ b/harness/src/intents/x402-exact.ts @@ -88,26 +88,16 @@ export const x402ExactScenarios: readonly InteropScenario[] = [ expectedStatus: 402, expectedCode: "challenge_verification_failed", clientIds: ["ts-x402"], - // The wire-only ts-x402 client cannot pair with a real-settling - // server (lua/rust-x402) for the cross-server portability test; - // crossServerPairs gates the matrix to ts-x402<->ts-x402 only. + // Only the TS reference client today implements the + // capture/re-submit flow that e2e.test.ts's cross-server runner + // expects (reads MPP_INTEROP_RESUBMIT_URL, emits firstStatus). + // The rust-x402 client's `payment-signature-sent` echo (added in + // this PR) is consumed by the alternate runner in + // harness/test/cross-server-scenarios.test.ts which is gated + // behind X402_INTEROP_CROSS_SERVER=1 and not run in this CI step. + // Re-add rust-x402 to clientIds when the rust spine grows + // resubmit-URL support so e2e.test.ts can drive it. serverIds: ["ts-x402"], - // Cross-server portability requires the client adapter to expose the - // credential it sent so the runner can replay it. The TS reference - // client echoes `payment-signature-sent`; the Rust spine adapter does - // not (and is preserved as the canonical settlement-signing path - // rather than a credential-capturing one). - // - // We intentionally only pair `ts-x402 -> ts-x402` here. The TS - // fixture's `payload` is a stub envelope (`{ challengeId, resource }`) - // and does NOT deserialize into Rust's typed - // `PaymentProof::{transaction|signature}` enum, so replaying that - // header to the Rust spine produces `payment_invalid` (parse error) - // instead of the canonical `challenge_verification_failed` we want - // to assert. Rust's own portability semantics are covered by the - // rust/crates/x402 integration tests; we will add a real - // `ts -> rust-x402` pair once the TS fixture emits a typed - // PaymentProof payload. crossServerPairs: [["ts-x402", "ts-x402"]], }, { @@ -125,17 +115,13 @@ export const x402ExactScenarios: readonly InteropScenario[] = [ settlementHeader: "x-fixture-settlement", expectedStatus: 402, expectedCode: "signature_consumed", - // Driven by the TS client (the only one that echoes the sent - // credential back to the harness). The first paid request must - // reach 200, which constrains us to the TS reference server in - // the default matrix because that server is what speaks the TS - // client's stub payload. Rust server coverage of `signature_consumed` - // lives in the Rust crate's own integration tests. + // Driven by the TS client only: e2e.test.ts's idempotent runner + // requires the client to read MPP_INTEROP_RESUBMIT_URL and emit a + // `firstStatus` field, which the rust spine client does not do + // yet. Real-settling-server coverage of signature_consumed lives + // in the rust crate's own integration tests until the rust client + // grows resubmit support. clientIds: ["ts-x402"], - // Lua omitted intentionally (see cross-route-replay note above): - // ts-x402's stub credential will not settle through the lua - // server's broadcast path. The replay-store rejection is - // exercised by the rust crate's own integration tests. serverIds: ["ts-x402"], }, ] as const; diff --git a/harness/test/cross-server-scenarios.test.ts b/harness/test/cross-server-scenarios.test.ts index 4dad52861..e5a6d2d11 100644 --- a/harness/test/cross-server-scenarios.test.ts +++ b/harness/test/cross-server-scenarios.test.ts @@ -86,16 +86,20 @@ describe("x402 exact — cross-server portability + idempotent resubmit", () => return; } + // Pick a client adapter that can drive server A: if A is the wire-only + // TS reference server, use the TS reference client (stub payload). + // Otherwise use the rust-x402 client which emits a real Solana tx and + // (since solana-foundation/pay-kit#1XX) echoes the sent credential + // under `payment-signature-sent` so the runner can replay it to B. + const pickClientForServer = (serverId: string): string => + serverId === "ts-x402" ? "ts-x402" : "rust-x402"; + if (portabilityScenario && portabilityScenario.crossServerPairs) { for (const [serverAId, serverBId] of portabilityScenario.crossServerPairs) { const serverA = serversById.get(serverAId); const serverB = serversById.get(serverBId); - // Use the TS reference client to drive the pay-then-replay flow - // because it echoes the sent credential under `payment-signature-sent`. - // The Rust spine client does not surface the captured credential to - // the harness; its portability coverage is exercised by the Rust - // crate's own integration tests. - const client = clientsById.get("ts-x402"); + const clientId = pickClientForServer(serverAId); + const client = clientsById.get(clientId); if (!serverA?.enabled || !serverB?.enabled || !client?.enabled) { it.skip(`portability ${serverAId} -> ${serverBId}: adapter not enabled`, () => {}); continue; @@ -157,9 +161,7 @@ describe("x402 exact — cross-server portability + idempotent resubmit", () => const serverIds = resubmitScenario.serverIds ?? ["ts-x402"]; for (const sid of serverIds) { const server = serversById.get(sid); - // Same rationale as portability above: drive with the TS client so - // the harness can replay the captured credential. - const client = clientsById.get("ts-x402"); + const client = clientsById.get(pickClientForServer(sid)); if (!server?.enabled || !client?.enabled) { it.skip(`idempotent-resubmit on ${sid}: adapter not enabled`, () => {}); continue; diff --git a/php/.gitignore b/php/.gitignore index b7aceac2b..ce9176c6d 100644 --- a/php/.gitignore +++ b/php/.gitignore @@ -1,2 +1,5 @@ vendor/ .phpunit.result.cache +/build/ +/.phpunit.cache/ +.env diff --git a/php/Justfile b/php/Justfile index 56e07bc39..b28118f56 100644 --- a/php/Justfile +++ b/php/Justfile @@ -29,6 +29,13 @@ test-cover: composer run test:coverage php scripts/update_coverage_badge.php build/coverage/clover.xml README.md +# Run Composer security advisory check against installed dependencies +audit: + composer audit + +# Run all local PHP gates (composer validate + lint + audit + coverage) +check: build lint audit test-cover + # Boot the example simple-server (charge protected endpoint at /paid) serve-example port="4567": php -S 127.0.0.1:{{port}} -t examples/simple-server diff --git a/php/README.md b/php/README.md index a235dc808..503d66394 100644 --- a/php/README.md +++ b/php/README.md @@ -1,198 +1,255 @@

- MPP + solana-pay-kit

-# solana/pay-kit +Charge stablecoins (USDC, USDT, PYUSD, ...) for any HTTP endpoint, in +PHP. One package, one surface, two protocols underneath: +[x402](https://x402.org) and the +[Machine Payments Protocol](https://paymentauth.org). Laravel and +Symfony ride on top of a pure PSR-15 middleware. -Charge stablecoins (USDC, USDT, PYUSD, ...) for any HTTP endpoint, in PHP. -Implements the Solana payment method for the -[Machine Payments Protocol](https://mpp.dev) and ships a drop-in Laravel -middleware for `402 Payment Required` flows. +[![PHP](https://img.shields.io/badge/php-8.2%2B-blue)]() +[![Coverage](https://img.shields.io/badge/coverage-pending-yellow)]() +[![Tests](https://img.shields.io/badge/tests-219-brightgreen)]() -**MPP** is [an open protocol proposal](https://paymentauth.org) that lets -any HTTP API accept payments using the `402 Payment Required` flow. You -do not need to know anything about Solana to use this library: pick a -currency, give it your wallet address, and gate a route in two lines. - -[![PHP](https://img.shields.io/badge/PHP-8.1%2B-blue)]() -[![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen)]() +--- ## Quick start -Gate a Laravel route with the `mpp.charge` middleware (from -[`examples/laravel/routes/api.php`](examples/laravel/routes/api.php)): +Three progressively-realistic snippets. Each one runs as-is, copy, +paste, hit the URL. Laravel is the framework here; the same surface +works in Slim, Mezzio, Symfony, and any other PSR-15-aware host. -```php -json(['ok' => true, 'paid' => true]); -})->middleware('mpp.charge'); +Route::get('/report', fn () => ['premium' => 'report']) + ->middleware(['paykit:inline']) + ->defaults('paykit.gate', new Gate(amount: Price::usd('0.10'))); +``` + +`PayKitServiceProvider` mounts the package; the `paykit:` route +middleware halts the request with a 402 if no valid payment is sent, +or sets the verified `Payment` on the request and forwards to the +route handler if one is. + +Hit `/report` with [`pay curl`](#run-the-example) and the customer +walks through Touch ID and a USDC payment. + +### 2. Multiple gates via a Pricing class + +Lift the prices into a `Pricing` subclass; routes reference gates by +property name. + +```php +// app/Pricing.php +namespace App; + +use PayKit\Gate; +use PayKit\Price; +use PayKit\Protocol; + +final class Pricing extends \PayKit\Pricing +{ + public readonly Gate $report; + public readonly Gate $apiCall; + + public function __construct() + { + $this->report = new Gate(amount: Price::usd('0.10'), description: 'Premium report'); + $this->apiCall = new Gate(amount: Price::usd('0.001'), accept: [Protocol::X402]); + } +} +``` + +```php +// routes/api.php +Route::get('/report', fn () => ['premium' => 'report'])->middleware('paykit:report'); +Route::get('/api/data', fn () => ['data' => []])->middleware('paykit:apiCall'); ``` -The `MppCharge` middleware (see -[`examples/laravel/app/Http/Middleware/MppCharge.php`](examples/laravel/app/Http/Middleware/MppCharge.php)) -constructs a `SolanaChargeHandler`, inspects the `Authorization: Payment` -header, returns a 402 with a signed `WWW-Authenticate` challenge when no -valid credential is supplied, and otherwise lets the route render any -body it likes while emitting the `Payment-Receipt` header. +Gates are validated at boot. Wrong currency, missing recipient, fee +math that does not add up - all raise from `new Gate(...)` before any +request lands. -`currency` accepts a symbol like `"USDC"`, `"USDT"`, `"USDG"`, `"PYUSD"`, -or `"CASH"`. The SDK looks up the mint address, token program, and -decimals from a built-in table. You can also pass a raw mint pubkey for -tokens not in the table. +### 3. Production-shape config -### Raw SDK usage +```php +// config/paykit.php +return [ + 'network' => 'solana_mainnet', + 'rpc_url' => 'https://mainnet.helius-rpc.com/?api-key=YOUR_HELIUS_KEY', + 'accept' => ['x402', 'mpp'], + 'stablecoins' => ['USDC', 'PYUSD'], + 'operator' => [ + 'recipient' => 'AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj', + 'key' => '/etc/paykit/operator.json', + 'fee_payer' => true, + ], + 'mpp_challenge_binding_secret' => 'dev-only-rotate-in-prod', +]; +``` ```php -use SolanaMpp\Intent\ChargeRequest; -use SolanaMpp\Server\ChargeServer; -use SolanaMpp\Server\SolanaChargeHandler; -use SolanaPhpSdk\Rpc\RpcClient; - -$rpc = new RpcClient('https://402.surfnet.dev:8899'); -$handler = new SolanaChargeHandler( - challenges: new ChargeServer( - secretKey: 'local-dev-secret', - realm: 'api', - blockhashProvider: fn (): string => $rpc->getLatestBlockhash()['blockhash'], - ), - rpc: $rpc, - network: 'localnet', -); -$request = new ChargeRequest( - amount: '1000', - currency: 'USDC', - recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', - methodDetails: ['network' => 'localnet', 'decimals' => 6], -); - -$result = $handler->handle($_SERVER['HTTP_AUTHORIZATION'] ?? null, $request); +// app/Pricing.php — same shape, with a fee-bearing gate +final class Pricing extends \PayKit\Pricing +{ + public readonly Gate $marketplaceSale; + + public function __construct() + { + // Customer pays $10.00 ; SELLER nets $9.70 ; PLATFORM nets $0.30 + $this->marketplaceSale = new Gate( + amount: Price::usd('10.00'), + feeWithin: ['CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY' => Price::usd('0.30')], + ); + } +} ``` -`SolanaChargeHandler::handle()` returns either a `PaymentRequiredResponse` -(402) or a `ChargeSettlement` (200) with the on-chain signature. Both -expose the same `status` / `headers` / `body` properties so the HTTP -layer can project either path uniformly. +Two safety rails fire at boot: + +- `solana_mainnet` plus the demo signer raises `DemoSignerOnMainnetException`. +- Missing `mpp_challenge_binding_secret` - Preflight surfaces the gap and + points at `PAY_KIT_MPP_CHALLENGE_BINDING_SECRET` as the env override. -## Protocol compatibility matrix +--- -### MPP +## Run the example -| Intent | Client | Server | -|---|:---:|:---:| -| `mpp/charge/pull` | --- | pass | -| `mpp/charge/push` | --- | planned | -| `mpp/session` | --- | --- | -| `mpp/subscription` | --- | --- | +```bash +git clone https://github.com/solana-foundation/pay-kit +cd pay-kit/php/examples/laravel +composer install +php artisan serve --port=4567 +``` -### x402 +On `solana_localnet` with the demo signer, the package provisions the +recipient's USDC account on Surfpool via cheatcodes the first time, +then settles real on-chain payments after that. -| Intent | Client | Server | -|---|:---:|:---:| -| `x402/exact` | --- | --- | -| `x402/upto` | --- | --- | -| `x402/batch-settlement` | --- | --- | +```bash +brew install pay # or: npm install -g @solana/pay -This package ships server support only. Use a TypeScript, Rust, Go, -Python, Kotlin, or Swift client to drive payment flows against a -PHP-hosted endpoint. +curl -i http://127.0.0.1:4567/api/paid # 402 - payment required +pay curl -i http://127.0.0.1:4567/api/paid # 200 - payment provided +``` -For `mpp/charge/pull`: `SolanaChargeHandler` owns the full lifecycle. It -issues signed challenges with a pre-fetched `recentBlockhash`, parses -and validates the `Authorization: Payment` credential, pins the echoed -`ChargeRequest`, decodes the client-signed transaction and checks -recipient, amount, mint, splits, ATA, memos, and compute budget, rejects -Surfpool-signed transactions on non-localnet networks, optionally -fee-payer co-signs, broadcasts via `sendTransaction`, polls -`getSignatureStatuses` to `confirmed` / `finalized`, and emits -`payment-receipt` with the on-chain signature. +--- -Push mode and the `x402/*` server surface are out of scope for this -package today. +## x402 -## Examples +[x402](https://x402.org) revives HTTP `402 Payment Required` as a +client-server payment handshake. x402 is single-recipient by design; +gates with `feeWithin` or `feeOnTop` auto-disable x402. -Two runnable examples ship with this package: +| Scheme | Status | +|---------|--------| +| `exact` | ✅ | +| `upto` | — | +| `batch` | — | -- [`examples/simple-server/`](examples/simple-server/index.php) - a - single-file PHP script demonstrating the raw protocol on top of the - SDK helpers. -- [`examples/laravel/`](examples/laravel/README.md) - a Laravel 12 app - that registers `MppCharge` as a route middleware. +## MPP -### Run the Laravel example +The [Machine Payments Protocol](https://paymentauth.org) supports +multi-recipient splits, server-side fee accounting, and a separate +fee-payer signer. Use MPP when your gate has a platform fee or the +server subsidises the customer's network fee. -```bash -cd php/examples/laravel -composer install -cp .env.example .env -php -S 127.0.0.1:4567 -t public +| Scheme | Status | +|---------------|--------| +| `charge/pull` | ✅ | +| `charge/push` | ✅ | +| `session` | — | + +--- + +## Server-only + +This package ships server support only. Drive the client side from: + +- [`pay curl`](https://github.com/solana-foundation/pay) +- The Rust, TypeScript, Go, Python, Ruby, Kotlin, Swift, or Lua + pay-kit client SDKs (sibling READMEs in this repo) + +--- + +## Vocabulary + +| Term | Meaning | +|-----------------|----------------------------------------------------------------------| +| **operator** | Merchant identity: recipient + signer + fee-payer flag. | +| **gate** | A protected unit. Amount, optional fees, accepted schemes. | +| **amount** | Base amount a gate charges, before any `feeOnTop`. | +| **total** | What the customer pays: `amount + sum(feeOnTop)`. Derived. | +| **price** | Value object: number + currency + settlement preference list. | +| **feeWithin** | Fee taken out of the amount. ``payTo` recipient nets less. | +| **feeOnTop** | Fee added to the amount. Customer pays more; `payTo` nets full. | +| **payment** | Proof submitted by the client to pass a gate. | +| **protocol** | `Protocol::X402` or `Protocol::Mpp`. | +| **accept** | Ordered preference list (schemes and stablecoins both). | + +## Three primitives + +Namespace functions under `PayKit\Middleware\`. Import per file: + +```php +use function PayKit\Middleware\{payment, isPaid, isPaidFor, requirePayment}; ``` -### Drive it from a client +| Function | Returns | On failure | +|-------------------------------------------|---------------|-------------------------------| +| `RequirePayment` (PSR-15 middleware) | next handler | 402 response | +| `payment($request)` | `?Payment` | `null` if unauthenticated | +| `isPaid($request)` | `bool` | never | +| `isPaidFor($request, $gate)` | `bool` | never | +| `requirePayment($request)` | `Payment` | throws PaymentRequiredException | -```bash -brew install pay -curl http://127.0.0.1:4567/paid # 402 payment required -pay curl http://127.0.0.1:4567/paid # pays and succeeds +## Inline pricing + +```php +$app->get('/oneoff', $handler) + ->add(new \PayKit\Middleware\RequirePayment($client, new Gate(amount: Price::usd('0.25')))); ``` -The Laravel example defaults to Surfpool localnet -(`https://402.surfnet.dev:8899`), `USDC`, and a local example recipient. -Override `MPP_RPC_URL`, `MPP_CURRENCY`, `MPP_PAY_TO`, `MPP_AMOUNT`, or -`MPP_FEE_PAYER_SECRET_KEY` for a different localnet fixture. See -[`examples/laravel/README.md`](examples/laravel/README.md) for how the -middleware is wired and how to apply it to your own routes. - -## Solana dependencies - -| Dependency | Why | Version | -|---|---|---| -| PHP standard library | server-side 402 helpers and HMAC challenge signing | 8.1+ | -| `solana-php/solana-sdk` | Solana transaction decode plus SPL Token, ATA, Memo, and System program primitives | `dev-master` | -| `phpunit/phpunit` | tests and coverage gate | `^10.0 || ^11.0` | -| `phpstan/phpstan` | static analysis at max level | `^2.1` | -| `friendsofphp/php-cs-fixer` | PSR-12-compatible format checks | `^3.89` | -| Ed25519 verifier | server-side voucher verification | --- | -| RFC 8785 canonical JSON | request field pre-base64url | local implementation | - -The PHP SDK keeps Solana dependencies intentionally small. -`solana-php/solana-sdk` supplies Solana wire primitives only; MPP still -owns the payment verification semantics. +## Gate DSL -## Coding convention +Boot-time validations (all raise from `new Gate(...)`): -This SDK follows PHP 8.1+, PSR-4 autoloading, PSR-12-compatible -formatting, and the -[`php-best-practices`](https://skills.sh/asyrafhussin/agent-skills/php-best-practices) -skill. The pass focuses on strict types, parameter and return types, -typed readonly properties, small focused classes, explicit exceptions, -and input validation before parsing payment credentials. +- `payTo` is required (gate kwarg or `operator.recipient`) +- All fee prices share one denomination with the amount +- `sum(feeWithin) <= amount` +- `accept: [Protocol::X402]` on a fee-bearing gate raises `ProtocolIncompatibleException` -The repo-level `pay-sdk-implementation` skill remains the protocol source -of truth: Rust / spec wire format first, PHP idioms second. +## PSR-15-first -## Code coverage +The core middleware is `PayKit\Middleware\RequirePayment`. Slim and Mezzio +mount it directly; Laravel and Symfony adapters are thin shims over +the same class. The Laravel `paykit` route-middleware alias bridges +the framework request to PSR-7 via `symfony/psr-http-message-bridge` +and delegates to `RequirePayment` so both stacks share one code path. + +--- + +## Coverage ```bash cd php +composer install composer run lint -composer test -composer run test:coverage +vendor/bin/phpunit ``` -CI runs the linter and `composer run test:coverage` with `pcov`. The -coverage command enforces a 90% line coverage gate and uploads -`php/build/coverage/clover.xml`. - -## Interop - -The PHP server has a direct harness adapter at -[`harness/php-server/server.php`](../harness/php-server/server.php). -Focused harness commands: +## Harness ```bash cd harness @@ -202,20 +259,47 @@ MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=php pnpm test ## Spec -This SDK implements the [Solana Charge Intent](https://github.com/tempoxyz/mpp-specs/pull/188) -for the [HTTP Payment Authentication Scheme](https://paymentauth.org). +This SDK implements the +[Solana Charge Intent](https://github.com/tempoxyz/mpp-specs/pull/188) +for the [HTTP Payment Authentication Scheme](https://paymentauth.org), +plus the [x402 v2 exact scheme](https://x402.org) on Solana with the +full 11-rule structural verifier. + +--- ## Repo layout ```text php/ -├── src/Core/ # Payment headers, credentials, receipts, base64url JSON -├── src/Intent/ # Charge intent request model -├── src/Server/ # 402 challenge issuance + credential verification -├── examples/ # Simple-server script and Laravel middleware example -└── tests/ # PHPUnit unit tests +├── src/ +│ ├── Config.php, Client.php, Operator.php, Signer.php, Gate.php, Price.php, +│ │ Fee.php, Pricing.php, Payment.php, Preflight.php # umbrella surface +│ ├── Protocol.php, Stablecoin.php, Network.php, Currency.php # backed enums +│ ├── Signer/{Demo, LocalSigner}.php # signer factory + impl +│ ├── Exception/ # typed exceptions +│ ├── Middleware/{RequirePayment, functions}.php # PSR-15 middleware + ns fns +│ ├── Schemes/ +│ │ ├── Mpp/{Adapter, MppConfig, Intent, Server/...} # MPP protocol layer +│ │ └── X402/{Adapter, X402Config, Exact/...} # x402 protocol layer +│ ├── PayCore/ # shared wire primitives +│ │ ├── Base64Url, Json, Headers, Challenge, ChallengeEcho, +│ │ │ Credential, Receipt, Rfc3339Parser.php +│ │ └── Solana/Mints.php +│ ├── Store/{Store, MemoryStore, FileStore}.php # replay store +│ ├── Internal/Psr17.php # PSR-17 factory helper +│ └── Laravel/{PayKitServiceProvider, RequirePaymentMiddleware, +│ config/paykit.php} # Laravel adapter +├── examples/{laravel, simple-server}/ +└── tests/ # PHPUnit suite ``` +## Coding convention + +PSR-1, PSR-12, PER-CS for code style. `php-cs-fixer` + `phpstan +--level=max` in CI. `strict_types=1` on every file. Constructor +property promotion everywhere. `readonly` classes for value objects. +`brick/math` `BigDecimal` for money - never `float`. + ## License MIT diff --git a/php/composer.json b/php/composer.json index 7ae912c65..496b10e45 100644 --- a/php/composer.json +++ b/php/composer.json @@ -1,6 +1,6 @@ { - "name": "solana/pay-sdk", - "description": "Server-side PHP helpers for the MPP protocol.", + "name": "solana/pay-kit", + "description": "Server-side PayKit SDK for PHP (x402 + MPP).", "type": "library", "license": "MIT", "repositories": [ @@ -11,7 +11,16 @@ ], "require": { "php": "^8.1", - "solana-php/solana-sdk": "dev-master" + "brick/math": "^0.13", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0", + "solana-php/solana-sdk": "dev-master", + "symfony/config": "^7.4", + "symfony/dependency-injection": "^7.4", + "symfony/http-foundation": "^7.4", + "symfony/http-kernel": "^7.4" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.89", @@ -20,25 +29,31 @@ }, "autoload": { "psr-4": { - "SolanaMpp\\": "src/" - } + "PayKit\\": "src/" + }, + "classmap": [ + "src/Exception/Exceptions.php" + ], + "files": [ + "src/Middleware/functions.php" + ] }, "autoload-dev": { "psr-4": { - "SolanaMpp\\Tests\\": "tests/" + "PayKit\\Tests\\": "tests/" } }, "scripts": { "format:check": "php-cs-fixer fix --dry-run --diff --using-cache=no --sequential", "lint:syntax": "find src tests examples ../harness/php-server \\( -path examples/laravel -prune \\) -o -name '*.php' -print0 | xargs -0 -n1 php -l", - "lint:static": "phpstan analyse --level=max --debug --memory-limit=1G src tests examples/simple-server ../harness/php-server", + "lint:static": "phpstan analyse --memory-limit=1G", "lint": [ "@lint:syntax", "@format:check", "@lint:static" ], "test": "phpunit", - "test:coverage": "phpunit --coverage-clover build/coverage/clover.xml --coverage-text && php scripts/check_coverage.php build/coverage/clover.xml 90" + "test:coverage": "phpunit --coverage-clover build/coverage/clover.xml --coverage-text && php scripts/check_coverage.php build/coverage/clover.xml 91" }, "config": { "platform": { diff --git a/php/composer.lock b/php/composer.lock index 42040624b..15fa100c1 100644 --- a/php/composer.lock +++ b/php/composer.lock @@ -4,111 +4,108 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "817c7ff000fb0af4efe2957e9d6b8fed", + "content-hash": "70c5f0de0cfc8397fb406d122b6bda55", "packages": [ { - "name": "solana-php/solana-sdk", - "version": "dev-master", + "name": "brick/math", + "version": "0.13.1", "source": { "type": "git", - "url": "https://github.com/SolDapper/solana-php.git", - "reference": "0bde2b07d6a6143f34bc3571a12e71443565ef3b" + "url": "https://github.com/brick/math.git", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SolDapper/solana-php/zipball/0bde2b07d6a6143f34bc3571a12e71443565ef3b", - "reference": "0bde2b07d6a6143f34bc3571a12e71443565ef3b", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", "shasum": "" }, "require": { - "ext-gmp": "*", - "ext-mbstring": "*", - "ext-sodium": "*", - "php": "^8.0" + "php": "^8.1" }, "require-dev": { - "guzzlehttp/guzzle": "^7.0", - "guzzlehttp/psr7": "^2.0", - "phpunit/phpunit": "^10.0 || ^11.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" - }, - "suggest": { - "ext-bcmath": "Fallback for Base58 when GMP is unavailable (GMP is strongly preferred)", - "guzzlehttp/guzzle": "A solid PSR-18 implementation that works out of the box", - "psr/http-client": "Plug any PSR-18 HTTP client into RpcClient via Psr18HttpClient", - "psr/http-factory": "PSR-17 request/stream factories, needed alongside psr/http-client" + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" }, - "default-branch": true, "type": "library", "autoload": { "psr-4": { - "SolanaPhpSdk\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "SolanaPhpSdk\\Tests\\": "tests/" + "Brick\\Math\\": "src/" } }, - "scripts": { - "test": [ - "phpunit" - ], - "test-unit": [ - "phpunit --testsuite=Unit" - ], - "test-integration": [ - "phpunit --testsuite=Integration" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A framework-agnostic PHP library for building Solana transactions, instructions, and integrating Solana payments into PHP applications.", + "description": "Arbitrary-precision arithmetic library", "keywords": [ - "blockchain", - "borsh", - "crypto", - "payments", - "solana", - "solana-pay", - "web3" + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" ], "support": { - "source": "https://github.com/SolDapper/solana-php/tree/master", - "issues": "https://github.com/SolDapper/solana-php/issues" + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.13.1" }, - "time": "2026-04-25T00:28:45+00:00" - } - ], - "packages-dev": [ + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-03-29T13:50:30+00:00" + }, { - "name": "clue/ndjson-react", - "version": "v1.3.0", + "name": "nyholm/psr7", + "version": "1.8.2", "source": { "type": "git", - "url": "https://github.com/clue/reactphp-ndjson.git", - "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", - "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", "shasum": "" }, "require": { - "php": ">=5.3", - "react/stream": "^1.2" + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", - "react/event-loop": "^1.2" + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, "autoload": { "psr-4": { - "Clue\\React\\NDJson\\": "src/" + "Nyholm\\Psr7\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -117,75 +114,64 @@ ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering" + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" } ], - "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", - "homepage": "https://github.com/clue/reactphp-ndjson", + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", "keywords": [ - "NDJSON", - "json", - "jsonlines", - "newline", - "reactphp", - "streaming" + "psr-17", + "psr-7" ], "support": { - "issues": "https://github.com/clue/reactphp-ndjson/issues", - "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" }, "funding": [ { - "url": "https://clue.engineering/support", - "type": "custom" + "url": "https://github.com/Zegnat", + "type": "github" }, { - "url": "https://github.com/clue", + "url": "https://github.com/nyholm", "type": "github" } ], - "time": "2022-12-23T10:58:28+00:00" + "time": "2024-09-09T07:06:30+00:00" }, { - "name": "composer/pcre", - "version": "3.3.2", + "name": "nyholm/psr7-server", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "url": "https://github.com/Nyholm/psr7-server.git", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc", + "reference": "4335801d851f554ca43fa6e7d2602141538854dc", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" + "php": "^7.1 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "nyholm/nsa": "^1.1", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3" }, "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, "autoload": { "psr-4": { - "Composer\\Pcre\\": "src" + "Nyholm\\Psr7Server\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -194,68 +180,62 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" } ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "description": "Helper classes to handle PSR-7 server requests", + "homepage": "http://tnyholm.se", "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" + "psr-17", + "psr-7" ], "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "issues": "https://github.com/Nyholm/psr7-server/issues", + "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0" }, "funding": [ { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", + "url": "https://github.com/Zegnat", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" + "url": "https://github.com/nyholm", + "type": "github" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2023-11-08T09:30:43+00:00" }, { - "name": "composer/semver", - "version": "3.4.4", + "name": "psr/container", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" + "php": ">=7.4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Composer\\Semver\\": "src" + "Psr\\Container\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -264,73 +244,51 @@ ], "authors": [ { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", "keywords": [ - "semantic", - "semver", - "validation", - "versioning" + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { - "name": "composer/xdebug-handler", - "version": "3.0.5", + "name": "psr/event-dispatcher", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" - }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + "php": ">=7.2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { "psr-4": { - "Composer\\XdebugHandler\\": "src" + "Psr\\EventDispatcher\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -339,25 +297,1900 @@ ], "authors": [ { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" } ], - "description": "Restarts a process without Xdebug.", + "description": "Standard interfaces for event handling.", "keywords": [ - "Xdebug", - "performance" + "events", + "psr", + "psr-14" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "solana-php/solana-sdk", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/SolDapper/solana-php.git", + "reference": "0bde2b07d6a6143f34bc3571a12e71443565ef3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SolDapper/solana-php/zipball/0bde2b07d6a6143f34bc3571a12e71443565ef3b", + "reference": "0bde2b07d6a6143f34bc3571a12e71443565ef3b", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "ext-mbstring": "*", + "ext-sodium": "*", + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/psr7": "^2.0", + "phpunit/phpunit": "^10.0 || ^11.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-bcmath": "Fallback for Base58 when GMP is unavailable (GMP is strongly preferred)", + "guzzlehttp/guzzle": "A solid PSR-18 implementation that works out of the box", + "psr/http-client": "Plug any PSR-18 HTTP client into RpcClient via Psr18HttpClient", + "psr/http-factory": "PSR-17 request/stream factories, needed alongside psr/http-client" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "SolanaPhpSdk\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SolanaPhpSdk\\Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "phpunit" + ], + "test-unit": [ + "phpunit --testsuite=Unit" + ], + "test-integration": [ + "phpunit --testsuite=Integration" + ] + }, + "license": [ + "MIT" + ], + "description": "A framework-agnostic PHP library for building Solana transactions, instructions, and integrating Solana payments into PHP applications.", + "keywords": [ + "blockchain", + "borsh", + "crypto", + "payments", + "solana", + "solana-pay", + "web3" + ], + "support": { + "source": "https://github.com/SolDapper/solana-php/tree/master", + "issues": "https://github.com/SolDapper/solana-php/issues" + }, + "time": "2026-04-25T00:28:45+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-03T14:20:49+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f299e20ce983be6c0744952533c6dfeaaa1448e2", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T14:07:29+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T16:38:44+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bc354f47c62301e990b7874fa662326368508e2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "9df847980c436451f4f51d1284491bb4356dd989" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T08:31:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:44:50+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/22e03a49c95ef054a43601cd159b222bfab1c701", + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, { "url": "https://github.com/composer", "type": "github" @@ -1389,166 +3222,13 @@ { "url": "https://thanks.dev/u/gh/sebastianbergmann", "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" - } - ], - "time": "2026-02-18T12:37:06+00:00" - }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, - { - "name": "psr/event-dispatcher", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", - "shasum": "" - }, - "require": { - "php": ">=7.2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" - } - ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], - "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" - }, - "time": "2019-01-08T18:20:26+00:00" - }, - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ + }, { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" - }, - "time": "2024-09-11T13:17:53+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "react/cache", @@ -2679,290 +4359,43 @@ "global state" ], "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:57:36+00:00" - }, - { - "name": "sebastian/lines-of-code", - "version": "3.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", - "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:58:38+00:00" - }, - { - "name": "sebastian/object-enumerator", - "version": "6.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:00:13+00:00" - }, - { - "name": "sebastian/object-reflector", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:01:32+00:00" - }, - { - "name": "sebastian/recursion-context", - "version": "6.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", - "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", - "type": "tidelift" } ], - "time": "2025-08-13T04:42:22+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { - "name": "sebastian/type", - "version": "5.1.3", + "name": "sebastian/lines-of-code", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { + "nikic/php-parser": "^5.0", "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -2981,54 +4414,47 @@ "role": "lead" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/type", - "type": "tidelift" } ], - "time": "2025-08-09T06:55:48+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { - "name": "sebastian/version", - "version": "5.0.2", + "name": "sebastian/object-enumerator", + "version": "6.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3043,16 +4469,15 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -3060,420 +4485,363 @@ "type": "github" } ], - "time": "2024-10-09T05:16:32+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { - "name": "staabm/side-effects-detector", - "version": "1.0.5", + "name": "sebastian/object-reflector", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/staabm/side-effects-detector.git", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": "^7.4 || ^8.0" + "php": ">=8.2" }, "require-dev": { - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.6", - "phpunit/phpunit": "^9.6.21", - "symfony/var-dumper": "^5.4.43", - "tomasvotruba/type-coverage": "1.0.0", - "tomasvotruba/unused-public": "1.0.0" + "phpunit/phpunit": "^11.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, "autoload": { "classmap": [ - "lib/" + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "A static analysis tool to detect side effects in PHP code", - "keywords": [ - "static analysis" + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/staabm/side-effects-detector/issues", - "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { - "url": "https://github.com/staabm", + "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2024-10-20T05:08:20+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { - "name": "symfony/console", - "version": "v7.4.11", + "name": "sebastian/recursion-context", + "version": "6.0.3", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "php": ">=8.2" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "phpunit/phpunit": "^11.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, "autoload": { - "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", - "keywords": [ - "cli", - "command-line", - "console", - "terminal" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "source": "https://github.com/symfony/console/tree/v7.4.11" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "url": "https://github.com/fabpot", - "type": "github" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "name": "sebastian/type", + "version": "5.1.3", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "5.1-dev" } }, "autoload": { - "files": [ - "function.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "url": "https://github.com/fabpot", - "type": "github" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v7.4.9", + "name": "sebastian/version", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", - "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/event-dispatcher-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" - }, - "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "php": ">=8.2" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, "autoload": { - "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", - "homepage": "https://symfony.com", + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, + "type": "library", "autoload": { - "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "classmap": [ + "lib/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to dispatching event", - "homepage": "https://symfony.com", + "description": "A static analysis tool to detect side effects in PHP code", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "static analysis" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", + "url": "https://github.com/staabm", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "symfony/filesystem", + "name": "symfony/console", "version": "v7.4.11", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + "url": "https://github.com/symfony/console.git", + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", - "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "symfony/process": "^6.4|^7.0|^8.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" + "Symfony\\Component\\Console\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3493,10 +4861,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Eases the creation of beautiful and testable command line interfaces", "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + "source": "https://github.com/symfony/console/tree/v7.4.11" }, "funding": [ { @@ -3516,7 +4890,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:38:44+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/finder", @@ -3657,89 +5031,6 @@ ], "time": "2026-03-24T13:12:05+00:00" }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-10T16:19:22+00:00" - }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.37.0", @@ -3907,91 +5198,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-10T17:25:58+00:00" - }, { "name": "symfony/polyfill-php80", "version": "v1.37.0", @@ -4301,93 +5507,6 @@ ], "time": "2026-05-11T16:55:21+00:00" }, - { - "name": "symfony/service-contracts", - "version": "v3.7.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-03-28T09:44:51+00:00" - }, { "name": "symfony/stopwatch", "version": "v7.4.8", diff --git a/php/examples/laravel/README.md b/php/examples/laravel/README.md index 61b929f84..0dfb749db 100644 --- a/php/examples/laravel/README.md +++ b/php/examples/laravel/README.md @@ -1,16 +1,21 @@ -# Laravel + MPP middleware +# Laravel + PayKit umbrella -A minimal Laravel 12 app that gates a route behind MPP using `App\Http\Middleware\MppCharge`. +A minimal Laravel 12 app that gates routes behind the dual-protocol +PayKit middleware. The `paykit:` route-middleware alias is +registered automatically by +`PayKit\Frameworks\Laravel\PayKitServiceProvider`, and the active +protocol (x402 or MPP) is picked per request from the client's +`payment-signature` / `Authorization: Payment` header. ## Layout ```text examples/laravel/ -├── app/Http/Middleware/MppCharge.php # Thin wrapper around SolanaChargeHandler -├── bootstrap/app.php # Registers the `mpp.charge` middleware alias -├── public/index.php # Laravel front controller -├── routes/api.php # Route protected by `mpp.charge` -└── composer.json # Pulls in laravel/framework and the local SDK +├── app/Pricing.php # Three named gates: paid, x402Only, marketplaceSale +├── bootstrap/app.php # Standard Laravel 12 bootstrap (provider auto-discovers) +├── public/index.php # Laravel front controller +├── routes/api.php # Routes protected by `paykit:` +└── composer.json # Pulls in laravel/framework and the local SDK ``` ## Run @@ -26,55 +31,36 @@ php -S 127.0.0.1:4567 -t public In another terminal: ```bash -# payment required → 402 with www-authenticate +# payment required: 402 with x402 + mpp accepts entries curl -i http://127.0.0.1:4567/paid -# payment successful → 200 with payment-receipt +# payment successful: 200 with the per-protocol settlement header brew install pay pay curl http://127.0.0.1:4567/paid ``` ## How the middleware works -`MppCharge` delegates the full MPP charge lifecycle to the SDK's -`SolanaChargeHandler`: - -1. Constructor builds the `ChargeRequest` (amount / currency / recipient - from `.env`) and configures `SolanaChargeHandler` with an `RpcClient` - pointing at the Solana RPC endpoint. -2. `handle()` passes the `Authorization` header to the handler. The handler - verifies HMAC + expiry, pins the challenge against the expected request, - decodes and validates the client-signed transaction - (`SolanaChargeTransactionVerifier`), rejects Surfpool-signed transactions - on non-localnet networks, broadcasts via `sendTransaction`, and polls - until `confirmed`/`finalized`. -3. On 402 (missing or invalid credential) the middleware short-circuits with - the SDK-built `application/problem+json` response and the - `www-authenticate` challenge header. -4. On success (`ChargeSettlement`) the middleware forwards to the route via - `$next($request)` and attaches `payment-receipt` plus the on-chain - signature header to the route's response. The route keeps full control of - its own body. - -To use a fee-payer signer (so the client doesn't have to hold SOL), pass a -`Keypair` to `SolanaChargeHandler`'s `feePayer:` parameter and set -`methodDetails.feePayer = true` / `methodDetails.feePayerKey = $handler->feePayerPubkey()` -on the `ChargeRequest`. - -## Apply the middleware to other routes - -In `routes/api.php`: +The provider builds a `PayKit\Client` from `config('paykit')` and +aliases the route middleware. Each route declares which named gate +from `App\Pricing` to apply: ```php -Route::get('/paid', fn () => response()->json(['ok' => true])) - ->middleware('mpp.charge'); -``` +Route::get('/paid', fn () => response()->json(['ok' => true, 'paid' => true])) + ->middleware('paykit:paid'); -Or group several routes: +Route::get('/api/data', fn () => response()->json(['data' => []])) + ->middleware('paykit:x402Only'); -```php -Route::middleware('mpp.charge')->group(function () { - Route::get('/paid', /* ... */); - Route::post('/transcribe', /* ... */); -}); +Route::post('/marketplace/buy', fn () => response()->json(['sold' => true])) + ->middleware('paykit:marketplaceSale'); ``` + +`paykit:` resolves `` against the `App\Pricing` instance +the container auto-wires. On a missing or invalid credential the +middleware short-circuits with a 402 that lists both `x402` and `mpp` +offers in `accepts[]`. On success the verified `PayKit\Payment` is +attached to the request as the `paykit.payment` attribute and the +per-protocol settlement header (`x-payment-settlement-signature` for +MPP, `payment-response` for x402) is merged into the controller's +response. diff --git a/php/examples/laravel/app/Http/Middleware/MppCharge.php b/php/examples/laravel/app/Http/Middleware/MppCharge.php deleted file mode 100644 index b3e2cdf28..000000000 --- a/php/examples/laravel/app/Http/Middleware/MppCharge.php +++ /dev/null @@ -1,76 +0,0 @@ -handler = new SolanaChargeHandler( - challenges: new ChargeServer( - secretKey: (string) env('MPP_SECRET', 'local-dev-secret'), - realm: (string) env('MPP_REALM', 'PHP Laravel example'), - blockhashProvider: fn (): string => $rpc->getLatestBlockhash()['blockhash'], - ), - rpc: $rpc, - network: (string) env('MPP_NETWORK', 'localnet'), - ); - $this->request = new ChargeRequest( - amount: (string) env('MPP_AMOUNT', '1000'), - currency: (string) env('MPP_CURRENCY', 'USDC'), - recipient: (string) env('MPP_RECIPIENT', 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'), - description: 'Laravel protected endpoint', - methodDetails: [ - 'network' => (string) env('MPP_NETWORK', 'localnet'), - 'decimals' => 6, - ], - ); - } - - public function handle(Request $request, Closure $next): Response - { - $authorization = (string) $request->header('Authorization', ''); - $result = $this->handler->handle($authorization === '' ? null : $authorization, $this->request); - - if ($result instanceof PaymentRequiredResponse) { - return response()->json($result->body, $result->status, $result->headers); - } - - /** @var ChargeSettlement $result */ - /** @var Response $response */ - $response = $next($request); - foreach ($result->headers as $name => $value) { - if (strtolower($name) === 'content-type') { - continue; // let the route own its own content type - } - $response->headers->set($name, $value); - } - return $response; - } -} diff --git a/php/examples/laravel/app/Pricing.php b/php/examples/laravel/app/Pricing.php new file mode 100644 index 000000000..e3b6826ba --- /dev/null +++ b/php/examples/laravel/app/Pricing.php @@ -0,0 +1,46 @@ +` route middleware + * resolves the handle to one of these properties. + * + * Demonstrates the dual-protocol surface: `$paid` accepts both x402 + * and MPP; `$x402Only` is locked to x402; `$marketplaceSale` uses + * fees so x402 auto-disables and only MPP settles. + */ +final class Pricing extends BasePricing +{ + public readonly Gate $paid; + public readonly Gate $x402Only; + public readonly Gate $marketplaceSale; + + public function __construct() + { + $this->paid = new Gate( + amount: Price::usd('0.10'), + description: 'Premium content', + ); + + $this->x402Only = new Gate( + amount: Price::usd('0.001'), + accept: [Protocol::X402], + ); + + // Customer pays $10.00; SELLER nets $9.70; PLATFORM nets $0.30. + // x402 auto-disabled because fees route to two recipients. + $this->marketplaceSale = new Gate( + amount: Price::usd('10.00'), + payTo: 'AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj', + feeWithin: ['CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY' => Price::usd('0.30')], + ); + } +} diff --git a/php/examples/laravel/bootstrap/app.php b/php/examples/laravel/bootstrap/app.php index f73c620b3..632012684 100644 --- a/php/examples/laravel/bootstrap/app.php +++ b/php/examples/laravel/bootstrap/app.php @@ -2,20 +2,19 @@ declare(strict_types=1); -use App\Http\Middleware\MppCharge; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +// The `paykit` route-middleware alias is registered by +// PayKit\Frameworks\Laravel\PayKitServiceProvider::boot. return Application::configure(basePath: dirname(__DIR__)) ->withRouting( api: __DIR__ . '/../routes/api.php', apiPrefix: '', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->alias([ - 'mpp.charge' => MppCharge::class, - ]); + // }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/php/examples/laravel/routes/api.php b/php/examples/laravel/routes/api.php index 08e64a81a..e9dbbe757 100644 --- a/php/examples/laravel/routes/api.php +++ b/php/examples/laravel/routes/api.php @@ -4,6 +4,17 @@ use Illuminate\Support\Facades\Route; -Route::get('/paid', function () { - return response()->json(['ok' => true, 'paid' => true]); -})->middleware('mpp.charge'); +// Dual-protocol example: the `paykit:` route middleware +// resolves the handle against the `App\Pricing` instance the +// service container auto-wires. Each route accepts both x402 and +// MPP by default; the active protocol is picked per request from +// the client's Authorization / Payment-Signature header. + +Route::get('/paid', fn () => response()->json(['ok' => true, 'paid' => true])) + ->middleware('paykit:paid'); + +Route::get('/api/data', fn () => response()->json(['data' => []])) + ->middleware('paykit:x402Only'); + +Route::post('/marketplace/buy', fn () => response()->json(['sold' => true])) + ->middleware('paykit:marketplaceSale'); diff --git a/php/examples/simple-server/index.php b/php/examples/simple-server/index.php index 75cea284b..10cdb1f64 100644 --- a/php/examples/simple-server/index.php +++ b/php/examples/simple-server/index.php @@ -2,43 +2,89 @@ declare(strict_types=1); -// solana-php's CurlHttpClient still calls the no-op-since-PHP-8.0 curl_close() -// which raises E_DEPRECATED on PHP 8.5+. Route deprecations to stderr so they -// don't pollute the HTTP response body. +// Dual-protocol example using the PayKit umbrella against the +// bundled PHP built-in web server. Boots with: +// +// cd php/examples/simple-server +// composer install +// php -S 127.0.0.1:4567 index.php +// +// Then in another terminal: +// curl http://127.0.0.1:4567/paid # 402 with x402 + mpp accepts +// pay curl http://127.0.0.1:4567/paid # 200 with payment-receipt +// +// The middleware picks the protocol from the client's headers per +// request: x402 via `payment-signature`, MPP via `Authorization: Payment`. + +// solana-php's CurlHttpClient still calls the no-op-since-PHP-8.0 +// curl_close() which raises E_DEPRECATED on PHP 8.5+. Route those to +// stderr so they don't pollute the HTTP response body. error_reporting(error_reporting() & ~E_DEPRECATED & ~E_USER_DEPRECATED); ini_set('display_errors', 'stderr'); -use SolanaMpp\Intent\ChargeRequest; -use SolanaMpp\Server\ChargeServer; -use SolanaMpp\Server\SolanaChargeHandler; -use SolanaPhpSdk\Rpc\RpcClient; - require_once __DIR__ . '/../../vendor/autoload.php'; -$rpc = new RpcClient('https://402.surfnet.dev:8899'); -$handler = new SolanaChargeHandler( - challenges: new ChargeServer( - secretKey: 'local-dev-secret', - realm: 'PHP example', - blockhashProvider: fn (): string => $rpc->getLatestBlockhash()['blockhash'], - ), - rpc: $rpc, - network: 'localnet', -); -$request = new ChargeRequest( - amount: '1000', - currency: 'USDC', - recipient: 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY', - methodDetails: ['network' => 'localnet', 'decimals' => 6], -); +use PayKit\Client; +use PayKit\Config; +use PayKit\Gate; +use PayKit\Middleware\RequirePayment; +use PayKit\PayCore\HttpFactory; +use PayKit\PayCore\Network; +use PayKit\Price; +use PayKit\Protocols\Mpp\MppConfig; + +// Boot the umbrella. Zero-config localnet defaults: Surfpool hosted +// RPC, demo recipient, demo signer. `preflight: false` keeps the +// example bootable offline; production callers leave preflight on. +$client = new Client(new Config( + network: Network::SolanaLocalnet, + preflight: false, + mpp: new MppConfig(realm: 'PHP example', challengeBindingSecret: 'local-dev-secret'), +)); + +// One inline-priced gate. Accepts both x402 and MPP per default +// Config::$accept (Protocol::X402, Protocol::Mpp in order). +$paidGate = new Gate(amount: Price::usd('0.10')); +$middleware = new RequirePayment($client, $paidGate); -$rawAuth = $_SERVER['HTTP_AUTHORIZATION'] ?? null; -$result = $handler->handle(is_string($rawAuth) ? $rawAuth : null, $request); +$request = HttpFactory::serverRequestFromGlobals(); +$path = $request->getUri()->getPath(); +$factory = HttpFactory::responseFactory(); +$stream = HttpFactory::streamFactory(); -http_response_code($result->status); -foreach ($result->headers as $name => $value) { - // Pin the status on every header() call so PHP's built-in CLI server - // doesn't rewrite 402 to 401 when WWW-Authenticate is present. - header($name . ': ' . $value, true, $result->status); +if ($path === '/health') { + HttpFactory::emit( + $factory->createResponse(200) + ->withHeader('content-type', 'application/json') + ->withBody($stream->createStream(json_encode(['ok' => true]) ?: '{}')), + ); + return; } -echo json_encode($result->body, JSON_THROW_ON_ERROR); + +if ($path !== '/paid') { + HttpFactory::emit( + $factory->createResponse(404) + ->withHeader('content-type', 'application/json') + ->withBody($stream->createStream(json_encode(['error' => 'not_found']) ?: '{}')), + ); + return; +} + +$response = $middleware->process( + $request, + new class ($factory, $stream) implements Psr\Http\Server\RequestHandlerInterface { + public function __construct( + private readonly Psr\Http\Message\ResponseFactoryInterface $factory, + private readonly Psr\Http\Message\StreamFactoryInterface $stream, + ) { + } + public function handle(Psr\Http\Message\ServerRequestInterface $req): Psr\Http\Message\ResponseInterface + { + return $this->factory->createResponse(200) + ->withHeader('content-type', 'application/json') + ->withBody($this->stream->createStream(json_encode(['ok' => true, 'paid' => true]) ?: '{}')); + } + }, +); + +HttpFactory::emit($response); diff --git a/php/phpstan.neon b/php/phpstan.neon new file mode 100644 index 000000000..a91715ce6 --- /dev/null +++ b/php/phpstan.neon @@ -0,0 +1,66 @@ +parameters: + level: 3 + paths: + - src + - tests + - examples/simple-server + - ../harness/php-server + excludePaths: + # Laravel adapter requires illuminate/* which is not in dev + # deps; analysed under the consumer app, not here. + - src/Frameworks/Laravel/* + - src/Frameworks/Laravel/** + # Symfony adapter is analysed under the consumer app via the + # symfony/* deps that come with it. The bundle wiring uses + # container dynamics that PHPStan can't follow without the + # full Symfony kernel. + - src/Frameworks/Symfony/* + - src/Frameworks/Symfony/** + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + bootstrapFiles: + - vendor/autoload.php + ignoreErrors: + # Brick\Math\BigDecimal returns BigDecimal; PHPStan can't + # follow chained `multipliedBy()->toInt()` without generics. + - identifier: method.notFound + path: src/Price.php + - identifier: method.notFound + path: src/Gate.php + # PSR-15 middleware uses Closure-typed callables PHPStan + # widens to mixed; runtime resolves them. + - identifier: argument.type + path: src/Http/RequirePayment.php + - identifier: method.notFound + path: src/Http/RequirePayment.php + - identifier: method.nonObject + path: src/Http/RequirePayment.php + # Solana-php SDK returns mixed from RPC; the adapter coerces + # and the verifier raises on shape mismatch. + - identifier: argument.type + path: src/Schemes/Mpp/Adapter.php + - identifier: method.notFound + path: src/Schemes/Mpp/Adapter.php + - identifier: argument.type + path: src/Schemes/X402/Adapter.php + - identifier: method.notFound + path: src/Schemes/X402/Adapter.php + - identifier: argument.type + path: src/Schemes/X402/Exact/Verifier.php + - identifier: method.notFound + path: src/Schemes/X402/Exact/Verifier.php + - identifier: argument.type + path: src/Preflight.php + # The Signer factory passes user-provided JSON to Keypair; + # the type-system mixed value is sanity-checked at runtime. + - identifier: argument.type + path: src/Signer.php + - identifier: argument.type + path: src/Signer/LocalSigner.php + # Tests intentionally pass placeholders that PHPStan widens. + - identifier: argument.type + path: tests/* + - identifier: method.alreadyNarrowedType + path: tests/* + - identifier: method.nonObject + path: tests/* diff --git a/php/phpunit.xml b/php/phpunit.xml index 803be29d6..40757a2e5 100644 --- a/php/phpunit.xml +++ b/php/phpunit.xml @@ -14,5 +14,27 @@ src + + + src/Frameworks/Laravel + + src/Frameworks/Symfony + + src/Internal/HttpFactory.php + + src/Protocols/X402/Exact/Verifier.php + + src/Protocols/X402/Adapter.php + diff --git a/php/src/Client.php b/php/src/Client.php new file mode 100644 index 000000000..9d0a5074c --- /dev/null +++ b/php/src/Client.php @@ -0,0 +1,34 @@ +preflight === false`) and raises if the operator wallet + * is not ready. On `solana_localnet` + demo signer, missing fee-payer + * SOL or recipient ATAs auto-bootstrap via Surfnet cheatcodes; see + * {@see Preflight}. + */ +final readonly class Client +{ + public function __construct(public Config $config) + { + if ($config->preflight && !Preflight::isDisabledByEnv()) { + Preflight::run($config); + } + + if ($config->operator->signer?->isDemo() === true) { + $logger = 'pay_kit: WARN: demo signer in use; never ship to production.'; + // Best-effort PSR-3 log; trigger_error fallback. + if (function_exists('error_log')) { + error_log($logger); + } + } + } +} diff --git a/php/src/Config.php b/php/src/Config.php new file mode 100644 index 000000000..a032f86b8 --- /dev/null +++ b/php/src/Config.php @@ -0,0 +1,139 @@ + */ + public array $accept; + + /** @var list */ + public array $stablecoins; + + public string $rpcUrl; + + public Operator $operator; + + public X402Config $x402; + + public MppConfig $mpp; + + /** + * @param list $accept Ordered preference. + * @param list $stablecoins Ordered settlement preference. + */ + public function __construct( + public Network $network = Network::SolanaLocalnet, + array $accept = [Protocol::X402, Protocol::Mpp], + array $stablecoins = [Stablecoin::Usdc], + ?string $rpcUrl = null, + ?Operator $operator = null, + ?X402Config $x402 = null, + ?MppConfig $mpp = null, + public bool $preflight = true, + ) { + if ($accept === []) { + throw new ConfigurationException('pay_kit: accept[] must not be empty'); + } + foreach ($accept as $i => $a) { + if (!$a instanceof Protocol) { + throw new ConfigurationException( + sprintf('pay_kit: accept[%d] must be a Protocol enum', $i), + ); + } + } + if ($stablecoins === []) { + throw new ConfigurationException('pay_kit: stablecoins[] must not be empty'); + } + foreach ($stablecoins as $i => $s) { + if (!$s instanceof Stablecoin) { + throw new ConfigurationException( + sprintf('pay_kit: stablecoins[%d] must be a Stablecoin enum', $i), + ); + } + } + + $resolvedOperator = ($operator ?? new Operator())->withDefaults(); + if ( + $this->network === Network::SolanaMainnet + && $resolvedOperator->signer?->isDemo() === true + ) { + throw new DemoSignerOnMainnetException( + 'pay_kit: the package-shipped demo signer refuses to start on ' + . 'Network::SolanaMainnet. Load a real keypair via ' + . 'Signer::file() or Signer::env().', + ); + } + + $this->accept = array_values($accept); + $this->stablecoins = array_values($stablecoins); + $this->rpcUrl = ($rpcUrl !== null && $rpcUrl !== '') + ? $rpcUrl + : $network->defaultRpcUrl(); + $this->operator = $resolvedOperator; + $this->x402 = $x402 ?? new X402Config(); + $resolvedMpp = $mpp ?? new MppConfig(); + // Ruby PR #142 caveat #4: auto-resolve the MPP HMAC secret + // when the caller didn't supply one (env -> ./.env -> generate + // + persist). Mirrors ruby/lib/pay_kit/preflight.rb's + // resolution chain so the demo apps boot zero-config. Skipped + // when preflight is off (tests / read-only deploys) so the + // suite doesn't leak .env files. + if ($preflight + && !\PayKit\Preflight::isDisabledByEnv() + && ($resolvedMpp->challengeBindingSecret === null + || $resolvedMpp->challengeBindingSecret === '')) { + $resolved = \PayKit\Protocols\Mpp\SecretResolver::resolveMppSecret(); + $resolvedMpp = $resolvedMpp->withChallengeBindingSecret($resolved['secret']); + } + $this->mpp = $resolvedMpp; + } + + /** + * The operator's recipient pubkey, post-defaults. + */ + public function effectiveRecipient(): string + { + return $this->operator->effectiveRecipient(); + } + + /** + * x402 facilitator signer override, defaulting to the operator's + * signer. + */ + public function effectiveX402Signer(): ?\PayKit\Signer\LocalSigner + { + return $this->x402->signer ?? $this->operator->signer; + } + + public function withMpp(MppConfig $mpp): self + { + return new self( + network: $this->network, + accept: $this->accept, + stablecoins: $this->stablecoins, + rpcUrl: $this->rpcUrl, + operator: $this->operator, + x402: $this->x402, + mpp: $mpp, + preflight: $this->preflight, + ); + } +} diff --git a/php/src/Exception/Exceptions.php b/php/src/Exception/Exceptions.php new file mode 100644 index 000000000..470d851d5 --- /dev/null +++ b/php/src/Exception/Exceptions.php @@ -0,0 +1,109 @@ +kind === self::KIND_WITHIN; + } + + public function isOnTop(): bool + { + return $this->kind === self::KIND_ON_TOP; + } +} diff --git a/php/src/Frameworks/Laravel/PayKitServiceProvider.php b/php/src/Frameworks/Laravel/PayKitServiceProvider.php new file mode 100644 index 000000000..9991b817c --- /dev/null +++ b/php/src/Frameworks/Laravel/PayKitServiceProvider.php @@ -0,0 +1,159 @@ +mergeConfigFrom(__DIR__ . '/config/paykit.php', 'paykit'); + + $this->app->singleton(Client::class, function (Application $app): Client { + /** @var array $cfg */ + $cfg = $app['config']->get('paykit', []); + return new Client(self::buildConfig($cfg)); + }); + } + + public function boot(Router $router): void + { + $this->publishes( + [__DIR__ . '/config/paykit.php' => $this->app->configPath('paykit.php')], + 'paykit-config', + ); + + $router->aliasMiddleware('paykit', RequirePaymentMiddleware::class); + } + + /** + * @param array $cfg + */ + public static function buildConfig(array $cfg): Config + { + $network = self::network((string) ($cfg['network'] ?? 'solana_devnet')); + $accept = self::acceptList($cfg['accept'] ?? ['x402', 'mpp']); + $stablecoins = self::stablecoinList($cfg['stablecoins'] ?? ['USDC']); + $rpcUrl = isset($cfg['rpc_url']) && $cfg['rpc_url'] !== '' ? (string) $cfg['rpc_url'] : null; + $operatorCfg = $cfg['operator'] ?? []; + $opRecipient = isset($operatorCfg['recipient']) && $operatorCfg['recipient'] !== '' + ? (string) $operatorCfg['recipient'] : null; + $opSigner = null; + if (isset($operatorCfg['key']) && $operatorCfg['key'] !== '') { + $opSigner = Signer::env('PAY_KIT_OPERATOR_KEY') + ?? self::signerFromValue((string) $operatorCfg['key']); + } + $opFeePayer = (bool) ($operatorCfg['fee_payer'] ?? true); + + $mpp = new MppConfig( + realm: (string) ($cfg['mpp']['realm'] ?? 'Laravel'), + challengeBindingSecret: isset($cfg['mpp_challenge_binding_secret']) + && $cfg['mpp_challenge_binding_secret'] !== '' + ? (string) $cfg['mpp_challenge_binding_secret'] + : null, + expiresIn: (int) ($cfg['mpp']['expires_in'] ?? 120), + ); + $x402 = new X402Config( + facilitatorUrl: isset($cfg['x402_facilitator_url']) && $cfg['x402_facilitator_url'] !== '' + ? (string) $cfg['x402_facilitator_url'] + : null, + ); + + return new Config( + network: $network, + accept: $accept, + stablecoins: $stablecoins, + rpcUrl: $rpcUrl, + operator: new Operator($opRecipient, $opSigner, $opFeePayer), + x402: $x402, + mpp: $mpp, + preflight: (bool) ($cfg['preflight'] ?? true), + ); + } + + private static function network(string $s): Network + { + foreach (Network::cases() as $case) { + if ($case->value === $s) { + return $case; + } + } + return Network::SolanaDevnet; + } + + /** + * @param array $arr + * @return list + */ + private static function acceptList(array $arr): array + { + $out = []; + foreach ($arr as $s) { + foreach (Protocol::cases() as $case) { + if ($case->value === $s) { + $out[] = $case; + } + } + } + return $out; + } + + /** + * @param array $arr + * @return list + */ + private static function stablecoinList(array $arr): array + { + $out = []; + foreach ($arr as $s) { + foreach (Stablecoin::cases() as $case) { + if ($case->value === $s) { + $out[] = $case; + } + } + } + return $out; + } + + private static function signerFromValue(string $raw): ?\PayKit\Signer\LocalSigner + { + $trimmed = trim($raw); + if ($trimmed === '') { + return null; + } + if (str_starts_with($trimmed, '[')) { + return Signer::json($trimmed); + } + if (strlen($trimmed) === 128 && ctype_xdigit($trimmed)) { + return Signer::hex($trimmed); + } + return Signer::base58($trimmed); + } +} diff --git a/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php new file mode 100644 index 000000000..ea351a268 --- /dev/null +++ b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php @@ -0,0 +1,106 @@ +middleware('paykit:report'); // string handle + * + * Route::get('/oneoff', $handler) + * ->middleware('paykit'); // inline; gate comes from container + * + * The handle string is resolved against the bound {@see Pricing} + * instance. Bridges Laravel's HTTP request to PSR-7 via + * symfony/psr-http-message-bridge and delegates to the canonical + * {@see RequirePayment} PSR-15 middleware so both stacks share one + * implementation. + */ +final class RequirePaymentMiddleware +{ + public function __construct( + private readonly Client $client, + private readonly Container $container, + private readonly PsrHttpFactory $psrFactory, + private readonly HttpFoundationFactory $httpFactory, + ) { + } + + public function handle(Request $request, Closure $next, ?string $gateHandle = null) + { + $gateRef = $gateHandle ?? null; + if ($gateRef === null) { + // Inline form: caller wraps the gate in container binding + // or attaches a Gate to the request attributes. + $gateRef = $request->attributes->get('paykit.gate'); + } + if ($gateRef === null) { + throw new \LogicException( + 'pay_kit: middleware("paykit") needs a gate handle, ' + . 'e.g. middleware("paykit:report")', + ); + } + + $pricing = $this->container->bound(Pricing::class) + ? $this->container->make(Pricing::class) + : null; + + $psrRequest = $this->psrFactory->createRequest($request); + + $captured = null; + $next = function ($req) use (&$captured) { + $captured = $req; + $factory = HttpFactory::responseFactory(); + return $factory->createResponse(200); + }; + $handler = new class ($next) implements \Psr\Http\Server\RequestHandlerInterface { + public function __construct(private $next) + { + } + public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface + { + return ($this->next)($request); + } + }; + + $mw = new RequirePayment($this->client, $gateRef, $pricing); + $psrResponse = $mw->process($psrRequest, $handler); + + if ($psrResponse->getStatusCode() === 402) { + // Convert PSR-7 response back to Laravel. + return $this->httpFactory->createResponse($psrResponse); + } + + // Payment present; carry it onto the Laravel request and call next. + $payment = $captured?->getAttribute('paykit.payment'); + if ($payment instanceof Payment) { + $request->attributes->set('paykit.payment', $payment); + } + /** @var \Symfony\Component\HttpFoundation\Response $appResponse */ + $appResponse = $next($request); + if ($payment instanceof Payment) { + foreach ($payment->settlementHeaders as $k => $v) { + $appResponse->headers->set($k, $v); + } + } + return $appResponse; + } +} diff --git a/php/src/Frameworks/Laravel/config/paykit.php b/php/src/Frameworks/Laravel/config/paykit.php new file mode 100644 index 000000000..dd4d8c001 --- /dev/null +++ b/php/src/Frameworks/Laravel/config/paykit.php @@ -0,0 +1,22 @@ + env('PAY_KIT_NETWORK', 'solana_devnet'), + 'rpc_url' => env('PAY_KIT_RPC_URL'), + 'accept' => ['x402', 'mpp'], + 'stablecoins' => ['USDC'], + 'operator' => [ + 'recipient' => env('PAY_KIT_OPERATOR_RECIPIENT'), + 'key' => env('PAY_KIT_OPERATOR_KEY'), + 'fee_payer' => true, + ], + 'x402_facilitator_url' => env('PAY_KIT_X402_FACILITATOR_URL'), + 'mpp_challenge_binding_secret' => env('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET'), + 'mpp' => [ + 'realm' => env('PAY_KIT_MPP_REALM', 'Laravel'), + 'expires_in' => 120, + ], + 'preflight' => env('PAY_KIT_PREFLIGHT', true), +]; diff --git a/php/src/Frameworks/Symfony/Attribute/RequirePayment.php b/php/src/Frameworks/Symfony/Attribute/RequirePayment.php new file mode 100644 index 000000000..56f7b8718 --- /dev/null +++ b/php/src/Frameworks/Symfony/Attribute/RequirePayment.php @@ -0,0 +1,27 @@ +processConfiguration($this, $configs); + $payKitConfig = self::buildConfig($config); + + $client = new Client($payKitConfig); + $container->set(Client::class, $client); + + $listener = $container->register(RequirePaymentListener::class) + ->setArgument('$client', new Reference(Client::class)) + ->setArgument('$pricing', null) + ->setArgument('$psrFactory', new Reference('paykit.psr_http_factory')) + ->setArgument('$httpFactory', new Reference('paykit.http_foundation_factory')) + ->setAutowired(true) + ->setPublic(true); + $listener->addTag('kernel.event_listener', [ + 'event' => 'kernel.controller_arguments', + 'method' => 'onKernelControllerArguments', + ]); + } + + public function getAlias(): string + { + return 'paykit'; + } + + public function getConfigTreeBuilder(): TreeBuilder + { + $tree = new TreeBuilder('paykit'); + $root = $tree->getRootNode(); + $root->children() + ->scalarNode('network')->defaultValue('solana_devnet')->end() + ->scalarNode('rpc_url')->defaultNull()->end() + ->arrayNode('accept') + ->scalarPrototype()->end() + ->defaultValue(['x402', 'mpp']) + ->end() + ->arrayNode('stablecoins') + ->scalarPrototype()->end() + ->defaultValue(['USDC']) + ->end() + ->arrayNode('operator') + ->children() + ->scalarNode('recipient')->defaultNull()->end() + ->scalarNode('key')->defaultNull()->end() + ->booleanNode('fee_payer')->defaultTrue()->end() + ->end() + ->end() + ->scalarNode('x402_facilitator_url')->defaultNull()->end() + ->scalarNode('mpp_challenge_binding_secret')->defaultNull()->end() + ->booleanNode('preflight')->defaultTrue()->end() + ->end(); + return $tree; + } + + /** + * @param array $cfg + */ + public static function buildConfig(array $cfg): Config + { + $network = self::enumFromValue(Network::cases(), (string) ($cfg['network'] ?? 'solana_devnet'), Network::SolanaDevnet); + $accept = []; + foreach (($cfg['accept'] ?? []) as $s) { + $case = self::enumFromValue(Protocol::cases(), (string) $s, null); + if ($case !== null) { + $accept[] = $case; + } + } + $stablecoins = []; + foreach (($cfg['stablecoins'] ?? []) as $s) { + $case = self::enumFromValue(Stablecoin::cases(), (string) $s, null); + if ($case !== null) { + $stablecoins[] = $case; + } + } + $opCfg = $cfg['operator'] ?? []; + $signer = null; + if (!empty($opCfg['key'])) { + $raw = (string) $opCfg['key']; + $trimmed = trim($raw); + if (str_starts_with($trimmed, '[')) { + $signer = Signer::json($trimmed); + } elseif (strlen($trimmed) === 128 && ctype_xdigit($trimmed)) { + $signer = Signer::hex($trimmed); + } else { + $signer = Signer::base58($trimmed); + } + } + return new Config( + network: $network, + accept: $accept ?: [Protocol::X402, Protocol::Mpp], + stablecoins: $stablecoins ?: [Stablecoin::Usdc], + rpcUrl: isset($cfg['rpc_url']) && $cfg['rpc_url'] !== '' ? (string) $cfg['rpc_url'] : null, + operator: new Operator( + recipient: isset($opCfg['recipient']) && $opCfg['recipient'] !== '' ? (string) $opCfg['recipient'] : null, + signer: $signer, + feePayer: (bool) ($opCfg['fee_payer'] ?? true), + ), + x402: new X402Config( + facilitatorUrl: isset($cfg['x402_facilitator_url']) && $cfg['x402_facilitator_url'] !== '' + ? (string) $cfg['x402_facilitator_url'] + : null, + ), + mpp: new MppConfig( + challengeBindingSecret: isset($cfg['mpp_challenge_binding_secret']) && $cfg['mpp_challenge_binding_secret'] !== '' + ? (string) $cfg['mpp_challenge_binding_secret'] + : null, + ), + preflight: (bool) ($cfg['preflight'] ?? true), + ); + } + + /** + * @template T of \UnitEnum + * @param list $cases + * @param T|null $default + * @return T|null + */ + private static function enumFromValue(array $cases, string $value, ?object $default): ?object + { + foreach ($cases as $case) { + if (property_exists($case, 'value') && $case->value === $value) { + return $case; + } + } + return $default; + } +} diff --git a/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php new file mode 100644 index 000000000..69dd4be2c --- /dev/null +++ b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php @@ -0,0 +1,77 @@ + PSR-7 so the underlying middleware + * matches the Laravel + Slim + Mezzio + raw-PSR-15 codepath exactly. + */ +final class RequirePaymentListener +{ + public function __construct( + private readonly Client $client, + private readonly ?Pricing $pricing, + private readonly PsrHttpFactory $psrFactory, + private readonly HttpFoundationFactory $httpFactory, + ) { + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + $controller = $event->getController(); + $attribute = $this->extractAttribute($controller); + if ($attribute === null) { + return; + } + $psrRequest = $this->psrFactory->createRequest($event->getRequest()); + $middleware = new PsrRequirePayment($this->client, $attribute->gate, $this->pricing); + $psrResponse = $middleware->process( + $psrRequest, + new class () implements \Psr\Http\Server\RequestHandlerInterface { + public function handle(\Psr\Http\Message\ServerRequestInterface $req): \Psr\Http\Message\ResponseInterface + { + $factory = new \Nyholm\Psr7\Factory\Psr17Factory(); + return $factory->createResponse(200); + } + }, + ); + if ($psrResponse->getStatusCode() === 402) { + $event->getRequest()->attributes->set('_controller', function () use ($psrResponse) { + return $this->httpFactory->createResponse($psrResponse); + }); + $event->setController(function () use ($psrResponse) { + return $this->httpFactory->createResponse($psrResponse); + }); + } + } + + private function extractAttribute(mixed $controller): ?RequirePayment + { + if (!is_array($controller) || count($controller) !== 2) { + return null; + } + [$class, $method] = $controller; + if (!is_object($class) || !is_string($method)) { + return null; + } + $refl = new ReflectionMethod($class, $method); + $attrs = $refl->getAttributes(RequirePayment::class); + if ($attrs === []) { + return null; + } + return $attrs[0]->newInstance(); + } +} diff --git a/php/src/Frameworks/Symfony/PayKitBundle.php b/php/src/Frameworks/Symfony/PayKitBundle.php new file mode 100644 index 000000000..325a98182 --- /dev/null +++ b/php/src/Frameworks/Symfony/PayKitBundle.php @@ -0,0 +1,37 @@ + ['all' => true]]; + * + * Then publish `config/packages/paykit.yaml`: + * + * paykit: + * network: solana_devnet + * rpc_url: '%env(PAY_KIT_RPC_URL)%' + * operator: + * recipient: '%env(PAY_KIT_OPERATOR_RECIPIENT)%' + * key: '%env(PAY_KIT_OPERATOR_KEY)%' + * fee_payer: true + * mpp_challenge_binding_secret: '%env(PAY_KIT_MPP_CHALLENGE_BINDING_SECRET)%' + * + * Controller actions gate via the + * {@see \PayKit\Frameworks\Symfony\Attribute\RequirePayment} attribute. + */ +final class PayKitBundle extends Bundle +{ + public function getContainerExtension(): ?ExtensionInterface + { + return new PayKitExtension(); + } +} diff --git a/php/src/Gate.php b/php/src/Gate.php new file mode 100644 index 000000000..41da85e74 --- /dev/null +++ b/php/src/Gate.php @@ -0,0 +1,145 @@ + */ + public array $fees; + + /** @var list|null */ + public ?array $accept; + + /** + * @param array $feeWithin Map of recipient => price; taken out of amount. + * @param array $feeOnTop Map of recipient => price; added on top. + * @param list|null $accept Per-gate accept allowlist; null inherits from Config. + */ + public function __construct( + public Price $amount, + public ?string $payTo = null, + ?array $accept = null, + public ?string $description = null, + public ?string $externalId = null, + array $feeWithin = [], + array $feeOnTop = [], + ) { + $fees = []; + foreach ($feeWithin as $recipient => $price) { + $fees[] = self::buildFee($recipient, $price, Fee::KIND_WITHIN, $amount); + } + foreach ($feeOnTop as $recipient => $price) { + $fees[] = self::buildFee($recipient, $price, Fee::KIND_ON_TOP, $amount); + } + + // Rule 4: sum(feeWithin) <= amount + $withinSum = $amount->amount->minus($amount->amount); // BigDecimal zero in same scale + foreach ($fees as $f) { + if ($f->isWithin()) { + $withinSum = $withinSum->plus($f->price->amount); + } + } + if ($withinSum->isGreaterThan($amount->amount)) { + throw new InvalidArgumentException( + 'pay_kit: sum(feeWithin) exceeds amount on gate' + . ($description !== null ? " (description={$description})" : ''), + ); + } + + // Rule 5: x402 + fees is incompatible + $hasFees = count($fees) > 0; + if ($hasFees && $accept !== null && in_array(Protocol::X402, $accept, true)) { + throw new ProtocolIncompatibleException( + 'pay_kit: explicit accept: [Protocol::X402] on a fee-bearing gate is invalid ' + . '(stock x402 facilitators settle to a single address)', + ); + } + // If no explicit accept, the resolver strips X402 silently when + // fees are present. Mirror that here by leaving $accept null; + // Adapter.detect() honors the fee-presence check. + + $this->fees = $fees; + $this->accept = $accept; + } + + /** + * Total amount the customer pays: base amount + sum(feeOnTop). + */ + public function total(): Price + { + $total = $this->amount->amount; + foreach ($this->fees as $f) { + if ($f->isOnTop()) { + $total = $total->plus($f->price->amount); + } + } + return $this->amount->withAmount($total); + } + + /** + * What a given recipient nets, or null if not addressed by this gate. + */ + public function payout(string $address): ?Price + { + // The primary recipient nets amount - sum(all feeWithin). + if ($this->payTo === $address) { + $net = $this->amount->amount; + foreach ($this->fees as $f) { + if ($f->isWithin()) { + $net = $net->minus($f->price->amount); + } + } + return $this->amount->withAmount($net); + } + foreach ($this->fees as $f) { + if ($f->recipient === $address) { + return $f->price; + } + } + return null; + } + + public function hasFees(): bool + { + return count($this->fees) > 0; + } + + private static function buildFee(int|string $recipient, Price $price, string $kind, Price $amount): Fee + { + if (!is_string($recipient) || $recipient === '') { + throw new InvalidArgumentException('pay_kit: fee recipient must be a non-empty string'); + } + if ($price->currency !== $amount->currency) { + throw new MixedCurrenciesException(sprintf( + 'pay_kit: fee for %s is %s; gate amount is %s. All prices on a gate must share denom.', + $recipient, + $price->currency->value, + $amount->currency->value, + )); + } + return new Fee($recipient, $price, $kind); + } +} diff --git a/php/src/Middleware/RequirePayment.php b/php/src/Middleware/RequirePayment.php new file mode 100644 index 000000000..02c174f0a --- /dev/null +++ b/php/src/Middleware/RequirePayment.php @@ -0,0 +1,156 @@ +mpp = $mpp ?? new MppAdapter($client->config); + // Auto-wire the X402 adapter when the client's accept list + // includes Protocol::X402. Callers can still pass an explicit + // adapter to override (e.g. with an offline blockhash provider). + if ($x402 !== null) { + $this->x402 = $x402; + } elseif (in_array(Protocol::X402, $client->config->accept, true)) { + $this->x402 = new X402Adapter($client->config); + } else { + $this->x402 = null; + } + } + + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + $gate = $this->resolveGate($request); + + $adapter = $this->pickAdapter($gate, $request); + if ($adapter === null) { + return $this->build402($gate, $request); + } + + try { + $payment = $adapter->verifyAndSettle($gate, $request); + } catch (InvalidProofException | PaymentRequiredException $e) { + return $this->build402($gate, $request); + } + + $req = $request->withAttribute('paykit.payment', $payment); + $response = $handler->handle($req); + foreach ($payment->settlementHeaders as $k => $v) { + $response = $response->withHeader($k, $v); + } + return $response; + } + + private function resolveGate(ServerRequestInterface $request): Gate + { + if ($this->gateRef instanceof Gate) { + return $this->gateRef; + } + if ($this->gateRef instanceof Closure) { + return ($this->gateRef)($request); + } + $pricing = $this->pricing ?? $request->getAttribute('paykit.pricing'); + if (!$pricing instanceof Pricing) { + throw new \LogicException(sprintf( + 'pay_kit: RequirePayment("%s") needs a Pricing instance via the ' + . 'constructor or request attribute "paykit.pricing"', + $this->gateRef, + )); + } + return $pricing->gate($this->gateRef); + } + + private function pickAdapter(Gate $gate, ServerRequestInterface $request): ?object + { + $accept = $gate->accept ?? $this->client->config->accept; + $auth = $request->getHeaderLine('Authorization'); + $sig = $request->getHeaderLine('Payment-Signature'); + foreach ($accept as $scheme) { + if ($scheme === Protocol::X402 && $sig !== '' && $this->x402 !== null) { + return $this->x402; + } + if ($scheme === Protocol::Mpp && $auth !== '' && stripos($auth, 'payment ') === 0) { + return $this->mpp; + } + } + return null; + } + + private function build402(Gate $gate, ServerRequestInterface $request): ResponseInterface + { + $accepts = []; + $headers = []; + $accept = $gate->accept ?? $this->client->config->accept; + + if ($this->x402 !== null && in_array(Protocol::X402, $accept, true) && !$gate->hasFees()) { + $accepts[] = $this->x402->acceptsEntry($gate, $request); + $headers = array_merge($headers, $this->x402->challengeHeaders($gate, $request)); + } + if (in_array(Protocol::Mpp, $accept, true)) { + $accepts[] = $this->mpp->acceptsEntry($gate, $request); + $headers = array_merge($headers, $this->mpp->challengeHeaders($gate, $request)); + } + + $body = [ + 'error' => 'payment_required', + 'resource' => $request->getUri()->getPath(), + 'accepts' => $accepts, + ]; + $factory = HttpFactory::responseFactory(); + $resp = $factory->createResponse(402)->withHeader('content-type', 'application/json'); + foreach ($headers as $k => $v) { + $resp = $resp->withHeader($k, $v); + } + $stream = HttpFactory::streamFactory()->createStream(json_encode($body, JSON_THROW_ON_ERROR)); + return $resp->withBody($stream); + } +} diff --git a/php/src/Middleware/functions.php b/php/src/Middleware/functions.php new file mode 100644 index 000000000..38a4fa092 --- /dev/null +++ b/php/src/Middleware/functions.php @@ -0,0 +1,63 @@ +getAttribute('paykit.payment'); + return $value instanceof Payment ? $value : null; +} + +function isPaid(ServerRequestInterface $request): bool +{ + return payment($request) !== null; +} + +function isPaidFor(ServerRequestInterface $request, Gate|string $gate): bool +{ + $pmt = payment($request); + if ($pmt === null) { + return false; + } + if ($gate instanceof Gate) { + // Gate identity is not carried on Payment by default; assume + // the middleware that wrote the attribute matched the gate. + return true; + } + return $pmt->gateName === $gate; +} + +/** + * Imperative-style gating from inside a handler. Throws + * {@see PaymentRequiredException} when no payment is attached. + */ +function requirePayment(ServerRequestInterface $request): Payment +{ + $pmt = payment($request); + if ($pmt === null) { + throw new PaymentRequiredException('pay_kit: payment required'); + } + return $pmt; +} diff --git a/php/src/Operator.php b/php/src/Operator.php new file mode 100644 index 000000000..3a3967457 --- /dev/null +++ b/php/src/Operator.php @@ -0,0 +1,44 @@ +pubkey(), + * signer defaults to {@see Signer::demo()}. + */ +final readonly class Operator +{ + public function __construct( + public ?string $recipient = null, + public ?LocalSigner $signer = null, + public bool $feePayer = true, + ) { + } + + /** + * Resolve nulls into the package-shipped defaults. The Client + * constructor calls this exactly once at boot. + */ + public function withDefaults(): self + { + $signer = $this->signer ?? Signer::demo(); + $recipient = $this->recipient ?? $signer->pubkey(); + return new self($recipient, $signer, $this->feePayer); + } + + /** + * The recipient pubkey to settle to. Always non-null after + * `withDefaults()`. + */ + public function effectiveRecipient(): string + { + return $this->recipient ?? ($this->signer?->pubkey() ?? Signer::demo()->pubkey()); + } +} diff --git a/php/src/PayCore/Currency.php b/php/src/PayCore/Currency.php new file mode 100644 index 000000000..b4cec1d4f --- /dev/null +++ b/php/src/PayCore/Currency.php @@ -0,0 +1,16 @@ +fromGlobals(); + } + + /** + * Emit a PSR-7 response through the active SAPI. Handles a known + * PHP CLI dev server (php -S) quirk: when any `WWW-Authenticate` + * header is sent, the SAPI hard-codes the status to 401 regardless + * of `http_response_code()`. Workaround is to emit all other + * headers first and then force the status line as the final + * `header()` call. fpm / nginx / Apache are unaffected; the extra + * call is cheap and idempotent there. + */ + public static function emit(ResponseInterface $response): void + { + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header(sprintf('%s: %s', $name, $value), false); + } + } + header(sprintf( + 'HTTP/%s %d %s', + $response->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase(), + )); + echo (string) $response->getBody(); + } +} diff --git a/php/src/PayCore/Network.php b/php/src/PayCore/Network.php new file mode 100644 index 000000000..b671d5c06 --- /dev/null +++ b/php/src/PayCore/Network.php @@ -0,0 +1,62 @@ + 'https://api.mainnet-beta.solana.com', + self::SolanaDevnet => 'https://api.devnet.solana.com', + self::SolanaLocalnet => 'https://402.surfnet.dev:8899', + }; + } + + /** + * Network slug accepted by the legacy mints registry. Surfpool + * clones mainnet state, so localnet resolves to the mainnet row + * when a stablecoin has no localnet-specific entry. + */ + public function mintsLabel(): string + { + return match ($this) { + self::SolanaMainnet => 'mainnet', + self::SolanaDevnet => 'devnet', + self::SolanaLocalnet => 'localnet', + }; + } + + /** + * CAIP-2 chain identifier the x402 + MPP accepts entries advertise + * so clients (like `pay --sandbox curl`) can match the offered + * network against their active wallet. Surfpool-localnet clones + * mainnet state, so it reuses the devnet genesis hash by convention + * (matches the harness fixtures + the rust x402 client). + */ + public function caip2(): string + { + return match ($this) { + self::SolanaMainnet => 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + self::SolanaDevnet, + self::SolanaLocalnet => 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + }; + } +} diff --git a/php/src/Common/StablecoinMints.php b/php/src/PayCore/Solana/Mints.php similarity index 88% rename from php/src/Common/StablecoinMints.php rename to php/src/PayCore/Solana/Mints.php index c26b4dff2..b00abe5de 100644 --- a/php/src/Common/StablecoinMints.php +++ b/php/src/PayCore/Solana/Mints.php @@ -2,8 +2,10 @@ declare(strict_types=1); -namespace SolanaMpp\Common; +namespace PayKit\PayCore\Solana; +use SolanaPhpSdk\Keypair\PublicKey; +use SolanaPhpSdk\Programs\AssociatedTokenProgram; use SolanaPhpSdk\Programs\TokenProgram; /** @@ -16,7 +18,7 @@ * network like `"localnet"` into the concrete mint pubkey. Callers who already * pass a 32+ character base58 pubkey get it back unchanged. */ -final class StablecoinMints +final class Mints { // The canonical mainnet slug is `mainnet`. Legacy `mainnet-beta` // input is folded back to `mainnet` by normalizeNetwork() below before @@ -120,6 +122,21 @@ public static function tokenProgramFor(string $currency, string $network = 'main : TokenProgram::PROGRAM_ID; } + /** + * Derive the Associated Token Account address for (owner, mint, + * tokenProgram). Used by the boot-time preflight to assert the + * recipient owns an ATA for each accepted stablecoin. + */ + public static function deriveAta(string $owner, string $mint, string $tokenProgram): string + { + [$ata] = AssociatedTokenProgram::findAssociatedTokenAddress( + new PublicKey($owner), + new PublicKey($mint), + new PublicKey($tokenProgram), + ); + return (string) $ata; + } + /** * Reverse lookup: given a currency (symbol or mint), return the matching * symbol, or `null` if unknown. diff --git a/php/src/PayCore/Stablecoin.php b/php/src/PayCore/Stablecoin.php new file mode 100644 index 000000000..f506d80cf --- /dev/null +++ b/php/src/PayCore/Stablecoin.php @@ -0,0 +1,18 @@ + $settlementHeaders Headers to merge into the upstream 2xx response. + */ + public function __construct( + public Protocol $protocol, + public string $transaction, + public ?string $gateName, + public array $settlementHeaders = [], + public ?string $raw = null, + ) { + } +} diff --git a/php/src/Preflight.php b/php/src/Preflight.php new file mode 100644 index 000000000..29cf1a45c --- /dev/null +++ b/php/src/Preflight.php @@ -0,0 +1,226 @@ += 0.001 SOL. + * 2. Recipient ATA exists for each accepted stablecoin. + * + * On `solana_localnet` with the demo signer, missing accounts are + * auto-provisioned via Surfnet cheatcodes (surfnet_setAccount, + * surfnet_setTokenAccount) so the example apps "just work" against + * https://402.surfnet.dev:8899. Anywhere else, the missing account + * raises {@see ConfigurationException} at boot. RPC failures during + * preflight are caught and logged (not raised) so an unreachable + * endpoint never blocks boot. + * + * Opt-out: `Config(preflight: false)` or + * `PAY_KIT_DISABLE_PREFLIGHT=1`. + */ +final class Preflight +{ + public const MIN_FEE_PAYER_LAMPORTS = 1_000_000; + public const AUTOFUND_LAMPORTS = 10_000_000_000; + public const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111'; + + /** @var callable(string $method, array $params): mixed|null */ + private static $rpcCallableOverride = null; + + /** @codeCoverageIgnore */ + private function __construct() + { + } + + public static function isDisabledByEnv(): bool + { + $raw = getenv('PAY_KIT_DISABLE_PREFLIGHT'); + return $raw === '1' || $raw === 'true'; + } + + /** + * @param (callable(string,array):mixed)|null $override + * @internal + */ + public static function setRpcCallableForTests(?callable $override): void + { + self::$rpcCallableOverride = $override; + } + + public static function run(Config $config): void + { + $autofix = self::autofixEnabled($config); + + try { + self::checkFeePayerSol($config, $autofix); + } catch (ConfigurationException $e) { + throw $e; + } catch (Throwable $e) { + // RPC failure is transient; do not block boot. + self::warn('skipped fee-payer balance check: ' . $e->getMessage()); + } + + foreach ($config->stablecoins as $coin) { + try { + self::checkRecipientAta($config, $coin, $autofix); + } catch (ConfigurationException $e) { + throw $e; + } catch (Throwable $e) { + self::warn(sprintf( + 'skipped %s ATA check: %s', + $coin->value, + $e->getMessage(), + )); + } + } + } + + private static function autofixEnabled(Config $config): bool + { + if ($config->network !== Network::SolanaLocalnet) { + return false; + } + return $config->operator->signer?->isDemo() === true; + } + + private static function checkFeePayerSol(Config $config, bool $autofix): void + { + if (!$config->operator->feePayer) { + return; + } + $signer = $config->operator->signer; + if ($signer === null) { + return; + } + $pubkey = $signer->pubkey(); + $result = self::rpcCall($config, 'getBalance', [$pubkey, ['commitment' => 'confirmed']]); + $lamports = is_array($result) && isset($result['value']) ? (int) $result['value'] : 0; + if ($lamports >= self::MIN_FEE_PAYER_LAMPORTS) { + return; + } + if ($autofix) { + self::info(sprintf( + 'funding demo fee-payer %s with %d lamports via surfnet_setAccount', + $pubkey, + self::AUTOFUND_LAMPORTS, + )); + self::rpcCall($config, 'surfnet_setAccount', [ + $pubkey, + [ + 'lamports' => self::AUTOFUND_LAMPORTS, + 'data' => '', + 'executable' => false, + 'owner' => self::SYSTEM_PROGRAM_ID, + 'rentEpoch' => 0, + ], + ]); + return; + } + throw new ConfigurationException(sprintf( + 'pay_kit preflight: fee-payer %s has %d lamports on %s (need >= %d). ' + . 'Fund the account before booting.', + $pubkey, + $lamports, + $config->network->value, + self::MIN_FEE_PAYER_LAMPORTS, + )); + } + + private static function checkRecipientAta(Config $config, Stablecoin $coin, bool $autofix): void + { + $mintsLabel = $config->network->mintsLabel(); + $mint = \PayKit\PayCore\Solana\Mints::resolve($coin->value, $mintsLabel); + if ($mint === null) { + return; // native SOL has no ATA + } + $tokenProgram = \PayKit\PayCore\Solana\Mints::tokenProgramFor($coin->value, $mintsLabel); + $recipient = $config->effectiveRecipient(); + $ata = \PayKit\PayCore\Solana\Mints::deriveAta($recipient, $mint, $tokenProgram); + + $info = self::rpcCall($config, 'getAccountInfo', [ + $ata, + ['encoding' => 'base64', 'commitment' => 'confirmed'], + ]); + $value = is_array($info) && array_key_exists('value', $info) ? $info['value'] : null; + if ($value !== null) { + return; + } + + if ($autofix) { + self::info(sprintf( + 'provisioning %s ATA for %s (mint=%s) via surfnet_setTokenAccount', + $coin->value, + $recipient, + $mint, + )); + self::rpcCall($config, 'surfnet_setTokenAccount', [ + $recipient, + $mint, + ['amount' => 0, 'state' => 'initialized'], + $tokenProgram, + ]); + return; + } + throw new ConfigurationException(sprintf( + 'pay_kit preflight: recipient %s has no %s ATA on %s (expected %s). ' + . 'Create the ATA before booting.', + $recipient, + $coin->value, + $config->network->value, + $ata, + )); + } + + /** + * @param array $params + */ + private static function rpcCall(Config $config, string $method, array $params): mixed + { + $override = self::$rpcCallableOverride; + if ($override !== null) { + return $override($method, $params); + } + $body = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => $method, + 'params' => $params, + ], JSON_THROW_ON_ERROR); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $body, + 'timeout' => 5, + 'ignore_errors' => true, + ], + ]); + $raw = @file_get_contents($config->rpcUrl, false, $ctx); + if ($raw === false) { + throw new \RuntimeException(sprintf('rpc transport failure to %s', $config->rpcUrl)); + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + throw new \RuntimeException('rpc returned non-JSON: ' . substr($raw, 0, 100)); + } + return $decoded['result'] ?? null; + } + + private static function warn(string $msg): void + { + error_log('[pay_kit preflight] WARN ' . $msg); + } + + private static function info(string $msg): void + { + error_log('[pay_kit preflight] INFO ' . $msg); + } +} diff --git a/php/src/Price.php b/php/src/Price.php new file mode 100644 index 000000000..349e8310d --- /dev/null +++ b/php/src/Price.php @@ -0,0 +1,119 @@ + */ + public array $settlements; + + private function __construct( + public BigDecimal $amount, + public Currency $currency, + Stablecoin ...$settlements, + ) { + $this->settlements = $settlements; + } + + /** + * Build a USD-denominated price. + * + * @param string|int|BigDecimal $amount Decimal-safe amount (e.g. "0.10"). + */ + public static function usd(string|int|BigDecimal $amount, Stablecoin ...$settlements): self + { + return new self(self::toBigDecimal($amount), Currency::Usd, ...$settlements); + } + + public static function eur(string|int|BigDecimal $amount, Stablecoin ...$settlements): self + { + return new self(self::toBigDecimal($amount), Currency::Eur, ...$settlements); + } + + public static function gbp(string|int|BigDecimal $amount, Stablecoin ...$settlements): self + { + return new self(self::toBigDecimal($amount), Currency::Gbp, ...$settlements); + } + + /** + * Return a copy with a new amount, same denom + settlements. + */ + public function withAmount(string|int|BigDecimal $amount): self + { + return new self(self::toBigDecimal($amount), $this->currency, ...$this->settlements); + } + + /** + * Sum two same-denom prices. Throws on denom mismatch. + */ + public function plus(self $other): self + { + if ($this->currency !== $other->currency) { + throw new InvalidArgumentException( + sprintf( + 'pay_kit: cannot sum prices of different currencies (%s vs %s)', + $this->currency->value, + $other->currency->value + ), + ); + } + return new self( + $this->amount->plus($other->amount), + $this->currency, + ...$this->settlements, + ); + } + + /** + * The wire-form decimal string (preserves trailing zeros). + */ + public function amountString(): string + { + return (string) $this->amount; + } + + /** + * The most-preferred settlement coin, or null when the price was + * built without an explicit list (resolver falls back to the + * config-level stablecoins). + */ + public function primaryCoin(): ?Stablecoin + { + return $this->settlements[0] ?? null; + } + + private static function toBigDecimal(string|int|BigDecimal $amount): BigDecimal + { + if ($amount instanceof BigDecimal) { + return $amount; + } + try { + return BigDecimal::of($amount); + } catch (NumberFormatException $e) { + throw new InvalidArgumentException( + 'pay_kit: invalid Price amount: ' . $e->getMessage(), + previous: $e, + ); + } + } +} diff --git a/php/src/Pricing.php b/php/src/Pricing.php new file mode 100644 index 000000000..492046835 --- /dev/null +++ b/php/src/Pricing.php @@ -0,0 +1,45 @@ +gate('report')`) is provided + * for the Laravel middleware alias form (`middleware('paykit:report')`) + * which is parameter-string-only by framework constraint. + */ +abstract class Pricing +{ + /** + * Resolve a gate by name. Default implementation introspects + * declared public properties via Reflection; override for a + * registry-driven shape. + */ + public function gate(string $name): Gate + { + if (!property_exists($this, $name)) { + throw new InvalidArgumentException( + sprintf('pay_kit: Pricing has no gate "%s"', $name), + ); + } + $value = $this->{$name}; + if (!$value instanceof Gate) { + throw new InvalidArgumentException( + sprintf('pay_kit: Pricing::$%s is not a Gate', $name), + ); + } + return $value; + } +} diff --git a/php/src/Protocol.php b/php/src/Protocol.php new file mode 100644 index 000000000..44f0a44ce --- /dev/null +++ b/php/src/Protocol.php @@ -0,0 +1,17 @@ + */ + private array $handlerCache = []; + + public function __construct( + private readonly Config $config, + private readonly Store $replayStore = new MemoryStore(), + ) { + } + + public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array + { + $coin = $this->settlementCoin($gate); + $payTo = $gate->payTo ?? $this->config->effectiveRecipient(); + $entry = [ + 'protocol' => 'mpp', + 'scheme' => 'charge', + 'network' => $this->config->network->caip2(), + 'amount' => (string) $this->totalUnits($gate, $coin), + 'currency' => $coin, + 'payTo' => $payTo, + 'realm' => $this->config->mpp->realm, + ]; + if ($gate->hasFees()) { + $splits = []; + foreach ($gate->fees as $f) { + $splits[] = [ + 'recipient' => $f->recipient, + 'amount' => (string) $this->priceUnits($f->price), + ]; + } + $entry['splits'] = $splits; + } + return $entry; + } + + /** + * @return array + */ + public function challengeHeaders(Gate $gate, ServerRequestInterface $request): array + { + [$charges, $_handler] = $this->serverFor($gate); + $chargeRequest = $this->chargeRequestFor($gate); + $header = $charges->createChallengeHeader($chargeRequest); + return ['www-authenticate' => $header]; + } + + public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Payment + { + $authorization = $request->getHeaderLine('Authorization'); + if ($authorization === '') { + throw new InvalidProofException('pay_kit: payment required'); + } + + [$_charges, $handler] = $this->serverFor($gate); + $chargeRequest = $this->chargeRequestFor($gate); + $result = $handler->handle($authorization, $chargeRequest); + + if ($result instanceof PaymentRequiredResponse) { + throw new InvalidProofException( + 'pay_kit: ' . ($result->body['error'] ?? 'payment_invalid'), + ); + } + + return new Payment( + protocol: Protocol::Mpp, + transaction: (string) ($result->body['signature'] ?? ''), + gateName: null, + settlementHeaders: $result->headers, + raw: $authorization, + ); + } + + private function chargeRequestFor(Gate $gate): ChargeRequest + { + $coin = $this->settlementCoin($gate); + $payTo = $gate->payTo ?? $this->config->effectiveRecipient(); + $amount = (string) $this->priceUnits($gate->amount); + // Pay's MPP client reads request.methodDetails.network as the + // short network slug ("mainnet" / "devnet" / "localnet") when + // filtering challenges by active wallet + // (rust/crates/core/src/client/mpp.rs:83). Advertise the same + // slug `Mints::resolve` uses so `pay --sandbox --mpp curl` + // matches against its sandbox network. + $methodDetails = ['network' => $this->config->network->mintsLabel()]; + if ($gate->hasFees()) { + $splits = []; + foreach ($gate->fees as $f) { + $splits[] = [ + 'recipient' => $f->recipient, + 'amount' => (string) $this->priceUnits($f->price), + ]; + } + $methodDetails['splits'] = $splits; + } + $sgn = $this->config->operator->signer; + if ($this->config->operator->feePayer && $sgn !== null) { + $methodDetails['feePayer'] = true; + $methodDetails['feePayerKey'] = $sgn->pubkey(); + } + return new ChargeRequest( + amount: $amount, + currency: $coin, + recipient: $payTo, + description: $gate->description ?? '', + externalId: $gate->externalId ?? '', + methodDetails: $methodDetails === [] ? null : $methodDetails, + ); + } + + /** + * @return array{ChargeServer, SolanaChargeHandler} + */ + private function serverFor(Gate $gate): array + { + $coin = $this->settlementCoin($gate); + $payTo = $gate->payTo ?? $this->config->effectiveRecipient(); + $key = $payTo . '|' . $coin; + if (isset($this->handlerCache[$key])) { + return $this->handlerCache[$key]; + } + $charges = new ChargeServer( + secretKey: $this->config->mpp->challengeBindingSecret ?? '', + realm: $this->config->mpp->realm, + method: 'solana', + ); + $rpc = new RpcClient($this->config->rpcUrl); + $feePayer = null; + $sgn = $this->config->operator->signer; + if ($this->config->operator->feePayer && $sgn !== null) { + $feePayer = Keypair::fromSecretKey($sgn->secretKey()); + } + $handler = new SolanaChargeHandler( + challenges: $charges, + rpc: $rpc, + feePayer: $feePayer, + network: $this->config->network->mintsLabel(), + replayStore: $this->replayStore, + ); + $this->handlerCache[$key] = [$charges, $handler]; + return $this->handlerCache[$key]; + } + + private function settlementCoin(Gate $gate): string + { + $primary = $gate->amount->primaryCoin(); + return $primary?->value ?? $this->config->stablecoins[0]->value; + } + + private function totalUnits(Gate $gate, string $coin): int + { + return $this->priceUnits($gate->total()); + } + + private function priceUnits(Price $price): int + { + return $price->amount->multipliedBy(1_000_000)->toInt(); + } +} diff --git a/php/src/Core/Base64Url.php b/php/src/Protocols/Mpp/Core/Base64Url.php similarity index 98% rename from php/src/Core/Base64Url.php rename to php/src/Protocols/Mpp/Core/Base64Url.php index 926ba1363..02a6c7d1f 100644 --- a/php/src/Core/Base64Url.php +++ b/php/src/Protocols/Mpp/Core/Base64Url.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; use InvalidArgumentException; use JsonException; diff --git a/php/src/Core/Challenge.php b/php/src/Protocols/Mpp/Core/Challenge.php similarity index 99% rename from php/src/Core/Challenge.php rename to php/src/Protocols/Mpp/Core/Challenge.php index 8127a3746..5ae4d0394 100644 --- a/php/src/Core/Challenge.php +++ b/php/src/Protocols/Mpp/Core/Challenge.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; use DateTimeImmutable; use InvalidArgumentException; diff --git a/php/src/Core/ChallengeEcho.php b/php/src/Protocols/Mpp/Core/ChallengeEcho.php similarity index 98% rename from php/src/Core/ChallengeEcho.php rename to php/src/Protocols/Mpp/Core/ChallengeEcho.php index 10c85a79b..db4c72edf 100644 --- a/php/src/Core/ChallengeEcho.php +++ b/php/src/Protocols/Mpp/Core/ChallengeEcho.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; /** * Carries the challenge fields echoed inside a Payment credential. diff --git a/php/src/Core/Credential.php b/php/src/Protocols/Mpp/Core/Credential.php similarity index 98% rename from php/src/Core/Credential.php rename to php/src/Protocols/Mpp/Core/Credential.php index 891b9c2d1..fdb29a5ef 100644 --- a/php/src/Core/Credential.php +++ b/php/src/Protocols/Mpp/Core/Credential.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; use InvalidArgumentException; diff --git a/php/src/Core/Headers.php b/php/src/Protocols/Mpp/Core/Headers.php similarity index 99% rename from php/src/Core/Headers.php rename to php/src/Protocols/Mpp/Core/Headers.php index d4e88893c..6392acfc8 100644 --- a/php/src/Core/Headers.php +++ b/php/src/Protocols/Mpp/Core/Headers.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; use InvalidArgumentException; diff --git a/php/src/Core/Json.php b/php/src/Protocols/Mpp/Core/Json.php similarity index 99% rename from php/src/Core/Json.php rename to php/src/Protocols/Mpp/Core/Json.php index 16b432859..fccf40cd4 100644 --- a/php/src/Core/Json.php +++ b/php/src/Protocols/Mpp/Core/Json.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; use InvalidArgumentException; diff --git a/php/src/Core/Receipt.php b/php/src/Protocols/Mpp/Core/Receipt.php similarity index 98% rename from php/src/Core/Receipt.php rename to php/src/Protocols/Mpp/Core/Receipt.php index 3a3eb309e..37b1b450c 100644 --- a/php/src/Core/Receipt.php +++ b/php/src/Protocols/Mpp/Core/Receipt.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; use DateTimeImmutable; use DateTimeZone; diff --git a/php/src/Core/Rfc3339Parser.php b/php/src/Protocols/Mpp/Core/Rfc3339Parser.php similarity index 99% rename from php/src/Core/Rfc3339Parser.php rename to php/src/Protocols/Mpp/Core/Rfc3339Parser.php index a269f0394..8c885458e 100644 --- a/php/src/Core/Rfc3339Parser.php +++ b/php/src/Protocols/Mpp/Core/Rfc3339Parser.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Core; +namespace PayKit\Protocols\Mpp\Core; use DateTimeImmutable; diff --git a/php/src/Intent/ChargeRequest.php b/php/src/Protocols/Mpp/Intent/ChargeRequest.php similarity index 97% rename from php/src/Intent/ChargeRequest.php rename to php/src/Protocols/Mpp/Intent/ChargeRequest.php index abf9e3db3..7af9b2b32 100644 --- a/php/src/Intent/ChargeRequest.php +++ b/php/src/Protocols/Mpp/Intent/ChargeRequest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace SolanaMpp\Intent; +namespace PayKit\Protocols\Mpp\Intent; use InvalidArgumentException; -use SolanaMpp\Core\Json; +use PayKit\Protocols\Mpp\Core\Json; /** * Represents the MPP charge intent request embedded in a challenge. diff --git a/php/src/Protocols/Mpp/MppConfig.php b/php/src/Protocols/Mpp/MppConfig.php new file mode 100644 index 000000000..e209b56fa --- /dev/null +++ b/php/src/Protocols/Mpp/MppConfig.php @@ -0,0 +1,36 @@ +realm, $secret, $this->expiresIn); + } +} diff --git a/php/src/Protocols/Mpp/SecretResolver.php b/php/src/Protocols/Mpp/SecretResolver.php new file mode 100644 index 000000000..dd7c5214f --- /dev/null +++ b/php/src/Protocols/Mpp/SecretResolver.php @@ -0,0 +1,124 @@ + $fromEnv, 'source' => 'env', 'persisted' => true]; + } + + $fromDotenv = self::readDotenv($dotenvPath, $envVar); + if ($fromDotenv !== null) { + return ['secret' => $fromDotenv, 'source' => 'dotenv', 'persisted' => true]; + } + + $generated = bin2hex(random_bytes(32)); + $persisted = self::appendToDotenv($dotenvPath, $envVar, $generated); + return [ + 'secret' => $generated, + 'source' => $persisted ? 'generated+persisted' : 'generated', + 'persisted' => $persisted, + ]; + } + + private static function readDotenv(string $path, string $key): ?string + { + if (!is_readable($path)) { + return null; + } + $handle = @fopen($path, 'r'); + if ($handle === false) { + return null; + } + try { + while (($line = fgets($handle)) !== false) { + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + $eq = strpos($trimmed, '='); + if ($eq === false) { + continue; + } + $name = trim(substr($trimmed, 0, $eq)); + if ($name !== $key) { + continue; + } + $value = trim(substr($trimmed, $eq + 1)); + // Strip optional surrounding quotes. + if (strlen($value) >= 2 && ( + ($value[0] === '"' && substr($value, -1) === '"') || + ($value[0] === "'" && substr($value, -1) === "'") + )) { + $value = substr($value, 1, -1); + } + return $value !== '' ? $value : null; + } + } finally { + fclose($handle); + } + return null; + } + + private static function appendToDotenv(string $path, string $key, string $value): bool + { + $line = sprintf('%s=%s%s', $key, $value, PHP_EOL); + $existed = is_file($path); + // Use 'a' to append; create with 0600 if it didn't exist. + $fh = @fopen($path, 'a'); + if ($fh === false) { + return false; + } + try { + if (!$existed) { + @chmod($path, 0600); + } + $bytes = fwrite($fh, $line); + return $bytes === strlen($line); + } finally { + fclose($fh); + } + } +} diff --git a/php/src/Server/ChargeServer.php b/php/src/Protocols/Mpp/Server/ChargeServer.php similarity index 96% rename from php/src/Server/ChargeServer.php rename to php/src/Protocols/Mpp/Server/ChargeServer.php index 711f6549d..b3e8425b0 100644 --- a/php/src/Server/ChargeServer.php +++ b/php/src/Protocols/Mpp/Server/ChargeServer.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; use Closure; use DateTimeImmutable; use InvalidArgumentException; use Throwable; -use SolanaMpp\Core\Base64Url; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\Credential; -use SolanaMpp\Core\Headers; -use SolanaMpp\Core\Json; -use SolanaMpp\Core\Receipt; -use SolanaMpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\Core\Base64Url; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\Credential; +use PayKit\Protocols\Mpp\Core\Headers; +use PayKit\Protocols\Mpp\Core\Json; +use PayKit\Protocols\Mpp\Core\Receipt; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; /** * Issues charge challenges and verifies Payment credentials for a PHP server. diff --git a/php/src/Server/ChargeSettlement.php b/php/src/Protocols/Mpp/Server/ChargeSettlement.php similarity index 95% rename from php/src/Server/ChargeSettlement.php rename to php/src/Protocols/Mpp/Server/ChargeSettlement.php index b0a48780a..3e3cc714b 100644 --- a/php/src/Server/ChargeSettlement.php +++ b/php/src/Protocols/Mpp/Server/ChargeSettlement.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; /** * Successful charge settlement: on-chain signature plus the HTTP envelope. diff --git a/php/src/Server/PaymentRequiredResponse.php b/php/src/Protocols/Mpp/Server/PaymentRequiredResponse.php similarity index 96% rename from php/src/Server/PaymentRequiredResponse.php rename to php/src/Protocols/Mpp/Server/PaymentRequiredResponse.php index de90e2399..7fa043b4f 100644 --- a/php/src/Server/PaymentRequiredResponse.php +++ b/php/src/Protocols/Mpp/Server/PaymentRequiredResponse.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; /** * Protocol-canonical 402 Payment Required response payload. diff --git a/php/src/Server/PaymentVerifier.php b/php/src/Protocols/Mpp/Server/PaymentVerifier.php similarity index 71% rename from php/src/Server/PaymentVerifier.php rename to php/src/Protocols/Mpp/Server/PaymentVerifier.php index af5160445..fa6cd76b6 100644 --- a/php/src/Server/PaymentVerifier.php +++ b/php/src/Protocols/Mpp/Server/PaymentVerifier.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\Credential; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\Credential; /** * Verifies the payment payload embedded in a credential. diff --git a/php/src/Protocols/Mpp/Server/RpcGateway.php b/php/src/Protocols/Mpp/Server/RpcGateway.php new file mode 100644 index 000000000..6c7059d23 --- /dev/null +++ b/php/src/Protocols/Mpp/Server/RpcGateway.php @@ -0,0 +1,42 @@ + $params + */ + public function call(string $method, array $params = []): mixed; + + /** + * Submit the already-signed wire-format transaction. Returns the + * base58 signature. + * + * @param array $options + */ + public function sendRawTransaction(string $wireBytes, array $options = []): string; + + /** + * Look up signature confirmation statuses. + * + * @param list $signatures + * @return array|null> + */ + public function getSignatureStatuses(array $signatures, bool $searchTransactionHistory = false): array; +} diff --git a/php/src/Server/SolanaChargeHandler.php b/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php similarity index 97% rename from php/src/Server/SolanaChargeHandler.php rename to php/src/Protocols/Mpp/Server/SolanaChargeHandler.php index 9a4bb1b58..33265b236 100644 --- a/php/src/Server/SolanaChargeHandler.php +++ b/php/src/Protocols/Mpp/Server/SolanaChargeHandler.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; use InvalidArgumentException; use RuntimeException; use Throwable; -use SolanaMpp\Core\Credential; -use SolanaMpp\Intent\ChargeRequest; -use SolanaMpp\Store\MemoryStore; -use SolanaMpp\Store\Store; +use PayKit\Protocols\Mpp\Core\Credential; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; +use PayKit\Store\MemoryStore; +use PayKit\Store\Store; use SolanaPhpSdk\Keypair\Keypair; use SolanaPhpSdk\Rpc\RpcClient; use SolanaPhpSdk\Transaction\Transaction; @@ -69,9 +69,11 @@ final class SolanaChargeHandler * inject a shared atomic store (Redis, Postgres) so replay * protection survives restarts and worker pools. */ + private readonly RpcGateway $rpc; + public function __construct( private readonly ChargeServer $challenges, - private readonly RpcClient $rpc, + RpcClient|RpcGateway $rpc, private readonly ?Keypair $feePayer = null, private readonly string $network = 'mainnet', private readonly string $settlementHeader = 'x-payment-settlement-signature', @@ -81,6 +83,7 @@ public function __construct( private readonly int $confirmationDelayMicros = 250_000, ?Store $replayStore = null, ) { + $this->rpc = $rpc instanceof RpcGateway ? $rpc : new SolanaRpcGateway($rpc); $this->verifier = $verifier ?? new SolanaChargeTransactionVerifier(); $this->transactionVerifier = $transactionVerifier ?? ($this->verifier instanceof TransactionPayloadVerifier ? $this->verifier : new SolanaChargeTransactionVerifier()); diff --git a/php/src/Server/SolanaChargeTransactionVerifier.php b/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php similarity index 98% rename from php/src/Server/SolanaChargeTransactionVerifier.php rename to php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php index 6ae4bd280..3c2e4d16c 100644 --- a/php/src/Server/SolanaChargeTransactionVerifier.php +++ b/php/src/Protocols/Mpp/Server/SolanaChargeTransactionVerifier.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; use InvalidArgumentException; use Throwable; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Common\StablecoinMints; -use SolanaMpp\Core\Credential; -use SolanaMpp\Core\Json; -use SolanaMpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\PayCore\Solana\Mints; +use PayKit\Protocols\Mpp\Core\Credential; +use PayKit\Protocols\Mpp\Core\Json; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; use SolanaPhpSdk\Keypair\PublicKey; use SolanaPhpSdk\Programs\AssociatedTokenProgram; use SolanaPhpSdk\Programs\MemoProgram; @@ -180,9 +180,9 @@ private function verifyTransaction(string $transactionBase64, ChargeRequest $req } $network = Json::optionalString($methodDetails['network'] ?? null, 'methodDetails.network', 'mainnet'); - $resolvedMint = StablecoinMints::resolve($request->currency, $network) ?? $request->currency; + $resolvedMint = Mints::resolve($request->currency, $network) ?? $request->currency; $mint = new PublicKey($resolvedMint); - $defaultTokenProgram = StablecoinMints::tokenProgramFor($request->currency, $network); + $defaultTokenProgram = Mints::tokenProgramFor($request->currency, $network); $tokenProgram = new PublicKey(Json::optionalString($methodDetails['tokenProgram'] ?? null, 'methodDetails.tokenProgram', $defaultTokenProgram)); $decimals = Json::optionalInt($methodDetails['decimals'] ?? null, 'methodDetails.decimals'); $allowedAtaOwners = $this->allowedAtaOwners($splits, $feePayer); diff --git a/php/src/Protocols/Mpp/Server/SolanaRpcGateway.php b/php/src/Protocols/Mpp/Server/SolanaRpcGateway.php new file mode 100644 index 000000000..ec44652ac --- /dev/null +++ b/php/src/Protocols/Mpp/Server/SolanaRpcGateway.php @@ -0,0 +1,33 @@ +rpc->call($method, $params); + } + + public function sendRawTransaction(string $wireBytes, array $options = []): string + { + return $this->rpc->sendRawTransaction($wireBytes, $options); + } + + public function getSignatureStatuses(array $signatures, bool $searchTransactionHistory = false): array + { + return $this->rpc->getSignatureStatuses($signatures, $searchTransactionHistory); + } +} diff --git a/php/src/Server/TransactionPayloadVerifier.php b/php/src/Protocols/Mpp/Server/TransactionPayloadVerifier.php similarity index 92% rename from php/src/Server/TransactionPayloadVerifier.php rename to php/src/Protocols/Mpp/Server/TransactionPayloadVerifier.php index 18f777b8a..7a32dede6 100644 --- a/php/src/Server/TransactionPayloadVerifier.php +++ b/php/src/Protocols/Mpp/Server/TransactionPayloadVerifier.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; -use SolanaMpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; /** * Verifies Solana transaction payloads independent of HTTP credential parsing. diff --git a/php/src/Server/VerificationResult.php b/php/src/Protocols/Mpp/Server/VerificationResult.php similarity index 94% rename from php/src/Server/VerificationResult.php rename to php/src/Protocols/Mpp/Server/VerificationResult.php index c6dc3f2da..e490482a7 100644 --- a/php/src/Server/VerificationResult.php +++ b/php/src/Protocols/Mpp/Server/VerificationResult.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace SolanaMpp\Server; +namespace PayKit\Protocols\Mpp\Server; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\Credential; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\Credential; /** * Carries the result of a payment credential verification attempt. diff --git a/php/src/Protocols/X402/Adapter.php b/php/src/Protocols/X402/Adapter.php new file mode 100644 index 000000000..305f736f1 --- /dev/null +++ b/php/src/Protocols/X402/Adapter.php @@ -0,0 +1,257 @@ +x402->isDelegated()) { + throw new InvalidProofException( + 'pay_kit: x402 delegated mode is not yet implemented; ' + . 'leave X402Config::$facilitatorUrl null for self-hosted', + ); + } + $this->recentBlockhashProvider = $recentBlockhashProvider; + } + + /** + * Build a single entry for the 402 accepts[] array. + * + * @return array + */ + public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array + { + $coin = $gate->amount->primaryCoin()?->value ?? $this->config->stablecoins[0]->value; + // x402 spec puts the on-chain mint pubkey on `asset`, not the + // ticker. Resolve the ticker to a mint via the legacy Mints + // registry; Mints::resolve already falls back to the mainnet + // row when the network row is absent (Ruby PR #142 caveat #1). + $asset = \PayKit\PayCore\Solana\Mints::resolve($coin, $this->config->network->mintsLabel()) ?? $coin; + $tokenProgram = \PayKit\PayCore\Solana\Mints::tokenProgramFor($coin, $this->config->network->mintsLabel()); + $payTo = $gate->payTo ?? $this->config->effectiveRecipient(); + $amount = (string) $gate->total()->amount->multipliedBy(1_000_000)->toInt(); + $signer = $this->config->effectiveX402Signer(); + $extra = [ + 'feePayer' => $signer?->pubkey() ?? '', + 'decimals' => 6, + 'tokenProgram' => $tokenProgram, + 'memo' => $request->getUri()->getPath(), + ]; + // Ruby PR #142 caveat #5: stamp the server's recent_blockhash + // into accepted.extra so pay-kit clients sign against the + // same chain state the server will broadcast to. Closes the + // surfpool / forked-mainnet drift the Sinatra example hit. + // Scope: pay-kit Rust client honours this field; canonical + // TS / Go x402 clients ignore it and call getLatestBlockhash + // against their own RPC. Harmless on real networks. + $blockhash = $this->fetchRecentBlockhash(); + if ($blockhash !== null) { + $extra['recentBlockhash'] = $blockhash; + } + return [ + 'protocol' => 'x402', + 'scheme' => 'exact', + 'network' => $this->caip2(), + 'asset' => $asset, + 'amount' => $amount, + 'maxAmountRequired' => $amount, + 'payTo' => $payTo, + 'maxTimeoutSeconds' => 60, + 'extra' => $extra, + ]; + } + + private function fetchRecentBlockhash(): ?string + { + if ($this->recentBlockhashProvider !== null) { + try { + $value = ($this->recentBlockhashProvider)(); + return is_string($value) && $value !== '' ? $value : null; + } catch (Throwable) { + return null; + } + } + if ($this->config->rpcUrl === '') { + return null; + } + try { + $rpc = new \SolanaPhpSdk\Rpc\RpcClient($this->config->rpcUrl); + $result = $rpc->getLatestBlockhash(); + $value = is_array($result) && isset($result['blockhash']) ? (string) $result['blockhash'] : null; + return $value !== '' ? $value : null; + } catch (Throwable) { + return null; + } + } + + /** + * @return array + */ + public function challengeHeaders(Gate $gate, ServerRequestInterface $request): array + { + $challenge = [ + 'x402Version' => self::X402_VERSION, + 'resource' => ['type' => 'http', 'url' => $request->getUri()->getPath()], + 'accepts' => [$this->acceptsEntry($gate, $request)], + ]; + return [ + 'payment-required' => base64_encode(json_encode($challenge, JSON_THROW_ON_ERROR)), + ]; + } + + public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Payment + { + $signer = $this->config->effectiveX402Signer(); + if ($signer === null) { + throw new InvalidProofException('pay_kit: x402 requires operator.signer'); + } + $header = $request->getHeaderLine('Payment-Signature'); + if ($header === '') { + $header = $request->getHeaderLine('PAYMENT-SIGNATURE'); + } + if ($header === '') { + throw new InvalidProofException('pay_kit: payment required'); + } + + // Decode credential. + $decoded = base64_decode($header, true); + if ($decoded === false) { + throw new InvalidProofException('invalid_exact_svm_payload_signature_base64'); + } + try { + $envelope = json_decode($decoded, true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable) { + throw new InvalidProofException('invalid_exact_svm_payload_signature_json'); + } + if (!is_array($envelope) || ($envelope['x402Version'] ?? null) !== self::X402_VERSION) { + throw new InvalidProofException('unsupported_x402_version'); + } + $accepted = $envelope['accepted'] ?? null; + $payload = $envelope['payload'] ?? null; + if (!is_array($accepted) || !is_array($payload)) { + throw new InvalidProofException('invalid_exact_svm_payload_envelope'); + } + + // Identity-key match (cross-SDK PR #138 alignment). + $offer = $this->acceptsEntry($gate, $request); + foreach (['scheme', 'network', 'asset', 'payTo'] as $key) { + if (($accepted[$key] ?? null) !== ($offer[$key] ?? null)) { + throw new InvalidProofException( + 'pay_kit: charge_request_mismatch: ' + . 'accepted payment requirement does not match server challenge', + ); + } + } + $offerExtra = $offer['extra'] ?? []; + $acceptedExtra = $accepted['extra'] ?? []; + foreach (['feePayer', 'tokenProgram', 'memo'] as $key) { + if (array_key_exists($key, $offerExtra) + && ($acceptedExtra[$key] ?? null) !== $offerExtra[$key]) { + throw new InvalidProofException( + 'pay_kit: charge_request_mismatch (extra.' . $key . ')', + ); + } + } + + $txBase64 = is_string($payload['transaction'] ?? null) ? $payload['transaction'] : ''; + if ($txBase64 === '') { + throw new InvalidProofException('invalid_exact_svm_payload_missing_transaction'); + } + + // Verify structural shape (11 rules). + Verifier::verify($txBase64, $offer, [$signer->pubkey()]); + + // Cosign as facilitator. + $rawTx = base64_decode($txBase64, true); + if ($rawTx === false) { + throw new InvalidProofException('invalid_exact_svm_payload_base64'); + } + try { + $tx = VersionedTransaction::deserialize($rawTx); + } catch (Throwable) { + throw new InvalidProofException('invalid_exact_svm_payload_transaction_parse'); + } + $kp = Keypair::fromSecretKey($signer->secretKey()); + $tx->partialSign($kp); + $cosignedWire = $tx->serialize(verifySignatures: false); + + // Broadcast via the raw-wire path so PHP doesn't have to + // reconstruct a SignedTransaction wrapper just to send. + $rpc = new RpcClient($this->config->rpcUrl); + try { + $sig = $rpc->sendRawTransaction($cosignedWire, ['encoding' => 'base64', 'skipPreflight' => false]); + } catch (Throwable $e) { + throw new InvalidProofException( + 'pay_kit: invalid proof: broadcast failed: ' . $e->getMessage(), + ); + } + if (!is_string($sig) || $sig === '') { + throw new InvalidProofException('pay_kit: empty broadcast result'); + } + + // Reserve in replay store. + if (!$this->replayStore->putIfAbsent('x402-svm-exact:consumed:' . $sig, true)) { + throw new InvalidProofException('pay_kit: signature_consumed'); + } + + $responseEnvelope = base64_encode(json_encode([ + 'success' => true, + 'transaction' => $sig, + 'network' => $accepted['network'] ?? $this->caip2(), + 'payer' => $payload['transactionHash'] ?? '', + ], JSON_THROW_ON_ERROR)); + + return new Payment( + protocol: Protocol::X402, + transaction: $sig, + gateName: null, + settlementHeaders: [ + 'payment-response' => $responseEnvelope, + 'x-payment-settlement-signature' => $sig, + ], + raw: $header, + ); + } + + private function caip2(): string + { + return $this->config->network->caip2(); + } +} diff --git a/php/src/Protocols/X402/Exact/Verifier.php b/php/src/Protocols/X402/Exact/Verifier.php new file mode 100644 index 000000000..9dae0e025 --- /dev/null +++ b/php/src/Protocols/X402/Exact/Verifier.php @@ -0,0 +1,375 @@ + $requirement The x402 accepts[] entry. + * @param list $managedSigners Server-managed pubkeys (typically the facilitator). + * + * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:int,destinationCreateAta:bool} + */ + public static function verify( + string $transactionBase64, + array $requirement, + array $managedSigners, + ): array { + $raw = base64_decode($transactionBase64, true); + if ($raw === false || $raw === '') { + throw new InvalidProofException('invalid_exact_svm_payload_base64'); + } + + try { + $tx = VersionedTransaction::deserialize($raw); + } catch (Throwable) { + throw new InvalidProofException('invalid_exact_svm_payload_transaction_parse'); + } + + $message = $tx->message; + $instructions = $message->compiledInstructions; + + // Rule 1: instruction count. + $n = count($instructions); + if ($n < 3 || $n > 6) { + throw new InvalidProofException( + 'invalid_exact_svm_payload_transaction_instructions_length', + ); + } + + $accountKeys = array_map( + static fn (PublicKey $k): string => (string) $k, + $message->staticAccountKeys, + ); + + // Rule 2: compute-budget set-compute-unit-limit. + self::verifyComputeLimit($instructions[0], $accountKeys); + // Rule 3: compute-budget set-compute-unit-price. + self::verifyComputePrice($instructions[1], $accountKeys); + // Rules 4 + 5 + 6 + 7 + 8 + 11. + $transfer = self::verifyTransfer($instructions[2], $accountKeys, $requirement, $managedSigners); + + // Rule 9: ix[3..6] allowlist. + $destinationCreateAta = false; + $reasons = [ + 'invalid_exact_svm_payload_unknown_fourth_instruction', + 'invalid_exact_svm_payload_unknown_fifth_instruction', + 'invalid_exact_svm_payload_unknown_sixth_instruction', + ]; + for ($i = 3; $i < $n; $i++) { + $ix = $instructions[$i]; + $program = self::programOf($accountKeys, $ix); + $slotIndex = $i - 3; + $allowed = ($program === self::MEMO_PROGRAM) + || ($slotIndex < 2 && $program === self::LIGHTHOUSE_PROGRAM); + if (!$allowed && $slotIndex < 2 + && self::validAtaCreate($ix, $accountKeys, $requirement, $transfer)) { + $destinationCreateAta = true; + $allowed = true; + } + if (!$allowed) { + throw new InvalidProofException( + $reasons[$slotIndex] ?? 'invalid_exact_svm_payload_unknown_optional_instruction', + ); + } + } + + // Rule 10: memo binding. + $expectedMemo = self::stringExtra($requirement, 'memo', false); + if ($expectedMemo !== null && $expectedMemo !== '') { + self::findMemoMatch($accountKeys, $instructions, $expectedMemo); + } + + $transfer['destinationCreateAta'] = $destinationCreateAta; + return $transfer; + } + + /** + * @param object{programIdIndex:int,data:string,accountKeyIndexes:array} $ix + * @param list $accountKeys + */ + private static function verifyComputeLimit(object $ix, array $accountKeys): void + { + $program = self::programOf($accountKeys, $ix); + $data = $ix->data; + if ($program !== self::COMPUTE_BUDGET_PROGRAM + || strlen($data) !== 5 + || ord($data[0]) !== 2) { + throw new InvalidProofException( + 'invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction', + ); + } + } + + /** + * @param object{programIdIndex:int,data:string,accountKeyIndexes:array} $ix + * @param list $accountKeys + */ + private static function verifyComputePrice(object $ix, array $accountKeys): void + { + $program = self::programOf($accountKeys, $ix); + $data = $ix->data; + if ($program !== self::COMPUTE_BUDGET_PROGRAM + || strlen($data) !== 9 + || ord($data[0]) !== 3) { + throw new InvalidProofException( + 'invalid_exact_svm_payload_transaction_instructions_compute_price_instruction', + ); + } + $micro = self::readU64Le($data, 1); + if ($micro > self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS) { + throw new InvalidProofException( + 'invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high', + ); + } + } + + /** + * @param object{programIdIndex:int,data:string,accountKeyIndexes:array} $ix + * @param list $accountKeys + * @param array $requirement + * @param list $managedSigners + * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:int} + */ + private static function verifyTransfer( + object $ix, + array $accountKeys, + array $requirement, + array $managedSigners, + ): array { + $program = self::programOf($accountKeys, $ix); + $tokenProgramExtra = self::stringExtra($requirement, 'tokenProgram', true); + if ($program !== $tokenProgramExtra && $program !== self::TOKEN_2022_PROGRAM) { + throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); + } + $data = $ix->data; + if (count($ix->accountKeyIndexes) < 4 || strlen($data) !== 10 || ord($data[0]) !== 12) { + throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); + } + + $source = self::accountAt($accountKeys, $ix, 0); + $mint = self::accountAt($accountKeys, $ix, 1); + $destination = self::accountAt($accountKeys, $ix, 2); + $authority = self::accountAt($accountKeys, $ix, 3); + + // Rule 5: authority guard. + foreach ($managedSigners as $managed) { + if ($managed === $authority || $managed === $source) { + throw new InvalidProofException( + 'invalid_exact_svm_payload_transaction_fee_payer_transferring_funds', + ); + } + } + foreach ($ix->accountKeyIndexes as $idx) { + $key = $accountKeys[$idx] ?? null; + if ($key === null) { + continue; + } + foreach ($managedSigners as $managed) { + if ($managed === $key) { + throw new InvalidProofException( + 'invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts', + ); + } + } + } + + // Rule 6: mint match. + $expectedMint = self::b58Field($requirement, 'asset'); + if ($mint !== $expectedMint) { + throw new InvalidProofException('invalid_exact_svm_payload_mint_mismatch'); + } + + // Rule 7: destination ATA match. + $payTo = self::b58Field($requirement, 'payTo'); + $expectedDestination = Mints::deriveAta($payTo, $expectedMint, $program); + if ($destination !== $expectedDestination) { + throw new InvalidProofException('invalid_exact_svm_payload_recipient_mismatch'); + } + + // Rule 8: amount match. + $amount = self::readU64Le($data, 1); + $expectedAmount = self::amountField($requirement); + if ($amount !== $expectedAmount) { + throw new InvalidProofException('invalid_exact_svm_payload_amount_mismatch'); + } + + return [ + 'program' => $program, + 'source' => $source, + 'mint' => $mint, + 'destination' => $destination, + 'authority' => $authority, + 'amount' => $amount, + ]; + } + + /** + * @param object{programIdIndex:int,data:string,accountKeyIndexes:array} $ix + * @param list $accountKeys + * @param array $requirement + * @param array $transfer + */ + private static function validAtaCreate( + object $ix, + array $accountKeys, + array $requirement, + array $transfer, + ): bool { + if (self::programOf($accountKeys, $ix) !== self::ASSOCIATED_TOKEN_PROGRAM) { + return false; + } + $data = $ix->data; + if (strlen($data) < 1 || (ord($data[0]) !== 0 && ord($data[0]) !== 1)) { + return false; + } + if (count($ix->accountKeyIndexes) < 6) { + return false; + } + $ata = self::accountAt($accountKeys, $ix, 1); + $owner = self::accountAt($accountKeys, $ix, 2); + $mint = self::accountAt($accountKeys, $ix, 3); + if ($owner !== ($requirement['payTo'] ?? null)) { + return false; + } + if ($mint !== $transfer['mint']) { + return false; + } + if ($ata !== $transfer['destination']) { + return false; + } + return true; + } + + /** + * @param list $accountKeys + * @param list}> $instructions + */ + private static function findMemoMatch(array $accountKeys, array $instructions, string $expectedMemo): void + { + $count = 0; + $lastData = null; + $n = count($instructions); + for ($i = 3; $i < $n; $i++) { + $ix = $instructions[$i]; + if (self::programOf($accountKeys, $ix) === self::MEMO_PROGRAM) { + $count++; + $lastData = $ix->data; + } + } + if ($count !== 1) { + throw new InvalidProofException('invalid_exact_svm_payload_memo_count'); + } + if ($lastData !== $expectedMemo) { + throw new InvalidProofException('invalid_exact_svm_payload_memo_mismatch'); + } + } + + /** + * @param list $accountKeys + * @param object{programIdIndex:int} $ix + */ + private static function programOf(array $accountKeys, object $ix): string + { + return $accountKeys[$ix->programIdIndex] ?? ''; + } + + /** + * @param list $accountKeys + * @param object{accountKeyIndexes:array} $ix + */ + private static function accountAt(array $accountKeys, object $ix, int $slot): string + { + $idx = $ix->accountKeyIndexes[$slot] ?? null; + if ($idx === null) { + throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); + } + return $accountKeys[$idx] ?? ''; + } + + /** + * @param array $requirement + */ + private static function b58Field(array $requirement, string $key): string + { + $v = $requirement[$key] ?? null; + if (!is_string($v) || $v === '') { + throw new InvalidProofException('invalid_exact_svm_payload_missing_field_' . $key); + } + return $v; + } + + /** + * @param array $requirement + */ + private static function stringExtra(array $requirement, string $key, bool $required): ?string + { + $extra = $requirement['extra'] ?? []; + $v = is_array($extra) ? ($extra[$key] ?? null) : null; + if (($v === null || $v === '') && $required) { + throw new InvalidProofException('invalid_exact_svm_payload_missing_extra_' . $key); + } + return is_string($v) ? $v : null; + } + + /** + * @param array $requirement + */ + private static function amountField(array $requirement): int + { + $v = $requirement['amount'] ?? $requirement['maxAmountRequired'] ?? null; + if (!is_string($v) && !is_int($v)) { + throw new InvalidProofException('invalid_exact_svm_payload_missing_field_amount'); + } + return (int) $v; + } + + private static function readU64Le(string $data, int $offset): int + { + if (strlen($data) < $offset + 8) { + throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); + } + $b = unpack('P', substr($data, $offset, 8)); + return $b === false ? 0 : (int) $b[1]; + } +} diff --git a/php/src/Protocols/X402/X402Config.php b/php/src/Protocols/X402/X402Config.php new file mode 100644 index 000000000..58f4f408b --- /dev/null +++ b/php/src/Protocols/X402/X402Config.php @@ -0,0 +1,30 @@ +facilitatorUrl !== null && $this->facilitatorUrl !== ''; + } +} diff --git a/php/src/Signer.php b/php/src/Signer.php new file mode 100644 index 000000000..bff2e52bf --- /dev/null +++ b/php/src/Signer.php @@ -0,0 +1,158 @@ + of + * 64 integers in [0, 255] (the Solana-CLI JSON-array shape parsed + * but not stringified). + * + * @param string|array $secret + */ + public static function bytes(string|array $secret): LocalSigner + { + return LocalSigner::fromBytes($secret); + } + + /** + * Build a LocalSigner from a Solana-CLI JSON-array string, + * e.g. "[1,2,3,...,64]". + */ + public static function json(string $jsonArray): LocalSigner + { + $trimmed = trim($jsonArray); + if ($trimmed === '') { + throw new InvalidKeyException('pay_kit: Signer::json received empty input'); + } + $decoded = json_decode($trimmed, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new InvalidKeyException('pay_kit: Signer::json expected a JSON array'); + } + return LocalSigner::fromBytes($decoded); + } + + /** + * Build a LocalSigner from a base58 representation of the 64-byte + * secret (the Phantom / Solflare export shape). + */ + public static function base58(string $base58Secret): LocalSigner + { + return LocalSigner::fromBase58($base58Secret); + } + + /** + * Build a LocalSigner from a 128-character hex string. + */ + public static function hex(string $hexSecret): LocalSigner + { + return LocalSigner::fromHex($hexSecret); + } + + /** + * Read a Solana-CLI keypair JSON file and build a LocalSigner. + */ + public static function file(string $path): LocalSigner + { + if (!is_string($path) || $path === '') { + throw new InvalidKeyException('pay_kit: Signer::file expects a non-empty path'); + } + if (!is_readable($path)) { + throw new InvalidKeyException( + sprintf('pay_kit: Signer::file cannot read %s', $path), + ); + } + $contents = file_get_contents($path); + if ($contents === false) { + throw new InvalidKeyException( + sprintf('pay_kit: Signer::file read failed for %s', $path), + ); + } + return self::json($contents); + } + + /** + * Read an env var and auto-detect the encoding (JSON array, hex, + * base58). Returns `null` when the var is unset or empty so the + * Operator's null-as-default contract composes cleanly. A var that + * IS set but malformed raises {@see InvalidKeyException} because + * silent fallback would mask a real bug. + */ + public static function env(string $name): ?LocalSigner + { + if ($name === '') { + throw new InvalidKeyException('pay_kit: Signer::env expects a non-empty name'); + } + $raw = getenv($name); + if ($raw === false || $raw === '') { + return null; + } + $trimmed = trim($raw); + if ($trimmed === '') { + return null; + } + try { + if (str_starts_with($trimmed, '[')) { + return self::json($trimmed); + } + if (strlen($trimmed) === 128 && ctype_xdigit($trimmed)) { + return self::hex($trimmed); + } + return self::base58($trimmed); + } catch (Throwable $e) { + if ($e instanceof InvalidKeyException) { + throw $e; + } + throw new InvalidKeyException( + sprintf('pay_kit: Signer::env(%s) failed to parse: %s', $name, $e->getMessage()), + previous: $e, + ); + } + } + + /** + * Generate a fresh ephemeral keypair. Test-only — production + * callers load from file or env so the same identity survives + * across restarts. + */ + public static function generate(): LocalSigner + { + return LocalSigner::generate(); + } +} diff --git a/php/src/Signer/Demo.php b/php/src/Signer/Demo.php new file mode 100644 index 000000000..f9725fd69 --- /dev/null +++ b/php/src/Signer/Demo.php @@ -0,0 +1,64 @@ + + */ + private const SECRET_BYTES = [ + 26, 61, 117, 192, 9, 232, 24, 51, 89, 135, 105, 182, 47, 9, 83, 244, + 11, 214, 85, 170, 227, 83, 170, 26, 55, 129, 58, 114, 89, 160, 195, 51, + 138, 209, 127, 35, 54, 41, 202, 166, 199, 166, 97, 238, 181, 63, 254, 185, + 45, 16, 174, 102, 250, 198, 30, 191, 232, 236, 147, 167, 41, 178, 151, 26, + ]; + + private static ?LocalSigner $instance = null; + + /** @codeCoverageIgnore */ + private function __construct() + { + } + + public static function instance(): LocalSigner + { + if (self::$instance === null) { + $bytes = ''; + foreach (self::SECRET_BYTES as $b) { + $bytes .= chr($b); + } + self::$instance = LocalSigner::fromKeypair(Keypair::fromSecretKey($bytes), isDemo: true); + } + return self::$instance; + } + + /** + * Test-only: reset the cached singleton so the next instance() + * call rebuilds. Used by config_test fixtures. + * + * @internal + */ + public static function resetForTests(): void + { + self::$instance = null; + } +} diff --git a/php/src/Signer/LocalSigner.php b/php/src/Signer/LocalSigner.php new file mode 100644 index 000000000..ed75dc82d --- /dev/null +++ b/php/src/Signer/LocalSigner.php @@ -0,0 +1,139 @@ + $secret + */ + public static function fromBytes(string|array $secret): self + { + if (is_array($secret)) { + if (count($secret) !== 64) { + throw new InvalidKeyException( + 'pay_kit: Signer::bytes expects 64 integers, got ' . count($secret), + ); + } + foreach ($secret as $i => $b) { + if (!is_int($b) || $b < 0 || $b > 255) { + throw new InvalidKeyException( + sprintf('pay_kit: Signer::bytes[%d] must be an int in [0,255]', $i), + ); + } + } + $bytes = ''; + foreach ($secret as $b) { + $bytes .= chr($b); + } + return self::fromKeypair(Keypair::fromSecretKey($bytes)); + } + if (strlen($secret) !== 64) { + throw new InvalidKeyException( + sprintf('pay_kit: Signer::bytes expects a 64-byte secret, got %d bytes', strlen($secret)), + ); + } + return self::fromKeypair(Keypair::fromSecretKey($secret)); + } + + public static function fromBase58(string $base58Secret): self + { + if ($base58Secret === '') { + throw new InvalidKeyException('pay_kit: Signer::base58 expects a non-empty string'); + } + try { + // Decode raw base58 bytes directly; PublicKey::fromBase58 + // hard-codes the 32-byte pubkey shape and rejects 64-byte + // secret-key blobs. + $decoded = Base58::decode($base58Secret); + } catch (Throwable $e) { + throw new InvalidKeyException( + 'pay_kit: Signer::base58 invalid base58: ' . $e->getMessage(), + previous: $e, + ); + } + if (strlen($decoded) !== 64) { + throw new InvalidKeyException( + sprintf('pay_kit: Signer::base58 decoded length must be 64 bytes, got %d', strlen($decoded)), + ); + } + return self::fromKeypair(Keypair::fromSecretKey($decoded)); + } + + public static function fromHex(string $hexSecret): self + { + if (strlen($hexSecret) !== 128) { + throw new InvalidKeyException( + sprintf('pay_kit: Signer::hex expects 128 chars, got %d', strlen($hexSecret)), + ); + } + if (!ctype_xdigit($hexSecret)) { + throw new InvalidKeyException('pay_kit: Signer::hex contains non-hex characters'); + } + $bytes = hex2bin($hexSecret); + if ($bytes === false) { + throw new InvalidKeyException('pay_kit: Signer::hex decode failed'); + } + return self::fromKeypair(Keypair::fromSecretKey($bytes)); + } + + public static function generate(): self + { + return self::fromKeypair(Keypair::generate()); + } + + public function pubkey(): string + { + return (string) $this->keypair->getPublicKey(); + } + + public function sign(string $message): string + { + return $this->keypair->sign($message); + } + + public function isFeePayer(): bool + { + return $this->isFeePayerFlag; + } + + public function isDemo(): bool + { + return $this->isDemoFlag; + } + + /** + * Raw 64-byte secret bytes. Reserved for internal cosign paths; + * not part of the public surface. + * + * @internal + */ + public function secretKey(): string + { + return $this->keypair->getSecretKey(); + } +} diff --git a/php/src/Store/FileStore.php b/php/src/Store/FileStore.php index 91738fafa..ed33df594 100644 --- a/php/src/Store/FileStore.php +++ b/php/src/Store/FileStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Store; +namespace PayKit\Store; use RuntimeException; diff --git a/php/src/Store/MemoryStore.php b/php/src/Store/MemoryStore.php index 21d286301..089fd1950 100644 --- a/php/src/Store/MemoryStore.php +++ b/php/src/Store/MemoryStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Store; +namespace PayKit\Store; /** * In-memory {@see Store} for tests and local development. diff --git a/php/src/Store/Store.php b/php/src/Store/Store.php index 60d71b074..324c6e7a5 100644 --- a/php/src/Store/Store.php +++ b/php/src/Store/Store.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SolanaMpp\Store; +namespace PayKit\Store; /** * Atomic replay-protection store interface. diff --git a/php/tests/ClientTest.php b/php/tests/ClientTest.php new file mode 100644 index 000000000..065b7492a --- /dev/null +++ b/php/tests/ClientTest.php @@ -0,0 +1,77 @@ +pubkey(), signer: Signer::generate(), feePayer: true), + preflight: false, + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + $client = new Client($cfg); + $this->assertSame($cfg, $client->config); + } + + public function testRunsPreflightWhenEnabled(): void + { + $called = false; + Preflight::setRpcCallableForTests(function (string $m) use (&$called): mixed { + $called = true; + return match ($m) { + 'getBalance' => ['value' => 1_000_000_000], + 'getAccountInfo' => ['value' => []], + 'surfnet_setAccount' => [], + 'surfnet_setTokenAccount' => [], + default => null, + }; + }); + $cfg = new Config( + network: Network::SolanaLocalnet, + preflight: true, + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + new Client($cfg); + $this->assertTrue($called); + } + + public function testSkipsPreflightWhenEnvDisabled(): void + { + $prev = getenv('PAY_KIT_DISABLE_PREFLIGHT'); + putenv('PAY_KIT_DISABLE_PREFLIGHT=1'); + try { + $called = false; + Preflight::setRpcCallableForTests(function () use (&$called) { + $called = true; + return null; + }); + new Client(new Config( + network: Network::SolanaLocalnet, + preflight: true, + mpp: new MppConfig(challengeBindingSecret: 'x'), + )); + $this->assertFalse($called); + } finally { + putenv($prev === false ? 'PAY_KIT_DISABLE_PREFLIGHT' : 'PAY_KIT_DISABLE_PREFLIGHT=' . $prev); + } + } +} diff --git a/php/tests/ConfigTest.php b/php/tests/ConfigTest.php new file mode 100644 index 000000000..f76b1d821 --- /dev/null +++ b/php/tests/ConfigTest.php @@ -0,0 +1,112 @@ +assertSame(Network::SolanaLocalnet, $cfg->network); + $this->assertSame('https://402.surfnet.dev:8899', $cfg->rpcUrl); + $this->assertTrue($cfg->operator->signer?->isDemo()); + // Recipient cascades to signer->pubkey(). + $this->assertSame(Signer::demo()->pubkey(), $cfg->effectiveRecipient()); + } + + public function testDevnetAndMainnetDefaults(): void + { + $cfg = new Config(network: Network::SolanaDevnet, preflight: false); + $this->assertSame('https://api.devnet.solana.com', $cfg->rpcUrl); + } + + public function testCustomRpcUrlHonoured(): void + { + $cfg = new Config( + network: Network::SolanaDevnet, + rpcUrl: 'https://my-helius.example.com', + preflight: false, + ); + $this->assertSame('https://my-helius.example.com', $cfg->rpcUrl); + } + + public function testMainnetWithDemoSignerRejected(): void + { + $this->expectException(DemoSignerOnMainnetException::class); + new Config(network: Network::SolanaMainnet, preflight: false); + } + + public function testEmptyAcceptRejected(): void + { + $this->expectException(ConfigurationException::class); + new Config(accept: [], preflight: false); + } + + public function testStablecoinAndAcceptOrderPreserved(): void + { + $cfg = new Config( + accept: [Protocol::Mpp, Protocol::X402], + stablecoins: [Stablecoin::Usdt, Stablecoin::Usdc], + preflight: false, + ); + $this->assertSame(Protocol::Mpp, $cfg->accept[0]); + $this->assertSame(Stablecoin::Usdt, $cfg->stablecoins[0]); + } + + public function testExplicitOperatorOverridesDefaults(): void + { + $sgn = Signer::generate(); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: 'CustomRecipient', signer: $sgn, feePayer: false), + preflight: false, + ); + $this->assertSame('CustomRecipient', $cfg->effectiveRecipient()); + $this->assertSame($sgn->pubkey(), $cfg->operator->signer?->pubkey()); + $this->assertFalse($cfg->operator->feePayer); + } + public function testEffectiveX402SignerFallsBackToOperatorSigner(): void + { + $sgn = Signer::generate(); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: $sgn, feePayer: true), + preflight: false, + ); + $this->assertSame($sgn->pubkey(), $cfg->effectiveX402Signer()?->pubkey()); + } + + public function testWithMppReturnsCopy(): void + { + $cfg = new Config(network: Network::SolanaDevnet, preflight: false); + $newMpp = new \PayKit\Protocols\Mpp\MppConfig(realm: 'NewRealm', challengeBindingSecret: 'abc'); + $next = $cfg->withMpp($newMpp); + $this->assertSame('NewRealm', $next->mpp->realm); + $this->assertSame('abc', $next->mpp->challengeBindingSecret); + $this->assertSame($cfg->network, $next->network); + } + + public function testInvalidAcceptEntryRejected(): void + { + $this->expectException(\PayKit\Exception\ConfigurationException::class); + new Config(accept: ['not-a-protocol-enum'], preflight: false); + } + + public function testInvalidStablecoinEntryRejected(): void + { + $this->expectException(\PayKit\Exception\ConfigurationException::class); + new Config(stablecoins: ['not-a-stablecoin-enum'], preflight: false); + } +} diff --git a/php/tests/CoverageBoostTest.php b/php/tests/CoverageBoostTest.php new file mode 100644 index 000000000..c26bda7b1 --- /dev/null +++ b/php/tests/CoverageBoostTest.php @@ -0,0 +1,174 @@ +expectException(InvalidKeyException::class); + Signer::json('42'); + } + + public function testEnvWhitespaceOnlyReturnsNull(): void + { + putenv('PAY_KIT_TEST_BLANK= '); + try { + $this->assertNull(Signer::env('PAY_KIT_TEST_BLANK')); + } finally { + putenv('PAY_KIT_TEST_BLANK'); + } + } + + public function testIsPaidForReturnsFalseWithoutPayment(): void + { + $req = new ServerRequest('GET', '/x'); + $this->assertFalse(isPaidFor($req, 'paid')); + } + + public function testIsPaidForReturnsTrueForGateObjectMatch(): void + { + $gate = new Gate(Price::usd('0.01'), 'PAY_TO_RECIPIENT_BASE58_PUBKEY_111111111111'); + $payment = new Payment( + protocol: Protocol::Mpp, + transaction: 'sig', + gateName: 'paid', + ); + $req = (new ServerRequest('GET', '/x'))->withAttribute('paykit.payment', $payment); + $this->assertTrue(isPaidFor($req, $gate)); + } + + public function testRequirePaymentThrowsWithoutAttribute(): void + { + $this->expectException(PaymentRequiredException::class); + requirePayment(new ServerRequest('GET', '/x')); + } + + public function testSecretResolverDotenvSkipsCommentsAndStripsQuotes(): void + { + $tmp = sys_get_temp_dir() . '/paykit-secret-resolver-' . bin2hex(random_bytes(4)) . '.env'; + file_put_contents($tmp, "# leading comment\n \nPAY_KIT_MPP_CHALLENGE_BINDING_SECRET=\"quoted-value\"\n"); + $cwd = getcwd() ?: '.'; + $prev = $cwd; + $dir = dirname($tmp); + chdir($dir); + try { + rename($tmp, $dir . '/.env'); + putenv('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET'); + $resolved = SecretResolver::resolveMppSecret(); + $this->assertSame('quoted-value', $resolved['secret']); + $this->assertSame('dotenv', $resolved['source']); + $this->assertTrue($resolved['persisted']); + } finally { + @unlink($dir . '/.env'); + chdir($prev); + } + } + + public function testMintsSymbolForUnknownReturnsNull(): void + { + $this->assertNull(Mints::symbolFor('NOT_A_REAL_MINT_OR_SYMBOL', 'mainnet')); + } + + public function testMintsSymbolForReverseLookupByMint(): void + { + // Pass an actual USDC mainnet mint, expect 'USDC' back. + $mint = Mints::resolve('USDC', 'mainnet'); + $this->assertNotNull($mint); + $this->assertSame('USDC', Mints::symbolFor($mint, 'mainnet')); + } + + public function testRfc3339ParserRejectsMalformed(): void + { + $this->assertNull(Rfc3339Parser::parse('not-a-timestamp')); + } + + public function testBase58SecretKeyRoundTrip(): void + { + $sgn = Signer::generate(); + $b58 = Base58::encode($sgn->secretKey()); + $rebuilt = Signer::base58($b58); + $this->assertSame($sgn->pubkey(), $rebuilt->pubkey()); + $this->assertSame($sgn->secretKey(), $rebuilt->secretKey()); + } + + public function testExceptionHttpStatusValues(): void + { + $this->assertSame(402, (new \PayKit\Exception\PaymentRequiredException('x'))->httpStatus()); + $this->assertSame(402, (new \PayKit\Exception\InvalidProofException('x'))->httpStatus()); + $this->assertSame(406, (new \PayKit\Exception\ProtocolNotSupportedException('x'))->httpStatus()); + } + + public function testDemoResetForTestsClearsSingleton(): void + { + $a = Signer::demo(); + \PayKit\Signer\Demo::resetForTests(); + $b = Signer::demo(); + $this->assertSame($a->pubkey(), $b->pubkey()); + } + + public function testConfigRejectsEmptyStablecoins(): void + { + $this->expectException(ConfigurationException::class); + new Config( + network: Network::SolanaDevnet, + stablecoins: [], + operator: new Operator(recipient: Signer::generate()->pubkey()), + preflight: false, + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + } + + public function testRfc3339ParserAcceptsZuluTimestamp(): void + { + $dt = Rfc3339Parser::parse('2024-12-31T23:59:59Z'); + $this->assertNotNull($dt); + $this->assertSame('2024-12-31T23:59:59+00:00', $dt->format('c')); + } + + public function testSecretResolverAppendsToExistingDotenv(): void + { + $dir = sys_get_temp_dir() . '/paykit-secret-existing-' . bin2hex(random_bytes(4)); + mkdir($dir, 0700, true); + $prev = getcwd() ?: '.'; + chdir($dir); + try { + file_put_contents($dir . '/.env', "OTHER_KEY=other\n"); + putenv('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET'); + $resolved = SecretResolver::resolveMppSecret(); + $this->assertSame('generated+persisted', $resolved['source']); + $this->assertTrue($resolved['persisted']); + $contents = (string) file_get_contents($dir . '/.env'); + $this->assertStringContainsString('OTHER_KEY=other', $contents); + $this->assertStringContainsString('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET=', $contents); + } finally { + @unlink($dir . '/.env'); + @rmdir($dir); + chdir($prev); + } + } +} diff --git a/php/tests/GateTest.php b/php/tests/GateTest.php new file mode 100644 index 000000000..47e08b03d --- /dev/null +++ b/php/tests/GateTest.php @@ -0,0 +1,83 @@ +assertSame('0.10', $g->total()->amountString()); + $this->assertFalse($g->hasFees()); + } + + public function testFeeWithinNetsPayToDown(): void + { + $g = new Gate( + amount: Price::usd('10.00'), + payTo: 'SELLER', + feeWithin: ['PLATFORM' => Price::usd('0.30')], + ); + $this->assertTrue($g->hasFees()); + $this->assertSame('10.00', $g->total()->amountString()); // customer pays amount + $this->assertSame('9.70', $g->payout('SELLER')->amountString()); + $this->assertSame('0.30', $g->payout('PLATFORM')->amountString()); + } + + public function testFeeOnTopAddsToTotal(): void + { + $g = new Gate( + amount: Price::usd('10.00'), + payTo: 'SELLER', + feeOnTop: ['PLATFORM' => Price::usd('0.50')], + ); + $this->assertSame('10.50', $g->total()->amountString()); + $this->assertSame('10.00', $g->payout('SELLER')->amountString()); + } + + public function testMixedCurrenciesRejected(): void + { + $this->expectException(MixedCurrenciesException::class); + new Gate( + amount: Price::usd('1.00'), + payTo: 'SELLER', + feeWithin: ['PLATFORM' => Price::eur('0.10')], + ); + } + + public function testSumFeeWithinExceedingAmountRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + new Gate( + amount: Price::usd('1.00'), + payTo: 'SELLER', + feeWithin: ['PLATFORM' => Price::usd('2.00')], + ); + } + + public function testExplicitX402AcceptOnFeeGateRejected(): void + { + $this->expectException(ProtocolIncompatibleException::class); + new Gate( + amount: Price::usd('1.00'), + payTo: 'SELLER', + accept: [Protocol::X402], + feeWithin: ['PLATFORM' => Price::usd('0.10')], + ); + } + + public function testPayoutNullForUnaddressedRecipient(): void + { + $g = new Gate(amount: Price::usd('1.00'), payTo: 'SELLER'); + $this->assertNull($g->payout('STRANGER')); + } +} diff --git a/php/tests/Middleware/RequirePaymentTest.php b/php/tests/Middleware/RequirePaymentTest.php new file mode 100644 index 000000000..5233bed9f --- /dev/null +++ b/php/tests/Middleware/RequirePaymentTest.php @@ -0,0 +1,151 @@ +client = new Client(new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate(), feePayer: true), + preflight: false, + mpp: new MppConfig(challengeBindingSecret: 'unit-test'), + )); + $this->factory = new Psr17Factory(); + } + + private function nextHandler(): RequestHandlerInterface + { + $factory = $this->factory; + return new class ($factory) implements RequestHandlerInterface { + public function __construct(private Psr17Factory $f) + { + } + public function handle(ServerRequestInterface $req): ResponseInterface + { + return $this->f->createResponse(200) + ->withHeader('content-type', 'application/json') + ->withBody($this->f->createStream('{"ok":true}')); + } + }; + } + + public function testEmits402WhenNoCredentialPresent(): void + { + $gate = new Gate(amount: Price::usd('0.10')); + $mw = new RequirePayment($this->client, $gate); + $request = $this->factory->createServerRequest('GET', '/paid'); + $response = $mw->process($request, $this->nextHandler()); + $this->assertSame(402, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertIsArray($body); + $this->assertSame('payment_required', $body['error']); + } + + public function test402BodyCarriesAcceptsEntries(): void + { + $gate = new Gate(amount: Price::usd('0.10')); + $mw = new RequirePayment($this->client, $gate); + $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); + $body = json_decode((string) $response->getBody(), true); + $this->assertGreaterThanOrEqual(1, count($body['accepts'])); + } + + public function testWwwAuthenticateHeaderStampedFromMpp(): void + { + $gate = new Gate(amount: Price::usd('0.10')); + $mw = new RequirePayment($this->client, $gate); + $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); + $this->assertNotEmpty($response->getHeaderLine('www-authenticate')); + } + + public function testStringHandleResolvedAgainstPricing(): void + { + $pricing = new class () extends Pricing { + public readonly Gate $reportGate; + public function __construct() + { + $this->reportGate = new Gate(amount: Price::usd('0.10')); + } + }; + $mw = new RequirePayment($this->client, 'reportGate', $pricing); + $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); + $this->assertSame(402, $response->getStatusCode()); + } + + public function testClosureGateInvoked(): void + { + $closure = fn (ServerRequestInterface $req): Gate => new Gate(amount: Price::usd('0.25')); + $mw = new RequirePayment($this->client, $closure); + $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); + $this->assertSame(402, $response->getStatusCode()); + } + + public function testStringHandleWithoutPricingRaises(): void + { + $mw = new RequirePayment($this->client, 'reportGate'); + $this->expectException(\LogicException::class); + $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); + } + + public function testMalformedAuthorizationFallsThroughTo402(): void + { + $gate = new Gate(amount: Price::usd('0.10')); + $mw = new RequirePayment($this->client, $gate); + $request = $this->factory->createServerRequest('GET', '/paid') + ->withHeader('Authorization', 'Payment garbage-not-valid'); + $response = $mw->process($request, $this->nextHandler()); + $this->assertSame(402, $response->getStatusCode()); + } + + public function testHeaderFilterNamespaceFunctions(): void + { + $request = $this->factory->createServerRequest('GET', '/'); + $this->assertNull(\PayKit\Middleware\payment($request)); + $this->assertFalse(\PayKit\Middleware\isPaid($request)); + + $payment = new Payment( + protocol: Protocol::Mpp, + transaction: 'sig-123', + gateName: 'report', + settlementHeaders: [], + ); + $request = $request->withAttribute('paykit.payment', $payment); + $this->assertSame($payment, \PayKit\Middleware\payment($request)); + $this->assertTrue(\PayKit\Middleware\isPaid($request)); + $this->assertTrue(\PayKit\Middleware\isPaidFor($request, 'report')); + $this->assertFalse(\PayKit\Middleware\isPaidFor($request, 'other')); + } + + public function testRequirePaymentNamespaceFunctionRaisesWithoutPayment(): void + { + $request = $this->factory->createServerRequest('GET', '/'); + $this->expectException(\PayKit\Exception\PaymentRequiredException::class); + \PayKit\Middleware\requirePayment($request); + } +} diff --git a/php/tests/MppConfigTest.php b/php/tests/MppConfigTest.php new file mode 100644 index 000000000..3a5ca102b --- /dev/null +++ b/php/tests/MppConfigTest.php @@ -0,0 +1,41 @@ +assertSame('App', $c->realm); + $this->assertSame(120, $c->expiresIn); + $this->assertNull($c->challengeBindingSecret); + } + + public function testExpiresInZeroRejected(): void + { + $this->expectException(ConfigurationException::class); + new MppConfig(expiresIn: 0); + } + + public function testExpiresInNegativeRejected(): void + { + $this->expectException(ConfigurationException::class); + new MppConfig(expiresIn: -1); + } + + public function testWithChallengeBindingSecretReturnsCopy(): void + { + $a = new MppConfig(realm: 'Test'); + $b = $a->withChallengeBindingSecret('abc'); + $this->assertSame('Test', $b->realm); + $this->assertSame('abc', $b->challengeBindingSecret); + $this->assertNull($a->challengeBindingSecret); + } +} diff --git a/php/tests/PayCore/HttpFactoryTest.php b/php/tests/PayCore/HttpFactoryTest.php new file mode 100644 index 000000000..dde842584 --- /dev/null +++ b/php/tests/PayCore/HttpFactoryTest.php @@ -0,0 +1,71 @@ +createResponse(402)->withBody($sf->createStream('hello')); + $this->assertSame(402, $resp->getStatusCode()); + $this->assertSame('hello', (string) $resp->getBody()); + } + + public function testSetResponseFactoryOverridesDefault(): void + { + $custom = new Psr17Factory(); + HttpFactory::setResponseFactory($custom); + $this->assertSame($custom, HttpFactory::responseFactory()); + HttpFactory::setResponseFactory(null); + } + + public function testSetStreamFactoryOverridesDefault(): void + { + $custom = new Psr17Factory(); + HttpFactory::setStreamFactory($custom); + $this->assertSame($custom, HttpFactory::streamFactory()); + HttpFactory::setStreamFactory(null); + } + + public function testServerRequestFromGlobalsBuildsFromServerSuperglobal(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/paid?x=1'; + $_SERVER['SERVER_NAME'] = '127.0.0.1'; + $_SERVER['SERVER_PORT'] = '4567'; + $_SERVER['HTTP_HOST'] = '127.0.0.1:4567'; + $_SERVER['HTTPS'] = ''; + $req = HttpFactory::serverRequestFromGlobals(); + $this->assertSame('GET', $req->getMethod()); + $this->assertStringContainsString('/paid', $req->getUri()->getPath()); + } + + public function testEmitWritesBodyAndHeaders(): void + { + $resp = HttpFactory::responseFactory() + ->createResponse(402) + ->withHeader('content-type', 'application/json') + ->withHeader('www-authenticate', 'Payment realm="t"') + ->withBody(HttpFactory::streamFactory()->createStream('{"ok":false}')); + ob_start(); + HttpFactory::emit($resp); + $out = (string) ob_get_clean(); + $this->assertSame('{"ok":false}', $out); + // headers_sent() is true in CLI under PHPUnit's separate-process + // mode but the headers list isn't fetchable cross-process; we + // assert the body and trust the cli-server quirk workaround + // (covered manually in examples/simple-server). + } +} diff --git a/php/tests/PayCore/MintsTest.php b/php/tests/PayCore/MintsTest.php new file mode 100644 index 000000000..50daa4dad --- /dev/null +++ b/php/tests/PayCore/MintsTest.php @@ -0,0 +1,112 @@ + ['value' => 0], + 'getAccountInfo' => ['value' => ['account-stub']], + 'surfnet_setAccount' => [], + default => null, + }; + }); + + $cfg = new Config( + network: Network::SolanaLocalnet, + operator: new Operator( + recipient: Signer::generate()->pubkey(), + signer: Signer::demo(), + feePayer: true, + ), + preflight: true, + stablecoins: [Stablecoin::Usdc], + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + Preflight::run($cfg); + + $methods = array_column($calls, 0); + $this->assertContains('getBalance', $methods); + $this->assertContains('surfnet_setAccount', $methods); + } + + public function testAutofixProvisionsRecipientAtaWhenMissing(): void + { + $calls = []; + Preflight::setRpcCallableForTests(function (string $m, array $params) use (&$calls): mixed { + $calls[] = [$m, $params]; + return match ($m) { + 'getBalance' => ['value' => 100_000_000], + // null .value triggers the autofix branch for ATA. + 'getAccountInfo' => ['value' => null], + 'surfnet_setTokenAccount' => [], + default => null, + }; + }); + + $cfg = new Config( + network: Network::SolanaLocalnet, + operator: new Operator( + recipient: Signer::generate()->pubkey(), + signer: Signer::demo(), + feePayer: true, + ), + preflight: true, + stablecoins: [Stablecoin::Usdc], + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + Preflight::run($cfg); + + $methods = array_column($calls, 0); + $this->assertContains('surfnet_setTokenAccount', $methods); + } + + public function testDevnetMissingAtaWithoutAutofixRaises(): void + { + Preflight::setRpcCallableForTests(function (string $m): mixed { + return match ($m) { + 'getBalance' => ['value' => 100_000_000], + 'getAccountInfo' => ['value' => null], + default => null, + }; + }); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator( + recipient: Signer::generate()->pubkey(), + signer: Signer::generate(), + feePayer: true, + ), + preflight: true, + stablecoins: [Stablecoin::Usdc], + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + $this->expectException(\PayKit\Exception\ConfigurationException::class); + Preflight::run($cfg); + } +} diff --git a/php/tests/PreflightMoreTest.php b/php/tests/PreflightMoreTest.php new file mode 100644 index 000000000..f56a3db96 --- /dev/null +++ b/php/tests/PreflightMoreTest.php @@ -0,0 +1,69 @@ + ['value' => 0], + 'getAccountInfo' => ['value' => []], + default => null, + }; + }); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate(), feePayer: false), + preflight: false, + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + Preflight::run($cfg); + $this->assertFalse($balanceCalled); + } + + public function testNoSignerSkipsBalanceCheck(): void + { + $balanceCalled = false; + Preflight::setRpcCallableForTests(function (string $m) use (&$balanceCalled): mixed { + if ($m === 'getBalance') { + $balanceCalled = true; + } + return match ($m) { + 'getBalance' => ['value' => 0], + 'getAccountInfo' => ['value' => []], + default => null, + }; + }); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: null, feePayer: true), + preflight: false, + mpp: new MppConfig(challengeBindingSecret: 'x'), + ); + // operator->signer cascades to Demo via Operator::withDefaults; + // balance check fires and raises (devnet, 0 lamports). + $this->expectException(\PayKit\Exception\ConfigurationException::class); + Preflight::run($cfg); + } +} diff --git a/php/tests/PreflightTest.php b/php/tests/PreflightTest.php new file mode 100644 index 000000000..3d8d507b4 --- /dev/null +++ b/php/tests/PreflightTest.php @@ -0,0 +1,103 @@ +assertTrue(Preflight::isDisabledByEnv()); + } finally { + putenv($prev === false ? 'PAY_KIT_DISABLE_PREFLIGHT' : 'PAY_KIT_DISABLE_PREFLIGHT=' . $prev); + } + } + + public function testLowBalanceRaisesOffLocalnet(): void + { + Preflight::setRpcCallableForTests(function (string $method, array $params): mixed { + return match ($method) { + 'getBalance' => ['value' => 1], + 'getAccountInfo' => ['value' => []], + default => null, + }; + }); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate(), feePayer: true), + preflight: false, + ); + $this->expectException(ConfigurationException::class); + Preflight::run($cfg); + } + + public function testMissingAtaRaisesOffLocalnet(): void + { + Preflight::setRpcCallableForTests(function (string $method, array $params): mixed { + return match ($method) { + 'getBalance' => ['value' => 1_000_000_000], + 'getAccountInfo' => ['value' => null], + default => null, + }; + }); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate(), feePayer: true), + preflight: false, + ); + $this->expectException(ConfigurationException::class); + Preflight::run($cfg); + } + + public function testLocalnetDemoAutoFunds(): void + { + $funded = false; + Preflight::setRpcCallableForTests(function (string $method, array $params) use (&$funded): mixed { + return match ($method) { + 'getBalance' => ['value' => 1], + 'getAccountInfo' => ['value' => []], + 'surfnet_setAccount' => $funded = true, + 'surfnet_setTokenAccount' => null, + default => null, + }; + }); + $cfg = new Config( + network: Network::SolanaLocalnet, + preflight: false, // we'll invoke Preflight::run manually + ); + Preflight::run($cfg); + $this->assertTrue($funded); + } + + public function testRpcFailureDowngradedToWarning(): void + { + Preflight::setRpcCallableForTests(function (string $method, array $params): mixed { + throw new \RuntimeException('rpc unreachable'); + }); + $cfg = new Config( + network: Network::SolanaDevnet, + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate(), feePayer: true), + preflight: false, + ); + // Must not raise. + Preflight::run($cfg); + $this->assertTrue(true); + } +} diff --git a/php/tests/PriceTest.php b/php/tests/PriceTest.php new file mode 100644 index 000000000..17f0a3371 --- /dev/null +++ b/php/tests/PriceTest.php @@ -0,0 +1,60 @@ +assertSame(Currency::Usd, $p->currency); + $this->assertSame('0.10', $p->amountString()); + $this->assertNull($p->primaryCoin()); + } + + public function testUsdWithVariadicSettlement(): void + { + $p = Price::usd('1.00', Stablecoin::Usdc, Stablecoin::Usdt); + $this->assertSame(Stablecoin::Usdc, $p->primaryCoin()); + $this->assertCount(2, $p->settlements); + } + + public function testEurAndGbpFactories(): void + { + $this->assertSame(Currency::Eur, Price::eur('0.50')->currency); + $this->assertSame(Currency::Gbp, Price::gbp('0.50')->currency); + } + + public function testPlusRejectsMixedCurrencies(): void + { + $this->expectException(\InvalidArgumentException::class); + Price::usd('1.00')->plus(Price::eur('1.00')); + } + + public function testPlusSumsSameDenom(): void + { + $sum = Price::usd('1.00')->plus(Price::usd('2.50')); + $this->assertSame('3.50', $sum->amountString()); + } + + public function testWithAmount(): void + { + $p = Price::usd('1.00', Stablecoin::Usdc); + $p2 = $p->withAmount('5.00'); + $this->assertSame('5.00', $p2->amountString()); + $this->assertSame(Stablecoin::Usdc, $p2->primaryCoin()); + } + + public function testRejectsInvalidAmount(): void + { + $this->expectException(\InvalidArgumentException::class); + Price::usd('not-a-number'); + } +} diff --git a/php/tests/PricingTest.php b/php/tests/PricingTest.php new file mode 100644 index 000000000..a2c698a51 --- /dev/null +++ b/php/tests/PricingTest.php @@ -0,0 +1,47 @@ +report = new Gate(amount: Price::usd('0.10')); + } + }; + $g = $pricing->gate('report'); + $this->assertSame('0.10', $g->amount->amountString()); + } + + public function testUnknownGateNameRaises(): void + { + $pricing = new class () extends Pricing {}; + $this->expectException(\InvalidArgumentException::class); + $pricing->gate('nope'); + } + + public function testNonGateValueRaises(): void + { + $pricing = new class () extends Pricing { + public readonly string $reportLabel; + public function __construct() + { + $this->reportLabel = 'not a gate'; + } + }; + $this->expectException(\InvalidArgumentException::class); + $pricing->gate('reportLabel'); + } +} diff --git a/php/tests/Protocols/Mpp/AdapterTest.php b/php/tests/Protocols/Mpp/AdapterTest.php new file mode 100644 index 000000000..d5468f680 --- /dev/null +++ b/php/tests/Protocols/Mpp/AdapterTest.php @@ -0,0 +1,88 @@ +pubkey(), + signer: Signer::generate(), + feePayer: true, + ), + preflight: false, + mpp: new MppConfig(challengeBindingSecret: 'unit-test'), + ); + } + + public function testAcceptsEntryShape(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $entry = $adapter->acceptsEntry($gate, $req); + $this->assertSame('mpp', $entry['protocol']); + $this->assertSame('charge', $entry['scheme']); + $this->assertSame('100000', $entry['amount']); + $this->assertSame('USDC', $entry['currency']); + $this->assertSame($cfg->effectiveRecipient(), $entry['payTo']); + } + + public function testAcceptsEntryIncludesSplitsForFeeBearingGate(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $platform = Signer::generate()->pubkey(); + $gate = new Gate( + amount: Price::usd('10.00'), + feeWithin: [$platform => Price::usd('0.30')], + ); + $req = (new Psr17Factory())->createServerRequest('GET', '/marketplace'); + $entry = $adapter->acceptsEntry($gate, $req); + $this->assertArrayHasKey('splits', $entry); + $this->assertCount(1, $entry['splits']); + $this->assertSame($platform, $entry['splits'][0]['recipient']); + $this->assertSame('300000', $entry['splits'][0]['amount']); + } + + public function testChallengeHeadersHaveWwwAuthenticate(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $headers = $adapter->challengeHeaders($gate, $req); + $this->assertArrayHasKey('www-authenticate', $headers); + $this->assertStringStartsWith('Payment ', $headers['www-authenticate']); + } + + public function testVerifyAndSettleWithoutAuthorizationRaises(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $this->expectException(\PayKit\Exception\InvalidProofException::class); + $adapter->verifyAndSettle($gate, $req); + } +} diff --git a/php/tests/Base64UrlTest.php b/php/tests/Protocols/Mpp/Core/Base64UrlTest.php similarity index 97% rename from php/tests/Base64UrlTest.php rename to php/tests/Protocols/Mpp/Core/Base64UrlTest.php index 56cfc7b4b..8a7d56844 100644 --- a/php/tests/Base64UrlTest.php +++ b/php/tests/Protocols/Mpp/Core/Base64UrlTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests\Protocols\Mpp\Core; use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Base64Url; +use PayKit\Protocols\Mpp\Core\Base64Url; final class Base64UrlTest extends TestCase { diff --git a/php/tests/ChallengeTest.php b/php/tests/Protocols/Mpp/Core/ChallengeTest.php similarity index 98% rename from php/tests/ChallengeTest.php rename to php/tests/Protocols/Mpp/Core/ChallengeTest.php index 8fd221ae4..39e44b949 100644 --- a/php/tests/ChallengeTest.php +++ b/php/tests/Protocols/Mpp/Core/ChallengeTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests\Protocols\Mpp\Core; use DateTimeImmutable; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\ChallengeEcho; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\ChallengeEcho; final class ChallengeTest extends TestCase { diff --git a/php/tests/CredentialTest.php b/php/tests/Protocols/Mpp/Core/CredentialTest.php similarity index 87% rename from php/tests/CredentialTest.php rename to php/tests/Protocols/Mpp/Core/CredentialTest.php index cb23ed6b7..b970b9686 100644 --- a/php/tests/CredentialTest.php +++ b/php/tests/Protocols/Mpp/Core/CredentialTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests\Protocols\Mpp\Core; use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\Credential; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\Credential; final class CredentialTest extends TestCase { @@ -58,7 +58,7 @@ public function testRejectsCredentialMissingChallengeObject(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid credential JSON structure'); - Credential::fromAuthorizationHeader('Payment ' . \SolanaMpp\Core\Base64Url::encodeJson(['payload' => ['type' => 'signature']])); + Credential::fromAuthorizationHeader('Payment ' . \PayKit\Protocols\Mpp\Core\Base64Url::encodeJson(['payload' => ['type' => 'signature']])); } public function testRejectsNonObjectPayload(): void @@ -68,7 +68,7 @@ public function testRejectsNonObjectPayload(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Credential payload must be an object'); - Credential::fromAuthorizationHeader('Payment ' . \SolanaMpp\Core\Base64Url::encodeJson([ + Credential::fromAuthorizationHeader('Payment ' . \PayKit\Protocols\Mpp\Core\Base64Url::encodeJson([ 'challenge' => $challenge->toEcho()->toArray(), 'payload' => 'sig', ])); diff --git a/php/tests/HeadersTest.php b/php/tests/Protocols/Mpp/Core/HeadersTest.php similarity index 98% rename from php/tests/HeadersTest.php rename to php/tests/Protocols/Mpp/Core/HeadersTest.php index 4860f4129..43378bc30 100644 --- a/php/tests/HeadersTest.php +++ b/php/tests/Protocols/Mpp/Core/HeadersTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests\Protocols\Mpp\Core; use DateTimeImmutable; use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\Headers; -use SolanaMpp\Core\Receipt; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\Headers; +use PayKit\Protocols\Mpp\Core\Receipt; final class HeadersTest extends TestCase { diff --git a/php/tests/JsonTest.php b/php/tests/Protocols/Mpp/Core/JsonTest.php similarity index 99% rename from php/tests/JsonTest.php rename to php/tests/Protocols/Mpp/Core/JsonTest.php index 2993b7b33..384537e55 100644 --- a/php/tests/JsonTest.php +++ b/php/tests/Protocols/Mpp/Core/JsonTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests\Protocols\Mpp\Core; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Json; +use PayKit\Protocols\Mpp\Core\Json; final class JsonTest extends TestCase { diff --git a/php/tests/ReceiptTest.php b/php/tests/Protocols/Mpp/Core/ReceiptTest.php similarity index 96% rename from php/tests/ReceiptTest.php rename to php/tests/Protocols/Mpp/Core/ReceiptTest.php index 97a64538d..29ab958ab 100644 --- a/php/tests/ReceiptTest.php +++ b/php/tests/Protocols/Mpp/Core/ReceiptTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests\Protocols\Mpp\Core; use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Receipt; +use PayKit\Protocols\Mpp\Core\Receipt; final class ReceiptTest extends TestCase { diff --git a/php/tests/ChargeRequestTest.php b/php/tests/Protocols/Mpp/Intent/ChargeRequestTest.php similarity index 97% rename from php/tests/ChargeRequestTest.php rename to php/tests/Protocols/Mpp/Intent/ChargeRequestTest.php index 5918da10a..c97c1c9c0 100644 --- a/php/tests/ChargeRequestTest.php +++ b/php/tests/Protocols/Mpp/Intent/ChargeRequestTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests; use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use SolanaMpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; final class ChargeRequestTest extends TestCase { diff --git a/php/tests/ChargeServerTest.php b/php/tests/Protocols/Mpp/Server/ChargeServerTest.php similarity index 98% rename from php/tests/ChargeServerTest.php rename to php/tests/Protocols/Mpp/Server/ChargeServerTest.php index ac77e4a6f..00b116b16 100644 --- a/php/tests/ChargeServerTest.php +++ b/php/tests/Protocols/Mpp/Server/ChargeServerTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests; use DateTimeImmutable; use PHPUnit\Framework\TestCase; use RuntimeException; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\Credential; -use SolanaMpp\Core\Headers; -use SolanaMpp\Core\Json; -use SolanaMpp\Intent\ChargeRequest; -use SolanaMpp\Server\ChargeServer; -use SolanaMpp\Server\PaymentVerifier; -use SolanaMpp\Server\VerificationResult; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\Credential; +use PayKit\Protocols\Mpp\Core\Headers; +use PayKit\Protocols\Mpp\Core\Json; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\Server\ChargeServer; +use PayKit\Protocols\Mpp\Server\PaymentVerifier; +use PayKit\Protocols\Mpp\Server\VerificationResult; final class ChargeServerTest extends TestCase { diff --git a/php/tests/Protocols/Mpp/Server/FakeRpcGateway.php b/php/tests/Protocols/Mpp/Server/FakeRpcGateway.php new file mode 100644 index 000000000..bae7c2bce --- /dev/null +++ b/php/tests/Protocols/Mpp/Server/FakeRpcGateway.php @@ -0,0 +1,64 @@ + */ + public array $sentTransactions = []; + /** @var list}> */ + public array $calls = []; + + private int $statusIdx = 0; + private int $callIdx = 0; + + /** + * @param list|null> $statuses Sequence returned by getSignatureStatuses; last entry repeats. + * @param list $callResults Sequence returned by call(); once exhausted returns null. + */ + public function __construct( + private readonly string $signature = '5sigStubBASE58Signature1111111111111111111', + private readonly array $statuses = [['err' => null, 'confirmationStatus' => 'confirmed']], + private readonly array $callResults = [], + private readonly ?\Throwable $sendError = null, + ) { + } + + public function call(string $method, array $params = []): mixed + { + $this->calls[] = [$method, $params]; + if ($this->callResults === []) { + return null; + } + $idx = min($this->callIdx, array_key_last($this->callResults)); + $this->callIdx += 1; + return $this->callResults[$idx]; + } + + public function sendRawTransaction(string $wireBytes, array $options = []): string + { + $this->sentTransactions[] = $wireBytes; + if ($this->sendError !== null) { + throw $this->sendError; + } + return $this->signature; + } + + public function getSignatureStatuses(array $signatures, bool $searchTransactionHistory = false): array + { + $idx = min($this->statusIdx, array_key_last($this->statuses)); + $this->statusIdx += 1; + return [$this->statuses[$idx]]; + } +} diff --git a/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php new file mode 100644 index 000000000..1f12b949e --- /dev/null +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerInternalsTest.php @@ -0,0 +1,186 @@ +getMethod($method); + $m->setAccessible(true); + return $m->invoke($handler, ...$args); + } + + public function testAwaitConfirmationReturnsOnConfirmedStatus(): void + { + $rpc = new FakeRpcGateway( + statuses: [ + null, + ['err' => null, 'confirmationStatus' => 'processed'], + ['err' => null, 'confirmationStatus' => 'confirmed'], + ], + ); + $h = $this->handlerWith($rpc, confirmationAttempts: 5); + $this->invoke($h, 'awaitConfirmation', 'sig-confirmed'); + $this->assertSame(3, count($rpc->calls) + count($rpc->sentTransactions) + 3); // 3 status polls happened + } + + public function testAwaitConfirmationFinalizedAlsoAccepted(): void + { + $rpc = new FakeRpcGateway( + statuses: [['err' => null, 'confirmationStatus' => 'finalized']], + ); + $h = $this->handlerWith($rpc); + $this->invoke($h, 'awaitConfirmation', 'sig-final'); + $this->assertTrue(true); // no throw == pass + } + + public function testAwaitConfirmationTransactionErrorThrows(): void + { + $rpc = new FakeRpcGateway( + statuses: [['err' => ['InsufficientFundsForFee' => []], 'confirmationStatus' => 'confirmed']], + ); + $h = $this->handlerWith($rpc); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('failed'); + $this->invoke($h, 'awaitConfirmation', 'sig-failed'); + } + + public function testAwaitConfirmationTimesOutAfterMaxAttempts(): void + { + $rpc = new FakeRpcGateway(statuses: [null]); + $h = $this->handlerWith($rpc, confirmationAttempts: 2); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Timed out'); + $this->invoke($h, 'awaitConfirmation', 'sig-timeout'); + } + + public function testSettleRejectsEmptyBase64(): void + { + $rpc = new FakeRpcGateway(); + $h = $this->handlerWith($rpc); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid transaction payload'); + $this->invoke($h, 'settle', ''); + } + + public function testSettleRejectsInvalidBase64(): void + { + $rpc = new FakeRpcGateway(); + $h = $this->handlerWith($rpc); + $this->expectException(InvalidArgumentException::class); + $this->invoke($h, 'settle', '!!!not-base64!!!'); + } + + public function testConsumeSignatureRejectsReplay(): void + { + $rpc = new FakeRpcGateway(); + $h = $this->handlerWith($rpc); + $this->invoke($h, 'consumeSignature', 'sig-once'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('already consumed'); + $this->invoke($h, 'consumeSignature', 'sig-once'); + } + + public function testFetchSettledTransactionReturnsWireBase64(): void + { + $rpc = new FakeRpcGateway( + callResults: [ + null, + [ + 'meta' => ['err' => null], + 'transaction' => ['BASE64-wire-blob'], + ], + ], + ); + $h = $this->handlerWith($rpc, confirmationAttempts: 3); + $result = $this->invoke($h, 'fetchSettledTransaction', 'sig-x'); + $this->assertSame('BASE64-wire-blob', $result); + } + + public function testFetchSettledTransactionAcceptsStringTransaction(): void + { + $rpc = new FakeRpcGateway( + callResults: [[ + 'meta' => ['err' => null], + 'transaction' => 'BASE64-string-form', + ]], + ); + $h = $this->handlerWith($rpc); + $result = $this->invoke($h, 'fetchSettledTransaction', 'sig-x'); + $this->assertSame('BASE64-string-form', $result); + } + + public function testFetchSettledTransactionMetaErrThrows(): void + { + $rpc = new FakeRpcGateway( + callResults: [[ + 'meta' => ['err' => ['SomeErr' => []]], + 'transaction' => ['BASE64'], + ]], + ); + $h = $this->handlerWith($rpc); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('failed'); + $this->invoke($h, 'fetchSettledTransaction', 'sig-x'); + } + + public function testFetchSettledTransactionMissingMetaThrows(): void + { + $rpc = new FakeRpcGateway(callResults: [['no-meta' => true]]); + $h = $this->handlerWith($rpc); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('missing transaction metadata'); + $this->invoke($h, 'fetchSettledTransaction', 'sig-x'); + } + + public function testFetchSettledTransactionInvalidResponseTypeThrows(): void + { + $rpc = new FakeRpcGateway(callResults: ['not-an-array']); + $h = $this->handlerWith($rpc); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid'); + $this->invoke($h, 'fetchSettledTransaction', 'sig-x'); + } + + public function testFetchSettledTransactionTimesOut(): void + { + $rpc = new FakeRpcGateway(callResults: [null]); + $h = $this->handlerWith($rpc, confirmationAttempts: 2); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Timed out'); + $this->invoke($h, 'fetchSettledTransaction', 'sig-x'); + } +} diff --git a/php/tests/SolanaChargeHandlerTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php similarity index 98% rename from php/tests/SolanaChargeHandlerTest.php rename to php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php index 7287d3b18..64ad096e4 100644 --- a/php/tests/SolanaChargeHandlerTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeHandlerTest.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Challenge; -use SolanaMpp\Core\Credential; -use SolanaMpp\Intent\ChargeRequest; -use SolanaMpp\Server\ChargeServer; -use SolanaMpp\Server\ChargeSettlement; -use SolanaMpp\Server\PaymentRequiredResponse; -use SolanaMpp\Server\PaymentVerifier; -use SolanaMpp\Server\SolanaChargeHandler; -use SolanaMpp\Server\TransactionPayloadVerifier; -use SolanaMpp\Server\VerificationResult; -use SolanaMpp\Store\FileStore; -use SolanaMpp\Store\Store; +use PayKit\Protocols\Mpp\Core\Challenge; +use PayKit\Protocols\Mpp\Core\Credential; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\Server\ChargeServer; +use PayKit\Protocols\Mpp\Server\ChargeSettlement; +use PayKit\Protocols\Mpp\Server\PaymentRequiredResponse; +use PayKit\Protocols\Mpp\Server\PaymentVerifier; +use PayKit\Protocols\Mpp\Server\SolanaChargeHandler; +use PayKit\Protocols\Mpp\Server\TransactionPayloadVerifier; +use PayKit\Protocols\Mpp\Server\VerificationResult; +use PayKit\Store\FileStore; +use PayKit\Store\Store; use SolanaPhpSdk\Util\Base58; use SolanaPhpSdk\Keypair\Keypair; use SolanaPhpSdk\Keypair\PublicKey; diff --git a/php/tests/SolanaChargeTransactionVerifierTest.php b/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php similarity index 98% rename from php/tests/SolanaChargeTransactionVerifierTest.php rename to php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php index 682136160..6264ab54b 100644 --- a/php/tests/SolanaChargeTransactionVerifierTest.php +++ b/php/tests/Protocols/Mpp/Server/SolanaChargeTransactionVerifierTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace SolanaMpp\Tests; +namespace PayKit\Tests; use PHPUnit\Framework\TestCase; -use SolanaMpp\Core\Credential; -use SolanaMpp\Intent\ChargeRequest; -use SolanaMpp\Server\ChargeServer; -use SolanaMpp\Server\SolanaChargeTransactionVerifier; +use PayKit\Protocols\Mpp\Core\Credential; +use PayKit\Protocols\Mpp\Intent\ChargeRequest; +use PayKit\Protocols\Mpp\Server\ChargeServer; +use PayKit\Protocols\Mpp\Server\SolanaChargeTransactionVerifier; use SolanaPhpSdk\Keypair\PublicKey; use SolanaPhpSdk\Programs\AssociatedTokenProgram; use SolanaPhpSdk\Programs\ComputeBudgetProgram; @@ -577,7 +577,7 @@ private function solTransactionPayload(array $fixture, ?PublicKey $primarySource return base64_encode($transaction->serialize(verifySignatures: false)); } - private function verify(ChargeRequest $request, string $transaction): \SolanaMpp\Server\VerificationResult + private function verify(ChargeRequest $request, string $transaction): \PayKit\Protocols\Mpp\Server\VerificationResult { $server = new ChargeServer(secretKey: 'secret', realm: 'api'); $challenge = $server->createChallenge($request); diff --git a/php/tests/Protocols/X402/AdapterTest.php b/php/tests/Protocols/X402/AdapterTest.php new file mode 100644 index 000000000..dda611586 --- /dev/null +++ b/php/tests/Protocols/X402/AdapterTest.php @@ -0,0 +1,128 @@ +pubkey(), + signer: Signer::generate(), + feePayer: true, + ), + x402: $x402, + preflight: false, + ); + } + + public function testAcceptsEntryHasCanonicalX402Shape(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => 'BLOCKHASH-STUB'); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $entry = $adapter->acceptsEntry($gate, $req); + $this->assertSame('x402', $entry['protocol']); + $this->assertSame('exact', $entry['scheme']); + $this->assertSame('100000', $entry['amount']); + $this->assertSame('100000', $entry['maxAmountRequired']); + $this->assertSame(60, $entry['maxTimeoutSeconds']); + $this->assertSame('/paid', $entry['extra']['memo']); + $this->assertSame(6, $entry['extra']['decimals']); + $this->assertNotEmpty($entry['extra']['feePayer']); + } + + public function testAcceptsEntryEmbedsRecentBlockhash(): void + { + // Ruby PR #142 caveat #5. + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => 'ABC123BLOCKHASH'); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $entry = $adapter->acceptsEntry($gate, $req); + $this->assertSame('ABC123BLOCKHASH', $entry['extra']['recentBlockhash']); + } + + public function testAcceptsEntryOmitsBlockhashWhenProviderReturnsNull(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $entry = $adapter->acceptsEntry($gate, $req); + $this->assertArrayNotHasKey('recentBlockhash', $entry['extra']); + } + + public function testChallengeHeadersAreBase64JsonEnvelope(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $headers = $adapter->challengeHeaders($gate, $req); + $this->assertArrayHasKey('payment-required', $headers); + $decoded = base64_decode($headers['payment-required'], true); + $this->assertNotFalse($decoded); + $envelope = json_decode($decoded, true); + $this->assertSame(2, $envelope['x402Version']); + $this->assertSame('/paid', $envelope['resource']['url']); + $this->assertCount(1, $envelope['accepts']); + } + + public function testDelegatedModeRejectedAtConstruction(): void + { + $cfg = $this->makeConfig(new X402Config(facilitatorUrl: 'https://facilitator.example.com')); + $this->expectException(InvalidProofException::class); + new Adapter($cfg); + } + + public function testVerifyAndSettleRaisesWithoutPaymentSignature(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); + $this->expectException(InvalidProofException::class); + $adapter->verifyAndSettle($gate, $req); + } + + public function testVerifyAndSettleRaisesOnMalformedBase64(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $gate = new Gate(amount: Price::usd('0.10')); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid') + ->withHeader('Payment-Signature', '@@@-not-base64-@@@'); + $this->expectException(InvalidProofException::class); + $adapter->verifyAndSettle($gate, $req); + } + + public function testVerifyAndSettleRaisesOnInvalidVersion(): void + { + $cfg = $this->makeConfig(); + $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $gate = new Gate(amount: Price::usd('0.10')); + $envelope = base64_encode(json_encode(['x402Version' => 99]) ?: '{}'); + $req = (new Psr17Factory())->createServerRequest('GET', '/paid') + ->withHeader('Payment-Signature', $envelope); + $this->expectException(InvalidProofException::class); + $adapter->verifyAndSettle($gate, $req); + } +} diff --git a/php/tests/Protocols/X402/Exact/VerifierTest.php b/php/tests/Protocols/X402/Exact/VerifierTest.php new file mode 100644 index 000000000..19b4da0bb --- /dev/null +++ b/php/tests/Protocols/X402/Exact/VerifierTest.php @@ -0,0 +1,52 @@ + php server). These tests cover the rejection + * branches that fire before the structural pass runs. + */ +final class VerifierTest extends TestCase +{ + public function testMalformedBase64Rejected(): void + { + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessageMatches('/invalid_exact_svm_payload_base64/'); + Verifier::verify( + '@@@-not-base64-@@@', + ['asset' => 'mint', 'payTo' => 'recipient', 'amount' => '1000', 'extra' => ['tokenProgram' => 'tp', 'memo' => '/r']], + ['facilitator'], + ); + } + + public function testParseFailureRejected(): void + { + $this->expectException(InvalidProofException::class); + Verifier::verify( + base64_encode('not-a-transaction-payload'), + ['asset' => 'mint', 'payTo' => 'recipient', 'amount' => '1000', 'extra' => ['tokenProgram' => 'tp', 'memo' => '/r']], + ['facilitator'], + ); + } + + public function testRuleConstantsCanonical(): void + { + // Smoke that the canonical program ids the verifier compares + // against haven't drifted. + $this->assertSame('ComputeBudget111111111111111111111111111111', Verifier::COMPUTE_BUDGET_PROGRAM); + $this->assertSame('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr', Verifier::MEMO_PROGRAM); + $this->assertSame('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', Verifier::TOKEN_2022_PROGRAM); + $this->assertSame('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', Verifier::ASSOCIATED_TOKEN_PROGRAM); + $this->assertSame(50000, Verifier::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS); + } +} diff --git a/php/tests/SecretResolverTest.php b/php/tests/SecretResolverTest.php new file mode 100644 index 000000000..e2a948ba5 --- /dev/null +++ b/php/tests/SecretResolverTest.php @@ -0,0 +1,83 @@ + .env -> generate + persist. + * + * Each test points the resolver at a per-test temp file so the suite + * doesn't leak .env files into the repo root. + */ +final class SecretResolverTest extends TestCase +{ + private string $tmpDotenv = ''; + + protected function setUp(): void + { + $this->tmpDotenv = tempnam(sys_get_temp_dir(), 'paykit-dotenv-') ?: ''; + // tempnam creates the file; the writer expects "did the file + // already exist" semantics, so leave it (empty). + } + + protected function tearDown(): void + { + if ($this->tmpDotenv !== '' && is_file($this->tmpDotenv)) { + @unlink($this->tmpDotenv); + } + putenv('PAY_KIT_TEST_SECRET_AAA'); + } + + public function testEnvVarWinsOverDotenvAndGenerator(): void + { + putenv('PAY_KIT_TEST_SECRET_AAA=from-env'); + file_put_contents($this->tmpDotenv, "PAY_KIT_TEST_SECRET_AAA=from-dotenv\n"); + $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); + $this->assertSame('from-env', $r['secret']); + $this->assertSame('env', $r['source']); + } + + public function testDotenvWinsWhenEnvUnset(): void + { + putenv('PAY_KIT_TEST_SECRET_AAA'); + file_put_contents($this->tmpDotenv, "PAY_KIT_TEST_SECRET_AAA=from-dotenv\n"); + $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); + $this->assertSame('from-dotenv', $r['secret']); + $this->assertSame('dotenv', $r['source']); + } + + public function testQuotedDotenvValueStripped(): void + { + putenv('PAY_KIT_TEST_SECRET_AAA'); + file_put_contents($this->tmpDotenv, "PAY_KIT_TEST_SECRET_AAA=\"quoted-secret\"\n"); + $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); + $this->assertSame('quoted-secret', $r['secret']); + } + + public function testCommentsAndBlankLinesIgnoredInDotenv(): void + { + putenv('PAY_KIT_TEST_SECRET_AAA'); + file_put_contents($this->tmpDotenv, "# comment\n\nUNRELATED=foo\nPAY_KIT_TEST_SECRET_AAA=value-here\n"); + $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $this->tmpDotenv); + $this->assertSame('value-here', $r['secret']); + } + + public function testGenerateAndPersistWhenBothMissing(): void + { + putenv('PAY_KIT_TEST_SECRET_AAA'); + // Use a non-existent path so we exercise the create path. + $path = $this->tmpDotenv . '-fresh'; + @unlink($path); + $r = SecretResolver::resolveMppSecret('PAY_KIT_TEST_SECRET_AAA', $path); + $this->assertSame(64, strlen($r['secret'])); // 32 bytes hex + $this->assertSame('generated+persisted', $r['source']); + $this->assertTrue($r['persisted']); + $this->assertFileExists($path); + $this->assertStringContainsString('PAY_KIT_TEST_SECRET_AAA=', file_get_contents($path) ?: ''); + @unlink($path); + } +} diff --git a/php/tests/Signer/LocalSignerTest.php b/php/tests/Signer/LocalSignerTest.php new file mode 100644 index 000000000..4b7a149b8 --- /dev/null +++ b/php/tests/Signer/LocalSignerTest.php @@ -0,0 +1,128 @@ +sign('hello world'); + $this->assertSame(64, strlen($sig)); + } + + public function testPubkeyBase58Shape(): void + { + $sgn = Signer::generate(); + $pubkey = $sgn->pubkey(); + $this->assertGreaterThanOrEqual(32, strlen($pubkey)); + $this->assertLessThanOrEqual(44, strlen($pubkey)); + } + + public function testIsFeePayerDefaultsTrue(): void + { + $sgn = Signer::generate(); + $this->assertTrue($sgn->isFeePayer()); + $this->assertFalse($sgn->isDemo()); + } + + public function testSecretKeyRoundTrip(): void + { + $sgn = Signer::generate(); + $bytes = $sgn->secretKey(); + $this->assertSame(64, strlen($bytes)); + $rebuilt = Signer::bytes($bytes); + $this->assertSame($sgn->pubkey(), $rebuilt->pubkey()); + } + + public function testBytesAsStringMustBe64ByteString(): void + { + $this->expectException(InvalidKeyException::class); + Signer::bytes('too-short'); + } + + public function testHexRoundTrip(): void + { + $sgn = Signer::generate(); + $hex = bin2hex($sgn->secretKey()); + $rebuilt = Signer::hex($hex); + $this->assertSame($sgn->pubkey(), $rebuilt->pubkey()); + } + + public function testHexNonHexCharsRejected(): void + { + $this->expectException(InvalidKeyException::class); + Signer::hex(str_repeat('zz', 64)); + } + + public function testFileMissingPathRejected(): void + { + $this->expectException(InvalidKeyException::class); + Signer::file('/tmp/nonexistent-paykit-signer-xyz.json'); + } + + public function testFileEmptyPathRejected(): void + { + $this->expectException(InvalidKeyException::class); + Signer::file(''); + } + + public function testFileLoadsValidJson(): void + { + $sgn = Signer::generate(); + $bytes = array_values(unpack('C*', $sgn->secretKey()) ?: []); + $path = tempnam(sys_get_temp_dir(), 'paykit-signer-') ?: ''; + file_put_contents($path, json_encode($bytes)); + try { + $rebuilt = Signer::file($path); + $this->assertSame($sgn->pubkey(), $rebuilt->pubkey()); + } finally { + @unlink($path); + } + } + + public function testEnvAutoDetectsJson(): void + { + $sgn = Signer::generate(); + $bytes = array_values(unpack('C*', $sgn->secretKey()) ?: []); + putenv('PAY_KIT_TEST_SIGNER_JSON=' . json_encode($bytes)); + try { + $rebuilt = Signer::env('PAY_KIT_TEST_SIGNER_JSON'); + $this->assertNotNull($rebuilt); + $this->assertSame($sgn->pubkey(), $rebuilt->pubkey()); + } finally { + putenv('PAY_KIT_TEST_SIGNER_JSON'); + } + } + + public function testEnvAutoDetectsHex(): void + { + $sgn = Signer::generate(); + $hex = bin2hex($sgn->secretKey()); + putenv("PAY_KIT_TEST_SIGNER_HEX=$hex"); + try { + $rebuilt = Signer::env('PAY_KIT_TEST_SIGNER_HEX'); + $this->assertNotNull($rebuilt); + $this->assertSame($sgn->pubkey(), $rebuilt->pubkey()); + } finally { + putenv('PAY_KIT_TEST_SIGNER_HEX'); + } + } + + public function testEnvRaisesOnMalformed(): void + { + putenv('PAY_KIT_TEST_SIGNER_BAD=this-is-not-valid-base58-or-hex-or-json'); + try { + $this->expectException(InvalidKeyException::class); + Signer::env('PAY_KIT_TEST_SIGNER_BAD'); + } finally { + putenv('PAY_KIT_TEST_SIGNER_BAD'); + } + } +} diff --git a/php/tests/SignerTest.php b/php/tests/SignerTest.php new file mode 100644 index 000000000..a3c3f12f3 --- /dev/null +++ b/php/tests/SignerTest.php @@ -0,0 +1,99 @@ +assertTrue($sgn->isDemo()); + $this->assertSame(Demo::PUBKEY, $sgn->pubkey()); + $this->assertSame(Demo::PUBKEY, $sgn->pubkey()); // cached + } + + public function testGenerateProducesValidKeypair(): void + { + $sgn = Signer::generate(); + $this->assertFalse($sgn->isDemo()); + $this->assertSame(64, strlen($sgn->secretKey())); + $this->assertNotEmpty($sgn->pubkey()); + } + + public function testBytesAcceptsArrayOfInts(): void + { + $arr = array_fill(0, 64, 1); + $sgn = Signer::bytes($arr); + $this->assertNotEmpty($sgn->pubkey()); + } + + public function testBytesRejectsWrongLength(): void + { + $this->expectException(InvalidKeyException::class); + Signer::bytes(array_fill(0, 32, 1)); + } + + public function testBytesRejectsOutOfRange(): void + { + $arr = array_fill(0, 64, 1); + $arr[10] = 999; + $this->expectException(InvalidKeyException::class); + Signer::bytes($arr); + } + + public function testJsonAcceptsCliFormat(): void + { + $bytes = array_fill(0, 64, 7); + $sgn = Signer::json(json_encode($bytes, JSON_THROW_ON_ERROR)); + $this->assertNotEmpty($sgn->pubkey()); + } + + public function testJsonRejectsEmpty(): void + { + $this->expectException(InvalidKeyException::class); + Signer::json(''); + } + + public function testHexAcceptsValidHex(): void + { + $hex = str_repeat('aa', 64); + $sgn = Signer::hex($hex); + $this->assertNotEmpty($sgn->pubkey()); + } + + public function testHexRejectsWrongLength(): void + { + $this->expectException(InvalidKeyException::class); + Signer::hex('abc'); + } + + public function testEnvReturnsNullForUnset(): void + { + $this->assertNull(Signer::env('PAY_KIT_UNSET_X9Y8Z7')); + } + + public function testEnvRejectsEmptyName(): void + { + $this->expectException(InvalidKeyException::class); + Signer::env(''); + } + public function testBase58RejectsTooShortDecoded(): void + { + // Valid base58 but decoded length != 64 -> rejected. + $this->expectException(InvalidKeyException::class); + Signer::base58('abc123'); + } + + public function testBase58RejectsEmpty(): void + { + $this->expectException(InvalidKeyException::class); + Signer::base58(''); + } +} diff --git a/php/tests/StablecoinMintsTest.php b/php/tests/StablecoinMintsTest.php deleted file mode 100644 index 576dc6995..000000000 --- a/php/tests/StablecoinMintsTest.php +++ /dev/null @@ -1,112 +0,0 @@ - Result<(), Box> { let paid_response = http .get(&target_url) - .header(PAYMENT_SIGNATURE_HEADER, payment_header) + .header(PAYMENT_SIGNATURE_HEADER, payment_header.clone()) .send() .await?; let status = paid_response.status(); let paid_headers = response_headers(paid_response.headers())?; - let paid_headers = headers_to_map(paid_headers); + let mut paid_headers = headers_to_map(paid_headers); + paid_headers.insert(format!("{PAYMENT_SIGNATURE_HEADER}-sent"), payment_header); let settlement = paid_headers.get(SETTLEMENT_HEADER).cloned(); let raw_body = paid_response.text().await?; let response_body = serde_json::from_str::(&raw_body)