Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/lua.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ jobs:
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: lua/lua_modules
key: ${{ runner.os }}-luarocks-v2-${{ hashFiles('lua/pay-kit-dev-1.rockspec', 'lua/.luacov', 'lua/.luacheckrc') }}
restore-keys: ${{ runner.os }}-luarocks-v2-
key: ${{ runner.os }}-luarocks-dev-v3-${{ hashFiles('lua/pay-kit-dev-1.rockspec', 'lua/.luacov', 'lua/.luacheckrc') }}
restore-keys: ${{ runner.os }}-luarocks-dev-v3-

- name: Install dev rocks (luacheck + luacov + luasocket + luasodium + luasec + lua-resty-openssl + cjson)
if: steps.cache-rocks.outputs.cache-hit != 'true'
Expand Down Expand Up @@ -112,8 +112,8 @@ jobs:
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: lua/lua_modules
key: ${{ runner.os }}-luarocks-v2-${{ hashFiles('lua/pay-kit-dev-1.rockspec', 'lua/.luacov', 'lua/.luacheckrc') }}
restore-keys: ${{ runner.os }}-luarocks-v2-
key: ${{ runner.os }}-luarocks-runtime-v3-${{ hashFiles('lua/pay-kit-dev-1.rockspec', 'lua/.luacov', 'lua/.luacheckrc') }}
restore-keys: ${{ runner.os }}-luarocks-runtime-v3-

- name: Install runtime rocks
if: steps.cache-rocks.outputs.cache-hit != 'true'
Expand All @@ -129,7 +129,8 @@ jobs:

- uses: ./.github/actions/setup-harness
with:
cargo-bins: "paykit-harness-bins:mpp_harness_client"
# The dual-protocol smoke below also runs the Rust x402 client.
cargo-bins: "paykit-harness-bins:mpp_harness_client,x402_harness_client"
cargo-cache-key: lua

# Cross-SDK conformance vectors for Lua: the server-only Lua exact verifier
Expand Down
41 changes: 41 additions & 0 deletions lua/cmd/conformance/main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ local instructions = require('pay_kit.solana.instructions')
local base58 = require('pay_kit.solana.base58')
local ata = require('pay_kit.solana.ata')
local mints = require('pay_kit.solana.mints')
local x402_exact_verify = require('pay_kit.protocols.x402.exact.verify')

local UNSUPPORTED_MODE = 'unsupported-mode'

Expand Down Expand Up @@ -473,6 +474,8 @@ end
--
-- The Lua SDK is SERVER-only, so:
-- * build-transaction (x402) -> unsupported-mode (driver SKIPs).
-- * verify-x402-transaction -> run the real 11-rule SVM exact verifier
-- directly against the vector transaction / requirement / managed keys.
-- * verify-transaction (x402) -> run the server-side credential decode
-- (version dispatch + per-version network gate) + the v2 accepted-vs-
-- route field comparison, emit accept/reject (+ rejectCode), and
Expand Down Expand Up @@ -705,10 +708,40 @@ local function run_x402_verify(vector)
}
end

-- verify-x402-transaction is deliberately separate from the HTTP envelope
-- oracle above. It drives the production exact verifier directly, so neither
-- a fabricated payment header nor an envelope-shaped success result can mask
-- a transaction-level fund-safety regression.
local function run_x402_exact_transaction(vector)
local input = vector.input or {}
if type(input.transaction) ~= 'string' or input.transaction == '' then
error('invalid payload: verify-x402-transaction vector missing input.transaction')
end
if type(input.x402ExactRequirement) ~= 'table' then
error('invalid payload: verify-x402-transaction vector missing input.x402ExactRequirement')
end
local managed_signers = input.x402ExactManagedSigners
if managed_signers == nil then
managed_signers = {}
elseif type(managed_signers) ~= 'table' then
error('invalid payload: x402ExactManagedSigners must be an array')
end

x402_exact_verify.verify(
input.transaction,
input.x402ExactRequirement,
managed_signers
)
return { id = vector.id, outcome = 'accept' }
end

-- x402-exact dispatch. build vectors have no server-only equivalent
-- (the Lua SDK ships no client builder), so they emit unsupported-mode
-- and the driver SKIPs them. verify vectors exercise the real verifier.
local function run_x402_vector(vector)
if vector.mode == 'verify-x402-transaction' then
return run_x402_exact_transaction(vector)
end
if vector.mode == 'build-transaction' then
return {
id = vector.id,
Expand Down Expand Up @@ -824,6 +857,11 @@ local function main()

local ok, vector = pcall(json.decode, raw)
if not ok then
local id = raw:match('"id"%s*:%s*"([%w%._%-]+)"')
if id ~= nil then
emit({ id = id, outcome = 'reject', error = tostring(vector) })
return
end
io.stderr:write('failed to parse vector: ' .. tostring(vector) .. '\n')
os.exit(1)
end
Expand All @@ -843,6 +881,9 @@ local function main()
message = tostring(run_err)
end
result = { id = vector.id, outcome = 'reject', error = message }
if vector.intent == 'x402-exact' and vector.mode == 'verify-x402-transaction' then
result.x402ExactRejectCode = message:match('invalid_exact_svm_payload_[%w_]+')
end
local code = classify_reject(message)
if code ~= nil then
result.rejectCode = code
Expand Down
61 changes: 35 additions & 26 deletions lua/pay_kit/protocols/mpp/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,16 @@ local M = {}
local Adapter = {}
Adapter.__index = Adapter

-- Emit a one-shot warning when the MPP replay store falls back to the
-- volatile in-memory default. Localnet is exempt (single-worker dev is the
-- expected shape there); mainnet/devnet warn so an operator who forgot to
-- wire a shared store is told at first server build rather than after a
-- cross-worker double-spend.
-- Emit a one-shot warning for the localnet-only volatile fallback. Mainnet
-- and devnet fail at construction without an explicit shared replay store.
local _warned_volatile_replay_store = false
local function warn_volatile_replay_store(network)
if network == 'localnet' then return end
if network ~= 'localnet' then return end
if _warned_volatile_replay_store then return end
_warned_volatile_replay_store = true
local msg = 'pay_kit: MPP replay protection is using the default in-memory ' ..
'store, which is process-local and lost on restart. On a multi-worker or ' ..
'multi-node deploy a settled signature can be replayed against another ' ..
'worker. Supply config.mpp.replay_store with a shared (ngx.shared.dict / ' ..
'Redis-backed) store in production.'
'store for localnet. It is process-local and must not be used for devnet ' ..
'or mainnet; configure config.mpp.replay_store there.'
local ngx_ref = rawget(_G, 'ngx')
if ngx_ref and ngx_ref.log and ngx_ref.WARN then
ngx_ref.log(ngx_ref.WARN, msg)
Expand All @@ -54,6 +49,16 @@ local function warn_volatile_replay_store(network)
end
end

local function replay_store_is_shared(replay_store)
if type(replay_store) ~= 'table' then return false end
if type(replay_store.is_shared) == 'function' then
return replay_store:is_shared() == true
end
-- Custom Redis/Postgres adapters can declare the capability without
-- inheriting this package's shared-dict implementation.
return replay_store.shared == true or replay_store.durable == true
end

local function map_pay_kit_network(network)
-- The legacy mpp.server.new accepts "mainnet" / "devnet" / "localnet";
-- pay_kit config uses solana_* prefixes.
Expand Down Expand Up @@ -83,35 +88,38 @@ local function build_mpp_server(config, gate, store)
fee_payer_signer = mpp_signer.from_bytes(sgn:_secret_key_bytes())
end

local rpc_mod = require('pay_kit.solana.rpc')
local rpc_transport_mod = require('pay_kit.solana.rpc_transport')
local charge_handler = require('pay_kit.protocols.mpp.server.charge_handler')
local solana_verify = require('pay_kit.protocols.mpp.server.solana_verify')
local store_mod = require('pay_kit.protocols.mpp.store')

local rpc = rpc_mod.new({url = config.rpc_url, transport = rpc_transport_mod.new()})
local verifier_bundle = solana_verify.new_real_verifier({pull_signer = fee_payer_signer})
-- The legacy mpp.store expects (key, value) semantics on
-- put_if_absent (it stores arbitrary values keyed by signature).
-- pay_kit.store uses (key, ttl) for the x402 replay path, so
-- they are NOT interchangeable - bind the mpp handler to its own
-- store. The `store` argument from the dispatcher is reserved for
-- the x402 adapter.
local _ = store
-- Replay store. The default `store.memory()` is process-local and lost
-- on worker restart, so it only protects against replays seen by the
-- SAME worker since boot - acceptable for single-worker dev, NOT for a
-- multi-worker / multi-node production deploy where a replay reservation
-- must be visible across all settlers. Callers wire a shared store
-- (e.g. an ngx.shared.dict / Redis-backed adapter) via
-- `config.mpp.replay_store`; when none is supplied we fall back to the
-- volatile in-memory store and warn once so the dev-only nature is
-- explicit. Mirrors the Ruby/PHP "default volatile replay store" caveat.
-- Replay reservations must be shared outside localnet. A process-local
-- fallback is retained only for explicitly local development, with a
-- one-shot warning so it cannot be mistaken for deployable protection.
local replay_store = config.mpp and config.mpp.replay_store
if not replay_store then
if network ~= 'localnet' then
error('MPP replay store is required outside localnet; set config.mpp.replay_store')
end
replay_store = store_mod.memory()
warn_volatile_replay_store(network)
end
if network ~= 'localnet' and not replay_store_is_shared(replay_store) then
error('MPP replay store must be shared outside localnet; process-local stores are unsafe')
end

-- Enforce the replay-store policy before loading or initializing transport
-- dependencies. A production configuration error must fail deterministically
-- even in a minimal environment that has not installed LuaSocket yet.
local rpc_mod = require('pay_kit.solana.rpc')
local rpc_transport_mod = require('pay_kit.solana.rpc_transport')
local charge_handler = require('pay_kit.protocols.mpp.server.charge_handler')
local solana_verify = require('pay_kit.protocols.mpp.server.solana_verify')
local rpc = rpc_mod.new({url = config.rpc_url, transport = rpc_transport_mod.new()})
local verifier_bundle = solana_verify.new_real_verifier({pull_signer = fee_payer_signer})
local handler = charge_handler.new({
rpc = rpc,
network = network,
Expand All @@ -129,6 +137,7 @@ local function build_mpp_server(config, gate, store)
realm = config.mpp.realm,
network = network,
rpc_url = config.rpc_url,
store = replay_store,
verify_payment = handler:as_callback(),
}
if fee_payer_signer then
Expand Down
38 changes: 21 additions & 17 deletions lua/pay_kit/protocols/mpp/server/charge_handler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ local function verifier_error(message, code)
error({ code = code or error_codes.PAYMENT_INVALID, message = message })
end

local function replay_store_is_shared(replay_store)
if type(replay_store) ~= 'table' then return false end
if type(replay_store.is_shared) == 'function' then
return replay_store:is_shared() == true
end
return replay_store.shared == true or replay_store.durable == true
end

--- Construct a new charge handler.
--
-- @param config table
Expand Down Expand Up @@ -123,12 +131,16 @@ function M.new(config)
if type(config.replay_store) ~= 'table' then
error('replay_store is required')
end
local network = config.network or 'mainnet'
if network ~= 'localnet' and not replay_store_is_shared(config.replay_store) then
error('replay_store must be shared outside localnet; process-local stores are unsafe')
end
if type(config.transaction_verifier) ~= 'function' then
error('transaction_verifier function is required')
end
local instance = {
rpc = config.rpc,
network = config.network or 'mainnet',
network = network,
replay_store = config.replay_store,
-- Audit #5: push mode (type=signature credentials) is opt-in and default
-- OFF. Spec §13.5 accepts that push binds a confirmed tx to a challenge by
Expand Down Expand Up @@ -300,27 +312,19 @@ end
--- Build a `verify_payment` callback compatible with `mpp.server.new`. The
-- callback consumes the replay store inside the handler (in `settle_pull`
-- between broadcast and await; in `settle_push` after on-chain shape
-- verification) and reports back with `consumed = true` so the outer
-- `Server:_finalize_verification` skips its own `put_if_absent` call.
-- This is the contract that lets the Kong / OpenResty wiring share a
-- single replay store between `charge_handler.new({ replay_store })` and
-- `mpp.server.new({ store })` without the second consume colliding with
-- the first and rejecting a valid first payment as `signature_consumed`.
-- `options.replay_key_prefix` is retained for backward compatibility and
-- still surfaces the namespaced server-level key in the returned table
-- even though the outer consume is now a no-op.
function Handler:as_callback(options)
options = options or {}
local prefix = options.replay_key_prefix or 'solana-charge:server-noop:'
-- verification) and returns that exact marker as evidence. The outer server
-- only honors `consumed = true` when it can find this marker in its own store,
-- so callers should pass the same replay store to both constructors.
function Handler:as_callback(_options)
local handler = self
return function(context)
local signature = handler:settle(context.payload, context.request)
return {
reference = signature,
replay_key = prefix .. signature,
-- Signals to `Server:_finalize_verification` that the durable
-- replay marker is already in place; the outer put_if_absent is
-- skipped so we do not re-assert against the same shared store.
replay_key = CONSUMED_PREFIX .. signature,
-- Signals to `Server:_finalize_verification` that the durable marker
-- is already in place. The server independently confirms it before
-- skipping its own put_if_absent call.
consumed = true,
}
end
Expand Down
Loading
Loading