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.
| Contract | Role |
|---|---|
MultisigWalletImpl | The implementation. Holds no state of its own; initializers are disabled on it. |
MultisigBeacon | An UpgradeableBeacon holding the implementation address. Proxies read it on every call. |
FoundationWallet | A 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:
| Change | Effect on existing confirmations |
|---|---|
Lowering numConfirmationsRequired | Invalidates every confirmation on every transaction |
| Removing an owner | Invalidates that owner’s confirmations everywhere |
Raising numConfirmationsRequired | No invalidation |
| Adding an owner | No 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.
upgradeToon 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 ofMultiSignatureWalletbehind a proxy that has no upgrade path, such as an OpenZeppelinERC1967Proxy, instead of using the beacon. ABeaconProxyfollows its beacon’s owner for as long as that beacon can be upgraded. - The beacon cannot be orphaned.
renounceOwnershipalways reverts: withOwnershipRenunciationDisabledwhen the beacon owner calls it, and withOwnableUnauthorizedAccountfor any other caller. Ownership transfer is two-step:transferOwnershiponly records a pending owner, who must callacceptOwnership. Ownership cannot move to an address that does not act.
Who can call what
| Function | Caller | Effect |
|---|---|---|
receive() | Anyone | Deposits native SUPRA into the wallet |
removeExpiredTransaction(uint256) | Anyone | Clears an expired transaction |
getOwners, getTransaction, getTransactionContentHash, hasValidNumberOfConfirmations, getNextTransactionIndex, hashTransactionContent, numConfirmationsRequired, txCount, maxTimeoutDuration | Anyone | Reads |
isConfirmed(uint256,address) | Anyone | Read, but reverts unless the queried address is an owner |
submitTransaction | Owner | Creates a transaction and confirms it |
confirmTransaction, revokeConfirmation | Owner | Adds or removes the caller’s confirmation, naming the transaction’s content hash |
executeTransaction | Owner | Executes once the threshold is met |
addOwners, removeOwners, updateNumConfirmations, updateMaxTimeoutDuration, cancelTransaction, deployContract | The wallet itself | Administration, 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:
| Function | Caller | Effect |
|---|---|---|
implementation(), owner(), pendingOwner() | Anyone | Reads |
upgradeTo(address) | Beacon owner | Replaces the implementation for every proxy |
transferOwnership(address) | Beacon owner | Records a pending owner |
acceptOwnership() | Pending owner | Completes the transfer |
renounceOwnership() | Beacon owner | Always reverts, for every caller |
When calls fail
| Error | Condition |
|---|---|
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.
| Event | Emitted when | Parameters |
|---|---|---|
Deposit(address,uint256,uint256) | Native SUPRA is sent to the wallet by a plain transfer | sender 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 transaction | owner 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 confirms | owner indexed — the confirming owner; txIndex indexed — the transaction confirmed |
RevokeConfirmation(address,uint256) | An owner revokes a confirmation | owner indexed — the revoking owner; txIndex indexed — the transaction |
ExecuteTransaction(address,uint256,bytes) | A transaction executes successfully | owner 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 cancelTransaction | txIndex indexed — the transaction cancelled |
TransactionExpired(uint256) | removeExpiredTransaction clears an expired transaction | txIndex indexed — the transaction removed |
ContractDeployed(address) | deployContract succeeds | deployedContract indexed — the new contract’s address |
OwnersAdded(address[]) | addOwners adds at least one owner | owners — the addresses actually added; addresses that were already owners are left out |
OwnersRemoved(address[]) | removeOwners removes at least one owner | owners — the addresses actually removed; addresses that were not owners are left out |
NumConfirmationUpdated(uint256) | The confirmation threshold changes | newNumConfirmation — the new threshold |
MaxTimeoutDurationUpdated(uint64) | The timeout cap changes | newMaxTimeoutDuration — 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
| Signature | Selector | Mutability |
|---|---|---|
getOwners() | 0xa0e67e2b | view |
getNextTransactionIndex() | 0x25322bd2 | view |
isConfirmed(uint256,address) | 0x80f59a65 | view |
getTransaction(uint256) | 0x33ea3dc8 | view |
hasValidNumberOfConfirmations(uint256) | 0x912e827c | view |
getTransactionContentHash(uint256) | 0xfe2bb3ea | view |
numConfirmationsRequired() | 0xd0549b85 | view |
txCount() | 0x3c540687 | view |
maxTimeoutDuration() | 0x528d226e | view |
hashTransactionContent(address,uint256,bytes) | 0x0d168a60 | pure |
submitTransaction(address,uint256,uint64,bytes) | 0x26738359 | payable |
confirmTransaction(uint256,bytes32) | 0xf04b58f9 | nonpayable |
executeTransaction(uint256,bytes32) | 0x14206043 | nonpayable |
revokeConfirmation(uint256,bytes32) | 0x6407297c | nonpayable |
removeExpiredTransaction(uint256) | 0x7ee75c45 | nonpayable |
cancelTransaction(uint256) | 0x3380c0d8 | nonpayable |
addOwners(address[]) | 0x6c46a2c5 | nonpayable |
removeOwners(address[]) | 0xa9a5e3af | nonpayable |
updateNumConfirmations(uint256) | 0xe29aff81 | nonpayable |
updateMaxTimeoutDuration(uint64) | 0x3f46dbc3 | nonpayable |
deployContract(bytes,uint256) | 0x2aa5cc28 | nonpayable |
initialize(address[],uint256) | 0x60b5bb3f | nonpayable |
MultisigBeacon:
| Signature | Selector | Mutability |
|---|---|---|
implementation() | 0x5c60da1b | view |
owner() | 0x8da5cb5b | view |
pendingOwner() | 0xe30c3978 | view |
upgradeTo(address) | 0x3659cfe6 | nonpayable |
transferOwnership(address) | 0xf2fde38b | nonpayable |
acceptOwnership() | 0x79ba5097 | nonpayable |
renounceOwnership() | 0x715018a6 | view, 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"
}
]Related
- System Contracts — the ownership model that routes all governance through the foundation wallet.
- AutomationRegistry and BlockMeta — the contracts whose owner-gated functions are reached through this wallet.