Skip to content

feat(rust): unified dual-protocol axum payment gate (paid_get)#171

Merged
lgalabru merged 5 commits into
mainfrom
feat/rust-paykit-axum-gate
Jun 16, 2026
Merged

feat(rust): unified dual-protocol axum payment gate (paid_get)#171
lgalabru merged 5 commits into
mainfrom
feat/rust-paykit-axum-gate

Conversation

@lgalabru

Copy link
Copy Markdown
Collaborator

What

Brings the Rust SDK to ergonomic parity with the other pay-kit SDKs: a one-liner axum gate that accepts both MPP charge and x402 on a single route, in the abstracted paid_get / Payment style (not intent-specific).

let pay = PayKit::new(PayKitConfig {
    recipient: "CXhr…MPY".to_string(),
    ..Default::default()
})?;

let app = Router::new().route("/report", paid_get(report, "0.10", &pay));

async fn report(payment: Payment) -> String { payment.reference }

How it works

  • PayKit::new(PayKitConfig) (in solana-pay-kit) derives both an Mpp charge handler and an X402 handler from one shared config. The optional fee_payer_signer drives MPP fee-sponsorship and supplies x402's fee-payer address from the same key.
  • paid_get / paid_post gate a handler. An unpaid request gets a 402 carrying both challengesWWW-Authenticate (MPP) and PAYMENT-REQUIRED (x402). The headers are disjoint, so the paid retry is detected unambiguously (Authorization: Payment → MPP, PAYMENT-SIGNATURE/X-PAYMENT → x402) and verified by the matching protocol.
  • The resolved price is pinned during verification, so a credential minted for a cheaper route on the same server is rejected.
  • Payment handler extractor exposes amount / protocol / reference; the response carries the protocol's settlement header.
  • Price::dynamic prices a route per request.

Supporting change in solana-mpp: a reusable Mpp::verify_payment_for_amount(credential, amount) (the amount-pinned verify the gate builds on). The existing MppCharge<C> typed extractor is unchanged.

Tests / docs

cargo test -p solana-pay-kit --features axum — 5 gate tests + a doctest, including "unpaid → 402 with both protocol challenges" and "cross-route replay → 402". Adds a runnable examples/axum_quickstart.rs and a crate README.

Notes

Separate from the cross-language audit PR (#169). Independent of it — based directly on main.

🤖 Generated with Claude Code

lgalabru and others added 4 commits June 15, 2026 17:33
A convenience that parses an Authorization: Payment header, rebuilds the
route's expected request from the given amount, and verifies via
verify_credential_with_expected — so the amount is pinned and a credential
minted for a different price is rejected. The unified pay-kit axum gate
builds on this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a facade-level gate to solana-pay-kit that accepts both MPP charge and
x402 on one route:
- PayKit::new(PayKitConfig{..}) derives both an Mpp and an X402 from one
  shared config (the fee-payer signer drives MPP sponsorship and supplies
  x402's fee-payer address).
- paid_get/paid_post one-liner; an unpaid request returns 402 carrying BOTH
  the MPP WWW-Authenticate and the x402 PAYMENT-REQUIRED challenges. The paid
  retry is detected by header and verified by the matching protocol, with the
  resolved price pinned (cross-route replay rejected).
- Payment handler extractor (amount/protocol/reference), Price::dynamic for
  per-request pricing.
- axum_quickstart example + crate README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite the solana-pay-kit README to mirror the TypeScript README's
structure (banner, progressive quick start, run-the-example, MPP, x402,
client, vocabulary, primitives, pricing, signers, install/features, test,
harness, spec, repo layout) adapted to the Rust dual-protocol gate. Point
the workspace rust/README.md at it as the SDK guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the comprehensive TypeScript-structured guide to rust/README.md (the
canonical place) and drop the duplicate at crates/kit/README.md. The kit
crate's `readme` points at ../../README.md so crates.io renders the same
guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lgalabru lgalabru force-pushed the feat/rust-paykit-axum-gate branch from 936865c to 8040768 Compare June 15, 2026 21:41
@lgalabru lgalabru requested a review from EfeDurmaz16 June 15, 2026 21:42
EfeDurmaz16
EfeDurmaz16 previously approved these changes Jun 15, 2026

@EfeDurmaz16 EfeDurmaz16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Light review of the gate logic and the amount-pinned verify — looks solid.

  • Fail-closed. The handler runs only after a successful verify, and the Payment extension is inserted only on success, so the extractor is unreachable on an unpaid request; missing / unknown / failed payment all fall through to a 402. challenge_response builds the 402 best-effort, so a header-format error still denies access rather than opening it.
  • Cross-route replay pinned. verify_payment_for_amount rebuilds the expected request from the resolved price (and x402 process_payment pins the same amount), so a credential minted for a cheaper route is rejected. verify_payment_for_amount reuses the existing verify_credential_with_expected — no new trust surface.
  • Protocol disambiguation is unambiguous (Authorization: Payment → MPP, PAYMENT-SIGNATURE/X-PAYMENT → x402), MPP taking precedence when both are present.

Two non-blocking nits: Err(_) on verify swallows the reason (no log) — correct behavior, just harder to debug a rejected payment; and query_param returns the raw, undecoded query value, worth a doc note for the dynamic-price closure.

LGTM.

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces the unified dual-protocol axum payment gate (paid_get/paid_post) for the Rust SDK, allowing a single route to advertise and verify both MPP and x402 payments, along with a convenience verify_payment_for_amount on Mpp and a substantially rewritten README.

  • rust/crates/kit/src/gate.rs (new): Core of the PR — PayKit, Price/PriceCtx, Payment extractor, paid_get/paid_post helpers, and all middleware dispatch logic including the percent_decode helper and challenge_response with per-protocol tracing::warn! logging.
  • rust/crates/mpp/src/server/charge.rs: Adds verify_payment_for_amount, a thin amount-pinned wrapper over verify_credential_with_expected; blockhash is explicitly excluded from comparison (documented and tested).
  • Supporting files: Cargo.toml dependency additions, lib.rs feature gate re-exports, examples/axum_quickstart.rs, and the README rewrite are all clean.

Confidence Score: 4/5

Safe to merge after addressing the incomplete percent-decode fix in query_param.

The query_param helper percent-decodes the value (fixing the previously flagged issue) but still compares the parameter key with raw bytes. A client can send ?ti%65r=premium to get a key of "ti%65r" that never matches "tier", causing both challenge generation and verification to resolve at the default lower price — a complete, end-to-end price bypass. The rest of the gate logic, the verify_payment_for_amount wrapper, the challenge_response logging, and the unsigned-tx reference guard are all well-implemented and tested.

rust/crates/kit/src/gate.rs — specifically the query_param key comparison and the settlement header error paths.

Important Files Changed

Filename Overview
rust/crates/kit/src/gate.rs New dual-protocol payment gate: PayKit, paid_get/paid_post, Payment extractor, Price/PriceCtx, and all middleware logic. Incomplete percent-decode fix leaves query-key bypass open; settlement header errors are silently dropped.
rust/crates/mpp/src/server/charge.rs Adds verify_payment_for_amount: rebuilds the route's expected ChargeRequest from amount via charge(), then calls the existing verify_credential_with_expected. Blockhash is intentionally excluded from comparison (well-documented). Makes one extra RPC call per verification to fetch a blockhash that is then discarded; clean and correct otherwise.
rust/crates/kit/src/lib.rs Conditionally re-exports the new gate module under the axum feature flag. Straightforward, no issues.
rust/crates/kit/Cargo.toml Adds axum 0.8 and tracing 0.1 as optional dependencies; expands the axum feature to pull both protocol servers; adds dev-deps (tower, serde_json, tokio) and the quickstart example. README path points to workspace-level README for crates.io rendering.
rust/crates/kit/examples/axum_quickstart.rs Runnable minimal example: one fixed-price route and one dynamically-priced route using the demo Surfpool sandbox. Clear and concise.
rust/README.md Complete README rewrite: quick-start snippets, protocol comparison tables, feature-flag table, vocabulary glossary, and crate layout. Documentation only.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Gate as gate_middleware
    participant Mpp as Mpp handler
    participant X402 as X402 handler
    participant Handler

    Client->>Gate: GET /route (no payment header)
    Gate->>Gate: resolve price (fixed or dynamic)
    Gate->>Mpp: charge(amount) → WWW-Authenticate
    Gate->>X402: payment_required_header(amount) → PAYMENT-REQUIRED
    Gate-->>Client: 402 + both challenge headers

    Client->>Gate: GET /route (Authorization: Payment mpp-cred)
    Gate->>Gate: resolve price
    Gate->>Mpp: verify_payment_for_amount(cred, amount)
    Mpp->>Mpp: charge(amount) → expected ChargeRequest
    Mpp->>Mpp: verify_credential_with_expected(cred, expected)
    alt verification OK
        Mpp-->>Gate: Receipt
        Gate->>Handler: run(req + Payment ext)
        Handler-->>Gate: response
        Gate->>Gate: attach_mpp_receipt
        Gate-->>Client: 200 + Payment-Receipt header
    else verification FAIL
        Gate-->>Client: 402 + both challenges
    end

    Client->>Gate: GET /route (PAYMENT-SIGNATURE: x402-cred)
    Gate->>Gate: resolve price
    Gate->>X402: process_payment(header, amount)
    alt verification OK
        X402-->>Gate: VerifiedExactPayment
        Gate->>Gate: x402_reference → Some(ref)
        Gate->>Handler: run(req + Payment ext)
        Handler-->>Gate: response
        Gate->>Gate: attach_x402_response
        Gate-->>Client: 200 + Payment-Response header
    else verification FAIL or no signature
        Gate-->>Client: 402 + both challenges
    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 Client
    participant Gate as gate_middleware
    participant Mpp as Mpp handler
    participant X402 as X402 handler
    participant Handler

    Client->>Gate: GET /route (no payment header)
    Gate->>Gate: resolve price (fixed or dynamic)
    Gate->>Mpp: charge(amount) → WWW-Authenticate
    Gate->>X402: payment_required_header(amount) → PAYMENT-REQUIRED
    Gate-->>Client: 402 + both challenge headers

    Client->>Gate: GET /route (Authorization: Payment mpp-cred)
    Gate->>Gate: resolve price
    Gate->>Mpp: verify_payment_for_amount(cred, amount)
    Mpp->>Mpp: charge(amount) → expected ChargeRequest
    Mpp->>Mpp: verify_credential_with_expected(cred, expected)
    alt verification OK
        Mpp-->>Gate: Receipt
        Gate->>Handler: run(req + Payment ext)
        Handler-->>Gate: response
        Gate->>Gate: attach_mpp_receipt
        Gate-->>Client: 200 + Payment-Receipt header
    else verification FAIL
        Gate-->>Client: 402 + both challenges
    end

    Client->>Gate: GET /route (PAYMENT-SIGNATURE: x402-cred)
    Gate->>Gate: resolve price
    Gate->>X402: process_payment(header, amount)
    alt verification OK
        X402-->>Gate: VerifiedExactPayment
        Gate->>Gate: x402_reference → Some(ref)
        Gate->>Handler: run(req + Payment ext)
        Handler-->>Gate: response
        Gate->>Gate: attach_x402_response
        Gate-->>Client: 200 + Payment-Response header
    else verification FAIL or no signature
        Gate-->>Client: 402 + both challenges
    end
Loading

Reviews (2): Last reviewed commit: "fix(rust/kit): address Greptile review o..." | Re-trigger Greptile

Comment thread rust/crates/kit/src/gate.rs
Comment thread rust/crates/kit/src/gate.rs
Comment thread rust/crates/kit/src/gate.rs Outdated
Comment thread rust/crates/kit/src/gate.rs
- P1: percent-decode query params in PriceCtx::query_param so a percent-
  encoded tier (e.g. ?tier=premi%75m) can't bypass the premium price and
  land on the cheaper default. Adds a regression test.
- Log (tracing::warn) each error arm in challenge_response so a dropped
  MPP/x402 challenge header is diagnosable instead of silently missing.
- x402_reference now returns Option; a verified payment with no signature
  is rejected with a fresh 402 rather than handing the handler an empty
  settlement reference.
- Add an x402-path test (invalid PAYMENT-SIGNATURE -> 402).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lgalabru

Copy link
Copy Markdown
Collaborator Author

Addressed the Greptile review in 27d49d1:

  • P1 — percent-encoding bypass: PriceCtx::query_param now percent-decodes (%XX + +), so ?tier=premi%75m resolves to the premium price instead of falling through to the cheaper default. Added the query_param_percent_decodes regression test.
  • P2 — silent challenge-header omission: each error arm in challenge_response now logs via tracing::warn! (with the amount and error), so a dropped MPP/x402 challenge header is diagnosable.
  • P2 — empty x402 reference: x402_reference returns Option; a verified payment carrying no signature is now rejected with a fresh 402 (and a warn) rather than handing the handler/Payment-Response an empty reference.
  • P2 — no x402-path test: added x402_invalid_signature_returns_402 (a bad PAYMENT-SIGNATURE takes the x402 path and 402s, not falling through to MPP). The full x402 happy-path (valid credential → 200) is covered by the cross-language harness, which runs the rust/ts x402 clients against servers; a unit-level happy path would require constructing a signed on-chain envelope.

@lgalabru lgalabru requested a review from EfeDurmaz16 June 15, 2026 21:59

@EfeDurmaz16 EfeDurmaz16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked 27d49d1, all four hold:

  • percent_decode covers %XX and + with safe bounds, so the premi%75m bypass is closed and the regression test pins it.
  • challenge_response now warns on every dropped-header arm, so a dropped MPP/x402 challenge is visible to operators instead of silent.
  • x402_reference returns Option, so an unsigned tx 402s instead of reaching the handler with an empty reference.
  • x402_invalid_signature_returns_402 locks the x402 rejection path.

Fail-closed gating and price pinning still hold. Good to go.

@lgalabru lgalabru merged commit b4a6ea9 into main Jun 16, 2026
29 checks passed
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.

2 participants