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

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
package.json
{
"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"
}
}
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 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
// 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
// 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):

  1. raises version to at least 2 (the minimum that supports the options field),
  2. sets the TX_GUARDED bit in options (so options becomes 2),
  3. sets tx.guardian to 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

Pitfall 1: apply the guardian BEFORE signing

applyGuardian mutates version, options, and guardian. Sign first and both signatures cover the wrong bytes, so the network rejects them. Apply, then sign.

Pitfall 2: a guarded transaction costs an extra 50,000 gas

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.

Pitfall 3: the guarded constants are not exported from the barrel

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).

Pitfall 4: the account must actually be guarded on-chain

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