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:
4
contracts/advert/README.md
Normal file
4
contracts/advert/README.md
Normal 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).
|
||||
4
contracts/escrow/README.md
Normal file
4
contracts/escrow/README.md
Normal 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.
|
||||
18
contracts/marketplace/.github/workflows/continuous-integration.yml
vendored
Normal file
18
contracts/marketplace/.github/workflows/continuous-integration.yml
vendored
Normal 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
6
contracts/marketplace/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Aiken compilation artifacts
|
||||
artifacts/
|
||||
# Aiken's project working directory
|
||||
build/
|
||||
# Aiken's default documentation export
|
||||
docs/
|
||||
8
contracts/marketplace/README.md
Normal file
8
contracts/marketplace/README.md
Normal 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.
|
||||
15
contracts/marketplace/aiken.lock
Normal file
15
contracts/marketplace/aiken.lock
Normal 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]
|
||||
18
contracts/marketplace/aiken.toml
Normal file
18
contracts/marketplace/aiken.toml
Normal 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]
|
||||
202
contracts/marketplace/lib/marketplace/common.ak
Normal file
202
contracts/marketplace/lib/marketplace/common.ak
Normal 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
|
||||
}
|
||||
127
contracts/marketplace/lib/marketplace/types.ak
Normal file
127
contracts/marketplace/lib/marketplace/types.ak
Normal 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
|
||||
}
|
||||
404
contracts/marketplace/plutus.json
Normal file
404
contracts/marketplace/plutus.json
Normal file
File diff suppressed because one or more lines are too long
336
contracts/marketplace/validators/advert.ak
Normal file
336
contracts/marketplace/validators/advert.ak
Normal 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
|
||||
}
|
||||
735
contracts/marketplace/validators/escrow.ak
Normal file
735
contracts/marketplace/validators/escrow.ak
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user