CompanyWallet Documentation
Overview
CompanyWallet is a minimal execution wallet for DEUSS company accounts.
It is intentionally narrow in scope:
- stores wallet ownership,
- stores the active
PolicyRegistry, - accepts native currency,
- accepts ERC-721 and ERC-1155 safe transfers,
- executes arbitrary external calls,
- lets the owner manually advance the policy epoch for incident recovery,
- delegates non-owner authorization to
PolicyRegistry.
The wallet does not store execution roles, operation-role mappings, delegated policy admins, or template state.
It also does not call EntityRegistry during runtime authorization.
Operational deployment patterns, including the recommended Root-Controlled Subwallet Mode for broker and custodian wallet fleets, are documented in the security operational model.
Prerequisites
- Contract is deployed as the implementation behind the CompanyWallet beacon.
- Proxy instance must be initialized through
initialize(owner, policyRegistry). ownermust be a non-zero address.policyRegistrymust be a deployed contract address implementingIPolicyRegistry.- Non-owner execution requires the configured policy registry to allow the call.
Contract Architecture
CompanyWallet inherits:
ICompanyWalletCompanyWalletStorageOwnableReentrancyGuardInitializableERC721HolderERC1155Holder
Key architecture decisions:
- Owner is the root authority and bypasses external policy checks.
- Non-owner execution is delegated to
PolicyRegistry.canExecute(wallet, caller, target, value, data). - Call validation remains local to the wallet because it is execution hygiene, not business policy.
- Native currency can be deposited directly via
receive()or forwarded atomically throughexecute(...). - ERC-721 and ERC-1155 assets can be deposited through standard
safeTransferFrom(...)flows. - Revert data from downstream calls is bubbled up through
Address.verifyCallResult. - The wallet rejects self-calls, zero targets, EOAs, and calldata shorter than 4 bytes.
- Pure native transfers to EOAs are not supported by
execute(...)because the target must be a contract and calldata must contain at least a function selector. - An ownership epoch counter (
_ownershipEpoch) is incremented on every successful ownership transfer and can be manually advanced by the owner throughadvancePolicyEpoch(bytes32 reason), allowingPolicyRegistryto scope all wallet-local policy state by(wallet, epoch). Old policy state becomes unreachable in O(1) without any enumeration. renounceOwnership()is disabled. Non-owner callers fail the inheritedonlyOwnercheck; owner callers revert withCompanyWallet__RenounceOwnershipDisabled.
Owner bypass is intentionally transitive. If the wallet owner is another smart account, multisig, Kernel account, or CompanyWallet, calls forwarded by that owner are owner calls from this wallet's perspective. Parent-level delegates that can make the owner call this wallet's generic execute(...) path can therefore receive broad downstream authority unless the parent policy uses a calldata-aware module. See the security operational model for the operational controls.
Authorization Model
Owner
Owner can:
- call
execute(...)withoutPolicyRegistry, - rotate the policy registry via
setPolicyRegistry(...), - manually advance the policy epoch to invalidate previous delegated policy state,
- transfer wallet ownership through inherited
Ownableflow.
When the owner is a smart contract, its own delegates, modules, session keys, or multisig policy are outside the child wallet's visibility. The child wallet trusts the owner address, not the original actor behind the owner call.
Non-owner caller
Non-owner can execute only when:
targetis valid,data.length >= 4,PolicyRegistry.canExecute(...)returnstrue.
Out of scope
CompanyWallet intentionally does not enforce:
- entity enablement checks,
- wallet-scoped role management,
- template logic,
- module routing,
- calldata-aware policy validation.
Those concerns live in PolicyRegistry or elsewhere in the protocol.
Core Functions
initialize(address owner_, address policyRegistry_)
Initializes a wallet proxy.
Prerequisites:
- Function is called only once.
owner_ != address(0).policyRegistry_is a deployed contract.
Parameters:
owner_: Root wallet owner.policyRegistry_: External authorization registry.
Events:
OwnershipTransferred(address oldOwner, address newOwner)viaOwnablePolicyRegistryUpdated(address previousPolicyRegistry, address newPolicyRegistry)
Errors:
ZeroAddress():owner_is zero.CompanyWallet__InvalidPolicyRegistry(address): registry is zero or has no code.CompanyWallet__UnsupportedPolicyRegistry(address): registry does not supportIPolicyRegistry.
Notes:
- Sets
_ownershipEpochto1on first initialization. Existing beacon proxy instances upgraded from a pre-epoch implementation start at0; their epoch is incremented on the first ownership transfer.
Sequence Diagram:
execute(address target, uint256 value, bytes calldata data)
Executes an external call from the wallet.
Prerequisites:
target != address(0)target != address(this)target.code.length != 0msg.value == 0 || msg.value == valuedata.length >= 4- If caller is not owner,
PolicyRegistry.canExecute(...)must returntrue
Parameters:
target: Call target.value: Native value forwarded with the call.data: Encoded function call.
Events:
Execution(address caller, address target, uint256 value, bytes data, bytes returnData)
Errors:
CompanyWallet__InvalidCallTarget(): target is zero, self, or non-contract.CompanyWallet__MsgValueMismatch():msg.valueis non-zero but does not match the forwardedvalue.CompanyWallet__InvalidCallData(): calldata is shorter than selector length.CompanyWallet__Unauthorized(): non-owner call is rejected by the policy registry.- Propagated downstream revert from
target.call(...).
Notes:
- The wallet does not know whether the registry used only bitmap RBAC or an additional policy module.
- The wallet remains policy-agnostic and only consumes the final authorization result.
- When
sender == owner(),PolicyRegistryis not consulted. If the owner is another wallet or smart account, this includes forwarded owner calls. msg.value == valueenables same-transaction funding and forwarding.msg.value == 0allows spending ETH already held by the wallet.- Spending existing wallet ETH is intentional for contract calls. For example, a caller may pass
msg.value == 0andvalue > 0if the wallet already has enough native balance. execute(...)cannot be used for a plain ETH transfer to an EOA because EOA targets and empty calldata are rejected.
Sequence Diagram:
setPolicyRegistry(address policyRegistry_)
Updates the active external policy registry.
Prerequisites:
- Caller is wallet owner.
policyRegistry_is a deployed contract implementingIPolicyRegistry.
Parameters:
policyRegistry_: New policy registry.
Events:
PolicyRegistryUpdated(address previousPolicyRegistry, address newPolicyRegistry)
Errors:
CompanyWallet__InvalidPolicyRegistry(address): registry is zero or has no code.CompanyWallet__UnsupportedPolicyRegistry(address): registry does not supportIPolicyRegistry.
Sequence Diagram:
advancePolicyEpoch(bytes32 reason)
Manually advances the epoch used by PolicyRegistry to scope wallet-local policy state.
Prerequisites:
- Caller is wallet owner.
reason != bytes32(0).
Parameters:
reason: Opaque owner-supplied reason code for auditability. Use a stable value such askeccak256("KERNEL_RECOVERY")for recovery playbooks.
Events:
OwnershipEpochAdvanced(uint256 previousEpoch, uint256 newEpoch, bytes32 reason, address caller)
Errors:
CompanyWallet__ZeroPolicyEpochReason(): reason is zero.
Notes:
- This function does not change the wallet owner.
- It invalidates previous-epoch delegated policy admins, user roles, operation roles, and operation modules because
PolicyRegistryreads the current epoch from the wallet. - Same-address smart-account recovery flows, such as Kernel controller recovery, should call this function after recovery when existing wallet delegates must be cleared.
- Normal recovery can skip this function when preserving existing delegated policy is intended.
Sequence Diagram:
policyRegistry()
Returns the current policy registry address.
receive()
Accepts direct native currency transfers into the wallet.
Events:
NativeReceived(address sender, uint256 amount)
Notes:
- Native currency received here can be forwarded later only through a valid contract call via
execute(...). - Solidity
.transfer(...)and.send(...)forward only the 2300-gas stipend and may fail becausereceive()emitsNativeReceived; use.call{value: amount}("")with sufficient gas for direct deposits.
onERC721Received(address, address, uint256, bytes)
Accepts ERC-721 safe transfers.
Notes:
- The hook returns the ERC-721 receiver selector.
- The wallet does not apply policy checks on incoming token receipts; policy checks apply when moving assets out through
execute(...).
onERC1155Received(address, address, uint256, uint256, bytes)
Accepts single-token ERC-1155 safe transfers.
Notes:
- The hook returns the ERC-1155 single-token receiver selector.
- The wallet does not apply policy checks on incoming token receipts; policy checks apply when moving assets out through
execute(...).
onERC1155BatchReceived(address, address, uint256[], uint256[], bytes)
Accepts batch ERC-1155 safe transfers.
Notes:
- The hook returns the ERC-1155 batch receiver selector.
- The wallet does not apply policy checks on incoming token receipts; policy checks apply when moving assets out through
execute(...).
owner()
Returns the current wallet owner.
ownershipEpoch()
Returns the current ownership epoch. Starts at 1 for new wallets. Incremented by 1 on every successful ownership transfer (transferOwnership or completeOwnershipHandover) and on every successful advancePolicyEpoch(bytes32 reason) call.
PolicyRegistry reads this value to scope all wallet-local policy state by (wallet, epoch). When the epoch advances, all previous policy state (admins, user roles, operation roles, operation modules) becomes unreachable without any enumeration or deletion.
Events:
OwnershipEpochAdvanced(uint256 previousEpoch, uint256 newEpoch, bytes32 reason, address caller)— emitted whenever the epoch advances.reasonisbytes32(0)for ordinary ownership transfers and the owner-supplied value for manual policy epoch advances.
transferOwnership(address newOwner)
Transfers wallet ownership to newOwner and advances _ownershipEpoch.
Errors:
CompanyWallet__OwnerTransferToSelf():newOwneris the current owner.NewOwnerIsZeroAddress()(solady):newOwnerisaddress(0).
completeOwnershipHandover(address pendingOwner)
Completes a pending two-step ownership handover initiated by pendingOwner and advances _ownershipEpoch.
Notes:
- Handover requests expire after 48 hours (solady default).
- New owners should call
cancelOwnershipHandoverfor any pending requesters they do not intend to honour, as completing an unexpected handover advances the epoch and wipes the current policy state.
renounceOwnership()
Ownership cannot be renounced; the wallet must always have an owner.
Errors:
Unauthorized()from SoladyOwnable: non-owner caller failsonlyOwnerbefore the function body.CompanyWallet__RenounceOwnershipDisabled(): owner caller reaches the disabled function body.
supportsInterface(bytes4 interfaceId)
Reports support for ICompanyWallet, IERC165, IERC721Receiver, and IERC1155Receiver.
Trust Boundary
CompanyWalletis trusted to custody assets and forward calls.- Wallet owner is trusted as the ultimate recovery and administration authority.
PolicyRegistryis trusted for non-owner authorization.- Optional policy modules are not trusted directly by the wallet; they are only consumed through
PolicyRegistry. EntityRegistryis intentionally outside the wallet execution path.- Native-currency support is generic EVM compatibility. The primary EBSI deployment target is gasless, so native balances should be treated as operational edge cases unless a deployment explicitly relies on them.
- ERC-721 and ERC-1155 receiver hooks are passive custody hooks. Any direct token deposits must be managed or recovered through authorized wallet execution.