Every createEcdsaAirdrop / createMerkleAirdrop call returns a complianceManager address alongside airdrop - a separate, per-instance ComplianceRoleManager clone, deployed and wired in the same transaction as the campaign itself.
import { createConfidentialAirdropFactoryClient } from "@tokenops/sdk/fhe-airdrop";
const { airdrop, complianceManager } = await factory.createMerkleAirdrop({
params, mode: "clone", userSalt,
});
// `complianceManager` is a separate clone, deployed and wired atomically
// in the same create transaction - there is no "deployed but not wired"
// state to observe.Two delegate slots#
The clone carries FHE user-decryption delegation, scoped to the(clone, airdrop instance) pair, and splits into two slots that behave differently:
- Platform delegate- wired once, at create time, only if the factory's compliance policy was effectively ON for that creator.
ComplianceManagerClient.complianceDelegate()returns the zero address unambiguously meaning the policy was off. Irrevocable by construction - no method on the clone ever removes it. The factory starts with a non-zero platform delegate and the default ON from its constructor, so there is no wiring step after deploy;setComplianceDelegaterotates the delegate for future creates but can never clear it. Every create also commits to the delegate it will wire, so a rotation between quote and send is refused as aCreateCommitmentMismatchErrorinstead of silently wiring a different one. - Client delegates - the clone starts with one,
complianceAdminitself (named at create, the creator if left zero), seeded with never-expiring decrypt rights over the instance from the create block. That same admin adds or removes delegates, including itself, viaaddDelegate/revokeDelegateunderDELEGATION_ADMIN_ROLE.clientDelegates()lists only this slot;isActiveDelegate(delegate)is the authoritative check across both slots, since it reads the ACL expiry directly rather than a mirrored set.
A delegate can be authorised and still read nothing#
Delegation grants a party the right to decrypt on this instance's behalf. It does not grant them a handle. The token rotates the instance's balance handle on everyincoming transfer - including a direct one no airdrop code observes, such as a third party transferring into the pool - and grants the fresh handle to the token and the holder only. The compliance clone's grant is left behind on a handle the token has already replaced, and nothing in the delegate set says so.
refreshComplianceBalance() on the instance re-grants the clone on the current balance and returns the handle it was granted on. It is permissionless, and deliberately so: there is no argument to abuse, since the only handle read is the instance's own balance and the only grantee is the clone wired at initialization, and the alternative is a campaign whose clone cannot read the pool with no admin necessarily around to fix it. An admin's adminGetCurrentBalance re-grants the clone on the same handle, so it doubles as a refresh.
The ACL-propagation-lag gotcha#
Adding a delegate takes effect on chain immediately, but the off-chain relayer gateway that services a delegated decrypt (decryption.delegatedDecryptValues on a ZamaSDK) can lag behind it by a short window, about 10 blocks. A decrypt attempted right after wiring a delegate can come back as an HTTP 400 or a DelegationNotPropagatedErrorthat actually means "the ACL check hasn't propagated to the gateway yet," not "this delegate isn't authorized." Where the SDK maps the error itself, it arrives as the retryable AclNotPropagatedError.
isAclPropagationError(error) classifies that specific transient condition so a caller can retry instead of surfacing a hard denial to a user who is, in fact, authorized:
import { createComplianceManagerClient, isAclPropagationError } from "@tokenops/sdk/fhe-airdrop";
const compliance = createComplianceManagerClient({ publicClient, walletClient, address: complianceManager });
await compliance.addDelegate({ delegate: auditorAddress });
try {
// auditorSdk: the delegate's own ZamaSDK (@zama-fhe/sdk 3.6)
await auditorSdk.decryption.delegatedDecryptValues(inputs, delegatorAddress);
} catch (err) {
if (isAclPropagationError(err)) {
// The delegate grant landed on-chain, but the gateway hasn't caught
// up yet. Retry after a short delay rather than treating this as a
// hard denial.
} else {
throw err;
}
}DISCLOSURE_ADMIN_ROLE vs delegation#
Delegation and disclosure are separate mechanisms on separate contracts. DISCLOSURE_ADMIN_ROLE lives on the airdrop instance itself, and gates adminGetCurrentBalance, adminDiscloseBalanceToParty, and adminBatchDiscloseBalanceToParties- direct, one-off ACL grants on the instance's own balance handle. The generic discloseHandleToParty / batchDiscloseHandlesToParty pair needs no role at all, but a non-admin caller must already hold persistent user-decryption ACL on the handle it is disclosing, in both the caller and instance legs. A transient allowance no longer satisfies either, which makes a handle fresh out of an FHE op the trap: it fails with FheHandleNotAllowedErrorcarrying the offending handle. Disclose handles that came from the encrypted views on the instance client, whose grants are persistent. The compliance clone's delegate model is a third, independent path: an ongoing decryption right for a party the compliance admin names, not a one-off grant.
A related, separate-block gotcha#
Adding and revoking the same delegate address within one block is rejected by the FHEVM ACL itself with an opaque revert - it has no decodable selector the SDK can classify into a typed error. Space out an add-then-revoke sequence for the same address across blocks rather than batching them together.