Skip to content

E2E devnet tests for voucher spend (closes #15)#19

Merged
paolino merged 7 commits intomainfrom
feat/devnet-spend
Apr 20, 2026
Merged

E2E devnet tests for voucher spend (closes #15)#19
paolino merged 7 commits intomainfrom
feat/devnet-spend

Conversation

@paolino
Copy link
Copy Markdown
Contributor

@paolino paolino commented Apr 20, 2026

Summary

End-to-end tests for the harvest voucher spend path against a real
Cardano devnet. Closes #15.

Each it block reads as executable narrative of the protocol (see
DevnetSpendSpec module haddock). The test suite exercises the
applied validator bytecode, the Haskell tx builder in
Harvest.Transaction, and the three bindings from the constitution
§V (d via Groth16, acceptor/TxOutRef via Ed25519, pk_c via
byte-split cross-check).

Coverage

  • Environment (T020): withDevnet bracket + funded genesis.
  • Deploy (T064): script UTxO at applied validator address with
    inline datum + split reificator fee/collateral UTxOs.
  • Golden path (T021, FR-001): legitimate spend submitted, validator
    accepts.
  • Negative — signed_data tamper (T030, FR-002.1): byte flip after
    re-sign, Ed25519 rejects.
  • Negative — d mismatch (T031, FR-002.2): redeemer.d ≠
    signed_data.d, validator's defence-in-depth check rejects.
  • Negative — pk_c substitution (T032, FR-002.3): customer_pubkey
    flip, byte-split cross-check rejects.
  • Negative — TxOutRef replay (T033, FR-002.4): bogus txid bound
    into signed_data, membership check rejects.

Design

  • Four orthogonal Mutations hooks (mBundle, mLiveTxid,
    mLiveIx, mSignedData) let each negative test corrupt exactly
    the pipeline stage matching its attack vector.
  • Phase-2 script-eval failure at build-time is collapsed into
    SubmitResult.Rejected, so all four negatives uniformly assert
    shouldSatisfy isRejected without pinning error text.
  • No cardano-api anywhere — ledger types + cardano-crypto-class
    only.

Test plan

  • cabal test harvest-test locally: 40 examples, 0 failures.
  • Bisect-safe verified (both commits pass on their own).
  • fourmolu -m check and hlint clean.

paolino added 7 commits April 18, 2026 18:50
Brings up a real cardano-node devnet inside each scenario via the
new DevnetEnv.withEnv bracket. Proves the environment end-to-end:

  "devnet comes up with a funded genesis address" ✔

The bracket is a thin wrapper over cardano-node-clients:devnet's
withDevnet: constructs the Provider + Submitter, derives a fresh
reificator Ed25519 key from a fixed seed, queries the genesis UTxO
set, and yields a DevnetEnv record to each scenario.

Wiring:
- flake.nix gains a cardano-node-clients input pinned to the current
  main SHA (b9fbbb5). We reuse its packages.devnet-genesis so harvest
  does not fork the genesis JSON set.
- nix/checks.nix exports E2E_GENESIS_DIR pointing at that genesis
  store path so withDevnet can copy it into its temp workdir.
- harvest.cabal test-suite gains cardano-ledger-api and
  cardano-ledger-conway deps. These currently come from ledger
  packages directly; follow-up is to route ledger types through
  cardano-node-clients re-exports (flagged with FIXME in DevnetEnv).

Current scenario status:
- loads the fixture bundle cleanly ✔
- devnet comes up with a funded genesis address ✔
- a customer spends at an acceptor — validator accepts: pending
  (script deploy + spend submit is the next chunk)
- four negatives: pending

Test run: 39 examples, 0 failures, 5 pending. The bracket adds ~50s
to CI (cold devnet startup), amortised across all scenarios in the
same spec file.

Refs #15.
Replace the placeholder FIXME with the real constraint: harvest
explicitly avoids cardano-api (the higher-level wrapper), not the
ledger packages themselves. The ledger imports here are the same
low-level seam cardano-node-clients:devnet consumes, so routing
them through a re-export would add indirection without changing
the dependency surface.
Adds SpendSetup.deploySpendState, which submits a single setup tx that:
  1. spends a genesis UTxO,
  2. pays 100 ADA to the reificator's address for tx fees,
  3. creates the 5-ADA script UTxO at the applied validator's address
     with an inline VoucherDatum matching the fixture's commit_S_old,
  4. lets build(..) balance change back to genesisAddr,
and waits for both outputs to materialise via queryUTxOs.

DevnetSpendSpec now wires the deploy step into a new scenario:
  "deploys the voucher script UTxO and funds the reificator"
which asserts both TxIns are distinct and present. This is the first
test that submits a real tx to the harness devnet and observes
confirmation.

Helpers (witnessKeyHashFromSignKey, waitForUtxos, an empty NoQ
interpreter) are copied inline from cardano-node-clients' own
TxBuildSpec — they are not on the public surface, and upstreaming a
narrow set is a later discussion.

Plumbing: test-suite deps gain cardano-ledger-api,
cardano-ledger-conway, cardano-ledger-mary, containers, microlens
(all already in the closure via cardano-node-clients).

Test run: 40 examples, 0 failures, 5 pending. Devnet cold start +
deploy tx confirmation adds ~10s to the previous bracket run.

Refs #15.
The earlier /= check between dsScriptTxIn and dsReificatorTxIn was
structurally tautological — two outputs in one tx can't share a TxIn
by construction, so the assertion couldn't fail even if the deploy
logic was wrong.

Replaces it with four load-bearing checks against the returned
DeployedSpend:
  - script output's address == the applied validator's address
  - script output's value == the declared script-pay coin
  - script output's datum is an inline datum, not NoDatum (catches a
    regression where payTo' silently degrades to payTo)
  - reificator output's address == the reificator's own address
  - reificator output's value == the declared funding coin

Exposes dsScriptAddr, dsReificatorPay, dsScriptPay on DeployedSpend
so the spec has all the expected values in one place rather than
hardcoding coin amounts in two modules.

40 examples, 0 failures, 5 pending.

Refs #15.
A Cardano tx can use a given UTxO as EITHER a regular input (fees,
balance) OR a collateral input, never both — on a successful script
run, collateral is untouched, which would leave a UTxO double-counted
in the balance equation. The spend scenario therefore needs two
distinct reificator outputs from the setup tx.

Changes:
- SpendSetup.deploySpendState now pays the reificator twice (50 ADA
  fee + 10 ADA collateral). DeployedSpend exposes both pairs
  (dsReificatorFeeTxIn/Out + dsReificatorCollateralTxIn/Out) plus
  their declared values.
- DevnetSpendSpec asserts both outputs land at the reificator
  address with their declared amounts, and that the two TxIns are
  distinct (otherwise the spend tx can't use one as collateral and
  the other for fees).

Still 40 examples, 0 failures, 5 pending. Setup for T065 (submit
the spend tx).

Refs #15.
Wires the first live-devnet happy-path scenario into
DevnetSpendSpec. 'SpendScenario.submitSpend' drives the full
Harvest.Transaction.spendVoucher builder against the running
node: deploy the script UTxO + reificator inputs, re-sign
signed_data against the live reificator fee TxOutRef, build,
sign with the reificator key, submit.

The 'Mutations' record exposes the four orthogonal hooks the
upcoming T030-T033 negatives will need (bundle, live TxOutRef,
post-re-sign signed_data) so the API stabilises with the golden
path rather than being refactored later. The golden path runs
on 'identityMutations' so the hooks are inert here.

Adds cardano-crypto-class to the test-suite build-depends for
hashToBytes / extractHash, which we use to pull the raw 32-byte
txid out of the fee TxIn for the re-sign binding.

Script-evaluation failures at build-time collapse into
SubmitResult.Rejected so negative tests in the follow-up can
assert on 'Rejected' uniformly; other build errors remain
uncaught (they indicate harness bugs, not validator rejection).
Fills in the four FR-002 negative scenarios that were
pendingWith-stubbed when the golden path landed. Each 'it'
block picks the 'Mutations' hook that matches its attack vector:

  * T030 (signed_data tampering): post-re-sign byte flip at
    offsetAcceptorAy; Ed25519 check rejects.
  * T031 (d mismatch): bundle-only bump of sbD; validator's
    signed_data.d == redeemer.d check rejects.
  * T032 (pk_c substitution): bundle-only byte flip of
    sbCustomerPubkey; validator's byte-split check rejects.
  * T033 (TxOutRef replay): override the live txid bound into
    signed_data with a fabricated value; the re-sign covers it
    so VerifyEd25519Signature passes, but the
    signed_data.txOutRef ∈ tx.inputs check rejects.

Each scenario asserts 'Rejected' without pinning the error text
(the ledger reword-rate is high enough that matching on text
would flap across node versions). 'flipByteAt' and 'isRejected'
are small local helpers; no new test infrastructure.
@paolino paolino added the test Tests label Apr 20, 2026
@paolino paolino self-assigned this Apr 20, 2026
@paolino paolino merged commit cd4b8e1 into main Apr 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tests: end-to-end coverage for validator, Groth16 verifier, and Ed25519 signature path

1 participant