Apply a guardian to a transaction and co-sign it
Once an account is guarded, every transaction it sends needs a guardian
co-signature. This recipe takes a plain EGLD transfer and makes it a guarded
transaction with TransactionComputer.applyGuardian, then co-signs it with both
the sender and the guardian, verifying the version, options, and guardian fields
end up correct.
Unlike a relayer (which must share the sender's shard, see Build a relayed v3 transaction), a guardian has no shard constraint; it is purely a co-signer.
Prerequisites
- Node.js >= 20.19.0.
- Nothing else. This recipe generates two fresh, unfunded accounts and only needs devnet read access before proving the shape via a clean rejection.
Install
mkdir apply-guardian-to-transaction
cd apply-guardian-to-transaction
# Create the project files shown on this page.
npm install
npm run build
npm start
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-apply-guardian-to-transaction",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — apply a guardian to a transaction with TransactionComputer.applyGuardian and co-sign it, asserting version, options, and guardian fields.",
"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 apply-guardian recipe.
//
// Usage:
// npm run build
// npm start
//
// It generates a fresh sender and guardian, builds a guarded EGLD transfer,
// prints the asserted guarded fields (version / options / guardian /
// signatures), and broadcasts. Two unfunded accounts get a clean insufficient
// funds rejection keyed to the sender — proving the guarded shape is
// well-formed without spending real EGLD.
import { Address, DevnetEntrypoint } from '@multiversx/sdk-core';
import { generateSenderAndGuardian } from './keys';
import { buildGuardedTransfer } from './guarded';
// A well-known devnet address, used only as the transfer's receiver.
const RECEIVER = Address.newFromBech32(
'erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th',
);
async function main(): Promise<void> {
const entrypoint = new DevnetEntrypoint({ clientName: 'mvx-cookbook' });
const { sender, guardian } = await generateSenderAndGuardian();
// Only the sender's nonce is needed (the guardian merely co-signs).
sender.nonce = await entrypoint.recallAccountNonce(sender.address);
console.log(`Sender: ${sender.address.toBech32()} (nonce ${sender.nonce})`);
console.log(`Guardian: ${guardian.address.toBech32()}`);
console.log(`Receiver: ${RECEIVER.toBech32()}\n`);
const { transaction, assertions } = await buildGuardedTransfer(
sender,
guardian,
RECEIVER,
'D',
1_000_000_000_000_000_000n, // 1 EGLD
);
console.log('Guarded transaction fields:');
console.log(` version: ${transaction.version}`);
console.log(` options: ${transaction.options}`);
console.log(` guardian: ${transaction.guardian.toBech32()}`);
console.log(` gasLimit: ${transaction.gasLimit} (50000 base + 50000 guarded extra)`);
console.log(` signature length: ${transaction.signature.length} bytes`);
console.log(` guardianSig length: ${transaction.guardianSignature.length} bytes\n`);
console.log('Assertions:');
console.log(` version >= 2: ${assertions.versionIsAtLeast2}`);
console.log(` guarded bit set: ${assertions.guardedBitSet}`);
console.log(` guardian field matches: ${assertions.guardianMatches}`);
console.log(` sender signature set: ${assertions.hasSenderSignature}`);
console.log(` guardian signature set: ${assertions.hasGuardianSignature}`);
const allPass = Object.values(assertions).every(Boolean);
if (!allPass) {
console.error('\nOne or more guarded-field assertions failed.');
process.exitCode = 1;
return;
}
console.log('\nBroadcasting the guarded transfer...');
try {
const txHash = await entrypoint.sendTransaction(transaction);
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
First, a fresh sender and guardian (two independent accounts, no shard search needed):
// src/keys.ts — generate a sender and a guardian as two fresh, independent,
// intentionally-unfunded accounts.
//
// Unlike a relayer (which must share the sender's shard — see the
// relayed-v3-transaction recipe), a guardian has NO shard constraint: it is
// purely a co-signer. So there is no same-shard search here — two independent
// mnemonics are enough, reflecting that the guardian is a separate party (a
// guardian service, a second device) from the account owner.
import { Account, Mnemonic } from '@multiversx/sdk-core';
/** Generate a fresh sender and a fresh guardian (both unfunded). */
export async function generateSenderAndGuardian(): Promise<{
sender: Account;
guardian: Account;
}> {
const sender = Account.newFromMnemonic(Mnemonic.generate().toString());
const guardian = Account.newFromMnemonic(Mnemonic.generate().toString());
return { sender, guardian };
}
Then apply the guardian, assert the guarded fields, and co-sign:
// src/guarded.ts — the subject of this recipe: taking any transaction and
// making it a GUARDED transaction, then co-signing it.
//
// A guarded transaction carries a second signature from the account's
// guardian. `TransactionComputer.applyGuardian(tx, guardianAddress)` does
// three things (confirmed by reading the installed v15.4.1 source):
// 1. raises `version` to at least 2 (the minimum that supports options),
// 2. sets the TX_GUARDED bit in `options`,
// 3. sets `tx.guardian` to the guardian's address.
// It does NOT touch gas — the +50,000 for guarded transactions is the
// caller's job on a hand-built transaction (the controllers add it for you).
//
// Signing ORDER matters: applyGuardian must run BEFORE either party signs, so
// both signatures cover the version/options/guardian fields.
import { Transaction, TransactionComputer } from '@multiversx/sdk-core';
import type { Account, Address } from '@multiversx/sdk-core';
// sdk-core's internal constant for the extra guarded-transaction gas (guarded
// transactions require an extra 50,000 gas). It is not re-exported from the
// package barrel, so it is restated here.
const EXTRA_GAS_LIMIT_FOR_GUARDED_TRANSACTIONS = 50_000n;
export interface GuardedBuildResult {
transaction: Transaction;
assertions: {
versionIsAtLeast2: boolean;
guardedBitSet: boolean;
guardianMatches: boolean;
hasSenderSignature: boolean;
hasGuardianSignature: boolean;
};
}
/**
* Build a plain EGLD transfer, apply a guardian to it, assert the guarded
* fields, then co-sign it with both the sender and the guardian.
*/
export async function buildGuardedTransfer(
sender: Account,
guardian: Account,
receiver: Address,
chainID: string,
amount: bigint,
): Promise<GuardedBuildResult> {
const transaction = new Transaction({
sender: sender.address,
receiver,
gasLimit: 50_000n, // minimum for a plain EGLD transfer, before the guarded extra
chainID,
value: amount,
nonce: sender.getNonceThenIncrement(),
});
const computer = new TransactionComputer();
// Apply the guardian BEFORE signing, and add the guarded gas ourselves.
computer.applyGuardian(transaction, guardian.address);
transaction.gasLimit += EXTRA_GAS_LIMIT_FOR_GUARDED_TRANSACTIONS;
// Co-sign: the sender signs `signature`, the guardian signs
// `guardianSignature` — both over the same (now guarded) bytes.
transaction.signature = await sender.signTransaction(transaction);
transaction.guardianSignature = await guardian.signTransaction(transaction);
return {
transaction,
assertions: {
versionIsAtLeast2: transaction.version >= 2,
guardedBitSet: computer.hasOptionsSetForGuardedTransaction(transaction),
guardianMatches: transaction.guardian.toBech32() === guardian.address.toBech32(),
hasSenderSignature: transaction.signature.length > 0,
hasGuardianSignature: transaction.guardianSignature.length > 0,
},
};
}
Run it
A real captured run (addresses differ every run):
Guarded transaction fields:
version: 2
options: 2
guardian: erd16pwwy3l8du89tljqht00tg8rt3tuhqysv85x5kakpda3e0znfmkqpdas4v
gasLimit: 100000 (50000 base + 50000 guarded extra)
signature length: 64 bytes
guardianSig length: 64 bytes
Assertions:
version >= 2: true
guarded bit set: true
guardian field matches: true
sender signature set: true
guardian signature set: true
Broadcasting the guarded transfer...
Rejected (expected for an unfunded wallet): ... insufficient funds for address erd12kzk5e...
How it works
TransactionComputer.applyGuardian(tx, guardianAddress) does exactly three things
(confirmed by reading the installed v15.4.1 source):
- raises
versionto at least2(the minimum that supports the options field), - sets the
TX_GUARDEDbit inoptions(sooptionsbecomes2), - sets
tx.guardianto the guardian's address.
It does not touch gas, the 50,000 guarded premium is the caller's job on a
hand-built transaction (the controllers add it for you). This recipe adds it
explicitly, so the transfer's 50,000 minimum becomes 100,000.
Order matters. applyGuardian runs before either party signs, so both
signatures cover the version/options/guardian fields. The sender signs
signature; the guardian signs guardianSignature. Both are 64-byte Ed25519
signatures over the same serialized bytes. The unfunded broadcast is rejected
with a clean insufficient funds keyed to the sender, confirming the guarded
shape is well-formed.
For built-in function and guardian detail, see docs.multiversx.com/developers/built-in-functions.
Pitfalls
applyGuardian mutates version, options, and guardian. Sign first and both
signatures cover the wrong bytes, so the network rejects them. Apply, then sign.
applyGuardian does not add it. On a hand-built transaction you must add 50,000
yourself (the controllers do it automatically). Under-budget and the transaction
is rejected for gas.
TRANSACTION_OPTIONS_TX_GUARDED and EXTRA_GAS_LIMIT_FOR_GUARDED_TRANSACTIONS
are internal to sdk-core. Read the guarded flag through
TransactionComputer.hasOptionsSetForGuardedTransaction, and restate the
50,000 gas constant locally (as this recipe does).
This recipe proves the transaction shape with unfunded accounts. A real guarded
transfer also requires the sender's account to be guarded
(Guard an account)
with a guardian whose key produces the guardianSignature.
See also
- Set a guardian on an account nominates the guardian whose signature co-signs here.
- Guard and unguard an account activates guardianship so guarded transactions are actually required.
- Build a relayed v3 transaction is the other multi-signature shape, where a relayer pays gas instead of a guardian co-signing.