Offline Safe Transactions
⚗️ Experimental — available at next.evmcrispr.com.
The -offline commands of the safe module never contact the Safe
Transaction Service. They put Safe transaction JSON (or Safe message
JSON) in a variable, and owners exchange it however they like: chat, files,
IPFS, or the CLI's stdin. Nothing about the transaction is stored anywhere
but in that JSON until someone executes it. For the service and the Safe web
app, see Operating a Safe.
"Offline" means without the Safe Transaction Service. RPC access is still
used to read the Safe's owners, threshold, and nonce, and the nonce defaults
to the current on-chain nonce, since pending service proposals are not
consulted. The same EVML runs in the terminal and with
evmcrispr run workflow.evml. The Safe module is experimental; the CLI
accepts --experimental. All addresses, hashes, and nonces below are
placeholders. Each code block is a separate script, unless stated otherwise.
The offline commands
Section titled “The offline commands”| Step | Command | Result |
|---|---|---|
| Propose | safe:propose-offline $tx <safe> <block | cancel | "message"> | Unsigned JSON in $tx. No wallet needed. |
| Confirm | safe:confirm-offline $tx <safe> <$tx | safeTxHash> | The JSON with your signature added, after reviewing it. |
| Combine | @safe:merge($a $b …) | One JSON with the signatures of copies signed in parallel. |
| Confirm on-chain | safe:confirm-onchain <safe> $tx | An approveHash transaction; nothing to exchange. |
| Execute | safe:execute <safe> $tx | The transaction, executed. |
| Hand to a dapp | @safe:signature(<safe> $msg) | The packed owner signatures of a Safe message. |
@safe:verify(<safe> $tx) is not a step
of its own. It works on JSON as well as on queued transactions, and returns
the review that the confirm and execute commands already run, without
signing anything.
User flows
Section titled “User flows”| # | Flow | Commands | Section |
|---|---|---|---|
| 1 | Team exchanging JSON | propose-offline → confirm-offline (each owner) → @safe:merge → execute $tx | Prepare, sign, execute |
| 2 | On-chain confirmations from JSON | propose-offline → confirm-onchain $tx (each owner) → execute $tx | Confirm on-chain |
| 3 | Moving between the service and JSON | propose $tx (JSON → service), confirm-offline $tx <hash> (service → JSON) | Service and JSON |
| 4 | Owner Safes | confirm-offline, run by each owner of the owner Safe | Owner Safes |
| 5 | Signing a message for a dapp | propose-offline $msg "text" → confirm-offline $msg $msg → @safe:signature | Sign messages |
| 6 | Cancelling a pending transaction | propose-offline $r cancel --nonce <n>, then flow 1 or 2 | Cancel |
| 7 | Air-gapped review | @safe:verify($safe $tx no-rpc:true) | Review without signing |
Prepare a Safe transaction
Section titled “Prepare a Safe transaction”Anyone, including a non-owner without a wallet, can prepare the unsigned
transaction with safe:propose-offline:
load safe
set $safe 0x1111111111111111111111111111111111111111set $receiver 0x2222222222222222222222222222222222222222
safe:propose-offline $tx $safe ( exec @token(DAI) "transfer(address,uint256)" $receiver 100e18)print $txShare the complete JSON, not the block. Rebuilding a block later can produce a different transaction if a helper read, such as a balance or nonce, has changed in the meantime.
Save it to a file
Section titled “Save it to a file”Only print writes to stdout. Status messages and signing hashes go to
stderr, so the printed JSON can be redirected straight into a file. With the
script above saved as prepare.evml:
evmcrispr --experimental run prepare.evml > transaction.jsonPrint only the Safe transaction when exporting a JSON file: several print
commands produce consecutive outputs, not a single JSON document. In the
terminal, Download output saves the same printed text.
Load it back
Section titled “Load it back”Scripts read input with @fetch(stdin:). EVML cannot read filesystem paths or
file: URLs, so the host always supplies the data:
- CLI: pipe the file into the command, for example
cat transaction.json | evmcrispr --experimental run sign.evml. Input comes from the shell, independently of the script file.evmcrispr run -instead reads the script itself from stdin, so it cannot also supply data. - Terminal: use Choose input file before running. The input stays in memory and is not uploaded or saved.
- Terminal link: pass URL-encoded text in the
?stdin=parameter. In a hash link, it goes after the script path.
Simulation uses the same supplied input.
Review and sign
Section titled “Review and sign”Each owner loads the Safe transaction into $tx and adds a signature with
safe:confirm-offline. Save this as sign.evml:
load safeload http [@fetch]
set $safe 0x1111111111111111111111111111111111111111set $tx @fetch(stdin:)
safe:confirm-offline $tx $safe $txprint $txsafe:confirm-offline reviews the JSON before your wallet prompts, the same
way safe:confirm reviews a queued transaction. It recomputes the domain
hash, message hash and safeTxHash from the JSON and prints them: compare
them with what your wallet displays before confirming on the device. It also
checks every call, and refuses on a blocking finding, such as a delegatecall
to an unknown contract, a gas refund, or a change of owners, threshold,
modules, guards or the fallback handler. It goes ahead only once you name
what you reviewed. The refusal ends with the options to paste, for example
--allow-new-owners 0x3333333333333333333333333333333333333333 --allow-change-threshold-to 3.
Because the options name the new owners, threshold, guards and targets,
they do not cover a JSON file that was changed after you reviewed it. The
Safe guide lists the
findings and their options. The other transactions queued at the same nonce
are only known to the Safe Transaction Service, so they are checked when
you export a queued transaction by its safeTxHash, not for JSON.
From the CLI, sign with an external signing provider:
cat transaction.json | evmcrispr --experimental run sign.evml --wallet-rpc http://127.0.0.1:8545 --account 0x1111111111111111111111111111111111111111 > signed.jsonUse an account controlled by that wallet endpoint. The terminal uses its
connected wallet instead. RPC reads use the EVMCRISPR_RPC_URL and per-chain
settings in the CLI, and the chain configuration in the terminal.
Owners can sign one after another, each passing on the JSON they received plus their signature, or in parallel from the same unsigned file.
Review without signing
Section titled “Review without signing”To inspect the JSON before anyone signs, for example on a machine without a
wallet, @safe:verify returns the same
review as JSON: hashes, decoded calls, findings, signatures, readiness, and
a verdict with the options a signing command would need under
requires:
load safeload http [@fetch]
set $safe 0x1111111111111111111111111111111111111111set $tx @fetch(stdin:)
print @safe:verify($safe $tx)For a strictly air-gapped review, pass no-rpc:true. It makes no network
calls; ownership, threshold, and nonce are then reported as unchecked.
Without the Safe's current state, owner, threshold, guard and handler
changes are reported call by call, not by what they leave the Safe as, so
requires can list a threshold or guard that is already set.
Combine and execute
Section titled “Combine and execute”Owners who signed in parallel produce separate JSON; merge it with
@safe:merge. Once the signatures add up to the threshold, any account
executes it. The shell can assemble the signed files into one JSON array
with jq, or you can select an equivalent file in the terminal:
jq -s . owner-a.json owner-b.json | evmcrispr --experimental run execute.evmlload safeload http [@fetch]
set $safe 0x1111111111111111111111111111111111111111set $input @fetch(stdin:)set $tx @safe:merge(@http:json($input "[0]") @http:json($input "[1]"))
print @http:json(@safe:verify($safe $tx) readiness)safe:execute $safe $txready only means that the signatures and the nonce are sufficient. Execution
can still fail for insufficient funds, a guard, or the underlying call.
Execution checks the chain, Safe, hash, current nonce, owner membership, and
threshold again. It sorts and deduplicates signers and fails on
ExecutionFailure even when the outer transaction succeeds. An owner who
executes counts as a confirmation, so the JSON can be one signature short
when an owner sends it.
Confirm on-chain from JSON
Section titled “Confirm on-chain from JSON”Instead of signing, owners can confirm on-chain: each one sends approveHash
for the transaction in the JSON with safe:confirm-onchain. The Safe records
the confirmation, so the JSON only has to reach whoever executes, and it does
not need to carry any signatures:
safe:confirm-onchain $safe $txsafe:execute $safe $txOn-chain confirmations and off-chain signatures in the JSON can be mixed in one execution. An on-chain confirmation cannot be withdrawn: to cancel it, execute a rejection at the same nonce.
Move between the service and JSON
Section titled “Move between the service and JSON”Signed JSON can be handed to the Safe web app at any point.
safe:propose $safe $tx posts it: its first owner signature proposes it and
the rest become confirmations, with no wallet prompt, so a non-owner can post
owner-signed JSON. It reviews the JSON first, like safe:confirm-offline,
and also checks the service for other transactions queued at its nonce. In the other direction,
safe:confirm-offline $tx $safe <safeTxHash> exports a queued transaction
with its confirmations plus your signature:
load safe
set $safe 0x1111111111111111111111111111111111111111safe:confirm-offline $tx $safe 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaprint $txOwner Safes
Section titled “Owner Safes”When a Safe owns the Safe you sign for, its owners run the same
sign.evml as a direct owner. safe:confirm-offline finds how your wallet
owns the Safe, up to three levels deep, and stores your signature under the
owner Safe in the JSON. Each owner of the owner Safe adds theirs, and once
its threshold is met the owner Safe's contract signature is packed into the
transaction's signatures. --via <ownerSafe> picks the owner Safe when you
own several at the same depth.
@safe:verify shows an owner Safe's progress while its owners sign
(incomplete, 1 of 2). This is also how a proposal from a multisig owner
Safe starts: safe:propose refuses one that needs more than your signature,
so collect its signatures here and then post the JSON with
safe:propose $safe $tx.
Sign messages offline
Section titled “Sign messages offline”Dapps ask a Safe to sign off-chain messages (EIP-1271), such as a login or an
order. safe:propose-offline takes a quoted string (an EIP-191 text message)
or EIP-712 typed-data JSON and returns Safe message JSON. Owners sign it with
safe:confirm-offline, like a transaction, and
@safe:signature returns the packed signatures to hand to the dapp once the
threshold is met:
load safe
set $safe 0x1111111111111111111111111111111111111111safe:propose-offline $msg $safe "I agree to the terms"safe:confirm-offline $msg $safe $msgprint @safe:signature($safe $msg)In a Safe with more than one owner, print $msg after your signature and
pass it on, as with a transaction; merge copies signed in parallel with
@safe:merge.
Cancel offline
Section titled “Cancel offline”A pending transaction is cancelled by executing another one at its nonce.
cancel in place of the block prepares the rejection the Safe web app uses,
a zero-value call from the Safe to itself:
load safe
set $safe 0x1111111111111111111111111111111111111111safe:propose-offline $rejection $safe cancel --nonce 42print $rejectionSign and execute it like any other Safe transaction. Deleting the JSON of
the original transaction does not cancel it: a Safe transaction stays
executable until its nonce is used, and an on-chain approveHash approval
stays in place.
Drafts and sharing
Section titled “Drafts and sharing”Drafts are ordinary files. Keep them wherever the owners already exchange
documents, and feed them back in as described in
Load it back. To share a Safe transaction by CID, use @ipfs
and @ipfs.get. Uploading currently uses Pinata and is optional; local files
need no pinning service.