Migrate to @lucid-evolution/lucid, fix agent registration (blake2b dkLen), contract spend (PlutusV2), and add Ogmios fallback for agent resolution

This commit is contained in:
david
2026-03-19 15:57:51 +01:00
parent daf2f9aa62
commit 0b3ac4ae82
6 changed files with 4914 additions and 392 deletions

4850
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
"vector-mcp": "./build/index.js"
},
"scripts": {
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs 2>/dev/null || true",
"clean": "rm -rf build",
"build": "tsup && chmod +x build/index.js",
"typecheck": "tsc --noEmit --incremental",
@@ -47,7 +48,8 @@
"@modelcontextprotocol/sdk": "^1.4.1",
"cross-fetch": "^4.0.0",
"dotenv": "^16.4.5",
"lucid-cardano": "^0.10.11",
"@lucid-evolution/lucid": "^0.4.29",
"@noble/hashes": "^1.7.1",
"zod": "^3.24.1"
},
"devDependencies": {

View File

@@ -1,18 +1,13 @@
// @ts-nocheck
// Agent network tools: register, discover, message, get_profile, update, deregister, transfer
// ts-nocheck isolates C (Cardano WASM) imports from tsc's complex type inference
import { z } from "zod";
import { Lucid, fromText, toText, Data, Constr, C } from 'lucid-cardano';
import { Lucid, fromText, toText, Data, Constr, validatorToAddress, validatorToScriptHash, getAddressDetails, credentialToAddress } from '@lucid-evolution/lucid';
import { blake2b } from '@noble/hashes/blake2b';
import { OgmiosProvider } from './ogmios-provider.js';
import { safetyLayer } from './safety.js';
import { rateLimiter } from './rate-limiter.js';
// Lucid v0.10.x lacks PlutusV3 — downcast to PlutusV2 for hashing (same algorithm)
function lucidCompat(validator) {
return validator.type === 'PlutusV3' ? { ...validator, type: 'PlutusV2' } : validator;
}
// Env config (mirrors vector.ts)
const VECTOR_OGMIOS_URL = process.env.VECTOR_OGMIOS_URL || 'https://ogmios.vector.testnet.apexfusion.org';
const VECTOR_SUBMIT_URL = process.env.VECTOR_SUBMIT_URL || 'https://submit.vector.testnet.apexfusion.org/api/submit/tx';
@@ -27,6 +22,16 @@ const AGENT_MESSAGE_LABEL = 674;
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Cardano metadata strings must be ≤ 64 bytes. Chunk long strings into arrays.
function metadataStr(s: string): string | string[] {
if (s.length <= 64) return s;
const chunks: string[] = [];
for (let i = 0; i < s.length; i += 64) {
chunks.push(s.slice(i, i + 64));
}
return chunks;
}
function lovelaceToAda(lovelace) {
return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6);
}
@@ -43,7 +48,7 @@ function newProvider() {
// Matches Aiken's derive_asset_name (verified via CBOR parity tests in agent-registry repo)
function deriveNftAssetName(txHash, outputIndex) {
const outRefCbor = Data.to(new Constr(0, [txHash, BigInt(outputIndex)]));
const hashBytes = C.hash_blake2b256(Buffer.from(outRefCbor, 'hex'));
const hashBytes = blake2b(Buffer.from(outRefCbor, 'hex'), { dkLen: 32 });
return Buffer.from(hashBytes).toString('hex');
}
@@ -101,7 +106,24 @@ function parseDid(agentId) {
async function resolveAgentUtxo(provider, agentId) {
const { unit } = parseDid(agentId);
const utxo = await provider.getUtxoByUnit(unit);
let utxo;
try {
utxo = await provider.getUtxoByUnit(unit);
} catch {
// Koios unavailable or asset not found — will try Ogmios fallback below
}
// If Koios returned a UTxO without datum, or didn't find one, scan via Ogmios
if (!utxo || !utxo.datum) {
const registryAddress = await getRegistryAddress();
const allUtxos = await provider.getUtxos(registryAddress);
const ogmiosUtxo = allUtxos.find(u => u.assets[unit] && u.assets[unit] > 0n);
if (ogmiosUtxo) {
utxo = ogmiosUtxo;
}
}
if (!utxo) throw new Error(`Agent not found: no UTxO holds NFT ${unit}. The agent may not exist or may have deregistered.`);
if (!utxo.datum) throw new Error('Registry UTxO found but has no inline datum.');
const profile = parseAgentDatum(utxo.datum, `${utxo.txHash}#${utxo.outputIndex}`, utxo.assets);
@@ -138,7 +160,7 @@ function validateCapabilities(capabilities) {
async function initLucid(mnemonic, accountIndex = 0) {
const provider = newProvider();
const lucid = await Lucid.new(provider, 'Mainnet');
const lucid = await Lucid(provider, 'Mainnet');
if (!mnemonic) throw new Error('mnemonic is required');
const trimmed = mnemonic.trim();
const words = trimmed.split(/\s+/);
@@ -146,11 +168,7 @@ async function initLucid(mnemonic, accountIndex = 0) {
if (!validLengths.includes(words.length)) {
throw new Error(`Invalid mnemonic: Expected 12, 15, 18, 21 or 24 words, got ${words.length}`);
}
lucid.selectWalletFromSeed(trimmed, { accountIndex });
// Store the full wallet address so credential-based UTxO lookups resolve correctly
const address = await lucid.wallet.address();
provider.setWalletAddress(address);
lucid.selectWallet.fromSeed(trimmed, { accountIndex });
return lucid;
}
@@ -159,10 +177,8 @@ async function initLucid(mnemonic, accountIndex = 0) {
let _registryAddress = null;
async function getRegistryAddress() {
if (_registryAddress) return _registryAddress;
const provider = newProvider();
const lucid = await Lucid.new(provider, 'Mainnet');
const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR };
_registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript));
const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR };
_registryAddress = validatorToAddress('Mainnet', registryScript);
return _registryAddress;
}
@@ -308,12 +324,12 @@ ${capList}
if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`);
const lucid = await initLucid(mnemonic);
const walletAddress = await lucid.wallet.address();
const addressDetails = lucid.utils.getAddressDetails(walletAddress);
const walletAddress = await lucid.wallet().address();
const addressDetails = getAddressDetails(walletAddress);
const vkeyHash = addressDetails.paymentCredential?.hash;
if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address');
const utxos = await lucid.wallet.getUtxos();
const utxos = await lucid.utxosAt(await lucid.wallet().address());
const seedUtxo = utxos.find(u => {
const keys = Object.keys(u.assets);
return keys.length === 1 && keys[0] === 'lovelace' && u.assets['lovelace'] >= MIN_AP3X_DEPOSIT + 2_000_000n;
@@ -323,18 +339,18 @@ ${capList}
const nftAssetName = deriveNftAssetName(seedUtxo.txHash, seedUtxo.outputIndex);
const nftUnit = `${REGISTRY_POLICY_ID}${nftAssetName}`;
const datum = buildAgentDatum(vkeyHash, name, description, capabilities, framework, endpoint);
const registryScript = { type: "PlutusV3", script: REGISTRY_SCRIPT_CBOR };
const registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript));
const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR };
const registryAddress = validatorToAddress('Mainnet', registryScript);
const registerRedeemer = Data.to(new Constr(0, [new Constr(0, [seedUtxo.txHash, BigInt(seedUtxo.outputIndex)])]));
const tx = await lucid.newTx()
.collectFrom([seedUtxo])
.mintAssets({ [nftUnit]: 1n }, registerRedeemer)
.attachMintingPolicy(lucidCompat(registryScript))
.payToContract(registryAddress, { inline: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.attach.MintingPolicy(registryScript)
.pay.ToAddressWithData(registryAddress, { kind: "inline", value: datum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.addSigner(walletAddress)
.complete();
const signedTx = await tx.sign().complete();
const signedTx = await tx.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, Number(MIN_AP3X_DEPOSIT), registryAddress);
@@ -398,8 +414,8 @@ Save your Agent DID — you'll need it to update, deregister, or let other agent
if (capabilities !== undefined) validateCapabilities(capabilities);
const lucid = await initLucid(mnemonic);
const walletAddress = await lucid.wallet.address();
const vkeyHash = lucid.utils.getAddressDetails(walletAddress).paymentCredential?.hash;
const walletAddress = await lucid.wallet().address();
const vkeyHash = getAddressDetails(walletAddress).paymentCredential?.hash;
if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address');
const provider = newProvider();
@@ -414,17 +430,17 @@ Save your Agent DID — you'll need it to update, deregister, or let other agent
const newEndpoint = endpoint ?? profile.endpoint;
const newDatum = buildAgentDatum(vkeyHash, newName, newDesc, newCaps, newFramework, newEndpoint, profile.registeredAt);
const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR };
const registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript));
const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR };
const registryAddress = validatorToAddress('Mainnet', registryScript);
const spendRedeemer = Data.to(new Constr(0, [])); // Update
const tx = await lucid.newTx()
.collectFrom([utxo], spendRedeemer)
.attachSpendingValidator(lucidCompat(registryScript))
.payToContract(registryAddress, { inline: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.attach.SpendingValidator(registryScript)
.pay.ToAddressWithData(registryAddress, { kind: "inline", value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.addSigner(walletAddress)
.complete();
const signedTx = await tx.sign().complete();
const signedTx = await tx.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, 0, registryAddress);
@@ -479,26 +495,26 @@ The agent's profile has been updated on-chain. The identity NFT and deposit are
if (rateLimited) return rateLimited;
try {
const lucid = await initLucid(mnemonic);
const walletAddress = await lucid.wallet.address();
const vkeyHash = lucid.utils.getAddressDetails(walletAddress).paymentCredential?.hash;
const walletAddress = await lucid.wallet().address();
const vkeyHash = getAddressDetails(walletAddress).paymentCredential?.hash;
if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address');
const provider = newProvider();
const { profile, utxo, nftUnit } = await resolveAgentUtxo(provider, agent_id);
verifyOwnership(profile, vkeyHash);
const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR };
const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR };
const spendRedeemer = Data.to(new Constr(1, [])); // Deregister
const mintRedeemer = Data.to(new Constr(1, [])); // Burn
const tx = await lucid.newTx()
.collectFrom([utxo], spendRedeemer)
.attachSpendingValidator(lucidCompat(registryScript))
.attach.SpendingValidator(registryScript)
.mintAssets({ [nftUnit]: -1n }, mintRedeemer)
.attachMintingPolicy(lucidCompat(registryScript))
.attach.MintingPolicy(registryScript)
.addSigner(walletAddress)
.complete();
const signedTx = await tx.sign().complete();
const signedTx = await tx.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, 0, walletAddress);
@@ -548,14 +564,14 @@ The agent's identity NFT has been burned and the 10 AP3X deposit has been return
if (rateLimited) return rateLimited;
try {
const lucid = await initLucid(mnemonic);
const walletAddress = await lucid.wallet.address();
const vkeyHash = lucid.utils.getAddressDetails(walletAddress).paymentCredential?.hash;
const walletAddress = await lucid.wallet().address();
const vkeyHash = getAddressDetails(walletAddress).paymentCredential?.hash;
if (!vkeyHash) throw new Error('Cannot derive payment key hash from wallet address');
// Validate new owner address
let newOwnerDetails;
try {
newOwnerDetails = lucid.utils.getAddressDetails(new_owner_address);
newOwnerDetails = getAddressDetails(new_owner_address);
} catch {
throw new Error(`Invalid new owner address: "${new_owner_address}". Must be a valid bech32 address.`);
}
@@ -574,17 +590,17 @@ The agent's identity NFT has been burned and the 10 AP3X deposit has been return
profile.capabilities, profile.framework, profile.endpoint,
profile.registeredAt
);
const registryScript = { type: 'PlutusV3', script: REGISTRY_SCRIPT_CBOR };
const registryAddress = lucid.utils.validatorToAddress(lucidCompat(registryScript));
const registryScript = { type: 'PlutusV3' as const, script: REGISTRY_SCRIPT_CBOR };
const registryAddress = validatorToAddress('Mainnet', registryScript);
const spendRedeemer = Data.to(new Constr(0, [])); // Update (transfer uses Update redeemer)
const tx = await lucid.newTx()
.collectFrom([utxo], spendRedeemer)
.attachSpendingValidator(lucidCompat(registryScript))
.payToContract(registryAddress, { inline: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.attach.SpendingValidator(registryScript)
.pay.ToAddressWithData(registryAddress, { kind: "inline", value: newDatum }, { lovelace: MIN_AP3X_DEPOSIT, [nftUnit]: 1n })
.addSigner(walletAddress)
.complete();
const signedTx = await tx.sign().complete();
const signedTx = await tx.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, 0, registryAddress);
@@ -640,17 +656,17 @@ Ownership has been transferred. The new owner can now update, transfer, or dereg
if (!profile.ownerVkeyHash) throw new Error('Could not parse agent owner from registry datum');
const lucid = await initLucid(mnemonic);
const senderAddress = await lucid.wallet.address();
const recipientAddress = lucid.utils.credentialToAddress({ type: 'Key', hash: profile.ownerVkeyHash });
const senderAddress = await lucid.wallet().address();
const recipientAddress = credentialToAddress('Mainnet', { type: 'Key', hash: profile.ownerVkeyHash });
const minAda = 2_000_000n;
const safetyCheck = safetyLayer.checkTransaction(Number(minAda));
if (!safetyCheck.allowed) throw new Error(`Safety limit exceeded: ${safetyCheck.reason}`);
const tx = await lucid.newTx()
.payToAddress(recipientAddress, { lovelace: minAda })
.attachMetadata(AGENT_MESSAGE_LABEL, { msg: ['a2a'], from: senderAddress, to: agent_id, type: message_type, payload })
.pay.ToAddress(recipientAddress, { lovelace: minAda })
.attachMetadata(AGENT_MESSAGE_LABEL, { msg: ['a2a'], from: metadataStr(senderAddress), to: metadataStr(agent_id), type: message_type, payload: metadataStr(payload) })
.complete();
const signedTx = await tx.sign().complete();
const signedTx = await tx.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, Number(minAda), recipientAddress);

View File

@@ -1,7 +1,7 @@
/**
* Custom Lucid Provider backed by Ogmios HTTP JSON-RPC + Vector submit-api.
*
* Implements the lucid-cardano Provider interface so we can use Lucid's
* Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's
* transaction building/signing without depending on Blockfrost.
*/
import fetch from 'cross-fetch';
@@ -12,8 +12,8 @@ import type {
OutRef,
Credential,
Delegation,
// @ts-ignore - type aliases used as string in lucid
} from 'lucid-cardano';
EvalRedeemer,
} from '@lucid-evolution/lucid';
interface OgmiosProviderConfig {
ogmiosUrl: string;
@@ -25,7 +25,6 @@ export class OgmiosProvider implements Provider {
private ogmiosUrl: string;
private submitUrl: string;
private koiosUrl: string | undefined;
private walletAddress: string | undefined;
constructor(config: OgmiosProviderConfig) {
// Strip trailing slashes for consistent URL building
@@ -34,14 +33,6 @@ export class OgmiosProvider implements Provider {
this.koiosUrl = config.koiosUrl?.replace(/\/+$/, '');
}
/**
* Store the wallet's full bech32 address so credential-based lookups
* (used internally by Lucid during tx building) can resolve to it.
*/
setWalletAddress(address: string): void {
this.walletAddress = address;
}
/**
* Send a JSON-RPC request to Ogmios
*/
@@ -93,7 +84,9 @@ export class OgmiosProvider implements Provider {
collateralPercentage: result.collateralPercentage ?? 150,
maxCollateralInputs: result.maxCollateralInputs ?? 3,
costModels: this.parseCostModels(result.plutusCostModels ?? result.costModels ?? {}),
minfeeRefscriptCostPerByte: result.minFeeReferenceScripts?.base ?? 15,
minFeeRefScriptCostPerByte: result.minFeeReferenceScripts?.base ?? 15,
drepDeposit: BigInt(result.delegateRepresentativeDeposit?.ada?.lovelace ?? 500000000),
govActionDeposit: BigInt(result.governanceActionDeposit?.ada?.lovelace ?? 100000000000),
} as ProtocolParameters;
}
@@ -125,21 +118,14 @@ export class OgmiosProvider implements Provider {
}
async getUtxos(addressOrCredential: string | Credential): Promise<UTxO[]> {
let address: string;
if (typeof addressOrCredential === 'string') {
address = addressOrCredential;
} else if (this.walletAddress) {
// Lucid's wallet internally calls getUtxos(paymentCredential) during tx building.
// Ogmios requires a full bech32 address, so use the stored wallet address.
address = this.walletAddress;
} else {
if (typeof addressOrCredential !== 'string') {
throw new Error(
'Credential-based UTxO lookup requires a wallet address. ' +
'Call provider.setWalletAddress(addr) after selecting a wallet.'
'Credential-based UTxO lookup is not supported by Ogmios. ' +
'Please provide a bech32 address string.'
);
}
const result = await this.rpc('queryLedgerState/utxo', { addresses: [address] });
const result = await this.rpc('queryLedgerState/utxo', { addresses: [addressOrCredential] });
return this.ogmiosUtxosToLucid(result);
}
@@ -149,23 +135,33 @@ export class OgmiosProvider implements Provider {
}
async getUtxoByUnit(unit: string): Promise<UTxO> {
// This requires an indexer; try Koios if available
const policyId = unit.slice(0, 56);
const assetName = unit.slice(56);
// Try Koios indexer first (fastest path)
if (this.koiosUrl) {
const policyId = unit.slice(0, 56);
const assetName = unit.slice(56);
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _asset_list: [[policyId, assetName]] }),
});
if (response.ok) {
const data = await response.json();
if (data.length > 0) {
return this.koiosUtxoToLucid(data[0]);
try {
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _asset_list: [[policyId, assetName]] }),
});
if (response.ok) {
const data = await response.json();
if (data.length > 0) {
return this.koiosUtxoToLucid(data[0]);
}
}
} catch (err) {
// Koios unreachable — caller can fall back to scanning UTxOs
}
}
throw new Error('getUtxoByUnit requires an indexer (Koios). Not available or asset not found.');
throw new Error(
`Asset ${policyId.slice(0, 8)}...${assetName ? assetName.slice(0, 8) + '...' : '(empty)'} not found. ` +
`${this.koiosUrl ? 'Koios returned no results.' : 'No Koios indexer configured.'} ` +
`The asset may not exist on-chain yet (wait for TX confirmation) or may have been burned.`
);
}
async getUtxosByOutRef(outRefs: OutRef[]): Promise<UTxO[]> {
@@ -263,9 +259,31 @@ export class OgmiosProvider implements Provider {
return false;
}
/**
* Evaluate a transaction (lucid-evolution Provider interface).
* Returns execution units for script validation as EvalRedeemer[].
*/
async evaluateTx(tx: string, additionalUTxOs?: UTxO[]): Promise<EvalRedeemer[]> {
const result = await this.rpc('evaluateTransaction', {
transaction: { cbor: tx },
});
// Parse result to EvalRedeemer[] format
if (Array.isArray(result)) {
return result.map((item: any) => ({
ex_units: {
mem: item.budget?.memory ?? 0,
steps: item.budget?.cpu ?? 0,
},
redeemer_index: item.validator?.index ?? 0,
redeemer_tag: item.validator?.purpose ?? 'spend',
}));
}
return [];
}
/**
* Evaluate a transaction without submitting (dry run).
* Returns execution units for script validation.
* Backward-compatible alias — returns raw Ogmios response.
*/
async evaluateTransaction(txCborHex: string): Promise<any> {
return this.rpc('evaluateTransaction', {

View File

@@ -1,19 +1,8 @@
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Lucid, fromText, Data, applyDoubleCborEncoding } from 'lucid-cardano';
import type { SpendingValidator } from 'lucid-cardano';
import { Lucid, fromText, Data, applyDoubleCborEncoding, validatorToAddress, validatorToScriptHash, getAddressDetails } from '@lucid-evolution/lucid';
import type { SpendingValidator } from '@lucid-evolution/lucid';
/**
* Lucid v0.10.x lacks PlutusV3 support. This helper downcasts PlutusV3 to
* PlutusV2 for hashing/address derivation (same blake2b-224 algorithm).
* Use this when passing validators to any Lucid method.
*/
function lucidCompat(validator: SpendingValidator): SpendingValidator {
if ((validator.type as string) === 'PlutusV3') {
return { ...validator, type: 'PlutusV2' as const };
}
return validator;
}
import * as dotenv from 'dotenv';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
@@ -83,7 +72,7 @@ async function initLucid(mnemonic: string, accountIndex: number = 0) {
});
// Vector uses --mainnet flag, so addresses are addr1... format
const lucid = await Lucid.new(provider, 'Mainnet');
const lucid = await Lucid(provider, 'Mainnet');
if (!mnemonic) {
throw new Error('mnemonic is required');
@@ -97,23 +86,20 @@ async function initLucid(mnemonic: string, accountIndex: number = 0) {
throw new Error(`Invalid mnemonic: Expected 12, 15, 18, 21 or 24 words, got ${words.length}`);
}
lucid.selectWalletFromSeed(trimmedMnemonic, { accountIndex });
lucid.selectWallet.fromSeed(trimmedMnemonic, { accountIndex });
const address = await lucid.wallet.address();
const address = await lucid.wallet().address();
if (!address) {
throw new Error('Failed to derive address from mnemonic');
}
// Store the full wallet address so credential-based UTxO lookups resolve correctly
provider.setWalletAddress(address);
return lucid;
}
// Get wallet info
export async function getWalletInfo(mnemonic: string, accountIndex: number = 0): Promise<VectorWalletInfo> {
const lucid = await initLucid(mnemonic, accountIndex);
const address = await lucid.wallet.address();
const address = await lucid.wallet().address();
const utxos = await lucid.utxosAt(address);
let adaBalance = '0';
@@ -187,31 +173,26 @@ export async function sendAda(
}
const lucid = await initLucid(mnemonic);
const senderAddress = await lucid.wallet.address();
const senderAddress = await lucid.wallet().address();
// Validate recipient address
try {
// @ts-ignore
lucid.utils.getAddressDetails(recipientAddress);
getAddressDetails(recipientAddress);
} catch (error) {
throw new Error(`Invalid recipient address: ${error instanceof Error ? error.message : String(error)}`);
}
// Build transaction
// @ts-ignore
let tx = lucid.newTx()
.payToAddress(recipientAddress, { lovelace: BigInt(lovelaceAmount) });
.pay.ToAddress(recipientAddress, { lovelace: BigInt(lovelaceAmount) });
if (metadata) {
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
// @ts-ignore
tx = tx.attachMetadata(674, parsedMetadata);
}
// @ts-ignore
tx = await tx.complete();
// @ts-ignore
const signedTx = await tx.sign().complete();
const signBuilder = await tx.complete();
const signedTx = await signBuilder.sign.withWallet().complete();
const txHash = await signedTx.submit();
// Record in safety layer
@@ -251,11 +232,10 @@ export async function sendTokens(
}
const lucid = await initLucid(mnemonic);
const senderAddress = await lucid.wallet.address();
const senderAddress = await lucid.wallet().address();
try {
// @ts-ignore
lucid.utils.getAddressDetails(recipientAddress);
getAddressDetails(recipientAddress);
} catch (error) {
throw new Error(`Invalid recipient address: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -272,17 +252,14 @@ export async function sendTokens(
? BigInt(Math.floor(adaAmount * 1_000_000))
: BigInt(2_000_000); // Default min ADA
// @ts-ignore
let tx = lucid.newTx()
.payToAddress(recipientAddress, {
.pay.ToAddress(recipientAddress, {
lovelace: outputLovelace,
...assets,
});
// @ts-ignore
tx = await tx.complete();
// @ts-ignore
const signedTx = await tx.sign().complete();
const signBuilder = await tx.complete();
const signedTx = await signBuilder.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, Number(outputLovelace), recipientAddress);
@@ -325,7 +302,6 @@ export async function buildTransaction(
const lucid = await initLucid(mnemonic);
// @ts-ignore
let tx = lucid.newTx();
for (const output of outputs) {
@@ -337,27 +313,22 @@ export async function buildTransaction(
assets[unit] = BigInt(qty);
}
}
tx = tx.payToAddress(output.address, assets);
tx = tx.pay.ToAddress(output.address, assets);
}
if (metadata) {
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
// @ts-ignore
tx = tx.attachMetadata(674, parsedMetadata);
}
// @ts-ignore
const completedTx = await tx.complete();
// @ts-ignore
const fee = completedTx.fee;
// @ts-ignore
const txJson = (completedTx as any).toJSON?.() ?? {};
const fee = txJson?.body?.fee ?? '0';
const txHash = completedTx.toHash();
// @ts-ignore
const txCbor = completedTx.toString();
const txCbor = completedTx.toCBOR();
if (submit) {
// @ts-ignore
const signedTx = await completedTx.sign().complete();
const signedTx = await completedTx.sign.withWallet().complete();
const submittedHash = await signedTx.submit();
safetyLayer.recordTransaction(submittedHash, totalLovelace, outputs.map(o => o.address).join(', '));
@@ -405,21 +376,16 @@ export async function deployContract(
script: applyDoubleCborEncoding(scriptCbor),
};
// @ts-ignore
const scriptAddress = lucid.utils.validatorToAddress(lucidCompat(validator));
// @ts-ignore
const scriptHash = lucid.utils.validatorToScriptHash(lucidCompat(validator));
const scriptAddress = validatorToAddress('Mainnet', validator);
const scriptHash = validatorToScriptHash(validator);
const datum = initialDatum || Data.void();
// @ts-ignore
let tx = lucid.newTx()
.payToContract(scriptAddress, { inline: datum }, { lovelace: BigInt(lovelaceAmount) });
.pay.ToAddressWithData(scriptAddress, { kind: "inline", value: datum }, { lovelace: BigInt(lovelaceAmount) });
// @ts-ignore
tx = await tx.complete();
// @ts-ignore
const signedTx = await tx.sign().complete();
const signBuilder = await tx.complete();
const signedTx = await signBuilder.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, lovelaceAmount, scriptAddress);
@@ -446,15 +412,14 @@ export async function interactWithContract(
assets: Record<string, string> | null = null,
): Promise<VectorInteractContractResult> {
const lucid = await initLucid(mnemonic);
const walletAddress = await lucid.wallet.address();
const walletAddress = await lucid.wallet().address();
const validator: SpendingValidator = {
type: scriptType as any,
script: applyDoubleCborEncoding(scriptCbor),
};
// @ts-ignore
const scriptAddress = lucid.utils.validatorToAddress(lucidCompat(validator));
const scriptAddress = validatorToAddress('Mainnet', validator);
if (action === 'lock') {
const safetyCheck = safetyLayer.checkTransaction(lovelaceAmount);
@@ -470,14 +435,11 @@ export async function interactWithContract(
}
}
// @ts-ignore
let tx = lucid.newTx()
.payToContract(scriptAddress, { inline: datumData }, outputAssets);
.pay.ToAddressWithData(scriptAddress, { kind: "inline", value: datumData }, outputAssets);
// @ts-ignore
tx = await tx.complete();
// @ts-ignore
const signedTx = await tx.sign().complete();
const signBuilder = await tx.complete();
const signedTx = await signBuilder.sign.withWallet().complete();
const txHash = await signedTx.submit();
safetyLayer.recordTransaction(txHash, lovelaceAmount, scriptAddress);
@@ -492,9 +454,9 @@ export async function interactWithContract(
// SPEND: collect from script
let scriptUtxos;
if (utxoRef) {
scriptUtxos = await lucid.provider.getUtxosByOutRef([utxoRef]);
scriptUtxos = await lucid.utxosByOutRef([utxoRef]);
} else {
scriptUtxos = await lucid.provider.getUtxos(scriptAddress);
scriptUtxos = await lucid.utxosAt(scriptAddress);
}
if (!scriptUtxos || scriptUtxos.length === 0) {
@@ -503,27 +465,23 @@ export async function interactWithContract(
const redeemerData = redeemer || Data.void();
// @ts-ignore
let tx = lucid.newTx()
.collectFrom(scriptUtxos, redeemerData)
.attachSpendingValidator(lucidCompat(validator))
.addSigner(walletAddress);
let completedTx;
try {
// @ts-ignore
tx = await tx.complete();
completedTx = await lucid.newTx()
.collectFrom(scriptUtxos, redeemerData)
.attach.SpendingValidator(validator)
.addSigner(walletAddress)
.complete();
} catch (err) {
// Retry without native UPLC evaluator if it fails
// @ts-ignore
tx = await lucid.newTx()
completedTx = await lucid.newTx()
.collectFrom(scriptUtxos, redeemerData)
.attachSpendingValidator(lucidCompat(validator))
.attach.SpendingValidator(validator)
.addSigner(walletAddress)
.complete({ nativeUplc: false });
.complete({ localUPLCEval: false });
}
// @ts-ignore
const signedTx = await tx.sign().complete();
const signedTx = await completedTx.sign.withWallet().complete();
const txHash = await signedTx.submit();
// No spend recording for collecting — funds are coming back to wallet
@@ -541,7 +499,6 @@ export async function interactWithContract(
export function registerVectorTools(server: McpServer) {
// vector_get_balance — Get balance for any address
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_get_balance",
"Get ADA and token balances for a Vector address",
@@ -615,7 +572,6 @@ ${tokens.length > 0 ? `Token Holdings (${tokens.length}):\n${tokenList}` : 'No t
);
// vector_get_address — Get the agent's wallet address and balance
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_get_address",
"Get the Vector wallet address, balance, and token holdings derived from a mnemonic",
@@ -664,7 +620,6 @@ ${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length}
);
// vector_get_utxos — List UTxOs for an address or the wallet
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_get_utxos",
"List unspent transaction outputs (UTxOs) for a Vector address or a wallet derived from a mnemonic",
@@ -692,7 +647,7 @@ ${walletInfo.tokens.length > 0 ? `## Token Holdings (${walletInfo.tokens.length}
} else {
if (!mnemonic) throw new Error('Provide either address or mnemonic');
const lucid = await initLucid(mnemonic);
queryAddress = await lucid.wallet.address();
queryAddress = await lucid.wallet().address();
utxos = await lucid.utxosAt(queryAddress);
}
@@ -734,7 +689,6 @@ ${utxoList}`,
);
// vector_send_apex — Send APEX with safety limits (or craft unsigned TX)
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_send_apex",
"Send ADA from a wallet to a recipient address. Set unsigned_only=true to return unsigned CBOR without submitting (transaction-crafter mode).",
@@ -754,19 +708,15 @@ ${utxoList}`,
if (unsigned_only) {
const lovelaceAmount = BigInt(Math.floor(amount * 1_000_000));
const lucid = await initLucid(mnemonic);
// @ts-ignore
let txBuilder = lucid.newTx().payToAddress(recipientAddress, { lovelace: lovelaceAmount });
let txBuilder = lucid.newTx().pay.ToAddress(recipientAddress, { lovelace: lovelaceAmount });
if (metadata) {
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
// @ts-ignore
txBuilder = txBuilder.attachMetadata(674, parsedMetadata);
}
// @ts-ignore
const tx = await txBuilder.complete();
// @ts-ignore
const fee = tx.fee;
// @ts-ignore
const txCbor = tx.toString();
const txJson = (tx as any).toJSON?.() ?? {};
const fee = txJson?.body?.fee ?? '0';
const txCbor = tx.toCBOR();
return {
content: [{
type: "text",
@@ -819,7 +769,6 @@ Amount: ${result.amount} ADA
);
// vector_send_tokens — Send native tokens with safety limits (or craft unsigned TX)
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_send_tokens",
"Send Vector native tokens from a wallet to a recipient address. Set unsigned_only=true to return unsigned CBOR without submitting.",
@@ -848,14 +797,12 @@ Amount: ${result.amount} ADA
const outputLovelace = adaAmount
? BigInt(Math.floor(adaAmount * 1_000_000))
: BigInt(2_000_000);
// @ts-ignore
const tx = await lucid.newTx()
.payToAddress(recipientAddress, { lovelace: outputLovelace, [unit]: BigInt(amount) })
.pay.ToAddress(recipientAddress, { lovelace: outputLovelace, [unit]: BigInt(amount) })
.complete();
// @ts-ignore
const fee = tx.fee;
// @ts-ignore
const txCbor = tx.toString();
const txJson = (tx as any).toJSON?.() ?? {};
const fee = txJson?.body?.fee ?? '0';
const txCbor = tx.toCBOR();
return {
content: [{
type: "text",
@@ -916,7 +863,6 @@ Included ADA: ${result.ada} ADA
);
// vector_get_spend_limits — Check safety layer status
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_get_spend_limits",
"Check current spend limits and remaining daily budget",
@@ -961,7 +907,6 @@ ${log.length > 0 ? `## Recent Transactions (last ${Math.min(5, log.length)}):\n$
);
// vector_build_transaction — Build a complex multi-output transaction
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_build_transaction",
"Build a complex multi-output transaction with metadata. Set submit=true to sign and submit, or false to return unsigned CBOR for review.",
@@ -1032,7 +977,6 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction
);
// vector_dry_run — Simulate a transaction without submitting
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_dry_run",
"Simulate a transaction without submitting — returns fee estimate and validation result",
@@ -1060,7 +1004,6 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction
if (!mnemonic) throw new Error('mnemonic is required when building from outputs');
const lucid = await initLucid(mnemonic);
// @ts-ignore
let tx = lucid.newTx();
for (const output of outputs) {
const assets: Record<string, bigint> = { lovelace: BigInt(output.lovelace) };
@@ -1069,22 +1012,19 @@ Use vector_dry_run with this CBOR to simulate, or call vector_build_transaction
assets[unit] = BigInt(qty);
}
}
tx = tx.payToAddress(output.address, assets);
tx = tx.pay.ToAddress(output.address, assets);
}
if (metadata) {
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
// @ts-ignore
tx = tx.attachMetadata(674, parsedMetadata);
}
// @ts-ignore
const completedTx = await tx.complete();
// @ts-ignore
feeFromBuild = String(completedTx.fee);
// @ts-ignore
const signedTx = await completedTx.sign().complete();
cborToEvaluate = signedTx.toString();
const txJson = (completedTx as any).toJSON?.() ?? {};
feeFromBuild = String(txJson?.body?.fee ?? '0');
const signedTx = await completedTx.sign.withWallet().complete();
cborToEvaluate = signedTx.toCBOR();
}
if (!cborToEvaluate) {
@@ -1165,7 +1105,6 @@ No transaction was submitted to the network.`,
);
// vector_get_transaction_history — Get transaction history via Koios
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_get_transaction_history",
"Get transaction history for a Vector address via Koios indexed queries",
@@ -1185,7 +1124,7 @@ No transaction was submitted to the network.`,
if (!queryAddress) {
if (!mnemonic) throw new Error('Provide either address or mnemonic');
const lucid = await initLucid(mnemonic);
queryAddress = await lucid.wallet.address();
queryAddress = await lucid.wallet().address();
}
const provider = new OgmiosProvider({
@@ -1240,7 +1179,6 @@ ${txList}
);
// vector_deploy_contract — Deploy a Plutus/Aiken smart contract
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_deploy_contract",
"Deploy a Plutus/Aiken smart contract to Vector by sending funds to its script address",
@@ -1299,7 +1237,6 @@ Funds locked at the script address. Use vector_interact_contract to interact wit
);
// vector_interact_contract — Interact with a deployed smart contract
// @ts-ignore: MCP SDK deep type instantiation
server.tool(
"vector_interact_contract",
"Interact with a deployed Plutus/Aiken smart contract — lock funds or spend from it",

View File

@@ -2,13 +2,14 @@ import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { startServer, stopServer, callTool, getMnemonic, wait, ServerContext } from './setup.ts';
// Always-succeeds PlutusV3 validator (accepts any datum/redeemer/context)
const ALWAYS_SUCCEEDS_V3 = '49480100002221200101';
// Always-succeeds PlutusV2 validator (accepts any datum/redeemer/context, returns True)
const ALWAYS_SUCCEEDS_V2 = '49480100002221200101';
let ctx: ServerContext;
let mnemonic: string;
let walletAddress: string;
let walletHasAda = false;
let walletBalanceAda = 0;
let agentDid: string | null = null;
before(async () => {
@@ -62,6 +63,7 @@ describe('Wallet Tools', () => {
const balanceMatch = text.match(/ADA Balance:\s*([\d.]+)/);
if (balanceMatch && parseFloat(balanceMatch[1]) > 0) {
walletHasAda = true;
walletBalanceAda = parseFloat(balanceMatch[1]);
console.log(`Wallet funded: ${balanceMatch[1]} ADA`);
} else {
console.log('Wallet has 0 ADA - transaction tests will verify error handling');
@@ -108,6 +110,41 @@ describe('History & Limits', () => {
});
});
// ─── UTxO Consolidation ─────────────────────────────────────────────────────
// Repeated test runs fragment the wallet into many small UTxOs. Plutus script
// transactions need collateral (≥5 ADA in ≤3 inputs). Consolidate up-front so
// later tests don't fail from fragmentation.
describe('UTxO Consolidation', () => {
test('consolidate wallet UTxOs', { timeout: 120_000 }, async () => {
if (!walletHasAda) return;
const utxoText = await callTool(ctx.client, 'vector_get_utxos', { mnemonic });
const countMatch = utxoText.match(/Total:\s*(\d+)/);
const utxoCount = countMatch ? parseInt(countMatch[1], 10) : 0;
if (utxoCount <= 3) {
console.log(`Only ${utxoCount} UTxO(s) — no consolidation needed`);
return;
}
// Send most of the balance to self, forcing Lucid to consume many inputs.
// This reduces the UTxO set to ~2 (output + change).
const consolidateAmount = Math.floor(walletBalanceAda) - 2; // leave buffer for fees
if (consolidateAmount < 2) {
console.log(`Balance too low (${walletBalanceAda} ADA) to consolidate`);
return;
}
console.log(`${utxoCount} UTxOs detected — consolidating ${consolidateAmount} ADA to self...`);
const text = await callTool(ctx.client, 'vector_send_apex', {
recipientAddress: walletAddress,
amount: consolidateAmount,
mnemonic,
});
console.log(text);
assertSuccessOrKnownError(text, /Transaction Hash:/, 'consolidate UTxOs');
console.log('Waiting 10s for consolidation tx to confirm...');
await wait(10);
});
});
// ─── Transaction Tools ──────────────────────────────────────────────────────
describe('Transaction Tools', () => {
@@ -187,8 +224,8 @@ describe('Smart Contract Tools', () => {
await wait(10);
}
const text = await callTool(ctx.client, 'vector_deploy_contract', {
scriptCbor: ALWAYS_SUCCEEDS_V3,
scriptType: 'PlutusV3',
scriptCbor: ALWAYS_SUCCEEDS_V2,
scriptType: 'PlutusV2',
mnemonic,
});
console.log(text);
@@ -201,8 +238,8 @@ describe('Smart Contract Tools', () => {
await wait(10);
}
const text = await callTool(ctx.client, 'vector_interact_contract', {
scriptCbor: ALWAYS_SUCCEEDS_V3,
scriptType: 'PlutusV3',
scriptCbor: ALWAYS_SUCCEEDS_V2,
scriptType: 'PlutusV2',
action: 'lock',
mnemonic,
datum: 'd87980',
@@ -218,8 +255,8 @@ describe('Smart Contract Tools', () => {
await wait(10);
}
const text = await callTool(ctx.client, 'vector_interact_contract', {
scriptCbor: ALWAYS_SUCCEEDS_V3,
scriptType: 'PlutusV3',
scriptCbor: ALWAYS_SUCCEEDS_V2,
scriptType: 'PlutusV2',
action: 'spend',
mnemonic,
redeemer: 'd87980',
@@ -268,7 +305,7 @@ describe('Agent Network Tools', () => {
test('vector_get_agent_profile', { timeout: 120_000 }, async () => {
// Use a known DID format even if registration failed, to test the tool responds
const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V3}:${'a'.repeat(64)}`;
const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V2}:${'a'.repeat(64)}`;
const text = await callTool(ctx.client, 'vector_get_agent_profile', { agent_id: testDid });
console.log(text);
assertSuccessOrKnownError(text, /Agent Profile/, 'vector_get_agent_profile');
@@ -312,7 +349,7 @@ describe('Agent Network Tools', () => {
test('vector_message_agent', { timeout: 120_000 }, async () => {
// Use a known DID format even if registration failed, to test the tool responds
const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V3}:${'a'.repeat(64)}`;
const testDid = agentDid || `did:vector:agent:${ALWAYS_SUCCEEDS_V2}:${'a'.repeat(64)}`;
if (walletHasAda && agentDid) {
console.log('Waiting 10s for UTxOs to settle before message...');
await wait(10);