Skip to main content

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 (per docs/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 triggersHowProtection
The beneficiarySigns 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 idCalls creatorConsolidate or creatorConsolidateAndShield directlyminOut = 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 signed minOut scaled 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 ShieldRequest for 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 call recredit(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 tokenBlocklist and reverts if the current shieldFee is 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.json wires 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​

PathWho triggersDestination
Claim to RailgunThe beneficiary signs a Claim with the Railgun adapterA ShieldRequest the beneficiary built just now
Shield templateThe creator triggers consolidation for the beneficiaryOne 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 Claimed event and the Railgun Shield event 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.

Privacy Pools

An adapter for 0xbow Privacy Pools was researched and is deferred. Railgun is the only private exit in this release.