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

Guard and unguard an account

Once a guardian is nominated (see Set a guardian), two transactions toggle protection:

  • GuardAccount activates guardianship. From here on, every transaction from the account must carry the guardian's co-signature.
  • UnGuardAccount removes guardianship. Because the account is guarded when you run this, the unguard transaction itself must be co-signed by the current guardian.

This recipe builds both with sdk-core's AccountController (and factory), decodes the payloads, and shows the guarded flag being set on the unguard.

Prerequisites

  • Node.js >= 20.19.0.
  • A devnet wallet, see Send EGLD to an address's Prerequisites. No devnet EGLD required, see "How it works".

Install

mkdir guard-unguard-account
cd guard-unguard-account
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem --guardian erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
Complete project files omitted from the main walkthrough
package.json
{
"name": "cookbook-recipe-guard-unguard-account",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — activate guardianship with GuardAccount and remove it with UnGuardAccount via sdk-core, including the guardian co-sign requirement.",
"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/account.ts
// src/account.ts — load a devnet Account from a PEM file and prime its
// local nonce from the network. Framework-agnostic sdk-core, no sdk-dapp
// involved — this is the "your own code holds the keys" pattern (a script,
// a bot, a backend service), not a browser wallet-connected flow.
//
// Identical to the helper used by issue-fungible-token / send-egld /
// manage-nonces. Fetch the account nonce from the network first, then
// increment locally with getNonceThenIncrement().

import { Account } from '@multiversx/sdk-core';
import type { DevnetEntrypoint } from '@multiversx/sdk-core';

/**
* Loads an Account from a PEM file and sets its local `nonce` from the
* network's current value — the "fetch" half of the fetch-then-increment
* pattern (see the manage-nonces recipe for the "increment" half).
*/
export async function loadDevnetAccount(
entrypoint: DevnetEntrypoint,
pemPath: string,
): Promise<Account> {
const account = await Account.newFromPem(pemPath);
account.nonce = await entrypoint.recallAccountNonce(account.address);
return account;
}
src/index.ts
// src/index.ts — CLI entry point for the guard/unguard recipe.
//
// Usage:
// npm run build
// npm start -- <pemPath> [--guardian <bech32>] [--factory]
//
// Example:
// npm start -- ./wallet.pem --guardian erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
//
// It decodes a GuardAccount payload and an UnGuardAccount payload. When a
// --guardian is supplied, the unguard is marked as guarded (options bit set,
// guardian field populated) — the co-sign requirement. It then broadcasts the
// GuardAccount; an unfunded wallet is rejected for insufficient funds,
// proving the payload is well-formed without spending real EGLD.

import { Address, DevnetEntrypoint } from '@multiversx/sdk-core';
import { loadDevnetAccount } from './account';
import { guardAccount, unguardAccount, describeGuardPayload } from './guard';

function printPayload(label: string, decoded: ReturnType<typeof describeGuardPayload>): void {
console.log(`${label}`);
console.log(` function: ${decoded.function}`);
console.log(` receiver: ${decoded.receiver}`);
console.log(` options: ${decoded.options} (guarded bit set: ${decoded.isGuardedBitSet})`);
console.log(` guardian: ${decoded.guardian}`);
console.log(` gasLimit: ${decoded.gasLimit}`);
}

async function main(): Promise<void> {
const args = process.argv.slice(2);
const pemPath = args[0];
const useFactory = args.includes('--factory');
const guardianFlagIndex = args.indexOf('--guardian');
const guardianArg = guardianFlagIndex >= 0 ? args[guardianFlagIndex + 1] : undefined;
const guardian = guardianArg ? Address.newFromBech32(guardianArg) : undefined;

if (!pemPath) {
console.error('Usage: npm start -- <pemPath> [--guardian <bech32>] [--factory]');
process.exitCode = 1;
return;
}

const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
const sender = await loadDevnetAccount(entrypoint, pemPath);

console.log(`Sender: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`API level: ${useFactory ? 'factory' : 'controller'}\n`);

const guardTx = await guardAccount(entrypoint, sender, useFactory);
printPayload('GUARD ACCOUNT (activate):', describeGuardPayload(guardTx));

const unguardTx = await unguardAccount(entrypoint, sender, guardian, useFactory);
console.log('');
printPayload(
guardian
? 'UNGUARD ACCOUNT (co-signed by current guardian):'
: 'UNGUARD ACCOUNT (no guardian supplied — not marked guarded):',
describeGuardPayload(unguardTx),
);

console.log('\nBroadcasting the GuardAccount transaction...');
try {
const txHash = await entrypoint.sendTransaction(guardTx);
console.log(`Sent. Transaction hash: ${txHash}`);
console.log(`Explorer: https://devnet-explorer.multiversx.com/transactions/${txHash}`);
} catch (err) {
console.error(`Rejected (expected for an unfunded wallet): ${(err as Error).message}`);
process.exitCode = 1;
}
}

main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});

The code

src/guard.ts
// src/guard.ts — the subject of this recipe: turning guardianship on and off
// once a guardian has been nominated (see set-guardian for the nomination).
//
// - GuardAccount — activate guardianship. From here on, every transaction
// from this account must carry the guardian's co-signature.
// - UnGuardAccount — remove guardianship. Because the account is guarded
// when you run this, the UnGuardAccount transaction ITSELF must be
// co-signed by the current guardian — otherwise a stolen primary key
// could simply unguard the account and bypass the guardian entirely.
//
// Both are builtin functions on the caller's own account, so the receiver is
// the sender. Note the wire spelling: `GuardAccount` and `UnGuardAccount`
// (capital G in "Guard").

import { Address, TransactionComputer } from '@multiversx/sdk-core';
import type { Account, DevnetEntrypoint, Transaction } from '@multiversx/sdk-core';

/** Build a GuardAccount transaction (activate guardianship). */
export async function guardAccount(
entrypoint: DevnetEntrypoint,
sender: Account,
useFactory: boolean,
): Promise<Transaction> {
if (useFactory) {
const factory = entrypoint.createAccountTransactionsFactory();
const transaction = await factory.createTransactionForGuardingAccount(sender.address);
transaction.nonce = sender.getNonceThenIncrement();
transaction.signature = await sender.signTransaction(transaction);
return transaction;
}

const controller = entrypoint.createAccountController();
return controller.createTransactionForGuardingAccount(sender, sender.getNonceThenIncrement(), {});
}

/**
* Build an UnGuardAccount transaction (remove guardianship).
*
* Pass the current `guardian` to have the SDK mark the transaction as guarded
* (version stays 2, options gets the TX_GUARDED bit, and `guardian` is set),
* ready for the guardian's co-signature in `guardianSignature`. Without it,
* the network rejects the unguard on a guarded account.
*/
export async function unguardAccount(
entrypoint: DevnetEntrypoint,
sender: Account,
guardian: Address | undefined,
useFactory: boolean,
): Promise<Transaction> {
if (useFactory) {
const factory = entrypoint.createAccountTransactionsFactory();
const transaction = await factory.createTransactionForUnguardingAccount(
sender.address,
guardian ? { guardian } : {},
);
transaction.nonce = sender.getNonceThenIncrement();
transaction.signature = await sender.signTransaction(transaction);
return transaction;
}

const controller = entrypoint.createAccountController();
return controller.createTransactionForUnguardingAccount(
sender,
sender.getNonceThenIncrement(),
guardian ? { guardian } : {},
);
}

/** Decode a guard/unguard transaction's shape, including the guarded bit. */
export function describeGuardPayload(transaction: Transaction): {
function: string;
receiver: string;
options: number;
isGuardedBitSet: boolean;
guardian: string;
gasLimit: string;
} {
const guardianBech = transaction.guardian.isEmpty() ? '(none)' : transaction.guardian.toBech32();
// The TX_GUARDED options constant is not re-exported from the sdk-core
// barrel, so read the flag through TransactionComputer instead of
// hardcoding the bit.
const computer = new TransactionComputer();
return {
function: Buffer.from(transaction.data).toString().split('@')[0] ?? '',
receiver: transaction.receiver.toBech32(),
options: transaction.options,
isGuardedBitSet: computer.hasOptionsSetForGuardedTransaction(transaction),
guardian: guardianBech,
gasLimit: transaction.gasLimit.toString(),
};
}

Run it

npm start -- <pemPath> [--guardian <bech32>] [--factory]

A real captured run (unfunded wallet, guardian supplied):

GUARD ACCOUNT (activate):
function: GuardAccount
receiver: erd18l2cww3g3g6uc35wy60efd87ss85xdqj9gh9qn6ga6halz3jscaqd4dl3k
options: 0 (guarded bit set: false)
guardian: (none)
gasLimit: 318000

UNGUARD ACCOUNT (co-signed by current guardian):
function: UnGuardAccount
options: 2 (guarded bit set: true)
guardian: erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
gasLimit: 371000

Broadcasting the GuardAccount transaction...
Rejected (expected for an unfunded wallet): ... insufficient funds for address erd18l2c...

How it works

Both are builtin functions on the caller's own account, so the receiver is the sender. GuardAccount carries no arguments; its gas was 318000, matching 50,000 + 1,500 × 12 (data bytes) + 250,000.

The interesting one is UnGuardAccount. Passing the current guardian marks the transaction as guarded: options becomes 2 (the TX_GUARDED bit), guardian is populated, and the SDK adds the 50,000 guarded-transaction gas premium, 371000 total versus the 321000 an unguarded build would cost. This is the network's protection: a stolen primary key alone cannot lift guardianship, because the unguard must itself be co-signed by the guardian being removed. Add that co-signature with the guardian's key (see Apply a guardian to a transaction) before broadcasting a real unguard.

For built-in function detail, see docs.multiversx.com/developers/built-in-functions.

Pitfalls

Pitfall 1: unguarding a guarded account needs the guardian's co-signature

Send UnGuardAccount from the primary key alone and the network rejects it. Pass the current guardian so the transaction is marked guarded, then attach the guardianSignature.

Pitfall 2: the guarded unguard costs an extra 50,000 gas

Because it is itself a guarded transaction. The controller adds it automatically when a guardian is present (371000 vs 321000 here); budget for it on a hand-built transaction.

Pitfall 3: the TX_GUARDED options constant is not exported from the barrel

TRANSACTION_OPTIONS_TX_GUARDED is internal to sdk-core. Read the flag through TransactionComputer.hasOptionsSetForGuardedTransaction(tx) instead of hardcoding 2, as this recipe does.

See also