Authors: @Thomas Lavaur

Reviewers

Change log

Status Description Date
Draft Draft status set 2026-04-03
Research Domain Expert approved 2026-04-15
Research Lead approved 2026-04-23
Verified Verified status set 2026-04-23
Engineering Lead approved 2026-04-23
Engineering Domain Expert approved 2026-04-24
Merged Merged status set 2026-04-24

Motivation

In [1.3.0] Mantle, the NoteId is derived from the Transfer Operation that mints it:

The introduction of LEADER_CLAIM and CHANNEL_WITHDRAW operations creates a collision risk in NoteId derivation. Both operations allow a Mantle transaction to be balanced without consuming any note as input, breaking the hash chain that previously guaranteed NoteId uniqueness. As a result, two distinct Mantle transactions can produce notes with identical NoteIds whenever their TRANSFER operations share the same output value and public key and carry no inputs.

Proposal

We propose three complementary changes:

Generalize NoteId derivation to use a per-operation identifier OpId

Rather than deriving NoteId from a transfer_hash specific to TRANSFER, we introduce a generic OpId that each Operation creating new notes computes independently.

The NoteId derivation ZK circuit is left entirely unchanged, each Operation that outputs notes simply supplies its own op_id as the first input. Uniqueness is guaranteed as long as each op_id is itself unique.

So instead of using

# v 1.3
def derive_note_id(transfer_hash: zkhash, output_number: int, note: Note) -> NoteId:
	  return zkhash(
			  FiniteField(b"NOTE_ID_V1", byte_order="little", modulus= p)
        transfer_hash,
        FiniteField(output_number, byte_order="little", modulus= p)
        FiniteField(note.value, byte_order="little", modulus= p)
        note.public_key
    )

we would be using:

# new version
def derive_note_id(op_id: OpId, output_index: int, note: Note) -> NoteId:
    return ZkHash(
        FiniteField(b"NOTE_ID_V1",  byte_order="little", modulus=p),
        op_id,                      # transfer_id, leader_claim_id, etc.
        FiniteField(output_index,   byte_order="little", modulus=p),
        FiniteField(note.value,     byte_order="little", modulus=p),
        note.public_key,
    )

Restrict TRANSFER to require at least one input note