Guide: Building with On-Chain Escrow
NHBCoin escrow locks NHB or ZNHB in a protocol-level vault until both sides of a trade agree it's done -- no smart contract, no third-party custodian. There are two ways to build with it: the hosted Escrow Gateway, a REST API recommended for almost every app, or submitting your own signed transactions directly to the chain if you'd rather self-host and hold your own gas wallet. This guide covers both, starting with the Gateway.
Read the Escrow Gateway reference on GitHubIf you built against an earlier version of this guide: the direct escrow_create/escrow_release/escrow_refund/escrow_dispute/escrow_resolve JSON-RPC methods are now permanently disabled -- calling any of them returns an HTTP 410 error. They used to mutate validator state directly, outside the normal transaction pipeline, which could crash or fork the network; every escrow action now goes through a real, consensus-verified transaction instead. If anything you've built calls those RPC methods by name, switch it to the Gateway below or to signed transactions in the Advanced section.
Path 1: The Escrow Gateway (recommended)
The Gateway is a hosted relayer at api.nhbcoin.com that builds, signs, and submits the underlying blockchain transactions for you -- your app never needs to hold NHB for gas or construct a raw transaction. It can't act on a user's behalf without that user's permission, though: every state-changing call still requires a real signature from the actual participant (payer, payee, or mediator), which the Gateway forwards on-chain where it's checked again, independently, by the network itself.
Step 1: Authenticate every request
Every call to the Gateway needs an API key and an HMAC-SHA256 request signature, issued when you register your app. Send four headers: X-Api-Key, X-Timestamp (unix seconds), X-Nonce (a fresh random value per request), and X-Signature -- the hex HMAC of timestamp + nonce + METHOD + path + body (newline-joined), keyed with your API secret. A request outside a small timestamp window, or reusing a nonce, is rejected -- that’s what stops a captured request from being replayed.
// escrow-gateway-client.mjs -- shared helper, used by every call below
import { createHmac, randomUUID } from 'node:crypto';
const GATEWAY_URL = 'https://api.nhbcoin.com';
const API_KEY = process.env.NHB_ESCROW_API_KEY;
const API_SECRET = process.env.NHB_ESCROW_API_SECRET; // shared HMAC secret
export async function gatewayRequest(method, path, body, walletHeaders = {}) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomUUID();
const rawBody = body ? JSON.stringify(body) : '';
// The Gateway recomputes this exact string server-side and compares --
// order and the newline separators matter.
const canonical = [timestamp, nonce, method.toUpperCase(), path, rawBody].join('\n');
const signature = createHmac('sha256', API_SECRET).update(canonical).digest('hex');
const res = await fetch(GATEWAY_URL + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-Api-Key': API_KEY,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
'Idempotency-Key': randomUUID(),
...walletHeaders // X-Sig-Addr / X-Sig -- see Step 2 below
},
body: rawBody || undefined
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error('Gateway ' + path + ' failed (' + res.status + '): ' + (err.error ?? 'unknown error'));
}
return res.json();
}Step 2: Why create, release, refund, and dispute also need your signature
The API key above proves your app is a registered Gateway client -- it does not prove you're the payer, payee, or mediator on any specific escrow. So for the four actions that move funds or change an escrow's state (create, release, refund, dispute), the Gateway also requires a signature from that actual participant's own private key, over the exact on-chain action being authorized -- not over the HTTP request itself. In practice: your API key gets your request in the door, but only the real payer/payee/mediator's signature can move their funds. The Gateway checks this signature immediately, a fast, clear rejection on mismatch, and the chain checks it again independently once the Gateway relays the action as a real transaction -- so the Gateway's own relayer key never grants itself authority over anyone's funds; it only pays the gas.
Concretely, you sign the JSON below with the participant's private key -- plain keccak256 of the exact bytes, not EIP-191/personal_sign-wrapped -- and send the address as X-Sig-Addr and the hex signature as X-Sig:
// Create -- signed by the payer. Address fields are raw 20-byte hex here,
// NOT the nhb1... bech32 form used in the request body itself.
{
"action": "create",
"payer": "a1b2c3...20 bytes",
"payee": "d4e5f6...20 bytes",
"token": "NHB",
"amount": "100000000000000000000",
"feeBps": 0,
"deadline": 1730000000,
"nonce": 1,
"mediator": "...omitted entirely if unset",
"meta": "...omitted entirely if unset",
"realm": "...omitted entirely if unset"
}
// Release / Refund -- signed by the payee or mediator (release),
// or the payer (refund)
{ "escrowId": "0x...64 hex chars...", "action": "release" }
// Dispute -- signed by either the payer or the payee
{ "escrowId": "0x...64 hex chars...", "action": "dispute", "reason": "item damaged" }payer/payee/mediator here are lower-case hex of the raw 20-byte address -- not the nhb1... form used everywhere else in this API. The Gateway converts for you when building the request body; only the signed envelope uses raw hex.
import { ethers } from 'ethers';
// wallet holds the real participant's private key -- never the
// Gateway's relayer key. Run this wherever you control that key:
// your backend, or (for a browser wallet) the customer's own device.
function signEscrowEnvelope(envelope, wallet) {
// Field order matters -- JSON.stringify on a plain object preserves
// insertion order, so build 'envelope' in the same order shown above.
const canonicalJson = JSON.stringify(envelope);
const digest = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(canonicalJson));
// Raw digest signing -- NOT wallet.signMessage(), which adds an
// EIP-191 prefix the Gateway does not expect and will reject.
const signingKey = new ethers.utils.SigningKey(wallet.privateKey);
const signature = signingKey.signDigest(digest);
return ethers.utils.joinSignature(signature); // 0x-prefixed, 65 bytes
}Step 3: Create, fund, and release
Create the escrow from the payer's side. feeBps is an optional protocol fee taken on release; mediator is optional and only needed if you want a third party able to release or refund the funds instead of relying on payer/payee agreement alone.
// The exact bytes the payer signs -- address fields are raw hex here,
// not the nhb1... form used in the request body below.
const createEnvelope = {
action: 'create',
payer: payerAddressHex,
payee: payeeAddressHex,
token: 'NHB',
amount: '100000000000000000000', // 100 NHB, 18 decimals
feeBps: 0,
deadline: Math.floor(Date.now() / 1000) + 86400, // 24h to fund
nonce: 1
};
const signature = signEscrowEnvelope(createEnvelope, payerWallet);
const { escrowId, payIntent } = await gatewayRequest(
'POST',
'/escrow/create',
{
payer: 'nhb1payer...',
payee: 'nhb1payee...',
token: 'NHB',
amount: '100000000000000000000',
feeBps: 0,
deadline: createEnvelope.deadline
},
{ 'X-Sig-Addr': 'nhb1payer...', 'X-Sig': signature }
);
// -> escrowId: '0x...', payIntent: { vault, memo, qr }The response's payIntent is how the escrow actually gets funded -- there's no separate "fund" API call. Have the payer send amount of token to payIntent.vault with payIntent.memo attached (or use payIntent.qr to build a scannable payment request); the Gateway watches the chain and flips the escrow to FUNDED once that payment lands.
Check status any time with a plain, unauthenticated GET -- this one skips all the headers above, since it's just a read of public escrow state:
// No API key or signature needed -- this is a public, read-only lookup.
const escrow = await fetch(
'https://api.nhbcoin.com/escrow/' + escrowId
).then((res) => res.json());
// -> { status: 'FUNDED', payer, payee, token, amount, deadline, mediator, events: [...] }Once the payee is satisfied, the payee (or the mediator, if one is set) releases the funds; the payer can refund before that happens; either side can raise a dispute:
// Payee (or mediator) releases the funds to the payee
const releaseEnvelope = { escrowId, action: 'release' };
const releaseSig = signEscrowEnvelope(releaseEnvelope, payeeWallet);
await gatewayRequest(
'POST', '/escrow/release', { escrowId },
{ 'X-Sig-Addr': 'nhb1payee...', 'X-Sig': releaseSig }
); // -> 202 { queued: true }
// Payer refunds before release
const refundEnvelope = { escrowId, action: 'refund' };
const refundSig = signEscrowEnvelope(refundEnvelope, payerWallet);
await gatewayRequest(
'POST', '/escrow/refund', { escrowId },
{ 'X-Sig-Addr': 'nhb1payer...', 'X-Sig': refundSig }
); // -> 202 { queued: true }
// Either side raises a dispute instead
const disputeEnvelope = { escrowId, action: 'dispute', reason: 'item damaged' };
const disputeSig = signEscrowEnvelope(disputeEnvelope, payerWallet);
await gatewayRequest(
'POST', '/escrow/dispute', { escrowId, reason: 'item damaged' },
{ 'X-Sig-Addr': 'nhb1payer...', 'X-Sig': disputeSig }
); // -> 202 { ok: true }Not available yet: POST /escrow/resolve currently returns 502. The safe on-chain resolve path needs the escrow to have been created against a registered arbitration realm -- a committee of arbitrators with a signing threshold -- and no deployment has provisioned one yet. That’s a one-time operator setup step, not something you can pass per-request today.
Step 4: Listen for webhooks instead of polling
Configure a webhook URL for your app and the Gateway will POST to it on every state change: escrow.created, .funded, .released, .refunded, .expired, .disputed, and .resolved. Each payload carries escrowId, payer, payee, token, amount, an optional txHash, and meta.reference -- use meta.reference to correlate back to your own order or trade ID, and treat a webhook, not the GET endpoint and never the browser, as the source of truth for fulfillment.
// POSTed to your configured webhook URL on every state change
{
"event": "escrow.released",
"escrowId": "0x...",
"payer": "nhb1...",
"payee": "nhb1...",
"token": "NHB",
"amount": "100000000000000000000",
"txHash": "0x...",
"meta": { "reference": "your-own-order-id" },
"provider": "nhbchain-escrow-gateway"
}Advanced: Direct on-chain transactions
If you'd rather hold your own NHB for gas, sign your own transactions, and skip the Gateway entirely -- for example, a backend service with its own hot wallet -- these base transaction types were never disabled and work exactly as they always have. Build, sign, and submit them yourself via the standard nhb_sendTransaction RPC, the same way you'd submit any other signed NHBChain transaction.
TxTypeCreateEscrow--0x03(3): create the escrow.TxTypeLockEscrow--0x09(9): buyer funds/commits.TxTypeReleaseEscrow--0x04(4): release funds to the payee.TxTypeRefundEscrow--0x05(5): refund funds to the payer.TxTypeDisputeEscrow--0x0A(10): raise a dispute, locking the funds from both sides.TxTypeArbitrateRelease--0x0B(11): admin/arbitrator-only ruling, releases to the payee.TxTypeArbitrateRefund--0x0C(12): admin/arbitrator-only ruling, refunds the payer.TxTypeExpireEscrow--0x3B(59): permissionless -- anyone can pay the gas to sweep a stale, past-deadline escrow back to its payer.
A signed example, built the same way as any other native transaction:
// Self-hosted: you hold the NHB, you sign, you submit -- no Gateway involved.
// Field encoding here is illustrative -- see the transaction type list
// above and core/types/transaction.go for the exact wire format.
const createEscrowTx = {
type: 3, // TxTypeCreateEscrow
nonce: payerAccount.nonce,
gasLimit: 100000,
gasPrice: 1,
data: JSON.stringify({
payee: payeeAddress,
token: 'NHB',
amount: '100000000000000000000',
feeBps: 0,
deadline: Math.floor(Date.now() / 1000) + 86400
})
};
const signedTx = await payerWallet.signTransaction(createEscrowTx);
await fetch('https://api.nhbcoin.com', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'nhb_sendTransaction',
params: [signedTx],
id: 1
})
});This is also how the Gateway works under the hood, just with an extra layer: it relays through the delegated variants of these same actions (TxTypeDelegatedCreateEscrow/DelegatedReleaseEscrow/DelegatedRefundEscrow/DelegatedDisputeEscrow, hex 0x41/0x3C/0x3D/0x3E) using its own relayer key to pay gas, while your wallet signature from Step 2 above is what's embedded in the transaction and independently verified on-chain -- the mechanics here are exactly what Path 1 automates for you.
The read-only lookup (escrow_get) was never part of the disabled set and still works over JSON-RPC exactly as before -- only the state-mutating escrow_create/release/refund/dispute/resolve methods are gone. See the API Reference for the full RPC surface.