neuronagent
Version:
AI agent for Internet Computer governance and neuron management
314 lines (263 loc) • 12.3 kB
text/typescript
import { GovernanceCanister, Topic, Vote, memoToNeuronAccountIdentifier, ProposalInfo } from "npm:@dfinity/nns";
import { LedgerCanister, AccountIdentifier } from "npm:@dfinity/ledger-icp";
import { Principal } from "npm:@dfinity/principal";
import { bold, cyan, red, green, yellow } from "https://deno.land/std/fmt/colors.ts";
import { GOVERNANCE_ID, SIX_MONTHS_AND_ONE_DAY } from "./types.ts";
import { storeProposal, proposalExists, updateMinimumProposalId, getMinimumProposalId, getUnprocessedProposals, markProposalProcessed } from "./db.ts";
import { toState } from "./utils.ts";
/**
* Retrieves all proposals and stores them in the database
* Uses pagination to get all proposals until we reach one we already have
*/
export async function retrieveAndStoreProposals(governance: GovernanceCanister) {
try {
let beforeProposal: { id: bigint } | undefined = undefined;
let continueFetching = true;
let totalStored = 0;
let batchCount = 0;
console.log(bold(cyan("\n=== Retrieving and storing proposals ===")));
// Check if we have a minimum proposal ID already
const minProposalId = getMinimumProposalId();
if (minProposalId) {
console.log(`Found existing minimum proposal ID: ${minProposalId}`);
console.log(`Will only fetch proposals newer than this ID`);
// No need to set beforeProposal here - we'll fetch newest proposals first
}
const startTime = Date.now();
while (continueFetching) {
batchCount++;
// Fetch a batch of proposals
const response = await governance.listProposals({
request: {
limit: 30,
includeRewardStatus: [],
beforeProposal: beforeProposal ? beforeProposal.id : undefined,
excludeTopic: [],
includeAllManageNeuronProposals: false,
includeStatus: [],
omitLargeFields: true
},
certified: true
});
const proposals = response.proposals || [];
console.log(`Batch ${batchCount}: Retrieved ${proposals.length} proposals`);
if (proposals.length === 0) {
console.log("No more proposals to retrieve.");
break;
}
// Track the minimum proposal ID we've seen
let batchMinId: bigint | null = null;
let allExist = true;
let reachedExistingMinimum = false;
// Process each proposal in the batch
for (const proposal of proposals) {
if (!proposal.id) continue;
const proposalId = proposal.id.toString();
// Check if we've reached our minimum stored proposal ID
if (minProposalId && BigInt(proposalId) <= BigInt(minProposalId)) {
console.log(`Reached minimum proposal ID (${minProposalId}). Stopping retrieval.`);
reachedExistingMinimum = true;
break;
}
// Update minimum ID
if (batchMinId === null || proposal.id < batchMinId) {
batchMinId = proposal.id;
}
// Always store the proposal to refresh its data
const exists = proposalExists(proposalId);
storeProposal(proposalId, proposal);
if (!exists) {
allExist = false;
totalStored++;
}
}
// If we've reached the minimum ID, stop fetching
if (reachedExistingMinimum) {
continueFetching = false;
continue;
}
// Update minimum proposal ID in database if this is our first run
if (batchMinId !== null && !minProposalId) {
// Only set the minimum ID from the first batch on first run
if (batchCount === 1) {
updateMinimumProposalId(batchMinId.toString());
console.log(green(`Set minimum proposal ID to ${batchMinId.toString()}`));
}
}
// If we have all proposals in this batch, we can stop fetching
if (allExist && proposals.length > 0) {
console.log("Reached proposals we already have in the database.");
continueFetching = false;
}
// If we have more to fetch, set up pagination for next batch
if (proposals.length > 0 && continueFetching) {
// Use the last proposal's ID for pagination
const lastProposal = proposals[proposals.length - 1];
if (lastProposal.id) {
beforeProposal = { id: lastProposal.id };
} else {
continueFetching = false;
}
} else {
continueFetching = false;
}
// Safety check: don't fetch too many batches
if (batchCount >= 100) {
console.log(yellow("⚠️ Reached batch limit (100). Stopping retrieval."));
continueFetching = false;
}
}
const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(green(bold(`✅ Retrieved and refreshed all proposals. Added ${totalStored} new proposals in ${elapsedSeconds}s`)));
} catch (error) {
console.error(red(bold(`❌ Error retrieving proposals: ${error instanceof Error ? error.message : String(error)}`)));
}
}
/**
* Process stored proposals and vote on eligible ones
*/
export async function processAndVoteOnStoredProposals(governance: GovernanceCanister, neuronId: bigint) {
console.log(bold(cyan("\n=== Processing stored proposals ===")));
try {
// Get all unprocessed proposals
const proposals = getUnprocessedProposals();
console.log(`Found ${proposals.length} unprocessed proposals`);
if (proposals.length === 0) {
return;
}
// Process each proposal
for (const proposal of proposals) {
console.log(`Processing proposal ID: ${proposal.id}`);
try {
// Check if the proposal is still open for voting
if (proposal.status === 0 &&
Number(proposal.proposalTimestampSeconds) +
Number(proposal.deadlineTimestampSeconds || 0) > Date.now() / 1000) {
console.log(`Proposal ${proposal.id} is still open for voting.`);
// Check if the neuron has already voted
const voted = proposal.ballots?.some((ballot: any) =>
ballot.neuronId?.toString() === neuronId.toString());
if (voted) {
console.log(`Neuron ${neuronId.toString()} has already voted on proposal ${proposal.id}.`);
} else {
console.log(`Neuron ${neuronId.toString()} hasn't voted on proposal ${proposal.id}. Casting reject vote...`);
// Cast reject vote
await governance.registerVote({
neuronId,
proposalId: BigInt(proposal.id),
vote: Vote.No,
});
console.log(green(`✅ Successfully cast reject vote for proposal ${proposal.id}`));
}
} else {
console.log(`Proposal ${proposal.id} is closed or no longer eligible for voting.`);
}
} catch (voteError) {
console.error(red(`❌ Error voting on proposal ${proposal.id}: ${voteError instanceof Error ? voteError.message : String(voteError)}`));
}
// Don't mark as processed - keep proposals in database
// markProposalProcessed(proposal.id);
}
} catch (error) {
console.error(red(bold(`❌ Error processing proposals: ${error instanceof Error ? error.message : String(error)}`)));
}
}
/**
* Lists and votes on open proposals
* This is the main entry point that orchestrates proposal retrieval and voting
*/
export async function listAndVoteOnProposals(governance: GovernanceCanister, neuronId: bigint) {
try {
// Step 1: Retrieve and store all new proposals
await retrieveAndStoreProposals(governance);
// Step 2: Process the stored proposals and vote
await processAndVoteOnStoredProposals(governance, neuronId);
} catch (error) {
console.error(red(bold("❌ Error in proposal management:")), error);
}
}
export async function createNeuron(governance: GovernanceCanister, ledger: LedgerCanister, principal: Principal) {
console.log("No neuron found for this principal. A new neuron will be staked.");
console.log(`A dissolve delay of ${Number(SIX_MONTHS_AND_ONE_DAY)/(24*60*60)} days will be set.`);
const confirmStake = confirm("Proceed to stake 1.001 ICP to create a neuron? [y/n]");
if (!confirmStake) {
Deno.exit(0);
}
try {
const memo = BigInt(Date.now()); // use current timestamp as unique memo
const destAccount = memoToNeuronAccountIdentifier({
controller: principal, memo, governanceCanisterId: GOVERNANCE_ID
});
const amountE8s = BigInt(1_001_00000), feeE8s = BigInt(10_000);
console.log(bold(cyan("Transferring:")), `${Number(amountE8s) / 100_000_000} ICP (${amountE8s} e8s)`);
console.log(bold(cyan("Fee:")), `${Number(feeE8s) / 100_000_000} ICP (${feeE8s} e8s)`);
// Check balance first
try {
const accountId = AccountIdentifier.fromPrincipal({ principal }).toHex();
const balance = await ledger.accountBalance({ accountIdentifier: accountId });
console.log(bold(cyan("Current balance:")), `${Number(balance) / 100_000_000} ICP (${balance} e8s)`);
if (balance < amountE8s + feeE8s) {
console.error(red(bold(`❌ Insufficient funds: Need ${Number(amountE8s + feeE8s) / 100_000_000} ICP but have ${Number(balance) / 100_000_000} ICP`)));
Deno.exit(1);
}
} catch (balanceError) {
console.error(red(bold("❌ Error checking balance:")), balanceError);
Deno.exit(1);
}
const blockIndex = await ledger.transfer({ to: destAccount, amount: amountE8s, fee: feeE8s, memo });
console.log(bold(green("ICP sent in block index:")), blockIndex);
const newNeuronId = await governance.claimOrRefreshNeuronFromAccount({ controller: principal, memo });
if (!newNeuronId) {
throw new Error("Neuron creation failed. (No neuron ID returned)");
}
console.log("Neuron created with ID:", newNeuronId.toString());
// After neuron is created, configure dissolve delay and followees
try {
// Default dissolve delay for new neurons is 7 days (604800 seconds)
const DEFAULT_DISSOLVE_SECONDS = 7 * 24 * 60 * 60;
// Calculate additional seconds needed
const additionalSeconds = SIX_MONTHS_AND_ONE_DAY + DEFAULT_DISSOLVE_SECONDS;
// Increase the dissolve delay (rather than setting it directly)
await governance.increaseDissolveDelay({
neuronId: newNeuronId,
additionalDissolveDelaySeconds: additionalSeconds
});
// Follow no one (manual voting) for all topics
for (const topic of Object.values(Topic).filter(t => typeof t === "number") as number[]) {
try {
await governance.setFollowees({ neuronId: newNeuronId, topic, followees: [] });
} catch (error) {
// Log the error but continue with other topics
console.log(yellow(`ℹ️ Skipping topic ${topic}: ${error instanceof Error ? error.message : String(error)}`));
}
}
const totalDays = SIX_MONTHS_AND_ONE_DAY / (24 * 60 * 60);
console.log(`Neuron is now configured with a ${totalDays.toFixed(1)} day dissolve delay and no followees.`);
return newNeuronId;
} catch (error) {
console.error(red(bold("❌ Error managing neuron:")), error);
// Handle BigInt serialization
try {
const safeError = JSON.parse(JSON.stringify(error, (_, value) =>
typeof value === 'bigint' ? value.toString() + 'n' : value
));
console.error(red("Error details:"), safeError);
} catch (_jsonError) {
console.error(red("Error details:"), String(error));
}
Deno.exit(1);
}
} catch (error) {
console.error(red(bold("❌ Transfer Error:")), error);
// Handle BigInt serialization
try {
const safeError = JSON.parse(JSON.stringify(error, (_, value) =>
typeof value === 'bigint' ? value.toString() + 'n' : value
));
console.error(red("Error details:"), safeError);
} catch (_jsonError) {
console.error(red("Error details:"), String(error));
}
Deno.exit(1);
}
}