Skip to content

test(mpp): cover session on-chain channel-binding rejects#242

Open
EfeDurmaz16 wants to merge 106 commits into
split/pr216-ci-harness-megafrom
test/session-onchain-mismatch-rejects
Open

test(mpp): cover session on-chain channel-binding rejects#242
EfeDurmaz16 wants to merge 106 commits into
split/pr216-ci-harness-megafrom
test/session-onchain-mismatch-rejects

Conversation

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator

Follow-up to the #216 re-delivery cascade. Retires the reject-coverage gap documented in the mpp/server/session.rs entry of scripts/check-rust-coverage.py (added when that file was exempted from the per-file coverage floor).

What

SessionServer::process_topup fetches the real channel account off-chain and rejects when its binding fields diverge from the session config or the stored channel:

  • on-chain status is not OPEN
  • on-chain mint mismatch
  • on-chain payee mismatch
  • on-chain rent_payer mismatch
  • on-chain authorized_signer does not match the stored channel
  • on-chain payer does not match the stored channel

The existing process_topup_rejects_* tests only exercised the pass side of these checks (they seed a fully-matching channel and reach the later grace_period / distribution_hash / resulting-deposit rejects), so the divergent-binding rejects themselves were unasserted despite being reachable through the existing SessionAccountRpc mock.

Tests added (6)

Each seeds the mock RPC with a channel that matches on every field the checks reach before the one under test (so the intended reject fires first), drives process_topup, asserts the specific rejection, and asserts the stored deposit is left untouched. Modeled on process_topup_rejects_grace_period_mismatch. The rent_payer case builds the channel explicitly because encoded_channel ties rent_payer to payee.

Test-only; no production change.

Merge notes

Adversarial review: dropping the reversed release order (canonical key
deleted first) passed the whole suite while opening the stranding race
the docstring warns about, and nothing asserted that a losing claim
performs zero deletes through the full adapter path. Both are pinned,
and the legacy-marker block tests now assert the signature_consumed
code and that the old worker's marker survives untouched.
MppAdapter re-raises the store policy violation as the public
ConfigurationError (a PayKitError, not a PaymentError), so the boot
probe's except PaymentError was dead: the rejection escaped as a bare
traceback that only matched the fail-closed signature by luck of the
message landing in the stderr ring buffer. Catch the exact public type
and correct the docstring to the real exception and message.
The generic Node job omits boot-policy.test.ts because that suite
requires the TypeScript charge server to fail closed, which ships in the
TypeScript subscription leaf, not this Python leaf. Document that the Go
and TypeScript probes return to CI there, so a reader does not read the
omission as lost coverage.
server.New now enforces the core.SharedStore/IsShared() contract on any
injected replay Store: an injected store must affirmatively implement
SharedStore and report IsShared() == true on every network, closing the
gap where a non-shared custom store passed the prior concrete-type check.

AllowUnsafeMemoryStore is an explicit per-server development opt-in. It
only ever authorizes the internally created process-local MemoryStore
when Config.Store is nil; it never blesses an injected unshared store,
and it is rejected outright on mainnet. A nil store without the opt-in
fails closed on all networks, including localnet.

The high-level paykit MPP adapter threads ReplayStore and the opt-in
through and validates lazily at first serverFor(), so the guard surfaces
as a challenge/settle error rather than at client construction.
Adds a ReplayStoreValidator that fails closed unless an injected store
affirmatively declares a durable/shared replay capability via DurableStore
or ReplayStoreCapability (and every declared capability method returns
true). The MPP Adapter now enforces it: an injected store must pass the
validator, allowUnsafeMemoryStore only permits an internally created
process-local MemoryStore, that opt-in is rejected on mainnet, and a
missing store fails closed on every network including localnet.

Threads allowUnsafeMemoryStore through MppConfig, the middleware, and the
Laravel/Symfony wiring, and updates the cross-SDK PHP harness to opt into
the process-local store explicitly.
Introduces an atomic charge replay store distinct from the session store.
mpp/server/store.ts adds ReplayStore plus resolveReplayStore, which fails
closed unless an injected store reports isShared and isDurable or the
explicit allowUnsafeMemoryStore flag authorizes an SDK-created process-local
store; mainnet plus that flag is rejected. solana.charge and the push-mode
path now reserve the consumed-signature marker with putIfAbsent after every
fallible settlement check and raise VerificationFailedError (mapped to 402)
on collision, so a broadcast ambiguity or timeout never burns a legitimate
retry and only one concurrent caller obtains a receipt.

pay-kit/replay-store.ts bridges the store into pay-kit with an identity-based
production brand (declareProductionReplayStore) that copying capability
booleans onto an unknown store cannot forge. configure() resolves and
validates the store, and assertReplayStorePolicy revalidates it at every
adapter boundary. The MPP adapter forwards the atomic view to both charge
and subscription construction so subscription consumed-markers also upgrade
to atomic putIfAbsent.

Session-store durability policy (Session.ts, session/store.ts) and the
subscription store threading stay with the subscription delivery.
Session store: mark createMemorySessionStore() with an enumerable
global-symbol brand so a shallow spread copy ({ ...store }) still reports
as process-local via isMemorySessionStore and is rejected off-localnet. A
symbol key stays invisible to JSON.stringify and Object.keys regardless of
the enumerable flag, so nothing leaks into serialized output; the prior
non-enumerable brand was dropped by spread and could be smuggled onto
mainnet as durable. Export isMemorySessionStore from the server entrypoint
so pay-kit adapters can detect it across the package boundary.

Subscription store: require isShared and isDurable outside localnet and
route consumed-reservation cleanup through releaseConsumedBestEffort so a
failed delete keeps the reservation instead of throwing.

Adds session-store brand tests (factory detection, JSON/Object.keys
invisibility, spread fail-closed, non-memory-store rejection) and extends
the subscription-server tests.
…lnet

Generalize the config store gate to requiresSettlementReplayStore, covering
both mpp and x402 settlement paths, and materialize a single shared
in-memory replay store only on the devnet opt-in
(PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1). The session adapter rejects an
explicit createMemorySessionStore() off-localnet via isMemorySessionStore
so process-local session state cannot reach mainnet, and the x402 path is
held to the same policy. Error copy generalized from "MPP replayStore" to
"replayStore".

Extends config, mpp-adapter, mpp-session, and paykit tests, including a new
x402 config case.
Drive the TypeScript fail-closed store contract through a real createPayKit
+ requirePayment fixture (paykit-boot.ts) and require a covered probe only
once its SDK carries the PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE guard marker
in-tree (detected by git-grepping the SDK source). Go and TypeScript ship
the guard here so they run; Python asserts-SKIP with a loud note until its
remediation lands, after which the same grep auto-promotes it to a required
probe. This keeps the leaf green without hardcoding "python is red".

Wires the ordered @solana/mpp -> @solana/pay-kit build and a dedicated
fixture typecheck into CI behind a build-pay-kit input, excludes the
fixture from the mpp-only baseline typecheck, and bumps @solana/kit to
^6.10.0 (lockfile fan-out only: @solana/* to 6.10, undici-types transitive,
commander dropped).
The low-level session() method silently fell back to a process-local
in-memory SessionStore on any network, including mainnet. A direct
@solana/mpp consumer that bypasses the pay-kit adapter therefore lost
every open channel on restart and never shared channel state across
workers, letting cumulative vouchers double-spend.

resolveSessionStore now mirrors the replay-store policy: a
durable-shared store is honored everywhere, localnet may use a
process-local store freely, devnet only behind the
PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE (or allowUnsafeMemoryStore) dev
opt-in, and mainnet forbids it outright. A mainnet session() with no
store or a memory store throws a ConfigurationError.

Adds the isMemorySessionStore marker and SessionStoreDurability type
consumed by the adapter layer, plus regression tests covering the
mainnet-reject, devnet-opt-in, localnet, and durable-store paths.
Repo text uses no em-dash; two remediation comment lines and two
earlier subscription comment lines carried them.
Reconcile #238 (MPP subscription) onto #237's nominal replay-store policy,
which the maintainer chose as the single config-layer model. #238's flag-based
policy is removed; all its unique behaviour is preserved on top of #237's model.

config.ts:
- Delete #238's validateReplayStore + requiresSettlementReplayStore.
- Keep #237's assertReplayStorePolicy as the single replay policy
  (isAtomicReplayStore / isProductionReplayStore / declareProductionReplayStore
  / isAuthorizedUnsafeMemoryReplayStore).
- x402 config coverage stays adapter-only: the x402 store cannot satisfy the
  putIfAbsent production marker and #237's own tests require x402-only configs
  to accept a legacy Store.Store, so the mpp-only gate is left in place and
  x402 replay is fenced by the x402 adapter (#236). Never silently accepts a
  non-durable x402 mainnet store.
- Preserve #238's validators, inMemoryStoresAllowed, freezePayKitConfig /
  freezePayKitSigner snapshot, 32-byte challenge-secret check, expiresIn date
  check, and demo-signer-by-address detection (now also enforced at the
  assertReplayStorePolicy boundary). validatePayKitConfig runs the replay/signer
  policy before the challenge-secret length so mainnet/store misconfig reports
  first.

subscription reconciliation:
- Subscription adapter requires a durable shared store via #237's nominal
  isProductionReplayStore rather than isShared/isDurable flags; the devnet
  in-memory opt-in (createUnsafeMemoryReplayStore, isAuthorizedUnsafe) is
  rejected for subscription activation and never accepted on mainnet.
- The atomic view (createAtomicReplayStoreView) now exposes reserve() aliasing
  putIfAbsent, so #238's reserve-based subscription server runs unchanged
  against the operator's putIfAbsent production store. subscription-replay-store.ts
  is removed.

session store: keep #237's low-level durability guard (sessionStoreDurability)
and adopt #238's more robust global-registry enumerable isMemorySessionStore
brand (survives package-copy and shallow-spread).

tests: rewrite #238 tests that asserted the deleted validateReplayStore messages
to the nominal assertReplayStorePolicy model; every replay/session/subscription
money-path test still asserts fail-closed. Broaden the harness fail-closed
signature to accept #237's Go "forbidden on mainnet" wording.
# Conflicts:
#	.github/workflows/ci.yml
#	harness/test/boot-policy.test.ts
Read the on-chain Channel account at the confirmed slot and bind session
authoritative state (deposit, splits, grace period) to it. Session state is
never advanced or trusted for an unconfirmed or economically mismatched
channel. No sibling PR touches the Rust session server; this layer is unique
to #239 and applies cleanly on the settled base.
Add #239's unique fail-closed binding: a session open/top-up is rejected
unless the on-chain Channel account (fetched via getAccountInfo at the
confirmed slot) confirms the claimed deposit, mint, payee, authorized
signer, grace period and distribution hash, and a signature-only open is
bound to its canonical broadcast transaction.

- server/session/on-chain.ts: verifyChannelAccountState,
  verifySignatureOnlyOpenTransaction, GetAccountInfoRpc, TopUpTransactionRpc.
- server/session/wire-tx.ts + store.ts: openSignature/salt/settlement fields
  carried on ChannelState for the binding.
- server/Session.ts: wire the verify calls into open/top-up/routes; export
  SettlementRpc.
- tests: session-state-binding, session-signature-only-open, plus the #239
  additions to session-on-chain / session-server-on-chain / session-store.

Dedup against stacked siblings #237/#238 (already in the base): keep their
session-store durability guard (resolveSessionStore + allowUnsafeMemoryStore)
and replay-store policy; drop #239's independent re-implementation of the
same concern (requireProductionSessionSafety + allowUnsafeEphemeralStoreOffLocalnet
+ the adapter wiring), which conflicted with those siblings' regression tests.
Superseded session-store-durability assertions were removed from the #239
test files; the mainnet fail-closed control is unchanged (stronger via #237).
Reconcile #239's on-chain Channel-account state binding onto the base's
solana-go v2 migration and hardened open/top-up verification (#214). Adds the
authoritative state-binding layer that no sibling or the base delivers:
fetchAndBindChannelAccount, NewOpenStateTxVerifier, bindConfirmedOpenChannel,
validateBoundOpenChannel, NewTopUpStateTxVerifier and confirmedTransactionSlot.
Session open/top-up state is bound to the on-chain Channel account read at the
confirmed slot; an unconfirmed or economically mismatched channel is rejected
(fail closed). Base store-safety (env-gated) is preserved; the config-field
guard reconciles to honor the same PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in.

Ports #239's v1 solana-go usage to v2 and keeps base's structural verifiers +
concurrency/replay hardening; drops #239's duplicate top-up-fence and legacy
verifier redefinitions superseded by the base.
Add PR #239's unique behavior on top of sibling #228: the state-aware open
verifier that fetches the on-chain Channel account at the confirmed slot and
binds deposit, splits, grace period and PDA seeds to on-chain reality, so a
payload's claimed economics are never persisted as facts.

- session_onchain: BoundChannel, fetch_and_bind_channel_account,
  new_open_state_tx_verifier (+ signature-only confirm/bind helpers,
  _session_distribution_hash, _expected_session_grace_period). The structural
  open path reuses #228's verify_open_tx via the recipients contract.
- confirm_transaction_signature now returns the confirmed slot and rejects a
  present-but-malformed slot, so the account read is pinned with
  min_context_slot; a status that omits the slot still confirms.
- session: SessionConfig.verify_open_state_tx / open_tx_submitter seams,
  VerifiedOpenFacts, and process_open fail-closed: off localnet a
  payment-channel open requires the state verifier and persists only
  account-derived facts.
- session_method: new_session installs the state verifier off localnet;
  _handle_open propagates the challenge recentSlot for signature-only push.
- session_store: ChannelState.salt / open_signature round-trip.
- rpc: SolanaRpc.get_account_info min_context_slot + typed get_transaction.
- tests: add test_session_state_binding.py (adapted to #228's
  ProductionChannelStore policy, which is kept as-is).

#228's structural open, top-up fence, channel-store policy and replay code are
kept unchanged; their tests continue to pass.
Give the shared test operator a real signing key (sessionTestOperatorKey backs
sessionTestRecipient) and co-sign the rentPayer slot so signature-only open
fixtures reproduce a fully signed confirmed transaction. Base #214 verifies open
signatures cryptographically, so the prior dummy-signature fixtures no longer
pass. Also restores base's env-gated nil-store default (a merge artifact had
re-added an unconditional off-localnet rejection that ignored the opt-in).
Base #214 hardened open verification (real wire-bound signatures, no
placeholder acceptance) and the session store policy became env-gated.
Update the stale Go test fixtures/assertions to the reconciled behavior:

- flip TestNewOpenTxVerifierKeepsStructuralPlaceholderAcceptance to
  TestNewOpenTxVerifierRejectsPlaceholderSignature: the seam now rejects the
  pending placeholder ("must be a real transaction signature")
- assert the real confirmed signature and derived PDA in the signature-only
  open test rather than the dummy reference the caller supplied
- thread the derived channel PDA into the top-up test and reflect the
  post-top-up deposit in the authoritative on-chain account
- match the reconciled off-localnet store-policy message, and enforce the
  unmarked-store rejection at the runtime safety gate (where it now lives)
  rather than at construction
- rename the idle-close no-signer test and assert the no-op: closeOnIdle bails
  before touching the channel when no merchant signer is configured

Test-only; no production source changed.
… path

prepareOpenTx (SubmitOpenTx, the gasless server-submit flow) verified the
open with allowUnsignedFeePayer=false, rejecting the placeholder fee-payer
signature before the co-sign step the same function performs. The Go
client signs only as the channel payer and leaves the operator slot for
the server, so the server rejected its own client's open. Use the same
verifyOpenTx(..., true) pre-verification handleOpen already uses: the
payer signature is still verified and the post-co-sign zero-signature
guard keeps broadcast fail-closed.
…resh test doubles

confirm_transaction_signature returns int|None (a confirmed status may omit the
slot), so _fetch_and_verify_signature_only_open's tuple[int, ...] annotation was
unsound; the slot flows into min_context_slot (int|None), so widen the return.
Refresh the _Rpc/_OpenConfig test doubles to the current RpcClient
(search_transaction_history kwarg) and OpenVerifierConfig (settlement_window)
protocols so pyright's structural check passes (13 errors -> 0).
…aths

Add rejection-branch tests for validateBoundOpenChannel, resolveSessionTokenProgram,
NewOpenStateTxVerifier/verifySignatureOnlyOpen, fetchAndBindChannelAccount, and the
top-up verifiers (each asserts the concrete fail-closed error, not just coverage).
Raises Go aggregate coverage 90.3% -> 91.1%, back over the 91.0 floor.
…ard for radar

Prettier the mpp server index/session store re-exports. Mark the localnet-only
MemoryChannelStore fallback nosemgrep: enforce_channel_store_policy above already
rejects an absent/in-memory store off-localnet without the opt-in, which the radar
rule's inline-guard pattern cannot see.
…ture

The epoch-addressed channel PDA seeds include open_slot, so the session
challenge now pre-fetches recentSlot alongside recentBlockhash and the open
builder fails closed without it. The defect-verifier fixture predates that
contract and crashed at construction instead of reaching its rejection
assertions; supply the challenge slot so all five value-binding rejections
(and the accept controls) exercise their intended paths.
semgrep only honors a nosemgrep comment on the match line or the line
immediately before it; the wrapped two-line comment left the marker one
line too far and the blocking radar scan still flagged the guarded
localnet-only MemoryChannelStore fallback.
process_topup fetches the real channel account and rejects when its
status/mint/payee/rent_payer/authorized_signer/payer diverge from the session
config or the stored channel, but the existing *_mismatch tests only exercised
the pass side of those checks (they reach the later grace_period /
distribution_hash / resulting-deposit rejects). Add six unit tests that seed the
SessionAccountRpc mock with a channel diverging on exactly one binding field and
assert each reject fires and leaves the stored deposit untouched. Retires the
reject-coverage gap documented in the mpp/server/session.rs coverage exemption.
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown

Too many files changed for review. (238 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant