Infrastructure for turning fees collected in many assets into a single chosen token.
Guides for operators and searchers are in the Uniswap developer docs.
Derivative notice This repository is a derivative of Uniswap/protocol-fees at the
unification-proposalref, pruned to the TJAR scope — the Token Jar and Releaser contracts and their supporting base/interfaces.Everything specific to the Uniswap fee rollout — the V3 fee adapter, Merkle generator, deployers, deploy scripts, UNI vesting, the EAS agreement-anchor signing flow, the governance proposal, and the Firepit burn releasers (
Firepit,OptimismBridgedResourceFirepit) — has been removed. For the canonical Uniswap deployment, governance proposal, fee adapters, and Firepits, see the upstream repository.
The Token Jar + Releaser pattern is a reusable primitive: an immutable contract (the Token Jar) that accumulates arbitrary assets from any number of fee sources, and a Releaser that lets anyone atomically redeem those accumulated assets in exchange for a single chosen token (the resource).
While it originated as Uniswap's protocol-fee mechanism (accumulate fees → redeem for UNI → burn the UNI), the pattern is broadly useful beyond Uniswap. Any system that collects fees in heterogeneous assets and wants to convert them into a single token — whether to burn it, distribute it, or route it to a treasury — can use this infrastructure unchanged. The Token Jar is agnostic about where assets come from; the Releaser is agnostic about what the chosen token is or what happens to it.
Fee Sources → Token Jar → Releaser → (searcher sends resource) → resourceRecipient
└→ optional protocol fee → FeeController.feeRecipient
The Factory and FeeController sit at the same address on every supported chain.
| Contract | Address |
|---|---|
| Factory | 0xfAc70EEEE259F2140CBbB8e3157b75A52ce39fee |
| FeeController | 0xfEeC0FEE579Be52a5409cd75a42bCc9E6b149EF8 |
TJAR Deployments lists the supported chains with explorer links.
TokenJar.sol is an immutable collection point. It receives assets (native or ERC-20) from any source and exposes a single privileged role, the releaser, which is the only address permitted to withdraw. Withdrawals are atomic full-balance transfers of the specified assets to a recipient.
- Universal collector — accepts fees from arbitrary sources
- Single admin — only the
releasercan move assets out - Atomic — releases the full balance of the specified assets
Releasers serve as the Token Jar's releaser and implement the redemption logic:
ExchangeReleaser.sol— the concrete, deployable releaser. A searcher sends a fixedthresholdamount of theresourcetoken and, in exchange, the Token Jar's accumulated assets are released to a recipient they specify. The collectedresourceis forwarded to theresourceRecipient. Sequential nonces (seeNonce.sol) prevent front-running and enforce deterministic ordering. An_afterReleasehook stays virtual as the extension point for specialized variants.OptimismBridgedResourceReleaser.sol— a variant for OP-stack L2s where the chosen token's meaningful destination lives on L1: e.g. an operator's L2 jar is cleared with bridged USDC and that USDC belongs in their mainnet operations wallet — or, for burn-style configs, at0xdead, since a local L2 transfer wouldn't burn the canonical token. It collects theresourceat itself (resourceRecipientpinned toaddress(this)), then_afterReleaseinitiates an L2→L1 bridge withdrawal of its full balance to a configurablel1ResourceRecipient(settable by the owner orconfigSetter; changing it doesn't bump the nonce since it doesn't alter searcher terms). Theresourceis frozen after construction (a different bridged token means a fresh releaser and a jar rotation) and must be withdrawable through the standard bridge (anOptimismMintableERC20like USDC.e — not native CCTP USDC), which the constructor verifies; if the token ever stops being withdrawable (e.g. a bridged→native migration), releases revert and fees safely accumulate in the jar until the jar owner rotates to a fresh releaser.thresholdandmaxReleaseLengthstay tunable through the usual atomicsetConfig. Risk on the L1 leg:l1ResourceRecipientis mutable and rotating it redirects all future withdrawals immediately, but the bridge binds each already-initiated withdrawal to the recipient it was initiated with — so if that recipient cannot receive the token on L1 (e.g. blacklisted), those in-flight withdrawals sit in bridge escrow, replayable by anyone but never re-addressable. Non-burn configs should prefer a small owner-controlled L1 forwarder as the recipient and rotate viasetL1ResourceRecipienton first sign of trouble; for burn configs, escrowed is economically burned.
The release configuration — resource, resourceRecipient, threshold, maxReleaseLength — is mutable, but only atomically, through a single setConfig call. There are deliberately no per-field setters: threshold is denominated in resource, so sequential single-field updates would pass through intermediate configs that are validly releasable at their own nonce (e.g. a WETH threshold briefly expressed in USDC units). setConfig is callable by the owner or an appointed configSetter, and every call bumps the release nonce — a pending release priced against old terms reverts instead of executing against terms the caller never saw. Rotating the configSetter itself does not bump; it changes who may set terms, not the terms. The resource token's own behavior (upgrade, pause, blacklist, fee toggle) also sits outside the nonce guarantee and is trusted for the config's life — prefer immutable, non-upgradeable resources.
Fee-on-transfer resources — release() is send-denominated: the searcher is debited exactly threshold, so a fee-on-transfer resource delivers threshold × (1 − f) to the resourceRecipient (the protocol-fee leg is shaved identically). Operators compensate by grossing up the config (threshold = N / (1 − f)) and re-tuning via setConfig if the token's fee ever changes. If exact delivery is ever worth enforcing on-chain, it belongs in a future receive-denominated FotExchangeReleaser variant (require the recipient's balance delta to reach threshold), adopted per operator through the standard releaser rotation — not in the hot path of every deployment.
Upstream protocol-fees ships Firepit releasers that burn the RESOURCE by hard-coding the recipient to 0xdead. TJAR removed them: burning is a configuration of the Releaser, not a separate contract — and the immutability a hard-coded burn address appears to offer is weaker than it looks, because the Token Jar owner can always rotate to a different releaser via setReleaser. A burn commitment is a property of the whole system (Jar ownership and Releaser config), never of the Releaser alone.
(This holds for the bridged variant too: OptimismBridgedResourceReleaser exists for the delivery behavior — an L2→L1 withdrawal — and a canonical burn is, once again, just a configuration of it: l1ResourceRecipient = 0xdead.)
To replicate a Firepit in TJAR, pick the guarantee level you actually need:
- Burn economics — deploy the Releaser with
resourceRecipient = 0xdead. Every release burns the chosen token, but the owner retains control. - Trust-minimized burn — additionally place ownership of the Jar and the Releaser behind a timelock or governance, so any change of terms is slow and publicly visible.
- Trustless burn — relinquish control entirely, deployable atomically in one transaction:
factory.deploy(..., owner: 0xdead, configSetter: address(0), ...). No key ever holds power over the pair, and nothing — recipient, threshold, or the jar↔releaser wiring — can ever change. The trade-off is a frozenthreshold: if the chosen token's price drifts far enough, releases become over- or under-priced forever, with no way to retune. (An intermediate variant:owner = 0xdeadwith a liveconfigSetterfreezes the roles and wiring but keeps terms tunable by that fixed address.) Factory releasers read the FeeController on every release, so release liveness rests on protocol governance keeping a fee recipient the resource can transfer to — fees buffer safely in the jar if it ever isn't. - Exactly upstream semantics (immutable burn recipient with a governance-tunable threshold) — deploy the audited Firepit from Uniswap/protocol-fees itself. TJAR intentionally doesn't maintain a parallel copy.
FeeController.sol is a chain-wide singleton holding an optional protocol fee charged on top of a release's threshold. It is independent of any individual Releaser: Releasers are designed to hold an immutable reference to it and read its configuration when pricing a release, so the fee can be changed in one place without touching operator contracts.
- On top of the threshold —
thresholdstays the operator's net amount; the fee isthreshold * feeBps / 10_000, sent by the searcher in the currentRESOURCE. It starts at 0 bps, so there is no fee by default. - Capped —
feeBpscan never exceedMAX_FEE_BPS(1000 bps = 10%), a compile-time constant. - Atomic read —
feeConfig()returns(feeBps, feeRecipient)in a single call, so searchers and Releasers always price against consistent state. - Governance-owned — the owner is intended to be the Uniswap governance address; only it can change the fee or recipient. Operators cannot zero it out within a factory-deployed Releaser. Known limitation: an operator can rotate their jar to a custom releaser that skips the fee entirely — enforced at the business layer, not on-chain. A
feeRecipientthe resource refuses to transfer to (e.g. blacklisted) reverts releases; fees buffer safely in the jar, remedied by a resource change or releaser rotation. - Fee recipient — intended to be Uniswap's own protocol Token Jar, redeemed through Uniswap's own (upstream Firepit) releaser. This is a different jar from the operator's jar a releaser drains — never the releaser's own
TOKEN_JAR— so the fee the searcher sends lands outside the jar being swept rather than back in it. This separation is enforced at the business layer, not on-chain: afeeRecipientpointed at a factory-released jar would let a searcher who lists theRESOURCEamong the released assets reclaim the fee in the same release. A non-zero fee cannot be enabled until a recipient is set. - Deterministic — deployed once per chain at the same address via CREATE2; its only constructor arg (the initial owner) is passed identically on every chain, keeping the initcode — and therefore the address — chain-invariant.
The rule that governs everything here: whatever is in initcode must be byte-identical on every chain (that is what fixes a CREATE2 address), while anything in storage is free to differ per chain.
The FeeController is deployed on each chain through the canonical CREATE2 deployer (0x4e59b44847b379578588920cA78FbF26c0B4956C) with the same salt and the same _owner argument. The owner is an explicit constructor arg because msg.sender inside the constructor would be the deployer proxy itself, which can never call the setters — deploying with Owned(msg.sender) through this path would brick the contract. The _owner must be an address controlled on every chain: an EOA (automatic), or a deterministically deployed Safe.
The per-chain ceremony, all in one script run:
- Deploy
FeeController(opsOwner)— lands at the same address on every chain - Configure —
setFeeRecipient(uniswapTokenJar); the recipient is itself deterministically deployed, so this value is also chain-invariant - Hand off —
transferOwnership(<this chain's governance executor>): the Timelock on mainnet, the bridge executor Uniswap governance acts through on each L2. These differ per chain, which is fine — ownership is storage, not initcode.
Deploy order per chain is FeeController → Factory → operator deployments, since factory-deployed releasers call feeConfig() on every release. The window before governance handoff is low-risk by construction: the fee starts at 0 bps, is hard-capped at MAX_FEE_BPS, and cannot be non-zero until a recipient is set. One caution: Owned.transferOwnership is single-step, so verify each L2's governance executor address before sending — a transfer to the wrong alias is unrecoverable.
Factory.sol deploys TokenJar + ExchangeReleaser pairs and hands all authority to the operator:
factory.deploy(revenueToken, revenueRecipient, threshold, owner, configSetter, salt) → (jar, releaser)In a single transaction the Factory CREATE2-deploys the jar and the releaser (wired to the jar address, the initial config, and the FeeController), sets the releaser on the jar, transfers ownership of both contracts to owner, and appoints configSetter on the releaser (pass address(0) to leave the role unset). It emits JarDeployed (the initial config is covered by the releaser's own ConfigSet in the same transaction) and keeps no registry and no residual authority — the Factory can never touch a deployed pair again.
The two roles are independent because they only make sense as different addresses — the owner can always call setConfig directly, so the configSetter role exists for delegation (cold Safe as owner, warm ops key tuning terms). Since setConfig controls the threshold and resourceRecipient, choose it carefully and secure it accordingly. The combinations map to real configurations: owner + ops configSetter (the standard setup), owner + zero (owner-only config), 0xdead + a live setter (roles and wiring frozen forever, terms still tunable by a fixed party), or 0xdead + zero (trustless from birth — see the burn tiers above). A zero owner reverts; 0xdead states relinquishment intent unambiguously.
- Permissionless — anyone can deploy; the Factory holds no privileges worth gating.
- Sender-scoped salts — the effective CREATE2 salt is
keccak256(msg.sender, salt). The TokenJar takes no constructor args (that is what makes its address chain-invariant), so an unscoped salt would let a griefer front-run an operator's deploy on another chain and squat their predicted jar address with a different admin. Scoping means an address is only reachable by the same deployer with the same salt — deploy from the same address on every chain and the jar lands at the same address everywhere. - Predictable —
predictJar(deployer, salt)returns the jar address before anything is deployed, so an operator can wire their fee sources (e.g. a TAPI integration) to it in advance. Wire addresses in advance, never funds — see the prefunding caution below. - Chain-invariant — the Factory itself deploys through the canonical CREATE2 deployer, and its only constructor arg is the (chain-invariant) FeeController address, so the Factory sits at the same address on every chain.
Prefunding caution — deploy the jar (one cheap, permissionless transaction) before fees flow to its address. A predicted-but-undeployed address is reachable only through the canonical Factory on that chain and only by the same deployer identity, and neither assumption is enforceable on-chain: if the Factory never lands on a chain, funds sent early are stranded; and a contract-account deployer whose address does not commit its controller (e.g. a CREATE-based Safe recreatable on another chain, or a counterfactual account anyone can claim) lets an unintended party call deploy() as that sender and take the jar under their own owner. Deploy from an EOA for any salt reused across chains, and verify the Factory's code at its canonical address on each new chain before wiring anything.
An operator can move their jar to a new releaser (new terms, new config, or a specialized variant) in two steps, both under their own keys:
factory.deployReleaser(jar, revenueToken, revenueRecipient, threshold, owner, configSetter, salt)— deploys a fresh releaser pointed at the existing jar, owned byowner. It is inert until adopted: anyone can deploy a releaser at any jar, but an unadopted releaser cannot move a single asset.jar.setReleaser(newReleaser)— the jar owner adopts it. When the admin is a Safe, both steps batch into one atomic transaction.
The Factory deliberately has no power to redirect a jar — setReleaser belongs solely to the jar owner, which keeps "the Factory retains no authority" true forever and jar ownership the system's only trust anchor. Once a rotation lands, a pending release against the old releaser reverts atomically (the jar rejects it before any asset moves), searchers re-simulate on the jar's ReleaserSet event, and the old releaser is harmlessly orphaned.
ResourceManager.sol— holds the release configuration (resource,resourceRecipient,threshold,maxReleaseLength,configSetter) and enforces the atomic-setConfig+ nonce-bump invariants.Nonce.sol— sequences pending releases to prevent front-running.ReentrancyGuard.sol— transient-storage (EIP-1153) reentrancy guard applied toreleaseon both the Token Jar and the Releaser, blocking a malicious jar asset from re-entering mid-sweep.src/interfaces/—ITokenJar,IReleaser,IFeeController,IFactory, and base interfaces (IResourceManager,INonce,IOwned).
Redemption is permissionless and competitive: a searcher profits whenever the value of the assets accumulated in the Token Jar exceeds the RESOURCE cost of a release — the operator's threshold plus any protocol fee. This means no manual intervention, keeper network, or price oracle is required — economic actors collect and convert fees on their own schedule purely for profit. Fixed redemption amounts limit MEV extraction, and sequential nonces make ordering deterministic.
git clone https://github.com/Uniswap/tjar
cd tjar
# Install submodule dependencies
forge install
# Build
forge buildforge test # run all tests
forge test -vvv # verbose
forge coverage # coverage reportsrc/
├── TokenJar.sol // Accumulates fees; only the releaser can withdraw
├── FeeController.sol // Chain-wide singleton holding the optional protocol fee
├── Factory.sol // Deploys jar + releaser pairs; hands all authority to the operator
├── base
│ ├── Nonce.sol // Sequences pending releases to prevent front-running
│ ├── ReentrancyGuard.sol // Transient-storage reentrancy guard for release()
│ └── ResourceManager.sol // Atomic release config (setConfig) + nonce-bump invariants
├── interfaces
│ ├── ITokenJar.sol
│ ├── IReleaser.sol
│ ├── IFeeController.sol
│ ├── IFactory.sol
│ ├── base/ // INonce, IOwned, IResourceManager
│ └── external/ // Vendored OP-stack interfaces (IL2StandardBridge, IOptimismMintableERC20)
└── releasers
├── ExchangeReleaser.sol // Concrete: exchange resource for Token Jar assets
└── OptimismBridgedResourceReleaser.sol // Bridges the collected resource to an L1 recipient
script
├── Deploy.s.sol // Deterministic FeeController + Factory deployment
├── HandoffFeeController.s.sol // Per-chain governance handoff ceremony
└── README.md // Deploy runbook: invariants, order, env vars
test
├── TokenJar.t.sol
├── FeeController.t.sol
├── ExchangeReleaser.t.sol
├── Factory.t.sol
├── OptimismBridgedResourceReleaser.t.sol
├── ReentrancyGuard.t.sol // Transient-storage guard unit tests
├── Deploy.t.sol // Deployment-path tests (incl. mainnet-fork proxy test)
├── mocks/ // mock tokens and releasers
└── utils/ // shared test base
Audit reports are available in audit/:
- OpenZeppelin — TJAR (
openzeppelin-tjar-diff.pdf) — audit of the TJAR-specific changes (the diff against upstream: the pruning to TJAR scope plus the protocol fee, factory, atomic config, and bridged-releaser work). All findings have been remediated. - OpenZeppelin — upstream (
openzeppelin-1.pdf,openzeppelin-2.pdf,openzeppelin-3.pdf) - Cantina — upstream (
cantina.pdf)
The three OpenZeppelin
-1/-2/-3reports and the Cantina report cover the upstream Uniswap/protocol-fees code this repository is derived from. The OpenZeppelin TJAR report covers the changes made on top of it — see the derivative notice at the top of this README for what that diff includes.
AGPL-3.0-only.