Skip to main content

Sign & broadcast a transaction

The lowest-level recipe — manually build canonical JSON, hash, sign with Ed25519, broadcast.

Most use cases should use SDK builders which encapsulate this. Use this recipe when:

  • Implementing a new SDK in an unsupported language
  • Debugging signature mismatches
  • Verifying tx integrity off-chain

Algorithm

1. Build base fields in canonical order (per tx type)
2. Add publicKey = ed25519_pubkey_from(privateKey).hex()
3. Compute hash = sha256(canonicalJson(base + {publicKey})).hex()
4. Compute signature = ed25519_sign(privateKey, utf8(hash)).hex()
5. Final tx = base + { publicKey, hash, signature }
6. POST to /api/chain/tx

Canonical JSON rules

  • Keys serialized in insertion order (NOT sorted alphabetically)
  • No whitespace between tokens
  • bigint → JSON string (e.g., 1000000n"1000000")
  • UTF-8 string encoding

TypeScript example (manual, no SDK)

import { sha256 } from "@noble/hashes/sha256";
import { ed25519 } from "@noble/curves/ed25519";

// 1. Build base
const base = {
type: "TRANSFER",
from: "7a8b70389a52a96aa85ca641c5ad2fb7a1e2e1ca",
to: "abcdef0123456789abcdef0123456789abcdef01",
amount: "1000000", // bigint as string
fee: "1000",
nonce: 5,
timestamp: Date.now(),
};

// 2. Derive publicKey from private seed
const privateKeyBytes = hexToBytes("YOUR_32_BYTE_PRIVATE_KEY_HEX");
const publicKey = bytesToHex(ed25519.getPublicKey(privateKeyBytes));

const withPubKey = { ...base, publicKey };

// 3. Canonical JSON + hash
const canonical = JSON.stringify(withPubKey); // insertion order preserved
const hashBytes = sha256(new TextEncoder().encode(canonical));
const hashHex = bytesToHex(hashBytes);

// 4. Sign UTF-8 bytes of hex hash
const sigBytes = ed25519.sign(new TextEncoder().encode(hashHex), privateKeyBytes);
const signatureHex = bytesToHex(sigBytes);

// 5. Final tx
const tx = { ...withPubKey, hash: hashHex, signature: signatureHex };

// 6. Broadcast
const res = await fetch("https://api.ombra-net.com/api/chain/tx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tx),
});
const { hash } = await res.json();
console.log("submitted:", hash);

function hexToBytes(hex: string): Uint8Array {
return new Uint8Array(hex.match(/.{2}/g)!.map(b => parseInt(b, 16)));
}
function bytesToHex(b: Uint8Array): string {
return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");
}

Verify offline

function verifyTx(tx: Record<string, any>): boolean {
// 1. from must derive from publicKey
const pkBytes = hexToBytes(tx.publicKey);
const derivedAddr = bytesToHex(sha256(pkBytes).slice(0, 20));
if (tx.from !== derivedAddr) return false;

// 2. hash recomputation
const { hash, signature, ...payload } = tx;
const recomputedHash = bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(payload))));
if (recomputedHash !== hash) return false;

// 3. Ed25519 verify
return ed25519.verify(
hexToBytes(signature),
new TextEncoder().encode(hash),
pkBytes,
);
}

Common pitfalls

PitfallSymptom
Field reordering (e.g., alphabetical sort)Signature invalid
amount as JSON number instead of stringSignature invalid (different canonical bytes)
Whitespace in JSON (pretty-print)Signature invalid
Signing raw 32-byte hash bytes instead of utf8(hexString)Signature invalid (this is the unusual rule)
nonce not incremented after batchTx rejected as "nonce stale"
Account doesn't exist yetFirst tx must be received (e.g., faucet) before sending

Easier path: use the SDK

import { buildTransferTx } from "@ombrachain/sdk";
const tx = buildTransferTx(from, to, amount, fee, nonce, privateKey);
// already signed, ready to submit