import { describe, test, before } from 'node:test'; import assert from 'node:assert/strict'; 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, 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, registryScript, } from '../src/vector/registry-build.ts'; // The REAL registry validator evaluates during .complete() (native UPLC, offline). // A passing build here is ground-truth phase-2 validity, minus signatures. const OWN_VKEY_HASH = getAddressDetails(OWN_ADDRESS).paymentCredential!.hash; // A registry UTxO owned by OWN_ADDRESS, for message-resolution tests. const AGENT_NFT_NAME = 'ab'.repeat(32); // 64 hex chars const AGENT_UNIT = `${REGISTRY_POLICY_ID}${AGENT_NFT_NAME}`; const AGENT_DID = `did:vector:agent:${REGISTRY_POLICY_ID}:${AGENT_NFT_NAME}`; let REGISTRY_UTXO: any; before(() => { REGISTRY_UTXO = { txHash: 'cd'.repeat(32), outputIndex: 0, address: getRegistryAddress(), assets: { lovelace: MIN_AP3X_DEPOSIT, [AGENT_UNIT]: 1n }, datum: buildAgentDatum(OWN_VKEY_HASH, 'FixtureAgent', 'offline fixture agent', ['testing'], 'custom', '', 1750000000000), }; }); function decodeTx(cborHex: string) { return CML.Transaction.from_cbor_hex(cborHex); } // Shared by the mint-content and registry-output-asset checks below: Mint's // as_positive_multiasset() and Value's multi_asset() both return the same // MultiAsset type (policy ScriptHash -> AssetName -> quantity), so one // traversal serves both call sites. function findAssetQuantity(ma: any, policyIdHex: string, assetNameHex: string): bigint | undefined { const policies = ma.keys(); for (let i = 0; i < policies.len(); i++) { const policy = policies.get(i); if (policy.to_hex() !== policyIdHex) continue; const assets = ma.get_assets(policy); if (!assets) continue; const names = assets.keys(); for (let n = 0; n < names.len(); n++) { const name = names.get(n); if (name.to_hex() === assetNameHex) return assets.get(name); } } return undefined; } // Decodes a TransactionMetadatum leaf as text, joining metadataStr()'s // chunked-list form (used for values over 64 chars) back into one string. function metadatumText(m: any): string | undefined { const text = m.as_text(); if (text !== undefined) return text; const list = m.as_list(); if (list) { let joined = ''; for (let i = 0; i < list.len(); i++) joined += list.get(i).as_text() ?? ''; return joined; } return undefined; } describe('registry constants and helpers', () => { test('registry address derives to the live testnet address', () => { assert.equal(getRegistryAddress(), 'addr1wxlp5z3fztdpsp6ha57dvx6khw82kqvgcxwu8s8rjykjcqghprf42'); }); test('parseDid round-trips and rejects malformed DIDs', () => { const d = parseDid(AGENT_DID); assert.equal(d.policyId, REGISTRY_POLICY_ID); assert.equal(d.assetName, AGENT_NFT_NAME); assert.equal(d.unit, AGENT_UNIT); assert.throws(() => parseDid('did:vector:wrong:x:y'), /Invalid agent DID/); assert.throws(() => parseDid(`did:vector:agent:${REGISTRY_POLICY_ID}:NOTHEX`), /hex/); }); test('parseAgentDatum inverts buildAgentDatum exactly', () => { const datum = buildAgentDatum(OWN_VKEY_HASH, 'A name', 'desc', ['a', 'b'], 'fw', 'https://x.example', 1234); const p = parseAgentDatum(datum, 'ref#0', { lovelace: 1n, [AGENT_UNIT]: 1n })!; assert.equal(p.name, 'A name'); assert.deepEqual(p.capabilities, ['a', 'b']); assert.equal(p.ownerVkeyHash, OWN_VKEY_HASH); assert.equal(p.registeredAt, 1234); assert.equal(p.agentId, AGENT_DID); }); test('parseAgentDatum returns null on garbage, never throws', () => { assert.equal(parseAgentDatum('deadbeef', 'ref#0', {}), null); assert.equal(parseAgentDatum(Data.to(new Constr(1, [])), 'ref#0', {}), null); }); test('parseAgentDatum returns null when the datum has the wrong field count (missing registeredAt)', () => { const sixFieldDatum = Data.to(new Constr(0, [ new Constr(0, ['00'.repeat(28)]), fromText('x'), fromText('x'), [], fromText('x'), fromText('x'), ])); assert.equal(parseAgentDatum(sixFieldDatum, 'ref#0', {}), null); }); test('MIN_AP3X_DEPOSIT matches the protocol deposit floor exactly', () => { assert.equal(MIN_AP3X_DEPOSIT, 10_000_000n, 'protocol deposit constant drifted'); }); }); describe('buildRegisterAgent (real validator, offline)', () => { let lucid: LucidEvolution; before(async () => { lucid = await lucidForAddress(new FixtureProvider(FIXTURE_UTXOS), OWN_ADDRESS); }); test('builds an evaluable register tx: mint +1, deposit output, inline datum, owner signer', async () => { const r = await buildRegisterAgent(lucid, { name: 'UnitAgent', description: 'unit test agent', capabilities: ['testing', 'ci'], framework: 'custom', endpoint: '', }); const tx = decodeTx(r.txCbor); // unsigned const vkeys = tx.witness_set().vkeywitnesses(); assert.ok(vkeys === undefined || vkeys.len() === 0, 'register build must be unsigned'); // mint exactly +1 of the derived unit const mint = tx.body().mint()!; assert.ok(mint, 'no mint field'); // DID binds to the consumed one-shot input assert.match(r.agentId, new RegExp(`^did:vector:agent:${REGISTRY_POLICY_ID}:[0-9a-f]{64}$`)); assert.equal(r.nftAssetName.length, 64); assert.equal( findAssetQuantity(mint.as_positive_multiasset(), REGISTRY_POLICY_ID, r.nftAssetName), 1n, 'minted unit must be exactly +1 of policy:nftAssetName, not just any positive mint', ); // one output to the registry address carrying deposit + NFT const outs = tx.body().outputs(); let registryOut: any = null; for (let i = 0; i < outs.len(); i++) { const o = outs.get(i); if (o.address().to_bech32() === getRegistryAddress()) registryOut = o; } assert.ok(registryOut, 'no output to the registry address'); // literal is the load-bearing assertion: comparing only to the constant // under test is tautological (it would pass even if MIN_AP3X_DEPOSIT drifted // above the real on-chain floor - see the dedicated pin test above). assert.equal(registryOut.amount().coin(), 10_000_000n, 'registry deposit must equal the literal on-chain floor'); assert.equal(registryOut.amount().coin(), MIN_AP3X_DEPOSIT, 'MIN_AP3X_DEPOSIT should still equal the literal floor pinned above'); assert.ok(registryOut.datum() && registryOut.datum()!.kind() === 1, 'registry output datum must be inline'); // registry output must actually carry the NFT, not just the deposit const registryAssets = registryOut.amount().multi_asset(); assert.ok(registryAssets, 'registry output carries no native assets'); assert.equal( findAssetQuantity(registryAssets, REGISTRY_POLICY_ID, r.nftAssetName), 1n, 'registry output must hold exactly 1 of the derived NFT unit', ); // required signer = owner const signers = tx.body().required_signers(); assert.ok(signers && signers.len() >= 1, 'no required_signers'); let found = false; for (let i = 0; i < signers!.len(); i++) { if (signers!.get(i).to_hex() === OWN_VKEY_HASH) found = true; } assert.ok(found, 'owner vkey hash missing from required_signers'); assert.equal(r.depositLovelace, MIN_AP3X_DEPOSIT.toString()); }); test('rejects invalid endpoint and empty capability before building', async () => { await assert.rejects(buildRegisterAgent(lucid, { name: 'X', description: '', capabilities: [], framework: 'c', endpoint: 'not a url', }), /Invalid endpoint URL/); await assert.rejects(buildRegisterAgent(lucid, { name: 'X', description: '', capabilities: ['ok', ' '], framework: 'c', endpoint: '', }), /capability/i); }); test('rejects a script-credential changeAddress with a clear error', async () => { // 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); }); }); describe('buildMessageAgent (resolves via fixture registry UTxO)', () => { test('pays 2 AP3X to the owner-derived address with label-674 metadata', async () => { const provider = new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]); const lucid = await lucidForAddress(provider, OWN_ADDRESS); const r = await buildMessageAgent(lucid, provider, { agentId: AGENT_DID, messageType: 'inquiry', payload: 'unit ping', }); assert.equal(r.agentName, 'FixtureAgent'); const tx = decodeTx(r.txCbor); // 2 AP3X output to the recipient (owner == OWN_ADDRESS's key, enterprise-derived) const outs = tx.body().outputs(); let paid = 0; for (let i = 0; i < outs.len(); i++) { if (outs.get(i).address().to_bech32() === r.recipientAddress && outs.get(i).amount().coin() === 2_000_000n) paid++; } assert.ok(paid >= 1, 'no 2 AP3X output to the recipient address'); // metadata label present const aux = tx.auxiliary_data(); assert.ok(aux && aux.metadata() && aux.metadata()!.get(BigInt(AGENT_MESSAGE_LABEL)), 'label-674 metadata missing'); // metadata content: from/to must be the real sender/recipient, not swapped const metaMap = aux!.metadata()!.get(BigInt(AGENT_MESSAGE_LABEL))!.as_map()!; const fromVal = metadatumText(metaMap.get(CML.TransactionMetadatum.new_text('from'))!); const toVal = metadatumText(metaMap.get(CML.TransactionMetadatum.new_text('to'))!); assert.equal(fromVal, OWN_ADDRESS, 'metadata "from" must be the sending changeAddress'); assert.equal(toVal, AGENT_DID, 'metadata "to" must be the recipient agent DID'); const vkeys = tx.witness_set().vkeywitnesses(); assert.ok(vkeys === undefined || vkeys.len() === 0, 'message build must be unsigned'); }); test('unknown agent DID fails with agent-not-found, not a network error', async () => { const provider = new FixtureProvider(FIXTURE_UTXOS); // no registry UTxO const lucid = await lucidForAddress(provider, OWN_ADDRESS); await assert.rejects(buildMessageAgent(lucid, provider, { agentId: AGENT_DID, messageType: 'inquiry', payload: 'x', }), /Agent not found/); }); }); describe('resolveAgentUtxo (Koios/address-scan fallback)', () => { test('falls through to the address re-scan when getUtxoByUnit resolves but with no datum', async () => { // getUtxoByUnit "succeeding" with a datum-less UTxO (e.g. a Koios hit that // only carries a datum hash, not the inline datum) must not be treated as // resolved - resolveAgentUtxo has to notice the missing datum and fall // back to scanning the registry address, same as when getUtxoByUnit // throws outright. Under plain FixtureProvider this branch is dead // (getUtxoByUnit always throws), so it needs its own provider double. const base = new FixtureProvider([...FIXTURE_UTXOS, REGISTRY_UTXO]); const wrapped = { getUtxos: (a: string) => base.getUtxos(a), getUtxoByUnit: async () => ({ ...REGISTRY_UTXO, datum: null }), }; const { profile, utxo, nftUnit } = await resolveAgentUtxo(wrapped as any, AGENT_DID); assert.equal(nftUnit, AGENT_UNIT); assert.equal(utxo.datum, REGISTRY_UTXO.datum, 're-scanned UTxO must carry the real inline datum, not the datum-less stand-in'); assert.equal(profile.name, 'FixtureAgent'); 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, { 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, { 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, { 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, { 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, { 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, { 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'); }); test('deregister picks the LARGEST pure-AP3X wallet UTxO as the extra input', async () => { const small = { txHash: 'a1'.repeat(32), outputIndex: 0, address: OWN_ADDRESS, assets: { lovelace: 3_000_000n } }; const large = { txHash: 'a2'.repeat(32), outputIndex: 0, address: OWN_ADDRESS, assets: { lovelace: 900_000_000n } }; // 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, { agentId: AGENT_DID }); const tx = CML.Transaction.from_cbor_hex(r.txCbor); const ins = tx.body().inputs(); let sawLarge = false, sawSmall = false; for (let i = 0; i < ins.len(); i++) { const h = ins.get(i).transaction_id().to_hex(); if (h === 'a2'.repeat(32)) sawLarge = true; if (h === 'a1'.repeat(32)) sawSmall = true; } assert.ok(sawLarge, 'largest pure-AP3X UTxO must be the chosen extra input'); assert.ok(!sawSmall, 'the smaller pure-AP3X UTxO must not be chosen over the larger'); }); }); // 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, { 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, { 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'); // Pin: buildDeregisterAgent's returned `detail` is the ACTUAL spent // registry UTxO lovelace (25 AP3X here), not the MIN_AP3X_DEPOSIT // constant. Goes through buildDeregisterAgent itself (unlike the direct // 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, { agentId: AGENT_DID }); assert.equal(r.detail, '25000000', 'detail must be the spent registry UTxO lovelace, not the constant'); }); 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, { agentId: AGENT_DID, }), /AP3X-only UTxO/); }); });