feat(builder): keyless registry owner ops - update, transfer, deregister vs the real validator
This commit is contained in:
@@ -191,6 +191,125 @@ export async function buildRegisterAgent(lucid: LucidEvolution, p: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildUpdateAgent(
|
||||
lucid: LucidEvolution,
|
||||
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
|
||||
p: {
|
||||
changeAddress: string; agentId: string; name?: string; description?: string;
|
||||
capabilities?: string[]; framework?: string; endpoint?: string;
|
||||
},
|
||||
): Promise<VectorBuildAgentOpResult> {
|
||||
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<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
|
||||
p: { changeAddress: string; agentId: string; newOwnerAddress: string },
|
||||
): Promise<VectorBuildAgentOpResult> {
|
||||
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<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
|
||||
p: { changeAddress: string; agentId: string },
|
||||
): Promise<VectorBuildAgentOpResult> {
|
||||
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<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit'>,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user