shared: opt-in CIP-33 reference-scripts for script-spending txs

When ESCROW_REF_UTXO / ADVERT_REF_UTXO env vars are set (format
<txhash>#<idx>), Claim/Submit/Accept/Reclaim/RetireAdvert call
.readFrom([refUtxo]) instead of inlining the validator CBOR via
.attach.SpendingValidator(). Lucid-evolution 0.4.30 auto-extracts
utxo.scriptRef into the script-witness map (dist/index.js:1903–1913),
so no separate attach call is needed. Default (env unset) is unchanged.

Adds buildLiveTxForPublishReferenceScripts (a one-shot tx that
publishes both validators as scriptRef-bearing UTxOs at a caller-
supplied address — typically the all-zero-pkh enterprise burn address)
and the supplier tx:publish-reference-scripts CLI that wraps it.

Targeted at testnet trial; mainnet rollout is a separate flip after
verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-05-14 15:25:59 +02:00
parent 15693c8532
commit 5cae0c4f6c
7 changed files with 351 additions and 18 deletions

View File

@@ -10,6 +10,14 @@ export type { Signer } from "./signer.js";
export { MockSigner } from "./signer.js";
export type { Blueprint } from "./blueprint.js";
export { loadBlueprint } from "./blueprint.js";
// Script-bytes loaders (used by the publish-reference-scripts CLI).
export { loadEscrowScript, loadAdvertScript } from "./internal/liveCbor.js";
export { pkhToEnterpriseAddress } from "./internal/pkhAddress.js";
export type {
LivePublishRefScriptsParams,
PublishRefScriptsBuildResult,
} from "./internal/publishReferenceScripts.js";
export { buildLiveTxForPublishReferenceScripts } from "./internal/publishReferenceScripts.js";
// Advert builders
export type { PostAdvertParams } from "./advert/postAdvert.js";

View File

@@ -20,7 +20,7 @@ import { readFileSync } from "fs";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import type { UTxO as LucidUTxO, Script } from "@lucid-evolution/lucid";
import type { LucidEvolution, UTxO as LucidUTxO, Script } from "@lucid-evolution/lucid";
import type { OutputReference, Utxo } from "../../chain/ChainProvider.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
@@ -167,8 +167,10 @@ function buildLucidProvider(chain: LiveOgmiosProvider): OgmiosLucidProvider {
});
}
/** Load the escrow spending validator script bytes from the blueprint. */
function loadEscrowScript(): { script: Script; address: string } {
/** Load the escrow spending validator script bytes from the blueprint.
* Exported so the publish-reference-scripts CLI can publish the same bytes
* under a CIP-33 scriptRef without duplicating plutus.json reading. */
export function loadEscrowScript(): { script: Script; address: string } {
const blueprint = loadBlueprint();
// Read plutus.json directly to grab compiledCode (the blueprint loader only
// exposes the hash). We piggyback on the same path resolution.
@@ -199,8 +201,9 @@ 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 } {
/** Load the advert spending validator script bytes from the blueprint.
* Exported for the same reason as loadEscrowScript above. */
export function loadAdvertScript(): { script: Script; address: string } {
const blueprint = loadBlueprint();
const compiled = readAdvertCompiledCode();
const script: Script = { type: "PlutusV3", script: compiled };
@@ -229,6 +232,48 @@ function readAdvertCompiledCode(): string {
throw new Error("loadAdvertScript: plutus.json missing advert validator compiledCode");
}
/** Parse a `<txhash>#<idx>` env var into an OutRef. Returns null if unset. */
function parseRefUtxoEnv(envKey: string): { txHash: string; outputIndex: number } | null {
const v = process.env[envKey];
if (!v) return null;
const [txHash, idxStr] = v.split("#");
if (!txHash || !idxStr) {
throw new Error(`${envKey} must be <txhash>#<idx>, got: ${v}`);
}
const outputIndex = Number(idxStr);
if (!Number.isInteger(outputIndex) || outputIndex < 0) {
throw new Error(`${envKey} index must be a non-negative integer, got: ${idxStr}`);
}
return { txHash, outputIndex };
}
/** Resolve script-witness mode for a spending tx.
* - If `<envKey>` is set, fetch the referenced UTxO and use CIP-33 reference-script mode.
* The caller threads the UTxO into `tx.readFrom([refUtxo])`; lucid-evolution 0.4.30
* auto-extracts `refUtxo.scriptRef` and registers it as a witness
* (dist/index.js:1903–1913). No `attach.SpendingValidator` call is needed.
* - Otherwise, fall back to the inlined script (current behavior). */
async function resolveSpendCtx(
lucid: LucidEvolution,
envKey: "ESCROW_REF_UTXO" | "ADVERT_REF_UTXO",
inlineScript: Script,
): Promise<{ kind: "ref"; refUtxo: LucidUTxO } | { kind: "inline"; script: Script }> {
const refSpec = parseRefUtxoEnv(envKey);
if (!refSpec) return { kind: "inline", script: inlineScript };
const [refUtxo] = await lucid.utxosByOutRef([refSpec]);
if (!refUtxo) {
throw new Error(
`${envKey} ${refSpec.txHash}#${refSpec.outputIndex} not found on chain`,
);
}
if (!refUtxo.scriptRef) {
throw new Error(
`${envKey} ${refSpec.txHash}#${refSpec.outputIndex} has no scriptRef attached`,
);
}
return { kind: "ref", refUtxo };
}
/** 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. */
@@ -375,6 +420,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
}, { usePresetProtocolParameters: true });
const { script, address: escrowAddress } = loadEscrowScript();
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
const datumHex = encodeEscrowDatum(opts.newDatum);
const redeemerHex = opts.redeemerHex;
const lucidInput = chainUtxoToLucidScriptInput(opts.escrowUtxo, escrowAddress);
@@ -403,9 +449,11 @@ async function buildSpendOpenOrClaimedTx(opts: {
let signed;
try {
const txBuilder = lucid
.newTx()
.attach.SpendingValidator(script)
const txBuilder = (
spendCtx.kind === "ref"
? lucid.newTx().readFrom([spendCtx.refUtxo])
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
)
.collectFrom([lucidInput], redeemerHex)
.pay.ToAddressWithData(
escrowAddress,
@@ -525,6 +573,7 @@ export async function buildLiveTxForAccept(params: LiveAcceptParams): Promise<Bu
}, { usePresetProtocolParameters: true });
const { script, address: escrowAddress } = loadEscrowScript();
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
const acceptRedeemerHex = encodeNullaryConstrHex(REDEEMER_ACCEPT);
@@ -548,9 +597,11 @@ export async function buildLiveTxForAccept(params: LiveAcceptParams): Promise<Bu
let signed;
try {
const txBuilder = lucid
.newTx()
.attach.SpendingValidator(script)
const txBuilder = (
spendCtx.kind === "ref"
? lucid.newTx().readFrom([spendCtx.refUtxo])
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
)
.collectFrom([lucidInput], acceptRedeemerHex)
.pay.ToAddress(supplierAddress, { lovelace: supplierLovelace })
.pay.ToAddress(buyerAddress, { lovelace: buyerLovelace })
@@ -594,6 +645,7 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
}, { usePresetProtocolParameters: true });
const { script, address: escrowAddress } = loadEscrowScript();
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
const reclaimRedeemerHex = encodeNullaryConstrHex(REDEEMER_RECLAIM);
@@ -610,9 +662,11 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
let signed;
try {
const txBuilder = lucid
.newTx()
.attach.SpendingValidator(script)
const txBuilder = (
spendCtx.kind === "ref"
? lucid.newTx().readFrom([spendCtx.refUtxo])
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
)
.collectFrom([lucidInput], reclaimRedeemerHex)
.pay.ToAddress(buyerAddress, { lovelace: buyerLovelace })
.addSignerKey(params.buyerKey.pubKeyHash)
@@ -717,6 +771,7 @@ export async function buildLiveTxForRetireAdvert(
}, { usePresetProtocolParameters: true });
const { script, address: advertAddress } = loadAdvertScript();
const spendCtx = await resolveSpendCtx(lucid, "ADVERT_REF_UTXO", script);
const lucidInput = chainUtxoToLucidScriptInput(advertUtxo, advertAddress);
const retireRedeemerHex = encodeNullaryConstrHex(REDEEMER_RETIRE_ADVERT);
@@ -732,9 +787,11 @@ export async function buildLiveTxForRetireAdvert(
let signed;
try {
const nowMs = Date.now();
const txBuilder = lucid
.newTx()
.attach.SpendingValidator(script)
const txBuilder = (
spendCtx.kind === "ref"
? lucid.newTx().readFrom([spendCtx.refUtxo])
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
)
.collectFrom([lucidInput], retireRedeemerHex)
.pay.ToAddress(supplierAddress, { lovelace: refundLovelace })
.addSignerKey(walletKey.pubKeyHash)

View File

@@ -0,0 +1,107 @@
/**
* publishReferenceScripts.ts — one-shot tx builder that publishes the escrow
* and advert validator scripts as CIP-33 reference UTxOs.
*
* The two outputs are paid to a caller-supplied address (typically a known
* burn address such as the all-zero-pkh enterprise address) with the script
* bytes attached as `scriptRef`. Subsequent script-spending txs reference
* these UTxOs via env (ESCROW_REF_UTXO, ADVERT_REF_UTXO) and skip inlining
* the script bytes — saving ~5 KB per script-spending tx.
*
* One-shot operational tool. Invoke via supplier/src/cli/publish-reference-scripts.ts.
*/
import type { Script } from "@lucid-evolution/lucid";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
import type { BuildResult, WalletKey } from "../types.js";
import { TxConstructionError } from "../types.js";
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
import { createLucidContext } from "./lucidContext.js";
export interface LivePublishRefScriptsParams {
chain: LiveOgmiosProvider;
walletKey: WalletKey;
/** Address to publish the two reference-script UTxOs to. Typically a known
* burn address (cryptographically unspendable) so refs cannot be spent. */
burnAddr: string;
escrowScript: Script;
advertScript: Script;
/** Lovelace per output. Default 30_000_000n (30 AP3X) — covers min-UTxO for
* a ~5 KB script (4310 lovelace/byte × 5_140 bytes ≈ 22.2 AP3X) with headroom. */
lovelacePerOutput?: bigint;
}
export interface PublishRefScriptsBuildResult extends BuildResult {
/** Output index 0 — escrow script reference. */
escrowRef: { txHash: string; outputIndex: number };
/** Output index 1 — advert script reference. */
advertRef: { txHash: string; outputIndex: number };
formattedEscrowRef: string;
formattedAdvertRef: string;
}
const DEFAULT_LOVELACE_PER_OUTPUT = 30_000_000n;
export async function buildLiveTxForPublishReferenceScripts(
params: LivePublishRefScriptsParams,
): Promise<PublishRefScriptsBuildResult> {
const { chain, walletKey, burnAddr, escrowScript, advertScript } = params;
const lovelacePerOutput = params.lovelacePerOutput ?? DEFAULT_LOVELACE_PER_OUTPUT;
const provider = new OgmiosLucidProvider({
ogmiosUrl: chain.url,
fetch: chain.fetchImpl,
});
const { lucid } = await createLucidContext(provider, walletKey, {
networkId: 1,
systemStartUnix: 0,
slotLengthMs: 1000,
}, { usePresetProtocolParameters: true });
const realWalletUtxos = await lucid.wallet().getUtxos();
let signed;
try {
const txBuilder = lucid
.newTx()
// Output 0: escrow script reference.
.pay.ToAddressWithData(
burnAddr,
undefined,
{ lovelace: lovelacePerOutput },
escrowScript,
)
// Output 1: advert script reference.
.pay.ToAddressWithData(
burnAddr,
undefined,
{ lovelace: lovelacePerOutput },
advertScript,
)
.addSignerKey(walletKey.pubKeyHash)
.setMinFee(500_000n);
const completed = await txBuilder.complete({
presetWalletInputs: realWalletUtxos,
localUPLCEval: false,
});
signed = await completed.sign.withWallet().complete();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new TxConstructionError(`publish-reference-scripts build failed: ${msg}`);
}
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await chain.submitTx(txCborHex);
return {
txCborHex,
expectedTxHash,
escrowRef: { txHash: expectedTxHash, outputIndex: 0 },
advertRef: { txHash: expectedTxHash, outputIndex: 1 },
formattedEscrowRef: `${expectedTxHash}#0`,
formattedAdvertRef: `${expectedTxHash}#1`,
};
}