Skip to main content

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)​

  1. msg.sender == vault, not paused, not re-entered.
  2. Decode the payload.
  3. asset == usdt; beneficiaryId != 0; 0 < amount ≤ type(uint120).max.
  4. preimage.token == (ERC20, usdt, 0).
  5. preimage.value is 0 (amount-agnostic) or exactly amount. The adapter sets it to amount (gross; Railgun deducts its fee from it).
  6. 0 < npk < SNARK_SCALAR_FIELD.
  7. !railgun.tokenBlocklist(usdt) and railgun.shieldFee() ≤ maxFeeBps; when a sanctionsList is 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.
  8. Deploy or reuse the beneficiary's ShieldSender, transfer amount to it, and shield.
  9. Revert with ShieldAmountMismatch unless Railgun pulled exactly amount.
  10. Emit Shielded(beneficiaryId, sender, gross, base, fee).

Functions​

FunctionAccessDescription
onWithdraw(asset, amount, data)FeeVaultShield, 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) purePayload helpers
shieldSenderOf(beneficiaryId) viewPredicted CREATE2 address of the clone
deployShieldSender(beneficiaryId)anyoneDeploy the clone if missing (idempotent)
setMaxFeeBps(uint16)ownerAt most MAX_FEE_BPS_CAP = 100 (1%); 25 recommended
setSanctionsList(address)ownerChainalysis-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()ownerStop / resume new shields (claims to this adapter revert; funds stay in the vault)
railgun, vault, usdt, shieldSenderImplementation, maxFeeBps, sanctionsListviewConfiguration (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.

FunctionAccessDescription
shield(req, asset, amount)adapterforceApprove, shield, reset approval to 0
recredit(asset) returns (uint256)anyoneDeposit the clone's whole asset balance into FeeVault.deposit(beneficiaryId, …)
beneficiaryId() viewThe 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
Functionshield(((bytes32,(uint8,address,uint256),uint120),(bytes32[3],bytes32))[])
Selector0x044a40c3 (checked by a test)
Shield fee25 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.