FREEDOM/FACTORY FREEDOM/FACTORY

Safe multisig transactions, decoded on the device

On 21 February 2025, Bybit lost roughly 401,000 ETH, about $1.4 billion, in a single transaction. Every signer used a hardware wallet. Every signer approved. Nobody was careless.

The transaction was a Safe multisig transaction, and the field that mattered was not on any screen.

This is the piece of self-custody that hardware wallets have quietly never solved. Not the key storage. Not the signature scheme. The gap between what you approve and what you can see. Below is exactly what a Safe transaction contains, why the standard device shows you a hash instead, and what PQ1™ puts on the screen in its place.

What a Safe transaction actually is

A Safe (formerly Gnosis Safe) is a smart-contract wallet. When owners “sign a transaction,” they are not signing an Ethereum transaction at all. They are signing an EIP-712 typed-data struct called SafeTx, with ten fields plus a nonce:

SafeTx(address to, uint256 value, bytes data, uint8 operation,
       uint256 safeTxGas, uint256 baseGas, uint256 gasPrice,
       address gasToken, address refundReceiver, uint256 nonce)

Those eleven values are hashed together with a domain separator (for Safe v1.3.0 and later, EIP712Domain(uint256 chainId, address verifyingContract)) into a single 32-byte digest, the safeTxHash. That digest is what the threshold of owners commits to.

Two things about this structure make it hostile to hardware wallets.

First, data is committed as a hash, not as bytes. The struct hash contains keccak256(data), not data itself. So the digest binds the calldata perfectly, and tells you absolutely nothing about it. A device holding only the safeTxHash has cryptographic certainty about a payload it cannot read.

Second, operation is a single byte that changes everything. operation = 0 is CALL: the Safe calls another contract, normally. operation = 1 is DELEGATECALL: the target’s code runs inside the Safe’s own storage context, with the Safe’s balance, the Safe’s permissions, and the Safe’s storage slots. A delegatecall can rewrite the Safe’s owner list, its threshold, its guard, or the pointer to its own implementation contract. It is one byte, sitting between two uint256s, and it is the difference between sending a payment and handing over the vault.

Why the device shows you a hash

There are two ways an owner approves a SafeTx, and both fail the same way on a conventional device.

Off-chain signature. The owner signs the safeTxHash with ECDSA. Those signatures are collected and passed to execTransaction. The device receives a 32-byte digest and a request to sign it. There is no preimage. There is nothing to decode.

On-chain approveHash. The owner sends a transaction calling approveHash(bytes32), selector 0xd4d9bdcd, on the Safe, which records approvedHashes[owner][safeTxHash] = 1. The calldata is 36 bytes: four bytes of selector, thirty-two bytes of opaque digest. Again, no preimage.

In both cases the only thing that knows what the transaction means is the web interface. The device shows 0x5a1f…, the interface shows “Transfer 10 ETH to Treasury,” and the user reconciles the two by faith.

That is not a UX complaint. It is the entire attack surface.

Bybit, precisely

The Bybit attack did not break any cryptography. Attackers compromised Safe{Wallet}’s developer infrastructure and served malicious JavaScript that activated only for Bybit’s specific Safe address. Post-incident forensics established the mechanism:

  • The transaction was an execTransaction with operation = 1, a DELEGATECALL.
  • The target was an attacker-deployed contract whose calldata carried the ERC-20 transfer(address,uint256) selector, so any naive decoder would render it as a token transfer.
  • That function’s actual body wrote to storage slot 0 of the caller. Under DELEGATECALL, the caller is the Safe, and slot 0 of a Safe holds the pointer to its implementation contract.

One approved transaction and the Safe was no longer a Safe. It was running the attacker’s code, and the attacker drained it.

The signers saw a plausible transfer in a compromised interface, and a digest on their devices. The single byte that turned a transfer into a takeover, operation, appeared on no screen in the loop. This was not an isolated shape: WazirX lost roughly $235 million in July 2024 to a Safe transaction that replaced the wallet’s implementation, and Radiant Capital lost roughly $50 million in October 2024 to signers approving payloads their interface misrepresented.

The industry’s response has largely been to improve the interfaces. That is backwards. A hardware wallet exists precisely because the interface may be lying.

What PQ1 does instead

PQ1™ never renders a Safe transaction it has not decoded and cryptographically bound itself, inside the secure world, from bytes it hashed with its own keccak implementation. It supports both entry points.

The approveHash path

The companion app attaches an optional trailer to the signing request carrying the canonical 281-byte SafeTx (every field, laid out at fixed offsets) plus the raw inner-call bytes. The device then runs an eight-step verification pipeline before a single pixel of semantics reaches the screen:

  1. Framing. The trailer is at least 281 + 2 bytes and the declared payload length fits inside the supplied buffer.
  2. Selector. The inner calldata bears approveHash(bytes32).
  3. Length. The calldata is exactly 36 bytes.
  4. Chain pinning. canonical.chain_id equals the transaction’s chain. A mainnet SafeTx cannot be replayed against an L2 request.
  5. Address pinning. canonical.safe_address equals the Safe being called.
  6. Operation gate. CALL passes. DELEGATECALL passes only in one tightly pinned shape (below). Everything else stops here.
  7. Data bind. keccak256(raw_data) equals the data_hash inside the canonical: the bytes the device is about to render are the bytes the digest commits to.
  8. Digest bind. The device recomputes the full EIP-712 safeTxHash from the canonical (domain separator, struct hash, final digest) and byte-compares it against calldata[4..36].

Step 8 is the load-bearing one, and it works because of an accident of design that favours the defender: the approveHash calldata contains the digest itself. The device does not need to trust that the trailer describes the transaction. It derives the digest from the trailer’s fields and checks it against the number the chain will record. If they match, the fields are the transaction. There is no gap left to lie in.

Any failure returns nothing, and then a second gate fires: an approveHash-shaped call with no verified trailer is refused outright, showing Safe sign / safe_v1 required. It does not fall back to a blind-sign page. If it did, a hostile companion could simply strip the trailer to force the user into the blind path, which is the very habituation this design exists to prevent.

That gate keys on the selector alone, not on calldata length. A 2026-06-28 internal audit found a parser differential here: Safe.approveHash(bytes32) ignores trailing calldata on-chain, so appending one padding byte made the old exact-length test false, skipped the mandatory gate, and fell through to a generic blind-sign of an approveHash that would pre-approve an arbitrary, never-displayed SafeTx. Since a Safe treats an owner’s approveHash as that owner’s full signature over the entire transaction, that was a complete bypass. Keying on the selector closed it.

The execTransaction path

When PQ1™ is the party that actually triggers execution, the SafeTx fields are encoded directly into the calldata’s argument list (selector 0x6a761202), so no trailer is needed. The device decodes them in place under strict rules: minimum head length, canonical address encoding (Solidity accepts non-canonical zero-extension on input; the firmware does not, so the on-device reading can never disagree with the chain’s), operation ∈ {0,1}, and fully bounds-checked dynamic-tail framing. A malformed or truncated execTransaction is refused, never downgraded to a lower rendering tier.

The Bybit rule

Here is the design decision that matters most, stated plainly:

PQ1™ refuses every DELEGATECALL except one.

The single exception is a batch: operation = 1 whose target is one of the three canonical MultiSendCallOnly deployments (0x40A2…130D for v1.3.0, 0xA1da…102B for v1.3.0 eip155, 0x9641…02e2 for v1.4.1) and whose calldata is a strict multiSend(bytes) payload, selector 0x8d80ff0a. This is the shape the Safe interface emits for anything multi-step, so refusing it outright would break ordinary use.

Even inside that exception, the payload faces hard rules:

  • Strict ABI framing. Head offset exactly 0x20, exact total length, zero padding. Anything Solidity would not emit is refused.
  • Per-record operation == 0. Mirrors MultiSendCallOnly’s own on-chain revert, so no nested delegatecall can ride inside a record even if the address allowlist were somehow wrong.
  • At most six records, each decoded individually (packed as op(1) ‖ to(20) ‖ value(32) ‖ dataLen(32) ‖ data) and each routed through the same rendering ladder as a top-level call, separated by divider pages.
  • Page-budget enforcement. If the full batch will not fit on screen, the device refuses to sign rather than truncating. A record the user never sees is exactly the failure this flow exists to close.

Plain MultiSend, which permits per-record delegatecall, is deliberately not on the allowlist, even though it exists and is widely deployed. A smaller allowlist fails closed.

Note what this rule is not. It is not a blocklist of known-malicious contracts, which would have been useless against Bybit: the attacker’s contract was deployed days earlier and appeared on no list anywhere. It is an allowlist of the only delegatecall shape that can be honestly explained to a non-expert on a four-row screen. The Bybit transaction fails at step 6 regardless of what its inner calldata decodes to, regardless of whether anyone has ever seen the target before, and regardless of what the compromised interface claimed. A DELEGATECALL is never blind-signed.

The five fields nobody shows you

A SafeTx carries five fields the inner call’s semantics never surface: safeTxGas, baseGas, gasPrice, gasToken, refundReceiver. They exist so an executor can be reimbursed. They are all folded into the safeTxHash, so they are tamper-proof the moment you sign. But bound and shown are not the same thing, and this is where a genuinely nasty drain lives.

When gasPrice > 0, execTransaction pays the executor (gasUsed + baseGas) × gasPrice, denominated in gasToken, sent to refundReceiver. For native ETH, Safe caps this with min(gasPrice, tx.gasprice). For a token refund, there is no cap.

So an attacker sets gasToken to whatever ERC-20 the Safe holds most of, refundReceiver to themselves, and gasPrice high enough that the “refund” equals the Safe’s entire balance of that token, all wrapped around an inner call that reads as transfer 1 USDC, or even an empty call. Every interface in the ecosystem renders the inner call. The drain is in the envelope.

PQ1™ renders a loud two-page block whenever any of gasPrice, gasToken or refundReceiver is non-zero, on both the approveHash and execTransaction paths:

! GAS REFUND        Refund to:
Safe pays in:       <full address
0xA0b8..6eB48        or tx.origin>

The exact amount cannot be shown, since it depends on runtime gas the device cannot know, but the token and the recipient are the facts a human can act on. That is my USDC, going to an address I do not recognise. The inner value the Safe forwards is likewise given its own page for every kind of transaction, not only for plain ETH transfers.

Changing the Safe itself

When a SafeTx targets the Safe’s own address, PQ1™ decodes the eight singleton-management selectors natively and renders each with an explicit intent banner rather than a function name:

Operation What the screen says
addOwnerWithThreshold Add Safe owner + new threshold, with ! MULTISIG OFF if it drops to 1
removeOwner Remove Safe ownr + the removed owner + new threshold
swapOwner Swap Safe owner + old and new owner, in full
changeThreshold Change thrshld + ! MULTISIG OFF at 1, ! THRSHLD = 0 at zero
enableModule ! ENABLE MODULE / Grants exec auth + the module address
disableModule Disable module + the module address
setGuard ! CHANGE GUARD / Grants veto/log on every tx, or REMOVING GUARD
setFallbackHandler ! CHG FALLBACK / Runs unkn calls

Details here are deliberate. Every address parameter must arrive canonically encoded, twelve zero bytes then twenty address bytes, or the decode fails and the transaction drops to the loud unknown path; the Solidity ABI is lenient about this on input and the firmware is not, so the screen can never disagree with the chain. Thresholds must fit in a uint16; anything larger renders as ! >64k rather than silently truncating to a plausible small number. The linked-list prev pointer that Safe’s owner and module lists use renders as the word SENTINEL when it is the 0x000…001 marker, so a user can tell a list pointer apart from a real address that happens to start with zeros.

“Add an owner and drop the threshold to 1” is a complete takeover, and it should read like one on the screen. It does.

What “decoded on the device” means here

The phrase gets used loosely, so it is worth being exact about the claim.

Nothing PQ1™ displays comes from the companion app as text. The companion supplies bytes (the canonical SafeTx, the raw inner calldata) and the device independently hashes them, re-derives the EIP-712 digest, and compares it against the value the chain will record. Only then does the renderer turn those verified bytes into words. Token symbols and decimals come from a Merkle-verified metadata bundle checked against a firmware-pinned root, and if the metadata’s contract address does not match the call’s target, the device silently falls back to raw hex rather than accepting a symbol for the wrong token. An approve for the maximum uint256 renders the word unlimited, not a 78-digit number a human will not read.

Where a token amount cannot be resolved, it renders as a hex head and tail the user can compare byte-for-byte against the dapp. Where the inner call is genuinely unknown, the device shows a loud ! BLIND SIGN page, and only after proving the call is absent from its own descriptor catalogue. A call the firmware ought to understand but could not bind is a hard refusal, not a blind page. The one thing that never happens is a confident-looking sentence with nothing behind it.

How we know the decoders are right

A decoder that disagrees with the EVM is worse than no decoder, because it produces a screen the user is entitled to believe. So the decoders are not just tested:

  • The typehashes verify themselves. Every CI run recomputes keccak256 of each canonical type string and asserts it equals the hardcoded constant, for the domain typehash, the SafeTx typehash, and all eight management selectors. A typo cannot survive a build.
  • The decoders are bounded-verified. The canonical SafeTx decoder, the execTransaction decoder, the multiSend framing decoder and the management-op classifier carry Kani proofs over symbolic input: freedom from panics, arithmetic overflow and out-of-bounds access, plus decode soundness: if the decoder accepts, every displayed field is a verbatim copy of its byte range in the original input. The reverse property, completeness, is deliberately not claimed: a false rejection means refusing to clear-sign, which is safe.
  • The batch walker is differentially tested against the real chain. The packed-record walk is diffed against the actual deployed MultiSendCallOnly v1.3.0 runtime bytecode executed in revm, with a call-tracing inspector recovering the exact (to, value, data) sequence the EVM dispatches. The asserted property is the dangerous direction: if the firmware accepts a batch, the EVM’s dispatched sequence is byte-identical to what the screen showed. The Rust parser is strict where the on-chain assembly is lenient: the assembly ignores trailing bytes and tolerates unbounded record counts. Every one of those divergences is a refusal, never an acceptance.

The oracle there is worth dwelling on: it is not our own reference implementation. It is a different language, written by a different team, executed by a third-party interpreter, running the exact bytecode at the address the firmware allowlists.

Where the post-quantum part comes in

PQ1™ signs SPHINCS+C10, not ECDSA. Safe contracts cannot verify a SPHINCS+ signature, so the off-chain signature path is closed to us. The approveHash path is not, and that turns out to be a feature.

approvedHashes[owner][safeTxHash] counts toward the threshold no matter how the owner arrived at it. The Safe does not inspect a signature; it reads a storage slot the owner set. So a PQ1™ smart account can be an owner of an existing Safe today, with no changes to Safe’s contracts, no ECDSA fallback anywhere on the device, and no migration for the other owners. Every other owner keeps signing exactly as they do now. Yours is the one that survives a quantum adversary and, more immediately, the one that will not approve a delegatecall.

What it does not do

Being precise about the limits is the point of the exercise:

  • Safe v1.1.x and earlier are not supported. They used a domain separator without chainId, so our recomputed digest will not match the calldata and the transaction is refused. They self-police; we do not pretend otherwise.
  • Arbitrary DELEGATECALL is refused, not warned about. There is no honest way to explain “run this unknown contract’s code inside your vault” on four rows of sixteen characters. A future version may add an explicit expert opt-in behind its own threat model. This one refuses.
  • The refund amount is not shown, because it depends on runtime gas consumption. The token and recipient are.
  • Batches beyond six records, or beyond the page budget, are refused rather than summarised.
  • Truly unknown inner calls still produce a blind-sign page. The device tells you loudly that it does not understand, shows you the target, selector, length, and a hash you can compare against the dapp, and leaves the decision with you. Clear-signing everything is not a claim anyone can honestly make. Refusing to pretend is.

The short version

Bybit’s signers did nothing wrong. They used hardware wallets, they checked what was on the screen, and the screen did not contain the field that mattered. Blind signing is not a UX inconvenience; it is the largest exploitable surface in self-custody, and the industry has spent a decade normalising it.

PQ1™ takes the opposite position. If the device cannot prove what a transaction does, it does not sign it. If it can, it says so in words, derived from bytes it verified itself, including the fields nobody else shows you.

Every line of that decoding is published under GPLv3. You do not have to take our word for any of it.

They ask you to trust. We let you verify.

PQ1™.

Related reading

Built for what comes next

PQ1™ is the first hardware wallet that protects Ethereum and EVM holdings against quantum computers. Hash-based post-quantum signing (SPHINCS+, per the NIST FIPS-205 design) in an open-firmware, air-gapped device. $179.

Reserve PQ1™