import { encodeInstanceRoleSplit, planInstanceRoleSplit } from "@tokenops/sdk/fhe-airdrop";
// assignment.admin must not be the Safe itself: omit admin to keep it.
const steps = planInstanceRoleSplit({ assignment, caller: safeAddress, airdropType: "merkle" });
// steps: RoleGrantStep[] - { kind, role: RoleName, holder }
// roleConstants: the bytes32 values, e.g. read once from the instance with
// airdrop.PAUSER_ROLE(), airdrop.MERKLE_ADMIN_ROLE(), airdrop.DEFAULT_ADMIN_ROLE()
const calls = encodeInstanceRoleSplit({ airdrop, steps, roleConstants });
// calls: InstanceRoleCall[] ({ to, data, value: 0n }) in plan order -
// propose them to the Safe as one MultiSend batch.Airdrop v2 role constants.
Every instance role goes to the factory-injected admin at init, except the self-administered fee collector role and SIGNER_ROLE, which goes to params.signer.
| Role | Permits | Granted by |
|---|---|---|
| PAUSER_ROLE 0x65d7a28e3265b37a…0d862a | pause() / unpause() on the instance. Halts every claim path immediately; does not affect admin/treasury methods. | DEFAULT_ADMIN_ROLE |
| WINDOW_ADMIN_ROLE 0xdbb0f7f8b01ce70e…e63294 | extendClaimWindow(newEndTime). Forward-only, and only if canExtendClaimWindow was set true at create - otherwise reverts ExtensionNotAllowed regardless of who calls it. | DEFAULT_ADMIN_ROLE |
| TREASURY_ROLE 0xe1dcbdb91df27212…5dfca9 | withdrawConfidential(recipient) - sweeps the instance's entire encrypted token pool, in the clear-never amount. Refused with ClawbackRequiresPauseError while the campaign is unpaused inside its claim window: pause first, or run it outside the window. | DEFAULT_ADMIN_ROLE |
| RESCUER_ROLE 0xcf6f9f892731e14b…31a050 | rescueERC20, rescueOtherConfidentialToken and rescueNativeToken (ETH, zero-fee campaigns only - NativeRescueRequiresZeroFeeError otherwise). The two token rescues BOTH reject the campaign's own token via CannotRescueAirdropToken - ERC-7984 and ERC-20 are not disjoint in practice, since a wrapper token answers both interfaces, so the pool was never out of rescueERC20's reach by type alone. Use withdrawConfidential for the pool. | DEFAULT_ADMIN_ROLE |
| FEE_COLLECTOR_ROLE 0x2dca0f5ce7e75a4b…038821 | withdrawGasFee - sweeps the ETH claim fees recipients attached. Self-administered (re-parented to itself at init), so only a current holder can grant or revoke it, not even DEFAULT_ADMIN_ROLE. planInstanceRoleSplit refuses to plan a grant for this role for exactly that reason. | FEE_COLLECTOR_ROLE |
| UPGRADER_ROLE 0x189ab7a9244df084…c9d2e3 | upgradeToAndCall - authorizes a UUPS upgrade. Inert on clone-mode instances (the proxy layer itself refuses the call), and unreachable on any Merkle instance since the SDK refuses mode: "uups" for Merkle outright. | DEFAULT_ADMIN_ROLE |
| DISCLOSURE_ADMIN_ROLE 0xcf8a7913f3d76add…feb0c8 | adminGetCurrentBalance, adminDiscloseBalanceToParty, adminBatchDiscloseBalanceToParties, and bypasses gate #1 (the anti-theft sender-ACL check) on discloseHandleToParty / batchDiscloseHandlesToParty. | DEFAULT_ADMIN_ROLE |
| SIGNER_ROLE 0xe2f4eaae4a9751e8…285f70 | ECDSA-only. Authorizes EIP-712 claim digests - checked before signature verification, so an ERC-1271 staticcall is only reached for an already-authorized signer. Granted to the signer passed to createEcdsaAirdrop at init. Hold it in a dedicated key that is never EIP-7702-delegated (or an ERC-1271 contract signing the same digest): a delegation gives the key code, verification switches to ERC-1271 against the delegate, and every outstanding voucher fails InvalidSignature. Pre-check vouchers with isSignatureValid. | DEFAULT_ADMIN_ROLE |
| MERKLE_ADMIN_ROLE 0x434a11e5e6141714…938def | Merkle-only. setMerkleRoot(newRoot) - reverts FeatureDisabledError if the campaign was created with isMerkleRootMutable: false. | DEFAULT_ADMIN_ROLE |
| DELEGATION_ADMIN_ROLE 0xa434a7eae4dc33d3…52e91e | Lives on the per-instance ComplianceRoleManager clone, not on the airdrop instance itself. Gates addDelegate / revokeDelegate - the client-side compliance delegate roster (separate from the irrevocable platform delegate). | DEFAULT_ADMIN_ROLE (compliance manager) |
Factory roles
These five roles live on the AirdropFactory singleton, not on a campaign instance, and are not part of the instance role constants. They are seeded at factory deploy time and are administrable afterwards through the factory client's own grantRole / revokeRole / renounceRole - what there is no equivalent of here is planInstanceRoleSplit, so a factory handover is assembled call by call rather than planned as a set. DEFAULT_ADMIN_ROLE is floored at one live member, so grant the successor before renouncing. On Sepolia all five sit on the deployer account; on mainnet DEFAULT_ADMIN_ROLE is held by one account and the four operational roles by another ( FACTORY_ROLE_CONCENTRATION_NOTE). Check the current holders with getRoleMembers rather than assuming a split.
| Role | Permits | Granted by |
|---|---|---|
| FEE_MANAGER_ROLE 0x6c0757dc3e6b28b2…04ff1c | setFeeCollector, setDefaultGasFee, setCustomFee, disableCustomFee on the factory. NOT setMaxGasFee - the ceiling those fees must sit under is DEFAULT_ADMIN_ROLE's, so the fee manager moves fees only inside a range it cannot raise. | DEFAULT_ADMIN_ROLE (factory) |
| IMPL_MANAGER_ROLE 0xa9921a7b6cf4e3ab…1b231f | setEcdsaImplementation, setMerkleImplementation. Rotating a pointer affects only new creates - existing clones hardcode their impl in bytecode. | DEFAULT_ADMIN_ROLE (factory) |
| COMPLIANCE_WIRING_ROLE 0x7a00f6efd9a6d752…ad0d44 | setComplianceManagerImpl, setComplianceDelegate, setDefaultDelegateToCompliance, setCompliancePolicy, clearCompliancePolicy. The constructor seeds a non-zero platform delegate with compliance ON, so there is no wiring step after deploy; setComplianceDelegate rotates the delegate but can never clear it. | DEFAULT_ADMIN_ROLE (factory) |
| UPGRADE_MANAGER_ROLE 0xa76ace73a908083d…726db5 | setDefaultUpgradeable, setUpgradeabilityPolicy, clearUpgradeabilityPolicy - the gate a mode: "uups" create is checked against via effectiveUpgradeable(creator). | DEFAULT_ADMIN_ROLE (factory) |
| DEFAULT_ADMIN_ROLE 0x0000000000000000…000000 | Administers the other four factory roles, gates setMaxGasFee, and can grant/revoke/renounce. Floored at one live member, matching the instances: revoking or renouncing the sole holder reverts LastAdmin, and granting the role to the zero address reverts ZeroAdminGrant, so the set can neither be emptied nor satisfied by a member nobody controls. A handover is the supported route - grant the successor first, then renounce. Per FACTORY_ROLE_CONCENTRATION_NOTE, one deployer key holds all five factory roles on Sepolia, while mainnet holds DEFAULT_ADMIN_ROLE separately from the four operational roles - check useFactoryRoleMembers rather than assume a split. | DEFAULT_ADMIN_ROLE (factory) |