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`: \"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#18 `caliperforge/pay-kit@c94203d:php/src/Config.php:113`: \"P2\" **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`: \"P2\" **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`: \"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": [ + "**" + ] + }, + { + "rule": "pay-kit#18 `caliperforge/pay-kit@e227243:harness/src/artifacts.ts:48`: \"P1\" **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`: \"P1\" **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/python/Justfile b/python/Justfile index 89fcd8295..cc9430aab 100644 --- a/python/Justfile +++ b/python/Justfile @@ -28,6 +28,10 @@ fmt: typecheck: {{uv_run}} pyright +# Run the pinned-seed instruction-allowlist negatives (not collected by `test`). +allowlist-gen: + {{uv_run}} pytest tests/allowlist_generator.py + # Run tests with the 90% line-coverage gate. test-cover: {{uv_run}} pytest --cov=solana_pay_kit --cov-report=term-missing --cov-fail-under=90 --ignore=tests/test_server_html.py diff --git a/python/README.md b/python/README.md index 21f026b20..c692b4422 100644 --- a/python/README.md +++ b/python/README.md @@ -523,6 +523,9 @@ pyright pytest --cov=solana_pay_kit --cov-fail-under=90 ``` +The pinned-seed instruction-allowlist negatives are opt-in: `just allowlist-gen` +(override with `PAY_KIT_ALLOWLIST_GEN_SEED` / `PAY_KIT_ALLOWLIST_GEN_CASES`). + The `solana_pay_kit` surface is gated at 90 percent line coverage in CI. The `solana_pay_kit.preflight` module is omitted from the gate: it wraps live Solana RPC + Surfnet cheatcodes that cannot run inside the offline unit suite, and diff --git a/python/tests/allowlist_generator.py b/python/tests/allowlist_generator.py new file mode 100644 index 000000000..e090b81b3 --- /dev/null +++ b/python/tests/allowlist_generator.py @@ -0,0 +1,231 @@ +"""Pinned-seed negative cases for the fee-payer co-sign instruction allowlist. + +Opt-in, kept out of default CI: + just allowlist-gen +""" + +from __future__ import annotations + +import base64 +import os +import random +from collections.abc import Callable +from typing import NamedTuple + +import pytest +from solders.hash import Hash +from solders.instruction import AccountMeta, Instruction +from solders.keypair import Keypair +from solders.message import MessageV0 +from solders.pubkey import Pubkey +from solders.system_program import TransferParams, transfer +from solders.transaction import VersionedTransaction + +from solana_pay_kit._paycore.errors import CODE_PAYMENT_INVALID, PaymentError, canonical_code +from solana_pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM, TOKEN_PROGRAM, MethodDetails, resolve_mint +from solana_pay_kit.protocols.mpp.intents.charge import ChargeRequest +from solana_pay_kit.protocols.mpp.server._tx_decode import ( + _COMPUTE_BUDGET_PROGRAM, + _MEMO_V1_PROGRAM, + _SYSTEM_PROGRAM, + MAX_COMPUTE_UNIT_LIMIT, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED, +) +from solana_pay_kit.protocols.mpp.server.charge import _validate_instruction_allowlist + +SEED = int(os.environ.get("PAY_KIT_ALLOWLIST_GEN_SEED", "108")) +CASES = int(os.environ.get("PAY_KIT_ALLOWLIST_GEN_CASES", "240")) + +BLOCKHASH = Hash.from_string("4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs") +MINT = Pubkey.from_string(resolve_mint("USDC", "devnet")) +TOKEN = Pubkey.from_string(TOKEN_PROGRAM) +ATA_PROGRAM = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) +SOL = (False, False) +SPONSORED_SOL = (False, True) +USDC = (True, False) +SPONSORED_USDC = (True, True) +BASES = (SOL, SPONSORED_SOL, USDC, SPONSORED_USDC) + +Case = tuple[str, ChargeRequest, MethodDetails, str | None] + + +class _Base(NamedTuple): + fee_payer: Keypair + client: Keypair + recipient: Pubkey + amount: int + usdc: bool + sponsored: bool + + +_Built = tuple[_Base, list[Instruction]] + + +def _key(rng: random.Random) -> Keypair: + return Keypair.from_seed(rng.randbytes(32)) + + +def _base(rng: random.Random, usdc: bool, sponsored: bool) -> _Base: + return _Base(_key(rng), _key(rng), _key(rng).pubkey(), rng.randint(2, 10**12), usdc, sponsored) + + +def _ata(owner: Pubkey) -> Pubkey: + return Pubkey.find_program_address([bytes(owner), bytes(TOKEN), bytes(MINT)], ATA_PROGRAM)[0] + + +def _sol_transfer(source: Pubkey, destination: Pubkey, lamports: int) -> Instruction: + return transfer(TransferParams(from_pubkey=source, to_pubkey=destination, lamports=lamports)) + + +def _token_transfer(source: Pubkey, destination: Pubkey, authority: Pubkey, amount: int) -> Instruction: + return Instruction( + TOKEN, + bytes([12]) + amount.to_bytes(8, "little") + bytes([6]), + [ + AccountMeta(source, False, True), + AccountMeta(MINT, False, False), + AccountMeta(destination, False, True), + AccountMeta(authority, True, False), + ], + ) + + +def _payment(base: _Base) -> Instruction: + if not base.usdc: + return _sol_transfer(base.client.pubkey(), base.recipient, base.amount) + client = base.client.pubkey() + return _token_transfer(_ata(client), _ata(base.recipient), client, base.amount) + + +def _around(rng: random.Random, base: _Base, extra: Instruction) -> _Built: + return base, rng.choice(([extra, _payment(base)], [_payment(base), extra])) + + +def _compute_budget(data: bytes, accounts: list[AccountMeta]) -> Instruction: + return Instruction(Pubkey.from_string(_COMPUTE_BUDGET_PROGRAM), data, accounts) + + +def _compile(base: _Base, instructions: list[Instruction]) -> Case: + message = MessageV0.try_compile(base.fee_payer.pubkey(), instructions, [], BLOCKHASH) + signer_keys = message.account_keys[: message.header.num_required_signatures] + signers = [kp for kp in (base.fee_payer, base.client) if kp.pubkey() in signer_keys] + tx_b64 = base64.b64encode(bytes(VersionedTransaction(message, signers))).decode("ascii") + fee_payer = str(base.fee_payer.pubkey()) if base.sponsored else None + request = ChargeRequest( + amount=str(base.amount), + currency="USDC" if base.usdc else "SOL", + recipient=str(base.recipient), + ) + details = MethodDetails( + network="devnet", + decimals=6 if base.usdc else None, + token_program=TOKEN_PROGRAM if base.usdc else None, + fee_payer=base.sponsored, + fee_payer_key=fee_payer or "", + ) + return tx_b64, request, details, fee_payer + + +def _drain(rng: random.Random) -> _Built: + base = _base(rng, *rng.choice((SPONSORED_SOL, SPONSORED_USDC))) + amount = rng.choice((base.amount, base.amount - 1, base.amount + 1, 0, 2**64 - 1)) + payer = base.fee_payer.pubkey() + if not base.usdc: + return base, [_sol_transfer(payer, base.recipient, amount)] + client = base.client.pubkey() + source, authority = rng.choice(((_ata(payer), payer), (_ata(client), payer), (_ata(payer), client))) + return base, [_token_transfer(source, _ata(base.recipient), authority, amount)] + + +def _extra_system_transfer(rng: random.Random) -> _Built: + base = _base(rng, *rng.choice((SOL, SPONSORED_SOL))) + client = base.client.pubkey() + extras = [_sol_transfer(client, base.recipient, base.amount + delta) for delta in (-1, 0, 1)] + extras.append(_sol_transfer(client, _key(rng).pubkey(), base.amount)) + return _around(rng, base, rng.choice(extras)) + + +def _unknown_program(rng: random.Random) -> _Built: + base = _base(rng, *rng.choice(BASES)) + return _around(rng, base, Instruction(_key(rng).pubkey(), rng.randbytes(rng.randint(0, 64)), [])) + + +def _memo_v1(rng: random.Random) -> _Built: + base = _base(rng, *rng.choice(BASES)) + data = rng.randbytes(rng.choice((0, rng.randint(1, 64)))) + return _around(rng, base, Instruction(Pubkey.from_string(_MEMO_V1_PROGRAM), data, [])) + + +def _attacker_ata(rng: random.Random) -> _Built: + base = _base(rng, *USDC) + owner = rng.choice((_key(rng).pubkey(), base.recipient)) + create = Instruction( + ATA_PROGRAM, + b"\x01", + [ + AccountMeta(base.fee_payer.pubkey(), True, True), + AccountMeta(_ata(owner), False, True), + AccountMeta(owner, False, False), + AccountMeta(MINT, False, False), + AccountMeta(Pubkey.from_string(_SYSTEM_PROGRAM), False, False), + AccountMeta(TOKEN, False, False), + ], + ) + return _around(rng, base, create) + + +def _oversized_compute_budget(rng: random.Random) -> _Built: + def limit(units: int) -> bytes: + return bytes([2]) + units.to_bytes(4, "little") + + def price(microlamports: int) -> bytes: + return bytes([3]) + microlamports.to_bytes(8, "little") + + valid_limit = limit(rng.randint(0, MAX_COMPUTE_UNIT_LIMIT)) + shapes: list[tuple[tuple[tuple[bool, bool], ...], bytes, list[AccountMeta]]] = [ + (BASES, limit(rng.choice((MAX_COMPUTE_UNIT_LIMIT + 1, 2**32 - 1))), []), + ((SOL, USDC), price(MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + 1), []), + ((SPONSORED_SOL, SPONSORED_USDC), price(MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + 1), []), + (BASES, bytes([0]) + valid_limit[1:], []), + (BASES, valid_limit, [AccountMeta(_key(rng).pubkey(), False, False)]), + ] + bases, data, accounts = rng.choice(shapes) + return _around(rng, _base(rng, *rng.choice(bases)), _compute_budget(data, accounts)) + + +FAMILIES: tuple[Callable[[random.Random], _Built], ...] = ( + _drain, + _extra_system_transfer, + _unknown_program, + _memo_v1, + _attacker_ata, + _oversized_compute_budget, +) + + +def _case(seed: int, i: int) -> Case: + rng = random.Random(seed * 1_000_003 + i) + return _compile(*FAMILIES[i % len(FAMILIES)](rng)) + + +@pytest.mark.parametrize("i", range(CASES)) +def test_generated_case_is_refused(i: int) -> None: + tx_b64, request, details, fee_payer = _case(SEED, i) + with pytest.raises(PaymentError) as exc: + _validate_instruction_allowlist(tx_b64, request, details, fee_payer) + assert canonical_code(exc.value.code) == CODE_PAYMENT_INVALID + + +def test_cases_cover_every_family() -> None: + assert len(FAMILIES) <= CASES + + +def test_same_seed_gives_identical_cases() -> None: + assert [_case(SEED, i)[0] for i in range(CASES)] == [_case(SEED, i)[0] for i in range(CASES)] + + +@pytest.mark.parametrize(("usdc", "sponsored"), BASES) +def test_unmutated_base_passes(usdc: bool, sponsored: bool) -> None: + base = _base(random.Random(SEED), usdc, sponsored) + _validate_instruction_allowlist(*_compile(base, [_payment(base)]))