RailgunShieldAdapter
Source: contracts/src/adapters/RailgunShieldAdapter.sol, ShieldSender.sol,
interfaces/IRailgunSmartWallet.sol · Implements: IWithdrawalAdapter (frozen) · MIT.
Ownable2Step, Pausable, transient ReentrancyGuard.
Purpose
The FeeVault transfers USDT to the adapter and calls onWithdraw. The adapter forwards the full
amount to the beneficiary's ShieldSender clone, which calls RailgunSmartWallet.shield. A
Railgun refund ("unshield to origin") therefore lands in that clone and can be re-credited to the
same FeeVault id by anyone. Neither the adapter nor the sender keeps any part of a shield.
interface IWithdrawalAdapter {
function onWithdraw(address asset, uint256 amount, bytes calldata data) external;
}
Payload
data = abi.encode(bytes32 beneficiaryId, RgShieldRequest req) // static 320 bytes
The owner signs Claim.dataHash = keccak256(data). The same format is used for pre-registered
shield templates. Railgun's request (V2, mirrored in IRailgunSmartWallet because Railgun's
source is unlicensed):
struct ShieldRequest {
CommitmentPreimage preimage; // (bytes32 npk, TokenData token, uint120 value)
ShieldCiphertext ciphertext; // (bytes32[3] encryptedBundle, bytes32 shieldKey)
}
struct TokenData { uint8 tokenType; address tokenAddress; uint256 tokenSubID; }
Build it off-chain from the recipient's 0zk address (SDK buildShieldRequest /
buildRailgunClaimData). The ciphertext encrypts only the note's random value, so the request is
amount-agnostic.
onWithdraw checks (in order)
msg.sender == vault, not paused, not re-entered.- Decode the payload.
asset == usdt;beneficiaryId != 0;0 < amount ≤ type(uint120).max.preimage.token == (ERC20, usdt, 0).preimage.valueis 0 (amount-agnostic) or exactlyamount. The adapter sets it toamount(gross; Railgun deducts its fee from it).0 < npk < SNARK_SCALAR_FIELD.!railgun.tokenBlocklist(usdt)andrailgun.shieldFee() ≤ maxFeeBps; when asanctionsListis set, the transaction's submitter (tx.origin, which is what Railgun's PPOI screens) must not be listed (SubmitterSanctioned). For a claim the submitter is the relayer; for a creator-triggered template shield it is the creator's address, so a listed creator's shield reverts atomically and the USDT and the template stay in the vault.- Deploy or reuse the beneficiary's
ShieldSender, transferamountto it, and shield. - Revert with
ShieldAmountMismatchunless Railgun pulled exactlyamount. - Emit
Shielded(beneficiaryId, sender, gross, base, fee).
Functions
| Function | Access | Description |
|---|---|---|
onWithdraw(asset, amount, data) | FeeVault | Shield, as above |
previewShield(asset, amount, data) view returns (sender, base, fee) | Same checks as onWithdraw; reverts with the same error | |
canShield(asset, amount, data) view returns (bool) | Non-reverting form, for consolidation paths that fall back to leaving USDT in the vault | |
encodeShieldData(beneficiaryId, req) pure / decodeShieldData(data) pure | Payload helpers | |
shieldSenderOf(beneficiaryId) view | Predicted CREATE2 address of the clone | |
deployShieldSender(beneficiaryId) | anyone | Deploy the clone if missing (idempotent) |
setMaxFeeBps(uint16) | owner | At most MAX_FEE_BPS_CAP = 100 (1%); 25 recommended |
setSanctionsList(address) | owner | Chainalysis-style oracle (isSanctioned(address)) screening each shield's submitter; address(0) turns screening off. config/56.json wires the Chainalysis oracle 0x40C5…C8fb (fork-checked on 2026-10-04) |
pause() / unpause() | owner | Stop / resume new shields (claims to this adapter revert; funds stay in the vault) |
railgun, vault, usdt, shieldSenderImplementation, maxFeeBps, sanctionsList | view | Configuration (first four immutable) |
Constructor: (initialOwner, railgun, vault, usdt, maxFeeBps).
ShieldSender
An OZ Clones CREATE2 clone with salt beneficiaryId and immutable argument
abi.encode(beneficiaryId). No owner and only one exit.
| Function | Access | Description |
|---|---|---|
shield(req, asset, amount) | adapter | forceApprove, shield, reset approval to 0 |
recredit(asset) returns (uint256) | anyone | Deposit the clone's whole asset balance into FeeVault.deposit(beneficiaryId, …) |
beneficiaryId() view | The bound id |
Events and errors
Adapter events: ShieldSenderDeployed(beneficiaryId, sender),
Shielded(beneficiaryId, sender, gross, base, fee), MaxFeeBpsSet(maxFeeBps),
SanctionsListSet(sanctionsList).
ShieldSender: Recredited(beneficiaryId, asset, credited).
Adapter errors: ZeroAddress, NotVault, UnsupportedAsset(asset), ZeroBeneficiary,
InvalidAmount, InvalidTokenData, ValueMismatch(value, amount), InvalidNpk,
TokenBlocked(token), ShieldFeeTooHigh(feeBps, maxFeeBps), MaxFeeAboveCap(maxFeeBps),
ShieldAmountMismatch, SubmitterSanctioned(submitter), plus OZ EnforcedPause. ShieldSender errors: NotAdapter, NotClone,
NothingToRecredit.
Railgun entry point
| Value | |
|---|---|
| RailgunSmartWallet proxy (BSC) | 0x590162bf4b50F6576a459B75309eE21D92178A10 |
| Function | shield(((bytes32,(uint8,address,uint256),uint120),(bytes32[3],bytes32))[]) |
| Selector | 0x044a40c3 (checked by a test) |
| Shield fee | 25 bps, inclusive, governance-mutable (read on a BSC fork on 2026-10-04 by the fork test) |
| Gas (local estimate) | ~870k cold, ~636k warm |
Tests
contracts/test/adapters/: a faithful mock of Railgun's shield (same preimage checks, blocklist,
inclusive fee, balance-delta check, treasury fee), FeeVault integration (direct and relayed
claims), reentrancy, invariants, submitter sanctions screening
(RailgunShieldAdapterSanctions.t.sol), and a BSC fork test against the real proxy (skipped
without BSC_RPC_URL). Details: docs/tracks/adapters.REPORT.md.