Sign + verify a transaction (offline)
Sign a Transaction offline with a raw secret key and verify the signature three
ways: Account.verifyTransactionSignature, a UserVerifier built from the
address, and the raw UserPublicKey. Then prove the check works by tampering
with the transaction and watching verification fail. This is the
transaction-signing counterpart to
Sign a message + verify a signature:
no devnet, no broadcast, pure local Ed25519.
Prerequisites
- Node.js >= 20.19.0.
- Nothing else. This recipe generates its own throwaway keys and never touches the network.
Install
mkdir sign-verify-transaction
cd sign-verify-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-sign-verify-transaction",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — sign a transaction offline with a raw secret key and verify it three ways, plus tamper checks. Fully offline: no devnet, 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 — sign arbitrary data with a UserSigner, then sign a
// Transaction OFFLINE with a raw secret key and verify the signature three
// ways (UserVerifier, UserPublicKey.verify, Account.verifyTransactionSignature).
// Prove the check works by tampering with the transaction and watching
// verification fail.
//
// Fully offline. No devnet, no broadcast — signing and verifying are pure
// local Ed25519 cryptography. This is the transaction-signing counterpart to
// the message-signing recipe. Fresh keys each run; the true/false pattern of
// the output is stable.
//
// Grounded in the installed @multiversx/sdk-core v15.4.1: UserSigner /
// UserVerifier (wallet/), TransactionComputer (core/transactionComputer.d.ts).
import {
Account,
Address,
Mnemonic,
Transaction,
TransactionComputer,
UserSigner,
UserVerifier,
} from '@multiversx/sdk-core';
async function main(): Promise<void> {
const account = Account.newFromMnemonic(Mnemonic.generate().toString(), 0);
const other = Account.newFromMnemonic(Mnemonic.generate().toString(), 0);
const secretKey = account.secretKey;
const publicKey = account.publicKey;
// === 1. Sign arbitrary bytes with a UserSigner, verify with UserVerifier. ===
// UserSigner wraps a secret key; its sign() is ASYNC (returns a Promise).
// UserVerifier needs only the public key to check the signature. (Step 2
// below uses the raw secretKey.sign, which is SYNC — the contrast matters.)
const data = new Uint8Array(Buffer.from('arbitrary bytes to authenticate'));
const signer = new UserSigner(secretKey);
const dataSignature = await signer.sign(data);
const verifier = new UserVerifier(publicKey);
console.log(`1. UserSigner-signed data verified by UserVerifier: ${await verifier.verify(data, dataSignature)}`);
// === 2. Build a transaction and sign it offline with the raw key. ===
// account.signTransaction would work too, but doing it by hand shows what
// that method does internally: serialize with computeBytesForSigning, then
// sign those bytes. No network, no nonce fetch — this is a local signature.
const transactionComputer = new TransactionComputer();
const transaction = new Transaction({
nonce: 42n,
value: 1000000000000000000n, // 1 EGLD
sender: account.address,
receiver: other.address,
gasLimit: 50000n,
chainID: 'D',
});
transaction.signature = secretKey.sign(transactionComputer.computeBytesForSigning(transaction));
console.log(`2. Transaction signed. Signature length: ${transaction.signature.length} bytes.`);
// === 3. Verify the transaction signature three ways. ===
// (a) Account convenience method.
const viaAccount = await account.verifyTransactionSignature(transaction, transaction.signature);
// (b) UserVerifier built from the sender's address alone — the path a
// third party uses, needing only the public address.
const viaVerifier = await UserVerifier.fromAddress(account.address).verify(
transactionComputer.computeBytesForVerifying(transaction),
transaction.signature,
);
// (c) The raw public key.
const viaPublicKey = await publicKey.verify(
transactionComputer.computeBytesForVerifying(transaction),
transaction.signature,
);
console.log(`3. Verified via account / UserVerifier / publicKey: ${viaAccount} / ${viaVerifier} / ${viaPublicKey}`);
// === 4. Tamper with the transaction, keep the signature. ===
// Changing any signed field (here, the value) makes the recomputed bytes
// no longer match the signature — verification must return false.
transaction.value = 2000000000000000000n; // 2 EGLD, was 1
const afterTamper = await account.verifyTransactionSignature(transaction, transaction.signature);
console.log(`4. Verify after changing the value: ${afterTamper} (expected false)`);
// === 5. A different key cannot verify the (untampered) signature. ===
// Restore the value, then check the signature against the wrong address.
transaction.value = 1000000000000000000n;
const wrongSigner = await UserVerifier.fromAddress(other.address).verify(
transactionComputer.computeBytesForVerifying(transaction),
transaction.signature,
);
console.log(`5. Verify against a different address: ${wrongSigner} (expected false)`);
// A convenience helper so the "wrong-key" address is unmistakably distinct.
const distinct = !account.address.equals(Address.newFromBech32(other.address.toBech32()));
console.log(` (signer and other address are distinct: ${distinct})`);
console.log('\nExpected: steps 1 and 3 all true; steps 4 and 5 false.');
}
main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});
Run it
Keys are generated fresh each run; the pattern of true/false is stable:
1. UserSigner-signed data verified by UserVerifier: true
2. Transaction signed. Signature length: 64 bytes.
3. Verified via account / UserVerifier / publicKey: true / true / true
4. Verify after changing the value: false (expected false)
5. Verify against a different address: false (expected false)
(signer and other address are distinct: true)
The two false lines are the point: they prove verification actually checks
something, rather than always returning true.
How it works
Grounded in the installed @multiversx/sdk-core v15.4.1: UserSigner /
UserVerifier (wallet/) and TransactionComputer
(core/transactionComputer.d.ts):
- The primitive. A
UserSignerwraps the secret key and signs arbitrary data (async);new UserVerifier(publicKey).verify(bytes, signature)checks it, needing only the public key. - Sign a transaction offline. Build a
Transaction, serialize it withtransactionComputer.computeBytesForSigning(tx), and sign those bytes with the raw key. This is exactly whataccount.signTransactiondoes internally, no network, no nonce fetch. - Verify three ways.
account.verifyTransactionSignature; aUserVerifier.fromAddress(sender)(the path a third party uses, needing only the public address); and the rawpublicKey.verify. - Tamper. Change the transaction's value; verification returns
false. - Wrong key. The signature does not verify against a different address.
Pitfalls
UserSecretKey.sign(bytes) returns a Uint8Array directly (no await). But
UserVerifier.verify, UserPublicKey.verify, account.verifyTransactionSignature,
and account.sign all return Promises. Mixing these up is an easy
tsc --strict error or a silently unawaited promise.
For an ordinary transaction (options bit unset) they return identical bytes, which is why signing one and verifying against the other works here. The moment you set the hash-signing option, they diverge, see Hash-signing a transaction.
Changing the value, receiver, nonce, gas, data, or chainID after signing invalidates the signature. There is no "unsigned metadata" on a transaction, if it is serialized, it is signed.
See also
- Sign a message + verify a signature
is the message-signing counterpart, using
MessageComputerinstead ofTransactionComputer. - Hash-signing a transaction is the options-bit variant, where signing and verifying bytes deliberately differ.
- Build a relayed v3 transaction is another accounts-and-signing recipe, devnet-verified.