diff --git a/greptile.json b/greptile.json new file mode 100644 index 000000000..8cd7d96bb --- /dev/null +++ b/greptile.json @@ -0,0 +1,190 @@ +{ + "autoReview": [], + "strictness": 1, + "customContext": { + "rules": [ + { + "rule": "pay-kit#317 `solana-foundation/pay-kit@a58c78e:go/protocols/mpp/wire/challenge_test.go:56`: `now` was fixed at 1900, so the leap-second cases proved only that parsing succeeded, not the mapping to the last nanosecond of `:59` (https://github.com/solana-foundation/pay-kit/pull/317#discussion_r3982265139).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#13 `caliperforge/pay-kit@efff41c:harness/protocol-runners/kotlin.json:3`: the manifest execs an installed Gradle distribution that no CI job builds, so on a clean checkout the runner cannot start (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253848).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#13 `caliperforge/pay-kit@8c59ab9:harness/test/protocol-conformance.test.ts:195`: a spawn failure, a non-zero exit or empty stdout counted as the expected divergence, so a runner that never ran looked green.", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#13 `caliperforge/pay-kit@2291822:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:77`: decoding with unknown keys ignored silently dropped the `hash` payload of `credential_with_source` (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253870).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#13 `caliperforge/pay-kit@c4341ee:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:57-66`: the same lenient decoding dropped the challenge `description` (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253852).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#320 `solana-foundation/pay-kit@a55ee48:php/tests/PayCore/Rfc3339Test.php:48`: long-fraction vectors were checked with `format('c')`, which drops microseconds, so a parser that discards the fraction still passed (https://github.com/solana-foundation/pay-kit/pull/320#discussion_r3992887690).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#320 `solana-foundation/pay-kit@a55ee48:php/tests/PayCore/Rfc3339Test.php:88`: no case exercised the last-day-of-month rule for `23:59:60Z`, so deleting the check kept the suite green (https://github.com/solana-foundation/pay-kit/pull/320#discussion_r3992887696).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:271`: an empty `PAY_KIT_RPC_URL` overrode the network default with `\"\"`, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737040).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:277`: a zero or negative `PAY_KIT_MPP_EXPIRES_IN` was accepted, issuing already-expired challenges, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737053).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:308`: `yes`/`no`/`on`/`off` for `PAY_KIT_PREFLIGHT` raised at boot, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737067).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:268`: lowercase `pay_kit_*` variables were silently ignored, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737071).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#15 `caliperforge/pay-kit@1f02e55:swift/Sources/mpp-protocol-runner/main.swift:74`: `challenge.parse` dropped `description` (https://github.com/caliperforge/pay-kit/pull/15#discussion_r4173474978).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#15 `caliperforge/pay-kit@1f02e55:swift/Sources/mpp-protocol-runner/main.swift:97`: `credential.format` required `transaction` for `type: \"transaction\"`, so the canonical `basic_credential` vector could not be formatted (https://github.com/caliperforge/pay-kit/pull/15#discussion_r4173474983).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#322 `solana-foundation/pay-kit@c35533e:swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Models.swift:125`: a review asked to allow leap seconds only in June and December; RFC 3339 ยง5.7 and Appendix D do not restrict them, the UTC month-end check matches the Rust reference, and the finding was withdrawn (https://github.com/solana-foundation/pay-kit/pull/322#discussion_r4035505691).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#282 `solana-foundation/pay-kit@e2f2b1d:harness/src/protocol/vectors.ts:436`: `collectExpiresCases()` had no callers, though comments said the per-language tests used it (https://github.com/solana-foundation/pay-kit/pull/282#discussion_r3751682861).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#282 `solana-foundation/pay-kit@faf3250:typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts:449`: the test imported `../shared/rfc3339.js`, which did not exist at that SHA, so the whole file failed to load (https://github.com/solana-foundation/pay-kit/pull/282#discussion_r3960191945).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#282 `solana-foundation/pay-kit@faf3250:typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts:478`: the loop tested only `parseRfc3339` while the production guards still used `new Date(...)` and `Date.parse`, so the tests could pass without exercising the shipped code (https://github.com/solana-foundation/pay-kit/pull/282#discussion_r3960191963).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#313 `solana-foundation/pay-kit@a752d51:typescript/packages/mpp/src/shared/rfc3339.ts:38`: rejected `second = 60`, which RFC 3339 allows for a positive leap second (https://github.com/solana-foundation/pay-kit/pull/313#discussion_r3962113765).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#14 `caliperforge/pay-kit@208fbd7:harness/test/protocol-conformance.test.ts:179`: an expectation checked only that `description` was absent, so `result: {}` would also pass (https://github.com/caliperforge/pay-kit/pull/14#discussion_r4173234527).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#13 `caliperforge/pay-kit@66449a5:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:66`: \"P1\" **Challenge description is dropped** When a valid header contains a top-level `description`, such as the canonical `full_challenge`, this response omits it. The parsed challenge no longer matches the expected object, and the basic-only runner test does not catch the difference. (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253852).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#13 `caliperforge/pay-kit@66449a5:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:65`: \"P1\" **Plain opaque values fail parsing** The SDK treats `opaque` as a pass-through string. When a valid challenge contains a plain value such as `trace-123`, decoding it as base64url JSON throws, so the runner returns `parse_error` instead of the parsed challenge. (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253859).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#13 `caliperforge/pay-kit@8c59ab9:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:77`: \"P1\" **Hash payload is lost** The canonical `credential_with_source` case contains a valid `hash` payload. This code deserializes it while ignoring unknown fields, but the Kotlin payload type has no `hash` property. Formatting then emits an Authorization header without the hash value. (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253870).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:78`: \"P1\" **Opaque values are misparsed** When a valid Payment challenge includes `opaque`, the runner tries to decode it as base64url JSON even though the SDK and protocol adapter treat it as an opaque string. A value that is not JSON becomes a parse error; a JSON-bearing value is returned with the wrong type. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902439).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:85`: \"P2\" **Missing request becomes valid** If a `credential.format` input omits the required `challenge.request`, the runner inserts an empty request and can return a successful Authorization header. This hides an invalid input and produces a header that does not represent the supplied challenge. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902463).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:77`: \"P2\" **Empty optional values disappear** When a parsed challenge contains an explicitly empty `expires` or `digest`, these checks omit the field from the result. The adapter distinguishes a present empty value from an absent field, so the runner returns the wrong object for those headers. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902477).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:33`: \"P2\" **Conformance skips recognized operations** Seven recognized operations always return fixed errors, and the Kotlin divergence tests assert those responses instead of exercising protocol behavior. A green Kotlin protocol job therefore does not verify encoding, challenge generation, or those header operations. Implement them or separate these gap checks from conformance coverage. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902484).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#20 `caliperforge/pay-kit@473293a:php/src/Gate.php:136`: \"P1\" **Inherited protocol limits are ignored** When a gate has no explicit `accept` list, this method returns true for any fee-free protocol without checking `Config`. With a configuration that accepts only MPP, `gate->accepts(Protocol::X402)` therefore returns true even though the middleware excludes X402. Callers of this new public method get an admission decision that contradicts the configured protocol limit. (https://github.com/caliperforge/pay-kit/pull/20#discussion_r4188609106).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#14 `caliperforge/pay-kit@17dd9a2:harness/test/protocol-conformance.test.ts:212`: \"P2\" **Formatter cases are skipped** This filter selects no successful `credential.format` vector and no `receipt.format` vector: neither operation is in `smokeOps`, and the Kotlin map has only a failing credential-format case. The credential formatter could fail every valid request without failing this suite, while the declared receipt-format gap gets no conformance check. Add a successful formatter case and track the receipt-format gap. (https://github.com/caliperforge/pay-kit/pull/14#discussion_r4173234531).", + "scope": [ + "**" + ] + }, + { + "rule": "pay-kit#14 `caliperforge/pay-kit@b976cea:harness/test/protocol-conformance.test.ts:179`: \"P2\" **Challenge fields go unchecked** For challenges with a description, this expectation checks only that `description` is absent. A response with `result: {}` would also pass, so the test would miss regressions in required challenge fields or the decoded request. Check those fields against the vector while allowing the known omission. (https://github.com/caliperforge/pay-kit/pull/14#discussion_r4173234527).", + "scope": [ + "**" + ] + } + ], + "files": [ + { + "path": "docs/paykit-interface.md", + "scope": [ + "**" + ] + } + ] + } +} 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);