Hash-signing a transaction
Opt a transaction into hash signing, assert the version and options bits are
set, and sign it correctly by signing the transaction hash. Along the way it
exposes a real v15.4.1 trap: Account.signTransaction does not
honor the hash-signing option, so its output fails verification. Fully offline,
nothing is broadcast.
Hash signing sets the least-significant bit of the transaction options field.
When set, the signature must be over the keccak-256 hash of the serialized
transaction, not over the serialized bytes themselves. It lets a signer (for
example a hardware wallet) sign a short fixed-size digest instead of an
arbitrarily long payload.
Prerequisites
- Node.js >= 20.19.0.
- Nothing else. This recipe generates its own throwaway keys and never touches the network.
Install
mkdir hash-signing-transaction
cd hash-signing-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-hash-signing-transaction",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — opt a transaction into hash signing, assert the version/options bits, and sign the transaction hash correctly. Fully offline, no broadcast.",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.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": ["esnext"],
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
The code
// src/index.ts — opt a transaction into HASH SIGNING, assert the version and
// options bits are set, and sign it correctly by signing the transaction
// HASH. Also demonstrates a real v15.4.1 trap: Account.signTransaction does
// NOT honor the hash-signing option, so its output fails verification.
//
// Fully offline. No devnet, no broadcast — this builds and signs locally and
// asserts on the bytes. Nothing is sent. Fresh keys each run.
//
// Background: hash signing sets the least-significant bit of the transaction
// `options` field. When set, the signature must be over the KECCAK-256 hash
// of the serialized transaction, not over the serialized bytes themselves.
// It lets a signer (e.g. a hardware wallet) sign a short fixed-size digest
// instead of an arbitrarily long payload.
//
// Grounded in the installed @multiversx/sdk-core v15.4.1: TransactionComputer
// (core/transactionComputer.d.ts) and Account (accounts/account.d.ts).
import { Account, Mnemonic, Transaction, TransactionComputer } from '@multiversx/sdk-core';
import { strict as assert } from 'node:assert';
async function main(): Promise<void> {
const account = Account.newFromMnemonic(Mnemonic.generate().toString(), 0);
const other = Account.newFromMnemonic(Mnemonic.generate().toString(), 0);
const transactionComputer = new TransactionComputer();
const transaction = new Transaction({
nonce: 7n,
value: 0n,
sender: account.address,
receiver: other.address,
gasLimit: 50000n,
chainID: 'D',
});
// === 1. Before: a default transaction is version 2, options 0. ===
console.log(`1. Before: version=${transaction.version}, options=${transaction.options}`);
console.log(` hasOptionsSetForHashSigning: ${transactionComputer.hasOptionsSetForHashSigning(transaction)}`);
// === 2. Opt into hash signing and assert the bits. ===
// applyOptionsForHashSigning sets the options LSB (0b0001) and ensures
// version >= 2. This is the core of the recipe: proving the flags land.
transactionComputer.applyOptionsForHashSigning(transaction);
console.log(`\n2. After applyOptionsForHashSigning: version=${transaction.version}, options=${transaction.options}`);
assert.equal(transaction.version, 2, 'version must be >= 2 for options to be honored');
assert.equal(transaction.options & 0b0001, 0b0001, 'the hash-signing bit (0b0001) must be set');
assert.equal(transactionComputer.hasOptionsSetForHashSigning(transaction), true);
// The hash-signing bit and the guarded bit (0b0010) are independent.
assert.equal(transactionComputer.hasOptionsSetForGuardedTransaction(transaction), false);
console.log(' Asserted: version === 2, options bit 0b0001 set, hasOptionsSetForHashSigning === true.');
// === 3. Sign CORRECTLY: sign the hash, verify against it. ===
// computeHashForSigning returns the keccak-256 digest (32 bytes).
// computeBytesForVerifying already returns that same hash when the option
// is set, so signing the hash and verifying line up.
const hashForSigning = transactionComputer.computeHashForSigning(transaction);
console.log(`\n3. computeHashForSigning length: ${hashForSigning.length} bytes (keccak-256).`);
transaction.signature = account.secretKey.sign(hashForSigning);
const correct = await account.publicKey.verify(
transactionComputer.computeBytesForVerifying(transaction),
transaction.signature,
);
console.log(` Sign the hash -> verify: ${correct} (expected true)`);
// === 4. THE TRAP: Account.signTransaction does NOT honor the option. ===
// Account.signTransaction serializes with computeBytesForSigning, which in
// v15.4.1 does NOT branch on the hash-signing option — it returns the full
// JSON bytes, not the hash. verifyTransactionSignature DOES branch (it
// verifies against the hash). So the two disagree and verification fails.
const trapSignature = await account.signTransaction(transaction);
const trapVerify = await account.verifyTransactionSignature(transaction, trapSignature);
console.log(`\n4. TRAP: account.signTransaction -> account.verifyTransactionSignature: ${trapVerify} (expected false!)`);
const signingBytes = transactionComputer.computeBytesForSigning(transaction);
const verifyingBytes = transactionComputer.computeBytesForVerifying(transaction);
console.log(
` Why: computeBytesForSigning is ${signingBytes.length} bytes (full JSON) but computeBytesForVerifying is ${verifyingBytes.length} bytes (the hash) — they differ.`,
);
assert.equal(trapVerify, false, 'demonstrates the documented trap');
assert.equal(correct, true, 'the manual hash-signing path is the correct one');
console.log('\nExpected: step 3 true (sign the hash), step 4 false (the trap). Use the step-3 path in real code.');
}
main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});
Run it
Keys are fresh each run; the true/false pattern and the byte counts are stable:
1. Before: version=2, options=0
hasOptionsSetForHashSigning: false
2. After applyOptionsForHashSigning: version=2, options=1
Asserted: version === 2, options bit 0b0001 set, hasOptionsSetForHashSigning === true.
3. computeHashForSigning length: 32 bytes (keccak-256).
Sign the hash -> verify: true (expected true)
4. TRAP: account.signTransaction -> account.verifyTransactionSignature: false (expected false!)
Why: computeBytesForSigning is 250 bytes (full JSON) but computeBytesForVerifying is 32 bytes (the hash) — they differ.
The source's assert calls turn this behavior, including the trap, into hard
test assertions, so the run fails loudly if the SDK ever changes.
How it works
Grounded in the installed @multiversx/sdk-core v15.4.1 TransactionComputer
(core/transactionComputer.d.ts) and Account (accounts/account.d.ts):
- Before. A default transaction is
version: 2,options: 0. - Opt in and assert.
applyOptionsForHashSigning(tx)sets the options bit0b0001and ensuresversion >= 2. The recipe assertsversion === 2, that bit0b0001is set, and thathasOptionsSetForHashSigning(tx)istrue(the independent guarded bit0b0010stays unset). This is the core deliverable, proving the flags land. - Sign correctly.
computeHashForSigning(tx)returns the 32-byte keccak-256 digest.computeBytesForVerifying(tx)already returns that same hash when the option is set, so signing the hash and verifying against it line up:true. - The trap.
account.signTransaction(tx)thenaccount.verifyTransactionSignature(tx, sig)returnsfalse.
Pitfalls
It serializes with computeBytesForSigning, which does not branch on the option
and returns the full ~250-byte JSON. But computeBytesForVerifying does branch
and returns the 32-byte hash. The two disagree, so a hash-flagged transaction
signed with account.signTransaction fails its own verifyTransactionSignature,
and would be rejected by the network. Sign computeHashForSigning(tx)
directly instead (step 3).
applyOptionsForHashSigning OR-s in bit 0b0001; the guarded flag is 0b0010.
Setting tx.options = 1 by hand would clobber a guarded flag. Use the apply...
/ hasOptionsSet... helpers rather than assigning the field.
Options are only honored at version >= 2. The helper raises the version for
you; if you set the bit by hand on a version: 1 transaction, the network
ignores the options entirely.
See also
- Sign + verify a transaction (offline) is the ordinary (non-hash) signing path, where signing and verifying bytes match.
- Sign a message + verify a signature is message signing, a related but distinct primitive family.
- Build a relayed v3 transaction
is another advanced transaction shape using the
options/versionmachinery.