Math.floor(amountApex * 1_000_000) silently drops a lovelace whenever the IEEE-754 product lands just under the intended integer (empirically confirmed on this Node build: 1.005, 2.05, and 8.11 AP3X all lose exactly 1 lovelace this way). Adds apexToLovelace(amountApex): bigint in packages/shared/src/tx.ts - a string-exact conversion via Number.prototype.toString() (not toFixed(), which reveals the same binary noise this function exists to avoid) that rejects non-finite, negative, and >6-decimal-place amounts instead of silently truncating. Replaces every AP3X-to-lovelace float multiply on a money path: buildSendApex and buildSendTokens' apexAmount path in build.ts, and the three stake conversions (critique/endorse/proposal-lock) in gov-build.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
809 lines
35 KiB
TypeScript
809 lines
35 KiB
TypeScript
// Keyless build core for the self-improvement family (spec PR 8).
|
|
// Constructs UNSIGNED transactions only. The user's key never enters this
|
|
// package: signing happens on the local signer (packages/signer), submission
|
|
// via vector_submit_transaction. Constants, datum parsers, token-name
|
|
// derivation, and helpers here are transcribed from the (still custodial,
|
|
// pending Task 5) packages/builder/src/vector/self-improvement.ts, typed and
|
|
// with instanceof Constr guards added to the parsers - datum field indices
|
|
// and Data.to/Constr layouts are otherwise unchanged, since the deployed
|
|
// module validators parse these exact bytes.
|
|
import {
|
|
fromText, toText, Data, Constr, credentialToAddress, getAddressDetails, SLOT_CONFIG_NETWORK,
|
|
} from '@lucid-evolution/lucid';
|
|
import type { LucidEvolution, UTxO } from '@lucid-evolution/lucid';
|
|
import { blake2b } from '@noble/hashes/blake2b';
|
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
import type { OgmiosProvider } from '@apexfusion/vector-mcp-shared/provider';
|
|
import { apexToLovelace } from '@apexfusion/vector-mcp-shared/tx';
|
|
import type {
|
|
VectorBuildStakeResult, VectorBuildProposalLockResult, VectorBuildProposalSpendResult,
|
|
} from '@apexfusion/vector-mcp-shared/types';
|
|
import { toBuildResult } from './build.js';
|
|
import { getRegistryAddress } from './registry-build.js';
|
|
|
|
// ─── Module constants (env-overridable exactly as the custodial module) ────
|
|
|
|
// Agent Registry policy ID - this module's own copy (self-improvement.ts has
|
|
// always declared this independently of registry-build.ts's REGISTRY_POLICY_ID;
|
|
// preserved as-is, not unified, since that's a separate, out-of-scope change).
|
|
export const AGENT_REGISTRY_POLICY = process.env.AGENT_REGISTRY_POLICY || 'be1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01';
|
|
|
|
// Module contract hashes (from deploy_state.json). GOV_* names are pre-existing
|
|
// deployed config keys - kept as-is (renaming them is a separate, out-of-scope
|
|
// change; see the PR body's env-var exemption note).
|
|
export const GOV_PROPOSAL_SPEND_HASH = process.env.GOV_PROPOSAL_SPEND_HASH || 'f815f51a76002d6a973e83fecf60f45473e040acee85c631fcce134d';
|
|
export const GOV_PROPOSAL_MINT_HASH = process.env.GOV_PROPOSAL_MINT_HASH || 'e8f38052352a3d20c5fe025e2a02d615826a154b26f2239286b8d565';
|
|
export const GOV_CRITIQUE_SPEND_HASH = process.env.GOV_CRITIQUE_SPEND_HASH || 'ced52074861af95e2082004d6061b0fc4bb30fded61f9605bfc20e55';
|
|
export const GOV_CRITIQUE_MINT_HASH = process.env.GOV_CRITIQUE_MINT_HASH || '2e252a89894d379ce5c0023a57de4627056e4a96da72bd8fedba04bd';
|
|
export const GOV_ENDORSEMENT_SPEND_HASH = process.env.GOV_ENDORSEMENT_SPEND_HASH || '5fc449848d85f30287e5bc0bd2b3e95d872ef97be27f1480c12f1a9d';
|
|
export const GOV_TREASURY_ADDRESS = process.env.GOV_TREASURY_ADDRESS || 'addr1wx434t2jc3m5uhdf7tq05xjdqu3q5z7a2lhrmn5mapsd43srh7ll8';
|
|
|
|
// Reference script UTxOs (CIP-33) - for the validated proposal spend+mint (Task 4).
|
|
export const GOV_PROPOSAL_SPEND_REF = process.env.GOV_PROPOSAL_SPEND_REF || 'c70a410c9c0c543a0a1103049680b89cbf6fd2277e8469c4d52632dc52b80996#0';
|
|
export const GOV_PROPOSAL_MINT_REF = process.env.GOV_PROPOSAL_MINT_REF || 'c40b64fff5c4056689354076aa3d06431d786a4f8f0c1e492e70261d2309e50a#0';
|
|
|
|
// Infrastructure UTxOs (module reference inputs).
|
|
export const GOV_PARAMS_UTXO = process.env.GOV_PARAMS_UTXO || '2c082e833649175b4a543a5a0cf61f9b736acdfa0d315d1184645185e9a52796#0';
|
|
export const GOV_ORACLE_UTXO = process.env.GOV_ORACLE_UTXO || '7a23dfdf9468dd35cee3cad03008f2538c86834d4e5140e0ffaf2ff93e7c04a7#0';
|
|
export const GOV_CROSSREFS_UTXO = process.env.GOV_CROSSREFS_UTXO || '96a4acff8be0fb96b3839ee6c9c1fa75809b94f4967218eaf813ac56b939c4b2#0';
|
|
|
|
// Proposal state CBOR constructor tags.
|
|
export const STATE_NAMES: Record<number, string> = {
|
|
0: 'Open',
|
|
1: 'Amended',
|
|
2: 'Adopted',
|
|
3: 'Rejected',
|
|
4: 'Expired',
|
|
5: 'Withdrawn',
|
|
};
|
|
|
|
export const TYPE_NAMES: Record<number, string> = {
|
|
0: 'ParameterChange',
|
|
1: 'TreasurySpend',
|
|
2: 'ProtocolUpgrade',
|
|
3: 'GameActivation',
|
|
4: 'GeneralSuggestion',
|
|
};
|
|
|
|
export const PRIORITY_NAMES: Record<number, string> = {
|
|
0: 'Standard',
|
|
1: 'Emergency',
|
|
};
|
|
|
|
export const CRITIQUE_TYPE_NAMES: Record<number, string> = {
|
|
0: 'Supportive',
|
|
1: 'Opposing',
|
|
2: 'Amendment',
|
|
};
|
|
|
|
// Wire-level stake minimums (mirrored in the future tool schemas, Task 5) -
|
|
// enforced here too so a direct caller of buildCritique/buildEndorse cannot
|
|
// bypass them.
|
|
export const MIN_CRITIQUE_STAKE_APEX = 10;
|
|
export const MIN_ENDORSE_STAKE_APEX = 5;
|
|
export const MIN_PROPOSAL_STAKE_APEX = 25;
|
|
|
|
// ─── Slot/time config ───────────────────────────────────────────────────────
|
|
|
|
// Minimal provider surface needed for slot/time queries. OgmiosProvider
|
|
// satisfies this at runtime; the base Lucid `Provider` interface does not
|
|
// declare either method (they are OgmiosProvider-specific additions), so
|
|
// build functions that only take a `lucid` recover this surface via
|
|
// slotClockOf() below rather than taking a redundant provider parameter.
|
|
export interface SlotClockProvider {
|
|
getSystemStartMs(): Promise<number>;
|
|
getNetworkTip?(): Promise<{ slot: number; hash?: string }>;
|
|
}
|
|
|
|
// Recovers the provider a LucidEvolution instance was built with (Lucid()'s
|
|
// own config closure returns the exact same reference it was constructed
|
|
// with - see @lucid-evolution/lucid's Lucid() factory). Lets build functions
|
|
// that only take `lucid` still reach slot/tip queries: callers (including
|
|
// offline tests) supply the extended provider once, at lucidForAddress() time.
|
|
export function slotClockOf(lucid: LucidEvolution): SlotClockProvider {
|
|
const provider = lucid.config().provider;
|
|
if (!provider) throw new Error('Lucid instance has no provider configured - build it via lucidForAddress first.');
|
|
return provider as unknown as SlotClockProvider;
|
|
}
|
|
|
|
// Vector slot config - resolved from chain via Ogmios queryNetwork/startTime.
|
|
// Cached after first query so SLOT_CONFIG_NETWORK is set before Lucid needs
|
|
// it. Module-global cache, same as the custodial original: callers (tests
|
|
// included) must use a consistent zeroTime across calls in one process.
|
|
let vectorZeroTime: number | null = null;
|
|
|
|
export async function ensureSlotConfig(provider: Pick<SlotClockProvider, 'getSystemStartMs'>): Promise<number> {
|
|
if (vectorZeroTime === null) {
|
|
vectorZeroTime = await provider.getSystemStartMs();
|
|
SLOT_CONFIG_NETWORK.Mainnet = { zeroTime: vectorZeroTime, zeroSlot: 0, slotLength: 1000 };
|
|
}
|
|
return vectorZeroTime;
|
|
}
|
|
|
|
// ─── Small helpers ───────────────────────────────────────────────────────────
|
|
|
|
export function lovelaceToApex(lovelace: number | bigint): string {
|
|
return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6);
|
|
}
|
|
|
|
export function scriptHashToAddress(hash: string): string {
|
|
return credentialToAddress('Mainnet', { type: 'Script', hash });
|
|
}
|
|
|
|
export function parseUtxoRef(ref: string): { txHash: string; outputIndex: number } {
|
|
const [txHash, idx] = ref.split('#');
|
|
return { txHash, outputIndex: parseInt(idx, 10) };
|
|
}
|
|
|
|
// Address helper local to this family (mirrors registry-build.ts's
|
|
// paymentKeyHashOf exactly, kept as an independent copy rather than a
|
|
// cross-family import - each build-core file depends only on build.ts, not
|
|
// on a sibling family file; see task-3-report.md for the reasoning).
|
|
//
|
|
// Deliberate exception to that rule (Task 4): buildProposalSpend below DOES
|
|
// import getRegistryAddress from registry-build.ts - the self-improvement
|
|
// family's NFT-resolution fallback needs the registry's own script address
|
|
// to scan, and that address is derived from registry-build.ts's own
|
|
// REGISTRY_SCRIPT_CBOR constant, not something this file could reasonably
|
|
// duplicate without risking the two copies drifting apart. One-directional
|
|
// (gov-build.ts -> registry-build.ts only, never the reverse), so it does
|
|
// not introduce a cycle.
|
|
export function paymentKeyHashOf(address: string): string {
|
|
let details;
|
|
try {
|
|
details = getAddressDetails(address);
|
|
} catch (e) {
|
|
throw new Error(`Invalid address: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
if (details.paymentCredential?.type !== 'Key' || !details.paymentCredential.hash) {
|
|
throw new Error('Address must have a verification key payment credential, not a script credential.');
|
|
}
|
|
return details.paymentCredential.hash;
|
|
}
|
|
|
|
// Derive token name: prefix + blake2b_256(CBOR(data))[0..27]
|
|
export function deriveTokenName(prefix: string, cborHex: string): string {
|
|
const hashBytes = blake2b(Buffer.from(cborHex, 'hex'), { dkLen: 32 });
|
|
const prefixHex = Buffer.from(prefix, 'utf-8').toString('hex');
|
|
const hashSlice = Buffer.from(hashBytes).toString('hex').slice(0, 54); // 27 bytes = 54 hex chars
|
|
return prefixHex + hashSlice;
|
|
}
|
|
|
|
// AS-IS, audit-#10-verified: this flat Constr(0, [txHash, idx]) through
|
|
// Lucid's own Data.to (indefinite-length CBOR arrays) reproduces the token
|
|
// name actually minted by the deployed bytecode - see
|
|
// packages/builder/test/gov-build.test.ts's golden-pin test and
|
|
// packages/builder/test/fixtures/gov-state.fixture.json's goldenTokenPin,
|
|
// both captured live 2026-07-30. Do not "fix" the encoding.
|
|
export function deriveProposalTokenName(txHash: string, outputIndex: number): string {
|
|
const outRefCbor = Data.to(new Constr(0, [txHash, BigInt(outputIndex)]));
|
|
return deriveTokenName('prop_', outRefCbor);
|
|
}
|
|
|
|
export function deriveActivityTokenName(agentDid: string): string {
|
|
const didBytes = Buffer.from(agentDid, 'hex');
|
|
const hashBytes = blake2b(didBytes, { dkLen: 32 });
|
|
const prefixHex = Buffer.from('pact_', 'utf-8').toString('hex');
|
|
const hashSlice = Buffer.from(hashBytes).toString('hex').slice(0, 54);
|
|
return prefixHex + hashSlice;
|
|
}
|
|
|
|
// ─── Filebase IPFS upload ────────────────────────────────────────────────────
|
|
|
|
const FILEBASE_ACCESS_KEY = process.env.FILEBASE_ACCESS_KEY || '';
|
|
const FILEBASE_SECRET_KEY = process.env.FILEBASE_SECRET_KEY || '';
|
|
const FILEBASE_BUCKET = process.env.FILEBASE_BUCKET || '';
|
|
|
|
// A network side effect that lives in the hosted builder by design (moved
|
|
// verbatim from the custodial module): no wallet key material ever enters
|
|
// this function, it only ever sees the already-resolved document text.
|
|
export async function uploadToFilebase(document: string, namePrefix: string): Promise<{ cid: string; hash: string }> {
|
|
if (!FILEBASE_ACCESS_KEY || !FILEBASE_SECRET_KEY || !FILEBASE_BUCKET) {
|
|
throw new Error('Filebase not configured. Set FILEBASE_ACCESS_KEY, FILEBASE_SECRET_KEY, and FILEBASE_BUCKET env vars.');
|
|
}
|
|
|
|
// Canonical JSON: parse then re-stringify for deterministic bytes
|
|
const parsed = JSON.parse(document);
|
|
const canonical = JSON.stringify(parsed);
|
|
const docBytes = new TextEncoder().encode(canonical);
|
|
|
|
// blake2b_256 hash
|
|
const hashBytes = blake2b(docBytes, { dkLen: 32 });
|
|
const hashHex = Buffer.from(hashBytes).toString('hex');
|
|
|
|
const key = `${namePrefix}-${hashHex.slice(0, 16)}.json`;
|
|
|
|
const s3 = new S3Client({
|
|
region: 'us-east-1',
|
|
endpoint: 'https://s3.filebase.com',
|
|
credentials: { accessKeyId: FILEBASE_ACCESS_KEY, secretAccessKey: FILEBASE_SECRET_KEY },
|
|
forcePathStyle: true,
|
|
});
|
|
|
|
const resp = await s3.send(new PutObjectCommand({
|
|
Bucket: FILEBASE_BUCKET,
|
|
Key: key,
|
|
Body: canonical,
|
|
ContentType: 'application/json',
|
|
}));
|
|
|
|
// Filebase returns the CID in response metadata
|
|
const cid = resp.$metadata?.httpStatusCode === 200
|
|
? (resp as unknown as { VersionId?: string }).VersionId || ''
|
|
: '';
|
|
|
|
// If CID not in response, try HeadObject to get it from x-amz-meta-cid
|
|
let finalCid = cid;
|
|
if (!finalCid) {
|
|
const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
|
|
const head = await s3.send(new HeadObjectCommand({ Bucket: FILEBASE_BUCKET, Key: key }));
|
|
finalCid = head.Metadata?.cid || '';
|
|
}
|
|
|
|
if (!finalCid) {
|
|
throw new Error('Filebase upload succeeded but CID not returned. Check bucket is IPFS-enabled.');
|
|
}
|
|
|
|
return { cid: finalCid, hash: hashHex };
|
|
}
|
|
|
|
// ─── Datum parsers ───────────────────────────────────────────────────────────
|
|
|
|
export interface GovProposal {
|
|
proposerDid: string;
|
|
proposalHash: string;
|
|
proposalType: string;
|
|
storageUri: string;
|
|
stakeAmount: number;
|
|
submittedAt: number;
|
|
reviewWindow: number;
|
|
priority: string;
|
|
amendmentCount: number;
|
|
incorporatedCritiques: number;
|
|
state: string;
|
|
}
|
|
|
|
export interface GovCritique {
|
|
criticDid: string;
|
|
proposalRef: Constr<Data>;
|
|
critiqueHash: string;
|
|
storageUri: string;
|
|
critiqueType: string;
|
|
stakeAmount: number;
|
|
submittedAt: number;
|
|
incorporated: boolean;
|
|
}
|
|
|
|
export interface GovEndorsement {
|
|
endorserDid: string;
|
|
proposalRef: Constr<Data>;
|
|
stakeAmount: number;
|
|
createdAt: number;
|
|
}
|
|
|
|
// Parse ProposalDatum from CBOR. Field indices kept exactly as the custodial
|
|
// module (0-11); instanceof Constr guards added around the indexed-Constr
|
|
// fields, matching registry-build.ts's parseAgentDatum pattern.
|
|
export function parseProposalDatum(datumCbor: string): GovProposal | null {
|
|
try {
|
|
const c = Data.from(datumCbor);
|
|
if (!(c instanceof Constr) || c.fields.length < 12) return null;
|
|
|
|
const proposerDid = c.fields[0];
|
|
const proposalHash = c.fields[2];
|
|
const typeField = c.fields[3];
|
|
const priorityField = c.fields[8];
|
|
const stateField = c.fields[11];
|
|
const incorporatedCritiques = c.fields[10];
|
|
if (typeof proposerDid !== 'string' || typeof proposalHash !== 'string') return null;
|
|
if (!(typeField instanceof Constr) || !(priorityField instanceof Constr) || !(stateField instanceof Constr)) return null;
|
|
|
|
return {
|
|
proposerDid,
|
|
proposalHash,
|
|
proposalType: TYPE_NAMES[Number(typeField.index)] || 'Unknown',
|
|
storageUri: toText(c.fields[4] as string),
|
|
stakeAmount: Number(c.fields[5]),
|
|
submittedAt: Number(c.fields[6]),
|
|
reviewWindow: Number(c.fields[7]),
|
|
priority: PRIORITY_NAMES[Number(priorityField.index)] || 'Standard',
|
|
amendmentCount: Number(c.fields[9]),
|
|
incorporatedCritiques: Array.isArray(incorporatedCritiques) ? incorporatedCritiques.length : 0,
|
|
state: STATE_NAMES[Number(stateField.index)] || 'Unknown',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Parse CritiqueDatum from CBOR. Field indices kept exactly as the custodial
|
|
// module (0-8).
|
|
export function parseCritiqueDatum(datumCbor: string): GovCritique | null {
|
|
try {
|
|
const c = Data.from(datumCbor);
|
|
if (!(c instanceof Constr) || c.fields.length < 9) return null;
|
|
|
|
const criticDid = c.fields[0];
|
|
const proposalRef = c.fields[2];
|
|
const critiqueHash = c.fields[3];
|
|
const critiqueTypeField = c.fields[5];
|
|
const incorporatedField = c.fields[8];
|
|
if (typeof criticDid !== 'string' || typeof critiqueHash !== 'string') return null;
|
|
if (!(proposalRef instanceof Constr) || !(critiqueTypeField instanceof Constr) || !(incorporatedField instanceof Constr)) return null;
|
|
|
|
return {
|
|
criticDid,
|
|
proposalRef,
|
|
critiqueHash,
|
|
storageUri: toText(c.fields[4] as string),
|
|
critiqueType: CRITIQUE_TYPE_NAMES[Number(critiqueTypeField.index)] || 'Unknown',
|
|
stakeAmount: Number(c.fields[6]),
|
|
submittedAt: Number(c.fields[7]),
|
|
incorporated: Number(incorporatedField.index) === 1,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Parse EndorsementDatum from CBOR. Field indices kept exactly as the
|
|
// custodial module (0-4).
|
|
export function parseEndorsementDatum(datumCbor: string): GovEndorsement | null {
|
|
try {
|
|
const c = Data.from(datumCbor);
|
|
if (!(c instanceof Constr) || c.fields.length < 5) return null;
|
|
|
|
const endorserDid = c.fields[0];
|
|
const proposalRef = c.fields[2];
|
|
if (typeof endorserDid !== 'string') return null;
|
|
if (!(proposalRef instanceof Constr)) return null;
|
|
|
|
return {
|
|
endorserDid,
|
|
proposalRef,
|
|
stakeAmount: Number(c.fields[3]),
|
|
createdAt: Number(c.fields[4]),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Build: critique ─────────────────────────────────────────────────────────
|
|
|
|
export async function buildCritique(lucid: LucidEvolution, p: {
|
|
agentDid: string;
|
|
proposalTxHash: string;
|
|
proposalOutputIndex: number;
|
|
critiqueType: 'Supportive' | 'Opposing' | 'Amendment';
|
|
stakeApex: number;
|
|
critiqueDocument?: string;
|
|
critiqueHash?: string;
|
|
storageUri?: string;
|
|
}): Promise<VectorBuildStakeResult> {
|
|
const changeAddress = await lucid.wallet().address();
|
|
|
|
// Validation order: stake minimum -> document/hash resolution (Filebase
|
|
// upload if a document was given) -> datum build -> lock build.
|
|
if (!(p.stakeApex >= MIN_CRITIQUE_STAKE_APEX)) {
|
|
throw new Error(`Critique stake must be at least ${MIN_CRITIQUE_STAKE_APEX} AP3X.`);
|
|
}
|
|
const stakeLovelace = apexToLovelace(p.stakeApex);
|
|
|
|
let finalHash = p.critiqueHash;
|
|
let finalUri = p.storageUri;
|
|
let ipfsCid: string | undefined;
|
|
let documentHash: string | undefined;
|
|
|
|
if (p.critiqueDocument) {
|
|
const uploaded = await uploadToFilebase(p.critiqueDocument, 'critique');
|
|
finalHash = uploaded.hash;
|
|
finalUri = `ipfs://${uploaded.cid}`;
|
|
ipfsCid = uploaded.cid;
|
|
documentHash = uploaded.hash;
|
|
}
|
|
|
|
if (!finalHash || finalHash.length !== 64) {
|
|
throw new Error('critiqueHash must be 64 hex characters (32 bytes). Provide critiqueDocument for automatic hashing or critiqueHash manually.');
|
|
}
|
|
if (!finalUri) {
|
|
throw new Error('storageUri is required. Provide critiqueDocument for automatic upload or storageUri manually.');
|
|
}
|
|
|
|
const vkeyHash = paymentKeyHashOf(changeAddress);
|
|
|
|
let critiqueTypeDatum: Constr<Data>;
|
|
switch (p.critiqueType) {
|
|
case 'Supportive': critiqueTypeDatum = new Constr(0, []); break;
|
|
case 'Opposing': critiqueTypeDatum = new Constr(1, []); break;
|
|
case 'Amendment': critiqueTypeDatum = new Constr(2, [finalHash]); break;
|
|
default: {
|
|
const exhaustive: never = p.critiqueType;
|
|
throw new Error(`Unknown critiqueType: ${String(exhaustive)}`);
|
|
}
|
|
}
|
|
|
|
const provider = slotClockOf(lucid);
|
|
await ensureSlotConfig(provider);
|
|
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
|
|
const currentSlot = tip.slot || 0;
|
|
|
|
const critiqueDatum = Data.to(new Constr(0, [
|
|
p.agentDid, // critic_did
|
|
new Constr(0, [vkeyHash]), // critic_credential
|
|
new Constr(0, [p.proposalTxHash, BigInt(p.proposalOutputIndex)]), // proposal_ref
|
|
finalHash, // critique_hash
|
|
fromText(finalUri), // storage_uri
|
|
critiqueTypeDatum, // critique_type
|
|
stakeLovelace, // stake_amount
|
|
BigInt(currentSlot), // submitted_at
|
|
new Constr(0, []), // incorporated = False
|
|
]));
|
|
|
|
const critiqueSpendAddress = scriptHashToAddress(GOV_CRITIQUE_SPEND_HASH);
|
|
|
|
const completed = await lucid.newTx()
|
|
.pay.ToAddressWithData(
|
|
critiqueSpendAddress,
|
|
{ kind: 'inline', value: critiqueDatum },
|
|
{ lovelace: stakeLovelace + 2_000_000n },
|
|
)
|
|
.complete({ localUPLCEval: false });
|
|
|
|
return {
|
|
...toBuildResult(completed),
|
|
op: 'critique',
|
|
proposalRef: `${p.proposalTxHash}#${p.proposalOutputIndex}`,
|
|
stakeLovelace: stakeLovelace.toString(),
|
|
scriptAddress: critiqueSpendAddress,
|
|
storageUri: finalUri,
|
|
...(ipfsCid ? { ipfsCid, documentHash } : {}),
|
|
};
|
|
}
|
|
|
|
// ─── Build: endorse ──────────────────────────────────────────────────────────
|
|
|
|
export async function buildEndorse(lucid: LucidEvolution, p: {
|
|
agentDid: string;
|
|
proposalTxHash: string;
|
|
proposalOutputIndex: number;
|
|
stakeApex: number;
|
|
}): Promise<VectorBuildStakeResult> {
|
|
const changeAddress = await lucid.wallet().address();
|
|
|
|
if (!(p.stakeApex >= MIN_ENDORSE_STAKE_APEX)) {
|
|
throw new Error(`Endorsement stake must be at least ${MIN_ENDORSE_STAKE_APEX} AP3X.`);
|
|
}
|
|
const stakeLovelace = apexToLovelace(p.stakeApex);
|
|
|
|
const vkeyHash = paymentKeyHashOf(changeAddress);
|
|
|
|
const provider = slotClockOf(lucid);
|
|
await ensureSlotConfig(provider);
|
|
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
|
|
const currentSlot = tip.slot || 0;
|
|
|
|
const endorsementDatum = Data.to(new Constr(0, [
|
|
p.agentDid, // endorser_did
|
|
new Constr(0, [vkeyHash]), // endorser_credential
|
|
new Constr(0, [p.proposalTxHash, BigInt(p.proposalOutputIndex)]), // proposal_ref
|
|
stakeLovelace, // stake_amount
|
|
BigInt(currentSlot), // created_at
|
|
]));
|
|
|
|
const endorsementSpendAddress = scriptHashToAddress(GOV_ENDORSEMENT_SPEND_HASH);
|
|
|
|
const completed = await lucid.newTx()
|
|
.pay.ToAddressWithData(
|
|
endorsementSpendAddress,
|
|
{ kind: 'inline', value: endorsementDatum },
|
|
{ lovelace: stakeLovelace + 2_000_000n },
|
|
)
|
|
.complete({ localUPLCEval: false });
|
|
|
|
return {
|
|
...toBuildResult(completed),
|
|
op: 'endorse',
|
|
proposalRef: `${p.proposalTxHash}#${p.proposalOutputIndex}`,
|
|
stakeLovelace: stakeLovelace.toString(),
|
|
scriptAddress: endorsementSpendAddress,
|
|
};
|
|
}
|
|
|
|
// ─── Build: proposal lock (step 1 of 2) ─────────────────────────────────────
|
|
|
|
export async function buildProposalLock(lucid: LucidEvolution, p: {
|
|
agentDid: string;
|
|
proposalType: 'ParameterChange' | 'TreasurySpend' | 'ProtocolUpgrade' | 'GameActivation' | 'GeneralSuggestion';
|
|
stakeApex: number;
|
|
typeParams?: {
|
|
paramName?: string; currentValue?: number; proposedValue?: number;
|
|
amount?: number; recipientDescription?: string; upgradeHash?: string; gameId?: number;
|
|
};
|
|
priority?: 'Standard' | 'Emergency';
|
|
proposalDocument?: string;
|
|
proposalHash?: string;
|
|
storageUri?: string;
|
|
}): Promise<VectorBuildProposalLockResult> {
|
|
const changeAddress = await lucid.wallet().address();
|
|
|
|
// Validation order: stake minimum -> type-specific params (cheap,
|
|
// synchronous) -> document/hash/URI resolution (the one network side
|
|
// effect, a Filebase upload, is deliberately validated last).
|
|
if (!(p.stakeApex >= MIN_PROPOSAL_STAKE_APEX)) {
|
|
throw new Error(`Proposal stake must be at least ${MIN_PROPOSAL_STAKE_APEX} AP3X.`);
|
|
}
|
|
const stakeLovelace = apexToLovelace(p.stakeApex);
|
|
|
|
// Proposal-type datum. Same Constr indices as the custodial switch - the
|
|
// deployed module validator parses these exact bytes.
|
|
let typeDatum: Constr<Data>;
|
|
switch (p.proposalType) {
|
|
case 'ParameterChange':
|
|
if (!p.typeParams?.paramName || p.typeParams?.currentValue == null || p.typeParams?.proposedValue == null) {
|
|
throw new Error('ParameterChange requires paramName, currentValue, proposedValue');
|
|
}
|
|
typeDatum = new Constr(0, [fromText(p.typeParams.paramName), BigInt(p.typeParams.currentValue), BigInt(p.typeParams.proposedValue)]);
|
|
break;
|
|
case 'TreasurySpend':
|
|
if (!p.typeParams?.amount || !p.typeParams?.recipientDescription) {
|
|
throw new Error('TreasurySpend requires amount, recipientDescription');
|
|
}
|
|
typeDatum = new Constr(1, [BigInt(p.typeParams.amount), fromText(p.typeParams.recipientDescription)]);
|
|
break;
|
|
case 'ProtocolUpgrade':
|
|
typeDatum = new Constr(2, [p.typeParams?.upgradeHash || '']);
|
|
break;
|
|
case 'GameActivation':
|
|
typeDatum = new Constr(3, [BigInt(p.typeParams?.gameId || 0)]);
|
|
break;
|
|
case 'GeneralSuggestion':
|
|
typeDatum = new Constr(4, []);
|
|
break;
|
|
default: {
|
|
const exhaustive: never = p.proposalType;
|
|
throw new Error(`Unknown proposalType: ${String(exhaustive)}`);
|
|
}
|
|
}
|
|
|
|
// Document/hash/URI resolution: Filebase upload if a document was given,
|
|
// else the caller-supplied hash + URI must already be valid.
|
|
let finalHash = p.proposalHash;
|
|
let finalUri = p.storageUri;
|
|
let ipfsCid: string | undefined;
|
|
|
|
if (p.proposalDocument) {
|
|
const uploaded = await uploadToFilebase(p.proposalDocument, 'proposal');
|
|
finalHash = uploaded.hash;
|
|
finalUri = `ipfs://${uploaded.cid}`;
|
|
ipfsCid = uploaded.cid;
|
|
}
|
|
|
|
if (!finalHash || finalHash.length !== 64) {
|
|
throw new Error('proposalHash must be 64 hex characters (32 bytes). Provide proposalDocument for automatic hashing or proposalHash manually.');
|
|
}
|
|
if (!finalUri) {
|
|
throw new Error('storageUri is required. Provide proposalDocument for automatic upload or storageUri manually.');
|
|
}
|
|
|
|
const vkeyHash = paymentKeyHashOf(changeAddress);
|
|
|
|
const provider = slotClockOf(lucid);
|
|
const zeroTime = await ensureSlotConfig(provider);
|
|
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
|
|
const submittedAtMs = zeroTime + (tip.slot || 0) * 1000; // POSIX ms - the validity-range unit, not slots
|
|
|
|
const priorityDatum = p.priority === 'Emergency' ? new Constr(1, []) : new Constr(0, []);
|
|
|
|
const proposalDatum = Data.to(new Constr(0, [
|
|
p.agentDid, // proposer_did
|
|
new Constr(0, [vkeyHash]), // proposer_credential
|
|
finalHash, // proposal_hash
|
|
typeDatum, // proposal_type
|
|
fromText(finalUri), // storage_uri
|
|
stakeLovelace, // stake_amount
|
|
BigInt(submittedAtMs), // submitted_at
|
|
604_800_000n, // review_window (~7 days in ms)
|
|
priorityDatum, // priority
|
|
0n, // amendment_count
|
|
[], // incorporated_critiques
|
|
new Constr(0, []), // state = Open
|
|
]));
|
|
|
|
const proposalSpendAddress = scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH);
|
|
|
|
const completed = await lucid.newTx()
|
|
.pay.ToAddressWithData(
|
|
proposalSpendAddress,
|
|
{ kind: 'inline', value: proposalDatum },
|
|
{ lovelace: stakeLovelace + 2_000_000n },
|
|
)
|
|
.complete({ localUPLCEval: false });
|
|
|
|
return {
|
|
...toBuildResult(completed),
|
|
scriptAddress: proposalSpendAddress,
|
|
stakeLovelace: stakeLovelace.toString(),
|
|
storageUri: finalUri,
|
|
proposalHash: finalHash,
|
|
...(ipfsCid ? { ipfsCid } : {}),
|
|
};
|
|
}
|
|
|
|
// ─── Build: proposal spend (step 2 of 2 - validated spend + mint) ──────────
|
|
|
|
export async function buildProposalSpend(
|
|
lucid: LucidEvolution,
|
|
// Assumed to be the same provider instance `lucid` was constructed with
|
|
// (lucidForAddress(provider, changeAddress)) - passed explicitly (unlike
|
|
// buildCritique/buildEndorse's slotClockOf recovery) because this build
|
|
// resolves UTxOs by outref/unit directly against it, not through lucid's
|
|
// own UTxO cache. SlotClockProvider (not OgmiosProvider's own stricter
|
|
// getNetworkTip) supplies the slot/tip surface, same as slotClockOf's
|
|
// callers elsewhere in this file - keeps the (optional) hash field out of
|
|
// this build's contract, since it never uses it.
|
|
provider: Pick<OgmiosProvider, 'getUtxos' | 'getUtxoByUnit' | 'getUtxosByOutRef'> & SlotClockProvider,
|
|
p: { agentDid: string; lockTxHash: string; lockOutputIndex?: number },
|
|
// Test-only escape hatch, not part of the documented call contract: every
|
|
// production caller (Task 5's tool handler included) omits this and gets
|
|
// parity with the custodial module (provider-side eval). Only
|
|
// gov-build.test.ts's dedicated native-eval-attempt test passes
|
|
// { localUPLCEval: true }, to ask the REAL deployed validators a question
|
|
// FixtureProvider's missing evaluateTx would otherwise make impossible to
|
|
// ask at all.
|
|
opts?: { localUPLCEval?: boolean },
|
|
): Promise<VectorBuildProposalSpendResult> {
|
|
const changeAddress = await lucid.wallet().address();
|
|
const lockOutputIndex = p.lockOutputIndex ?? 0;
|
|
const proposalSpendAddress = scriptHashToAddress(GOV_PROPOSAL_SPEND_HASH);
|
|
|
|
// 1. Resolve the locked UTxO. Its datum is reused VERBATIM below as the
|
|
// continuing datum - the chain's own bytes are the truth, not a
|
|
// reconstruction.
|
|
const lockedUtxos = await provider.getUtxosByOutRef([{ txHash: p.lockTxHash, outputIndex: lockOutputIndex }]);
|
|
const lockedUtxo = lockedUtxos[0];
|
|
if (!lockedUtxo) {
|
|
throw new Error('Locked proposal UTxO not found - has the lock transaction confirmed? Run vector_await_transaction { txHash } first.');
|
|
}
|
|
// Fail fast on a caller passing an unrelated txHash that happens to
|
|
// resolve to a real UTxO elsewhere (same defensive pattern as
|
|
// build.ts's buildInteractContract spend-action address check) - a
|
|
// datum-parse failure two lines below would eventually catch most cases
|
|
// of this, but this gives a specific, actionable error instead of a
|
|
// generic "malformed data" one.
|
|
if (lockedUtxo.address !== proposalSpendAddress) {
|
|
throw new Error(
|
|
`Locked UTxO ${p.lockTxHash}#${lockOutputIndex} is not at the proposal script address ` +
|
|
`(found ${lockedUtxo.address}) - pass the lock transaction hash returned by ` +
|
|
`vector_build_self_improvement_proposal_lock.`,
|
|
);
|
|
}
|
|
if (!lockedUtxo.datum) {
|
|
throw new Error('Locked proposal UTxO has no inline datum - the lock transaction may be malformed.');
|
|
}
|
|
|
|
// 2. Sanity-parse + cross-check the proposer DID before spending anything.
|
|
const proposal = parseProposalDatum(lockedUtxo.datum);
|
|
if (!proposal) {
|
|
throw new Error('Could not parse the locked proposal datum. The on-chain data may be malformed.');
|
|
}
|
|
if (proposal.proposerDid !== p.agentDid) {
|
|
throw new Error(`Proposer DID mismatch: the locked proposal belongs to ${proposal.proposerDid}, not ${p.agentDid}.`);
|
|
}
|
|
|
|
// 3. Reference-script UTxOs (CIP-33) - availability errors transcribed
|
|
// verbatim from the custodial module (these can be accidentally consumed
|
|
// by an unrelated transaction).
|
|
const refScriptUtxos = await provider.getUtxosByOutRef([
|
|
parseUtxoRef(GOV_PROPOSAL_SPEND_REF),
|
|
parseUtxoRef(GOV_PROPOSAL_MINT_REF),
|
|
]);
|
|
if (refScriptUtxos.length < 2) {
|
|
throw new Error(
|
|
`Reference script UTxOs missing: found ${refScriptUtxos.length}/2. ` +
|
|
`spend_ref=${GOV_PROPOSAL_SPEND_REF}, mint_ref=${GOV_PROPOSAL_MINT_REF}. ` +
|
|
`These UTxOs may have been consumed - redeploy with scripts/redeploy_ref_scripts.py ` +
|
|
`and update GOV_PROPOSAL_SPEND_REF / GOV_PROPOSAL_MINT_REF env vars.`,
|
|
);
|
|
}
|
|
const missingScriptRef = refScriptUtxos.filter((u) => !u.scriptRef);
|
|
if (missingScriptRef.length > 0) {
|
|
throw new Error(
|
|
`Reference script UTxOs found but missing scriptRef field for ${missingScriptRef.length} UTxO(s). ` +
|
|
`The UTxOs at ${missingScriptRef.map((u) => u.txHash).join(', ')} exist but don't contain scripts. ` +
|
|
`Redeploy reference scripts and update env vars.`,
|
|
);
|
|
}
|
|
|
|
// Module infrastructure reference inputs - resolved the same way the
|
|
// custodial module did (not individually validated here; the deployed
|
|
// validator itself rejects the spend if one is missing from the tx).
|
|
const govRefUtxos = await provider.getUtxosByOutRef([
|
|
parseUtxoRef(GOV_PARAMS_UTXO),
|
|
parseUtxoRef(GOV_ORACLE_UTXO),
|
|
parseUtxoRef(GOV_CROSSREFS_UTXO),
|
|
]);
|
|
|
|
// 4. The agent's registry NFT (CIP-31 reference input, proves the DID).
|
|
// Same try-getUtxoByUnit-catch-scan-getUtxos(registryAddress) fallback as
|
|
// registry-build.ts's resolveAgentUtxo: getUtxoByUnit needs a live indexer
|
|
// (FixtureProvider always throws), so offline building depends on the
|
|
// scan fallback actually running.
|
|
const nftUnit = AGENT_REGISTRY_POLICY + p.agentDid;
|
|
let nftUtxo: UTxO | undefined;
|
|
try {
|
|
nftUtxo = await provider.getUtxoByUnit(nftUnit);
|
|
} catch {
|
|
// Koios unavailable, asset not found, or an offline provider - fall through to the address scan.
|
|
}
|
|
if (!nftUtxo) {
|
|
const registryUtxos = await provider.getUtxos(getRegistryAddress());
|
|
nftUtxo = registryUtxos.find((u) => u.assets[nftUnit] && u.assets[nftUnit] > 0n);
|
|
}
|
|
if (!nftUtxo) {
|
|
throw new Error(
|
|
`Agent registry NFT not found on-chain. Expected token: ${AGENT_REGISTRY_POLICY.slice(0, 12)}...${p.agentDid.slice(0, 12)}... ` +
|
|
`The agent may need to re-register with vector_build_register_agent.`,
|
|
);
|
|
}
|
|
|
|
// 5. Token names, validity window, activity datum.
|
|
const propTokenName = deriveProposalTokenName(p.lockTxHash, lockOutputIndex);
|
|
const actTokenName = deriveActivityTokenName(p.agentDid);
|
|
const propTokenUnit = GOV_PROPOSAL_MINT_HASH + propTokenName;
|
|
const actTokenUnit = GOV_PROPOSAL_MINT_HASH + actTokenName;
|
|
|
|
const zeroTime = await ensureSlotConfig(provider);
|
|
const tip = (await provider.getNetworkTip?.()) ?? { slot: 0 };
|
|
// The validator checks activity.last_proposal_slot == tx validity lower bound.
|
|
const validFromMs = zeroTime + (tip.slot || 0) * 1000;
|
|
const validToMs = validFromMs + 360_000; // 6 minutes - the agent's signing deadline
|
|
|
|
const vkeyHash = paymentKeyHashOf(changeAddress);
|
|
|
|
// First proposal from this agent: activity count starts at 1.
|
|
// TODO: for a subsequent proposal, find the existing pact_ UTxO and
|
|
// increment count instead of always minting a fresh one - the custodial
|
|
// module carried the same simplification; not fixed here.
|
|
const activityDatum = Data.to(new Constr(0, [
|
|
p.agentDid, // agent_did
|
|
new Constr(0, [vkeyHash]), // agent_credential
|
|
1n, // active_proposal_count
|
|
BigInt(validFromMs), // last_proposal_slot (POSIX ms, matches tx validity lower bound)
|
|
]));
|
|
|
|
const submitRedeemer = Data.to(new Constr(0, []));
|
|
|
|
const completed = await lucid.newTx()
|
|
.collectFrom([lockedUtxo], submitRedeemer)
|
|
.readFrom(refScriptUtxos)
|
|
.readFrom(govRefUtxos)
|
|
.readFrom([nftUtxo])
|
|
.mintAssets({ [propTokenUnit]: 1n, [actTokenUnit]: 1n }, submitRedeemer)
|
|
.pay.ToAddressWithData(
|
|
proposalSpendAddress,
|
|
{ kind: 'inline', value: lockedUtxo.datum },
|
|
// Value-preservation (registry-family lesson): carry the locked
|
|
// UTxO's OWN coin through rather than recomputing stake+2 AP3X - that
|
|
// constant only happens to equal it today.
|
|
{ ...lockedUtxo.assets, [propTokenUnit]: 1n },
|
|
)
|
|
.pay.ToAddressWithData(
|
|
proposalSpendAddress,
|
|
{ kind: 'inline', value: activityDatum },
|
|
{ lovelace: 2_000_000n, [actTokenUnit]: 1n },
|
|
)
|
|
.addSigner(changeAddress)
|
|
.validFrom(validFromMs)
|
|
.validTo(validToMs)
|
|
.complete({ localUPLCEval: opts?.localUPLCEval ?? false });
|
|
|
|
return {
|
|
...toBuildResult(completed),
|
|
proposalTokenName: propTokenName,
|
|
activityTokenName: actTokenName,
|
|
scriptAddress: proposalSpendAddress,
|
|
validToMs,
|
|
};
|
|
}
|