Skip to content
LogoLogo

Multi-sig wallet

Three owners, two confirmations required. The owners are a fixed-size list(address, 3), so the loop that records them has a length known when the contract is compiled.

multisig.lithe
(* A 2-of-3 wallet for ether payments. Owners submit a payment, confirm it, and
   anyone can execute it once it has enough confirmations. *)
 
import "std/evm";
 
error NotOwner;
error AlreadyInitialized;
error NoSuchTx of uint;
error AlreadyExecuted of uint;
error AlreadyConfirmed of uint;
error NotConfirmed of uint;
error NotEnoughConfirmations of record {
  id : uint;
  confirmations : uint;
}
 
event Submitted of record {
  indexed id : uint;
  indexed to : address;
  value : wei;
}
 
event Confirmed of record {
  indexed id : uint;
  indexed owner : address;
}
 
event Revoked of record {
  indexed id : uint;
  indexed owner : address;
}
 
event Executed of record {
  indexed id : uint;
}
 
type Payment is record {
  to : address;
  value : wei;
  executed : bool;
  confirmations : uint;
}
 
storage is record {
  isOwner : map(address, bool);
  threshold : uint;
  count : uint;
  payments : map(uint, Payment);
  confirmed : map(uint, map(address, bool));
}
 
external function init(const owners : list(address, 3))
  raises AlreadyInitialized
is {
  assert(storage.threshold = 0, AlreadyInitialized);
  for owner : address in owners do {
    storage.isOwner[owner] := true;
  }
  storage.threshold := 2;
}
 
external payable function deposit() is {
  skip
}
 
view function onlyOwner() is {
  assert(storage.isOwner[Evm.sender()], NotOwner);
}
 
view function pending(const id : uint) is {
  assert(id < storage.count, NoSuchTx(id));
  assert(not storage.payments[id].executed, AlreadyExecuted(id));
}
 
external function submit(const to : address; const value : wei) : uint
  raises NotOwner
is {
  onlyOwner();
  const id : uint := storage.count;
  storage.payments[id] := { to = to; value = value; executed = false; confirmations = 0; };
  storage.count := id + 1;
  emit Submitted { id = id; to = to; value = value; };
} with id
 
external function confirm(const id : uint)
  raises NotOwner | NoSuchTx | AlreadyExecuted | AlreadyConfirmed
is {
  onlyOwner();
  pending(id);
  assert(not storage.confirmed[id][Evm.sender()], AlreadyConfirmed(id));
  storage.confirmed[id][Evm.sender()] := true;
  storage.payments[id].confirmations := storage.payments[id].confirmations + 1;
  emit Confirmed { id = id; owner = Evm.sender(); };
}
 
external function revoke(const id : uint)
  raises NotOwner | NoSuchTx | AlreadyExecuted | NotConfirmed
is {
  onlyOwner();
  pending(id);
  assert(storage.confirmed[id][Evm.sender()], NotConfirmed(id));
  storage.confirmed[id][Evm.sender()] := false;
  storage.payments[id].confirmations := storage.payments[id].confirmations - 1;
  emit Revoked { id = id; owner = Evm.sender(); };
}
 
external nonreentrant function execute(const id : uint)
  raises NoSuchTx | AlreadyExecuted | NotEnoughConfirmations
is {
  pending(id);
  const p : Payment := storage.payments[id];
  assert(
    p.confirmations >= storage.threshold,
    NotEnoughConfirmations { id = id; confirmations = p.confirmations; }
  );
  storage.payments[id].executed := true;
  emit Executed { id = id; };
  defer Evm.transfer(p.to, p.value);
}
 
external view function paymentOf(const id : uint) : Payment is
  storage.payments[id]
  • execute marks the payment executed before the deferred transfer runs, and nonreentrant stops the recipient from calling execute again.
  • This wallet sends ether only. Lithe has no arbitrary-calldata calls, so a general-purpose multisig would dispatch typed calls through interfaces.