From 59dd3cb57645979e623ad346789aec7a5773a048 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:34:36 +0200 Subject: [PATCH] feat(builder): keyless build core - multi-output, deploy, interact --- packages/builder/src/vector/build.ts | 91 +++++++++++++++ packages/builder/test/build-contracts.test.ts | 106 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 packages/builder/test/build-contracts.test.ts diff --git a/packages/builder/src/vector/build.ts b/packages/builder/src/vector/build.ts index 9d1ee0b..bba4799 100644 --- a/packages/builder/src/vector/build.ts +++ b/packages/builder/src/vector/build.ts @@ -94,3 +94,94 @@ export async function buildSendTokens( }); return toBuildResult(await tx.complete()); } + +export async function buildMultiOutput( + lucid: LucidEvolution, + p: { outputs: TxOutput[]; metadataJson?: string }, +): Promise { + if (!p.outputs || p.outputs.length === 0) { + throw new Error('At least one output is required'); + } + for (const o of p.outputs) requireValidRecipient(o.address); + let tx = lucid.newTx(); + for (const output of p.outputs) { + const assets: Record = { lovelace: BigInt(output.lovelace) }; + if (output.assets) { + for (const [unit, qty] of Object.entries(output.assets)) assets[unit] = BigInt(qty); + } + tx = tx.pay.ToAddress(output.address, assets); + } + if (p.metadataJson) { + tx = tx.attachMetadata(674, JSON.parse(p.metadataJson)); + } + return toBuildResult(await tx.complete()); +} + +export async function buildDeployContract( + lucid: LucidEvolution, + p: { scriptCbor: string; scriptType: 'PlutusV1' | 'PlutusV2' | 'PlutusV3'; initialDatum?: string; lovelaceAmount?: number }, +): Promise { + const validator: SpendingValidator = { + type: p.scriptType, + script: applyDoubleCborEncoding(p.scriptCbor), + }; + const scriptAddress = validatorToAddress('Mainnet', validator); + const scriptHash = validatorToScriptHash(validator); + const datum = p.initialDatum || Data.void(); + const lovelace = BigInt(p.lovelaceAmount ?? 2_000_000); + const tx = lucid.newTx() + .pay.ToAddressWithData(scriptAddress, { kind: 'inline', value: datum }, { lovelace }); + return { ...toBuildResult(await tx.complete()), scriptAddress, scriptHash, scriptType: p.scriptType }; +} + +export async function buildInteractContract( + lucid: LucidEvolution, + p: { + scriptCbor: string; scriptType: 'PlutusV1' | 'PlutusV2' | 'PlutusV3'; action: 'lock' | 'spend'; + changeAddress: string; redeemer?: string; datum?: string; lovelaceAmount?: number; + utxoRef?: { txHash: string; outputIndex: number }; assets?: Record; + }, +): Promise { + const validator: SpendingValidator = { + type: p.scriptType, + script: applyDoubleCborEncoding(p.scriptCbor), + }; + const scriptAddress = validatorToAddress('Mainnet', validator); + + if (p.action === 'lock') { + const datumData = p.datum || Data.void(); + const outputAssets: Record = { lovelace: BigInt(p.lovelaceAmount ?? 2_000_000) }; + if (p.assets) { + for (const [unit, qty] of Object.entries(p.assets)) outputAssets[unit] = BigInt(qty); + } + const tx = lucid.newTx() + .pay.ToAddressWithData(scriptAddress, { kind: 'inline', value: datumData }, outputAssets); + return { ...toBuildResult(await tx.complete()), scriptAddress, action: 'lock' }; + } + + // SPEND: collect from the script back to the wallet + const scriptUtxos = p.utxoRef + ? await lucid.utxosByOutRef([p.utxoRef]) + : await lucid.utxosAt(scriptAddress); + if (!scriptUtxos || scriptUtxos.length === 0) { + throw new Error(`No UTxOs found at script address ${scriptAddress}`); + } + const redeemerData = p.redeemer || Data.void(); + let completed; + try { + completed = await lucid.newTx() + .collectFrom(scriptUtxos, redeemerData) + .attach.SpendingValidator(validator) + .addSigner(p.changeAddress) + .complete(); + } catch { + // Retry without the native UPLC evaluator — falls back to the provider's + // evaluateTx (network); mirrors the pre-split behaviour for chain quirks. + completed = await lucid.newTx() + .collectFrom(scriptUtxos, redeemerData) + .attach.SpendingValidator(validator) + .addSigner(p.changeAddress) + .complete({ localUPLCEval: false }); + } + return { ...toBuildResult(completed), scriptAddress, action: 'spend' }; +} diff --git a/packages/builder/test/build-contracts.test.ts b/packages/builder/test/build-contracts.test.ts new file mode 100644 index 0000000..8c53602 --- /dev/null +++ b/packages/builder/test/build-contracts.test.ts @@ -0,0 +1,106 @@ +// packages/builder/test/build-contracts.test.ts +import { describe, test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { CML, applyDoubleCborEncoding, validatorToAddress } from '@lucid-evolution/lucid'; +import type { LucidEvolution, SpendingValidator, UTxO } from '@lucid-evolution/lucid'; +import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS, FIXTURE_TXHASH } from './fixtures/fixture-provider.ts'; +import { lucidForAddress, buildMultiOutput, buildDeployContract, buildInteractContract } from '../src/vector/build.ts'; + +const ALWAYS_SUCCEEDS_V2 = '49480100002221200101'; +const validator: SpendingValidator = { type: 'PlutusV2', script: applyDoubleCborEncoding(ALWAYS_SUCCEEDS_V2) }; +const SCRIPT_ADDRESS = validatorToAddress('Mainnet', validator); + +function outputsOf(cborHex: string): Array<{ address: string; lovelace: bigint }> { + const outs = CML.Transaction.from_cbor_hex(cborHex).body().outputs(); + const result = []; + for (let i = 0; i < outs.len(); i++) { + const o = outs.get(i); + result.push({ address: o.address().to_bech32(), lovelace: BigInt(o.amount().coin().toString()) }); + } + return result; +} + +let lucid: LucidEvolution; +before(async () => { + lucid = await lucidForAddress(new FixtureProvider(FIXTURE_UTXOS), OWN_ADDRESS); +}); + +describe('buildMultiOutput', () => { + test('pays every output and conserves value', async () => { + const r = await buildMultiOutput(lucid, { + outputs: [ + { address: FOREIGN_ADDRESS, lovelace: 2_000_000 }, + { address: FOREIGN_ADDRESS, lovelace: 3_000_000 }, + ], + }); + const outs = outputsOf(r.txCbor); + const foreign = outs.filter((o) => o.address === FOREIGN_ADDRESS); + assert.equal(foreign.length, 2); + assert.deepEqual(foreign.map((o) => o.lovelace).sort(), [2_000_000n, 3_000_000n]); + }); + + test('rejects an empty output list', async () => { + await assert.rejects(() => buildMultiOutput(lucid, { outputs: [] }), /At least one output/); + }); +}); + +describe('buildDeployContract', () => { + test('locks funds at the derived script address with an inline datum', async () => { + const r = await buildDeployContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', lovelaceAmount: 2_000_000, + }); + assert.equal(r.scriptAddress, SCRIPT_ADDRESS); + const outs = outputsOf(r.txCbor); + const toScript = outs.find((o) => o.address === SCRIPT_ADDRESS); + assert.ok(toScript, 'no output to script address'); + assert.equal(toScript.lovelace, 2_000_000n); + // unsigned, as always + const vkeys = CML.Transaction.from_cbor_hex(r.txCbor).witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0); + }); +}); + +describe('buildInteractContract', () => { + test('lock: sends funds to the script address', async () => { + const r = await buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'lock', + changeAddress: OWN_ADDRESS, lovelaceAmount: 2_000_000, + }); + assert.equal(r.action, 'lock'); + const toScript = outputsOf(r.txCbor).find((o) => o.address === SCRIPT_ADDRESS); + assert.ok(toScript && toScript.lovelace === 2_000_000n); + }); + + test('spend: collects a script UTxO back to the wallet (offline, native eval)', async () => { + const scriptUtxo: UTxO = { + txHash: FIXTURE_TXHASH, outputIndex: 7, address: SCRIPT_ADDRESS, + assets: { lovelace: 2_000_000n }, datum: 'd87980', + }; + const withScript = new FixtureProvider([...FIXTURE_UTXOS, scriptUtxo]); + const l = await lucidForAddress(withScript, OWN_ADDRESS); + const r = await buildInteractContract(l, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + }); + assert.equal(r.action, 'spend'); + const tx = CML.Transaction.from_cbor_hex(r.txCbor); + // the script input must be among the consumed inputs + const inputs = tx.body().inputs(); + let consumedScriptInput = false; + for (let i = 0; i < inputs.len(); i++) { + const inp = inputs.get(i); + if (inp.transaction_id().to_hex() === FIXTURE_TXHASH && Number(inp.index()) === 7) consumedScriptInput = true; + } + assert.ok(consumedScriptInput, 'script UTxO was not consumed'); + }); + + test('spend: refuses when the script address holds nothing', async () => { + await assert.rejects( + () => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + }), + /No UTxOs found at script address/, + ); + }); +});