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 |
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.
We propose three complementary changes:
NoteId derivation to use a per-operation identifier OpIdRather 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,
)
TRANSFER to require at least one input note