diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e5bbead --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1757347588, + "narHash": "sha256-tLdkkC6XnsY9EOZW9TlpesTclELy8W7lL2ClL+nma8o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b599843bad24621dcaa5ab60dac98f9b0eb1cabe", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..68c3bb9 --- /dev/null +++ b/flake.nix @@ -0,0 +1,110 @@ +{ + description = "Agoric Cosmos Proposal Builder - Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Node.js version - using LTS 20 which is compatible with React 18 + nodejs = pkgs.nodejs_20; + + # Yarn version specified in package.json + yarn = pkgs.yarn.override { inherit nodejs; }; + + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # Core development tools + nodejs + yarn + + # Git for version control + git + + # Docker and Docker Compose (for the project's docker setup) + docker + docker-compose + + # Additional useful tools + jq # Used in package.json scripts + curl + + # Development utilities + nodePackages.typescript + nodePackages.typescript-language-server + nodePackages.vscode-langservers-extracted # HTML/CSS/JSON language servers + + # Testing and linting tools (though these will be installed via yarn) + nodePackages.eslint + nodePackages.prettier + ]; + + shellHook = '' + + echo "📦 Node.js version: $(node --version)" + echo "🧶 Yarn version: $(yarn --version)" + echo "" + + # Set up environment variables + export NODE_ENV=development + + # Ensure node_modules/.bin is in PATH for local package binaries + export PATH="$PWD/node_modules/.bin:$PATH" + + # Create .envrc for direnv integration (optional but recommended) + if [ ! -f .envrc ]; then + echo "use flake" > .envrc + echo "💡 Created .envrc file for direnv integration" + echo " Run 'direnv allow' to enable automatic environment loading" + fi + ''; + + # Environment variables that might be useful + env = { + # Ensure npm/yarn uses the correct Node.js version + npm_config_nodejs_version = nodejs.version; + + # Disable npm update notifications in development + NO_UPDATE_NOTIFIER = "1"; + + # Enable Yarn's offline mirror for better reproducibility + YARN_ENABLE_OFFLINE_MODE = "false"; + }; + }; + + # Optional: Add packages that can be built/installed + packages = { + # You could add custom packages here if needed + inherit nodejs yarn; + }; + + # Optional: Define apps that can be run with `nix run` + apps = { + dev = { + type = "app"; + program = "${pkgs.writeShellScript "dev" '' + cd ${toString ./.} + ${yarn}/bin/yarn install + ${yarn}/bin/yarn dev + ''}"; + }; + + build = { + type = "app"; + program = "${pkgs.writeShellScript "build" '' + cd ${toString ./.} + ${yarn}/bin/yarn install + ${yarn}/bin/yarn build + ''}"; + }; + }; + } + ); +} diff --git a/src/components/GovV1ParameterInputs.tsx b/src/components/GovV1ParameterInputs.tsx new file mode 100644 index 0000000..4f30f56 --- /dev/null +++ b/src/components/GovV1ParameterInputs.tsx @@ -0,0 +1,639 @@ +import { useEffect, useState, forwardRef, useImperativeHandle } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useNetwork } from "../hooks/useNetwork"; +import { govV1ParamsQuery } from "../lib/queries"; +import { selectGovV1Params } from "../lib/selectors"; +import type { GovV1ParamFormData } from "../types/gov"; + +// Constants for 64-bit integer bounds for Duration.seconds +// From the protobuf spec: Must be from -315,576,000,000 to +315,576,000,000 inclusive +const MIN_DURATION_SECONDS = BigInt(-315576000000); +const MAX_DURATION_SECONDS = BigInt(315576000000); + +// Validation function for duration values +const validateDuration = (seconds: string): string | null => { + if (!seconds.trim()) return null; // Allow empty values + + try { + const secondsBigInt = BigInt(seconds); + + if (secondsBigInt < MIN_DURATION_SECONDS || secondsBigInt > MAX_DURATION_SECONDS) { + return `Duration must be between ${MIN_DURATION_SECONDS.toLocaleString()} and ${MAX_DURATION_SECONDS.toLocaleString()} seconds (protobuf 64-bit limit)`; + } + + if (secondsBigInt < BigInt(0)) { + return "Duration cannot be negative"; + } + + return null; // Valid + } catch (error) { + return "Invalid number format"; + } +}; + +// Interface for the ref methods +export interface GovV1ParameterInputsMethods { + getFormData: () => GovV1ParamFormData | null; + reset: () => void; + hasValidationErrors: () => boolean; + getValidationErrors: () => Record; +} + +const GovV1ParameterInputsBase = ( + props: { + defaultAuthorityAddress: string | undefined; + }, + ref: React.ForwardedRef, +) => { + const { defaultAuthorityAddress } = props; + const { api } = useNetwork(); + const paramsQuery = useQuery(govV1ParamsQuery(api)); + const currentParams = selectGovV1Params(paramsQuery); + + // State for form data + const [formData, setFormData] = useState(null); + + // State for validation errors + const [validationErrors, setValidationErrors] = useState>({}); + + // Load current parameters into form when available + useEffect(() => { + if (currentParams && !formData) { + setFormData(currentParams); + } + }, [currentParams, formData]); + + // Expose methods through ref + useImperativeHandle(ref, () => ({ + getFormData: () => formData, + reset: () => { + setFormData(currentParams || null); + setValidationErrors({}); + }, + hasValidationErrors: () => Object.keys(validationErrors).some(key => validationErrors[key]), + getValidationErrors: () => validationErrors, + })); + + // Helper to update form data + const updateField = (field: keyof GovV1ParamFormData, value: any) => { + if (!formData) return; + setFormData({ ...formData, [field]: value }); + }; + + // Helper to update duration fields with validation + const updateDurationField = (field: keyof GovV1ParamFormData, value: string) => { + if (!formData) return; + + // Update the form data + setFormData({ ...formData, [field]: value }); + + // Validate the duration + const error = validateDuration(value); + setValidationErrors(prev => { + const newErrors = { ...prev }; + if (error) { + newErrors[field] = error; + } else { + delete newErrors[field]; + } + return newErrors; + }); + }; + + // Helper to update minDeposit array + const updateMinDeposit = ( + index: number, + field: "denom" | "amount", + value: string, + ) => { + if (!formData) return; + const newMinDeposit = [...(formData?.minDeposit || [])]; + newMinDeposit[index] = { ...newMinDeposit[index], [field]: value }; + setFormData({ ...formData, minDeposit: newMinDeposit }); + }; + + const addMinDeposit = () => { + if (!formData) return; + setFormData({ + ...formData, + minDeposit: [...(formData?.minDeposit || []), { denom: "", amount: "" }], + }); + }; + + const removeMinDeposit = (index: number) => { + if (!formData) return; + const newMinDeposit = (formData?.minDeposit || []).filter( + (_, i) => i !== index, + ); + setFormData({ ...formData, minDeposit: newMinDeposit }); + }; + + // Handle loading and error states + if (paramsQuery.isLoading) { + return ( +
+ Loading current governance parameters... +
+ ); + } + + if (paramsQuery.isError) { + return ( +
+ ⚠️ Error loading governance parameters: {paramsQuery.error?.toString()} +
+ + Please check your network connection and API endpoint. + +
+ ); + } + + if (!currentParams) { + return ( +
+ ⚠️ No governance parameters found +
+ + The API did not return any Gov v1 parameters. This could be due to: +
No chain selected - Please select a network + first +
+ • You're connected to a chain that doesn't support Gov v1 +
+ • API endpoint issue or network connectivity problem +
+ • Chain doesn't have Gov v1 module enabled +
+
+ Current API: {api || "None selected"} +
+ Query endpoint:{" "} + {api ? `${api}/cosmos/gov/v1/params` : "No API endpoint"} +
+ Raw response:{" "} + {JSON.stringify(paramsQuery.data) || "Empty/null"} +
+
+ ); + } + + if (!formData) { + return ( +
+ Initializing form with current parameters... +
+ ); + } + + return ( +
+ {/* Authority Section */} +
+ +
+ +

+ The governance module authority address (required for parameter + updates) +

+
+
+ + {/* Min Deposit Section */} +
+ +
+ {formData?.minDeposit?.map((deposit, index) => ( +
+ + updateMinDeposit(index, "denom", e.target.value) + } + className="flex-1 rounded-md border-0 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> + + updateMinDeposit(index, "amount", e.target.value) + } + className="flex-1 rounded-md border-0 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> + +
+ ))} + +
+
+ + {/* Duration Fields */} +
+
+ + updateDurationField('maxDepositPeriod', e.target.value)} + className={`block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset placeholder:text-gray-400 focus:ring-1 focus:ring-inset ${ + validationErrors.maxDepositPeriod + ? 'ring-red-500 focus:ring-red-500' + : 'ring-light focus:ring-red' + }`} + /> + {validationErrors.maxDepositPeriod && ( +

+ {validationErrors.maxDepositPeriod} +

+ )} +

+ Time for deposits in seconds (e.g., 172800 = 48 hours)
+ Max: {MAX_DURATION_SECONDS.toLocaleString()} seconds (≈10,000 years) +

+
+ +
+ + updateDurationField('votingPeriod', e.target.value)} + className={`block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset placeholder:text-gray-400 focus:ring-1 focus:ring-inset ${ + validationErrors.votingPeriod + ? 'ring-red-500 focus:ring-red-500' + : 'ring-light focus:ring-red' + }`} + /> + {validationErrors.votingPeriod && ( +

+ {validationErrors.votingPeriod} +

+ )} +

+ Time for voting in seconds (e.g., 604800 = 7 days)
+ Max: {MAX_DURATION_SECONDS.toLocaleString()} seconds (≈10,000 years) +

+
+
+ + {/* Percentage Fields */} +
+
+ + updateField("quorum", e.target.value)} + placeholder="0.334000000000000000" + className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> +

+ 33.4% = 0.334000000000000000 +

+
+ +
+ + updateField("threshold", e.target.value)} + placeholder="0.500000000000000000" + className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> +

+ 50% = 0.500000000000000000 +

+
+ +
+ + updateField("vetoThreshold", e.target.value)} + placeholder="0.334000000000000000" + className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> +

+ 33.4% = 0.334000000000000000 +

+
+
+ + {/* Additional Ratios */} +
+
+ + + updateField("minInitialDepositRatio", e.target.value) + } + placeholder="0.000000000000000000" + className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> +

+ Initial deposit ratio required +

+
+ +
+ + updateField("min_deposit_ratio", e.target.value)} + placeholder="0.010000000000000000" + className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> +

+ 1% = 0.010000000000000000 +

+
+
+ + {/* Proposal Cancellation */} +
+ + updateField("proposal_cancel_ratio", e.target.value)} + placeholder="0.500000000000000000" + className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> +

+ Ratio of total stake needed to cancel a proposal (50% = + 0.500000000000000000) +

+
+ + {/* Expedited Proposal Settings */} +
+ + +
+
+ + updateDurationField('expedited_voting_period', e.target.value)} + placeholder="86400" + className={`block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset placeholder:text-gray-400 focus:ring-1 focus:ring-inset ${ + validationErrors.expedited_voting_period + ? 'ring-red-500 focus:ring-red-500' + : 'ring-light focus:ring-red' + }`} + /> + {validationErrors.expedited_voting_period && ( +

+ {validationErrors.expedited_voting_period} +

+ )} +

+ 86400 = 24 hours
+ Max: {MAX_DURATION_SECONDS.toLocaleString()} seconds (≈10,000 years) +

+
+ +
+ + + updateField("expedited_threshold", e.target.value) + } + placeholder="0.667000000000000000" + className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> +

+ 66.7% = 0.667000000000000000 +

+
+
+ + {/* Expedited Min Deposit */} +
+ +
+ {formData?.expedited_min_deposit?.map((deposit, index) => ( +
+ { + const newExpedited = [ + ...(formData?.expedited_min_deposit || []), + ]; + newExpedited[index] = { + ...newExpedited[index], + denom: e.target.value, + }; + updateField("expedited_min_deposit", newExpedited); + }} + className="flex-1 rounded-md border-0 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> + { + const newExpedited = [ + ...(formData?.expedited_min_deposit || []), + ]; + newExpedited[index] = { + ...newExpedited[index], + amount: e.target.value, + }; + updateField("expedited_min_deposit", newExpedited); + }} + className="flex-1 rounded-md border-0 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-light placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-red" + /> + +
+ ))} + +
+
+
+ + {/* Boolean Fields */} +
+ +
+ + + + + +
+
+
+ ); +}; + +// Create the forwardRef component +const GovV1ParameterInputs = forwardRef(GovV1ParameterInputsBase); + +export { GovV1ParameterInputs }; diff --git a/src/config/agoric/agoric.spec.tsx b/src/config/agoric/agoric.spec.tsx index ef32356..b03c478 100644 --- a/src/config/agoric/agoric.spec.tsx +++ b/src/config/agoric/agoric.spec.tsx @@ -23,10 +23,11 @@ describe("Agoric Config", () => { ); expect(tabs).toEqual([ "Text Proposal", - "CoreEval Proposal", + "CoreEval Proposal", "Install Bundle", "Parameter Change Proposal", "Community Pool Spend", + "Gov v1 Parameters", ]); }); diff --git a/src/config/agoric/agoric.tsx b/src/config/agoric/agoric.tsx index 28f5cba..e21aae1 100644 --- a/src/config/agoric/agoric.tsx +++ b/src/config/agoric/agoric.tsx @@ -4,6 +4,10 @@ import { Code } from "../../components/inline"; import { BundleForm, BundleFormArgs } from "../../components/BundleForm"; import { ProposalForm, ProposalArgs } from "../../components/ProposalForm"; import { Tabs } from "../../components/Tabs"; +import { + GovV1ParameterInputs, + GovV1ParameterInputsMethods, +} from "../../components/GovV1ParameterInputs"; import { useNetwork } from "../../hooks/useNetwork"; import { useWallet } from "../../hooks/useWallet"; import { compressBundle } from "../../lib/compression"; @@ -13,6 +17,9 @@ import { makeInstallBundleMsg, makeParamChangeProposalMsg, makeCommunityPoolSpendProposalMsg, + makeGovV1ProposalMsg, + makeMsgUpdateGovParams, + createGovV1UpdateParamsAny, } from "../../lib/messageBuilder"; import { isValidBundle } from "../../utils/validate"; import { makeSignAndBroadcast } from "../../lib/signAndBroadcast"; @@ -22,6 +29,7 @@ import { useQueries, useQuery, UseQueryResult } from "@tanstack/react-query"; import { accountBalancesQuery, + moduleAccountQuery, depositParamsQuery, votingParamsQuery, } from "../../lib/queries.ts"; @@ -35,6 +43,7 @@ const Agoric = () => { const proposalFormRef = useRef(null); const corEvalFormRef = useRef(null); const bundleFormRef = useRef(null); + const govV1ParamsRef = useRef(null); const watchBundle = useWatchBundle(networkConfig?.rpc, { clipboard: window.navigator.clipboard, }); @@ -56,6 +65,10 @@ const Agoric = () => { }, }); + const { data: defaultAuthorityAddress } = useQuery( + moduleAccountQuery(api, "gov"), + ); + const signAndBroadcast = useMemo( () => makeSignAndBroadcast(stargateClient, walletAddress, netName), [stargateClient, walletAddress, netName], @@ -139,6 +152,85 @@ const Agoric = () => { } }; } + + // Special handler for Gov v1 parameter changes + function handleGovV1ParameterChange() { + return async (event: React.FormEvent) => { + event.preventDefault(); + + if (!walletAddress) { + toast.error("Wallet not connected.", { autoClose: 3000 }); + throw new Error("wallet not connected"); + } + + const formData = new FormData(event.target as HTMLFormElement); + + // Extract Gov v1 form data + const authority = formData.get("authority") as string; + const title = formData.get("title") as string; + const description = formData.get("description") as string; + + if (!authority || !title || !description) { + toast.error("Please fill in all required fields.", { autoClose: 3000 }); + return; + } + + // Get form data from the Gov v1 component using ref (following ParameterChangeForm pattern) + const govV1FormData = govV1ParamsRef.current?.getFormData(); + if (!govV1FormData) { + toast.error( + "Gov v1 parameter data not available. Please ensure the form is loaded.", + { autoClose: 3000 }, + ); + return; + } + + if (govV1ParamsRef.current?.hasValidationErrors()) { + const errors = govV1ParamsRef.current.getValidationErrors(); + const errorMessages = Object.entries(errors) + .filter(([_, error]) => error) + .map(([field, error]) => `${field}: ${error}`) + .join(', '); + toast.error(`Please fix validation errors: ${errorMessages}`, { autoClose: 5000 }); + return; + } + + try { + // 1. Create MsgUpdateParams + const msgUpdateParams = makeMsgUpdateGovParams({ + authority, + formData: govV1FormData, + }); + + // 2. Encode as Any + const anyMsg = createGovV1UpdateParamsAny(msgUpdateParams); + + // 3. Create MsgSubmitProposal with the encoded message + const proposalMsg = makeGovV1ProposalMsg({ + messages: [anyMsg], + initialDeposit: minDeposit || [], + proposer: walletAddress, + metadata: "", + title, + summary: description, + }); + + // 4. Submit + await signAndBroadcast(proposalMsg, "proposal"); + proposalFormRef.current?.reset(); + + toast.success("Gov v1 parameter change proposal submitted!", { + autoClose: 5000, + }); + } catch (e) { + console.error(e); + toast.error("Failed to submit proposal. Check console for details.", { + autoClose: 5000, + }); + } + }; + } + const [alertBox, setAlertBox] = useState(true); const canDeposit = useMemo( @@ -308,6 +400,90 @@ const Agoric = () => { /> ), }, + { + title: "Gov v1 Parameters", + msgType: "govV1ParameterChange", + content: ( +
+
+
+

+ Gov v1 Parameter Change Proposal +

+

+ This is a governance proposal to update governance module + parameters using Gov v1. This includes settings like + voting periods, deposit requirements, and burn settings. +

+ +
+ {/* Title and Description - moved to top */} +
+ +
+ +
+
+ +
+ +
+