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
| Pitfall | Symptom |
|---|---|
| Field reordering (e.g., alphabetical sort) | Signature invalid |
amount as JSON number instead of string | Signature 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 batch | Tx rejected as "nonce stale" |
| Account doesn't exist yet | First 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