Create a governance proposal
A governance proposal points at a Git commit (its commitHash) and opens a voting
window between two epochs. Creating one costs a proposal fee in EGLD, held by the
governance system contract until the proposal closes. This recipe reads the live
fee and config with getConfig(), then creates a proposal both ways, the
controller and the factory, against the real governance contract.
The default npm start reads the live governance config (fee, thresholds, last
proposal nonce), so you see real output without a funded wallet, and you learn the
fee you would need before spending it.
Prerequisites
- Node.js >= 20.19.0.
- For the default config read and
payloaddemos: devnet network access only. - For an actual proposal: a devnet PEM holding at least the proposal fee (500 EGLD on devnet at time of writing) plus gas.
Install
mkdir governance-create-proposal
cd governance-create-proposal
# Create the project files shown on this page.
npm install
npm run build
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-governance-create-proposal",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — create a MultiversX governance proposal with GovernanceController and factory, reading the live proposal fee and config first.",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.1",
"axios": "1.18.1",
"bignumber.js": "9.3.1",
"protobufjs": "7.6.5"
},
"devDependencies": {
"@types/node": "20.19.43",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.19.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false,
"outDir": "dist",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
// src/index.ts - CLI entry point for the governance-create-proposal recipe.
//
// Three modes:
// npm start -> read the live governance config (proposal fee,
// thresholds, last proposal nonce) - no wallet
// npm start -- payload -> build (do not send) a proposal and print its
// wire payload offline
// npm start -- propose <pem> -> actually create a proposal (needs a funded
// devnet PEM holding the proposal fee). Add
// --factory for the factory path.
//
// With an unfunded wallet, `propose` fails cleanly with "insufficient funds"
// (the fee is 500 EGLD on devnet), proving the payload and signature are
// well-formed - the verification level this recipe was authored at.
import { Account, DevnetEntrypoint } from '@multiversx/sdk-core';
import {
readGovernanceConfig,
createProposalViaController,
createProposalViaFactory,
describeProposalPayload,
} from './proposal';
// A 40-character Git commit hash the proposal points at. Governance requires
// exactly 40 chars (a full SHA-1) - see Pitfalls.
const EXAMPLE_COMMIT_HASH = 'abcdef0123456789abcdef0123456789abcdef01';
function egld(wei: bigint): string {
return `${wei / 10n ** 18n} EGLD`;
}
function makeEntrypoint(): DevnetEntrypoint {
return new DevnetEntrypoint({
clientName: 'cookbook-governance-create-proposal',
networkProviderConfig: { timeout: 15_000 },
});
}
/** Read the current epoch so proposals open a window in the future. */
async function currentEpoch(entrypoint: DevnetEntrypoint): Promise<number> {
const status = await entrypoint.createNetworkProvider().getNetworkStatus();
return status.currentEpoch;
}
async function runConfig(): Promise<void> {
const entrypoint = makeEntrypoint();
const config = await readGovernanceConfig(entrypoint);
console.log('Governance config (live devnet):');
console.log(` proposal fee: ${config.proposalFeeWei} wei (${egld(config.proposalFeeWei)})`);
console.log(` lost-proposal fee: ${config.lostProposalFeeWei} wei (${egld(config.lostProposalFeeWei)})`);
console.log(` min quorum: ${config.minQuorum}`);
console.log(` min pass threshold: ${config.minPassThreshold}`);
console.log(` min veto threshold: ${config.minVetoThreshold}`);
console.log(` last proposal nonce: ${config.lastProposalNonce}`);
console.log('\nCreating a proposal needs the proposal fee above in your wallet.');
}
async function runPayload(): Promise<void> {
const entrypoint = makeEntrypoint();
const [config, epoch, throwaway] = await Promise.all([
readGovernanceConfig(entrypoint),
currentEpoch(entrypoint),
entrypoint.createAccount(),
]);
const tx = await createProposalViaFactory(entrypoint, throwaway, {
commitHash: EXAMPLE_COMMIT_HASH,
startVoteEpoch: epoch + 1,
endVoteEpoch: epoch + 6,
feeWei: config.proposalFeeWei,
});
const p = describeProposalPayload(tx);
console.log(`function: ${p.function}`);
console.log(`commitHash: ${p.commitHashHex} (hex of the 40-char commit string)`);
console.log(`startEpoch: 0x${p.startEpochHex} = ${parseInt(p.startEpochHex || '0', 16)}`);
console.log(`endEpoch: 0x${p.endEpochHex} = ${parseInt(p.endEpochHex || '0', 16)}`);
console.log(`receiver: ${p.receiver} (the governance system contract)`);
console.log(`value (fee): ${p.feeWei} wei (${egld(p.feeWei)})`);
console.log(`gasLimit: ${p.gasLimit} (set automatically by the SDK)`);
}
async function runPropose(pemPath: string, useFactory: boolean): Promise<void> {
const entrypoint = makeEntrypoint();
const [config, epoch] = await Promise.all([readGovernanceConfig(entrypoint), currentEpoch(entrypoint)]);
const proposer = await Account.newFromPem(pemPath);
proposer.nonce = await entrypoint.recallAccountNonce(proposer.address);
console.log(`Proposer: ${proposer.address.toBech32()} (nonce ${proposer.nonce})`);
console.log(`Path: ${useFactory ? 'factory' : 'controller'}`);
console.log(`Fee: ${egld(config.proposalFeeWei)}, voting epochs ${epoch + 1}..${epoch + 6}`);
const create = useFactory ? createProposalViaFactory : createProposalViaController;
const tx = await create(entrypoint, proposer, {
commitHash: EXAMPLE_COMMIT_HASH,
startVoteEpoch: epoch + 1,
endVoteEpoch: epoch + 6,
feeWei: config.proposalFeeWei,
});
const txHash = await entrypoint.sendTransaction(tx);
console.log(` broadcast txHash: ${txHash}`);
}
async function main(): Promise<void> {
const [mode, ...rest] = process.argv.slice(2);
if (mode === 'propose') {
const pemPath = rest[0];
if (!pemPath) {
console.error('Usage: npm start -- propose <pemPath> [--factory]');
process.exitCode = 1;
return;
}
try {
await runPropose(pemPath, rest.includes('--factory'));
} catch (err) {
console.error(`Proposal rejected: ${(err as Error).message}`);
process.exitCode = 1;
}
return;
}
if (mode === 'payload') {
await runPayload();
return;
}
await runConfig();
}
main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});
Creating a proposal
// src/proposal.ts - the subject of this recipe: creating a MultiversX
// governance proposal with the GovernanceController and its factory.
//
// A governance proposal points at a Git commit (its `commitHash`) and opens a
// voting window between two epochs. Creating one costs a proposal fee in EGLD,
// which the governance system contract holds until the proposal closes. The
// fee is not a constant - read it from the live config with `getConfig()`
// rather than hard-coding it.
//
// The proposal is sent to the governance system contract (a protocol contract
// at a fixed address the SDK knows), not to a contract you deploy. Unlike the
// multisig writes, governance transactions set their own gas limit from the
// SDK's config defaults, so you do not pass one.
import { Account } from '@multiversx/sdk-core';
import type { DevnetEntrypoint, Transaction } from '@multiversx/sdk-core';
export interface GovernanceConfigView {
proposalFeeWei: bigint;
lostProposalFeeWei: bigint;
minQuorum: number;
minPassThreshold: number;
minVetoThreshold: number;
lastProposalNonce: number;
}
/** Read the live governance config, including the current proposal fee. */
export async function readGovernanceConfig(entrypoint: DevnetEntrypoint): Promise<GovernanceConfigView> {
const controller = entrypoint.createGovernanceController();
const config = await controller.getConfig();
return {
proposalFeeWei: config.proposalFee,
lostProposalFeeWei: config.lostProposalFee,
minQuorum: config.minQuorum,
minPassThreshold: config.minPassThreshold,
minVetoThreshold: config.minVetoThreshold,
lastProposalNonce: config.lastProposalNonce,
};
}
export interface ProposalInput {
commitHash: string;
startVoteEpoch: number;
endVoteEpoch: number;
feeWei: bigint;
}
/**
* Create a proposal, path 1 - the controller. `createTransactionForNewProposal`
* builds, sets the nonce, and signs in one call. The fee travels in the
* transaction's `value`.
*/
export async function createProposalViaController(
entrypoint: DevnetEntrypoint,
proposer: Account,
input: ProposalInput,
): Promise<Transaction> {
const controller = entrypoint.createGovernanceController();
return controller.createTransactionForNewProposal(proposer, proposer.getNonceThenIncrement(), {
commitHash: input.commitHash,
startVoteEpoch: input.startVoteEpoch,
endVoteEpoch: input.endVoteEpoch,
nativeTokenAmount: input.feeWei,
});
}
/**
* Create a proposal, path 2 - the factory. Builds the unsigned transaction
* only; the caller sets the nonce and signs.
*/
export async function createProposalViaFactory(
entrypoint: DevnetEntrypoint,
proposer: Account,
input: ProposalInput,
): Promise<Transaction> {
const factory = entrypoint.createGovernanceTransactionsFactory();
const transaction = await factory.createTransactionForNewProposal(proposer.address, {
commitHash: input.commitHash,
startVoteEpoch: input.startVoteEpoch,
endVoteEpoch: input.endVoteEpoch,
nativeTokenAmount: input.feeWei,
});
transaction.nonce = proposer.getNonceThenIncrement();
transaction.signature = await proposer.signTransaction(transaction);
return transaction;
}
export interface ProposalPayload {
function: string;
commitHashHex: string;
startEpochHex: string;
endEpochHex: string;
receiver: string;
feeWei: bigint;
gasLimit: bigint;
}
/** Decode a proposal transaction's wire fields. */
export function describeProposalPayload(transaction: Transaction): ProposalPayload {
const parts = Buffer.from(transaction.data).toString().split('@');
return {
function: parts[0] ?? '',
commitHashHex: parts[1] ?? '',
startEpochHex: parts[2] ?? '',
endEpochHex: parts[3] ?? '',
receiver: transaction.receiver.toBech32(),
feeWei: transaction.value,
gasLimit: transaction.gasLimit,
};
}
Run it
# Read the live governance config (fee, thresholds) - no wallet, no funds:
npm start
# Inspect the proposal wire payload offline:
npm start -- payload
# Actually create a proposal (needs a devnet PEM holding the fee); add --factory:
npm start -- propose ./wallet.pem
Output of the default config read, and of payload:
Governance config (live devnet):
proposal fee: 500000000000000000000 wei (500 EGLD)
lost-proposal fee: 10000000000000000000 wei (10 EGLD)
min quorum: 0.2
min pass threshold: 0.6667
min veto threshold: 0.33
last proposal nonce: 138
function: proposal
commitHash: 616263...363738396162636465663031 (hex of the 40-char commit string)
startEpoch: 0x1850 = 6224
endEpoch: 0x1855 = 6229
receiver: erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqrlllsrujgla (the governance system contract)
value (fee): 500000000000000000000 wei (500 EGLD)
gasLimit: 50198500 (set automatically by the SDK)
How it works
Controller vs factory.
controller.createTransactionForNewProposal(account, nonce, { commitHash, startVoteEpoch, endVoteEpoch, nativeTokenAmount })
builds, sets the nonce, and signs. The factory form only builds; you set nonce
and signature. The governance controller and factory take no ABI, the governance
contract's interface is baked into the SDK.
Read the fee, do not hard-code it. nativeTokenAmount is the proposal fee and
travels in the transaction's value. The fee is a network parameter, so the
recipe reads it from getConfig().proposalFee (500 EGLD on devnet) rather than
assuming a constant that could drift.
The receiver is a system contract. Proposals go to erd1qqq...rlllsrujgla, the
governance system contract, whose address the SDK derives from its own constants
(GOVERNANCE_CONTRACT_ADDRESS_HEX). You never pass it. The wire payload is
proposal@<commitHash hex>@<startEpoch>@<endEpoch>.
Pitfalls
Governance expects a full 40-character Git commit hash (a SHA-1). The SDK encodes
whatever string you pass as-is (StringValue), so a wrong length is not caught
when building, it is rejected on-chain. Pass the real 40-char commit of the change
you are proposing.
Unlike the multisig writes (which require an explicit gasLimit), governance
transactions set their gas limit automatically from the SDK config
(gasLimitForProposal, 50,000,000 plus data). Do not pass a gasLimit, there is
no parameter for it here.
The proposal fee travels in value and is held by the contract. If the proposal
fails to pass, part of it is kept as the lostProposalFee (10 EGLD on devnet);
only the rest is returned when the proposal is closed. Proposing is not free even
when it works.
startVoteEpoch / endVoteEpoch open the window; a window in the past is rejected
on-chain. This recipe reads the current epoch from the network and offsets from it,
rather than hard-coding epoch numbers that go stale.
See also
- Vote and close a proposal covers the next steps for the proposal this recipe creates.
- Delegate (stake) EGLD is what gives an address the voting power to back a proposal.