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

Account ID structure updates #140

Open
bobbinth opened this issue Jun 6, 2023 · 66 comments
Open

Account ID structure updates #140

bobbinth opened this issue Jun 6, 2023 · 66 comments
Assignees
Labels
kernels Related to transaction, batch, or block kernels refactoring Code clean-ups, improvements, and refactoring
Milestone

Comments

@bobbinth
Copy link
Contributor

bobbinth commented Jun 6, 2023

This issues combines several proposed changes to the account ID structure as they are all likely to be implemented together. These changes are:

  1. Reduce account ID size to 60 bits (originally described in #104). This could be done by requiring that during the grinding process the 4 least significant bits of the account ID are set to all 0s.
  2. Expand account storage mode options (originally described in #139). This would give special meaning to the 4th most significant bit of the account ID.
  3. Change how account ID seed is computed (originally described in #87). Specifically, we'd compute the value of the seed as seed = hash(code_root, storage_root, nonce) and then seed[3] becomes the account ID.

Given the above changes, we should probably update the grinding conditions to work as follows:

  • For regular accounts 16 least significant bits of seed[0] must be zeros.
  • For faucet accounts 24 least significant bits of seed[0] must be zeros.

This is because we now require grinding on 8 bits within the account ID itself (4 most significant bits for type/storage mode, and 4 least significant bits for reducing ID length to 60 bits). Thus, the total still remains 24 and 32 bits of grinding for regular and faucet accounts respectively.

@bobbinth
Copy link
Contributor Author

Points 2 and 3 listed above are actually done now (for point 2, we did add an extra bit but for now left it unused). So, the only thing remaining here is reducing the ID size to 60 bits and adjusting PoW threshold accordingly.

@bobbinth bobbinth added the kernels Related to transaction, batch, or block kernels label Apr 13, 2024
@bobbinth
Copy link
Contributor Author

One of the negative aspects of the current account ID generation scheme is that we kind of "waste" the work that goes into generating the IDs (it is not technically wasted as we do get more security from the operators trying to highjack an account ID, but that's just "one-time" effect). There are some ways in which we could make this work useful - for example:

  1. We could reduce the size of the ID to make it easier to remember.
  2. In case of faucet accounts, we could encode the ticker symbol directly into the ID (as proposed here).

Below is a proposal on how we could alter the account ID encoding scheme to make the PoW more useful.

First, we still maintain the basics of the current scheme in that to derive an account ID we start with account_seed, code_root, and storage_root and compute the ID as follows:

let digest = hash(account_seed || code_root || storage_root);
let account_id = digest[0];

Then, we'd impose the following rules:

  • For non-faucet accounts, the last 16 bits of the iD must be zeros. The first 2 bits would still define the storage mode of the account (i.e., public, private), and the 3rd bit would define whether the account code is mutable or not.
    • This effectively means that the user needs to grind about $2^{19}$ seeds to generate an ID, which on modern smartphone/laptop CPUs should be between 1 - 3 seconds.
  • For faucet accounts, the last 32 bits of the ID must match the last 32 bits of account_seed[0]. The first 2 bits of the ID would also define account storage mode, and the 3rd bit would specify whether the faucet is for fungible or non-fungible assets. The lower 32 bits would be subject to additional rules described below.
    • This effectively means that the user needs to grind about $2^{35}$ seeds to generate a faucet ID, which on modern server CPUs (e.g., 64 cores) should take about 30 mins.

The additional rules for the lower 32 bits of a faucet ID are as follows:

  • The 4 least significant bits must be 0s.
  • The remaining bits encode a ticker symbol which can be up to 6 letters long. The alphabet for the ticker consists of 26 letters + 0 symbol (i.e., 27 characters total).
  • The first character in the ticker cannot be 0. This guarantees that the lower 16 bits of faucet ID will be non-zero.

Some benefits of the above scheme:

  1. Inside the VM, we can pretty easily determine whether an account is a faucet or not - if the lower 16 bits are zeros, it is a non-faucet account, otherwise it is a faucet account.
  2. For non-faucet accounts, the IDs are pretty short 8 - 12 characters depending on encoding (this is within range of memorization for most people).
    a. For faucet accounts they are also not that long: 10 - 15 chars depending on encoding. And because faucet and non-faucet IDs have different lengths, it should be relatively easy to tel them apart visually.
  3. For faucet accounts, ticker symbol is encoded in the ID and so no lookups are needed to determine the ticker. This also extends to assets (since faucet ID is a part of each asset). That is, by looking at the asset one can immediately determine its ticker symbol.

Potential downsides:

  • Ticker encoding scheme is too rigid and is the same for fungible and non-fungible assets.
  • Encoding ticker into the faucet ID / asset may not be a good idea since we can't guarantee that a ticker is globally unique (i.e., two faucets may have the same ticker). The counter-argument to this is that this is no different from storing ticker symbol in storage slot 1 (as we do now).

@hackaugusto
Copy link
Contributor

hackaugusto commented Apr 24, 2024

it is not technically wasted as we do get more security from the operators trying to highjack an account ID, but that's just "one-time" effect

As a side note, I always get confused by this. The account id adds to the security of the hash. That is to say the attacker needs to both, find a result with enough PoW, and which also matches the target account id, for a total of 80bits (~64bits for account id + 16 of Pow). Which is a great place to be in, the user can use a cellphone to generate a valid account in seconds, but an attacker would be in a bad spot to find a match with the same account id.

With that said, we probably should still make this configurable, to maintain the cost in the infeasible area as hash power increases (80bits should be plenty, but it is in the low end). Ideally this should be done in such a way that a user can change the setting, and the protocol is agnostic to it. This has the benefit of giving control to the user, and removing the need for the protocol to estimate or agree on hash power.

We can pack the PoW requirements in the account id. Since we use one of the Felts in the Digest to encode the AccountId, we have a max of 3 Felts to do PoW for a total of 192bits, meaning we need a max of 8bits to encode the PoW requirements, that leaves about 53bits for the account itself, that is about $10^{16}$ accounts (the 8bits would also be part of the PoW, so the maximum PoW would be 200bits)

Edit: This could also be a nice way of getting rid of the patching for the tests. Meaning we can just configure the test to have a very low PoW requirement, and assume users will actually use a high value. The downside of this is that we don't guarantee security, only allow for it, and a improperly configured client could result into problems (not sure how bad this is, it is basically always the case)

@hackaugusto
Copy link
Contributor

hackaugusto commented Apr 24, 2024

For non-faucet accounts, the last 16 bits of the iD must be zeros

We could also pack a protocol version in these bits. Instead of using all zeros, we could use something like chain_id. The benefit here is one of sanity check, I'm not sure if replay attacks would be a thing, because I assume the user would still control the account anyways. But it feels this hasn't a significant downside.

@hackaugusto
Copy link
Contributor

hackaugusto commented Apr 24, 2024

For faucet accounts, the last 32 bits of the ID must match the last 32 bits of account_seed[0]

Maybe lets make this a first class concept and add a metadata field to the hash? so it would be hash(account_seed || code_root || storage_root || metadata) instead. We can make the input a multiple of the hasher rate by changing the size of account_seed. (Edit: And I think the seed size should be at a minimum a word)

Ticker encoding scheme is too rigid and is the same for fungible and non-fungible assets.

A few notes about the ticker:

  • If we are going to add it as part of the protocol, it may makes sense to make it unique, to prevent spoofing attacks, for example, we should probably have single ETH account id.
  • We don't allow code changes to faucets, but that doesn't mean a project won't want to upgrade. If we have the above to prevent spoofing, we should probably add a version along side the ticker, and a way to authenticate that a new account is indeed an upgrade of an older version.
  • The spoofing prevention would also mean there is a race, and the first to deploy the ETH would control that, which can be a huge risk. I'm not sure how to secure against this. We could have a blessed bridge, and then we would have the ETH from the bridge and possible the native ETH, and we would then rely on UI to make it clear to the user which is which. We could try to actively preregister all the known names, which would cover most of the existing cases, but leave the future ones open.
    • Perhaps we should add extra metadata, things like denomination names, longer names, project URL, and some primitive to authenticate the account (instead of the uniqueness requirement mentioned above). The data doesn't need to be necessarily stored in the rollup, but maybe its hash would be useful.

@bobbinth
Copy link
Contributor Author

With that said, we probably should still make this configurable, to maintain the cost in the infeasible area as hash power increases (80bits should be plenty, but it is in the low end). Ideally this should be done in such a way that a user can change the setting, and the protocol is agnostic to it. This has the benefit of giving control to the user, and removing the need for the protocol to estimate or agree on hash power.

We can pack the PoW requirements in the account id. Since we use one of the Felts in the Digest to encode the AccountId, we have a max of 3 Felts to do PoW for a total of 192bits, meaning we need a max of 8bits to encode the PoW requirements, that leaves about 53bits for the account itself, that is about 1016 accounts (the 8bits would also be part of the PoW, so the maximum PoW would be 200bits)

I like it! I don't think we need 8 bits though - 6 bits should be enough. With 6 bits we would get up to ~128 bits PoW (64 bits from the account ID itself and up to 64 bits from the extra PoW specified via these 6 bits.

We could also pack a protocol version in these bits. Instead of using all zeros, we could use something like chain_id. The benefit here is one of sanity check, I'm not sure if replay attacks would be a thing, because I assume the user would still control the account anyways. But it feels this hasn't a significant downside.

This would be a bit more tricky as we probably can't "allocate" too many bits for this (e.g., probably not much more than ~8). I'm also not sure this is needed: if I can create an account ID for a given combination of storage and code on one chain, it may actually be convenient to create the same account ID for the same combination on a different chain.

Maybe lets make this a first class concept and add a metadata field to the hash? so it would be hash(account_seed || code_root || storage_root || metadata) instead. We can make the input a multiple of the hasher rate by changing the size of account_seed. (Edit: And I think the seed size should be at a minimum a word)

Yep - agreed. I'd probably call it something like account_config rather than metadata. Also, I'm not sure if having account seed be a word is needed. I think 128 bits (2 field elements) may be sufficient for the seed. @Al-Kindi-0 - what do you think?

  • If we are going to add it as part of the protocol, it may makes sense to make it unique, to prevent spoofing attacks, for example, we should probably have single ETH account id.
  • We don't allow code changes to faucets, but that doesn't mean a project won't want to upgrade. If we have the above to prevent spoofing, we should probably add a version along side the ticker, and a way to authenticate that a new account is indeed an upgrade of an older version.
  • The spoofing prevention would also mean there is a race, and the first to deploy the ETH would control that, which can be a huge risk. I'm not sure how to secure against this. We could have a blessed bridge, and then we would have the ETH from the bridge and possible the native ETH, and we would then rely on UI to make it clear to the user which is which. We could try to actively preregister all the known names, which would cover most of the existing cases, but leave the future ones open.

I thought about this, but think it would be too risky specifically because of your last point: we don't want to mediate who gets ETH, BTC etc. tickers and we don't want to leave it on the "first-come-first-served" basis.

  • Perhaps we should add extra metadata, things like denomination names, longer names, project URL, and some primitive to authenticate the account (instead of the uniqueness requirement mentioned above). The data doesn't need to be necessarily stored in the rollup, but maybe its hash would be useful.

We have some of this already. For the sample faucet contract, some of this info is stored in slot 1 of the account storage (this currently also includes the ticker symbol).

Edit: This could also be a nice way of getting rid of the patching for the tests. Meaning we can just configure the test to have a very low PoW requirement, and assume users will actually use a high value. The downside of this is that we don't guarantee security, only allow for it, and a improperly configured client could result into problems (not sure how bad this is, it is basically always the case)

This could work for regular accounts but if we do want to encode extra data into faucet IDs, we would still need to have special treatment for them.

@bobbinth
Copy link
Contributor Author

As a side note, I always get confused by this. The account id adds to the security of the hash. That is to say the attacker needs to both, find a result with enough PoW, and which also matches the target account id, for a total of 80bits (~64bits for account id + 16 of Pow). Which is a great place to be in, the user can use a cellphone to generate a valid account in seconds, but an attacker would be in a bad spot to find a match with the same account id.

Also, I just realized that the scheme I proposed for regular account IDs actually doesn't give us any extra security beyond 64 bits. It does create extra work for the original account creator to find an ID with 16 trailing zeros, but for the attacker, they jus need to find a 64-bit pre-image and if they use random search, it doesn't matter whether the last 16 bits must be 0 or not.

Same thing actually applies to encoding ticker symbols. Basically, encoding extra info into the ID itself, does require extra work for the user, but I don't think it provides any additional security against a potential attacker. Not sure if there is a way around this yet. @Al-Kindi-0 - curious what you think.

@hackaugusto
Copy link
Contributor

hackaugusto commented Apr 24, 2024

I think 128 bits (2 field elements) may be sufficient for the seed. @Al-Kindi-0 - what do you think?

To expand on my comment, hash is a one-to-one mapping, imagine the worst hash function possible, something like the id function, changing only 2 field elements of the domain changes only 2 field elements in the image, always leaving the top bits of the digest set to 0, which is to say, from the possible $2^{256}$ output values it covers only $2^{128}$, this would be true for a CHF too. IOW, if we only have 2 field elements for the seed, we are guaranteed to not cover the codomain, having the seed size being at least the digest size would make it possible to cover the codomain (but AFAIK there is not guarantee). I have no idea how bad that is, specially because the property that we want is not to cover the codomain, but to have an image that satisfy our criteria, it would likely not be a real issue in practice, but I don't think there are downsides to having the seed being bigger (?)

@Al-Kindi-0
Copy link
Collaborator

Also, I just realized that the scheme I proposed for regular account IDs actually doesn't give us any extra security beyond 64 bits. It does create extra work for the original account creator to find an ID with 16 trailing zeros, but for the attacker, they jus need to find a 64-bit pre-image and if they use random search, it doesn't matter whether the last 16 bits must be 0 or not.

But wouldn't the pre-image found by the attacker go through the "the last 16 bits must be 0"-check at some point for this to be useful for the attacker? Or does any pre-image do?

@Al-Kindi-0
Copy link
Collaborator

Yep - agreed. I'd probably call it something like account_config rather than metadata. Also, I'm not sure if having account seed be a word is needed. I think 128 bits (2 field elements) may be sufficient for the seed. @Al-Kindi-0 - what do you think?

I think this boils down to whether we are concerned about collisions or only pre-image resistance. I think in the current context collisions are not a problem and thus 128 bits for the seed should be fine.

@Al-Kindi-0
Copy link
Collaborator

I think 128 bits (2 field elements) may be sufficient for the seed. @Al-Kindi-0 - what do you think?

To expand on my comment, hash is a one-to-one mapping, imagine the worst hash function possible, something like the id function, changing only 2 field elements of the domain changes only 2 field elements in the image, always leaving the top bits of the digest set to 0, which is to say, from the possible 2256 output values it covers only 2128, this would be true for a CHF too. IOW, if we only have 2 field elements for the seed, we are guaranteed to not cover the codomain, having the seed size being at least the digest size would make it possible to cover the codomain (but AFAIK there is not guarantee). I have no idea how bad that is, specially because the property that we want is not to cover the codomain, but to have an image that satisfy our criteria, it would likely not be a real issue in practice, but I don't think there are downsides to having the seed being bigger (?)

Usually, a hash function compresses the input in some way and the simplest such case would be a 2-to-1 compression. This is clearly not one-to-one and hence I am probably missing something in your comment.

@bobbinth
Copy link
Contributor Author

But wouldn't the pre-image found by the attacker go through the "the last 16 bits must be 0"-check at some point for this to be useful for the attacker? Or does any pre-image do?

The way I think about it is this:

For the user, the task is to find $x = hash(seed_u || code_u || storage_u)[0]$ such that $x$ has the last significant 16 bits set to 0 (and some other properties which I'm skipping here for brevity) by trying different values of $seed_u$. The work involved in this is $2^{16}$.

For the attacker, the task is to find $x = hash(seed_a || code_a || storage_a)[0]$ where $x$ is exactly the same as the $x$ found by the user (also by trying different values of $seed_a$). Since $x$ is 64 bits (i.e., the first element of the word), the work involved in this is $2^{64}$. This work does not depend on the number of trailing zeros in $x$. For example, if we require the user to find $x$ with 32 trailing zeros, the attacker would still need to expand $2^{64}$ PoW.

@Al-Kindi-0
Copy link
Collaborator

Exactly, the hardness of finding a preimage is dependent only on the size of the image space (i.e., where x lives) for a cryptographic hash function and not on specific properties of the pre-image itself.

@bobbinth
Copy link
Contributor Author

So, this seems to suggest that the amount of data we can encode in the ID is pretty limited because otherwise we'd impose too big of a computational burden on the user. Basically, we have a trade-off:

  1. Impose PoW against the non-ID portion of the hash (as we do now) to increase the security against ID highjacking attacks.
  2. Encode useful info into the ID.

The PoW requirement for the user would be the sum of PoW for both items. For the attacker, the security in bits would be 64 + PoW from item 1 above.

So, for example, if we want to have 80 bits of security against ID highjacking and 16 trailing zeros in the ID, we'd need 32 bits of PoW for the user - which I think is too much.

I think we do want to have at least 80 bits of security against ID hijacking - which means that we are probably limited to:

  • 4 - 8 bits of useful info in the ID of regular accounts (of which we already use 4). This way, total PoW does not exceed 24 bits.
  • ~20 bits of useful info in the ID of faucets (of which we also already use 4). This way total PoW does not exceed ~36 bits.

@hackaugusto
Copy link
Contributor

Usually, a hash function compresses the input in some way and the simplest such case would be a 2-to-1 compression. This is clearly not one-to-one and hence I am probably missing something in your comment.

My bad, I was trying to paint a picture using the id function and made invalid statements. Basically I was trying to say for n different inputs we will have at most n distinct outputs, we have 256bits space for the output but limit ourselves to 128bits in the input, so we can't cover the whole output space. Not sure how bad that is.

@bobbinth
Copy link
Contributor Author

bobbinth commented Oct 4, 2024

The Note Metadata representation can accomodate an account ID up to 120 bits without restrictions and up to 128 bits if reducing the aux value's size is fine.

Agreed - though I think the layout to accomodate 120-bit IDs would need to be different from what is in the diagram.

The non fungible asset is the biggest open question (to me) as it mainly depends on the required security level and must be balanced between the asset digest minimum length and the account id minimum length. The simplest solution would be to go beyond a one word representation of the non fungible asset, and presumably the fungible asset too for consistency.

Indeed, this is the most unclear case to handle. I guess a couple more questions on this:

  • At which size of account ID could we still keep non-fungible assets to a single word? e.g., could we do it at 80 bits? 96 bits? (I'm pretty sure that if we go to 120 bits, we can't keep them to one word).
  • Assuming we increase size of non-fungible assets beyond one word (probably going directly to 2 words), what are the implications? There are several aspects to this I can think of:
    • Serialized size: this would probably increase by 50% for non-fungible assets (going from 4 elements to 6) and by 50% for fungible assets (going from 2 elements to 3).
    • Hashing inside the VM: this is actually likely to get a bit more efficient as hashing double-words in the VM is easier than single words.
    • Hashing outside the VM: this will slow down by about 50% as hashing double-words requires twice as much work as hashing single words.
  • If we are increasing the size of the assets anyway - should we re-define things in a bit more radical way? For example, the two words could be something like [ASSET_ID, ASSET_DATA] where ASSET_ID = hash("miden" || faucet_id). This may make it simpler to map assets to other assets in the AggLayer (@plafer - do you remember how assets are defined there?).

@PhilippGackstatter
Copy link
Contributor

At which size of account ID could we still keep non-fungible assets to a single word? e.g., could we do it at 80 bits? 96 bits?

This is what I tried to illustrate with the collision resistance part. Increasing the Account ID decreases the room for the Non Fungible Asset (NFA) in the word, so some examples for that trade-off:

Account ID Non Fungible Asset Digest NFA Collision Resistance / cost Account ID Rainbow Table Storage/Compute cost*
64 bits 192 bits $2^{96}$ / $20Q USD $160K USD / $4800 USD
80 bits 176 bits $2^{88}$ / $80T USD $217B USD / $314M USD
96 bits 160 bits $2^{80}$ / $314B USD $15000T USD / $20T USD
112 bits 144 bits $2^{72}$ / $1.2B USD ...
120 bits 136 bits $2^{68}$ / $76M USD ...

* Cost is for 1/1000th of the total required storage / compute cost to build the rainbow table (as done here).

You discussed some cost estimations above and I tried to extrapolate from those:

We can estimate current cost of computing 2^60 hashes at around $300K USD.

The compute and storage estimation was then calculated this way:

compute_estimate = lambda x: (300_000 * (2**x) / (2**60))
# 10 dollars for 1 TB
# Divide by 1T to get number of terrabytes
# Divide by 1000 to get 1/1000th of the cost
storage_estimate = lambda x: ((8 + x//8) * 2**x) / 1_000_000_000_000 * 10 / 1000

It's worth mentioning that in your comment (linked above) you mentioned that

Building such a rainbow table (assuming some efficient GPU-based implementation) could cost anywhere between $1B and $10B USD.

while my estimations here seem lower.

If the calculations and assumptions are correct, then building a rainbow table for 1/1000th of the possible Account IDs at 80 bits would cost $217B + $314M USD. I guess we also want a safety margin here to account for some major improvement in the future, say 1000x. Then an account worth more than $217M USD would be worth highjacking by an attacker with this table with 1/1000th of a chance and only if the account was not already registered on chain, iiuc. That seems acceptable to me given the mitigating circumstances under which this could happen. Do you see flaws in that logic?

On the other end of the spectrum, with 120 bits for the Account ID we definitely couldn't represent NFAs in one word as the collision resistance for them would be too low.

@bobbinth
Copy link
Contributor Author

bobbinth commented Oct 8, 2024

As briefly discussed online, seems like we have roughly 2 options here:

  1. A slightly more aggressive option of using 80 or 96 bits for the ID. In this case, we should be able to keep asset sizes to one word.
  2. A slightly more conservative option of using 120 bits for the ID. In this case, assets would need to be 2 words.

I am leaning somewhat towards the conservative option - just os that we don't have to face this problem again in the future. Assuming this, I think we have a couple of outstanding questions:

  1. What should the actual design of the account ID generation process be. E.g., how much proof of work we should require and should we make it configurable?
  2. This will be a fairly large change, and I wonder if we can stage it somehow. For example, we could implement changes to the assets first and only after these are propagated everywhere, update the actual ID design. Maybe something similar could be done with note metadata too.

@PhilippGackstatter
Copy link
Contributor

I am leaning somewhat towards the conservative option

I agree with the conservative option simply for safety and future-proof reasons.

This will be a fairly large change, and I wonder if we can stage it somehow. For

I agree. Stages sound like a good approach.

  • As a first step we could indeed update assets to two words. Perhaps it would already make sense to move the account ID to its final position in the layout at this stage so this doesn't have to be changed later. Ideally this change would make it so that the account ID later is just a drop-in replacement, but not sure if this is possible everywhere without doing extra work that would have to be reversed later.
    • Maybe at the same stage or as a follow-up we could also already expand the memory representation of all existing account_ids in the VM to two Felts (e.g. leaving the second felt 0) so that later we only have to replace that 0 with the new parts of the account id. One question that came up while looking at the masm code was whether we would represent an account_id or faucet_id when it is standalone (and not embedded in an ASSET) as two felts or as a WORD on the stack.
  • For the Note Metadata only the internal layout changes (see below), iiuc, so this should only affect parts where fields of it are accessed. So this shouldn't be a very large change?
  • And then lastly we can update the Account ID to the new generation process and update it on the Rust side and ideally keep the changes to MASM code somewhat in check due to the earlier work.

Note Metadata

Agreed - though I think the layout to accomodate 120-bit IDs would need to be different from what is in the diagram.

The layout I sketched was for 96-bits, but I wasn't very clear there - sorry.

To actually accommodate 120 bits comes with quite a few constraints when it comes to the note metadata. This is a proposal for a layout which should be possible.

image

  • Second Element: Require that any of the 9th to 12th byte of the account id has at least a single 0 bit, to ensure that the entire value is a valid felt. This is needed because we append the NoteType and NoteExecutionHint Tag here which have potentially any value. If we still have POW, we could perhaps also require that a certain number of leading (instead of trailing) zeroes exist to ensure a valid Felt instead of the "single 0 bit"?
    • This means we impose constraints on the account ID specifically for note metadata (but also any future layout where we would want to merge values). An alternative design would be to restrict the NoteExecutionHint Tag to 6 bits but with the highest bit set to 0. That would reduce the possible values of the tag from 64 to 32. We could then append the tag at the front of the second felt and then the second part of the account ID could be of arbitrary value. That seems like a more convoluted layout though and would be annoying to work with I think.
    • So: It seems okay to me though to impose this 1 bit restriction on the account ID whether through PoW or not.
  • For the NoteExecutionHint payload we have the AfterBlock { block_num: u32 } variant which currently requires the full 32-bits while the others require less. Similar as before, we could reduce block_num to 31 effective bits, leaving the high bit at 0. If this is too low, we could interpret the block_num as actual_block_num = block_num * 2. In other words, users can only specify blocks of even number here. Then we could still express block numbers up to 2^32.
    • At some point I heard a 3s block time. Assuming that, with 2^32 for the block number it gives us 408 years before we run out of numbers and with 2^31 it would be 204 years which is plenty still.
    • This would then also restrict any future variant we add in that payload which may or may not be a problem?
  • And finally the aux is a Felt already, so no additional constraints here.

Proof of Work

What should the actual design of the account ID generation process be. E.g., how much proof of work we should require?

Is there some documentation how you came up with these numbers here (trailing zeroes and min ones)?

/// Specifies a minimum number of trailing zeros required in the last element of the seed
/// digest.
///
/// Note: The account id includes 4 bits of metadata, these bits determine the account type
/// (normal account, fungible token, non-fungible token), the storage type (on/off chain), and
/// for the normal accounts if the code is updatable or not. These metadata bits are also
/// checked by the PoW and add to the total work defined below.
#[cfg(not(any(feature = "testing", test)))]
pub const REGULAR_ACCOUNT_SEED_DIGEST_MIN_TRAILING_ZEROS: u32 = 23;
#[cfg(not(any(feature = "testing", test)))]
pub const FAUCET_SEED_DIGEST_MIN_TRAILING_ZEROS: u32 = 31;
#[cfg(any(feature = "testing", test))]
pub const REGULAR_ACCOUNT_SEED_DIGEST_MIN_TRAILING_ZEROS: u32 = 5;
#[cfg(any(feature = "testing", test))]
pub const FAUCET_SEED_DIGEST_MIN_TRAILING_ZEROS: u32 = 6;
/// Specifies a minimum number of ones for a valid account ID.
pub const MIN_ACCOUNT_ONES: u32 = 5;

That would be helpful for looking into this.

... and should we make it configurable?

Do we want to make this potentially user-configurable or protocol-configurable?
I read something about user-configurable in this thread but if it's that, it doesn't serve as spam protection, unless we enforce a minimum and allow users to go higher? But then what is the purpose of going higher? Given a rainbow table it doesn't protect against account id highjacking, iiuc.

Also can you remind me: Is PoW used for anything other than spam protection?

Asset Layout

If what I wrote is roughly what you also had in mind, then I think one of the last questions is the asset layout and if we should orient the design with the AggLayer? I tired to find some docs on this but wasn't able to so far. There seems to be little information.

@bobbinth
Copy link
Contributor Author

Maybe at the same stage or as a follow-up we could also already expand the memory representation of all existing account_ids in the VM to two Felts (e.g. leaving the second felt 0) so that later we only have to replace that 0 with the new parts of the account id.

I would increase account size in a follow-up PR.

One question that came up while looking at the masm code was whether we would represent an account_id or faucet_id when it is standalone (and not embedded in an ASSET) as two felts or as a WORD on the stack.

I am thinking two felts. For example, when we store account header in kernel memory, currently the ID is stored in the same word as nonce and looks something like [account_id, 0, 0, nonce]. After the increase it would be [account_id_hi, account_id_lo, 0, nonce]). But I'm open to the alternatives too.

For the Note Metadata only the internal layout changes (see below), iiuc, so this should only affect parts where fields of it are accessed. So this shouldn't be a very large change?

Yep, that should be pretty small.

It seems okay to me though to impose this 1 bit restriction on the account ID whether through PoW or not.

Yes, agreed - this seems like this simplest solution.

For the NoteExecutionHint payload we have the AfterBlock { block_num: u32 } variant which currently requires the full 32-bits while the others require less. Similar as before, we could reduce block_num to 31 effective bits, leaving the high bit at 0. If this is too low, we could interpret the block_num as actual_block_num = block_num * 2. In other words, users can only specify blocks of even number here. Then we could still express block numbers up to 2^32.

  • At some point I heard a 3s block time. Assuming that, with 2^32 for the block number it gives us 408 years before we run out of numbers and with 2^31 it would be 204 years which is plenty still.

I think requiring that the top most significant bit is 0 is the best option here (and even if we switch to 1 second block times, we still have almost 70 years to address any potential issues). It is a bit annoying - but I don't see a better alternative.

Is there some documentation how you came up with these numbers here (trailing zeroes and min ones)?

There are probably discussions in issues somewhere - though, may be difficult to track down now. But from memory:

  • "min ones" was an arbitrary value to make sure no ID consists of mostly (or all) zeros. I believe it is currently 5, but it could be any other small number just as well.
  • trailing zeros are set to make grinding account IDs difficult. What we want to avoid is having a bunch of account IDs that share the same 64-bit prefix. Without extra PoW, finding 2 such accounts would take only 32 bits of work. But if we require 23 bits of work, this goes up to 55 bits. Not a tonne - but also not negligible.
  • Using different PoW values for faucets and accounts had a justification that faucets should be even more difficult to grind on. But I'm not sure if that's a strong reason.

Overall, the main purpose of PoW was to make sure people can't grind too much on account IDs - but if we explicitly prohibit collisions on the first 64 bits, then we could try to get rid of the PoW entirely. Basically, as soon as one account with a given 64-bit prefix is recorded on-chain, no other account with the same prefix can be recorded. The only potential downside of this is that people may create colliding accounts on accident - but that should be pretty rare (1 in 4 billion chance?) and also we could add some ways to mitigate this.

@PhilippGackstatter
Copy link
Contributor

What we want to avoid is having a bunch of account IDs that share the same 64-bit prefix.

Is this because of the account ID being the key in the account DB sparse merkle tree?
We're currently using the ~64-bit account ID as the LeafIndex in the Account DB. This happens to match the key length of the Smt<64> exactly but even if IDs are 120 bits we still want to use only an SMT of depth 64?
Changing the depth to 120 would require changing the NodeIndex structure which is an argument against doing this. Proofs would only be one element larger which doesn't seem like a huge problem.
Assuming a 64-deep SMT, though, if users can grind account IDs then they can effectively put many IDs in the same 64-bit bucket which makes the SMT less efficient.
Feels like I'm missing part of the full picture though.

... we could try to get rid of the PoW entirely

Getting rid of PoW entirely would be nice, because in my mind it has a bad public reputation due to being a waste of CPU cycles. On a practical level it would also speed up the account creation process and as a consequence we would no longer have to differentiate between testing and non-testing scenarios, which is also nice.

as soon as one account with a given 64-bit prefix is recorded on-chain, no other account with the same prefix can be recorded.

This seems ideal to me but I'm wondering about an attack similar to account id highjacking here. Before we said that highjacking is possible for 64-bit account ids due to rainbow tables being feasible for this security level. If we allow a 64-bit prefix to be registered once, then an attacker could not actually highjack an id, but they could make it inaccessible (DoS) by registering one of their ids with the same 64-bit prefix as the to-be-attacked id, before that id appears on chain. That takes the same amount of work as highjacking a 64-bit account id, iiuc.
I guess we could argue that a DoS attack is much less useful than an actual highjack because the attacker does not get any value out of it, which they do with highjacking. So this would really only be a problem for a very, very limited number of scenarios.

Regarding implementing this existence check:
For new accounts, could we require users to add a merkle proof from the account DB for the account ID they're trying to create to the advice provider? This proof would have to be for an empty leaf proving that some 64-bit prefix is empty and that prefix has to match their account ID?
I'm not sure if this would work correctly when executing multiple transactions in the same block that try to claim the same ID, i.e. if both transactions would provide the same proof and it would succeed? I don't yet understand if the account root would change after every tx or only after every block - in the latter case this would be a problem.

@bobbinth bobbinth modified the milestones: v0.6, v0.7 Oct 17, 2024
@bobbinth
Copy link
Contributor Author

What we want to avoid is having a bunch of account IDs that share the same 64-bit prefix.

Is this because of the account ID being the key in the account DB sparse merkle tree? We're currently using the ~64-bit account ID as the LeafIndex in the Account DB. This happens to match the key length of the Smt<64> exactly but even if IDs are 120 bits we still want to use only an SMT of depth 64? Changing the depth to 120 would require changing the NodeIndex structure which is an argument against doing this. Proofs would only be one element larger which doesn't seem like a huge problem. Assuming a 64-deep SMT, though, if users can grind account IDs then they can effectively put many IDs in the same 64-bit bucket which makes the SMT less efficient.

Yes, it is somewhat related to this. If account IDs are 64 bits, then we can use a SimpleSmt of depth 64. But if they are bigger, we need to use a more complicated Smt data structure which is quite a bit less efficient (especially in the VM). But if we can guarantee that there won't be collisions in the first 64 bits, then we can use a SimpleSmt again.

Getting rid of PoW entirely would be nice, because in my mind it has a bad public reputation due to being a waste of CPU cycles. On a practical level it would also speed up the account creation process and as a consequence we would no longer have to differentiate between testing and non-testing scenarios, which is also nice.

I'm not too concerned about reputation of PoW here because here it would be done by end-user devices and should be nearly imperceptible. But being able to use the same code for testing and production is definitely a big benefit.

as soon as one account with a given 64-bit prefix is recorded on-chain, no other account with the same prefix can be recorded.

This seems ideal to me but I'm wondering about an attack similar to account id highjacking here. Before we said that highjacking is possible for 64-bit account ids due to rainbow tables being feasible for this security level. If we allow a 64-bit prefix to be registered once, then an attacker could not actually highjack an id, but they could make it inaccessible (DoS) by registering one of their ids with the same 64-bit prefix as the to-be-attacked id, before that id appears on chain. That takes the same amount of work as highjacking a 64-bit account id, iiuc.

Good point! I didn't think about this.

Regarding implementing this existence check:
For new accounts, could we require users to add a merkle proof from the account DB for the account ID they're trying to create to the advice provider? This proof would have to be for an empty leaf proving that some 64-bit prefix is empty and that prefix has to match their account ID?

It would work slightly differently. The user won't need to do anything extra (in fact trying to prove non-existence of an account would be very difficult for the user because the root of the account tree may change by the time the transaction makes it to the block producer), but the block producers would preform this check before inserting a new account into the account tree. This check would actually not be too difficult and use the advice provider in the way similar to what you described.

I'm not sure if this would work correctly when executing multiple transactions in the same block that try to claim the same ID, i.e. if both transactions would provide the same proof and it would succeed? I don't yet understand if the account root would change after every tx or only after every block - in the latter case this would be a problem.

The root of the account tree would change after every transaction - and so if one transaction modifies the tree, the proof of any other transaction would become invalid. This is the reason why verifying consistency of the account states is left to the block producer.


Overall, it seems like we have two options here:

  1. Allow storing multiple accounts with the same 64-bit prefix in a leaf of the account tree. The consequence of this is that we'll probably need some PoW to make the collisions more difficult and would also need to use a somewhat less efficient Smt data structure.
  2. Enforce that no two account share the same 64-bit prefix. In this case, we can probably get rid of PoW and can use the more efficient SimpleSmt data structure. But there is a risk of "account front-running" which and adversary can try to use to disrupt the network.

@PhilippGackstatter
Copy link
Contributor

I think one of the last outstanding question is the account ID generation process and the two options you mentioned (the other outstanding question being whether we want to significantly change the asset layout or just expand it to allow 120-bit account ids to be contained).

I think from a UX and DevX point of view the option without Proof of Work would be preferable due to better ID generation performance and not having to differentiate between testing and non-testing code as well as for efficiency as we can use SimpleSmt rather than Smt.

Block Producer Account ID Existence Check

the block producers would preform this check before inserting a new account into the account tree.

I wanted to understand this a little better and looked deeper into the node. If I understand correctly we're currently using this compute_account_root procedure to update the hashes of all changed accounts:

https://github.com/0xPolygonMiden/miden-node/blob/f05a40c14856b4048ca944cac15508d81229e5bb/crates/block-producer/src/block_builder/prover/asm/block_kernel.masm#L15-L22

Assuming we add a way to determine if the account we're iterating is new, would it be possible to assert here that the mtree_set operation's return value is the EMPTY_WORD if the account is new? So basically: if account_is_new && previous_leaf_value != EMPTY_WORD { return Err("duplicate 64-bit account id prefix")}?

Account ID front-running

But there is a risk of "account front-running" which and adversary can try to use to disrupt the network.

There is, but given that an adversary can now no longer highjack an account but only DOS it makes the attack less attractive. The adversary doesn't gain any assets of value but only prevents the actual owner of accessing it and the potential assets that were sent to that account. If the sender of the note would use the P2IDR script then a dos'ed account's note could just be recalled and the process tried again, so not much harm done.
The attack is also only viable in the rather specific circumstance when the to-be-attacked account is not yet registered on chain which makes the attack even less generally useful. Given all that I don't see that anyone would spend that much resources on building the expensive rainbow table we've discussed.

Account ID Generation

The simplest account id generation process would be similar to the one we have now but without Proof of Work. We would generate a random seed from a CSPRNG and hash it together with the code and storage commitment hash(SEED, CODE_COMMITMENT, STORAGE_COMMITMENT, 0, 0, 0, 0) (exactly as it is now) and then take 15 bytes from the first two field elements of the digest.

We might still need a couple of rounds of generating random seeds to satisfy the criteria of an account ID like storage mode and type.

As mentioned before, an addition to the current validation of IDs would be the restriction:

Require that any of the 9th to 12th byte of the account id has at least a single 0 bit, to ensure that the entire value is a valid felt.

The "entire value" here being any other arbitrary value like the note type and note execution hint tag.

Still this scheme would be super fast to generate an ID and its conceptually very similar to what we have now so the changes should not be massive.

With this the chance of a collision is 2^60 assuming 120 bit IDs. Accidental collisions should practically never happen thus protecting users and the account SMT. As you mentioned earlier:

What we want to avoid is having a bunch of account IDs that share the same 64-bit prefix. Without extra PoW, finding 2 such accounts would take only 32 bits of work. But if we require 23 bits of work, this goes up to 55 bits. Not a tonne - but also not negligible.

Regular accounts currently require 55 bits and faucets 63 bits of work for a collision. Requiring 60 bits of work with this proposed scheme to produce a collision makes me think we can unify the ID generation process for both account types and make handling the different account types easier on that front.

So put simply: Isn't the account ID generation with POW that we have now roughly equivalent in terms of collision security to the same process without POW but increasing the ID space to 120 bits?

@bobbinth
Copy link
Contributor Author

bobbinth commented Nov 17, 2024

The attack is also only viable in the rather specific circumstance when the to-be-attacked account is not yet registered on chain which makes the attack even less generally useful. Given all that I don't see that anyone would spend that much resources on building the expensive rainbow table we've discussed.

Agreed. This is an expensive attack which doesn't yield any direct benefit to the attacker (though, we shouldn't underestimate potential of indirect benefits). There may also be some additional ways for us to make this attack difficult. For example, we could integrate block epochs into account ID somehow (e.g., set bits 48 - 64 of account ID based on the block the account was created at), and then compute the account ID base as hash(SEED, CODE_COMMITMENT, STORAGE_COMMITMENT, BLOCK_HASH). This way, a potential attacker would not be able to compute all possible IDs ahead of time.

Not sure if the complications associated with this are worth it though.

Assuming we add a way to determine if the account we're iterating is new, would it be possible to assert here that the mtree_set operation's return value is the EMPTY_WORD if the account is new? So basically: if account_is_new && previous_leaf_value != EMPTY_WORD { return Err("duplicate 64-bit account id prefix")}?

Yes, this check shouldn't be too difficult to add. This does still make it possible to generate a bunch of account IDs with shorter common prefixes. For example, someone could try to generate 100M accounts all sharing the same 32 bit prefix. Without additional PoW, this would take roughly 45 bits of work. Inserting this many accounts under a single subtree may make storage less efficient. But maybe that's OK.

In general, one of the reasons for PoW was to make account creation process relatively expensive (e.g., to cost a few cents) so as to discourage spam. But maybe I was overthinking this.


Assuming we go with 120-bit IDs, let's sketch out how the ID would look like. The most straight-forward way is to take the current approach and just add extra bits - e.g.,:

  • First element:
    • The first two bits specify account storage mode.
    • The next two bits specify account type.
    • The remaining 60 bits are random.
  • Second element:
    • The first 56 bits are random (though, maybe we set some zeros somewhere to make sure the element is always valid).
    • The remaining 8 bits are set to zeros.

But we could also have other arrangements: each element is 60 bits (with top 4 bits set to zeros).

Also, maybe we should allocate some number of bits (e.g., 8) to account/id version - in case we want to make changes in the future.

For faucets, we could also encode things like ticker symbol + number of decimal places into the ID - but not sure if that's a good idea.


One other thing that I was thinking about. Maybe even with 120-bit IDs we can keep assets to 1 word. For fungible assets this is straight-forward. For non-fungible assets, we'd loose the ability to tell from the asset itself which faucet it was issued by. But maybe even here it is not such a big issue. Specifically, maybe we still can set one of the asset elements to the first 64 bits of the originating faucet. This should be enough to uniquely identify the faucet as we won't allow accounts with the same 64 bit prefixes. The collision resistance for the remaining bits would be 96 - which is probably fine.

@PhilippGackstatter
Copy link
Contributor

PhilippGackstatter commented Nov 19, 2024

Block Hash Dependence

For example, we could integrate block epochs into account ID somehow (e.g., set bits 48 - 64 of account ID based on the block the account was created at), and then compute the account ID base as hash(SEED, CODE_COMMITMENT, STORAGE_COMMITMENT, BLOCK_HASH).

I guess this refers to an earlier comment:

One potential mitigation for this is to "anchor" each ID to a specific epoch. This could be done by setting some 16 bits of an account ID to 16 bits upper bits of the block number. Then, block hash of that epoch would need to be used in computing account ID. This would basically force the attacker to recompute the rainbow table every 2^16 blocks (e.g., every 3 - 7 days) and if the cost of this is signifiant enough, this may be an acceptable solution.

The creator could pick any block hash from that epoch, right? It is generally possible for a transaction to reference any earlier block (as long as expiration delta is 0), correct? This means there is no time window in which the creator must act before their account ID would become invalid, which would be a big UX annoyance.

That approach would be very effective in ruling out the kinds of targeted DOS attack that we've discussed. It would not be a solution for the "chain-wide attack", where many accounts with the same prefix are created. That could be solved with fees. As we've discussed online, with the introduction of fees it would be fairly straightforward to put a higher price on account creation. But it would not be high enough to deter these targeted DOS attacks. It seems to me we need a combination of these two approaches to rule out both kinds of attacks.

The block epoch approach together with a note specifying an expiration delta could prohibit someone from creating their account in some circumstances. If an account-creating transaction references block 1000 which is the last block in the allowed epoch, the note this transaction consumes specifies a delta of 5 and the latest block is 1010, then this transaction could not be included in the chain. I assume that this would not be a frequent problem, but when it happens it could likely lead to loss of a note's assets that were sent to that account ID. That is a caveat that the sender would have to be aware of to mitigate effectively, like always sending recallable/reclaimable notes to accounts that are not yet on-chain. Overall it seems like a footgun we should avoid if possible.

Block Hash Dependence With Proof

To fix this maybe we could reference blocks through a proof instead, so we no longer depend on the block hash input to the transaction.
So we take the epoch X from the account id and add one more authentication path to the ChainMmr so we can prove the inclusion of some block header whose block number falls into epoch X. This would at most require one additional authentication path and we need to verify an additional proof in the kernel.

Thinking through the example above, with this approach the note could be consumed in block 1009 but the proof could reference block 1000. The other properties of the approach should be the same. For an attacker to highjack/DOS a specific ID which is bound to epoch X, they can pick one block hash from epoch X and then starting hashing many seeds. For any other epoch Y, they must repeat this process so it is prohibitively expensive. They also cannot know any block hash from a future epoch Z before that epoch has begun, so it's not possible to precompute IDs.

64-bit IDs

One question this also raises is if we were to go with one of the approaches of using the block hash, could we keep account IDs at 64 bits? In this context you first discussed this idea.
On the one hand with the current ID the rainbow table is very expensive already and if it now requires recomputing it every ~3 days should make this attack much more infeasible.
On the other hand, adding the epoch would reduce the "random" part of the ID to 48 bits (64 minus 16 bits for the epoch). So then computing a rainbow table for these IDs with 48 random bits would be way too low, even when bound to a specific block hash I presume. (I estimated this with the compute_estimate(48) and storage_estimate(48) from further above and the numbers are in the realm of < $100 for 1/1000th of the table, which is probably because the estimations are very optimistic about what will be possible in the future, but still, it gives an idea.)

First Felt Layout

This does still make it possible to generate a bunch of account IDs with shorter common prefixes.

Assuming 120 bits again but in the context of the above approach, one interesting question is whether we should actually control parts (like the 16 bits epoch) of the first 64 bits so we can force account ids of certain epochs into certain subtrees in storage or if we want it to be completely random. I'd say random is better since the number of IDs per epoch would be dependent on chain usage and this will not be uniformly distributed.

Account ID Version

Also, maybe we should allocate some number of bits (e.g., 8) to account/id version - in case we want to make changes in the future.

I like the idea of having a version, but is 8 bits necessary? I'm wondering because versions are usually at the very front of a layout so adding 8 bits for version would again reduce the random part of the ID further. Maybe 8/16 versions (3-4 bits) is also fine? If we actually get to 8/16 versions then it should be possible to designate the last version as an extension of the version, so that 0b1111 would indicate that more version bits follow. Not great, but it means we don't need to reserve so much space ahead of time.
And adding the version at the front makes sense I think, so a future version can add additional types or storage modes or other fields if needed.

It also depends on whether we require the storage mode, type and version to be generated directly from the seed or if we simply overwrite the first x bits with those parts. If the size of the version is significant, then we'd effectively introduce non-trivial proof of work again. I guess we can overwrite parts of the ID, we just need to make sure that it is still a valid felt. If that was the only reason to generate those fields directly from the seed so far, then we wouldn't have to worry about it initially as long as version is small (like 0), since the top x bits would be 0 and so the entire element would always be valid.

Second Felt Layout

Regarding the second felt being valid I previously suggested:

Require that any of the 9th to 12th byte of the account id has at least a single 0 bit, to ensure that the entire value is a valid felt.

Which was to accommodate the layout of the note metadata. But I now realized that is only a useful description if we still want to grind on the ID as it gives flexibility. If we don't, which I would prefer, then just picking one of the higher bits to clear is best to ensure felt validity. I'll just pick 0 arbitrarily.

Layout Suggestion

So building on your suggestion, I would suggest:

1st felt: [version (3 bits) | storage mode (2 bits) | type (2 bits) | random (57 bits)]
2nd felt: [zero bit | random (55 bits) | 8 zero bits]

One-Word Non-Fungible Assets

This should be enough to uniquely identify the faucet as we won't allow accounts with the same 64 bit prefixes.

As discussed online, I agree that should be possible and would be really neat.
And this is also why all the metadata like version, type and storage mode should be in the first element so we can use that information when processing non fungible assets.
I went through the procedures operatings on non_fungible assets and I can't find any problems with this approach. Whenever we compare faucet IDs we can do it based on the first element and since they're unique it should work out fine.

@bobbinth
Copy link
Contributor Author

Block Hash Dependence With Proof

That's roughly how I imaged it would work. Though, I was thinking the user would "anchor" only to "epoch" blocks (i.e., blocks with numbers in multiples of $2^{16}$) but other than that, they would be free to choose any of the past epochs.

So, basically, when creating a new account, I would be free to choose any epoch block from the past (even the genesis block) and then would prove locally that (via authenticating against the MMR) that I've used that block's hash as input into the account seed computation.

This should work pretty well. The only potential complication is that the user needs to pick and prove some block from the chain, but if we allow using genesis block, that shouldn't be an issue.

64-bit IDs

Yeah, 48-bit space is just too small to be comfortable. With 80 bits, this could be workable (i.e., set the 16 least significant bits to the epoch number) - but since this requires 2 field elements, the benefits vs. 120 bit IDs are not too significant (I think).

Assuming 120 bits again but in the context of the above approach, one interesting question is whether we should actually control parts (like the 16 bits epoch) of the first 64 bits so we can force account ids of certain epochs into certain subtrees in storage or if we want it to be completely random. I'd say random is better since the number of IDs per epoch would be dependent on chain usage and this will not be uniformly distributed.

Yes, I'd say having a more uniform distribution is better.

I like the idea of having a version, but is 8 bits necessary? I'm wondering because versions are usually at the very front of a layout so adding 8 bits for version would again reduce the random part of the ID further. Maybe 8/16 versions (3-4 bits) is also fine? If we actually get to 8/16 versions then it should be possible to designate the last version as an extension of the version, so that 0b1111 would indicate that more version bits follow. Not great, but it means we don't need to reserve so much space ahead of time.
And adding the version at the front makes sense I think, so a future version can add additional types or storage modes or other fields if needed.

I don't think it matters if we put the version in front or not - as long as it is in the first element. I would probably put it into the least significant bits to make ID distribution more uniform (same can actually be argued for the other "control bits").

It also depends on whether we require the storage mode, type and version to be generated directly from the seed or if we simply overwrite the first x bits with those parts.

I haven't spent too much time thinking about it, but I think overwriting reduces the amount of work one needs to generate colliding IDs. Not sure if that's an issue here though.

Layout Suggestion

So building on your suggestion, I would suggest:

1st felt: [version (3 bits) | storage mode (2 bits) | type (2 bits) | random (57 bits)]
2nd felt: [zero bit | random (55 bits) | 8 zero bits]

As mentioned above, we could move the version and other "control bits" to the end. This would make the distribution of IDs more uniform. The only thing that would get affected by this is NoteTags. Could you check if this would be a problem?

An alternative suggestion of the layout:

1st felt: [zero bit | random (55 bits) | version (4 bits) | storage mode (2 bits) | type (2 bits)]
2nd felt: [random (56 bits) | 8 zero bits]

Assuming we generate valid field elements during hashing, we don't need to set the top bit of the 2nd felt to 0 as resetting the lower 8 bits will always map a valid field element to a valid field element.

@PhilippGackstatter
Copy link
Contributor

PhilippGackstatter commented Nov 20, 2024

DOS Attack

I don't think I fully understand how this scheme would prevent the targeted DOS attack.

when creating a new account, I would be free to choose any epoch block from the past (even the genesis block) and then would prove locally that (via authenticating against the MMR) that I've used that block's hash as input into the account seed computation.

Unless I misunderstand something then this wouldn't force an attacker to recompute the rainbow table every $2^{16}$ blocks. If the attacker can always pick the genesis block's hash in any account IDs generation process, then they can just assume this as a static value next to code and storage commitment and then build a rainbow table only by varying the seed. But I thought we need to force the attacker to vary both seed and block hash to make it much harder to build the rainbow table. Because of that, we need to set some kind of limit on how far back a block's hash can be chosen.

  • One way of doing this is by requiring that the "account id generation block"'s number is greater than latest block - time limit, but this comes with the UX problem of needing to register the account on-chain within some time window.
  • Another way is the anchoring to a specific epoch X by requiring that the epoch block for X must be used to generate account ID's containing epoch X.

But for the latter, allowing any block from some epoch Y to be used where Y <= X would also allow an attacker to use the genesis block's hash afaict.

The only potential complication is that the user needs to pick and prove some block from the chain, but if we allow using genesis block, that shouldn't be an issue.

Why is it complicated for a user to prove a block, and why is proving genesis easier? The only thing I can think of is that recent block's MMR proofs will typically change more frequently because newer MMR peaks are smaller and are combined more frequently than older ones. But I guess you would just ask the chain for an up-to-date proof anyway when constructing the account-creating transaction, and the chain should have blocks from recent epochs available I assume.

Proof of Work

With 80 bits, this could be workable [...] but since this requires 2 field elements, the benefits vs. 120 bit IDs are not too significant (I think).

Yeah I think so too. The only reason to keep IDs short is to be able to embed them fully in other layouts. Since we can uniquely identify accounts with 64 bits this may not be a real concern though. That means whether IDs are 80, 96 or 120 bits in total doesn't matter for embedding an issuer in other layouts, like non fungible assets.

I haven't spent too much time thinking about it, but I think overwriting reduces the amount of work one needs to generate colliding IDs. Not sure if that's an issue here though.

I think you're right. Assuming we put the epoch in the second felt and 4 bit versions, 2 bit storage mode and 2 bit type in the first felt, we'd have only 56 random bits in the 64 bit prefix. And those would only require 28 bits of work to find a collision.

Doesn't this also mean that we need some mechanism like proof of work to make finding a collision on the 64 bit prefix harder than it is without Pow? Without Pow it should just be 32 bits of work to find a collision. On an RTX 4090 with 140 megahashes/s it takes 30s to compute such a collision, if my math is correct. This is so short that the block hash dependence doesn't matter either.

If this is true, then:

  • we either need Proof of Work anyway for at least the 64 bit prefix (which we could do with the version, type, storage mode and 16 bit epoch for a total of 24 bits of work, iiuc),
  • or we must include the full account ID in the non fungible asset and use Smt rather than SimpleSmt for the account tree, and perhaps also include some PoW to prevent too many IDs sharing the same prefix.

I hope I've missed something and this is not the case.

Version and NoteTag

I don't think it matters if we put the version in front or not - as long as it is in the first element.

Good point, that's true.

As mentioned above, we could move the version and other "control bits" to the end. This would make the distribution of IDs more uniform. The only thing that would get affected by this is NoteTags. Could you check if this would be a problem?

Sounds good.
The NoteTag requires an update but I don't think there's a problem. What's not entirely clear is whether the Note Tag should include the control bits of the account or not. It's only used for "fuzzy matching" to quickly find notes for a specific purpose or recipient, right?

  • For NoteExecutionMode::Public we still have the case that the first bit of an account id's first felt is 0 if we go with your latest layout suggestion. So we can set the high bit to 0 for the mode and then use the first 31 bits of the account ID to get a high bit prefix of 0b00.
  • Similarly, for NoteExecutionMode::Private we could just use the first 14 bits (or skip the zero bit of the account ID and use the following 14 bits which may be better).
  • I think this might be an improvement as we would not include the control bits but only the random part of the ID which should allow for slightly better matching, because there are many accounts with the same storage mode and account type but fewer accounts that match those 4 additional random bits I imagine.
  • Other than that the Account ID isn't used in the NoteTag so I think that's it.

Assuming we generate valid field elements during hashing, we don't need to set the top bit of the 2nd felt to 0 as resetting the lower 8 bits will always map a valid field element to a valid field element.

Good point, I hadn't thought of this.

@bobbinth
Copy link
Contributor Author

Unless I misunderstand something then this wouldn't force an attacker to recompute the rainbow table every 2 16 blocks. If the attacker can always pick the genesis block's hash in any account IDs generation process, then they can just assume this as a static value next to code and storage commitment and then build a rainbow table only by varying the seed. But I thought we need to force the attacker to vary both seed and block hash to make it much harder to build the rainbow table. Because of that, we need to set some kind of limit on how far back a block's hash can be chosen.

  • One way of doing this is by requiring that the "account id generation block"'s number is greater than latest block - time limit, but this comes with the UX problem of needing to register the account on-chain within some time window.
  • Another way is the anchoring to a specific epoch X by requiring that the epoch block for X must be used to generate account ID's containing epoch X.

But for the latter, allowing any block from some epoch Y to be used where Y <= X would also allow an attacker to use the genesis block's hash afaict.

I think I had something like your second point in mind. Basically, the user can use any block as a reference block in the transaction (e.g., i can always reference the genesis block as a reference), but once the reference block is chosen, it would make sense to use the epoch of that reference block as the input into account ID computation.

The assumption here is also that the epoch gets copied into the ID (the first element) and so the attacker can't pre-compute seeds until they know block hash for a given epoch. To illustrate this: let's say we set the lower 16 bits of the first element to the epoch value. Say this value is $x$. Then, to derive the account ID, we'd need to compute:

hash(code_commitment, storage_commitment, hash_of_x, seed)

In the transaction kernel, the user would have to verify that hash_of_x is actually the hash of block $x$. And so, this means that until block $x$ is produced, nobody can compute the set of IDs where the lower 16 bits of the first element are set to $x$.

Why is it complicated for a user to prove a block, and why is proving genesis easier? The only thing I can think of is that recent block's MMR proofs will typically change more frequently because newer MMR peaks are smaller and are combined more frequently than older ones. But I guess you would just ask the chain for an up-to-date proof anyway when constructing the account-creating transaction, and the chain should have blocks from recent epochs available I assume.

Yeah, the main complication is that you'd need to get some info from the chain before creating an account (which may complicate testing too). But if you always have an option to create an account against the genesis block, this is somewhat mitigated as you don't need to sync (assuming genesis block is hard-coded into the client at some point).

I think you're right. Assuming we put the epoch in the second felt and 4 bit versions, 2 bit storage mode and 2 bit type in the first felt, we'd have only 56 random bits in the 64 bit prefix. And those would only require 28 bits of work to find a collision.

Doesn't this also mean that we need some mechanism like proof of work to make finding a collision on the 64 bit prefix harder than it is without Pow? Without Pow it should just be 32 bits of work to find a collision. On an RTX 4090 with 140 megahashes/s it takes 30s to compute such a collision, if my math is correct. This is so short that the block hash dependence doesn't matter either.

Collisions are a problem only for DoS attack (e.g., someone tries to create many IDs with the same prefix). They are not that much of a problem for the "highjacking" or "frontrunning" attacks (e.g., someone wants to generate some specific 64-bit prefix) because this requires 64 bits of work. So, the only option here the attacker has is to pre-compute a rainbow table.

But I agree, from DoS standpoint, it is not great that generating a bunch of collisions is not that expensive. So, maybe adding a small proof of work (e.g., 16 bits) is a good idea after all (this should be negligible for the user, but would still be annoying for tests).

I we are willing to accept 16-bit PoW and want to add epoch anchoring, the layout could look like so:

1st felt: [zero bit | random (39 bits) | epoch (16 bits) | version (4 bits) | storage mode (2 bits) | type (2 bits)]
2nd felt: [random (56 bits) | 8 zero bits]

The ID would be derived from:

hash(code_commitment, storage_commitment, hash_of_epoch_block, seed)

Here, the user would specify code and storage commitments as well as an epoch block. Then, they would perform PoW on the seed until the epoch bits in the ID match the block number of the epoch block.

The NoteTag requires an update but I don't think there's a problem. What's not entirely clear is whether the Note Tag should include the control bits of the account or not. It's only used for "fuzzy matching" to quickly find notes for a specific purpose or recipient, right?

I'm not sure it needs to. I think we made use of the ID structure to "pack" one extra bit into the tag - but not sure if that's all that important.

@PhilippGackstatter
Copy link
Contributor

Block Hash Dependence

the user can use any block as a reference block in the transaction (e.g., i can always reference the genesis block as a reference), but once the reference block is chosen, it would make sense to use the epoch of that reference block as the input into account ID computation

I think linking the transaction's reference block and having to use the epoch block from the reference block in the account ID computation has this UX problem I described earlier:

The block epoch approach together with a note specifying an expiration delta could prohibit someone from creating their account in some circumstances. [...]

One possible account ID generation for an end user could look like the following, assuming the approach you described:

  • User picks the genesis block as reference block for the later transaction in which the account will be generated. So for account ID generation the user needs to reference the genesis block's hash. Now the user has an ID whose epoch points to the genesis, say, 0.
  • User sends the account ID to some third party, say an exchange, who will send a note to that account ID so the user has some assets to pay for the account-creating transaction later.
  • Some time (days) may pass between this sending and the user proceeding with the next step.
  • If for whatever reason the exchange specified an expiration delta of expiration number of blocks, then the user now cannot execute the account-creating transaction. The expiration delta would force the user to use some block within latest block - expiration and latest block, but the account-creation process forces the user to pick block 0. It's impossible to fulfil both conditions, so the user can neither create the account nor consume the note and the note's assets are now lost.

So linking the reference block and the epoch block introduces this caveat for account IDs that are generated offline and are only (much) later registered on chain.

If we just require that the user provides a proof to some arbitrary epoch block of their choosing, then, afaict, we would avoid this problem. This would still allow the user to pick the genesis epoch block if they want to (which we could do for testing scenarios), but we should discourage that for real-world usage, because an attacker could build a rainbow table based on hash(code_commitment, storage_commitment, hash_of_genesis_block, seed) where all variables except the seed are fixed.

Proof of Work

Fees

Collisions are a problem only for DoS attack (e.g., someone tries to create many IDs with the same prefix). They are not that much of a problem for the "highjacking" or "frontrunning" attacks (e.g., someone wants to generate some specific 64-bit prefix) because this requires 64 bits of work.

Ah, thank you! I had a hunch that there was an error in my logic somewhere, but couldn't figure out where. That's good news to me, because then I still have hopes we can avoid significant PoW. If we only need to use it to make it hard for attackers to produce many IDs with common prefixes so we can protect storage efficiency, then isn't that better solved by making account creation reasonably expensive with fees? I think we should be able to find a sweet spot between making it very expensive to attack the storage which can only be done with a ton of accounts, and still making legitimate use cases where a few accounts are created cheap enough.

Account Storage

And if that is not a solution, I'm trying to understand the issue with storage better.

  • In-memory, the SimpleSmt uses a self-balancing BTreeMap underneath, so this should work fine with many shared prefixes.
  • I guess the main concern is database or disk storage later in the node? Wouldn't it be possible to apply some cheap non-cryptographic hash function to each 64-bit account ID prefix to create a more uniform distribution again, before storing it? What I basically want to say is: Is it wortwhile to try and solve this problem on the account ID itself or can we fix this at some later point as well?

Layout without PoW

If we can avoid dedicated PoW, regarding the layout, I'm wondering if we can move the epoch to the second felt. This is to avoid having to grind a seed that produces a hash with the desired epoch. We only need the epoch to verify during account id creation, beyond that it is basically useless. It doesn't really tell anything about the ID other than that an ID with epoch X was created before epoch X+1, because any available epoch block could've been chosen. So it seems optional to have it in the first felt. In that case we would allow overwriting the 16 epoch bits of the second felt so those bits don't incur PoW.

We'd have some non-zero but still insignificant PoW left. For the first felt we'd have the 4 version bits which also act like PoW as long as we have a single valid version. (This would get progressively easier with the introduction of more versions, but that's fine since we're not using it for security reasons here.) For a user wanting to create a specific account type and storage mode, they'd have 4 more bits of work for a total of 8 bits of work. This is very similar to the current testing requirement of 5 trailing zeroes plus account type and storage iiuc.

So would the following layout be viable?

1st felt: [zero bit | random (55 bits) | version (4 bits) | storage mode (2 bits) | type (2 bits)]
2nd felt: [zero bit | random (39 bits) | epoch (16 bits) | 7 zero bits]
  • For the 2nd felt we have two options to ensure felt validity:
    • Use the layout above which requires the high bit to be zero and reduces random bits to 39 so we still have a total of 56 bits but it would be one more bit of PoW. This would be slightly cheaper to validate.
    • Use random (40 bits) | epoch (16 bits) | 8 zero bits and require that any of the 64..=95-th bits is 0. This would be slightly more expensive to validate but technically an easier condition to fulfil PoW for.
  • Since they seem roughly equivalent, the only differntiator is ID validation. Since we have to validate IDs quite often but IDs have to be ground only once, and we have to do validation in the VM, we could arguably just choose option 1.

I'm still not quite sure if we can't just overwrite all parts of the ID with control bits and epoch so we don't need to grind at all. It does lower collision resistance, but if we can protect against various attacks with cryptographic (block hash + proof) or economic (fees) means before collision resistance matters, maybe it's fine?

@bobbinth
Copy link
Contributor Author

I think linking the transaction's reference block and having to use the epoch block from the reference block in the account ID computation has this UX problem I described earlier:

Ah yes - agreed that your proposal is better.

Ah, thank you! I had a hunch that there was an error in my logic somewhere, but couldn't figure out where. That's good news to me, because then I still have hopes we can avoid significant PoW. If we only need to use it to make it hard for attackers to produce many IDs with common prefixes so we can protect storage efficiency, then isn't that better solved by making account creation reasonably expensive with fees? I think we should be able to find a sweet spot between making it very expensive to attack the storage which can only be done with a ton of accounts, and still making legitimate use cases where a few accounts are created cheap enough.

Generally agreed that handling this via fees is probably better, but 2 comments:

  • It does feel somewhat safe to have the PoW, just in case we are missing something.
  • 16-bits of PoW is negligible. On vast majority of devices this should take less than 1 second to generate on a single core. On devices where multiple cores are available, it would take less than 100ms. So, in my mind, PoW is only potentially problematic for testing.

Account Storage

And if that is not a solution, I'm trying to understand the issue with storage better.

  • In-memory, the SimpleSmt uses a self-balancing BTreeMap underneath, so this should work fine with many shared prefixes.
  • I guess the main concern is database or disk storage later in the node? Wouldn't it be possible to apply some cheap non-cryptographic hash function to each 64-bit account ID prefix to create a more uniform distribution again, before storing it? What I basically want to say is: Is it wortwhile to try and solve this problem on the account ID itself or can we fix this at some later point as well?

Yes, the main concern is for the future when the tree gets too big to keep it all in memory. Probably not a concern until we have more than 10M accounts or so. But once the tree is big, having leaves more evenly distributed allows for various storage optimizations.

So would the following layout be viable?

1st felt: [zero bit | random (55 bits) | version (4 bits) | storage mode (2 bits) | type (2 bits)]
2nd felt: [zero bit | random (39 bits) | epoch (16 bits) | 7 zero bits]
  • For the 2nd felt we have two options to ensure felt validity:

    • Use the layout above which requires the high bit to be zero and reduces random bits to 39 so we still have a total of 56 bits but it would be one more bit of PoW. This would be slightly cheaper to validate.
    • Use random (40 bits) | epoch (16 bits) | 8 zero bits and require that any of the 64..=95-th bits is 0. This would be slightly more expensive to validate but technically an easier condition to fulfil PoW for.
  • Since they seem roughly equivalent, the only differntiator is ID validation. Since we have to validate IDs quite often but IDs have to be ground only once, and we have to do validation in the VM, we could arguably just choose option 1.

I'd probably go with the second option here (i.e., second felt random (40 bits) | epoch (16 bits) | 8 zero bits) because I think it will be easer to use this encoding the note metadata. To make validation easier, we could even do something like: zero bit | random (39 bits) | epoch (16 bits) | 8 zero bits as I don't think we lose that much by giving up 1 bit.

But also, I'm still thinking that putting the epoch into the first element may be better as it makes it very difficult to build a rainbow table for all 64-bit prefixes. Maybe we do this w/o PoW for now and then introduce PoW later, if needed.

@PhilippGackstatter
Copy link
Contributor

But also, I'm still thinking that putting the epoch into the first element may be better as it makes it very difficult to build a rainbow table for all 64-bit prefixes. Maybe we do this w/o PoW for now and then introduce PoW later, if needed.

I don't understand this point. If we put the epoch in the first element but don't do PoW, meaning we overwrite those parts, then it makes it easier to create a rainbow table, not harder, or am I misunderstanding?

Aren't the options we have to:

  • either put epoch in the first element and then have 16+4+2+2+1 = 25 bits of total proof of work,
  • or put it in the second element and have proof of work on the first element of 4+2+2+1 = 9 bits?

(Note that in practice I'm seeing an average of 20 bits of work for the first option and 7 bits of work for the second one, not sure yet where the difference comes from. But even so, the former would be quite significant which is why I don't think it's a viable option to put the epoch in the first felt with PoW.)

I've now started to implement what we're discussing with this layout:

1st felt: [zero bit | random (55 bits) | version (4 bits) | storage mode (2 bits) | type (2 bits)]
2nd felt: [zero bit | random (39 bits) | epoch (16 bits) | 8 zero bits]

During grinding, I'm only validating that the first felt meets the requirements here, while the requirements of the second felt are ensured by setting the bits to the expected values (i.e. "overwriting").

We can still adjust things of course, like if we actually can put the epoch in the first felt, but I wanted to get started with something.

@bobbinth
Copy link
Contributor Author

If we put the epoch in the first element but don't do PoW, meaning we overwrite those parts, then it makes it easier to create a rainbow table, not harder, or am I misunderstanding?

Yes, that's correct. I was just thinking we could introduce PoW later (e.g., closer to mainnet) and for now skip it to simplify testing.

I've now started to implement what we're discussing with this layout:

1st felt: [zero bit | random (55 bits) | version (4 bits) | storage mode (2 bits) | type (2 bits)]
2nd felt: [zero bit | random (39 bits) | epoch (16 bits) | 8 zero bits]

During grinding, I'm only validating that the first felt meets the requirements here, while the requirements of the second felt are ensured by setting the bits to the expected values (i.e. "overwriting").

We can still adjust things of course, like if we actually can put the epoch in the first felt, but I wanted to get started with something.

Sounds good. Let's go with this approach for now and we can adjust later if needed. One though about the specific layout: we can probably put the epoch into the top 16 bits of the second element (epoch with all ones is not going to happen until very close to the exhaustion of the block number address space).

1st felt: [zero bit | random (55 bits) | version (4 bits) | storage mode (2 bits) | type (2 bits)]
2nd felt: [epoch (16 bits) | random (40 bits) |  8 zero bits]

Other things we could add to the ID:

  • Some number of bits for to identify the network (e.g., devent vs. testnet vs. mainnet). This would help us avoid potential confusion in the future.
  • A checksum of some sort to make ID errors less likely.

Though, maybe these are better handled at ID format/encoding level.

@PhilippGackstatter
Copy link
Contributor

we can probably put the epoch into the top 16 bits of the second element (epoch with all ones is not going to happen until very close to the exhaustion of the block number address space).

Great point! Used that approach now.

  • some number of bits for to identify the network
  • A checksum

I think this would be better handled by some additional encoding layer like bech32. The RPC API could receive such an address and verify network and checksum and then pass only AccountId to the lower layers. I think this would cause less confusion for whether we have to validate network or checksum at some deeper node layer which should be network agnostic.

Not sure yet if bech32 is the best format here, but this could look something like:

  • mainnet: miden1qw508d6...
  • testnet: midentest1qw508d6...
  • devnet: midendev1qw508d6...

@PhilippGackstatter
Copy link
Contributor

One thing that's constantly on the back of my mind during implementation is whether we really need 120 bit IDs, so I want to recap our rationale again. Please let me know if this is an accurate summary.

120 bit ID rationale

  • We need to make it hard for an attacker to build a rainbow table of all possible IDs so they cannot highjack an account ID when it is created but not yet registered on chain, and assets have been sent to that ID that the attacker could steal.
  • Our main solution for this is to encode a 16 bit epoch somewhere in the ID. The ID generation then depends on the block hash from that epoch's block. This results in a combinatiorial explosion since the ID depends on both seed and block hash. Moreover, block hashes become available only as time progresses, so using a recent block ID means it is extremely unlikely that an attacker has built a rainbow table for that block ID.
  • Now assume we try to encode the epoch in a 64 bit ID.
    • Option 1: We would either need proof of work to mine a hash with that epoch, version, type and storage mode. This would be about 25 bits of extra work which is fairly significant and annoying in testing scenarios.
    • Option 2: We overwrite 16 bits of the single field element with the epoch and assume we still mine version, type and storage mode. This reduces the collision resistance to unacceptable levels (an attacker must compute only $\sqrt{2^{64-16}} = 2^{24}$ values to find a collision) and mining a specific ID would be $2^{48}$ which is too low to be future-proof.
  • Generally, increasing the image space of the hash function is better as it makes it exponentially harder to compute a rainbow table with every added bit, so just from a future-proofing perspective a larger ID is better.
  • This is enough of a justification to move beyond 64 bits, and once we move to two field elements we can pretty comfortably use much of the second field element's range, hence 120 bits in total.

64 bit prefix uniqueness

All of the above is important for the creation process of the ID. But since we ensure the 64 bit prefix is unique, we said we can use just the prefix as the faucet of non fungible assets. Where else is that the case? More generally, when do we actually need to use the full 120 bit ID and when is the 64 bit prefix sufficient?

Once the ID is registered on chain, could we not essentially use just the 64 bit prefix from that point on, since it is unique? Post-creation the ID is no longer used for "authentication" in the sense that it is only used as an identifier but we no longer prove anything about it (e.g. that we have a seed and other values that hash to that ID). Instead the authentication is done via actual signatures. The only important property of an identifier is uniqueness and that is the case with just the 64 bit prefix.

If we could use the full ID just for the creation and the prefix beyond that for everything else, that would keep many things similar as they are now. Any thoughts on this?

If this is not possible for some reason, then my specific question would be whether we need the full ID or just the prefix in fungible assets and NoteInputs when passing a target account ID.

@bobbinth
Copy link
Contributor Author

I think there are two related but separate attacks that we are trying to prevent:

  1. The "highjacking" attack - i.e., i see some notes appear on the network which are directed to account x which is not yet registered on-chain. I can find a different combination of code/storage that map to the same ID as x and so I can register my account first, thereby getting all notes originally sent to x.
  2. The "fronrtunning" attack - similar to the above but we assume that 64-bit prefixes are unique (or accounts are just 64 bits). In this attack, the attacker can create an account with a different ID that shares the first 64 bits with the ID of x. Then they can register their account first, thereby preventing the user from getting their notes. The difference though is that in this scenario, the attacker doesn't get the notes.

The first attack doesn't worry me too much because it is applicable only in a very narrow set of cases and there are possible workarounds. For example, it works only if the notes directed to account x are public. If the notes are private (or, in the future, encrypted), the attacker won't be able to tell which account a note is directed to. Or if a note encodes a public key rather than account ID into its spending condition (e.g., P2PK rather than P2ID) this attack won't work either.

The frontrunning attack is a bit more troublesome as it can be used to disrupt the network. E.g., people try to create accounts, but the attacker is faster and can create a "blocking" accounts before them. The attacker doesn't benefit from this attack directly (in fact, they'd need to pay a lot of money to execute such an attack), but maybe disrupting the network leads to financial gains elsewhere. It is hard to say where the realistic threshold lies and it would also depend on how much is riding on Miden - but ideally, we'd want to make sure that an attacker willing to "burn" $100B USD, cannot cause significant disruptions to the network (though, with such resources, there are probably many other ways to cause network disruptions).

The main way to execute both of the above attacks is to pre-compute a rainbow table with either all or some meaningful fraction of 64-bit prefixes. Currently, and even for the next few years, this is not very practical: assuming we enforce 9-bit PoW building a table with all 64-bit prefixes would require 73 bits of work (though, would be good to double-check my math) which would cost over $1T USD. But as technology improves, the costs will come down (e.g., bitcoin network does 95 bits of PoW in a year).

So, even if we keep IDs to 64 bits and if we could impose 9 bit of PoW now, that's pretty safe. In the future, we could increase PoW to 25 bits, and that should be good enough for medium-term future (e.g., 5 - 10 years). But 64-bit IDs are not future-proof.

So, the way I think about it, we have 2 alternatives:

  1. We could stick with 64 bit IDs and by imposing additional PoW we are probably safe in the medium-term future. But at some point we would probably need to go to larger IDs and that would be a major change to the protocol.
  2. We could update the IDs to 120 bits now and impose 64-bit prefix uniqueness. In the future, this uniqueness may become an issue, and at that time we could relax this condition. This would still be a significant change to the protocol, but very likely much less significant than going from 64 bit IDs to 120 bit IDs.

@bobbinth
Copy link
Contributor Author

And to answer your questions more concretely:

If we could use the full ID just for the creation and the prefix beyond that for everything else, that would keep many things similar as they are now. Any thoughts on this?

I think this would basically be equivalent to keeping 64-bit IDs (we can impose a bunch of extra conditions on the part of the digest that does not become the ID - but for all practical purposes, w/e is used in the rest of the protocol is the ID). As I mentioned in the previous comment, this is probably fine for 5 - 10 year timeframe, but not future-proof beyond that.

Once the ID is registered on chain, could we not essentially use just the 64 bit prefix from that point on, since it is unique?

As I mentioned in the previous comment, 64-bit prefix uniqueness property makes the account frontrunning attack still possible. So, I think of this as a potentially temporary restriction that we may need to relax in the future.

More generally, when do we actually need to use the full 120 bit ID and when is the 64 bit prefix sufficient?

I would use the full 120-bit ID everywhere except the non-fungible asset definition. Unfortunately, it does affect a lot of places - but curious if you see any specifically problematic ones.

@PhilippGackstatter
Copy link
Contributor

64 bit uniqueness

The main way to execute both of the above attacks is to pre-compute a rainbow table with either all or some meaningful fraction of 64-bit prefixes. Currently, and even for the next few years, this is not very practical: assuming we enforce 9-bit PoW building a table with all 64-bit prefixes would require 73 bits of work (though, would be good to double-check my math) which would cost over $1T USD.

Thanks for clarifying that. What I missed before is that the frontrun attack is possible with a (seed, genesis_block_hash) combination if the epoch is encoded in the second felt, as we've landed on in the latest layout. This is because the attacker only needs to have a match on the 64 bit prefix and the block hash dependence doesn't matter because the epoch is not part of the prefix.

Regarding the math, assuming our 120 bit scheme, then our prefixes are 64 bits long and have no trailing zeroes requirement, so no extra PoW from that. We require specific bits to be set a certain way, i.e. one zero bit, 4 version bits, 2 bits for storage mode and 2 bits for type.

  • Generating all 64 bit prefixes without restrictions requires 2^64 hashes.
  • Generating all 64 bit prefixes with those restrictions also requires just 2^64 hashes, because that prefix is just one particular 64 bit prefix. In other words, the prefixes that satisfy our restrictions are a subset of all 64 bit prefixes, and the latter ones can already be computed with 2^64 hashes.

So from what I can tell, this doesn't impose additional work for someone searching for a specific 64 bit prefix. It only imposes additional work (2^9) for someone wanting to generate any 64 bit prefix with those restrictions. Based on the earlier estimation in this thread of 2^60 hashes costing $300K, 2^64 would be just $4.8M but storage cost would be $2.95B (both costs assuming all prefixes, not a fraction of it). Doing this for just 1/1000th of prefixes would feasibly allow a significant disruption of the network.

If this is correct, then I'm thinking we might want to make the frontrun attack harder, though I'm not sure how. Semi-great options I can think of:

  • Put the epoch into the first felt so that the prefix depends on the block hash.
    • Grinding also the epoch in addition to the control bits would be $2^{9+16} = 2^{25}$ hashes to compute any such ID, which is quite a lot and is why I didn't want to put this in the first felt before.
    • Overwrite 16 bits of the prefix with the epoch. This means just $2^{64-16} = 2^{48}$ hashes to compute a specific ID, but the hash now depends on the block hash. But with just $2^{48}$, the block hash dependence doesn't matter in practice because it is way too cheap.
    • I briefly considered whether variable length encoding the epoch would make any sense, but it doesn't really. The general idea being that grinding epochs as part of the first felt would be encoded into shorter bit strings in the beginning and only require more bits to encode in the future, when presumably more compute power is available. But we already need 11 bits after 2^11 epochs (4.2 years after mainnet launch, conservatively assuming a 1s block time) or 12 bits after 2^12 epochs (which is ~8.5 years after mainnet launch), maybe even one more bit to account for the varint format. This makes it ~4x better than 16 bits, but not amazing.
  • Use the full ID everywhere and use Smt instead of SimpleSmt for the account tree. Implies 120 bit IDs in non-fungible assets and pretty much no way around a two word asset representation.

The Smt option would be the most comprehensive solution overall, as we just wouldn't have to deal with 64 bit IDs anymore and the option I like the most out of those.

64-bit IDs with configurable PoW

Following from a brief discussion with @bobbinth, we also wanted to explore the idea of user-configurable PoW again.

This is a worthwhile approach because the ID would still be just a single field element of ~64 bits. Some number of bits in the ID encode the PoW requirement (e.g. trailing zeroes). Users can choose their security level by picking an appropriate number of trailing zeroes.

There is a baseline PoW which is the Proof of Work that must be done even if the user configures zero additional PoW. This comes from the trailing zeroes, storage mode, type and potentially version being encoded into the layout.

For this approach the ID layout could look something like:

[random (58-z bits) | trailing zeroes (z bits) | storage mode (2 bit) | account type (2 bits) | version (2 bits)]

The storage mode and type bits are unchanged.

To keep baseline PoW very low but still add some way to differentiate potentially future versions of account IDs, we could add two bits for the version (see below for why it is at the end).

z bits that, interpreted as an integer zeroes, express the number of trailing zeroes.
We could come up with more elaborate schemes to pack as much into this as makes sense. We can calculate the actual number of trailing_zeroes from those zeroes so we have to use fewer bits, resulting in less baseline PoW. Some ideas:

  • trailing_zeroes = 2 * zeroes which assumes that if one wants to increase PoW, one would want to do it in somewhat larger steps (3 or 4 is also thinkable). With 2 here and z = 4 we can express up to 32 trailing zeroes.
  • trailing_zeroes = if zeroes != 0 { 23 + zeroes } else { 0 } deriving from the fact that a PoW of <= 23 (or some other lower bound) is not secure in a mainnet setting and so no one would choose it anyway, but for testing purposes we still want to be able to set zeroes = 0. With z = 4 we can express the range of 23..=39 trailing zeroes.

Those would allow us to set z = 4 just with different trade offs.

Highjack and Frontrun attacks

An attacker wanting to highjack or frontrun another ID must have a pre-computed rainbow table which matches the trailing zeroes requirement of the to-be-attacked ID. The more bits we allocate for those trailing zeroes the less likely that is. Here it is actually harder than in the 64 bit prefix discussion before, because the trailing zeroes are part of the larger 256 bit hash, and so computing a 64 bit prefix requires $2^{64+z}$, where $z$ is the number of trailing zeroes. It seems likely to me that in practice the distribution of the trailing zeroes number will not be uniform. On the contrary, it'll probably linger around a certain range of numbers. To be more concrete, if we ship a client right now it'll probably come configured with a number of trailing zeroes it will use to create an account ID (or at least a likely range of numbers; perhaps it picks a random one to make this slightly less predictable). But still, the most likely numbers for trailing zeroes would be fairly predictable. It'll be a small range of maybe 15 numbers that is considered reasonably secure at that time and at the same time not too difficult to make grinding the IDs infeasible on standard hardware. This allows an attacker to precompute one rainbow table for each of these likely numbers. Compared to our current design, I think it only effectively adds a "factor of difficulty" of 15 - but is that enough? It seems too low to me. We'd probably want a > 100x increase in difficulty over the current design.

Another way to put this is that an attacker can predict what likely future account IDs look like. For example, they might estimate that account IDs with 30 trailing zeroes become highly used in the future and so they precompute a table for exactly that number. Then if users happen to increase their security requirement to that number, they will be susceptible to an attack. It might not feasibly allow an attacker to target a specific account ID, but it would allow them to disrupt the network for all account IDs with their chosen number which is still bad.

Comparing this with the block hash dependence with 120 bit IDs on the other hand, every epoch that appears increases that "factor of difficulty" by one, in the sense that each new epoch block requires another rainbow table. Another differentiator is that even though an ID of a future epoch X is known, the underlying block hash from which it is computed isn't, which is a really good property to have here.

Future PoW requirements

As @bobbinth pointed out in the initial discussion, in the medium to long-term future an ASIC might become available that speeds up hashes and in those cases users could pick higher PoW requirements to combat that. And if users can no longer compute such IDs on their own machines due to the high requirements, they can delegate ID creation to services.
I would probably reframe that and consider that as quite a downside of this approach. It would be really bad for permissionlessness and decentralization if users can not compute an ID themselves but depend on external parties, since an ID is needed to enter the network. Dependence on third parties introduces trust and censorship risks which we should avoid.

Version Placement

I've moved the version to the very end because I think (and this applies to our earlier layouts as well) it's essential for parsing that the version field is at a static offset. For example, assume the tail end of a layout we had earlier with:

[..., version (4 bits) | storage mode (2 bits) | type (2 bits)]

Assume a second version adds a new type and must increase to 3 bits:

[..., version (4 bits) | storage mode (2 bits) | type (3 bits)]

In our ID parser, the first thing we need to parse is the version, so we can parse the remainder of the ID correctly.
The version in the first layout is at bits 56..=59 but in the second layout it's at 55..=58 which makes it impossible to read the version of the ID generically for any ID version, and so we can't parse the remainder in a sensible way as well. Hence why the version should be at the beginning or the end so it is at a static offset even if the rest of the ID layout changes.

Overall, I would go with 120 bit IDs. They are a lot more future-proof, but we have to consider the frontrun attack.

@bobbinth
Copy link
Contributor Author

bobbinth commented Dec 1, 2024

Pretty much agree with all of the analysis above. A few comments:

I've moved the version to the very end because I think (and this applies to our earlier layouts as well) it's essential for parsing that the version field is at a static offset.

Makes sense. Let's do it.

Grinding also the epoch in addition to the control bits would be $2^9 + 2^{16} = 2^{25}$ hashes to compute any such ID, which is quite a lot and is why I didn't want to put this in the first felt before.

We can reduce this to $2^{24}$ by disallowing the top 32 bits of the ID to be all zeros. This should be relatively negligible restriction but would also allows to have valid field elements starting with $1$ as the first bit.

I think in the long run, the secure ID structure should look something like this, but for now, even $2^{24}$ work is too expensive. So, I'm thinking we start with ID version 0 which would look like this:

1st felt: [random (56 bits) | storage mode (2 bits) | type (2 bits) | version (0000)]
2nd felt: [epoch (16 bits) | random (40 bits) |  8 zero bits]

The above would require about 8 bits of PoW.

But at some point after mainnet (or maybe even before if we have some fast WebGpu implementations which can grind through $2^{24}$ bits PoW fast enough), we switch to the next ID version which could look like so:

1st felt: [random (40 bits) | epoch (16 bits) | storage mode (2 bits) | type (2 bits) | version (0001)]
2nd felt: [random (56 bits) |  8 zero bits]

The above would require about 24 bits of PoW.

Use the full ID everywhere and use Smt instead of SimpleSmt for the account tree. Implies 120 bit IDs in non-fungible assets and pretty much no way around a two word asset representation.

We will be using the Smt tree to store any ID which is bigger than 64 bits because we need to store both the key (the 64-bit prefix) and the value (the full 120 bit ID).


If we are thinking of using some relatively high level of PoW after all, maybe we could consider slightly shorter IDs as well. Seems like maybe 96 bits should be "good enough" even in the long term?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
kernels Related to transaction, batch, or block kernels refactoring Code clean-ups, improvements, and refactoring
Projects
Status: Todo
Development

No branches or pull requests

4 participants