fix(builder): registry owner ops preserve input value; deregister floor handled explicitly - correct the validator-semantics diagnosis
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user