Stage owner transactions for Safe multisigs as Safe Transaction Builder files, rehearse those files on a fork exactly as the Safe will execute them, and propose them to the Safe Transaction Service.
Not on npm: install a release tag from git. Each tag carries the built dist, so nothing is built on install.
"@bloq/safe-ops": "github:bloq/safe-ops#v0.2.0"Needs Node 22 or later, viem 2.42 or later as a peer, and TypeScript 5 for the typings.
import { proposeStagedSafeBatches, stageSafeTx } from "@bloq/safe-ops/hardhat";
// In a deploy script: runs directly when hardhat-deploy can sign for `owner`, otherwise stages it for the Safe.
await stageSafeTx(hre, () => hre.deployments.execute("Vault", { from: owner }, "setFee", 100));
// Or, if the script already calls catchUnknownSigner itself:
const unsigned = await hre.deployments.catchUnknownSigner(execute(/* ... */));
await stageSafeTx(hre, unsigned);
// In the last deploy script (runAtTheEnd): propose what was staged, or rehearse it on a fork.
const func: DeployFunction = async hre => {
await proposeStagedSafeBatches(hre);
};Staging. Each run stages into one Transaction Builder file per Safe, written after every call, in safe-batches/<network>/<timestamp>-<safe>.json (e.g. 20261001T153000Z-0xd1de3f.json). The timestamp is the run's first staged call, so a long run is still one file. Inspect a file, or import it in the Safe UI (Apps → Transaction Builder), but never both import it and propose it.
On a live network, calls a run makes again while they are still pending for the Safe are skipped with a warning, so the file holds only what is left to propose: deploy scripts can't see queued calls and stage them again on every run. Pending proposals execute before the file, so this only applies to calls made before anything new for that Safe; once a new call is staged, later pending calls are kept and the file reads as partial, rather than run out of the script's order. Only proposals that run as a batch would (plain calls, no refund) on a nonce without competing proposals count. Each Safe's queue is read once per run, on its first call; if the Safe service can't be read, staging warns and goes on without the check. stagedFilesThisRun(hre) returns the files staged so far, e.g. to record them, or to stop the deploy once something is left to the Safe.
Proposing (proposeStagedSafeBatches) handles this run's files, or every staged file for the network when the run staged nothing (e.g. a later run of just the last script); older files are left for later and named in the log. A file staged on a fork is never proposed. Each file is checked against its Safe first:
| File | Result |
|---|---|
| Nothing pending or executed shares its calls | Proposed exactly as written, after the pending proposals; file deleted |
| One pending proposal makes exactly its calls | Not proposed again; file deleted (kept if that nonce has competing proposals) |
| An execution since staging made exactly its calls (including a UI import) | Not proposed; file deleted |
| Proposals share some of its calls | Not proposed; file kept. Wait for those to execute or reject them, then rerun the deploy, or discard the file |
It also warns when something executed since staging changed the file's contracts without sharing its calls, so a rerun can pick that up.
Confirmation. Files staged by the same deploy run are proposed without asking: running the deploy is the decision. Anything else (explicit files, or older staged files when the run staged nothing) is first shown as a plan, the same as safe:list, followed by Propose N file(s) and delete M? [y/N]. Without a terminal (CI, piped output) it refuses unless yes is passed (--yes for the task). Each file is checked again right before it is proposed, in case the plan went stale. Only the files the plan counted are acted on. A refused or failed file (fork file, edited, wrong chain, service error) doesn't stop other Safes' files, but holds that Safe's later files, which would otherwise take its nonce or jump ahead of it; the run fails at the end, naming each file and why.
On a local node (Hardhat or Anvil by web3_clientVersion), or a network named hardhat or localhost, nothing is ever proposed: the files are rehearsed through the real Safe instead, and kept. Local files are named <safe>.json, replaced by each run, and marked FORK REHEARSAL, DO NOT SIGN; a fork run rehearses only what it staged itself.
- Proposer: defaults to Hardhat's
deployeraccount, signing through Hardhat's provider. Passsignerfor another one, e.g. when Hardhat has no key fordeployer: any object withaddressandsignMessage({ message: { raw } }), such as a viem account of any version (privateKeyToAccount(key),mnemonicToAccount(mnemonic)fromviem/accounts), or an ethers wallet wrapped as{ address: w.address, signMessage: ({ message }) => w.signMessage(getBytes(message.raw)) }(ethers v6; v5 usesutils.arrayify). It must be an owner or a proposer of the Safe (owners add proposers in the Safe UI settings). The signature is checked against that address before anything is posted. - Service: looked up per chain from Safe's config service; pass
txServiceUrlfor another one, or URLs by chain id ({ 743111: "https://…/api" }) for chains Safe doesn't serve. Either way it must report the node's chain. api.safe.global needs an API key (developer.safe.global):apiKeydefaults toSAFE_API_KEYfor staging, proposing and the tasks. Pass the same service options tostageSafeTxandproposeStagedSafeBatches. A redirect is refused rather than followed, since it would drop the key: oldsafe-transaction-<chain>.safe.globalURLs name the one to use. Requests time out after 30 seconds; a proposal that timed out may still have landed, and the next run finds it pending. - Service lag: right after an execution, the service may not have indexed it yet, so its proposal would look neither pending nor executed. While the service still lists the proposal at the Safe's latest used nonce as not executed, checking a file fails with "try again in a minute".
- What signers see: each proposal names
safe-opsas its origin (originto change it) and notes the file's name, e.g.Deploy 2026-10-01 15:30 UTC, in the Safe UI. - Nonce gaps: a proposal goes after every pending one, so a nonce nobody proposed yet holds it back; the verdict warns.
- Safe versions: proposing needs a Safe of 1.3.0 or later; before that, a call that fails still uses up the nonce.
- Duplicates while staging: a call identical to the latest call staged for the same contract is dropped with a warning, since deploy scripts check executed state and can't see staged calls.
x.update(1), x.update(2), x.update(1)keeps all three. A deliberate repeat, such as a secondharvest(), needs{ allowDuplicate: true }. - Targets must have code: calldata to an address without code does nothing on chain, so proposals and rehearsals refuse it unless
allowCallsWithoutCodeis set. - Impersonated Safes: when the node impersonates a Safe (hardhat-deploy's
autoImpersonate, or an impersonation on the node), its calls run directly and nothing is staged;stageSafeTxwarns. SetHARDHAT_DEPLOY_NO_IMPERSONATION=1(orautoImpersonate: false). - Calls that depend on each other: hardhat-deploy estimates gas for each call from the Safe first, so a call that only works after an earlier staged call (a setter after an upgrade) fails there. Stage those as
stageSafeTx(hre, { from: safe, to, data }).
// hardhat.config.ts
import { registerSafeTasks } from "@bloq/safe-ops/hardhat";
registerSafeTasks(task);npx hardhat safe:list --network mainnet # and what safe:propose would do with each
npx hardhat safe:rehearse --network hardhat --from mainnet # forks mainnet in-process, runs the exact files
npx hardhat safe:propose --network mainnet [--file …] [--yes] # shows the plan, asks before acting
npx hardhat safe:discard --network mainnet --file …safe:list on a live network runs the same status check as safe:propose and says what would happen to each file (proposed at which nonce, deleted as pending or executed, or kept as partial), without doing it. registerSafeTasks(task, { apiKey, txServiceUrl, signer }) sets the service and proposer for the tasks. safe:rehearse resets the in-process Hardhat network (it refuses any other) to a fork of --from's URL, and checks each file is for that chain. On a chain Hardhat doesn't know (Hemi, Plasma), calls at the fork block fail with "No known hardfork" unless the hardhat network sets chains: { 43111: { hardforkHistory: { cancun: 1 } } }. The rehearsal runs at the Safe's current nonce without the proposals already pending, so execute or reject those first if the batch depends on them.
nodeProvider(hre) is the node itself for an HTTP network: on a network with accounts (keys from .env), Hardhat's own provider signs locally and rejects impersonated owners (HH103). Rehearsals use it.
Everything takes an EIP-1193 provider and plain data; nothing reads files.
createSafeBatch,validateSafeBatch,safeBatchCalls: Transaction Builder files with the Safe UI's checksum.rehearseSafeBatch(provider, batch): runs a batch through its Safe on a local Hardhat or Anvil fork.executeOnFork(provider, safe, calls)does the same for raw calls:thresholdimpersonated owners approve, thenexecTransaction; batches go through the verified MultiSendCallOnly for the Safe's version (1.3.0 for older Safes), so they are atomic.resolveTxService,readSafeQueue,safeBatchStatus,proposeSafeBatch(provider, service, signer, batch, { origin, note }): the checks and proposal described above.decodeMultiSendCall: the calls inside amultiSend(bytes)payload, e.g. a batch's data copied from the Safe UI.classifyAccount,readSafe,resolveOwnerRoute(provider, owner, caller, { service }): EOA, EIP-7702 account, Safe or other contract, and how a caller can act for an owner: directly, through the owner's Safe, or not at all. A Safe is a proxy whose slot 0 holds an official Safe singleton (from safe-deployments); other contracts are never called. A caller that is not an owner of the Safe goes through it only as a proposer, which needsservice.isProposer(provider, service, safe, account): whether an account is a proposer (delegate) of a Safe, counted as the service does: its delegator must still be an owner, and it must not have expired.
pnpm install
pnpm lint
pnpm typecheck
pnpm test
FORK_URL=<ethereum rpc> HEMI_FORK_URL=<hemi rpc> pnpm test:fork # needs anvil
pnpm buildTo release, bump version on a branch and merge it, then on the merged main: pnpm release builds and tags v<version> on a commit that adds dist on top of main (on no branch), and prints the git push origin v<version> to run. The dev dependency on viem stays pinned to the oldest supported version, so the tests cover it.