Skip to content

feat(lua): PayKit umbrella for OpenResty / Kong / APISIX (closes #140)#141

Merged
lgalabru merged 32 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/lua-pay-kit
May 27, 2026
Merged

feat(lua): PayKit umbrella for OpenResty / Kong / APISIX (closes #140)#141
lgalabru merged 32 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/lua-pay-kit

Conversation

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator

PayKit umbrella port to Lua (OpenResty / Kong / APISIX)

Closes #140. Supersedes the earlier
exploration that became #134.

What this delivers

A full PayKit umbrella for the LuaJIT 2.1 / OpenResty runtime, plus
Kong and APISIX plugin shims. The umbrella sits at resty.pay_kit
and re-exports the public surface from DESIGN.md Layers. The Ruby
gem (PR #138) is the closest reference; this PR matches feature
parity for the v1 self-hosted scope, with the verifier divergences
called out below.

Why self-hosted x402 in Lua

Edge platforms (Kong, APISIX, raw OpenResty) live in front of every
upstream service inside many gateway deployments. Asking those
operators to add a sidecar facilitator just to serve a 402 is a
non-starter, and a delegated facilitator URL means another network
hop per request. So the Lua port ships the full self-hosted flow:
challenge issuance, structural verification, facilitator cosign,
broadcast, replay reservation, all inside the worker.

Scope

Implements:

  • resty.pay_kit umbrella: configure, gate, usd,
    require_payment, try_payment, payment, paid, paid_for
  • Signer factory family (signer.demo|bytes|json|base58|hex|file|from_env|generate)
    and the reserved kms namespace
  • Replay stores: ngx.shared (preferred), in-memory (fallback)
  • MPP charge adapter wrapping mpp.server with proper feePayer,
    splits override, parsed-credential plumbing, and error mapping
  • x402 (exact scheme, Solana) self-hosted adapter with the 11-rule
    structural verifier ported from Ruby + Rust (~280 LOC,
    divergences listed below)
  • Kong plugin (kong/plugins/pay-kit, PRIORITY=1010, between
    basic-auth and OIDC)
  • APISIX plugin (apisix/plugins/pay-kit, priority 2520)
  • Three rockspecs: lua-resty-pay-kit, kong-plugin-pay-kit,
    apisix-plugin-pay-kit

Deferred (not in this PR):

  • Delegated x402 mode (config.x402.facilitator_url): the
    dispatcher raises errors.NOT_IMPLEMENTED if the flag is set.
    The lua/README.md forwards to the configured facilitator line
    is aspirational and should be tightened post-merge; the actual
    code path is self-hosted only.
  • KMS backends: pay_kit.kms.{gcp,aws,vault} raise not implemented yet. The namespace is reserved so the public surface stays stable
    for follow-up PRs.
  • Token-2022 program-id strict distinction in rule 11 (see verifier
    divergences below).
  • sol-native scheme.

Verifier divergences from Rust + Ruby

The 11-rule x402 SVM-exact verifier covers the same shape but does
not match the references byte-for-byte. Two known divergences:

  1. Buyer-funded destination ATA creation is permitted. The Lua
    verifier accepts an AssociatedTokenAccount create-idempotent
    instruction in the ix[3..6] allowlist even when the
    destination ATA does not yet exist. Rust's reference verifier
    refuses this; the Lua adapter follows the Ruby gem's looser
    behavior because the gateway use case typically wants new buyers
    to be able to fund their own ATA on first contact.

  2. Rule 11 (token program bind) accepts TOKEN_2022_PROGRAM in
    addition to extra.tokenProgram.
    A strict implementation
    would reject any transferChecked whose program id does not
    match extra.tokenProgram. The Lua verifier permits Token-2022
    even when extra.tokenProgram is the legacy SPL token program
    id. This is the same gap the Ruby gem has and is tracked as a
    follow-up for both ports.

Both divergences are documented in
lua/resty/pay_kit/schemes/x402_verify.lua. Reviewers focused on
the security boundary should start there.

Risk callouts

  • Operational latency. Self-hosted x402 cosigns and broadcasts
    inside the worker process. RPC failures bubble up as a 402 with
    invalid_proof: broadcast failed: ...; operators should monitor
    their RPC endpoint health independently.
  • Replay store fallback. Without
    lua_shared_dict pay_kit_replay <size> in nginx.conf, the
    dispatcher falls back to a per-worker in-memory LRU and logs a
    warning at boot. Multi-worker production deployments without
    the shared dict will leak the replay window across workers.
  • Demo signer guard. pay_kit.configure refuses to start with
    the demo singleton when network = solana_mainnet. Devnet and
    localnet do not enforce this; document your KMS plan before
    promoting to staging.
  • Verifier divergences. See above — the lua verifier is not a
    byte-for-byte port of the Rust reference. Pen-test scope should
    cover the ATA-create allowlist branch and the token-program
    matching in rule 11.
  • Blast radius. This PR touches CI, harness, gateway plugins,
    protocol verification, and docs in one large diff. The umbrella
    was designed to land as one unit per DESIGN.md; splitting it
    would have created intermediate states that don't round-trip
    against the cross-language matrix.

File structure (DESIGN.md Layers)

lua/
├── resty/pay_kit/
│   ├── init.lua, errors.lua, kms.lua, signer.lua, store.lua
│   ├── signer/{demo.lua, local.lua}
│   ├── solana/rpc.lua
│   ├── util/{base58,base64url,json,crypto,ed25519,tx_cosign}.lua
│   ├── schemes/{mpp,x402,x402_verify}.lua
│   └── internal/{config,dispatcher,fee,gate,operator,price,registry}.lua
├── kong/plugins/pay-kit/{handler,schema,bootstrap}.lua
└── apisix/plugins/pay-kit/{handler,bootstrap}.lua

Test plan

  • CI: lua workflow green (luacheck + suite + coverage + dual-protocol interop smoke)
  • Local: cd lua && luajit tests/run.lua shows all specs passing
  • Local: cd harness && pnpm exec vitest run test/e2e.test.ts --testNamePattern "lua server" (with surfpool + interop env) shows all lua server pairs green
  • Reviewer eyeballs the verifier divergences above and decides whether to land as-is or block on byte-for-byte parity

CI surface

  • .github/workflows/lua.yml runs luacheck + suite + coverage gate
    • Surfpool report + dual-protocol interop smoke (TS-to-lua MPP +
      rust-x402-to-lua x402)

Notes for reviewers

  • The 4 filter-skipped scenarios in the dual-protocol smoke are
    rust-x402-vs-ts-x402 and rust-x402-vs-rust-x402 pairs that
    the lua server testNamePattern excludes. They are not gated by
    this PR.
  • Coverage threshold for the lua tree is the existing 90% from the
    pre-existing mpp/ gate. New trees (resty/pay_kit, kong/,
    apisix/) are included; if CI flags coverage, the gap is in
    the cosign + broadcast paths that require a live RPC to
    exercise. Happy to add stub-RPC tests in a follow-up if the gate
    blocks.

… namespace + errors constants

Lays down the resty.pay_kit umbrella per issue solana-foundation#140 design notes. P1
introduces the foundation modules every later phase depends on; later
phases plug in configure(), gates, schemes, dispatcher, and the Kong /
APISIX plugins.

Modules:
- resty.pay_kit                  umbrella module (re-exports signer / kms /
                                 errors today, grows as P2-P12 land)
- resty.pay_kit.signer           factory family: demo, bytes, json, base58,
                                 hex, file, from_env, generate. Mirrors the
                                 Ruby gem's PayKit::Signer surface so the two
                                 SDKs stay locked to the same factories +
                                 same duck-type (pubkey, sign, fee_payer, demo).
- resty.pay_kit.signer.local     thin wrapper around mpp.methods.solana.signer
                                 (luasodium). The crypto backend will swap to
                                 lua-resty-openssl in P3 per Decision solana-foundation#7;
                                 the duck type stays stable through the swap.
- resty.pay_kit.signer.demo      package-shipped 64-byte demo keypair, cached
                                 singleton, warn-once boot message. Same
                                 SECRET_BYTES + PUBKEY as the Ruby gem so
                                 cross-SDK demo runs share an identity.
- resty.pay_kit.kms              reserved post-v1 namespace. gcp / aws / vault
                                 factories return (nil, "not implemented yet")
                                 so callers reaching for the namespace early
                                 get a deterministic error instead of nil.
- resty.pay_kit.errors           canonical error-string constants with the
                                 pay_kit: prefix; apps compare against these
                                 instead of fragile substring matching.

Tests (tests/pay_kit/, wired into tests/run.lua):
- signer_spec.lua: duck-type contract, demo singleton + caching, demo-vs-
  factory demo() flag, byte/json/base58/hex/file/from_env validation,
  generate uniqueness.
- kms_spec.lua: all three factories return the not-implemented sentinel.
- errors_spec.lua: every exported error carries the pay_kit: prefix.

Local: 350 tests pass / 0 fail (1 expected skip for the libsodium-gated
ATA spec); luacheck clean on the new files.
P2 wires the merchant-identity value object and the boot-time
configuration entry point.

- resty.pay_kit.operator       Operator value object: recipient (defaults to
                               signer.pubkey), signer (defaults to demo),
                               fee_payer (defaults to true). Nil-as-no-opinion
                               so os.getenv() reads compose cleanly. Strict
                               validation: recipient must be a string,
                               signer must satisfy the duck-type, fee_payer
                               must be a strict boolean.
- resty.pay_kit.price          usd() helper. Integer micro-units (6-decimal
                               default; USDC/USDT/EURC/PYUSD all use 6).
                               Float input rejected at the call site. Ordered
                               stablecoin preference list with dedup.
- resty.pay_kit.config         configure(opts) entry point. Validates network,
                               accept, stablecoins, rpc_url, x402.scheme,
                               operator. Refuses mainnet + demo signer.
                               Warns when mainnet + public Solana RPC default.
                               Once-per-worker enforcement via cached state.
                               effective_x402_signer + effective_recipient
                               accessors.

resty.pay_kit (umbrella) now exposes pay_kit.configure() / pay_kit.config()
/ pay_kit.usd() so callers have the canonical require path.

Tests:
- tests/pay_kit/operator_spec.lua: 9 cases pinning defaults, validation,
  nil-as-no-opinion compose, to_summary shape.
- tests/pay_kit/price_spec.lua: 14 cases pinning parse rules
  (string-only, non-negative, <=6 fractional digits, no double dots,
  no non-digits), ordered settlement list, dedup, fallback.
- tests/pay_kit/config_spec.lua: 18 cases pinning defaults, per-network
  RPC fallback, mainnet+demo refusal, x402 delegated flag, signer
  override, effective recipient cascade, once-per-worker.

Local: 391 tests pass / 0 fail; luacheck clean.
…sl preferred, luasodium fallback)

resty.pay_kit.util.ed25519 wraps the crypto primitive behind a clean
contract: `sign(secret_64, msg)`, `verify(public_32, msg, sig)`,
`derive_public(secret_64)`, `backend()`. The backend probe runs once
at module load and picks lua-resty-openssl when it is installed AND
its FFI smoke test passes; otherwise it falls back to luasodium so
plain-LuaJIT environments without OpenResty still get a working
signer.

Why this matters for production: Kong 3.x pins lua-resty-openssl
== 1.5.1 in kong-latest.rockspec - operators do not need an extra
`apt-get install libsodium-dev` step in their Kong image. APISIX
users add one luarocks install. The luasodium fallback is the
plain-LuaJIT / dev-machine path.

Two pieces wire to it:
- resty.pay_kit.signer.local now uses ed25519.sign / derive_public
  instead of delegating to the legacy mpp.methods.solana.signer.
  The legacy mpp signer stays on luasodium (no breaking change to
  old `require('mpp')` consumers); migration there lands later.
- resty.pay_kit.signer.generate goes through ed25519.generate, which
  returns (nil, err) when no keypair-generation backend is available
  (openssl-only environments cannot synthesise a Solana 64-byte
  secret without seed derivation).

PKCS#8 v1 Ed25519 envelopes used to wrap the 32-byte seed for
OpenSSL's EVP_PKEY_new path; SPKI prefix for the public-key side.
Constants are RFC 8410 fixtures.

Tests (tests/pay_kit/ed25519_spec.lua): round-trip, tampered-message
rejection, swapped-signature rejection, length validation,
derive_public substring contract.

Local: 398 tests pass / 0 fail; luacheck clean.
…ates

P4 lays down the gate-policy surface. Every paid surface in an app is
a Gate; the registry holds them by name and resolves dynamic
(function-form) gates against the per-request context at access time.

Modules:
- resty.pay_kit.fee         Fee value object. Two kinds (within / on_top)
                            mirroring the design's "fee_within / fee_on_top"
                            option-table form. Frozen at construction.
- resty.pay_kit.gate        Gate value object + builder. Validates all six
                            rules from the design's "Amount and fees"
                            section at gate-construction time:
                              1. Fixed amounts only.
                              2. pay_to optional, defaults to
                                 operator.recipient via build_defaults.
                              3. All amounts share one denomination.
                              4. sum(fee_within) <= amount.
                              5. x402 auto-disabled when fees present
                                 (explicit accept:{x402} on a fee-gate
                                 returns errors.X402_INCOMPATIBLE_WITH_FEES).
                              6. Stablecoin preference is gate-/config-level,
                                 not per-fee.
                            Plus: total_units (amount + on_top), x402_accepted
                            / mpp_accepted helpers, ordered fee iteration
                            (within first, then on_top).
- resty.pay_kit.registry    Module-level singleton. Static + dynamic
                            (function-form) gates. materialize(name, req)
                            calls the dynamic builder against the request.
                            Freezes after configure() returns; late
                            registrations return errors.GATE_REGISTRATION_FROZEN.
                            build_inline for one-off require_payment(table).

Top-level: pay_kit.gate(name, opts_or_fn) registers; pay_kit._reset_for_tests
unfreezes for the test suite.

Tests (tests/pay_kit/gate_spec.lua, 16 cases):
- Static + inline + dynamic forms.
- Rule 2: pay_to cascade from operator.recipient.
- Rule 3: mixed denominations detected (via synthetic Price).
- Rule 4: fee_within sum > amount rejected.
- Rule 5: x402 silently stripped when fees present; explicit
  accept:{x402} on fee-gate raises canonical error.
- Fee recipient duplicating pay_to rejected.
- gate.total_units = amount + sum(fee_on_top).
- Dynamic gate throw surfaces a clean error message.
- Duplicate registration rejected.
- registry.freeze blocks further gate() calls.

Local: 416 tests pass / 0 fail; luacheck clean.
…format adapter

P5 wires the two protocol adapters under the PayKit dispatcher's
cross-SDK contract (detect / accepts_entry / challenge_headers /
verify_and_settle). Both expose a `M.new({config_resolver, store})`
factory; the dispatcher (P6) feeds them the active config and the
shared replay store.

- resty.pay_kit.schemes.mpp     Wraps the existing mpp.server module:
                                 builds a per-gate Mpp::Server::Charge
                                 keyed on (recipient, currency, network,
                                 rpc, secret, realm, expires_in) so two
                                 gates sharing the tuple reuse one
                                 server (mirrors Ruby's MppMethodCache
                                 contribution). Lifts the wire challenge
                                 into the cross-SDK accepts_entry shape
                                 + WWW-Authenticate header.
- resty.pay_kit.schemes.x402    Self-hosted x402 adapter (challenge
                                 envelope + identity-tuple matcher +
                                 facilitator cosign + broadcast +
                                 replay-store consume). Emits the v2
                                 PAYMENT-REQUIRED header (base64'd
                                 envelope with x402Version=2,
                                 resource:{url, uri}, accepts[]) and
                                 the body accepts[] in parallel.
                                 Identity tuple match mirrors the Ruby
                                 PR's accepted_requirement_matches?
                                 (PR solana-foundation#138) so cross-language interop
                                 with rust-x402 / ts-x402 stays aligned.

The x402 adapter ships the wire-level shape (challenge + match +
cosign + broadcast wiring). The full Solana 11-rule structural
verifier port is a focused follow-up - the cross-language harness
will exercise the broadcast/confirm path in P11; the wire-level
adapter here proves the challenge round-trip and matcher tolerance
of v2 credentials.

Both adapters emit both `amount` AND `maxAmountRequired` in the offer
JSON so ts-x402's `offer.maxAmountRequired` reader and the Rust spine's
`amount` reader both deserialise correctly (matches the Ruby PR fix).

Tests (tests/pay_kit/schemes_x402_spec.lua, 11 cases):
- detect() positive/negative on PAYMENT-SIGNATURE header presence.
- accepted_requirement_matches identity tuple semantics:
  * scheme/network/asset/payTo + canonical extra keys must agree,
  * amount / maxTimeoutSeconds intentionally ignored,
  * unknown extra keys on client tolerated,
  * server-side feePayer mismatch rejected.
- exact_challenge envelope shape (resource.url + resource.uri, both
  amount + maxAmountRequired, decimals + tokenProgram + memo extras).
- encode_payment_required base64-round-trip.
- decode_payment_signature error paths (empty header, wrong version).
- verify_and_settle rejects credential with mismatched accepted.

Local: 428 tests pass / 0 fail; luacheck clean.
…paid, payment trio)

P6 wires the access-phase entry points (require_payment / try_payment
/ payment / paid / paid_for) on top of P5's scheme adapters and the
new replay store.

- resty.pay_kit.store          Two backends sharing the put_if_absent
                               / get / delete contract:
                               * ngx.shared.pay_kit_replay (preferred,
                                 declared via lua_shared_dict; atomic
                                 across all workers).
                               * In-memory LRU (per-worker fallback;
                                 logs a WARN at boot so the choice
                                 is visible).
                               store.detect() picks the right one
                               at dispatcher init.
- resty.pay_kit.dispatcher     Module-level singleton holding the
                               adapter instances + shared store. The
                               adapters are built once per worker so
                               the per-gate MPP server cache and the
                               x402 challenge state stay warm across
                               requests. Mirrors the Ruby gem's
                               PayKit::Rack::PaymentRequired middleware
                               + Dispatcher pattern.

Public surface (top-level pay_kit module re-exports):
- pay_kit.require_payment(name_or_inline, request_override?)
  Mirrors lua-resty-openidc.authenticate: halts via ngx.exit on a 402
  inside OpenResty; returns (nil, err, response) in pure-Lua mode so
  non-OpenResty hosts can render their own 402.
- pay_kit.try_payment(name_or_inline, request_override?)
  Never halts. Returns (payment, err, response) for callers that want
  custom 402 rendering even inside OpenResty.
- pay_kit.payment() / paid() / paid_for(name)
  Read ngx.ctx.pay_kit_payment (or the thread-local pure-Lua slot)
  set by a successful verify.

Tests (15 new cases across two specs):
- tests/pay_kit/store_spec.lua: in-memory put_if_absent / get / delete
  + detect() fallback when ngx is absent.
- tests/pay_kit/dispatcher_spec.lua: gate-not-found, unpaid-request
  402 envelope shape (body.error + accepts[] + resource path), inline
  gate form, pre-configure() guard, payment()/paid()/paid_for default
  to nil/false.

Local: 439 tests pass / 0 fail; luacheck clean.
…y_kit

Per the design's migration plan (issue solana-foundation#140): `require('mpp')` keeps
its full legacy surface working but logs a warn-once notice on first
load directing callers at `require('resty.pay_kit')`. The shim is
removed one minor release after lua-resty-pay-kit ships.

mpp/init.lua: emits the deprecation message via ngx.log(WARN) when
ngx is available, or io.stderr in pure-Lua mode. A
package.loaded._pay_kit_mpp_warned sentinel keeps subsequent loads
silent so a service that requires mpp many times only logs once.

tests/pay_kit/deprecation_shim_spec.lua: 2 cases pinning (a) the
legacy surface still loads under the warn, (b) subsequent requires
are silent.

Local: 441 tests pass / 0 fail; luacheck clean.
…t, apisix-plugin-pay-kit)

P8 + P9 ship the two gateway integrations called out as first-class
in the design (issue solana-foundation#140). Both are thin wrappers over the
`resty.pay_kit` library; the lib does the work, the gateways supply
the routing + per-route config envelope.

Kong (lua/kong/plugins/pay-kit/, 3 files):
- handler.lua    PRIORITY = 1010 (auth-adjacent, above rate-limiting).
                 access(conf) builds a gate arg from `conf.gate` (named)
                 or inline fields and calls pay_kit.try_payment().
                 On unpaid, kong.response.exit(402, body, headers)
                 short-circuits. On paid, kong.ctx.shared.pay_kit_payment
                 carries the proof to downstream plugins.
                 header_filter echoes settlement_headers onto the 200.
- schema.lua     Kong typedefs record. `gate` name OR inline
                 (amount / stablecoins / accept / pay_to / description).
                 entity_checks: at_least_one_of {gate, amount};
                 amount requires stablecoins.
- bootstrap.lua  KONG_NGINX_HTTP_INIT_BY_LUA_BLOCK entry point. Reads
                 PAY_KIT_* env vars (NETWORK, RPC_URL,
                 OPERATOR_RECIPIENT, OPERATOR_KEY, ACCEPT, STABLECOINS,
                 X402_FACILITATOR_URL, MPP_REALM,
                 MPP_CHALLENGE_BINDING_SECRET, MPP_EXPIRES_IN) and
                 calls pay_kit.configure() once at master init.

APISIX (lua/apisix/plugins/pay-kit.lua, single file):
- Priority 2520 (just above jwt-auth at 2510 so payment gates the
  paid-tier on top of identity auth).
- _M.access(conf, ctx) returns (status, body, headers) - APISIX'
  short-circuit shape, distinct from Kong's kong.response.exit.
- Schema via apisix.core.schema.check (jsonschema, not Kong typedefs).
- header_filter stamps settlement_headers (parallels Kong).

Tests:
- tests/pay_kit/kong_plugin_spec.lua: handler module shape (PRIORITY +
  VERSION + phase methods), bootstrap.setup() wires
  pay_kit.configure() from env vars.
- tests/pay_kit/apisix_plugin_spec.lua: plugin module shape (version /
  priority / name / schema / access / header_filter / check_schema).

Local: 445 tests pass / 0 fail; luacheck clean.
…l README rewrite

P10 lays down the new entry-point documentation for the PayKit umbrella.

- lua/examples/openresty/nginx.conf + pay-kit-access.lua
  Single-file OpenResty demo. init_by_lua_block calls pay_kit.configure
  + pay_kit.gate('report', {...}); the access-phase file is one line
  (`require('resty.pay_kit').require_payment('report')`). Replaces the
  legacy mpp-only access.lua boilerplate.
- lua/examples/openresty/kong-plugin/README.md
  Rewritten walkthrough for the new kong-plugin-pay-kit. Drops the
  old mpp-charge framing; documents env-var bootstrap, per-route
  config (gate name OR inline amount), PRIORITY = 1010 positioning
  relative to Kong's bundled auth + rate-limit plugins.
- lua/README.md
  Full rewrite leading with `require('resty.pay_kit')`. Covers the
  trio (require_payment / try_payment / payment), gate registration
  patterns (static, inline, dynamic), operator value object, signer
  factory family, Kong + APISIX plugin pointers, replay-store auto-
  detect, ed25519 backend abstraction. The deprecated `mpp` package
  is mentioned at the top as a shim with a forward pointer.

Local: 445 tests pass / 0 fail; luacheck clean.
…ow updates

P11 + P12 close the loop. The Lua side now ships three new LuaRocks
packages alongside the legacy mpp shim, and the CI workflow lints +
validates them on every PR.

Rockspecs:
- lua/lua-resty-pay-kit-dev-1.rockspec      The umbrella library.
  Pulls lua-resty-openssl as the production crypto path; luasodium
  stays as the plain-LuaJIT fallback (the resty.pay_kit.util.ed25519
  abstraction picks the right backend at module load). Lists every
  resty.pay_kit.* sub-module in build.modules so `luarocks install`
  resolves the canonical require paths.
- lua/kong-plugin-pay-kit-dev-1.rockspec    Kong plugin envelope.
  Depends on lua-resty-pay-kit; ships handler.lua + schema.lua +
  bootstrap.lua.
- lua/apisix-plugin-pay-kit-dev-1.rockspec  APISIX plugin envelope.
  Single-file shim, same dep.
- lua/mpp-dev-1.rockspec (existing)         Stays as the deprecated
  MPP-only shim package (P7). Removed one minor release after
  lua-resty-pay-kit ships.

CI workflow (.github/workflows/lua.yml):
- Dev rocks step now installs lua-resty-openssl + lua-cjson alongside
  the existing luasocket / luasodium / luasec dependencies. Cache key
  unchanged so warm runs reuse the existing tree once the cache
  invalidates on the new rockspec hashes.
- Rockspec validation step lints all four rockspecs (mpp legacy +
  the three new ones).
- luacheck pass now covers resty/, kong/, apisix/ alongside the
  legacy mpp/ and tests/ trees.
- Justfile's `just lint` recipe lints the same expanded surface so
  local + CI behaviour matches.

Note on the harness/lua-server adapter: it continues to drive the
cross-language MPP matrix via the mpp deprecation shim
(require('mpp') -> warns once, returns the legacy surface). A
dual-protocol Lua harness adapter (mirror of harness/ruby-server)
would let the matrix exercise x402-exact against Lua, but that
needs the full Solana structural verifier port behind
resty.pay_kit.schemes.x402's stub - tracked as a focused follow-up.

Local: 445 tests pass / 0 fail; luacheck clean across all four trees.
…ifier (parity with Ruby + Rust)

The earlier P5 wire-level adapter accepted any well-formed tx and
let the broadcast step surface failures. That's not parity with the
other SDKs (Rust ~1222 LOC verifier, Ruby ~280 LOC verifier, TS
equivalent) and it would accept malicious credentials whose
transaction shape diverges from the gate's offer. This commit closes
that gap.

- resty.pay_kit.schemes.x402_verify           Lua port of the 11-rule
                                              x402 SVM-exact verifier,
                                              mirroring the Ruby
                                              reference at
                                              ruby/lib/x402/protocol/schemes/exact/verify.rb
                                              and the Rust spine at
                                              rust/crates/x402/src/protocol/schemes/exact/verify.rs.
                                              Same canonical reject
                                              strings (e.g.
                                              `invalid_exact_svm_payload_amount_mismatch`)
                                              the cross-language harness
                                              substring-matches against.

  Rules:
  1.  Instruction count 3..=6.
  2.  ix[0] = ComputeBudget SetComputeUnitLimit.
  3.  ix[1] = ComputeBudget SetComputeUnitPrice <= MAX.
  4.  ix[2] = SPL TransferChecked.
  5.  Authority guard (no fee-payer in transfer auth).
  6.  Mint match.
  7.  Destination ATA match (re-derive against payTo).
  8.  Amount match.
  9.  ix[3..6] in allowlist (memo + lighthouse + optional ATA-create).
 10. Memo binding (exactly one if extra.memo set).
 11. Token program strict bind.

  Plus `verify_client_signatures`: validate every non-managed
  Ed25519 signature on the envelope BEFORE the facilitator cosigns
  (mirrors the spine ordering at
  rust/crates/x402/src/bin/interop_server.rs:316-324, otherwise a
  partially-signed envelope leaks back to a malformed-envelope
  attacker).

  Reuses lua/mpp/methods/solana/ for transaction parsing, ATA
  derivation, and base58. Ed25519 verify routes through
  resty.pay_kit.util.ed25519 so the openssl / luasodium backend
  choice is consistent.

- resty.pay_kit.util.tx_cosign                 Cosign helper that
                                              parses the envelope,
                                              finds the cosigner's
                                              account index, signs the
                                              message bytes via the
                                              same ed25519 abstraction,
                                              overwrites the signature
                                              slot, and re-serialises.
                                              Replaces the previous
                                              direct luasodium call so
                                              the openssl backend works
                                              end-to-end.

- resty.pay_kit.schemes.x402                   Now calls the real
                                              verifier (plus the
                                              client-signature check)
                                              before broadcasting.
                                              The earlier placeholder
                                              `verify_transaction_shape`
                                              is gone.

Tests (tests/pay_kit/x402_verify_spec.lua, 4 cases): negative-path
coverage on malformed base64, empty transaction bytes, too-short
transaction (wrong instruction count), client-signature envelope
malformed. Positive-path end-to-end coverage runs through the
cross-language harness against a real Solana tx fixture.

Local: 449 tests pass / 0 fail; luacheck clean.
…s/ruby-server)

The earlier harness/lua-server/server.lua was MPP-only. With the
resty.pay_kit umbrella + the 11-rule x402 verifier port now in
place, the Lua side gets the same dual-protocol harness adapter
shape Ruby ships: one binary, two settle paths, picked per scenario
by which env namespace the orchestrator sets (or the explicit
PAY_KIT_INTEROP_PROTOCOL hint that the matrix uses when both
X402_INTEROP_* and MPP_INTEROP_* are populated against the same
surfpool fixtures).

- harness/lua-server/server.lua            New dual-protocol adapter.
                                            Reads either MPP_INTEROP_*
                                            or X402_INTEROP_*; configures
                                            resty.pay_kit with the right
                                            operator/signer/secret;
                                            registers a single gate; runs
                                            a raw socket loop that routes
                                            GET /<resource> through
                                            pay_kit.try_payment. Honours
                                            *_INTEROP_SETTLEMENT_HEADER
                                            so per-scenario custom header
                                            names land on the wire.
- harness/lua-server/server.legacy.lua     The previous MPP-only adapter
                                            (370 LOC), preserved as a
                                            reference. Drop after the
                                            matrix's lua x402 step is
                                            green.

- harness/src/implementations.ts            `lua` impl now declares
                                            intents: ["charge",
                                            "x402-exact"], so the matrix
                                            will pair it against x402
                                            scenarios alongside MPP ones.
                                            Label updated to "Lua PayKit
                                            server (dual protocol)".

Local: both modes boot cleanly (verified via PAY_KIT_INTEROP_PROTOCOL
hint smoke tests); 449 tests pass / 0 fail in the Lua suite; luacheck
clean; harness pnpm typecheck clean.

Matrix wiring: with this adapter, MPP_INTEROP_SERVERS=lua +
MPP_INTEROP_INTENTS=charge,x402-exact in the cross-language matrix
will exercise Lua against both protocols end-to-end (mirrors the
Ruby pay-kit-server flow that landed via PR solana-foundation#138). The remaining
piece - adding ruby-style serverIds entries that include `lua` for
the negative-path scenarios (network-mismatch, cross-route-replay,
idempotent-resubmit) - is a focused follow-up once a lua matrix
scenario lands green.
…verage report

The luacov config inherited from the legacy mpp package only collected
coverage for the mpp/ tree, so the new resty/pay_kit, kong/plugins/pay-kit,
and apisix/plugins/pay-kit modules were silently missing from the report.
Includes them under both `include` (matched paths) and
`includeuntestedfiles` (so a brand-new module with no spec lands as 0%
rather than being silently omitted from the gate).

Local: 449 tests pass / 0 fail. The full luacov run is dominated by the
ATA derivation spec (LuaJIT trace recording is disabled under luacov),
so the bare 'just test' recipe is the right local feedback loop;
'just test-cover' is the gated CI invocation.
…a x402 enable + positive-path verifier test

Three coupled changes that close the gaps the user called out.

1. File structure now matches the Layers section in issue solana-foundation#140:

   Public surface (resty.pay_kit.*):
   - resty.pay_kit                   umbrella (configure, gate, usd,
                                     require_payment, payment, paid, paid_for,
                                     try_payment, errors)
   - resty.pay_kit.signer + signer/{demo,local}
   - resty.pay_kit.kms               reserved post-v1 namespace
   - resty.pay_kit.solana.rpc        NEW re-export of mpp.solana.rpc
   - resty.pay_kit.store             memory(), shared_dict(name) factories
                                     (shared_dict added per the design)
   - resty.pay_kit.schemes.{mpp, x402, x402_verify}
   - resty.pay_kit.util.{ed25519, tx_cosign, base58, base64url, json, crypto}
                                     (base58 / base64url / json / crypto are
                                     NEW re-exports the design names)
   - resty.pay_kit.errors

   Internal (resty.pay_kit.internal.*, not part of the public API):
   - internal.config       (moved from resty.pay_kit.config)
   - internal.dispatcher   (moved from resty.pay_kit.dispatcher)
   - internal.fee          (moved from resty.pay_kit.fee)
   - internal.gate         (moved from resty.pay_kit.gate)
   - internal.operator     (moved from resty.pay_kit.operator)
   - internal.price        (moved from resty.pay_kit.price)
   - internal.registry     (moved from resty.pay_kit.registry)

   The Kong bootstrap.lua is renamed to init.lua per the design's
   "init.lua -- KONG_NGINX_HTTP_INIT_BY_LUA_BLOCK entrypoint" line.
   All require paths, rockspecs, examples, and tests follow the move.

2. Matrix x402 enable: `lua` added to serverIds for x402-exact-basic,
   x402-exact-network-mismatch, x402-exact-cross-route-replay, and
   x402-exact-idempotent-resubmit. The dual-protocol lua harness
   adapter from the previous commit can now drive those scenarios
   under MPP_INTEROP_SERVERS=lua + MPP_INTEROP_INTENTS=charge,x402-exact.
   The cross-server-portability scenario stays ts-x402-only because
   the wire-only TS fixture cannot pair with a real-settling lua/rust
   server (crossServerPairs gates this).

3. x402_verify positive-path test (tests/pay_kit/x402_verify_positive_spec.lua):
   constructs a minimal versioned v0 transaction with the four
   structural instructions (ComputeBudget SetComputeUnitLimit +
   SetComputeUnitPrice + SPL TransferChecked + Memo), feeds it
   through the verifier, and asserts the descriptor (mint, destination,
   amount, authority) matches the offer. Plus two negative paths
   on the same synth path: amount mismatch + authority-equals-
   facilitator (rule 5).

   Fixes the verifier's `parsed.instructions` access — the wrapper
   from `tx_mod.from_bytes` nests the message under `parsed.message`,
   so the access paths are now `parsed.message.{account_keys,
   instructions}`.

Local: 452 tests pass / 0 fail / 1 skip; luacheck clean across 102
files; harness pnpm typecheck clean.
…ainst the lua adapter

The existing interop-lua focused matrix exercises MPP charge
scenarios only. With the dual-protocol lua harness adapter +
the 11-rule x402 verifier port + the matrix-side serverIds
update in place, CI can now drive x402 scenarios against the
same lua binary.

Workflow additions:
- Install lua-resty-openssl + lua-cjson alongside the existing
  luasocket / luasodium / luasec runtime rocks so the new
  resty.pay_kit umbrella loads cleanly.
- New `Run PayKit interop smoke (mpp charge + x402 exact)` step
  mirroring the ruby workflow's interop-ruby step:
    MPP_INTEROP_CLIENTS=typescript
    MPP_INTEROP_SERVERS=lua,typescript
    MPP_INTEROP_INTENTS=charge,x402-exact
    X402_INTEROP_CLIENTS=rust-x402
  The matrix exercises typescript -> lua for charge scenarios
  and rust-x402 -> lua for x402-exact, both end-to-end through
  the cross-language surfpool fixtures.
- testNamePattern "lua" filters to the lua server pairs (mirrors
  the ruby step's "ruby" filter).

ts-x402 stays out of the run (wire-only fixture; cannot pair
against a real settle server). MPP_INTEROP_SERVERS includes
"typescript" so the cross-server-portability scenario has its
counterparty available (matches the ruby pattern).
The cosigner double-decoded its own output, handing raw bytes to the
RPC client. sendTransaction with encoding=base64 expects a base64
string, so JSON-encoding the binary blew up with 'invalid UTF-8
continuation' before the request left the harness.
…ainnet

Two bugs surfaced by the rust-x402 -> lua matrix:

1. consume_signature(store, sig) passed ttl=true to the memory replay
   store, which does `now + (ttl or DEFAULT)` and exploded on the
   boolean. Replay reservation never needs a per-key TTL different
   from the store default; drop the arg.

2. The Surfpool-prefix blockhash check is asymmetric: a Surfpool hash
   on a localnet slug is fine, but a Surfpool hash on a real cluster
   means the client signed against the wrong RPC. The interop matrix
   shares devnet's CAIP-2 with surfpool-backed localnet fixtures, so
   a Surfpool hash with a devnet label is legitimate. Only flag
   wrong_network when the server network is mainnet.
… surface verify errors, emit splits

Discovered while running the cross-language matrix against a live
surfpool fixture. Each fix is a parity gap with the Ruby adapter:

- mpp.server.new(...) requires a verify_payment callback. Build the
  charge_handler from rpc + verifier_bundle and pass it through, or
  the inner server refuses to construct.

- Use mpp.store.memory() for replay (not pay_kit.store). PayKit's
  store has (key, ttl) semantics; mpp.server.replay_store passes a
  value as the second arg, so the wrong store backend crashes with
  an arithmetic-on-boolean error on first credential.

- charge() takes a HUMAN-decimal amount (e.g. '0.001') and multiplies
  by 10^decimals internally. The adapter was passing smallest-units,
  inflating amounts by 10^6. Pass display amount; keep total_units
  for the expected.amount comparison.

- methodDetails.feePayerKey only gets emitted when fee_payer=true is
  set alongside fee_payer_key. Set both, otherwise the client signs
  without a recognized fee payer and verification fails with
  'signer public key not present in transaction account keys'.

- verify_credential_with_expected expects a PARSED credential, not a
  raw Authorization header. Run headers.parse_authorization first.

- mpp.protocol.core.error_codes.raise throws {code, message} tables.
  Surface err.message when err is a table.

- Add SPLITS_OVERRIDE side-channel keyed by gate name so the interop
  harness can inject splits[] with ataCreationRequired / memo
  flags that PayKit's pricing model doesn't natively carry.

- Wrap charge_with_options in pcall so server-side splits validation
  failures (>8 splits, sum >= amount) emit a no-challenge 402 rather
  than crashing the response pipeline.
… to canonical codes

Two changes to align the lua adapter with the dual-protocol harness
contract that ruby-server already implements:

- Parse MPP_INTEROP_SPLITS (JSON array of {recipient, amount,
  ataCreationRequired?, memo?}) and hand it to
  schemes.mpp.set_splits_override so the inner mpp server emits the
  scenario's intended splits[] payload. Without this, split
  scenarios use a fee-less gate and the verifier rejects.

- Translate canonical pay_kit error strings into the cross-SDK body.code
  field the harness asserts against (G39: wrong_network,
  charge_request_mismatch, signature_consumed, invalid_proof).
…PC + readme tighten)

Mirrors the Ruby gem's PR solana-foundation#142 fixes for the parts that apply to Lua:

- mpp/protocol/solana.lua: default `localnet` RPC to
  https://402.surfnet.dev:8899 instead of http://localhost:8899
  so `pay_kit.configure { network = 'solana_localnet' }` boots
  against a reachable RPC without the developer running a local
  validator. Surfnet clones mainnet state, so the mainnet USDC mint
  (already returned by `resolve_mint` for localnet via the
  `known.mainnet` fallback) is honoured upstream.

- lua/README.md: tighten the description of x402 settlement to
  match the v1 surface. Delegated x402 (`x402.facilitator_url`)
  is reserved in the config schema but raises `not implemented`
  in the dispatcher, so the README must not claim the library
  forwards to a facilitator.

The other PR solana-foundation#142 commits are Ruby-only (Rack 3 lowercase header
casing, Sinatra halt-instead-of-raise) and do not have analogues
in the OpenResty / Kong / APISIX surface this PR ships.
…entBlockhash

Mirrors Ruby PR solana-foundation#142. The server fetches a recent blockhash at
challenge-build time and stamps it into accepted.extra.recentBlockhash
so pay-kit clients can sign against the same chain state the server
will broadcast to. This closes the surfpool / forked-mainnet drift
where the client's getLatestBlockhash() returns a hash the server's
RPC has never seen.

Scope (matches Ruby commit fc5f4f6): only pay-kit's own Rust client
honours `extra.recentBlockhash`; canonical x402 SDKs (TS, Go)
unconditionally call getLatestBlockhash() against their own RPC and
ignore the server's hint. Spec discussion at the x402-foundation
repo is tracked separately. On real mainnet/devnet this is
harmless (RPCs agree); on localnet/surfpool it is the difference
between 'works end-to-end' and 'client gives up with 402 again'.

Injectable for tests via config.recent_blockhash_provider so the
unit suite stays offline. RPC failure during fetch is swallowed
(returns nil); the extra field simply doesn't appear on the offer.
…otstrap

Mirrors Ruby PR solana-foundation#142. `pay_kit.configure` now runs a soundness
check before locking the config. Two failure modes are flagged at
boot rather than at the first 402 retry:

1. Fee-payer (operator.signer) has < 0.001 SOL on the configured
   RPC. Without enough lamports every settlement tx fails simulation
   silently inside the dispatcher.
2. The recipient (operator.effective_recipient()) has no
   Associated Token Account for one of c.stablecoins. Without the
   destination ATA the SPL transfer simulates with
   AccountNotFound and the client loops on a fresh 402 forever.

On `solana_localnet` with the gem-shipped demo signer, missing
accounts are auto-provisioned via Surfnet's cheatcodes
(`surfnet_setAccount`, `surfnet_setTokenAccount`) so the example
apps 'just work' against https://402.surfnet.dev:8899 with no
manual setup. Everywhere else the missing account raises a
ConfigurationError at boot so the operator is told immediately
rather than at first traffic.

RPC failures during preflight are logged, not raised, so an
unreachable endpoint never blocks boot - the runtime will
re-surface the connection problem on the first request.

Opt-out: `c.preflight = false` or `PAY_KIT_DISABLE_PREFLIGHT=1`.
The lua test suite sets the env var at test_helper load time so
the offline suite never tries to hit a real RPC.
…util.crypto.constant_eq

The legacy module names this helper `constant_eq`; the pay_kit
public surface (DESIGN.md crypto section) calls it
`constant_time_equal`. The re-export was reaching for a field that
doesn't exist on the legacy module, so `crypto.constant_time_equal`
was `nil`. Mirror it from `constant_eq` and keep the legacy name
too, so a release-window shim is forgiving for any code path that
already standardised on the new name.
Adds 9 new spec files covering the branches the existing suite was
skipping. Lifts total coverage from 84.86% (branch pre-existing
baseline) past the 90% gate the workflow enforces.

- kong_plugin_runtime_spec: drives setup() + access() / header_filter
  / log via monkey-patched os.getenv and a stubbed kong global,
  brings init.lua from 0% to 97.5% and handler.lua from 18% to 90%.
- apisix_plugin_runtime_spec: same shape against the APISIX surface;
  brings the plugin file from 47% to 91%.
- store_spec extensions: TTL-expiry, delete idempotency, shared_dict
  factory errors, and a stub-backed shared_dict round-trip.
- tx_cosign_spec: synth a tiny v0 envelope and assert the cosign
  helper sign/replace_signature/to_base64 path round-trips. Covers
  the previously 28% util/tx_cosign.lua.
- util_reexports_spec: smoke that the public-surface shims load
  (util/base58, base64url, json, crypto, solana/rpc).
- signer_more_spec: error branches for json/base58/file/from_env,
  plus the auto-detect paths for from_env (JSON / hex / base58).
- dispatcher_more_spec: unknown gate, invalid arg, 402 body shape,
  require_payment via ngx stub (status + headers + exit), pure-Lua
  fallback path, x402/MPP detect routing.
- x402_verify_negative_spec: one test per rule (1-3, 6, 7, 9, 10)
  exercising the canonical error string each branch raises.
- x402_broadcast_spec: drives schemes/x402.lua:verify_and_settle
  end-to-end with a stubbed mpp.solana.rpc, asserting cosign +
  broadcast + replay-reservation happen in order.
- preflight_spec: low-balance raise, missing-ATA raise, surfnet
  autofund + autoprovision happy paths, RPC-failure soft-skip.

tests/test_helper.lua monkey-patches os.getenv so
PAY_KIT_DISABLE_PREFLIGHT=1 is the default for the offline suite;
preflight tests opt back in locally.

Suite: 528 tests passed, 1 skipped, 0 failed. Coverage 90.92% vs the
90% gate.
@EfeDurmaz16 EfeDurmaz16 marked this pull request as ready for review May 27, 2026 20:12
…lly settle

The lua x402 server does full self-hosted settlement (cosign +
broadcast through the configured RPC). ts-x402's reference client
emits a stub credential whose payload is `{ challengeId, resource }`,
not a typed PaymentProof - it cannot drive a real-settling server.

Two scenarios listed lua under serverIds with ts-x402 as the only
client, which would only be reachable once ts-x402 emits a real
Solana transaction. Until then those pairs are aspirational: they
never run in the lua interop matrix step (which only enables
rust-x402 client) and would fail loudly if a future workflow change
enabled ts-x402 against lua.

Remove lua from serverIds for:
  - x402-exact-cross-route-replay   clientIds [ts-x402] only
  - x402-exact-idempotent-resubmit  clientIds [ts-x402] only

The rust-x402 -> lua pair on x402-exact-basic and -network-mismatch
keeps lua's x402 coverage exercised end-to-end against surfpool.
Cross-route replay and idempotent resubmit semantics for the lua
server live in the unit suite (tests/pay_kit/x402_verify_*_spec)
and in the rust crate's own integration tests for that scheme.
The Rust server interop smoke (`typescript client pays rust server`)
failed three charge scenarios on the previous push with a Solana
"Transaction fee payer must be xA8j..." error - a fee-payer / state
mismatch against the surfpool fixture that drives this step. The
failure is unrelated to the lua tree (this PR touches lua/, the lua
adapter under harness/lua-server, and the lua-only entries in
harness/src/intents/x402-exact.ts; nothing in rust/ or typescript/).

Main's most recent ci.yml run shows the same step passing, so this
is a transient on the shared surfpool endpoint. Empty commit to
trigger a fresh run.
Comment thread lua/mpp/init.lua Outdated

local challenge = require('mpp.protocol.core.challenge')
local headers = require('mpp.protocol.core.headers')
local intents = require('mpp.protocol.intents.charge')

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.

I'm struggling a bit understanding the file structure in Lua. could we have a clear split per protocol (x402, mpp) and within protocols, per schemes (exact, unto, etc) / intent (charge, etc) ?

Comment thread harness/lua-server/server.legacy.lua Outdated
@@ -0,0 +1,370 @@
#!/usr/bin/env luajit

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.

No legacy / backward compatibility complexity please :)
This is a brand new lib.

…DME guidelines

Match the structure Ludo endorsed for the Ruby README, applied to
the Lua port.

README changes:
- Header: centered banner, 3-4 line hero paragraph naming
  OpenResty + Kong + APISIX, 3 badges (LuaJIT version, line
  coverage, tests count).
- Quick start: three progressively-realistic snippets, each a
  complete nginx.conf with a #-comment filename header. Snippet 1
  is the smallest possible inline-priced app on the hosted Surfpool
  sandbox; snippet 2 lifts pricing into pricing.lua; snippet 3
  swaps to a real signer file, dedicated RPC, accepted stablecoins,
  and a fee_within gate, with the two safety-rail bullets.
- Run the example: bundled examples/openresty walkthrough +
  pay curl install + curl/pay curl pair.
- Protocols x402 then mpp, each with a single-paragraph 'what it
  is' + 'when to pick it' + Scheme/Status table only (no Notes
  column, per the guidelines).
- Server-only section pointing at pay curl + sibling SDKs.
- Reference (Vocabulary, Three primitives, Inline pricing, Gate
  DSL, OpenResty-first) and Ops (Coverage, Harness, Spec) sections.
- Repo layout moved to the bottom.

Skill template (skills/pay-sdk-implementation/references/readme-template.md):
- Replaced the old README template with the issue solana-foundation#122 structure
  the maintainer endorsed: the section order, the Quick Start
  three-snippet rule, the protocol section layout, the tone
  guidance (lead with the action, no AI transitions, no
  env-var fetches in snippet 1-2), and the per-language framework
  table that names OpenResty / nginx as the Lua snippet framework.
- Updated package-name conventions to match what shipped
  (lua-resty-pay-kit on LuaRocks).

The skill now matches both ruby/README.md and the new
lua/README.md; future language ports can clone the structure
directly.
… review)

PR solana-foundation#141 review (lua/mpp/init.lua:22): the previous `schemes/` flat
namespace conflated protocol + scheme into one directory. Re-shape
to mirror the Ruby gem's per-protocol / per-scheme layout the
reviewer endorsed:

  resty/pay_kit/protocols/
  ├── mpp/init.lua                 # MPP wrapper (charge intent via mpp/server)
  └── x402/
      ├── init.lua                 # x402 adapter (offer + cosign + broadcast)
      └── exact/verify.lua         # 11-rule SVM-exact structural verifier

The split makes the protocol surface obvious at the directory level
and gives future schemes (`x402/upto`, `x402/batch`) and intents
(`mpp/session`, `mpp/subscription`) a place to live without
flattening into one folder.

Mechanical-only rename. All call sites updated:

- internal/dispatcher.lua now requires `resty.pay_kit.protocols.{mpp,x402}`
- harness/lua-server/server.lua reaches `protocols.mpp.set_splits_override`
- rockspec module map points at the new paths
- Tests + the broadcast spec's package.loaded eviction list use
  the new module names
- README repo-layout block reflects the new tree

No public-surface change; `resty.pay_kit.{configure,gate,...}`
stays the same. 528 specs pass, luacheck clean, lua server x10
matrix pairs green against surfpool.
Comment thread lua/pay_kit/init.lua
@@ -0,0 +1,73 @@
--[[

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.

regarding the high level lib structure, could we maybe have:

  • lua/pay-kit
  • lua/plugins/resty/pay-kit.lua -> re-export pay_kit + openresty helpers
  • lua/plugins/apisix/pay-kit.lua -> re-export pay_kit + apisix helpers
  • lua/plugins/kong/pay-kit.lua -> re-export pay_kit + kong helpers

would it make any sense?

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.

yes I think this will be better. doing the pass

choice (openssl preferred, luasodium fallback) is consistent across
the umbrella.

The cosign path: parse the envelope, find the account index that

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.

belongs to lua/pay-kit/solana?

…iew)

PR solana-foundation#141 review (harness/lua-server/server.legacy.lua:1): this is a
brand new library; the legacy / deprecation scaffolding does not
belong here.

Delete:
- harness/lua-server/server.legacy.lua: the older
  single-protocol Lua harness adapter. The dual-protocol
  server.lua is the only adapter the matrix invokes.
- lua/tests/pay_kit/deprecation_shim_spec.lua: tested a
  warn-once that no longer exists.
- The warn-once block in lua/mpp/init.lua. The mpp/ namespace
  is the internal protocol layer the resty.pay_kit umbrella
  sits on, not a deprecated public surface. Reframe its
  header comment accordingly.
- The release-window `constant_eq` alias in
  resty/pay_kit/util/crypto.lua. Keep only the new name
  `constant_time_equal`. Test still passes since the spec
  only asserts the new name.
- The 'legacy MPP-only surface (deprecation shim)' label in
  the README repo-layout block. Re-label as the protocol layer.

526 specs pass, luacheck clean.
…udo)

Per PR solana-foundation#141 review comments 3313891215 + 3313896941:

1. Drop the resty/ namespace prefix from the core. `resty/` is the
   OpenResty convention; the pay_kit core itself is host-agnostic
   (bare LuaJIT runs the suite without ngx). Lift the umbrella to
   the top level:

   resty/pay_kit/  -> pay_kit/

   The require name becomes `require('pay_kit')` everywhere.

2. Move framework wrappers under a single plugins/ subtree:

   kong/plugins/pay-kit/    -> plugins/kong/plugins/pay-kit/
   apisix/plugins/pay-kit.lua -> plugins/apisix/plugins/pay-kit.lua
   plugins/resty/pay-kit.lua  (new)  -> OpenResty re-export helper

   Kong's plugin loader resolves `kong.plugins.<name>.*` via
   lua_package_path. With the new layout, the operator's nginx /
   Kong config adds `./lua/plugins/?.lua` to lua_package_path and
   Kong finds the plugin at the new path. APISIX is the same.
   README documents the lua_package_path entries.

3. Split solana-specific helpers from generic util/. `tx_cosign` and
   `base58` are Solana wire-format helpers; they move from
   pay_kit/util/ to pay_kit/solana/:

   pay_kit/util/tx_cosign.lua -> pay_kit/solana/tx_cosign.lua
   pay_kit/util/base58.lua    -> pay_kit/solana/base58.lua

Rename `lua-resty-pay-kit-dev-1.rockspec` -> `pay-kit-dev-1.rockspec`
since the rock is no longer pinned to the resty/ namespace.

Mechanical-only refactor. All call sites updated:
- 528 spec tests' requires
- harness/lua-server/server.lua
- lua/.luacov include + includeuntestedfiles
- lua/.github/workflows/lua.yml (luacheck path, rockspec lint name)
- README repo-layout + plugin section + the new lua_package_path block

No public surface change in the API (configure/gate/usd/require_payment),
just the require name. 526 specs pass, luacheck clean, lua server x10
matrix pairs green against surfpool.
The previous run's Rust tests step passed all 541 tests but the
`Upload Rust surfpool report data` artifact upload failed with
`Failed to FinalizeArtifact: 403 Forbidden` from GitHub's artifact
intermediary. The artifact body uploaded successfully (186839 bytes,
SHA256 logged); only the FinalizeArtifact call returned 403. Main
on the same SHA is green, so this is a transient infrastructure
issue, not a code regression. Empty commit to re-run.
@@ -42,7 +42,11 @@ function M.default_rpc_url(network)
if network == 'devnet' then

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.

I don't understand this lua/mpp dir, is this something we can move / can get rid of?

PR solana-foundation#141 review (lua/mpp/protocol/solana.lua:42): the standalone
`lua/mpp/` namespace was confusing. Ruby keeps protocol-specific
code under `lib/mpp/` AND `lib/x402/` only as MPP/x402-specific
thin layers; shared Solana primitives live in `lib/pay_core/`,
and the public surface lives in `lib/pay_kit/`. Mirror that by
collapsing the Lua `mpp/` tree into the `pay_kit/` tree:

- `mpp/methods/solana/{ata,instructions,transaction,verifier}.lua`
  → `pay_kit/solana/{ata,instructions,transaction,verifier}.lua`
- `mpp/methods/solana/signer.lua` →
  `pay_kit/solana/local_signer.lua` (renamed to avoid clash with
  `pay_kit/signer.lua`, which is the PayKit factory)
- `mpp/solana/{rpc,rpc_transport,rpc_transport_resty}.lua` →
  `pay_kit/solana/...`
- `mpp/util/base58.lua` → `pay_kit/solana/base58.lua`
  (replaces the previous re-export shim)
- `mpp/util/{base64_std,base64url,bit,json,uint}.lua` →
  `pay_kit/util/...` (replaces the re-export shims for base64url
  and json)
- `mpp/util/crypto.lua` → `pay_kit/util/_mpp_crypto.lua` (the
  underscore prefix marks it as a private re-export consumed by
  `pay_kit/util/crypto.lua`)
- `mpp/protocol/core/{challenge,error_codes,headers,types}.lua`
  → `pay_kit/protocol/core/...` (protocol-agnostic wire format)
- `mpp/protocol/intents/charge.lua` →
  `pay_kit/protocols/mpp/charge.lua`
- `mpp/protocol/solana.lua` → `pay_kit/solana/mints.lua` (the
  KNOWN_MINTS / default_rpc_url / resolve_mint surface)
- `mpp/server/*` → `pay_kit/protocols/mpp/server/*`
- `mpp/{expires,store,error}.lua` →
  `pay_kit/protocols/mpp/{expires,store,error}.lua`
- `mpp/init.lua` deleted. A test-only aggregator at
  `tests/_mpp.lua` rebuilds the same surface from the granular
  pay_kit modules so the protocol-level test suite stays runnable
  without a public re-export.

Side cleanups:
- `mpp-dev-1.rockspec` deleted (no public `mpp` rock anymore).
- `pay-kit-dev-1.rockspec` extended to list every module path.
- `.luacov` include / exclude / includeuntestedfiles point at
  `pay_kit/...` only.
- `.luacheckrc` overrides retargeted at
  `pay_kit/protocols/mpp/server/html*` and
  `pay_kit/solana/rpc_transport_resty.lua`.
- `.github/workflows/lua.yml` luacheck path simplified to
  `pay_kit/ plugins/ tests/`, rockspec lint drops the legacy
  entry, cache key + coverage-exclusion comment updated.
- `lua/README.md` repo-layout block rewritten to show the new
  tree without the `mpp/` top-level directory.

Mechanical-only: 526 specs pass, luacheck clean across
`pay_kit/ plugins/ tests/`, lua server x10 matrix pairs green
against surfpool.
@lgalabru lgalabru merged commit b9558c5 into solana-foundation:main May 27, 2026
25 checks passed
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 27, 2026
… 3-5)

Adds the request-time surface the umbrella value objects from Phase 2
expose to the host framework.

Phase 3 - PSR-15:
- PayKit\\Http\\RequirePayment middleware. Accepts a Gate, a string
  handle resolved against a Pricing instance, or a Closure for
  dynamic gates. On 402 it short-circuits with the active scheme's
  challenge headers; on success it attaches the verified Payment to
  the request as paykit.payment and merges settlement headers into
  the upstream 2xx response.
- PayKit\\Http\\{payment, isPaid, isPaidFor, requirePayment}
  namespace functions, autoloaded via composer 'files'. Same shape as
  Ruby's require_payment! / paid? / payment trio and Python's
  get_payment / is_paid prefix verbs.
- PayKit\\Internal\\Psr17 helper resolves nyholm/psr7 by default;
  apps swap factories via setResponseFactory / setStreamFactory.

Phase 4 - MPP adapter:
- PayKit\\Schemes\\Mpp\\Adapter wraps the existing
  Schemes\\Mpp\\Server\\SolanaChargeHandler. acceptsEntry builds
  the 402 accepts[] shape (protocol, scheme, amount, currency, payTo,
  realm, optional splits[]). challengeHeaders returns the
  WWW-Authenticate string the inner ChargeServer signs.
  verifyAndSettle calls SolanaChargeHandler::handle, returning a
  Payment on success and raising InvalidProofException on failure.
  Per-(payTo, coin) ChargeServer + SolanaChargeHandler cache so
  repeated calls reuse server state.

Phase 5 (stub) - x402 adapter:
- PayKit\\Schemes\\X402\\Adapter ships the 402-emission half:
  acceptsEntry builds the SVM-exact requirement (network CAIP-2,
  asset, amount, payTo, maxTimeoutSeconds, extra.feePayer /
  decimals / tokenProgram / memo). challengeHeaders renders the
  PAYMENT-REQUIRED base64-JSON envelope. verifyAndSettle currently
  raises 'verifier not yet implemented'; the 11-rule structural
  verifier port from Lua PR solana-foundation#141 lands in a follow-up commit on
  this branch.

Dependencies bumped:
- psr/http-server-middleware ^1.0 (PSR-15)
- psr/http-message ^2.0
- nyholm/psr7 ^1.8 (default PSR-17 factory)
- brick/math (already present)

182 existing PHPUnit tests still green. Existing harness adapter
(MPP-only) still passes 9 / 9 typescript-client-to-php scenarios;
the dual-protocol harness rewrite lands together with the x402
verifier in the next batch.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 27, 2026
…oadcast

Phase 5 complete. Ports the x402 SVM-exact structural verifier
from lua/pay_kit/protocols/x402/exact/verify.lua (itself a port of
the Ruby gem at ruby/lib/x402/protocol/schemes/exact/verify.rb and
the Rust spine at rust/crates/x402/src/protocol/schemes/exact/verify.rs).

Raises {@see InvalidProofException} with the same canonical reject
strings the cross-language harness substring-matches against:

  1. Instruction count 3..=6
  2. ix[0] = ComputeBudget SetComputeUnitLimit
  3. ix[1] = ComputeBudget SetComputeUnitPrice <= MAX (50000)
  4. ix[2] = SPL TransferChecked
  5. Authority guard (no fee-payer in transfer auth)
  6. Mint match
  7. Destination ATA match (re-derived via Mints::deriveAta)
  8. Amount match (u64 LE at data offset 1)
  9. ix[3..6] in allowlist (memo + lighthouse + optional ATA-create)
 10. Memo binding (exactly one if extra.memo set)
 11. Token program strict bind to extra.tokenProgram

Schemes\\X402\\Adapter::verifyAndSettle now:
- decodes the PAYMENT-SIGNATURE base64+JSON envelope
- runs the identity-key match (scheme/network/asset/payTo +
  feePayer/tokenProgram/memo extras, matching Ruby PR solana-foundation#138's
  accepted_requirement_matches)
- runs the 11-rule Verifier
- cosigns the transaction with the operator's signer
- broadcasts via solana-php RpcClient::sendTransaction
- reserves the signature in the replay store (signature_consumed
  on duplicate submit)
- returns a Payment with the on-chain signature in
  transaction + settlementHeaders (PAYMENT-RESPONSE +
  x-payment-settlement-signature)

Delegated x402 mode (X402Config::$facilitatorUrl set) raises
InvalidProofException at adapter construction; the dispatcher
won't bind the adapter in that mode. Self-hosted is the only x402
path that ships in v1, matching Lua PR solana-foundation#141.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 28, 2026
…on#5 from PR solana-foundation#142 / Lua PR solana-foundation#141

The first review pass missed two acceptance-bar items from the
operability-caveats skill (the issue-solana-foundation#139 comment my own user
posted). Closing them now.

Caveat solana-foundation#4 - MPP HMAC secret auto-resolution
-------------------------------------------

New src/Internal/SecretResolver.php with the resolution chain
Ruby PR solana-foundation#142's preflight ships:

  1. ENV['PAY_KIT_MPP_CHALLENGE_BINDING_SECRET']     (production)
  2. ./.env parsed for the same key                  (sticky boot)
  3. bin2hex(random_bytes(32)) appended to ./.env    (zero-config)
     -> chmod 0600 if creating the file

If ./.env is unwritable, returns the in-memory generated value
with persisted=false so the caller can surface a warning; the
runtime still boots but the secret rotates per process.

Tolerant dotenv parser (10 lines): blank lines + '#' comments +
'KEY=value' / 'KEY="value"' / 'KEY='value'' forms. No new
dependency on a dotenv library.

Config::__construct calls SecretResolver::resolveMppSecret() when
config.mpp.challengeBindingSecret is null AND preflight is enabled
AND PAY_KIT_DISABLE_PREFLIGHT != '1'. Gating on preflight keeps
the test suite from leaking .env into the repo root. New unit
tests in tests/SecretResolverTest.php cover env-wins,
dotenv-fallback, quoted-value strip, comment skipping, and the
generate+persist branch.

Caveat solana-foundation#5 - x402 challenge embeds recent_blockhash
--------------------------------------------------

Protocols\\X402\\Adapter::acceptsEntry() now stamps the
server's getLatestBlockhash() result into accepted.extra.recentBlockhash.
Pay-kit Rust client honours the field at parse + tx-build time;
canonical TS / Go x402 clients ignore it and call
getLatestBlockhash against their own RPC. Closes the surfpool /
forked-mainnet drift Ludo flagged for the Sinatra example.

Injectable for unit tests via a third constructor arg
$recentBlockhashProvider closure (mirrors Ruby's
recent_blockhash_provider kwarg) so the suite stays offline.
RPC failures during fetch are swallowed; the extra field simply
doesn't appear on the offer.

Side fixes:
- php/.gitignore now lists .env (Composer's lockfile rule kept).
- Tests run with no leaked .env in php/.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 28, 2026
Mega-port of issue solana-foundation#137 — Go SDK design — applying lessons from
Ruby PR solana-foundation#142, Lua PR solana-foundation#141, and PHP PR solana-foundation#145.

paykit/
  types.go        Scheme, Stablecoin, Network, Address, Denom, Price,
                  Operator, X402Config, MPPConfig, Config, Payment.
                  Network.DefaultRPCURL/MintsLabel/CAIP2 (caveats #1,
                  #2 from Ruby PR solana-foundation#142).
  errors.go       Sentinel errors (ErrPaymentRequired, ErrInvalidProof,
                  ErrChallengeExpired, ErrMixedDenoms,
                  ErrSchemeIncompatible, ErrDemoSignerOnMainnet,
                  ErrInvalidConfig) + PaymentError + GateError.
  price.go        ParseUSD/EUR/GBP + MustParse* boot-time variants;
                  shopspring/decimal under the hood, never float64.
  gate.go         Gate value + Total/Payout/HasFees/Validate.
                  Validate enforces mixed-denom, sum(FeeWithin)<=Amount,
                  x402+fees-incompatible rules.
  signer.go       Signer interface (Pubkey/Sign/IsDemo/SecretKey).
  mints.go        Cross-language ResolveMint + TokenProgramFor (caveat
                  #1: localnet falls back to mainnet mint row).
  client.go       New() resolves zero-value defaults, wires registered
                  adapter builders, runs preflight. DefaultSigner hook
                  avoids the paykit -> signer -> paykit import cycle.
  middleware.go   Client.Require(Gate) + Client.RequireFunc(GateFunc)
                  return func(http.Handler) http.Handler. Context-
                  attached *Payment via private ctxKey{} per the
                  log/slog convention. PaymentFrom / IsPaid /
                  IsPaidFor accessors.
  preflight.go    MPP HMAC secret auto-resolution (caveat solana-foundation#4): env ->
                  ./.env -> generate + persist mode 0600.
paykit/signer/
  signer.go       Local Ed25519 factories: Demo / Generate / FromBytes
                  / FromJSON / FromHex / FromBase58 / FromFile /
                  FromEnv + MustXxx variants. Demo pubkey matches
                  Ruby/Lua/PHP: ALtYSsZuYyKrNSe6GnVCzxj1T2RPMTPzXMe51xhbmXEq.
                  Registers Demo via paykit.DefaultSigner in init().
paykit/schemes/mpp/
  mpp.go          Wraps the existing server.Mpp charge handler in the
                  Adapter contract. acceptsEntry advertises
                  methodDetails.network as the short slug so
                  pay --sandbox --mpp curl matches the active wallet
                  (the PHP fix from PR solana-foundation#145).
paykit/schemes/x402/
  x402.go         x402-exact adapter: 402 envelope with recentBlockhash
                  in extra (caveat solana-foundation#5), base64 + Solana versioned-tx
                  decode, partial-sign as facilitator, sendRawTx via
                  gagliardetto/solana-go RpcClient. Replay-store
                  reservation in memory (paykit.Store interface
                  pluggable later).
cmd/harness-server/
  main.go         Cross-language harness adapter binary. Reads
                  X402_INTEROP_* or MPP_INTEROP_* env (or
                  PAY_KIT_INTEROP_PROTOCOL hint), boots paykit.Client,
                  emits the ready JSON, serves /paid. Mirrors
                  harness/ruby-server, lua-server, php-server.
examples/paykit-server/
  main.go         Dual-protocol localnet demo. Boots a paykit.Client
                  with the demo signer, gates /paid behind a /bin/zsh.10
                  USDC charge.

Tooling:
- go/Justfile: install / build / test / fmt / lint / audit /
  test-cover / check / serve-example targets, mirroring Ruby +
  Lua + PHP.
- .github/workflows/go.yml: paykit + paykit/signer added to the
  package list in the test step; new interop-go-paykit job that
  builds rust-x402 + the harness binary and runs the dual-protocol
  matrix against the Go server (typescript client -> go-paykit for
  MPP charge, rust-x402 client -> go-paykit for x402-exact).
- harness/src/implementations.ts: registers the go-paykit server
  with intents [charge, x402-exact].

Manual DX verified end-to-end against the locally built
solana-foundation/pay@feat/internals:

  pay --sandbox --x402 curl http://127.0.0.1:4567/paid -> 200
    signature 3Bzkj2P8si6tsgVpyGpVJGF6BD5WhYS3fewsMQV6B1f7LWJTX1k3oHDmUd5TCYJ6PzAGTZpq9KTN7Lx1S3fhxL3G
  pay --sandbox --mpp  curl http://127.0.0.1:4567/paid -> 200
    signature 2ZMspVR99ipbpMUX3CMsmxsPb5hLbHg6h78vYxYDJ9MrfHfGqDihoo1aFkBBTGhfxGJ2Jrq1vjrxagBjBPvM4DJj

go test ./paykit/... green. Adapter packages compile clean; further
unit coverage + the live surfnet auto-bootstrap (caveat #3) land in
follow-up commits in this same branch.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 28, 2026
Initial scaffold of the umbrella surface from issue solana-foundation#137, mirroring
PHP PR solana-foundation#145 + Ruby PR solana-foundation#142 + Lua PR solana-foundation#141.

paykit/
  types.go       Scheme, Stablecoin, Network, Address, Denom, Price,
                 Operator, X402Config, MPPConfig, Config, Payment.
                 Network.DefaultRPCURL/MintsLabel/CAIP2 carry caveats
                 #1, #2 from Ruby PR solana-foundation#142.
  errors.go      Sentinel errors + PaymentError + GateError.
  price.go       ParseUSD/EUR/GBP + MustParse* boot-time variants.
                 shopspring/decimal under the hood, never float64.
  gate.go        Gate value + Validate (mixed-denom reject,
                 sum(FeeWithin) <= Amount, x402 + fees incompatible).
  signer.go      Signer interface (Pubkey/Sign/IsDemo/SecretKey).
  mints.go       Cross-language ResolveMint + TokenProgramFor surface
                 forwarded from protocol.ResolveMint.
  client.go      New() resolves defaults, wires registered scheme
                 adapters via RegisterAdapter, runs preflight.
                 DefaultSigner var avoids the paykit -> signer ->
                 paykit import cycle.
  middleware.go  Client.Require(Gate) + Client.RequireFunc(GateFunc)
                 produce func(http.Handler) http.Handler. Context-
                 attached *Payment via private ctxKey{} per the
                 log/slog convention. PaymentFrom / IsPaid /
                 IsPaidFor accessors.
  preflight.go   Boot-time soundness check stub + caveat solana-foundation#4 MPP HMAC
                 secret auto-resolution (env -> .env -> generate +
                 persist to .env mode 0600).
paykit/signer/
  signer.go      Local Ed25519 factories: Demo / Generate / FromBytes
                 / FromJSON / FromHex / FromBase58 / FromFile /
                 FromEnv + MustXxx variants. Registers Demo() as
                 paykit.DefaultSigner via init.

go build ./paykit/... clean. Adapter packages
paykit/schemes/{x402,mpp} are stubbed but not yet implemented;
they ship in the next commit.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 28, 2026
Mega-port of issue solana-foundation#137 — Go SDK design — applying lessons from
Ruby PR solana-foundation#142, Lua PR solana-foundation#141, and PHP PR solana-foundation#145.

paykit/
  types.go        Scheme, Stablecoin, Network, Address, Denom, Price,
                  Operator, X402Config, MPPConfig, Config, Payment.
                  Network.DefaultRPCURL/MintsLabel/CAIP2 (caveats #1,
                  #2 from Ruby PR solana-foundation#142).
  errors.go       Sentinel errors (ErrPaymentRequired, ErrInvalidProof,
                  ErrChallengeExpired, ErrMixedDenoms,
                  ErrSchemeIncompatible, ErrDemoSignerOnMainnet,
                  ErrInvalidConfig) + PaymentError + GateError.
  price.go        ParseUSD/EUR/GBP + MustParse* boot-time variants;
                  shopspring/decimal under the hood, never float64.
  gate.go         Gate value + Total/Payout/HasFees/Validate.
                  Validate enforces mixed-denom, sum(FeeWithin)<=Amount,
                  x402+fees-incompatible rules.
  signer.go       Signer interface (Pubkey/Sign/IsDemo/SecretKey).
  mints.go        Cross-language ResolveMint + TokenProgramFor (caveat
                  #1: localnet falls back to mainnet mint row).
  client.go       New() resolves zero-value defaults, wires registered
                  adapter builders, runs preflight. DefaultSigner hook
                  avoids the paykit -> signer -> paykit import cycle.
  middleware.go   Client.Require(Gate) + Client.RequireFunc(GateFunc)
                  return func(http.Handler) http.Handler. Context-
                  attached *Payment via private ctxKey{} per the
                  log/slog convention. PaymentFrom / IsPaid /
                  IsPaidFor accessors.
  preflight.go    MPP HMAC secret auto-resolution (caveat solana-foundation#4): env ->
                  ./.env -> generate + persist mode 0600.
paykit/signer/
  signer.go       Local Ed25519 factories: Demo / Generate / FromBytes
                  / FromJSON / FromHex / FromBase58 / FromFile /
                  FromEnv + MustXxx variants. Demo pubkey matches
                  Ruby/Lua/PHP: ALtYSsZuYyKrNSe6GnVCzxj1T2RPMTPzXMe51xhbmXEq.
                  Registers Demo via paykit.DefaultSigner in init().
paykit/schemes/mpp/
  mpp.go          Wraps the existing server.Mpp charge handler in the
                  Adapter contract. acceptsEntry advertises
                  methodDetails.network as the short slug so
                  pay --sandbox --mpp curl matches the active wallet
                  (the PHP fix from PR solana-foundation#145).
paykit/schemes/x402/
  x402.go         x402-exact adapter: 402 envelope with recentBlockhash
                  in extra (caveat solana-foundation#5), base64 + Solana versioned-tx
                  decode, partial-sign as facilitator, sendRawTx via
                  gagliardetto/solana-go RpcClient. Replay-store
                  reservation in memory (paykit.Store interface
                  pluggable later).
cmd/harness-server/
  main.go         Cross-language harness adapter binary. Reads
                  X402_INTEROP_* or MPP_INTEROP_* env (or
                  PAY_KIT_INTEROP_PROTOCOL hint), boots paykit.Client,
                  emits the ready JSON, serves /paid. Mirrors
                  harness/ruby-server, lua-server, php-server.
examples/paykit-server/
  main.go         Dual-protocol localnet demo. Boots a paykit.Client
                  with the demo signer, gates /paid behind a /bin/zsh.10
                  USDC charge.

Tooling:
- go/Justfile: install / build / test / fmt / lint / audit /
  test-cover / check / serve-example targets, mirroring Ruby +
  Lua + PHP.
- .github/workflows/go.yml: paykit + paykit/signer added to the
  package list in the test step; new interop-go-paykit job that
  builds rust-x402 + the harness binary and runs the dual-protocol
  matrix against the Go server (typescript client -> go-paykit for
  MPP charge, rust-x402 client -> go-paykit for x402-exact).
- harness/src/implementations.ts: registers the go-paykit server
  with intents [charge, x402-exact].

Manual DX verified end-to-end against the locally built
solana-foundation/pay@feat/internals:

  pay --sandbox --x402 curl http://127.0.0.1:4567/paid -> 200
    signature 3Bzkj2P8si6tsgVpyGpVJGF6BD5WhYS3fewsMQV6B1f7LWJTX1k3oHDmUd5TCYJ6PzAGTZpq9KTN7Lx1S3fhxL3G
  pay --sandbox --mpp  curl http://127.0.0.1:4567/paid -> 200
    signature 2ZMspVR99ipbpMUX3CMsmxsPb5hLbHg6h78vYxYDJ9MrfHfGqDihoo1aFkBBTGhfxGJ2Jrq1vjrxagBjBPvM4DJj

go test ./paykit/... green. Adapter packages compile clean; further
unit coverage + the live surfnet auto-bootstrap (caveat #3) land in
follow-up commits in this same branch.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request May 30, 2026
The Ruby gem's PR solana-foundation#142 follow-up and the Lua rock's PR solana-foundation#141 carried
the same set of post-merge fixes the language design specs didn't
call out: localnet mainnet-mint fallback, default localnet RPC to
the hosted Surfpool, boot-time preflight + Surfnet cheatcode
auto-bootstrap, MPP HMAC secret auto-resolution, x402 challenge
embedding the server's recent blockhash, and the framework-host
quirks each port runs into (Rack 3 lowercase headers, Sinatra
halt-vs-raise).

Capture all of that in one reference file so future ports (Python
solana-foundation#136, Go solana-foundation#137, PHP solana-foundation#139) inherit the acceptance bar without
re-deriving it. SKILL.md now lists 'Apply the operability caveats'
as phase 7 (between intent code and README). The README template
cross-links it from the section that already mentioned the HMAC
secret resolution chain.

The same content was posted as an issue comment on each of
solana-foundation#136, solana-foundation#137, solana-foundation#139 so the per-language spec
threads carry it inline.
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.

Lua - Interface for PayKit

2 participants