Skip to content

feat(typescript): add support for x402:upto + revisit playground #176

Merged
lgalabru merged 12 commits into
mainfrom
feat/paykit-npm-packaging
Jun 19, 2026
Merged

feat(typescript): add support for x402:upto + revisit playground #176
lgalabru merged 12 commits into
mainfrom
feat/paykit-npm-packaging

Conversation

@lgalabru

Copy link
Copy Markdown
Collaborator

No description provided.

lgalabru added 3 commits June 18, 2026 23:56
…os + playground

Bring the in-progress TypeScript SDK work onto this branch:

- pay-kit: add `session` and `subscription` gate kinds alongside fixed/usage;
  a unified payment-aware client (`@solana/pay-kit/client`) that settles a 402
  over MPP or x402 (with an optional protocol override); optional per-split
  on-chain memos on fees (`feeWithin`/`feeOnTop` accept `{ price, memo }`); the
  x402 exact/upto + mpp-session adapters; OpenAPI discovery; and the docs server.
- playground UI: dual-protocol toggle on the dual-rail endpoint, nav tile
  color grouping (blue → pink → yellow → docs), simplified USDC-only wallet
  onboarding, deduplicated event log.
- playground-api: generic `/api/v1/<name>` routes; the joke endpoint is now an
  MPP charge with a platform-fee split (memo-labelled).
…tarballs

pay-kit depended on @x402/core + @x402/svm through machine-absolute `file:`
tarball paths — not reproducible, not npm-publishable. Replace that:

- Add a git submodule at typescript/external/x402 pinning the Solana @x402 fork
  (recent-blockhash + upto work), and a `just x402-vendor` target that builds its
  @x402/core + @x402/svm into tarballs committed under typescript/.x402-vendor/
  (committed so frozen installs are reproducible without building the submodule).
- pay-kit now builds with tsup, bundling @x402/* (and zod) into its dist, so the
  published package is self-contained and carries no @x402 dependency; the fork
  tarballs are devDependencies (relative paths) only. @solana-program/* become
  externalized deps; a root pnpm override pins svm's transitive @x402/core to the
  vendored fork.
- Add a parallel @solana/pay-kit job to the npm-publish workflow, guarding that
  the published runtime deps carry no workspace:/file:/link: paths or @x402/*.
- playground-api no longer pins @x402 directly (it consumes pay-kit's bundled dist).
- Run prettier --write over the new pay-kit sources/tests (Lint & Format job).
- Refresh the playground and playground-api lockfiles after dropping their
  direct @x402 deps; the committed playground-api lockfile still pinned the old
  machine-absolute @x402 tarball path, which broke the Playground E2E install.
@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds x402:upto (usage-based metered payment) support to the TypeScript SDK — a new X402Upto adapter, a Charge meter, and GateKind extensions for usage, subscription, and session gates — alongside a revamped playground. The settle-memoization and concurrent-replay guard issues from previous review rounds are addressed.

  • x402-upto.ts: New Charge meter class (clamps, floors negatives) and X402Upto engine (verifyOpen / settle) with in-flight channel dedup via inFlightUptoChannels.
  • gate.ts / pricing.ts: Added GateKind enum and usage(), subscription(), session() factory helpers; Gate.create now validates kind-specific constraints at boot time.
  • paykit.ts: Two settle-failure paths for usage gates remain inconsistently guarded: runBufferedSettle (Express) discards the buffered handler response and returns a 402 on settle error, while the fetch success path has no guard around withSettlement() throwing — both behave differently from the Hono path that swallows settle failures and preserves the handler response.

Confidence Score: 4/5

Safe to merge with caution — two settle-failure paths in paykit.ts behave inconsistently across frameworks for usage gates.

The new X402Upto adapter, Charge meter, and gate validation are solid. The concurrency and double-settlement issues from earlier rounds are fixed. However, when the on-chain settle call fails transiently in the Express path, the buffered handler response is thrown away and the client receives an empty 402 — the opposite of what happened. The fetch success path has the same gap: withSettlement() can propagate a settle exception and lose the response. Both paths affect only usage (upto) gates but the consequences are visible to end users.

typescript/packages/pay-kit/src/paykit.ts — specifically the runBufferedSettle catch block (lines 808–821) and the fetch withSettlement call (line 626).

Important Files Changed

Filename Overview
typescript/packages/pay-kit/src/adapters/x402-upto.ts New usage-based x402 upto adapter: Charge meter, verifyOpen, and settle with memoization and in-flight dedup. Logic looks sound.
typescript/packages/pay-kit/src/paykit.ts Core orchestration extended for usage/subscription/session gates. Two settle-failure paths — Express runBufferedSettle discards the handler response and sends a misleading 402; fetch success path is unguarded against withSettlement throwing.
typescript/packages/pay-kit/src/gate.ts Added GateKind, SessionConfig, SubscriptionConfig, FeeSpec; validation rules for usage/subscription/session gates. Clean.
typescript/packages/pay-kit/src/pricing.ts Added usage(), subscription(), session() helper factories plus PricingDef type; createPricing updated to handle bare Price values. No issues.
typescript/packages/pay-kit/src/config.ts x402 added to accepted protocols; challengeBindingSecret now optional when MPP is not in accept; html flag added to MppOptions. Clean.
typescript/packages/pay-kit/src/tests/usage.test.ts New test file covering gate validation and challenge shape for usage gates. Settle-failure paths are not tested.
.github/workflows/npm-publish.yml Added publish-pay-kit job; vendored x402 tarballs are used for frozen install. No issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    REQ[Incoming Request] --> RP[requirePayment]
    RP --> KIND{Gate Kind}

    KIND -- fixed --> RF[requireFixed]
    KIND -- usage --> RU[requireUsage]
    KIND -- session --> RS[requireSession]
    KIND -- subscription --> RF

    RF -- no credential --> D402[PaymentDenied 402]
    RF -- valid --> VS[verifyAndSettle]
    VS --> FG[granted: settle = resolved headers]

    RU -- no header --> U402[PaymentDenied 402]
    RU -- has header --> VO[upto.verifyOpen - escrows ceiling on-chain]
    VO --> DEDUP{channelId in-flight?}
    DEDUP -- yes --> U402
    DEDUP -- no --> METER[new Charge meter + settlePromise memoized]
    METER --> UG[granted: settle = upto.settle + cleanup]

    RS --> SE[sessionEngine.handler]
    SE -- 402 --> D402
    SE -- open --> SG[granted: settle = receipt headers]

    FG & UG & SG --> FW{Framework}
    FW -- fetch --> FH[handler runs then withSettlement response]
    FW -- hono --> HH[next then finally settle best-effort]
    FW -- express --> EH[runBufferedSettle buffer settle replay]

    FH -. settle throws .-> FERR[exception propagates]
    EH -. settle throws .-> EERR[402 sent buffer discarded]
    HH -. settle throws .-> HOK[swallowed response preserved]
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"}}}%%
flowchart TD
    REQ[Incoming Request] --> RP[requirePayment]
    RP --> KIND{Gate Kind}

    KIND -- fixed --> RF[requireFixed]
    KIND -- usage --> RU[requireUsage]
    KIND -- session --> RS[requireSession]
    KIND -- subscription --> RF

    RF -- no credential --> D402[PaymentDenied 402]
    RF -- valid --> VS[verifyAndSettle]
    VS --> FG[granted: settle = resolved headers]

    RU -- no header --> U402[PaymentDenied 402]
    RU -- has header --> VO[upto.verifyOpen - escrows ceiling on-chain]
    VO --> DEDUP{channelId in-flight?}
    DEDUP -- yes --> U402
    DEDUP -- no --> METER[new Charge meter + settlePromise memoized]
    METER --> UG[granted: settle = upto.settle + cleanup]

    RS --> SE[sessionEngine.handler]
    SE -- 402 --> D402
    SE -- open --> SG[granted: settle = receipt headers]

    FG & UG & SG --> FW{Framework}
    FW -- fetch --> FH[handler runs then withSettlement response]
    FW -- hono --> HH[next then finally settle best-effort]
    FW -- express --> EH[runBufferedSettle buffer settle replay]

    FH -. settle throws .-> FERR[exception propagates]
    EH -. settle throws .-> EERR[402 sent buffer discarded]
    HH -. settle throws .-> HOK[swallowed response preserved]
Loading

Reviews (7): Last reviewed commit: "fix(pay-kit): re-render the html payment..." | Re-trigger Greptile

Comment thread typescript/packages/pay-kit/src/paykit.ts Outdated
Comment thread typescript/packages/pay-kit/src/client/index.ts
Comment on lines +699 to +705
try {
next();
} catch (error) {
restoreAndReplay();
next(error);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Synchronous next() throw skips settlement, leaving the upto channel open

If the downstream Express handler throws synchronously (instead of propagating through next(err)), the catch block calls restoreAndReplay() and re-invokes next(error) — but settle() is never reached. At this point verifyOpen has already broadcast the channel open on-chain (escrowing the ceiling), so skipping settlement leaves the channel in an unfinalized state without a refund. The comment on line 709 ("Settle even on a handler error") documents the intent to always finalize, but the synchronous-throw path violates it. A finally block wrapping the await endedsettle() sequence would ensure settlement is always attempted.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 461cce8 — the synchronous-throw path now attempts result.settle() (best-effort; the meter is 0 so it refunds the escrowed ceiling) before forwarding the error via next(error), instead of returning early and leaking the open channel.

lgalabru added 2 commits June 19, 2026 00:11
…ntract

The route restructure dropped /api/v1/fortune, but the payment-link E2E and the
cross-language harnesses hardcode it (GET /api/v1/fortune → 402 → `{ "fortune" }`).
Re-add it as a fixed charge (MPP or x402) returning a fortune, so the Playground
E2E and harness clients have their canonical paid endpoint back.
…ay-kit

The playground showcases pay-kit, and the other languages' snippets already
show their pay-kit equivalents — only the TypeScript snippets still used the raw
Mppx API. Update the charge client/server to `createPayKitClient` / `createPayKit`
+ `pay.express`, and the x402 client to the same unified client (forcing the
x402 rail). The x402 client snippet was also referencing `x402-fetch` / `x402`,
which were dropped from the playground-api deps — so it no longer typechecked;
the pay-kit client removes that dependency. Drop the now-unused path mappings
and regenerate the snippet manifest. (subscription + session snippets still use
@solana/mpp directly.)
Comment on lines +554 to +562
hono(gate: GateRef<P>): HonoMiddleware {
return async (c, next) => {
const result = await instance.requirePayment(c.req.raw, gate);
if (result.status === 402) return result.response;
await next();
for (const [name, value] of Object.entries(await result.settle())) c.res.headers.set(name, value);
return undefined;
};
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Settlement skipped when Hono handler throws for usage gates

await next() is called without any error guard before result.settle(). If the downstream Hono handler rejects (throws asynchronously), execution exits the async function immediately and result.settle() is never reached. Because verifyOpen already broadcast the channel open on-chain — escrowing the ceiling — the upto channel stays open with the user's funds locked indefinitely. Wrapping the next() + settle sequence in a try/finally would guarantee settlement is always attempted, mirroring the intent documented in runBufferedSettle for the Express path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 461cce8 — wrapped await next() in try/finally so result.settle() always runs, finalizing the channel even when the Hono handler throws.

lgalabru added 3 commits June 19, 2026 00:28
…/pay-kit

Finishes the snippet migration: subscription client/server and the session
server now use createPayKitClient / createPayKit + the subscription()/session()
pricing gates and pay.express (+ pay.sessionRoutes for the session side-channel).
The session *client* keeps @solana/mpp's createSessionFetch — pay-kit's unified
client delegates streaming sessions to it. All TS snippets now typecheck and no
longer use the raw Mppx API.
- requireUsage: memoize the settle Promise (not its resolved value) so two
  concurrent callers (e.g. Promise.all([settle(), withSettlement()])) share one
  in-flight settle and upto.settle() broadcasts exactly once. (P1)
- hono middleware: wrap next() in try/finally so settlement always runs even
  when the handler throws — verifyOpen already escrowed the ceiling on-chain, so
  the channel must be finalized regardless. (P1)
- runBufferedSettle (express): on a synchronous handler throw, settle (refund)
  before forwarding the error instead of returning early and leaking the open
  channel. (P2)
- client: drop the hardcoded `decimals: 6` from the x402 challenge progress
  event — the requirement only carries the asset address, so 6 misreports
  non-6-decimal assets; leave precision to the consumer. (P2)
… browser 402s

Adds opt-in HTML payment-link support so a browser hitting a paid route gets the
interactive "Continue with Solana" pay.sh page (and its service worker) instead
of a JSON 402 — restoring the behaviour the payment-link E2E expects, which the
move from raw solana.charge to pay.express had dropped.

- config: `mpp.html` flag (default false). When set, the MPP charge method is
  built with mppx's `html: true`, which content-negotiates the 402.
- adapter: optional `respond(gate, request)` — a protocol can own the response
  for an unpaid request. The MPP adapter returns mppx's negotiated Response:
  the HTML page for `Accept: text/html` (402) and the service-worker script for
  `?__mppx_worker` (200).
- paykit: new `PaymentRespond` result variant; `requireFixed` returns it for
  browser/worker requests, while API clients (JSON) keep the combined 402 that
  advertises every accepted protocol. Wired through the express/hono/fetch paths.
- playground-api: enable `mpp.html`.

Verified end to end: browser → 402 text/html, `?__mppx_worker` → 200
application/javascript, API → JSON 402 with www-authenticate. Unit test added.
Comment on lines +573 to +581
fetch(gate: GateRef<P>, handler: FetchHandler): (request: Request) => Promise<Response> {
return async request => {
const result = await instance.requirePayment(request, gate);
if ('respond' in result) return result.respond;
if (result.status === 402) return result.response;
const response = await handler(request, result.payment);
return await result.withSettlement(response);
};
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Usage-gate channel left open when fetch handler throws

For a usage (upto) gate, requireUsage calls upto.verifyOpen() before returning, which broadcasts the channel open on-chain and escrows the ceiling. If handler then throws, result.withSettlement (and thus result.settle()) is never reached, leaving the channel open indefinitely with the payer's funds locked. The Express and Hono paths both protect against this in the same commit (Express via try/catch around next(), Hono via try/finally), but the fetch wrapper has no equivalent guard.

A try/finally around handler(...) would close the channel even on errors, matching the intent documented in runBufferedSettle:

let response: Response;
try {
    response = await handler(request, result.payment);
} catch (error) {
    // Best-effort: finalize the upto channel (meter is 0 → refunds the ceiling).
    try { await result.settle(); } catch { /* swallow; re-throw original error */ }
    throw error;
}
return await result.withSettlement(response);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d7c50be — the fetch wrapper now wraps the handler in try/catch and finalizes the upto channel (best-effort result.settle()) before re-throwing, matching the express + hono paths.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Already addressed in d7c50be (the line numbers shifted with later commits). The fetch wrapper now wraps handler(...) in try/catch and best-effort result.settle()s before re-throwing — see the current code at this location.

Comment on lines +588 to +596
try {
await next();
} finally {
// Settle even if the handler threw: for usage (upto) gates
// `verifyOpen` already escrowed the ceiling on-chain, so the
// channel must be finalized regardless of the handler outcome.
for (const [name, value] of Object.entries(await result.settle())) c.res.headers.set(name, value);
}
return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Settlement failure in finally block can propagate as an unhandled exception

For a usage (upto) gate, result.settle() makes an on-chain network call (upto.settle()) that can throw on a transient RPC failure. In the finally block, an uncaught exception from result.settle() has two bad outcomes:

  • Success path: next() returned normally, the handler's response is ready, but the settle error now propagates out of the middleware — Hono returns a 500 instead of the handler's response, even though the request was served correctly.
  • Error path: next() threw, the finally settle error replaces the original handler error, making the failure harder to diagnose.

For fixed and session gates settle() returns Promise.resolve(...) and cannot throw, so this is exclusive to upto gates. Wrapping the block in a try/catch (and logging or swallowing the settle error) would match the pattern used in the Express sync-throw path on lines 744–747.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d7c50be — the hono finally now runs result.settle() inside its own try/catch, so a transient settle (RPC) failure is swallowed and cannot mask the handler response (success) or the original error (throw).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Already addressed in d7c50be. The hono finally now runs result.settle() inside its own try/catch, so a transient settle failure is swallowed and cannot mask the handler response or the original error.

lgalabru added 4 commits June 19, 2026 07:39
…ardened x402

Mirrors the cross-implementation review on the Rust upto PR (#174) on the TS side:

- interop: the x402-upto adapter now emits the spec `extra.facilitator` (was the
  non-spec `facilitatorAddress`) and `profiles: ['payment-channel']`, so a Rust/
  other-SDK upto client can act on a pay-kit-issued 402. Offer-building reads
  `facilitator` too.
- concurrent double-serve: `requireUsage` now dedups in-flight upto channel IDs
  (acquired after verify, released when settle completes), so a replayed
  PAYMENT-SIGNATURE can't serve the metered resource twice for one deposit.
- re-pin the external/x402 submodule + vendored tarballs to the hardened fork
  commit (channel-open co-sign allowlist + ALT reject + channel.payer binding +
  the same facilitator/profiles rename).
…settle

Follow-up to the settle-on-throw hardening (Greptile P1s):
- `fetch` wrapper now finalizes the upto channel if the handler throws (the
  express + hono paths already did; fetch was missed), so an escrowed ceiling
  isn't left locked.
- `hono` settle runs in the finally inside its own try/catch so a transient
  settle (RPC) failure can't mask the handler's response or its error.
The pay.sh payment page registers its service worker at scope "/", but the
worker is served from the resource sub-path (/api/v1/x?__mppx_worker). Browsers
reject the broader scope unless the worker response carries
`Service-Worker-Allowed: /`, which mppx doesn't set — so registration threw and
the payment-link E2E's "clicking pay" test timed out. Add the header in the MPP
adapter's worker response.
…ayment

When a browser submits a payment that fails verification, requireFixed returned
pay-kit's JSON 402 (code: invalid_proof) even for `Accept: text/html`. The pay.sh
page (via the service worker) reloads into that JSON, so the user sees a raw
error and can't retry. Content-negotiate the rejection too: re-render the HTML
payment page for browsers (retry-friendly), JSON for API clients — same path as
the unpaid case. Fixes the payment-link E2E full-flow test (the SW flow now ends
on the payment page, not an unrecognized JSON body).
Comment on lines +808 to +821
try {
// Settle even on a handler error: the meter is 0 unless the handler
// reported usage, so a failed request finalizes the channel and refunds.
for (const [name, value] of Object.entries(await result.settle())) res.setHeader(name, value);
} catch {
settled = true;
res.writeHead = originalWriteHead;
res.write = originalWrite;
res.end = originalEnd;
bufferedCalls = [];
res.statusCode = 402;
originalEnd();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Settle failure discards valid handler response and returns an empty 402

When result.settle() throws after await ended (handler already ran, response buffered), the catch block sets bufferedCalls = [] and calls originalEnd() with res.statusCode = 402. This discards the buffered handler body entirely and tells the client "payment required" for a resource it already paid for and received — the inverse of what happened. A transient RPC error during settlement should not override a successful 200 handler response.

The Hono path explicitly swallows settle failures and preserves the handler outcome. The Express path should do the same: call restoreAndReplay() (without the settlement header) instead of discarding bufferedCalls.

}
throw error;
}
return await result.withSettlement(response);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Settle failure on the handler-success path loses the response in the fetch wrapper

On the handler-error path (lines 615–624), result.settle() is called best-effort and any settle error is swallowed before re-throwing the original error. But on the handler-success path, result.withSettlement(response) calls settle() without any guard: if a transient RPC failure causes settle() to reject, the exception propagates out of the async function and the client receives a 500 (or an unhandled rejection) even though the handler produced a valid response. For usage (upto) gates this is a live failure path; fixed gates' settle() always resolves.

The fix is to wrap result.withSettlement(response) in a try/catch that returns response unchanged if settle fails — consistent with the Hono finally path that swallows settle errors to preserve the handler outcome.

@lgalabru lgalabru requested a review from EfeDurmaz16 June 19, 2026 12:56
@lgalabru lgalabru merged commit e362511 into main Jun 19, 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