Request for Comments: Shared Partial Note

Overview

We outline SharedUintNote, a kind of partial note that allows a reimbursement to be performed in a given token TOKEN0, while the publicly known value of the reimbursement is in units of another token TOKEN1, without leaking the exchange rate between the tokens.

I.e., running the following in public

SharedUintNote{..., value}.mul(reimbursement)

would result in the completion of two private notes:

  • UintNote{owner: creator, value: price * reimbursement}
  • UintNote{owner: completer, value: price * (value - reimbursement)}

with the following guarantees:

  • creator, completer, and price remain private
  • Total token supply is conserved

Recap: partial notes now and before

Now

Currently, note hashes have the following form

commitment = poseidon2_hash_with_separator(
  [owner, randomness, storage_slot],
  GENERATOR_INDEX__NOTE_HASH,
)
complete_hash = poseidon2_hash_with_separator(
  [commitment, value],
  GENERATOR_INDEX__NOTE_HASH,
)

This allows for the construction of

struct PartialUintNote {
  commitment: Field
}

that can be constructed in private but completed in public, since the commitment suffices for any actor (the “completer”) to construct the note hash for a normal note with the committed data and a value chosen by the completer.

Before

Note hashes used MSM (multi-scalar multiplication). Paraphrasing these docs,

hash =
  owner * g_owner
  + randomness * g_randomness
  + storage_slot * g_slot
  + value * g_value;

where the g variables are elliptic curve generators.

This allows the completion of a partial note after an additive operation without knowing the initial value, since hash + delta_value * g_value is the hash for a note with the same data but value value + delta_value.

Note that this hash computation can again be split as follows:

commitment = owner * g_owner + randomness * g_randomness + storage_slot * g_slot + value
hash = commitment + delta_value * g_value

Partial note flow

The usual flow of a partial note is as follows

sequenceDiagram
  participant AppContract
  participant TokenContract
  participant UintNote
  participant PartialNote
  
  rect rgb(0, 64, 64, .5)
    note over AppContract, PartialNote: Private
		AppContract ->> TokenContract: call prepare_partial_note(..., owner)
		TokenContract ->> UintNote: call partial(..., owner)
		note over UintNote: log stuff
		note over UintNote: compute commitment(owner, randomness)
		note over UintNote: let partial_note = PartialNote{commitment}
		UintNote -->> TokenContract: return partial_note
		TokenContract -->> AppContract: return partial_note
		note over AppContract: enqueue public_function(..., partial_note)
	end
	rect rgb(128, 90, 100, .5)
		note over AppContract, PartialNote: Public
		note over AppContract: compute amount
		AppContract ->> TokenContract: call finalize(partial_note, amount)
		note over TokenContract: balances[sender] -= amount
		TokenContract ->> PartialNote: call partial_note.complete(amount)
		note over PartialNote: log stuff
		note over PartialNote: compute note_hash(commitment, amount)
		note over PartialNote: emit note_hash
	end

Logging is required to allow for note discovery by the note’s owner.

Concept: multiplying a partial note’s value

We could define the following hash for a note instead

data_commitment =
  owner * g_owner
  + data_randomness * g_randomness
  + storage_slot * g_slot;
value_commitment =
  value * g_value
  + value_randomness * g_randomness;
hash = data_commitment + value_commitment;

The partial note would look like this

struct PartialUintNote {
  data_commitment: Field,
  value_commitment: Field,
}

Now the value_commitment can be operated upon in two basic ways:

  • value_commitment + delta_value * g_value as before
  • factor * value_commitment

Given that

\mathrm{factor}\cdot\mathrm{value\_commitment}=\mathrm{factor}\cdot(\mathrm{value}\cdot g_\mathrm{value}+\mathrm{value\_randomness}\cdot g_\mathrm{randomness})\\ =\mathrm{factor}\cdot\mathrm{value}\cdot g_\mathrm{value}+\mathrm{factor}\cdot\mathrm{value\_randomness}\cdot g_\mathrm{randomness},

the assignment value_commitment = factor * value_commitment turns the commitment to {value: value, randomness: randomness} into a commitment to {value: factor * value, randomness: factor * randomness}, which produces a valid note.

This way, if TOKEN0 = price * TOKEN1, you could privately construct a partial note storing the value of price. When completing the note, you could assign the price in units of TOKEN1, but multiplying against the partial note’s value commitment would generate a note that’s in units of TOKEN0.

It’s worth noting that price can be a fraction, because the math can be done between Field elements, whose product is associative and commutative. Hence, if price = numerator / denominator, amount_token_1 * price == numerator * amount_token_1 / denominator.

Issue: the token can no longer enforce total supply invariance

Since the value of the note is no longer known in public, the token can no longer publicly decrement value from the completer’s balance in order to ensure conservation of total supply. This can be solved with the shared note, introduced in the next section.

Concept: shared note

We can build upon the previous concept to introduce the following

struct SharedPartialNote {
  creator_partial_note: PartialUintNote,
  completer_partial_note: PartialUintNote,
  amount: Field,
}

When creating such a note, the token would consume notes from the issuer, worth consumed_amount = amount * price. The note is meant as a transaction to the completer, where a refund_amount is to be given back to the creator but can only be computed in public. In order to conceal the exchange rate between TOKEN0 and TOKEN1, all public computations are denominated in units of TOKEN1.

Completion would look like this:

Token::at(token_address)
  .finalize_transfer_to_private(refund_amount, shared_partial_note)
  .call(&mut context);

This public function would then complete the two partial notes as follows

creator_partial_note.mul_complete(refund_amount);
completer_partial_note.mul_complete(amount - refund_amount);

The resulting notes’ amount would satisfy

let creator_amount = refund_amount * price;
let completer_amount = (max_amount - refund_amount) * price;
assert!(completer_amount + creator_amount == consumed_amount);

This ensures the total token supply remains invariant, addressing the issue mentioned in the previous section.

Conceptually, the creator of the shared note burns their own notes in order to create a “shared” partial note that’s worth the same amount. The completer will define the amount to return to the creator, and the shared note will then emit two normal notes: one with the return amount for the creator, and another with the rest for the completer.

The exchange rate between the public amounts and the private amounts is kept private by emitting private notes to both the creator and the completer, instead of publicly modifying the completer’s balance.

Shared note flow

A shared note’s flow is almost identical to that of a normal partial note from the perspective of the app contract, except we must specify the private parameter price and the public parameter amount. Remember that to allow this, the partial note now must work as described in the previous section.

sequenceDiagram
  participant AppContract
  participant TokenContract
  participant UintNote
  participant SharedNote
  participant PartialNote
  
  rect rgb(0, 64, 64, .5)
    note over AppContract, PartialNote: Private
    note over AppContract: define price and max_amount
		AppContract ->> TokenContract: call prepare_shared_note(..., owner, completer, price, max_amount)
		note over TokenContract: burn notes from the creator worth (max_amount * price)
		TokenContract ->> UintNote: call shared(..., owner, completer, price, max_amount)
		note over UintNote: log stuff
		note over UintNote: compute owner_commitment(owner, owner_randomness)
		note over UintNote: compute completer_commitment(completer, completer_randomness)
		note over UintNote: compute price_commitment(price, price_randomness)
		note over UintNote: let owner_note = PartialNote{owner_commitment, price_commitment}
		note over UintNote: let completer_note = PartialNote{completer_commitment, price_commitment}
		note over UintNote: let shared_note = SharedNote{owner_note, completer_note, max_amount}
		UintNote -->> TokenContract: return shared_note
		TokenContract -->> AppContract: return shared_note
		note over AppContract: enqueue public_function(..., shared_note)
	end
	rect rgb(128, 90, 100, .5)
		note over AppContract, PartialNote: Public
		note over AppContract: compute amount
		AppContract ->> TokenContract: call finalize(shared_note, amount)
		note over TokenContract: let amount = max(max_amount, amount)
		TokenContract ->> SharedNote: call shared_note.mul_complete(amount)
		SharedNote ->> PartialNote: call owner_note.mul_complete(amount)
		note over PartialNote: log stuff and emit finalized note hash
		SharedNote ->> PartialNote: call completer_note.mul_complete(max_amount - amount)
		note over PartialNote: log stuff and emit finalized note hash
	end

Use case: FPCs

With the current design of private notes, an FPC that takes an arbitrary token TOK as input would be forced to reveal the price between TOK and AZT (FeeJuice), which leaks some information. This scheme would allow the FPC to quantify the reimbursement in AZT but produce notes for TOK, only leaking the used token but not the exchange rate.

1 Like