invariants/universal/* may only ever import core/types.ts and
invariants/shared.ts — never anything under chain-adapters/ directly. Only
invariants/algorand/* is allowed to import Algorand-specific code. Supporting a second chain
means writing a new chain-adapters/<chain>/ and wiring it into cli/setup.ts — the universal
invariants themselves don’t change.
Directory layout
Core contracts
Four interfaces insrc/core/types.ts define the entire seam between “what an invariant is” and
“what a chain provides”:
Invariant
Invariant
source cites exactly what the invariant is checking
against — the x402 spec, the USENIX study, or a specific facilitator code path — and is
surfaced verbatim in ward test / ward fuzz output and the JSON report.ChainAdapter
ChainAdapter
AlgorandChainAdapter is the only
implementation today.FacilitatorClient
FacilitatorClient
facilitator-client/client.ts) over any facilitator’s
/verify, /settle, and /supported REST endpoints — the exact same three endpoints every
x402 facilitator implements regardless of which chain it settles on.PayloadMutation
PayloadMutation
A closed set of tamper operations (
network, asset, recipient, amount,
feePayerAmount, feePayerReceiver, feePayerCloseRemainderTo, feePayerRekeyTo,
feePayerFee, spliceExtraTxn, custom) that ChainAdapter.tamperPayload() applies. This is
the vocabulary every invariant uses to express “what if this field were malicious” without
knowing how tampering is actually implemented on a given chain.Execution flow
1
cli/setup.ts builds a Scenario
Loads config from
.env, constructs an AlgorandChainAdapter and HttpFacilitatorClient,
derives a TestFixture (funded client account, pay-to address, asset, amount) from
WARD_CLIENT_PRIVATE_KEY and friends, and registers all eight invariant factories into an
InvariantRegistry.2
core/runner.ts executes the registry
runInvariants(registry.all(), ctx, { concurrent }) runs each Invariant.run(ctx), catching
and recording failures per-invariant rather than aborting the whole suite on one exception.3
Each invariant builds, tampers, and asserts
A typical invariant calls
chain.buildPayment(...) for a valid payload, optionally
chain.tamperPayload(payload, mutation) for an adversarial variant, submits both to
facilitator.verify() / facilitator.settle(), and asserts the facilitator’s response is
correct — using util/evidence.ts’s EvidenceCollector to record every request/response pair
verbatim.4
core/report-engine.ts + report-summary.ts render results
buildReport() assembles all InvariantResults plus run metadata (facilitator name, chain,
commit) into the JSON shape written to reports/; summarizeReport() produces the compact
pass/fail summary both the CLI table and the paid API’s JSON response use.The Algorand chain adapter
chain-adapters/algorand/ is where every Algorand-specific detail lives — nothing in
invariants/universal/* or core/ knows about atomic groups, ASAs, or rekeying.
Why the atomic-group order matters
Algorand’s gasless/sponsored-fee flow builds a 2-transaction atomic group: an unsigned fee-payer self-payment at index 0 (only the facilitator signs it, at verify/settle time) and the client’s signed payment at index 1. Because index 0 is never signed by the client, it’s the exact seamA1 exploits — see A1 and
D7
for how this was confirmed against the installed package source rather than assumed from docs.
Two consumers, one engine
src/cli/* and src/server/index.ts are both thin wrappers around the exact same
core/, chain-adapters/, and invariants/ code — the paid verification server
(POST /verify-facilitator) calls buildScenario(), runInvariants(), and buildReport()
directly, just swapping which facilitator URL gets tested. See
Verification API for how that’s wired up.
