feat(builder): keyless build core - multi-output, deploy, interact
This commit is contained in:
@@ -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<VectorUnsignedBuildResult> {
|
||||
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<string, bigint> = { 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<VectorBuildDeployResult> {
|
||||
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<string, string>;
|
||||
},
|
||||
): Promise<VectorBuildInteractResult> {
|
||||
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<string, bigint> = { 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' };
|
||||
}
|
||||
|
||||
106
packages/builder/test/build-contracts.test.ts
Normal file
106
packages/builder/test/build-contracts.test.ts
Normal file
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user