Every campaign instance deploys as one of two shells, chosen at create time with mode: "clone" | "uups". A clone is a minimal, non-upgradeable EIP-1167 proxy - cheapest to deploy, and permanently pinned to the implementation it was created against. A UUPS instance is an ERC-1967 proxy whose implementation pointer can be rewritten later by whoever holds UPGRADER_ROLE on that instance.
Telling them apart: deploymentMode()#
AirdropBaseClient.deploymentMode() reads the ERC-1967 implementation slot directly - one eth_getStorageAt, no ABI call - and returns "clone" when the slot is unset, "uups"when it isn't.
import { createAirdropBaseClient } from "@tokenops/sdk/fhe-airdrop";
const base = createAirdropBaseClient({ publicClient, walletClient, address: instance });
// One eth_getStorageAt against the ERC-1967 implementation slot - no ABI
// call, and it works whether or not you already know the variant.
const mode = await base.deploymentMode(); // "clone" | "uups"Why the two obvious probes don't work#
proxiableUUID()carries OpenZeppelin's notDelegated guard and reverts on both shells when called through a proxy - it only answers when called directly on the implementation contract, so it cannot distinguish a live clone from a live UUPS instance at all. upgradeInterfaceVersion() reads a fixed constant that answers identically for both shells, so it carries no information either. Reading the storage slot directly is the only reliable discriminator, which is exactly what deploymentMode() does.
upgradeToAndCall
upgradeToAndCall({ newImplementation, data?, value? }) points a UUPS instance's proxy at a new implementation, optionally calling into it in the same transaction. It requires UPGRADER_ROLE (the contract's _authorizeUpgrade is role-gated) and is inert on clone-mode instances - a clone has no ERC-1967 implementation slot to rewrite, so the call reverts at the proxy layer rather than doing anything partial. Check deploymentMode() - or remember the create-time mode - before offering this in a UI.
Why Merkle can never be UUPS#
This is a storage-layout fact, not a policy choice. createMerkleAirdrop refuses mode: "uups" outright with MerkleUupsUnsupportedError, and there is no way to opt back in. The Merkle contract's per-account claimedAmount mapping occupies an ERC-7201 namespaced storage slot that an earlier contract revision typed differently - the slot used to hold a claimedLeafmapping instead. An in-place UUPS upgrade across that retype would reinterpret already-written storage as the new type, silently resetting every account's claimed counter and re-opening every settled claim. ECDSA campaigns carry no equivalent retype and are not restricted this way - the guardrail is specific to the Merkle variant.