Skip to content

Multisig contracts draft feedback#378

Draft
pepebndc wants to merge 36 commits intomainfrom
add-multisig
Draft

Multisig contracts draft feedback#378
pepebndc wants to merge 36 commits intomainfrom
add-multisig

Conversation

@pepebndc
Copy link

No description provided.

@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 82eb1aab-ab0f-4a76-8771-f36a9a2af001

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-multisig

Comment @coderabbitai help to get the list of available commands and usage tips.


constructor(
initialOwner: Either<ZswapCoinPublicKey, ContractAddress>,
signers: Vector<3, Either<ZswapCoinPublicKey, ContractAddress>>,
Copy link
Author

Choose a reason for hiding this comment

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

Why are we forcing the size of this vector to be 3?

Copy link
Contributor

Choose a reason for hiding this comment

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

The initializer is generic in the module so it can support n signers in a for loop. The compiler needs fully determined types and circuit shapes so it can define the constraints. On the contract layer, we can't leave it as generic

Copy link
Author

Choose a reason for hiding this comment

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

any workaround to be flexible and not have to create different contracts for 3,4,5... signers?

Maybe creating the size to be relatively big (ej 32 signers), and any extra signers not required to be initialized to a null address of some sort?

The initialize circuit in the module can then keep control of this null address and stop iterating to save on gas

Copy link
Contributor

Choose a reason for hiding this comment

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

Soooo yes and no to your question. We can’t use a null address bc this assumes that all Signers will bottom out to a Bytes<32> type. The workaround for this workaround is to wrap the signers as Maybe<T> and hardcode 32 or w/e max amount in the module's initialize. This is what it looks like

  export circuit initializePad32(
    signers: Vector<32, Maybe<T>>,
    thresh: Uint<8>
  ): [] {
    assert(thresh > 0, "SignerManager: threshold must be > 0");
    _threshold = disclose(thresh);

    for (const signer of signers) {
      // No early exit capability in compact
      if (signer.is_some) {
        _addSigner(signer.value);
      }
    }

    assert(_signerCount >= thresh, "SignerManager: threshold exceeds signer count");
  }

Compact does not support early exiting a loop ATM so there are no savings to be had (the break keyword is reserved though). I benchmarked both approaches:

  circuit "initialize" (k=11, rows=1817)  
  circuit "initializePad32" (k=15, rows=23229)   // 16x domain size, 12.8x constraints

If we wanted to consider this flexibility, it should not be enforced. The options as I see it:

  1. Have two initialize circuits that users can choose from
    • And perhaps support a smaller amount of potential signers to bring down the cost
  2. Have the contract not use the initialize circuit and instead insert this logic directly in the preset contract's constructor. This undermines the module design, but we're not bloating the module code
  3. Accept the current tech stack limitation with generics. If users c/p the contract code, they can insert the appropriate number of signers in their contract. It's not genius engineering, it's just simple and efficient:
constructor(
  signers: Vector<5, Either<ZswapCoinPublicKey, ContractAddress>>,
  thresh: Uint<8>
) {
  Signer_initialize<5>(signers, thresh);
}

@pepebndc
Copy link
Author

The basic multisig proposed is just for shielded tokens, maybe worth changing the name/clearly document this

@pepebndc pepebndc changed the title Multisig contracts draft Multisig contracts draft feedback Mar 10, 2026
@andrew-fleming
Copy link
Contributor

The basic multisig proposed is just for shielded tokens, maybe worth changing the name/clearly document this

Fixed 1b979e6

Copy link
Member

@0xisk 0xisk left a comment

Choose a reason for hiding this comment

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

Looking good @andrew-fleming Thank you! Left comments, questions, and suggestions. Also we need to add @circuitInfo for all.

@@ -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;

@@ -0,0 +1,207 @@
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/ShieldedTreasury.compact)
pragma language_version >= 0.21.0;

@@ -0,0 +1,195 @@
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/SignerManager.compact)
pragma language_version >= 0.21.0;

@@ -0,0 +1,113 @@
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/UnshieldedTreasury.compact)
pragma language_version >= 0.21.0;

// ─── 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


// ─── Constant ───────────────────────────────────────────────────

export circuit UINT128_MAX(): Uint<128> {
Copy link
Member

Choose a reason for hiding this comment

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

That is repeated in the ShieldedTreasury, that should be replaced by just one, maybe for now that can be added in the Utils.compact with a TODO to be removed once math lib is available.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed 👍

Comment on lines +60 to +63
receiveUnshielded(disclose(color), disclose(amount));

const bal = getTokenBalance(color);
_balances.insert(disclose(color), disclose(bal + amount as Uint<128>));
Copy link
Member

Choose a reason for hiding this comment

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

Maybe that is a dump question, but is it possible that the user sends tokens to the contract directly without using _deposit(), which has the receiveUnshielded(). Just need to double check this, my understanding is that it is not possible, but I might be wrong. As this might cause out-sync of the balances map.

Copy link
Contributor

@andrew-fleming andrew-fleming Mar 24, 2026

Choose a reason for hiding this comment

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

My understanding is that this is not possible. From the docs (ref):

Sending tokens to contracts is only possible if contract claims the tokens, which means an explicit contract interaction needs to be involved.

This was something we tried and confirmed a long time ago, didn't we? It wouldn't hurt to reconfirm

Comment on lines +28 to +29
ledger _shieldedReceived: Map<Bytes<32>, Uint<128>>;
ledger _shieldedSent: Map<Bytes<32>, Uint<128>>;
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
ledger _shieldedReceived: Map<Bytes<32>, Uint<128>>;
ledger _shieldedSent: Map<Bytes<32>, Uint<128>>;
ledger _received: Map<Bytes<32>, Uint<128>>;
ledger _sent: Map<Bytes<32>, Uint<128>>;

nit: I think that's better

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed

Comment on lines +130 to +131
const currentSent = getSentTotal(color);
assert(currentSent <= UINT128_MAX() - amount, "ShieldedTreasury: overflow");
Copy link
Member

Choose a reason for hiding this comment

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

I think it is better to check this overflow from the beginning before the send operation.

const newCount = _signerCount - 1 as Uint<8>;
assert(newCount >= _threshold, "SignerManager: removal would breach threshold");

_signers.remove(disclose(signer));
Copy link
Member

Choose a reason for hiding this comment

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

If we mean to use ledger _signers: Map<T, Boolean>; then shouldn't it be just an update with false here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants