Swagger

Private tokens end to end

The complete integration: the SDK and circuit artifacts your client needs, the exact calldata the rail accepts, when a registration travels on its own and when it rides in front of a deposit, both allowances, and the code for every step.

Everything a client builds

Everything below is what a client application does to move private tokens through UGTP. The conceptual page states what the four operations mean; this one states what you send, what comes back, and what has to be true on chain before each call succeeds.

The work splits in one place and only one: UGTP relays and prices the operation, and your client builds the encrypted-token calldata and its zero-knowledge proof. That split is not a division of labour we chose — an encrypted balance can only be spent by proving knowledge of its plaintext, and the key that reads it is derived from your user's own wallet signature and never leaves their device. If UGTP built the proof, UGTP would hold that key, and nothing about the balance would be private.

What the four operations mean, first →

Before the first call

A project API key, and a private-token project

UGTP API calls use the project key in the X-API-Key header. Keep this key on your backend and proxy authenticated requests from the browser. Deposit and withdrawal quotes require the executor rail; an incompatible project receives 409 PRIVATE_QUOTE_RAIL_UNSUPPORTED. Registration and private transfers return sponsored signing payloads.

The AvaCloud encrypted-token SDK

Npm i @avalabs/eerc-sdk — the public package that derives the key, encrypts, proves, and ABI-encodes the four calls. Construct it in CONVERTER mode: the UGTP deployment wraps existing ERC-20s 1:1 rather than minting a token of its own. Use it for the cryptography only; it must not send transactions, because UGTP relays them.

Groth16 circuit artifacts you host yourself

Proving runs in your user's browser, so the five circuit pairs (registration, mint, transfer, withdraw, burn — each a .wasm and a .zkey) must be reachable over HTTP. They are published on this site under /eerc-circuits/, so you can start against them immediately and copy them to your own origin before you go live. Do not build your own: they must match this deployment's verifier contracts, and a proof from a different trusted setup is rejected on chain as InvalidProof.

An Avalanche C-Chain RPC endpoint

Three things are read straight from chain rather than from UGTP: whether an address is registered, the encrypted balance your client decrypts for display, and the token id the converter assigned to an underlying. Use an endpoint you control — the public RPC rate-limits under proving traffic.

There is no UGTP package to install today: you drive the encrypted-token layer through AvaCloud's own public SDK, exactly as described here. We may publish a wrapper later that folds the steps on this page into one dependency; it would be additive, and an integration built against this page keeps working.

The whole integration, before the detail

WhenDo thisFor this
Once, at startupGET /private/tokensLearn the contract addresses and the two precisions. Cache the answer.
Once, in your clientWire up the encrypted-token SDKMake it hand you calldata instead of broadcasting. Part 2 — nothing works before it.
Once per accountRead isUserRegistered on chainDecide whether it can hold private tokens yet.
Once per account, if notPOST /private/prepare with private_registerActivate it. Free. Skip if its first move is a deposit — bundle it there instead.
Once per token, before a first depositPOST /transaction/allowance-setup, twiceApprove the converter for the principal, and the executor for the fee.
Before every deposit or withdrawalPOST /private/quoteCheck the account can afford it, before the device proves anything.
For every operationBuild the calldata with the SDKProduce the proof. Only your client can: the key is the user's.
For every operationPOST /private/prepareGet the operationId and the payload to sign.
For every operationSign, POST /transaction/submit, poll GET /transaction/:idGet it on chain and find out when it settled.

The token registry

GET /private/tokens is the only place a client learns which private tokens exist and which contracts they live on. Read it at startup and cache it; never hard-code an address, because a redeployment moves the contracts and the registry is what moves with them.

FieldTypeWhat it is
symbolstringThe identifier every other private endpoint takes, e.g. esUSDT. Not an on-chain name — the converter stores none.
displayNamestringThe human label, e.g. "ES USDT". Presentation only.
networkIdstringThe chain the token lives on. Every entry today is 43114, Avalanche C-Chain.
eercAddressstringThe EncryptedERC converter — the target of a deposit, a private transfer and a withdrawal. It repeats across entries: one converter serves every private token on the network.
registrarAddressstringThe Registrar — the target of a registration, and the contract you read isUserRegistered from.
underlyingAddressstringThe public ERC-20 wrapped 1:1. It is also the fee token for every operation on this token.
underlyingSymbolstringThat public token's symbol, e.g. USDT.
underlyingDecimalsnumberThat public token's own precision. amountRaw on deposit and withdraw is expressed in these base units.
eercDecimalsnumberThe converter's INTERNAL precision — 2 on this deployment, for every token. Not the underlying's, and not settable per token.
iconUrlstring | nullA CDN URL for the token's artwork, or null. Render it directly; it is the only pointer to the file there is.
Private tokenNameWrapsUnderlying addressamountRaw is inPrivate balance precision
esUSDTES USDTUSDT0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c76 decimals2 decimals
esUSDCES USDCUSDC0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E6 decimals2 decimals
esWAVAXES WAVAXWAVAX0xB31f66AA3C1e785363F0875A1B74E27b85FD66c718 decimals2 decimals
esDAIES DAIDAI0xd586E7F844cEa2F87f50152665BCbc2C279D8d7018 decimals2 decimals

Treat this table as an illustration and the endpoint as the authority: the set changes without an API version change. Note esWAVAX — the converter pulls an ERC-20 under an allowance, and the native coin cannot be approved at all, so the wrapped form is what is offered and what the user deposits.

The circuit artifacts, and where to get them

Proving happens in your user's browser, against the Groth16 artifacts of this deployment's trusted setup. They are published here so you can build against them from the first minute, and so there is exactly one place they can be wrong. They are the upstream ava-labs/EncryptedERC artifacts at the pinned commit, unmodified by UGTP — the verification keys beside them let you check that for yourself.

Download all circuits eerc-circuits.zip · 32 MB

Unzip it into your app's static folder and you are done: it already contains the eerc-circuits/ directory with all five circuits inside, so public/ ends up holding public/eerc-circuits/transfer/transfer.zkey and the rest — exactly the paths circuitURLs uses. Grab the individual files below instead only if you are serving them from somewhere else.

CircuitUsed byDownloadVerification key
registrationprivate_register, and the registration bundled into a first depositregistration.wasm 1.8 MBcircuit_final.zkey 1.0 MBregistration_verification_key.json
transferprivate_transfertransfer.wasm 2.0 MBtransfer.zkey 14 MBtransfer_verification_key.json
withdrawprivate_withdrawwithdraw.wasm 1.9 MBcircuit_final.zkey 6.8 MBwithdraw_verification_key.json
mintNothing here — it belongs to the standalone mode UGTP does not runmint.wasm 2.6 MBmint.zkey 10 MBmint_verification_key.json
burnNothing here — sameburn.wasm 1.9 MBburn.zkey 9.7 MBburn_verification_key.json
What to do with these files
  1. Download the archive

    The archive includes five .wasm circuits and five .zkey proving keys, plus optional verification-key JSON files. Serve the binaries as static files; the SDK fetches them while building proofs.

  2. Unzip it into your app's static folder

    Use the directory your framework serves as static files, such as public/ in Next.js or Vite, or static/ in SvelteKit. The archive already carries the eerc-circuits/ folder, so unzipping there produces the exact paths circuitURLs uses. Do not import the files in code and do not put them through your bundler; they are served, not built.

  3. Confirm they are reachable in the browser

    Open https://your-app/eerc-circuits/transfer/transfer.zkey in a tab. You should get a file download, not a 404 and not your app's HTML fallback — a single-page app that serves index.html for unknown paths will hand the SDK a page instead of a proving key, and the failure surfaces much later as a broken proof.

  4. The _verification_key.json files are optional

    The SDK never reads them, and circuitURLs has no slot for them. They are there so you can verify independently that these artifacts match the verifier contracts deployed on chain, rather than taking our word for it. Ship them or leave them out; nothing in the flow depends on it.

  5. Cache the circuit artifacts

    The registration, transfer and withdrawal artifacts total about 28 MB. The browser fetches the artifacts needed for each proof. Use versioned URLs and cache headers, and allow for downloads on a new device or after cache eviction.

A deposit needs no circuit at all: deposit(uint256, address, uint256[7]) carries no proof, because the amount it moves is public anyway. Three of the five therefore do the work. Serve all five regardless — the SDK is constructed with the whole map, and a missing entry would surface as a failure at proof time rather than at startup. Two file names break the pattern, registration and withdraw, which both end in circuit_final.zkey. If the encrypted-token contracts are ever redeployed, these files change with them: re-copy the set rather than assuming the ones you cached still verify.

Amounts: what the user types, and what you send

Limit the user's amount to token.eercDecimals decimal places, currently 2. Encode deposit and withdrawal amountRaw using token.underlyingDecimals. For example, 10.55 USDT has two display decimals but becomes 10550000 at USDT's six-decimal precision.

TokenThe user typesUnderlying decimalsamountRaw you sendPrivate balance becomes
esUSDT10.5561055000010.55
esUSDC10.5561055000010.55
esWAVAX10.55181055000000000000000010.55
esDAI10.55181055000000000000000010.55

In code that is one line — parseUnits(typedAmount, token.underlyingDecimals).toString() — with the input field limited to token.eercDecimals places. Two things to notice in the table: the private balance is the number the user typed, unchanged, and amountRaw differs by a factor of a trillion between a 6-decimal and an 18-decimal underlying for the very same amount. And if the user types 10.5512, the last two places do not become private: the deposit converts 10.55 and the converter returns the remainder to their public balance as dust. Refuse the extra places in the field instead of letting that happen silently. One more convention exists below this one: the SDK that BUILDS the calldata takes the 2-decimal figure for a transfer and a withdrawal, and the underlying's for a deposit — Part 2 of the walkthrough sets all three side by side.

Two different precisions, and you need both

Private balance precision comes from eercDecimals

The current converter uses eercDecimals=2 for all listed tokens. Limit amounts to that precision: 10.55 is representable, while 10.5512 is not. Read eercDecimals from the token registry.

amountRaw is always in the underlying's decimals

6 for esUSDT and esUSDC, 18 for esWAVAX and esDAI. Depositing 1 USDT is amountRaw "1000000"; depositing 1 DAI is amountRaw "1000000000000000000". Getting these two numbers the wrong way round is the easiest mistake on this page to make, and on an 18-decimal token it is wrong by a factor of a trillion.

So clamp the input field to 2, and send the amount at the underlying's

The limit your form enforces and the units you put on the wire are different numbers for the same amount. Anything finer than 0.01 is handed back to the user as dust instead of being converted — not lost, but not private either, and an account left holding 0 private after a deposit cannot send anything.

What 0.01 is worth depends on the price, not the decimals

On a dollar stablecoin it is a cent. On esWAVAX it is a hundredth of an AVAX, which is a visible amount and rises with the market — so a careless deposit leaves a far more noticeable remainder there than on esUSDT.

Token artwork

IconUrl points at the UGTP asset CDN, laid out as crypto/private-tokens/<chainId>/<symbol>/icon.svg, with 24, 128 and 256 pixel PNGs beside it. The artwork is the underlying token's own, unmarked — what marks an asset as private in a UI is the surrounding screen, not the picture. Key your cache by the private symbol rather than by a contract address: every private token shares one converter address, so keying by address collapses them all onto one icon. Fall back to the underlying token's ordinary icon, then to the symbol's initials.

The calldata each operation must carry

POST /private/prepare accepts calls the client built, but it does not accept arbitrary ones. Every call is checked against the registry and against the function the executor is pinned to for that kind, before anything is signed — so a mistake here is a clean 400 rather than a transaction that reverts after your user has already approved it.

OperationTargetSelectorFunctionamountRaw
private_registerregistrarAddress0x37729b5cregister(((uint256[2],uint256[2][2],uint256[2]),uint256[5]))amountRaw must be omitted.
private_depositeercAddress0x82f09ea2deposit(uint256 amount, address tokenAddress, uint256[7] amountPCT)amountRaw required, in the underlying's base units. It is re-decoded from argument 0 of this call and the decoded figure is what you are charged on.
private_transfereercAddress0x2d947610transfer(address to, uint256 tokenId, ((uint256[2],uint256[2][2],uint256[2]),uint256[32]) proof, uint256[7] balancePCT)amountRaw must be omitted. The amount is never received and never stored.
private_withdraweercAddress0xa5566e25withdraw(uint256 tokenId, ((uint256[2],uint256[2][2],uint256[2]),uint256[16]) proof, uint256[7] balancePCT)amountRaw required, in the underlying's base units. It is re-decoded from proof.publicSignals[0] and rescaled from eercDecimals; the decoded figure is what you are charged on.
  • Encode the overload WITHOUT the trailing bytes message. Both shapes exist on the contracts and the SDK can build either, but the executor is pinned to the four selectors above; the other shape is refused at prepare and would revert on chain.
  • Point each call at the address the registry gave you — the registrar for a registration, the converter for everything else. The target is re-resolved from the registry server-side, so a call aimed anywhere else is refused.
  • Send at most two calls, and a second one only as a leading registration in front of a deposit. This rail executes typed actions, so an approve cannot ride along in the same request; grant it beforehand.
  • Set value to 0 on every call. A private operation never carries native value.
  • A deposit takes the underlying's ADDRESS; a transfer and a withdrawal take the converter's token ID for it. Read that id from the converter's tokenIds(address) mapping — it is assigned on the first deposit of that underlying and never changes afterwards. Zero means no deposit has ever bound that underlying, so there is nothing to transfer or withdraw yet.

Registration: on its own, or with a deposit

Registration binds an address to the public key its balances are encrypted under. Nothing private can be held, sent or received before it, and it is the one step with two legitimate shapes — which one you use decides what the operation costs and what it needs.

ShapeWhen you use itWhat you sendWhat it costs
On its ownThe account is activating before it holds anything — onboarding, or an account that will receive a private token before it ever deposits.operationKind private_register, one call carrying the registration proof, aimed at registrarAddress. No amountRaw.Nothing. It answers rail "sponsored", carries no fee terms at all, and needs no allowance and no token holding — which is what lets an account with an empty wallet activate.
In front of a first depositThe account's first private action is a deposit. The converter refuses a deposit from an unregistered address, so the two have to travel together.operationKind private_deposit, two calls: the registration proof aimed at registrarAddress first, then the deposit aimed at eercAddress. amountRaw is the deposit's.The deposit's own cost, once. The fee is collected after both calls, so the account pays for its activation only in the sense that it was already paying for the deposit.
  • Decide between the two by reading Registrar.isUserRegistered(address) on chain. There is no UGTP endpoint for it: it is a plain public view, needs no key and no signature, and reading it yourself keeps the answer as fresh as the chain.
  • A registration can never be repeated for the same address. A second key would leave earlier balances encrypted under the first and later ones under the second, and a sum of both decrypts under neither. An address whose key is lost has to be replaced, not re-registered.
  • A private transfer requires BOTH sides to be registered — the contract enforces it on the recipient as well as the sender. Read the recipient's registration before you let a user start composing a transfer, not after the proof is built.
  • The key is derived from a signature by the owner EOA, so it is deterministic: the same wallet re-derives the same key on a new device, and re-registration is never the answer to a lost session.

The two allowances

Two different contracts move the public token during a private operation, so there are two allowances, with two different spenders and two different error codes. Both are granted through the ordinary allowance endpoints, and both are one-time per token and per account.

SpenderNeeded forWhyRefusal
The EncryptedERC converterA deposit only.The converter pulls the underlying from the account to its own address, so an allowance to the executor is never read on that path.EERC_ALLOWANCE_MISSING — the response carries the token, the spender, the current allowance and the required amount as fields.
The Avalanche executorA deposit and a withdrawal.The executor collects the network-and-service fee, and moves the UGTP product fee as its own transfer, both in the underlying.EXECUTOR_ALLOWANCE_MISSING — the response carries the token, the current allowance and the required amount.

Clear either one the same way: POST /transaction/allowance-setup for the token, then relay what it asks for — a signed EIP-2612 permit through POST /transaction/setup-permit, or an ordinary approve through POST /transaction/setup-approval when the token has no permit. To ask about the CONVERTER rather than the executor, add privateTokenSymbol to the setup call; the spender is then resolved from the registry. Neither endpoint accepts a spender address or an amount from the caller, in either case. A registration and a private transfer need no allowance at all.

How an allowance is granted →

What each operation costs

Every fee on a private operation is charged in the public underlying, never in the private token. An account can therefore hold a large private balance and still be refused for want of the public one, which is the single most common integration surprise and the reason POST /private/quote exists.

OperationRailUGTP feeNetwork feeTaken from
private_registersponsoredNone.None — the platform pays the gas.Nothing is taken.
private_transfersponsoredNone.None — the platform pays the gas.Nothing is taken.
private_depositforward_requestA percentage of the deposited amount, 0.25% by default and configurable per project.Gas plus the static service charge, in the underlying.The public balance, beside the amount being deposited — so a deposit costs amount + fees.
private_withdrawforward_requestA percentage of the withdrawn amount, 0.25% by default and configurable per project.Gas plus the static service charge, in the underlying.The proceeds the withdrawal itself released — so the user receives amount − fees.
  • The product fee on a deposit or a withdrawal is computed from the amount DECODED out of your calldata, not from the amountRaw you declared. Declaring less does not lower the fee; declaring an amount that cannot be decoded is a 400.
  • The fee token is always the private token's own underlying and is not selectable. A withdrawal pays out of what it just released, which only works when the two are the same token.
  • requiredRaw from POST /private/quote is a CEILING, not an estimate. It reserves against the worst-case network fee, because the terms the account signs are capped on that same maximum — quoting the expected figure would enable a send the backend then refuses.
  • Quote before you prove. Building the proof is the expensive part of the flow for your user's device, and prepare cannot price the operation without it; a quote prices the shape instead and answers in one round trip.
  • Gate a button on the quote's sufficient flag rather than on your own comparison: it is measured against the same balance the executor's own validation reads.

Offering a maximum amount

A "deposit everything" control has to pay for itself: the cost comes out of the same public balance the amount does, so proposing the whole balance proposes a figure the fee gate then refuses. Solve amount + productFee(amount) + networkFee ≤ balance rather than subtracting a guessed reserve, and read the rate from the quote (productFeeRaw ÷ the amount you quoted) instead of assuming 0.25% — it is a per-project setting. A withdrawal is the opposite case: its fee comes out of the proceeds, so reserving against it would shrink the amount twice.

The sponsored lane, and its ceilings

Registrations and private transfers answer rail "sponsored": they carry no fee terms, so there is no ugtpFee field to read and nothing for the account to hold. They are signed and submitted exactly like the executor rail. Because they spend platform gas and return nothing, they are also the only private operations with their own ceilings.

  • 30 per day for one account, per project and per network, counting registrations and private transfers together.
  • 120 per hour for the whole project, per network.
  • Both answer 429 with a Retry-After header. Deposits and withdrawals are not counted against either, because they pay their own way.

The walkthrough

Follow the parts in order. Parts 1 and 2 are done once and everything after depends on them; Part 3 is how every operation finishes; Parts 4 to 7 are the four operations themselves. Every step states what it is for, what you send, what comes back, and what to do with it. The code is the working shape, not pseudocode.

Part 1 · Set up, once

Two reads your client does before anything else. Neither costs anything and neither needs a signature.

Step 1 · Read the token registry

What this is for: Get the contract addresses and the two precisions for the token you are about to use.

This is the only place your client learns which private tokens exist and which contracts they live on. Do it at startup and keep the result; never hard-code an address, because a redeployment moves the contracts and this endpoint moves with them.

curl "https://api.ugtp.io/v1/private/tokens?networkId=43114" \
  -H "X-API-Key: $UGTP_API_KEY"

{
  "tokens": [
    {
      "symbol": "esUSDT",
      "displayName": "ES USDT",
      "networkId": "43114",
      "eercAddress": "0x10dFB26576d490FF985f61B4Ec3F8DA65aF7B656",
      "registrarAddress": "0x906FAF1D7AD681c92E37B4843A18c56a56f0c85d",
      "underlyingAddress": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7",
      "underlyingSymbol": "USDT",
      "underlyingDecimals": 6,
      "eercDecimals": 2,
      "iconUrl": "https://assets.extrasafe.chat/crypto/private-tokens/43114/esUSDT/icon.svg?v=2"
    }
  ],
  "count": 4
}

Then: Keep the whole entry for the token the user picked. eercAddress and registrarAddress are call targets, underlyingAddress is the fee token, underlyingDecimals parses what the user typed, eercDecimals limits what they may type. The example is trimmed to one entry; four come back.

Step 2 · Ask the chain whether this address is registered

What this is for: Find out whether this address may hold private tokens yet. The answer decides which flow you run.

There is no UGTP endpoint for this — it is a plain public view on the registrar, needs no key and no signature. Read it before you show the user anything.

import { createPublicClient, http } from "viem";
import { avalanche } from "viem/chains";

const chain = createPublicClient({ chain: avalanche, transport: http(YOUR_RPC_URL) });

const REGISTRAR_ABI = [{
  type: "function",
  name: "isUserRegistered",
  stateMutability: "view",
  inputs: [{ name: "user", type: "address" }],
  outputs: [{ type: "bool" }],
}];

// Keep this as a function: you need it for the account AND for every transfer recipient.
export async function isUserRegistered(address) {
  return await chain.readContract({
    address: token.registrarAddress,   // from Step 1
    abi: REGISTRAR_ABI,
    functionName: "isUserRegistered",
    args: [address],
  });
}

const isRegistered = await isUserRegistered(owner.address);

true    // this address can already hold and receive private tokens
false   // it cannot yet — see Part 4, or bundle the registration into a first deposit (Part 5)

Then: Run the same read for a TRANSFER RECIPIENT too. The contract refuses a private transfer whose recipient is unregistered, and nobody can register on their behalf.

Part 2 · Wire up the encrypted-token SDK

Configure @avalabs/eerc-sdk to produce calldata for UGTP to relay. The examples below capture its transaction requests and adapt its preflight checks to the relayed flow. Reuse these adapters for each private operation.

Step 1 · A wallet that captures instead of sending

What this is for: Get the encoded eERC call out of the SDK rather than letting it broadcast a transaction.

The SDK touches exactly three things on the wallet you give it: account.address, signMessage, and writeContract. Implement those three. writeContract encodes the request and stores it instead of sending, and returns a placeholder hash so the SDK's own method resolves normally.

import { encodeFunctionData, toHex } from "viem";

// owner exposes address and signMessage. The signature must be deterministic
// for the same message: it derives the viewing key. Check your wallet's behavior
// before registering an account; a different signature derives a different key.
export function createCaptureWallet(owner) {
  const captured = [];

  const account = {
    address: owner.address,          // the eERC identity AND the proof's sender
    type: "local",
    signMessage: ({ message }) => owner.signMessage({ message }),
  };

  const wallet = {
    account,
    signMessage: ({ message }) => owner.signMessage({ message }),
    // Called with the request simulateContract returned: { address, abi, functionName, args }.
    async writeContract(request) {
      captured.push({
        to: request.address,
        data: encodeFunctionData({
          abi: request.abi,
          functionName: request.functionName,
          args: request.args ?? [],
        }),
        value: toHex(request.value ?? 0n),
      });
      return "0x" + "0".repeat(64);   // placeholder; nothing was sent
    },
  };

  return { wallet, captured, clear: () => { captured.length = 0; } };
}

// Normally the wallet your user connected — proving runs in their browser, so
// this is the account they already control:
const owner = {
  address: walletClient.account.address,
  signMessage: ({ message }) => walletClient.signMessage({
    account: walletClient.account,
    message,
  }),
};

// A key you hold yourself only makes sense server-side, or in a test:
// import { privateKeyToAccount } from "viem/accounts";
// const owner = privateKeyToAccount(process.env.PRIVATE_KEY);

Then: Everything the SDK "sends" now lands in captured as { to, data, value } — which is exactly the shape POST /private/prepare takes in its calls array. Call clear() before each build so you capture one operation at a time.

Step 2 · Adapt the SDK preflight checks for relaying

What this is for: Stop the SDK from refusing to build calldata for an operation that is perfectly valid on our rail.

Two of the SDK's pre-flight checks assume it is about to broadcast on its own, and both fail in a relayed model. Without this shim deposit() throws before you ever see calldata. Give it a public client whose transport intercepts exactly those two cases and passes everything else through to a real RPC.

import { createPublicClient, custom } from "viem";
import { avalanche } from "viem/chains";

// The four eERC write methods, by selector.
const WRITE_SELECTORS = new Set([
  "0x37729b5c",  // register
  "0x82f09ea2",  // deposit
  "0x2d947610",  // transfer
  "0xa5566e25",  // withdraw
]);

export function createEercReadClient() {
  return createPublicClient({
    chain: avalanche,
    transport: custom({
      async request({ method, params }) {
        const selector =
          method === "eth_call" && typeof params?.[0]?.data === "string"
            ? params[0].data.slice(0, 10)
            : "";

        // 1. deposit() pre-checks the ERC-20 allowance and throws
        //    "Insufficient approval amount!" if it looks short. The allowance is
        //    real, granted ahead of time (Part 5, Steps 1-2) — answer max uint256.
        if (selector === "0xdd62ed3e") return "0x" + "f".repeat(64);

        // 2. The SDK simulates the write before "sending". Simulated in isolation
        //    it reverts, because on our rail the operation runs inside the
        //    executor's batch. Answer success so the SDK proceeds to
        //    writeContract, which is what you capture. Real validity is enforced
        //    on chain when the operation actually executes.
        if (WRITE_SELECTORS.has(selector)) return "0x";

        return await yourRpc(method, params);   // everything else: a real RPC
      },
    }),
  });
}

Then: Only these two cases are intercepted. Every genuine read — registration status, the encrypted balance, the auditor key — goes to the real node untouched.

Step 3 · Construct the SDK

What this is for: Bind the SDK to our contracts, our circuits, and the two objects above.

The constructor is positional, not an options object, and the order matters. isConverter is true on the UGTP deployment: it wraps existing ERC-20s rather than minting a token of its own.

import { EERC } from "@avalabs/eerc-sdk";

// The five circuit pairs, as you serve them from your own origin. Download them from
// the table above, put them under /eerc-circuits/, and these paths work unchanged.
// Two file names break the pattern on purpose: registration and withdraw both end in
// circuit_final.zkey.
const CIRCUIT_URLS = {
  register: { wasm: "/eerc-circuits/registration/registration.wasm",
              zkey: "/eerc-circuits/registration/circuit_final.zkey" },
  mint:     { wasm: "/eerc-circuits/mint/mint.wasm",
              zkey: "/eerc-circuits/mint/mint.zkey" },
  transfer: { wasm: "/eerc-circuits/transfer/transfer.wasm",
              zkey: "/eerc-circuits/transfer/transfer.zkey" },
  withdraw: { wasm: "/eerc-circuits/withdraw/withdraw.wasm",
              zkey: "/eerc-circuits/withdraw/circuit_final.zkey" },
  burn:     { wasm: "/eerc-circuits/burn/burn.wasm",
              zkey: "/eerc-circuits/burn/burn.zkey" },
};

function createSession({ owner, token, decryptionKey }) {
  const client = createEercReadClient();
  const { wallet, captured, clear } = createCaptureWallet(owner);

  const eerc = new EERC(
    client,                     // 1. the read client from Step 2
    wallet,                     // 2. the capture wallet from Step 1
    token.eercAddress,          // 3. the converter,  from the registry
    token.registrarAddress,     // 4. the registrar,  from the registry
    true,                       // 5. isConverter — always true here
    CIRCUIT_URLS,               // 6. your hosted artifacts
    decryptionKey,              // 7. optional: reuse a derived key
  );

  return { eerc, captured, clear };
}

Then: Download the ten files from the table earlier on this page, put them under /eerc-circuits/ on your own origin, and the paths above work as written. They must be the artifacts of this deployment's trusted setup: a proof from a different one is rejected on chain as InvalidProof, and only at the last step, after the user has signed.

Step 4 · Know which amount goes where

What this is for: Avoid the one mistake that is silent: passing the right number in the wrong precision.

The SDK does not take one amount convention. A deposit is denominated in the public token, because on chain it is an ERC-20 transfer of it. A transfer and a withdrawal are denominated in the converter's own 2-decimal precision, because they are measured against a private balance that is held at that precision. The gateway's amountRaw is a third thing again, and it is always the underlying's.

import { parseUnits } from "viem";

const typed = "10.55";                                 // the user may type 2 places, never more

const underlyingRaw = parseUnits(typed, token.underlyingDecimals); // 10550000n  (USDT, 6)
const eercRaw       = parseUnits(typed, token.eercDecimals);       // 1055n      (always 2)

// Building the calldata — the SDK's own convention, per operation:
await eerc.deposit(underlyingRaw, token.underlyingAddress, BigInt(token.eercDecimals));
await eerc.transfer(to, eercRaw, encryptedBalance, decrypted, [auditorKey[0], auditorKey[1]], token.underlyingAddress);
await eerc.withdraw(eercRaw, encryptedBalance, decrypted, [auditorKey[0], auditorKey[1]], token.underlyingAddress);

// Talking to UGTP — always the underlying, and absent entirely for a transfer:
//   private_deposit   → amountRaw: underlyingRaw.toString()
//   private_withdraw  → amountRaw: underlyingRaw.toString()
//   private_transfer  → no amountRaw at all
//   private_register  → no amountRaw at all

Then: A withdrawal therefore carries both numbers in one flow: 1055 goes into the proof, "10550000" goes to the gateway. They describe the same 10.55 — the gateway re-derives its figure from your proof and refuses the request if the two disagree, so this is checked rather than trusted.

Part 3 · The three calls every operation ends with

All four operations finish the same way: sign what prepare returned, submit the signature, poll until it settles. Read this once; Parts 4 to 7 point back at it instead of repeating it.

Step A · Sign the payload prepare returned

What this is for: Turn the payload prepare returned into a signature the executor will accept.

Every prepare answers with a signingPayload. Pass it to eth_signTypedData_v4 unchanged — do not rebuild the domain or reorder the message. Same call on both rails; only primaryType differs (ExecutionRequest for a deposit or withdrawal, SponsoredRequest for a registration or private transfer).

const signature = await walletClient.signTypedData({
  account:     ownerAddress,
  domain:      prepared.signingPayload.domain,
  types:       prepared.signingPayload.types,
  primaryType: prepared.signingPayload.primaryType,
  message:     prepared.signingPayload.message,
});

"0x3fd5…1c"   // 65 bytes of hex

Then: Never attach an EIP-7702 authorization on this network. Avalanche C-Chain has no chain-level delegation, so GET /account/delegation-status reports it as unsupported — that is its steady state, not an error.

Step B · Submit the signature

What this is for: Hand UGTP the signature so it can relay the operation on chain.

The same endpoint every other UGTP operation submits to. Send the operationId prepare gave you and the signature from Step A, and nothing else.

curl -X POST "https://api.ugtp.io/v1/transaction/submit" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operationId": "ugtp_operation_BJL0njW5DcGh",
    "signature": "0x3fd5…1c"
  }'

{
  "operationId": "ugtp_operation_BJL0njW5DcGh",
  "status": "received",
  "userOperationHash": "0x141d4abead5569196b9c3165e040dab50a6a91940a1da44b779a76c5cc01307d",
  "networkId": "43114"
}

Then: Note the field name: on these two rails userOperationHash carries the REQUEST HASH, the same value prepare returned as requestHash. There is no user operation on Avalanche. Store operationId — it is what you poll with, and the only identifier that never changes.

Step C · Poll until it settles

What this is for: Find out whether it actually settled, and stop asking once it has.

Poll by operationId, never by a hash: transactionHash changes if the outer transaction is replaced. Stop at a terminal status.

curl "https://api.ugtp.io/v1/transaction/ugtp_operation_BJL0njW5DcGh" \
  -H "X-API-Key: $UGTP_API_KEY"

{
  "operationId": "ugtp_operation_BJL0njW5DcGh",
  "status": "finalized",
  "transactionHash": "0x5d0827600d4bf987ec907143c2479f1d84e49e92d26bd967411a21cb03246506",
  "userOperationHash": "0x141d4abead5569196b9c3165e040dab50a6a91940a1da44b779a76c5cc01307d",
  "networkId": "43114",
  "replacementCount": 0,
  "reason": null
}

Then: Track unknown → received → queued → submitted → confirmed → finalized. Stop the normal loop at finalized, failed or dropped. Before retrying a dropped operation, inspect reason and canRetry; it is not proof that the original request can never execute. Keep polling active operations instead of preparing replacements.

Part 4 · Activate an account (registration on its own)

Run this when the account has nothing to deposit yet — at onboarding, or because it is about to receive a private token. It moves no asset, costs the account nothing, and needs no allowance and no token holding, so an empty wallet can do it. If the account's first action is a deposit, skip this part: Part 5 bundles the registration in.

Step 1 · Build the registration calldata

What this is for: Produce the one call that binds this address to its encryption key.

register() does three things in one go: it derives the viewing key (one signature from the user), builds the registration proof, and "writes" the call — which your capture wallet turns into calldata. Do not call generateDecryptionKey() first: register() derives it internally, so a pre-call only costs the user a second signature prompt.

const { eerc, captured, clear } = createSession({ owner, token });

clear();
await eerc.register();
const calls = [...captured];

// An account that is ALREADY registered captures nothing: register() returns early.
// That is why Part 1 Step 2 runs first — only offer "Activate" when it is needed.
if (calls.length === 0) return alreadyActive();

[
  {
    "to":    "0x906FAF1D7AD681c92E37B4843A18c56a56f0c85d",   // the registrar
    "data":  "0x37729b5c…",                                   // register(...) + proof
    "value": undefined
  }
]

Then: Send value as "0x0" or omit it to use the API default. A supplied value must be a hexadecimal quantity. One call, aimed at the registrar; if the selector is anything but 0x37729b5c the request is refused before it is signed.

Step 2 · Prepare the operation

What this is for: Have UGTP check the call and hand back the payload to sign.

Send the call. There is no amountRaw on a registration — it moves nothing.

curl -X POST "https://api.ugtp.io/v1/private/prepare" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ownerAddress": "0x8cf41f02ea9ade6ec7bb4639fd3f13cdff6efe47",
    "networkId": "43114",
    "tokenSymbol": "esUSDT",
    "operationKind": "private_register",
    "calls": [
      { "to": "0x906FAF1D7AD681c92E37B4843A18c56a56f0c85d", "data": "0x37729b5c…", "value": "0x0" }
    ]
  }'

{
  "operationId": "ugtp_operation_OLPsVDhbAeKF",
  "rail": "sponsored",
  "requestHash": "0x29694be930b9b7c3ee21f6d1d35f7a4bf63e8f6b74daedf3775e317fd84a2412",
  "signingPayload": {
    "domain": { "name": "UGTPAvalancheExecutor", "version": "1", "chainId": 43114,
                "verifyingContract": "0xA7C8776C6ac6f21eC923321793F62A4887f9E767" },
    "types": {
      "SponsoredRequest": [
        { "name": "account",    "type": "address" },
        { "name": "nonce",      "type": "uint256" },
        { "name": "privates",   "type": "Private[]" },
        { "name": "validUntil", "type": "uint48" }
      ],
      "Private": [
        { "name": "kind",             "type": "uint8" },
        { "name": "eercContract",     "type": "address" },
        { "name": "token",            "type": "address" },
        { "name": "amount",           "type": "uint256" },
        { "name": "proofAndCalldata", "type": "bytes" }
      ]
    },
    "primaryType": "SponsoredRequest",
    "message": {
      "account": "0x8cf41f02ea9ade6ec7bb4639fd3f13cdff6efe47",
      "nonce": "0",
      "privates": [
        { "kind": 0, "eercContract": "0x906FAF1D…", "token": "0x0000…0000",
          "amount": "0", "proofAndCalldata": "0x37729b5c…" }
      ],
      "validUntil": 1788357919
    }
  },
  "token": { "symbol": "esUSDT", "…": "the rest of the registry entry" },
  "productFee": { "kind": "none", "asset": "underlying", "feeRaw": "0" }
}

Then: Two things to read. rail is "sponsored", so there is no ugtpFee key at all — treat it as absent, not as zero. validUntil is about five minutes out: if the user takes longer to sign, prepare again rather than submitting a stale payload.

Step 3 · Sign, submit, poll

What this is for: Get the activation on chain.

Exactly the three calls in Part 3, with no differences.

const signature = await signTypedData(prepared.signingPayload);
await post("/transaction/submit", { operationId: prepared.operationId, signature });
const settled = await track(prepared.operationId);   // → status "finalized"

Then: Once it is finalized, isUserRegistered returns true for this address forever. A registration can never be repeated for the same address, and an address whose key is lost has to be replaced rather than re-registered.

Part 5 · Make a public token private (deposit)

The account spends its public token and receives the private one 1:1. This is the flow with the most preconditions, so it is stated in full: two allowances, a quote, and — on a first-ever deposit — a registration riding in the same request.

Step 1 · Check the converter allowance

What this is for: Let the converter pull the tokens this deposit will wrap.

The encrypted-token contract pulls the underlying from the account to its own address, so it — not the executor — is the spender here. Ask what this token needs; you never choose the spender or the amount. privateTokenSymbol is what selects the converter instead of the executor.

curl -X POST "https://api.ugtp.io/v1/transaction/allowance-setup" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "networkId": "43114",
    "ownerAddress": "0x97514588f9ada381050763ed59109fad5d861920",
    "tokenAddress": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7",
    "privateTokenSymbol": "esUSDT"
  }'

{
  "kind": "permit",
  "domain": { "name": "TetherToken", "version": "1", "chainId": 43114, "verifyingContract": "0x9702230A…" },
  "types": { "Permit": [ { "name": "owner", "type": "address" }, { "name": "spender", "type": "address" },
                         { "name": "value", "type": "uint256" }, { "name": "nonce", "type": "uint256" },
                         { "name": "deadline", "type": "uint256" } ] },
  "primaryType": "Permit",
  "message": { "owner": "0x97514588…", "spender": "0x10dFB265…", "value": "115792…639935",
               "nonce": "0", "deadline": 1790000000 },
  "deadline": 1790000000
}

Then: On "permit": sign the typed data and POST it to /transaction/setup-permit as { networkId, ownerAddress, tokenAddress, deadline, signature, privateTokenSymbol } — it answers { transactionHash, status, allowance }. On "approve": have the account sign an ordinary approve transaction and POST its raw bytes to /transaction/setup-approval as { networkId, rawTransaction } — it answers { fundingTransactionHash, approvalTransactionHash, approvalStatus, allowance }. Prefer permit: it needs no native coin at all.

Step 2 · Check the executor allowance

What this is for: Let the executor collect the fee for this deposit.

A second, different allowance: the executor is what collects the fee, in the same public token. Same endpoint, with privateTokenSymbol left out — that is the only difference, and it is what switches the spender to the executor.

curl -X POST "https://api.ugtp.io/v1/transaction/allowance-setup" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "networkId": "43114",
    "ownerAddress": "0x97514588f9ada381050763ed59109fad5d861920",
    "tokenAddress": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7"
  }'

// Same two shapes as Step 1 — "permit" or "approve" — but the spender is now
// the executor 0xA7C8776C6ac6f21eC923321793F62A4887f9E767.

Then: Both allowances apply to the account, token, spender and network. Reuse them while sufficient. Prepare reports EERC_ALLOWANCE_MISSING for the converter or EXECUTOR_ALLOWANCE_MISSING for the executor; inspect details.token, allowance and required. The converter error also includes details.spender. Funded approval setup has its own limits.

Step 3 · Quote the deposit before building a proof

What this is for: Find out whether the account can afford the deposit before the device spends effort proving it.

Both fees are taken in the public token, so an account rich in the private one can still be refused. amountRaw is in the underlying's base units — 1.00 USDT at 6 decimals is "1000000".

curl -X POST "https://api.ugtp.io/v1/private/quote" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ownerAddress": "0x97514588f9ada381050763ed59109fad5d861920",
    "networkId": "43114",
    "tokenSymbol": "esUSDT",
    "operationKind": "private_deposit",
    "amountRaw": "1000000"
  }'

{
  "feeToken": { "address": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", "symbol": "USDT", "decimals": 6 },
  "productFeeRaw": "2500",
  "networkFeeRaw": "52000",
  "requiredRaw": "1054500",
  "balanceRaw": "5000000",
  "sufficient": true
}

Then: Gate your button on sufficient. requiredRaw is a ceiling — it reserves against the worst-case network fee, because the terms the account signs are capped on that same maximum. If you offer a "deposit everything" control, solve amount + productFee(amount) + networkFee ≤ balance rather than subtracting a guessed reserve, and read the rate as productFeeRaw ÷ the amount you quoted instead of assuming 0.25%.

Step 4 · Build the deposit calldata

What this is for: Produce the deposit call — and the registration in front of it, if this is the account's first ever.

Two captures in one function. register() first: it returns early and captures nothing when the account is already active, so the same code serves both cases. Then the deposit itself, in the underlying's units.

const { eerc, captured, clear } = createSession({ owner, token });

// 1. Registration, if needed. Captures nothing when the account is already registered —
//    the converter is onlyIfUserRegistered, so a first-timer MUST carry this.
clear();
await eerc.register();
const registerCalls = [...captured];

// 2. The deposit. amountRaw here is in the UNDERLYING's decimals (Part 2, Step 4).
clear();
await eerc.deposit(underlyingRaw, token.underlyingAddress, BigInt(token.eercDecimals));

const calls = [...registerCalls, ...captured];   // 1 call, or 2 with registration first

[
  { "to": "0x906FAF1D…", "data": "0x37729b5c…" },   // registration, FIRST
  { "to": "0x10dFB265…", "data": "0x82f09ea2…" }    // the deposit
]

Then: Never add an approve to this array — the executor runs typed actions and has no slot for one, which is what Steps 1 and 2 are for. Use one deposit call, or a registration followed by the deposit. Registration must come first. The amount inside the calldata is re-decoded server-side and the decoded figure is what you are charged on, so a smaller amountRaw changes nothing.

Step 5 · Prepare the deposit

What this is for: Have UGTP check the calls, price them, and hand back the payload to sign.

Same endpoint as a registration, different operationKind, and this time amountRaw is required — in the underlying's units, matching what you encoded.

curl -X POST "https://api.ugtp.io/v1/private/prepare" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ownerAddress": "0x97514588f9ada381050763ed59109fad5d861920",
    "networkId": "43114",
    "tokenSymbol": "esUSDT",
    "operationKind": "private_deposit",
    "amountRaw": "1000000",
    "calls": [
      { "to": "0x10dFB26576d490FF985f61B4Ec3F8DA65aF7B656", "data": "0x82f09ea2…", "value": "0x0" }
    ]
  }'

{
  "operationId": "ugtp_operation__tFU-ye2x-Y1",
  "rail": "forward_request",
  "requestHash": "0x91e55a96f4c8b7d75f2daa0463539df2c271fc5d76bd884606aa8bf52cb134a2",
  "signingPayload": {
    "domain": { "name": "UGTPAvalancheExecutor", "version": "1", "chainId": 43114,
                "verifyingContract": "0xA7C8776C6ac6f21eC923321793F62A4887f9E767" },
    "types": { "ExecutionRequest": [ "…" ], "Private": [ "…" ], "Send": [ "…" ], "Terms": [ "…" ] },
    "primaryType": "ExecutionRequest",
    "message": {
      "account": "0x97514588f9ada381050763ed59109fad5d861920",
      "nonce": "2",
      "sends":    [ { "token": "0x9702230A…", "recipient": "0x…", "amount": "2500" } ],
      "swaps":    [],
      "bridges":  [],
      "privates": [ { "kind": 1, "eercContract": "0x10dFB265…", "token": "0x9702230A…",
                      "amount": "1000000", "proofAndCalldata": "0x82f09ea2…" } ],
      "terms": { "feeToken": "0x9702230A…", "maxFeeTokenAmount": "52024", "validUntil": 1788169477, "…": "…" }
    }
  },
  "ugtpFee": { "tokenAmount": "52024", "expectedTokenAmount": "51415", "feeToken": "0x9702230A…" },
  "productFee": { "kind": "bps", "asset": "underlying", "feeRaw": "2500",
                  "recipient": "0x…", "tokenAddress": "0x9702230A…" },
  "token": { "symbol": "esUSDT", "…": "the rest of the registry entry" }
}

Then: rail is "forward_request" and ugtpFee is present here. Show expectedTokenAmount to the user and reserve against tokenAmount — the second is the signed ceiling, the first is what usually settles. productFee.feeRaw is our cut, riding as the "sends" entry visible in the message.

Step 6 · Sign, submit, poll

What this is for: Get the deposit on chain.

The three calls from Part 3, unchanged.

const signature = await signTypedData(prepared.signingPayload);
await post("/transaction/submit", { operationId: prepared.operationId, signature });
const settled = await track(prepared.operationId);

Then: The public balance decreases by the converted amount plus fees, and the private balance increases by the converted amount. Anything below one unit of eercDecimals is returned as dust rather than converted — clamp the amount field so this cannot happen.

Part 6 · Send privately (transfer)

Moves the private token between two registered accounts with the amount hidden. It costs the account nothing and needs no allowance — but the proof needs three things read from chain first, and two preconditions the other flows do not have.

Step 1 · Read the balance and the auditor key

What this is for: Gather the three inputs a transfer proof cannot be built without.

A transfer proof proves the remaining balance is non-negative, so it takes the current encrypted balance, its decrypted value, and the auditor's public key. This same code reads the balance you display to the user.

// Read the encrypted balance and decrypt it LOCALLY. Needed to display a private
// balance, and needed again as an input to a transfer or a withdrawal proof.
const BALANCE_ABI = [{
  type: "function",
  name: "getBalanceFromTokenAddress",
  stateMutability: "view",
  inputs: [{ name: "user", type: "address" }, { name: "tokenAddress", type: "address" }],
  outputs: [
    { name: "eGCT", type: "tuple", components: [
      { name: "c1", type: "tuple", components: [{ name: "x", type: "uint256" }, { name: "y", type: "uint256" }] },
      { name: "c2", type: "tuple", components: [{ name: "x", type: "uint256" }, { name: "y", type: "uint256" }] },
    ]},
    { name: "nonce", type: "uint256" },
    { name: "amountPCTs", type: "tuple[]", components: [
      { name: "pct", type: "uint256[7]" }, { name: "index", type: "uint256" },
    ]},
    { name: "balancePCT", type: "uint256[7]" },
    { name: "transactionIndex", type: "uint256" },
  ],
}];

async function readPrivateBalance({ eerc, client, owner, token }) {
  await eerc.generateDecryptionKey();           // one signature; the key stays in memory

  const [eGCT, , amountPCTs, balancePCT] = await client.readContract({
    address: token.eercAddress,
    abi: BALANCE_ABI,
    functionName: "getBalanceFromTokenAddress",
    args: [owner.address, token.underlyingAddress],
  });

  // The only place the plaintext ever exists. In the token's eERC precision (2 dp).
  const decrypted = eerc.calculateTotalBalance(eGCT, amountPCTs, balancePCT);

  // The flattened ElGamal ciphertext the transfer/withdraw proofs take as input.
  const encryptedBalance = [eGCT.c1.x, eGCT.c1.y, eGCT.c2.x, eGCT.c2.y];

  return { decrypted, encryptedBalance };
}

// The auditor's public key — both proofs encrypt the amount to it as well.
const AUDITOR_ABI = [{
  type: "function", name: "auditorPublicKey", stateMutability: "view",
  inputs: [], outputs: [{ name: "x", type: "uint256" }, { name: "y", type: "uint256" }],
}];

// A function, because Part 7 needs it too.
export async function readAuditorKey(client, token) {
  return await client.readContract({
    address: token.eercAddress,
    abi: AUDITOR_ABI,
    functionName: "auditorPublicKey",
  });
}

const auditorKey = await readAuditorKey(client, token);

Then: decrypted is in the converter's 2-decimal precision — the same scale you pass to eerc.transfer, and the same scale you compare the user's amount against. Do not rescale it to the underlying.

Step 2 · Check the recipient, and the sender's own balance

What this is for: Rule out the two things that make a transfer revert on chain instead of failing politely.

The contract refuses a transfer to an unregistered recipient, and nobody can register on their behalf. And the circuit can only prove a non-negative remainder, so over-sending does not fail politely — it reverts, and all the backend can report afterwards is a failed simulation.

// Do both BEFORE prompting for a signature.
if (!(await isUserRegistered(recipientAddress))) return showInviteRecipient();
if (eercRaw > decrypted) return showInsufficientPrivateBalance();

Then: If the recipient is not registered there is nothing to prepare: the only way forward is getting them to run Part 4. Check when the user picks a recipient, not at submit.

Step 3 · Build the transfer calldata

What this is for: Produce the proof that moves the amount without disclosing it.

Note the amount: eercRaw, in the converter's 2-decimal precision — not the underlying's. The underlying address is still passed, but only to identify which token inside the converter is moving.

clear();
await eerc.transfer(
  recipientAddress,        // their owner EOA — their eERC identity
  eercRaw,                 // 2-decimal precision, e.g. 1055n for 10.55
  encryptedBalance,        // from Step 1
  decrypted,               // from Step 1
  [auditorKey[0], auditorKey[1]],
  token.underlyingAddress, // identifies the token inside the converter
);
const calls = [...captured];

[
  { "to": "0x10dFB265…", "data": "0x2d947610…" }   // ~1572 bytes: ciphertext + proof
]

Then: The transfer amount is protected by the encrypted calldata and proof. Omit the plaintext amount from the API request, and keep it out of application logs and analytics.

Step 4 · Prepare the transfer

What this is for: Have UGTP wrap the proof into a request to sign, without ever learning the amount.

The one request that carries no amount. amountRaw must be omitted — sending it is a 400, and the amount never reaches UGTP and is never stored.

curl -X POST "https://api.ugtp.io/v1/private/prepare" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ownerAddress": "0xe6cd7933282709399a31d035638c8bd2a805918d",
    "networkId": "43114",
    "tokenSymbol": "esUSDT",
    "operationKind": "private_transfer",
    "calls": [
      { "to": "0x10dFB26576d490FF985f61B4Ec3F8DA65aF7B656", "data": "0x2d947610…", "value": "0x0" }
    ]
  }'

{
  "operationId": "ugtp_operation__jcUxmgBDnva",
  "rail": "sponsored",
  "requestHash": "0x20623579dade316efc1a739e0d5c0c8c66f5a7ed335b2434dc77dc9a1a4b4157",
  "signingPayload": {
    "domain": { "…": "as in Part 4" },
    "primaryType": "SponsoredRequest",
    "message": {
      "account": "0xe6cd7933282709399a31d035638c8bd2a805918d",
      "nonce": "0",
      "privates": [
        { "kind": 2, "eercContract": "0x10dFB265…", "token": "0x0000…0000",
          "amount": "0", "proofAndCalldata": "0x2d947610…" }
      ],
      "validUntil": 1788250577
    }
  },
  "token": { "symbol": "esUSDT", "…": "the rest of the registry entry" },
  "productFee": { "kind": "none", "asset": "underlying", "feeRaw": "0" }
}

Then: token is the zero address and amount is "0" in the signed message — on this path there is no public amount and no public token to name. That is the shape, not a placeholder to fill in. Finish with the three calls from Part 3.

Part 7 · Make it public again (withdraw)

Unwraps the private token back into the public one. It reads the same three inputs a transfer does, and it is the one flow where the same amount is expressed two different ways in the same request — once in the proof, once for UGTP.

Step 1 · Read, then build the withdrawal calldata

What this is for: Produce the proof that releases the public token.

The reads are identical to Part 6 Step 1. The withdrawal amount goes into the proof in the converter's 2-decimal precision. No converter allowance is needed here — nothing is pulled from the account — but the executor's still is, for the fee.

const { decrypted, encryptedBalance } = await readPrivateBalance({ eerc, client, owner, token });
const auditorKey = await readAuditorKey(client, token);

clear();
await eerc.withdraw(
  eercRaw,                 // 2-decimal precision, e.g. 1055n for 10.55
  encryptedBalance,
  decrypted,
  [auditorKey[0], auditorKey[1]],
  token.underlyingAddress,
);
const calls = [...captured];

[
  { "to": "0x10dFB265…", "data": "0xa5566e25…" }   // ~1280 bytes: proof + calldata
]

Then: You now hold two numbers for the same amount: 1055 went into the proof, and "10550000" is what the next call sends. The gateway re-derives its figure from your proof and refuses the request if they disagree.

Step 2 · Quote and prepare

What this is for: Have UGTP price the unwrap and hand back the payload to sign.

Quote first, exactly as for a deposit. Then prepare with operationKind private_withdraw and amountRaw in the underlying's units.

curl -X POST "https://api.ugtp.io/v1/private/prepare" \
  -H "X-API-Key: $UGTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ownerAddress": "0x098afb6194f54a1ea0097fca5c9593b5ab81348e",
    "networkId": "43114",
    "tokenSymbol": "esUSDT",
    "operationKind": "private_withdraw",
    "amountRaw": "200000",
    "calls": [
      { "to": "0x10dFB26576d490FF985f61B4Ec3F8DA65aF7B656", "data": "0xa5566e25…", "value": "0x0" }
    ]
  }'

{
  "operationId": "ugtp_operation_BJL0njW5DcGh",
  "rail": "forward_request",
  "requestHash": "0x141d4abead5569196b9c3165e040dab50a6a91940a1da44b779a76c5cc01307d",
  "signingPayload": { "primaryType": "ExecutionRequest", "…": "as in Part 5" },
  "ugtpFee": { "tokenAmount": "55304", "expectedTokenAmount": "54671", "feeToken": "0x9702230A…" },
  "productFee": { "kind": "bps", "asset": "underlying", "feeRaw": "500",
                  "recipient": "0x…", "tokenAddress": "0x9702230A…" },
  "token": { "symbol": "esUSDT", "…": "the rest of the registry entry" }
}

Then: Show the user the net: they part with the amount in private form and receive amount − fees in public form. A "withdraw everything" control should take the whole private balance with nothing held back — reserving against a fee that comes out of the proceeds would shrink the amount twice. Then finish with the three calls from Part 3.

What to keep per operation: operationId (the key for everything afterwards), requestHash, operationKind, the private token's symbol, and the last status you saw. What not to keep: a private transfer's amount. It is not in the request, it is not in any response, and a client that stores it locally has undone the point of the feature — render a pending transfer as masked, the same way its settled row will read.

Every status, and what it asks of you →

Precision, dust and over-sending

The converter holds balances at its own precision — eercDecimals, which is 2 on this deployment for every token — rather than at the underlying's. Everything surprising about amounts follows from that one fact.

  • A deposit converts only whole multiples of that precision and returns the remainder as dust. It is credited back, never lost, but it does not become private: a user who deposits 0.005 of a stablecoin gets nothing private and their money back.
  • What the precision COSTS depends on the underlying's price, not its decimals. 0.01 of a dollar stablecoin is a cent; 0.01 WAVAX is a visible amount and rises with the market.
  • Clamp the amount field to eercDecimals on the deposit screen as well as the send screen, so a user cannot enter a figure that silently becomes dust and leaves them with an unspendable zero balance.
  • Check the private balance before building a transfer proof. The circuit can only prove a non-negative remainder, so an over-send does not fail politely — it reverts on chain, and the only thing your client can report afterwards is a failed simulation.

What is hidden, and what is not

Be precise with your users about what this hides, because it hides amounts rather than participants, and it is not unconditional.

  • Public by nature: both addresses of a private transfer, which the contract indexes in its event, and the amounts of deposits and withdrawals, which have a public leg on chain by definition.
  • Hidden on chain: every private balance, and the amount of a private transfer. UGTP never receives a transfer amount — the request has no field for it — and never stores one.
  • Held by your client: the key that decrypts a balance. It is derived from the owner EOA's signature, stays on the user's device, and cannot be recovered by UGTP.
  • An audit key exists, and the standard requires it: the contract refuses to operate without one. A private transfer and a withdrawal each emit their amount encrypted to that key alongside the event; a deposit does not need to, because its amount is public already. UGTP keeps the key offline and uses it only for a lawful request. The honest statement to your users is that amounts are hidden on chain and an audit key is held offline — not that no one can ever read them.

Refusals you will meet

CodeStatusWhat happenedWhat to do
EERC_ALLOWANCE_MISSING400The converter has not been approved to pull the deposit.Run the allowance setup for the spender the response names, then prepare again.
EXECUTOR_ALLOWANCE_MISSING400The executor has not been approved to collect the fee.Run the allowance setup for the token the response names, then prepare again.
INSUFFICIENT_TOKEN_BALANCE400The public underlying does not cover the operation and its fees.Quote first; the response names the token and the shortfall.
PRIVATE_CALL_WOULD_REVERT400A sponsored call was simulated and would revert — a spent proof, a malformed one, or an unregistered party.Rebuild the proof against fresh state. Do not retry the same bytes.
PRIVATE_KIND_NOT_SPONSORABLE400That kind is not armed for the free lane on this contract.Not retryable by the client. Report it.
PRIVATE_REGISTER_SELECTOR_MISMATCH400A registration's calldata carries a different function.Encode the registrar's register overload listed above.
PRIVATE_METADATA_MISMATCH400The declared amount or token does not match what the calldata encodes.Send the same figure you encoded, in the underlying's base units.
PRIVATE_CALLDATA_UNDECODABLE400The calldata does not decode as the overload this rail relays.Encode the no-message overload.
PRIVATE_QUOTE_RAIL_UNSUPPORTED409The project's operations do not travel the executor rail.Not retryable. Private tokens require an executor-rail project.
PRIVATE_TOKEN_NOT_FOUND404The symbol names no active private token on that network. This is the code a quote answers with; a prepare answers the same status.Re-read GET /private/tokens; the set is per network and can change.
PRIVATE_UNKNOWN_TOKEN_ID400A withdrawal named a token id the converter has never bound.Read the id from the converter's tokenIds(address) for the underlying; zero means nothing has been deposited for it yet.

Every refusal on this path is JSON. A plain one is { "error": "<message>" }; a coded one adds the fields a client acts on: { "error", "code", "reason"?, "remediation"?, "details"? }. An allowance refusal carries remediation "allowance_setup" and a details object naming the token, the spender and the shortfall — branch on code, never on the message text.

The shared error and retry rules →

Before you go live

  • The registry is read from GET /private/tokens at startup and cached, and no contract address is compiled into the client.
  • The five circuit artifact pairs are hosted on your own origin and served to the browser that proves.
  • Registration state is read from the registrar for the account AND for any transfer recipient, before a proof is built.
  • A first deposit prepends its registration to the same request; a standalone activation is used only when nothing is being deposited.
  • Both allowances are handled: the converter's before a first deposit, the executor's before a first deposit or withdrawal.
  • Every amount field is clamped to eercDecimals, and a "deposit everything" control solves for the fee rather than guessing a reserve.
  • POST /private/quote gates the submit button, and requiredRaw is treated as a ceiling.
  • The response's rail field is what your client branches on — never a network table of your own.
  • amountRaw is absent from every private_transfer request your client can produce, and no transfer amount is logged or persisted.
  • Operations are polled by operationId to a terminal status, and 429 on the sponsored lane is retried against Retry-After.
The private-token endpoint reference →