owned this note
owned this note
Published
Linked with GitHub
# DARKPOOL GATEWAY FOR DEFI [[SPEC]]
**Version:** 0.1.0
**Date:** 2025-06-11
**Status:** Draft
This Current document is specification/Implmentation Guide for building a Decentralized Darkpool gateway. users of this darkpool can interact with any defi application in a private and anonymous manner! protecting their identity and trade information.
Rest of document is structured into 3 parts
- spec for building financial operations over assets
- spec for integrations between darkpool and existing protocols
- Native tightly integration spec for building defi applications and MPC networks on Xythum Darkpool.
## Concepts
### Commitments
A **commitment** a commitment represents a user's shielded assets.
* **Structure:** `commitment = H_pedersen(value, asset_address, precommitment)`
* `value`: The amount of the asset.
* `asset_address`: The ERC20 token contract address (or a similar identifier for other asset types).
* `precommitment`: A cryptographic hash binding the user's `secret` and `nullifier`.
* // precommitment = pedersen_hash([nullifier, secret]);
### Secret and Nullifier Generation
For each commitment, a unique `secret` and `nullifier` are generated client-side.
1. **Xythum Local Private Key:** A user-specific private key for Xythum operations is derived from their primary wallet by signing a constant string:
```javascript
const signature = await signMessage("Xythum_Gateway_Derivation_Salt_v1", user_L1_privkey);
const xythum_local_privkey = keccak256(signature);
```
2. **Secret and Nullifier:** For each new note, a unique `note_nonce` is used to derive the `secret` and `nullifier`:
```javascript
const secret = keccak256(abi.encodePacked(xythum_local_privkey, note_nonce, "XYTHUM_SECRET"));
const nullifier = keccak256(abi.encodePacked(xythum_local_privkey, note_nonce, "XYTHUM_NULLIFIER"));
```
* `secret`: A private value known only to the user, used in generating the commitment.
* `nullifier`: A value that, when revealed, marks the associated commitment as spent, preventing double-spending. It must be unique per commitment.
Stack : zkps [ NOIR | Barretenberg (ultraHonk backend)], Solidity - EVM, DA layer (storing encrypted orders)
Core of the protocol relies on Incremental merkle trees [Incremental Merkle Tree Semaphore v4](https://hackmd.io/@vplasencia/S1whLBN16), In one liner for every action user has to generate a zk proof stating he owns the commitment and he is invalidating it(nullifier reveal). At any point in time user would be proving they know a previous commitment but never reveal what the commiement was. All the asset management operations will also be proven with zkps.
## CASE 1: PRIVACY FOR ASSETS
Flow:
1. user deposits assets into the protocol (public tracable interaction with xythum darkpool).
2. From here on all the financial actions [withdraw, split, join, transfer, topup, claim, defi interactions] are completely anonymous and untracable with original deposit.
3. every action with darkpool issues a **commitment**, anyone being able to prove their hold commitment can have authority over assets.
---
> ## Deposit Action
Refer [here](#Secret-and-Nullifier-Generation) for secret generation method!.
Now user locally computers a pre commitment. which is user for deposit.
```rs
precommitment = pedersan( nullifier , secret )
```
User broadcasts this pre-commitment onchain for deposit.
```solidity=
function deposit(precommitment, value, asset) public {
require(_value > 0, "Deposit value must be positive");
require(asset != address(0), "Invalid asset address");
bytes32 commitment = pedersenHasher.hash(
abi.encodePacked(uint256(value), uint256(uint160(asset)), precommitment)
update_merkle_root(commitment);
safetransferFrom(msg.sender, asset, value);
emit NewCommitment(...);
}
```
As we can see merkle tree is directly maintained onChain and it is incremental merkle tree in structure. clients can directly query for **path to root** from contract.
> ## Withdraw Action
In order for a user to withdraw any amount of intial deposit without revealing any link to original deposit yet maintaining the protocol integrety. user must proove following statements to the contract.
- They own a valid, unspent commitment (existing_commitment) within the Merkle tree (proven via Merkle path and root).
- The existing_commitment corresponds to existing_value, asset_address, existing_nullifier, and existing_secret.
- The existing_nullifier has not been revealed (this is checked on-chain, but the proof links it to the commitment).
- A new_commitment is correctly calculated for the remaining balance (remaining_value = existing_value - withdraw_value), using - a fresh new_nullifier and new_secret. If remaining_value is zero, no new commitment is needed, or a special zero-value commitment is handled.
- The withdraw_value and remaining_value are within acceptable bounds (e.g., non-negative, fit in uint128).
User has to generate a proof from a circuit whose inputs are
== CIRCUITS ==
```rs
# psuedo code for noir cicuit
use std::hash::pedersen_hash;
pub fn commitment_hasher(
value: Field,
label: Field,
nullifier: Field, /
secret: Field
) -> (Field, Field) {
// 1. Compute nullifier hash
// Circom's pedersen(1) with one input
let nullifier_hash = pedersen_hash([nullifier]);
// 2. Compute precommitment
// Circom's pedersen(2) with two inputs
let precommitment = pedersen_hash([nullifier, secret]);
// 3. Compute commitment hash
// Circom's pedersen(3) with three inputs
let commitment = pedersen_hash([value, label, precommitment]);
// 4. Return output signals
(commitment, nullifier_hash)
}
```
== WITHDRAW CIRCUIT ==
```rs
fn withdraw<N: u32>(
pub withdrawn_value: Field,
pub root: Field,
pub tree_depth: Field,
pub existing_nullifier: Field,
label: Field,
existing_value: Field,
existing_secret: Field,
new_nullifier: Field,
new_secret: Field,
path: [Field; N],
index: Field
) -> (Field, Field) {
let (existing_commitment, _) = commitment_hasher(existing_value, label, existing_nullifier, existing_secret);
let calculated_state_root = lean_imt_inclusion_proof::<N>(existing_commitment, index, path, tree_depth);
assert_eq(state_root, calculated_state_root);
let remaining_value = existing_value - withdrawn_value;
constrain_in_128_bits(remaining_value);
constrain_in_128_bits(withdrawn_value);
let nullifiers_are_equal = are_equal(existing_nullifier, new_nullifier);
assert_eq(nullifiers_are_equal, 0); // it has to be false
let (calculated_new_commitment_hash, _) = commitment_hasher(remaining_value, label, new_nullifier, new_secret);
let new_commitment_hash_output = calculated_new_commitment_hash;
let _context_squared = context * context;
(new_commitment_hash_output, existing_nullifier)
}
```
When user intent is to withdraw certain amount from his existing balance he would have to compute withdraw_proof.
Onchain solidity contract could look something like this
```solidity
function withdraw(Proof withdrawProof, address reciver) public {
let (uint236 transfer_value, bytes32 root, uint32 depth, bytes32 nullifier,bytes32 newcommitment,IERC20 asset) = verifier.verify(withdrawProof);
validate_tree_state(root, depth);
check_and_invadidate_nullifier(nullifier); // pure mapping
update_merkle_tree(newcommitment);
asset.safeTransfer(withdraw_value, reciver);
}
```
- prevents double spending via nullifiers.
- old commitment is never revealed! So this withdraw spend can never be traced back to original commitment.
:::info
**Compliance Considerations**:
To facilitate optional regulatory compliance, Xythum can incorporate a mechanism for users to disclose their transaction history. During proof generation for any action (withdraw, transfer, etc.), the client can create an encrypted "Action Intent" data packet. This packet might contain details like operation_type, value, asset_address, and counterparty_info_if_applicable.
This encrypted data can be:
- Stored off-chain by the user.
Optionally emitted as part of an event (if small enough and privacy implications are managed).
- Stored on a dedicated Data Availability (DA) layer, retrievable by the user.
However, if they choose or are required to "exit" the system transparently or comply with a lawful request, they can reveal the decryption key(s) associated with their Xythum identity. This would allow reconstruction of their transaction flow. Xythum provides privacy by default, with opt-in transparency.
:::
:::warning
The code blocks are too generic and only meant for understanding, lot of sanity checks are excluded.
:::
> ## Split
The Split action allows a user to take an existing shielded commitment and divide its value into two new shielded commitments.
Statements to Prove (Zero-Knowledge):
1. Ownership of commitment_old (unspent, valid Merkle path).
commitment_old corresponds to value_old, asset_address, nullifier_old, secret_old.
2. Two new commitments (commitment_new1, commitment_new2) are correctly formed for value_new1 and value_new2 respectively, using the same asset_address and new secret/nullifier pairs.
3. Value conservation: value_old == value_new1 + value_new2.
4. value_new1 > 0 and value_new2 > 0.
```rust
fn split_asset_circuit<N: u32>(
// Private Inputs
value_old: Field,
asset_address: Field,
secret_old: Field,
merkle_path: [Field; N],
leaf_index: Field,
value_new1: Field, // value_new2 is derived
nullifier_new1: Field,
secret_new1: Field,
nullifier_new2: Field,
secret_new2: Field,
// Public Inputs
pub merkle_root: Field,
pub nullifier_old: Field,
) {
let precommitment_old = compute_precommitment(nullifier_old, secret_old);
let commitment_old = compute_commitment(value_old, asset_address, precommitment_old);
let is_member = lean_imt_inclusion_proof::<N>(commitment_old, merkle_leaf_index_old, merkle_path_old, merkle_root);
assert(is_member);
let value_new2_derived = value_old - value_new1;
assert value_new1 > 0;
assert value_new2_derived > 0;
assert value_new2_derived < 2**128;
// Compute and verify first new commitment
let precommitment_new1 = compute_precommitment(nullifier_new1, secret_new1);
let calculated_commitment_new1 = compute_commitment(value_new1, asset_address_public, precommitment_new1);
// Compute and verify second new commitment
let precommitment_new2 = compute_precommitment(nullifier_new2, secret_new2);
let calculated_commitment_new2 = compute_commitment(value_new2_derived, asset_address_public, precommitment_new2);
// 8. Ensure nullifier uniqueness (good practice)
// new1 != old, new2 != old, new1 != new2
assert(nullifier_new1, nullifier_old);
assert(nullifier_new2, nullifier_old);
assert(nullifier_new1, nullifier_new2);
}
```
Onchain code would bascially update new commitments and invalidate old commitments
> #### The Join action is the opposite of Split. It allows a user to combine two existing shielded commitments of the same asset type into a single new shielded commitment. This can help consolidate funds and reduce the number of notes a user manages.
---
> ## Topup Action
The Topup action allows a user to add more funds from their public L1 wallet to their shielded balance of a specific asset. This is achieved by consuming an existing shielded commitment and creating a new one with an increased value equal to old_value + topup_value. The topup_value is transferred from the user's L1 wallet to the contract during the transaction.
---
> ## Transfer Action
The Transfer action allows a user (Alice) to privately send assets to another user (Bob) within the Xythum Darkpool. This is a two-stage process:
1. **Transfer:** Alice spends her existing commitment and creates an encrypted "Transfer Note" on-chain. This note specifies the amount, asset, and a cryptographic identifier for Bob (e.g., hash of Bob's public receiving key). Alice may also create a change commitment for herself if the spent commitment's value exceeds the transfer amount.
2. **Claim:** Bob (or anyone with Bob's receiving secret) uses a ZK proof to claim the assets from the Transfer Note, creating a new standard commitment for themselves.
**Transfer Phase Details:**
Alice wishes to transfer `value_transfer` of `asset_address` to Bob.
- Alice has `commitment_old_A`.
- Alice creates `commitment_change_A` for `value_old_A - value_transfer` (if > 0).
- Alice prepares a `hashed_receiver_info` (e.g., `H(bob_public_key)`). Bob must be able to reconstruct this with his private key and the blinding factor (which Alice must communicate to Bob off-chain,).
| withdraw circuit can be user for transfer circuit as well. but onchain function changes
```solidity
function transfer(Proof transferProof, bytes32 reciver_hash) public {
let (uint236 transfer_value, bytes32 root, uint32 depth, bytes32 nullifier,bytes32 newcommitment,IERC20 asset) = verifier.verify(transferProof);
validate_tree_state(root, depth);
check_and_invadidate_nullifier(nullifier); // pure mapping
update_merkle_tree(newcommitment);
note_nonce++;
notes[keccak(abi.encodePacked(note_nonce, reciver_hash))] = Note({
asset,
transfer_value,
reciver_hash
isClaimed: false,
claimedTimestamp: now()
})
asset.safeTransfer(transfer_value, reciver);
}
```
:::info
One of the idea is that instead of bob reciver used raw, we could try combinations of bob_address + blinding_factor per NOTE. (this is not circuit dependent dervied private key can be tweaked with blinding_factor)
This is if Bob concerned of leaking his address.! an addional privacy layer. but adds complexity of managing all blinding factors [ bob can store blinding factors on a DA and have privkey for himself ]
Although bob_address is not plain HD wallet derived address. most likely it would be `SIGNATURE AGAINST SOME XYTHUM CONSTANT`
:::
> ## Claim Action
The Claim action allows the intended recipient (Bob) of a Transfer Note to take ownership of the assets specified in the note. Bob does this by providing a ZK proof that he possesses the necessary secret(s) to reconstruct the hashedReceiverInfo associated with an unclaimed note.
```rust
// Noir (Conceptual)
fn claim_note_circuit(
receiver_secret_key: Field, // Bob's secret key
nullifier_new_B_private: Field, // For Bob's new commitment
secret_new_B_private: Field, // For Bob's new commitment
// Public Inputs (some fetched from contract storage based on noteId)
note_value_public: Field, // Value from the note
note_asset_address_public: Field, // Asset from the note
pub hashed_receiver_address: Field,
// Inputs to nullify old commitments can also be used to topup existing Bob commitments
) {
let calculated_hashed_address = pedersen_hash(Pubkey(receiver_secret_key_private)); // Domain sep 2 for note
assert_eq(calculated_hashed_address, hashed_receiver_address);
// 2. Compute Bob's new commitment
let precommitment_new_B = compute_precommitment(nullifier_new_B_private, secret_new_B_private);
let calculated_commitment_new_B = compute_commitment(note_value_public, note_asset_address_public, precommitment_new_B);
assert(note_value_public > 0);
}
```
Onchain Claim Solidity
```rust
function claim(Proof claimProof) public {
// check Bob zk Proof validity about his ownership over `address`.
// validate order amounts and nonce from circuit vs in-storage mapping Note.
// If everything looks good update the commitment tree with new commitment.
}
```
:::warning
SECONT 2 - 3 Pending.
- Discusses about Onchain Defi Integrations.
- As a spoiler. For interaction with other defi application user has to prove he owns a commitment like in any other action and also passes reciver_hash as a param to defi integration function. xthum Darkpool would validate and perform action using users funds and create NOTE.
- Notes are just like precommitments its just notes offer easier ways to manage the commitments.
:::