Add Filebase IPFS upload to governance tools, fix stale script hashes

- Add @aws-sdk/client-s3 dependency for Filebase S3-compatible IPFS uploads
- Add uploadToFilebase() helper: uploads JSON, computes blake2b_256 hash,
  returns CID from Filebase IPFS bucket
- vector_governance_submit_proposal: add optional proposalDocument param
  for automatic IPFS upload + hash computation (proposalHash/storageUri
  become optional when document is provided)
- vector_governance_critique: add optional critiqueDocument param, same flow
- Fix stale default script hashes to match v6 deployment
- Fix address derivation: replace broken validatorToAddress(CBOR) with
  credentialToAddress(hash) — eliminates need for GOV_*_CBOR env vars
- Fix getAddressDetails: import from Lucid instead of globalThis
- Backward compatible: manual proposalHash + storageUri still works

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-04-09 10:53:04 +02:00
parent 178a6a4154
commit 8ceb92aec8
3 changed files with 1758 additions and 24 deletions

1656
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,11 +45,12 @@
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1027.0",
"@lucid-evolution/lucid": "^0.4.29",
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.4.1",
"@noble/hashes": "^1.7.1",
"cross-fetch": "^4.0.0", "cross-fetch": "^4.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"@lucid-evolution/lucid": "^0.4.29",
"@noble/hashes": "^1.7.1",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -5,6 +5,7 @@
import { z } from "zod"; import { z } from "zod";
import { Lucid, fromText, toText, Data, Constr, credentialToAddress, getAddressDetails } from '@lucid-evolution/lucid'; import { Lucid, fromText, toText, Data, Constr, credentialToAddress, getAddressDetails } from '@lucid-evolution/lucid';
import { blake2b } from '@noble/hashes/blake2b'; import { blake2b } from '@noble/hashes/blake2b';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { OgmiosProvider } from './ogmios-provider.js'; import { OgmiosProvider } from './ogmios-provider.js';
import { safetyLayer } from './safety.js'; import { safetyLayer } from './safety.js';
import { rateLimiter } from './rate-limiter.js'; import { rateLimiter } from './rate-limiter.js';
@@ -100,6 +101,62 @@ function deriveActivityTokenName(agentDid: string): string {
return prefixHex + hashSlice; 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 || '';
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 any).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 };
}
// Parse ProposalDatum from CBOR // Parse ProposalDatum from CBOR
function parseProposalDatum(datumCbor: string): any | null { function parseProposalDatum(datumCbor: string): any | null {
try { try {
@@ -341,13 +398,14 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`,
server.tool( server.tool(
"vector_governance_submit_proposal", "vector_governance_submit_proposal",
"Submit a governance proposal to the Vector Governance Suggestion Engine. Requires staking AP3X. The proposal document should be stored off-chain (IPFS/OriginTrail) and its blake2b_256 hash provided.", "Submit a governance proposal to the Vector Governance Suggestion Engine. Requires staking AP3X. Provide proposalDocument (JSON string) for automatic IPFS upload via Filebase and blake2b_256 hashing, OR provide proposalHash and storageUri manually.",
{ {
mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the wallet"), mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the wallet"),
agentDid: z.string().describe("Agent DID (hex) — the asset name from Agent Registry NFT"), agentDid: z.string().describe("Agent DID (hex) — the asset name from Agent Registry NFT"),
proposalHash: z.string().describe("blake2b_256 hash of proposal document (64 hex chars)"), proposalDocument: z.string().optional().describe("Full proposal document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically. If provided, proposalHash and storageUri are ignored."),
proposalHash: z.string().optional().describe("blake2b_256 hash of proposal document (64 hex chars). Required if proposalDocument is not provided."),
proposalType: z.enum(["ParameterChange", "TreasurySpend", "ProtocolUpgrade", "GameActivation", "GeneralSuggestion"]).describe("Category of the proposal"), proposalType: z.enum(["ParameterChange", "TreasurySpend", "ProtocolUpgrade", "GameActivation", "GeneralSuggestion"]).describe("Category of the proposal"),
storageUri: z.string().describe("Off-chain storage URI for the full proposal (IPFS CID or OriginTrail UAL)"), storageUri: z.string().optional().describe("Off-chain storage URI for the full proposal (IPFS CID or OriginTrail UAL). Required if proposalDocument is not provided."),
stakeApex: z.number().min(25).describe("AP3X to stake (minimum 25)"), stakeApex: z.number().min(25).describe("AP3X to stake (minimum 25)"),
typeParams: z.object({ typeParams: z.object({
paramName: z.string().optional(), paramName: z.string().optional(),
@@ -360,7 +418,7 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`,
}).optional().describe("Type-specific parameters (required for ParameterChange and TreasurySpend)"), }).optional().describe("Type-specific parameters (required for ParameterChange and TreasurySpend)"),
priority: z.enum(["Standard", "Emergency"]).default("Standard").describe("Priority level (Emergency requires higher stake and reputation)"), priority: z.enum(["Standard", "Emergency"]).default("Standard").describe("Priority level (Emergency requires higher stake and reputation)"),
}, },
async ({ mnemonic, agentDid, proposalHash, proposalType, storageUri, stakeApex, typeParams, priority }) => { async ({ mnemonic, agentDid, proposalDocument, proposalHash, proposalType, storageUri, stakeApex, typeParams, priority }) => {
const rateLimited = checkRateLimit(); const rateLimited = checkRateLimit();
if (rateLimited) return rateLimited; if (rateLimited) return rateLimited;
@@ -371,7 +429,20 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`,
} }
try { try {
if (proposalHash.length !== 64) throw new Error('proposalHash must be 64 hex characters (32 bytes)'); // IPFS upload: if proposalDocument provided, upload to Filebase and derive hash + URI
let finalHash = proposalHash;
let finalUri = storageUri;
let ipfsCid = '';
if (proposalDocument) {
const uploaded = await uploadToFilebase(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 auto-hashing or proposalHash manually.');
if (!finalUri) throw new Error('storageUri is required. Provide proposalDocument for auto-upload or storageUri manually.');
const provider = newProvider(); const provider = newProvider();
const lucid = await Lucid(provider, 'Mainnet'); const lucid = await Lucid(provider, 'Mainnet');
@@ -418,9 +489,9 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`,
const proposalDatum = Data.to(new Constr(0, [ const proposalDatum = Data.to(new Constr(0, [
agentDid, // proposer_did agentDid, // proposer_did
new Constr(0, [vkeyHash]), // proposer_credential (VerificationKey) new Constr(0, [vkeyHash]), // proposer_credential (VerificationKey)
proposalHash, // proposal_hash finalHash, // proposal_hash
typeDatum, // proposal_type typeDatum, // proposal_type
fromText(storageUri), // storage_uri fromText(finalUri), // storage_uri
BigInt(stakeLovelace), // stake_amount BigInt(stakeLovelace), // stake_amount
BigInt(currentSlot), // submitted_at BigInt(currentSlot), // submitted_at
BigInt(604_800_000), // review_window (~7 days in ms) BigInt(604_800_000), // review_window (~7 days in ms)
@@ -455,8 +526,9 @@ Each batch UTxO holds ~30 AP3X for adoption rewards.`,
**Stake:** ${stakeApex} AP3X **Stake:** ${stakeApex} AP3X
**Type:** ${proposalType} **Type:** ${proposalType}
**Priority:** ${priority} **Priority:** ${priority}
**Storage:** ${storageUri} **Storage:** ${finalUri}
**Script Address:** ${proposalSpendAddress} **Script Address:** ${proposalSpendAddress}
${ipfsCid ? `**IPFS CID:** ${ipfsCid}\n**Hash (auto-computed):** ${finalHash}` : ''}
The proposal datum is now locked at the governance script address. The proposal datum is now locked at the governance script address.
@@ -487,18 +559,19 @@ proposal token minting requires the multi-validator flow (coming soon).`,
server.tool( server.tool(
"vector_governance_critique", "vector_governance_critique",
"Submit a critique on a governance proposal. Critiques can support, oppose, or propose amendments. Requires staking AP3X.", "Submit a critique on a governance proposal. Critiques can support, oppose, or propose amendments. Requires staking AP3X. Provide critiqueDocument (JSON string) for automatic IPFS upload, or critiqueHash + storageUri manually.",
{ {
mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the wallet"), mnemonic: z.string().describe("15 or 24-word BIP39 mnemonic for the wallet"),
agentDid: z.string().describe("Agent DID (hex)"), agentDid: z.string().describe("Agent DID (hex)"),
proposalTxHash: z.string().describe("TX hash of the proposal UTxO to critique"), proposalTxHash: z.string().describe("TX hash of the proposal UTxO to critique"),
proposalOutputIndex: z.number().default(0).describe("Output index of the proposal UTxO"), proposalOutputIndex: z.number().default(0).describe("Output index of the proposal UTxO"),
critiqueHash: z.string().describe("blake2b_256 hash of critique document (64 hex chars)"), critiqueDocument: z.string().optional().describe("Full critique document as JSON string. Uploaded to IPFS via Filebase; hash and CID computed automatically."),
critiqueHash: z.string().optional().describe("blake2b_256 hash of critique document (64 hex chars). Required if critiqueDocument is not provided."),
critiqueType: z.enum(["Supportive", "Opposing", "Amendment"]).describe("Type of critique"), critiqueType: z.enum(["Supportive", "Opposing", "Amendment"]).describe("Type of critique"),
storageUri: z.string().describe("Off-chain storage URI for the critique document"), storageUri: z.string().optional().describe("Off-chain storage URI for the critique document. Required if critiqueDocument is not provided."),
stakeApex: z.number().min(10).describe("AP3X to stake (minimum 10)"), stakeApex: z.number().min(10).describe("AP3X to stake (minimum 10)"),
}, },
async ({ mnemonic, agentDid, proposalTxHash, proposalOutputIndex, critiqueHash, critiqueType, storageUri, stakeApex }) => { async ({ mnemonic, agentDid, proposalTxHash, proposalOutputIndex, critiqueDocument, critiqueHash, critiqueType, storageUri, stakeApex }) => {
const rateLimited = checkRateLimit(); const rateLimited = checkRateLimit();
if (rateLimited) return rateLimited; if (rateLimited) return rateLimited;
@@ -509,7 +582,20 @@ proposal token minting requires the multi-validator flow (coming soon).`,
} }
try { try {
if (critiqueHash.length !== 64) throw new Error('critiqueHash must be 64 hex characters'); // IPFS upload: if critiqueDocument provided, upload to Filebase
let finalHash = critiqueHash;
let finalUri = storageUri;
let ipfsCid = '';
if (critiqueDocument) {
const uploaded = await uploadToFilebase(critiqueDocument, 'critique');
finalHash = uploaded.hash;
finalUri = `ipfs://${uploaded.cid}`;
ipfsCid = uploaded.cid;
}
if (!finalHash || finalHash.length !== 64) throw new Error('critiqueHash must be 64 hex characters. Provide critiqueDocument or critiqueHash manually.');
if (!finalUri) throw new Error('storageUri is required. Provide critiqueDocument or storageUri manually.');
const provider = newProvider(); const provider = newProvider();
const lucid = await Lucid(provider, 'Mainnet'); const lucid = await Lucid(provider, 'Mainnet');
@@ -521,7 +607,7 @@ proposal token minting requires the multi-validator flow (coming soon).`,
switch (critiqueType) { switch (critiqueType) {
case 'Supportive': critiqueTypeDatum = new Constr(0, []); break; case 'Supportive': critiqueTypeDatum = new Constr(0, []); break;
case 'Opposing': critiqueTypeDatum = new Constr(1, []); break; case 'Opposing': critiqueTypeDatum = new Constr(1, []); break;
case 'Amendment': critiqueTypeDatum = new Constr(2, [critiqueHash]); break; case 'Amendment': critiqueTypeDatum = new Constr(2, [finalHash]); break;
} }
const tip = await provider.getNetworkTip?.() || { slot: 0 }; const tip = await provider.getNetworkTip?.() || { slot: 0 };
@@ -536,8 +622,8 @@ proposal token minting requires the multi-validator flow (coming soon).`,
agentDid, // critic_did agentDid, // critic_did
new Constr(0, [vkeyHash]), // critic_credential new Constr(0, [vkeyHash]), // critic_credential
new Constr(0, [proposalTxHash, BigInt(proposalOutputIndex)]), // proposal_ref new Constr(0, [proposalTxHash, BigInt(proposalOutputIndex)]), // proposal_ref
critiqueHash, // critique_hash finalHash, // critique_hash
fromText(storageUri), // storage_uri fromText(finalUri), // storage_uri
critiqueTypeDatum, // critique_type critiqueTypeDatum, // critique_type
BigInt(stakeLovelace), // stake_amount BigInt(stakeLovelace), // stake_amount
BigInt(currentSlot), // submitted_at BigInt(currentSlot), // submitted_at
@@ -568,7 +654,8 @@ proposal token minting requires the multi-validator flow (coming soon).`,
**Type:** ${critiqueType} **Type:** ${critiqueType}
**Stake:** ${stakeApex} AP3X **Stake:** ${stakeApex} AP3X
**Proposal:** ${proposalTxHash}#${proposalOutputIndex} **Proposal:** ${proposalTxHash}#${proposalOutputIndex}
**Storage:** ${storageUri} **Storage:** ${finalUri}
${ipfsCid ? `**IPFS CID:** ${ipfsCid}\n**Hash (auto-computed):** ${finalHash}` : ''}
[View on Explorer](${explorerTxLink(txHash)})`, [View on Explorer](${explorerTxLink(txHash)})`,
}], }],