Skip to content

Pre-release security hardening and CI/harness push (9 SDKs)#216

Open
EfeDurmaz16 wants to merge 113 commits into
mainfrom
audit/mega-fix-h1-l5
Open

Pre-release security hardening and CI/harness push (9 SDKs)#216
EfeDurmaz16 wants to merge 113 commits into
mainfrom
audit/mega-fix-h1-l5

Conversation

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator

What this changes

Pre-release security hardening and a CI/harness push across all nine SDKs (typescript, rust, go, python, ruby, php, lua, kotlin, swift).

Security fixes, each ported to every SDK that implements the behavior:

  • Subscription activation now enforces a strict instruction allowlist and pins the sponsored fee payer to account index 0, so a subscriber can no longer append fund-draining instructions to a co-signed activation transaction.
  • x402 exact settlement reserves the payment signature before serving and releases it only on a proven not-landed outcome, closing cross-process double-serve windows on the pull path and under confirmation-timeout.
  • The x402 replay guard is now injectable with a shared put-if-absent store, so multi-replica deployments dedupe across processes instead of per-process.
  • Session top-up binds the raised deposit to the on-chain Channel account in the core method (go and python now match rust), and fails closed off localnet.
  • Batch settlement gates the paid serve on the in-lock committed watermark delta, closing an under-pay race.
  • The WWW-Authenticate challenge serializer escapes quotes and rejects CR/LF, and the x402 exact fee-payer fund-mover rule is unified across the six verifier ports with shared cross-SDK reject vectors.
  • Plus the lower-severity parity and robustness items: channel-status checks, transaction-version rejection, header size caps, voucher reject-tag unification, and idle per-channel lock eviction.

CI and harness hardening:

  • Release-gate legs fail on zero selected tests, the on-chain settlement gate hard-fails when its flag is unset, and python CI installs are frozen, so a rename or desync can no longer ship green with tests silently skipped.
  • Per-file coverage floors (go), frozen installs and least-privilege publish permissions, and a new repo-hygiene guard that blocks audit IDs left in shipped source and dependency pins stranded in package.json's pnpm field (ignored by pnpm v10).

Reproducibility:

  • rust/Cargo.lock is now tracked and pinned, toolchains are pinned, and the dependency security overrides are enforced from pnpm-workspace.yaml where pnpm v10 actually reads them.

Why

These SDKs are going live. The audit surfaced one critical and several high-severity issues, nearly all of them the same shape: a guard hardened in one SDK but never ported to its siblings, with no shared conformance vector to catch the drift. This lands the fixes with cross-SDK parity and closes the CI gaps that let that class of divergence ship unnoticed.

Tests

The full local CI matrix was run and is green:

  • TypeScript: lint, typecheck, 1053 tests pass.
  • Rust: cargo fmt, clippy with -D warnings, tests pass.
  • Go: tests pass, per-file and 91.0% aggregate coverage gate passes.
  • Python: 1331 tests pass, pyright reports 0 errors.
  • Ruby, PHP, Swift: green (Swift line coverage 95.4%).
  • Kotlin and Lua verified structurally; their runners (gradle 9.5.1, luarocks) are CI-only.

Every fix ships with a regression test that fails before the fix and passes after it. The x402 exact verifier gains shared cross-SDK reject vectors that every port must satisfy.

What to review especially

  • The subscription activation allowlist and the fee-payer index-0 pin (rust and typescript).
  • The consume-signature ordering across the x402 exact servers (rust, go, ruby, typescript) and the shared-store injection.
  • The in-core top-up deposit bind (go and python) against the rust reference.
  • The batch-settlement in-lock watermark gate.
  • The CI gate changes under .github/workflows and scripts/.

Note on size: this is the full audit branch, so it is large (99 commits, ~228 files). rust/Cargo.lock accounts for roughly 13k of the added lines. It is best reviewed by area using the list above.

Fable 5 reviewer note

This branch was put through a multi-model adversarial review before it opened: Claude (Fable 5) across four domain slices, plus independent passes from codex (GPT) and opencode (GLM). That review found one blocker and several high and medium issues, all since fixed and re-reviewed as closed. The blocker was that the strict subscription allowlist rejected the TypeScript client's own first-time-subscriber transaction; the client now pre-broadcasts the subscription-authority init as a separate transaction, matching the Rust client. The highs are also closed: the challenge-serialization guard now runs on the real 402 emission path, the false-green CI fix covers every release-gating leg, the repo-hygiene guard checks every package.json and catches both dashed and dashless audit IDs, and the new cross-SDK reject vectors are wired into CI. The batch-settlement per-channel gate is bounded and now has a regression test that actually fails if the gate is removed.

The full local CI matrix was run and is green: TypeScript, Rust (clippy with -D warnings), Go (91 percent coverage gate), Python (pyright clean), Ruby, PHP, and Swift. Kotlin and Lua were verified structurally since their runners are CI-only locally. Two items are worth a reviewer's attention: the harness assert-run-count values were not re-derived by hand and will fail loudly rather than pass silently if wrong, and Cargo.lock is committed and pinned.

verifyTransaction (type=transaction) reserved the consumed-signature marker
after broadcast but never checked it and took no per-signature lock, so the
same signed transaction could be verified concurrently or resubmitted, settling
one on-chain payment as many receipts. This is the pull-path sibling of the
push-mode TOCTOU fixed in #211.

Derive the fee-payer signature from the signed wire transaction before
broadcast (it equals the value sendTransaction returns) and run the
consumed-check, broadcast, reserve, and on-chain verify inside withKeyLock,
sharing the same solana-charge:consumed namespace as the push path. Release the
reservation only when the transaction definitively never landed, so an honest
retry of a still-valid transaction is not bricked.

Adds sequential and concurrent pull-mode replay regression tests.
verifySolTransfer compared String(info.lamports) === expectedAmount, where the
jsonParsed lamports is a JS number. Above 2^53 that number is lossy, so a
correct large SOL transfer could mismatch (the SPL path is safe because the RPC
returns amounts as strings). Parse lamports to an exact bigint with a
safe-integer guard and fail closed on a numeric value beyond the safe range.

Also format the interactive payment-page amount from bigint instead of
Number(amount) / 10 ** dec, which loses precision on large amounts (display
only; settlement already uses the exact string).

Adds a regression test that a system transfer with lamports at 2^53 is rejected.
process_open (push mode) trusted the client-supplied channelId, deposit, payer,
payee, and authorizedSigner, and only confirmed that *some* transaction
signature had succeeded. A client could therefore claim a fabricated deposit up
to max_cap against any confirmed signature and be served metered vouchers up to
that phantom balance, with settlement later failing.

When an rpc_url is configured, validate the payload against the challenge and
the derived channel PDA (reusing payment_channel_open_params), confirm the open
transaction succeeded, then fetch the on-chain Channel account and bind status,
mint, payee, authorized_signer, and payer to it, persisting the on-chain deposit
rather than the client's claim. Mirrors the x402 batch-settlement fetch-and-bind
path. Off localnet with no rpc_url the open now fails closed instead of trusting
the client.

Adds a fail-closed regression test.
replayStore defaulted to undefined, so the mpp adapter passed no store and each
cached charge handler built its own in-memory store. Replay markers were
therefore process-local: a second replica or a restart would accept a replayed
payment, and even within one process distinct handlers did not share the
consumed set.

Resolve the store once into a single shared instance (mirroring
resolveChallengeBindingSecret) and fail closed outside localnet when no store is
provided, unless PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 opts into single-process
scope. The error points operators at a shared, atomic-reserve backend.

Adds a fail-closed regression test and updates off-localnet config tests.
…model

Commit rust/Cargo.lock so CI builds, the coverage gate, and hashFiles cache
keys are reproducible; without it every build re-resolved floating transitive
deps and a fresh transitive release could break any branch. The workspace ships
binaries, so a committed lockfile is appropriate.

Add a Deployment threat model section to SECURITY.md covering the shared,
atomic replay-store requirement and the on-chain session-open binding, which the
keyLock and charge-verify comments reference.
The native-SOL on-chain verify compared info.lamports (a Lua number = double on
the jsonParsed RPC path under LuaJIT/Lua 5.1) via uint.compare. A u64 value at
or above 2^53 is lossy and serializes as scientific notation, raising
"invalid unsigned integer" on legitimate large payments and risking a
truncated-amount match. Add uint.exact to coerce only exact integers (rejecting
NaN/inf/negative/non-integral/out-of-range doubles) and guard both the SOL and
SPL amount comparisons; a rejected value is a non-match, falling through to the
canonical rejection. Adds regression tests that pass lamports as a number.
The MPP adapter fell back to an in-memory replay store with only a warning, so
off-localnet deployments got process-local replay markers that a restart or a
second replica would not see. Mirror the TS resolveReplayStore guard: fail
closed off localnet unless a store is injected or
PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 opts into single-process scope.
Go, Ruby, and Swift were already safe (Go/Ruby stores expose an atomic
put_if_absent checked on the broadcast path; Swift is client-only with lamports
only ever an exact UInt64). Add regression tests so the pull-mode replay guard
and the exact-integer lamports handling cannot silently regress into the
TypeScript failure mode.
…rt, npm env gate)

- Pin the rolling/branch-tracked actions to commit SHAs: rust-toolchain@stable,
  gh-action-pypi-publish@release/v1, install-action@cargo-llvm-cov. These refs
  literally move, so an upstream compromise would run in CI and the publish jobs.
- Add a Dependabot github-actions config to keep the pinned SHAs fresh.
- report.yml: drop contents:write to contents:read. It runs in the privileged
  workflow_run context over fork-PR artifacts and only downloads and comments;
  it never pushes.
- npm-publish: add a protected 'npm' environment gate to both publish jobs
  (mirroring pypi) and document npm OIDC trusted publishing.

Note: the version-tagged actions (actions/*@vn, etc.) remain on tags and are
Dependabot-tracked; a full SHA sweep is a mechanical follow-up. The surfpool
feat/sdk action ref in report.yml could not be resolved (branch renamed) and
still needs a SHA pin.
Mirror the Rust process_open fix: a push-mode session open must not trust the
client-supplied deposit, channelId, payer, or authorizedSigner. When an RPC is
configured, confirm the open signature, read the authoritative on-chain Channel
account, bind mint/payee/authorized_signer, and persist the on-chain deposit
rather than the client's claim. Off localnet without an RPC the open fails
closed. Adds a fetch_and_bind_channel_account helper and regression tests.
Complete the supply-chain hardening: every third-party action across all
workflows and composite actions is now pinned to a full commit SHA with a
version comment, so a retagged or force-moved upstream ref cannot inject code
into CI or the publish jobs. Dependabot (added earlier) keeps the SHAs fresh.

Local composite actions (./.github/actions/*) are unaffected. The surfpool
feat/sdk external action ref in report.yml still needs a SHA once the upstream
branch/ref is confirmed.
Both publish jobs now check npm for the target version before publishing and
fail if it already exists, so a re-dispatch or a forgotten version bump cannot
silently no-op or error mid-publish. Combined with the earlier protected
environment gate and OIDC provenance, this closes the L3 release-workflow gaps.
…sible

M4: add an off-chain golden test asserting the account order, signer/writable
flags, and discriminator for settle, settle_and_finalize, request_close, and
finalize. A codama regen or hand edit that reorders accounts or flips a flag
(the #202 class) now fails without a live program. settle_and_finalize stays
merchant-first by design (merchant must equal the channel payee).

M2: the on-chain settlement test skipped silently when the surfnet was
unreachable, so a broken or absent on-chain gate passed green. It still skips
locally, but fails hard when PAYKIT_ONCHAIN_REQUIRED=1 (which the CI on-chain
job sets), so the gate cannot pass without actually running.
new_session silently defaulted to an in-memory channel store, which is
process-local: a second replica or a restart drops the voucher watermark and
would accept a replayed voucher. Off localnet, fail closed unless a store is
provided or PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 opts into single-process
scope, mirroring the charge/pay-kit replay-store guard. Adds a regression test.
…adapter (H3)

The x402 adapter fell back to an in-memory replay store with only a warning, so
off-localnet deployments got process-local settlement-replay markers (a restart
or a second replica accepts a replayed settlement). Mirror the MPP adapter guard
(defaultReplayStore): fail closed off localnet unless a store is injected or
PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 opts into single-process scope.

Also fixes a regression the earlier MPP replay-store guard introduced but could
not catch without phpunit: Middleware/RequirePaymentTest builds a devnet client
whose adapters are auto-constructed with no store, so it now opts into
single-process scope. x402 adapter tests inject a store; adds x402 H3 tests.
Verified: full php suite 435 tests OK.
verifyChargeTransaction (pre-broadcast, pull) and verifyInstructions (on-chain,
push) are two copies of the same charge policy over different transaction
representations, so they can drift. Add a parity test asserting the same
tampered payment (underpayment, wrong recipient) is rejected by both paths.

A deeper structural dedup of the two verifiers is deferred: they operate on
different data shapes (compiled message vs jsonParsed RPC) and merging them is a
large, risky rewrite of the security-critical verifier better done outside a
pre-release change. The parity test is the anti-drift guard in the meantime.
…loor (M5)

The Rust coverage floor excluded both src/mpp/program/ and src/x402/, so the
instruction builders (the #202-relevant code) and the entire x402 implementation
were invisible to the gate. Now:
- src/mpp/program/ is inside the 90% mpp floor (verified 90.57%), its account
  layout additionally pinned by settle_family_account_layout_golden.
- src/x402/ gets its own ratchet floor at 75% (verified 77.50%) instead of being
  excluded, so x402 regressions are caught. Raising it to 90% needs new tests for
  server/batch_settlement (~37%) and server/upto (~63%), tracked as follow-up.

One instrumented run enforces both floors from per-file data.
Swift shipped tests with code coverage but enforced no floor, so it could
silently regress to near-zero. Add scripts/check_coverage.sh, which parses the
llvm-cov JSON SwiftPM exports (located via swift test --show-codecov-path, robust
to the arch-specific build layout) and gates Sources/ line coverage. Wire it
into swift.yml at an 80% ratchet (current Sources coverage is ~89%). Verified
locally: 89.40% >= 80%.
…sions (M5)

phpunit.xml excluded the security-critical x402 Verifier and Adapter from the
91% coverage gate, so their verification and settlement logic was untested by the
floor. Remove both exclusions and add branch-focused tests:
- VerifierBranchTest (31 cases): mutate one field per test to hit every rejection
  branch (compute-budget guards, transfer program/data/account, fee-payer
  authority, mint/recipient/amount, optional-slot allowlist, memo, missing fields)
  plus Token + Token-2022 happy paths.
- AdapterSettleTest (14 cases): verifyAndSettle end-to-end via FakeRpcGateway
  (v2 + v1 happy paths, payment-id extension gate, replay guard, broadcast
  failure, confirmation timeout, malformed payloads).

Verifier 69->97%, Adapter 67->95%, overall 92.83% (floor 91). Full suite 482 OK.
Cover the genuinely under-tested modules so x402 can be gated at 90 rather than
excluded. Aggregate line coverage: x402 77.5 -> 94.3%, mpp 90.6 -> 93.2%.

- x402 settlement servers (batch_settlement 37->94%, upto 63->93%, exact 89->92%)
  are now testable via a test-only in-process mock Solana JSON-RPC server
  (mock_rpc.rs, served over a std TcpListener, no new dependency, no production
  change): the concrete RpcClient reads config.rpc_url, so pointing it at the
  mock exercises the broadcast/confirm/channel-bind paths and every bind-mismatch
  branch.
- x402 client builders + protocol schemes + error (44/55/81/85/80 -> 94-99%) via
  pure error/edge-path unit tests.
- mpp subscription 63->91%, authenticate 84->98% (subscription has one
  behavior-preserving extraction of the terms-validation block so its mismatch
  branches are unit-testable).

Full lib suite 1095 passing, fmt clean.
…to >=90

mpp/error.rs, mpp/client/subscription.rs, and mpp/protocol/intents/subscription.rs
were 78-88% line; add error/edge-path unit tests to bring each over 90 line and
region. Full lib suite 1119 passing.
…+region

Cover the last core modules via the test-only mock JSON-RPC server:
- mpp/server/session.rs 88->98% line: the H1 on-chain open-bind path and every
  bind-mismatch branch (mint/payee/signer/payer/status/deposit), asserting the
  persisted deposit equals the on-chain value not the client's claim.
- mpp/server/charge.rs 88->94% line: pull-mode broadcast/confirm and push-mode
  on-chain verify (transfer match, allowlist, memo, ATA owner, mint resolution).
- core/settlement/worker.rs 80->95% line: RpcBroadcaster + actor drain/regroup
  and the confirm-loop Failed/transient/pending arms via scripted broadcasters.
- mock_rpc.rs (test-only) gains getTransaction + set_transaction and a corrected
  failed-status fixture.

mpp 96.8% line / 96.7% region, x402 94.4% / 93.8%. Full lib suite 1210 passing.
…covery)

blockhash.rs had no tests. Add the empty-get, set+fresh-get, stale-entry
(seeded with an old Instant so the MAX_AGE false arm runs without a 45s wait),
and poisoned-lock recovery cases, bringing it >=90 line and region.
Cover the SIWX Default impl, chain selection (supported + unsupported), header
create/encode, and every verify error branch (nonce mismatch, issued-at too old,
not-before/expiration bounds, malformed/pre-epoch RFC3339). 91.4->97.8% line,
89.3->95.7% region.
… (M5)

Replace the mpp>=90 / x402>=75-ratchet split with a single strong gate: both mpp
and x402 must hold >=90% on LINE and REGION (llvm-cov's branch-like metric), and
the floor is enforced PER-FILE as well as in aggregate, so a weak file cannot
hide behind inflated easy ones and no branch class can silently regress. x402 is
now fully inside the floor (was excluded). The test-only mock JSON-RPC server is
excluded. Verified against a real run: mpp 96.8/96.7, x402 94.9/94.2, every file
>=90 on both metrics, 1224 lib tests passing.
Add coverage tests for the least-covered Sources files (X402 Exact Types
61->99%, RpcClient 52->96%, Transaction 75->90%, SolanaPayKit 84->98%, plus
SolanaSigner/Ed25519/x402 client guards) via dedicated URLProtocol stubs, each
suite isolated so Swift Testing's parallel suites don't race on shared stub
state. Sources aggregate 88->95%; raise the CI floor from 80 to 90. 231 tests
passing.
The TypeScript reference SDK collected coverage but enforced no floor, and sat at
79% line / 69% branch. Exclude the Codama-generated client and barrel files from
coverage (matching the Rust src/generated exclusion), then add real tests across
the under-covered surface:
- pay-kit adapters (mpp/x402/x402-upto/mpp-session 44-59 -> ~100%), paykit.ts
  (70 -> 98%), errors/express-routes/protocol.
- mpp server Charge.ts (81 -> 98% line / 91% branch), Session.ts (87 -> 94/91),
  session/on-chain.ts (91/71 -> 99/93), session/lifecycle.ts (0 -> 100),
  Subscription.ts (93/75 -> 98/91), challenge-guard.
- mpp client PaymentChannels/SessionFetch/MultiDelegate/Charge/Subscription and
  shared/voucher branch coverage to >=90.

Aggregate: line 97.8, branch 91.9, functions 97.6, statements 96.4. Add a vitest
thresholds block (90 on all four metrics) so the reference SDK is now gated like
the others. 955 tests passing.
…payer to index 0 (C1)

The subscription activation path co-signs the client-supplied transaction
with the server's sponsored fee-payer key. Two gaps let an attacker abuse
that signature:

1. validate_activation_scope scanned for the subscribe/transfer pair and
   silently skipped every other instruction, so a client could append an
   arbitrary instruction (e.g. a System or SPL transfer draining the
   fee payer) and still pass scope validation.
2. co_sign_as_fee_payer located the fee-payer key with position() and
   signed wherever it appeared, so the key could be placed at a non-zero
   index as the authority/source of an attacker-inserted instruction and
   still collect the server signature.

Enforce a strict program allowlist (subscriptions program subscribe/
transfer only, plus compute-budget, idempotent-ATA, and memo aux
programs) and reject anything else instead of skipping. Require the fee
payer to be account index 0 (Solana's fee-payer slot) before signing.

Add regression tests: reject an instruction on an unknown program, an
out-of-range program index, a disallowed subscriptions-program
discriminator, and a fee-payer key at a non-zero index; accept the full
legitimate activation shape.
…enticate verifiers (M3)

The subscription and SIWX-authenticate verifiers compared the client-
supplied challenge id against the recomputed HMAC with String equality,
which short-circuits on the first differing byte and leaks a timing
side channel usable to recover a valid id byte-by-byte. Use the existing
constant_time_eq helper, matching the charge verifier.
…ture mode (H3)

Signature (push) mode presented an already-confirmed on-chain transaction
and accepted it after only checking the transfer amount/mint/recipient.
That let a single confirmed transfer be replayed to satisfy unlimited
requests, any same-priced route, and arbitrarily old transactions, since
there was no consumed-signature store and no freshness check.

Add three guards to the Signature arm of verify_envelope_payload:
- require a route-bound memo (extra.memo) so a push payment is tied to a
  specific challenge, not just an amount+recipient (verify_transaction_details
  then enforces the tx actually carries it);
- enforce freshness against max_age using the tx block_time;
- atomically consume the settlement signature via a pluggable Store
  (default in-process MemoryStore; with_store injects a shared one for
  multi-replica deployments), mirroring the MPP charge consume_signature.

Add Error::StaleTransaction and Error::MissingSignatureBinding. Regression
tests via the mock JSON-RPC cover: missing-memo rejection, fresh+bound
success then replay rejection, stale-transaction rejection, and shared-store
single-use across two instances.
…blockhash, and release reservations on pre-broadcast failure
Strip four stray dashless audit IDs (H1/C1/H3) from Rust inline test-module
comments that the widened repo-hygiene guard now flags, and build the x402
library test step with the server and client features so its feature-gated
test imports compile.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps A few focused areas for this review, please:

  1. Confirm the x402 exact replay guard reserves the settlement signature before serving and releases it only on a proven not-landed outcome, consistently across rust (exact.rs), go (exact.go), ruby (exact.rb), and typescript (x402.ts).
  2. Verify the subscription activation allowlist and the index-0 fee-payer pin match between the Rust (subscription.rs) and TypeScript (Subscription.ts, transactions.ts) servers, and that the TS client pre-broadcasting the subscription-authority init as a separate transaction is correct.
  3. Check that the six x402 exact verifiers enforce identical fund-safety rules per harness/vectors/x402-exact-reject.json, and that the go/python in-core top-up deposit bind matches the Rust reference behavior.
  4. Confirm the new CI gates (harness assert-run-count, passWithNoTests, and scripts/check-repo-hygiene.sh) actually fail when their protected condition is violated, and that the changes follow the repo existing code conventions.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown

Greptile Summary

This PR lands a large pre-release security hardening pass across all nine SDKs, fixing issues surfaced by an external audit. The core patterns are: atomic subscription-activation replay guards (upfront put_if_absent before broadcast in Rust, withKeyLock + claimConsumed in TypeScript), a strict instruction allowlist + index-0 fee-payer pin for activation transactions, per-channel serialization gates on batch-settlement watermark commits, injectable shared replay stores for x402 exact (Rust, Go), and a WWW-Authenticate serializer that escapes quotes and rejects CR/LF.

  • Subscription activation hardening (Rust + TypeScript): both transaction-mode and signature-mode replay guards are now atomic upfront with proper release-on-error, closing the double-receipt window identified in prior reviews. The instruction allowlist rejects any program outside the subscription + compute-budget + memo + ATA allowlist so a co-signed fee-payer cannot be made to sign fund-draining extra instructions.
  • x402 exact settlement (Rust, Go, TypeScript, Ruby): replay store injection unifies push and pull path consumed-signature tracking; the Go adapter's "reserve before broadcast → pin on RPC accept → not-landed release on timeout" mirrors the Rust "broadcast → reserve → confirmed-or-not-landed" pattern, both fail-closed.
  • CI hardening: SHA-pinned GitHub Actions, passWithNoTests: false, on-chain gate that throws under CI without HARNESS_ONCHAIN=1, and a zero-test floor on every release-gate leg close the false-green CI paths.

Confidence Score: 5/5

This PR is safe to merge. All previously-identified double-receipt and replay-guard gaps in the subscription activation and x402 exact settlement paths are now closed with atomic upfront reservations, release-on-error across every failure path, and shared injectable stores. The batch-settlement watermark race and the co-signed fee-payer fund-drain vector are also addressed with proper per-channel serialization gates and a strict instruction allowlist.

Every critical path reviewed — TypeScript subscription activation (both transaction and signature modes), Rust subscription, Rust/Go x402 exact pull and push, batch settlement watermark gate — has correct atomic guard semantics with documented failure-mode behavior. CI gates close the false-green paths identified in the audit. No new correctness or security defects were found across the reviewed paths.

No files require special attention. The key security paths (Subscription.ts, exact.rs, exact.go, batch_settlement.rs) all have regression tests that fail without the fix, and the CI harness now validates cross-SDK parity.

Important Files Changed

Filename Overview
typescript/packages/mpp/src/server/Subscription.ts Both transaction-mode and signature-mode replay guards are now atomic (withKeyLock + claimConsumed before broadcast); release-on-error on every failure path prevents permanent bricking of transient retries. The instruction allowlist and ATA validation close the co-signed-fee-payer fund-drain vector.
rust/crates/kit/src/mpp/server/subscription.rs Activation replay guard upgraded to atomic put_if_absent before broadcast; release_on_err wraps every fallible step between claim and receipt issuance. validate_activation_scope gains the instruction allowlist and index-0 fee-payer pin.
rust/crates/kit/src/x402/server/exact.rs Pull path now uses broadcast → reserve → confirm with not-landed release on timeout; push (signature) path gains put_if_absent single-use enforcement. Store is injectable for cross-replica deployments. Header size cap added.
go/paykit/adapters/x402/exact.go Switches from sync.Map to injectable mppcore.Store; reserve-before-broadcast with cleanupConsumed=false pinning and settlementNotLanded release. cosign function pinned to index-0 (fee-payer slot) instead of scanning the full account list.
rust/crates/kit/src/x402/server/batch_settlement.rs Per-channel voucher_gates (refcounted, evicting) serialize the watermark read → price-gate → commit span; process_deposit holds the gate across put_channel and first accept to close the under-pay race. Regression tests verify gating behaviour.
typescript/packages/mpp/src/shared/challenge-guard.ts Adds serialize/serializeList wrappers that escape backslash/double-quote in quoted-string fields and reject CR/LF, closing the header-injection and round-trip asymmetry in mppx's raw serializer.
go/protocols/mpp/server/session_onchain.go Introduces fetchAndValidateChannel shared helper eliminating ~45-line duplication; fetchAndBindChannelAccount and fetchTopUpChannelAccount now both delegate to it. NewTopUpTxVerifier gains config parameter, fails closed off localnet, and binds on-chain deposit.
typescript/packages/mpp/src/server/Session.ts Bare push-open without RPC now requires explicit trustedClientOpen opt-in (fails closed otherwise); top-up path wired through verifyTopUpTx + verifyChannelAccountState to bind the on-chain deposit. Channel account state check added for all payment-channel-backed opens.
.github/workflows/release-gate.yml New reusable workflow fans out to every CI surface and exposes a single gate job that publish workflows must depend on, preventing a red sibling from being missed by a narrow per-workflow gate.
.github/workflows/ci.yml All action references upgraded to SHA pins, repo-hygiene and publish-workflow-guard self-tests added to the lint leg, html assets build switched to npm ci --ignore-scripts, and a payment-channels regen-diff gate added.
typescript/packages/mpp/src/server/replayReserve.ts New module defines ReservingStore interface (atomic put-if-absent) and claimConsumed helper that degrades gracefully to get-then-put when the store doesn't implement reserve — correctly documenting the single-process vs multi-replica scope boundary.
python/src/solana_pay_kit/protocols/mpp/server/session_onchain.py new_top_up_tx_verifier now binds the on-chain deposit (mirrors Rust/Go); fetch_and_bind_channel_account and _fetch_and_validate_channel added to close the bare-push deposit-fabrication vector. Fails closed off localnet without an RPC client.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client
    participant S as Server (x402 exact)
    participant BC as Blockchain
    participant RS as Replay Store

    Note over S: Pull (Transaction) path
    C->>S: VerifyAndSettle(credential)
    S->>RS: PutIfAbsent(consumed_key)
    RS-->>S: "inserted=true"
    S->>BC: send_transaction(tx)
    BC-->>S: broadcast_sig
    Note over S: Pin marker (cleanupConsumed=false)
    S->>BC: awaitConfirmation(sig)
    alt Confirmed
        BC-->>S: confirmed
        S-->>C: PaymentOK
    else Timeout - Not Landed
        S->>RS: Delete(consumed_key)
        S-->>C: Error (client can retry)
    else Timeout - Landed or Indeterminate
        Note over RS: Marker KEPT (fail-closed)
        S-->>C: Error
    end

    Note over S: Subscription Activation (TS)
    C->>S: settleActivation(credential)
    S->>S: withKeyLock(consumedKey)
    S->>RS: claimConsumed(consumedKey)
    RS-->>S: "claimed=true"
    S->>BC: broadcast + waitForConfirmation
    alt Confirmed
        BC-->>S: confirmed
        S-->>C: consumedKey and subscriber
    else Error
        S->>RS: delete(consumedKey)
        S-->>C: Error (retry safe)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client
    participant S as Server (x402 exact)
    participant BC as Blockchain
    participant RS as Replay Store

    Note over S: Pull (Transaction) path
    C->>S: VerifyAndSettle(credential)
    S->>RS: PutIfAbsent(consumed_key)
    RS-->>S: "inserted=true"
    S->>BC: send_transaction(tx)
    BC-->>S: broadcast_sig
    Note over S: Pin marker (cleanupConsumed=false)
    S->>BC: awaitConfirmation(sig)
    alt Confirmed
        BC-->>S: confirmed
        S-->>C: PaymentOK
    else Timeout - Not Landed
        S->>RS: Delete(consumed_key)
        S-->>C: Error (client can retry)
    else Timeout - Landed or Indeterminate
        Note over RS: Marker KEPT (fail-closed)
        S-->>C: Error
    end

    Note over S: Subscription Activation (TS)
    C->>S: settleActivation(credential)
    S->>S: withKeyLock(consumedKey)
    S->>RS: claimConsumed(consumedKey)
    RS-->>S: "claimed=true"
    S->>BC: broadcast + waitForConfirmation
    alt Confirmed
        BC-->>S: confirmed
        S-->>C: consumedKey and subscriber
    else Error
        S->>RS: delete(consumedKey)
        S-->>C: Error (retry safe)
    end
Loading

Reviews (8): Last reviewed commit: "test(mpp): keep subscription co-sign bra..." | Re-trigger Greptile

Comment thread rust/crates/kit/src/x402/server/exact.rs
Comment thread rust/crates/kit/src/mpp/server/subscription.rs
Comment thread rust/crates/kit/src/mpp/server/subscription.rs Outdated
Comment thread rust/crates/kit/src/x402/server/exact.rs Outdated
Comment thread typescript/packages/pay-kit/src/adapters/x402.ts
…m loop

confirm_pull_settlement is async and runs on a Tokio worker; std::thread::sleep
held the worker for up to 6s per confirmation timeout, which under concurrent
settlement can starve the executor. Use tokio::time::sleep().await so the wait
yields.
…fork replace

harness/go-server built against the fork's go.sum; after the main module moved
to upstream gagliardetto/solana-go and a local ATA helper, its go.mod/go.sum
were stale and 'go build' failed. go mod tidy resolves them.
…nd IO-cap plugin autoload

- PHP x402 harness server: inject a process-scoped FileStore into the
  X402 adapter so it no longer fails closed on the non-localnet harness
  network, mirroring the MPP charge path.
- python-x402 / python-x402-upto clients: run through the SDK uv env
  (uv run --project ../python) like python-session so runtime deps such
  as httpx resolve instead of failing under a bare python3.
- IO-cap CI step: disable the anchorpy pytest plugin on the command line
  (-p no:pytest_anchorpy) so it does not autoload and crash importing the
  uninstalled pytest-xprocess; addopts alone did not apply for a target
  outside python/.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Thanks — all three review findings are resolved:

  1. P1 async blocking sleep (x402 exact confirm_pull_settlement): replaced std::thread::sleep with tokio::time::sleep().await so the confirmation poll yields to the executor instead of holding the Tokio worker thread for up to 6s. (commit 7447faa)

  2. P1 TOCTOU on the subscription activation replay guard: replaced the non-atomic store.get-then-store.put with the Store trait atomic put_if_absent, so exactly one of two concurrent identical activations claims the marker and issues a receipt while the other is rejected as consumed. Added release-on-failure (a transient RPC error releases the marker so a legitimate retry is not bricked) and two regression tests that fail on the old get-then-put and pass on the fix. (commit 30c0085)

  3. P2 Go/Python channel-fetch duplication: extracted a shared fetchAndValidateChannel / _fetch_and_validate_channel helper in both SDKs; each caller keeps only its divergent tail (authorizedSigner for bind, deposit for top-up). Behavior, error messages, and validation order are byte-identical. (commit 07c2dc2)

@greptile-apps please run a full general re-review of the whole branch (not limited to these areas).

The go-x402 harness client (go-client) still pinned the removed lgalabru
solana-go fork in its go.mod/go.sum, so 'go run .' failed to resolve deps.
go mod tidy moves it to upstream, matching go/ and harness/go-server.
go mod tidy alone kept the explicit replace => lgalabru/solana-go directive,
so the go-x402 client still tried to fetch the unreliable fork in clean CI.
Remove the replace so it resolves upstream gagliardetto/solana-go v1.22.0,
matching go/ and harness/go-server.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review

Rust used solana-x402-exact:consumed:, Go and TypeScript used
x402-exact:consumed:, while Python, Ruby, and PHP used x402-svm-exact:consumed:.
Two differently-languaged replicas sharing one replay store wrote to different
namespaces and would not dedupe each other's replays. Standardize every SDK on
x402-svm-exact:consumed: so a shared, cross-process store protects across all
language servers.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Resolved the cross-SDK replay-namespace divergence: unified the x402 exact consumed-signature key prefix to x402-svm-exact:consumed: across every SDK (Rust used solana-x402-exact:, Go and TypeScript used x402-exact:; Python/Ruby/PHP already used x402-svm-exact:). A shared cross-process store now dedupes replays across differently-languaged replicas. Verified: Rust 54, Go, and the TypeScript x402-adapter (31) tests pass. (commit 201e8fd)

@greptile-apps please re-review.

The RELEASE_SAFE_SETTLE_REASONS set hard-codes @x402/svm's errorReason strings;
note the vendored 2.16.0 version so an upgrade prompts re-deriving the set
rather than silently reclassifying a payload between release and keep.
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps review — all inline findings are resolved: the async sleep (tokio::time::sleep, 7447faa), the subscription replay TOCTOU (atomic put_if_absent + release-on-failure + regression tests, 30c0085), the cross-SDK consumed-key prefix (unified to x402-svm-exact:consumed:, 201e8fd), and the @x402/svm reason-set version pin (ef6e977). Please run a fresh full review of the latest commit.

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

Resolved the remaining finding — the TypeScript subscription activation TOCTOU. settleActivation now claims the consumed key atomically via claimConsumed under withKeyLock in BOTH transaction and signature modes (before broadcast), releases the reservation on every failure path so a transient error does not brick a retry, and keeps it only when a receipt is produced. This mirrors the Rust fix (30c0085) and the charge path's usage. Added two regression tests (concurrent-activation admits exactly one; release-on-failure allows retry), both verified to fail on the old get/put and pass on the fix. Full @solana/mpp suite: 1057 tests pass. (commit ca8396e)

@greptile-apps please run a fresh full review of the latest commit.

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@lgalabru PR #216 (the pre-release security hardening and CI/harness push across all nine SDKs) is ready for your review and merge: CI is green, Greptile is at 5/5 with every finding resolved, and all 100+ commits are signed and Verified.

@solana-foundation solana-foundation deleted a comment from greptile-apps Bot Jul 4, 2026
@EfeDurmaz16

Copy link
Copy Markdown
Collaborator Author

@greptile-apps please run a fresh full review of the latest commit. better if you can do TREX review!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant