feat(lua): PayKit umbrella for OpenResty / Kong / APISIX (closes #140)#141
Conversation
… 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.
…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.
|
|
||
| local challenge = require('mpp.protocol.core.challenge') | ||
| local headers = require('mpp.protocol.core.headers') | ||
| local intents = require('mpp.protocol.intents.charge') |
There was a problem hiding this comment.
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) ?
| @@ -0,0 +1,370 @@ | |||
| #!/usr/bin/env luajit | |||
There was a problem hiding this comment.
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.
| @@ -0,0 +1,73 @@ | |||
| --[[ | |||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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.
… 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.
…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.
…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/.
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.
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.
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.
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.
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_kitand re-exports the public surface from
DESIGN.mdLayers. The Rubygem (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_kitumbrella:configure,gate,usd,require_payment,try_payment,payment,paid,paid_forsigner.demo|bytes|json|base58|hex|file|from_env|generate)and the reserved
kmsnamespacengx.shared(preferred), in-memory (fallback)mpp.serverwith proper feePayer,splits override, parsed-credential plumbing, and error mapping
structural verifier ported from Ruby + Rust (~280 LOC,
divergences listed below)
kong/plugins/pay-kit, PRIORITY=1010, betweenbasic-auth and OIDC)
apisix/plugins/pay-kit, priority 2520)lua-resty-pay-kit,kong-plugin-pay-kit,apisix-plugin-pay-kitDeferred (not in this PR):
config.x402.facilitator_url): thedispatcher raises
errors.NOT_IMPLEMENTEDif the flag is set.The
lua/README.mdforwards to the configured facilitatorlineis aspirational and should be tightened post-merge; the actual
code path is self-hosted only.
pay_kit.kms.{gcp,aws,vault}raisenot implemented yet. The namespace is reserved so the public surface stays stablefor follow-up PRs.
divergences below).
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:
Buyer-funded destination ATA creation is permitted. The Lua
verifier accepts an
AssociatedTokenAccountcreate-idempotentinstruction 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.
Rule 11 (token program bind) accepts TOKEN_2022_PROGRAM in
addition to
extra.tokenProgram. A strict implementationwould reject any transferChecked whose program id does not
match
extra.tokenProgram. The Lua verifier permits Token-2022even when
extra.tokenProgramis the legacy SPL token programid. 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 onthe security boundary should start there.
Risk callouts
inside the worker process. RPC failures bubble up as a 402 with
invalid_proof: broadcast failed: ...; operators should monitortheir RPC endpoint health independently.
lua_shared_dict pay_kit_replay <size>in nginx.conf, thedispatcher 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.
pay_kit.configurerefuses to start withthe demo singleton when
network = solana_mainnet. Devnet andlocalnet do not enforce this; document your KMS plan before
promoting to staging.
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.
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)
Test plan
luaworkflow green (luacheck + suite + coverage + dual-protocol interop smoke)cd lua && luajit tests/run.luashows all specs passingcd harness && pnpm exec vitest run test/e2e.test.ts --testNamePattern "lua server"(with surfpool + interop env) shows all lua server pairs greenCI surface
.github/workflows/lua.ymlruns luacheck + suite + coverage gaterust-x402-to-lua x402)
Notes for reviewers
rust-x402-vs-ts-x402andrust-x402-vs-rust-x402pairs thatthe
lua servertestNamePattern excludes. They are not gated bythis PR.
pre-existing
mpp/gate. New trees (resty/pay_kit,kong/,apisix/) are included; if CI flags coverage, the gap is inthe cosign + broadcast paths that require a live RPC to
exercise. Happy to add stub-RPC tests in a follow-up if the gate
blocks.