How to handle private escrows between two parties..?

The Wonderland team is building a private oracle contract on the Sandbox. This contract allows any user to ask a question privately to a divinity of their choice, locking down a fee in a given Token. The divinity can then provide an answer for a question, which unlocks the fee payment to them, or the user can cancel their question, which returns the fee to them. Both answering or cancelling a question emit the same nullifier, ensuring only one of those actions is possible.

The problem comes when locking the fee in the Oracle contract. This is achieved by transferring the funds, via an authwitted private token.transfer call, from the user to the Oracle contract. However, the Oracle contract, being an “app” contract, doesn’t have a private key, so it cannot receive private value notes. So how do we deal with this…?

Using a “public” encryption private key for the contract

We deploy the Oracle contract with a private key that’s shared with the world. Anyone who wants to interact with the contract, simply registers that key in their pxe so they can “see” all the value notes owned by the oracle. This is the simplest solution, but it leaks privacy, since now everyone can see when a private tx calls the oracle contract.

Using public token balances

Another easy solution: instead of calling token.transfer in the Oracle contract, we unshield from the user to it, and then shield back to the user or to the divinity. This leaks not just that the Oracle is in use, but also who the answering divinity is in each transfer.

Escrow contract per question (or per user/divinity pair)

Whenever we need to escrow funds in a note that must be visible by a user and a divinity, we deploy a new contract with an encryption key that’s shared between the two. The shared encryption key can be generated using the following scheme:

PubKeyAlice = PrivKeyAlice * G
PubKeyBob = PrivKeyBob * G
SS = PubKeyAlice * PrivKeyBob = PubKeyBob * PrivKeyAlice

Knowing the deployment pubkey and bytecode of the escrow contract, it should be possible to reconstruct the expected escrow contract address within the Oracle contract (like the address of Uniswap trading pair contracts can be reconstructed from the address of each token).

Now, whenever we need to lock the fee from the user, we transfer it to a specific escrow contract with an encryption key that allows both the user and the divinity to “see” the value note and use it for either answering or cancelling the question.

However, this adds a massive overhead to the protocol. It also needs a separate transaction for deploying each escrow contract, since we don’t yet support deploying a contract from another one.

Transfer to a shared pubkey

The easiest solution would probably be to create a value note owned by the Oracle contract but encrypted for a pubkey specific to the user/divinity pair. However, we don’t support that in our Token contract implementation since we mapped addresses and pubkeys 1:1.

To support this, we would need to modify the token transfer function (or add an overload) to receive the recipient address and pubkey separately. The Oracle contract would then calculate the shared encryption private key using a combination of get_secret_key(user) and get_public_key(divinity), derive the public key, and execute a transfer to self using that derived pubkey.

Note that in both this scenario and the previous one the app would need to register this shared encryption key as a new account on both the user and the recipient pxes, which means a large additional note decryption overhead for a pretty much ephemeral pubkey, that can be tossed out once the question was finished.

Reencrypt the same note under multiple pubkeys

An alternative that does not require overloading the pxe with additional keys is to simply re-encrypt the same note under the pubkey of each recipient. In other words, having a single commitment on the note hash tree, but emit it as an encrypted log multiple times. The tradeoff here is more calldata used in the tx.

An issue with this approach is nullifying the note. Since multiple users have “access” to the note, they all need to be able to nullify it. The shared secret, derived from the recipients pubkeys, could be used here. Or a random nullifier key could be just embedded as part of the encrypted note, which becomes visible to all those who can decrypt it.

This implies that we cannot use the same note implementation for multi-encryption that we use for individual transfers, since we cannot just rely on get_secret(note.owner) for nullifying. But it’s a fairly small change, and it also plays well with storing nullifier secrets or identifiers as part of the note itself if we allow for rotating nullifier keys.

73 Likes

To elaborate on this approach, we’ll need to modify the token contract and introduce a new note type. This new note, let’s call it SharedValueNote for lack of a better name (please, dear reader, think of a better name!), should be similar to the ValueNote except for:

  • compute_nullifier should not use the owner’s nullifier but a random secret generated by the note creator and embedded into the note, so it’s accessible by all viewers.
  • broadcast should not use the owner pubkey, but receive a list of the addresses for which to emit the encrypted log. Note that, given that this diverges from the default NoteInterface, we may need to make the note’s broadcast a noop and handle it manually.

Note that we still want to keep the owner field since that’s the address that’s authorised for actually spending this note in the contract logic.

The token contract then needs logic for creating and consuming these notes. An easy solution is to have a dedicated method create_shared_note that consumes existing regular ValueNotes of equivalent value, and then a consume_shared_note which does the opposite, similar to shield/unshield operations. An open question is how the balance_of method would work, since it’d have to add balance across both flavors of notes.

Alternatively, it’d be interesting to see if we can merge both the SharedValueNote and the ValueNote into a single note type somehow, since the only practical difference is whether the nullifier is set as a random value by the sender or generated from the recipient’s secret. This would probably require having a special flavor of the transfer function that accepts a list of “viewers” for the note.

79 Likes

Great post as always!
Wanted to summarise some of the discussion we have been having offline.

1. Have a “public” key for the contract - simple but leaks privacy.

2. Unshield into the public domain - arguably easier than (1) since you have no overhead of keys. But depending on app, might leak more privacy. Works “well” for L1<>L2 interactions though and for transient escrowing - where the funds only stay in the contract for just the duration of the tx.

3. New contract per question/bet and give it a key - this wouldn’t be too expensive, especially if all the bytecode is in private! However it does mean you can’t have an open ended bet/escrow where you can challenge anyone (not a fixed address) to take part in your bet/escrow. Unfortunately, as you say, we need to implement the ability for contracts to deploy other contracts.

4. One contract. Different key per interaction - this avoids having to deploy a new contract. And if I understand you correctly, uses a Deffie-Hellman like key exchange. This is probably the neatest solution… As you say, notes have two kinds of owners, or as Lasse would call it:
a. Owner that provides nullifier secret (ie. owner in the protocol domain)
b. Owner that defines when to nullify (ie. owner in the application domain)

In this model, (a) will be the shared secret key. (b) will the contract address.

BAD NEWS - Unfortunately, this seems to break the note tagging scheme that is currently being designed. TL;DR from what I understand is you have to be like an account contract to wield your nullifier secret.

GOOD NEWS - there are workarounds:
i. For the special case of “transient escrowing” where the funds only live temporarily for the tx and get nullified in the same tx (as is used in most defi interactions), we don’t need to discover such notes, so it might work out. Lasse has explained in depth here but if I may butcher his words: token.transfer() would have a new parameter to_secret_provider which would be this secret key.

ii. If we mix this approach with design 3, then we might have something!

Okay, so what’s possible TODAY?
Design 1,2.

We are trying to finalise the spec for transient escrowing and that might unlock new designs! Will comment here once we come to an agreement on that!

Shoutout to Mike, Lasse, Jan and others for jamming with me on this!

66 Likes