Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
78196e0
add multisig compile as turbo task
andrew-fleming Mar 9, 2026
3f13582
add init multisig modules
andrew-fleming Mar 9, 2026
48330f1
add helpers
andrew-fleming Mar 10, 2026
1252803
add basic multisig contract
andrew-fleming Mar 10, 2026
7abe151
remove unused param
andrew-fleming Mar 10, 2026
5def030
fix newCount var in _approveProposal
andrew-fleming Mar 10, 2026
e15782e
remove setup, remove ownable
andrew-fleming Mar 10, 2026
8348bc9
fix isProposalApprovedBySigner
andrew-fleming Mar 10, 2026
7db320b
add member check to _approveProposal
andrew-fleming Mar 10, 2026
41d9888
replace cancelApproval with revokeApproval
andrew-fleming Mar 10, 2026
f13f3a0
fix isProposalApprovedBySigner
andrew-fleming Mar 10, 2026
66cd730
add empty witnesses
andrew-fleming Mar 11, 2026
22ca211
export compact types from mock
andrew-fleming Mar 11, 2026
b4e36f9
add SignerManager sim
andrew-fleming Mar 11, 2026
700bdb2
add SignerManager tests
andrew-fleming Mar 11, 2026
0fccc11
fix param order
andrew-fleming Mar 11, 2026
da3365d
move deposit
andrew-fleming Mar 11, 2026
1b979e6
move file/improve contract name
andrew-fleming Mar 11, 2026
fdbb640
add comma to param
andrew-fleming Mar 11, 2026
e5b4e06
fix fmt
andrew-fleming Mar 11, 2026
f521b3d
add multisig script to contracts manifest
andrew-fleming Mar 11, 2026
c780647
normalize file names
andrew-fleming Mar 11, 2026
2452867
update import path
andrew-fleming Mar 11, 2026
cd23631
Merge branch 'main' into add-multisig
pepebndc Mar 11, 2026
75d36ea
validate threshold does not exceed signer count after initialization
pepebndc Mar 11, 2026
e26647e
Add multisig tests (#380)
pepebndc Mar 12, 2026
fd2d72b
Merge branch 'main' into add-multisig
pepebndc Mar 16, 2026
c4c0f77
Merge branch 'main' into add-multisig
pepebndc Mar 16, 2026
01ed666
fix fmt
andrew-fleming Mar 17, 2026
0d64f87
add stateless treasury and multisig v2
andrew-fleming Mar 24, 2026
5aefaaf
fix mod name, add stateless mocj
andrew-fleming Mar 24, 2026
71b8a6e
export circuit
andrew-fleming Mar 24, 2026
d1552a5
remove export
andrew-fleming Mar 24, 2026
75ec231
clean up comment
andrew-fleming Mar 24, 2026
0938974
fix domain
andrew-fleming Mar 24, 2026
eafb62a
fix title
andrew-fleming Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"compact": "compact-compiler",
"compact:access": "compact-compiler --dir access",
"compact:archive": "compact-compiler --dir archive",
"compact:multisig": "compact-compiler --dir multisig",
"compact:security": "compact-compiler --dir security",
"compact:token": "compact-compiler --dir token",
"compact:utils": "compact-compiler --dir utils",
Expand Down
310 changes: 310 additions & 0 deletions contracts/src/multisig/ProposalManager.compact
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
pragma language_version >= 0.21.0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pragma language_version >= 0.21.0;
// SPDX-License-Identifier: MIT
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/ProposalManager.compact)
pragma language_version >= 0.21.0;


/**
* @module ProposalManager
* @description Token-agnostic proposal lifecycle management for multisig
* governance contracts.
*
* Supports shielded and unshielded proposals through a unified
* Recipient type with a RecipientKind tag. Typed helper circuits
* provide safe construction of recipients without exposing the
* internal Bytes<32> representation to consumers.
*/
module ProposalManager {
import CompactStandardLibrary;

// ─── Types ──────────────────────────────────────────────────────

export enum ProposalStatus {
Inactive,
Active,
Executed,
Cancelled
}

export enum RecipientKind {
ShieldedUser,
UnshieldedUser,
Contract
}

export struct Recipient {
kind: RecipientKind,
address: Bytes<32>
}

export struct Proposal {
to: Recipient,
color: Bytes<32>,
amount: Uint<128>,
status: ProposalStatus
}

// ─── State ──────────────────────────────────────────────────────

ledger _nextProposalId: Counter;
ledger _proposals: Map<Uint<64>, Proposal>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it private? I think that should be exported so that the developer could easily fetch the whole state without only needing to use the getters. Because with the getters, the user would actually need to do txs with paying gas to call them and also wait for the generated proof, so the dev would simply fetch the public state and then be able to do all the getters but from the client-side, which will be for free and instant without any proofing time. Similar to this example from Lunarswap frontend to fetch all the pool data from the ledger: https://github.com/OpenZeppelin/midnight-apps/blob/7063bc0fb81b6d93d54c7953deb3f363f94dbcf5/apps/lunarswap-ui/lib/lunarswap-integration.ts#L188

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I recall correctly, wasn't there discussion about having the getters be free (if they're not already somehow)? Either way, agreed for now 👍 will update


// ─── Recipient Helpers ──────────────────────────────────────────

/**
* @description Constructs a shielded user recipient.
*
* @param {ZswapCoinPublicKey} key - The shielded recipient's public key.
* @returns {Recipient} The typed recipient.
*/
export circuit shieldedUserRecipient(key: ZswapCoinPublicKey): Recipient {
return Recipient { kind: RecipientKind.ShieldedUser, address: key.bytes };
}

/**
* @description Constructs an unshielded user recipient.
*
* @param {UserAddress} addr - The unshielded recipient's address.
* @returns {Recipient} The typed recipient.
*/
export circuit unshieldedUserRecipient(addr: UserAddress): Recipient {
return Recipient { kind: RecipientKind.UnshieldedUser, address: addr.bytes };
}

/**
* @description Constructs a contract recipient.
*
* @param {ContractAddress} addr - The contract address.
* @returns {Recipient} The typed recipient.
*/
export circuit contractRecipient(addr: ContractAddress): Recipient {
return Recipient { kind: RecipientKind.Contract, address: addr.bytes };
}

/**
* @description Converts a Recipient to a shielded send recipient.
* Handles both ShieldedUser and Contract kinds.
*
* Requirements:
*
* - Recipient kind must be ShieldedUser or Contract.
*
* @param {Recipient} r - The recipient.
* @returns {Either<ZswapCoinPublicKey, ContractAddress>} The shielded recipient.
*/
export circuit toShieldedRecipient(r: Recipient): Either<ZswapCoinPublicKey, ContractAddress> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will be the need for this API?

if (r.kind == RecipientKind.ShieldedUser) {
return left<ZswapCoinPublicKey, ContractAddress>(
ZswapCoinPublicKey { bytes: r.address }
);
}
assert(r.kind == RecipientKind.Contract, "ProposalManager: invalid shielded recipient");
return right<ZswapCoinPublicKey, ContractAddress>(
ContractAddress { bytes: r.address }
);
}

/**
* @description Converts a Recipient to an unshielded send recipient.
* Handles both UnshieldedUser and Contract kinds.
*
* Requirements:
*
* - Recipient kind must be UnshieldedUser or Contract.
*
* @param {Recipient} r - The recipient.
* @returns {Either<ContractAddress, UserAddress>} The unshielded recipient.
*/
export circuit toUnshieldedRecipient(r: Recipient): Either<ContractAddress, UserAddress> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will be the need for this API?

if (r.kind == RecipientKind.Contract) {
return left<ContractAddress, UserAddress>(
ContractAddress { bytes: r.address }
);
}
assert(r.kind == RecipientKind.UnshieldedUser, "ProposalManager: invalid unshielded recipient");
return right<ContractAddress, UserAddress>(
UserAddress { bytes: r.address }
);
}

// ─── Guards ─────────────────────────────────────────────────────

/**
* @description Asserts that a proposal exists.
*
* Requirements:
*
* - Proposal with `id` must have been created.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {[]} Empty tuple.
Comment on lines +135 to +136
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param {Uint<64>} id - The proposal ID.
* @returns {[]} Empty tuple.
* @param {Uint<64>} id - The proposal ID.
*
* @returns {[]} Empty tuple.

nit: the same for all the rest.

*/
export circuit assertProposalExists(id: Uint<64>): [] {
assert(
_proposals.member(disclose(id)),
"ProposalManager: proposal not found"
);
}

/**
* @description Asserts that a proposal exists and is active.
*
* Requirements:
*
* - Proposal must exist.
* - Proposal status must be Active.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {[]} Empty tuple.
*/
export circuit assertProposalActive(id: Uint<64>): [] {
assertProposalExists(id);
assert(
_proposals.lookup(disclose(id)).status == ProposalStatus.Active,
"ProposalManager: proposal not active"
);
}

// ─── Proposal Lifecycle ─────────────────────────────────────────

/**
* @description Creates a new proposal.
*
* @notice Access control is NOT enforced here.
* The consuming contract must gate this behind its own
* authorization policy.
*
* Requirements:
*
* - `amount` must be greater than 0.
*
* @param {Recipient} to - The recipient (constructed via helper circuits).
* @param {Bytes<32>} color - The token color.
* @param {Uint<128>} amount - The amount to transfer.
* @returns {Uint<64>} The new proposal ID.
*/
export circuit _createProposal(
to: Recipient,
color: Bytes<32>,
amount: Uint<128>
): Uint<64> {
assert(amount > 0, "ProposalManager: zero amount");

_nextProposalId.increment(1);
const id = _nextProposalId;

_proposals.insert(disclose(id), disclose(Proposal {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_proposals.insert(disclose(id), disclose(Proposal {
_proposals.insert(id, disclose(Proposal {

unneeded disclose.

to: to,
color: color,
amount: amount,
status: ProposalStatus.Active
}));

return id;
}

/**
* @description Cancels a proposal.
*
* @notice Access control is NOT enforced here.
* The consuming contract must gate this behind its own
* authorization policy.
*
* Requirements:
*
* - Proposal must be active.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {[]} Empty tuple.
*/
export circuit _cancelProposal(id: Uint<64>): [] {
assertProposalActive(id);

const proposal = _proposals.lookup(disclose(id));
_proposals.insert(disclose(id), disclose(Proposal {
to: proposal.to,
color: proposal.color,
amount: proposal.amount,
status: ProposalStatus.Cancelled
}));
Comment on lines +220 to +225
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_proposals.insert(disclose(id), disclose(Proposal {
to: proposal.to,
color: proposal.color,
amount: proposal.amount,
status: ProposalStatus.Cancelled
}));
_proposals.insert(disclose(id), Proposal {
...proposal
status: ProposalStatus.Cancelled
});

disclose is not needed.

}

/**
* @description Marks a proposal as executed.
*
* @notice Access control is NOT enforced here.
* The consuming contract must gate this behind its own
* authorization policy.
*
* Requirements:
*
* - Proposal must be active.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {[]} Empty tuple.
*/
export circuit _markExecuted(id: Uint<64>): [] {
assertProposalActive(id);

const proposal = _proposals.lookup(disclose(id));
_proposals.insert(disclose(id), disclose(Proposal {
to: proposal.to,
color: proposal.color,
amount: proposal.amount,
status: ProposalStatus.Executed
}));
Comment on lines +246 to +251
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_proposals.insert(disclose(id), disclose(Proposal {
to: proposal.to,
color: proposal.color,
amount: proposal.amount,
status: ProposalStatus.Executed
}));
_proposals.insert(disclose(id), Proposal {
...proposal,
status: ProposalStatus.Executed
});

}

// ─── View ───────────────────────────────────────────────────────

/**
* @description Returns the full proposal data.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {Proposal} The proposal.
*/
export circuit getProposal(id: Uint<64>): Proposal {
assertProposalExists(id);
return _proposals.lookup(disclose(id));
}

/**
* @description Returns the recipient of a proposal.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {Recipient} The recipient.
*/
export circuit getProposalRecipient(id: Uint<64>): Recipient {
assertProposalExists(id);
return _proposals.lookup(disclose(id)).to;
}

/**
* @description Returns the amount of a proposal.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {Uint<128>} The amount.
*/
export circuit getProposalAmount(id: Uint<64>): Uint<128> {
assertProposalExists(id);
return _proposals.lookup(disclose(id)).amount;
}

/**
* @description Returns the token color of a proposal.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {Bytes<32>} The token color.
*/
export circuit getProposalColor(id: Uint<64>): Bytes<32> {
assertProposalExists(id);
return _proposals.lookup(disclose(id)).color;
}

/**
* @description Returns the status of a proposal.
*
* @param {Uint<64>} id - The proposal ID.
* @returns {ProposalStatus} The proposal status.
*/
export circuit getProposalStatus(id: Uint<64>): ProposalStatus {
assertProposalExists(id);
return _proposals.lookup(disclose(id)).status;
}
}
Loading
Loading