A threshold Safe's submission returns a safeTxHash that does not mine until the other owners co-sign, so a write that waits for its receipt would hang. The writes below take waitForReceipt: false (default true) and return as soon as the submission hash is known; their React hooks pass the option through.
ReceiptMode, PendingWrite and MinedWrite#
Every receipt-free result extends PendingWrite and every mined counterpart extends MinedWrite, so one check, if (result.pending), narrows a result from any product. The three are exported from the root @tokenops/sdk and each FHE product subpath.
interface PendingWrite { pending: true } // returned before the tx was observed executing
interface MinedWrite { pending?: never } // the receipt was read; every field is set
type ReceiptMode<W extends boolean = boolean> = [W] extends [false]
? { waitForReceipt: false }
: { waitForReceipt?: W | undefined };Each method is overloaded on the literal. The option is kept off the exported *Args types, so a wrapper typed with one of them still gets the mined result; the methods take Args & ReceiptMode.
import { createConfidentialVestingFactoryClient } from "@tokenops/sdk/fhe-vesting";
const factory = createConfidentialVestingFactoryClient({ publicClient, walletClient });
// Literal false: the overload returns PendingCreateManagerResult.
const pending = await factory.createManager({ token, userSalt, waitForReceipt: false });
trackSafeTx(pending.hash); // the safeTxHash; pending.manager is undefined
// Omitted (or true): the mined CreateManagerResult with manager set.
const mined = await factory.createManager({ token, userSalt });
openManager(mined.manager);import type { ReceiptMode } from "@tokenops/sdk";
import type { CreateManagerArgs } from "@tokenops/sdk/fhe-vesting";
// A runtime boolean gets the union, narrowed by pending.
async function deploy(args: CreateManagerArgs & ReceiptMode) {
const result = await factory.createManager(args);
return result.pending ? undefined : result.manager;
}The receipt-free writes#
| Product | Method | Receipt-derived field on the pending result |
|---|---|---|
| /fhe-vesting | createManager, createManagerAndGetAddress | manager: undefined. Read the executed tx's ManagerCreated log. |
| /fhe-vesting | splitVesting | newVestingId: undefined. Read the VestingSplit log. |
| /fhe-disperse | register | wallets: undefined. Read the UserRegistered log. |
| /fhe-disperse | disperse | distributions: undefined. Read the WalletDistribution / DirectDistribution logs. |
| /fhe-disperse | withdrawTokenFee | transferredHandle: undefined. Read the TokenFeeWithdrawn log. |
| /fhe-disperse | discloseHandleToParty, batchDiscloseHandlesToParty | discloser, party, disclosedHandles: undefined. Read the HandlesDisclosedToParty log. |
| /fhe-airdrop (@beta) | createEcdsaAirdrop, createMerkleAirdrop, createAndFundEcdsaAirdrop, createAndFundMerkleAirdrop | None: every address comes from the commitments the create was sent with. |
A pending airdrop create carries the same fields as the mined result, taken from the create's commitments rather than a receipt: the factory reverts unless the instance lands at the committed address. Confirm by reading ConfidentialAirdropCreated from the executed transaction, or isAirdrop(airdrop) on the factory. If that transaction never executes, no instance exists there.
Hooks resolve Mined | Pending#
A hook cannot overload, so useCreateManager, useCreateManagerAndGetAddress, useSplitVesting, useRegister, useDisperse, useWithdrawTokenFee, useDiscloseHandleToParty, useBatchDiscloseHandlesToParty (both in /fhe-disperse/react) and the four airdrop create hooks resolve the union. Receipt-derived fields on data type as X | undefined until you narrow with if (data.pending) return;. Headless calls that never pass the option keep the mined result type.
import { useCreateManager } from "@tokenops/sdk/fhe-vesting/react";
const create = useCreateManager();
const isSafe = connector?.id === "safe";
create.mutate({ token, userSalt, waitForReceipt: !isSafe });
// data is CreateManagerResult | PendingCreateManagerResult.
if (create.data) {
if (create.data.pending) return <SafeQueued hash={create.data.hash} />;
return <ManagerLink address={create.data.manager} />;
}The operator grant from a Safe#
setOperator, ensureOperator and useEnsureOperator also wait for their receipt by default. A Safe passes waitForReceipt: false there too and lets the approval execute before the funding transaction does. That covers vesting funding, every disperse mode and the airdrop createAndFund* / fundAirdrop paths. See Operators.
import { setOperator } from "@tokenops/sdk/fhe";
// From a Safe: submit the approval without waiting, and let it execute before the funding tx.
await setOperator({ publicClient, walletClient, token, spender: factoryAddress, waitForReceipt: false });Airdrop role splits as one Safe batch#
grantInstanceRoles mines each step before simulating the next, which a threshold Safe cannot do in one session. encodeInstanceRoleSplit turns a planInstanceRoleSplit plan into zero-value grantRole / revokeRole calls that run in order inside one MultiSend transaction, so the plan's grant-before-revoke ordering holds. The batch is atomic: one reverting call reverts the whole split.
import { encodeInstanceRoleSplit, planInstanceRoleSplit } from "@tokenops/sdk/fhe-airdrop";
// Plan with caller set to the Safe: it is the account whose DEFAULT_ADMIN_ROLE the handoff revokes.
const steps = planInstanceRoleSplit({
assignment: { pauser, treasury, admin: opsMultisig },
caller: safeAddress,
airdropType: "merkle",
});
const calls = encodeInstanceRoleSplit({
airdrop,
steps,
roleConstants: {
DEFAULT_ADMIN_ROLE: await client.DEFAULT_ADMIN_ROLE(),
PAUSER_ROLE: await client.PAUSER_ROLE(),
TREASURY_ROLE: await client.TREASURY_ROLE(),
},
});
// One MultiSend transaction; Safe SDKs type value as a decimal string.
const transactions = calls.map((c) => ({ ...c, value: "0" }));- To keep the Safe as co-admin, drop the
revokeDEFAULT_ADMIN_ROLEstep from the plan before encoding. - A step that names a role whose constant you did not pass throws
InvalidArgumentError, exceptDEFAULT_ADMIN_ROLE, which defaults to OpenZeppelin's fixedbytes32(0). - Each call is a plain CALL, so
operationis omitted.
What has no receipt-free option#
Encrypted views and the vesting and airdrop disclosures return a handle read from the receipt, and that handle is only useful to an account that can user-decrypt in-session. Run them from an EOA or a delegate.