Transaction format
Every OmbraChain transaction is a JSON object with these universal fields, plus type-specific fields documented in subsequent pages.
Universal fields
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | yes | One of 37 tx types (see other pages) |
from | string | yes | Sender address — 40 lowercase hex chars |
nonce | number | yes | Per-sender incrementing counter, starts at 0 |
timestamp | number | yes | Unix ms — Date.now() |
publicKey | string | yes (set by signer) | 64 hex chars — Ed25519 public key |
hash | string | yes (set by signer) | 64 hex chars — sha256 of canonical JSON |
signature | string | yes (set by signer) | 128 hex chars — Ed25519 sig over hash bytes |
Additional fields are type-specific.
Address derivation
address = sha256(publicKey)[:20].hex().lower()
A 40-char lowercase hex string. Validator MUST check from == sha256(publicKey)[:20] — otherwise the tx is rejected with "from doesn't derive from publicKey".
Canonical JSON
Every signature is over the sha256 of the canonical JSON representation of the tx (excluding signature and hash fields themselves).
Rules:
- Insertion order — keys are serialized in the order they were added (NOT sorted). The SDK builders define the canonical order per tx type.
- No whitespace — no spaces, no newlines. Compact JSON.
- UTF-8 — string fields encoded as UTF-8.
- BigInt → string — any field that semantically is a
bigint(e.g.,amount,fee,maxFee) is serialized as a JSON string, not a number. This avoids JSONNumberprecision loss.
Example pseudo-code:
function canonicalJson(obj):
// walk obj, emit each (key, value) in insertion order
// for value:
// bigint → JSON string (e.g., 1000000n → "1000000")
// string/number/boolean/null → standard JSON
// object → recurse
// array → recurse
// no whitespace between tokens
function hashTx(tx):
payload = { ...tx }
delete payload.hash
delete payload.signature
return sha256(utf8Bytes(canonicalJson(payload))).hex()
function signTx(tx, privateKey):
tx.publicKey = ed25519PubFrom(privateKey).hex()
tx.hash = hashTx(tx)
tx.signature = ed25519Sign(privateKey, utf8Bytes(tx.hash)).hex()
return tx
The signature is over the UTF-8 bytes of the hex hash string, NOT the raw 32 hash bytes. This is unusual but cross-language consistent.
Verify a tx
function verifyTx(tx):
// 1. from must derive from publicKey
if tx.from != sha256(hexDecode(tx.publicKey))[:20].hex(): return false
// 2. hash must match recomputed hash
payload = { ...tx }
delete payload.hash
delete payload.signature
if hashTx(payload) != tx.hash: return false
// 3. signature must verify
return ed25519Verify(
publicKey = hexDecode(tx.publicKey),
message = utf8Bytes(tx.hash),
signature = hexDecode(tx.signature),
)
Nonce semantics
nonce starts at 0 for new accounts. Every tx increments the sender's nonce by 1 in state. The mempool rejects txs with nonce < state.nonce(from).
To submit multiple txs in a single block, pre-fetch nonce once and increment locally:
let nonce = (await client.chain.getAccount(addr)).nonce;
for (const target of recipients) {
const tx = buildTransferTx(addr, target, 1_000_000n, 1_000n, nonce, privKey);
await client.chain.submitTx(tx);
nonce++;
}
Fee fields
Different tx types use different fee fields:
| Tx type | Fee field | Min |
|---|---|---|
TRANSFER | fee | 1 micro (no enforced min) |
TASK_SUBMIT | fee | 10_000 micro (MIN_TASK_FEE) |
AGENT_REQUEST | maxFee (escrow) | — |
CHAT_TURN | (implicit CHAT_TURN_FEE = 100) | — |
| All others | none | (no fee) |