Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: private fee payment with private rebate #4891

Closed
wants to merge 7 commits into from

Conversation

alexghr
Copy link
Contributor

@alexghr alexghr commented Mar 1, 2024

Private fee payment with private rebate

This PR implements a new fee payment flow whereby a sender pays tx fees by creating two partial notes: one for itself (to receive any rebates back) and one for the fee payment contract that will pay tx costs in the gas token.

Changes to note hashes

In order to achieve this the way notes are hashed has been changed: previously a note hash was defined as:

let inner_note_hash = h(storage_slot, note_content_hash);
let siloed_note_hash = h(contract_address, inner_note_hash);

(where note_content_hash is up to the note to implement)

Now hashing requires one extra step:

let partial_note_hash = h(storage_slot, inner_note_content_hash);
let non_siloed_note_hash = h(partial_note_hash, outer_note_content_hash);
let siloed_note_hash = h(contract_address, non_siloed_note_hash);

inner_note_content_hash and outer_note_content_hash are left to the note implementation to define.

The partial_note_hash hides away the storage slot (as long as inner_note_content_hash contains randomness so that the whole hash is slated) and enables the note to be completed in public without revealing who the owner of the note is.

Costs of an extra hashing operation

The extra level of hasing has to be paid for every note so it might look expensive, but this will mostly be done in private execution (where most notes are created and consumed) so it won't affect total tx costs. Notes fully created in public (like the TransparentNote) will have to pay this cost though.

Partial note pairs in the token contract

The concept of "partial note pairs" is added to the Token contract. A user takes an amount out of their private balance, creates two partial notes and allows a 3rd party to complete these partial notes. The Token contract enforces this and also enforces that the two completed notes add up exactly to the original amount (similar to the public authwit scheme for account contract). This is used by fee payment contracts to "escrow" an amount of tokens from the sender's private balance and at the end build two notes, one for itself with an amount proportional to the tx fee and one for the sender with the leftover.

Cost of setting an extra storage slot

This will increase how much a tx costs, but it only affects txs that use this fee payment flow.

Disabled test

This PR disables the dapp subscription entrypoint E2E test due to a bug in how nonRevertible and normal side effects are currently tracked in public. This issue will be fixed in a future PR. See #4958

@AztecBot
Copy link
Collaborator

AztecBot commented Mar 1, 2024

Benchmark results

Metrics with a significant change:

  • note_successful_decrypting_time_in_ms (32): 832 (+43%)
  • note_trial_decrypting_time_in_ms (8): 18.3 (-83%)
Detailed results

All benchmarks are run on txs on the Benchmarking contract on the repository. Each tx consists of a batch call to create_note and increment_balance, which guarantees that each tx has a private call, a nested private call, a public call, and a nested public call, as well as an emitted private note, an unencrypted log, and public storage read and write.

This benchmark source data is available in JSON format on S3 here.

Values are compared against data from master at commit 37476196 and shown if the difference exceeds 1%.

L2 block published to L1

Each column represents the number of txs on an L2 block published to L1.

Metric 8 txs 32 txs 64 txs
l1_rollup_calldata_size_in_bytes 5,700 18,884 36,452
l1_rollup_calldata_gas 66,156 238,964 469,808
l1_rollup_execution_gas 194,104 500,270 909,066
l2_block_processing_time_in_ms 1,188 4,481 (-1%) 8,907 (-1%)
note_successful_decrypting_time_in_ms 210 (+6%) ⚠️ 832 (+43%) 1,079 (+9%)
note_trial_decrypting_time_in_ms ⚠️ 18.3 (-83%) 56.2 (-45%) 58.7 (-53%)
l2_block_building_time_in_ms 15,869 63,140 126,682
l2_block_rollup_simulation_time_in_ms 12,069 48,064 96,622 (+1%)
l2_block_public_tx_process_time_in_ms 3,770 14,991 29,941

L2 chain processing

Each column represents the number of blocks on the L2 chain where each block has 16 txs.

Metric 5 blocks 10 blocks
node_history_sync_time_in_ms 14,161 (-2%) 26,851 (+1%)
note_history_successful_decrypting_time_in_ms 1,326 (+3%) 2,589 (+9%)
note_history_trial_decrypting_time_in_ms 114 (-1%) 106 (-26%)
node_database_size_in_bytes 18,772,048 35,508,304
pxe_database_size_in_bytes 29,923 59,478

Circuits stats

Stats on running time and I/O sizes collected for every circuit run across all benchmarks.

Circuit circuit_simulation_time_in_ms circuit_input_size_in_bytes circuit_output_size_in_bytes
private-kernel-init 245 44,736 28,001
private-kernel-ordering 176 52,625 14,627
base-rollup 1,277 177,932 933
root-rollup 70.7 (+1%) 4,192 825
private-kernel-inner 311 73,715 28,001
public-kernel-app-logic 190 32,254 25,379
merge-rollup 5.67 (-1%) 2,712 933

Tree insertion stats

The duration to insert a fixed batch of leaves into each tree type.

Metric 1 leaves 2 leaves 8 leaves 16 leaves 32 leaves 64 leaves 128 leaves 512 leaves 1024 leaves 2048 leaves 4096 leaves
batch_insert_into_append_only_tree_16_depth_ms 9.81 10.1 (-6%) 13.9 (-5%) 16.1 22.2 35.4 N/A N/A N/A N/A N/A
batch_insert_into_append_only_tree_16_depth_hash_count 16.9 17.5 23.0 31.6 47.0 79.0 N/A N/A N/A N/A N/A
batch_insert_into_append_only_tree_16_depth_hash_ms 0.570 0.563 (-6%) 0.592 (-5%) 0.500 0.465 (-1%) 0.441 N/A N/A N/A N/A N/A
batch_insert_into_append_only_tree_32_depth_ms N/A N/A N/A N/A N/A 45.6 71.7 229 442 868 (-2%) 1,722 (-2%)
batch_insert_into_append_only_tree_32_depth_hash_count N/A N/A N/A N/A N/A 96.0 159 543 1,055 2,079 4,127
batch_insert_into_append_only_tree_32_depth_hash_ms N/A N/A N/A N/A N/A 0.468 0.443 0.419 0.415 0.413 (-2%) 0.412 (-2%)
batch_insert_into_indexed_tree_20_depth_ms N/A N/A N/A N/A N/A 54.9 (+2%) 106 337 (-1%) 656 1,313 2,612
batch_insert_into_indexed_tree_20_depth_hash_count N/A N/A N/A N/A N/A 104 207 691 1,363 2,707 5,395
batch_insert_into_indexed_tree_20_depth_hash_ms N/A N/A N/A N/A N/A 0.489 (+2%) 0.480 0.456 0.453 0.457 0.452
batch_insert_into_indexed_tree_40_depth_ms N/A N/A N/A N/A 60.8 (-1%) N/A N/A N/A N/A N/A N/A
batch_insert_into_indexed_tree_40_depth_hash_count N/A N/A N/A N/A 109 N/A N/A N/A N/A N/A N/A
batch_insert_into_indexed_tree_40_depth_hash_ms N/A N/A N/A N/A 0.533 (-1%) N/A N/A N/A N/A N/A N/A

Miscellaneous

Transaction sizes based on how many contracts are deployed in the tx.

Metric 0 deployed contracts
tx_size_in_bytes 19,179

Transaction processing duration by data writes.

Metric 0 new note hashes 1 new note hashes
tx_pxe_processing_time_ms 2,531 1,343
Metric 0 public data writes 1 public data writes
tx_sequencer_processing_time_ms 0.0303 (+2%) 464

@alexghr alexghr force-pushed the feat/private-fee-rebate branch 3 times, most recently from 9b66ea1 to e14d356 Compare March 5, 2024 07:38
@alexghr alexghr changed the title feat: partial notes feat: private fee payment with private rebate Mar 5, 2024
@alexghr alexghr marked this pull request as ready for review March 5, 2024 09:51
@alexghr alexghr requested a review from just-mitch March 5, 2024 15:35
@alexghr alexghr linked an issue Mar 6, 2024 that may be closed by this pull request
Copy link
Contributor

@LeilaWang LeilaWang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Copy link
Contributor

@just-mitch just-mitch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Where are we planning to document this?

@iAmMichaelConnor
Copy link
Contributor

iAmMichaelConnor commented Mar 6, 2024

I have some concerns with some of these changes, but it might take me some time to understand and articulate them.
Many of the concerns are around whether these changes are generic enough that they enable other notions of partial notes, and whether the user experience for a developer wishing to define their own notes is becoming too difficult.

Also, @LHerskind and his team might need to be looped into such large aztec.nr changes. I'm not sure who the 'owner' of aztec.nr is anymore/yet (I know there's going to be some application process).

Edit: but I do appreciate this looks like a lot of great effort, and can't have been an easy set of changes to make. I also wouldn't want to block progress on fees work. I'm just conscious there were recent concerns raised that aztec.nr might not have "one voice" anymore, and that people were worried stuff was being added to aztec.nr by different teams without some 'visionary' ensuring consistency. I'm also conscious that if we discover that this approach to partial notes isn't generic enough, it's going to be quite difficult to unpick or maintain.

Copy link
Contributor

@rahul-kothari rahul-kothari left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am thinking about better naming to the new note hash methods introduced

@alexghr alexghr mentioned this pull request Mar 7, 2024
Copy link
Collaborator

@PhilWindle PhilWindle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a question about nullifying the storage slot


assert(storage.partial_notes.at(hash).read(), "Partial notes not authorized");
// only completable once
context.push_new_nullifier(hash.to_field(), 0);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this temporary? The idea is to set the public slot to 0 isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually thought this would be neater as it signals both that it was an action that was allowed at some point in the past and that it was consumed and can not be run anymore (in way, similar to contract initialiasation)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that makes sense. I was just thinking that this uses 32 bytes more DA. Setting the storage to 0 would presumably be squashed onto the setting of it to 1 in auth_partial_notes_pair()

@alexghr alexghr requested a review from LHerskind March 7, 2024 13:26
@alexghr
Copy link
Contributor Author

alexghr commented Mar 7, 2024

I'll keep this PR unmerged until we reach consensus :D

Many of the concerns are around whether these changes are generic enough that they enable other notions of partial notes, and whether the user experience for a developer wishing to define their own notes is becoming too difficult.

@iAmMichaelConnor one advantage this "patching" implementation has (that this PR does a very poor job of conveying) is that the "partial note" and "note completion" events are entirely under the contract's control. Once we have event selectors (and once encrypted logs are generalised) all the PXE needs to do is to keep track of the partial note and the completion patch (by e.g. requiring them to have a known shape or use a specific log selector).

The PXE could then defer control to the contract implementation to complete a note, similar to compute_note_hash_and_nullifier (and we could even pre-generate this function by using the note_type_id):

contract TokenContract {

  unconstrained fn complete_partial_note(
    note_type_id: Field,
    partial_note_content: [Field; 20],
    note_patch: [Field; 20]
  ) -> TokenNote {
    if note_type_id == TokenNote::get_note_type_id() {
      TokenNote::complete_note(partial_note_content, note_patch)
    } else {
      // ...
    }
  }
}

impl TokenNote {
  fn complete_note(content: [Field; 3], patch: [Field; 1] {
    TokenNote { amount: patch[0], owner: content[1], randomness: content[2] }
  }
}

(the partial content above could be optimized down to just two fields too!)

Later edit: and all the information will live on chain so even if I restore my private key into a new PXE, I can recreate all of my partial notes back to full notes (which is not the case with e.g. pending shields -- if my human brain forgets the preimage to the secret hash before I claim it, then it's lost forever)

Copy link
Contributor

@LHerskind LHerskind left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something I don't particularly like is that it increase complexity of all notes, but the benefit is only to the very few types of notes that people will be using to pay fees.

The amount of extra stuff to handle is a bit concerning. We have approval in public storage (64 bytes) but it can be reset afterwards to not broadcast it, we have an extra nullifier (32 bytes), we have two partial notes (~200bytes each?), unencrypted logs (~32+64 bytes for the contract address and things for each partial note?) sounds like we are somewhere around 700 bytes to handle the fee then?

If a transaction is private, the gas-limit is essentially fixed and if there is a base-fee it can be fairly well predicted. This means that the refunds would likely be small. We need to be mindful, that if the refund is small, the cost of the refund could be larger than the fee.

Alternative that is a bit less neat from wallet POV, but don't change life-cycle: Use pending shields. As part of the execution provide a secretHash, a note is inserted. And you can collect the refund later 🤷‍♂ If you have plenty of refunds you can aggregate them at once, or you can ignore the ones that might be to expensive to fully retrieve.


Ran a test. The mint_public transaction in the test emits 1857 bytes when it is paying the fee. Without the fee only 185, so fee is 9/10 or the transaction.

@PhilWindle
Copy link
Collaborator

As @alexghr says, this can be made completely generic. Once we have event selectors everything comes down to a contract's implementation as to what it means to build a partial note and then complete it.

The general principle of building a private note containing information sourced from both private and public domains seems pretty useful, so I disagree that this would only be used for fee payments. Why wouldn't shielding be done using a similar mechanism? This seems much more elegant than storing a secret and having to submit a second transaction.

Regarding some of the other concerns. In this example Alice creates 3 notes:

  1. A change note for her in private only.
  2. A fee note for the FPC.
  3. A refund note for her.

We could get rid of the note in 1. Alice could collect enough notes to be >= the max fee, and get everything back minus the fee in note 2 as a refund. Then we would be down to 2 notes, 2 events and the public storage write which I'd like to think we could remove completely as it resets back to the value it was before the transaction was sent. I can't think of a way to keep the whole exchange using private notes and get the data requirements any lower.

Of course all of this is optional. Alice could pay directly in AZT. Or Alice could be doing a private only transaction, pays privately and uses a 'don't bother with the refund' method on the FPC. In both of these sceanrios there is no need for the setup and teardown phases either.

@alexghr
Copy link
Contributor Author

alexghr commented Mar 8, 2024

I should've used stacked PRs because I think things are getting muddied wrt the implementation enabling partial notes (inner_note_content_hash, outer_note_content_hash and partial_note_hash) vs the specific feature of using them in the context of the token contract enabling private fee payment+rebates.

I think it would be great to decide if we want to merge:

  1. the change to note hashing enabling partial notes
  2. or, change 1 + the use of partial note pairs in the token contract
  3. or, none

Change 1 - has a cost in terms of developers need to be aware of it and implement the new Note interface accordingly (it leaks into the Aztec.nr API). I had to make changes to the global NoteInterface because notes need to be consistently hashed. If Noir gets default trait implementations then this would alleviate the burden on developers. In terms of performance - it makes private execution slower because every note has to go through one extra hashing layer.

Change 2 - a specific feature using the partial note implementation. Yes, this could be made more efficient in the future by: emitting just two fields instead of all three for partial ntoes (which would require event selectors), using two public storage writes (which would get squashed down to one) instead of public storage + nullifiers.

We need to be mindful, that if the refund is small, the cost of the refund could be larger than the fee.

I've brought this up as well and the consensus was to make the rebate step optional. We should support multiple fee paying methods: some can use partial notes, some can use shield/unshield, some are public, others can be without rebate so you'd have to be really really tight during simulation.

Use pending shields.

👍. But should this be the only option?

If you have plenty of refunds you can aggregate them at once, or you can ignore the ones that might be to expensive to fully retrieve.

Up to max-number-of-fn-calls in a tx (currently 4). Using shields just defers the cost to later, the sender still has to pay to turn a shield into a note. With that partial token notes implementation here that cost is paid immediately, with shields it's some future tx (which could be cheaper if base gas price goes down).

@just-mitch
Copy link
Contributor

I agree with the view that this would be useful, but is not strictly necessary since we can achieve private refunds with pending shields, and it comes with significant costs, particularly on DevEx (mental burden) and UX (expensive).

I'd speculate a user would prefer two cheap transactions (one to do the thing, one to get their money back) over 1 expensive transaction, especially since the wallet can handle the complexity for them.

I think it was useful to demonstrate the patching, and how partial notes could be implemented, but I'd rather we incorporate them in response to demand, since again, they are not strictly necessary for private refunds.

So should we revisit this when we have a larger community of developers and understanding of the real costs?

@iAmMichaelConnor
Copy link
Contributor

iAmMichaelConnor commented Mar 8, 2024

Here's an example of an alternative approach to creating notes, that might require no changes to aztec.nr. Instead, it puts the burden of interpreting app-specific events on the javascript dapp itself. I'm not necessarily saying it's an approach I like, yet: I haven't thought about this subject enough, and there could be better approaches. Maybe I'm just playing devil's advocate, or something.

The flow for partial notes, as I understand it, is:

  • Alice wants someone (maybe Alice) to receive a note at some point in future, but she doesn't know all of its fields: some of its fields depend on a computation that hasn't happened yet.
  • Alice creates a partial note commitment: a commitment to all of the note fields that she does know now.
  • Alice sends that partial note to public land.
    • She could broadcast that partial note commitment via an event; or
    • She could store that partial note commitment in public storage.
    • Maybe there's not much of a difference between the two. Something that does need consideration is whether this "partial note hash" is a protocol-recognised and aztec.nr-recognised thing, or whether it's just some bytes of data (that are opaque to the protocol/aztec.nr) that only the app can interpret. More on this in a sec.
  • Something is computed in public-land. Maybe it's a fee rebate amount. Maybe its the result of a swap (like aztec connect). Maybe it's several values. Whatever.
  • A public function now needs to inject the previously-unknown but now-computed-and-publicly-known values into the partial note. Or, to rephrase, the function now needs to create a note containing all of the note's fields, with no placeholder null fields.
    • Something that needs consideration is whether this resulting "completed note" needs to be recognised by the protocol as some special kind of CompletedNote, or whether it's just a note. The former requires aztec.nr changes, but I'm preferring the idea that we can just use the existing aztec.nr framework for creating a note.
  • The note is broadcast to whomever the owner is (maybe Alice).
  • The owner discovers their completed note and stores it in their db.

A half-thought-out idea:

  • Don't add any new concepts to aztec.nr.
  • I use Pedersen commitments for their homomorphic properties in this example, to mimic aztec connect.
  • None of the events below are the broadcasting of notes:
    • The private event is just a private message to the msg.sender, containing some info that the dapp will understand.
    • The public events are just public data that the dapp can subscribe to, just like subscribing to solidity events.

I haven't looked at Noir trait syntax, so this is bad pseudocode that won't reflect aztec.nr anymore:

The MyNote declaration file:

// Declare a note, which adheres to the NoteInterface, as normal. Give it some optional data members, to emulate "partial-ness" of this note.
struct MyNote {
    a: Option<Field>,
    b: Option<Field>,
    c: Option<Field>
    rand: Field,
}

// I don't know if enums are a thing in rust.
enum MyNoteGeneratorEnum {
    domain_separator,
    a,
    a_null,
    b,
    b_null,
    c,
    c_null,
    rand,
} 

// computing the generators at compile time not shown here.
global GENERATOR_DOMAIN_SEP: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::domain_separator); 
global GENERATOR_A: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::a);
global GENERATOR_A_NULL: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::a_null);
global GENERATOR_B: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::b);
global GENERATOR_B_NULL: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::b_null);
global GENERATOR_C: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::c);
global GENERATOR_C_NULL: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::c_null);
global GENERATOR_RAND: GrumpkinPoint = compute_generator(MyNoteGeneratorEnum::rand);

impl NoteInterface for MyNote {
    compute_note_body_hash(&self) { // I don't recall the name or syntax, sorry
        if (self.a.is_none || self.b.is_none || self.c.is_none) {
            throw new Error("this is a partial note: it's missing some values");
        }
        
        pedersen("MyNote".to_field(), self.a.unwrap_unsafe(), self.b.unwrap_unsafe(), self.c.unwrap_unsafe().to_field(), self.rand)
    }
}

// Also implement some custom partial note methods. Not sure where these should live, but I'll write them here.
// Very inefficient function, but it's illustrative:
fn compute_partial_note_commitment(note: MyNote) -> GrumpkinPoint {
        // Multiply the scalars by the corresponding points, and add the results together.
        // This is basically a homomorphic pedersen hash:
        Grumpkin::multi_scalar_mul(
            scalars: [
                "MyNote".to_field(),
                self.a.is_none ? 1 : self.a.unwrap_unsafe(),
                self.b.is_none ? 1 : self.b.unwrap_unsafe(),
                self.c.is_none ? 1 : self.c.unwrap_unsafe(),
                rand,
            ],
            points: [
                GENERATOR_DOMAIN_SEP,
                self.a.is_none ? GENERATOR_A_NULL : GENERATOR_A,
                self.b.is_none ? GENERATOR_B_NULL : GENERATOR_B,
                self.c.is_none ? GENERATOR_C_NULL : GENERATOR_C,
                GENERATOR_RAND,
            ]
        )       
}
// Note: the `NULL` generators are required to allow `0` to be a valid field of the completed note.

The Contract which makes use of the note:

MyContract {

storage {
    interaction_nonce: PublicMutableState<u64>;
    partial_commitments: Map<Field, PublicMutableState<GrumpkinPoint>>;
}

// Create a partial note with data members `b` and `c` missing:
#[aztec(private)]
fn create_the_partial_note_commitment_with_b_and_c_missing(a, rand) {
    let partial_note_commitment: GrumpkinPoint = compute_partial_note_commitment(
        MyNote {
            a,
            Option::none,
            Option::none
            rand,
        }
    );

    // Encrypt and emit `a` and `rand` as an outgoing message (NOT as a note).
    // Syntax not shown because I don't know what it is.
    
    // Then store the partial note in public-land:
    this.register_partial_note_commitment(partial_note_commitment);
}

// Register the partial commitment, to be completed some time later
#[aztec(public)]
fn register_partial_note_commitment(partial_note_commitment: GrumpkinPoint) {
    // store it somewhere:
    let nonce = interaction_nonce.read();
    partial_commitments.at(++nonce).write(partial_note_commitment); // we've stored it for later.
    interaction_nonce.write(nonce);

    // The caller will subscribe to this contract and nonce, somehow.
    emit NewPartialNoteRegistered(nonce, partial_note_commitment); // emit an event with the interaction nonce
}

#[aztec(public)]
fn complete_partial_note_commitment_with_b_and_c(interaction_nonce: u64, b: Field, c: Field) {
    let partial_note_commitment: GrumpkinPoint = partial_commitments.at(interaction_nonce).read();
    
    // maybe delete this entry, so it can't be 'completed' again:
    partial_commitments.at(interaction_nonce).write(GrumpkinPoint{ 0, 0, });
    
    // I'm being lazier with notation here: `+` and `*` are elliptic curve operations here:
    let completed_note_commitment: GrumpkinPoint = partial_note_commitment + 
        (b * GENERATOR_B - GENERATOR_B_NULL) +
        (c * GENERATOR_C - GENERATOR_C_NULL);
    
    let completed_note_hash: Field = completed_note_commitment.x; // assuming taking the x-coord is collision resistant, which it probably isn't.
    
    // Inform the owner of the note (who should be subscribed to this event and interaction_nonce) about the completion of the note.
    emit NewlyCompletedNote(interaction_nonce, b, c, note_commitment);
}

} // MyContract

@iAmMichaelConnor
Copy link
Contributor

I haven't delved deeply enough into the details of this PR to compare these two approaches, and I'm biased because I worked on Aztec Connect's circuits for so long. But it might be helpful if someone could provide pros/cons to these approaches (or propose another approach).

@alexghr
Copy link
Contributor Author

alexghr commented Mar 13, 2024

Thanks for submitting the code Mike, this is amazing!

If I understand the cryptography correctly we can subtract the "null" points and add in the missing ones once we know the values. One thing that's not clear to me (and this is related to aztec.nr's current design): what about the storage slot associated with the note?

I can see partial note hashes are stored at a known location so that the contract can find them later (sidenote I think the nonce would have to be random so that multiple users could interact with contract in private without having to synchronize) but in the current design a note is stored against a storage slot (possibly derived from the user's address) and this is currently mixed into the note hash by aztec.nr (it's not the note developer's responsibility, compute_inner_note_hash). I guess for partial notes we could come up with a different API that mixes the storage slot into the partial note but I think that would require many changes to functions consuming notes.

Other than the advanced crypto I think the flows are the same:

  1. generate a partial commitment (compute_partial_note_hash vs pedersen commitment)
  2. store it somewhere for auth later (partial_notes.at(pair_hash) vs partial_commitments.at(interaction_nonce))
  3. emit partial note (emit_encrypted_log vs emit NewPartialNoteRegistered)
  4. complete the note
  5. insert completed note into the notes hash tree
  6. emit completion event (emit_unencrypted_log(patch) vs emit NewlyCompletedNote)

Out of all of these steps, I think 1, 3, 5, 6 are concerns for aztec.nr, while steps 2, 4 are the responsibility of the contract developer (along with the data emitted by steps 3 and 6 and the data necessary for steps 1 and 5).

I don't think this gets away from having to patch the note in the PXE as the server would still have to somehow recreate the full note and store it in its database (so that get_notes_oracle can find it).

--

we can achieve private refunds with pending shields,

yes, we can (and this is how I assumed we'd implement them tbf) but there's big downsides to them in the context of fee rebates:

  1. they're not part of your balance so we'd put extra burden on wallet developers to write specific code integrating with the token interface to sum up your private balance + all of the transparent notes you know the secret for
  2. the secret is off chain so if it's not generated deterministically by the wallet (e.g. hashing your private key + nonce) you could lose access to your transparent notes. Admittedly we could emit the secret as an encrypted log for the owner in the context of fee rebates but that makes it expensive again, and the wallet/PXE would have to know to look for them to restore transparent notes
  3. there's a finite number of transparent notes that can be claimed per tx
  4. each tx claiming them could produce other transparent notes
  5. does not support the fully private flow where the FPC receives a note in private. Alice has to unshield to ensure the FPC has enough public funds to shield a portion back to her.

Regarding the costs of the above, things could be made cheaper if Alice calls unshield instead of transfer (see 5. above) so it would cost one encrypted log for the partial note, 2 public storage writes (which would get squashed out) and one unencrypted log for the rebate amount.

@iAmMichaelConnor
Copy link
Contributor

iAmMichaelConnor commented Mar 13, 2024

One thing that's not clear to me (and this is related to aztec.nr's current design): what about the storage slot associated with the note?

Oh good point. I'd forgotten to consider storage slots in my code snippets, and indeed in my example function complete_partial_note_commitment_with_b_and_c, I've forgotten to actually 'store' (or .insert();) the completed note into a Singleton/Set.

With this homomorphic approach to commitments, the storage_slot might need to also be included in the note as storage_slot * GENERATOR_STORAGE_SLOT + <the rest of of the stuff shown above>. Interestingly, with this approach, and depending on the application, the storage slot can either be computed at the time the partial note is computed (if it is known at that time and if it needs to stay secret), or at the time the 'completed' note is computed (if it is only known at that time).

I'm not sure how my proposed approach would work if the storage slot is hashed into the note in a non-homomorphic way (which is the way storage slots are included at the moment with aztec.nr).

Edit: I'll need to think more on your other comments later in the week :)

@just-mitch
Copy link
Contributor

@iAmMichaelConnor

A half-thought-out idea

Nice. I had been hopeful we could solve our problems with math 😁.

If I understand, if we took that general approach, a token could create a

trait Partialable<T> where
    T: NoteInterface<N> {
    fn compute_partial_note_content_hash(self) -> Field;
}

plus have a few new helpers for just emitting relevant logs and helping with the crypto.

@alexghr

I don't think this gets away from having to patch the note in the PXE

PXE still needs a way to record these partial note logs, but I don't think we strictly need the ability to patch, since I think that once we see the complete note, we just drop the corresponding partial note? Side note, I think if we remove the constraint that emitted encrypted logs are always notes (and e.g. prefix logs with a byte that encodes their type), this becomes a lot more clean. I think some weirdness could come though if we enshrine a log type of "partial note" without enshrining the concept of "partialable" in aztec-nr. But maybe its not a big deal, since probably from the PXE perspective a "partial note" would just be treated as "something for which I eventually expect a full note".

I think the only reason you would need to "patch" is if you fill in some more of an existing partial note without completing it. But that seems like it could be its own interface that extends "partial-able" which we could define and implement in the future.

Am I understanding correctly?


Generally I like this much more than obliging all note types to adhere to an inner/outer hash. And I think homomorphic crypto could be a good implementation: it feels like it fits very well with what we're trying to achieve here.

Side note, I expect we could eventually pull that trait and helpers into aztec-nr once they are stable; the nice thing is that it would be an extension, and not change any existing api or implementation.

In the meantime, if we're all on board, I'd say to design concretely what this could look to get it working end-to-end with just the TokenNote.

@alexghr
Copy link
Contributor Author

alexghr commented Mar 13, 2024

trait Partialable where

That would be amazing indeed but we somehow need to mix in the storage slot in private.

PXE still needs a way to record these partial note logs, but I don't think we strictly need the ability to patch, since I think that once we see the complete note, we just drop the corresponding partial note?

Maybe I'm missing something, but my understanding (based on working with the code in this PR) is that the PXE sees hashes being inserted into the notes tree from public. It somehow needs to get the preimage to that hash in order to add a note to its database. I called it "patching" the partial encrypted log with public data (ie. "I got an array of fields from private execution, I got another array of fields from public execution, put two and two together and gimme a note"), we can call it something else but I don't see how it can be avoided?

Side note, I think if we remove the constraint that emitted encrypted logs are always notes (and e.g. prefix logs with a byte that encodes their type)

I agree and iirc there's a ticket to track it.

Edit: found it #1988

Generally I like this much more than obliging all note types to adhere to an inner/outer hash. And I think homomorphic crypto could be a good implementation: it feels like it fits very well with what we're trying to achieve here.

Me too, but we just need to udnerstand how the storage slot fits into all of this and if it changes how note hashing is done.

@just-mitch
Copy link
Contributor

It almost feels then like we solve #1988 and come back to this?

needs to get the preimage to that hash

Ah yes, you're right! We spoke past each other. In my mind "patch" implies you can keep patching, but here it's a special patch that is "merge/patch and complete".

@alexghr
Copy link
Contributor Author

alexghr commented Apr 24, 2024

Closing this

@alexghr alexghr closed this Apr 24, 2024
@alexghr alexghr deleted the feat/private-fee-rebate branch April 24, 2024 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Private refunds from FPC via partial notes
8 participants