Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"test": "node scripts/run-tests.mjs",
"test:cjs": "jest",
"test:esm": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c jest.esm.config.mjs --passWithNoTests",
"test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts",
"test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts src/__tests__/rationaleAnchor.test.ts",
"test:bot:integration": "jest src/__tests__/botApi.integration.test.ts --runInBand",
"test:bot": "npm run test:bot:unit && npm run test:bot:integration",
"test:watch": "jest --watch",
Expand Down
19 changes: 15 additions & 4 deletions src/__tests__/mcpTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("MCP tool registry", () => {
"governance_vote_history",
"governance_open_proposals",
"ballot_upsert",
"ballot_publish_rationale",
]);
});

Expand All @@ -49,10 +50,20 @@ describe("MCP tool registry", () => {
});

it("exposes no tool that can sign, spend or broadcast", () => {
// This release is read-only plus ballot drafts. Any new write tool must be a
// deliberate decision that updates this test, not a quiet registry addition.
// The agreed boundary: agents may read, draft ballots, and publish a
// rationale to IPFS. Submitting a vote and signing stay with humans. Any
// further write tool must be a deliberate decision that updates this list,
// not a quiet registry addition.
const writable = MCP_TOOLS.filter((t) => !t.annotations.readOnlyHint);
expect(writable.map((t) => t.name)).toEqual(["ballot_upsert"]);
expect(writable.map((t) => t.name)).toEqual([
"ballot_upsert",
"ballot_publish_rationale",
]);
// Neither write tool may be destructive: they add or replace drafts and
// anchors, they never remove a ballot or move value.
for (const tool of writable) {
expect(tool.annotations.destructiveHint).toBe(false);
}

const forbidden = [
"signTransaction",
Expand All @@ -73,7 +84,7 @@ describe("MCP tool registry", () => {
}
});

it("marks the one write tool as non-destructive", () => {
it("marks the ballot draft tool as non-destructive and idempotent", () => {
const ballot = MCP_TOOLS.find((t) => t.name === "ballot_upsert");
expect(ballot?.annotations.destructiveHint).toBe(false);
expect(ballot?.annotations.idempotentHint).toBe(true);
Expand Down
110 changes: 110 additions & 0 deletions src/__tests__/rationaleAnchor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from "@jest/globals";
import { blake2b } from "@noble/hashes/blake2b";

import {
buildRationaleAnchor,
buildRationaleDocument,
serializeRationale,
} from "@/lib/server/rationaleAnchor";

/**
* The anchor is only useful if a verifier can fetch the pinned document,
* re-hash it, and get the hash recorded on-chain. Everything here defends that
* one property.
*/

const blake256 = (s: string) =>
Buffer.from(blake2b(Buffer.from(s, "utf8"), { dkLen: 32 })).toString("hex");

const base = {
summary: "Voting No: the budget is unfunded.",
rationaleStatement: "The proposal does not identify a funding source.",
};

describe("rationale document", () => {
it("carries the CIP-100/136 context and declares its hash algorithm", () => {
const doc = buildRationaleDocument(base) as Record<string, any>;
expect(doc.hashAlgorithm).toBe("blake2b-256");
expect(doc["@context"].CIP100).toContain("CIP-0100");
expect(doc["@context"].CIP136).toContain("CIP-0136");
expect(doc.body.summary).toBe(base.summary);
expect(doc.body.rationaleStatement).toBe(base.rationaleStatement);
});

it("truncates the summary to the 300-char CIP-136 limit", () => {
const doc = buildRationaleDocument({
...base,
summary: "x".repeat(500),
}) as Record<string, any>;
expect(doc.body.summary).toHaveLength(300);
});

it("omits empty optional fields entirely", () => {
// An empty string is still a key, and every key changes the hash.
const doc = buildRationaleDocument({
...base,
precedentDiscussion: " ",
conclusion: "",
references: [{ label: "", uri: "" }],
}) as Record<string, any>;
expect(doc.body).not.toHaveProperty("precedentDiscussion");
expect(doc.body).not.toHaveProperty("conclusion");
expect(doc.body).not.toHaveProperty("references");
});

it("includes optional fields that carry content", () => {
const doc = buildRationaleDocument({
...base,
counterargumentDiscussion: "Some disagree.",
references: [{ label: "Thread", uri: "https://forum.example/1" }],
}) as Record<string, any>;
expect(doc.body.counterargumentDiscussion).toBe("Some disagree.");
expect(doc.body.references).toEqual([
{ "@type": "Other", label: "Thread", uri: "https://forum.example/1" },
]);
});
});

describe("serialization and hashing", () => {
it("serializes as two-space pretty JSON", () => {
// Not cosmetic: hashDrepAnchor hashes JSON.stringify(doc, null, 2), so the
// bytes pinned to IPFS must be exactly this form or the anchor will not
// verify against the fetched document.
const json = serializeRationale(buildRationaleDocument(base));
expect(json).toContain('\n "hashAlgorithm"');
expect(json).toBe(JSON.stringify(JSON.parse(json), null, 2));
});

it("hashes exactly the bytes it returns for pinning", () => {
const anchor = buildRationaleAnchor(
base,
(doc) => blake256(JSON.stringify(doc, null, 2)),
"rationale-tx#0",
);
// The invariant a verifier depends on.
expect(anchor.hash).toBe(blake256(anchor.json));
});

it("is deterministic for identical input", () => {
const mk = () =>
buildRationaleAnchor(base, (d) => blake256(JSON.stringify(d, null, 2)), "f");
expect(mk().hash).toBe(mk().hash);
});

it("changes the hash when the rationale changes", () => {
const h = (input: typeof base) =>
buildRationaleAnchor(input, (d) => blake256(JSON.stringify(d, null, 2)), "f").hash;
expect(h(base)).not.toBe(h({ ...base, rationaleStatement: "Different." }));
});

it("sanitises the filename and keeps a .jsonld extension", () => {
const anchor = buildRationaleAnchor(
base,
() => "deadbeef",
"rationale-aa11#0/../../etc/passwd",
);
expect(anchor.filename).toMatch(/^[a-zA-Z0-9._-]+\.jsonld$/);
expect(anchor.filename).not.toContain("/");
expect(anchor.filename).not.toContain("#");
});
});
46 changes: 46 additions & 0 deletions src/lib/mcp/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,49 @@ export const OPEN_PROPOSALS_INPUT: JsonSchema = {
required: ["walletId"],
additionalProperties: false,
};

export const PUBLISH_RATIONALE_INPUT: JsonSchema = {
type: "object",
properties: {
walletId,
ballotId: {
type: "string",
minLength: 1,
description: "Ballot id, as returned by governance_list_ballots.",
},
proposalId: {
type: "string",
minLength: 1,
description: "Governance proposal id (<txHash>#<certIndex>) on that ballot.",
},
summary: {
type: "string",
maxLength: 300,
description:
"Short stance and reason, plain text, max 300 chars. Defaults to the rationale already drafted on the ballot.",
},
rationaleStatement: {
type: "string",
description:
"The full argument; markdown allowed. Defaults to the rationale already drafted on the ballot.",
},
precedentDiscussion: { type: "string" },
counterargumentDiscussion: { type: "string" },
conclusion: { type: "string", description: "Plain text, no markdown." },
references: {
type: "array",
maxItems: 20,
items: {
type: "object",
properties: {
label: { type: "string", minLength: 1 },
uri: { type: "string", minLength: 1 },
},
required: ["label", "uri"],
additionalProperties: false,
},
},
},
required: ["walletId", "ballotId", "proposalId"],
additionalProperties: false,
};
41 changes: 41 additions & 0 deletions src/lib/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ACTIVE_PROPOSALS_INPUT,
BALLOT_UPSERT_INPUT,
OPEN_PROPOSALS_INPUT,
PUBLISH_RATIONALE_INPUT,
VOTE_HISTORY_INPUT,
WALLET_BALLOTS_INPUT,
EMPTY_INPUT,
Expand Down Expand Up @@ -81,6 +82,7 @@ const load = {
botBallots: () => import("@/pages/api/v1/botBallots"),
drepInfo: () => import("@/pages/api/v1/drepInfo"),
drepVotes: () => import("@/pages/api/governance/drepVotes"),
ballotRationaleAnchor: () => import("@/pages/api/v1/ballotRationaleAnchor"),
};

/** Vote history is two hops: resolve the wallet's DRep, then read its votes. */
Expand Down Expand Up @@ -431,6 +433,45 @@ export const MCP_TOOLS: McpToolDef[] = [
},
}),
},
{
name: "ballot_publish_rationale",
title: "Publish a rationale to IPFS",
description:
"Publish a ballot proposal's rationale as a CIP-100/136 JSON-LD document on IPFS, and record the resulting anchor URL and hash on the ballot. Defaults to the rationale already drafted on the ballot; pass summary/rationaleStatement to override. This prepares the anchor for a vote — it does NOT cast or submit one. Submitting the vote and signing stay with the wallet's signers.",
scope: "ballots:write",
inputSchema: PUBLISH_RATIONALE_INPUT,
annotations: {
readOnlyHint: false,
// Writes an anchor onto the ballot and pins a document, but destroys
// nothing and casts no vote. Re-running replaces the anchor for that
// proposal with an equivalent one.
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
v1Path: "ballotRationaleAnchor.ts",
run: async (args, ctx) =>
callV1(load.ballotRationaleAnchor, ctx, {
method: "POST",
body: {
walletId: args.walletId,
ballotId: args.ballotId,
proposalId: args.proposalId,
...(args.summary !== undefined ? { summary: args.summary } : {}),
...(args.rationaleStatement !== undefined
? { rationaleStatement: args.rationaleStatement }
: {}),
...(args.precedentDiscussion !== undefined
? { precedentDiscussion: args.precedentDiscussion }
: {}),
...(args.counterargumentDiscussion !== undefined
? { counterargumentDiscussion: args.counterargumentDiscussion }
: {}),
...(args.conclusion !== undefined ? { conclusion: args.conclusion } : {}),
...(args.references !== undefined ? { references: args.references } : {}),
},
}),
},
];

/**
Expand Down
80 changes: 80 additions & 0 deletions src/lib/server/pinataUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { env } from "@/env";
import { ipfsGatewayUrl } from "@/lib/ipfs";

/**
* Server-side Pinata upload.
*
* Extracted from `src/pages/api/pinata-storage/put.ts` so callers that already
* hold an authenticated, authorized request can pin content without a loopback
* HTTP hop through that route.
*/

type PinataResponse = {
data: { id: string; cid: string };
};

export type PinnedFile = {
url: string;
cid: string;
id: string;
};

/** Cap on what we will pin in one call. Rationale documents are small. */
export const MAX_PIN_BYTES = 256 * 1024;

export class PinataUploadError extends Error {
constructor(
message: string,
readonly status: number,
readonly details?: string,
) {
super(message);
this.name = "PinataUploadError";
}
}

/**
* Pin `value` under `filename` and return a gateway URL.
*
* The exact bytes of `value` are what get pinned — callers that also hash the
* content must hash the identical string, or the anchor will not verify against
* the fetched document.
*/
export async function pinJsonLd(
filename: string,
value: string,
): Promise<PinnedFile> {
const bytes = Buffer.byteLength(value, "utf8");
if (bytes > MAX_PIN_BYTES) {
throw new PinataUploadError(
`Document exceeds the ${MAX_PIN_BYTES}-byte pin limit`,
413,
);
}

const formData = new FormData();
const blob = new Blob([Buffer.from(value, "utf-8")], {
type: "application/ld+json",
});
// Strip any folder structure; Pinata takes a flat filename.
formData.append("file", blob, filename.split("/").pop() || filename);
formData.append("network", "public");

const response = await fetch("https://uploads.pinata.cloud/v3/files", {
method: "POST",
headers: { Authorization: `Bearer ${env.PINATA_JWT}` },
body: formData,
});

if (!response.ok) {
const details = await response.text();
throw new PinataUploadError("Pinata upload failed", response.status, details);
}

const pinata = (await response.json()) as PinataResponse;
return {
url: ipfsGatewayUrl(pinata.data.cid),
cid: pinata.data.cid,
id: pinata.data.id,
};
}
Loading
Loading