Set a guardian on an account
A guardian is a second key that must co-sign an account's transactions once the account is guarded. Turning that protection on is a two-step sequence:
SetGuardiannominates the guardian (this recipe).GuardAccountactivates guardianship (see Guard and unguard an account).
This recipe builds the SetGuardian transaction with sdk-core's
AccountController (and the matching AccountTransactionsFactory), decodes the
payload, and broadcasts it.
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 set-guardian
cd set-guardian
# Create the project files shown on this page.
npm install
npm run build
npm start -- ./wallet.pem erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th MyGuardianService
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-set-guardian",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — set a guardian on an account with SetGuardian via sdk-core's AccountController and AccountTransactionsFactory, payload verified on devnet.",
"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 SetGuardian recipe.
//
// Usage:
// npm run build
// npm start -- <pemPath> <guardianBech32> [serviceID] [--factory]
//
// Example:
// npm start -- ./wallet.pem erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th MyGuardianService
//
// It decodes the SetGuardian payload, then broadcasts it. An unfunded wallet
// is rejected for insufficient funds, proving the payload is well-formed
// without spending real EGLD. `serviceID` is illustrative — the real value
// depends on the guardian service you use.
import { Address, DevnetEntrypoint } from '@multiversx/sdk-core';
import { loadDevnetAccount } from './account';
import { setGuardian, describeSetGuardianPayload } from './guardian';
async function main(): Promise<void> {
const args = process.argv.slice(2);
const [pemPath, guardianArg] = args;
const useFactory = args.includes('--factory');
// The third positional (if not a flag) is the serviceID.
const serviceID = args[2] && !args[2].startsWith('--') ? args[2] : 'MyGuardianService';
if (!pemPath || !guardianArg) {
console.error('Usage: npm start -- <pemPath> <guardianBech32> [serviceID] [--factory]');
process.exitCode = 1;
return;
}
const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
const sender = await loadDevnetAccount(entrypoint, pemPath);
const guardianAddress = Address.newFromBech32(guardianArg);
console.log(`Sender: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`Guardian: ${guardianAddress.toBech32()}`);
console.log(`serviceID: ${serviceID} (illustrative)`);
console.log(`API level: ${useFactory ? 'factory' : 'controller'}\n`);
const tx = await setGuardian(entrypoint, sender, { guardianAddress, serviceID }, useFactory);
const decoded = describeSetGuardianPayload(tx);
console.log('SetGuardian payload:');
console.log(` function: ${decoded.function}`);
console.log(` receiver: ${decoded.receiver}`);
console.log(` guardian: ${decoded.guardian}`);
console.log(` serviceID: ${decoded.serviceID}`);
console.log(` gasLimit: ${decoded.gasLimit}`);
console.log('\nBroadcasting...');
try {
const txHash = await entrypoint.sendTransaction(tx);
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/guardian.ts — the subject of this recipe: nominating a guardian for an
// account with the `SetGuardian` builtin, via sdk-core's AccountController
// and AccountTransactionsFactory.
//
// A guardian is a second key that must co-sign your transactions once the
// account is guarded. Setting one is a two-transaction dance:
// 1. SetGuardian — nominate the guardian (this recipe). The nomination
// only becomes active after a cooldown (unless the account is already
// guarded, in which case the current guardian co-signs and it is
// immediate).
// 2. GuardAccount — activate guardianship (see guard-unguard-account).
//
// SetGuardian is a builtin function executed in the caller's own account
// context, so the transaction's receiver is the sender itself.
//
// SetGuardianInput = { guardianAddress: Address; serviceID: string }. The
// serviceID identifies the guardian SERVICE (e.g. a Trusted Co-Signer
// Service). It is written to the wire verbatim as hex; the exact value
// depends on the service you use, so this recipe takes it as a parameter.
import { Address } from '@multiversx/sdk-core';
import type { Account, DevnetEntrypoint, Transaction } from '@multiversx/sdk-core';
export interface SetGuardianInput {
guardianAddress: Address;
serviceID: string;
}
/**
* Build a SetGuardian transaction.
*
* Controller path: `createTransactionForSettingGuardian` sets the nonce and
* signs (it takes the whole Account). Factory path: it only builds; the
* caller owns nonce + signature. Same method name on both.
*/
export async function setGuardian(
entrypoint: DevnetEntrypoint,
sender: Account,
input: SetGuardianInput,
useFactory: boolean,
): Promise<Transaction> {
if (useFactory) {
const factory = entrypoint.createAccountTransactionsFactory();
const transaction = await factory.createTransactionForSettingGuardian(sender.address, {
guardianAddress: input.guardianAddress,
serviceID: input.serviceID,
});
transaction.nonce = sender.getNonceThenIncrement();
transaction.signature = await sender.signTransaction(transaction);
return transaction;
}
const controller = entrypoint.createAccountController();
return controller.createTransactionForSettingGuardian(sender, sender.getNonceThenIncrement(), {
guardianAddress: input.guardianAddress,
serviceID: input.serviceID,
});
}
/**
* Decode a SetGuardian transaction's `data` field:
* `SetGuardian@<guardianPubKeyHex>@<serviceIdHex>`. The guardian argument is
* a raw 32-byte public key (hex), NOT bech32.
*/
export function describeSetGuardianPayload(transaction: Transaction): {
function: string;
receiver: string;
guardian: string;
serviceID: string;
gasLimit: string;
} {
const parts = Buffer.from(transaction.data).toString().split('@');
const guardianHex = parts[1] ?? '';
const serviceHex = parts[2] ?? '';
return {
function: parts[0] ?? '',
receiver: transaction.receiver.toBech32(),
guardian: guardianHex ? Address.newFromHex(guardianHex).toBech32() : '(none)',
serviceID: Buffer.from(serviceHex, 'hex').toString(),
gasLimit: transaction.gasLimit.toString(),
};
}
Run it
npm start -- <pemPath> <guardianBech32> [serviceID] [--factory]
A real captured run (unfunded wallet):
SetGuardian payload:
function: SetGuardian
receiver: erd18l2cww3g3g6uc35wy60efd87ss85xdqj9gh9qn6ga6halz3jscaqd4dl3k
guardian: erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th
serviceID: MyGuardianService
gasLimit: 466500
Broadcasting...
Rejected (expected for an unfunded wallet): ... insufficient funds for address erd18l2c...
How it works
SetGuardian is a builtin function on the caller's own account, so the
transaction's receiver is the sender itself. Its data is
SetGuardian@<guardianPublicKeyHex>@<serviceIdHex>. The guardian argument is a
raw 32-byte public key in hex, not bech32, this recipe's decoder converts it
back to a readable address. The serviceID identifies the guardian service
(for example a Trusted Co-Signer Service) and is written verbatim as hex; its
exact value depends on the service you use, so the recipe takes it as a
parameter.
Gas was 466500, matching 50,000 + 1,500 × 111 (data bytes) + 250,000 (gasLimitSetGuardian) to the unit. The unfunded broadcast is rejected with a
clean insufficient funds keyed to the sender, the payload is well-formed.
A newly set guardian is not usable immediately: on an unguarded account the
nomination becomes active only after a protocol cooldown. If the account is
already guarded, the current guardian co-signs the SetGuardian and the change
is immediate. Either way, SetGuardian only nominates,
GuardAccount
is what activates protection.
For built-in function detail, see docs.multiversx.com/developers/built-in-functions.
Pitfalls
On the wire SetGuardian carries the guardian's raw 32-byte public key in hex.
The SDK converts your Address for you, but if you hand-decode the payload, read
it back with Address.newFromHex, not newFromBech32.
This recipe uses an illustrative serviceID. The real value is defined by the
guardian service you register with; passing the wrong one nominates a guardian
the service cannot co-sign for.
SetGuardian only nominates. Until you send
GuardAccount,
transactions still sign with the primary key alone.
See also
- Guard and unguard an account activates the guardian you nominated here, and removes it.
- Apply a guardian to a transaction co-signs an individual transaction once guardianship is active.
- Build a relayed v3 transaction is the other multi-signature transaction shape (a relayer paying gas, rather than a guardian co-signing).