diff --git a/docs/paykit-interface.md b/docs/paykit-interface.md
index a7d433c31..fbe769ded 100644
--- a/docs/paykit-interface.md
+++ b/docs/paykit-interface.md
@@ -293,7 +293,6 @@ conformant.
functions over the request attribute) so non-middleware code paths exist,
matching Ruby and Python.
- Add `Config::fromEnv()`.
-- Add `accepts(Protocol $p): bool` on `Gate`.
- Add `payer` to `Payment`.
- Simplify `MppConfig::resolveExpiresIn` (plain int + dedicated
`EXPIRES_NEVER = 0` constant).
diff --git a/greptile.json b/greptile.json
new file mode 100644
index 000000000..d4b2e42f5
--- /dev/null
+++ b/greptile.json
@@ -0,0 +1,214 @@
+{
+ "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`:
**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`:
**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`:
**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`:
**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`:
**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`:
**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`:
**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`:
**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#18 `caliperforge/pay-kit@c94203d:php/src/Config.php:113`:
**Blank expiry bypasses validation** If `PAY_KIT_MPP_EXPIRES_IN` is set but blank, this helper turns it into `null`. The TTL resolver then uses the 120-second default instead of rejecting the malformed value, so a mistyped setting goes unnoticed. (https://github.com/caliperforge/pay-kit/pull/18#discussion_r4188597519).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#18 `caliperforge/pay-kit@c94203d:php/tests/ConfigTest.php:31`:
**Tests clear existing configuration** If the shell already has a production configuration variable set, these tests do not isolate themselves from it and `tearDown()` removes it instead of restoring it. For example, an existing `PAY_KIT_NETWORK=solana_mainnet` makes the defaults test throw because it selects mainnet with a demo signer; after cleanup, subsequent tests see a different environment. (https://github.com/caliperforge/pay-kit/pull/18#discussion_r4188597530).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#14 `caliperforge/pay-kit@17dd9a2:harness/test/protocol-conformance.test.ts:212`:
**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`:
**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": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#18 `caliperforge/pay-kit@e227243:harness/src/artifacts.ts:48`:
**Swift conformance build fails** The Swift conformance target builds from `swift/`, but its `Package.swift` is at the repository root. The macOS artifact build therefore cannot find the package manifest and fails before the Swift harness legs can run. (https://github.com/caliperforge/pay-kit/pull/18#discussion_r4209441653).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#18 `caliperforge/pay-kit@e227243:typescript/packages/mpp/src/server/Charge.ts:815`:
**Failed sends strand payments** If `sendTransaction` fails before accepting a pull-mode payment, the replay reservation remains pending for 21 minutes. A retry of the same signed payment is rejected as already in progress; after the lease expires, recovery only checks for confirmation and never broadcasts it. A transient send failure can therefore leave a valid payment unable to complete. (https://github.com/caliperforge/pay-kit/pull/18#discussion_r4209441678).",
+ "scope": [
+ "**"
+ ]
+ }
+ ],
+ "files": [
+ {
+ "path": "docs/paykit-interface.md",
+ "scope": [
+ "**"
+ ]
+ }
+ ]
+ }
+}
diff --git a/php/src/Gate.php b/php/src/Gate.php
index e57eb9391..da24108af 100644
--- a/php/src/Gate.php
+++ b/php/src/Gate.php
@@ -127,6 +127,15 @@ public function hasFees(): bool
return count($this->fees) > 0;
}
+ /** Whether this gate admits `$p`; a null `accept` defers to `$config` when given. */
+ public function accepts(Protocol $p, ?Config $config = null): bool
+ {
+ if ($p === Protocol::X402 && $this->hasFees()) {
+ return false;
+ }
+ return in_array($p, $this->accept ?? $config?->accept ?? Protocol::cases(), true);
+ }
+
private static function buildFee(int|string $recipient, Price $price, string $kind, Price $amount): Fee
{
if (!is_string($recipient) || $recipient === '') {
diff --git a/php/src/Middleware/RequirePayment.php b/php/src/Middleware/RequirePayment.php
index 394b4d0d0..2dc685b0b 100644
--- a/php/src/Middleware/RequirePayment.php
+++ b/php/src/Middleware/RequirePayment.php
@@ -111,10 +111,13 @@ private function resolveGate(ServerRequestInterface $request): Gate
private function pickAdapter(Gate $gate, ServerRequestInterface $request): ?object
{
- $accept = $gate->accept ?? $this->client->config->accept;
+ $config = $this->client->config;
$auth = $request->getHeaderLine('Authorization');
$sig = $request->getHeaderLine('Payment-Signature');
- foreach ($accept as $protocol) {
+ foreach ($gate->accept ?? $config->accept as $protocol) {
+ if (!$gate->accepts($protocol, $config)) {
+ continue;
+ }
if ($protocol === Protocol::X402 && $sig !== '' && $this->x402 !== null) {
return $this->x402;
}
@@ -129,13 +132,13 @@ private function build402(Gate $gate, ServerRequestInterface $request): Response
{
$accepts = [];
$headers = [];
- $accept = $gate->accept ?? $this->client->config->accept;
+ $config = $this->client->config;
- if ($this->x402 !== null && in_array(Protocol::X402, $accept, true) && !$gate->hasFees()) {
+ if ($this->x402 !== null && $gate->accepts(Protocol::X402, $config)) {
$accepts[] = $this->x402->acceptsEntry($gate, $request);
$headers = array_merge($headers, $this->x402->challengeHeaders($gate, $request));
}
- if (in_array(Protocol::Mpp, $accept, true)) {
+ if ($gate->accepts(Protocol::Mpp, $config)) {
$accepts[] = $this->mpp->acceptsEntry($gate, $request);
$headers = array_merge($headers, $this->mpp->challengeHeaders($gate, $request));
}
diff --git a/php/tests/GateTest.php b/php/tests/GateTest.php
index 47e08b03d..e11518675 100644
--- a/php/tests/GateTest.php
+++ b/php/tests/GateTest.php
@@ -4,6 +4,7 @@
namespace PayKit\Tests;
+use PayKit\Config;
use PayKit\Exception\MixedCurrenciesException;
use PayKit\Exception\ProtocolIncompatibleException;
use PayKit\Gate;
@@ -75,6 +76,39 @@ public function testExplicitX402AcceptOnFeeGateRejected(): void
);
}
+ public function testAcceptsBothProtocolsWhenAcceptInherited(): void
+ {
+ $g = new Gate(amount: Price::usd('1.00'));
+ $this->assertTrue($g->accepts(Protocol::X402));
+ $this->assertTrue($g->accepts(Protocol::Mpp));
+ }
+
+ public function testAcceptsHonorsExplicitAllowlist(): void
+ {
+ $g = new Gate(amount: Price::usd('1.00'), accept: [Protocol::Mpp]);
+ $this->assertFalse($g->accepts(Protocol::X402));
+ $this->assertTrue($g->accepts(Protocol::Mpp));
+ }
+
+ public function testAcceptsRefusesX402OnFeeGate(): void
+ {
+ $g = new Gate(
+ amount: Price::usd('1.00'),
+ payTo: 'SELLER',
+ feeWithin: ['PLATFORM' => Price::usd('0.10')],
+ );
+ $this->assertFalse($g->accepts(Protocol::X402));
+ $this->assertTrue($g->accepts(Protocol::Mpp));
+ }
+
+ public function testAcceptsDefersToConfigWhenInherited(): void
+ {
+ $g = new Gate(amount: Price::usd('1.00'));
+ $cfg = new Config(accept: [Protocol::Mpp], preflight: false);
+ $this->assertFalse($g->accepts(Protocol::X402, $cfg));
+ $this->assertTrue($g->accepts(Protocol::Mpp, $cfg));
+ }
+
public function testPayoutNullForUnaddressedRecipient(): void
{
$g = new Gate(amount: Price::usd('1.00'), payTo: 'SELLER');