Skip to Content

MultiSignatureWallet

MultiSignatureWallet holds funds and executes calls that require approval from several owners. A transaction is submitted, confirmed by owners until the threshold is met, and then executed by any owner.

The genesis deployment serves two purposes. One instance, the foundation wallet, owns BlockMeta, the automation registry and MultisigBeacon, so governance of those contracts goes through it. Separately, the implementation sits behind a beacon that anyone may point their own proxy at, which makes the same wallet available to any user without redeploying it.

ContractRole
MultisigWalletImplThe implementation. Holds no state of its own; initializers are disabled on it.
MultisigBeaconAn UpgradeableBeacon holding the implementation address. Proxies read it on every call.
FoundationWalletA BeaconProxy instance used for governance. Owner of BlockMeta, the automation registry and MultisigBeacon.

Finding them

Read the addresses under the keys MultisigWalletImpl, MultisigBeacon and FoundationWallet in the contract registry, the 0x1::evm_config::EvmContractsDetails resource.

The examples on this page use these shell variables. RPC is a JSON-RPC endpoint, not a host. MULTISIG_BEACON is Devnet’s beacon, read from the contract registry; the foundation wallet’s Devnet address is in Addresses on Devnet. The other values are the reader’s:

RPC=https://rpc-devnet.supra.com/rpc/v1/eth/wallet_integration MULTISIG_BEACON=0xaf2ac32aeed6584ca7dcfe19f3fc16ec7b2d0060 WALLET="<address of the wallet proxy>" TARGET="<address the wallet's transaction calls>" CALLDATA="<hex calldata of that call>" CONTENT_HASH="<content hash the wallet recorded for the transaction>" NEW_OWNER="<address of the owner to add>" ALICE="<address of the first owner of a new wallet>" BOB="<address of the second owner>" CAROL="<address of the third owner>"

The beacon also reports the current implementation:

cast call "$MULTISIG_BEACON" "implementation()(address)" --rpc-url "$RPC"

The transaction lifecycle

A transaction is identified by its index and bound to its content by a hash.

# 1. An owner submits. Submitting counts as that owner's confirmation. cast send "$WALLET" "submitTransaction(address,uint256,uint64,bytes)" \ "$TARGET" 0 86400 "$CALLDATA" \ --keystore ./owner1 --rpc-url "$RPC" # 2. Read the content hash the wallet recorded. CONTENT_HASH=$(cast call "$WALLET" "getTransactionContentHash(uint256)(bytes32)" 0 --rpc-url "$RPC") # 3. Other owners confirm, passing that hash. cast send "$WALLET" "confirmTransaction(uint256,bytes32)" 0 "$CONTENT_HASH" \ --keystore ./owner2 --rpc-url "$RPC" # 4. Once the threshold is met, any owner executes. cast send "$WALLET" "executeTransaction(uint256,bytes32)" 0 "$CONTENT_HASH" \ --keystore ./owner1 --rpc-url "$RPC" # An owner may instead revoke, naming the same hash. cast send "$WALLET" "revokeConfirmation(uint256,bytes32)" 0 "$CONTENT_HASH" \ --keystore ./owner2 --rpc-url "$RPC"

The wallet deletes a transaction when it executes it, so a call that names an executed transaction’s index, such as getTransaction, getTransactionContentHash or revokeConfirmation, reverts with InvalidTxnId.

confirmTransaction, executeTransaction and revokeConfirmation all take the content hash and revert with TransactionContentMismatch if it does not match the stored transaction. This binds an approval — or its revocation — to exact calldata rather than to an index. Compute the expected hash independently before confirming, rather than trusting the value the wallet reports:

cast call "$WALLET" "hashTransactionContent(address,uint256,bytes)(bytes32)" \ "$TARGET" 0 "$CALLDATA" --rpc-url "$RPC"

hashTransactionContent is pure, so it can be evaluated off-chain: keccak256(abi.encode(to, value, data)). The timeout is not part of the hash.

Checking progress:

cast call "$WALLET" "hasValidNumberOfConfirmations(uint256)(bool)" 0 --rpc-url "$RPC" cast call "$WALLET" "getTransaction(uint256)(address,uint256,uint24,uint64,bytes)" 0 --rpc-url "$RPC"

Expiry

Every transaction carries a timeout, set at submission and capped by maxTimeoutDuration, which is 30 days at initialization. Once block.timestamp passes the timeout, the transaction can no longer be confirmed, executed or revoked; those calls revert with TransactionAlreadyExpired. getTransaction, getTransactionContentHash, hasValidNumberOfConfirmations and isConfirmed revert on an expired transaction as well.

removeExpiredTransaction clears an expired entry and is callable by anyone, not only owners, so cleanup does not consume owner attention.

Confirmation invalidation

Confirmations are stamped with two epoch counters and are only counted while both still match:

ChangeEffect on existing confirmations
Lowering numConfirmationsRequiredInvalidates every confirmation on every transaction
Removing an ownerInvalidates that owner’s confirmations everywhere
Raising numConfirmationsRequiredNo invalidation
Adding an ownerNo invalidation

Lowering the threshold invalidates everything because a pending transaction that had gathered confirmations under a higher bar would otherwise become executable the instant the bar dropped. Removing an owner is stamped per owner, so re-adding a previously removed owner does not restore their old confirmations.

After either change, owners must confirm again. hasValidNumberOfConfirmations reflects the post-change count, so check it rather than assuming earlier confirmations still stand.

revokeConfirmation is gated on raw confirmation membership rather than on the epochs, so an owner can revoke a confirmation that no longer counts, while the transaction is unexpired and the caller is still an owner. It requires the caller to name the transaction’s content hash.

Administering the wallet

The functions that change the owner set, the threshold and the timeout cap are callable only by the wallet itself. They cannot be invoked directly by an owner; they must be the target of a transaction the wallet submits, confirms and executes against its own address.

# Add an owner: the wallet calls itself. CALLDATA=$(cast calldata "addOwners(address[])" "[$NEW_OWNER]") cast send "$WALLET" "submitTransaction(address,uint256,uint64,bytes)" \ "$WALLET" 0 86400 "$CALLDATA" --keystore ./owner1 --rpc-url "$RPC"

When the call that executeTransaction makes reverts, executeTransaction reverts with ExecutionFailed and the transaction stays pending.

deployContract is in the same category: the wallet performs a raw CREATE with creation code supplied by an executed transaction.

Deploying your own wallet

MultisigBeacon is a standard OpenZeppelin UpgradeableBeacon with a public implementation(), and there is no factory, allowlist or registration step. Any account can deploy a BeaconProxy against it and get a wallet running the same implementation the foundation wallet uses, without deploying that implementation again. The foundation wallet is itself such an instance.

import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; address[] memory owners = new address[](3); owners[0] = alice; owners[1] = bob; owners[2] = carol; BeaconProxy wallet = new BeaconProxy( MULTISIG_BEACON, // initialize is declared on the implementation, not on IMultiSignatureWallet. abi.encodeWithSignature("initialize(address[],uint256)", owners, 2) );

The same without Solidity, deploying the stock OpenZeppelin BeaconProxy bytecode. Run it from a Foundry project in which OpenZeppelin Contracts v5.5.0 is installed, with the install command under Interfaces and ABIs, and use a keystore that holds SUPRA for gas:

INIT=$(cast calldata "initialize(address[],uint256)" "[$ALICE,$BOB,$CAROL]" 2) # --constructor-args takes every argument after it, so it goes last. forge create \ lib/openzeppelin-contracts/contracts/proxy/beacon/BeaconProxy.sol:BeaconProxy \ --keystore ./keystore --rpc-url "$RPC" --broadcast \ --constructor-args "$MULTISIG_BEACON" "$INIT"

Two consequences follow from the beacon being shared:

  • The beacon owner controls the implementation for every proxy. upgradeTo on the beacon changes behaviour for all proxies pointing at it, including wallets deployed by users. On the genesis beacon the owner is the foundation wallet, so an upgrade needs a transaction executed by that wallet. A user who needs the implementation to be immutable for their wallet deploys their own copy of MultiSignatureWallet behind a proxy that has no upgrade path, such as an OpenZeppelin ERC1967Proxy, instead of using the beacon. A BeaconProxy follows its beacon’s owner for as long as that beacon can be upgraded.
  • The beacon cannot be orphaned. renounceOwnership always reverts: with OwnershipRenunciationDisabled when the beacon owner calls it, and with OwnableUnauthorizedAccount for any other caller. Ownership transfer is two-step: transferOwnership only records a pending owner, who must call acceptOwnership. Ownership cannot move to an address that does not act.

Who can call what

FunctionCallerEffect
receive()AnyoneDeposits native SUPRA into the wallet
removeExpiredTransaction(uint256)AnyoneClears an expired transaction
getOwners, getTransaction, getTransactionContentHash, hasValidNumberOfConfirmations, getNextTransactionIndex, hashTransactionContent, numConfirmationsRequired, txCount, maxTimeoutDurationAnyoneReads
isConfirmed(uint256,address)AnyoneRead, but reverts unless the queried address is an owner
submitTransactionOwnerCreates a transaction and confirms it
confirmTransaction, revokeConfirmationOwnerAdds or removes the caller’s confirmation, naming the transaction’s content hash
executeTransactionOwnerExecutes once the threshold is met
addOwners, removeOwners, updateNumConfirmations, updateMaxTimeoutDuration, cancelTransaction, deployContractThe wallet itselfAdministration, reachable only through an executed transaction

MultiSignatureWallet is not Ownable. It has no owner() and no renounceOwnership; authority rests entirely with the owner set and the threshold.

On MultisigBeacon:

FunctionCallerEffect
implementation(), owner(), pendingOwner()AnyoneReads
upgradeTo(address)Beacon ownerReplaces the implementation for every proxy
transferOwnership(address)Beacon ownerRecords a pending owner
acceptOwnership()Pending ownerCompletes the transfer
renounceOwnership()Beacon ownerAlways reverts, for every caller

When calls fail

ErrorCondition
NotAnOwner()An owner-gated function called by a non-owner, or isConfirmed queried for a non-owner
OnlyMultisigAccountCanCall()An administration function called by anything other than the wallet itself
InvalidTxnId()The transaction index does not exist
TransactionAlreadyExpired()The transaction’s timeout has passed
NotExpired()removeExpiredTransaction called before the timeout
TransactionContentMismatch()confirmTransaction, executeTransaction or revokeConfirmation called with a hash that does not match the stored transaction
TxnAlreadyConfirmed()The caller has already confirmed, under the current epochs
TransactionNotConfirmed()revokeConfirmation with no confirmation recorded for the caller
NotEnoughConfirmation()executeTransaction before the threshold is met
ExecutionFailed(bytes)The executed call reverted. The payload is the callee’s return data
InvalidRecipient()submitTransaction with a zero to
TimeoutTooLong()submitTransaction with a timeout above maxTimeoutDuration
InvalidMaxTimeoutDuration()updateMaxTimeoutDuration with zero
OwnersRequired()An empty owner array passed to initialize, addOwners or removeOwners
InvalidOwner()A zero address in an owner array passed to initialize or addOwners; removeOwners does not check for it
OwnerNotUnique()A duplicate address in the initial owner array
InvalidNumberOfConfirmations()A threshold of zero or above the owner count, or a removal that would leave fewer owners than the threshold
EmptyCreationCode()deployContract with empty creation code
ContractCreationFailed(bytes)The CREATE in deployContract returned the zero address
OwnershipRenunciationDisabled()renounceOwnership on MultisigBeacon called by its owner; other callers get OwnableUnauthorizedAccount

Events

Parameters marked indexed are log topics; the rest are ABI-encoded in the log data.

EventEmitted whenParameters
Deposit(address,uint256,uint256)Native SUPRA is sent to the wallet by a plain transfersender indexed — the account that sent it; amount — the amount received; balance — the wallet’s balance after the deposit
SubmitTransaction(address,uint256,address,uint256,bytes)An owner submits a transactionowner indexed — the submitting owner; txIndex indexed — the index assigned to the transaction; to indexed — the account the transaction will call; value — the native SUPRA it will send; data — the calldata it will send
ConfirmTransaction(address,uint256)An owner confirmsowner indexed — the confirming owner; txIndex indexed — the transaction confirmed
RevokeConfirmation(address,uint256)An owner revokes a confirmationowner indexed — the revoking owner; txIndex indexed — the transaction
ExecuteTransaction(address,uint256,bytes)A transaction executes successfullyowner indexed — the owner who called executeTransaction; txIndex indexed — the transaction executed; txData — the return data of the executed call, not its calldata
TransactionCancelled(uint256)The wallet cancels a transaction through cancelTransactiontxIndex indexed — the transaction cancelled
TransactionExpired(uint256)removeExpiredTransaction clears an expired transactiontxIndex indexed — the transaction removed
ContractDeployed(address)deployContract succeedsdeployedContract indexed — the new contract’s address
OwnersAdded(address[])addOwners adds at least one ownerowners — the addresses actually added; addresses that were already owners are left out
OwnersRemoved(address[])removeOwners removes at least one ownerowners — the addresses actually removed; addresses that were not owners are left out
NumConfirmationUpdated(uint256)The confirmation threshold changesnewNumConfirmation — the new threshold
MaxTimeoutDurationUpdated(uint64)The timeout cap changesnewMaxTimeoutDuration — the new cap, in seconds

OwnersAdded and OwnersRemoved are not emitted when a call changes nothing. submitTransaction is payable, but native SUPRA sent with it emits no Deposit; only a plain transfer to the wallet does.

The four administration events index nothing, so filtering for governance changes matches on the wallet address and the event signature rather than on a topic.

Interfaces

Solidity

The full interface is src/interfaces/IMultiSignatureWallet.sol. Its entry points:

interface IMultiSignatureWallet { function submitTransaction(address _to, uint256 _value, uint64 _timeoutDuration, bytes memory _data) external payable; function confirmTransaction(uint256 _txIndex, bytes32 _contentHash) external; function executeTransaction(uint256 _txIndex, bytes32 _contentHash) external returns (bytes memory); function revokeConfirmation(uint256 _txIndex, bytes32 _contentHash) external; function removeExpiredTransaction(uint256 _txIndex) external; function addOwners(address[] memory _owners) external; function removeOwners(address[] memory _owners) external; function updateNumConfirmations(uint256 _numConfirmationsRequired) external; function updateMaxTimeoutDuration(uint64 _newMaxTimeoutDuration) external; function cancelTransaction(uint256 _txIndex) external; function deployContract(bytes memory _creationCode, uint256 _value) external returns (address deployed); function getOwners() external view returns (address[] memory); function getNextTransactionIndex() external view returns (uint256); function isConfirmed(uint256 _txIndex, address _owner) external view returns (bool); function getTransaction(uint256 _txIndex) external view returns (address to, uint256 value, uint24 numConfirmations, uint64 timeout, bytes memory data); function hasValidNumberOfConfirmations(uint256 _txIndex) external view returns (bool); function getTransactionContentHash(uint256 _txIndex) external view returns (bytes32); function hashTransactionContent(address _to, uint256 _value, bytes memory _data) external pure returns (bytes32); function numConfirmationsRequired() external view returns (uint256); function txCount() external view returns (uint256); function maxTimeoutDuration() external view returns (uint64); }

initialize(address[],uint256) is not declared on the interface; it is called once through the proxy constructor.

Signatures and selectors

SignatureSelectorMutability
getOwners()0xa0e67e2bview
getNextTransactionIndex()0x25322bd2view
isConfirmed(uint256,address)0x80f59a65view
getTransaction(uint256)0x33ea3dc8view
hasValidNumberOfConfirmations(uint256)0x912e827cview
getTransactionContentHash(uint256)0xfe2bb3eaview
numConfirmationsRequired()0xd0549b85view
txCount()0x3c540687view
maxTimeoutDuration()0x528d226eview
hashTransactionContent(address,uint256,bytes)0x0d168a60pure
submitTransaction(address,uint256,uint64,bytes)0x26738359payable
confirmTransaction(uint256,bytes32)0xf04b58f9nonpayable
executeTransaction(uint256,bytes32)0x14206043nonpayable
revokeConfirmation(uint256,bytes32)0x6407297cnonpayable
removeExpiredTransaction(uint256)0x7ee75c45nonpayable
cancelTransaction(uint256)0x3380c0d8nonpayable
addOwners(address[])0x6c46a2c5nonpayable
removeOwners(address[])0xa9a5e3afnonpayable
updateNumConfirmations(uint256)0xe29aff81nonpayable
updateMaxTimeoutDuration(uint64)0x3f46dbc3nonpayable
deployContract(bytes,uint256)0x2aa5cc28nonpayable
initialize(address[],uint256)0x60b5bb3fnonpayable

MultisigBeacon:

SignatureSelectorMutability
implementation()0x5c60da1bview
owner()0x8da5cb5bview
pendingOwner()0xe30c3978view
upgradeTo(address)0x3659cfe6nonpayable
transferOwnership(address)0xf2fde38bnonpayable
acceptOwnership()0x79ba5097nonpayable
renounceOwnership()0x715018a6view, always reverts

JSON ABI

Generate it with forge inspect IMultiSignatureWallet abi --json; see Interfaces and ABIs. The fragments for the confirm-and-execute path:

[ { "type": "function", "name": "confirmTransaction", "inputs": [ { "name": "_txIndex", "type": "uint256" }, { "name": "_contentHash", "type": "bytes32" } ], "outputs": [], "stateMutability": "nonpayable" }, { "type": "function", "name": "executeTransaction", "inputs": [ { "name": "_txIndex", "type": "uint256" }, { "name": "_contentHash", "type": "bytes32" } ], "outputs": [{ "name": "", "type": "bytes" }], "stateMutability": "nonpayable" }, { "type": "function", "name": "hashTransactionContent", "inputs": [ { "name": "_to", "type": "address" }, { "name": "_value", "type": "uint256" }, { "name": "_data", "type": "bytes" } ], "outputs": [{ "name": "", "type": "bytes32" }], "stateMutability": "pure" } ]
Last updated on