Skip to main content
AdvancedEst9 minsdk-core15.4.1Project build checked

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 payload demos: 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
package.json
{
"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"
}
}
tsconfig.json
{
"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
// 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
// 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

Pitfall 1: the commit hash must be exactly 40 characters

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.

Pitfall 2: governance sets its own gas; multisig does not

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.

Pitfall 3: the fee is at stake, not just a deposit

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.

Pitfall 4: vote epochs must be in the future

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