Skip to content

MultiToken refactor -> replace pk with sk#483

Merged
andrew-fleming merged 13 commits into
OpenZeppelin:mainfrom
andrew-fleming:fix-multi-sk
May 14, 2026
Merged

MultiToken refactor -> replace pk with sk#483
andrew-fleming merged 13 commits into
OpenZeppelin:mainfrom
andrew-fleming:fix-multi-sk

Conversation

@andrew-fleming
Copy link
Copy Markdown
Contributor

@andrew-fleming andrew-fleming commented May 5, 2026

Types of changes

What types of changes does your code introduce to OpenZeppelin Midnight Contracts?
Put an x in the boxes that apply

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation Update (if none of the other choices apply)

Fixes #455

Summary by CodeRabbit

  • Refactor
    • Updated the MultiToken contract's account identifier system to use witness-derived identity instead of public key-based authentication.
    • Revised transfer, approval, minting, and burning workflows to use canonicalized account identifiers for consistency.
    • Added new utility functions for account ID computation and canonical zero-account representation.

@andrew-fleming andrew-fleming requested review from a team as code owners May 5, 2026 06:36
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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: 983d757f-ff3f-4f6b-a2ed-b2dbd9eed81b

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

Walkthrough

The MultiToken contract refactors its authorization model from public-key-based identity to witness-derived secret-key-based identity using persistent hashing. Ledger key types change from Either<ZswapCoinPublicKey, ContractAddress> to Either<Bytes<32>, ContractAddress>, with all operations canonicalizing account identifiers before reads, writes, and comparisons. Supporting test utilities, simulators, and mocks are updated to reflect the new paradigm.

Changes

MultiToken Authorization Model Refactoring

Layer / File(s) Summary
Witness & Private State Definition
contracts/src/token/witnesses/MultiTokenWitnesses.ts, contracts/src/token/witnesses/test/MultiTokenWitnesses.test.ts
Implement concrete witness factory and private state: MultiTokenPrivateState now contains a 32-byte secretKey: Uint8Array; wit_MultiTokenSK() extracts and returns the secret key from witness context; utility methods generate random keys or validate/clone provided keys; comprehensive test coverage validates key generation, copying, length validation, and witness behavior.
Core Identity & Authorization
contracts/src/token/MultiToken.compact
Introduce wit_MultiTokenSK(): Bytes<32> witness and ZERO(): Either<Bytes<32>, ContractAddress> pure circuit; replace public-key authorization with identity derived from persistentHash(secretKey) via computeAccountId(); implement canonicalization workflow to normalize Either<Bytes<32>, ContractAddress> values before map operations and authorization checks.
Ledger & Transfer Logic
contracts/src/token/MultiToken.compact
Update _balances and _operatorApprovals map keys to Either<Bytes<32>, ContractAddress>; refactor _transfer, _unsafeTransfer, _update to canonicalize sender/receiver, enforce zero/contract checks via _isTargetZero, and use the new ledger shapes; update mint/burn to use ZERO() circuit rather than prior shielded-address pattern.
Public Circuit Interfaces
contracts/src/token/MultiToken.compact, contracts/src/token/test/mocks/MockMultiToken.compact
Update balanceOf, setApprovalForAll, isApprovedForAll, transferFrom, and all variants to use Either<Bytes<32>, ContractAddress> for account/operator/address parameters; add exported computeAccountId(secretKey: Bytes<32>): Bytes<32> pure circuit; remove ZswapCoinPublicKey from mock exports.
Simulator & Test Infrastructure
contracts/src/token/test/simulators/MultiTokenSimulator.ts
Update all public methods to accept Either<Uint8Array, ContractAddress> (TypeScript equivalent of circuit Bytes<32>); add ZERO() and computeAccountId() public methods; expose privateState object with injectSecretKey() and getCurrentSecretKey() helpers for test authorization setup.
Test Suite Refactoring
contracts/src/token/test/MultiToken.test.ts
Introduce deterministic secret-key and account-id helpers (buildAccountIdHash, eitherAccountId, eitherContract); update all user/contract test fixtures to use witness-based identity; add comprehensive canonicalization tests verifying non-canonical either inputs are normalized before ledger effects; refactor authorization setup to use privateState.injectSecretKey() instead of .as(); expand transfer/approval/mint/burn coverage for both account-id and contract address variants.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Circuit as MultiToken<br/>Circuit
    participant PrivateState as Witness Context<br/>Private State
    participant Ledger as Ledger Maps<br/>(_balances,<br/>_operatorApprovals)

    rect rgba(220, 100, 100, 0.5)
    Note over Caller,PrivateState: Old Model (Public Key)
    Caller->>Circuit: transferFrom(pubKeyEither, ...)
    Circuit->>Circuit: Use pubKey directly<br/>for authorization
    Circuit->>Ledger: Update ledger with<br/>pubKey variant
    end

    rect rgba(100, 150, 220, 0.5)
    Note over Caller,PrivateState: New Model (Secret Key)
    Caller->>PrivateState: injectSecretKey(sk)
    PrivateState->>PrivateState: Store secretKey<br/>(32 bytes)
    Caller->>Circuit: transferFrom(accountIdEither, ...)
    Circuit->>PrivateState: wit_MultiTokenSK()
    PrivateState-->>Circuit: Return secretKey
    Circuit->>Circuit: Derive accountId from secretKey<br/>via persistentHash()
    Circuit->>Circuit: Canonicalize all<br/>Either<Bytes<32>, CA> values
    Circuit->>Ledger: Lookup/update ledger<br/>with canonical identity
    Circuit-->>Caller: Authorization complete
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

A secret key in witness's embrace,
No public eyes upon this place—
Hash and canonize with care,
Identity flows everywhere.
Ledger keys in bytes now shine,
Authorization by design. 🐰✨

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (1 warning, 2 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The linked issue #455 provides no implementation details or requirements; cannot assess whether code changes meet specific objectives. Review issue #455 to confirm it documents the expected MultiToken sk refactoring requirements.
Title check ❓ Inconclusive The title 'Fix multi sk' is vague and non-descriptive, using cryptic abbreviations without conveying the actual change. Clarify the title to describe the actual change, such as 'Refactor MultiToken to use witness-derived account IDs' or 'Update MultiToken authorization model to use secret key witnesses'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Out of Scope Changes check ✅ Passed All changes (witness implementation, type updates, canonicalization logic, test coverage) directly support the core objective of replacing pk-based authorization with sk-based authorization.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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

@andrew-fleming andrew-fleming changed the title Fix multi sk MultiToken refactor -> replace pk with sk May 5, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
contracts/src/token/test/simulators/MultiTokenSimulator.ts (1)

277-283: 💤 Low value

The undefined check is redundant given type guarantees.

Since MultiTokenPrivateState.generate() and MultiTokenPrivateState.withSecretKey() always produce a defined secretKey, and the type system enforces secretKey: Uint8Array, this condition can never be true at runtime. Consider simplifying:

Suggested simplification
     getCurrentSecretKey: (): Uint8Array => {
       const sk = this.getPrivateState().secretKey;
-      if (typeof sk === 'undefined') {
-        throw new Error('Missing secret key');
-      }
       return Uint8Array.from(sk);
     },

If you prefer keeping defensive validation, use a more idiomatic check that TypeScript won't flag as unnecessary:

if (!sk || sk.length !== 32) {
  throw new Error('Invalid or missing secret key');
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/token/test/simulators/MultiTokenSimulator.ts` around lines 277
- 283, The typeof undefined check in getCurrentSecretKey is redundant because
MultiTokenPrivateState.generate() and MultiTokenPrivateState.withSecretKey()
guarantee a defined secretKey; remove the if (typeof sk === 'undefined') branch
and simply return Uint8Array.from(sk) directly. If you want defensive validation
instead, replace the redundant check with an idiomatic runtime assertion such as
verifying sk exists and has the expected length (e.g., if (!sk || sk.length !==
32) throw new Error('Invalid or missing secret key')) while keeping the rest of
getCurrentSecretKey unchanged; refer to getCurrentSecretKey,
MultiTokenPrivateState.generate, MultiTokenPrivateState.withSecretKey, and the
secretKey field to locate the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@contracts/src/token/test/simulators/MultiTokenSimulator.ts`:
- Around line 277-283: The typeof undefined check in getCurrentSecretKey is
redundant because MultiTokenPrivateState.generate() and
MultiTokenPrivateState.withSecretKey() guarantee a defined secretKey; remove the
if (typeof sk === 'undefined') branch and simply return Uint8Array.from(sk)
directly. If you want defensive validation instead, replace the redundant check
with an idiomatic runtime assertion such as verifying sk exists and has the
expected length (e.g., if (!sk || sk.length !== 32) throw new Error('Invalid or
missing secret key')) while keeping the rest of getCurrentSecretKey unchanged;
refer to getCurrentSecretKey, MultiTokenPrivateState.generate,
MultiTokenPrivateState.withSecretKey, and the secretKey field to locate the
code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1241a929-f484-4b87-ab49-75960f804a14

📥 Commits

Reviewing files that changed from the base of the PR and between a8c5225 and d7edf1b.

📒 Files selected for processing (6)
  • contracts/src/token/MultiToken.compact
  • contracts/src/token/test/MultiToken.test.ts
  • contracts/src/token/test/mocks/MockMultiToken.compact
  • contracts/src/token/test/simulators/MultiTokenSimulator.ts
  • contracts/src/token/witnesses/MultiTokenWitnesses.ts
  • contracts/src/token/witnesses/test/MultiTokenWitnesses.test.ts

@andrew-fleming andrew-fleming requested a review from a team as a code owner May 6, 2026 20:35
Copy link
Copy Markdown
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.

Thank you @andrew-fleming! Left one blocking comment.

Comment thread contracts/src/token/MultiToken.compact
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔵 followup: Redundant canonicalization through _unsafeTransferFrom_unsafeTransfer_update

if (!Utils_isKeyOrAddressZero(disclose(fromAddress))) {
const fromBalance = balanceOf(fromAddress, id);
if (!_isTargetZero(disclose(canonFrom))) {
const fromBalance = balanceOf(canonFrom, id);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔵 followup: _update re-enters exported balanceOf instead of a raw helper

const MAX_UINT128 = 340282366920938463463374607431768211455;
assert(MAX_UINT128 - toBalance >= value, "MultiToken: arithmetic overflow");
_balances.lookup(id).insert(disclose(to), disclose(toBalance + value as Uint<128>));
_balances.lookup(id).insert(disclose(canonTo), disclose(toBalance + value as Uint<128>));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔵 followup: Redundant as Uint<128> casts in _update

Comment on lines +12 to +15
const buildAccountIdHash = (sk: Uint8Array): Uint8Array => {
const rt_type = new CompactTypeVector(1, new CompactTypeBytes(32));
return persistentHash(rt_type, [sk]);
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔵 followup: that should mirror the circuit hasher so lets leave a comment to highlight that.

Copy link
Copy Markdown
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.

LGTM! thank you @andrew-fleming !

@andrew-fleming andrew-fleming merged commit 9b6b21a into OpenZeppelin:main May 14, 2026
7 checks passed
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.

C-01: MultiToken Contract

2 participants