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

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
package.json
{
"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"
}
}
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 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;
});
src/multisig.abi.json
{
"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
// 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

Pitfall 1: the ABI is required even for reads

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.

Pitfall 2: getActionSigners returns Address objects, not strings

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[].

Pitfall 3: the output is a live snapshot

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