Skip to main content

Partial Notes

Partial notes are a concept that allows users to commit to an encrypted value, and allows a counterparty to update that value without knowing the specific details of the encrypted value.

Use cases

Why is this useful?

Consider the case where a user wants to pay for a transaction fee, using a fee-payment contract and they want to do this privately. They can't be certain what the transaction fee will be because the state of the network will have progressed by the time the transaction is processed by the sequencer, and transaction fees are dynamic. So the user can commit to a value for the transaction fee, publicly post this commitment, the fee payer (aka paymaster) can update the public commitment, deducting the final cost of the transaction from the commitment and returning the unused value to the user.

So, in general, the user is:

  • doing some computation in private
  • encrypting/compressing that computation with a point
  • passing that point as an argument to a public function

And the paymaster is:

  • updating that point in public
  • treating/emitting the result(s) as a note hash(es)

The idea of committing to a value and allowing a counterparty to update that value without knowing the specific details of the encrypted value is a powerful concept that can be used in many different applications. For example, this could be used for updating timestamp values in private, without revealing the exact timestamp, which could be useful for many defi applications.

To do this, we leverage the following properties of elliptic curve operations:

  1. x_1 * G + x_2 * G equals (x_1 + x_2) * G and
  2. f(x) = x * G being a one-way function.

Property 1 allows us to be continually adding to a point on elliptic curve and property 2 allows us to pass the point to a public realm without revealing anything about the point preimage.

DEXes

Currently private swaps require 2 transactions. One to start the swap and another to claim the swapped token from the DEX. With partial notes, you can create a note with zero value for the received amount and have another party complete it later from a public function, with the final swapped amount. This reduces the number of transactions needed to swap privately.

Comparing to the flow above, the user is doing some private computation to stage the swap, encrypting the computation with a point and passing the point as an argument to a public function. Then another party is updating that point in public and emitting the result as a note hash for the user doing the swap.

Lending

A similar pattern can be used for a lending protocol. The user can deposit a certain amount of a token to the lending contract and create a partial note for the borrowed token that will be completed by another party. This reduces the number of required transactions from 2 to 1.

Private Refunds

Private transaction refunds from paymasters are the original inspiration for partial notes. Without partial notes, you have to claim your refund note. But the act of claiming itself needs gas! What if you overpaid fees on the refund tx? Then you have another 2nd order refund that you need to claim. This creates a never ending cycle! Partial notes allow paymasters to refund users without the user needing to claim the refund.

Before getting to partial notes let's recap what is the flow of standard notes.

Note lifecycle recap

The standard note flow is as follows:

  1. Create a note in your contract,
  2. compute the note hash,
  3. emit the note hash,
  4. emit the note (note hash preimage) as an encrypted note log,
  5. sequencer picks up the transaction, includes it in a block (note hash gets included in a note hash tree) and submits the block on-chain,
  6. nodes and PXEs following the network pick up the new block, update its internal state and if they have accounts attached they search for relevant encrypted note logs,
  7. if a users PXE finds a log it stores the note in its database,
  8. later on when we want to spend a note, a contract obtains it via oracle and stores a note hash read request within the function context (note hash read request contains a newly computed note hash),
  9. based on the note and a nullifier secret key a nullifier is computed and emitted,
  10. protocol circuits check that the note is a valid note by checking that the note hash read request corresponds to a real note in the note hash tree and that the new nullifier does not yet exist in the nullifier tree,
  11. if the conditions in point 10. are satisfied the nullifier is inserted into the nullifier tree and the note is at the end of its life.

Now let's do the same for partial notes.

Partial notes life cycle

  1. Create a partial/unfinished note in a private function of your contract --> partial here means that the values within the note are not yet considered finalized (e.g. amount in a UintNote),
  2. compute a note hiding point of the partial note using a multi scalar multiplication on an elliptic curve. For UintNote this would be done as G_amt * amount0 + G_npk * npk_m_hash + G_rnd * randomness + G_slot * slot, where each G_ is a generator point for a specific field in the note,
  3. emit partial note log,
  4. pass the note hiding point to a public function,
  5. in a public function determine the value you want to add to the note (e.g. adding a value to an amount) and add it to the note hiding point (e.g. NOTE_HIDING_POINT + G_amt * amount),
  6. get the note hash by finalizing the note hiding point (the note hash is the x coordinate of the point),
  7. emit the note hash,
  8. emit the value added to the note in public as an unencrypted log (PXE then matches it with encrypted partial note log emitted from private),
  9. from this point on the flow of partial notes is the same as for normal notes.

Private Fee Payment Example

Alice wants to use a fee-payment contract for fee abstraction, and wants to use private balances. That is, she wants to pay the FPC (fee-payment contract) some amount in an arbitrary token privately (e.g. a stablecoin), and have the FPC pay the transaction_fee.

Alice also wants to get her refund privately in the same token (e.g. the stablecoin).

The trouble is that the FPC doesn't know if Alice is going to run public functions, in which case it doesn't know what refund is due until the end of public execution.

And we can't use the normal flow to create a transaction fee refund note for Alice, since that demands we have Alice's address in public.

So we define a new type of note with its compute_note_hiding_point defined as:

amountGamount+addressGaddress+randomnessGrandomness+slotGslot\text{amount}*G_{amount} + \text{address}*G_{address} + \text{randomness}*G_{randomness} + \text{slot}*G_{slot}

Suppose Alice is willing to pay up to a set amount in stablecoins for her transaction. (Note, this amount gets passed into public so that when transaction_fee is known the FPC can verify that it isn't losing money. Wallets are expected to choose common values here, e.g. powers of 10).

Then we can subtract the set amount from Alice's balance of private stablecoins, and create a point in private like:

Pa:=alice addressGaddress+randaGrandomness+Alice note slotGslotP_a' := \text{alice address}*G_{address} + \text{rand}_a*G_{randomness} + \text{Alice note slot}*G_{slot}

We also need to create a point for the owner of the FPC (whom we call Bob) to receive the transaction fee, which will also need randomness.

So in the contract we compute randb:=h(randa,msg sender)\text{rand}_b := h(\text{rand}_a, \text{msg sender}).

warning

We need to use different randomness for Bob's note here to avoid potential privacy leak (see description of setup_refund function)

Pb:=bob addressGaddress+randbGrandomness+Bob note slotGslotP_b' := \text{bob address}*G_{address} + \text{rand}_b*G_{randomness} + \text{Bob note slot}*G_{slot}

Here, the PP's "partially encode" the notes that we are going to create for Alice and Bob. So we can use points as "Partial Notes".

We pass these points and the funded amount to public, and at the end of public execution, we compute tx fee point Pfee:=(transaction fee)GamountP_{fee} := (\text{transaction fee}) * G_{amount} and refund point Prefund:=(funded amounttransaction fee)GamountP_{refund} := (\text{funded amount} - \text{transaction fee}) * G_{amount}

Then, we arrive at the point that corresponds to the complete note by

Pa:=Pa+Prefund=(funded amounttransaction fee)Gamount+alice addressGaddress+randaGrandomness+Alice note slotGslotP_a := P_a'+P_{refund} = (\text{funded amount} - \text{transaction fee})*G_{amount} + \text{alice address}*G_{address} +\text{rand}_a*G_{randomness} + \text{Alice note slot}*G_{slot} Pb:=Pb+Pfee=(transaction fee)Gamount+bob addressGaddress+randbGrandomness+Bob note slotGslotP_b := P_b'+P_{fee} = (\text{transaction fee})*G_{amount} + \text{bob address}*G_{address} +\text{rand}_b*G_{randomness} + \text{Bob note slot}*G_{slot}

Then we just emit P_a.x and P_b.x as a note hashes, and we're done!

Private Fee Payment Implementation

NoteInterface.nr implements compute_note_hiding_point, which takes a note and computes the point "hides" it.

This is implemented by applying the partial_note attribute:

UintNote
#[partial_note(quote {value})]
pub struct UintNote {
// The amount of tokens in the note
value: U128,
owner: AztecAddress,
// Randomness of the note to hide its contents
randomness: Field,
}
Source code: noir-projects/aztec-nr/uint-note/src/uint_note.nr#L13-L22

Those G_x are generators that generated here. Anyone can use them for separating different fields in a "partial note".

We can see the complete implementation of creating and completing partial notes in an Aztec contract in the setup_refund and complete_refund functions.

setup_refund

setup_refund
#[private]
fn setup_refund(
fee_payer: AztecAddress, // Address of the entity which will receive the fee note.
user: AztecAddress, // A user for which we are setting up the fee refund.
funded_amount: Field, // The amount the user funded the fee payer with (represents fee limit).
) {
// 1. This function is called by fee paying contract (fee_payer) when setting up a refund so we need to support
// the authwit flow here and check that the user really permitted fee_payer to set up a refund on their behalf.
assert_current_call_valid_authwit(&mut context, user);

// 2. Since user is the logical sender of all the notes we get user's ovpk and use that in all of them.
let user_ovpk = get_public_keys(user).ovpk_m;

// 3. Deduct the funded amount from the user's balance - this is a maximum fee a user is willing to pay
// (called fee limit in aztec spec). The difference between fee limit and the actual tx fee will be refunded
// to the user in the `complete_refund(...)` function.
let change = subtract_balance(
&mut context,
storage,
user,
U128::from_integer(funded_amount),
INITIAL_TRANSFER_CALL_MAX_NOTES,
);
storage.balances.at(user).add(user, change).emit(encode_and_encrypt_note_unconstrained(
&mut context,
user_ovpk,
user,
user,
));

// 4. Now we get the partial payloads
// TODO(#7775): Manually fetching the randomness here is not great. If we decide to include randomness in all
// notes we could just inject it in macros.
let fee_payer_randomness = unsafe { random() };
let user_randomness = unsafe { random() };

let fee_payer_setup_payload = UintNote::setup_payload().new(
fee_payer,
fee_payer_randomness,
storage.balances.at(fee_payer).set.storage_slot,
);

let user_setup_payload = UintNote::setup_payload().new(
user,
user_randomness,
storage.balances.at(user).set.storage_slot,
);

// 5. We get transient storage slots
// Using the x-coordinate as a hiding point slot is safe against someone else interfering with it because
// we have a guarantee that the public functions of the transaction are executed right after the private ones
// and for this reason the protocol guarantees that nobody can front-run us in consuming the hiding point.
// This guarantee would break if `finalize_transfer_to_private` was not called in the same transaction. This
// however is not the flow we are currently concerned with. To support the multi-transaction flow we could
// introduce a `from` function argument, hash the x-coordinate with it and then repeat the hashing in
// `finalize_transfer_to_private`.

// We can also be sure that the `hiding_point_slot` will not overwrite any other value in the storage because
// in our state variables we derive slots using a different hash function from multi scalar multiplication
// (MSM).
let fee_payer_point_slot = fee_payer_setup_payload.hiding_point.x;
let user_point_slot = user_setup_payload.hiding_point.x;

// 6. We compute setup logs
let fee_payer_setup_log =
fee_payer_setup_payload.encrypt_log(&mut context, user_ovpk, fee_payer, fee_payer);
let user_setup_log =
user_setup_payload.encrypt_log(&mut context, user_ovpk, user, fee_payer);

// 7. We store the hiding points an logs in transients storage
Token::at(context.this_address())
._store_payload_in_transient_storage_unsafe(
fee_payer_point_slot,
fee_payer_setup_payload.hiding_point,
fee_payer_setup_log,
)
.enqueue(&mut context);
Token::at(context.this_address())
._store_payload_in_transient_storage_unsafe(
user_point_slot,
user_setup_payload.hiding_point,
user_setup_log,
)
.enqueue(&mut context);

// 8. Set the public teardown function to `complete_refund(...)`. Public teardown is the only time when a public
// function has access to the final transaction fee, which is needed to compute the actual refund amount.
context.set_public_teardown_function(
context.this_address(),
comptime { FunctionSelector::from_signature("complete_refund(Field,Field,Field)") },
[fee_payer_point_slot, user_point_slot, funded_amount],
);
}
Source code: noir-projects/noir-contracts/contracts/token_contract/src/main.nr#L672-L766

The setup_refund function sets the complete_refund function to be called at the end of the public function execution (set_public_teardown_function). This ensures that the partial notes will be completed and the fee payer will be paid and the user refund will be issued.

complete_refund

complete_refund
#[public]
#[internal]
fn complete_refund(fee_payer_slot: Field, user_slot: Field, funded_amount: Field) {
// TODO(#7728): Remove the next line
let funded_amount = U128::from_integer(funded_amount);
let tx_fee = U128::from_integer(context.transaction_fee());

// 1. We check that user funded the fee payer contract with at least the transaction fee.
// TODO(#7796): we should try to prevent reverts here
assert(funded_amount >= tx_fee, "funded amount not enough to cover tx fee");

// 2. We compute the refund amount as the difference between funded amount and tx fee.
let refund_amount = funded_amount - tx_fee;

// 3. We construct the note finalization payloads with the correct amounts and hiding points to get the note
// hashes and unencrypted logs.
let fee_payer_finalization_payload =
UintNote::finalization_payload().new(&mut context, fee_payer_slot, tx_fee);
let user_finalization_payload =
UintNote::finalization_payload().new(&mut context, user_slot, refund_amount);

// 4. At last we emit the note hashes and the final note logs.
fee_payer_finalization_payload.emit();
user_finalization_payload.emit();
// --> Once the tx is settled user and fee recipient can add the notes to their pixies.
}
Source code: noir-projects/noir-contracts/contracts/token_contract/src/main.nr#L786-L813

Future work

This pattern of making public commitments to notes that can be modified by another party, privately, can be generalized to work with different kinds of applications. The Aztec labs team is working on adding libraries and tooling to make this easier to implement in your own contracts.