Read a multisig's state
Before you sign or perform anything on a multisig, you read it: who is on the
board, how many signatures the quorum needs, and what is currently pending. This
recipe reads all of that with the MultisigController's view helpers. None of it
needs a wallet; it is a read-only queryContract, decoded through the
multisig ABI.
The reads split in two: the board configuration (quorum, board members,
proposers) and the pending actions (what has been proposed but not performed,
and for each, its signers and whether it has reached quorum). The default
npm start reads a real devnet multisig, so you get live output right away.
Prerequisites
- Node.js >= 20.19.0 and devnet network access. No wallet, ever.
Install
mkdir multisig-read-state
cd multisig-read-state
# Create the project files shown on this page.
npm install
npm run build
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-multisig-read-state",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — read a multisig contract's state (quorum, board members, pending actions, and per-action signers) with MultisigController, no wallet.",
"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 multisig-read-state recipe.
//
// Two modes, both read-only (no wallet, no funds, ever):
// npm start -> read a real devnet multisig's board and
// pending actions
// npm start -- <bech32> -> read any multisig contract you pass
//
// This is the read side that the propose / sign / perform recipes call before
// they act.
import * as fs from 'fs';
import * as path from 'path';
import { Abi, DevnetEntrypoint } from '@multiversx/sdk-core';
import { readBoard, readPendingActions, readUserRole } from './read';
// A real multisig contract on devnet (quorum 2, a 2-member board, at least one
// pending action at the time of writing).
const EXAMPLE_MULTISIG = 'erd1qqqqqqqqqqqqqpgqvjcswsav2rw4ccvguyuk0f0u6qycpsuvu3dq9rse9w';
function loadAbi(fileName: string): Abi {
const raw = fs.readFileSync(path.join(__dirname, '..', 'src', fileName), { encoding: 'utf8' });
return Abi.create(JSON.parse(raw) as Record<string, unknown>);
}
function makeEntrypoint(): DevnetEntrypoint {
return new DevnetEntrypoint({
clientName: 'cookbook-multisig-read-state',
networkProviderConfig: { timeout: 15_000 },
});
}
async function main(): Promise<void> {
const multisigAddress = process.argv[2] ?? EXAMPLE_MULTISIG;
const entrypoint = makeEntrypoint();
const abi = loadAbi('multisig.abi.json');
console.log(`Multisig: ${multisigAddress}\n`);
const board = await readBoard(entrypoint, abi, multisigAddress);
console.log('Board configuration:');
console.log(` quorum: ${board.quorum} of ${board.numBoardMembers} board members`);
console.log(` proposers (non-board): ${board.numProposers}`);
console.log(` actions ever created: ${board.actionLastIndex}`);
board.boardMembers.forEach((member, i) => console.log(` board[${i}]: ${member}`));
board.proposers.forEach((proposer, i) => console.log(` proposer[${i}]: ${proposer}`));
// Show the role of the first board member (a self-check of getUserRole).
if (board.boardMembers[0]) {
const role = await readUserRole(entrypoint, abi, multisigAddress, board.boardMembers[0]);
console.log(` role of board[0]: ${role}`);
}
const pending = await readPendingActions(entrypoint, abi, multisigAddress);
console.log(`\nPending actions: ${pending.length}`);
for (const action of pending) {
console.log(` action ${action.actionId} (group ${action.groupId}): ${action.type}`);
console.log(` signers: ${action.signers.join(', ') || '(none)'}`);
console.log(` quorum reached: ${action.quorumReached}`);
}
}
main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});
{
"name": "Multisig",
"constructor": {
"inputs": [
{
"name": "quorum",
"type": "u32"
},
{
"name": "board",
"type": "variadic<Address>",
"multi_arg": true
}
],
"outputs": []
},
"endpoints": [
{
"docs": [
"Minimum number of signatures needed to perform any action."
],
"name": "getQuorum",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "u32"
}
]
},
{
"docs": [
"Denormalized board member count.",
"It is kept in sync with the user list by the contract."
],
"name": "getNumBoardMembers",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "u32"
}
]
},
{
"docs": [
"Denormalized proposer count.",
"It is kept in sync with the user list by the contract."
],
"name": "getNumProposers",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "u32"
}
]
},
{
"docs": [
"The index of the last proposed action.",
"0 means that no action was ever proposed yet."
],
"name": "getActionLastIndex",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "u32"
}
]
},
{
"docs": [
"Returns `true` (`1`) if `getActionValidSignerCount >= getQuorum`."
],
"name": "quorumReached",
"mutability": "readonly",
"inputs": [
{
"name": "action_id",
"type": "u32"
}
],
"outputs": [
{
"type": "bool"
}
]
},
{
"docs": [
"Iterates through all actions and retrieves those that are still pending.",
"Serialized full action data:",
"- the action id",
"- the serialized action data",
"- (number of signers followed by) list of signer addresses."
],
"name": "getPendingActionFullInfo",
"mutability": "readonly",
"inputs": [
{
"name": "opt_range",
"type": "optional<tuple<u32,u32>>",
"multi_arg": true
}
],
"outputs": [
{
"type": "variadic<ActionFullInfo>",
"multi_result": true
}
],
"labels": [
"multisig-external-view"
],
"allow_multiple_var_args": true
},
{
"docs": [
"Indicates user rights.",
"`0` = no rights,",
"`1` = can propose, but not sign,",
"`2` = can propose and sign."
],
"name": "userRole",
"mutability": "readonly",
"inputs": [
{
"name": "user",
"type": "Address"
}
],
"outputs": [
{
"type": "UserRole"
}
],
"labels": [
"multisig-external-view"
]
},
{
"docs": [
"Lists all users that can sign actions."
],
"name": "getAllBoardMembers",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "variadic<Address>",
"multi_result": true
}
],
"labels": [
"multisig-external-view"
]
},
{
"docs": [
"Lists all proposers that are not board members."
],
"name": "getAllProposers",
"mutability": "readonly",
"inputs": [],
"outputs": [
{
"type": "variadic<Address>",
"multi_result": true
}
],
"labels": [
"multisig-external-view"
]
}
],
"events": [],
"hasCallback": false,
"types": {
"Action": {
"type": "enum",
"variants": [
{
"name": "Nothing",
"discriminant": 0
},
{
"name": "AddBoardMember",
"discriminant": 1,
"fields": [
{
"name": "0",
"type": "Address"
}
]
},
{
"name": "AddProposer",
"discriminant": 2,
"fields": [
{
"name": "0",
"type": "Address"
}
]
},
{
"name": "RemoveUser",
"discriminant": 3,
"fields": [
{
"name": "0",
"type": "Address"
}
]
},
{
"name": "ChangeQuorum",
"discriminant": 4,
"fields": [
{
"name": "0",
"type": "u32"
}
]
},
{
"name": "SendTransferExecuteEgld",
"discriminant": 5,
"fields": [
{
"name": "0",
"type": "CallActionData"
}
]
},
{
"name": "SendTransferExecuteEsdt",
"discriminant": 6,
"fields": [
{
"name": "0",
"type": "EsdtTransferExecuteData"
}
]
},
{
"name": "SendAsyncCall",
"discriminant": 7,
"fields": [
{
"name": "0",
"type": "CallActionData"
}
]
},
{
"name": "SCDeployFromSource",
"discriminant": 8,
"fields": [
{
"name": "amount",
"type": "BigUint"
},
{
"name": "source",
"type": "Address"
},
{
"name": "code_metadata",
"type": "CodeMetadata"
},
{
"name": "arguments",
"type": "List<bytes>"
}
]
},
{
"name": "SCUpgradeFromSource",
"discriminant": 9,
"fields": [
{
"name": "sc_address",
"type": "Address"
},
{
"name": "amount",
"type": "BigUint"
},
{
"name": "source",
"type": "Address"
},
{
"name": "code_metadata",
"type": "CodeMetadata"
},
{
"name": "arguments",
"type": "List<bytes>"
}
]
}
]
},
"ActionFullInfo": {
"type": "struct",
"docs": [
"Not used internally, just to retrieve results via endpoint."
],
"fields": [
{
"name": "action_id",
"type": "u32"
},
{
"name": "group_id",
"type": "u32"
},
{
"name": "action_data",
"type": "Action"
},
{
"name": "signers",
"type": "List<Address>"
}
]
},
"CallActionData": {
"type": "struct",
"fields": [
{
"name": "to",
"type": "Address"
},
{
"name": "egld_amount",
"type": "BigUint"
},
{
"name": "opt_gas_limit",
"type": "Option<u64>"
},
{
"name": "endpoint_name",
"type": "bytes"
},
{
"name": "arguments",
"type": "List<bytes>"
}
]
},
"EsdtTokenPayment": {
"type": "struct",
"fields": [
{
"name": "token_identifier",
"type": "TokenIdentifier"
},
{
"name": "token_nonce",
"type": "u64"
},
{
"name": "amount",
"type": "BigUint"
}
]
},
"EsdtTransferExecuteData": {
"type": "struct",
"fields": [
{
"name": "to",
"type": "Address"
},
{
"name": "tokens",
"type": "List<EsdtTokenPayment>"
},
{
"name": "opt_gas_limit",
"type": "Option<u64>"
},
{
"name": "endpoint_name",
"type": "bytes"
},
{
"name": "arguments",
"type": "List<bytes>"
}
]
},
"UserRole": {
"type": "enum",
"variants": [
{
"name": "None",
"discriminant": 0
},
{
"name": "Proposer",
"discriminant": 1
},
{
"name": "BoardMember",
"discriminant": 2
}
]
}
}
}
Reading
// src/read.ts - the subject of this recipe: reading a multisig contract's
// state with the MultisigController's view helpers. None of this needs a
// wallet - it is all read-only `queryContract` under the hood, decoded through
// the multisig ABI.
//
// What you can read splits into two groups:
// - the board configuration: quorum, board members, proposers;
// - the pending actions: what has been proposed but not yet performed, and
// for each, who has signed and whether it has reached quorum.
//
// Together these answer "who controls this multisig, and what is it about to
// do?" - the questions you ask before you sign or perform anything.
import { Address } from '@multiversx/sdk-core';
import type { Abi, DevnetEntrypoint } from '@multiversx/sdk-core';
export interface MultisigBoard {
quorum: number;
numBoardMembers: number;
numProposers: number;
actionLastIndex: number;
boardMembers: string[];
proposers: string[];
}
/** Read the board configuration: quorum and membership. */
export async function readBoard(
entrypoint: DevnetEntrypoint,
abi: Abi,
multisigAddress: string,
): Promise<MultisigBoard> {
const controller = entrypoint.createMultisigController(abi);
const [quorum, numBoardMembers, numProposers, actionLastIndex, boardMembers, proposers] = await Promise.all([
controller.getQuorum({ multisigAddress }),
controller.getNumBoardMembers({ multisigAddress }),
controller.getNumProposers({ multisigAddress }),
controller.getActionLastIndex({ multisigAddress }),
controller.getAllBoardMembers({ multisigAddress }),
controller.getAllProposers({ multisigAddress }),
]);
return { quorum, numBoardMembers, numProposers, actionLastIndex, boardMembers, proposers };
}
export interface PendingAction {
actionId: number;
groupId: number;
type: string;
signers: string[];
quorumReached: boolean;
}
/**
* Read every pending action, and for each, its signers and whether it has
* reached quorum. `getPendingActionFullInfo` returns the action id, group id,
* decoded action data (its `type` names what it will do), and the raw signer
* list; `quorumReached` tells you if it can be performed now.
*/
export async function readPendingActions(
entrypoint: DevnetEntrypoint,
abi: Abi,
multisigAddress: string,
): Promise<PendingAction[]> {
const controller = entrypoint.createMultisigController(abi);
const pending = await controller.getPendingActionFullInfo({ multisigAddress });
const result: PendingAction[] = [];
for (const action of pending) {
const quorumReached = await controller.quorumReached({ multisigAddress, actionId: action.actionId });
result.push({
actionId: action.actionId,
groupId: action.groupId,
type: action.actionData.type,
// `signers` here is `Address[]` (from FullMultisigAction), unlike the
// `getActionSigners` helper whose declared type is wrong - see Pitfalls.
signers: action.signers.map((address: Address) => address.toBech32()),
quorumReached,
});
}
return result;
}
/** Look up one user's role on the multisig: None, Proposer, or BoardMember. */
export async function readUserRole(
entrypoint: DevnetEntrypoint,
abi: Abi,
multisigAddress: string,
userAddress: string,
): Promise<string> {
const controller = entrypoint.createMultisigController(abi);
const role = await controller.getUserRole({ multisigAddress, userAddress });
return role.valueOf();
}
Run it
# Read the bundled example multisig - live, no wallet:
npm start
# Read any multisig you pass:
npm start -- erd1qqqqqqqqqqqqqpgq...
Expected output (a live snapshot, the pending action changes as the board acts):
Multisig: erd1qqqqqqqqqqqqqpgqvjcswsav2rw4ccvguyuk0f0u6qycpsuvu3dq9rse9w
Board configuration:
quorum: 2 of 2 board members
proposers (non-board): 0
actions ever created: 5
board[0]: erd1d4q9a3qx5695gwd02rap2qqad3hv7lga7s06y5e6m3shuy6pu3dqagj7qe
board[1]: erd1c8fjemhv646hwlladfna6ce4m85y5hr4esqlysceelqgl987y92sp248zr
role of board[0]: BoardMember
Pending actions: 1
action 4 (group 0): SendTransferExecuteEgld
signers: erd1d4q9a3qx5695gwd02rap2qqad3hv7lga7s06y5e6m3shuy6pu3dqagj7qe
quorum reached: false
How it works
One controller, many views. entrypoint.createMultisigController(abi) gives
you getQuorum, getNumBoardMembers, getAllBoardMembers, getAllProposers,
getActionLastIndex, getUserRole, getPendingActionFullInfo, quorumReached,
and more, all read-only queries. The ABI is required so the controller can decode
the raw query responses into numbers, addresses, and typed action data.
Pending actions carry decoded action data. getPendingActionFullInfo returns
each pending action's id, group id, signer list (Address[]), and an actionData
whose type names what it will do (SendTransferExecuteEgld, AddBoardMember,
ChangeQuorum, ...). That type is how you tell a spend proposal from a board
change before you sign.
Roles gate everything. getUserRole returns None, Proposer, or
BoardMember. Only proposers and board members may propose; only board members'
signatures count toward quorum.
Pitfalls
createMultisigController(abi) needs the multisig ABI to decode query responses.
Without it, the controller cannot turn raw returned bytes into the board list or the
pending actionData. Each recipe bundles the recipe-specific multisig.abi.json subset; ship the ABI
with your app too.
If you reach for getActionSigners(...) (used in the sign-and-perform recipe)
rather than getPendingActionFullInfo(...).signers, note it is declared
Promise<string[]> but actually returns Address[] at runtime (sdk-core v15.4.1).
The signers on a FullMultisigAction here is correctly typed Address[].
Quorum and board membership are stable, but pending actions come and go as the board
signs, performs, and discards. The example's action 4 may be gone by the time you
run this; pass your own multisig address as an argument to read a contract you
control.
See also
- Propose a multisig action creates a pending action to read here.
- Sign and perform a multisig action acts on what you read.
- Read a delegation contract's state is the same read-only, no-wallet pattern for delegation.