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— selector0x01000010, gas 75,000. Debits the payer, derives the vault address, credits the vault.ReleaseEscrow— selector0x01000011, gas 60,000. Vault → payee. Only the original payer can submit.RefundEscrow— selector0x01000012, 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 atCreateEscrowexecution. 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 callsstate.set_balancedirectly via a single auditable helper, never via normalTnzoToken::transfer.
Authorization invariants
The VM enforces strict payer-only authorization:
CreateEscrow.frommust equal the signing payer (verified at mempool admission). The transaction payload has no separatepayerfield.ReleaseEscrowrequirestx.from == escrow.payer, statusFunded, not expired, and a proof that satisfies the recordedrelease_conditions.RefundEscrowrequirestx.from == escrow.payerAND (escrow is expired ORrelease_conditions ∈ { Timeout, Custom }).
Release conditions
Six release-condition modes are supported, encoded in the transaction payload as { type: "..." }:
Timeout— release allowed afterexpires_at; refund allowed any timeProviderSignature— provider must sign the release proofConsumerSignature— consumer must signBothSignatures— both provider and consumer signatures requiredVerifierSignature— designated verifier (e.g., TEE attestor or ZK prover) signsCustom— 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 timeoutThe 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>— fullEscrowAccountrecord (JSON)escrow_payer:<address_hex>— Vec<escrow_id> index for payer lookupsescrow_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 idtenzro_listEscrowsByPayer { payer }— all escrows funded by a given payertenzro_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
- Micropayment Channels — off-chain per-token billing for AI inference
- Batch Processing — atomic multi-settlement operations
- Zero-Knowledge Proofs — generate and verify ZK proofs for release
- Trusted Execution Environments — TEE attestations as release proofs