MPP - Channel Mode
Verified against @stellar/mpp@0.7.1, mppx@0.8.14, @stellar/stellar-sdk@16.1.0.
| ID | Check Name | Destructive | Spec Reference | What It Checks | Pass Criteria |
|---|---|---|---|---|---|
MPP-10 | Channel Deploy | no | MPP Channel Guide | The channel contract deploys correctly, and is the same channel the target bills through | Contract address is valid and its state is queryable via getChannelState(), matching the parameters it was opened with (token, from, to, refundWaitingPeriod). The channel inspected is the one the target advertises in its 402 challenge, so MPP-10 and MPP-11/12/13/14 always report on the same contract. --channel (env CHANNEL_CONTRACT) asserts an expected address rather than selecting one: when it differs from the advertised channel the check fails and inspects nothing, because a run that reported on both would be reporting on two contracts at once. If the challenge cannot be read at all, an explicitly named channel is still inspected and the result is marked unverified against the target. |
MPP-11 | Cumulative Commitment Ordering | no | MPP Channel Guide §closing-the-channel; @stellar/mpp channel server (cumulativeMonotonicityError) | Both ordering rules: a commitment must exceed the stored cumulative, and must cover the price of the current request | Two probes, each rejected with HTTP 402: one committing exactly the stored cumulative, one advancing but falling short of cumulative + price. Each probe uses a fresh challenge, so the earlier-running challenge replay guard cannot fire and be mistaken for ordering enforcement. The second probe is reported as not-probed when the price is 1 base unit, since it collapses into the first. |
MPP-12 | Challenge Replay Rejection (negative) | no | MPP Channel Guide §closing-the-channel; @stellar/mpp channel server (atomic compare-and-set on challenge ID) | A byte-identical credential resubmitted against the same challenge must be rejected | HTTP 402 on the second submission. Isolation: the replayed credential is identical to one the server accepted moments earlier, so its signature and amount are already proven valid and only the challenge-ID claim can explain the rejection. |
MPP-14 | Commitment Replay Rejection (negative) | no | MPP Channel Guide §closing-the-channel; @stellar/mpp channel server (cumulativeMonotonicityError) | A captured (amount, signature) pair must not be redeemable against a new challenge | HTTP 402 when a previously accepted commitment is re-presented under a fresh challenge. This is the realistic double-spend: the challenge replay guard cannot help because the challenge ID is new, so only the cumulative rule stands in the way. The official client SDK cannot express this probe — it re-signs on every call. |
MPP-13 | Close Settlement | yes | MPP Channel Guide §closing-the-channel | Closing with the highest commitment settles on-chain | Server accepts the close credential (HTTP 200), then RPC confirms settlement: closeEffectiveAtLedger moves from null to a ledger sequence, and the contract's withdrawn getter equals exactly the committed amount. The channel balance is not asserted — close() pays the commitment to the recipient and then auto-refunds the remainder to the funder, so a closed channel always ends at zero. withdrawn is written in the same call that transfers the payout, and that transfer is non-fallible, so a mismatch would have reverted the whole close. Running this permanently ends the channel — skipped unless destructive checks are explicitly enabled, and it refuses to run unless the operator names the channel and the target's challenge advertises that same address. |
Reporting semantics (Week 2). Beyond PASS and SKIP, a run distinguishes two further outcomes, because "your service is broken" and "we never reached your service" are not the same claim:
- FAIL — the target answered, and the answer did not conform. This includes
a response that arrives but violates the spec: wrong status, unparseable
challenge, missing
channel/amount, non-numeric amounts. - ERROR — no verdict was produced about the target at all. Either it could
not be reached (
unreachable), or the run is misconfigured (configuration), or the harness itself failed (harness). An ERROR is never a statement about the target's conformance.
Exit codes follow from that: 0 every check conformed, 1 at least one
conformance failure, 2 at least one check produced no verdict. A run with
both exits 1, because a real finding outranks a missing one.
PREFLIGHT is not a check and carries no spec reference, which is why it
has no row above. It is a single diagnostic emitted when the target URL or the
network identifier is invalid — both are wrong for every check in the suite, so
they are reported once rather than repeated identically per check. When
preflight fails, no check runs.
Note on interfaces and access paths (Week 2). The catalogue is exercised by
two interfaces over one core: the wasit CLI and the wasit-mcp MCP server.
Both run identical check code against the same suite functions, so the two can
never disagree about the same target. Every check in this catalogue is
reachable from both: test / wasit_x402_test for x402, mpp-charge /
wasit_mpp_charge_test for charge mode, and mpp-channel /
wasit_mpp_channel_test for channel mode. Only the reporting surface differs — exit
codes are a CLI concept, and the MCP server reports the same verdict as an
outcome field in its structured output.
MPP-13 is reachable from both interfaces, but by different routes. Over MCP,
wasit_mpp_channel_test runs the non-destructive channel checks (MPP-10,
MPP-11, MPP-12, MPP-14) and reports MPP-13 as SKIP, so an agent can see
that the check exists and why it did not run, rather than silently receiving a
shorter catalogue. The destructive route is a separate tool,
wasit_mpp_channel_test_with_close, which is registered only when a human
starts the server with WASIT_ALLOW_DESTRUCTIVE=1 or --allow-destructive.
Without that opt-in the tool is absent from tools/list altogether: an agent
cannot invoke a tool it cannot see, which is a stronger guarantee than a boolean
argument it could set for itself. When the tool is registered, it still requires
a destructiveChannel argument naming the channel it is permitted to close, and
the guard described in the MPP-13 row still applies unchanged — the run
refuses unless the target's challenge advertises that same address. Signing keys
are read from the server process environment and are never accepted as tool
arguments, so an agent never handles them.
Revision note (Week 2, corrected): MPP-11/MPP-12 pass criteria were first written as "server rejects", then briefly revised to "zero balance delta / silent no-op" after reading only the stellar-experimental/one-way-channel on-chain contract source (which is genuinely a silent no-op for stale settle/close calls). That revision was corrected after reading the @stellar/mpp channel server implementation directly: the HTTP-facing server — the actual artifact Wasit tests — rejects stale/replayed commitments explicitly via ChannelVerificationError, before the on-chain contract is ever invoked. The contract's own no-op behavior only applies if the contract is called directly, bypassing the server, which is out of scope for Wasit.
Status note (Week 2, corrected). All thirteen checks in this catalogue are implemented
and reachable from both front ends. X402-02 deliberately accepts either header
name because Stellar's own official documentation is not yet internally
consistent (PAYMENT-REQUIRED vs X-Payment); that divergence is a
documentation defect upstream, not a choice this catalogue is making. Note that
the x402Version field-name difference checked by X402-04 is not a
divergence of the same kind: it is a deliberate, documented change between
protocol versions, and the SDK implements it correctly.
Note on error granularity (Week 2). All channel-mode rejections return the
same HTTP 402 body: {"type": ".../problems/verification-failed", "title": "Verification Failed", ...}. Replay, non-monotonic commitments, bad signatures
and a settling channel are indistinguishable from the response alone. Cause:
@stellar/mpp throws ChannelVerificationError, which extends StellarMppError
rather than mppx's PaymentError, so mppx rewraps every one of them into a
generic VerificationFailedError. mppx already defines precise types for this
family — session/invalid-signature, session/signer-mismatch,
session/amount-exceeds-deposit, session/delta-too-small,
session/insufficient-balance, session/channel-finalized — and none are
currently reachable from channel mode.
Wasit therefore isolates each rule by construction rather than by asserting on the response type: every check is built so that exactly one rejection path can fire, and the "Pass Criteria" column above records how. This is a gap in the official SDK, not in any service under test, and is documented in docs/findings/upstream-sdk.md for upstream reporting.
Note on close authorisation (Week 2). The channel contract's close() calls
to.require_auth() — the recipient must authorise the close, not the funder.
@stellar/mpp exposes this as feePayer.envelopeSigner, a name that implies fee
payment rather than authorisation; configuring it with the funder's key produces
a transaction that reaches the chain and fails there with an opaque
scecInvalidAction, which the SDK surfaces as [object Object]. By contrast,
close_start() requires from.require_auth() — the funder. Both details are documented in docs/findings/upstream-sdk.md for upstream reporting.
