From c8ce776ec1bc8b1763dd0ccd7491510fd83483cf Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:06:33 +0200 Subject: [PATCH] fix(builder): harden keyless contract paths - action guard, utxoRef address check, ?? datums, eval-order and signer pins --- packages/builder/src/vector/build.ts | 65 +++++++++-------- packages/builder/test/build-contracts.test.ts | 71 ++++++++++++++++++- 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/packages/builder/src/vector/build.ts b/packages/builder/src/vector/build.ts index bba4799..0446096 100644 --- a/packages/builder/src/vector/build.ts +++ b/packages/builder/src/vector/build.ts @@ -127,7 +127,7 @@ export async function buildDeployContract( }; const scriptAddress = validatorToAddress('Mainnet', validator); const scriptHash = validatorToScriptHash(validator); - const datum = p.initialDatum || Data.void(); + 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 }); @@ -149,7 +149,8 @@ export async function buildInteractContract( const scriptAddress = validatorToAddress('Mainnet', validator); if (p.action === 'lock') { - const datumData = p.datum || Data.void(); + // '' must NOT silently become void - ?? lets a caller bug fail loudly downstream + 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); @@ -157,31 +158,39 @@ export async function buildInteractContract( const tx = lucid.newTx() .pay.ToAddressWithData(scriptAddress, { kind: 'inline', value: datumData }, outputAssets); return { ...toBuildResult(await tx.complete()), scriptAddress, action: 'lock' }; + } else if (p.action === 'spend') { + // SPEND: collect from the script back to the wallet. + // no utxoRef: sweep ALL UTxOs at the script address - old tool's documented + // semantic ("otherwise spends all UTxOs at script address"), preserved here + // for behavior parity; this is not a gap, do not "fix" it. + 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}`); + } + if (p.utxoRef && scriptUtxos.some((u) => u.address !== scriptAddress)) { + throw new Error(`UTxO ${p.utxoRef.txHash}#${p.utxoRef.outputIndex} is not 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' }; + } else { + throw new Error(`Invalid action: ${String(p.action)} - expected 'lock' or 'spend'`); } - - // 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 index 8c53602..e41a579 100644 --- a/packages/builder/test/build-contracts.test.ts +++ b/packages/builder/test/build-contracts.test.ts @@ -1,7 +1,7 @@ // 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 { CML, applyDoubleCborEncoding, validatorToAddress, validatorToScriptHash, getAddressDetails } 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'; @@ -42,6 +42,13 @@ describe('buildMultiOutput', () => { test('rejects an empty output list', async () => { await assert.rejects(() => buildMultiOutput(lucid, { outputs: [] }), /At least one output/); }); + + test('rejects an invalid recipient address among the outputs', async () => { + await assert.rejects( + () => buildMultiOutput(lucid, { outputs: [{ address: 'addr1garbage', lovelace: 2_000_000 }] }), + /Invalid recipient address/, + ); + }); }); describe('buildDeployContract', () => { @@ -50,6 +57,7 @@ describe('buildDeployContract', () => { scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', lovelaceAmount: 2_000_000, }); assert.equal(r.scriptAddress, SCRIPT_ADDRESS); + assert.equal(r.scriptHash, validatorToScriptHash(validator)); const outs = outputsOf(r.txCbor); const toScript = outs.find((o) => o.address === SCRIPT_ADDRESS); assert.ok(toScript, 'no output to script address'); @@ -71,6 +79,26 @@ describe('buildInteractContract', () => { assert.ok(toScript && toScript.lovelace === 2_000_000n); }); + test('lock: rejects an empty-string datum instead of silently treating it as void', async () => { + // '' is falsy but caller-supplied; ?? must let it flow through and fail + // loudly downstream rather than defaulting to Data.void() (locking funds + // under a datum the caller never asked for). + await assert.rejects(() => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'lock', + changeAddress: OWN_ADDRESS, lovelaceAmount: 2_000_000, datum: '', + })); + }); + + test('rejects an invalid action', async () => { + await assert.rejects( + () => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'steal' as any, + changeAddress: OWN_ADDRESS, lovelaceAmount: 2_000_000, + }), + /Invalid action/, + ); + }); + 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, @@ -92,6 +120,36 @@ describe('buildInteractContract', () => { if (inp.transaction_id().to_hex() === FIXTURE_TXHASH && Number(inp.index()) === 7) consumedScriptInput = true; } assert.ok(consumedScriptInput, 'script UTxO was not consumed'); + // addSigner(changeAddress) must land in the tx body as a required signer, + // keyed by the wallet's payment key hash (not the script, not some other key). + const requiredSigners = tx.body().required_signers(); + assert.ok(requiredSigners && requiredSigners.len() > 0, 'no required_signers on the built tx'); + const signerHashes: string[] = []; + for (let i = 0; i < requiredSigners!.len(); i++) signerHashes.push(requiredSigners!.get(i).to_hex()); + const expectedHash = getAddressDetails(OWN_ADDRESS).paymentCredential?.hash; + assert.ok(expectedHash, 'OWN_ADDRESS has no payment credential to compare against'); + assert.deepEqual(signerHashes, [expectedHash]); + }); + + test('spend: succeeds via native UPLC eval without ever calling the provider fallback', 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]); + let evalCalls = 0; + (withScript as any).evaluateTx = async () => { + evalCalls++; + throw new Error('provider evaluateTx should not be reached for a natively-evaluable script'); + }; + const l = await lucidForAddress(withScript, OWN_ADDRESS); + await buildInteractContract(l, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + }); + // pins the ordering: native eval must succeed on its own; the catch-block + // fallback to the provider's evaluateTx must stay unreached here. + assert.equal(evalCalls, 0, 'native UPLC evaluator should have succeeded without a provider fallback'); }); test('spend: refuses when the script address holds nothing', async () => { @@ -103,4 +161,15 @@ describe('buildInteractContract', () => { /No UTxOs found at script address/, ); }); + + test('spend: refuses a utxoRef that does not point at the script address', async () => { + await assert.rejects( + () => buildInteractContract(lucid, { + scriptCbor: ALWAYS_SUCCEEDS_V2, scriptType: 'PlutusV2', action: 'spend', + changeAddress: OWN_ADDRESS, redeemer: 'd87980', + utxoRef: { txHash: FIXTURE_TXHASH, outputIndex: 0 }, // a real wallet UTxO, not at SCRIPT_ADDRESS + }), + /not at script address/, + ); + }); });