From c94203ddcd841dbe9b15d5b92021e26f4b4ef931 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Mon, 5 Oct 2026 14:42:26 -0600 Subject: [PATCH 1/2] PayKit interface drifts Add `Config::fromEnv(string $prefix = 'PAY_KIT_'): Config` to the PHP SDK (the PHP item "Add `Config::fromEnv()`"). `docs/paykit-interface.md:85-95` says every SDK must have a `configure_from_env` entry point, and maps it to `Config::fromEnv()` for PHP. Python (`configure_from`) and TypeScript (`configureFromEnv`) have one. PHP has none. --- php/src/Config.php | 56 ++++++++++++++++++++ php/tests/ConfigTest.php | 111 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/php/src/Config.php b/php/src/Config.php index a032f86b8..11067a963 100644 --- a/php/src/Config.php +++ b/php/src/Config.php @@ -106,6 +106,62 @@ public function __construct( $this->mpp = $resolvedMpp; } + public static function fromEnv(string $prefix = 'PAY_KIT_'): self + { + $env = static function (string $name) use ($prefix): ?string { + $raw = getenv($prefix . $name); + return $raw === false || trim($raw) === '' ? null : trim($raw); + }; + + $args = [ + 'rpcUrl' => $env('RPC_URL'), + 'operator' => new Operator( + recipient: $env('OPERATOR_RECIPIENT'), + signer: Signer::env($prefix . 'OPERATOR_KEY'), + ), + 'x402' => new X402Config(facilitatorUrl: $env('X402_FACILITATOR_URL')), + 'mpp' => new MppConfig( + realm: $env('MPP_REALM'), + challengeBindingSecret: $env('MPP_CHALLENGE_BINDING_SECRET'), + expiresIn: MppConfig::resolveExpiresIn($env('MPP_EXPIRES_IN')), + ), + ]; + if (($network = $env('NETWORK')) !== null) { + $args['network'] = Network::tryFrom($network) ?? throw new ConfigurationException( + sprintf('pay_kit: %sNETWORK has unknown value "%s"', $prefix, $network), + ); + } + if (($accept = $env('ACCEPT')) !== null) { + $args['accept'] = self::enumList(Protocol::class, $accept, $prefix . 'ACCEPT'); + } + if (($stablecoins = $env('STABLECOINS')) !== null) { + $args['stablecoins'] = self::enumList(Stablecoin::class, $stablecoins, $prefix . 'STABLECOINS'); + } + if (($preflight = $env('PREFLIGHT')) !== null) { + $args['preflight'] = filter_var($preflight, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) + ?? throw new ConfigurationException( + sprintf('pay_kit: %sPREFLIGHT is not a boolean: "%s"', $prefix, $preflight), + ); + } + + return new self(...$args); + } + + /** + * @template T of \BackedEnum + * @param class-string $enum + * @return list + */ + private static function enumList(string $enum, string $raw, string $var): array + { + return array_map( + static fn (string $item): \BackedEnum => $enum::tryFrom(trim($item)) ?? throw new ConfigurationException( + sprintf('pay_kit: %s has unknown entry "%s"', $var, trim($item)), + ), + explode(',', $raw), + ); + } + /** * The operator's recipient pubkey, post-defaults. */ diff --git a/php/tests/ConfigTest.php b/php/tests/ConfigTest.php index 4d6583c24..891fbbb2a 100644 --- a/php/tests/ConfigTest.php +++ b/php/tests/ConfigTest.php @@ -7,6 +7,7 @@ use PayKit\Config; use PayKit\Exception\ConfigurationException; use PayKit\Exception\DemoSignerOnMainnetException; +use PayKit\Exception\InvalidKeyException; use PayKit\PayCore\Network; use PayKit\Operator; use PayKit\Protocol; @@ -16,6 +17,116 @@ final class ConfigTest extends TestCase { + private const ENV_NAMES = [ + 'NETWORK', 'RPC_URL', 'ACCEPT', 'STABLECOINS', 'PREFLIGHT', + 'OPERATOR_RECIPIENT', 'OPERATOR_KEY', 'MPP_REALM', + 'MPP_CHALLENGE_BINDING_SECRET', 'MPP_EXPIRES_IN', 'X402_FACILITATOR_URL', + ]; + + protected function tearDown(): void + { + foreach (self::ENV_NAMES as $name) { + putenv("PAY_KIT_{$name}"); + putenv("ACME_{$name}"); + } + } + + public function testFromEnvWithNoVarsUsesDefaults(): void + { + putenv('PAY_KIT_PREFLIGHT=false'); + $cfg = Config::fromEnv(); + $this->assertEquals(new Config(preflight: false), $cfg); + $this->assertSame(Network::SolanaLocalnet, $cfg->network); + $this->assertSame([Protocol::X402, Protocol::Mpp], $cfg->accept); + $this->assertSame([Stablecoin::Usdc], $cfg->stablecoins); + $this->assertSame(120, $cfg->mpp->expiresIn); + $this->assertTrue($cfg->operator->signer?->isDemo()); + } + + public function testFromEnvReadsEveryVar(): void + { + $sgn = Signer::generate(); + putenv('PAY_KIT_NETWORK=solana_devnet'); + putenv('PAY_KIT_RPC_URL=https://rpc.example.com'); + putenv('PAY_KIT_ACCEPT=mpp, x402'); + putenv('PAY_KIT_STABLECOINS=USDT,USDC'); + putenv('PAY_KIT_PREFLIGHT=off'); + putenv('PAY_KIT_OPERATOR_RECIPIENT=CustomRecipient'); + putenv('PAY_KIT_OPERATOR_KEY=' . bin2hex($sgn->secretKey())); + putenv('PAY_KIT_MPP_REALM=Realm'); + putenv('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET=secret'); + putenv('PAY_KIT_MPP_EXPIRES_IN=0'); + putenv('PAY_KIT_X402_FACILITATOR_URL=https://facilitator.example.com'); + + $cfg = Config::fromEnv(); + + $this->assertSame(Network::SolanaDevnet, $cfg->network); + $this->assertSame('https://rpc.example.com', $cfg->rpcUrl); + $this->assertSame([Protocol::Mpp, Protocol::X402], $cfg->accept); + $this->assertSame([Stablecoin::Usdt, Stablecoin::Usdc], $cfg->stablecoins); + $this->assertFalse($cfg->preflight); + $this->assertSame('CustomRecipient', $cfg->effectiveRecipient()); + $this->assertSame($sgn->pubkey(), $cfg->operator->signer?->pubkey()); + $this->assertSame('Realm', $cfg->mpp->realm); + $this->assertSame('secret', $cfg->mpp->challengeBindingSecret); + $this->assertSame(0, $cfg->mpp->expiresIn); + $this->assertSame('https://facilitator.example.com', $cfg->x402->facilitatorUrl); + } + + public function testFromEnvHonoursCustomPrefix(): void + { + putenv('ACME_NETWORK=solana_devnet'); + putenv('ACME_PREFLIGHT=false'); + putenv('PAY_KIT_NETWORK=solana_mainnet'); + $this->assertSame(Network::SolanaDevnet, Config::fromEnv('ACME_')->network); + } + + /** + * @return array + */ + public static function malformedEnvProvider(): array + { + return [ + 'network' => ['NETWORK', 'solana_testnet'], + 'accept' => ['ACCEPT', 'x402,bogus'], + 'stablecoins' => ['STABLECOINS', 'USDC,DOGE'], + 'preflight' => ['PREFLIGHT', 'maybe'], + 'expires_in' => ['MPP_EXPIRES_IN', '2m'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('malformedEnvProvider')] + public function testFromEnvRejectsMalformedValues(string $name, string $value): void + { + putenv('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET=secret'); + putenv("PAY_KIT_{$name}={$value}"); + $this->expectException(ConfigurationException::class); + Config::fromEnv(); + } + + /** + * @return array}> + */ + public static function signerEnvProvider(): array + { + return [ + 'malformed key' => ['PAY_KIT_OPERATOR_KEY=not-a-key', InvalidKeyException::class], + 'mainnet without a key' => ['PAY_KIT_NETWORK=solana_mainnet', DemoSignerOnMainnetException::class], + ]; + } + + /** + * @param class-string<\Throwable> $exception + */ + #[\PHPUnit\Framework\Attributes\DataProvider('signerEnvProvider')] + public function testFromEnvRejectsBadSigner(string $assignment, string $exception): void + { + putenv('PAY_KIT_PREFLIGHT=false'); + putenv($assignment); + $this->expectException($exception); + Config::fromEnv(); + } + public function testZeroConfigUsesLocalnetDefaultsAndDemoSigner(): void { $cfg = new Config(preflight: false); From 4c3eb57b4ace63afa602719bd0912aeafb038852 Mon Sep 17 00:00:00 2001 From: caliperforge Date: Mon, 5 Oct 2026 14:42:27 -0600 Subject: [PATCH 2/2] greptile.json: review on request only --- greptile.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 greptile.json diff --git a/greptile.json b/greptile.json new file mode 100644 index 000000000..3e653b565 --- /dev/null +++ b/greptile.json @@ -0,0 +1 @@ +{"autoReview": []}