8 Oktober 2026 Bacaan 26 Menit Oleh Kunming Jiang, Ryan Cao, and the TFH Applied Research team

WorldPx: A Private, Accountable, Extensible Transactions System

Intro

Tl;dr for experts — see here!

600 years after the Florentine magnates brought modern banking to medieval Europe, humans invented Bitcoin and the more general notion of blockchains. Blockchains and the decentralized finance systems which sit on top of them have several advantages over traditional banking and payment systems, including censorship resistance and cryptographically backed ownership of assets. One reality of of moving transactions onto blockchains, however, is that all data (sender, recipient, and amount, as well as account balances) becomes public by necessity — nodes, sequencers, challengers, and ZK provers alike all must be able to access the entirety of the blockchain state and every transaction which happens. This, in turn, makes blockchain-based financial systems awkward for individuals and businesses alike: the former may not want it to be publicly known that they purchase a $W coffee from coffee shop X every morning, and the latter may not want to publicly disclose that they pay $Y to vendor Z every month for services.

Such concerns about financial privacy mitigating the usability of blockchain-based financial systems led to the development of private transactions. Existing projects have been deployed on public blockchains but use zero-knowledge (ZK) proofs and related cryptographic techniques to establish transaction systems that conceal the identities of participants and the amount transferred. Privacy, however, can be a double-edged sword. The same mechanisms that protect ordinary users from surveillance also make it harder to identify stolen funds, enforce sanctions, or investigate financial crime. Various attacks and illicit behavior on existing systems, for example, resulted in allegedly hundreds of millions of dollars in stolen assets, money laundering prosecutions, and more.

To allow for use cases such as private peer-to-peer transactions, business payments, protocol fees, and anonymous lending and swapping, hugely increasing the usefulness of blockchain-based financial primitives, while severely limiting the ability of bad actors to move large amounts privately and even allowing a trusted third-party auditor to keep an eye on all transactions, the TFH Applied Research Team is sharing technical ideas toward a theoretical construction: WorldPx. To attain such accountability, WorldPx’s construction combines Proof of Human Sybil resistance properties with the notion of an anonymous budget (as proposed in this paper), and enforces verifiable encryption of all private transaction contents under an auditor key. The overall construction remains extremely flexible as well — this blog details how to extend the private peer-to-peer payment scheme to anonymous lending and swapping, but can be extended to even more DeFi primitives.

This blog also covers the decisions that went into designing WorldPx, and discusses its future directions. Note that WorldPx is a technical idea in exploration, and is not in production.

Preface: a correct, private, and accountable transaction system

In WorldPx, we explore a transaction system with three properties: correctness, privacy, and accountability. Informally, a correct, privacy-preserving peer-to-peer transaction system should have the following properties, which we list below. Note that our exploration assumes a working public blockchain with programmable smart contracts and an existing system of public accounts and balances.

WorldPx “API” (basic, informal)

The most basic peer-to-peer private transaction system includes the following three primitives:

  • shield(addr, amount, type) — converts amount of token of type (like USDC or ETH) from addr owner's public balance to their private balance. In other words, the owner of addr's public balance should be reduced by amount and their private balance increased by amount. shield operation does not hide any of its inputs, but the final private balance of the user remains hidden.
  • unshield(addr, amount, type) — converts amount of token of type from the addr owner's private balance to their public balance. Similar to shield, unshield also does not hide any of its inputs.
  • private_transfer([sender_addr], [recipient_addr], [amount], [type]) — deducts amount from sender_addr's private balance of tokens of type and adds the corresponding amount to recipient_addr's private balance. In private_transfer, all input fields are private, even to the underlying blockchain. We use the bracket [] notation later as well to indicate private inputs.

Privacy properties (informal)

WorldPx as proposed would offer the following privacy guarantees:

  • During a private_transfer() call, the [sender], [recipient], and [amount] involved are private (indistinguishable from random) to every party except the auditor and the sender/recipient.
  • Every pair of private send operations are unlinkable against one another. In other words, if we have two transactions (sender_1, recipient_1, amount_1) and (sender_2, recipient_2, amount_2) , cases where sender_1 = sender_2 and sender_1 != sender_2 are indistinguishable from the public contents of the transactions (and similarly with the amount and recipient fields).
  • A corollary of the above two properties is private balances, where users’ balances are not known to any party but the auditor; in fact, no other party learns about the changes to a user's balance, or even if there is a change at all (with the exception of shield/unshield operations).

Correctness / liveness guarantees (informal)

A functioning and secure private transaction requires the following guarantees:

  • No negative private balances — senders cannot ever send more private token than they own.
  • No double-spend — once a private token has been spent, it cannot be spent again.
  • Ownership soundness — only the owner of a private token can spend it.
  • System liveness/censorship resistance — the liveness + censorship resistance properties of the system can be reduced to that of the underlying blockchain which it resides within.

WorldPx would further guarantee conservation of value — money cannot be created nor destroyed. Every private token which is created must have a corresponding amount of public token which is locked up. As a corollary, WorldPx would also allow users to exchange their private funds for public ones at any time — since there is always a corresponding amount of locked-up public token, users can always exchange their private tokens for public ones if they wish to exit the system altogether.

Accountability guarantees (informal)

As mentioned in the introduction, existing private transactions projects were challenged by the reality of wishing to extend privacy to everyday transactions, but in the process enabled malicious uses such as private money laundering or transactions with sanctioned wallets. To allow legitimate users to still transact privately while making it prohibitively difficult for bad actors to use the system in a malicious way without being identified, our technical exploration targets the following two specific accountability properties —

  • Limited private_transfer() and shield() amounts for each user within limited time periods, e.g. each user can only transfer $1000/month, in a manner which is cryptographically enforceable by the smart contract. This, in tandem with Proof of Human’s Sybil-resistance properties, severely raises the difficulty of private transfer at a large scale, and thus by default makes it difficult for bad actors who wish to money launder large amounts, e.g., to do so.
  • Verifiable auditability via a third-party trusted auditor — to continue allowing any blockchain analysis company to work with us, flagging suspicious accounts and transactions, updating their blacklist, etc., our system should ensure that they still have access to all transaction information, in a way which is also cryptographically enforceable by the smart contract. This ensures that all the existing analysis and processes which auditors already run against World Chain can be applied with no change at all to the proposed private transactions system, and makes it as challenging for bad actors to use the proposed private system without being detected by the auditors as it would be for them to use any public transactions system being monitored by the same auditing party.

Setting up the stage

WorldPx is a proposed L2 private transaction system inspired by previous ZK-based UTXO commitment-style constructions, made accountable by combining Proof of Human with monthly private transfer budget and verifiable trusted third-party auditing, with potential expansions to include additional DeFi features like Uniswap and lending pools (e.g. Aave). Its core protocol relies on a smart contract on World Chain (Ethereum L2) to process anonymous user requests and maintain a public state.

WorldPx would use the Unspent Transaction Output (UTXO) model: user balances are expressed using individual unspent tokens, each a record of ownership of a certain amount of token value. During each private transaction, the sender nullifies a token they own and generates a token of the same value for the recipient. To ensure correctness, the sender proves to the blockchain that they own a valid token and that the total value of the spent tokens matches that of the newly created tokens. To preserve privacy, these statements are proven in zero knowledge. To provide accountability, the sender additionally proves that the transfer stays within the allowed budget and includes an encrypted record of the transaction for a third-party auditor.

The actors: users, the auditor, the blockchain, and its state

Our private transaction exploration consists of three essential components. The first are private users, Alice and Bob, who participate in the transaction system and can behave arbitrarily and maliciously. The second is a trusted auditing authority, which holds a special auditing key, allowing it to monitor the system. The auditor has access to all information regarding each transaction and may choose to audit the transactions at its own pace. The third component is a public L2 blockchain, which we assume to be censorship-resistant and secure against malicious actors. Its correctness can be independently checked by anyone, and all data stored in its state is publicly visible. Alice and Bob must therefore avoid revealing their secrets when interacting with it.

The L2 blockchain contains a smart contract, StypeS_\text{type}, for each type of private token. StypeS_\text{type} includes WorldPx’s publicly visible state, which consists of four structures: a commitment tree, whose leaves are the commitments to all tokens ever created, stored in a public Merkle tree; a nullifier set, which consists of the nullifier hashes (nullifiers) of all spent tokens, as a public set; an address tree, the leaves of which are the public identities of all registered users, and an encrypted transaction list, which encrypts every transaction in history in ciphertext in two copies, one for the recipient and the other for the auditor. As the system processes more and more transactions, all four structures grow. They never shrink. We describe these terms and definitions in more detail in later sections.

The cryptographic props: hashes, Merkle trees, public-key encryption, and zero-knowledge proofs

WorldPx’s proposed design relies on three core concepts: hashes, public-key encryption, and zero-knowledge proofs.

Hashes + hash commitments

A hash is a one-way, collision-resistant fingerprint of a value. We denote a hash over a value XX as H(X)H(X). A hash is a one-way function: given only H(X)H(X), it is computationally infeasible to recover XX. We use H(X∣∣Y)H(X || Y) to denote a hash applied on the concatenation of XX and YY. The one-way property also guarantees YY is irrecoverable from H(X∣∣Y)H(X \mid \mid Y), even when XX is known. It is also collision-resistant: even knowing XX, it is computationally infeasible to find a different value Y≠XY \neq X such that H(X)=H(Y)H(X) = H(Y).

To ensure unambiguity of hash inputs, we use domain separators within our hash constructions — for example, our token’s inner hash commitment looks like the following, where "token" is the domain separator:

Cominner=Hinner2(Cominner1∣∣owner∣∣v∣∣type)=H(“inner2"∣∣Cominner1∣∣owner∣∣v∣∣type)\begin{align*} \text{Com}_\text{inner} &= H_\text{inner2}(\text{Com}_\text{inner1} \mid \mid \text{owner} \mid \mid v \mid \mid \text{type}) \\ &= H(\text{``inner2"} \mid \mid \text{Com}_\text{inner1} \mid \mid \text{owner} \mid \mid v \mid \mid \text{type}) \\ \end{align*}

The collision-resistance + one-way properties of a hash allow it to be used as a commitment scheme, i.e. a set of functions Commit\text{Commit} and Verify_Open\text{Verify\_Open} such that

  • For message mm and blinding factor rr, we can compute commitment Comm←Commit(m;r)\text{Com}_m \leftarrow \text{Commit}(m; r), which can simply be Comm←H(m∣∣r)\text{Com}_m \leftarrow H(m \mid \mid r).
  • For commitment Com\text{Com} and message mm and blinding factor rr, we an verify that commitment Com\text{Com} was generated exactly using message mm and blinding factor rr, using the function
Verify_Open(Com,m,r)=1  ⟺  Com=Commit(m;r)\text{Verify\_Open}(\text{Com}, m, r) = 1 \iff \text{Com} = \text{Commit}(m; r)
  • The binding property of the commitment ensures that it is computationally infeasible to find another tuple (m′,r′)≠(m,r)(m', r') \neq (m, r) which will cause Verify_Open\text{Verify\_Open} to return 11, and the hiding property of the commitment ensures that given just Com\text{Com}, a (polynomial time) adversary learns nothing about the (m,r)(m, r) which generated that commitment.

Merkle trees

Another application of hashing is that of Merkle trees, a cryptographic data structure which enables short membership proofs. Roughly speaking, a Merkle tree consists of a list of leaf nodes l1,...,l2kl_1, ..., l_{2^k}, and recursively builds up the next tree layer by taking the current layer and computing pairwise hashes, e.g. l1′=H(l1∣∣l2)l'_1 = H(l_1 \mid \mid l_2). In the final layer of the Merkle tree, there is a single node, denoted the tree root.

WorldPx’s construction stores token commitments on-chain as the leaves of a Merkle tree TtokenT_\text{token}, where each leaf is a token commitment and each internal node is the hash of its two children. For Alice to show that her claimed token commitment exists, she proves that there is a path from her token hash to the root of the commitment tree, in zero-knowledge, to avoid revealing the exact leaf her token is in. The collision-resistance property of the hash guarantees that the correct root of TtokenT_\text{token}, which is stored within a smart contract, can only be derived from a token hash which is a leaf TtokenT_\text{token}. The same concept also applies to the address tree TuniqueT_\text{unique}, where Alice can prove that she belongs to a list of users with Proof of Human without revealing her identity.

Public key encryption

A public-key (PK) cryptosystem enables any two parties to communicate with each other while hiding the content of the message from anyone else. To do so, all parties first derive a keypair, which consists of a private and public key. Everyone publishes their public keys. For a sender Alice to send a message to Bob, she encrypts it into a ciphertext using Bob’s public key. Since only Bob knows his private key, only he can decrypt the ciphertext and recover the transaction; to everyone else, the ciphertext reveals no information about its contents.

In our case, whenever Alice wishes to private_transfer() some tokens to Bob, she will include an encryption of the contents of the private transaction under Bob’s public key on chain, which allows Bob to scan through the contents of the blockchain and discover which transactions have him as a recipient, and the contents of the token commitments therein.

For efficiency, Alice can directly send the transaction contents to Bob if they have a previously established private side channel.

Zero-knowledge succinct argument of knowledge

Finally, a zero-knowledge (succinct) argument of knowledge (zk-SNARK) is a proof over the correctness of a statement, without revealing why it is true or disclosing the secret information used to prove it. In practical terms, a computationally-weak verifier (such as a smart contract) can force a powerful prover (such as Alice) to run a specific function correctly (such as private_transfer()!) and prove that outputs are correctly computed from inputs, while keeping private inputs and intermediate function values hidden from the verifier.

For example, as discussed earlier, Alice can use a zk-SNARK to show that she correctly computes a token commitment from a secret user address, serial number, token type, blinding factor, and value, without revealing any of those fields to the verifier smart contract! Within the same proof, and without exposing the commitment itself, Alice shows that a chain of secret values forms a valid Merkle path from her token commitment to the root of TtokenT_\text{token}. She then reveals only the root, and the smart contract checks that Alice’s claimed root is equivalent to the one stored within itself.

The proof therefore shows that Alice knows a token commitment that belongs to the commitment tree and that both the commitment and Merkle path were computed correctly. Combined with the security properties of the hash function and Merkle tree, this convinces everyone that Alice owns a token in the tree without revealing the token’s private information, or even its commitment.

The acts: shield, unshield, assign_budget, and private_transfer

As briefly described earlier, there are three user actions: shield converts some public tokens into an owned private token; unshield spends an owned private token and adds its value to a public account; and private_transfer transfers some of their private tokens to another user. Conceptually, these actions behave similarly to their counterparts in prior private transaction constructions, but with additional accountability guarantees from auditing and budgets.

For Alice to be eligible in WorldPx, she needs to first register with a registration authority, where she presents her Proof of Human to verify that she is a unique human. She samples a random value private_id\text{private\_id} as her private identity and publishes the corresponding public id owner=Hpub(private_id)\text{owner} = H_\text{pub}(\text{private\_id}), linked to a nullifier generated by her World ID (see here for details!). This address is added to the TuniqueT_\text{unique} Merkle tree.

WorldPx’s UTXO model states that every transaction is performed by either generating new private tokens and/or nullifying existing ones: in particular, shield generates private tokens, unshield nullifies private tokens, and private_transfer does both.

Each private token (r,owner,v,sn,type)(r, \text{owner}, v, \text{sn}, \text{type}) has five components: **the blinding factor rr is a random value known only by the owner, the owner id owner\text{owner} is the token’s owner, as defined above, the token value vv is self-explanatory, and the serial number sn\text{sn} is a unique, public value to differentiate between tokens.

The token commitment uses the above fields, and is defined as a nested hash:

Cominner1=Hinner1(r)Cominner2=Hinner2(Cominner1∣∣owner∣∣v∣∣type)Comtoken=Houter(Cominner2∣∣sn)\begin{align*} \text{Com}_\text{inner1} &= H_\text{inner1}(r)\\ \text{Com}_\text{inner2} &= H_\text{inner2}(\text{Com}_\text{inner1} \mid \mid \text{owner} \mid \mid v \mid \mid \text{type})\\ \text{Com}_\text{token} &= H_\text{outer}(\text{Com}_\text{inner2} \mid \mid \text{sn}) \end{align*}

The token commitment’s corresponding nullifier is defined as null=Hnull(private_id∣∣sn)\text{null} = H_\text{null}(\text{private\_id} \mid \mid \text{sn}), where the private_id\text{private\_id} is the one corresponding with the owner\text{owner} field above. This ensures that a token cannot be spent twice, without revealing anything about which tokens have already been spent.

Why do we structure our token commitment in this way, rather than e.g. just a flat hash? When generating a new token in private_transfer(), the user sends the outer hash to the smart contract. This allows the smart contract to choose the serial number sn\text{sn} without learning any of the internal fields, and protects against a “fool's gold” attack, where a malicious sender chooses an already-used serial number, causing the recipient to not be able to spend the resulting token. When generating a new token in shield , the user sends the inner hash so that the smart contract can directly apply the public values of owner\text{owner}, vv, and type\text{type} (and the serial number!), without learning the blinding factor rr.

Shielding a token

Let StypeS_\text{type} refer to the smart contract corresponding to the token type (e.g. USDC) which Alice wishes to interact with. Note that StypeS_\text{type} contains the following —

  • A public pool of token type type\text{type} which gets deposited into whenever users call shield, and which gets withdrawn from whenever users call unshield.
  • The root and frontier of TuniqueT_\text{unique}, the Merkle tree of Proof of Human verified participants.
  • The root and frontier of TtokenT_\text{token}, the Merkle tree of all valid private token commitments.

When Alice calls the shield functionality within StypeS_\text{type}, she sends vv amount of the token publicly to StypeS_\text{type}. She then samples the token randomness rr and computes Cominner\text{Com}_\text{inner}, the inner component of the token commitment, then sends it to StypeS_\text{type}. The contract then looks up the owner\text{owner} corresponding to her address, generates a unique serial number sn\text{sn} (by looking at the index of the next empty leaf slot in TtokenT_\text{token}), and generates both the second inner commitment Cominner2=Hinner2(Cominner1∣∣owner∣∣v∣∣type)\text{Com}_\text{inner2} = H_\text{inner2}(\text{Com}_\text{inner1} \mid \mid \text{owner} \mid \mid v \mid \mid \text{type})\\and the outer token commitment Comtoken=Houter(Cominner2∣∣sn)\text{Com}_\text{token} = H_\text{outer}(\text{Com}_\text{inner2} \mid \mid \text{sn}) and inserts Comtoken\text{Com}_\text{token} as a leaf in TtokenT_\text{token}.

At this point, Alice owns a private token. During shield, all of these values except rr are public. This does not introduce additional privacy loss, since the shield operation originates from Alice’s public account and the corresponding transaction details are already visible on-chain. Importantly, no future transaction will ever reveal the same Comtoken\text{Com}_\text{token} in the clear.

Unshielding a token

An unshield is the opposite. Alice nullifies her private token to withdraw the corresponding amount of public token from the smart contract. To do so, Alice generates a ZK proof πunshield\pi_\text{unshield} stating that:

  1. She knows a secret user id secret_id\text{secret\_id} which hashes to some public value owner\text{owner}.
  2. She knows a secret token (r,owner,v,sn,type)(r, \text{owner}, v, \text{sn}, \text{type}) with public value vv and type type\text{type}, whose commitment Comtoken\text{Com}_\text{token} is a leaf in TtokenT_\text{token} (i.e. has a Merkle path which leads to the on-chain root of TtokenT_\text{token}) without revealing Comtoken\text{Com}_\text{token}, sn\text{sn}, or rr.
  3. Using the same secret_id\text{secret\_id} and sn\text{sn}, she correctly computes the public nullifier null\text{null}.
  4. She correctly includes all relevant information about the transaction (in particular, the contents of the secret token) in an encrypted message EE for the auditor (i.e. encrypted with the auditor’s public key).

Alice then sends πunshield\pi_\text{unshield} to StypeS_\text{type} and invokes the unshield smart contract function call. StypeS_\text{type} verifies πunshield\pi_\text{unshield}, checks that null\text{null} has not previously appeared in the nullifier set, and looks up Alice’s wallet address addr\text{addr} corresponding to owner\text{owner}. If the checks pass, it publicly transfers vv tokens of type\text{type} to addr\text{addr}.

Transferring a token

Finally, a private_transfer combines token nullification and generation. WorldPx handles a general case for private_transfer in which Alice spends two input tokens and creates two output tokens. We assume Alice owns two tokens, (r1,ownerA,v1,sn1,type)(r_1, \text{owner}_A, v_1, \text{sn}_1, \text{type}) and (r2,ownerA,v2,sn2,type)(r_2, \text{owner}_A, v_2, \text{sn}_2, \text{type}) (note that ownerA\text{owner}_A is Alice’s public identity); if she doesn’t, she can always use shield to create a zero-value token. We denote the commitments to these tokens as Comtoken(1),Comtoken(2)\text{Com}_\text{token}^{(1)}, \text{Com}_\text{token}^{(2)}.

Suppose Alice wants to transfer some amount to Bob, but v1+v2v_1 + v_2 might not be the exact amount she wishes to send, so she nullifies both above tokens and generates two new tokens with the same combined value: (r3,ownerB,v3,sn3,type)(r_3, \text{owner}_B, v_3, \text{sn}_3, \text{type}), owned by Bob (note that ownerB\text{owner}_B is Bob’s public identity) and carrying the transfer amount, and (r4,ownerA,v4,sn4,type)(r_4, \text{owner}_A, v_4, \text{sn}_4, \text{type}), returned to Alice as change. We denote the commitments to these tokens as Comtoken(3),Comtoken(4)\text{Com}_\text{token}^{(3)}, \text{Com}_\text{token}^{(4)}.

Alice proves the correctness of this entire operation with a ZK proof πtransfer\pi_\text{transfer}, which establishes that:

  1. She knows a secret user id secret_idA\text{secret\_id}_A which hashes to a public ID ownerA\text{owner}_A, where ownerA\text{owner}_A is a leaf of TuniqueT_\text{unique}. She does not reveal ownerA\text{owner}_A.
  2. She knows two valid input tokens (r1,ownerA,v1,sn1,type)(r_1, \text{owner}_A, v_1, \text{sn}_1, \text{type}), (r2,ownerA,v2,sn2,type)(r_2, \text{owner}_A, v_2, \text{sn}_2, \text{type}), both owned by ownerA\text{owner}_A, whose commitments Comtoken(1),Comtoken(2)\text{Com}_\text{token}^{(1)}, \text{Com}_\text{token}^{(2)} are leaves of TtokenT_\text{token}, without revealing anything about the tokens or their commitments.
  3. Using the same secret_idA\text{secret\_id}_A and sn1,sn2\text{sn}_1, \text{sn}_2 from the two input tokens, she correctly computes their public nullifiers, null(1)\text{null}^{(1)} and null(2)\text{null}^{(2)}.
  4. She then creates two output token templates as described above, (r3,ownerB,v3,type)(r_3, \text{owner}_B, v_3, \text{type}) and (r4,ownerA,v4,type)(r_4, \text{owner}_A, v_4, \text{type}). Alice proves that v1+v2=v3+v4v_1 + v_2 = v_3 + v_4, i.e. no value was created nor destroyed (range checks required here to prevent field overflow).
  5. She then computes the inner commitments for (r3,ownerB,v3,type)(r_3, \text{owner}_B, v_3, \text{type}) and (r4,ownerA,v4,type)(r_4, \text{owner}_A, v_4, \text{type}) (note that she does not compute the final Comtoken\text{Com}_\text{token} for either since StypeS_\text{type} picks sn3,sn4\text{sn}_3, \text{sn}_4):
Cominner1(3)=Hinner1(r3)Cominner2(3)=Hinner2(Cominner1(3)∣∣ownerB∣∣v3∣∣type)Cominner1(4)=Hinner1(r4)Cominner2(4)=Hinner2(Cominner1(4)∣∣ownerA∣∣v4∣∣type)\begin{align*} \text{Com}^{(3)}_\text{inner1} &= H_\text{inner1}(r_3)\\ \text{Com}^{(3)}_\text{inner2} &= H_\text{inner2}(\text{Com}^{(3)}_\text{inner1} \mid \mid \text{owner}_B \mid \mid v_3 \mid \mid \text{type}) \\ \quad \\ \text{Com}^{(4)}_\text{inner1} &= H_\text{inner1}(r_4)\\ \text{Com}^{(4)}_\text{inner2} &= H_\text{inner2}(\text{Com}^{(4)}_\text{inner1} \mid \mid \text{owner}_A \mid \mid v_4 \mid \mid \text{type})\\ \end{align*}
  1. She shows that the type\text{type}s are all the same, and that ownerA\text{owner}_A matches the ones in Comtoken(1),Comtoken(2)\text{Com}_\text{token}^{(1)}, \text{Com}_\text{token}^{(2)}.
  2. She correctly encrypts the transaction details into a ciphertext EE for the auditor.

She sends null(1),null(2),πtransfer,E,Cominner2(3),Cominner2(4)\text{null}^{(1)}, \text{null}^{(2)}, \pi_\text{transfer}, E, \text{Com}_\text{inner2}^{(3)}, \text{Com}_\text{inner2}^{(4)}, and an encrypted message for Bob to StypeS_\text{type} and invokes the private_transfer function. The function checks that both nullifiers are unused and the πtransfer\pi_\text{transfer} verifies. If the checks pass, it generates unique serial numbers sn3,sn4\text{sn}_3, \text{sn}_4 for the new output tokens and hashes them into the corresponding partial commitments, then inserts these into TtokenT_\text{token}.

Comtoken(3)=Houter(Cominner2(3)∣∣sn3)Comtoken(4)=Houter(Cominner2(4)∣∣sn4)\begin{gathered} \text{Com}^{(3)}_\text{token} = H_\text{outer}(\text{Com}^{(3)}_\text{inner2} \mid \mid \text{sn}_3) \\ \text{Com}^{(4)}_\text{token} = H_\text{outer}(\text{Com}^{(4)}_\text{inner2} \mid \mid \text{sn}_4) \end{gathered}

Finally, Alice can then either notify Bob directly or rely on Bob to scan the public state and discover the transaction himself.

Tl;dr for experts

Note: If you’ve read the remainder of the blog up to this section, feel free to skip to the “Proof of Human for Sybil Resistance” section below!

WorldPx is a proposed augmented version of a standard UTXO commitment-based system which is (almost entirely) implementable as a pure smart contract on World Chain, an Ethereum-based L2. Note that WorldPx is in the exploratory stage, and is not implemented. This blog is an outline of what we feel is a clean, extensible design with accountability built-in. Key design choices in the basic peer-to-peer transaction construction include the following —

  • WorldPx uses Poseidon over BN-254’s scalar field to build its token commitment function, Merkle trees, and nullifier function.
  • WorldPx uses Groth16 over the BN-254 curve as its ZK proof system, and verifies Groth16 proofs directly via a verifier smart contract on World Chain.
  • WorldPx allows the smart contract to choose the newly minted token’s serial number during a private_transfer() call by using the Merkle index where the newly minted token’s commitment will live as a Merkle leaf. This constrains us to use a doubly-nested commitment where the sender computes a partial Poseidon commitment by absorbing the commitment’s blinding factor and the smart contract itself “completes” the commitment by absorbing the serial number, recipient public address, token type, and token value, but allows us to avoid serial number collisions and prevents the so-called “fool’s gold” attack where a sender simply burns a token rather than actually sending it to the recipient, by maliciously issuing a duplicate serial number token for the recipient.
  • Nullifiers are stored directly within the smart contract, as is the entire Merkle tree of token commitments. We can optimize both of these, but such optimization is outside the scope of this blog post.
  • Notation-wise, let Alice be the canonical sender, and let Bob be the canonical recipient. Let StypeS_\text{type} be the private token smart contract for token type type\text{type}.

The Acts II: Proof of Human, Anonymity Budget, Verifiable Encryption

Proof of Human for Sybil Resistance

In theory, to use Proof of Human for Sybil resistance, recall that WorldPx’s token commitment structure is as follows:

Cominner1=Hinner1(r)Cominner2=Hinner2(Cominner1∣∣owner∣∣v∣∣type)Comtoken=Houter(Cominner2∣∣sn)\begin{align*} \text{Com}_\text{inner1} &= H_\text{inner1}(r)\\ \text{Com}_\text{inner2} &= H_\text{inner2}(\text{Com}_\text{inner1} \mid \mid \text{owner} \mid \mid v \mid \mid \text{type})\\ \text{Com}_\text{token} &= H_\text{outer}(\text{Com}_\text{inner2} \mid \mid \text{sn}) \end{align*}

In particular, recall that owner=Hpub(secret_id)\text{owner} = H_\text{pub}(\text{secret\_id}) is the token owner’s public ID associated with a World Chain address addr\text{addr}. In WorldPx’s case, there is a separate Merkle tree (denote this TuniqueT_\text{unique}) stored within the smart contract whose leaves are exactly the set of all owner\text{owner}s who have registered with the smart contract via Proof of Human.

The only way a user can insert() themselves into TuniqueT_\text{unique} is to provide a Proof of Human ZK proof which absorbs owner\text{owner} into its transcript (but not its nullifier!) and computes the (Proof of Human, not WorldPx) nullifier

nullregister←H(WID merkle_idx∣∣OPRF secret∣∣“WorldPx_register")\text{null}_\text{register} \leftarrow H(\text{WID merkle\_idx} \mid \mid \text{OPRF secret} \mid \mid \text{``WorldPx\_register"})

Thus, in order to perform a shield() or private_transfer() call, the corresponding ZK proofs πshield,πtransfer\pi_\text{shield}, \pi_\text{transfer} require the sender to additionally prove that the owner\text{owner} field within Cominner\text{Com}_\text{inner} is a member of TuniqueT_\text{unique}. This ensures that every account which is attempting to shield or privately transfer is tied to Proof of Human, and allows WorldPx to support roughly one WorldPx-enabled account per unique human.

Anonymity budget a la UTT

With Sybil resistance properties from Proof of Human, WorldPx can now implement the “budget token” idea inspired by UTT.

WorldPx expands StypeS_\text{type} to contain yet another Merkle tree (denote this TbudgetT_\text{budget}) whose leaves contain “budget token” commitments of the following form:

Cominner=HBudget_Inner(r)Combudget=Hbudget(Cominner∣∣owner∣∣b∣∣type∣∣sn∣∣exp)\begin{gathered} \text{Com}_\text{inner} = H_\text{Budget\_Inner}(r)\\ \text{Com}_\text{budget} = H_\text{budget}(\text{Com}_\text{inner} \mid \mid \text{owner} \mid \mid b \mid \mid \text{type} \mid \mid \text{sn} \mid \mid \text{exp}) \end{gathered}

The idea is as follows — every month, each user is allotted bb “anonymity budget” to spend, i.e. the total amount of tokens they send via private_transfer() cannot exceed bb. Our exploration supports two ways for budget assignment. The simple way is to have StypeS_\text{type} itself “batch insert” a bunch of leaves into fresh TbudgetT_\text{budget}, where for each leaf (containing owner\text{owner}) in TuniqueT_\text{unique}, it creates a Combudget\text{Com}_\text{budget} with a blinding factor r=0r = 0 and expiration exp\text{exp} equal to the end of the time period (e.g. one month). A second approach is to have each user manually request their budget at the beginning of each month, e.g. via an assign_budget() call to StypeS_\text{type}. This function would take a Cominner\text{Com}_\text{inner} from above, and check that the owner\text{owner} who is requesting is a member of TuniqueT_\text{unique}. Finally, StypeS_\text{type} would create Combudget\text{Com}_\text{budget} from standard fields (bb, exp\text{exp}, type\text{type}) after picking a unique sn\text{sn}, and add the commitment to TbudgetT_\text{budget}.

During a private_transfer() call, WorldPx adds the following constraints to the πtransfer\pi_\text{transfer} proof:

  • The owner\text{owner} field within Combudget\text{Com}_\text{budget} is the same as the one within Comtoken\text{Com}_\text{token} (the token which is being spent)
  • The sender (Alice) creates a new Combudget′\text{Com}_\text{budget}' which contains the same owner,type\text{owner}, \text{type}, and exp\text{exp} (expiration date) fields as the current one, and additionally contains b−vb - v as its “value” field, where vv is the amount sent in the private_transfer() call. Of course, the user needs to prove b−v≥0b - v \geq 0.
  • Alice computes nullbudget=HBudget_Null(owner∣∣sn)\text{null}_\text{budget} = H_\text{Budget\_Null}(\text{owner} \mid \mid\text{sn}) to nullify the existing budget token.
  • StypeS_\text{type} inserts Combudget′\text{Com}_\text{budget}' into TbudgetT_\text{budget} and adds nullbudget\text{null}_\text{budget} into its nullifier pool, thus nullifying Combudget\text{Com}_\text{budget}.

Note that this anonymity budget idea depends on Proof of Human: during private account creation, a nullifier limits each unique human to one WorldPx-enabled account. Without this Sybil-resistance property, a single person could simply create unlimited accounts and privately transfer as much as they please.

Verifiable encryption for auditor

Just the above, however, is not sufficient to support an auditable design — future laws or regulation bodies may require a stronger notion of compliance, and thus ideally WorldPx would have the ability to give an external auditor/monitor the ability to “peer into” the contents of all transactions on-chain, without giving even ourselves (i.e. the operators of the World Chain sequencer) the ability to do so!

To satisfy these compliance requirements, we assume an auditor with keypair skaudit,pkaudit\text{sk}_\text{audit}, \text{pk}_\text{audit} and encryption scheme c←Enc(m;pkaudit)c \leftarrow \text{Enc}(m; \text{pk}_\text{audit}). Every time Alice creates or nullifies a private token, she includes the following ciphertext alongside each transaction:

caudit←Enc(r∣∣owner∣∣v∣∣type;pkaudit)c_\text{audit} \leftarrow \text{Enc}(r \mid \mid \text{owner} \mid \mid v \mid \mid \text{type}; \text{pk}_\text{audit})

This is the exact contents of Cominner\text{Com}_\text{inner} and it allows the holder of skaudit\text{sk}_\text{audit} to read the entire private_transfer() call in plaintext!

A malicious sender can, of course, simply encrypt random garbage. We thus force Alice to prove (as part of πtransfer\pi_\text{transfer}) that the values which reside within cauditc_\text{audit} are the same as those which open the corresponding token commitments.

Finally, to express the stream cipher relatively efficiently in circuit, we instantiate the above cipher via an ECDH + Poseidon instantiation of HPKE, using Poseidon as an AEAD stream cipher (we simply add an extra squeeze to receive a tag at the very end).

Encore: expanding WorldPx for DeFi

In this blog, we introduced an exploratory technical approach, WorldPx, which includes a basic private transaction model that achieves user privacy and transaction accountability across three operations: shield, unshield, and private_transfer. Our longer-term goal is to support decentralized finance (DeFi), where users can access financial services such as trading, lending, asset swapping, and borrowing directly on-chain. Building on the same core concepts and cryptographic tools, we sketch high-level designs for extending WorldPx to support several specific DeFi functionalities.

First, we introduce two new functions to StypeS_\text{type}: shield_for_recipient and unshield_for_recipient. These functions extend shield and unshield, respectively, by allowing funds to move between one user’s public account and another user’s private address.

  • shield_for_recipient(sender, [recipient], amount, type) sends amount of token type from sender's public balance to StypeS_\text{type}, then mints a new private token of type type and worth amount in the name of recipient.
  • unshield_for_recipient([sender], recipient, amount, type) nullifies a private token owned by sender with type type and worth amount, then sends the same amount of token type to recipient's public balance.

While technically straightforward, a general version of these creates a potential loophole in the monthly budget: unlike the original operations, which only move funds within the same user’s public/private account pair, these operations can act as transfers outside private_transfer. Since these functionalities are extraneous in the context of regular peer-to-peer transactions, we can start by simply having a whitelist of private addresses which shield_for_recipient() and unshield_for_recipient() are allowed to interact with.

Note that both applications will be identity-hiding, but not private, in the sense that the amounts and token types being exchanged will be known. The reason for this is as follows — in both cases, the action (adding capital to a lending pool, swapping one token type for another at some exchange rate) changes the global public state of the DeFi smart contract in some deterministic way! In the former case, anyone observing the state of the lending pool will immediately see that vv tokens of type\text{type} were just deposited, while anyone observing the state of the swap pool will also see that “v1v_1 tokens of type1\text{type}_1 were just exchanged for v2v_2 tokens of type2\text{type}_2”.

Additionally, such global state cannot be made private without fundamentally changing the financial primitive which these services provide. In the case of a lending pool, a borrower needs to learn the exact amount of tokens in the pool so they can decide how much to borrow, and for asset swap, they need to know the exchange rate. We thus do not attempt to hide the amounts or token types which are involved, and instead focus on hiding the identities of the users who are interacting with the services.

DeFi sketch 1: identity-hiding lending pool

We model the vanilla lending pool off a simplified version of Aave, and assume the point of view of an asset lender (not a borrower). Let SpoolS_\text{pool} denote the lending pool smart contract. Roughly speaking, there is a “compound factor” βt\beta_t which represents the cumulative compound interest factor from day 1 to day tt. SpoolS_\text{pool} stores βt\beta_t as a variable that periodically increases. To use such a service, a lender calls two smart contract functions —

  • deposit_to_pool(sender, amount, type) , which sends amount of token type type to SpoolS_\text{pool} and receives amountβt\frac{\text{amount}}{\beta_t} of a “pool token” type (which we’ll call pool_type) in return.
  • withdraw_from_pool(recipient, pool_token_amount, pool_type), which sends pool_token_amount of the pool token pool_type to StypeS_\text{type} and receives pool_token_amount⋅βt′\text{pool\_token\_amount} \cdot \beta_{t'} amount of token type in return.

In the identity-hiding version, we modify the functionality of deposit_to_pool() and withdraw_from_pool() slightly (indeed, by simply calling our helper functions shield_for_recipient() and unshield_for_recipient() , as described earlier):

  • We augment SpoolS_\text{pool} with an additional Merkle tree, TpoolT_\text{pool}, whose leaves will contain blinded commitments to all “pool tokens” which have been issued to users. SpoolS_\text{pool} will also contain a corresponding nullifier set for TpoolT_\text{pool}.
  • Commitments to pool tokens will be structured slightly differently —
Cominner=HPool_Inner(r∣∣owner)Compool=Hpool(Cominner∣∣v∣∣type∣∣sn)\begin{gathered} \text{Com}_\text{inner} = H_\text{Pool\_Inner}(r \mid \mid \text{owner})\\ \text{Com}_\text{pool} = H_\text{pool}(\text{Com}_\text{inner} \mid \mid v \mid \mid \text{type} \mid \mid \text{sn}) \end{gathered}
  • The reason for the above is similar to that of private transfer -- the smart contract shouldn’t learn any of the lender’s secrets, but needs to compute vv and sn\text{sn} “in real time”, and ensure that type\text{type} is the correct pool_type corresponding to the type of token which was just deposited.
  • identity_hiding_deposit_to_pool([lender], amount, type) now first has the lender call StypeS_\text{type}’s unshield_for_recipient([lender], $S_\text{pool}$, amount, type) function, which nullifies their private token of type worth amount and sends the corresponding amount publicly to SpoolS_\text{pool}. Next, the lender sends a partial commitment (Cominner\text{Com}_\text{inner} from above) to SpoolS_\text{pool}, who uses this, alongside computing a pool token amount using its βt\beta_t and the amount which was deposited, as well as a new serial number snpool\text{sn}_\text{pool}, to mint a new private pool token commitment Compool\text{Com}_\text{pool}. Finally, SpoolS_\text{pool} appends Compool\text{Com}_\text{pool} to TpoolT_\text{pool}, which means that the lender now owns a private pool token worth amountβt\frac{\text{amount}}{\beta_t}.
  • identity_hiding_withdraw_from_pool([lender], pool_token_amount, pool_type) now takes lender private pool token with amount pool_token_amount and nullifies it. Next, SpoolS_\text{pool} computes the corresponding amount←pool_token_amount⋅βt′\text{amount} \leftarrow \text{pool\_token\_amount} \cdot \beta_{t'} and calls the shield_for_recipient($S_\text{pool}$, [lender], amount, type) function within StypeS_\text{type}, depositing amount\text{amount} to StypeS_\text{type} and minting a new private token of type type for lender (note that the lender themselves needs to compute this partial/inner commitment and pass it through SpoolS_\text{pool}, since SpoolS_\text{pool} doesn’t have the information by itself). Finally, StypeS_\text{type} appends the new private token commitment to TtokenT_\text{token}, which means that the lender now owns a private token worth amount\text{amount}.

DeFi sketch 2: identity-hiding swap

Similarly, we model the simplified version of a swap protocol off of Uniswap. In this case, we assume that point of view of a trader (not a pool LP) who hopes to swap tokens of type type1\text{type}_1 for those of type2\text{type}_2. We denote the swap smart contract as SswapS_\text{swap}.

  • Assume that SswapS_\text{swap} has a paired pool specifically between tokens type1\text{type}_1 and type2\text{type}_2. In particular, this paired pool has some amount of each type, a1,a2a_1, a_2, and an exchange rate which is determined by the amounts, rate(a1,a2)\text{rate}(a_1, a_2).
  • Our trader calls a single function, swap(trader, type_1, type_2, amount) , i.e. “swap amount of token type type_1 for as much type_2 as possible”. Let f2(x)f_2(x) denote the amount of type2\text{type}_2 token remaining if xx amount of type1\text{type}_1 is deposited. We have that f2f_2 can be written as the following —
f2(x)=a2−∫0xrate(a1+t,f2(t))dtf_2(x) = a_2 - \int_{0}^{x} \text{rate}(a_1 + t, f_2(t)) dt
  • SpoolS_\text{pool} computes a2−f2(amount)a_2 - f_2(\text{amount}), i.e. the total amount of type2\text{type}_2 token which should be given back to the trader, and sends this to the trader, updating the pool balance in the process.

In the identity-hiding swap case, we instead have identity_hiding_swap([trader], type_1, type_2, amount):

  • The trader calls Stype1S_{\text{type}_1}’s unshield_for_recipient([trader], $S_\text{pool}$, amount, type_1) function, which nullifies a private token owned by trader of type_1 worth amount. Stype1S_{\text{type}_1} sends the resulting public amount of type_1 token to SswapS_\text{swap}.
  • SswapS_\text{swap} computes amount2←a2−f2(amount)\text{amount}_2 \leftarrow a_2 - f_2(\text{amount}), as described above, and calls Stype2S_{\text{type}_2}’s shield_for_recipient($S_\text{swap}$, [trader], amount_2, type_2) function. In other words, SswapS_\text{swap} sends amount_2 of type_2 publicly to Stype2S_{\text{type}_2} (alongside a pre-populated partial commitment from trader), who mints a new private token worth amount_2 of type_2 and adds the commitment to Ttoken2T_{\text{token}_2}.
  • The above means that trader now owns a private token of type_2 worth amount_2, thus completing the swap without ever revealing the identity of trader.

Outro: applause… for you!

If you’ve made it this far, thank you so much for reading our blog post! We’d like to note once again that none of the above is implemented or in production, and if this is the kind of stuff you like thinking about/working on (especially if you have any questions or extensions to the above), please do reach out to us!

Bergabunglah dengan jaringan manusia nyata.

Dapatkan World ID App