M1-F: end-to-end LLM marketplace lifecycle on Vector testnet

Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.

Workspace layout (pnpm + TypeScript):
  contracts/marketplace/      Aiken validators (advert + escrow) + plutus.json
  packages/shared/            Tx-builders (lucid-evolution), CBOR codecs,
                              chain providers (mock / read-only / live Ogmios)
  supplier/                   OpenAI-compatible HTTP node (Ollama by default;
                              vLLM / llama.cpp / OpenRouter / OpenClaw all
                              swap-in via OLLAMA_URL); 202+poll chat handler
  buyer/                      Express + React SPA; server-side runs the full
                              chain lifecycle via /v1/submit-prompt and
                              /v1/accept; SPA never holds the buyer key
  indexer/                    Chain-follower (extends apex-dashboard pattern),
                              SSE event stream + REST queries
  indexer-ui/                 Operator dashboard (events + suppliers + escrow
                              lookup)
  tests/                      1098 vitest unit + integration tests (10 skipped
                              under M1-F-D-defer for UPLC test plumbing)
  deploy/testnet/             Per-service docker-compose for the Hetzner stack
  docs/EXECUTIVE_SUMMARY.*    Marp deck (PDF + PPTX) — 11 slides, mermaid
                              diagrams pre-rendered for portable export
  docs/CLOUDFLARE_WAF_BYPASS.md  CF Skip-rule runbook for /v1/* paths
  docs/ARCHITECTURE.md        canonical spec

Live demo (testnet, 2026-05-06):
  https://mp-buyers.vector.testnet.apexfusion.org      (buyer SPA)
  https://mp-suppliers.vector.testnet.apexfusion.org   (supplier API)
  https://mp-indexer.vector.testnet.apexfusion.org     (indexer + UI)
This commit is contained in:
jelisaveta-talbot
2026-05-06 13:27:48 +02:00
commit cbab81efbd
323 changed files with 49725 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
## AdvertScript (Aiken)
Aiken source goes here in M1. See `docs/ARCHITECTURE.md` section 4.1 for the
datum schema and redeemer set (PostAdvert | UpdateAdvert | RetireAdvert).

View File

@@ -0,0 +1,4 @@
## EscrowScript (Aiken)
Aiken source goes here in M1. See `docs/ARCHITECTURE.md` sections 4.2 and 4.3 for the
datum schema, redeemers, and state-machine transitions this validator must enforce.

View File

@@ -0,0 +1,18 @@
name: Continuous Integration
on:
push:
branches: ["main"]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: aiken-lang/setup-aiken@v1
with:
version: v1.1.21
- run: aiken fmt --check
- run: aiken check -D
- run: aiken build

6
contracts/marketplace/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Aiken compilation artifacts
artifacts/
# Aiken's project working directory
build/
# Aiken's default documentation export
docs/

View File

@@ -0,0 +1,8 @@
# local-agents/marketplace
Aiken validators for the Local Agents Marketplace (M1, v1). Two validators ship from this project:
- **AdvertScript** (`validators/advert.ak`) — supplier-owned advertisement registry; redeemers `PostAdvert | UpdateAdvert | RetireAdvert`.
- **EscrowScript** (`validators/escrow.ak`) — happy-path bonded escrow, state machine `Open → Claimed → Submitted → Accepted | Released` (and `Open|Claimed → Reclaimed`); redeemers `Claim | Submit | Accept | Reclaim | Release`.
Datum schemas mirror `packages/shared/src/cbor/types.ts` byte-for-byte (Plutus Constr0 with fields in declaration order). Spec: `docs/ARCHITECTURE.md` §4. `ACCEPT_WINDOW = 600_000 ms` (10 min) lives in `lib/marketplace/common.ak`. Compile with `aiken check && aiken build`; the resulting `plutus.json` is consumed by M1-B's tx builders.

View File

@@ -0,0 +1,15 @@
# This file was generated by Aiken
# You typically do not need to edit this file
[[requirements]]
name = "aiken-lang/stdlib"
version = "v3.1.0"
source = "github"
[[packages]]
name = "aiken-lang/stdlib"
version = "v3.1.0"
requirements = []
source = "github"
[etags]

View File

@@ -0,0 +1,18 @@
name = "local-agents/marketplace"
version = "0.1.0"
compiler = "v1.1.21"
plutus = "v3"
license = "Apache-2.0"
description = "Local Agents Marketplace — AdvertScript and EscrowScript validators (v1, happy-path only)."
[repository]
user = "local-agents"
project = "marketplace"
platform = "github"
[[dependencies]]
name = "aiken-lang/stdlib"
version = "v3.1.0"
source = "github"
[config]

View File

@@ -0,0 +1,202 @@
//// Shared helpers for the AdvertScript and EscrowScript validators.
////
//// Conventions:
//// - All time values are POSIX milliseconds (matches TS `POSIXTime`).
//// - All "must be signed by X" checks compare a `VerificationKeyHash`
//// against `tx.extra_signatories`. We do NOT inspect input owners; the
//// ledger ensures `extra_signatories` carries the witness keys.
//// - Time-bound checks are conservative: we require the worst-case
//// boundary of the validity range to satisfy the rule, so the script
//// cannot be tricked by a wide validity window.
use aiken/interval.{
Finite, Interval, IntervalBound, NegativeInfinity, PositiveInfinity,
}
use aiken/crypto.{VerificationKeyHash}
use cardano/address.{Credential, Script, VerificationKey}
use cardano/assets.{Value}
use cardano/transaction.{Output, ValidityRange}
/// 10 minutes in milliseconds. The buyer has this long after the supplier's
/// `submitted_at` to call `Accept`; after the window the supplier may
/// `Release` and walk away with the buyer bond. ARCHITECTURE.md §4.3.
pub const accept_window_ms: Int = 600_000
// ─── Signer checks ───────────────────────────────────────────────────
/// True iff `signer` appears in the transaction's required signers list.
pub fn signed_by(extra_signatories: List<VerificationKeyHash>, signer: VerificationKeyHash) -> Bool {
list_contains_pkh(extra_signatories, signer)
}
fn list_contains_pkh(xs: List<VerificationKeyHash>, target: VerificationKeyHash) -> Bool {
when xs is {
[] -> False
[head, ..tail] ->
if head == target {
True
} else {
list_contains_pkh(tail, target)
}
}
}
// ─── Time-bound checks ───────────────────────────────────────────────
/// True iff every point in the validity range is `<= deadline`.
/// In other words, the upper bound is finite and at most `deadline`.
/// We use the inclusive interpretation: an upper bound of exactly `deadline`
/// is allowed (validity ranges in the ledger have inclusive upper bounds in
/// the common case). This is the rule for `Claim` and `Submit`: the tx must
/// be impossible to drift past `deliver_by`.
pub fn before_or_at(range: ValidityRange, deadline: Int) -> Bool {
when range.upper_bound.bound_type is {
Finite(upper) ->
if range.upper_bound.is_inclusive {
upper <= deadline
} else {
// Exclusive upper bound: any point < upper. We require the strict
// upper to be at most deadline + 1 (i.e. all points <= deadline).
upper <= deadline + 1
}
NegativeInfinity -> True
PositiveInfinity -> False
}
}
/// True iff every point in the validity range is `>= threshold`.
/// Used for `Reclaim` (after `deliver_by`) and `Release` (after
/// `submitted_at + ACCEPT_WINDOW`). We use the inclusive interpretation:
/// a lower bound of exactly `threshold` is allowed.
pub fn after_or_at(range: ValidityRange, threshold: Int) -> Bool {
when range.lower_bound.bound_type is {
Finite(lower) ->
if range.lower_bound.is_inclusive {
lower >= threshold
} else {
// Exclusive lower bound: any point > lower. We require lower >=
// threshold - 1 (i.e. all points >= threshold).
lower >= threshold - 1
}
PositiveInfinity -> True
NegativeInfinity -> False
}
}
/// Pick a representative POSIX timestamp from the validity range to stamp
/// onto an on-chain field (`submitted_at`). We use the **upper bound**: the
/// latest possible time the tx could have been included. Cardano's standard
/// convention. The bound must be finite for this to be meaningful.
pub fn upper_bound_of(range: ValidityRange) -> Option<Int> {
when range.upper_bound.bound_type is {
Finite(t) -> Some(t)
_ -> None
}
}
// ─── Address / output helpers ────────────────────────────────────────
/// True iff the output's payment credential is a verification-key with the
/// given hash. Used by `RetireAdvert` to ensure the deposit goes back to the
/// supplier's own wallet rather than another script address.
pub fn output_paid_to_pkh(output: Output, pkh: VerificationKeyHash) -> Bool {
when output.address.payment_credential is {
VerificationKey(vk) -> vk == pkh
_ -> False
}
}
/// True iff the output's payment credential is a script with the given
/// script hash. Used to find the "continuing" output for advert/escrow
/// state transitions.
pub fn output_paid_to_script(output: Output, script_hash: ByteArray) -> Bool {
when output.address.payment_credential is {
Script(sh) -> sh == script_hash
_ -> False
}
}
/// True iff the output's payment credential equals the given Credential.
/// More flexible than `output_paid_to_script` when callers already have a
/// `Credential` (e.g. taken from the script's own input).
pub fn output_paid_to_credential(output: Output, credential: Credential) -> Bool {
output.address.payment_credential == credential
}
/// Sum of lovelace across all outputs paying to the given pkh address
/// (verification-key payment credential, any/no stake credential).
pub fn lovelace_paid_to_pkh(outputs: List<Output>, pkh: VerificationKeyHash) -> Int {
sum_lovelace_to_pkh(outputs, pkh, 0)
}
fn sum_lovelace_to_pkh(outputs: List<Output>, pkh: VerificationKeyHash, acc: Int) -> Int {
when outputs is {
[] -> acc
[head, ..tail] ->
if output_paid_to_pkh(head, pkh) {
sum_lovelace_to_pkh(tail, pkh, acc + assets.lovelace_of(head.value))
} else {
sum_lovelace_to_pkh(tail, pkh, acc)
}
}
}
/// Total lovelace across all outputs paid to the given Credential.
pub fn lovelace_paid_to_credential(outputs: List<Output>, credential: Credential) -> Int {
sum_lovelace_to_credential(outputs, credential, 0)
}
fn sum_lovelace_to_credential(outputs: List<Output>, credential: Credential, acc: Int) -> Int {
when outputs is {
[] -> acc
[head, ..tail] ->
if output_paid_to_credential(head, credential) {
sum_lovelace_to_credential(tail, credential, acc + assets.lovelace_of(head.value))
} else {
sum_lovelace_to_credential(tail, credential, acc)
}
}
}
/// Find the unique output paying to the given Credential. `fail`s if there
/// is not exactly one. Use this to locate the "continuing" UTxO for the
/// advert/escrow validator's same-address rebind.
pub fn unique_output_to_credential(outputs: List<Output>, credential: Credential) -> Output {
let matches =
list_filter_outputs_to_credential(outputs, credential, [])
when matches is {
[single] -> single
_ -> fail @"expected exactly one continuing output to script address"
}
}
fn list_filter_outputs_to_credential(
outputs: List<Output>,
credential: Credential,
acc: List<Output>,
) -> List<Output> {
when outputs is {
[] -> reverse_list(acc, [])
[head, ..tail] ->
if output_paid_to_credential(head, credential) {
list_filter_outputs_to_credential(tail, credential, [head, ..acc])
} else {
list_filter_outputs_to_credential(tail, credential, acc)
}
}
}
fn reverse_list(xs: List<Output>, acc: List<Output>) -> List<Output> {
when xs is {
[] -> acc
[head, ..tail] -> reverse_list(tail, [head, ..acc])
}
}
// ─── Value comparison helpers ────────────────────────────────────────
/// True iff two values carry the same lovelace AND the same non-ADA assets.
/// Used to enforce "value preserved" on Claim and Submit transitions.
pub fn value_equal(a: Value, b: Value) -> Bool {
a == b
}

View File

@@ -0,0 +1,127 @@
//// Datum schemas for the Local Agents Marketplace.
////
//// On-chain encoding mirrors `packages/shared/src/cbor/types.ts`:
//// - Records serialize as Plutus `Constr0` (CBOR tag 121) with fields in
//// declaration order.
//// - Sum-type variants are tagged 121, 122, ... in declaration order, so
//// **declaration order is load-bearing**.
//// - `Option<T>` is the stdlib `Option`; `Some(v)` = tag 121 [v],
//// `None` = tag 122 []. (Stdlib re-exports it from `aiken/option`.)
//// - `OutputReference` from `cardano/transaction` is `Constr0[bytes, int]`
//// (`transaction_id`, `output_index`), matching the TS encoder's
//// `Constr0[txHash, index]`.
////
//// Cross-reference: `docs/ARCHITECTURE.md` §4.1, §4.2.
use aiken/crypto.{VerificationKeyHash}
use cardano/transaction.{OutputReference}
/// AdvertStatus mirrors the TS literal type:
/// Active -> Constr 121 []
/// Retired -> Constr 122 []
/// Declaration order MUST stay Active, then Retired.
pub type AdvertStatus {
Active
Retired
}
/// EscrowState mirrors the TS string-union, tag layout 121..126:
/// Open=121, Claimed=122, Submitted=123,
/// Accepted=124, Reclaimed=125, Released=126
/// Declaration order MUST match this exact sequence — it is checked against
/// `STATE_TAGS` in `packages/shared/src/cbor/EscrowDatum.ts`.
pub type EscrowState {
Open
Claimed
Submitted
Accepted
Reclaimed
Released
}
/// AdvertDatum — ARCHITECTURE.md §4.1. 13 fields, declaration order matches
/// the TS encoder in `packages/shared/src/cbor/AdvertDatum.ts`.
///
/// 0 supplier_pkh ByteArray(28)
/// 1 capability_id ByteArray (UTF-8)
/// 2 model ByteArray (UTF-8)
/// 3 max_output_tokens Int
/// 4 max_processing_ms Int
/// 5 price_lovelace Int
/// 6 supplier_bond_lovelace Int
/// 7 buyer_bond_lovelace Int
/// 8 endpoint_url ByteArray (UTF-8)
/// 9 detail_uri ByteArray (UTF-8)
/// 10 detail_hash ByteArray(32)
/// 11 advertised_at Int (POSIX ms)
/// 12 status AdvertStatus
pub type AdvertDatum {
supplier_pkh: VerificationKeyHash,
capability_id: ByteArray,
model: ByteArray,
max_output_tokens: Int,
max_processing_ms: Int,
price_lovelace: Int,
supplier_bond_lovelace: Int,
buyer_bond_lovelace: Int,
endpoint_url: ByteArray,
detail_uri: ByteArray,
detail_hash: ByteArray,
advertised_at: Int,
status: AdvertStatus,
}
/// EscrowDatum — ARCHITECTURE.md §4.2. 14 fields, declaration order matches
/// the TS encoder in `packages/shared/src/cbor/EscrowDatum.ts`.
///
/// 0 buyer_pkh ByteArray(28)
/// 1 supplier_pkh ByteArray(28)
/// 2 advert_ref OutputReference (Constr0[bytes, int])
/// 3 capability_id ByteArray (UTF-8)
/// 4 request_spec_hash ByteArray(32)
/// 5 prompt_hash ByteArray(32)
/// 6 payment_lovelace Int
/// 7 buyer_bond_lovelace Int
/// 8 supplier_bond_lovelace Int
/// 9 deliver_by Int (POSIX ms)
/// 10 posted_at Int (POSIX ms)
/// 11 submitted_at Option<Int>
/// 12 result_receipt_hash Option<ByteArray>
/// 13 state EscrowState
pub type EscrowDatum {
buyer_pkh: VerificationKeyHash,
supplier_pkh: VerificationKeyHash,
advert_ref: OutputReference,
capability_id: ByteArray,
request_spec_hash: ByteArray,
prompt_hash: ByteArray,
payment_lovelace: Int,
buyer_bond_lovelace: Int,
supplier_bond_lovelace: Int,
deliver_by: Int,
posted_at: Int,
submitted_at: Option<Int>,
result_receipt_hash: Option<ByteArray>,
state: EscrowState,
}
/// AdvertScript redeemer set. Declaration order is informational here — the
/// tx builder constructs the redeemer Data; tags 121/122/123 in declaration
/// order match the M1-B encoder.
pub type AdvertRedeemer {
PostAdvert
UpdateAdvert
RetireAdvert
}
/// EscrowScript redeemer set. Tags 121..125 in declaration order:
/// Claim=121, Submit=122, Accept=123, Reclaim=124, Release=125.
/// `Submit` carries the receipt-hash payload chosen by the supplier; the
/// validator binds it into `result_receipt_hash` of the new datum.
pub type EscrowRedeemer {
Claim
Submit { receipt_hash: ByteArray }
Accept
Reclaim
Release
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,336 @@
//// AdvertScript — supplier advertisement registry.
////
//// ARCHITECTURE.md §4.1. Three logical operations on an advert UTxO:
////
//// PostAdvert — Create a fresh advert UTxO with status: Active.
//// This is a CREATION event; spending validators do not
//// run on output creation. PostAdvert is enforced
//// OFF-CHAIN by M1-B's tx builder (datum shape, signer).
//// We declare it as a redeemer constructor for symmetry
//// with the TS side, but the on-chain `spend` handler
//// does not see it.
////
//// UpdateAdvert — Spend an existing advert UTxO and produce a new one
//// at the same script address. Same supplier_pkh in old
//// and new datum; new advertised_at >= old advertised_at;
//// status may flip Active <-> Retired or stay; signed by
//// supplier_pkh.
////
//// RetireAdvert — Spend the advert UTxO; produce an output to the
//// supplier's wallet (verification-key payment cred).
//// Returns the deposit. Signed by supplier_pkh.
////
//// Cross-spec note: ARCHITECTURE.md does not require the new advert UTxO
//// after `UpdateAdvert` to preserve any specific value, only that it remain
//// at the same script address. We do NOT enforce a value-preservation rule
//// here — the tx builder (M1-B) is responsible for choosing a deposit.
use cardano/address.{Address, Script, VerificationKey}
use cardano/assets
use cardano/transaction.{
InlineDatum, Input, NoDatum, Output, OutputReference, Transaction, find_input,
}
use marketplace/common.{
output_paid_to_pkh, signed_by, unique_output_to_credential,
}
use marketplace/types.{
Active, AdvertDatum, AdvertRedeemer, PostAdvert, Retired, RetireAdvert,
UpdateAdvert,
}
validator advert {
spend(
datum: Option<AdvertDatum>,
redeemer: AdvertRedeemer,
own_ref: OutputReference,
self: Transaction,
) {
expect Some(old_datum) = datum
when redeemer is {
// PostAdvert is a creation-event rule and cannot occur as a spend
// redeemer — there is nothing to spend at creation. If it ever shows
// up here, the tx is malformed and we reject.
PostAdvert -> False
UpdateAdvert -> handle_update(old_datum, own_ref, self)
RetireAdvert -> handle_retire(old_datum, self)
}
}
}
// ─── UpdateAdvert ────────────────────────────────────────────────────
fn handle_update(
old_datum: AdvertDatum,
own_ref: OutputReference,
self: Transaction,
) -> Bool {
// Find our own input to learn its address (we re-bind to that same
// address on output).
expect Some(own_input) = find_input(self.inputs, own_ref)
let own_credential = own_input.output.address.payment_credential
// Exactly one continuing output to the same script credential.
let continuing = unique_output_to_credential(self.outputs, own_credential)
// The new datum must be inline and parse as an AdvertDatum.
expect new_datum: AdvertDatum = inline_datum_of(continuing)
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let supplier_unchanged = new_datum.supplier_pkh == old_datum.supplier_pkh
let timestamp_monotonic = new_datum.advertised_at >= old_datum.advertised_at
signer_ok && supplier_unchanged && timestamp_monotonic
}
// ─── RetireAdvert ────────────────────────────────────────────────────
fn handle_retire(old_datum: AdvertDatum, self: Transaction) -> Bool {
// Signed by the supplier.
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
// At least one output pays back to the supplier's wallet (verification-key
// payment credential). We don't require a specific amount — the deposit
// size is M1-B's policy choice, and locking it here would couple the
// validator to a TS-side constant.
let returns_to_supplier =
output_to_supplier_present(self.outputs, old_datum.supplier_pkh)
signer_ok && returns_to_supplier
}
fn output_to_supplier_present(
outputs: List<Output>,
supplier_pkh: ByteArray,
) -> Bool {
when outputs is {
[] -> False
[head, ..tail] ->
if output_paid_to_pkh(head, supplier_pkh) {
True
} else {
output_to_supplier_present(tail, supplier_pkh)
}
}
}
// ─── Datum extraction ────────────────────────────────────────────────
fn inline_datum_of(output: Output) -> Data {
when output.datum is {
InlineDatum(d) -> d
_ -> fail @"continuing advert output must carry an inline datum"
}
}
// ─── Tests ───────────────────────────────────────────────────────────
//
// Aiken `test` blocks below cover the happy and adversarial paths called
// out in the M1-A brief and ARCHITECTURE.md §7.3.
const supplier_a: ByteArray = #"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
const supplier_b: ByteArray = #"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
const advert_script_hash: ByteArray =
#"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
const detail_hash_sample: ByteArray =
#"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fn advert_address() -> Address {
Address {
payment_credential: Script(advert_script_hash),
stake_credential: None,
}
}
fn supplier_address(pkh: ByteArray) -> Address {
Address {
payment_credential: VerificationKey(pkh),
stake_credential: None,
}
}
fn fresh_active_advert(supplier_pkh: ByteArray, ts: Int) -> AdvertDatum {
AdvertDatum {
supplier_pkh,
capability_id: "llm.text.generate.v1",
model: "qwen2.5:0.5b",
max_output_tokens: 512,
max_processing_ms: 60_000,
price_lovelace: 1_000_000,
supplier_bond_lovelace: 1_000_000,
buyer_bond_lovelace: 1_000_000,
endpoint_url: "https://supplier.example.com/v1",
detail_uri: "ipfs://Qm...",
detail_hash: detail_hash_sample,
advertised_at: ts,
status: Active,
}
}
fn advert_input(ref: OutputReference, datum: AdvertDatum) -> Input {
Input {
output_reference: ref,
output: Output {
address: advert_address(),
value: assets.from_lovelace(2_000_000),
datum: InlineDatum(datum),
reference_script: None,
},
}
}
fn advert_output(datum: AdvertDatum) -> Output {
Output {
address: advert_address(),
value: assets.from_lovelace(2_000_000),
datum: InlineDatum(datum),
reference_script: None,
}
}
fn supplier_output(pkh: ByteArray) -> Output {
Output {
address: supplier_address(pkh),
value: assets.from_lovelace(2_000_000),
datum: NoDatum,
reference_script: None,
}
}
fn placeholder_ref(ix: Int) -> OutputReference {
OutputReference {
transaction_id: #"0000000000000000000000000000000000000000000000000000000000000000",
output_index: ix,
}
}
fn base_tx() -> Transaction {
transaction.placeholder
}
// ─── Happy: PostAdvert (off-chain shape rule) ─────────────────────────
// PostAdvert is enforced off-chain (no spend triggered at creation). This
// test asserts that a freshly constructed advert datum has the spec-required
// shape — Active status, advertised_at populated, supplier_pkh non-empty.
test post_advert_shape_is_well_formed() {
let d = fresh_active_advert(supplier_a, 1_000_000)
d.status == Active && d.advertised_at == 1_000_000 && d.supplier_pkh == supplier_a
}
// ─── Happy: UpdateAdvert ──────────────────────────────────────────────
test update_advert_happy_path() {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum = fresh_active_advert(supplier_a, 2_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
handle_update(old_datum, own_ref, t)
}
test update_advert_active_to_retired_allowed() {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum =
AdvertDatum { ..old_datum, status: Retired, advertised_at: 1_500_000 }
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
handle_update(old_datum, own_ref, t)
}
// ─── Adversarial: UpdateAdvert ────────────────────────────────────────
test update_advert_rejects_different_supplier() fail {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum = fresh_active_advert(supplier_b, 2_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
expect handle_update(old_datum, own_ref, t)
True
}
test update_advert_rejects_unsigned() fail {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let new_datum = fresh_active_advert(supplier_a, 2_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [],
}
expect handle_update(old_datum, own_ref, t)
True
}
test update_advert_rejects_timestamp_regress() fail {
let own_ref = placeholder_ref(0)
let old_datum = fresh_active_advert(supplier_a, 2_000_000)
let new_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
inputs: [advert_input(own_ref, old_datum)],
outputs: [advert_output(new_datum)],
extra_signatories: [supplier_a],
}
expect handle_update(old_datum, own_ref, t)
True
}
// ─── Happy: RetireAdvert ──────────────────────────────────────────────
test retire_advert_happy_path() {
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
outputs: [supplier_output(supplier_a)],
extra_signatories: [supplier_a],
}
handle_retire(old_datum, t)
}
// ─── Adversarial: RetireAdvert ────────────────────────────────────────
test retire_advert_rejects_payment_to_wrong_address() fail {
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
outputs: [supplier_output(supplier_b)],
extra_signatories: [supplier_a],
}
expect handle_retire(old_datum, t)
True
}
test retire_advert_rejects_unsigned() fail {
let old_datum = fresh_active_advert(supplier_a, 1_000_000)
let t =
Transaction {
..base_tx(),
outputs: [supplier_output(supplier_a)],
extra_signatories: [],
}
expect handle_retire(old_datum, t)
True
}

View File

@@ -0,0 +1,735 @@
//// EscrowScript — happy-path bonded escrow.
////
//// ARCHITECTURE.md §4.2 + §4.3. State machine:
////
//// Reclaim (after deliver_by)
//// ┌──────────────────────┐
//// ▼ │
//// ┌───────┐ │
//// │ Open │ ── Claim ──┐ │
//// └───────┘ ▼ │
//// │ ┌─────────┐│
//// │ │ Claimed │┘
//// │ └────┬────┘
//// └────► Reclaim │ Submit
//// (after deliver_by)
//// ▼
//// ┌───────────┐
//// │ Submitted │
//// └─────┬─────┘
//// Accept (buyer) │ Release (after window)
//// ▼ ▼
//// ┌──────────┐ ┌──────────┐
//// │ Accepted │ │ Released │
//// └──────────┘ └──────────┘
////
//// Terminal states: Accepted, Reclaimed, Released. The validator never
//// sees a transition out of a terminal state because there's nothing to
//// re-bind: the funds have left the script address. Each transition rule
//// is implemented as a small pure function below; the spend handler is
//// just a router.
////
//// `advert_ref` is a SPEC-LOCK field — for v1 the validator does not
//// dereference or verify it points at a real Active advert. The TS-side
//// tx builder (M1-B) is responsible for ensuring `advert_ref` is real and
//// pricing matches. We do, however, propagate `advert_ref` through every
//// state transition by requiring the new datum to copy it unchanged. That
//// way the spec-lock is preserved end-to-end on-chain.
use aiken/interval.{Finite, Interval, IntervalBound}
use aiken/primitive/bytearray
use cardano/address.{Address, Script, VerificationKey}
use cardano/assets.{Value}
use cardano/transaction.{
InlineDatum, Input, NoDatum, Output, OutputReference, Transaction, find_input,
}
use marketplace/common.{
accept_window_ms, after_or_at, before_or_at, lovelace_paid_to_pkh, signed_by,
unique_output_to_credential, upper_bound_of, value_equal,
}
use marketplace/types.{
Accept, Claim, Claimed, EscrowDatum, EscrowRedeemer, Open, Reclaim, Release,
Submit, Submitted,
}
validator escrow {
spend(
datum: Option<EscrowDatum>,
redeemer: EscrowRedeemer,
own_ref: OutputReference,
self: Transaction,
) {
expect Some(old_datum) = datum
when redeemer is {
Claim -> handle_claim(old_datum, own_ref, self)
Submit { receipt_hash } -> handle_submit(old_datum, own_ref, receipt_hash, self)
Accept -> handle_accept(old_datum, self)
Reclaim -> handle_reclaim(old_datum, self)
Release -> handle_release(old_datum, self)
}
}
}
// ─── Common: same-script-address rebind ──────────────────────────────
/// Resolve "the same script address as my own input" and locate the unique
/// continuing output. Returns (continuing_output, new_datum_parsed).
fn resolve_continuing(
own_ref: OutputReference,
self: Transaction,
) -> (Output, EscrowDatum) {
expect Some(own_input) = find_input(self.inputs, own_ref)
let own_credential = own_input.output.address.payment_credential
let continuing = unique_output_to_credential(self.outputs, own_credential)
expect new_datum: EscrowDatum = inline_datum_of(continuing)
(continuing, new_datum)
}
fn own_input_value(own_ref: OutputReference, self: Transaction) -> Value {
expect Some(own_input) = find_input(self.inputs, own_ref)
own_input.output.value
}
fn inline_datum_of(output: Output) -> Data {
when output.datum is {
InlineDatum(d) -> d
_ -> fail @"continuing escrow output must carry an inline datum"
}
}
// ─── Claim (Open → Claimed) ──────────────────────────────────────────
fn handle_claim(
old_datum: EscrowDatum,
own_ref: OutputReference,
self: Transaction,
) -> Bool {
let (continuing, new_datum) = resolve_continuing(own_ref, self)
let in_value = own_input_value(own_ref, self)
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let state_ok = old_datum.state == Open
let new_state_ok = new_datum.state == Claimed
let value_ok = value_equal(continuing.value, in_value)
let datum_unchanged_apart_from_state =
expect_unchanged_modulo_state(old_datum, new_datum)
let deadline_ok = before_or_at(self.validity_range, old_datum.deliver_by)
signer_ok && state_ok && new_state_ok && value_ok && datum_unchanged_apart_from_state && deadline_ok
}
// ─── Submit (Claimed → Submitted) ────────────────────────────────────
fn handle_submit(
old_datum: EscrowDatum,
own_ref: OutputReference,
receipt_hash: ByteArray,
self: Transaction,
) -> Bool {
let (continuing, new_datum) = resolve_continuing(own_ref, self)
let in_value = own_input_value(own_ref, self)
// Validity-range upper bound serves as the canonical submission stamp.
expect Some(stamp) = upper_bound_of(self.validity_range)
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let state_ok = old_datum.state == Claimed
let new_state_ok = new_datum.state == Submitted
let value_ok = value_equal(continuing.value, in_value)
let receipt_hash_ok =
new_datum.result_receipt_hash == Some(receipt_hash) && bytearray_len(
receipt_hash,
) == 32
let stamp_ok = new_datum.submitted_at == Some(stamp)
let deadline_ok = before_or_at(self.validity_range, old_datum.deliver_by)
let datum_invariants =
expect_unchanged_modulo_submit(old_datum, new_datum)
signer_ok && state_ok && new_state_ok && value_ok && receipt_hash_ok && stamp_ok && deadline_ok && datum_invariants
}
// ─── Accept (Submitted → Accepted, terminal) ─────────────────────────
fn handle_accept(old_datum: EscrowDatum, self: Transaction) -> Bool {
let signer_ok = signed_by(self.extra_signatories, old_datum.buyer_pkh)
let state_ok = old_datum.state == Submitted
// Distribution rule:
// supplier receives payment + supplier_bond
// buyer receives buyer_bond
let supplier_due = old_datum.payment_lovelace + old_datum.supplier_bond_lovelace
let buyer_due = old_datum.buyer_bond_lovelace
let supplier_received =
lovelace_paid_to_pkh(self.outputs, old_datum.supplier_pkh)
let buyer_received = lovelace_paid_to_pkh(self.outputs, old_datum.buyer_pkh)
let distribution_ok =
supplier_received >= supplier_due && buyer_received >= buyer_due
// Time rule: accept must occur within the window from submitted_at.
expect Some(submitted_at) = old_datum.submitted_at
let deadline = submitted_at + accept_window_ms
let window_ok = before_or_at(self.validity_range, deadline)
signer_ok && state_ok && distribution_ok && window_ok
}
// ─── Reclaim (Open|Claimed → Reclaimed, terminal) ────────────────────
fn handle_reclaim(old_datum: EscrowDatum, self: Transaction) -> Bool {
let signer_ok = signed_by(self.extra_signatories, old_datum.buyer_pkh)
let state_ok = old_datum.state == Open || old_datum.state == Claimed
// After deliver_by: validity-range lower bound must be at or after.
let deadline_ok = after_or_at(self.validity_range, old_datum.deliver_by)
// Distribution: buyer receives payment + buyer_bond + supplier_bond.
let buyer_due =
old_datum.payment_lovelace + old_datum.buyer_bond_lovelace + old_datum.supplier_bond_lovelace
let buyer_received = lovelace_paid_to_pkh(self.outputs, old_datum.buyer_pkh)
let distribution_ok = buyer_received >= buyer_due
signer_ok && state_ok && deadline_ok && distribution_ok
}
// ─── Release (Submitted → Released, terminal) ────────────────────────
fn handle_release(old_datum: EscrowDatum, self: Transaction) -> Bool {
let signer_ok = signed_by(self.extra_signatories, old_datum.supplier_pkh)
let state_ok = old_datum.state == Submitted
// After submitted_at + ACCEPT_WINDOW.
expect Some(submitted_at) = old_datum.submitted_at
let threshold = submitted_at + accept_window_ms
let timing_ok = after_or_at(self.validity_range, threshold)
// Distribution: supplier receives payment + supplier_bond + buyer_bond.
let supplier_due =
old_datum.payment_lovelace + old_datum.supplier_bond_lovelace + old_datum.buyer_bond_lovelace
let supplier_received =
lovelace_paid_to_pkh(self.outputs, old_datum.supplier_pkh)
let distribution_ok = supplier_received >= supplier_due
signer_ok && state_ok && timing_ok && distribution_ok
}
// ─── Datum invariants ────────────────────────────────────────────────
/// On Claim, every field except `state` must be unchanged. This forbids the
/// supplier from rewriting hashes, prices, or the advert spec-lock.
fn expect_unchanged_modulo_state(
old: EscrowDatum,
new: EscrowDatum,
) -> Bool {
new.buyer_pkh == old.buyer_pkh && new.supplier_pkh == old.supplier_pkh && new.advert_ref == old.advert_ref && new.capability_id == old.capability_id && new.request_spec_hash == old.request_spec_hash && new.prompt_hash == old.prompt_hash && new.payment_lovelace == old.payment_lovelace && new.buyer_bond_lovelace == old.buyer_bond_lovelace && new.supplier_bond_lovelace == old.supplier_bond_lovelace && new.deliver_by == old.deliver_by && new.posted_at == old.posted_at && new.submitted_at == old.submitted_at && new.result_receipt_hash == old.result_receipt_hash
}
/// On Submit, every field except `state`, `submitted_at`, and
/// `result_receipt_hash` must be unchanged. The first two of those three are
/// checked separately above; this guards the rest.
fn expect_unchanged_modulo_submit(
old: EscrowDatum,
new: EscrowDatum,
) -> Bool {
new.buyer_pkh == old.buyer_pkh && new.supplier_pkh == old.supplier_pkh && new.advert_ref == old.advert_ref && new.capability_id == old.capability_id && new.request_spec_hash == old.request_spec_hash && new.prompt_hash == old.prompt_hash && new.payment_lovelace == old.payment_lovelace && new.buyer_bond_lovelace == old.buyer_bond_lovelace && new.supplier_bond_lovelace == old.supplier_bond_lovelace && new.deliver_by == old.deliver_by && new.posted_at == old.posted_at
}
fn bytearray_len(b: ByteArray) -> Int {
bytearray.length(b)
}
// ─── Tests ───────────────────────────────────────────────────────────
//
// Test fixtures: buyer/supplier with distinct pkhs, a script hash for the
// escrow address, and a deterministic OutputReference for the advert spec
// lock. Time values use POSIX ms; we pick "deliver_by = 5_000_000_000" as
// a baseline and slot the validity range relative to it.
const buyer_pkh: ByteArray =
#"1111111111111111111111111111111111111111111111111111111111"
const supplier_pkh: ByteArray =
#"2222222222222222222222222222222222222222222222222222222222"
const stranger_pkh: ByteArray =
#"3333333333333333333333333333333333333333333333333333333333"
const escrow_script_hash: ByteArray =
#"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefab"
const advert_tx_hash: ByteArray =
#"baadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaadbaad"
const request_hash: ByteArray =
#"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
const prompt_hash: ByteArray =
#"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
const receipt_hash_32: ByteArray =
#"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
const receipt_hash_short: ByteArray =
#"deadbeef"
const deliver_by_ms: Int = 5_000_000_000
const posted_at_ms: Int = 4_999_000_000
const submitted_at_ms: Int = 4_999_500_000
const payment_amt: Int = 5_000_000
const buyer_bond_amt: Int = 1_000_000
const supplier_bond_amt: Int = 1_000_000
fn locked_value() -> Value {
assets.from_lovelace(payment_amt + buyer_bond_amt + supplier_bond_amt)
}
fn escrow_address() -> Address {
Address {
payment_credential: Script(escrow_script_hash),
stake_credential: None,
}
}
fn pkh_address(pkh: ByteArray) -> Address {
Address {
payment_credential: VerificationKey(pkh),
stake_credential: None,
}
}
fn advert_ref() -> OutputReference {
OutputReference { transaction_id: advert_tx_hash, output_index: 0 }
}
fn placeholder_ref(ix: Int) -> OutputReference {
OutputReference {
transaction_id: #"0000000000000000000000000000000000000000000000000000000000000000",
output_index: ix,
}
}
fn open_datum() -> EscrowDatum {
EscrowDatum {
buyer_pkh,
supplier_pkh,
advert_ref: advert_ref(),
capability_id: "llm.text.generate.v1",
request_spec_hash: request_hash,
prompt_hash,
payment_lovelace: payment_amt,
buyer_bond_lovelace: buyer_bond_amt,
supplier_bond_lovelace: supplier_bond_amt,
deliver_by: deliver_by_ms,
posted_at: posted_at_ms,
submitted_at: None,
result_receipt_hash: None,
state: Open,
}
}
fn claimed_datum() -> EscrowDatum {
EscrowDatum { ..open_datum(), state: Claimed }
}
fn submitted_datum() -> EscrowDatum {
EscrowDatum {
..open_datum(),
submitted_at: Some(submitted_at_ms),
result_receipt_hash: Some(receipt_hash_32),
state: Submitted,
}
}
fn escrow_input(ref: OutputReference, datum: EscrowDatum) -> Input {
Input {
output_reference: ref,
output: Output {
address: escrow_address(),
value: locked_value(),
datum: InlineDatum(datum),
reference_script: None,
},
}
}
fn escrow_output(datum: EscrowDatum) -> Output {
Output {
address: escrow_address(),
value: locked_value(),
datum: InlineDatum(datum),
reference_script: None,
}
}
fn pkh_output(pkh: ByteArray, lovelace: Int) -> Output {
Output {
address: pkh_address(pkh),
value: assets.from_lovelace(lovelace),
datum: NoDatum,
reference_script: None,
}
}
/// Closed-interval validity range [lo, hi].
fn validity_between(lo: Int, hi: Int) -> Interval {
Interval {
lower_bound: IntervalBound { bound_type: Finite(lo), is_inclusive: True },
upper_bound: IntervalBound { bound_type: Finite(hi), is_inclusive: True },
}
}
fn base_tx() -> Transaction {
transaction.placeholder
}
// ─── Happy: Claim ─────────────────────────────────────────────────────
test claim_happy_path() {
let own_ref = placeholder_ref(0)
let old = open_datum()
let new = claimed_datum()
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
deliver_by_ms - 1_000_000,
deliver_by_ms - 500_000,
),
}
handle_claim(old, own_ref, t)
}
// ─── Happy: Submit ────────────────────────────────────────────────────
test submit_happy_path() {
let own_ref = placeholder_ref(0)
let old = claimed_datum()
let stamp = deliver_by_ms - 500_000
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: Some(receipt_hash_32),
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
handle_submit(old, own_ref, receipt_hash_32, t)
}
// ─── Happy: Accept ────────────────────────────────────────────────────
test accept_happy_path() {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
handle_accept(old, t)
}
// ─── Happy: Reclaim from Open ─────────────────────────────────────────
test reclaim_from_open_happy_path() {
let old = open_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(buyer_pkh, payment_amt + buyer_bond_amt + supplier_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(deliver_by_ms, deliver_by_ms + 1_000_000),
}
handle_reclaim(old, t)
}
// ─── Happy: Reclaim from Claimed ──────────────────────────────────────
test reclaim_from_claimed_happy_path() {
let old = claimed_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(buyer_pkh, payment_amt + buyer_bond_amt + supplier_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(deliver_by_ms, deliver_by_ms + 1_000_000),
}
handle_reclaim(old, t)
}
// ─── Happy: Release after window ──────────────────────────────────────
test release_happy_path() {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(
supplier_pkh,
payment_amt + supplier_bond_amt + buyer_bond_amt,
),
],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
submitted_at_ms + accept_window_ms,
submitted_at_ms + accept_window_ms + 1_000_000,
),
}
handle_release(old, t)
}
// ─── Adversarial: Claim ───────────────────────────────────────────────
test claim_rejects_non_supplier() fail {
let own_ref = placeholder_ref(0)
let old = open_datum()
let new = claimed_datum()
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [stranger_pkh],
validity_range: validity_between(
deliver_by_ms - 1_000_000,
deliver_by_ms - 500_000,
),
}
expect handle_claim(old, own_ref, t)
True
}
test claim_rejects_after_deliver_by() fail {
let own_ref = placeholder_ref(0)
let old = open_datum()
let new = claimed_datum()
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
// Upper bound past deliver_by — must reject.
validity_range: validity_between(
deliver_by_ms - 1_000,
deliver_by_ms + 1_000,
),
}
expect handle_claim(old, own_ref, t)
True
}
// ─── Adversarial: Submit ──────────────────────────────────────────────
test submit_rejects_no_receipt_hash() fail {
let own_ref = placeholder_ref(0)
let old = claimed_datum()
let stamp = deliver_by_ms - 500_000
// New datum reaches state Submitted but result_receipt_hash is None.
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: None,
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
// Note: continuing output's datum has receipt = None so the rule
// `new.result_receipt_hash == Some(receipt_hash)` fails.
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
expect handle_submit(old, own_ref, receipt_hash_32, t)
True
}
test submit_rejects_short_receipt_hash() fail {
let own_ref = placeholder_ref(0)
let old = claimed_datum()
let stamp = deliver_by_ms - 500_000
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: Some(receipt_hash_short),
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
expect handle_submit(old, own_ref, receipt_hash_short, t)
True
}
test submit_rejects_double_submit() fail {
let own_ref = placeholder_ref(0)
// Old state is already Submitted — double-submit attempt.
let old = submitted_datum()
let stamp = deliver_by_ms - 500_000
let new =
EscrowDatum {
..old,
submitted_at: Some(stamp),
result_receipt_hash: Some(receipt_hash_32),
state: Submitted,
}
let t =
Transaction {
..base_tx(),
inputs: [escrow_input(own_ref, old)],
outputs: [escrow_output(new)],
extra_signatories: [supplier_pkh],
validity_range: validity_between(deliver_by_ms - 1_000_000, stamp),
}
expect handle_submit(old, own_ref, receipt_hash_32, t)
True
}
// ─── Adversarial: Accept ──────────────────────────────────────────────
test accept_rejects_non_buyer() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
expect handle_accept(old, t)
True
}
test accept_rejects_after_window() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [buyer_pkh],
// Validity range strictly after the window — must reject.
validity_range: validity_between(
submitted_at_ms + accept_window_ms + 1,
submitted_at_ms + accept_window_ms + 1_000,
),
}
expect handle_accept(old, t)
True
}
test accept_rejects_supplier_shorted() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
// Supplier short-paid by 1 lovelace.
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt - 1),
pkh_output(buyer_pkh, buyer_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
expect handle_accept(old, t)
True
}
test accept_rejects_buyer_shorted() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(supplier_pkh, payment_amt + supplier_bond_amt),
// Buyer short-paid by 1 lovelace.
pkh_output(buyer_pkh, buyer_bond_amt - 1),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms,
),
}
expect handle_accept(old, t)
True
}
// ─── Adversarial: Reclaim ─────────────────────────────────────────────
test reclaim_rejects_before_deliver_by() fail {
let old = open_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(buyer_pkh, payment_amt + buyer_bond_amt + supplier_bond_amt),
],
extra_signatories: [buyer_pkh],
validity_range: validity_between(
deliver_by_ms - 1_000_000,
deliver_by_ms - 1,
),
}
expect handle_reclaim(old, t)
True
}
// ─── Adversarial: Release ─────────────────────────────────────────────
test release_rejects_before_window() fail {
let old = submitted_datum()
let t =
Transaction {
..base_tx(),
outputs: [
pkh_output(
supplier_pkh,
payment_amt + supplier_bond_amt + buyer_bond_amt,
),
],
extra_signatories: [supplier_pkh],
validity_range: validity_between(
submitted_at_ms + 1,
submitted_at_ms + accept_window_ms - 1,
),
}
expect handle_release(old, t)
True
}