shared: live-CBOR for deferred builders (Reclaim, RetireAdvert)
Closes 2/4 builders from the M1-F-D-defer list (postAdvert already live in
7ea7328; updateAdvert and release remain).
Reclaim:
- buildLiveTxForReclaim in liveCbor.ts (mirrors buildLiveTxForAccept):
spends the escrow UTxO with REDEEMER_RECLAIM (Constr3), pays
payment + buyer_bond + supplier_bond to buyer, delegates UPLC eval
to Ogmios via localUPLCEval:false. Validity range = tipMs ± 60s.
- reclaim.ts: replace the unconditional mockSlotToWallclockMs tipMs
computation with `isLive ? Date.now() : mockSlotToWallclockMs(tipSlot)`,
matching accept.ts:77. Adds the live-CBOR dispatch branch.
Funds-at-risk before this fix: with the broken tipMs path, every live
Reclaim was rejected client-side ("tip < deliver_by" using a Nov-1970
tip). If a supplier never Claimed, buyer's 6-7 AP3X stayed locked
indefinitely. Verified recovery of 4 stuck escrows on Vector L2 testnet
(txs f6660e52, 769f16fd, 79970d86, 9f4db14b — 14 AP3X total recovered).
RetireAdvert:
- buildLiveTxForRetireAdvert in liveCbor.ts: spends advert UTxO with
REDEEMER_RETIRE_ADVERT (Constr2), advert script address.
- retireAdvert.ts: live-CBOR dispatch.
- supplier CLI: retire-advert.ts + retireAdvertFlow.ts.
- supplier/package.json: tx:retire-advert script.
Indexer:
- follower/blockProcessor + worker: handle RetireAdvert events,
transition advert row state to Retired.
- db/cache.ts: markAdvertTerminal helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,12 @@
|
||||
*/
|
||||
|
||||
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
|
||||
import type { WalletKey, BuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
|
||||
export interface RetireAdvertParams {
|
||||
chain: ChainProvider;
|
||||
@@ -49,6 +51,20 @@ export async function buildRetireAdvertTx(
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Live path: build a real Plutus V3 spend via lucid-evolution.
|
||||
// Mock path falls through to the synthetic JSON-in-hex body below.
|
||||
if (detectCborBackend(chain) === "live") {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForRetireAdvert } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForRetireAdvert({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
walletKey,
|
||||
advertRef,
|
||||
advertUtxo: utxo,
|
||||
datum,
|
||||
});
|
||||
}
|
||||
|
||||
const body = {
|
||||
type: "retire-advert",
|
||||
inputs: [advertRef],
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { WalletKey, BuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { mockSlotToWallclockMs } from "../internal/constants.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
|
||||
export interface ReclaimParams {
|
||||
chain: ChainProvider;
|
||||
@@ -61,8 +63,10 @@ export async function buildReclaimTx(
|
||||
}
|
||||
|
||||
// 3. Tip must be at or past deliver_by.
|
||||
// Live backend uses real POSIX ms; mock backend keeps slot*1000.
|
||||
const tipSlot = await chain.tip();
|
||||
const tipMs = mockSlotToWallclockMs(tipSlot);
|
||||
const isLive = detectCborBackend(chain) === "live";
|
||||
const tipMs = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
|
||||
if (tipMs < datum.deliver_by) {
|
||||
throw new TxConstructionError(
|
||||
"reclaim before deliver_by",
|
||||
@@ -70,6 +74,21 @@ export async function buildReclaimTx(
|
||||
);
|
||||
}
|
||||
|
||||
// Live path: real Cardano CBOR via lucid-evolution.
|
||||
if (isLive) {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForReclaim } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForReclaim({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
buyerKey,
|
||||
escrowRef,
|
||||
escrowUtxo: utxo,
|
||||
datum,
|
||||
tipMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock path: synthetic testTxBody (preserves existing test behaviour).
|
||||
const buyerDue =
|
||||
datum.payment_lovelace + datum.buyer_bond_lovelace + datum.supplier_bond_lovelace;
|
||||
|
||||
|
||||
@@ -24,10 +24,17 @@ import type { UTxO as LucidUTxO, Script } from "@lucid-evolution/lucid";
|
||||
|
||||
import type { OutputReference, Utxo } from "../../chain/ChainProvider.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
import type { EscrowDatum } from "../../cbor/types.js";
|
||||
import type { ChatMessage, BuildResult, PostEscrowBuildResult, WalletKey } from "../types.js";
|
||||
import type { AdvertDatum, EscrowDatum } from "../../cbor/types.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
BuildResult,
|
||||
PostAdvertBuildResult,
|
||||
PostEscrowBuildResult,
|
||||
WalletKey,
|
||||
} from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
|
||||
import { encodeAdvertDatum } from "../../cbor/AdvertDatum.js";
|
||||
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
|
||||
import { plutusTag } from "../../cbor/plutus-tag.js";
|
||||
import { encodePlutus } from "../../cbor/plutus-encoder.js";
|
||||
@@ -53,6 +60,13 @@ const MIN_UTXO_LOVELACE = 2_000_000n;
|
||||
const REDEEMER_CLAIM = 121;
|
||||
const REDEEMER_SUBMIT = 122;
|
||||
const REDEEMER_ACCEPT = 123;
|
||||
const REDEEMER_RECLAIM = 124;
|
||||
|
||||
// AdvertRedeemer Constr indices per lib/marketplace/types.ak:
|
||||
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
|
||||
// UpdateAdvert → Constr1 (tag 122, nullary)
|
||||
// RetireAdvert → Constr2 (tag 123, nullary)
|
||||
const REDEEMER_RETIRE_ADVERT = 123;
|
||||
|
||||
// ─── Param types (legacy stub shape — preserved for compatibility) ────────────
|
||||
|
||||
@@ -98,6 +112,30 @@ export interface LiveAcceptParams {
|
||||
windowEnd: number;
|
||||
}
|
||||
|
||||
export interface LiveReclaimParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
buyerKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
escrowUtxo: Utxo;
|
||||
datum: EscrowDatum;
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LivePostAdvertParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
advertDatum: AdvertDatum;
|
||||
deposit_lovelace: bigint;
|
||||
}
|
||||
|
||||
export interface LiveRetireAdvertParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
advertRef: OutputReference;
|
||||
advertUtxo: Utxo;
|
||||
datum: AdvertDatum;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
@@ -161,6 +199,36 @@ function readEscrowCompiledCode(): string {
|
||||
throw new Error("loadEscrowScript: plutus.json missing escrow validator compiledCode");
|
||||
}
|
||||
|
||||
/** Load the advert spending validator script bytes from the blueprint. */
|
||||
function loadAdvertScript(): { script: Script; address: string } {
|
||||
const blueprint = loadBlueprint();
|
||||
const compiled = readAdvertCompiledCode();
|
||||
const script: Script = { type: "PlutusV3", script: compiled };
|
||||
return { script, address: blueprint.advertScriptAddress(MAINNET_ID) };
|
||||
}
|
||||
|
||||
let cachedAdvertCompiledCode: string | undefined;
|
||||
function readAdvertCompiledCode(): string {
|
||||
if (cachedAdvertCompiledCode) return cachedAdvertCompiledCode;
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = resolve(
|
||||
here,
|
||||
"..", "..", "..", "..", "..",
|
||||
"contracts", "marketplace", "plutus.json",
|
||||
);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
validators: Array<{ title: string; compiledCode?: string }>;
|
||||
};
|
||||
for (const v of parsed.validators) {
|
||||
if (typeof v.title === "string" && v.title.startsWith("advert.") && typeof v.compiledCode === "string") {
|
||||
cachedAdvertCompiledCode = v.compiledCode;
|
||||
return cachedAdvertCompiledCode;
|
||||
}
|
||||
}
|
||||
throw new Error("loadAdvertScript: plutus.json missing advert validator compiledCode");
|
||||
}
|
||||
|
||||
/** Build a lucid-shaped UTxO from our chain Utxo, swapping in the live
|
||||
* blueprint's mainnet-flavoured script address so output validation passes
|
||||
* on Mainnet network even when the test fixture used a testnet header. */
|
||||
@@ -514,3 +582,182 @@ export async function buildLiveTxForAccept(params: LiveAcceptParams): Promise<Bu
|
||||
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── Reclaim ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(params.chain);
|
||||
const { lucid } = await createLucidContext(provider, params.buyerKey, {
|
||||
networkId: 1,
|
||||
systemStartUnix: 0,
|
||||
slotLengthMs: 1000,
|
||||
}, { usePresetProtocolParameters: true });
|
||||
|
||||
const { script, address: escrowAddress } = loadEscrowScript();
|
||||
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
|
||||
const reclaimRedeemerHex = encodeNullaryConstrHex(REDEEMER_RECLAIM);
|
||||
|
||||
// Reclaim returns everything to the buyer: payment + buyer_bond + supplier_bond.
|
||||
const buyerDue =
|
||||
params.datum.payment_lovelace +
|
||||
params.datum.buyer_bond_lovelace +
|
||||
params.datum.supplier_bond_lovelace;
|
||||
const buyerLovelace = buyerDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : buyerDue;
|
||||
const buyerAddress = pkhToEnterpriseAddress(params.datum.buyer_pkh, MAINNET_ID);
|
||||
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
const presetWalletInputs = realWalletUtxos;
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = lucid
|
||||
.newTx()
|
||||
.attach.SpendingValidator(script)
|
||||
.collectFrom([lucidInput], reclaimRedeemerHex)
|
||||
.pay.ToAddress(buyerAddress, { lovelace: buyerLovelace })
|
||||
.addSignerKey(params.buyerKey.pubKeyHash)
|
||||
// Validity range mirrors Accept's pattern: a narrow window centred on
|
||||
// tipMs. The on-chain validator only checks `lower_bound >= deliver_by`,
|
||||
// which holds as long as `tipMs - 60s >= deliver_by` — the caller has
|
||||
// already verified tipMs >= deliver_by, so a 60s back-pad is safe.
|
||||
.validFrom(params.tipMs - 60_000)
|
||||
.validTo(params.tipMs + 60_000)
|
||||
.setMinFee(500_000n);
|
||||
|
||||
// Same UPLC eval delegation as Accept — lucid's CML evaluator falsely
|
||||
// rejects valid Plutus V3 spends; route to Ogmios.
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "reclaim build failed");
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await params.chain.submitTx(txCborHex);
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── PostAdvert ──────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForAdvert(
|
||||
params: LivePostAdvertParams,
|
||||
): Promise<PostAdvertBuildResult> {
|
||||
const { chain, walletKey, advertDatum, deposit_lovelace } = params;
|
||||
|
||||
const provider = buildLucidProvider(chain);
|
||||
const { lucid } = await createLucidContext(provider, walletKey, {
|
||||
networkId: 1,
|
||||
systemStartUnix: 0,
|
||||
slotLengthMs: 1000,
|
||||
}, { usePresetProtocolParameters: true });
|
||||
|
||||
const blueprint = loadBlueprint();
|
||||
const advertAddress = blueprint.advertScriptAddress(MAINNET_ID);
|
||||
const datumHex = encodeAdvertDatum(advertDatum);
|
||||
|
||||
// Pad to protocol min-utxo if the supplier bond is below floor — same
|
||||
// pattern as PostEscrow. The validator only checks ≥ supplier_bond_lovelace,
|
||||
// so excess ada in the output is fine.
|
||||
const lockedLovelace =
|
||||
deposit_lovelace < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : deposit_lovelace;
|
||||
|
||||
// postAdvert.ts validates advertised_at is within ±5 min of chain tip;
|
||||
// a centred 5-min window absorbs the build+submit gap. lucid's slot
|
||||
// rounding-up means we still subtract 60s from validFrom like PostEscrow.
|
||||
const advertisedAt = advertDatum.advertised_at;
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = lucid
|
||||
.newTx()
|
||||
.pay.ToAddressWithData(
|
||||
advertAddress,
|
||||
{ kind: "inline", value: datumHex },
|
||||
{ lovelace: lockedLovelace },
|
||||
)
|
||||
.addSignerKey(walletKey.pubKeyHash)
|
||||
.validFrom(advertisedAt - 60_000)
|
||||
.validTo(advertisedAt + 5 * 60_000)
|
||||
.setMinFee(500_000n);
|
||||
|
||||
const completed = await txBuilder.complete();
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "post-advert build failed");
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
advertOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── RetireAdvert ────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForRetireAdvert(
|
||||
params: LiveRetireAdvertParams,
|
||||
): Promise<BuildResult> {
|
||||
const { chain, walletKey, advertUtxo, datum } = params;
|
||||
|
||||
const provider = buildLucidProvider(chain);
|
||||
const { lucid } = await createLucidContext(provider, walletKey, {
|
||||
networkId: 1,
|
||||
systemStartUnix: 0,
|
||||
slotLengthMs: 1000,
|
||||
}, { usePresetProtocolParameters: true });
|
||||
|
||||
const { script, address: advertAddress } = loadAdvertScript();
|
||||
const lucidInput = chainUtxoToLucidScriptInput(advertUtxo, advertAddress);
|
||||
const retireRedeemerHex = encodeNullaryConstrHex(REDEEMER_RETIRE_ADVERT);
|
||||
|
||||
// Validator only requires: (1) signed by datum.supplier_pkh and (2) at
|
||||
// least one output to that pkh's vkh address. Pay the input lovelace
|
||||
// (less fee, handled by lucid coin-selection) back to the supplier.
|
||||
const supplierAddress = pkhToEnterpriseAddress(datum.supplier_pkh, MAINNET_ID);
|
||||
const refundLovelace =
|
||||
advertUtxo.lovelace < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : advertUtxo.lovelace;
|
||||
|
||||
const presetWalletInputs = await lucid.wallet().getUtxos();
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const nowMs = Date.now();
|
||||
const txBuilder = lucid
|
||||
.newTx()
|
||||
.attach.SpendingValidator(script)
|
||||
.collectFrom([lucidInput], retireRedeemerHex)
|
||||
.pay.ToAddress(supplierAddress, { lovelace: refundLovelace })
|
||||
.addSignerKey(walletKey.pubKeyHash)
|
||||
// Validator has no time check, but a sane window absorbs build+submit
|
||||
// propagation lag the same as sibling builders.
|
||||
.validFrom(nowMs - 60_000)
|
||||
.validTo(nowMs + 5 * 60_000)
|
||||
.setMinFee(500_000n);
|
||||
|
||||
// Same UPLC-eval delegation as Claim/Submit/Accept — see
|
||||
// buildSpendOpenOrClaimedTx for the lucid 0.4.30 false-rejection rationale.
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "retire-advert build failed");
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user