From fb3041a8a09857f13677384aac835b4f338e672b Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:44:47 +0200 Subject: [PATCH] feat(builder): keyless registry owner ops - update, transfer, deregister vs the real validator --- packages/builder/src/vector/registry-build.ts | 119 ++++++++++++++++++ packages/builder/test/registry-build.test.ts | 108 +++++++++++++++- 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/vector/registry-build.ts b/packages/builder/src/vector/registry-build.ts index 92ea09b..e55b18d 100644 --- a/packages/builder/src/vector/registry-build.ts +++ b/packages/builder/src/vector/registry-build.ts @@ -191,6 +191,125 @@ export async function buildRegisterAgent(lucid: LucidEvolution, p: { }; } +export async function buildUpdateAgent( + lucid: LucidEvolution, + provider: Pick, + p: { + changeAddress: string; agentId: string; name?: string; description?: string; + capabilities?: string[]; framework?: string; endpoint?: string; + }, +): Promise { + 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 { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId); + verifyOwnership(profile, vkeyHash); + const newDatum = buildAgentDatum( + vkeyHash, + p.name ?? profile.name, + p.description ?? profile.description, + p.capabilities ?? profile.capabilities, + p.framework ?? profile.framework, + p.endpoint ?? profile.endpoint, + profile.registeredAt, + ); + const spendRedeemer = Data.to(new Constr(0, [])); // Update + const completed = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .addSigner(p.changeAddress) + .complete(); + const updatedFields: string[] = []; + if (p.name !== undefined) updatedFields.push('name'); + if (p.description !== undefined) updatedFields.push('description'); + if (p.capabilities !== undefined) updatedFields.push('capabilities'); + if (p.framework !== undefined) updatedFields.push('framework'); + if (p.endpoint !== undefined) updatedFields.push('endpoint'); + return { + ...toBuildResult(completed), + agentId: p.agentId, op: 'update', agentName: profile.name, detail: updatedFields.join(', '), + }; +} + +export async function buildTransferAgent( + lucid: LucidEvolution, + provider: Pick, + p: { changeAddress: string; agentId: string; newOwnerAddress: string }, +): Promise { + const vkeyHash = paymentKeyHashOf(p.changeAddress); + let newOwnerHash: string; + try { + newOwnerHash = paymentKeyHashOf(p.newOwnerAddress); + } 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); + 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) + const completed = await lucid.newTx() + .collectFrom([utxo], spendRedeemer) + .attach.SpendingValidator(registryScript()) + .pay.ToAddressWithData(getRegistryAddress(), { kind: 'inline', value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n }) + .addSigner(p.changeAddress) + .complete(); + return { + ...toBuildResult(completed), + agentId: p.agentId, op: 'transfer', agentName: profile.name, detail: p.newOwnerAddress, + }; +} + +export async function buildDeregisterAgent( + lucid: LucidEvolution, + provider: Pick, + p: { changeAddress: string; agentId: string }, +): Promise { + const vkeyHash = paymentKeyHashOf(p.changeAddress); + const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, p.agentId); + 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. + 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 + .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(), + }; +} + export async function buildMessageAgent( lucid: LucidEvolution, provider: Pick, diff --git a/packages/builder/test/registry-build.test.ts b/packages/builder/test/registry-build.test.ts index 29a272e..acbd7fa 100644 --- a/packages/builder/test/registry-build.test.ts +++ b/packages/builder/test/registry-build.test.ts @@ -1,6 +1,8 @@ import { describe, test, before } from 'node:test'; import assert from 'node:assert/strict'; -import { CML, getAddressDetails, Data, Constr, toText, fromText } from '@lucid-evolution/lucid'; +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 { lucidForAddress } from '../src/vector/build.ts'; @@ -8,6 +10,7 @@ import { REGISTRY_POLICY_ID, MIN_AP3X_DEPOSIT, AGENT_MESSAGE_LABEL, getRegistryAddress, parseDid, buildAgentDatum, parseAgentDatum, resolveAgentUtxo, buildRegisterAgent, buildMessageAgent, + buildUpdateAgent, buildTransferAgent, buildDeregisterAgent, } from '../src/vector/registry-build.ts'; // The REAL registry validator evaluates during .complete() (native UPLC, offline). @@ -239,3 +242,106 @@ describe('resolveAgentUtxo (Koios/address-scan fallback)', () => { assert.equal(profile.agentId, AGENT_DID); }); }); + +describe('registry owner ops (real validator, offline)', () => { + function ownedProvider() { + return new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]); + } + + test('update merges fields, preserves registeredAt, keeps NFT + deposit at the registry', async () => { + 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', + }); + assert.equal(r.op, 'update'); + assert.equal(r.detail, 'description'); + 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(), MIN_AP3X_DEPOSIT); + assert.ok(registryOut.datum() && registryOut.datum()!.kind() === 1, 'continuing datum must be inline'); + // decode the continuing inline datum and verify the merge + const datumCbor = registryOut.datum()!.as_datum()!.to_cbor_hex(); + const p = parseAgentDatum(datumCbor, 'x#0', { [AGENT_UNIT]: 1n })!; + assert.equal(p.description, 'updated by unit test'); + assert.equal(p.name, 'FixtureAgent'); // preserved + assert.equal(p.registeredAt, 1750000000000); // preserved + const vkeys = tx.witness_set().vkeywitnesses(); + assert.ok(vkeys === undefined || vkeys.len() === 0, 'must be unsigned'); + }); + + test('update with no fields throws before any resolution', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + await assert.rejects(buildUpdateAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, + }), /At least one field/); + }); + + test('ownership fail-fast: a non-owner changeAddress is refused before building', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + // FOREIGN_ADDRESS is a script address in the fixture set; use a foreign KEY address instead: + // derive one from a different fixture or construct via credentialToAddress with a random hash. + const strangerHash = '00'.repeat(28); + const stranger = credentialToAddress('Mainnet', { type: 'Key', hash: strangerHash }); + // build a lucid for the stranger (their own address-only wallet, holding nothing at the registry) + const strangerProvider = new FixtureProvider([ + { txHash: 'ee'.repeat(32), outputIndex: 0, address: stranger, assets: { lovelace: 50_000_000n } }, + REGISTRY_UTXO, + ]); + const strangerLucid = await lucidForAddress(strangerProvider, stranger); + await assert.rejects(buildUpdateAgent(strangerLucid, strangerProvider, { + changeAddress: stranger, agentId: AGENT_DID, description: 'hijack attempt', + }), /Ownership check failed/); + }); + + test('transfer swaps the datum owner to the new key and rejects script-address owners', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + 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, + }); + 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); + } + const datumCbor = registryOut.datum()!.as_datum()!.to_cbor_hex(); + const p = parseAgentDatum(datumCbor, 'x#0', { [AGENT_UNIT]: 1n })!; + assert.equal(p.ownerVkeyHash, newOwnerHash); + 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(), + }), /verification key/i); + }); + + test('deregister burns the NFT and leaves no registry output (deposit comes back as change)', async () => { + const provider = ownedProvider(); + const lucid = await lucidForAddress(provider, OWN_ADDRESS); + const r = await buildDeregisterAgent(lucid, provider, { + changeAddress: OWN_ADDRESS, agentId: AGENT_DID, + }); + assert.equal(r.op, 'deregister'); + assert.equal(r.agentName, 'FixtureAgent'); + const tx = decodeTx(r.txCbor); + const outs = tx.body().outputs(); + for (let i = 0; i < outs.len(); i++) { + assert.notEqual(outs.get(i).address().to_bech32(), getRegistryAddress(), + 'deregister must not leave an output at the registry'); + // every output is change to the owner + assert.equal(outs.get(i).address().to_bech32(), OWN_ADDRESS); + } + assert.ok(tx.body().mint(), 'no mint (burn) field on the deregister tx'); + }); +});