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

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

  1. The primitive. A UserSigner wraps the secret key and signs arbitrary data (async); new UserVerifier(publicKey).verify(bytes, signature) checks it, needing only the public key.
  2. Sign a transaction offline. Build a Transaction, serialize it with transactionComputer.computeBytesForSigning(tx), and sign those bytes with the raw key. This is exactly what account.signTransaction does internally, no network, no nonce fetch.
  3. Verify three ways. account.verifyTransactionSignature; a UserVerifier.fromAddress(sender) (the path a third party uses, needing only the public address); and the raw publicKey.verify.
  4. Tamper. Change the transaction's value; verification returns false.
  5. Wrong key. The signature does not verify against a different address.

Pitfalls

Pitfall 1: secretKey.sign is synchronous; the verify methods are async

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.

Pitfall 2: computeBytesForSigning and computeBytesForVerifying match only when the transaction is NOT hash-signed

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.

Pitfall 3: any signed field is covered

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