From 6875100e6ff7e4cf604ca9b5678499ccc170571a Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:13:39 +0200 Subject: [PATCH] fix(builder): registry owner ops preserve input value; deregister floor handled explicitly - correct the validator-semantics diagnosis --- packages/builder/src/vector/registry-build.ts | 75 +++++++++------ packages/builder/test/registry-build.test.ts | 96 ++++++++++++++++++- 2 files changed, 141 insertions(+), 30 deletions(-) diff --git a/packages/builder/src/vector/registry-build.ts b/packages/builder/src/vector/registry-build.ts index e55b18d..7b081bd 100644 --- a/packages/builder/src/vector/registry-build.ts +++ b/packages/builder/src/vector/registry-build.ts @@ -206,10 +206,14 @@ export async function buildUpdateAgent( if (p.endpoint !== undefined) validateEndpoint(p.endpoint); if (p.capabilities !== undefined) validateCapabilities(p.capabilities); const vkeyHash = paymentKeyHashOf(p.changeAddress); - const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId); + const { profile, utxo } = await resolveAgentUtxo(provider, p.agentId); verifyOwnership(profile, vkeyHash); + // Owner is written from profile.ownerVkeyHash, not the caller's vkeyHash: + // makes owner-immutability on this path structural (the datum literally + // cannot carry a different owner out of buildUpdateAgent) rather than + // depending solely on the verifyOwnership() call two lines up. const newDatum = buildAgentDatum( - vkeyHash, + profile.ownerVkeyHash, p.name ?? profile.name, p.description ?? profile.description, p.capabilities ?? profile.capabilities, @@ -218,10 +222,16 @@ export async function buildUpdateAgent( profile.registeredAt, ); const spendRedeemer = Data.to(new Constr(0, [])); // Update + // The continuing output must carry the spent input's value EXACTLY + // (utxo.assets - lovelace AND the NFT), not a hardcoded + // { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n } - the deployed validator + // enforces value preservation on Update (differential-tested; see + // task-3-report.md's correction section). Hardcoding the floor here was + // the same latent defect the old custodial code had. const completed = await lucid.newTx() .collectFrom([utxo], spendRedeemer) .attach.SpendingValidator(registryScript()) - .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets) .addSigner(p.changeAddress) .complete(); const updatedFields: string[] = []; @@ -248,17 +258,20 @@ export async function buildTransferAgent( } catch (e) { throw new Error(`Invalid new owner address: ${e instanceof Error ? e.message : String(e)} The on-chain contract rejects script credentials as owners.`); } - const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId); + const { profile, utxo } = await resolveAgentUtxo(provider, p.agentId); verifyOwnership(profile, vkeyHash); const newDatum = buildAgentDatum( newOwnerHash, profile.name, profile.description, profile.capabilities, profile.framework, profile.endpoint, profile.registeredAt, ); const spendRedeemer = Data.to(new Constr(0, [])); // Update (transfer uses the Update redeemer) + // Value-preserving output, same reasoning as buildUpdateAgent above: the + // deployed validator requires the continuing output to carry the spent + // input's value exactly, not a hardcoded deposit-floor amount. const completed = await lucid.newTx() .collectFrom([utxo], spendRedeemer) .attach.SpendingValidator(registryScript()) - .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets) .addSigner(p.changeAddress) .complete(); return { @@ -277,36 +290,42 @@ export async function buildDeregisterAgent( verifyOwnership(profile, vkeyHash); const spendRedeemer = Data.to(new Constr(1, [])); // Deregister const mintRedeemer = Data.to(new Constr(1, [])); // Burn - let txBuilder = lucid.newTx() - .collectFrom([utxo], spendRedeemer) - .attach.SpendingValidator(registryScript()); - // The registry UTxO alone carries exactly MIN_AP3X_DEPOSIT and this build - // pays no explicit output (the deposit returns as ordinary change), so - // Lucid's automatic coin selection can conclude no further wallet input is - // needed to cover the fee. Empirically (see task-3-report.md) that exact - // shape - a single script input, a Burn mint redeemer, zero other inputs - - // makes Lucid's LOCAL UPLC evaluator crash even though the draft - // transaction is byte-for-byte correct otherwise; Lucid runs that local - // evaluation during .complete() BEFORE collateral is selected/applied - // (Anastasia-Labs/lucid-evolution#361 documents this same evaluation- - // before-collateral ordering as a source of bugs elsewhere in the - // library). Explicitly feeding in a second, plain pure-AP3X wallet UTxO - // sidesteps it; verified with both a small and a large pure-AP3X UTxO, so - // this isn't a fixture-specific fluke. + // CORRECTED root cause (see task-3-report.md's correction section for the + // full account - this replaces an earlier, wrong "Lucid library bug" + // diagnosis). The DEPLOYED registry validator (REGISTRY_SCRIPT_CBOR / + // REGISTRY_POLICY_ID above) does NOT match the in-workspace Aiken source + // under agent-infrastructure/contracts/agent-registry/ - that source + // compiles to policy 5dd51189..., a different script entirely (confirmed + // by hashing both; provenance worth resolving before the governance + // family trusts that source as ground truth). Differential-tested + // directly against the deployed bytecode instead: deregister enforces a + // floor on the owner's returned change - a sole-input deregister of a + // registry UTxO holding exactly MIN_AP3X_DEPOSIT fails, the identical + // build against a 25 AP3X registry UTxO succeeds unaided. Feeding in a + // second, plain pure-AP3X wallet UTxO - the LARGEST one available, so the + // returned change clears the floor with margin rather than by a hair - + // satisfies it for the common exactly-the-floor case. const walletUtxos = await lucid.wallet().getUtxos(); - const feeUtxo = walletUtxos.find((u) => { - const keys = Object.keys(u.assets); - return keys.length === 1 && keys[0] === 'lovelace'; - }); - if (feeUtxo) txBuilder = txBuilder.collectFrom([feeUtxo]); - const completed = await txBuilder + const feeUtxo = walletUtxos + .filter((u) => { + const keys = Object.keys(u.assets); + return keys.length === 1 && keys[0] === 'lovelace'; + }) + .sort((a, b) => Number(b.assets.lovelace - a.assets.lovelace))[0]; + if (!feeUtxo) { + throw new Error("Deregistration needs a plain AP3X-only UTxO in the wallet to clear the validator's returned-change floor; send yourself a small AP3X-only payment first."); + } + const completed = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .collectFrom([feeUtxo]) .mintAssets({ [nftUnit]: -1n }, mintRedeemer) .attach.MintingPolicy(registryScript()) .addSigner(p.changeAddress) .complete(); return { ...toBuildResult(completed), - agentId: p.agentId, op: 'deregister', agentName: profile.name, detail: MIN_AP3X_DEPOSIT.toString(), + agentId: p.agentId, op: 'deregister', agentName: profile.name, detail: String(utxo.assets.lovelace), }; } diff --git a/packages/builder/test/registry-build.test.ts b/packages/builder/test/registry-build.test.ts index acbd7fa..cc969eb 100644 --- a/packages/builder/test/registry-build.test.ts +++ b/packages/builder/test/registry-build.test.ts @@ -4,13 +4,15 @@ import { CML, getAddressDetails, Data, Constr, toText, fromText, credentialToAddress, } from '@lucid-evolution/lucid'; import type { LucidEvolution } from '@lucid-evolution/lucid'; -import { FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS } from './fixtures/fixture-provider.ts'; +import { + FixtureProvider, FIXTURE_UTXOS, OWN_ADDRESS, FOREIGN_ADDRESS, FIXTURE_TOKEN_UNIT, +} from './fixtures/fixture-provider.ts'; import { lucidForAddress } from '../src/vector/build.ts'; import { REGISTRY_POLICY_ID, MIN_AP3X_DEPOSIT, AGENT_MESSAGE_LABEL, getRegistryAddress, parseDid, buildAgentDatum, parseAgentDatum, resolveAgentUtxo, buildRegisterAgent, buildMessageAgent, - buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, + buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, registryScript, } from '../src/vector/registry-build.ts'; // The REAL registry validator evaluates during .complete() (native UPLC, offline). @@ -345,3 +347,93 @@ describe('registry owner ops (real validator, offline)', () => { assert.ok(tx.body().mint(), 'no mint (burn) field on the deregister tx'); }); }); + +// Correction round (see task-3-report.md): differential testing against the +// REAL deployed validator (not the in-workspace Aiken source, which turned +// out to compile to an unrelated policy - see the report) found the deployed +// script enforces value-preservation on Update/Transfer and a returned- +// change floor on Deregister. These tests pin that corrected understanding. +describe('registry owner ops - value preservation & deregister floor (correction, real validator)', () => { + function overfundedRegistryUtxo(lovelace: bigint) { + return { + txHash: 'ce'.repeat(32), outputIndex: 0, + address: getRegistryAddress(), + assets: { lovelace, [AGENT_UNIT]: 1n }, + datum: buildAgentDatum(OWN_VKEY_HASH, 'FixtureAgent', 'offline fixture agent', ['testing'], 'custom', '', 1750000000000), + }; + } + + test('update preserves the FULL input value on an over-funded registry UTxO, not just the deposit floor', async () => { + const overfunded = overfundedRegistryUtxo(25_000_000n); + const provider = new FixtureProvider([...FIXTURE_UTXOS, overfunded]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const r = await buildUpdateAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, description: 'overfunded update', + }); + assert.equal(r.op, 'update'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + let registryOut: any = null; + for (let i = 0; i < outs.len(); i++) { + if (outs.get(i).address().to_bech32() === getRegistryAddress()) registryOut = outs.get(i); + } + assert.ok(registryOut, 'no continuing registry output'); + assert.equal(registryOut.amount().coin(), 25_000_000n, 'continuing output must preserve the FULL input value, not the deposit floor'); + }); + + test('transfer preserves the FULL input value on an over-funded registry UTxO', async () => { + const overfunded = overfundedRegistryUtxo(25_000_000n); + const provider = new FixtureProvider([...FIXTURE_UTXOS, overfunded]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const newOwnerHash = '22'.repeat(28); + const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash }); + const r = await buildTransferAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: newOwner, + }); + assert.equal(r.op, 'transfer'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + let registryOut: any = null; + for (let i = 0; i < outs.len(); i++) { + if (outs.get(i).address().to_bech32() === getRegistryAddress()) registryOut = outs.get(i); + } + assert.ok(registryOut, 'no continuing registry output'); + assert.equal(registryOut.amount().coin(), 25_000_000n, 'continuing output must preserve the FULL input value on transfer too'); + }); + + test('validator floor pin: deregister of an over-funded (25 AP3X) registry UTxO succeeds with no extra input pulled in', async () => { + // Direct build (not through buildDeregisterAgent, which now + // unconditionally requires a fee UTxO whenever the wallet has one) - + // pins the underlying chain fact that motivates that requirement: the + // deployed validator's returned-change floor is cleared by the registry + // deposit alone once it's well above MIN_AP3X_DEPOSIT. Confirms this is + // a genuine value floor on the validator side, not "any sole-input + // deregister fails" (which was the earlier, wrong diagnosis). + const overfunded = overfundedRegistryUtxo(25_000_000n); + const provider = new FixtureProvider([overfunded, ...FIXTURE_UTXOS]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const spendRedeemer = Data.to(new Constr(1, [])); + const mintRedeemer = Data.to(new Constr(1, [])); + const completed = await lucid.newTx() + .collectFrom([overfunded], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .mintAssets({ [AGENT_UNIT]: -1n }, mintRedeemer) + .attach.MintingPolicy(registryScript()) + .addSigner(OWN_ADDRESS) + .complete(); + const tx = decodeTx(completed.toCBOR()); + assert.equal(tx.body().inputs().len(), 1, 'sole input - Lucid should not need to pull in any extra wallet UTxO here'); + }); + + test('deregister throws an actionable error (not a validator crash) when the wallet has no plain AP3X-only UTxO', async () => { + const tokenOnlyUtxo = { + txHash: 'df'.repeat(32), outputIndex: 0, address: OWN_ADDRESS, + assets: { lovelace: 1_000_000_000n, [FIXTURE_TOKEN_UNIT]: 500n }, + }; + const provider = new FixtureProvider([tokenOnlyUtxo, REGISTRY_UTXO]); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + await assert.rejects(buildDeregisterAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, + }), /AP3X-only UTxO/); + }); +});