fix(builder): registry owner ops preserve input value; deregister floor handled explicitly - correct the validator-semantics diagnosis

This commit is contained in:
FilipBl4gojevic
2026-07-29 18:13:39 +02:00
parent fb3041a8a0
commit 6875100e6f
2 changed files with 141 additions and 30 deletions

View File

@@ -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),
};
}

View File

@@ -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/);
});
});