Skip to main content

Transaction format

Every OmbraChain transaction is a JSON object with these universal fields, plus type-specific fields documented in subsequent pages.

Universal fields

FieldTypeRequiredNotes
typestringyesOne of 37 tx types (see other pages)
fromstringyesSender address — 40 lowercase hex chars
noncenumberyesPer-sender incrementing counter, starts at 0
timestampnumberyesUnix ms — Date.now()
publicKeystringyes (set by signer)64 hex chars — Ed25519 public key
hashstringyes (set by signer)64 hex chars — sha256 of canonical JSON
signaturestringyes (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:

  1. Insertion order — keys are serialized in the order they were added (NOT sorted). The SDK builders define the canonical order per tx type.
  2. No whitespace — no spaces, no newlines. Compact JSON.
  3. UTF-8 — string fields encoded as UTF-8.
  4. 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 JSON Number precision 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 typeFee fieldMin
TRANSFERfee1 micro (no enforced min)
TASK_SUBMITfee10_000 micro (MIN_TASK_FEE)
AGENT_REQUESTmaxFee (escrow)
CHAT_TURN(implicit CHAT_TURN_FEE = 100)
All othersnone(no fee)

Next: TRANSFER, BURN, ATTESTATION_REWARD →