Consolidation and Railgun shielding
A beneficiary's fees can arrive in several assets: WBNB, USDT, BTCB, XAUt and others, depending on each coin's quote token, plus anything donated directly. zk-pad's private exit works in one asset only, USDT, and in two steps: consolidate into USDT, then shield into Railgun.
Why one asset
- One asset means one shared anonymity set. If every beneficiary exits in USDT, their shields look alike. A shield of an unusual token is effectively unique and links straight to any later unshield of that token.
- USDT (BSC-USD,
0x55d398326f99059fF775485246999027B3197955, 18 decimals) is not upgradeable, has no blacklist and no transfer fee on BSC (perdocs/research/quote-tokens.md, to be confirmed on a fork). - The trade-off, accepted by the owner: every beneficiary depends on Tether / Binance-Peg USDT.
Step 1: consolidate into USDT (on demand)
Consolidation swaps one balance into USDT inside the vault, through the quote token's registered route. It happens only when someone asks for it, never automatically.
| Who triggers | How | Protection |
|---|---|---|
| The beneficiary | Signs Consolidate(beneficiaryId, assetIn, amountIn, minOut, nonce, deadline) with the stealth key; a relayer submits it (paid with a fee-only claim) | The beneficiary's own signed minOut |
| The creator of a coin whose fees are credited to that id | Calls creatorConsolidate or creatorConsolidateAndShield directly | minOut = Chainlink price × (1 − maxDeviationBps), feed at most 1 hour old, at most maxSwapSize per call, and at least minCreatorShield (1 USDT). Limited to that launch's credit (below). Tokens without a feed (USDC, USD1, FDUSD and XAUt in the mainnet config) cannot be consolidated by the creator. |
The result stays in the vault as a USDT balance of the same id. When the asset is already USDT, nothing is swapped.
What a creator may touch: the launch credit
A creator never gets power over the beneficiary's whole balance, only over what its own
launch credited: FeeVault.launchCredit(launchToken, asset). It is the fees the launch's locker
deposited (plus the USDT its creator consolidations produced, less what the creator already
consolidated or shielded), held as a pro-rata share of the id's balance in that asset. Every
owner-side withdrawal (a claim, a signed consolidation, the fallback sweep) shrinks every launch's
share in proportion. So:
- a launch's credit never exceeds the balance it draws on;
- it never reaches donations, other launches' fees, or anything credited after the owner withdrew;
- launching a coin that names someone else's id gives no power over that id's other balances.
The creator path's price bound (the registry's feeds and executors) is protocol-owner configuration. A malicious protocol owner working with a creator could drain launch-credited balances through a bad feed or executor; see trust assumptions. A beneficiary who does not want to rely on this can consolidate or claim the balance itself.
Taking the whole balance
A creator consolidation can land between the moment the beneficiary signs and the moment the relayer submits, changing the balance. To take everything regardless, the web app and SDK sign the vault's full-balance forms, which are resolved at execution:
Claim.amount = 2^256 − 1: the whole balance of the asset, with the signed relayer fee;Consolidate.amountIn = 2^255 | quotedAmountIn(consolidateAllAmountIn): the whole balance, with the signedminOutscaled down in proportion when less is left, so the signed rate holds.
Step 2: shield into Railgun
Railgun is an existing privacy system, live on BSC. A shield turns a public token balance into a private 0zk note that only the recipient's Railgun wallet can read and spend.
The vault shields through the RailgunShieldAdapter:
- USDT only. The adapter rejects any other asset.
- The beneficiary's wallet (or the SDK) builds a Railgun
ShieldRequestfor their 0zk address. The request does not depend on the amount: its token must be USDT, and the adapter fills in the value at execution time. - The claim signs
dataHash = keccak256(abi.encode(beneficiaryId, shieldRequest)), so the relayer cannot change the destination. - The shield is sent from a per-beneficiary proxy (
ShieldSender, a CREATE2 clone with salt = beneficiaryId). If Railgun ever refuses the shield, the "unshield to origin" refund lands in that proxy, and anyone can callrecredit(asset)to put it back into the beneficiary's vault balance. Funds are never trapped in the adapter. - The adapter checks Railgun's on-chain
tokenBlocklistand reverts if the currentshieldFeeis above its configured maximum. - The adapter can screen the transaction's submitter (
tx.origin, the address Railgun's PPOI screens) against a sanctions oracle.config/56.jsonwires the Chainalysis oracle. A listed submitter's shield reverts atomically, so the USDT stays in the vault.
Railgun's shield fee is 0.25% (25 bps, inclusive), set by Railgun governance. Leaving Railgun later (unshield) also costs 0.25%. Both figures are documented by Railgun but have not yet been read on-chain for BSC.
Two ways to shield
| Path | Who triggers | Destination |
|---|---|---|
| Claim to Railgun | The beneficiary signs a Claim with the Railgun adapter | A ShieldRequest the beneficiary built just now |
| Shield template | The creator triggers consolidation for the beneficiary | One of the beneficiary's pre-registered, single-use Railgun shield templates |
Shield templates let a beneficiary say in advance "if anyone consolidates my fees, shield
them to me". The beneficiary signs RegisterShieldTemplates(beneficiaryId, templatesHash, nonce, deadline) over a batch of up to 32 ShieldRequests. Each template is used once and never
reused, and the SDK generates fresh randomness for each one. Each template is pinned to the
shield adapter configured when it was registered and is only used while that adapter is still
the configured, allow-listed one: a later protocol change can block a template but never
redirect it.
Templates belong to an owner key. RotateOwner and a finalized rebind discard every unused
template, so templates planted with an old key (for example by the creator who generated a
stealth claim link) can never shield to a wallet the new owner does not control. Register new
templates from the new key after rotating.
The creator can never choose the destination. If no template is available, a
creator-triggered consolidation just leaves USDT in the vault. The creator calls
creatorConsolidateAndShield(launchToken, assetIn, amountIn, shieldAmount), and the vault
emits ShieldedFromTemplate(id, adapter, amount, templateIndex). shieldAmount = 0 shields the
launch's whole USDT credit, or nothing while that is below minCreatorShield (1 USDT); an
explicit amount must be at least minCreatorShield and at most the launch's USDT credit. A template is the same
abi.encode(id, ShieldRequest) payload as a claim, with value = 0, and the adapter fills in the
amount. If Railgun is paused, blocks USDT or charges more than the adapter's fee bound, the whole
call reverts: the USDT and the template both stay. To discard templates after changing Railgun
wallets, sign ClearShieldTemplates.
The 1-hour PPOI standby
Railgun uses Private Proofs of Innocence (PPOI). Every new shield is on standby for about one hour while list providers check it. During that hour the funds show as pending in the Railgun wallet, and the only possible action is "unshield to origin".
Things to know:
- The reference PPOI list provider screens the transaction sender (
tx.from). For a claim that is the relayer's address, not your stealth key or the adapter; relayers keep dedicated, clean addresses for this reason. For a creator-triggered template shield it is the creator's submitting address, which the adapter's sanctions screen checks first. - If a shield is ever refused, the refund goes to your ShieldSender proxy and can be recredited to your vault balance.
- PPOI is enforced by wallets and broadcasters, not by the Railgun contracts.
Anonymity-set caveats
Railgun hides who owns a note and what happens to it afterwards. It does not make you invisible:
- The BSC Railgun pool is small. One unverified estimate puts it below US$1M shielded. A small pool means fewer notes to hide among.
- The shield itself is public. On-chain, the
Claimedevent and the RailgunShieldevent in the same transaction show the beneficiary id, the amount and the time. What stays hidden is the 0zk address and what you do with the note. - Amounts and timing correlate. Unshielding the exact amount you shielded, soon afterwards, links the two. Wait, split amounts and avoid round-tripping exact figures.
- Spending privately needs a broadcaster paid in a token it accepts (typically WBNB or USDT). Holding USDT helps.
- Keep your shield private key private. If it leaks, your shields can be linked to your 0zk address. The SDK generates it in the browser and never sends it to a relayer or attestor.
Planned improvements (not in this release, see docs/NEXT_SPEC.md): automatic shielding in
fixed denominations, batched across beneficiaries by a keeper, and optional fixed-denomination
manual claims.
The direct alternative
A beneficiary can always claim directly to any address instead. Use a fresh address that has never been linked to you. A direct claim of WBNB can be unwrapped to native BNB.
An adapter for 0xbow Privacy Pools was researched and is deferred. Railgun is the only private exit in this release.