UPP — Universal Private PoolSDK Reference

Fee System

Pluggable per-operation fees collected by an external FeeManager contract — EIP-3009 self-pay, relayer transferFrom, configurable per FeeKind. Pre-audit, all live Sepolia amounts are zero.

UPP collects per-operation fees through a separate FeeManager contract, not the pool itself. The pool calls feeManager.collectFee(kind, auth, payer) once per protocol op; the manager looks up the amount for that FeeKind and routes payment through EIP-3009 (user self-pays) or transferFrom (relayer pays). All amounts default to zero and are tuned per-deployment by an admin role.

Pre-audit, all live amounts are zero

The FeeManager constructor does not seed any default amounts. Every UPP deployment on Sepolia today has every FeeKind set to 0, which means collectFee silently no-ops on every operation. The schedule below (1transfer/1 transfer / 5 STARK transfer / etc.) is a draft for production tuning, not what is charged today. Read the live amount via feeManager.feeAmount(kind) before assuming.

Architecture

UniversalPrivatePoolshield · transfer · withdraw …UPPSwapModuleSwapFill onlyother COLLECTORsfuture modulesFeeManagercollectFee(kind, auth, payer)_feeConfig per FeeKind_accumulatedFeesADMIN_ROLE · COLLECTOR_ROLEfeeToken (ERC20)UPD or whatever admin setspayer walletEIP-3009 or transferFromtreasurywithdrawAccumulatedFees(to)

Splitting the manager out of the pool was forced by EIP-170: the pool runtime is already at +134 bytes headroom under the 24,576-byte limit. Fee logic ships separately and is reachable from any contract granted COLLECTOR_ROLE.

FeeKind enum

Stable ABI enum — values are part of the surface. New kinds are appended only.

ValueFeeKindCharged onNotes
0Shieldshield, shieldPermit, shieldPermitRelay, shieldSTARK, shieldSTARKV4One fee per deposit, both rails
1Unshieldwithdraw, withdrawSTARK, withdrawSTARKV4Charged only on non-ragequit exits
2SnarkTransfertransfer, joinSplit, mergeTransfer2x2, mergeTransfer4x2All SNARK transfers route here — there is no separate JoinSplit fee
3SnarkMergemergeSNARK merge only
4StarkTransfertransferSTARK, transferSTARKV4, transferSTARKV4CommitAtomic V3, atomic V4, AND V4 commit-path all charge once at proof time
5StarkMerge(reserved)No STARK merge contract entry exists yet
6SwapFillSwap module fillCharged to the filler, not the maker

Two operations that look like they should charge but don't:

  • flushPendingV4Inserts(maxN) — anyone-callable, takes no FeeAuth, pays only its own gas. The fee for that operation was already collected at commit time.
  • Ragequit — the pool sets recipient = origin and skips the ASP check; the _collectFee(Unshield, …) path is structurally there but at zero amount today every ragequit is free. Whether ragequit gets a non-zero fee in production is a tuning decision, not a code change.

FeeAuth — the per-call argument

Every fee-bearing pool entry point takes a trailing FeeAuth argument. The struct mirrors the EIP-3009 fields plus a discriminator:

struct FeeAuth {
    address from;        // 0x0 = relayer path (transferFrom payer = msg.sender)
    uint256 validAfter;  // EIP-3009 valid window
    uint256 validBefore;
    bytes32 nonce;       // EIP-3009 single-use nonce
    uint8 v; bytes32 r; bytes32 s;  // EIP-712 signature over the auth
}

The encoders accept the same shape in TypeScript and default it to RELAYER_FEE_AUTH (an all-zero sentinel) so callers who don't need a fee can omit it entirely:

import { encodeShieldTx, RELAYER_FEE_AUTH } from '@permissionless-technologies/upp-sdk'

// Implicit relayer / no-fee path
const tx = encodeShieldTx({ token, amount, ownerHash, blinding, encryptedNote })

// Explicit relayer path (same effect)
const tx2 = encodeShieldTx({ … }, RELAYER_FEE_AUTH)

// User self-pay via EIP-3009 signature
const tx3 = encodeShieldTx({ … }, {
  from: userAddress,
  validAfter: 0n,
  validBefore: BigInt(Math.floor(Date.now() / 1000) + 600),
  nonce: '0x…',
  v, r, s,
})

RELAYER_FEE_AUTH is a sentinel: when auth.from == address(0) the FeeManager skips EIP-3009 and pulls funds from payer via transferFrom. The pool passes msg.sender as payer, so the relayer path requires the relayer to hold a standing approval on the fee token to the FeeManager.

Two payment paths

Path 1 — Self-pay (EIP-3009)

The user signs a one-time EIP-3009 receiveWithAuthorization for the exact fee amount. The pool forwards the signature to the FeeManager, which calls feeToken.receiveWithAuthorization(from, address(this), required, …). The user keeps full control:

  • No standing approval — every payment requires a fresh signature
  • Exact amount — manager hardcodes value == required (the configured fee), no overcharge possible
  • Single-use nonce — replay-safe
  • Time-bound — expires per validBefore
  • Front-run safe — the manager hardcodes to == address(this), so the auth can only credit the fee account

This is the recommended path when the user has a public wallet relationship anyway (deposits, ragequits, anything UI-driven).

Path 2 — Relayer pays

For fully private transfers via a relayer, the relayer fronts the fee using a standing approve to the FeeManager. The user passes RELAYER_FEE_AUTH (the all-zero sentinel) and the manager pulls from the caller via transferFrom. Reimbursement to the relayer happens out-of-band (or in a future bundled tx) — the contracts don't bundle them today.

Path 3 — Note-based (future)

Paying fees out of a shielded note (so there's no public wallet link at all) is on the roadmap. It would compose a small withdraw proof to the FeeManager into the same tx as the main operation. Not implemented today.

Encoders + FeeAuth

Every encoder accepts an optional trailing feeAuth: FeeAuthInput argument; omitted = RELAYER_FEE_AUTH.

import {
  encodeShieldTx,
  encodeTransferTx,
  encodeWithdrawTx,
  encodeMergeTx,
  encodeShieldSTARKV4Tx,
  encodeTransferSTARKV4CommitTx,
  encodeWithdrawSTARKV4Tx,
  encodeSwapFillTx,
  RELAYER_FEE_AUTH,
  type FeeAuthInput,
} from '@permissionless-technologies/upp-sdk'

// Same signature shape for every encoder:
//   encodeXxxTx(input, feeAuth?)  →  { abi, functionName, args }

The encode*FromBuildData convenience wrappers used by the React hooks accept the same optional feeAuth:

import {
  encodeShieldTxFromBuildData,
  encodeTransferTxFromBuildData,
  encodeWithdrawTxFromBuildData,
} from '@permissionless-technologies/upp-sdk'

const tx = encodeTransferTxFromBuildData(buildData, feeAuth)

The atomic encodeTransferSTARKV4Tx exists for completeness but won't submit through public RPCs because of the 16.77M per-tx gas cap. Use the commit/flush split instead — encodeTransferSTARKV4CommitTx charges the StarkTransfer fee at commit time, and encodeFlushPendingV4InsertsTx is free.

Admin surface

Tuning fees is a single-setter on the FeeManager, gated by ADMIN_ROLE:

function setFeeAmount(IFeeManager.FeeKind kind, uint96 newAmount) external;  // ADMIN_ROLE
function setFeeToken(address newToken) external;                              // ADMIN_ROLE
function withdrawAccumulatedFees(address to) external;                        // ADMIN_ROLE

uint96 is sufficient for ~79B tokens with 18 decimals — comfortable for flat fees.

// viem example — set the SNARK transfer fee to $1 UPD
await walletClient.writeContract({
  address: feeManagerAddress,
  abi: FEE_MANAGER_ABI,
  functionName: 'setFeeAmount',
  args: [2 /* FeeKind.SnarkTransfer */, parseUnits('1', 18)],
})

// Set the fee token
await walletClient.writeContract({
  address: feeManagerAddress,
  abi: FEE_MANAGER_ABI,
  functionName: 'setFeeToken',
  args: [UPD_TOKEN_ADDRESS],
})

// Drain accumulated fees to the treasury
await walletClient.writeContract({
  address: feeManagerAddress,
  abi: FEE_MANAGER_ABI,
  functionName: 'withdrawAccumulatedFees',
  args: [treasuryAddress],
})

Two role separations matter:

  • DEFAULT_ADMIN_ROLE — grants/revokes other roles. Held by the deployer at boot; should be transferred to a multisig or DAO.
  • ADMIN_ROLE — tunes fees, withdraws balances. Separated from the role-granting power so a fee-tuner key can be hotter than the role admin key.
  • COLLECTOR_ROLE — must be granted to every contract that calls collectFee. Today that's the pool proxy and the swap module; new modules need explicit grants.

Reading config

import { createPublicClient, parseAbi } from 'viem'

const feeManager = { address: feeManagerAddress, abi: FEE_MANAGER_ABI } as const

// Single value
const transferFee = await publicClient.readContract({
  ...feeManager,
  functionName: 'feeAmount',
  args: [2 /* FeeKind.SnarkTransfer */],
})

// Full config struct (one RPC call)
const config = await publicClient.readContract({
  ...feeManager,
  functionName: 'getFeeConfig',
})
// → { feeTokenAddr, shieldFee, unshieldFee,
//     snarkTransferFee, snarkMergeFee,
//     starkTransferFee, starkMergeFee, swapFillFee }

feeAmount(kind) returns the uint96 for a single kind; getFeeConfig() returns the whole packed struct (useful for an admin UI in one RPC).

Proposed production schedule

These numbers are not on-chain anywhere yet. They're the target distribution we'd ship with — STARK is 5× SNARK because the verification gas is ~50× higher.

FeeKindProposed amount
Shield, Unshield$0
SnarkTransfer$1 UPD
SnarkMerge$0.50 UPD
StarkTransfer$5 UPD
StarkMerge$2.50 UPD
SwapFill$1 UPD
Ragequitalways free (structurally)

Flat — not percentage. A 10Mtransfercoststhesameasa10M transfer costs the same as a 100 transfer. That's intentional: percentage-based fees on a privacy pool create a small but real incentive to break large amounts into smaller transactions, which fragments the anonymity set against itself.

See also

On this page