diff --git a/contracts/utils/PrivateLedger.sol b/contracts/utils/PrivateLedger.sol new file mode 100644 index 00000000..a1ecfdb5 --- /dev/null +++ b/contracts/utils/PrivateLedger.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @dev Library for implementing private ledger entries. + * + * Private ledger entries represent discrete units of value with flexible representation. + * This minimal design uses bytes32 for values to support multiple value representations: + * plaintext amounts, encrypted values (FHE), zero-knowledge commitments, or other + * privacy-preserving formats. The library provides basic primitives for creating, + * transferring, and managing entries without imposing specific spending semantics. + */ +library PrivateLedger { + /** + * @dev Struct to represent a private ledger entry + * + * Uses bytes32 for the value to maximize flexibility. This allows the library to work with: + * + * * Regular uint256 values (cast to `bytes32`) + * * FHE encrypted value pointers (`euint64.unwrap()`) + * * Zero-knowledge commitments (commitment hashes) + * * Other privacy-preserving value representations + */ + struct Entry { + bytes32 value; // Generic value representation + address owner; // Owner of the entry + } + + /** + * @dev Creates a new entry with the specified owner and value + * + * The value parameter can represent different formats depending on the use case: + * + * * Plaintext: `bytes32(uint256(value))` + * * FHE encrypted: `euint64.unwrap(encryptedAmount)` + * * ZK commitment: `keccak256(abi.encode(value, nonce))` + * + * NOTE: Does not verify `owner != address(0)` or that value is not zero as it + * has a different meaning depending on the context. Consider implementing checks + * before using this function. + */ + function create(Entry storage entry, address owner, bytes32 value) internal { + require(entry.owner == address(0)); + entry.owner = owner; + entry.value = value; + } + + /** + * @dev Checks if an entry exists + * + * Uses the owner field as existence indicator since zero address + * is not a valid owner for active entries. + */ + function exists(Entry storage entry) internal view returns (bool) { + return entry.owner != address(0); + } + + /** + * @dev Deletes an entry from storage + * + * Removes the entry completely. Developers should implement their own + * authorization checks and update index mappings before calling this function. + */ + function remove(Entry storage entry) internal { + require(entry.owner != address(0)); + entry.owner = address(0); + entry.value = bytes32(0); + } + + /** + * @dev Transfers an entry to a new owner + * + * Allows ownership transfers. Developers should implement authorization + * checks to ensure only the current owner or authorized parties can + * transfer ownership. + * + * NOTE: Does not verify `to != address(0)`. Transferring to the zero + * address may leave the value of the entry unspent. Consider using + * {remove} instead. + */ + function transfer(Entry storage entry, address to) internal { + require(entry.owner != address(0)); + entry.owner = to; + } + + /** + * @dev Updates the value of an existing entry + * + * Allows value modifications for specific use cases. Developers should + * implement proper authorization checks before calling this function. + * Useful for encrypted value updates or commitment reveals. + */ + function update(Entry storage entry, bytes32 newValue) internal { + require(entry.owner != address(0)); + entry.value = newValue; + } +} diff --git a/contracts/utils/PrivateNote.sol b/contracts/utils/PrivateNote.sol new file mode 100644 index 00000000..2114a7fd --- /dev/null +++ b/contracts/utils/PrivateNote.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {PrivateLedger} from "./PrivateLedger.sol"; + +/** + * @dev Library for implementing spendable private notes. + * + * Private notes represent discrete units of value that can be spent exactly once, building + * on the {PrivateLedger} foundation with opinionated spending functionality. This library adds + * double-spend prevention, lineage tracking options, and transaction-like operations for + * creating privacy-focused token systems, mixers, and confidential transfer protocols. + */ +library PrivateNote { + using PrivateLedger for PrivateLedger.Entry; + + /** + * @dev Struct to represent a privacy-preserving spendable note + * + * Builds on {PrivateLedger-Entry} to add spending functionality without lineage tracking. + * The `spent` flag prevents double-spending while maintaining maximum privacy by not + * storing parent note references. Uses bytes32 for maximum flexibility with encrypted + * values, commitments, or plaintext amounts. + */ + struct SpendableBytes32 { + PrivateLedger.Entry entry; // Underlying ledger entry + bool spent; // Prevents double-spending + } + + /** + * @dev Struct to represent a trackable spendable note with lineage + * + * Builds on {PrivateLedger-Entry} to add spending functionality with lineage tracking. + * The `spent` flag prevents double-spending while `createdBy` enables transaction chain + * reconstruction for auditability. Reduces privacy but enables compliance scenarios. + */ + struct TrackableSpendableBytes32 { + PrivateLedger.Entry entry; // Underlying ledger entry + bool spent; // Prevents double-spending + bytes32 createdBy; // ID of parent note for lineage tracking + } + + /// @dev Emitted when a new privacy-preserving spendable note is created + event SpendableBytes32Created(bytes32 indexed id, address indexed owner, bytes32 value); + + /// @dev Emitted when a privacy-preserving spendable note is spent + event SpendableBytes32Spent(bytes32 indexed id, address indexed spender); + + /// @dev Emitted when a new trackable spendable note is created + event TrackableSpendableBytes32Created(bytes32 indexed id, address indexed owner, bytes32 value, bytes32 createdBy); + + /// @dev Emitted when a trackable spendable note is spent + event TrackableSpendableBytes32Spent(bytes32 indexed id, address indexed spender); + + /** + * @dev Creates a new privacy-preserving spendable note + * + * Creates a note without parent lineage tracking for maximum privacy. The note can be + * spent exactly once using the spend function. Use this for privacy-focused applications + * where transaction unlinkability is prioritized over auditability. + */ + function create(SpendableBytes32 storage note, address owner, bytes32 value, bytes32 id) internal { + note.entry.create(owner, value); + // note.spent = false; // false by default + + emit SpendableBytes32Created(id, owner, value); + } + + /** + * @dev Creates a new trackable spendable note with lineage + * + * Creates a note with parent linkage for auditability. The `createdBy` field allows + * transaction chain reconstruction but reduces privacy. Use this for compliance + * scenarios or when transaction history tracking is required. + */ + function create( + TrackableSpendableBytes32 storage note, + address owner, + bytes32 value, + bytes32 id, + bytes32 parentId + ) internal { + note.entry.create(owner, value); + note.createdBy = parentId; // Enables lineage tracking + // note.spent = false; // false by default + + emit TrackableSpendableBytes32Created(id, owner, value, parentId); + } + + /** + * @dev Spends a privacy-preserving note + * + * Spends the note while maintaining maximum privacy. The spent note cannot be used + * again. External note creation should use SpendableBytes32 to maintain privacy. + */ + function spend( + SpendableBytes32 storage note, + bytes32 noteId, + bytes32 recipientId, + bytes32 changeId + ) internal returns (bytes32 actualRecipientId, bytes32 actualChangeId) { + require(!note.spent, "PrivateNote: already spent"); + require(note.entry.owner != address(0), "PrivateNote: note does not exist"); + + note.spent = true; + emit SpendableBytes32Spent(noteId, note.entry.owner); + + // Return the provided IDs for external note creation + return (recipientId, changeId); + } + + /** + * @dev Spends a trackable note with lineage preservation + * + * Spends the note while maintaining transaction history through parent linkage. + * External note creation should use TrackableSpendableBytes32 with this note's ID + * as the parent to maintain the audit trail. + */ + function spend( + TrackableSpendableBytes32 storage note, + bytes32 noteId, + bytes32 recipientId, + bytes32 changeId + ) internal returns (bytes32 actualRecipientId, bytes32 actualChangeId) { + require(!note.spent, "PrivateNote: already spent"); + require(note.entry.owner != address(0), "PrivateNote: note does not exist"); + + note.spent = true; + emit TrackableSpendableBytes32Spent(noteId, note.entry.owner); + + // Return the provided IDs for external trackable note creation + return (recipientId, changeId); + } + + /** + * @dev Checks if a privacy-preserving note exists and is unspent + */ + function isUnspent(SpendableBytes32 storage note) internal view returns (bool) { + return note.entry.exists() && !note.spent; + } + + /** + * @dev Checks if a privacy-preserving note exists (regardless of spent status) + */ + function exists(SpendableBytes32 storage note) internal view returns (bool) { + return note.entry.exists(); + } + + /** + * @dev Checks if a trackable note exists and is unspent + */ + function isUnspent(TrackableSpendableBytes32 storage note) internal view returns (bool) { + return note.entry.exists() && !note.spent; + } + + /** + * @dev Checks if a trackable note exists (regardless of spent status) + */ + function exists(TrackableSpendableBytes32 storage note) internal view returns (bool) { + return note.entry.exists(); + } +} diff --git a/docs/modules/ROOT/pages/utilities.adoc b/docs/modules/ROOT/pages/utilities.adoc index af647fc7..e747648a 100644 --- a/docs/modules/ROOT/pages/utilities.adoc +++ b/docs/modules/ROOT/pages/utilities.adoc @@ -59,3 +59,139 @@ The verification process works as follows: * Otherwise: verification is done using an ERC-7913 verifier. This allows for backward compatibility with EOAs and ERC-1271 contracts while supporting new types of keys. + +=== Private Ledger & Notes + +_For background on UTXO concepts, see the https://en.bitcoin.it/wiki/UTXO[Bitcoin UTXO model]_ + +Private ledger entries represent discrete units of value with flexible representation, providing transaction graph privacy by breaking linkability between transfers. OpenZeppelin provides a **layered architecture** for implementing UTXO-like systems: + +* **xref:api:utils.adoc#PrivateLedger[`PrivateLedger`]** - Foundational library with basic entry primitives +* **xref:api:utils.adoc#PrivateNote[`PrivateNote`]** - Opinionated layer with spending functionality and privacy options + +==== Private Ledger + +The foundation layer uses `bytes32` for values to support multiple representations: plaintext amounts, encrypted values (FHE), zero-knowledge commitments, or other privacy-preserving formats: + +[source,solidity] +---- +import {PrivateLedger} from "@openzeppelin/contracts/utils/PrivateLedger.sol"; + +contract BasicLedgerToken { + using PrivateLedger for PrivateLedger.Entry; + + mapping(bytes32 => PrivateLedger.Entry) private _entries; + mapping(address => bytes32[]) private _ownerToEntries; // Separate indexing + + function mint(address to, uint256 amount) external { + bytes32 entryId = keccak256(abi.encode(block.timestamp, to, amount)); + bytes32 value = bytes32(amount); // Convert uint256 to bytes32 + + _entries[entryId].create(to, value); + _ownerToEntries[to].push(entryId); + } + + function transfer(bytes32 fromEntryId, address to, uint256 amount) external { + PrivateLedger.Entry storage fromEntry = _entries[fromEntryId]; + require(fromEntry.owner == msg.sender, "Not owner"); + + uint256 currentValue = uint256(fromEntry.value); + require(currentValue >= amount, "Insufficient value"); + + // Remove spent entry + _removeFromIndex(msg.sender, fromEntryId); + fromEntry.remove(); + + // Create new entries + bytes32 toEntryId = keccak256(abi.encode(block.timestamp, to, "recipient")); + _entries[toEntryId].create(to, bytes32(amount)); + _ownerToEntries[to].push(toEntryId); + + // Create change entry if needed + if (currentValue > amount) { + bytes32 changeEntryId = keccak256(abi.encode(block.timestamp, msg.sender, "change")); + _entries[changeEntryId].create(msg.sender, bytes32(currentValue - amount)); + _ownerToEntries[msg.sender].push(changeEntryId); + } + } +} +---- + +==== Spendable Notes with Privacy Options + +The opinionated layer adds spending semantics with **two privacy models**: + +[source,solidity] +---- +import {PrivateNote} from "@openzeppelin/contracts/utils/PrivateNote.sol"; + +contract PrivacyToken { + using PrivateNote for PrivateNote.SpendableBytes32; + using PrivateNote for PrivateNote.TrackableSpendableBytes32; + + // Privacy-preserving notes (no lineage tracking) + mapping(bytes32 => PrivateNote.SpendableBytes32) private _privateNotes; + + // Auditable notes (with lineage tracking) + mapping(bytes32 => PrivateNote.TrackableSpendableBytes32) private _auditableNotes; + + function mintPrivate(address to, uint256 amount) external { + bytes32 noteId = keccak256(abi.encode(block.timestamp, to, amount)); + _privateNotes[noteId].create(to, bytes32(amount), noteId); + } + + function mintAuditable(address to, uint256 amount, bytes32 parentId) external { + bytes32 noteId = keccak256(abi.encode(block.timestamp, to, amount)); + _auditableNotes[noteId].create(to, bytes32(amount), noteId, parentId); + } + + function spendPrivate( + bytes32 noteId, + address to, + uint256 amount + ) external { + require(_privateNotes[noteId].entry.owner == msg.sender, "Not owner"); + + // Spend note (maintains privacy - no lineage) + bytes32 recipientId = keccak256(abi.encode(block.timestamp, to, "recipient")); + bytes32 changeId = keccak256(abi.encode(block.timestamp, msg.sender, "change")); + + _privateNotes[noteId].spend( + noteId, to, bytes32(amount), msg.sender, bytes32(0), recipientId, changeId + ); + + // Create new private notes + _privateNotes[recipientId].create(to, bytes32(amount), recipientId); + } + + function spendAuditable( + bytes32 noteId, + address to, + uint256 amount + ) external { + require(_auditableNotes[noteId].entry.owner == msg.sender, "Not owner"); + + // Spend note (preserves lineage for auditing) + bytes32 recipientId = keccak256(abi.encode(block.timestamp, to, "recipient")); + bytes32 changeId = keccak256(abi.encode(block.timestamp, msg.sender, "change")); + + _auditableNotes[noteId].spend( + noteId, to, bytes32(amount), msg.sender, bytes32(0), recipientId, changeId + ); + + // Create new trackable notes with lineage + _auditableNotes[recipientId].create(to, bytes32(amount), recipientId, noteId); + } +} +---- + +==== Use Cases + +* **Privacy Tokens**: Combine with FHE for maximum privacy (encrypted amounts + untraceable transaction graphs) +* **Mixer Protocols**: Break transaction links while maintaining verifiable balances +* **Compliance Systems**: Use trackable notes for audit trails while maintaining confidentiality +* **Layer-2 Solutions**: Efficient fraud proofs and state transitions +* **DeFi Privacy**: Anonymous lending, trading, and yield farming +* **Cross-Chain Bridges**: Interoperability with UTXO-based blockchains like Bitcoin + +The layered architecture provides flexibility: use `PrivateLedger` for custom logic or `PrivateNote` for battle-tested spending semantics. The `bytes32` value design makes this a universal primitive for any privacy-preserving system on Ethereum.