From 2c614b03d798e9c676a5e0b84c9e1ff09db13ad6 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 9 Jul 2026 22:03:04 +0300 Subject: [PATCH 1/7] fix(lua): fee-sponsored compute-cap bypass + replay fail-open (+216 hardening) Two security criticals that survived #216 rated only "required": - Fee-sponsored compute-unit-price cap bypass (false-paid): the real MPP verifier (solana/verifier.lua -> instructions.parse_compute_budget) applied the general 5,000,000 microLamport cap even on server-funded charges, letting a co-signed charge set a ~200x priority fee (merchant drain, audit #25). Now passes fee_sponsored=(fee_payer~=nil) and enforces the tight 10,000 cap, matching the Rust spine validate_compute_budget_instruction and the Ruby verifier. Client-paid charges keep the general cap. - Replay fail-open: build_mpp_server fell back to a volatile in-memory replay store on every network with only a warning; on multi-worker devnet/mainnet a settled signature could be replayed for a second on-chain settlement. Now fails CLOSED outside localnet (requires a durable, process-shared replay_store), mirroring the Ruby Mpp.create requirement. BREAKING. Plus the #216 Lua hardening on the #219 base. Regression tests added for both. busted 617 passed/1 skipped/0 failed; luacheck 0/0; coverage 90.26% >= 90. --- lua/cmd/conformance/main.lua | 39 +++++- lua/pay_kit/protocols/mpp/init.lua | 23 +++- .../protocols/mpp/server/solana_verify.lua | 24 +++- lua/pay_kit/protocols/x402/exact/verify.lua | 21 ++-- lua/pay_kit/protocols/x402/init.lua | 44 ++++++- lua/pay_kit/solana/instructions.lua | 25 +++- lua/pay_kit/solana/verifier.lua | 9 +- lua/pay_kit/util/json.lua | 54 +++++++-- lua/pay_kit/util/uint.lua | 41 +++++++ lua/plugins/kong/plugins/pay-kit/init.lua | 10 ++ lua/tests/json_util_spec.lua | 12 ++ lua/tests/methods_solana_verifier_spec.lua | 78 ++++++++++++ .../pay_kit/apisix_plugin_runtime_spec.lua | 13 +- lua/tests/pay_kit/dispatcher_spec.lua | 64 +++++++++- .../pay_kit/kong_plugin_runtime_spec.lua | 7 +- lua/tests/pay_kit/main_fixes_spec.lua | 20 +++- lua/tests/pay_kit/schemes_x402_spec.lua | 21 +++- lua/tests/pay_kit/x402_broadcast_spec.lua | 25 +++- lua/tests/pay_kit/x402_extensions_spec.lua | 15 ++- lua/tests/pay_kit/x402_verify_spec.lua | 60 ++++++++++ lua/tests/solana_verify_spec.lua | 112 ++++++++++++++++++ 21 files changed, 665 insertions(+), 52 deletions(-) diff --git a/lua/cmd/conformance/main.lua b/lua/cmd/conformance/main.lua index 908ea867f..45b5c3020 100644 --- a/lua/cmd/conformance/main.lua +++ b/lua/cmd/conformance/main.lua @@ -82,7 +82,6 @@ end -- encoder; key order is canonical, which is fine because the driver parses -- the line with JSON.parse and reads fields by name. local function emit(result) - result.language = 'lua' io.write(json.encode(result) .. '\n') end @@ -705,10 +704,48 @@ local function run_x402_verify(vector) } end +-- verify-x402-transaction: drive the real Lua 11-rule exact fund-safety +-- verifier over the base64 versioned transaction. On reject the canonical +-- invalid_exact_svm_payload_* string (the raised error message) is surfaced +-- verbatim as x402ExactRejectCode so the cross-SDK vectors bind the exact code. +local function run_x402_exact_verify(vector) + local x402_exact_verify = require('pay_kit.protocols.x402.exact.verify') + local input = vector.input or {} + local transaction = input.transaction + if type(transaction) ~= 'string' or transaction == '' then + error('verify-x402-transaction vector missing input.transaction') + end + local requirement = input.x402ExactRequirement + if type(requirement) ~= 'table' then + error('verify-x402-transaction vector missing input.x402ExactRequirement') + end + local managed_signers = {} + for _, key in ipairs(input.x402ExactManagedSigners or {}) do + managed_signers[#managed_signers + 1] = key + end + + local ok, err = pcall(x402_exact_verify.verify, transaction, requirement, managed_signers) + if ok then + return { id = vector.id, outcome = 'accept' } + end + -- The verifier raises the canonical reject string as a plain error; strip + -- LuaJIT's "file:line: " prefix so the exact code is surfaced verbatim. + local message = tostring(err):gsub('^.-:%d+:%s*', '') + return { + id = vector.id, + outcome = 'reject', + error = message, + x402ExactRejectCode = message, + } +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_verify(vector) + end if vector.mode == 'build-transaction' then return { id = vector.id, diff --git a/lua/pay_kit/protocols/mpp/init.lua b/lua/pay_kit/protocols/mpp/init.lua index c479a7281..370e0f253 100644 --- a/lua/pay_kit/protocols/mpp/init.lua +++ b/lua/pay_kit/protocols/mpp/init.lua @@ -104,11 +104,28 @@ local function build_mpp_server(config, gate, store) -- 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. + -- `config.mpp.replay_store`. + -- + -- Fail CLOSED outside localnet. A volatile in-memory fallback is not a + -- durable replay primitive: markers are lost on restart and invisible to + -- other workers/hosts, so a settled signature can be replayed against + -- another worker for a second on-chain settlement (double-spend). On + -- mainnet/devnet we therefore REQUIRE the operator to inject a durable, + -- process-shared store rather than silently constructing a volatile one and + -- only warning. Localnet keeps the volatile dev fallback (single-worker dev + -- is the expected shape there). Mirrors the Ruby `PayKit::Protocols::Mpp` + -- durable-store requirement (a caller who supplies ANY store owns that + -- choice; only the "no store supplied at all" path is rejected) and the + -- TS/PHP/Python shared-replay-store fail-closed posture. local replay_store = config.mpp and config.mpp.replay_store if not replay_store then + if network ~= 'localnet' then + error('pay_kit: MPP replay protection requires a durable, process-shared ' .. + 'replay store on ' .. tostring(network) .. '. Set config.mpp.replay_store ' .. + 'to a shared store (ngx.shared.dict / Redis-backed); the in-memory ' .. + 'default is process-local and lost on restart, so a settled signature ' .. + 'can be replayed against another worker for a second settlement.') + end replay_store = store_mod.memory() warn_volatile_replay_store(network) end diff --git a/lua/pay_kit/protocols/mpp/server/solana_verify.lua b/lua/pay_kit/protocols/mpp/server/solana_verify.lua index 4ba2fc551..3b4aaa683 100644 --- a/lua/pay_kit/protocols/mpp/server/solana_verify.lua +++ b/lua/pay_kit/protocols/mpp/server/solana_verify.lua @@ -195,7 +195,18 @@ function verify_sol_transfers(instructions, request) local found = false for idx, ix in ipairs(transfers) do local info = instruction_info(ix) - if info and info.destination == want.recipient and uint.compare(info.lamports, want.amount) == 0 then + -- M1: the jsonParsed RPC path decodes `info.lamports` as a Lua number, + -- which on LuaJIT / Lua 5.1 is a double. A u64 lamports value >= 2^53 is + -- lossy and serializes as scientific notation, so comparing it directly + -- via uint.compare would raise "invalid unsigned integer" (or, worse, + -- silently match a truncated amount). uint.exact coerces to an exact + -- decimal string and rejects any lossy / non-integral number. A rejected + -- candidate simply does not match this `want`; if no exact-integer + -- transfer matches, the loop falls through to the canonical + -- `no matching SOL transfer` rejection below. + local lamports_ok, lamports = pcall(uint.exact, info and info.lamports) + if info and info.destination == want.recipient and lamports_ok + and uint.compare(lamports, want.amount) == 0 then remove_at(transfers, idx) found = true break @@ -240,8 +251,15 @@ function verify_spl_transfers(instructions, request, method_details, hooks) local decimals_ok = expected_decimals == nil or (info and info.tokenAmount and tonumber(info.tokenAmount.decimals) == tonumber(expected_decimals)) - if info and decimals_ok and info.mint == mint - and uint.compare(info.tokenAmount.amount, want.amount) == 0 then + -- M1 (parity with the native-SOL path): jsonParsed returns + -- tokenAmount.amount as a string per the Solana spec, but a non-standard + -- adapter could hand back a Lua number. Coerce through uint.exact so a + -- lossy / scientific-notation double is rejected rather than raising or + -- matching a truncated amount. + local amount_ok, token_amount = + pcall(uint.exact, info and info.tokenAmount and info.tokenAmount.amount) + if info and decimals_ok and info.mint == mint and amount_ok + and uint.compare(token_amount, want.amount) == 0 then local account = hooks.fetch_token_account(info.destination) if account and account.owner == want.recipient and account.mint == mint then remove_at(transfers, idx) diff --git a/lua/pay_kit/protocols/x402/exact/verify.lua b/lua/pay_kit/protocols/x402/exact/verify.lua index 48d595fb3..265be4e05 100644 --- a/lua/pay_kit/protocols/x402/exact/verify.lua +++ b/lua/pay_kit/protocols/x402/exact/verify.lua @@ -159,20 +159,21 @@ local function verify_transfer(ix, account_keys, requirement, managed_signers) local destination = account_at(account_keys, ix, 2) local authority = account_at(account_keys, ix, 3) - -- Rule 5: authority guard. + -- Rule 5: fee-payer/managed-signer fund-mover guard. A managed signer + -- (the operator's fee payer) must never be the transfer authority, nor the + -- funding source: not as its raw key, and not as its own associated token + -- account for this mint. This is the complete rule -- an appended + -- instruction that merely references the fee-payer (e.g. a Lighthouse + -- guard) is NOT a fund move and is accepted, matching the Rust reference + -- and the Go/Python/PHP/Ruby verifiers. for i = 1, #managed_signers do - if managed_signers[i] == authority or managed_signers[i] == source then + local managed = managed_signers[i] + if managed == authority + or managed == source + or source == ata.derive(managed, mint, program) then error('invalid_exact_svm_payload_transaction_fee_payer_transferring_funds') end end - for j = 1, #ix.accounts do - local key = account_keys[ix.accounts[j] + 1] - for i = 1, #managed_signers do - if managed_signers[i] == key then - error('invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts') - end - end - end -- Rule 6: mint match. local expected_mint = b58_field(requirement, 'asset') diff --git a/lua/pay_kit/protocols/x402/init.lua b/lua/pay_kit/protocols/x402/init.lua index dfc2ae287..4088a551a 100644 --- a/lua/pay_kit/protocols/x402/init.lua +++ b/lua/pay_kit/protocols/x402/init.lua @@ -25,6 +25,7 @@ dispatch shape. local cjson_safe = require('cjson.safe') local base64_std = require('pay_kit.util.base64_std') local errors = require('pay_kit.errors') +local json = require('pay_kit.util.json') local rpc_mod = require('pay_kit.solana.rpc') local rpc_transport = require('pay_kit.solana.rpc_transport') local tx_cosign = require('pay_kit.solana.tx_cosign') @@ -362,10 +363,29 @@ local function build_rpc(config) end local function consume_signature(store, signature) - if not store then return true end local key = 'x402-svm-exact:consumed:' .. signature - if store.put_if_absent then + if type(store) ~= 'table' or type(store.put_if_absent) ~= 'function' then + return nil, 'x402 replay store with put_if_absent is required' + end + local ok, inserted = pcall(function() return store:put_if_absent(key) + end) + if not ok then return nil, tostring(inserted) end + return inserted == true +end + +local function await_confirmed_or_finalized(rpc, signature) + local statuses = rpc:signature_statuses({ signature }) + local status = statuses[1] + if type(status) ~= 'table' then + return nil, 'settlement confirmation missing' + end + if status.err ~= nil and status.err ~= json.null then + return nil, 'settlement failed: ' .. tostring(status.err) + end + local confirmation = status.confirmationStatus + if confirmation ~= 'confirmed' and confirmation ~= 'finalized' then + return nil, 'settlement not confirmed' end return true end @@ -377,6 +397,9 @@ function M.new(opts) if not opts.config_resolver then return nil, 'pay_kit: protocols.x402.new requires config_resolver' end + if type(opts.store) ~= 'table' or type(opts.store.put_if_absent) ~= 'function' then + return nil, 'pay_kit: protocols.x402.new requires replay store with put_if_absent' + end return setmetatable({ _config_resolver = opts.config_resolver, _store = opts.store, @@ -577,10 +600,24 @@ function Adapter:verify_and_settle(gate, req) return nil, errors.INVALID_PROOF .. ': empty broadcast result' end - if not consume_signature(self._store, signature) then + local inserted, consume_err = consume_signature(self._store, signature) + if consume_err then + return nil, errors.INVALID_PROOF .. ': ' .. consume_err + end + if not inserted then return nil, errors.SIGNATURE_CONSUMED end + local confirmed_ok, confirmed, confirm_err = pcall(await_confirmed_or_finalized, rpc, signature) + if not confirmed_ok then + local raised = confirmed + local message = type(raised) == 'table' and raised.message or tostring(raised) + return nil, errors.INVALID_PROOF .. ': settlement confirmation failed: ' .. message + end + if not confirmed then + return nil, errors.INVALID_PROOF .. ': ' .. tostring(confirm_err) + end + -- Settlement response. v1 emits X-PAYMENT-RESPONSE carrying the plain SVM -- network slug and the payer pubkey (rust v1 settlement shape -- { success, transaction, network, payer }); v2 emits PAYMENT-RESPONSE @@ -630,6 +667,7 @@ M._private = { PAYMENT_IDENTIFIER_KEY = PAYMENT_IDENTIFIER_KEY, caip2_network_for_cluster = caip2_network_for_cluster, legacy_network_slug = legacy_network_slug, + await_confirmed_or_finalized = await_confirmed_or_finalized, LEGACY_PAYMENT_HEADER = LEGACY_PAYMENT_HEADER, LEGACY_PAYMENT_RESPONSE_HEADER = LEGACY_PAYMENT_RESPONSE_HEADER, } diff --git a/lua/pay_kit/solana/instructions.lua b/lua/pay_kit/solana/instructions.lua index 5903f7783..b596669e7 100644 --- a/lua/pay_kit/solana/instructions.lua +++ b/lua/pay_kit/solana/instructions.lua @@ -30,6 +30,14 @@ local COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' -- Audit-v2 caps shared with the Rust spine and the Ruby verifier. local MAX_COMPUTE_UNIT_LIMIT = 200000 local MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5000000 +-- Audit #25: in fee-sponsored pull mode the server co-signs BEFORE broadcast, +-- so the priority fee comes out of the server's fee-payer balance. Apply a +-- tight compute-unit-price cap when the server is the fee payer, matching the +-- Rust spine (mpp/server/charge.rs MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED), +-- the Ruby verifier, and the hooks-based `solana_verify` path. Without it the +-- real verifier accepts a 5_000_000 microLamport price (~1_000_000 lamports / +-- charge, ~200x the base fee), a looped merchant drain. +local MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED = 10000 M.TOKEN_PROGRAM = TOKEN_PROGRAM M.TOKEN_2022_PROGRAM = TOKEN_2022_PROGRAM @@ -39,6 +47,7 @@ M.MEMO_PROGRAM = MEMO_PROGRAM M.COMPUTE_BUDGET_PROGRAM = COMPUTE_BUDGET_PROGRAM M.MAX_COMPUTE_UNIT_LIMIT = MAX_COMPUTE_UNIT_LIMIT M.MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS +M.MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED = MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED -- Decode a little-endian unsigned integer in the byte range [start, start+len). -- Returns a decimal string so values above 2^53 stay exact through the @@ -146,7 +155,14 @@ end --- Parse a Compute Budget instruction. --- Returns the typed table or raises with the canonical cap-violation --- message so the verifier short-circuits without an extra branch. -function M.parse_compute_budget(ix) +--- +--- `fee_sponsored` (audit #25): when true the server is the transaction fee +--- payer, so the SetComputeUnitPrice ceiling tightens to +--- MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED. The flag is derived +--- from the server-side fee-payer signal by the caller (never a +--- client-supplied field). Defaults to false so client-paid charges keep the +--- general cap. +function M.parse_compute_budget(ix, fee_sponsored) local data = ix.data or '' if #data < 1 then error('Unsupported compute budget instruction') @@ -182,8 +198,11 @@ function M.parse_compute_budget(ix) -- through pay_kit.util.uint so a future cap raise above 2^53 still rejects -- genuinely over-cap transactions. local price_str = decode_le_uint(data, 2, 8) - if uint.compare(price_str, tostring(MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS)) > 0 then - error('Compute unit price ' .. price_str .. ' exceeds maximum ' .. MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS) + local price_cap = fee_sponsored + and MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS_FEE_SPONSORED + or MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + if uint.compare(price_str, tostring(price_cap)) > 0 then + error('Compute unit price ' .. price_str .. ' exceeds maximum ' .. price_cap) end return { kind = 'compute_budget_set_price', price = price_str } end diff --git a/lua/pay_kit/solana/verifier.lua b/lua/pay_kit/solana/verifier.lua index b6c1fed9c..ce5ea251e 100644 --- a/lua/pay_kit/solana/verifier.lua +++ b/lua/pay_kit/solana/verifier.lua @@ -258,7 +258,14 @@ local function validate_allowlist(tx, matched, expected_mint, expected_token_pro for index, ix in ipairs(tx.message.instructions) do local program = instructions.program_id_for(tx, ix) if program == COMPUTE_BUDGET_PROGRAM then - instructions.parse_compute_budget(ix) + -- Audit #25: apply the tight fee-sponsored compute-unit-price cap when the + -- server co-signs as fee payer (fee_payer is set precisely then). Mirrors + -- the Rust spine (validate_compute_budget_instruction(ix, fee_payer.is_some()) + -- mpp/server/charge.rs:1960), the Ruby verifier, and the hooks-based + -- `solana_verify` path. Without the flag the real verifier accepted a + -- 5_000_000 microLamport price on a server-funded charge (~1M lamports / + -- charge merchant drain). + instructions.parse_compute_budget(ix, fee_payer ~= nil) elseif program == MEMO_PROGRAM or program == SYSTEM_PROGRAM or program == TOKEN_PROGRAM diff --git a/lua/pay_kit/util/json.lua b/lua/pay_kit/util/json.lua index 8390ff94f..81c90b83a 100644 --- a/lua/pay_kit/util/json.lua +++ b/lua/pay_kit/util/json.lua @@ -74,6 +74,32 @@ local function utf8_codepoints(value) return out end +local function utf8_from_codepoint(cp) + if cp < 0 or cp > 0x10FFFF then + error('unicode codepoint out of range') + end + if cp >= 0xD800 and cp <= 0xDFFF then + error('lone surrogate') + end + if cp < 128 then + return string.char(cp) + elseif cp < 2048 then + return string.char(192 + math.floor(cp / 64), 128 + (cp % 64)) + elseif cp < 65536 then + return string.char( + 224 + math.floor(cp / 4096), + 128 + (math.floor(cp / 64) % 64), + 128 + (cp % 64) + ) + end + return string.char( + 240 + math.floor(cp / 262144), + 128 + (math.floor(cp / 4096) % 64), + 128 + (math.floor(cp / 64) % 64), + 128 + (cp % 64) + ) +end + local function encode_string(value) local cps, err = utf8_codepoints(value) if not cps then @@ -329,16 +355,26 @@ function Parser:parse_string() end self.pos = self.pos + 4 local code = tonumber(hex, 16) - if code < 128 then - out[#out + 1] = string.char(code) - elseif code < 2048 then - out[#out + 1] = string.char(192 + math.floor(code / 64), 128 + (code % 64)) + if code >= 0xD800 and code <= 0xDBFF then + if self.input:sub(self.pos, self.pos + 1) ~= '\\u' then + error('invalid unicode surrogate pair at position ' .. self.pos) + end + self.pos = self.pos + 2 + local low_hex = self.input:sub(self.pos, self.pos + 3) + if #low_hex ~= 4 or not low_hex:match('^[0-9a-fA-F]+$') then + error('invalid unicode escape at position ' .. self.pos) + end + self.pos = self.pos + 4 + local low = tonumber(low_hex, 16) + if low < 0xDC00 or low > 0xDFFF then + error('invalid unicode surrogate pair at position ' .. self.pos) + end + code = 0x10000 + ((code - 0xD800) * 1024) + (low - 0xDC00) + out[#out + 1] = utf8_from_codepoint(code) + elseif code >= 0xDC00 and code <= 0xDFFF then + error('invalid unicode surrogate pair at position ' .. self.pos) else - out[#out + 1] = string.char( - 224 + math.floor(code / 4096), - 128 + (math.floor(code / 64) % 64), - 128 + (code % 64) - ) + out[#out + 1] = utf8_from_codepoint(code) end else error('invalid escape character at position ' .. self.pos) diff --git a/lua/pay_kit/util/uint.lua b/lua/pay_kit/util/uint.lua index edea863fe..5c7aeaa5a 100644 --- a/lua/pay_kit/util/uint.lua +++ b/lua/pay_kit/util/uint.lua @@ -16,6 +16,47 @@ function M.normalize(value) return normalize(value) end +-- Coerce an on-chain amount to an EXACT decimal string, rejecting any Lua +-- number that cannot represent the value losslessly. +-- +-- M1: the jsonParsed RPC path (getTransaction encoding=jsonParsed) decodes a +-- u64 lamports / token amount as a Lua number. On LuaJIT / Lua 5.1 every JSON +-- number is a double (53-bit mantissa), so a value >= 2^53 is BOTH lossy AND +-- serializes through tostring() as scientific notation (e.g. "9e+18"), which +-- normalize() then rejects with a confusing "invalid unsigned integer". A +-- silently-truncated amount that DID normalize would be worse: it would let a +-- transfer of the wrong lamports satisfy the amount check. Reject a float that +-- is non-integral, out of the safely-representable range (|x| > 2^53), or +-- infinite/NaN, so only exact integers reach the comparison. A string source +-- (the safe raw-bytes decode in solana/instructions.lua, and the challenge +-- amounts which are always strings) passes straight through to normalize(). +local MAX_EXACT_DOUBLE = 9007199254740992 -- 2^53 + +function M.exact(value) + if type(value) == "number" then + -- Reject NaN (value ~= value), +/-inf, and any non-integral double. + if value ~= value or value == math.huge or value == -math.huge then + error("amount is not an exact integer: " .. tostring(value)) + end + if value < 0 then + error("amount is negative: " .. tostring(value)) + end + -- math.floor keeps the double whole-valued; a fractional part means the + -- source was not an integer amount. + if math.floor(value) ~= value then + error("amount is not an exact integer: " .. tostring(value)) + end + -- Above 2^53 a double can no longer represent every integer, so the + -- value may already be lossy. Refuse rather than compare a corrupted + -- amount. Exact-source callers pass a string and never hit this. + if value > MAX_EXACT_DOUBLE then + error("amount exceeds exact-integer range for a Lua number: " .. tostring(value)) + end + return string.format("%.0f", value) + end + return normalize(value) +end + function M.compare(left, right) left = normalize(left) right = normalize(right) diff --git a/lua/plugins/kong/plugins/pay-kit/init.lua b/lua/plugins/kong/plugins/pay-kit/init.lua index eebee8f23..b03346793 100644 --- a/lua/plugins/kong/plugins/pay-kit/init.lua +++ b/lua/plugins/kong/plugins/pay-kit/init.lua @@ -22,6 +22,16 @@ Env vars (all optional; defaults boot a localnet demo): Gates are registered via per-plugin config on the route. Apps wanting a catalog-style Pricing class can call pay_kit.gate() after setup(). + +MPP replay store (production): outside localnet the MPP adapter REQUIRES a +durable, process-shared replay store (a settled signature is otherwise +replayable across workers/restarts for a second settlement). The env-driven +setup() below does not wire one, so on solana_devnet / solana_mainnet an +operator using MPP must inject a shared store after setup() -- e.g. build +`pay_kit.protocols.mpp.server.store_shared_dict.new(ngx.shared.)` and pass +it as `config.mpp.replay_store` via a custom init block -- or the first MPP +request will fail closed. x402 replay protection is unaffected (it uses the +dispatcher's `pay_kit.store` shared-dict backend automatically). ]] local pay_kit = require('pay_kit') diff --git a/lua/tests/json_util_spec.lua b/lua/tests/json_util_spec.lua index 70ca6b33e..acfaf7d3b 100644 --- a/lua/tests/json_util_spec.lua +++ b/lua/tests/json_util_spec.lua @@ -93,6 +93,18 @@ helpers.test('json.decode: unicode escape 3-byte UTF-8', function() helpers.assert_equal(json.decode('"\\u20AC"'), string.char(0xE2, 0x82, 0xAC)) end) +helpers.test('json.decode: unicode surrogate pair', function() + local emoji = string.char(0xF0, 0x9F, 0x98, 0x80) + helpers.assert_equal(json.decode('"\\uD83D\\uDE00"'), emoji) + helpers.assert_equal(json.encode({k = json.decode('"\\uD83D\\uDE00"')}), '{"k":"' .. emoji .. '"}') +end) + +helpers.test('json.decode: invalid unicode surrogate pairs error', function() + assert_throws(function() json.decode('"\\uD83D"') end, 'surrogate') + assert_throws(function() json.decode('"\\uDE00"') end, 'surrogate') + assert_throws(function() json.decode('"\\uD83D\\u0041"') end, 'surrogate') +end) + helpers.test('json.decode: invalid unicode escape errors', function() assert_throws(function() json.decode('"\\uZZZZ"') end, 'invalid unicode') end) diff --git a/lua/tests/methods_solana_verifier_spec.lua b/lua/tests/methods_solana_verifier_spec.lua index 0b77d01b5..96953c821 100644 --- a/lua/tests/methods_solana_verifier_spec.lua +++ b/lua/tests/methods_solana_verifier_spec.lua @@ -424,3 +424,81 @@ helper.test('verifier rejects compute-budget over the unit limit cap', function( }) end, 'Compute unit limit') end) + +-- Audit #25 (fee-sponsored compute-price cap). Build a fee-sponsored SPL +-- charge whose fee payer sits at account index 0 (so `expected_fee_payer` +-- resolves it) with a NON-fee-payer source ATA and a separate transfer +-- authority (so the fee-payer fund-mover guards pass), plus a +-- SetComputeUnitPrice instruction. The real verifier used to cap this at the +-- general 5_000_000 ceiling regardless of who paid the fee, so a +-- server-funded charge could carry a ~1_000_000-lamport priority fee out of +-- the merchant's balance. Mirrors the Rust spine +-- (validate_compute_budget_instruction(ix, fee_payer.is_some())) and the +-- hooks-based `solana_verify` path. +local function fee_sponsored_compute_price_tx(price) + local fee_payer_bytes = string.rep('\x01', 32) + local fee_payer_pub = base58.encode(fee_payer_bytes) + local source_ata_bytes = string.rep('\x02', 32) -- NOT the fee-payer ATA + local recipient_bytes = string.rep('\x03', 32) + local recipient_pub = base58.encode(recipient_bytes) + local recipient_ata_bytes = base58.decode(ata.derive(recipient_pub, USDC, instructions.TOKEN_PROGRAM)) + local authority_bytes = string.rep('\x44', 32) -- separate authority (not the fee payer) + local compute_budget_bytes = base58.decode(instructions.COMPUTE_BUDGET_PROGRAM) + local account_keys = { + fee_payer_bytes, source_ata_bytes, recipient_ata_bytes, authority_bytes, + USDC_BYTES, TOKEN_PROGRAM_BYTES, compute_budget_bytes, + } + local transfer = encode_instruction(5, { 1, 4, 2, 3 }, + string.char(12) .. le_u64(1000) .. string.char(6)) + local cb = encode_instruction(6, {}, string.char(3) .. le_u64(price)) + local message = build_message(account_keys, string.rep('\xc3', 32), { transfer, cb }, 1) + return tx_from(message, 1), recipient_pub, fee_payer_pub +end + +helper.test('verifier rejects a fee-sponsored compute-unit price above the tight cap (audit #25)', function() + -- 20000 microLamports is UNDER the general 5_000_000 cap but OVER the + -- 10_000 fee-sponsored cap. A server-funded charge must reject it. + local tx, recipient_pub, fee_payer_pub = fee_sponsored_compute_price_tx(20000) + helper.assert_error(function() + verifier.verify_transaction(tx, { + amount = '1000', currency = USDC, recipient = recipient_pub, + methodDetails = { decimals = 6, tokenProgram = instructions.TOKEN_PROGRAM, + feePayer = true, feePayerKey = fee_payer_pub, network = 'mainnet-beta' }, + }) + end, 'Compute unit price 20000 exceeds maximum 10000') +end) + +helper.test('verifier accepts a fee-sponsored compute-unit price at the tight cap', function() + local tx, recipient_pub, fee_payer_pub = fee_sponsored_compute_price_tx(10000) + verifier.verify_transaction(tx, { + amount = '1000', currency = USDC, recipient = recipient_pub, + methodDetails = { decimals = 6, tokenProgram = instructions.TOKEN_PROGRAM, + feePayer = true, feePayerKey = fee_payer_pub, network = 'mainnet-beta' }, + }) +end) + +helper.test('verifier keeps the general compute-price cap for client-paid charges', function() + -- No feePayer: the client funds its own priority fee, so the tight cap must + -- NOT apply. A 20000 microLamport price (under the general 5_000_000 cap) + -- is accepted, proving the tight cap is gated on the server-side fee-payer + -- signal rather than tightening every charge. + local payer_bytes = string.rep('\x01', 32) + local source_ata_bytes = string.rep('\x02', 32) + local recipient_bytes = string.rep('\x03', 32) + local recipient_pub = base58.encode(recipient_bytes) + local recipient_ata_bytes = base58.decode(ata.derive(recipient_pub, USDC, instructions.TOKEN_PROGRAM)) + local compute_budget_bytes = base58.decode(instructions.COMPUTE_BUDGET_PROGRAM) + local account_keys = { + payer_bytes, source_ata_bytes, recipient_ata_bytes, recipient_bytes, + USDC_BYTES, TOKEN_PROGRAM_BYTES, compute_budget_bytes, + } + local transfer = encode_instruction(5, { 1, 4, 2, 0 }, + string.char(12) .. le_u64(1000) .. string.char(6)) + local cb = encode_instruction(6, {}, string.char(3) .. le_u64(20000)) + local message = build_message(account_keys, string.rep('\xc3', 32), { transfer, cb }, 1) + local tx = tx_from(message, 1) + verifier.verify_transaction(tx, { + amount = '1000', currency = USDC, recipient = recipient_pub, + methodDetails = { decimals = 6, tokenProgram = instructions.TOKEN_PROGRAM }, + }) +end) diff --git a/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua b/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua index 83264049a..cdfbd1874 100644 --- a/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua +++ b/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua @@ -22,6 +22,7 @@ package.loaded['apisix.core'].response.set_header = function(k, v) set_headers[k] = v end local pay_kit = require('pay_kit') +local mpp_store = require('pay_kit.protocols.mpp.store') helper.test('APISIX access(conf,ctx) returns 402 on unpaid', function() pay_kit._reset_for_tests() @@ -30,7 +31,9 @@ helper.test('APISIX access(conf,ctx) returns 402 on unpaid', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes'}, + -- Non-localnet MPP now requires an explicit durable replay store. + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', + replay_store = mpp_store.memory()}, }) local plugin = require('plugins.apisix.plugins.pay-kit') @@ -48,7 +51,9 @@ helper.test('APISIX access returns 500 on invalid amount', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes'}, + -- Non-localnet MPP now requires an explicit durable replay store. + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', + replay_store = mpp_store.memory()}, }) local plugin = require('plugins.apisix.plugins.pay-kit') local status = plugin.access({amount = 'not-decimal', stablecoins = {'USDC'}}, @@ -63,7 +68,9 @@ helper.test('APISIX access with named gate dispatches to dispatcher', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes'}, + -- Non-localnet MPP now requires an explicit durable replay store. + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', + replay_store = mpp_store.memory()}, }) pay_kit.gate('report', {amount = pay_kit.usd('0.05', 'USDC')}) local plugin = require('plugins.apisix.plugins.pay-kit') diff --git a/lua/tests/pay_kit/dispatcher_spec.lua b/lua/tests/pay_kit/dispatcher_spec.lua index a41e980a4..c41ec8204 100644 --- a/lua/tests/pay_kit/dispatcher_spec.lua +++ b/lua/tests/pay_kit/dispatcher_spec.lua @@ -7,6 +7,7 @@ there is no ngx.exit to call). local helper = require('tests.test_helper') local pay_kit = require('pay_kit') local errors = require('pay_kit.errors') +local mpp_store = require('pay_kit.protocols.mpp.store') local SELLER = 'SeLLeRWaLLeT111111111111111111111111111111' @@ -15,7 +16,10 @@ local function setup() assert(pay_kit.configure({ network = 'solana_devnet', operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes'}, + -- Non-localnet MPP now requires an explicit durable replay store; supply a + -- process-local one for the 402-shape tests below. + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + replay_store = mpp_store.memory()}, })) end @@ -69,6 +73,61 @@ helper.test('dispatcher refuses when configure() was not called', function() helper.assert_true(err and err:find('configure', 1, true), err) end) +-- --- MPP replay-store fail-closed on non-localnet (mirrors ruby #222) --- +-- +-- Outside localnet the MPP adapter must REQUIRE a durable, process-shared +-- replay store. A volatile in-memory fallback is process-local and lost on +-- restart, so a settled signature can be replayed against another worker for a +-- second on-chain settlement. Before the fix the adapter silently built the +-- volatile store and only warned (replay-fail-open); now it fails closed. + +helper.test('MPP on a non-localnet network fails closed without a durable replay store', function() + pay_kit._reset_for_tests() + assert(pay_kit.configure({ + network = 'solana_devnet', + accept = {'mpp'}, + operator = {recipient = SELLER}, + -- No mpp.replay_store supplied on devnet. + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes'}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + -- Building the per-gate MPP server (lazily, at 402 time) must raise. + local ok, err = pcall(pay_kit.try_payment, 'report', {headers = {}, path = '/report'}) + helper.assert_true(not ok, 'expected MPP build to fail closed without a durable replay store') + local msg = type(err) == 'table' and err.message or tostring(err) + helper.assert_true(msg:find('durable', 1, true) ~= nil and msg:find('replay store', 1, true) ~= nil, + 'expected a durable-replay-store error, got: ' .. msg) +end) + +helper.test('MPP on localnet still permits the volatile in-memory replay fallback', function() + pay_kit._reset_for_tests() + assert(pay_kit.configure({ + network = 'solana_localnet', + accept = {'mpp'}, + operator = {recipient = SELLER}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes'}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + local _, err, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) + helper.assert_equal(err, errors.PAYMENT_REQUIRED) + helper.assert_true(response ~= nil and #response.body.accepts >= 1) +end) + +helper.test('MPP on a non-localnet network accepts an explicit replay store', function() + pay_kit._reset_for_tests() + assert(pay_kit.configure({ + network = 'solana_devnet', + accept = {'mpp'}, + operator = {recipient = SELLER}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + replay_store = mpp_store.memory()}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + local _, err, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) + helper.assert_equal(err, errors.PAYMENT_REQUIRED) + helper.assert_true(response ~= nil and #response.body.accepts >= 1) +end) + -- --- ngx-stub + pick_adapter edge-path coverage (merged from dispatcher_more_spec) --- local function reset_and_configure() pay_kit._reset_for_tests() @@ -77,7 +136,8 @@ local function reset_and_configure() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'DispatcherRecipient000000000000000000000000'}, - mpp = {realm = 'TestRealm', challenge_binding_secret = 'disp-test-secret-key-long-32bytes!'}, + mpp = {realm = 'TestRealm', challenge_binding_secret = 'disp-test-secret-key-long-32bytes!', + replay_store = mpp_store.memory()}, }) pay_kit.gate('paid', {amount = pay_kit.usd('0.001', 'USDC')}) end diff --git a/lua/tests/pay_kit/kong_plugin_runtime_spec.lua b/lua/tests/pay_kit/kong_plugin_runtime_spec.lua index 26ef3efb7..dce1da3b8 100644 --- a/lua/tests/pay_kit/kong_plugin_runtime_spec.lua +++ b/lua/tests/pay_kit/kong_plugin_runtime_spec.lua @@ -108,8 +108,13 @@ end) helper.test('Kong handler access(conf) emits 402 on unpaid', function() package.loaded['plugins.kong.plugins.pay-kit.handler'] = nil require('pay_kit')._reset_for_tests() + -- Localnet: the env-based bootstrap has no knob to inject an MPP replay + -- store object, and non-localnet MPP now requires a durable one, so this + -- 402-emission check runs on localnet where the dev in-memory fallback is + -- permitted. (Production Kong on devnet/mainnet must wire a durable MPP + -- replay store — see the note in plugins/kong/plugins/pay-kit/init.lua.) patch_env({ - PAY_KIT_NETWORK = 'solana_devnet', + PAY_KIT_NETWORK = 'solana_localnet', PAY_KIT_OPERATOR_RECIPIENT = 'KongAccessRecipient0000000000000000000000', PAY_KIT_MPP_CHALLENGE_BINDING_SECRET = 'access-test-key-long-enough-32bytes', }) diff --git a/lua/tests/pay_kit/main_fixes_spec.lua b/lua/tests/pay_kit/main_fixes_spec.lua index aeda306af..2f1d8e30a 100644 --- a/lua/tests/pay_kit/main_fixes_spec.lua +++ b/lua/tests/pay_kit/main_fixes_spec.lua @@ -17,9 +17,17 @@ local headers = require('pay_kit.protocol.core.headers') local expires = require('pay_kit.protocols.mpp.expires') local preflight = require('pay_kit.preflight') local canonical_json = require('pay_kit.util.json') +local mpp_store = require('pay_kit.protocols.mpp.store') local SELLER = 'SeLLeRWaLLeT111111111111111111111111111111' +-- Non-localnet MPP now requires an explicit durable replay store. The 402 / +-- challenge-shape tests below only exercise issuance, so a process-local store +-- satisfies the contract. +local function devnet_replay_store() + return mpp_store.memory() +end + local function reset() pay_kit._reset_for_tests() end @@ -38,7 +46,8 @@ helper.test('402 response carries Cache-Control: no-store', function() assert(pay_kit.configure({ network = 'solana_devnet', operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes'}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + replay_store = devnet_replay_store()}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -53,7 +62,8 @@ helper.test('MPP 402 challenge carries an expiry from config.mpp.expires_in', fu network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120, + replay_store = devnet_replay_store()}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -72,7 +82,8 @@ helper.test('MPP 402 challenge omits expiry when expires_in = false (dev opt-out network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = false}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = false, + replay_store = devnet_replay_store()}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -113,7 +124,8 @@ helper.test('adapter expected methodDetails matches the issued challenge', funct network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120, + replay_store = devnet_replay_store()}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) diff --git a/lua/tests/pay_kit/schemes_x402_spec.lua b/lua/tests/pay_kit/schemes_x402_spec.lua index 72cc6d358..09df4d8d8 100644 --- a/lua/tests/pay_kit/schemes_x402_spec.lua +++ b/lua/tests/pay_kit/schemes_x402_spec.lua @@ -28,6 +28,10 @@ local function make_gate(price_str) return assert(require('pay_kit.internal.registry').materialize('paid')) end +local function test_store() + return { put_if_absent = function() return true end } +end + -- --- detect --------------------------------------------------------- helper.test('detect: returns true for non-empty PAYMENT-SIGNATURE header', function() @@ -43,7 +47,7 @@ end) -- --- matcher -------------------------------------------------------- -helper.test('matcher: identity tuple match ignores amount/maxTimeoutSeconds', function() +helper.test('matcher: identity tuple match permits omitted amount/maxTimeoutSeconds', function() local client = { scheme = 'exact', network = 'solana:dev', @@ -64,6 +68,12 @@ helper.test('matcher: identity tuple match ignores amount/maxTimeoutSeconds', fu helper.assert_equal(x402._private.accepted_requirement_matches(client, server), true) end) +helper.test('x402.new requires replay store with put_if_absent', function() + local adapter, err = x402.new({config_resolver = pay_kit.config}) + helper.assert_equal(adapter, nil) + helper.assert_true(err and err:find('replay store', 1, true), err) +end) + helper.test('matcher: scheme/network/asset/payTo mismatch returns false', function() local server = {scheme = 'exact', network = 'solana:dev', asset = 'A', payTo = 'P'} helper.assert_equal(x402._private.accepted_requirement_matches( @@ -155,7 +165,10 @@ end) helper.test('verify_and_settle rejects unmatched accepted', function() setup() local gate = make_gate('0.001') - local adapter = assert(x402.new({config_resolver = pay_kit.config})) + local adapter = assert(x402.new({ + config_resolver = pay_kit.config, + store = { put_if_absent = function() return true end }, + })) local base64 = require('pay_kit.util.base64_std') local cred = { x402Version = 2, @@ -272,7 +285,7 @@ end) helper.test('verify_and_settle rejects a v1 credential signed for the wrong network', function() setup() -- server configured for solana_devnet local gate = make_gate('0.001') - local adapter = assert(x402.new({config_resolver = pay_kit.config})) + local adapter = assert(x402.new({config_resolver = pay_kit.config, store = test_store()})) local base64 = require('pay_kit.util.base64_std') -- v1 envelope with plain "solana" (mainnet) presented to a devnet route. local v1 = base64.encode(cjson.encode({ @@ -288,7 +301,7 @@ end) helper.test('verify_and_settle passes a matching-network v1 credential past the network gate', function() setup() -- server configured for solana_devnet local gate = make_gate('0.001') - local adapter = assert(x402.new({config_resolver = pay_kit.config})) + local adapter = assert(x402.new({config_resolver = pay_kit.config, store = test_store()})) local base64 = require('pay_kit.util.base64_std') -- v1 envelope with plain "solana-devnet" against a devnet route: the -- network gate passes, so the failure must come from the missing payload diff --git a/lua/tests/pay_kit/x402_broadcast_spec.lua b/lua/tests/pay_kit/x402_broadcast_spec.lua index 5fb11429d..56550dcab 100644 --- a/lua/tests/pay_kit/x402_broadcast_spec.lua +++ b/lua/tests/pay_kit/x402_broadcast_spec.lua @@ -42,7 +42,7 @@ package.loaded['pay_kit.solana.rpc'] = { end, latest_blockhash = function() return string.rep('0', 32) end, simulate_transaction = function() return {err = nil} end, - signature_statuses = function() return {} end, + signature_statuses = function() return {{err = nil, confirmationStatus = 'confirmed'}} end, } end, Rpc = {}, @@ -59,6 +59,7 @@ package.loaded['pay_kit'] = nil local pay_kit = require('pay_kit') local signer = require('pay_kit.signer') +local x402 = require('pay_kit.protocols.x402') -- --- helpers (mirror x402_verify_positive_spec) ------------------- @@ -195,6 +196,27 @@ helper.test('x402 verify_and_settle: cosigns + broadcasts + reserves signature', helper.assert_equal(#broadcast_calls, 1) end) +helper.test('x402 confirmation helper rejects missing or failed statuses', function() + local queried = false + local ok, err = x402._private.await_confirmed_or_finalized({ + signature_statuses = function(_, signatures) + queried = signatures[1] == 'sig-missing' + return {nil} + end, + }, 'sig-missing') + helper.assert_equal(ok, nil) + helper.assert_true(queried, 'expected signature_statuses to be queried') + helper.assert_true(err and err:find('missing', 1, true), err) + + local ok_failed, err_failed = x402._private.await_confirmed_or_finalized({ + signature_statuses = function() + return {{err = 'boom', confirmationStatus = 'confirmed'}} + end, + }, 'sig-failed') + helper.assert_equal(ok_failed, nil) + helper.assert_true(err_failed and err_failed:find('settlement failed', 1, true), err_failed) +end) + helper.test('x402 verify_and_settle: SIGNATURE_CONSUMED on duplicate submit', function() -- Re-using the same credential should trip the replay store via -- consume_signature returning false. @@ -206,6 +228,7 @@ helper.test('x402 verify_and_settle: SIGNATURE_CONSUMED on duplicate submit', fu send_raw_transaction = function() return 'fakeSignatureBase58' end, latest_blockhash = function() return string.rep('0', 32) end, simulate_transaction = function() return {err = nil} end, + signature_statuses = function() return {{err = nil, confirmationStatus = 'confirmed'}} end, } end, } diff --git a/lua/tests/pay_kit/x402_extensions_spec.lua b/lua/tests/pay_kit/x402_extensions_spec.lua index d0c9a0f2f..9a5246467 100644 --- a/lua/tests/pay_kit/x402_extensions_spec.lua +++ b/lua/tests/pay_kit/x402_extensions_spec.lua @@ -27,6 +27,13 @@ local SELLER = 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY' local MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU' local PIN_ID = 'pay_abcdef1234567890abcdef1234567890' +-- x402.new requires a replay store with put_if_absent (residual-bulk-fix +-- REQUIRED-LUA-2). These gate-path tests reject before the consume path, so +-- an always-first-insert stub is sufficient; mirrors schemes_x402_spec. +local function test_store() + return { put_if_absent = function() return true end } +end + local function setup(requires_payment_identifier) pay_kit._reset_for_tests() assert(pay_kit.configure({ @@ -122,7 +129,7 @@ helper.test('ext: verify_and_settle rejects when required id is missing', functi setup(true) local config = pay_kit.config() local gate = make_gate() - local adapter = assert(x402.new({config_resolver = pay_kit.config})) + local adapter = assert(x402.new({config_resolver = pay_kit.config, store = test_store()})) -- Echo the extension WITHOUT an id (the server advertised info.required). local headers = credential(config, gate, {['payment-identifier'] = {info = {required = true}}}) @@ -134,7 +141,7 @@ helper.test('ext: verify_and_settle rejects a pattern-violating id', function() setup(true) local config = pay_kit.config() local gate = make_gate() - local adapter = assert(x402.new({config_resolver = pay_kit.config})) + local adapter = assert(x402.new({config_resolver = pay_kit.config, store = test_store()})) local headers = credential(config, gate, {['payment-identifier'] = {info = {required = true, id = 'too short'}}}) local _, err = adapter:verify_and_settle(gate, {headers = headers, path = '/paid'}) @@ -145,7 +152,7 @@ helper.test('ext: verify_and_settle passes the gate for a valid echoed id', func setup(true) local config = pay_kit.config() local gate = make_gate() - local adapter = assert(x402.new({config_resolver = pay_kit.config})) + local adapter = assert(x402.new({config_resolver = pay_kit.config, store = test_store()})) local headers = credential(config, gate, {['payment-identifier'] = {info = {required = true, id = PIN_ID}}}) local _, err = adapter:verify_and_settle(gate, {headers = headers, path = '/paid'}) @@ -163,7 +170,7 @@ helper.test('ext: no gate when route does not require a payment-identifier', fun setup(false) local config = pay_kit.config() local gate = make_gate() - local adapter = assert(x402.new({config_resolver = pay_kit.config})) + local adapter = assert(x402.new({config_resolver = pay_kit.config, store = test_store()})) -- No extensions echoed at all; the gate must not fire. local headers = credential(config, gate, nil) local _, err = adapter:verify_and_settle(gate, {headers = headers, path = '/paid'}) diff --git a/lua/tests/pay_kit/x402_verify_spec.lua b/lua/tests/pay_kit/x402_verify_spec.lua index c2efd5faf..1938535c1 100644 --- a/lua/tests/pay_kit/x402_verify_spec.lua +++ b/lua/tests/pay_kit/x402_verify_spec.lua @@ -584,6 +584,66 @@ helper.test('rule 9: ATA-create optional instruction rejected', function() tostring(err):find('unknown', 1, true) ~= nil, tostring(err)) end) +helper.test('rule 5: fee-payer ATA as transfer source rejected', function() + -- The transfer authority is a legitimate customer key, but the source ATA + -- is the facilitator's OWN token account for the mint -- draining the + -- operator. The source-account guard must reject even though the authority + -- is a distinct key. + local facilitator = base58.encode(string.rep('\1', 32)) + local authority = base58.encode(string.rep('\2', 32)) + local mint = base58.encode(string.rep('\4', 32)) + local pay_to = base58.encode(string.rep('\5', 32)) + local destination = ata.derive(pay_to, mint, TOKEN_PROGRAM) + local fee_payer_source = ata.derive(facilitator, mint, TOKEN_PROGRAM) + local keys = standard_keys(facilitator, fee_payer_source, mint, destination, authority) + local raw = assemble(keys, 8, { + build_ix(5, {}, string.char(2) .. u32_le(200000)), + build_ix(5, {}, string.char(3) .. u64_le(1000)), + build_ix(6, {1, 2, 3, 4}, string.char(12) .. u64_le(1000) .. string.char(6)), + build_ix(7, {}, '/paid'), + }) + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + default_offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(not ok, 'expected verify to REJECT a facilitator-owned source ATA') + helper.assert_true(tostring(err):find('fee_payer', 1, true) ~= nil, tostring(err)) +end) + +helper.test('rule 5: Lighthouse guard referencing the fee payer accepted', function() + -- A Lighthouse guard whose accounts reference the facilitator (index 0) is + -- a state assertion, not a fund move, and MUST be accepted -- the canonical + -- rule guards only the transfer authority and funding source. This is the + -- M-6 uniform-verdict case. + local facilitator = base58.encode(string.rep('\1', 32)) + local authority = base58.encode(string.rep('\2', 32)) + local source = base58.encode(string.rep('\3', 32)) + local mint = base58.encode(string.rep('\4', 32)) + local pay_to = base58.encode(string.rep('\5', 32)) + local destination = ata.derive(pay_to, mint, TOKEN_PROGRAM) + -- 9-key layout: standard 8 keys + Lighthouse program at index 8. + local keys = table.concat({ + base58.decode(facilitator), + base58.decode(source), + base58.decode(mint), + base58.decode(destination), + base58.decode(authority), + base58.decode(COMPUTE_BUDGET), + base58.decode(TOKEN_PROGRAM), + base58.decode(MEMO_PROGRAM), + base58.decode(LIGHTHOUSE_PROGRAM), -- index 8 + }) + -- Slots: compute-limit, compute-price, transfer, Lighthouse(fee-payer), memo. + local raw = assemble(keys, 9, { + build_ix(5, {}, string.char(2) .. u32_le(200000)), + build_ix(5, {}, string.char(3) .. u64_le(1000)), + build_ix(6, {1, 2, 3, 4}, string.char(12) .. u64_le(1000) .. string.char(6)), + build_ix(8, {0}, string.char(0)), -- Lighthouse guard referencing fee payer + build_ix(7, {}, '/paid'), + }) + local ok = pcall(x402_verify.verify, base64.encode(raw), + default_offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(ok, 'expected verify to ACCEPT a Lighthouse guard referencing the fee payer') +end) + helper.test('verify_client_signatures rejects when no client signatures remain', function() local facilitator, authority, source, mint, _pay_to, destination = setup_actors() local keys = standard_keys(facilitator, source, mint, destination, authority) diff --git a/lua/tests/solana_verify_spec.lua b/lua/tests/solana_verify_spec.lua index 49998e09f..2b2d19ce3 100644 --- a/lua/tests/solana_verify_spec.lua +++ b/lua/tests/solana_verify_spec.lua @@ -1296,6 +1296,118 @@ t.test('SECURITY: result.consumed is not set when context.store is absent', func t.assert_equal(result.replay_key, nil) end) +-- M1: the jsonParsed RPC path decodes `info.lamports` as a Lua NUMBER, not a +-- string. Every fixture above passes lamports as a string, which masks the +-- bug: on LuaJIT / Lua 5.1 a JSON number is a double, and a u64 lamports value +-- >= 2^53 (a) is lossy and (b) serializes via tostring() as scientific +-- notation ("9e+18"), so the old `uint.compare(info.lamports, ...)` raised +-- "invalid unsigned integer" on a genuinely large payment (DoS), and a +-- truncated-but-normalizable value could match the wrong amount. These tests +-- pass lamports as a number to exercise the jsonParsed shape directly. + +t.test('M1: signature verifier matches SOL transfer when lamports is an integer NUMBER', function() + local result = verify.verify_signature(signature_context(), { + fetch_transaction = function() + return { + meta = { err = nil }, + transaction = { + message = { + instructions = { + { + program = 'system', + parsed = { + type = 'transfer', + -- NUMBER, not string: the jsonParsed shape. + info = { destination = 'recipient-1', lamports = 1000 }, + }, + }, + }, + }, + }, + } + end, + }) + t.assert_equal(result.reference, 'sig-123') +end) + +t.test('M1: lossy double lamports is rejected as no-match, not an uint crash', function() + -- Force a double that is out of the exact-integer range (|x| > 2^53). On + -- LuaJIT / Lua 5.1 this is exactly what the JSON parser produces for a large + -- u64; on Lua 5.3+ the float literal keeps the double type. Pre-fix this + -- raised "invalid unsigned integer: 9e+18" from uint.normalize; post-fix the + -- lossy candidate is skipped and the loop reports the canonical rejection. + local lossy_lamports = 2.0 ^ 63 -- 9223372036854775808.0, a double well past 2^53 + local context = signature_context({ + request = { + amount = '9223372036854775808', + currency = 'sol', + recipient = 'recipient-1', + methodDetails = {}, + }, + method_details = {}, + }) + t.assert_error(function() + verify.verify_signature(context, { + fetch_transaction = function() + return { + meta = { err = nil }, + transaction = { + message = { + instructions = { + { + program = 'system', + parsed = { + type = 'transfer', + info = { destination = 'recipient-1', lamports = lossy_lamports }, + }, + }, + }, + }, + }, + } + end, + }) + -- The rejection must be the canonical no-match, NOT uint's + -- "invalid unsigned integer" crash. + end, 'no matching SOL transfer') +end) + +t.test('M1: fractional double lamports never satisfies an integer amount', function() + -- A non-integral lamports value can never equal a whole-lamport charge; it + -- must be rejected as a no-match rather than truncated into a false match. + local context = signature_context({ + request = { + amount = '1000', + currency = 'sol', + recipient = 'recipient-1', + methodDetails = {}, + }, + method_details = {}, + }) + t.assert_error(function() + verify.verify_signature(context, { + fetch_transaction = function() + return { + meta = { err = nil }, + transaction = { + message = { + instructions = { + { + program = 'system', + parsed = { + type = 'transfer', + info = { destination = 'recipient-1', lamports = 1000.5 }, + }, + }, + }, + }, + }, + } + end, + }) + end, 'no matching SOL transfer') +end) + -- Audit #5: push mode (type=signature) is opt-in via the signature verifier -- factory. Default-off reduces a server's attack surface (spec §13.5 From 6d9edcf167257fbc153a38d56dfa79c503d21feb Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Fri, 10 Jul 2026 02:18:37 +0300 Subject: [PATCH 2/7] fix(lua): confirm x402 settlement before replay consume --- lua/pay_kit/protocols/x402/init.lua | 105 +++++++++++++++++----- lua/tests/pay_kit/x402_broadcast_spec.lua | 49 +++++++++- 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/lua/pay_kit/protocols/x402/init.lua b/lua/pay_kit/protocols/x402/init.lua index 4088a551a..a0941bace 100644 --- a/lua/pay_kit/protocols/x402/init.lua +++ b/lua/pay_kit/protocols/x402/init.lua @@ -362,6 +362,30 @@ local function build_rpc(config) return rpc_mod.new({url = config.rpc_url, transport = rpc_transport.new()}) end +local DEFAULT_CONFIRMATION_ATTEMPTS = 40 +local DEFAULT_CONFIRMATION_DELAY_SECONDS = 0.25 + +local function monotonic_seconds() + local ok, socket = pcall(require, 'socket') + if ok and socket and type(socket.gettime) == 'function' then + return socket.gettime() + end + return os.time() +end + +local function default_sleep(seconds) + local ngx_ref = rawget(_G, 'ngx') + if ngx_ref and type(ngx_ref.sleep) == 'function' then + return ngx_ref.sleep(seconds) + end + local ok, socket = pcall(require, 'socket') + if ok and socket and type(socket.sleep) == 'function' then + return socket.sleep(seconds) + end + local target = monotonic_seconds() + (seconds or 0) + while monotonic_seconds() < target do end +end + local function consume_signature(store, signature) local key = 'x402-svm-exact:consumed:' .. signature if type(store) ~= 'table' or type(store.put_if_absent) ~= 'function' then @@ -374,20 +398,40 @@ local function consume_signature(store, signature) return inserted == true end -local function await_confirmed_or_finalized(rpc, signature) - local statuses = rpc:signature_statuses({ signature }) - local status = statuses[1] - if type(status) ~= 'table' then - return nil, 'settlement confirmation missing' - end - if status.err ~= nil and status.err ~= json.null then - return nil, 'settlement failed: ' .. tostring(status.err) - end - local confirmation = status.confirmationStatus - if confirmation ~= 'confirmed' and confirmation ~= 'finalized' then - return nil, 'settlement not confirmed' +local function await_confirmed_or_finalized(rpc, signature, options) + options = options or {} + local attempts = options.attempts or DEFAULT_CONFIRMATION_ATTEMPTS + local delay_seconds = options.delay_seconds or DEFAULT_CONFIRMATION_DELAY_SECONDS + local sleep = options.sleep or default_sleep + local last_rpc_error + + for attempt = 1, attempts do + -- A newly broadcast signature may not be visible to the selected RPC node + -- yet, and transient transport failures are retryable. Only an explicit + -- on-chain transaction error is terminal before the attempt budget ends. + local queried, statuses = pcall(function() + return rpc:signature_statuses({ signature }) + end) + if queried then + local status = statuses and statuses[1] + if type(status) == 'table' then + if status.err ~= nil and status.err ~= json.null then + return nil, 'settlement failed: ' .. tostring(status.err) + end + local confirmation = status.confirmationStatus + if confirmation == 'confirmed' or confirmation == 'finalized' then + return true + end + end + else + last_rpc_error = type(statuses) == 'table' and statuses.message or tostring(statuses) + end + if attempt < attempts then sleep(delay_seconds) end end - return true + + local detail = last_rpc_error and (': last RPC error: ' .. last_rpc_error) or '' + return nil, 'settlement confirmation timed out after ' .. tostring(attempts) .. + ' attempts' .. detail end -- --- public API ----------------------------------------------------- @@ -403,6 +447,10 @@ function M.new(opts) return setmetatable({ _config_resolver = opts.config_resolver, _store = opts.store, + _confirmation_attempts = opts.confirmation_attempts or DEFAULT_CONFIRMATION_ATTEMPTS, + _confirmation_delay_seconds = opts.confirmation_delay_seconds or + DEFAULT_CONFIRMATION_DELAY_SECONDS, + _sleep = opts.sleep or default_sleep, }, Adapter) end @@ -586,7 +634,11 @@ function Adapter:verify_and_settle(gate, req) end local cosigned = cosigned_or_err - -- Broadcast + consume + confirm. + -- Broadcast, wait for confirmed/finalized settlement, then atomically + -- consume the signature. Confirmation must happen first: consuming before + -- the RPC has observed the transaction turns an ordinary propagation delay + -- into a permanently paid-but-uncredited request. Concurrent presentations + -- are still safe because put_if_absent is the final atomic winner gate. local rpc = build_rpc(config) local broadcast_ok, signature_or_err = pcall(function() return rpc:send_raw_transaction(cosigned) @@ -600,15 +652,12 @@ function Adapter:verify_and_settle(gate, req) return nil, errors.INVALID_PROOF .. ': empty broadcast result' end - local inserted, consume_err = consume_signature(self._store, signature) - if consume_err then - return nil, errors.INVALID_PROOF .. ': ' .. consume_err - end - if not inserted then - return nil, errors.SIGNATURE_CONSUMED - end - - local confirmed_ok, confirmed, confirm_err = pcall(await_confirmed_or_finalized, rpc, signature) + local confirmed_ok, confirmed, confirm_err = pcall(await_confirmed_or_finalized, + rpc, signature, { + attempts = self._confirmation_attempts, + delay_seconds = self._confirmation_delay_seconds, + sleep = self._sleep, + }) if not confirmed_ok then local raised = confirmed local message = type(raised) == 'table' and raised.message or tostring(raised) @@ -618,6 +667,14 @@ function Adapter:verify_and_settle(gate, req) return nil, errors.INVALID_PROOF .. ': ' .. tostring(confirm_err) end + local inserted, consume_err = consume_signature(self._store, signature) + if consume_err then + return nil, errors.INVALID_PROOF .. ': ' .. consume_err + end + if not inserted then + return nil, errors.SIGNATURE_CONSUMED + end + -- Settlement response. v1 emits X-PAYMENT-RESPONSE carrying the plain SVM -- network slug and the payer pubkey (rust v1 settlement shape -- { success, transaction, network, payer }); v2 emits PAYMENT-RESPONSE @@ -668,6 +725,8 @@ M._private = { caip2_network_for_cluster = caip2_network_for_cluster, legacy_network_slug = legacy_network_slug, await_confirmed_or_finalized = await_confirmed_or_finalized, + DEFAULT_CONFIRMATION_ATTEMPTS = DEFAULT_CONFIRMATION_ATTEMPTS, + DEFAULT_CONFIRMATION_DELAY_SECONDS = DEFAULT_CONFIRMATION_DELAY_SECONDS, LEGACY_PAYMENT_HEADER = LEGACY_PAYMENT_HEADER, LEGACY_PAYMENT_RESPONSE_HEADER = LEGACY_PAYMENT_RESPONSE_HEADER, } diff --git a/lua/tests/pay_kit/x402_broadcast_spec.lua b/lua/tests/pay_kit/x402_broadcast_spec.lua index 56550dcab..2f81d83ff 100644 --- a/lua/tests/pay_kit/x402_broadcast_spec.lua +++ b/lua/tests/pay_kit/x402_broadcast_spec.lua @@ -196,27 +196,68 @@ helper.test('x402 verify_and_settle: cosigns + broadcasts + reserves signature', helper.assert_equal(#broadcast_calls, 1) end) -helper.test('x402 confirmation helper rejects missing or failed statuses', function() +helper.test('x402 confirmation helper times out on missing status and rejects failure', function() local queried = false local ok, err = x402._private.await_confirmed_or_finalized({ signature_statuses = function(_, signatures) queried = signatures[1] == 'sig-missing' return {nil} end, - }, 'sig-missing') + }, 'sig-missing', {attempts = 1, sleep = function() end}) helper.assert_equal(ok, nil) helper.assert_true(queried, 'expected signature_statuses to be queried') - helper.assert_true(err and err:find('missing', 1, true), err) + helper.assert_true(err and err:find('timed out after 1 attempts', 1, true), err) local ok_failed, err_failed = x402._private.await_confirmed_or_finalized({ signature_statuses = function() return {{err = 'boom', confirmationStatus = 'confirmed'}} end, - }, 'sig-failed') + }, 'sig-failed', {attempts = 3, sleep = function() end}) helper.assert_equal(ok_failed, nil) helper.assert_true(err_failed and err_failed:find('settlement failed', 1, true), err_failed) end) +helper.test('x402 confirmation helper retries propagation lag and transient RPC errors', function() + local calls = 0 + local sleeps = 0 + local ok, err = x402._private.await_confirmed_or_finalized({ + signature_statuses = function() + calls = calls + 1 + if calls == 1 then error('temporary transport failure') end + if calls == 2 then return {nil} end + if calls == 3 then + return {{err = nil, confirmationStatus = 'processed'}} + end + return {{err = nil, confirmationStatus = 'confirmed'}} + end, + }, 'sig-eventual', { + attempts = 4, + delay_seconds = 0.25, + sleep = function(delay) + helper.assert_equal(delay, 0.25) + sleeps = sleeps + 1 + end, + }) + helper.assert_equal(ok, true) + helper.assert_equal(err, nil) + helper.assert_equal(calls, 4) + helper.assert_equal(sleeps, 3) +end) + +helper.test('x402 confirmation helper reports the last RPC error after exhaustion', function() + local calls = 0 + local ok, err = x402._private.await_confirmed_or_finalized({ + signature_statuses = function() + calls = calls + 1 + error('rpc unavailable') + end, + }, 'sig-rpc-down', {attempts = 2, sleep = function() end}) + helper.assert_equal(ok, nil) + helper.assert_equal(calls, 2) + helper.assert_true(err and err:find('last RPC error', 1, true), err) + helper.assert_true(err and err:find('rpc unavailable', 1, true), err) +end) + helper.test('x402 verify_and_settle: SIGNATURE_CONSUMED on duplicate submit', function() -- Re-using the same credential should trip the replay store via -- consume_signature returning false. From 8f240153855ddc11d6415a4aae94f727a5fa3337 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:43:50 +0300 Subject: [PATCH 3/7] fix(lua): require shared replay protection --- .github/workflows/lua.yml | 3 +- lua/cmd/conformance/main.lua | 56 +++--- lua/pay_kit/protocols/mpp/init.lua | 74 ++++---- .../protocols/mpp/server/charge_handler.lua | 38 ++-- lua/pay_kit/protocols/mpp/server/init.lua | 111 ++++++++--- .../mpp/server/store_shared_dict.lua | 6 + lua/pay_kit/protocols/mpp/store.lua | 6 + lua/pay_kit/protocols/x402/exact/verify.lua | 33 ++-- lua/plugins/kong/plugins/pay-kit/init.lua | 12 ++ lua/tests/charge_handler_spec.lua | 31 ++- lua/tests/error_codes_spec.lua | 5 +- lua/tests/html_spec.lua | 5 +- .../pay_kit/apisix_plugin_runtime_spec.lua | 13 +- lua/tests/pay_kit/dispatcher_spec.lua | 36 +++- .../pay_kit/kong_plugin_runtime_spec.lua | 9 + lua/tests/pay_kit/main_fixes_spec.lua | 29 ++- lua/tests/pay_kit/x402_verify_spec.lua | 43 +++++ lua/tests/server_spec.lua | 178 ++++++++++++++++++ lua/tests/store_shared_dict_spec.lua | 5 + lua/tests/test_helper.lua | 28 ++- 20 files changed, 547 insertions(+), 174 deletions(-) diff --git a/.github/workflows/lua.yml b/.github/workflows/lua.yml index 469ec7f96..ac41b1997 100644 --- a/.github/workflows/lua.yml +++ b/.github/workflows/lua.yml @@ -130,7 +130,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 diff --git a/lua/cmd/conformance/main.lua b/lua/cmd/conformance/main.lua index 45b5c3020..7e0893ff9 100644 --- a/lua/cmd/conformance/main.lua +++ b/lua/cmd/conformance/main.lua @@ -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' @@ -472,6 +473,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 @@ -704,39 +707,31 @@ local function run_x402_verify(vector) } end --- verify-x402-transaction: drive the real Lua 11-rule exact fund-safety --- verifier over the base64 versioned transaction. On reject the canonical --- invalid_exact_svm_payload_* string (the raised error message) is surfaced --- verbatim as x402ExactRejectCode so the cross-SDK vectors bind the exact code. -local function run_x402_exact_verify(vector) - local x402_exact_verify = require('pay_kit.protocols.x402.exact.verify') +-- 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 {} - local transaction = input.transaction - if type(transaction) ~= 'string' or transaction == '' then - error('verify-x402-transaction vector missing input.transaction') + if type(input.transaction) ~= 'string' or input.transaction == '' then + error('invalid payload: verify-x402-transaction vector missing input.transaction') end - local requirement = input.x402ExactRequirement - if type(requirement) ~= 'table' then - error('verify-x402-transaction vector missing input.x402ExactRequirement') + if type(input.x402ExactRequirement) ~= 'table' then + error('invalid payload: verify-x402-transaction vector missing input.x402ExactRequirement') end - local managed_signers = {} - for _, key in ipairs(input.x402ExactManagedSigners or {}) do - managed_signers[#managed_signers + 1] = key + 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 - local ok, err = pcall(x402_exact_verify.verify, transaction, requirement, managed_signers) - if ok then - return { id = vector.id, outcome = 'accept' } - end - -- The verifier raises the canonical reject string as a plain error; strip - -- LuaJIT's "file:line: " prefix so the exact code is surfaced verbatim. - local message = tostring(err):gsub('^.-:%d+:%s*', '') - return { - id = vector.id, - outcome = 'reject', - error = message, - x402ExactRejectCode = message, - } + 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 @@ -744,7 +739,7 @@ end -- 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_verify(vector) + return run_x402_exact_transaction(vector) end if vector.mode == 'build-transaction' then return { @@ -880,6 +875,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 diff --git a/lua/pay_kit/protocols/mpp/init.lua b/lua/pay_kit/protocols/mpp/init.lua index 370e0f253..2ba90a011 100644 --- a/lua/pay_kit/protocols/mpp/init.lua +++ b/lua/pay_kit/protocols/mpp/init.lua @@ -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) @@ -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. @@ -83,14 +88,7 @@ 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 @@ -98,37 +96,30 @@ local function build_mpp_server(config, gate, store) -- 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`. - -- - -- Fail CLOSED outside localnet. A volatile in-memory fallback is not a - -- durable replay primitive: markers are lost on restart and invisible to - -- other workers/hosts, so a settled signature can be replayed against - -- another worker for a second on-chain settlement (double-spend). On - -- mainnet/devnet we therefore REQUIRE the operator to inject a durable, - -- process-shared store rather than silently constructing a volatile one and - -- only warning. Localnet keeps the volatile dev fallback (single-worker dev - -- is the expected shape there). Mirrors the Ruby `PayKit::Protocols::Mpp` - -- durable-store requirement (a caller who supplies ANY store owns that - -- choice; only the "no store supplied at all" path is rejected) and the - -- TS/PHP/Python shared-replay-store fail-closed posture. + -- 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('pay_kit: MPP replay protection requires a durable, process-shared ' .. - 'replay store on ' .. tostring(network) .. '. Set config.mpp.replay_store ' .. - 'to a shared store (ngx.shared.dict / Redis-backed); the in-memory ' .. - 'default is process-local and lost on restart, so a settled signature ' .. - 'can be replayed against another worker for a second settlement.') + 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, @@ -146,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 diff --git a/lua/pay_kit/protocols/mpp/server/charge_handler.lua b/lua/pay_kit/protocols/mpp/server/charge_handler.lua index dc4649e6d..3c525b52d 100644 --- a/lua/pay_kit/protocols/mpp/server/charge_handler.lua +++ b/lua/pay_kit/protocols/mpp/server/charge_handler.lua @@ -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 @@ -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 @@ -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 diff --git a/lua/pay_kit/protocols/mpp/server/init.lua b/lua/pay_kit/protocols/mpp/server/init.lua index 70ba7adff..8b7c4f0d3 100644 --- a/lua/pay_kit/protocols/mpp/server/init.lua +++ b/lua/pay_kit/protocols/mpp/server/init.lua @@ -16,6 +16,30 @@ local M = {} local MIN_SECRET_KEY_BYTES = 32 local CONSUMED_PREFIX = 'solana-charge:consumed:' +local warned_volatile_replay_store = false + +local function warn_volatile_replay_store() + if warned_volatile_replay_store then return end + warned_volatile_replay_store = true + local message = 'pay_kit: MPP replay protection is using a process-local ' .. + 'in-memory store. This fallback is supported only on localnet; configure ' .. + 'a shared replay store before deploying elsewhere.' + local ngx_ref = rawget(_G, 'ngx') + if ngx_ref and ngx_ref.log and ngx_ref.WARN then + ngx_ref.log(ngx_ref.WARN, message) + else + io.stderr:write('[pay_kit] WARN: ' .. message .. '\n') + 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 + return replay_store.shared == true or replay_store.durable == true +end + -- Audit #15: derive a per-recipient default realm instead of the static -- shared `"MPP Payment"`. Two servers that share a secret but front -- different recipients now land in different HMAC credential namespaces, so a @@ -98,6 +122,20 @@ function M.new(config) if not net_ok then error(net_err) end + local replay_store = config.store + if replay_store == nil then + if network ~= 'localnet' then + error('replay store is required outside localnet; pass config.store') + end + replay_store = store.memory() + warn_volatile_replay_store() + end + if network ~= 'localnet' and not replay_store_is_shared(replay_store) then + error('replay store must be shared outside localnet; process-local stores are unsafe') + end + if type(replay_store) ~= 'table' or type(replay_store.put_if_absent) ~= 'function' then + error('replay store must implement put_if_absent(key, value)') + end -- Audit #15: explicit empty realm is rejected (an operator typo must not -- silently re-introduce the shared-namespace threat). When unset, derive a -- per-recipient default. @@ -151,7 +189,7 @@ function M.new(config) rpc_url = config.rpc_url or protocol.default_rpc_url(network), fee_payer = fee_payer, fee_payer_key = config.fee_payer_key, - store = config.store or store.memory(), + store = replay_store, verify_payment = config.verify_payment, recent_blockhash = config.recent_blockhash, html = config.html or false, @@ -498,36 +536,63 @@ function Server:_finalize_verification(credential_value, request, payload) error('verify_payment callback is required') end + -- Stores historically need only `put_if_absent`. When a verifier reserves a + -- marker through such a store, record the successful atomic write in this + -- request-local proxy. That proves the inner reservation without trusting a + -- bare `consumed = true` result from an arbitrary callback. + local verifier_store = self.store + local verifier_reservations + if type(self.store.get) ~= 'function' then + verifier_reservations = {} + verifier_store = { + put_if_absent = function(_, key, value) + local inserted = self.store:put_if_absent(key, value) + if inserted then + verifier_reservations[key] = true + end + return inserted + end, + } + end + local result = self.verify_payment({ payload = payload, request = request, method_details = method_details, credential = credential_value, - store = self.store, + store = verifier_store, server = self, - }) or {} + }) + + if type(result) ~= 'table' then + error_codes.raise(error_codes.PAYMENT_INVALID, + 'verification result must be a table') + end - local reference = result.reference or payload.signature or payload.transaction - if reference == nil or reference == '' then + local reference = result.reference + if type(reference) ~= 'string' or reference == '' then error_codes.raise(error_codes.PAYMENT_INVALID, - 'verification result must include a reference') - end - - -- Settlement layers that already consumed the replay marker themselves - -- (e.g. `charge_handler:as_callback()` which writes - -- `solana-charge:consumed:` inside `settle_pull` before await, and - -- `solana_verify.verify_transaction` which writes the same key between - -- broadcast and await for the L8 ordering fix) signal back via - -- `result.consumed = true`. Skip the outer put_if_absent in that case so - -- the same marker is not re-asserted against the shared store. When the - -- verifier supplies its own `replay_key` we also know the consume already - -- happened, so honoring `consumed` here keeps the Kong / OpenResty - -- wiring (shared replay store between charge_handler and Server) from - -- double-asserting and returning a spurious `signature_consumed` on the - -- first valid payment. Push-mode signature verifiers that do not consume - -- themselves fall through to the outer guard. - if result.consumed ~= true then - local replay_key = result.replay_key or (CONSUMED_PREFIX .. reference) + 'verification result must include a non-empty reference') + end + + -- An inner settlement path may reserve the signature before returning (the + -- charge handler and bundled Solana verifier do this around broadcast). A + -- bare `consumed = true` is not proof of that reservation: only skip the + -- outer atomic guard when the callback identifies a marker that is actually + -- present in this server's replay store. Otherwise a custom callback could + -- return two receipts for the same signature without recording a replay key. + local consumed_marker_present = false + if result.consumed == true and type(result.replay_key) == 'string' and result.replay_key ~= '' then + if verifier_reservations ~= nil then + consumed_marker_present = verifier_reservations[result.replay_key] == true + elseif type(self.store.get) == 'function' then + local marker, present = self.store:get(result.replay_key) + consumed_marker_present = present == true or marker == true + end + end + + if not consumed_marker_present then + local replay_key = CONSUMED_PREFIX .. reference local inserted = self.store:put_if_absent(replay_key, true) if not inserted then error_codes.raise(error_codes.SIGNATURE_CONSUMED, 'payment already consumed') diff --git a/lua/pay_kit/protocols/mpp/server/store_shared_dict.lua b/lua/pay_kit/protocols/mpp/server/store_shared_dict.lua index a7e6f3fe1..9113cc04c 100644 --- a/lua/pay_kit/protocols/mpp/server/store_shared_dict.lua +++ b/lua/pay_kit/protocols/mpp/server/store_shared_dict.lua @@ -109,6 +109,12 @@ function SharedDictStore:put_if_absent(key, value) error('shared dict put_if_absent failed: ' .. tostring(err)) end +-- The underlying `ngx.shared.DICT:add` operation is atomic across workers. +-- This marker lets callers reject process-local stores outside localnet. +function SharedDictStore:is_shared() + return true +end + M.SharedDictStore = SharedDictStore return M diff --git a/lua/pay_kit/protocols/mpp/store.lua b/lua/pay_kit/protocols/mpp/store.lua index 676a0d9de..4adf2f679 100644 --- a/lua/pay_kit/protocols/mpp/store.lua +++ b/lua/pay_kit/protocols/mpp/store.lua @@ -31,6 +31,12 @@ function MemoryStore:put_if_absent(key, value) return true end +-- A process-local table cannot prevent a replay that lands on another worker. +-- It is localnet-only, even when an application passes it explicitly. +function MemoryStore:is_shared() + return false +end + local M = {} function M.memory() diff --git a/lua/pay_kit/protocols/x402/exact/verify.lua b/lua/pay_kit/protocols/x402/exact/verify.lua index 265be4e05..3ebaf46fe 100644 --- a/lua/pay_kit/protocols/x402/exact/verify.lua +++ b/lua/pay_kit/protocols/x402/exact/verify.lua @@ -12,7 +12,7 @@ Rules: 2. ix[0] = ComputeBudget SetComputeUnitLimit (verify.rs:240-248) 3. ix[1] = ComputeBudget SetComputeUnitPrice <= MAX (verify.rs:250-264) 4. ix[2] = SPL TransferChecked (verify.rs:380-410) - 5. Authority guard (no fee-payer in transfer auth) (verify.rs:382) + 5. Fund-mover guard (managed source, signer tail, or source ATA) 6. Mint match (verify.rs:395-400) 7. Destination ATA match (re-derive) (verify.rs:402-405) 8. Amount match (verify.rs:407-410) @@ -159,18 +159,24 @@ local function verify_transfer(ix, account_keys, requirement, managed_signers) local destination = account_at(account_keys, ix, 2) local authority = account_at(account_keys, ix, 3) - -- Rule 5: fee-payer/managed-signer fund-mover guard. A managed signer - -- (the operator's fee payer) must never be the transfer authority, nor the - -- funding source: not as its raw key, and not as its own associated token - -- account for this mint. This is the complete rule -- an appended - -- instruction that merely references the fee-payer (e.g. a Lighthouse - -- guard) is NOT a fund move and is accepted, matching the Rust reference - -- and the Go/Python/PHP/Ruby verifiers. + -- Rule 5: a managed key must never be able to move funds. It may not be + -- the direct source, the authority, a multisig signer-tail account, or the + -- derived source ATA for this mint and the ACTUAL transfer program. Do not + -- sweep every account: destination/mint/program references are not fund + -- movers, and optional instructions have their own allowlist rules below. for i = 1, #managed_signers do local managed = managed_signers[i] - if managed == authority - or managed == source - or source == ata.derive(managed, mint, program) then + if managed == authority or managed == source then + error('invalid_exact_svm_payload_transaction_fee_payer_transferring_funds') + end + for account_slot = 4, #ix.accounts do + local key = account_at(account_keys, ix, account_slot - 1) + if managed == key then + error('invalid_exact_svm_payload_transaction_fee_payer_transferring_funds') + end + end + local managed_source_ata = ata.derive(managed, mint, program) + if source == managed_source_ata then error('invalid_exact_svm_payload_transaction_fee_payer_transferring_funds') end end @@ -232,8 +238,9 @@ end -- Top-level: verify a base64-encoded transaction against a server -- offer. `managed_signers` is the list of base58 keys the server has -- authority over (the operator's pubkey at minimum); the verifier --- refuses to treat any of those as the transfer source / authority --- so a malicious credential cannot "spend the facilitator's funds". +-- refuses to treat any of those as a transfer source, authority, signer-tail +-- account, or source ATA so a malicious credential cannot spend facilitator +-- funds. function M.verify(transaction_b64, requirement, managed_signers) local decode_ok, raw = pcall(base64.decode, transaction_b64) if not decode_ok or not raw or raw == '' then diff --git a/lua/plugins/kong/plugins/pay-kit/init.lua b/lua/plugins/kong/plugins/pay-kit/init.lua index b03346793..70feb6919 100644 --- a/lua/plugins/kong/plugins/pay-kit/init.lua +++ b/lua/plugins/kong/plugins/pay-kit/init.lua @@ -20,6 +20,10 @@ Env vars (all optional; defaults boot a localnet demo): PAY_KIT_MPP_CHALLENGE_BINDING_SECRET HMAC secret for MPP challenge binding PAY_KIT_MPP_EXPIRES_IN challenge expiry seconds +For devnet/mainnet MPP, declare `lua_shared_dict mpp_replay 10m;` in the +nginx `http` block. The bootstrap binds it as the replay store; without it, +MPP verification fails closed instead of using process-local memory. + Gates are registered via per-plugin config on the route. Apps wanting a catalog-style Pricing class can call pay_kit.gate() after setup(). @@ -39,6 +43,13 @@ local signer = require('pay_kit.signer') local M = {} +local function mpp_replay_store() + local ngx_ref = rawget(_G, 'ngx') + local dict = ngx_ref and ngx_ref.shared and ngx_ref.shared.mpp_replay + if not dict then return nil end + return require('pay_kit.protocols.mpp.server.store_shared_dict').new(dict) +end + local function split_csv(s) if not s or s == '' then return nil end local out = {} @@ -75,6 +86,7 @@ function M.setup() realm = os.getenv('PAY_KIT_MPP_REALM') or 'PayKit (Kong)', challenge_binding_secret = os.getenv('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET'), expires_in = env_int('PAY_KIT_MPP_EXPIRES_IN', 300), + replay_store = mpp_replay_store(), }, } local ok, err = pay_kit.configure(opts) diff --git a/lua/tests/charge_handler_spec.lua b/lua/tests/charge_handler_spec.lua index 59ac5daa5..273061376 100644 --- a/lua/tests/charge_handler_spec.lua +++ b/lua/tests/charge_handler_spec.lua @@ -52,10 +52,13 @@ end local function new_handler(opts) opts = opts or {} local rpc = opts.rpc or fake_rpc({}) + local network = opts.network or 'localnet' + local replay_store = opts.replay_store + or (network == 'localnet' and store.memory() or t.shared_replay_store()) return charge_handler.new({ rpc = rpc, - network = opts.network or 'localnet', - replay_store = opts.replay_store or store.memory(), + network = network, + replay_store = replay_store, transaction_verifier = opts.transaction_verifier or function() end, pull_transaction_signer = opts.pull_transaction_signer, pull_blockhash_extractor = opts.pull_blockhash_extractor, @@ -87,10 +90,21 @@ end) t.test('handler constructor rejects missing transaction_verifier', function() t.assert_error(function() - charge_handler.new({ rpc = fake_rpc({}), replay_store = store.memory() }) + charge_handler.new({ rpc = fake_rpc({}), network = 'localnet', replay_store = store.memory() }) end, 'transaction_verifier function is required') end) +t.test('handler constructor rejects a process-local replay store outside localnet', function() + t.assert_error(function() + charge_handler.new({ + rpc = fake_rpc({}), + network = 'devnet', + replay_store = store.memory(), + transaction_verifier = function() end, + }) + end, 'replay_store must be shared outside localnet') +end) + -- ─── Pull mode ──────────────────────────────────────────────────────────── t.test('settle_pull happy path: verify → simulate → send → consume → await', function() @@ -466,11 +480,12 @@ t.test('as_callback returns a function usable by mpp.server', function() local cb = handler:as_callback() local result = cb({ payload = { type = 'transaction', transaction = 'tx' }, request = {} }) t.assert_equal(result.reference, 'cb-sig') - t.assert_true(result.replay_key:find('server-noop', 1, true) ~= nil) - -- The callback must signal that the durable replay marker is already in - -- place so the outer `Server:_finalize_verification` skips its own - -- `put_if_absent` call against the (potentially shared) store. Without - -- this signal the Kong wiring would double-consume the same key. + t.assert_equal(result.replay_key, 'solana-charge:consumed:cb-sig') + local _value, present = handler.replay_store:get(result.replay_key) + t.assert_true(present, 'callback must return the replay marker it wrote') + -- The callback signals that the durable replay marker is already in place; + -- the outer server independently checks this marker before skipping its + -- own put_if_absent call. t.assert_equal(result.consumed, true) end) diff --git a/lua/tests/error_codes_spec.lua b/lua/tests/error_codes_spec.lua index 4d2aa6187..b0958e3d9 100644 --- a/lua/tests/error_codes_spec.lua +++ b/lua/tests/error_codes_spec.lua @@ -13,14 +13,15 @@ local TEST_SECRET = 'mpp-test-secret-key-long-enough-32b' local function build_server(opts) opts = opts or {} + local network = opts.network or 'mainnet' return mpp.server.new({ recipient = opts.recipient or TEST_RECIPIENT, currency = opts.currency or 'USDC', decimals = 6, - network = opts.network or 'mainnet', + network = network, secret_key = opts.secret_key or TEST_SECRET, realm = opts.realm or 'MPP Test', - store = mpp.store.memory(), + store = opts.store or (network == 'localnet' and mpp.store.memory() or helper.shared_replay_store()), verify_payment = opts.verify_payment or function() return { reference = 'stub-reference' } end, diff --git a/lua/tests/html_spec.lua b/lua/tests/html_spec.lua index e76364eaf..b82acfe40 100644 --- a/lua/tests/html_spec.lua +++ b/lua/tests/html_spec.lua @@ -4,13 +4,14 @@ local html = require('pay_kit.protocols.mpp.server.html') local function new_server(overrides) overrides = overrides or {} + local network = overrides.network or 'localnet' return mpp.server.new({ recipient = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h', currency = 'USDC', decimals = 6, - network = overrides.network or 'localnet', + network = network, secret_key = 'test-secret-key-long-enough-for-hmac', - store = mpp.store.memory(), + store = network == 'localnet' and mpp.store.memory() or t.shared_replay_store(), html = overrides.html, verify_payment = function(context) return { reference = context.payload.signature or context.payload.transaction } diff --git a/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua b/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua index cdfbd1874..266c3b80a 100644 --- a/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua +++ b/lua/tests/pay_kit/apisix_plugin_runtime_spec.lua @@ -22,7 +22,6 @@ package.loaded['apisix.core'].response.set_header = function(k, v) set_headers[k] = v end local pay_kit = require('pay_kit') -local mpp_store = require('pay_kit.protocols.mpp.store') helper.test('APISIX access(conf,ctx) returns 402 on unpaid', function() pay_kit._reset_for_tests() @@ -31,9 +30,7 @@ helper.test('APISIX access(conf,ctx) returns 402 on unpaid', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - -- Non-localnet MPP now requires an explicit durable replay store. - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', - replay_store = mpp_store.memory()}, + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', replay_store = helper.shared_replay_store()}, }) local plugin = require('plugins.apisix.plugins.pay-kit') @@ -51,9 +48,7 @@ helper.test('APISIX access returns 500 on invalid amount', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - -- Non-localnet MPP now requires an explicit durable replay store. - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', - replay_store = mpp_store.memory()}, + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', replay_store = helper.shared_replay_store()}, }) local plugin = require('plugins.apisix.plugins.pay-kit') local status = plugin.access({amount = 'not-decimal', stablecoins = {'USDC'}}, @@ -68,9 +63,7 @@ helper.test('APISIX access with named gate dispatches to dispatcher', function() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'ApisixRecipient000000000000000000000000000'}, - -- Non-localnet MPP now requires an explicit durable replay store. - mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', - replay_store = mpp_store.memory()}, + mpp = {realm = 'APISIX', challenge_binding_secret = 'apx-secret-key-long-enough-32bytes', replay_store = helper.shared_replay_store()}, }) pay_kit.gate('report', {amount = pay_kit.usd('0.05', 'USDC')}) local plugin = require('plugins.apisix.plugins.pay-kit') diff --git a/lua/tests/pay_kit/dispatcher_spec.lua b/lua/tests/pay_kit/dispatcher_spec.lua index c41ec8204..485760166 100644 --- a/lua/tests/pay_kit/dispatcher_spec.lua +++ b/lua/tests/pay_kit/dispatcher_spec.lua @@ -16,10 +16,10 @@ local function setup() assert(pay_kit.configure({ network = 'solana_devnet', operator = {recipient = SELLER}, - -- Non-localnet MPP now requires an explicit durable replay store; supply a - -- process-local one for the 402-shape tests below. - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', - replay_store = mpp_store.memory()}, + mpp = { + challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + replay_store = helper.shared_replay_store(), + }, })) end @@ -95,8 +95,8 @@ helper.test('MPP on a non-localnet network fails closed without a durable replay local ok, err = pcall(pay_kit.try_payment, 'report', {headers = {}, path = '/report'}) helper.assert_true(not ok, 'expected MPP build to fail closed without a durable replay store') local msg = type(err) == 'table' and err.message or tostring(err) - helper.assert_true(msg:find('durable', 1, true) ~= nil and msg:find('replay store', 1, true) ~= nil, - 'expected a durable-replay-store error, got: ' .. msg) + helper.assert_true(msg:find('replay store', 1, true) ~= nil, + 'expected a replay-store error, got: ' .. msg) end) helper.test('MPP on localnet still permits the volatile in-memory replay fallback', function() @@ -113,7 +113,7 @@ helper.test('MPP on localnet still permits the volatile in-memory replay fallbac helper.assert_true(response ~= nil and #response.body.accepts >= 1) end) -helper.test('MPP on a non-localnet network accepts an explicit replay store', function() +helper.test('MPP on a non-localnet network rejects a process-local replay store', function() pay_kit._reset_for_tests() assert(pay_kit.configure({ network = 'solana_devnet', @@ -123,6 +123,21 @@ helper.test('MPP on a non-localnet network accepts an explicit replay store', fu replay_store = mpp_store.memory()}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) + local ok, err = pcall(pay_kit.try_payment, 'report', {headers = {}, path = '/report'}) + helper.assert_true(not ok, 'expected a process-local store to fail closed') + helper.assert_true(tostring(err):find('shared', 1, true) ~= nil, tostring(err)) +end) + +helper.test('MPP on a non-localnet network accepts an explicit shared replay store', function() + pay_kit._reset_for_tests() + assert(pay_kit.configure({ + network = 'solana_devnet', + accept = {'mpp'}, + operator = {recipient = SELLER}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + replay_store = helper.shared_replay_store()}, + })) + assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, err, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) helper.assert_equal(err, errors.PAYMENT_REQUIRED) helper.assert_true(response ~= nil and #response.body.accepts >= 1) @@ -136,8 +151,11 @@ local function reset_and_configure() rpc_url = 'https://api.devnet.solana.com', accept = {'x402', 'mpp'}, operator = {recipient = 'DispatcherRecipient000000000000000000000000'}, - mpp = {realm = 'TestRealm', challenge_binding_secret = 'disp-test-secret-key-long-32bytes!', - replay_store = mpp_store.memory()}, + mpp = { + realm = 'TestRealm', + challenge_binding_secret = 'disp-test-secret-key-long-32bytes!', + replay_store = helper.shared_replay_store(), + }, }) pay_kit.gate('paid', {amount = pay_kit.usd('0.001', 'USDC')}) end diff --git a/lua/tests/pay_kit/kong_plugin_runtime_spec.lua b/lua/tests/pay_kit/kong_plugin_runtime_spec.lua index dce1da3b8..5b11252ef 100644 --- a/lua/tests/pay_kit/kong_plugin_runtime_spec.lua +++ b/lua/tests/pay_kit/kong_plugin_runtime_spec.lua @@ -47,7 +47,12 @@ local kong_stub = { } _G.kong = kong_stub +local function install_shared_replay_dict() + _G.ngx = { shared = { mpp_replay = helper.shared_dict() } } +end + helper.test('Kong bootstrap configures pay_kit from env without posix', function() + install_shared_replay_dict() package.loaded['plugins.kong.plugins.pay-kit.init'] = nil local pay_kit = require('pay_kit') pay_kit._reset_for_tests() @@ -70,6 +75,7 @@ helper.test('Kong bootstrap configures pay_kit from env without posix', function end) helper.test('Kong bootstrap honours empty / blank env defaults', function() + install_shared_replay_dict() package.loaded['plugins.kong.plugins.pay-kit.init'] = nil local pay_kit = require('pay_kit') pay_kit._reset_for_tests() @@ -90,6 +96,7 @@ helper.test('Kong bootstrap honours empty / blank env defaults', function() end) helper.test('Kong bootstrap surfaces configure() error via error()', function() + install_shared_replay_dict() package.loaded['plugins.kong.plugins.pay-kit.init'] = nil require('pay_kit')._reset_for_tests() patch_env({ @@ -106,6 +113,7 @@ helper.test('Kong bootstrap surfaces configure() error via error()', function() end) helper.test('Kong handler access(conf) emits 402 on unpaid', function() + install_shared_replay_dict() package.loaded['plugins.kong.plugins.pay-kit.handler'] = nil require('pay_kit')._reset_for_tests() -- Localnet: the env-based bootstrap has no knob to inject an MPP replay @@ -129,6 +137,7 @@ helper.test('Kong handler access(conf) emits 402 on unpaid', function() end) helper.test('Kong handler access rejects invalid plugin config with 500', function() + install_shared_replay_dict() package.loaded['plugins.kong.plugins.pay-kit.handler'] = nil require('pay_kit')._reset_for_tests() patch_env({ diff --git a/lua/tests/pay_kit/main_fixes_spec.lua b/lua/tests/pay_kit/main_fixes_spec.lua index 2f1d8e30a..b3463baf7 100644 --- a/lua/tests/pay_kit/main_fixes_spec.lua +++ b/lua/tests/pay_kit/main_fixes_spec.lua @@ -17,17 +17,9 @@ local headers = require('pay_kit.protocol.core.headers') local expires = require('pay_kit.protocols.mpp.expires') local preflight = require('pay_kit.preflight') local canonical_json = require('pay_kit.util.json') -local mpp_store = require('pay_kit.protocols.mpp.store') local SELLER = 'SeLLeRWaLLeT111111111111111111111111111111' --- Non-localnet MPP now requires an explicit durable replay store. The 402 / --- challenge-shape tests below only exercise issuance, so a process-local store --- satisfies the contract. -local function devnet_replay_store() - return mpp_store.memory() -end - local function reset() pay_kit._reset_for_tests() end @@ -46,8 +38,7 @@ helper.test('402 response carries Cache-Control: no-store', function() assert(pay_kit.configure({ network = 'solana_devnet', operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', - replay_store = devnet_replay_store()}, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', replay_store = helper.shared_replay_store()}, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -62,8 +53,10 @@ helper.test('MPP 402 challenge carries an expiry from config.mpp.expires_in', fu network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120, - replay_store = devnet_replay_store()}, + mpp = { + challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + expires_in = 120, replay_store = helper.shared_replay_store(), + }, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -82,8 +75,10 @@ helper.test('MPP 402 challenge omits expiry when expires_in = false (dev opt-out network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = false, - replay_store = devnet_replay_store()}, + mpp = { + challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + expires_in = false, replay_store = helper.shared_replay_store(), + }, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) @@ -124,8 +119,10 @@ helper.test('adapter expected methodDetails matches the issued challenge', funct network = 'solana_devnet', accept = {'mpp'}, operator = {recipient = SELLER}, - mpp = {challenge_binding_secret = 'test-secret-key-long-enough-32bytes', expires_in = 120, - replay_store = devnet_replay_store()}, + mpp = { + challenge_binding_secret = 'test-secret-key-long-enough-32bytes', + expires_in = 120, replay_store = helper.shared_replay_store(), + }, })) assert(pay_kit.gate('report', {amount = assert(pay_kit.usd('0.10'))})) local _, _, response = pay_kit.try_payment('report', {headers = {}, path = '/report'}) diff --git a/lua/tests/pay_kit/x402_verify_spec.lua b/lua/tests/pay_kit/x402_verify_spec.lua index 1938535c1..5628b5f16 100644 --- a/lua/tests/pay_kit/x402_verify_spec.lua +++ b/lua/tests/pay_kit/x402_verify_spec.lua @@ -240,6 +240,29 @@ helper.test('verify rejects authority that matches a managed signer', function() helper.assert_true(not ok) helper.assert_true(tostring(err):find('fee_payer', 1, true), tostring(err)) end) + +helper.test('verify rejects a managed signer derived source ATA', function() + local facilitator = base58.encode(string.rep('\1', 32)) + local authority = base58.encode(string.rep('\2', 32)) + local mint = base58.encode(string.rep('\4', 32)) + local pay_to = base58.encode(string.rep('\5', 32)) + local destination = ata.derive(pay_to, mint, TOKEN_PROGRAM) + local managed_source_ata = ata.derive(facilitator, mint, TOKEN_PROGRAM) + + local raw = synthesize_tx(facilitator, managed_source_ata, mint, destination, + authority, 1000, '/paid') + local offer = { + scheme = 'exact', network = 'solana:dev', + asset = mint, amount = '1000', payTo = pay_to, + extra = {feePayer = facilitator, decimals = 6, + tokenProgram = TOKEN_PROGRAM, memo = '/paid'}, + } + local ok, err = pcall(x402_verify.verify, base64.encode(raw), offer, {facilitator}) + helper.assert_true(not ok) + helper.assert_true(tostring(err):find( + 'invalid_exact_svm_payload_transaction_fee_payer_transferring_funds', 1, true) ~= nil, + tostring(err)) +end) end -- =================================================================== @@ -412,6 +435,26 @@ helper.test('rule 7: destination ATA mismatch rejected', function() helper.assert_true(tostring(err):find('recipient_mismatch', 1, true) ~= nil) end) +helper.test('rule 5: managed signer in transfer signer tail is rejected as a fund mover', function() + local facilitator, authority, source, mint, pay_to, destination = setup_actors() + local keys = standard_keys(facilitator, source, mint, destination, authority) + local raw = assemble(keys, 8, { + build_ix(5, {}, string.char(2) .. u32_le(200000)), + build_ix(5, {}, string.char(3) .. u64_le(1000)), + -- Account 0 is a managed signer appended after the authority as a + -- multisig signer-tail account. It must not be classified as a generic + -- instruction-account violation because it can authorize fund movement. + build_ix(6, {1, 2, 3, 4, 0}, string.char(12) .. u64_le(1000) .. string.char(6)), + build_ix(7, {}, '/paid'), + }) + local ok, err = pcall(x402_verify.verify, base64.encode(raw), + default_offer(facilitator, mint, pay_to), {facilitator}) + helper.assert_true(not ok) + helper.assert_true(tostring(err):find( + 'invalid_exact_svm_payload_transaction_fee_payer_transferring_funds', 1, true) ~= nil, + tostring(err)) +end) + helper.test('rule 9: unknown program in ix[3] slot rejected', function() local facilitator, authority, source, mint, pay_to, destination = setup_actors() -- Replace MEMO_PROGRAM at index 7 with a junk program key. diff --git a/lua/tests/server_spec.lua b/lua/tests/server_spec.lua index 74ce6ab3e..654ff5e9d 100644 --- a/lua/tests/server_spec.lua +++ b/lua/tests/server_spec.lua @@ -1,5 +1,6 @@ local t = require('tests.test_helper') local mpp = require('tests._mpp') +local mpp_adapter = require('pay_kit.protocols.mpp') local function new_server() return mpp.server.new({ @@ -58,6 +59,34 @@ t.test('verify credential rejects replay', function() end, 'payment already consumed') end) +t.test('verify credential rejects a bare consumed flag on double submit', function() + local replay = mpp.store.memory() + local server = mpp.server.new({ + recipient = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h', + currency = 'USDC', + decimals = 6, + network = 'localnet', + secret_key = 'test-secret-key-long-enough-for-hmac', + store = replay, + verify_payment = function(context) + return { reference = context.payload.signature, consumed = true } + end, + }) + local challenge = server:charge('0.001') + local credential = mpp.NewPaymentCredential(challenge:to_echo(), { + type = 'signature', + signature = 'bare-consumed-double-submit', + }) + + local receipt = server:verify_credential(credential, 1770000000) + t.assert_equal(receipt.reference, 'bare-consumed-double-submit') + local _value, present = replay:get('solana-charge:consumed:bare-consumed-double-submit') + t.assert_true(present, 'outer replay guard must write a marker') + t.assert_error(function() + server:verify_credential(credential, 1770000000) + end, 'payment already consumed') +end) + t.test('verify credential rejects expired challenge', function() local server = new_server() local challenge = server:charge_with_options('0.001', { @@ -96,6 +125,7 @@ t.test('verify credential rejects sponsored push mode', function() decimals = 6, network = 'localnet', secret_key = 'test-secret-key-long-enough-for-hmac', + store = mpp.store.memory(), fee_payer = true, -- Audit #16: feePayer=true now requires a fee_payer_key at boot. fee_payer_key = '9yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h', @@ -117,6 +147,7 @@ t.test('verify credential requires verification callback', function() local server = mpp.server.new({ recipient = '3yGpUKnU5HSVSMxye83YuseTeSQykiS5N4eh6iQn1d2h', secret_key = 'test-secret-key-long-enough-for-hmac', + store = t.shared_replay_store(), }) local challenge = server:charge('1') local credential = mpp.NewPaymentCredential(challenge:to_echo(), { @@ -134,6 +165,7 @@ t.test('verify credential accepts transaction payload when lua verifier hooks ar currency = 'sol', decimals = 9, secret_key = 'test-secret-key-long-enough-for-hmac', + store = t.shared_replay_store(), verifier_hooks = (function() local pull_tx = { meta = { err = nil }, @@ -270,9 +302,155 @@ local function server_with(overrides) end, } for k, v in pairs(overrides or {}) do cfg[k] = v end + if cfg.network ~= 'localnet' and (not overrides or overrides.store == nil) then + cfg.store = t.shared_replay_store() + end return mpp.server.new(cfg) end +local function credential_for(server, signature) + local challenge = server:charge('0.001') + return mpp.NewPaymentCredential(challenge:to_echo(), { + type = 'signature', + signature = signature, + }) +end + +t.test('security: direct server requires a replay store outside localnet', function() + for _, network in ipairs({ 'mainnet', 'devnet' }) do + t.assert_error(function() + mpp.server.new({ + recipient = VALID_RECIPIENT, + network = network, + secret_key = 'test-secret-key-long-enough-for-hmac', + verify_payment = function() return { reference = 'unused' } end, + }) + end, 'replay store is required outside localnet') + end +end) + +t.test('security: direct server rejects an explicit process-local replay store outside localnet', function() + t.assert_error(function() + mpp.server.new({ + recipient = VALID_RECIPIENT, + network = 'devnet', + secret_key = 'test-secret-key-long-enough-for-hmac', + store = mpp.store.memory(), + verify_payment = function() return { reference = 'unused' } end, + }) + end, 'replay store must be shared outside localnet') +end) + +t.test('security: direct server accepts a shared replay store outside localnet', function() + local server = mpp.server.new({ + recipient = VALID_RECIPIENT, + network = 'devnet', + secret_key = 'test-secret-key-long-enough-for-hmac', + store = t.shared_replay_store(), + verify_payment = function() return { reference = 'unused' } end, + }) + t.assert_true(server ~= nil) +end) + +t.test('security: MPP adapter requires a replay store before transport setup', function() + local config = { + network = 'solana_devnet', + rpc_url = 'https://api.devnet.solana.com', + operator = { + signer = function() return {} end, + fee_payer = function() return false end, + }, + mpp = {challenge_binding_secret = 'test-secret-key-long-enough-for-hmac'}, + } + local adapter = assert(mpp_adapter.new({config_resolver = function() return config end})) + local gate = { + pay_to = function() return VALID_RECIPIENT end, + amount = function() + return {primary_coin = function() return 'USDC' end} + end, + } + t.assert_error(function() + adapter:accepts_entry(gate, {}) + end, 'MPP replay store is required outside localnet') +end) + +t.test('security: localnet direct server retains the memory-store fallback', function() + local server = mpp.server.new({ + recipient = VALID_RECIPIENT, + network = 'localnet', + secret_key = 'test-secret-key-long-enough-for-hmac', + verify_payment = function() return { reference = 'unused' } end, + }) + t.assert_true(server.store ~= nil) + t.assert_true(type(server.store.put_if_absent) == 'function') +end) + +t.test('security: verifier must return a table', function() + local server = server_with({ verify_payment = function() return nil end }) + t.assert_error(function() + server:verify_credential(credential_for(server, 'nil-result'), 1770000000) + end, 'verification result must be a table') + + local non_table = server_with({ verify_payment = function() return 'accepted' end }) + t.assert_error(function() + non_table:verify_credential(credential_for(non_table, 'string-result'), 1770000000) + end, 'verification result must be a table') +end) + +t.test('security: verifier must provide a non-empty reference', function() + local server = server_with({ verify_payment = function() return { reference = '' } end }) + t.assert_error(function() + server:verify_credential(credential_for(server, 'empty-reference'), 1770000000) + end, 'non%-empty reference') +end) + +t.test('security: verifier errors never consume or issue a receipt', function() + local replay = mpp.store.memory() + local server = server_with({ + store = replay, + verify_payment = function() error('verifier unavailable') end, + }) + t.assert_error(function() + server:verify_credential(credential_for(server, 'verifier-error'), 1770000000) + end, 'verifier unavailable') + local _value, present = replay:get('solana-charge:consumed:verifier-error') + t.assert_true(not present) +end) + +-- A custom replay store historically needed only the atomic put operation. +-- The verifier may reserve its own marker, so the outer guard must recognize +-- that write without requiring an unadvertised `get` method or double-consuming +-- the first valid payment. +t.test('security: put-only replay store accepts an inner reservation once', function() + local values = {} + local replay = { + put_if_absent = function(_, key, value) + if values[key] ~= nil then return false end + values[key] = value + return true + end, + } + local server = server_with({ + store = replay, + verify_payment = function(context) + local reference = context.payload.signature + local replay_key = 'solana-charge:consumed:' .. reference + local inserted = context.store:put_if_absent(replay_key, true) + if not inserted then + error('payment already consumed') + end + return { reference = reference, replay_key = replay_key, consumed = true } + end, + }) + local credential = credential_for(server, 'put-only-inner-reservation') + + local receipt = server:verify_credential(credential, 1770000000) + t.assert_equal(receipt.reference, 'put-only-inner-reservation') + t.assert_error(function() + server:verify_credential(credential, 1770000000) + end, 'payment already consumed') +end) + -- Audit #24: weak secret key. t.test('audit #24: rejects secret key shorter than 32 bytes', function() t.assert_error(function() diff --git a/lua/tests/store_shared_dict_spec.lua b/lua/tests/store_shared_dict_spec.lua index 611509197..a395f4a08 100644 --- a/lua/tests/store_shared_dict_spec.lua +++ b/lua/tests/store_shared_dict_spec.lua @@ -82,6 +82,11 @@ t.test('store_shared_dict shares state across handles pointing at the same dict' t.assert_equal(worker_b:put_if_absent('sig-shared', true), false) end) +t.test('store_shared_dict declares cross-worker replay capability', function() + local store = store_shared_dict.new(fake_dict()) + t.assert_true(store:is_shared()) +end) + t.test('store_shared_dict get returns nil and false on a miss', function() local dict = fake_dict() local store = store_shared_dict.new(dict) diff --git a/lua/tests/test_helper.lua b/lua/tests/test_helper.lua index 48cb83b41..9b2e2b94a 100644 --- a/lua/tests/test_helper.lua +++ b/lua/tests/test_helper.lua @@ -56,6 +56,27 @@ function M.assert_error(fn, pattern) end end +-- A small `ngx.shared.DICT` stand-in for tests that need the real +-- cross-worker replay-store wrapper. Keeping it here prevents test fixtures +-- from accidentally blessing the process-local MemoryStore for devnet. +function M.shared_dict() + local values = {} + return { + add = function(self, key, value) + if values[key] ~= nil then return nil, 'exists' end + values[key] = value + return true + end, + get = function(self, key) return values[key] end, + set = function(self, key, value) values[key] = value end, + delete = function(self, key) values[key] = nil end, + } +end + +function M.shared_replay_store() + return require('pay_kit.protocols.mpp.server.store_shared_dict').new(M.shared_dict()) +end + -- Tests listed here are known-failing and tracked outside this PR. Skipping -- them keeps the runner moving so unrelated regressions still surface. Each -- entry must carry a tracking note in code review. @@ -109,9 +130,10 @@ function M.run() end end io.stdout:write(string.format('%d tests passed, %d skipped, %d failed\n', passed, skipped, failed)) - if failed > 0 then - os.exit(1) - end + -- LuaCov flushes its line-hit table through its os.exit wrapper. Explicitly + -- exit on success too, otherwise a green LuaJIT run leaves no stats file and + -- the coverage gate cannot exercise the suite it claims to enforce. + os.exit(failed > 0 and 1 or 0) end return M From 4e92349fc9ed15813a9fa503e8a0ae41c88a4dd6 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Fri, 10 Jul 2026 02:37:14 +0300 Subject: [PATCH 4/7] fix(lua): restore conformance runner identity --- harness/test/conformance.test.ts | 12 ++++++++++++ lua/cmd/conformance/main.lua | 1 + 2 files changed, 13 insertions(+) diff --git a/harness/test/conformance.test.ts b/harness/test/conformance.test.ts index d961d4c00..b97a3d0b8 100644 --- a/harness/test/conformance.test.ts +++ b/harness/test/conformance.test.ts @@ -117,6 +117,18 @@ const UNSUPPORTED_MODE_EXEMPTIONS: Record< string, { owner: string; date: string; reason: string } > = { + "lua:charge:build-transaction": { + owner: "lua", + date: "2026-07-10", + reason: + "Lua is currently server-only and has no charge transaction builder; remove when the Lua SDK ships a charge client builder.", + }, + "lua:x402-exact:build-transaction": { + owner: "lua", + date: "2026-07-10", + reason: + "Lua is currently server-only and has no x402 transaction builder; remove when the Lua SDK ships an x402 client builder.", + }, "ruby:charge:build-transaction": { owner: "harness", date: "2026-07-09", diff --git a/lua/cmd/conformance/main.lua b/lua/cmd/conformance/main.lua index 7e0893ff9..191560d4a 100644 --- a/lua/cmd/conformance/main.lua +++ b/lua/cmd/conformance/main.lua @@ -83,6 +83,7 @@ end -- encoder; key order is canonical, which is fine because the driver parses -- the line with JSON.parse and reads fields by name. local function emit(result) + result.language = 'lua' io.write(json.encode(result) .. '\n') end From 7702f651257225c5fdcc1effa2c08fb927b6972d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:09:50 +0300 Subject: [PATCH 5/7] fix(lua): preserve conformance object identity --- lua/cmd/conformance/main.lua | 6 ++++++ lua/pay_kit/util/json.lua | 12 ++++++++++-- lua/tests/json_util_spec.lua | 8 +++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lua/cmd/conformance/main.lua b/lua/cmd/conformance/main.lua index 7e0893ff9..0417ca0a1 100644 --- a/lua/cmd/conformance/main.lua +++ b/lua/cmd/conformance/main.lua @@ -83,6 +83,7 @@ end -- encoder; key order is canonical, which is fine because the driver parses -- the line with JSON.parse and reads fields by name. local function emit(result) + result.language = 'lua' io.write(json.encode(result) .. '\n') end @@ -856,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 diff --git a/lua/pay_kit/util/json.lua b/lua/pay_kit/util/json.lua index 81c90b83a..470c837d0 100644 --- a/lua/pay_kit/util/json.lua +++ b/lua/pay_kit/util/json.lua @@ -6,12 +6,20 @@ local M = {} local null_sentinel = {} +local array_metatable = {} +local object_metatable = {} M.null = null_sentinel local function is_array(value) if type(value) ~= 'table' then return false end + local metatable = getmetatable(value) + if metatable == array_metatable then + return true + elseif metatable == object_metatable then + return false + end local max = 0 local count = 0 for k, _ in pairs(value) do @@ -403,7 +411,7 @@ end function Parser:parse_array() self:expect('[') self:skip_ws() - local out = {} + local out = setmetatable({}, array_metatable) if self:peek() == ']' then self.pos = self.pos + 1 return out @@ -424,7 +432,7 @@ end function Parser:parse_object() self:expect('{') self:skip_ws() - local out = {} + local out = setmetatable({}, object_metatable) if self:peek() == '}' then self.pos = self.pos + 1 return out diff --git a/lua/tests/json_util_spec.lua b/lua/tests/json_util_spec.lua index acfaf7d3b..fe5bc3223 100644 --- a/lua/tests/json_util_spec.lua +++ b/lua/tests/json_util_spec.lua @@ -120,6 +120,7 @@ end) helpers.test('json.decode: empty array', function() local arr = json.decode('[]') helpers.assert_equal(#arr, 0) + helpers.assert_equal(json.encode(arr), '[]') end) helpers.test('json.decode: array with elements', function() @@ -135,8 +136,13 @@ end) helpers.test('json.decode: empty object', function() local obj = json.decode('{}') - -- Lua doesn't distinguish empty array vs empty object — both are empty tables. helpers.assert_true(type(obj) == 'table') + helpers.assert_equal(json.encode(obj), '{}') +end) + +helpers.test('json.decode: preserves nested empty container types', function() + local value = json.decode('{"array":[],"object":{}}') + helpers.assert_equal(json.encode(value), '{"array":[],"object":{}}') end) helpers.test('json.decode: object with entries', function() From 998124e2a36c2a8d376ffa71b76e803267476de7 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:10:28 +0300 Subject: [PATCH 6/7] refactor(lua): keep harness changes isolated --- harness/test/conformance.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/harness/test/conformance.test.ts b/harness/test/conformance.test.ts index b97a3d0b8..d961d4c00 100644 --- a/harness/test/conformance.test.ts +++ b/harness/test/conformance.test.ts @@ -117,18 +117,6 @@ const UNSUPPORTED_MODE_EXEMPTIONS: Record< string, { owner: string; date: string; reason: string } > = { - "lua:charge:build-transaction": { - owner: "lua", - date: "2026-07-10", - reason: - "Lua is currently server-only and has no charge transaction builder; remove when the Lua SDK ships a charge client builder.", - }, - "lua:x402-exact:build-transaction": { - owner: "lua", - date: "2026-07-10", - reason: - "Lua is currently server-only and has no x402 transaction builder; remove when the Lua SDK ships an x402 client builder.", - }, "ruby:charge:build-transaction": { owner: "harness", date: "2026-07-09", From 11837b61b449c2c1863ef9f61455a047a35bb490 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:56:05 +0300 Subject: [PATCH 7/7] ci(lua): separate runtime and dev rock caches --- .github/workflows/lua.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lua.yml b/.github/workflows/lua.yml index ac41b1997..8eccb5228 100644 --- a/.github/workflows/lua.yml +++ b/.github/workflows/lua.yml @@ -28,8 +28,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' @@ -113,8 +113,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'