refactor(builder): registry builds derive the change address from the wallet - mismatch now unrepresentable

This commit is contained in:
FilipBl4gojevic
2026-07-30 14:11:34 +02:00
parent 6e30845834
commit 98f9612619
3 changed files with 50 additions and 38 deletions

View File

@@ -159,7 +159,7 @@ ${capList}
if (rateLimited) return rateLimited;
try {
const lucid = await lucidForAddress(newProvider(), changeAddress);
const r = await buildRegisterAgent(lucid, { changeAddress, name, description, capabilities, framework, endpoint });
const r = await buildRegisterAgent(lucid, { name, description, capabilities, framework, endpoint });
return {
content: [{
type: "text" as const,
@@ -215,7 +215,7 @@ The DID above is bound to the exact wallet UTxO this build consumed - if that UT
try {
const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildUpdateAgent(lucid, provider, { changeAddress, agentId: agent_id, name, description, capabilities, framework, endpoint });
const r = await buildUpdateAgent(lucid, provider, { agentId: agent_id, name, description, capabilities, framework, endpoint });
return {
content: [{
type: "text" as const,
@@ -264,7 +264,7 @@ The identity NFT and deposit are unchanged by this update. ${SIGN_AND_SUBMIT_COP
try {
const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildTransferAgent(lucid, provider, { changeAddress, agentId: agent_id, newOwnerAddress: new_owner_address });
const r = await buildTransferAgent(lucid, provider, { agentId: agent_id, newOwnerAddress: new_owner_address });
return {
content: [{
type: "text" as const,
@@ -314,7 +314,7 @@ After this transaction confirms, only the new owner can update, transfer, or der
try {
const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildDeregisterAgent(lucid, provider, { changeAddress, agentId: agent_id });
const r = await buildDeregisterAgent(lucid, provider, { agentId: agent_id });
return {
content: [{
type: "text" as const,
@@ -366,7 +366,7 @@ The identity NFT is burned by this transaction; the deposit returns to the chang
try {
const provider = newProvider();
const lucid = await lucidForAddress(provider, changeAddress);
const r = await buildMessageAgent(lucid, provider, { changeAddress, agentId: agent_id, messageType: message_type, payload });
const r = await buildMessageAgent(lucid, provider, { agentId: agent_id, messageType: message_type, payload });
return {
content: [{
type: "text" as const,

View File

@@ -157,12 +157,13 @@ export function verifyOwnership(profile: RegistryAgentProfile, walletVkeyHash: s
}
export async function buildRegisterAgent(lucid: LucidEvolution, p: {
changeAddress: string; name: string; description: string;
name: string; description: string;
capabilities: string[]; framework: string; endpoint: string;
}): Promise<VectorBuildRegisterResult> {
const changeAddress = await lucid.wallet().address();
validateEndpoint(p.endpoint);
validateCapabilities(p.capabilities);
const vkeyHash = paymentKeyHashOf(p.changeAddress);
const vkeyHash = paymentKeyHashOf(changeAddress);
const utxos = await lucid.wallet().getUtxos();
// Same selection heuristic as the old custodial tool (behavior parity):
// prefer a pure-lovelace UTxO covering deposit + fee headroom, else the first.
@@ -180,7 +181,7 @@ export async function buildRegisterAgent(lucid: LucidEvolution, p: {
.mintAssets({ [nftUnit]: 1n }, registerRedeemer)
.attach.MintingPolicy(registryScript())
.pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.addSigner(p.changeAddress)
.addSigner(changeAddress)
.complete();
return {
...toBuildResult(completed),
@@ -195,17 +196,18 @@ export async function buildUpdateAgent(
lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: {
changeAddress: string; agentId: string; name?: string; description?: string;
agentId: string; name?: string; description?: string;
capabilities?: string[]; framework?: string; endpoint?: string;
},
): Promise<VectorBuildAgentOpResult> {
const changeAddress = await lucid.wallet().address();
if (p.name === undefined && p.description === undefined && p.capabilities === undefined
&& p.framework === undefined && p.endpoint === undefined) {
throw new Error('At least one field must be provided to update (name, description, capabilities, framework, or endpoint).');
}
if (p.endpoint !== undefined) validateEndpoint(p.endpoint);
if (p.capabilities !== undefined) validateCapabilities(p.capabilities);
const vkeyHash = paymentKeyHashOf(p.changeAddress);
const vkeyHash = paymentKeyHashOf(changeAddress);
const { profile, utxo } = await resolveAgentUtxo(provider, p.agentId);
verifyOwnership(profile, vkeyHash);
// Owner is written from profile.ownerVkeyHash, not the caller's vkeyHash:
@@ -232,7 +234,7 @@ export async function buildUpdateAgent(
.collectFrom([utxo], spendRedeemer)
.attach.SpendingValidator(registryScript())
.pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets)
.addSigner(p.changeAddress)
.addSigner(changeAddress)
.complete();
const updatedFields: string[] = [];
if (p.name !== undefined) updatedFields.push('name');
@@ -249,9 +251,10 @@ export async function buildUpdateAgent(
export async function buildTransferAgent(
lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: { changeAddress: string; agentId: string; newOwnerAddress: string },
p: { agentId: string; newOwnerAddress: string },
): Promise<VectorBuildAgentOpResult> {
const vkeyHash = paymentKeyHashOf(p.changeAddress);
const changeAddress = await lucid.wallet().address();
const vkeyHash = paymentKeyHashOf(changeAddress);
let newOwnerHash: string;
try {
newOwnerHash = paymentKeyHashOf(p.newOwnerAddress);
@@ -272,7 +275,7 @@ export async function buildTransferAgent(
.collectFrom([utxo], spendRedeemer)
.attach.SpendingValidator(registryScript())
.pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, utxo.assets)
.addSigner(p.changeAddress)
.addSigner(changeAddress)
.complete();
return {
...toBuildResult(completed),
@@ -283,9 +286,10 @@ export async function buildTransferAgent(
export async function buildDeregisterAgent(
lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: { changeAddress: string; agentId: string },
p: { agentId: string },
): Promise<VectorBuildAgentOpResult> {
const vkeyHash = paymentKeyHashOf(p.changeAddress);
const changeAddress = await lucid.wallet().address();
const vkeyHash = paymentKeyHashOf(changeAddress);
const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId);
verifyOwnership(profile, vkeyHash);
const spendRedeemer = Data.to(new Constr(1, [])); // Deregister
@@ -321,7 +325,7 @@ export async function buildDeregisterAgent(
.collectFrom([feeUtxo])
.mintAssets({ [nftUnit]: -1n }, mintRedeemer)
.attach.MintingPolicy(registryScript())
.addSigner(p.changeAddress)
.addSigner(changeAddress)
.complete();
return {
...toBuildResult(completed),
@@ -332,9 +336,10 @@ export async function buildDeregisterAgent(
export async function buildMessageAgent(
lucid: LucidEvolution,
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
p: { changeAddress: string; agentId: string; messageType: string; payload: string },
p: { agentId: string; messageType: string; payload: string },
): Promise<VectorBuildMessageResult> {
paymentKeyHashOf(p.changeAddress); // validate before any resolution work
const changeAddress = await lucid.wallet().address();
paymentKeyHashOf(changeAddress); // validate before any resolution work
const { profile } = await resolveAgentUtxo(provider, p.agentId);
if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum');
const recipientAddress = credentialToAddress('Mainnet', { type: 'Key', hash: profile.ownerVkeyHash });
@@ -342,7 +347,7 @@ export async function buildMessageAgent(
.pay.ToAddress(recipientAddress, { lovelace: 2_000_000n })
.attachMetadata(AGENT_MESSAGE_LABEL, {
msg: ['a2a'],
from: metadataStr(p.changeAddress),
from: metadataStr(changeAddress),
to: metadataStr(p.agentId),
type: p.messageType,
payload: metadataStr(p.payload),

View File

@@ -121,7 +121,7 @@ describe('buildRegisterAgent (real validator, offline)', () => {
test('builds an evaluable register tx: mint +1, deposit output, inline datum, owner signer', async () => {
const r = await buildRegisterAgent(lucid, {
changeAddress: OWN_ADDRESS, name: 'UnitAgent', description: 'unit test agent',
name: 'UnitAgent', description: 'unit test agent',
capabilities: ['testing', 'ci'], framework: 'custom', endpoint: '',
});
const tx = decodeTx(r.txCbor);
@@ -172,16 +172,23 @@ describe('buildRegisterAgent (real validator, offline)', () => {
test('rejects invalid endpoint and empty capability before building', async () => {
await assert.rejects(buildRegisterAgent(lucid, {
changeAddress: OWN_ADDRESS, name: 'X', description: '', capabilities: [], framework: 'c', endpoint: 'not a url',
name: 'X', description: '', capabilities: [], framework: 'c', endpoint: 'not a url',
}), /Invalid endpoint URL/);
await assert.rejects(buildRegisterAgent(lucid, {
changeAddress: OWN_ADDRESS, name: 'X', description: '', capabilities: ['ok', ' '], framework: 'c', endpoint: '',
name: 'X', description: '', capabilities: ['ok', ' '], framework: 'c', endpoint: '',
}), /capability/i);
});
test('rejects a script-credential changeAddress with a clear error', async () => {
await assert.rejects(buildRegisterAgent(lucid, {
changeAddress: getRegistryAddress(), name: 'X', description: '', capabilities: [], framework: 'c', endpoint: '',
// Pre-refactor this test passed a script-address changeAddress param that
// disagreed with `lucid`'s own wallet (OWN_ADDRESS) - exactly the
// mismatch class this refactor makes unrepresentable. Now it must
// construct a lucid instance FOR the script address itself, so the
// derived changeAddress (== lucid.wallet().address()) is the one that
// paymentKeyHashOf rejects.
const scriptLucid = await lucidForAddress(new FixtureProvider([REGISTRY_UTXO]), getRegistryAddress());
await assert.rejects(buildRegisterAgent(scriptLucid, {
name: 'X', description: '', capabilities: [], framework: 'c', endpoint: '',
}), /verification key|payment key/i);
});
});
@@ -191,7 +198,7 @@ describe('buildMessageAgent (resolves via fixture registry UTxO)', () => {
const provider = new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]);
const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildMessageAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, messageType: 'inquiry', payload: 'unit ping',
agentId: AGENT_DID, messageType: 'inquiry', payload: 'unit ping',
});
assert.equal(r.agentName, 'FixtureAgent');
const tx = decodeTx(r.txCbor);
@@ -219,7 +226,7 @@ describe('buildMessageAgent (resolves via fixture registry UTxO)', () => {
const provider = new FixtureProvider(FIXTURE_UTXOS); // no registry UTxO
const lucid = await lucidForAddress(provider, OWN_ADDRESS);
await assert.rejects(buildMessageAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, messageType: 'inquiry', payload: 'x',
agentId: AGENT_DID, messageType: 'inquiry', payload: 'x',
}), /Agent not found/);
});
});
@@ -254,7 +261,7 @@ describe('registry owner ops (real validator, offline)', () => {
const provider = ownedProvider();
const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildUpdateAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, description: 'updated by unit test',
agentId: AGENT_DID, description: 'updated by unit test',
});
assert.equal(r.op, 'update');
assert.equal(r.detail, 'description');
@@ -281,7 +288,7 @@ describe('registry owner ops (real validator, offline)', () => {
const provider = ownedProvider();
const lucid = await lucidForAddress(provider, OWN_ADDRESS);
await assert.rejects(buildUpdateAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID,
agentId: AGENT_DID,
}), /At least one field/);
});
@@ -299,7 +306,7 @@ describe('registry owner ops (real validator, offline)', () => {
]);
const strangerLucid = await lucidForAddress(strangerProvider, stranger);
await assert.rejects(buildUpdateAgent(strangerLucid, strangerProvider, {
changeAddress: stranger, agentId: AGENT_DID, description: 'hijack attempt',
agentId: AGENT_DID, description: 'hijack attempt',
}), /Ownership check failed/);
});
@@ -309,7 +316,7 @@ describe('registry owner ops (real validator, offline)', () => {
const newOwnerHash = '11'.repeat(28);
const newOwner = credentialToAddress('Mainnet', { type: 'Key', hash: newOwnerHash });
const r = await buildTransferAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: newOwner,
agentId: AGENT_DID, newOwnerAddress: newOwner,
});
assert.equal(r.op, 'transfer');
const tx = decodeTx(r.txCbor);
@@ -324,7 +331,7 @@ describe('registry owner ops (real validator, offline)', () => {
assert.equal(p.name, 'FixtureAgent'); // everything else preserved
// script-credential new owner refused
await assert.rejects(buildTransferAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID, newOwnerAddress: getRegistryAddress(),
agentId: AGENT_DID, newOwnerAddress: getRegistryAddress(),
}), /verification key/i);
});
@@ -332,7 +339,7 @@ describe('registry owner ops (real validator, offline)', () => {
const provider = ownedProvider();
const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildDeregisterAgent(lucid, provider, {
changeAddress: OWN_ADDRESS, agentId: AGENT_DID,
agentId: AGENT_DID,
});
assert.equal(r.op, 'deregister');
assert.equal(r.agentName, 'FixtureAgent');
@@ -353,7 +360,7 @@ describe('registry owner ops (real validator, offline)', () => {
// small listed FIRST so first-match selection would pick it
const provider = new FixtureProvider([small, large, REGISTRY_UTXO]);
const lucid = await lucidForAddress(provider, OWN_ADDRESS);
const r = await buildDeregisterAgent(lucid, provider, { changeAddress: OWN_ADDRESS, agentId: AGENT_DID });
const r = await buildDeregisterAgent(lucid, provider, { agentId: AGENT_DID });
const tx = CML.Transaction.from_cbor_hex(r.txCbor);
const ins = tx.body().inputs();
let sawLarge = false, sawSmall = false;
@@ -387,7 +394,7 @@ describe('registry owner ops - value preservation & deregister floor (correction
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',
agentId: AGENT_DID, description: 'overfunded update',
});
assert.equal(r.op, 'update');
const tx = decodeTx(r.txCbor);
@@ -407,7 +414,7 @@ describe('registry owner ops - value preservation & deregister floor (correction
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,
agentId: AGENT_DID, newOwnerAddress: newOwner,
});
assert.equal(r.op, 'transfer');
const tx = decodeTx(r.txCbor);
@@ -449,7 +456,7 @@ describe('registry owner ops - value preservation & deregister floor (correction
// build above), which is safe here because the provider also carries
// FIXTURE_UTXOS, so the extra fee UTxO that path unconditionally pulls in
// is available.
const r = await buildDeregisterAgent(lucid, provider, { changeAddress: OWN_ADDRESS, agentId: AGENT_DID });
const r = await buildDeregisterAgent(lucid, provider, { agentId: AGENT_DID });
assert.equal(r.detail, '25000000', 'detail must be the spent registry UTxO lovelace, not the constant');
});
@@ -461,7 +468,7 @@ describe('registry owner ops - value preservation & deregister floor (correction
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,
agentId: AGENT_DID,
}), /AP3X-only UTxO/);
});
});