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:
@@ -168,6 +168,16 @@ export class SqliteCache {
|
|||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark an advertisement row as Retired. Called when the indexer observes
|
||||||
|
* a RetireAdvert spend (advert UTxO spent with no continuing output at
|
||||||
|
* the advert script address). Drops the row out of listActiveAdvertisements
|
||||||
|
* which filters on `status = 'Active'`. */
|
||||||
|
markAdvertisementRetired(utxoRef: string): void {
|
||||||
|
this.db.prepare(
|
||||||
|
`UPDATE advertisements SET status = 'Retired' WHERE utxo_ref = ?`
|
||||||
|
).run(utxoRef);
|
||||||
|
}
|
||||||
|
|
||||||
listActiveAdvertisements(filter?: { capability_id?: string; supplier_pkh?: string }): AdvertRow[] {
|
listActiveAdvertisements(filter?: { capability_id?: string; supplier_pkh?: string }): AdvertRow[] {
|
||||||
let sql = `SELECT * FROM advertisements WHERE status = 'Active' AND rolled_back = 0`;
|
let sql = `SELECT * FROM advertisements WHERE status = 'Active' AND rolled_back = 0`;
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|||||||
@@ -192,6 +192,10 @@ export class ChainSyncWorker extends EventEmitter {
|
|||||||
} catch {
|
} catch {
|
||||||
// already warned by processBlock
|
// already warned by processBlock
|
||||||
}
|
}
|
||||||
|
} else if (event.type === "RetireAdvert") {
|
||||||
|
// The advert UTxO was spent with no continuing output → flip the row's
|
||||||
|
// status to Retired so listActiveAdvertisements drops it.
|
||||||
|
this.cache.markAdvertisementRetired(event.utxoRef);
|
||||||
} else if (event.type === "PostEscrow" || event.type === "ClaimEscrow" || event.type === "SubmitEscrow") {
|
} else if (event.type === "PostEscrow" || event.type === "ClaimEscrow" || event.type === "SubmitEscrow") {
|
||||||
try {
|
try {
|
||||||
const d = decodeEscrowDatum(event.datumHex);
|
const d = decodeEscrowDatum(event.datumHex);
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
|
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
|
||||||
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
|
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
|
||||||
import type { WalletKey, BuildResult } from "../types.js";
|
import type { WalletKey, BuildResult } from "../types.js";
|
||||||
import { TxConstructionError } from "../types.js";
|
import { TxConstructionError } from "../types.js";
|
||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
|
||||||
export interface RetireAdvertParams {
|
export interface RetireAdvertParams {
|
||||||
chain: ChainProvider;
|
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 = {
|
const body = {
|
||||||
type: "retire-advert",
|
type: "retire-advert",
|
||||||
inputs: [advertRef],
|
inputs: [advertRef],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { WalletKey, BuildResult } from "../types.js";
|
|||||||
import { TxConstructionError } from "../types.js";
|
import { TxConstructionError } from "../types.js";
|
||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
import { mockSlotToWallclockMs } from "../internal/constants.js";
|
import { mockSlotToWallclockMs } from "../internal/constants.js";
|
||||||
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
|
||||||
export interface ReclaimParams {
|
export interface ReclaimParams {
|
||||||
chain: ChainProvider;
|
chain: ChainProvider;
|
||||||
@@ -61,8 +63,10 @@ export async function buildReclaimTx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Tip must be at or past deliver_by.
|
// 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 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) {
|
if (tipMs < datum.deliver_by) {
|
||||||
throw new TxConstructionError(
|
throw new TxConstructionError(
|
||||||
"reclaim before deliver_by",
|
"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 =
|
const buyerDue =
|
||||||
datum.payment_lovelace + datum.buyer_bond_lovelace + datum.supplier_bond_lovelace;
|
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 { OutputReference, Utxo } from "../../chain/ChainProvider.js";
|
||||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
import type { EscrowDatum } from "../../cbor/types.js";
|
import type { AdvertDatum, EscrowDatum } from "../../cbor/types.js";
|
||||||
import type { ChatMessage, BuildResult, PostEscrowBuildResult, WalletKey } from "../types.js";
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
BuildResult,
|
||||||
|
PostAdvertBuildResult,
|
||||||
|
PostEscrowBuildResult,
|
||||||
|
WalletKey,
|
||||||
|
} from "../types.js";
|
||||||
import { TxConstructionError } from "../types.js";
|
import { TxConstructionError } from "../types.js";
|
||||||
|
|
||||||
|
import { encodeAdvertDatum } from "../../cbor/AdvertDatum.js";
|
||||||
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
|
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
|
||||||
import { plutusTag } from "../../cbor/plutus-tag.js";
|
import { plutusTag } from "../../cbor/plutus-tag.js";
|
||||||
import { encodePlutus } from "../../cbor/plutus-encoder.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_CLAIM = 121;
|
||||||
const REDEEMER_SUBMIT = 122;
|
const REDEEMER_SUBMIT = 122;
|
||||||
const REDEEMER_ACCEPT = 123;
|
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) ────────────
|
// ─── Param types (legacy stub shape — preserved for compatibility) ────────────
|
||||||
|
|
||||||
@@ -98,6 +112,30 @@ export interface LiveAcceptParams {
|
|||||||
windowEnd: number;
|
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 ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function bytesToHex(bytes: Uint8Array): string {
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
@@ -161,6 +199,36 @@ function readEscrowCompiledCode(): string {
|
|||||||
throw new Error("loadEscrowScript: plutus.json missing escrow validator compiledCode");
|
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
|
/** Build a lucid-shaped UTxO from our chain Utxo, swapping in the live
|
||||||
* blueprint's mainnet-flavoured script address so output validation passes
|
* blueprint's mainnet-flavoured script address so output validation passes
|
||||||
* on Mainnet network even when the test fixture used a testnet header. */
|
* 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 };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"tx:post-advert": "tsx src/cli/post-advert.ts"
|
"tx:post-advert": "tsx src/cli/post-advert.ts",
|
||||||
|
"tx:retire-advert": "tsx src/cli/retire-advert.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marketplace/shared": "workspace:*",
|
"@marketplace/shared": "workspace:*",
|
||||||
|
|||||||
274
supplier/src/cli/retire-advert.ts
Normal file
274
supplier/src/cli/retire-advert.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* retire-advert.ts — CLI entry point for `pnpm --filter @marketplace/supplier tx:retire-advert`.
|
||||||
|
*
|
||||||
|
* Spends an advert UTxO (named via --advert-ref or ADVERT_REF env), returning
|
||||||
|
* the locked bond to the supplier wallet. Validator: contracts/marketplace/
|
||||||
|
* validators/advert.ak handle_retire — requires supplier signature and at
|
||||||
|
* least one output to the supplier's vkh address.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm --filter @marketplace/supplier tx:retire-advert \
|
||||||
|
* --advert-ref <txhash>#<index>
|
||||||
|
*
|
||||||
|
* Required env: SUPPLIER_PRIV_KEY_HEX, NETWORK_ID.
|
||||||
|
* Optional env: OGMIOS_URL (defaults to Vector testnet for NETWORK_ID=0),
|
||||||
|
* ADVERT_REF (alternative to --advert-ref).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import { blake2b } from "@noble/hashes/blake2b";
|
||||||
|
import { bech32 } from "bech32";
|
||||||
|
import {
|
||||||
|
LiveOgmiosProvider,
|
||||||
|
MockChainProvider,
|
||||||
|
type ChainProvider,
|
||||||
|
type OutputReference,
|
||||||
|
} from "@marketplace/shared/chain";
|
||||||
|
import type { WalletKey } from "@marketplace/shared/tx";
|
||||||
|
import { runRetireAdvert } from "./retireAdvertFlow.js";
|
||||||
|
|
||||||
|
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
|
||||||
|
const h = createHash("sha512");
|
||||||
|
for (const m of messages) h.update(m);
|
||||||
|
return new Uint8Array(h.digest());
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NetworkId = 0 | 1;
|
||||||
|
|
||||||
|
export interface CliConfig {
|
||||||
|
ogmiosUrl: string;
|
||||||
|
privKeyHex: string;
|
||||||
|
networkId: NetworkId;
|
||||||
|
advertRef: OutputReference;
|
||||||
|
awaitTimeoutMs: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
useMock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX64_RE = /^[0-9a-f]{64}$/;
|
||||||
|
const ADVERT_REF_RE = /^([0-9a-f]{64})#(\d+)$/;
|
||||||
|
const TESTNET_OGMIOS_DEFAULT = "https://ogmios.vector.testnet.apexfusion.org";
|
||||||
|
const DEFAULT_AWAIT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
const VALUE_FLAGS = new Set<string>([
|
||||||
|
"--ogmios-url",
|
||||||
|
"--priv-key",
|
||||||
|
"--advert-ref",
|
||||||
|
"--await-timeout-ms",
|
||||||
|
]);
|
||||||
|
const BOOL_FLAGS = new Set<string>(["--dry-run", "--mock"]);
|
||||||
|
|
||||||
|
function requireEnv(env: Record<string, string | undefined>, name: string): string {
|
||||||
|
const v = env[name];
|
||||||
|
if (v === undefined || v === null || v === "") {
|
||||||
|
throw new Error(`missing required env var: ${name}`);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntStrict(name: string, raw: string): number {
|
||||||
|
if (!/^-?\d+$/.test(raw)) {
|
||||||
|
throw new Error(`${name} must be an integer (got: ${raw})`);
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
||||||
|
throw new Error(`${name} must be an integer (got: ${raw})`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdvertRef(name: string, raw: string): OutputReference {
|
||||||
|
const m = ADVERT_REF_RE.exec(raw);
|
||||||
|
if (!m) {
|
||||||
|
throw new Error(
|
||||||
|
`${name} must be <64-hex-txhash>#<index> (got: ${raw})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { txHash: m[1], index: Number(m[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawFlags {
|
||||||
|
ogmiosUrl?: string;
|
||||||
|
privKey?: string;
|
||||||
|
advertRef?: string;
|
||||||
|
awaitTimeoutMs?: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
useMock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawArgv(argv: string[]): RawFlags {
|
||||||
|
const out: RawFlags = { dryRun: false, useMock: false };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const tok = argv[i];
|
||||||
|
if (BOOL_FLAGS.has(tok)) {
|
||||||
|
if (tok === "--dry-run") out.dryRun = true;
|
||||||
|
else if (tok === "--mock") out.useMock = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (VALUE_FLAGS.has(tok)) {
|
||||||
|
const val = argv[i + 1];
|
||||||
|
if (val === undefined) throw new Error(`flag ${tok} requires a value`);
|
||||||
|
switch (tok) {
|
||||||
|
case "--ogmios-url": out.ogmiosUrl = val; break;
|
||||||
|
case "--priv-key": out.privKey = val; break;
|
||||||
|
case "--advert-ref": out.advertRef = val; break;
|
||||||
|
case "--await-timeout-ms": out.awaitTimeoutMs = val; break;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`unknown flag: ${tok}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCliArgs(
|
||||||
|
argv: string[],
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
): CliConfig {
|
||||||
|
const flags = parseRawArgv(argv);
|
||||||
|
|
||||||
|
const networkIdRaw = requireEnv(env, "NETWORK_ID");
|
||||||
|
if (networkIdRaw !== "0" && networkIdRaw !== "1") {
|
||||||
|
throw new Error(`NETWORK_ID must be "0" or "1", got: ${networkIdRaw}`);
|
||||||
|
}
|
||||||
|
const networkId: NetworkId = networkIdRaw === "1" ? 1 : 0;
|
||||||
|
|
||||||
|
const privKeyHex = flags.privKey ?? requireEnv(env, "SUPPLIER_PRIV_KEY_HEX");
|
||||||
|
if (!HEX64_RE.test(privKeyHex)) {
|
||||||
|
throw new Error("SUPPLIER_PRIV_KEY_HEX (or --priv-key) must be 64 lowercase hex chars");
|
||||||
|
}
|
||||||
|
|
||||||
|
let ogmiosUrl: string;
|
||||||
|
if (flags.ogmiosUrl !== undefined) {
|
||||||
|
ogmiosUrl = flags.ogmiosUrl;
|
||||||
|
} else if (env.OGMIOS_URL !== undefined && env.OGMIOS_URL !== "") {
|
||||||
|
ogmiosUrl = env.OGMIOS_URL;
|
||||||
|
} else if (networkId === 0) {
|
||||||
|
ogmiosUrl = TESTNET_OGMIOS_DEFAULT;
|
||||||
|
} else {
|
||||||
|
throw new Error("OGMIOS_URL is required when NETWORK_ID=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const advertRefRaw = flags.advertRef ?? env.ADVERT_REF;
|
||||||
|
if (advertRefRaw === undefined || advertRefRaw === "") {
|
||||||
|
throw new Error(
|
||||||
|
"advert ref required: pass --advert-ref <txhash>#<index> or set ADVERT_REF",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const advertRef = parseAdvertRef("--advert-ref", advertRefRaw);
|
||||||
|
|
||||||
|
const awaitTimeoutMs =
|
||||||
|
flags.awaitTimeoutMs === undefined
|
||||||
|
? DEFAULT_AWAIT_TIMEOUT_MS
|
||||||
|
: parseIntStrict("--await-timeout-ms", flags.awaitTimeoutMs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ogmiosUrl,
|
||||||
|
privKeyHex,
|
||||||
|
networkId,
|
||||||
|
advertRef,
|
||||||
|
awaitTimeoutMs,
|
||||||
|
dryRun: flags.dryRun,
|
||||||
|
useMock: flags.useMock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(b: Uint8Array): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveWalletKey(privHex: string, networkId: NetworkId): WalletKey {
|
||||||
|
const priv = hexToBytes(privHex);
|
||||||
|
const pub = ed.getPublicKey(priv);
|
||||||
|
const pubHex = bytesToHex(pub);
|
||||||
|
const pkh = blake2b(pub, { dkLen: 28 });
|
||||||
|
const pkhHex = bytesToHex(pkh);
|
||||||
|
const header = networkId === 0 ? 0x60 : 0x61;
|
||||||
|
const payload = new Uint8Array(29);
|
||||||
|
payload[0] = header;
|
||||||
|
payload.set(pkh, 1);
|
||||||
|
const words = bech32.toWords(payload);
|
||||||
|
const hrp = networkId === 0 ? "addr_test" : "addr";
|
||||||
|
const addr = bech32.encode(hrp, words, 1023);
|
||||||
|
return {
|
||||||
|
pubKeyHash: pkhHex,
|
||||||
|
pubKeyHex: pubHex,
|
||||||
|
privateKeyHex: privHex,
|
||||||
|
address: addr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main(
|
||||||
|
argv: string[],
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
): Promise<number> {
|
||||||
|
let cfg: CliConfig;
|
||||||
|
try {
|
||||||
|
cfg = parseCliArgs(argv, env);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
process.stderr.write(`error: ${msg}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let walletKey: WalletKey;
|
||||||
|
try {
|
||||||
|
walletKey = deriveWalletKey(cfg.privKeyHex, cfg.networkId);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
process.stderr.write(`error: wallet derivation failed: ${msg}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain: ChainProvider = cfg.useMock
|
||||||
|
? new MockChainProvider()
|
||||||
|
: new LiveOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
|
||||||
|
|
||||||
|
if (cfg.dryRun) {
|
||||||
|
process.stdout.write(
|
||||||
|
`dry-run: would retire advert ${cfg.advertRef.txHash}#${cfg.advertRef.index} ` +
|
||||||
|
`as supplier ${walletKey.pubKeyHash} (${walletKey.address})\n`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runRetireAdvert({
|
||||||
|
chain,
|
||||||
|
walletKey,
|
||||||
|
advertRef: cfg.advertRef,
|
||||||
|
awaitTimeoutMs: cfg.awaitTimeoutMs,
|
||||||
|
log: (line) => process.stderr.write(`${line}\n`),
|
||||||
|
});
|
||||||
|
process.stdout.write(`${result.formattedRef}\n`);
|
||||||
|
return 0;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
process.stderr.write(`error: ${msg}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1]?.endsWith("retire-advert.ts") ||
|
||||||
|
process.argv[1]?.endsWith("retire-advert.js")
|
||||||
|
) {
|
||||||
|
main(process.argv.slice(2), process.env)
|
||||||
|
.then((code) => process.exit(code))
|
||||||
|
.catch((err) => {
|
||||||
|
process.stderr.write(`retire-advert: fatal: ${(err as Error).message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
64
supplier/src/cli/retireAdvertFlow.ts
Normal file
64
supplier/src/cli/retireAdvertFlow.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* retireAdvertFlow.ts — pure logic layer for the `tx:retire-advert` CLI.
|
||||||
|
*
|
||||||
|
* Spends the advert UTxO and returns its bond to the supplier wallet.
|
||||||
|
* Validator only checks signer + at-least-one-output-to-supplier (see
|
||||||
|
* contracts/marketplace/validators/advert.ak handle_retire).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
|
||||||
|
import { buildRetireAdvertTx, type WalletKey } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
export interface RetireAdvertFlowParams {
|
||||||
|
chain: ChainProvider;
|
||||||
|
walletKey: WalletKey;
|
||||||
|
advertRef: OutputReference;
|
||||||
|
/** Milliseconds to wait for on-chain confirmation. Default: 120_000. */
|
||||||
|
awaitTimeoutMs?: number;
|
||||||
|
log?: (line: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetireAdvertFlowResult {
|
||||||
|
/** Hex-encoded transaction hash (64 chars). */
|
||||||
|
txHash: string;
|
||||||
|
/** Operator-paste string: "<txHash>". */
|
||||||
|
formattedRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRetireAdvert(
|
||||||
|
params: RetireAdvertFlowParams,
|
||||||
|
): Promise<RetireAdvertFlowResult> {
|
||||||
|
const { chain, walletKey, advertRef } = params;
|
||||||
|
const writeLog =
|
||||||
|
params.log ??
|
||||||
|
((line: string) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(line);
|
||||||
|
});
|
||||||
|
const timeoutMs = params.awaitTimeoutMs ?? 120_000;
|
||||||
|
|
||||||
|
writeLog(`retiring advert ${advertRef.txHash}#${advertRef.index}`);
|
||||||
|
|
||||||
|
const built = await buildRetireAdvertTx({ chain, walletKey, advertRef });
|
||||||
|
|
||||||
|
writeLog(`submitted tx ${built.expectedTxHash}`);
|
||||||
|
writeLog("awaiting confirmation");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chain.awaitTx(built.expectedTxHash, timeoutMs);
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
reason.includes(built.expectedTxHash)
|
||||||
|
? reason
|
||||||
|
: `awaitTx failed for txHash ${built.expectedTxHash}: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeLog("confirmed");
|
||||||
|
|
||||||
|
return {
|
||||||
|
txHash: built.expectedTxHash,
|
||||||
|
formattedRef: built.expectedTxHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user