Files
agents-marketplace/docs/buyer/lib/escrow_tx.py
jelisaveta-talbot cbab81efbd 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)
2026-05-06 13:27:48 +02:00

198 lines
6.4 KiB
Python

"""
Build, evaluate, and submit script transactions for escrow state transitions.
"""
import json
import os
import httpx
from pycardano import (
Redeemer,
Transaction,
TransactionBuilder,
TransactionOutput,
VerificationKeyWitness,
)
from pycardano.plutus import ExecutionUnits, RawPlutusData
async def build_and_submit_script_tx(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
task_id, step_name, validity_start_slot=None,
extra_outputs=None,
) -> str:
await context.async_protocol_param()
change_address = signer._wallet.payment_address
try:
tx_hash = await _build_direct(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, extra_outputs,
)
print(f"[{step_name}] TX submitted (direct): {tx_hash}")
return tx_hash
except ValueError as e:
if "collateral" not in str(e).lower():
raise
print(f"[{step_name}] Collateral check failed, falling back to Ogmios evaluate...")
tx_hash = await _build_with_ogmios_evaluate(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, step_name, extra_outputs,
)
print(f"[{step_name}] TX submitted (ogmios): {tx_hash}")
return tx_hash
def _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=None, skip_estimate=False,
extra_outputs=None,
):
builder = TransactionBuilder(context)
builder.add_input_address(signer._wallet.payment_address)
builder.fee_buffer = 10_000
if skip_estimate:
builder._should_estimate_execution_units = False
redeemer = Redeemer(RawPlutusData(redeemer_obj))
if ex_units:
redeemer.ex_units = ex_units
builder.add_script_input(utxo, script=script, datum=None, redeemer=redeemer)
if output_datum_obj is not None and output_value > 0:
builder.add_output(
TransactionOutput(script_address, output_value, datum=RawPlutusData(output_datum_obj))
)
# Add extra outputs (e.g. supplier payment in Accept TX)
if extra_outputs:
for out in extra_outputs:
builder.add_output(out)
builder.required_signers = [signer._wallet.payment_verification_key.hash()]
if validity_start_slot is not None:
builder.validity_start = validity_start_slot
return builder
def _sign_tx(signer, tx_body, witness_set):
sig = signer._wallet.payment_signing_key.sign(tx_body.hash())
vk_witness = VerificationKeyWitness(
signer._wallet.payment_verification_key, sig
)
witness_set.vkey_witnesses = [vk_witness]
return Transaction(tx_body, witness_set)
async def _build_direct(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, extra_outputs=None,
):
builder = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, extra_outputs=extra_outputs,
)
tx_body = builder.build(change_address=change_address)
witness_set = builder.build_witness_set(True)
tx = _sign_tx(signer, tx_body, witness_set)
tx_cbor = tx.to_cbor()
if isinstance(tx_cbor, bytes):
tx_cbor = tx_cbor.hex()
await context.async_submit_tx_cbor(tx_cbor)
return str(tx.id)
async def _build_with_ogmios_evaluate(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
change_address, validity_start_slot, step_name, extra_outputs=None,
):
ogmios_url = os.getenv("VECTOR_OGMIOS_URL")
if not ogmios_url:
raise RuntimeError("VECTOR_OGMIOS_URL not set")
dummy_ex_units = ExecutionUnits(500_000, 200_000_000)
builder = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=dummy_ex_units, skip_estimate=True,
extra_outputs=extra_outputs,
)
orig_set = builder._set_collateral_return
def _patched(*a, **k):
try:
return orig_set(*a, **k)
except ValueError:
return
builder._set_collateral_return = _patched
tx_body = builder.build(change_address=change_address)
witness_set = builder.build_witness_set(True)
dummy_tx = _sign_tx(signer, tx_body, witness_set)
tx_cbor = dummy_tx.to_cbor()
if isinstance(tx_cbor, bytes):
tx_cbor = tx_cbor.hex()
evaluate_payload = {
"jsonrpc": "2.0",
"method": "evaluateTransaction",
"params": {"transaction": {"cbor": tx_cbor}},
"id": 1,
}
async with httpx.AsyncClient() as client:
resp = await client.post(ogmios_url, json=evaluate_payload, timeout=30)
result = resp.json()
if "error" in result:
err_data = result["error"]
traces = ""
if isinstance(err_data.get("data"), list):
for item in err_data["data"]:
if "error" in item and "data" in item["error"]:
traces = str(item["error"]["data"].get("traces", ""))
raise RuntimeError(
f"[{step_name}] Validator failed: {err_data.get('message', '')} traces={traces}"
)
budget = result["result"][0]["budget"]
real_mem = int(budget["memory"] * 1.2)
real_cpu = int(budget["cpu"] * 1.2)
print(f"[{step_name}] Ogmios ex_units: mem={real_mem}, cpu={real_cpu}")
builder2 = _make_builder(
context, signer, utxo, script, script_address,
redeemer_obj, output_value, output_datum_obj,
validity_start_slot, ex_units=ExecutionUnits(real_mem, real_cpu),
skip_estimate=True, extra_outputs=extra_outputs,
)
orig_set2 = builder2._set_collateral_return
def _patched2(*a, **k):
try:
return orig_set2(*a, **k)
except ValueError:
return
builder2._set_collateral_return = _patched2
tx_body2 = builder2.build(change_address=change_address)
witness_set2 = builder2.build_witness_set(True)
real_tx = _sign_tx(signer, tx_body2, witness_set2)
real_cbor = real_tx.to_cbor()
if isinstance(real_cbor, bytes):
real_cbor = real_cbor.hex()
await context.async_submit_tx_cbor(real_cbor)
return str(real_tx.id)