Skip to content

Unify asset delta into one #2630

@PhilippGackstatter

Description

@PhilippGackstatter

See the parent issue for higher-level context.

The asset delta must be able to represent addition and removal of assets.

For non-fungible assets, it is relatively simple since they do not compose. So, we only need to be able to represent adding and removing a non-fungible asset, and validating that we do not add it twice or remove it twice.

For fungible assets, we need to compose them, e.g. adding 30 and 40 of the same asset to an account vault should give +70 at the end, or adding 30 and removing 40 should result in a delta that removes 10.

Suppose that:

  • {} is the empty delta,
  • { Add(x) } is a delta that adds asset x to the vault, analogously for Remove(x),
  • x and y are assets with a matching vault key.
  • merge(a, b) -> c is a procedure that runs c = a + b.
  • split(a, b) -> c is a procedure that runs c = a - b, i.e. "split b off of a".
  • ==, > and < are comparison operators on assets.
  • merge, split and > and < panic for non-fungible assets, but == is implemented.

For a general asset delta, we need to handle the following cases:

  • add(x, {}) = { Add(x) }
  • remove(x, {}) = { Remove(x) }
  • add(x, { Add(y) }) = { Add(merge(x, y)) }
  • remove(x, { Remove(y) }) = { Remove(merge(x, y)) }
  • add(x, { Remove(y) }) =
    • if y == x: {}
    • if y > x: Remove(split(y, x)) # "split X off Y"
    • if y < x: Add(split(x, y)) # "split Y off X"
  • remove(x, { Add(y) }) =
    • if y == x: {}
    • if y > x: Add(split(y, x)) # "split X off Y"
    • if y < x: Remove(split(x, y)) # "split Y off X"

To illustrate with examples:

  • For fungible assets:
    • add(30, { Remove(30) }) would result in an empty delta since the operations cancel.
    • add(30, { Remove(40) }) would result in Remove(10) since we get Remove(split(40, 30)), so we split 30 off of 40, leaving 10.
    • add(40, { Remove(30) }) would result in Add(10) since we call Add(split(40, 30)), same as above, just wrapped in Add.
    • remove(30, { Add(40) }) would result in Add(10) since we call Add(split(40, 30)).
    • remove(40, { Add(30) }) would result in Remove(10) since we call Remove(split(40, 30)).
  • For non-fungible assets:
    • Adding or removing them to an empty delta works fine.
    • Cancel case: remove(x, add(x, {})) = {} , because x == x, i.e. both key and value match.
    • Cancel case: add(x, remove(x, {})) = {}, because x == x, i.e. both key and value match.
      • If we have x and y with a matching key but a different value, these cancel cases would correctly panic instead (since non-fungible assets cannot be ordered).

Delta Commitment

The delta commitment needs to updated as well since we can no longer assume fungible or non-fungible assets.

The delta needs to commit to 1) the delta operation (add, remove) and 2) the asset itself. The main difficulty is that the asset itself takes up 2 words already, and the delta op is essentially one bit.

Option 1: Commit to 4 words

The straightforward option is to add 4 words per entry (sorted by key):

  • [[domain = 1, delta_op, 0, 0], [0, 0, 0, 0]]
  • [ASSET_KEY, ASSET_VALUE]

This works, but results in roughly double the amount of hashing than other options.

Option 2: Split into Add/Remove delta

Another option is to iterate the asset delta twice and in the first round, commit to the added assets, skipping removed ones, and in the second round commit to the removed assets, skipping added ones:

  • First round
    • Per Entry (sorted by key): [ASSET_KEY, ASSET_VALUE]
    • Once: [[domain = 1, num_added_assets, 0, 0], [0, 0, 0, 0]]
  • Second round
    • Per Entry (sorted by key): [ASSET_KEY, ASSET_VALUE]
    • Once: [[domain = 2, num_removed_assets, 0, 0], [0, 0, 0, 0]]

This is more efficient in terms of hashing, but requires iterating twice (more cycles).

Option 3: Keep separate Add/Remove delta

To avoid iterating twice, we can maintain separate maps, one representing added and one representing removed assets. That way, we can do the same as the previous option but without having to skip entries.

The downside here is that, e.g. when mutating the delta, we need to access the removed and added map in order to maintain the delta correctly. In some cases we will need to remove an entry from the added delta and insert one into the removed delta, which seems like quite a bit of complexity in masm.

Option 4: Put delta op into asset key

We could encode the delta op bit into the unused 8 bits of the asset key, to commit to the delta op within the two asset words. This would probably constrain us from using that bit in the future, so this is probably not a good idea.

Option 5: Keep current approach and ignore custom assets

Make the delta uniform over fungible and non-fungible assets, but only those by keeping the current approach of not committing to the asset ID. The asset ID has predictable values for the native assets, but this constraint is unlikely to be true for custom assets in the future.

So we could use the current approach, but we'd then have to add a new delta for custom assets anyway, defeating a large part of the overall goal.

Summary

Generally we can:

  • Optimize for the least cycles (option 1)
  • Optimize for the least hashing
    • Option 2: Delta commitment computation more costly than mutation.
    • Option 3: Delta mutation more costly than commitment computation.

In terms of complexity, I think option 2 is likely lower than option 3 and we probably want to optimize for keeping the number of cycles per delta mutation lower than the commitment computation (which should be done rarely, ideally once per tx - and we could look into caching).

Rust

The same change would have to be done accordingly to the AssetVaultDelta in Rust.

Metadata

Metadata

Assignees

No one assigned

    Labels

    kernelsRelated to transaction, batch, or block kernelsrustIssues that affect or pull requests that update Rust code

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions