Tenzro Testnet is live —request testnet TNZO

Escrow

Escrow on the Tenzro Network is a consensus-mediated on-chain primitive. Funds are locked at a vault address derived deterministically from the escrow id; only the original signing payer can later release funds to the payee or refund them to themselves. The Native VM is the source of truth for funds and state — there is no in-memory escrow store and no privileged RPC mint path.

Overview

Escrow is implemented as three new transaction types in tenzro-types, dispatched by the Native VM via 4-byte selectors:

  • CreateEscrow — selector 0x01000010, gas 75,000. Debits the payer, derives the vault address, credits the vault.
  • ReleaseEscrow — selector 0x01000011, gas 60,000. Vault → payee. Only the original payer can submit.
  • RefundEscrow — selector 0x01000012, gas 50,000. Vault → payer. Only the original payer can submit, and only after expiry (or with Timeout/Custom release conditions).

Writes flow through the standard transaction pipeline (sign + submit a typed transaction). Reads are served by tenzro_getEscrow, tenzro_listEscrowsByPayer, and tenzro_listEscrowsByPayee.

On-chain flow

client  TransactionBuilder  sign(payer key)  eth_sendRawTransaction
        mempool (signature verified at admission)
        block  Native VM dispatch (4-byte selector)
        debit payer / credit vault (or vault  payee/payer on release/refund)
        write-through to CF_SETTLEMENTS (escrow:<escrow_id>)

Deterministic identifiers

Both the escrow id and the vault address are derived deterministically from public inputs. This means clients can compute them in advance and the network cannot forge or collide them.

  • escrow_id = SHA-256("tenzro/escrow/id/v1" || payer || nonce_le)
    Derived by the VM at CreateEscrow execution. Surfaced to clients via the receipt log.
  • vault address = Address(SHA-256("tenzro/escrow/vault/v1" || escrow_id))
    Has no private key. Release/refund payouts are a privileged VM operation that calls state.set_balance directly via a single auditable helper, never via normal TnzoToken::transfer.

Authorization invariants

The VM enforces strict payer-only authorization:

  • CreateEscrow.from must equal the signing payer (verified at mempool admission). The transaction payload has no separate payer field.
  • ReleaseEscrow requires tx.from == escrow.payer, status Funded, not expired, and a proof that satisfies the recorded release_conditions.
  • RefundEscrow requires tx.from == escrow.payer AND (escrow is expired OR release_conditions ∈ { Timeout, Custom }).

Release conditions

Six release-condition modes are supported, encoded in the transaction payload as { type: "..." }:

  • Timeout — release allowed after expires_at; refund allowed any time
  • ProviderSignature — provider must sign the release proof
  • ConsumerSignature — consumer must sign
  • BothSignatures — both provider and consumer signatures required
  • VerifierSignature — designated verifier (e.g., TEE attestor or ZK prover) signs
  • Custom — application-defined; refund permitted at any time

Creating an escrow (CLI)

# Ambient auth: export TENZRO_BEARER_JWT + TENZRO_DPOP_PROOF first;
# the node resolves the signer from the DPoP-bound JWT's MPC wallet.
tenzro escrow create \
  --payer 0xpayer... \
  --payee 0xpayee... \
  --amount 1000000000000000000 \
  --asset TNZO \
  --expires-at 1735689600000 \
  --release-conditions timeout

The CLI builds and signs a CreateEscrow transaction, submits it via tenzro_signAndSendTransaction, and returns the transaction hash. The escrow id is observable in the transaction receipt.

Creating an escrow (TypeScript SDK)

import { TenzroClient } from "@tenzro/sdk";

// Ambient auth: process.env.TENZRO_BEARER_JWT + TENZRO_DPOP_PROOF
// must be set; the SDK forwards them as Authorization: DPoP <jwt>
// and DPoP: <proof> on every JSON-RPC call.
const client = new TenzroClient({ rpcUrl: "https://rpc.tenzro.network" });

const txHash = await client.settlement.createEscrow(
  payerAddress,
  payeeAddress,
  1_000_000_000_000_000_000n,
  "TNZO",
  BigInt(Date.now() + 24 * 60 * 60 * 1000),
  "timeout",
);

// later, release
await client.settlement.releaseEscrow(
  payerAddress,
  escrowIdHex,
);

// or refund (after expiry)
await client.settlement.refundEscrow(
  payerAddress,
  escrowIdHex,
);

Creating an escrow (raw JSON-RPC)

curl -X POST https://rpc.tenzro.network \
  -H "Content-Type: application/json" \
  -H "Authorization: DPoP $TENZRO_BEARER_JWT" \
  -H "DPoP: $TENZRO_DPOP_PROOF" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tenzro_signAndSendTransaction",
    "params": {
      "from": "0xpayer...",
      "to":   "0xpayee...",
      "value": 0,
      "gas_limit": 75000,
      "gas_price": 1000000000,
      "nonce": 0,
      "chain_id": 1337,
      "tx_type": {
        "type": "CreateEscrow",
        "data": {
          "payee": "0xpayee...",
          "amount": "1000000000000000000",
          "asset_id": "TNZO",
          "expires_at": 1735689600000,
          "release_conditions": { "type": "Timeout" }
        }
      }
    },
    "id": 1
  }'

Persistence and hydration

The Native VM is the source of truth for fund state. The EscrowManager in tenzro-settlement is a denormalized query cache used by read RPCs. When constructed via EscrowManager::with_storage(balances, storage), escrow records are persisted to RocksDB CF_SETTLEMENTS under three prefixes:

  • escrow:<escrow_id> — full EscrowAccount record (JSON)
  • escrow_payer:<address_hex> — Vec<escrow_id> index for payer lookups
  • escrow_payee:<address_hex> — Vec<escrow_id> index for payee lookups

On startup the manager scans escrow: and rebuilds in-memory indices. All multi-key mutations use KvStore::write_batch_sync for atomic, fsync'd writes. Escrow state survives node restart.

Read RPCs

  • tenzro_getEscrow { escrow_id } — fetch a single escrow record by 32-byte hex id
  • tenzro_listEscrowsByPayer { payer } — all escrows funded by a given payer
  • tenzro_listEscrowsByPayee { payee } — all escrows targeting a given payee

Channel disputes

Escrows themselves are atomic — release/refund are payer-signed VM transactions, so there is no escrow-level dispute concept. Disputes live one layer up, on micropayment channels opened against an escrow. Either party can open a dispute when off-chain state diverges; the network records evidence, runs a timeout, and finalizes the channel balance on-chain.

# Inspect a single dispute (challenger, evidence, status, resolution)
tenzro dispute status <dispute_id>

# Every dispute ever opened against a given channel
tenzro dispute list-by-channel --channel-id <channel_id>

Underlying RPCs: tenzro_getDispute { dispute_id } and tenzro_listDisputesByChannel { channel_id }. Disputes are persisted in CF_CHANNELS under the dispute:<dispute_id> prefix; MicropaymentChannelManager::open_dispute / resolve_dispute are the write paths.

Next steps