Guard and unguard an account
Once a guardian is nominated (see Set a guardian), two transactions toggle protection:
GuardAccountactivates guardianship. From here on, every transaction from the account must carry the guardian's co-signature.UnGuardAccountremoves 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
{
"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"
}
}
{
"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 — 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 — 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 — 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
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.
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.
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
- Set a guardian on an account nominates the guardian you activate here.
- Apply a guardian to a transaction attaches the guardian co-signature the unguard (and every guarded transaction) needs.
- Manage nonces (fetch-then-increment) is the nonce-fetch pattern this recipe uses.