diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2d4913185 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,10 @@ +[profile.default] +retries = 0 +slow-timeout = { period = "5m"} + +[profile.ci-default] +retries = { backoff = "exponential", count = 2, delay = "1s", jitter = true, max-delay = "10s"} +status-level = "skip" +failure-output = "immediate-final" +fail-fast = false +slow-timeout = { period = "3m", terminate-after = 4} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6612ed703..a676f9019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,9 @@ jobs: toolchain: ${{matrix.toolchain}} - name: Install cargo make run: cargo install cargo-make + - uses: taiki-e/install-action@nextest - name: cargo make - test - run: cargo make test + run: cargo make ci-test clippy: name: Clippy @@ -38,8 +39,8 @@ jobs: toolchain: stable - name: Install cargo make run: cargo install cargo-make - - name: cargo make - clippy - run: cargo make clippy + - name: cargo make - clippy-all + run: cargo make clippy-all rustfmt: name: rustfmt @@ -67,9 +68,11 @@ jobs: components: rustfmt - name: Install cargo make run: cargo install cargo-make + - uses: taiki-e/install-action@nextest - run: cargo make reset + - run: cargo make node - run: cargo make start-node > /dev/null & - - run: cargo make integration-test + - run: cargo make ci-integration-test - name: Kill miden-node if: always() run: cargo make kill-node diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ceb28e4..fb186af68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## v0.3.0 (2024-05-17) + +* Added swap transactions and example flows on integration tests. +* Flatten the CLI subcommand tree. +* Added a mechanism to retrieve MMR data whenever a note created on a past block is imported. +* Changed the way notes are added to the database based on `ExecutedTransaction`. +* Added more feedback information to commands `info`, `notes list`, `notes show`, `account new`, `notes import`, `tx new` and `sync`. +* Add `consumer_account_id` to `InputNoteRecord` with an implementation for sqlite store. +* Renamed the CLI `input-notes` command to `notes`. Now we only export notes that were created on this client as the result of a transaction. +* Added validation using the `NoteScreener` to see if a block has relevant notes. +* Added flags to `init` command for non-interactive environments +* Added an option to verify note existence in the chain before importing. +* Add new store note filter to fetch multiple notes by their id in a single query. +* [BREAKING] `Client::new()` now does not need a `data_store_store` parameter, and `SqliteStore`'s implements interior mutability. +* [BREAKING] The store's `get_input_note` was replaced by `get_input_notes` and a `NoteFilter::Unique` was added. +* Refactored `get_account` to create the account from a single query. +* Added support for using an account as the default for the CLI +* Replace instead of ignore note scripts with when inserting input/output notes with a previously-existing note script root to support adding debug statements. +* Added RPC timeout configuration field +* Add off-chain account support for the tonic client method `get_account_update`. +* Refactored `get_account` to create the account from a single query. +* Admit partial account IDs for the commands that need them. +* Added nextest to be used as test runner. +* Added config file to run integration tests against a remote node. +* Added `CONTRIBUTING.MD` file. +* Renamed `format` command from `Makefile.toml` to `check-format` and added a new `format` command that applies the formatting. +* Added methods to get output notes from client. +* Added a `input-notes list-consumable` command to the CLI. + ## 0.2.1 (2024-04-24) * Added ability to start the client in debug mode (#283). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..9c93a6f97 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,103 @@ +# Contributing to Miden Client + +#### First off, thanks for taking the time to contribute! + +We want to make contributing to this project as easy and transparent as possible, whether it's: + +- Reporting a [bug](https://github.com/0xPolygonMiden/miden-client/issues/new) +- Taking part in [discussions](https://github.com/0xPolygonMiden/miden-client/discussions) +- Submitting a [fix](https://github.com/0xPolygonMiden/miden-client/pulls) +- Proposing new [features](https://github.com/0xPolygonMiden/miden-client/issues/new) + +## Flow +We are using [Github Flow](https://docs.github.com/en/get-started/quickstart/github-flow), so all code changes happen through pull requests from a [forked repo](https://docs.github.com/en/get-started/quickstart/fork-a-repo). + +### Branching +- The current active branch is `next`. Every branch with a fix/feature must be forked from `next`. + +- The branch name should contain a short issue/feature description separated with hyphens [(kebab-case)](https://en.wikipedia.org/wiki/Letter_case#Kebab_case). + + For example, if the issue title is `Fix functionality X in component Y` then the branch name will be something like: `fix-x-in-y`. + +- New branch should be rebased from `next` before submitting a PR in case there have been changes to avoid merge commits. +i.e. this branches state: + ``` + A---B---C fix-x-in-y + / + D---E---F---G next + | | + (F, G) changes happened after `fix-x-in-y` forked + ``` + + should become this after rebase: + + + ``` + A'--B'--C' fix-x-in-y + / + D---E---F---G next + ``` + + + More about rebase [here](https://git-scm.com/docs/git-rebase) and [here](https://www.atlassian.com/git/tutorials/rewriting-history/git-rebase#:~:text=What%20is%20git%20rebase%3F,of%20a%20feature%20branching%20workflow.) + + +### Commit messages +- Commit messages should be written in a short, descriptive manner and be prefixed with tags for the change type and scope (if possible) according to the [semantic commit](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716) scheme. +For example, a new change to the `miden-node-store` crate might have the following message: `feat(miden-node-store): fix block-headers database schema` + +- Also squash commits to logically separated, distinguishable stages to keep git log clean: + ``` + 7hgf8978g9... Added A to X \ + \ (squash) + gh354354gh... oops, typo --- * ---------> 9fh1f51gh7... feat(X): add A && B + / + 85493g2458... Added B to X / + + + 789fdfffdf... Fixed D in Y \ + \ (squash) + 787g8fgf78... blah blah --- * ---------> 4070df6f00... fix(Y): fixed D && C + / + 9080gf6567... Fixed C in Y / + ``` + +### Code Style and Documentation +- For documentation in the codebase, we follow the [rustdoc](https://doc.rust-lang.org/rust-by-example/meta/doc.html) convention with no more than 100 characters per line. +- We also have technical and user documentation built with [mkdocs](https://github.com/mkdocs/mkdocs). You should update it whenever architectural changes or public interface (cli, client lib, etc.) changes are being made. +- For code sections, we use code separators like the following to a width of 100 characters:: + ``` + // CODE SECTION HEADER + // ================================================================================ + ``` + +- [Rustfmt](https://github.com/rust-lang/rustfmt), [Clippy](https://github.com/rust-lang/rust-clippy) and [Rustdoc](https://doc.rust-lang.org/rustdoc/index.html) linting is included in CI pipeline. Anyways it's preferable to run linting locally before push. To simplify running these commands in a reproducible manner we use [cargo-make](https://github.com/sagiegurari/cargo-make), you can run: + + ``` + cargo make lint + ``` + +You can find more information about the `cargo make` commands in the [Makefile](Makefile.toml) + +### Versioning +We use [semver](https://semver.org/) naming convention. + +## Pre-PR checklist +1. Repo forked and branch created from `next` according to the naming convention. +2. Commit messages and code style follow conventions. +3. Tests added for new functionality. +4. Documentation/comments updated for all changes according to our documentation convention. +5. Rustfmt, Clippy and Rustdoc linting passed. + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +## Any contributions you make will be under the MIT Software License +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. diff --git a/Cargo.toml b/Cargo.toml index 1bf860261..9b8678e99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,20 @@ [package] name = "miden-client" -version = "0.2.1" +version = "0.3.0" description = "Client library that facilitates interaction with the Miden rollup" authors = ["miden contributors"] readme = "README.md" license = "MIT" repository = "https://github.com/0xPolygonMiden/miden-client" -documentation = "https://docs.rs/miden-client/0.2.1" +documentation = "https://docs.rs/miden-client/0.3.0" keywords = ["miden", "client"] edition = "2021" -rust-version = "1.75" -default-run = "miden-client" +rust-version = "1.78" +default-run = "miden" + +[[bin]] +name = "miden" +path = "src/main.rs" [[test]] name = "integration" @@ -18,11 +22,7 @@ path = "tests/integration/main.rs" required-features = ["integration"] [features] -concurrent = [ - "miden-lib/concurrent", - "miden-objects/concurrent", - "miden-tx/concurrent", -] +concurrent = ["miden-lib/concurrent", "miden-objects/concurrent", "miden-tx/concurrent"] default = ["std"] integration = ["testing", "concurrent", "uuid"] std = ["miden-objects/std"] @@ -35,12 +35,12 @@ clap = { version = "4.3", features = ["derive"] } comfy-table = "7.1.0" figment = { version = "0.10", features = ["toml", "env"] } lazy_static = "1.4.0" -miden-lib = { version = "0.2", default-features = false } -miden-node-proto = { version = "0.2", default-features = false } -miden-tx = { version = "0.2", default-features = false } -miden-objects = { version = "0.2", features = ["serde"] } +miden-lib = { version = "0.3", default-features = false } +miden-node-proto = { version = "0.3", default-features = false } +miden-tx = { version = "0.3", default-features = false } +miden-objects = { version = "0.3", default-features = false, features = ["serde"] } rand = { version = "0.8.5" } -rusqlite = { version = "0.30.0", features = ["bundled"] } +rusqlite = { version = "0.30.0", features = ["vtab", "array", "bundled"] } rusqlite_migration = { version = "1.0" } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["raw_value"] } diff --git a/Makefile.toml b/Makefile.toml index e8ae4f300..e832fc42a 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -7,12 +7,27 @@ NODE_FEATURES_TESTING="testing" [tasks.format] toolchain = "nightly" command = "cargo" +args = ["fmt", "--all"] + +[tasks.check-format] +toolchain = "nightly" +command = "cargo" args = ["fmt", "--all", "--", "--check"] +[tasks.clippy-all] +dependencies = [ + "clippy", + "clippy-integration-tests" +] + [tasks.clippy] command = "cargo" args = ["clippy","--workspace", "--all-targets", "--", "-D", "clippy::all", "-D", "warnings"] +[tasks.clippy-integration-tests] +command = "cargo" +args = ["clippy","--workspace", "--tests", "--features", "integration", "--", "-D", "clippy::all", "-D", "warnings"] + [tasks.docs] env = { "RUSTDOCFLAGS" = "-D warnings" } command = "cargo" @@ -21,17 +36,28 @@ args = ["doc", "--all-features", "--keep-going", "--release"] [tasks.test] description = "Run the client testing suite" command = "cargo" -args = ["test", "--release", "--workspace", "--", "--nocapture"] +args = ["nextest", "run", "--release", "--workspace"] [tasks.integration-test] description = "Run the integration test binary. Requires a node to connect to." command = "cargo" -args = ["test", "--release", "--test=integration", "--features", "${FEATURES_INTEGRATION_TESTING}"] +args = ["nextest", "run", "--release", "--test=integration", "--features", "${FEATURES_INTEGRATION_TESTING}"] + +# Same commands as above but using ci profile for nextest +[tasks.ci-test] +description = "Run the client testing suite" +command = "cargo" +args = ["nextest", "run", "--profile", "ci-default", "--release", "--workspace"] + +[tasks.ci-integration-test] +description = "Run the integration test binary. Requires a node to connect to." +command = "cargo" +args = ["nextest", "run", "--profile", "ci-default", "--release", "--test=integration", "--features", "${FEATURES_INTEGRATION_TESTING}"] [tasks.lint] dependencies = [ - "format", - "clippy", + "check-format", + "clippy-all", "docs" ] @@ -48,18 +74,17 @@ args = ["-rf", "miden-node"] description = "Clone or update miden-node repository and clean up files" script_runner = "bash" script = [ - 'if [ -d miden-node ]; then cd miden-node && git checkout main; else git clone https://github.com/0xPolygonMiden/miden-node.git && cd miden-node && git checkout main; fi', + 'if [ -d miden-node ]; then cd miden-node ; else git clone https://github.com/0xPolygonMiden/miden-node.git && cd miden-node; fi', + 'git checkout main && git pull origin main && cargo update', 'rm -rf miden-store.sqlite3 miden-store.sqlite3-wal miden-store.sqlite3-shm', - 'cd bin/node', - 'cargo run --features $NODE_FEATURES_TESTING -- make-genesis --force' + 'cargo run --bin miden-node --features $NODE_FEATURES_TESTING -- make-genesis --inputs-path ../tests/config/genesis.toml --force', ] [tasks.start-node] description = "Start the miden-node" -dependencies = ["node"] script_runner = "bash" -cwd = "./miden-node/bin/node" -script = "cargo run --features ${NODE_FEATURES_TESTING} -- start node" +cwd = "./miden-node" +script = "cargo run --bin miden-node --features $NODE_FEATURES_TESTING -- start --config ../tests/config/miden-node.toml node" [tasks.docs-deps] description = "Install documentation dependencies" diff --git a/README.md b/README.md index cf1503906..7a96d2b76 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![LICENSE](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/0xPolygonMiden/miden-client/blob/main/LICENSE) [![CI](https://github.com/0xPolygonMiden/miden-client/actions/workflows/ci.yml/badge.svg)](https://github.com/0xPolygonMiden/miden-clinet/actions/workflows/ci.yml) -[![RUST_VERSION](https://img.shields.io/badge/rustc-1.77+-lightgray.svg)]() +[![RUST_VERSION](https://img.shields.io/badge/rustc-1.78+-lightgray.svg)]() [![crates.io](https://img.shields.io/crates/v/miden-client)](https://crates.io/crates/miden-client) This repository contains the Miden client, which provides a way to execute and prove transactions, facilitating the interaction with the Miden rollup. @@ -20,145 +20,64 @@ The Miden client currently consists of two components: The client's main responsibility is to maintain a partial view of the blockchain which allows for locally executing and proving transactions. It keeps a local store of various entities that periodically get updated by syncing with the node. -## Usage - -### Installing the CLI - -Before you can build and run the Miden client CLI, you'll need to make sure you have Rust [installed](https://www.rust-lang.org/tools/install). Miden client v0.2 requires Rust version **1.77** or later. - -You can then install the CLI on your system: - -```sh -cargo install miden-client -``` - -This will install the `miden-client` binary on `$HOME/.cargo/bin` by default. - -For testing, the following way of installing is recommended: - -```sh -cargo install miden-client --features testing,concurrent -``` - -The `testing` feature allows mainly for faster account creation. When using the the client CLI alongside a locally-running node, you will want to make sure the node is installed/executed with the `testing` feature as well, as some validations can fail if the settings do not match up both on the client and the node. - -Additionally, the `concurrent` flag enables optimizations that will result in faster transaction execution and proving. - -After installing the client, you can use it by running `miden-client`. In order to get more information about available CLI commands you can run `miden-client --help`. - -> [!IMPORTANT] -> In order to make transaction execution and proving faster, it's important that the client is built on release mode. When using `cargo install`, this is the default build configuration. However, in case you want to run the client using `cargo run`, this needs to be explicitly set (`cargo run --release --features testing, concurrent`). - -### Connecting to the network +For more info check: -The CLI can be configured through a TOML file ([`miden-client.toml`](miden-client.toml)). This file is expected to be located in the directory from where you are running the CLI. This is useful for connecting to a specific node when developing with the client, for example. - -If you installed `miden-client` with `cargo install miden-client` there won't be a config file present. That's why there is an `init` command that creates one in the current directory with some default values. - -In the configuration file, you will find a section for defining the node's endpoint and the store's filename. By default, the node will run on `localhost:57291`, so the example file defines this as the RPC endpoint. - -## Example: Executing, proving and submitting transactions - -### Prerequisites - -- This guide assumes a basic understanding of the Miden rollup, as it deals with some of its main concepts, such as Notes, Accounts, and Transactions. A good place to learn about these concepts is the [Polygon Miden Documentation](https://0xpolygonmiden.github.io/miden-base/introduction.html). -- It also assumes that you have set up a [Miden Node](https://github.com/0xPolygonMiden/miden-node) that can perform a basic local transaction. -- Currently, the client allows for submitting locally-proven transactions to the Miden node. The simplest way to test the client is by [generating accounts via the genesis file](https://github.com/0xPolygonMiden/miden-node?tab=readme-ov-file#generating-the-genesis-file). - - For this example, we will make use of 1 faucet account and 2 regular wallet accounts, so you should set your node's `toml` config file accordingly. We will refer to these accounts as having IDs `regular account ID A` and `regular account ID B` in order differentiate them. - - Once the account files have been generated, [make sure the node is running](https://github.com/0xPolygonMiden/miden-node?tab=readme-ov-file#running-the-node). If the node has some stored state from previous tests and usage, you might have to clear its database (`miden-store.sqlite3`). - - The client should be configured to use the running node's socket as its endpoint as explained in the previous section. - -### 1. Loading account data - -In order to execute transactions and change the account's states, you will first want to import the generated account information by running `miden-client account import`: - -```bash -miden-client account import /*.mac -``` - -The client will then import all account-related data generated by the node (stored as `*.mac` files), and insert them in the local store. The accounts directory should be the one generated by the node when running the `make-genesis` command. You can now list the imported accounts by running: - -```bash -miden-client account list -``` +- [Getting started](https://0xpolygonmiden.github.io/miden-base/introduction/getting-started.html) +- [CLI Reference](./docs/cli-reference.md#types-of-transaction) + - [Configuration](./docs/cli-config.md) +- [Online Documentation](https://docs.polygon.technology/miden/miden-client) -### 2. Synchronizing the state - -As briefly mentioned in the [Overview](#overview) section, the client needs to periodically query the node to receive updates about entities that might be important in order to run transactions. The way to do so is by running the `sync` command: - -```bash -miden-client sync -``` - -Running this command will update local data up to the chain tip. This is needed in order to execute and prove any transaction. - -### 3. Minting an asset - -Since we have now synced our local view of the blockchain and have account information, we are ready to execute and submit tranasctions. For a first test, we are going to mint a fungible asset for a regular account. - -```bash -miden-client tx new mint 1000 --note-type private -``` - -This will execute, prove and submit a transaction that mints assets to the node. The account that executes this transaction will be the faucet as was defined in the node's configuration file. In this case, it is minting `1000` fungible tokens to ``. - -This will add a transaction and an output note (containing the minted asset) to the local store in order to track their lifecycles. You can display them by running `miden-client tx list` and `miden-client input-notes list` respectively. If you do so, you will notice that they do not show a `commit height` even though they were submitted to the operator. This is because our local view of the network has not yet been updated, so the client does not have a way to prove the inclusion of the note in the blockchain. After updating it with a `sync`, you should see the height at which the transaction and the note containing the asset were committed. This will allow us to prove transactions that make use of this note, as we can compute valid proofs that state that the note exists in the blockchain. +## Usage -### 4. Consuming the note +Before you can use the Miden client, you'll need to make sure you have both +[Rust](https://www.rust-lang.org/tools/install) and sqlite3 installed. Miden +client requires rust version **1.78** or higher. -After creating the note with the minted asset, the regular account can now consume it and add the tokens to its vault. You can do this the following way: +### Adding miden-client as a dependency -```bash -miden-client tx new consume-notes -``` +In order to utilize the `miden-client` library, you can add the dependency to your project's `Cargo.toml` file: -This will consume the input note identified by its ID, which you can get by listing them as explained in the previous step. Note that you can consume more than one note in a single transaction. Additionally, it's possible to provide just a prefix of a note's ID. For example, instead of `miden-client tx new consume-notes 0x70b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0` you can do `miden-client tx new consume-notes 0x70b7ec`. +````toml +miden-client = { version = "0.3" } +```` -You will now be able to see the asset in the account's vault by running: +#### Features -```bash -miden-client account show -v -``` +- `concurrent`: used to enable concurrent proofs generation +- `testing`: useful feature that lowers PoW difficulty when enabled. Only use this during development and not on production. -### 5. Transferring assets between accounts +### Running `miden-client`'s CLI -Some of the tokens we minted can now be transferred to our second regular account. To do so, you can run: +You can either build from source with: ```bash -miden-client sync # Make sure we have an updated view of the state -miden-client tx new p2id 50 --note-type private # Transfers 50 tokens to account ID B +cargo build --release ``` -This will generate a Pay-to-ID (`P2ID`) note containing 50 assets, transferred from one regular account to the other. You can see the new note by running `miden-client input-notes list`. If we sync, we can now make use of the note and consume it for the receiving account: - -```bash -miden-client sync # Make sure we have an updated view of the state -miden-client tx new consume-notes # Consume the note -``` +Once the binary is built, you can find it on `./target/release/miden-client`. -That's it! You will now be able to see `950` fungible tokens in the first regular account, and `50` tokens in the remaining regular account: +Or you can install the CLI from crates-io with: ```bash -miden-client account show -v # Show account B's vault assets (50 fungible tokens) -miden-client account show -v # Show account A's vault assets (950 fungible tokens) +cargo install miden-client ``` -### Clearing the state +### Makefile -All state is maintained in `store.sqlite3`, located in the same directory where the client binary is. In case it needs to be cleared, the file can be deleted; it will later be created again when any command is executed. +As mentioned before, we use [cargo-make](https://github.com/sagiegurari/cargo-make) to encapsulate some tasks, such as running lints and tests. You can check out [Makefile.toml](./Makefile.toml) for all available tasks. +## Testing -## Utilizing the library +To test the project's code, we provide both unit tests (which can be run with `cargo test`) and integration tests. For more info on integration tests, refer to the [integration testing document](./tests/README.md) -In order to utilize the `miden-client` library, you can add the dependency to your project's `Cargo.toml` file: +The crate also comes with 2 feature flags that are used exclusively on tests: -````toml -miden-client = { version = "0.2", features = ["concurrent", "testing"] } -```` +- `test_utils`: used on unit tests to use the mocked RPC API. +- `integration`: only used to run integration tests and separate them from unit tests -## Testing +## Contributing -This crate has both unit tests (which can be run with `cargo test`) and integration tests. For more info on integration tests, refer to the [integration testing document](./tests/README.md) +Interested in contributing? Check [CONTRIBUTING.md](./CONTRIBUTING.md). ## License This project is [MIT licensed](./LICENSE). diff --git a/docs/cli-config.md b/docs/cli-config.md index b649cbdba..3f02f321c 100644 --- a/docs/cli-config.md +++ b/docs/cli-config.md @@ -5,27 +5,31 @@ comments: true After [installation](install-and-run.md#install-the-client), use the client by running the following and adding the [relevant commands](cli-reference.md#commands): ```sh -miden-client +miden ``` !!! info "Help" - Run `miden-client --help` for information on `miden-client` commands. + Run `miden --help` for information on `miden` commands. -## Configuration +## Client Configuration We configure the client using a [TOML](https://en.wikipedia.org/wiki/TOML) file ([`miden-client.toml`](https://github.com/0xPolygonMiden/miden-client/blob/main/miden-client.toml)). ```sh [rpc] endpoint = { protocol = "http", host = "localhost", port = 57291 } +timeout_ms = 10000 [store] database_filepath = "store.sqlite3" + +[cli] +default_account_id = "0x012345678" ``` The TOML file should reside in same the directory from which you run the CLI. -In the configuration file, you will find a section for defining the node's `endpoint` and the store's filename `database_filepath`. +In the configuration file, you will find a section for defining the node's rpc `endpoint` and timeout and the store's filename `database_filepath`. By default, the node is set up to run on `localhost:57291`. @@ -33,6 +37,23 @@ By default, the node is set up to run on `localhost:57291`. - Running the node locally for development is encouraged. - However, the endpoint can point to any remote node. +There's an additional **optional** section used for CLI configuration. It +currently contains the default account ID, which is used to execute +transactions against it when the account flag is not provided. + +By default none is set, but you can set and unset it with: + +```sh +miden account --default #Sets default account +miden account --default none #Unsets default account +``` + +You can also see the current default account ID with: + +```sh +miden account --default +``` + ### Environment variables - `MIDEN_DEBUG`: When set to `true`, enables debug mode on the transaction executor and the script compiler. For any script that has been compiled and executed in this mode, debug logs will be output in order to facilitate MASM debugging ([these instructions](https://0xpolygonmiden.github.io/miden-vm/user_docs/assembly/debugging.html) can be used to do so). This variable can be overriden by the `--debug` CLI flag. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9326f61d3..bd96a4663 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ comments: true --- -The following document lists the commands that the CLI currently supports. +The following document lists the commands that the CLI currently supports. !!! note Use `--help` as a flag on any command for more information. @@ -12,110 +12,228 @@ The following document lists the commands that the CLI currently supports. Call a command on the `miden-client` like this: ```sh -miden-client +miden ``` Optionally, you can include the `--debug` flag to run the command with debug mode, which enables debug output logs from scripts that were compiled in this mode: ```sh -miden-client --debug +miden --debug ``` Note that the debug flag overrides the `MIDEN_DEBUG` environment variable. ## Commands +### `init` + +Creates a configuration file for the client in the current directory. + +```sh +# This will create a config using default values +miden init + +# You can use the --rpc flag to override the default rpc config +miden init --rpc testnet.miden.io +# You can specify the port +miden init --rpc testnet.miden.io:8080 +# You can also specify the protocol (http/https) +miden init --rpc https://testnet.miden.io +# You can specify both +miden init --rpc https://testnet.miden.io:1234 + +# You can use the --store_path flag to override the default store config +miden init --store_path db/store.sqlite3 + +# You can provide both flags +miden init --rpc testnet.miden.io --store_path db/store.sqlite3 +``` + ### `account` -Create accounts and inspect account details. +Inspect account details. + +#### Action Flags + +| Flags | Description | Short Flag| +|-----------------|-----------------------------------------------------|-----------| +|`--list` | List all accounts monitored by this client | `-l` | +|`--show ` | Show details of the account for the specified ID | `-s` | +|`--default ` | Manage the setting for the default account | `-d` | + +The `--show` flag also accepts a partial ID instead of the full ID. For example, instead of: + +```sh +miden account --show 0x8fd4b86a6387f8d8 +``` + +You can call: + +```sh +miden account --show 0x8fd4b86 +``` + +For the `--default` flag, if `` is "none" then the previous default account is cleared. If no `` is specified then the default account is shown. + +### `new-wallet` + +Creates a new wallet account. + +This command has two optional flags: +- `--storage-type `: Used to select the storage type of the account (off-chain if not specified). It may receive "off-chain" or "on-chain". +- `--mutable`: Makes the account code mutable (it's immutable by default). -#### Sub-commands +After creating an account with the `new-wallet` command, it is automatically stored and tracked by the client. This means the client can execute transactions that modify the state of accounts and track related changes by synchronizing with the Miden node. -| Sub-command | Description | Aliases | -|---------|-----------------------------------------------------|---------| -| `list` | List all accounts monitored by this client | -l | -| `show` | Show details of the account for the specified ID | -s | -| `new ` | Create new account and store it locally | -n | -| `import` | Import accounts from binary files | -i | +### `new-faucet` -After creating an account with the `new` command, it is automatically stored and tracked by the client. This means the client can execute transactions that modify the state of accounts and track related changes by synchronizing with the Miden node. +Creates a new faucet account. + +This command has two optional flags: +- `--storage-type `: Used to select the storage type of the account (off-chain if not specified). It may receive "off-chain" or "on-chain". +- `--non-fungible`: Makes the faucet asset non-fungible (it's fungible by default). + +After creating an account with the `new-faucet` command, it is automatically stored and tracked by the client. This means the client can execute transactions that modify the state of accounts and track related changes by synchronizing with the Miden node. ### `info` View a summary of the current client state. -### `input-notes` +### `notes` -View and manage input notes. +View and manage notes. -#### Sub-commands +#### Action Flags -| Command | Description | Aliases | -|---------|-------------------------------------------------------------|---------| -| `list` | List input notes | -l | -| `show` | Show details of the input note for the specified note ID | -s | -| `export` | Export input note data to a binary file | -e | -| `import` | Import input note data from a binary file | -i | +| Flags | Description | Short Flag | +|-------------------|-------------------------------------------------------------|------------| +|`--list []`| List input notes | `-l` | +| `--show ` | Show details of the input note for the specified note ID | `-s` | -The `show` subcommand also accepts a partial ID instead of the full ID. For example, instead of: +The `--list` flag receives an optional filter: + - pending: Only lists pending notes. + - commited: Only lists commited notes. + - consumed: Only lists consumed notes. + - consumable: Only lists consumable notes. An additional `--account-id ` flag may be added to only show notes consumable by the specified account. +If no filter is specified then all notes are listed. + +The `--show` flag also accepts a partial ID instead of the full ID. For example, instead of: ```sh -miden-client input-notes show 0x70b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0 +miden notes --show 0x70b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0 ``` You can call: ```sh -miden-client input-notes show 0x70b7ec +miden notes --show 0x70b7ec ``` ### `sync` -Sync the client with the latest state of the Miden network. +Sync the client with the latest state of the Miden network. Shows a brief summary at the end. ### `tags` View and add tags. -#### Sub-commands +#### Action Flags -| Command | Description | Aliases | -|---------|----------------------------------------------------------|---------| -| `list` | List all tags monitored by this client | -l | -| `add` | Add a new tag to the list of tags monitored by this client | -a | +| Flag | Description | Aliases | +|-----------------|-------------------------------------------------------------|---------| +| `--list` | List all tags monitored by this client | `-l` | +| `--add ` | Add a new tag to the list of tags monitored by this client | `-a` | +| `--remove `| Remove a tag from the list of tags monitored by this client | `-r` | -### `tx` or `transaction` +### `tx` -Execute and view transactions. +View transactions. -#### Sub-commands +#### Action Flags | Command | Description | Aliases | |---------|----------------------------------------------------------|---------| -| `list` | List tracked transactions | -l | -| `new ` | Execute a transaction, prove and submit it to the node. Once submitted, it gets tracked by the client. | -n | +| `--list`| List tracked transactions | -l | After a transaction gets executed, two entities start being tracked: - The transaction itself: It follows a lifecycle from `pending` (initial state) and `committed` (after the node receives it). - Output notes that might have been created as part of the transaction (for example, when executing a pay-to-id transaction). -#### Types of transaction +### Transaction creation commands +#### `mint` + +Creates a note that contains a specific amount tokens minted by a faucet, that the target Account ID can consume. + +Usage: `miden mint --target --faucet --note-type ` + +#### `consume-notes` -| Command | Explanation | -|-----------------|-------------------------------------------------------------------------------------------------------------------| -| `p2id ` | Pay-to-id transaction. Sender Account creates a note that a target Account ID can consume. The asset is identifed by the tuple `(FAUCET ID, AMOUNT)`. | -| `mint ` | Creates a note that contains a specific amount tokens minted by a faucet, that the target Account ID can consume| -| `consume-notes [NOTES]` | Account ID consumes a list of notes, specified by their Note ID | +Account ID consumes a list of notes, specified by their Note ID. -For `consume-notes` subcommand, you can also provide a partial ID instead of the full ID for each note. So instead of +Usage: `miden consume-notes --account [NOTES]` + +For this command, you can also provide a partial ID instead of the full ID for each note. So instead of ```sh -miden-client consume-notes 0x70b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0 0x80b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0 -``` +miden consume-notes --account 0x70b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0 0x80b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0 +``` -You can do: +You can do: ```sh -miden-client consume-notes 0x70b7ecb 0x80b7ecb -``` \ No newline at end of file +miden consume-notes --account 0x70b7ecb 0x80b7ecb +``` + +#### `send` + +Sends assets to another account. Sender Account creates a note that a target Account ID can consume. The asset is identifed by the tuple `(FAUCET ID, AMOUNT)`. The note can be configured to be recallable making the sender able to consume it after a height is reached. + +Usage: `miden send --sender --target --faucet --note-type ` + +#### `swap` + +The source account creates a Swap note that offers some asset in exchange for some other asset. When another account consumes that note, it'll receive the offered amount and it'll have the requested amount removed from its assets (and put into a new note which the first account can then consume). Consuming the note will fail if the account doesn't have enough of the requested asset. + +Usage: `miden swap --source --offered_faucet --offered_amount --requested_faucet --requested_amount --note-type ` + +#### Tips +For `send` and `consume-notes`, you can omit the `--sender` and `--account` flags to use the default account defined in the [config](./cli-config.md). If you omit the flag but have no default account defined in the config, you'll get an error instead. + +For every command which needs an account ID (either wallet or faucet), you can also provide a partial ID instead of the full ID for each account. So instead of + +```sh +miden send --sender 0x80519a1c5e3680fc --target 0x8fd4b86a6387f8d8 --faucet 0xa99c5c8764d4e011 100 +``` + +You can do: + +```sh +miden send --sender 0x80519 --target 0x8fd4b --faucet 0xa99c5 100 +``` + +#### Transaction confirmation + +When creating a new transaction, a summary of the transaction updates will be shown and confirmation for those updates will be prompted: + +```sh +miden tx new ... + +TX Summary: + +... + +Continue with proving and submission? Changes will be irreversible once the proof is finalized on the rollup (Y/N) +``` + +This confirmation can be skipped in non-interactive environments by providing the `--force` flag (`miden send --force ...`): + +### `import` + +Import entities managed by the client, such as accounts and notes. The type of entitie is inferred. + +When importing notes the CLI verifies that they exist on chain. The user can add an optional flag `--no-verify` that skips this verification. + +### `export` + +Export input note data to a binary file . diff --git a/docs/install-and-run.md b/docs/install-and-run.md index 2b47a67df..77f63f6f7 100644 --- a/docs/install-and-run.md +++ b/docs/install-and-run.md @@ -4,7 +4,7 @@ comments: true ## Software prerequisites -- [Rust installation](https://www.rust-lang.org/tools/install) minimum version 1.77. +- [Rust installation](https://www.rust-lang.org/tools/install) minimum version 1.78. ## Install the client @@ -16,7 +16,7 @@ Run the following command to install the miden-client: cargo install miden-client --features testing,concurrent ``` -This installs the `miden-client` binary (at `~/.cargo/bin/miden-client`) with the [`testing`](#testing-feature) and [`concurrent`](#concurrent-feature) features. +This installs the `miden` binary (at `~/.cargo/bin/miden`) with the [`testing`](#testing-feature) and [`concurrent`](#concurrent-feature) features. ### `Testing` feature @@ -32,11 +32,10 @@ The `concurrent` flag enables optimizations that result in faster transaction ex ## Run the client -1. Make sure you have already [installed the client](#install-the-client). +1. Make sure you have already [installed the client](#install-the-client). If you don't have a `miden-client.toml` file in your directory, create one or run `miden init` to initialize one at the current working directory. You can do so without any arguments to use its defaults or define either the RPC config or the store config via `--rpc` and `--store-path` 2. Run the client CLI using: ```sh - miden-client + miden ``` - \ No newline at end of file diff --git a/docs/library.md b/docs/library.md index 2c8facaae..b53c8091d 100644 --- a/docs/library.md +++ b/docs/library.md @@ -7,7 +7,7 @@ To use the Miden client library in a Rust project, include it as a dependency. In your project's `Cargo.toml`, add: ```toml -miden-client = { version = "0.2" } +miden-client = { version = "0.3" } ``` ### Features @@ -15,7 +15,7 @@ miden-client = { version = "0.2" } The Miden client library supports the [`testing`](https://github.com/0xPolygonMiden/miden-client/blob/main/docs/install-and-run.md#testing-feature) and [`concurrent`](https://github.com/0xPolygonMiden/miden-client/blob/main/docs/install-and-run.md#concurrent-feature) features which are both recommended for developing applications with the client. To use them, add the following to your project's `Cargo.toml`: ```toml -miden-client = { version = "0.2", features = ["testing", "concurrent"] } +miden-client = { version = "0.3", features = ["testing", "concurrent"] } ``` ## Client instantiation @@ -27,15 +27,19 @@ The current supported store is the `SqliteDataStore`, which is a SQLite implemen ```rust let client: Client = { - let store = Store::new((&client_config).into()).map_err(ClientError::StoreError)?; - - Client::new( - - client_config, - TonicRpcClient::new(&rpc_endpoint), - SqliteDataStore::new(store), - - )? + let store = SqliteStore::new((&client_config).into()).map_err(ClientError::StoreError)?; + let store = Rc::new(store); + + let rng = miden_client::get_random_coin(); + let authenticator = StoreAuthenticator::new_with_rng(store.clone(), rng); + + let client = Client::new( + TonicRpcClient::new(&client_config.rpc), + rng, + store, + authenticator, + false, // set to true if you want a client with debug mode + ) }; ``` @@ -87,8 +91,8 @@ let payment_transaction = PaymentTransactionData::new( target_account_id, ); -let transaction_template: TransactionTemplate = TransactionTemplate::P2ID(payment_transaction); -let transaction_request = client.build_transaction_request(transaction_template).unwrap(); +let transaction_template: TransactionTemplate = TransactionTemplate::PayToId(payment_transaction, NoteType::OffChain); +let transaction_request = client.build_transaction_request(transaction_template)?; // Execute transaction. No information is tracked after this. let transaction_execution_result = client.new_transaction(transaction_request.clone())?; @@ -97,4 +101,5 @@ let transaction_execution_result = client.new_transaction(transaction_request.cl client.send_transaction(transaction_execution_result).await? ``` -You may also execute a transaction by manually defining a `TransactionRequest` instance. This allows you to run custom code, with custom note arguments as well. +You can decide whether you want the note details to be public or private through the second parameter of the `TransactionTemplate` enum. +You may also execute a transaction by manually defining a `TransactionRequest` instance. This allows you to run custom code, with custom note arguments as well. diff --git a/miden-client.toml b/miden-client.toml index 973d12330..55144b93c 100644 --- a/miden-client.toml +++ b/miden-client.toml @@ -6,6 +6,7 @@ # - database_filepath: path for the sqlite's database [rpc] endpoint = { protocol = "http", host = "localhost", port = 57291 } +timeout = 10000 [store] database_filepath = "store.sqlite3" diff --git a/rust-toolchain b/rust-toolchain index 3245dca3d..8e95c75da 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1 +1 @@ -1.77 +1.78 diff --git a/src/cli/account.rs b/src/cli/account.rs index 53ccee648..c83703692 100644 --- a/src/cli/account.rs +++ b/src/cli/account.rs @@ -1,164 +1,103 @@ -use std::{fs, path::PathBuf}; +use std::path::PathBuf; -use clap::{Parser, ValueEnum}; +use clap::Parser; use comfy_table::{presets, Attribute, Cell, ContentArrangement, Table}; use miden_client::{ - client::{accounts, rpc::NodeRpcClient, Client}, + client::{rpc::NodeRpcClient, Client}, + config::{CliConfig, ClientConfig}, store::Store, }; use miden_objects::{ - accounts::{AccountData, AccountId, AccountStorage, AccountType, StorageSlotType}, - assets::{Asset, TokenSymbol}, - crypto::{dsa::rpo_falcon512::SecretKey, rand::FeltRng}, + accounts::{AccountId, AccountStorage, AccountType, AuthSecretKey, StorageSlotType}, + assets::Asset, + crypto::{dsa::rpo_falcon512::SK_LEN, rand::FeltRng}, ZERO, }; -use miden_tx::utils::{bytes_to_hex_string, Deserializable, Serializable}; -use tracing::info; +use miden_tx::{ + utils::{bytes_to_hex_string, Serializable}, + TransactionAuthenticator, +}; +use super::{load_config, parse_account_id, update_config, CLIENT_CONFIG_FILE_NAME}; use crate::cli::create_dynamic_table; // ACCOUNT COMMAND // ================================================================================================ -#[derive(Debug, Clone, Parser)] -#[clap(about = "Create accounts and inspect account details")] -pub enum AccountCmd { - /// List all accounts monitored by this client - #[clap(short_flag = 'l')] - List, - - /// Show details of the account for the specified ID - #[clap(short_flag = 's')] - Show { - // TODO: We should create a value parser for catching input parsing errors earlier (ie AccountID) once complexity grows - #[clap()] - id: String, - #[clap(short, long, default_value_t = false)] - keys: bool, - #[clap(short, long, default_value_t = false)] - vault: bool, - #[clap(short, long, default_value_t = false)] - storage: bool, - #[clap(short, long, default_value_t = false)] - code: bool, - }, - /// Create new account and store it locally - #[clap(short_flag = 'n')] - New { - #[clap(subcommand)] - template: AccountTemplate, - }, - /// Import accounts from binary files (with .mac extension) - #[clap(short_flag = 'i')] - Import { - /// Paths to the files that contains the account data - #[arg()] - filenames: Vec, - }, -} - -#[derive(Debug, Parser, Clone)] -#[clap()] -pub enum AccountTemplate { - /// Creates a basic account (Regular account with immutable code) - BasicImmutable { - #[clap(short, long, value_enum, default_value_t = AccountStorageMode::OffChain)] - storage_type: AccountStorageMode, - }, - /// Creates a basic account (Regular account with mutable code) - BasicMutable { - #[clap(short, long, value_enum, default_value_t = AccountStorageMode::OffChain)] - storage_type: AccountStorageMode, - }, - /// Creates a faucet for fungible tokens - FungibleFaucet { - #[clap(short, long)] - token_symbol: String, - #[clap(short, long)] - decimals: u8, - #[clap(short, long)] - max_supply: u64, - #[clap(short, long, value_enum, default_value_t = AccountStorageMode::OffChain)] - storage_type: AccountStorageMode, - }, - /// Creates a faucet for non-fungible tokens - NonFungibleFaucet { - #[clap(short, long, value_enum, default_value_t = AccountStorageMode::OffChain)] - storage_type: AccountStorageMode, - }, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum AccountStorageMode { - OffChain, - OnChain, -} - -impl From for accounts::AccountStorageMode { - fn from(value: AccountStorageMode) -> Self { - match value { - AccountStorageMode::OffChain => accounts::AccountStorageMode::Local, - AccountStorageMode::OnChain => accounts::AccountStorageMode::OnChain, - } - } -} - -impl From<&AccountStorageMode> for accounts::AccountStorageMode { - fn from(value: &AccountStorageMode) -> Self { - accounts::AccountStorageMode::from(*value) - } +#[derive(Default, Debug, Clone, Parser)] +/// View and manage accounts. Defaults to `list` command. +pub struct AccountCmd { + /// List all accounts monitored by this client (default action) + #[clap(short, long, group = "action")] + list: bool, + /// Show details of the account for the specified ID or hex prefix + #[clap(short, long, group = "action", value_name = "ID")] + show: Option, + /// Manages default account for transaction execution + /// + /// If no ID is provided it will display the current default account ID. + /// If "none" is provided it will remove the default account else + /// it will set the default account to the provided ID + #[clap(short, long, group = "action", value_name = "ID")] + default: Option>, } impl AccountCmd { - pub fn execute( + pub fn execute( &self, - mut client: Client, + client: Client, ) -> Result<(), String> { match self { - AccountCmd::List => { - list_accounts(client)?; + AccountCmd { + list: false, + show: Some(id), + default: None, + } => { + let account_id = parse_account_id(&client, id)?; + show_account(client, account_id)?; }, - AccountCmd::New { template } => { - let client_template = match template { - AccountTemplate::BasicImmutable { storage_type: storage_mode } => { - accounts::AccountTemplate::BasicWallet { - mutable_code: false, - storage_mode: storage_mode.into(), - } + AccountCmd { + list: false, + show: None, + default: Some(id), + } => { + match id { + None => { + display_default_account_id()?; }, - AccountTemplate::BasicMutable { storage_type: storage_mode } => { - accounts::AccountTemplate::BasicWallet { - mutable_code: true, - storage_mode: storage_mode.into(), + Some(id) => { + let default_account = if id == "none" { + None + } else { + let account_id: AccountId = AccountId::from_hex(id) + .map_err(|_| "Input number was not a valid Account Id")?; + + // Check whether we're tracking that account + let (account, _) = client.get_account_stub_by_id(account_id)?; + + Some(account.id().to_hex()) + }; + + // load config + let (mut current_config, config_path) = load_config_file()?; + + // set default account + current_config.cli = Some(CliConfig { + default_account_id: default_account.clone(), + }); + + if let Some(id) = default_account { + println!("Setting default account to {id}..."); + } else { + println!("Removing default account..."); } + + update_config(&config_path, current_config)?; }, - AccountTemplate::FungibleFaucet { - token_symbol, - decimals, - max_supply, - storage_type: storage_mode, - } => accounts::AccountTemplate::FungibleFaucet { - token_symbol: TokenSymbol::new(token_symbol) - .map_err(|err| format!("error: token symbol is invalid: {}", err))?, - decimals: *decimals, - max_supply: *max_supply, - storage_mode: storage_mode.into(), - }, - AccountTemplate::NonFungibleFaucet { storage_type: _ } => todo!(), - }; - let (_new_account, _account_seed) = client.new_account(client_template)?; - }, - AccountCmd::Show { id, keys, vault, storage, code } => { - let account_id: AccountId = AccountId::from_hex(id) - .map_err(|_| "Input number was not a valid Account Id")?; - show_account(client, account_id, *keys, *vault, *storage, *code)?; - }, - AccountCmd::Import { filenames } => { - validate_paths(filenames, "mac")?; - for filename in filenames { - import_account(&mut client, filename)?; } - println!("Imported {} accounts.", filenames.len()); + }, + _ => { + list_accounts(client)?; }, } Ok(()) @@ -168,10 +107,10 @@ impl AccountCmd { // LIST ACCOUNTS // ================================================================================================ -fn list_accounts( - client: Client, +fn list_accounts( + client: Client, ) -> Result<(), String> { - let accounts = client.get_accounts()?; + let accounts = client.get_account_stubs()?; let mut table = create_dynamic_table(&[ "Account ID", @@ -198,15 +137,11 @@ fn list_accounts( Ok(()) } -pub fn show_account( - client: Client, +pub fn show_account( + client: Client, account_id: AccountId, - show_keys: bool, - show_vault: bool, - show_storage: bool, - show_code: bool, ) -> Result<(), String> { - let (account, _account_seed) = client.get_account(account_id)?; + let (account, _) = client.get_account(account_id)?; let mut table = create_dynamic_table(&[ "Account ID", "Account Hash", @@ -229,7 +164,8 @@ pub fn show_account( ]); println!("{table}\n"); - if show_vault { + // Vault Table + { let assets = account.vault().assets(); println!("Assets: "); @@ -250,7 +186,8 @@ pub fn show_account( println!("{table}\n"); } - if show_storage { + // Storage Table + { let account_storage = account.storage(); println!("Storage: \n"); @@ -286,13 +223,13 @@ pub fn show_account( println!("{table}\n"); } - if show_keys { + // Keys table + { let auth_info = client.get_account_auth(account_id)?; match auth_info { - miden_client::store::AuthInfo::RpoFalcon512(key_pair) => { - const KEY_PAIR_SIZE: usize = std::mem::size_of::(); - let auth_info: [u8; KEY_PAIR_SIZE] = key_pair + AuthSecretKey::RpoFalcon512(key_pair) => { + let auth_info: [u8; SK_LEN] = key_pair .to_bytes() .try_into() .expect("Array size is const and should always exactly fit SecretKey"); @@ -309,19 +246,22 @@ pub fn show_account( }; } - if show_code { + // Code related table + { let module = account.code().module(); let procedure_digests = account.code().procedures(); println!("Account Code Info:"); let mut table = create_dynamic_table(&["Procedure Digests"]); + for digest in procedure_digests { table.add_row(vec![digest.to_hex()]); } println!("{table}\n"); let mut code_table = create_dynamic_table(&["Code"]); + code_table.load_preset(presets::UTF8_HORIZONTAL_ONLY); code_table.add_row(vec![&module]); println!("{code_table}\n"); } @@ -329,49 +269,9 @@ pub fn show_account( Ok(()) } -// IMPORT ACCOUNT -// ================================================================================================ - -fn import_account( - client: &mut Client, - filename: &PathBuf, -) -> Result<(), String> { - info!( - "Attempting to import account data from {}...", - fs::canonicalize(filename).map_err(|err| err.to_string())?.as_path().display() - ); - let account_data_file_contents = fs::read(filename).map_err(|err| err.to_string())?; - let account_data = - AccountData::read_from_bytes(&account_data_file_contents).map_err(|err| err.to_string())?; - let account_id = account_data.account.id(); - - client.import_account(account_data)?; - println!("Imported account with ID: {}", account_id); - - Ok(()) -} - // HELPERS // ================================================================================================ -/// Checks that all files exist, otherwise returns an error. It also ensures that all files have a -/// specific extension -fn validate_paths(paths: &[PathBuf], expected_extension: &str) -> Result<(), String> { - let invalid_path = paths.iter().find(|path| { - !path.exists() || path.extension().map_or(false, |ext| ext != expected_extension) - }); - - if let Some(path) = invalid_path { - Err(format!( - "The path `{}` does not exist or does not have the appropiate extension", - path.to_string_lossy() - ) - .to_string()) - } else { - Ok(()) - } -} - fn account_type_display_name(account_type: &AccountType) -> String { match account_type { AccountType::FungibleFaucet => "Fungible faucet", @@ -389,3 +289,28 @@ fn storage_type_display_name(account: &AccountId) -> String { } .to_string() } + +/// Loads config file and displays current default account ID +fn display_default_account_id() -> Result<(), String> { + let (miden_client_config, _) = load_config_file()?; + let cli_config = miden_client_config + .cli + .ok_or("No CLI options found in the client config file".to_string())?; + + let default_account = cli_config.default_account_id.ok_or( + "No default account found in the CLI options from the client config file.".to_string(), + )?; + println!("Current default account ID: {default_account}"); + Ok(()) +} + +/// Loads config file from current directory and default filename and returns it alongside its path +fn load_config_file() -> Result<(ClientConfig, PathBuf), String> { + let mut current_dir = std::env::current_dir().map_err(|err| err.to_string())?; + current_dir.push(CLIENT_CONFIG_FILE_NAME); + let config_path = current_dir.as_path(); + + let client_config = load_config(config_path)?; + + Ok((client_config, config_path.into())) +} diff --git a/src/cli/export.rs b/src/cli/export.rs new file mode 100644 index 000000000..ef51d7472 --- /dev/null +++ b/src/cli/export.rs @@ -0,0 +1,66 @@ +use std::{fs::File, io::Write, path::PathBuf}; + +use miden_client::{ + client::{rpc::NodeRpcClient, Client}, + store::{InputNoteRecord, Store}, +}; +use miden_objects::{crypto::rand::FeltRng, Digest}; +use miden_tx::{utils::Serializable, TransactionAuthenticator}; + +use super::Parser; + +#[derive(Debug, Parser, Clone)] +#[clap(about = "Export client notes")] +pub struct ExportCmd { + /// ID of the output note to export + #[clap()] + id: String, + + /// Desired filename for the binary file. Defaults to the note ID if not provided + #[clap(short, long, default_value = "false")] + filename: Option, +} + +impl ExportCmd { + pub fn execute( + &self, + client: Client, + ) -> Result<(), String> { + export_note(&client, self.id.as_str(), self.filename.clone())?; + println!("Succesfully exported note {}", self.id.as_str()); + Ok(()) + } +} + +// EXPORT NOTE +// ================================================================================================ +pub fn export_note( + client: &Client, + note_id: &str, + filename: Option, +) -> Result { + let note_id = Digest::try_from(note_id) + .map_err(|err| format!("Failed to parse input note id: {}", err))? + .into(); + let output_note = client + .get_output_notes(miden_client::store::NoteFilter::Unique(note_id))? + .pop() + .expect("should have an output note"); + + // Convert output note into InputNoteRecord before exporting + let input_note: InputNoteRecord = output_note + .try_into() + .map_err(|_err| format!("Can't export note with ID {}", note_id.to_hex()))?; + + let file_path = filename.unwrap_or_else(|| { + let mut dir = PathBuf::new(); + dir.push(note_id.inner().to_string()); + dir + }); + + let mut file = File::create(file_path).map_err(|err| err.to_string())?; + + file.write_all(&input_note.to_bytes()).map_err(|err| err.to_string())?; + + Ok(file) +} diff --git a/src/cli/import.rs b/src/cli/import.rs new file mode 100644 index 000000000..799e96e03 --- /dev/null +++ b/src/cli/import.rs @@ -0,0 +1,266 @@ +use std::{ + fs::{self, File}, + io::Read, + path::PathBuf, +}; + +use miden_client::{ + client::{rpc::NodeRpcClient, Client}, + store::{InputNoteRecord, Store}, +}; +use miden_objects::{ + accounts::{AccountData, AccountId}, + crypto::rand::FeltRng, + notes::NoteId, + utils::Deserializable, +}; +use miden_tx::TransactionAuthenticator; +use tracing::info; + +use super::Parser; + +#[derive(Debug, Parser, Clone)] +#[clap(about = "Import client objects such as accounts and notes")] +pub struct ImportCmd { + /// Paths to the files that contains the account/note data + #[arg()] + filenames: Vec, + /// Skip verification of note's existence in the chain (Only when importing notes) + #[clap(short, long, default_value = "false")] + no_verify: bool, +} + +impl ImportCmd { + pub async fn execute( + &self, + mut client: Client, + ) -> Result<(), String> { + validate_paths(&self.filenames)?; + for filename in &self.filenames { + let note_id = import_note(&mut client, filename.clone(), !self.no_verify).await; + if note_id.is_ok() { + println!("Succesfully imported note {}", note_id.unwrap().inner()); + continue; + } + let account_id = import_account(&mut client, filename) + .map_err(|_| format!("Failed to parse file {}", filename.to_string_lossy()))?; + println!("Succesfully imported account {}", account_id); + } + Ok(()) + } +} + +// IMPORT ACCOUNT +// ================================================================================================ + +fn import_account( + client: &mut Client, + filename: &PathBuf, +) -> Result { + info!( + "Attempting to import account data from {}...", + fs::canonicalize(filename).map_err(|err| err.to_string())?.as_path().display() + ); + let account_data_file_contents = fs::read(filename).map_err(|err| err.to_string())?; + let account_data = + AccountData::read_from_bytes(&account_data_file_contents).map_err(|err| err.to_string())?; + let account_id = account_data.account.id(); + + client.import_account(account_data)?; + + Ok(account_id) +} + +// IMPORT NOTE +// ================================================================================================ + +pub async fn import_note( + client: &mut Client, + filename: PathBuf, + verify: bool, +) -> Result { + let mut contents = vec![]; + let mut _file = File::open(filename) + .and_then(|mut f| f.read_to_end(&mut contents)) + .map_err(|err| err.to_string()); + + let input_note_record = + InputNoteRecord::read_from_bytes(&contents).map_err(|err| err.to_string())?; + + let note_id = input_note_record.id(); + client + .import_input_note(input_note_record, verify) + .await + .map_err(|err| err.to_string())?; + + Ok(note_id) +} + +// HELPERS +// ================================================================================================ + +/// Checks that all files exist, otherwise returns an error. It also ensures that all files have a +/// specific extension +fn validate_paths(paths: &[PathBuf]) -> Result<(), String> { + let invalid_path = paths.iter().find(|path| !path.exists()); + + if let Some(path) = invalid_path { + Err(format!("The path `{}` does not exist", path.to_string_lossy()).to_string()) + } else { + Ok(()) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use std::env::temp_dir; + + use miden_client::{ + client::transactions::transaction_request::TransactionTemplate, + errors::IdPrefixFetchError, + mock::{ + create_test_client, mock_full_chain_mmr_and_notes, mock_fungible_faucet_account, + mock_notes, + }, + store::{InputNoteRecord, NoteFilter}, + }; + use miden_lib::transaction::TransactionKernel; + use miden_objects::{ + accounts::{ + account_id::testing::ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN, AccountId, AuthSecretKey, + }, + assets::FungibleAsset, + crypto::dsa::rpo_falcon512::SecretKey, + notes::Note, + }; + + use super::import_note; + use crate::cli::{export::export_note, get_input_note_with_id_prefix}; + + #[tokio::test] + async fn import_export_recorded_note() { + // This test will run a mint transaction that creates an output note and we'll try + // exporting that note and then importing it. So the client's state should be: + // + // 1. No notes at all + // 2. One output note + // 3. One output note, one input note. Both representing the same note. + + // generate test client + let mut client = create_test_client(); + + // Add a faucet account to run a mint tx against it + const FAUCET_ID: u64 = ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN; + const INITIAL_BALANCE: u64 = 1000; + let key_pair = SecretKey::new(); + + let faucet = mock_fungible_faucet_account( + AccountId::try_from(FAUCET_ID).unwrap(), + INITIAL_BALANCE, + key_pair.clone(), + ); + + client.sync_state().await.unwrap(); + client + .insert_account(&faucet, None, &AuthSecretKey::RpoFalcon512(key_pair)) + .unwrap(); + + // Ensure client has no notes + assert!(client.get_input_notes(NoteFilter::All).unwrap().is_empty()); + assert!(client.get_output_notes(NoteFilter::All).unwrap().is_empty()); + + // mint asset to create an output note + // using a random account id will mean that the note won't be included in the input notes + // table. + let transaction_template = TransactionTemplate::MintFungibleAsset( + FungibleAsset::new(faucet.id(), 5u64).unwrap(), + AccountId::from_hex("0x168187d729b31a84").unwrap(), + miden_objects::notes::NoteType::OffChain, + ); + + let transaction_request = client.build_transaction_request(transaction_template).unwrap(); + let transaction = client.new_transaction(transaction_request).unwrap(); + let created_note = transaction.created_notes().get_note(0).clone(); + client.submit_transaction(transaction).await.unwrap(); + + // Ensure client has no input notes and one output note + assert!(client.get_input_notes(NoteFilter::All).unwrap().is_empty()); + assert!(!client.get_output_notes(NoteFilter::All).unwrap().is_empty()); + let exported_note = client + .get_output_notes(NoteFilter::Unique(created_note.id())) + .unwrap() + .pop() + .unwrap(); + + // export the note with the CLI function + let mut filename_path = temp_dir(); + filename_path.push("test_import"); + export_note(&client, &exported_note.id().to_hex(), Some(filename_path.clone())).unwrap(); + + // Try importing the same note with the CLI function + let imported_note_id = import_note(&mut client, filename_path, false).await.unwrap(); + + // Ensure client has one input note and one output note + assert_eq!(client.get_input_notes(NoteFilter::All).unwrap().len(), 1); + assert_eq!(client.get_output_notes(NoteFilter::All).unwrap().len(), 1); + + let imported_note = client + .get_input_notes(NoteFilter::Unique(imported_note_id)) + .unwrap() + .pop() + .unwrap(); + + let exported_note: InputNoteRecord = exported_note.try_into().unwrap(); + let exported_note: Note = exported_note.try_into().unwrap(); + let imported_note: Note = imported_note.try_into().unwrap(); + + assert_eq!(exported_note, imported_note); + } + + #[tokio::test] + async fn get_input_note_with_prefix() { + // generate test client + let mut client = create_test_client(); + + // Ensure we get an error if no note is found + let non_existent_note_id = "0x123456"; + assert_eq!( + get_input_note_with_id_prefix(&client, non_existent_note_id), + Err(IdPrefixFetchError::NoMatch( + format!("note ID prefix {non_existent_note_id}").to_string() + )) + ); + + // generate test data + let assembler = TransactionKernel::assembler(); + let (consumed_notes, created_notes) = mock_notes(&assembler); + let (_, notes, ..) = mock_full_chain_mmr_and_notes(consumed_notes); + + let committed_note: InputNoteRecord = notes.first().unwrap().clone().into(); + let pending_note = InputNoteRecord::from(created_notes.first().unwrap().clone()); + + client.import_input_note(committed_note.clone(), false).await.unwrap(); + client.import_input_note(pending_note.clone(), false).await.unwrap(); + assert!(pending_note.inclusion_proof().is_none()); + assert!(committed_note.inclusion_proof().is_some()); + + // Check that we can fetch Both notes + let note = get_input_note_with_id_prefix(&client, &committed_note.id().to_hex()).unwrap(); + assert_eq!(note.id(), committed_note.id()); + + let note = get_input_note_with_id_prefix(&client, &pending_note.id().to_hex()).unwrap(); + assert_eq!(note.id(), pending_note.id()); + + // Check that we get an error if many match + let note_id_with_many_matches = "0x"; + assert_eq!( + get_input_note_with_id_prefix(&client, note_id_with_many_matches), + Err(IdPrefixFetchError::MultipleMatches( + format!("note ID prefix {note_id_with_many_matches}").to_string() + )) + ); + } +} diff --git a/src/cli/info.rs b/src/cli/info.rs index 44018cbdc..d6c951074 100644 --- a/src/cli/info.rs +++ b/src/cli/info.rs @@ -1,20 +1,52 @@ +use std::fs; + use miden_client::{ client::{rpc::NodeRpcClient, Client}, - store::Store, + config::ClientConfig, + store::{NoteFilter, Store}, }; use miden_objects::crypto::rand::FeltRng; +use miden_tx::TransactionAuthenticator; -pub fn print_client_info( - client: &Client, +pub fn print_client_info( + client: &Client, + config: &ClientConfig, ) -> Result<(), String> { - print_block_number(client) + println!("Client version: {}", env!("CARGO_PKG_VERSION")); + print_config_stats(config)?; + print_client_stats(client) } // HELPERS // ================================================================================================ -fn print_block_number( - client: &Client, +fn print_client_stats( + client: &Client, ) -> Result<(), String> { - println!("block number: {}", client.get_sync_height().map_err(|e| e.to_string())?); + println!("Block number: {}", client.get_sync_height().map_err(|e| e.to_string())?); + println!( + "Tracked accounts: {}", + client.get_account_stubs().map_err(|e| e.to_string())?.len() + ); + println!( + "Pending notes: {}", + client.get_input_notes(NoteFilter::Pending).map_err(|e| e.to_string())?.len() + ); + Ok(()) +} + +fn print_config_stats(config: &ClientConfig) -> Result<(), String> { + println!("Node address: {}", config.rpc.endpoint.host()); + let store_len = fs::metadata(config.store.database_filepath.clone()) + .map_err(|e| e.to_string())? + .len(); + println!("Store size: {} kB", store_len / 1024); + println!( + "Default account: {}", + config + .cli + .as_ref() + .and_then(|cli| cli.default_account_id.as_ref()) + .unwrap_or(&"-".to_string()) + ); Ok(()) } diff --git a/src/cli/init.rs b/src/cli/init.rs index 46c5fd3e8..035c7f775 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,73 +1,52 @@ -use std::{ - fs::File, - io::{self, Write}, - path::PathBuf, -}; +use std::{fs::File, io::Write, path::PathBuf}; +use clap::Parser; use miden_client::config::{ClientConfig, Endpoint}; -pub(crate) fn initialize_client(config_file_path: PathBuf) -> Result<(), String> { - let mut client_config = ClientConfig::default(); - - initialize_rpc_config(&mut client_config)?; - initialize_store_config(&mut client_config)?; - - let config_as_toml_string = toml::to_string_pretty(&client_config) - .map_err(|err| format!("error formatting config: {err}"))?; - - println!("Creating config file at: {:?}", config_file_path); - let mut file_handle = File::options() - .write(true) - .create_new(true) - .open(config_file_path) - .map_err(|err| format!("error opening the file: {err}"))?; - file_handle - .write(config_as_toml_string.as_bytes()) - .map_err(|err| format!("error writing to file: {err}"))?; - - Ok(()) +// Init COMMAND +// ================================================================================================ + +#[derive(Debug, Clone, Parser)] +#[clap(about = "Initialize the client")] +pub struct InitCmd { + /// Rpc config in the form of "{protocol}://{hostname}:{port}", being the protocol and port + /// optional. If not provided user will be + /// asked for input + #[clap(long)] + rpc: Option, + + /// Store file path + #[clap(long)] + store_path: Option, } -fn initialize_rpc_config(client_config: &mut ClientConfig) -> Result<(), String> { - println!("Protocol (default: http):"); - let mut protocol: String = String::new(); - io::stdin().read_line(&mut protocol).expect("Should read line"); - protocol = protocol.trim().to_string(); - if protocol.is_empty() { - protocol = client_config.rpc.endpoint.protocol().to_string(); - } +impl InitCmd { + pub fn execute(&self, config_file_path: PathBuf) -> Result<(), String> { + let mut client_config = ClientConfig::default(); + if let Some(endpoint) = &self.rpc { + let endpoint = Endpoint::try_from(endpoint.as_str()).map_err(|err| err.to_string())?; - println!("Host (default: localhost):"); - let mut host: String = String::new(); - io::stdin().read_line(&mut host).expect("Should read line"); - host = host.trim().to_string(); - if host.is_empty() { - host = client_config.rpc.endpoint.host().to_string(); - } + client_config.rpc.endpoint = endpoint; + } - println!("Node RPC Port (default: 57291):"); - let mut port_str: String = String::new(); - io::stdin().read_line(&mut port_str).expect("Should read line"); - port_str = port_str.trim().to_string(); - let port: u16 = if !port_str.is_empty() { - port_str.parse().map_err(|err| format!("Error parsing port: {err}"))? - } else { - client_config.rpc.endpoint.port() - }; + if let Some(path) = &self.store_path { + client_config.store.database_filepath = path.to_string(); + } - client_config.rpc.endpoint = Endpoint::new(protocol, host, port); + let config_as_toml_string = toml::to_string_pretty(&client_config) + .map_err(|err| format!("error formatting config: {err}"))?; - Ok(()) -} + let mut file_handle = File::options() + .write(true) + .create_new(true) + .open(&config_file_path) + .map_err(|err| format!("error opening the file: {err}"))?; + file_handle + .write(config_as_toml_string.as_bytes()) + .map_err(|err| format!("error writing to file: {err}"))?; -fn initialize_store_config(client_config: &mut ClientConfig) -> Result<(), String> { - println!("Sqlite file path (default: ./store.sqlite3):"); - let mut database_filepath: String = String::new(); - io::stdin().read_line(&mut database_filepath).expect("Should read line"); - database_filepath = database_filepath.trim().to_string(); - if !database_filepath.is_empty() { - client_config.store.database_filepath = database_filepath; - } + println!("Config file successfully created at: {:?}", config_file_path); - Ok(()) + Ok(()) + } } diff --git a/src/cli/input_notes.rs b/src/cli/input_notes.rs deleted file mode 100644 index 0e7d92a86..000000000 --- a/src/cli/input_notes.rs +++ /dev/null @@ -1,446 +0,0 @@ -use std::{ - fs::File, - io::{Read, Write}, - path::PathBuf, -}; - -use clap::ValueEnum; -use comfy_table::{presets, Attribute, Cell, ContentArrangement, Table}; -use miden_client::{ - client::rpc::NodeRpcClient, - errors::ClientError, - store::{InputNoteRecord, NoteFilter as ClientNoteFilter, Store}, -}; -use miden_objects::{ - crypto::rand::FeltRng, - notes::{NoteId, NoteInputs}, - Digest, -}; -use miden_tx::utils::{Deserializable, Serializable}; - -use super::{Client, Parser}; -use crate::cli::{create_dynamic_table, get_note_with_id_prefix}; - -#[derive(Clone, Debug, ValueEnum)] -pub enum NoteFilter { - Pending, - Committed, - Consumed, -} - -#[derive(Debug, Parser, Clone)] -#[clap(about = "View and manage input notes")] -pub enum InputNotes { - /// List input notes - #[clap(short_flag = 'l')] - List { - /// Filter the displayed note list - #[clap(short, long)] - filter: Option, - }, - - /// Show details of the input note for the specified note ID - #[clap(short_flag = 's')] - Show { - /// Note ID of the input note to show - #[clap()] - id: String, - - /// Show note script - #[clap(short, long, default_value = "false")] - script: bool, - - /// Show note vault - #[clap(short, long, default_value = "false")] - vault: bool, - - /// Show note inputs - #[clap(short, long, default_value = "false")] - inputs: bool, - }, - - /// Export input note data to a binary file - #[clap(short_flag = 'e')] - Export { - /// Note ID of the input note to show - #[clap()] - id: String, - - /// Path to the file that will contain the input note data. If not provided, the filename will be the input note ID - #[clap()] - filename: Option, - }, - - /// Import input note data from a binary file - #[clap(short_flag = 'i')] - Import { - /// Path to the file that contains the input note data - #[clap()] - filename: PathBuf, - }, -} - -impl InputNotes { - pub fn execute( - &self, - mut client: Client, - ) -> Result<(), String> { - match self { - InputNotes::List { filter } => { - let filter = match filter { - Some(NoteFilter::Committed) => ClientNoteFilter::Committed, - Some(NoteFilter::Consumed) => ClientNoteFilter::Consumed, - Some(NoteFilter::Pending) => ClientNoteFilter::Pending, - None => ClientNoteFilter::All, - }; - - list_input_notes(client, filter)?; - }, - InputNotes::Show { id, script, vault, inputs } => { - show_input_note(client, id.to_owned(), *script, *vault, *inputs)?; - }, - InputNotes::Export { id, filename } => { - export_note(&client, id, filename.clone())?; - println!("Succesfully exported note {}", id); - }, - InputNotes::Import { filename } => { - let note_id = import_note(&mut client, filename.clone())?; - println!("Succesfully imported note {}", note_id.inner()); - }, - } - Ok(()) - } -} - -// LIST INPUT NOTES -// ================================================================================================ -fn list_input_notes( - client: Client, - filter: ClientNoteFilter, -) -> Result<(), String> { - let notes = client.get_input_notes(filter)?; - print_notes_summary(¬es)?; - Ok(()) -} - -// EXPORT INPUT NOTE -// ================================================================================================ -pub fn export_note( - client: &Client, - note_id: &str, - filename: Option, -) -> Result { - let note_id = Digest::try_from(note_id) - .map_err(|err| format!("Failed to parse input note id: {}", err))? - .into(); - let note = client.get_input_note(note_id)?; - - let file_path = filename.unwrap_or_else(|| { - let mut dir = PathBuf::new(); - dir.push(note_id.inner().to_string()); - dir - }); - - let mut file = File::create(file_path).map_err(|err| err.to_string())?; - - file.write_all(¬e.to_bytes()).map_err(|err| err.to_string())?; - - Ok(file) -} - -// IMPORT INPUT NOTE -// ================================================================================================ -pub fn import_note( - client: &mut Client, - filename: PathBuf, -) -> Result { - let mut contents = vec![]; - let mut _file = File::open(filename) - .and_then(|mut f| f.read_to_end(&mut contents)) - .map_err(|err| err.to_string()); - - // TODO: When importing a RecordedNote we want to make sure that the note actually exists in the chain (RPC call) - // and start monitoring its nullifiers (ie, update the list of relevant tags in the state sync table) - let input_note_record = - InputNoteRecord::read_from_bytes(&contents).map_err(|err| err.to_string())?; - - let note_id = input_note_record.id(); - client.import_input_note(input_note_record)?; - - Ok(note_id) -} - -// SHOW INPUT NOTE -// ================================================================================================ -fn show_input_note( - client: Client, - note_id: String, - show_script: bool, - show_vault: bool, - show_inputs: bool, -) -> Result<(), String> { - let input_note_record = - get_note_with_id_prefix(&client, ¬e_id).map_err(|err| err.to_string())?; - - // print note summary - print_notes_summary(core::iter::once(&input_note_record))?; - - let mut table = Table::new(); - table - .load_preset(presets::UTF8_HORIZONTAL_ONLY) - .set_content_arrangement(ContentArrangement::DynamicFullWidth); - - // print note script - if show_script { - let script = input_note_record.details().script(); - - table - .add_row(vec![ - Cell::new("Note Script hash").add_attribute(Attribute::Bold), - Cell::new(script.hash()), - ]) - .add_row(vec![ - Cell::new("Note Script code").add_attribute(Attribute::Bold), - Cell::new(script.code()), - ]); - }; - - // print note vault - if show_vault { - table - .add_row(vec![ - Cell::new("Note Vault hash").add_attribute(Attribute::Bold), - Cell::new(input_note_record.assets().commitment()), - ]) - .add_row(vec![Cell::new("Note Vault").add_attribute(Attribute::Bold)]); - - input_note_record.assets().iter().for_each(|asset| { - table.add_row(vec![Cell::new(format!("{:?}", asset))]); - }) - }; - - if show_inputs { - let inputs = NoteInputs::new(input_note_record.details().inputs().clone()) - .map_err(ClientError::NoteError)?; - - table - .add_row(vec![ - Cell::new("Note Inputs hash").add_attribute(Attribute::Bold), - Cell::new(inputs.commitment()), - ]) - .add_row(vec![Cell::new("Note Inputs").add_attribute(Attribute::Bold)]); - - inputs.values().iter().enumerate().for_each(|(idx, input)| { - table.add_row(vec![Cell::new(idx).add_attribute(Attribute::Bold), Cell::new(input)]); - }); - }; - - println!("{table}"); - Ok(()) -} - -// HELPERS -// ================================================================================================ -fn print_notes_summary<'a, I>(notes: I) -> Result<(), String> -where - I: IntoIterator, -{ - let mut table = create_dynamic_table(&[ - "Note ID", - "Script Hash", - "Vault Vash", - "Inputs Hash", - "Serial Num", - "Commit Height", - ]); - - for input_note_record in notes { - let commit_height = input_note_record - .inclusion_proof() - .map(|proof| proof.origin().block_num.to_string()) - .unwrap_or("-".to_string()); - - let script = input_note_record.details().script(); - - let inputs = NoteInputs::new(input_note_record.details().inputs().clone()) - .map_err(ClientError::NoteError)?; - - table.add_row(vec![ - input_note_record.id().inner().to_string(), - script.hash().to_string(), - input_note_record.assets().commitment().to_string(), - inputs.commitment().to_string(), - Digest::new(input_note_record.details().serial_num()).to_string(), - commit_height, - ]); - } - - println!("{table}"); - - Ok(()) -} - -// TESTS -// ================================================================================================ - -#[cfg(test)] -mod tests { - use std::env::temp_dir; - - use miden_client::{ - client::get_random_coin, - config::{ClientConfig, Endpoint}, - errors::NoteIdPrefixFetchError, - mock::{mock_full_chain_mmr_and_notes, mock_notes, MockClient, MockRpcApi}, - store::{sqlite_store::SqliteStore, InputNoteRecord}, - }; - use miden_lib::transaction::TransactionKernel; - use uuid::Uuid; - - use crate::cli::{ - get_note_with_id_prefix, - input_notes::{export_note, import_note}, - }; - - #[tokio::test] - async fn import_export_recorded_note() { - // generate test client - let mut path = temp_dir(); - path.push(Uuid::new_v4().to_string()); - let client_config = ClientConfig::new( - path.into_os_string().into_string().unwrap().try_into().unwrap(), - Endpoint::default().into(), - ); - - let store = SqliteStore::new((&client_config).into()).unwrap(); - let rng = get_random_coin(); - let executor_store = SqliteStore::new((&client_config).into()).unwrap(); - - let mut client = MockClient::new( - MockRpcApi::new(&Endpoint::default().to_string()), - rng, - store, - executor_store, - true, - ); - - // generate test data - let assembler = TransactionKernel::assembler(); - let (consumed_notes, created_notes) = mock_notes(&assembler); - let (_, committed_notes, ..) = mock_full_chain_mmr_and_notes(consumed_notes); - - let committed_note: InputNoteRecord = committed_notes.first().unwrap().clone().into(); - let pending_note = InputNoteRecord::from(created_notes.first().unwrap().clone()); - - client.import_input_note(committed_note.clone()).unwrap(); - client.import_input_note(pending_note.clone()).unwrap(); - assert!(pending_note.inclusion_proof().is_none()); - assert!(committed_note.inclusion_proof().is_some()); - - let mut filename_path = temp_dir(); - filename_path.push("test_import"); - - let mut filename_path_pending = temp_dir(); - filename_path_pending.push("test_import_pending"); - - export_note(&client, &committed_note.id().inner().to_string(), Some(filename_path.clone())) - .unwrap(); - - assert!(filename_path.exists()); - - export_note( - &client, - &pending_note.id().inner().to_string(), - Some(filename_path_pending.clone()), - ) - .unwrap(); - - assert!(filename_path_pending.exists()); - - // generate test client to import notes to - let mut path = temp_dir(); - path.push(Uuid::new_v4().to_string()); - let client_config = ClientConfig::new( - path.into_os_string().into_string().unwrap().try_into().unwrap(), - Endpoint::default().into(), - ); - let store = SqliteStore::new((&client_config).into()).unwrap(); - let executor_store = SqliteStore::new((&client_config).into()).unwrap(); - - let mut client = MockClient::new( - MockRpcApi::new(&Endpoint::default().to_string()), - rng, - store, - executor_store, - true, - ); - - import_note(&mut client, filename_path).unwrap(); - let imported_note_record: InputNoteRecord = - client.get_input_note(committed_note.id()).unwrap(); - - assert_eq!(committed_note.id(), imported_note_record.id()); - - import_note(&mut client, filename_path_pending).unwrap(); - let imported_pending_note_record = client.get_input_note(pending_note.id()).unwrap(); - - assert_eq!(imported_pending_note_record.id(), pending_note.id()); - } - - #[tokio::test] - async fn get_input_note_with_prefix() { - // generate test client - let mut path = temp_dir(); - path.push(Uuid::new_v4().to_string()); - let client_config = ClientConfig::new( - path.into_os_string().into_string().unwrap().try_into().unwrap(), - Endpoint::default().into(), - ); - - let store = SqliteStore::new((&client_config).into()).unwrap(); - let rng = get_random_coin(); - let executor_store = SqliteStore::new((&client_config).into()).unwrap(); - - let mut client = MockClient::new( - MockRpcApi::new(&Endpoint::default().to_string()), - rng, - store, - executor_store, - true, - ); - - // Ensure we get an error if no note is found - let non_existent_note_id = "0x123456"; - assert_eq!( - get_note_with_id_prefix(&client, non_existent_note_id), - Err(NoteIdPrefixFetchError::NoMatch(non_existent_note_id.to_string())) - ); - - // generate test data - let assembler = TransactionKernel::assembler(); - let (consumed_notes, created_notes) = mock_notes(&assembler); - let (_, notes, ..) = mock_full_chain_mmr_and_notes(consumed_notes); - - let committed_note: InputNoteRecord = notes.first().unwrap().clone().into(); - let pending_note = InputNoteRecord::from(created_notes.first().unwrap().clone()); - - client.import_input_note(committed_note.clone()).unwrap(); - client.import_input_note(pending_note.clone()).unwrap(); - assert!(pending_note.inclusion_proof().is_none()); - assert!(committed_note.inclusion_proof().is_some()); - - // Check that we can fetch Both notes - let note = get_note_with_id_prefix(&client, &committed_note.id().to_hex()).unwrap(); - assert_eq!(note.id(), committed_note.id()); - - let note = get_note_with_id_prefix(&client, &pending_note.id().to_hex()).unwrap(); - assert_eq!(note.id(), pending_note.id()); - - // Check that we get an error if many match - let note_id_with_many_matches = "0x"; - assert_eq!( - get_note_with_id_prefix(&client, note_id_with_many_matches), - Err(NoteIdPrefixFetchError::MultipleMatches(note_id_with_many_matches.to_string())) - ); - } -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d305a6467..6918c8bdf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,4 @@ -use std::{env, path::Path}; +use std::{env, fs::File, io::Write, path::Path, rc::Rc}; use clap::Parser; use comfy_table::{presets, Attribute, Cell, ContentArrangement, Table}; @@ -10,20 +10,43 @@ use miden_client::{ client::{ get_random_coin, rpc::{NodeRpcClient, TonicRpcClient}, + store_authenticator::StoreAuthenticator, Client, }, config::ClientConfig, - errors::{ClientError, NoteIdPrefixFetchError}, - store::{sqlite_store::SqliteStore, InputNoteRecord, NoteFilter as ClientNoteFilter, Store}, + errors::{ClientError, IdPrefixFetchError}, + store::{ + sqlite_store::SqliteStore, InputNoteRecord, NoteFilter as ClientNoteFilter, + OutputNoteRecord, Store, + }, +}; +use miden_objects::{ + accounts::{AccountId, AccountStub}, + crypto::rand::FeltRng, +}; +use miden_tx::TransactionAuthenticator; +use tracing::info; +use transactions::TransactionCmd; + +use self::{ + account::AccountCmd, + export::ExportCmd, + import::ImportCmd, + init::InitCmd, + new_account::{NewFaucetCmd, NewWalletCmd}, + new_transactions::{ConsumeNotesCmd, MintCmd, SendCmd, SwapCmd}, + notes::NotesCmd, + tags::TagsCmd, }; -use miden_objects::crypto::rand::FeltRng; -#[cfg(not(feature = "mock"))] -use miden_objects::crypto::rand::RpoRandomCoin; mod account; +mod export; +mod import; mod info; mod init; -mod input_notes; +mod new_account; +mod new_transactions; +mod notes; mod sync; mod tags; mod transactions; @@ -31,6 +54,9 @@ mod transactions; /// Config file name const CLIENT_CONFIG_FILE_NAME: &str = "miden-client.toml"; +/// Client binary name +pub const CLIENT_BINARY_NAME: &str = "miden"; + /// Root CLI struct #[derive(Parser, Debug)] #[clap(name = "Miden", about = "Miden client", version, rename_all = "kebab-case")] @@ -47,20 +73,24 @@ pub struct Cli { /// CLI actions #[derive(Debug, Parser)] pub enum Command { - #[clap(subcommand)] - Account(account::AccountCmd), - Init, - #[clap(subcommand)] - InputNotes(input_notes::InputNotes), + Account(AccountCmd), + NewFaucet(NewFaucetCmd), + NewWallet(NewWalletCmd), + Import(ImportCmd), + Export(ExportCmd), + Init(InitCmd), + Notes(NotesCmd), /// Sync this client with the latest state of the Miden network. Sync, /// View a summary of the current client state Info, - #[clap(subcommand)] - Tags(tags::TagsCmd), - #[clap(subcommand, name = "tx")] - #[clap(visible_alias = "transaction")] - Transaction(transactions::Transaction), + Tags(TagsCmd), + #[clap(name = "tx")] + Transaction(TransactionCmd), + Mint(MintCmd), + Send(SendCmd), + Swap(SwapCmd), + ConsumeNotes(ConsumeNotesCmd), } /// CLI entry point @@ -72,8 +102,8 @@ impl Cli { // Check if it's an init command before anything else. When we run the init command for // the first time we won't have a config file and thus creating the store would not be // possible. - if matches!(&self.action, Command::Init) { - init::initialize_client(current_dir.clone())?; + if let Command::Init(init_cmd) = &self.action { + init_cmd.execute(current_dir.clone())?; return Ok(()); } @@ -87,30 +117,42 @@ impl Cli { // Create the client let client_config = load_config(current_dir.as_path())?; - let rpc_endpoint = client_config.rpc.endpoint.to_string(); let store = SqliteStore::new((&client_config).into()).map_err(ClientError::StoreError)?; + let store = Rc::new(store); + let rng = get_random_coin(); - let executor_store = - miden_client::store::sqlite_store::SqliteStore::new((&client_config).into()) - .map_err(ClientError::StoreError)?; + let authenticator = StoreAuthenticator::new_with_rng(store.clone(), rng); - let client: Client = Client::new( - TonicRpcClient::new(&rpc_endpoint), + let client = Client::new( + TonicRpcClient::new(&client_config.rpc), rng, store, - executor_store, + authenticator, in_debug_mode, ); - // Execute cli command + let default_account_id = + client_config.cli.clone().and_then(|cli_conf| cli_conf.default_account_id); + + // Execute CLI command match &self.action { Command::Account(account) => account.execute(client), - Command::Init => Ok(()), - Command::Info => info::print_client_info(&client), - Command::InputNotes(notes) => notes.execute(client), + Command::NewFaucet(new_faucet) => new_faucet.execute(client), + Command::NewWallet(new_wallet) => new_wallet.execute(client), + Command::Import(import) => import.execute(client).await, + Command::Init(_) => Ok(()), + Command::Info => info::print_client_info(&client, &client_config), + Command::Notes(notes) => notes.execute(client).await, Command::Sync => sync::sync_state(client).await, Command::Tags(tags) => tags.execute(client).await, Command::Transaction(transaction) => transaction.execute(client).await, + Command::Export(cmd) => cmd.execute(client), + Command::Mint(mint) => mint.clone().execute(client, default_account_id).await, + Command::Send(send) => send.clone().execute(client, default_account_id).await, + Command::Swap(swap) => swap.clone().execute(client, default_account_id).await, + Command::ConsumeNotes(consume_notes) => { + consume_notes.clone().execute(client, default_account_id).await + }, } } } @@ -140,30 +182,37 @@ pub fn create_dynamic_table(headers: &[&str]) -> Table { table } -/// Returns all client's notes whose ID starts with `note_id_prefix` +/// Returns the client input note whose ID starts with `note_id_prefix` /// /// # Errors /// -/// - Returns [NoteIdPrefixFetchError::NoMatch] if we were unable to find any note where +/// - Returns [IdPrefixFetchError::NoMatch] if we were unable to find any note where /// `note_id_prefix` is a prefix of its id. -/// - Returns [NoteIdPrefixFetchError::MultipleMatches] if there were more than one note found +/// - Returns [IdPrefixFetchError::MultipleMatches] if there were more than one note found /// where `note_id_prefix` is a prefix of its id. -pub(crate) fn get_note_with_id_prefix( - client: &Client, +pub(crate) fn get_input_note_with_id_prefix< + N: NodeRpcClient, + R: FeltRng, + S: Store, + A: TransactionAuthenticator, +>( + client: &Client, note_id_prefix: &str, -) -> Result { - let input_note_records = client +) -> Result { + let mut input_note_records = client .get_input_notes(ClientNoteFilter::All) .map_err(|err| { tracing::error!("Error when fetching all notes from the store: {err}"); - NoteIdPrefixFetchError::NoMatch(note_id_prefix.to_string()) + IdPrefixFetchError::NoMatch(format!("note ID prefix {note_id_prefix}").to_string()) })? .into_iter() .filter(|note_record| note_record.id().to_hex().starts_with(note_id_prefix)) .collect::>(); if input_note_records.is_empty() { - return Err(NoteIdPrefixFetchError::NoMatch(note_id_prefix.to_string())); + return Err(IdPrefixFetchError::NoMatch( + format!("note ID prefix {note_id_prefix}").to_string(), + )); } if input_note_records.len() > 1 { let input_note_record_ids = input_note_records @@ -175,8 +224,162 @@ pub(crate) fn get_note_with_id_prefix( note_id_prefix, input_note_record_ids ); - return Err(NoteIdPrefixFetchError::MultipleMatches(note_id_prefix.to_string())); + return Err(IdPrefixFetchError::MultipleMatches( + format!("note ID prefix {note_id_prefix}").to_string(), + )); + } + + Ok(input_note_records + .pop() + .expect("input_note_records should always have one element")) +} + +/// Returns the client output note whose ID starts with `note_id_prefix` +/// +/// # Errors +/// +/// - Returns [IdPrefixFetchError::NoMatch] if we were unable to find any note where +/// `note_id_prefix` is a prefix of its id. +/// - Returns [IdPrefixFetchError::MultipleMatches] if there were more than one note found +/// where `note_id_prefix` is a prefix of its id. +pub(crate) fn get_output_note_with_id_prefix< + N: NodeRpcClient, + R: FeltRng, + S: Store, + A: TransactionAuthenticator, +>( + client: &Client, + note_id_prefix: &str, +) -> Result { + let mut output_note_records = client + .get_output_notes(ClientNoteFilter::All) + .map_err(|err| { + tracing::error!("Error when fetching all notes from the store: {err}"); + IdPrefixFetchError::NoMatch(format!("note ID prefix {note_id_prefix}").to_string()) + })? + .into_iter() + .filter(|note_record| note_record.id().to_hex().starts_with(note_id_prefix)) + .collect::>(); + + if output_note_records.is_empty() { + return Err(IdPrefixFetchError::NoMatch( + format!("note ID prefix {note_id_prefix}").to_string(), + )); } + if output_note_records.len() > 1 { + let output_note_record_ids = output_note_records + .iter() + .map(|input_note_record| input_note_record.id()) + .collect::>(); + tracing::error!( + "Multiple notes found for the prefix {}: {:?}", + note_id_prefix, + output_note_record_ids + ); + return Err(IdPrefixFetchError::MultipleMatches( + format!("note ID prefix {note_id_prefix}").to_string(), + )); + } + + Ok(output_note_records + .pop() + .expect("input_note_records should always have one element")) +} + +/// Returns the client account whose ID starts with `account_id_prefix` +/// +/// # Errors +/// +/// - Returns [IdPrefixFetchError::NoMatch] if we were unable to find any account where +/// `account_id_prefix` is a prefix of its id. +/// - Returns [IdPrefixFetchError::MultipleMatches] if there were more than one account found +/// where `account_id_prefix` is a prefix of its id. +fn get_account_with_id_prefix< + N: NodeRpcClient, + R: FeltRng, + S: Store, + A: TransactionAuthenticator, +>( + client: &Client, + account_id_prefix: &str, +) -> Result { + let mut accounts = client + .get_account_stubs() + .map_err(|err| { + tracing::error!("Error when fetching all accounts from the store: {err}"); + IdPrefixFetchError::NoMatch( + format!("account ID prefix {account_id_prefix}").to_string(), + ) + })? + .into_iter() + .filter(|(account_stub, _)| account_stub.id().to_hex().starts_with(account_id_prefix)) + .map(|(acc, _)| acc) + .collect::>(); + + if accounts.is_empty() { + return Err(IdPrefixFetchError::NoMatch( + format!("account ID prefix {account_id_prefix}").to_string(), + )); + } + if accounts.len() > 1 { + let account_ids = accounts.iter().map(|account_stub| account_stub.id()).collect::>(); + tracing::error!( + "Multiple accounts found for the prefix {}: {:?}", + account_id_prefix, + account_ids + ); + return Err(IdPrefixFetchError::MultipleMatches( + format!("account ID prefix {account_id_prefix}").to_string(), + )); + } + + Ok(accounts.pop().expect("account_ids should always have one element")) +} + +/// Parses a user provided account id string and returns the corresponding `AccountId` +/// +/// `account_id` can fall into two categories: +/// +/// - it's a prefix of an account id of an account tracked by the client +/// - it's a full account id +/// +/// # Errors +/// +/// - Will return a `IdPrefixFetchError` if the provided account id string can't be parsed as an +/// `AccountId` and does not correspond to an account tracked by the client either. +pub(crate) fn parse_account_id< + N: NodeRpcClient, + R: FeltRng, + S: Store, + A: TransactionAuthenticator, +>( + client: &Client, + account_id: &str, +) -> Result { + if let Ok(account_id) = AccountId::from_hex(account_id) { + return Ok(account_id); + } + + let account_id = get_account_with_id_prefix(client, account_id) + .map_err(|_err| "Input account ID {account_id} is neither a valid Account ID nor a prefix of a known Account ID")? + .id(); + Ok(account_id) +} + +pub(crate) fn update_config(config_path: &Path, client_config: ClientConfig) -> Result<(), String> { + let config_as_toml_string = toml::to_string_pretty(&client_config) + .map_err(|err| format!("error formatting config: {err}"))?; + + info!("Writing config file at: {:?}", config_path); + let mut file_handle = File::options() + .write(true) + .truncate(true) + .open(config_path) + .map_err(|err| format!("error opening the file: {err}"))?; + file_handle + .write(config_as_toml_string.as_bytes()) + .map_err(|err| format!("error writing to file: {err}"))?; - Ok(input_note_records[0].clone()) + println!("Config updated successfully"); + Ok(()) } diff --git a/src/cli/new_account.rs b/src/cli/new_account.rs new file mode 100644 index 000000000..9b842497c --- /dev/null +++ b/src/cli/new_account.rs @@ -0,0 +1,122 @@ +use clap::{Parser, ValueEnum}; +use miden_client::{ + client::{ + accounts::{self, AccountTemplate}, + rpc::NodeRpcClient, + Client, + }, + store::Store, +}; +use miden_objects::{assets::TokenSymbol, crypto::rand::FeltRng}; +use miden_tx::TransactionAuthenticator; + +use crate::cli::CLIENT_BINARY_NAME; + +#[derive(Debug, Parser, Clone)] +/// Create a new faucet account +pub struct NewFaucetCmd { + #[clap(short, long, value_enum, default_value_t = AccountStorageMode::OffChain)] + /// Storage type of the account + storage_type: AccountStorageMode, + #[clap(short, long)] + /// Defines if the account assets are non-fungible (by default it is fungible) + non_fungible: bool, + #[clap(short, long)] + /// Token symbol of the faucet + token_symbol: Option, + #[clap(short, long)] + /// Decimals of the faucet + decimals: Option, + #[clap(short, long)] + max_supply: Option, +} + +impl NewFaucetCmd { + pub fn execute( + &self, + mut client: Client, + ) -> Result<(), String> { + if self.non_fungible { + todo!("Non-fungible faucets are not supported yet"); + } + + if self.token_symbol.is_none() || self.decimals.is_none() || self.max_supply.is_none() { + return Err( + "`token-symbol`, `decimals` and `max-supply` flags must be provided for a fungible faucet" + .to_string(), + ); + } + + let client_template = AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new( + self.token_symbol.clone().expect("token symbol must be provided").as_str(), + ) + .map_err(|err| format!("error: token symbol is invalid: {}", err))?, + decimals: self.decimals.expect("decimals must be provided"), + max_supply: self.max_supply.expect("max supply must be provided"), + storage_mode: self.storage_type.into(), + }; + + let (new_account, _account_seed) = client.new_account(client_template)?; + println!("Succesfully created new faucet."); + println!( + "To view account details execute `{CLIENT_BINARY_NAME} account -s {}`", + new_account.id() + ); + + Ok(()) + } +} + +#[derive(Debug, Parser, Clone)] +/// Create a new wallet account +pub struct NewWalletCmd { + #[clap(short, long, value_enum, default_value_t = AccountStorageMode::OffChain)] + /// Storage type of the account + pub storage_type: AccountStorageMode, + #[clap(short, long)] + /// Defines if the account code is mutable (by default it is not mutable) + pub mutable: bool, +} + +impl NewWalletCmd { + pub fn execute( + &self, + mut client: Client, + ) -> Result<(), String> { + let client_template = AccountTemplate::BasicWallet { + mutable_code: self.mutable, + storage_mode: self.storage_type.into(), + }; + + let (new_account, _account_seed) = client.new_account(client_template)?; + println!("Succesfully created new wallet."); + println!( + "To view account details execute `{CLIENT_BINARY_NAME} account -s {}`", + new_account.id() + ); + + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum AccountStorageMode { + OffChain, + OnChain, +} + +impl From for accounts::AccountStorageMode { + fn from(value: AccountStorageMode) -> Self { + match value { + AccountStorageMode::OffChain => accounts::AccountStorageMode::Local, + AccountStorageMode::OnChain => accounts::AccountStorageMode::OnChain, + } + } +} + +impl From<&AccountStorageMode> for accounts::AccountStorageMode { + fn from(value: &AccountStorageMode) -> Self { + accounts::AccountStorageMode::from(*value) + } +} diff --git a/src/cli/new_transactions.rs b/src/cli/new_transactions.rs new file mode 100644 index 000000000..f390ac897 --- /dev/null +++ b/src/cli/new_transactions.rs @@ -0,0 +1,445 @@ +use std::io; + +use clap::{Parser, ValueEnum}; +use miden_client::{ + client::{ + rpc::NodeRpcClient, + transactions::{ + transaction_request::{ + PaymentTransactionData, SwapTransactionData, TransactionTemplate, + }, + TransactionResult, + }, + }, + store::Store, +}; +use miden_objects::{ + accounts::AccountId, + assets::{Asset, FungibleAsset}, + crypto::rand::FeltRng, + notes::{NoteExecutionHint, NoteId, NoteTag, NoteType as MidenNoteType}, + Digest, NoteError, +}; +use miden_tx::TransactionAuthenticator; + +use super::{get_input_note_with_id_prefix, parse_account_id, Client}; +use crate::cli::create_dynamic_table; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum NoteType { + Public, + Private, +} + +impl From<&NoteType> for MidenNoteType { + fn from(note_type: &NoteType) -> Self { + match note_type { + NoteType::Public => MidenNoteType::Public, + NoteType::Private => MidenNoteType::OffChain, + } + } +} + +#[derive(Debug, Parser, Clone)] +/// Mint tokens from a fungible faucet to a wallet. +pub struct MintCmd { + /// Target account ID or its hex prefix + #[clap(short = 't', long = "target")] + target_account_id: String, + /// Faucet account ID or its hex prefix + #[clap(short = 'f', long = "faucet")] + faucet_id: String, + /// Amount of tokens to mint + #[clap(short, long)] + amount: u64, + #[clap(short, long, value_enum)] + note_type: NoteType, + /// Flag to submit the executed transaction without asking for confirmation + #[clap(short, long, default_value_t = false)] + force: bool, +} + +impl MintCmd { + pub async fn execute( + self, + mut client: Client, + default_account_id: Option, + ) -> Result<(), String> { + let force = self.force; + let transaction_template = self.into_template(&client, default_account_id)?; + execute_transaction(&mut client, transaction_template, force).await + } + + fn into_template( + self, + client: &Client, + _default_account_id: Option, + ) -> Result { + let faucet_id = parse_account_id(client, self.faucet_id.as_str())?; + let fungible_asset = + FungibleAsset::new(faucet_id, self.amount).map_err(|err| err.to_string())?; + let target_account_id = parse_account_id(client, self.target_account_id.as_str())?; + + Ok(TransactionTemplate::MintFungibleAsset( + fungible_asset, + target_account_id, + (&self.note_type).into(), + )) + } +} + +#[derive(Debug, Parser, Clone)] +/// Create a pay-to-id transaction. +pub struct SendCmd { + /// Sender account ID or its hex prefix. If none is provided, the default account's ID is used instead + #[clap(short = 's', long = "sender")] + sender_account_id: Option, + /// Target account ID or its hex prefix + #[clap(short = 't', long = "target")] + target_account_id: String, + /// Faucet account ID or its hex prefix + #[clap(short = 'f', long = "faucet")] + faucet_id: String, + #[clap(short, long, value_enum)] + note_type: NoteType, + /// Flag to submit the executed transaction without asking for confirmation + #[clap(long, default_value_t = false)] + force: bool, + /// Set the recall height for the transaction. If the note was not consumed by this height, the sender may consume it back. + /// + /// Setting this flag turns the transaction from a PayToId to a PayToIdWithRecall. + #[clap(short, long)] + recall_height: Option, + /// Amount of tokens to mint + amount: u64, +} + +impl SendCmd { + pub async fn execute( + self, + mut client: Client, + default_account_id: Option, + ) -> Result<(), String> { + let force = self.force; + let transaction_template = self.into_template(&client, default_account_id)?; + execute_transaction(&mut client, transaction_template, force).await + } + + fn into_template( + self, + client: &Client, + default_account_id: Option, + ) -> Result { + let faucet_id = parse_account_id(client, self.faucet_id.as_str())?; + let fungible_asset = FungibleAsset::new(faucet_id, self.amount) + .map_err(|err| err.to_string())? + .into(); + + // try to use either the provided argument or the default account + let sender_account_id = self + .sender_account_id + .clone() + .or(default_account_id) + .ok_or("Neither a sender nor a default account was provided".to_string())?; + let sender_account_id = parse_account_id(client, &sender_account_id)?; + let target_account_id = parse_account_id(client, self.target_account_id.as_str())?; + + let payment_transaction = + PaymentTransactionData::new(fungible_asset, sender_account_id, target_account_id); + if let Some(recall_height) = self.recall_height { + Ok(TransactionTemplate::PayToIdWithRecall( + payment_transaction, + recall_height, + (&self.note_type).into(), + )) + } else { + Ok(TransactionTemplate::PayToId(payment_transaction, (&self.note_type).into())) + } + } +} + +#[derive(Debug, Parser, Clone)] +/// Create a swap transaction. +pub struct SwapCmd { + /// Sender account ID or its hex prefix. If none is provided, the default account's ID is used instead + #[clap(short = 's', long = "source")] + sender_account_id: Option, + /// Offered Faucet account ID or its hex prefix + #[clap(long = "offered-faucet")] + offered_asset_faucet_id: String, + /// Offered amount + #[clap(long = "offered-amount")] + offered_asset_amount: u64, + /// Requested Faucet account ID or its hex prefix + #[clap(long = "requested-faucet")] + requested_asset_faucet_id: String, + /// Requested amount + #[clap(long = "requested-amount")] + requested_asset_amount: u64, + #[clap(short, long, value_enum)] + note_type: NoteType, + /// Flag to submit the executed transaction without asking for confirmation + #[clap(long, default_value_t = false)] + force: bool, +} + +impl SwapCmd { + pub async fn execute( + self, + mut client: Client, + default_account_id: Option, + ) -> Result<(), String> { + let force = self.force; + let transaction_template = self.into_template(&client, default_account_id)?; + execute_transaction(&mut client, transaction_template, force).await + } + + fn into_template( + self, + client: &Client, + default_account_id: Option, + ) -> Result { + let offered_asset_faucet_id = parse_account_id(client, &self.offered_asset_faucet_id)?; + let offered_fungible_asset = + FungibleAsset::new(offered_asset_faucet_id, self.offered_asset_amount) + .map_err(|err| err.to_string())? + .into(); + + let requested_asset_faucet_id = parse_account_id(client, &self.requested_asset_faucet_id)?; + let requested_fungible_asset = + FungibleAsset::new(requested_asset_faucet_id, self.requested_asset_amount) + .map_err(|err| err.to_string())? + .into(); + + // try to use either the provided argument or the default account + let sender_account_id = self + .sender_account_id + .clone() + .or(default_account_id) + .ok_or("Neither a sender nor a default account was provided".to_string())?; + let sender_account_id = parse_account_id(client, &sender_account_id)?; + + let swap_transaction = SwapTransactionData::new( + sender_account_id, + offered_fungible_asset, + requested_fungible_asset, + ); + + Ok(TransactionTemplate::Swap(swap_transaction, (&self.note_type).into())) + } +} + +#[derive(Debug, Parser, Clone)] +/// Consume with the account corresponding to `account_id` all of the notes from `list_of_notes`. +pub struct ConsumeNotesCmd { + /// The account ID to be used to consume the note or its hex prefix. If none is provided, the default + /// account's ID is used instead + #[clap(short = 'a', long = "account")] + account_id: Option, + /// A list of note IDs or the hex prefixes of their corresponding IDs + list_of_notes: Vec, + /// Flag to submit the executed transaction without asking for confirmation + #[clap(short, long, default_value_t = false)] + force: bool, +} + +impl ConsumeNotesCmd { + pub async fn execute( + self, + mut client: Client, + default_account_id: Option, + ) -> Result<(), String> { + let force = self.force; + let transaction_template = self.into_template(&client, default_account_id)?; + execute_transaction(&mut client, transaction_template, force).await + } + + fn into_template( + self, + client: &Client, + default_account_id: Option, + ) -> Result { + let list_of_notes = self + .list_of_notes + .iter() + .map(|note_id| { + get_input_note_with_id_prefix(client, note_id) + .map(|note_record| note_record.id()) + .map_err(|err| err.to_string()) + }) + .collect::, _>>()?; + + let account_id = self + .account_id + .clone() + .or(default_account_id) + .ok_or("Neither a sender nor a default account was provided".to_string())?; + let account_id = parse_account_id(client, &account_id)?; + + Ok(TransactionTemplate::ConsumeNotes(account_id, list_of_notes)) + } +} + +// EXECUTE TRANSACTION +// ================================================================================================ +async fn execute_transaction< + N: NodeRpcClient, + R: FeltRng, + S: Store, + A: TransactionAuthenticator, +>( + client: &mut Client, + transaction_template: TransactionTemplate, + force: bool, +) -> Result<(), String> { + let transaction_request = client.build_transaction_request(transaction_template.clone())?; + + println!("Executing transaction..."); + let transaction_execution_result = client.new_transaction(transaction_request)?; + + // Show delta and ask for confirmation + print_transaction_details(&transaction_execution_result); + if !force { + println!("Continue with proving and submission? Changes will be irreversible once the proof is finalized on the rollup (Y/N)"); + let mut proceed_str: String = String::new(); + io::stdin().read_line(&mut proceed_str).expect("Should read line"); + + if proceed_str.trim().to_lowercase() != "y" { + println!("Transaction was cancelled."); + return Ok(()); + } + } + + println!("Proving transaction and then submitting it to node..."); + + let transaction_id = transaction_execution_result.executed_transaction().id(); + let output_notes = transaction_execution_result + .created_notes() + .iter() + .map(|note| note.id()) + .collect::>(); + client.submit_transaction(transaction_execution_result).await?; + + if let TransactionTemplate::Swap(swap_data, note_type) = transaction_template { + let payback_note_tag: u32 = build_swap_tag( + note_type, + swap_data.offered_asset().faucet_id(), + swap_data.requested_asset().faucet_id(), + ) + .map_err(|err| err.to_string())? + .into(); + println!( + "To receive updates about the payback Swap Note run `miden tags add {}`", + payback_note_tag + ); + } + + println!("Succesfully created transaction."); + println!("Transaction ID: {}", transaction_id); + println!("Output notes:"); + output_notes.iter().for_each(|note_id| println!("\t- {}", note_id)); + + Ok(()) +} + +fn print_transaction_details(transaction_result: &TransactionResult) { + println!( + "The transaction will have the following effects on the account with ID {}", + transaction_result.executed_transaction().account_id() + ); + + let account_delta = transaction_result.account_delta(); + let mut table = create_dynamic_table(&["Storage Slot", "Effect"]); + + for cleared_item_slot in account_delta.storage().cleared_items.iter() { + table.add_row(vec![cleared_item_slot.to_string(), "Cleared".to_string()]); + } + + for (updated_item_slot, new_value) in account_delta.storage().updated_items.iter() { + let value_digest: Digest = new_value.into(); + table.add_row(vec![ + updated_item_slot.to_string(), + format!("Updated ({})", value_digest.to_hex()), + ]); + } + + println!("Storage changes:"); + println!("{table}"); + + let mut table = create_dynamic_table(&["Asset Type", "Faucet ID", "Amount"]); + + for asset in account_delta.vault().added_assets.iter() { + let (asset_type, faucet_id, amount) = match asset { + Asset::Fungible(fungible_asset) => { + ("Fungible Asset", fungible_asset.faucet_id(), fungible_asset.amount()) + }, + Asset::NonFungible(non_fungible_asset) => { + ("Non Fungible Asset", non_fungible_asset.faucet_id(), 1) + }, + }; + table.add_row(vec![asset_type, &faucet_id.to_hex(), &format!("+{}", amount)]); + } + + for asset in account_delta.vault().removed_assets.iter() { + let (asset_type, faucet_id, amount) = match asset { + Asset::Fungible(fungible_asset) => { + ("Fungible Asset", fungible_asset.faucet_id(), fungible_asset.amount()) + }, + Asset::NonFungible(non_fungible_asset) => { + ("Non Fungible Asset", non_fungible_asset.faucet_id(), 1) + }, + }; + table.add_row(vec![asset_type, &faucet_id.to_hex(), &format!("-{}", amount)]); + } + + println!("Vault changes:"); + println!("{table}"); + + if let Some(new_nonce) = account_delta.nonce() { + println!("New nonce: {new_nonce}.") + } else { + println!("No nonce changes.") + } +} + +// HELPERS +// ================================================================================================ + +/// Returns a note tag for a swap note with the specified parameters. +/// +/// Use case ID for the returned tag is set to 0. +/// +/// Tag payload is constructed by taking asset tags (8 bits of faucet ID) and concatenating them +/// together as offered_asset_tag + requested_asset tag. +/// +/// Network execution hint for the returned tag is set to `Local`. +/// +/// Based on miden-base's implementation () +/// +/// TODO: we should make the function in base public and once that gets released use that one and +/// delete this implementation. +fn build_swap_tag( + note_type: MidenNoteType, + offered_asset_faucet_id: AccountId, + requested_asset_faucet_id: AccountId, +) -> Result { + const SWAP_USE_CASE_ID: u16 = 0; + + // get bits 4..12 from faucet IDs of both assets, these bits will form the tag payload; the + // reason we skip the 4 most significant bits is that these encode metadata of underlying + // faucets and are likely to be the same for many different faucets. + + let offered_asset_id: u64 = offered_asset_faucet_id.into(); + let offered_asset_tag = (offered_asset_id >> 52) as u8; + + let requested_asset_id: u64 = requested_asset_faucet_id.into(); + let requested_asset_tag = (requested_asset_id >> 52) as u8; + + let payload = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16); + + let execution = NoteExecutionHint::Local; + match note_type { + MidenNoteType::Public => NoteTag::for_public_use_case(SWAP_USE_CASE_ID, payload, execution), + _ => NoteTag::for_local_use_case(SWAP_USE_CASE_ID, payload), + } +} diff --git a/src/cli/notes.rs b/src/cli/notes.rs new file mode 100644 index 000000000..9887c4acd --- /dev/null +++ b/src/cli/notes.rs @@ -0,0 +1,677 @@ +use std::collections::{HashMap, HashSet}; + +use clap::ValueEnum; +use comfy_table::{presets, Attribute, Cell, ContentArrangement}; +use miden_client::{ + client::{ + rpc::NodeRpcClient, + transactions::transaction_request::known_script_roots::{P2ID, P2IDR, SWAP}, + ConsumableNote, + }, + errors::{ClientError, IdPrefixFetchError}, + store::{InputNoteRecord, NoteFilter as ClientNoteFilter, NoteStatus, OutputNoteRecord, Store}, +}; +use miden_objects::{ + accounts::AccountId, + assets::Asset, + crypto::rand::FeltRng, + notes::{NoteInputs, NoteMetadata}, + Digest, +}; +use miden_tx::TransactionAuthenticator; + +use super::{Client, Parser}; +use crate::cli::{ + create_dynamic_table, get_input_note_with_id_prefix, get_output_note_with_id_prefix, +}; + +#[derive(Clone, Debug, ValueEnum)] +pub enum NoteFilter { + All, + Pending, + Committed, + Consumed, + Consumable, +} + +impl TryInto> for NoteFilter { + type Error = String; + + fn try_into(self) -> Result, Self::Error> { + match self { + NoteFilter::All => Ok(ClientNoteFilter::All), + NoteFilter::Pending => Ok(ClientNoteFilter::Pending), + NoteFilter::Committed => Ok(ClientNoteFilter::Committed), + NoteFilter::Consumed => Ok(ClientNoteFilter::Consumed), + NoteFilter::Consumable => Err("Consumable filter is not supported".to_string()), + } + } +} + +#[derive(Debug, Parser, Clone)] +#[clap(about = "View and manage notes")] +pub struct NotesCmd { + /// List notes with the specified filter. If no filter is provided, all notes will be listed. + #[clap(short, long, group = "action", default_missing_value="all", num_args=0..=1, value_name = "filter")] + list: Option, + /// Show note with the specified ID. + #[clap(short, long, group = "action", value_name = "note_id")] + show: Option, + /// (only has effect on `--list consumable`) Account ID used to filter list. Only notes consumable by this account will be shown. + #[clap(short, long, value_name = "account_id")] + account_id: Option, +} + +impl NotesCmd { + pub async fn execute( + &self, + client: Client, + ) -> Result<(), String> { + match self { + NotesCmd { list: Some(NoteFilter::Consumable), .. } => { + list_consumable_notes(client, &None)?; + }, + NotesCmd { list: Some(filter), .. } => { + list_notes( + client, + filter.clone().try_into().expect("Filter shouldn't be consumable"), + )?; + }, + NotesCmd { show: Some(id), .. } => { + show_note(client, id.to_owned())?; + }, + _ => { + list_notes(client, ClientNoteFilter::All)?; + }, + } + Ok(()) + } +} + +struct CliNoteSummary { + id: String, + script_hash: String, + assets_hash: String, + inputs_commitment: String, + serial_num: String, + note_type: String, + status: String, + tag: String, + sender: String, + exportable: bool, +} + +// LIST NOTES +// ================================================================================================ +fn list_notes( + client: Client, + filter: ClientNoteFilter, +) -> Result<(), String> { + let input_notes = client.get_input_notes(filter.clone())?; + let output_notes = client.get_output_notes(filter.clone())?; + + let mut all_note_ids = HashSet::new(); + let mut input_note_records = HashMap::new(); + let mut output_note_records = HashMap::new(); + + for note in input_notes { + all_note_ids.insert(note.id().to_hex()); + input_note_records.insert(note.id().to_hex(), note); + } + + for note in output_notes { + all_note_ids.insert(note.id().to_hex()); + output_note_records.insert(note.id().to_hex(), note); + } + + let zipped_notes = all_note_ids + .iter() + .map(|note_id| (input_note_records.get(note_id), output_note_records.get(note_id))); + + print_notes_summary(zipped_notes)?; + Ok(()) +} + +// SHOW NOTE +// ================================================================================================ +fn show_note( + client: Client, + note_id: String, +) -> Result<(), String> { + let input_note_record = get_input_note_with_id_prefix(&client, ¬e_id); + let output_note_record = get_output_note_with_id_prefix(&client, ¬e_id); + + // If we don't find an input note nor an output note return an error + if matches!(input_note_record, Err(IdPrefixFetchError::NoMatch(_))) + && matches!(output_note_record, Err(IdPrefixFetchError::NoMatch(_))) + { + return Err("Couldn't find notes matching the specified note ID".to_string()); + } + + // If either one of the two match with multiple notes return an error + if matches!(input_note_record, Err(IdPrefixFetchError::MultipleMatches(_))) + || matches!(output_note_record, Err(IdPrefixFetchError::MultipleMatches(_))) + { + return Err("The specified note ID hex prefix matched with more than one note.".to_string()); + } + + let input_note_record = input_note_record.ok(); + let output_note_record = output_note_record.ok(); + + // If we match one note as the input note and another one as the output note return an error + match (&input_note_record, &output_note_record) { + (Some(input_record), Some(output_record)) if input_record.id() != output_record.id() => { + return Err( + "The specified note ID hex prefix matched with more than one note.".to_string() + ); + }, + _ => {}, + } + + let mut table = create_dynamic_table(&["Note Information"]); + table + .load_preset(presets::UTF8_HORIZONTAL_ONLY) + .set_content_arrangement(ContentArrangement::DynamicFullWidth); + + let CliNoteSummary { + id, + mut script_hash, + assets_hash, + inputs_commitment, + serial_num, + note_type, + status, + tag, + sender, + exportable, + } = note_summary(input_note_record.as_ref(), output_note_record.as_ref())?; + + table.add_row(vec![Cell::new("ID"), Cell::new(id)]); + match script_hash.clone().as_str() { + P2ID => script_hash += " (P2ID)", + P2IDR => script_hash += " (P2IDR)", + SWAP => script_hash += " (SWAP)", + _ => {}, + }; + + table.add_row(vec![Cell::new("Script Hash"), Cell::new(script_hash)]); + table.add_row(vec![Cell::new("Assets Hash"), Cell::new(assets_hash)]); + table.add_row(vec![Cell::new("Inputs Hash"), Cell::new(inputs_commitment)]); + table.add_row(vec![Cell::new("Serial Number"), Cell::new(serial_num)]); + table.add_row(vec![Cell::new("Type"), Cell::new(note_type)]); + table.add_row(vec![Cell::new("Status"), Cell::new(status)]); + table.add_row(vec![Cell::new("Tag"), Cell::new(tag)]); + table.add_row(vec![Cell::new("Sender"), Cell::new(sender)]); + table.add_row(vec![Cell::new("Exportable"), Cell::new(if exportable { "✔" } else { "✘" })]); + + println!("{table}"); + + let (script, inputs) = match (&input_note_record, &output_note_record) { + (Some(record), _) => { + let details = record.details(); + (Some(details.script().clone()), Some(details.inputs().clone())) + }, + (_, Some(record)) => { + let details = record.details(); + ( + details.map(|details| details.script().clone()), + details.map(|details| details.inputs().clone()), + ) + }, + (None, None) => { + panic!("One of the two records should be Some") + }, + }; + + let assets = input_note_record + .map(|record| record.assets().clone()) + .or(output_note_record.map(|record| record.assets().clone())) + .expect("One of the two records should be Some"); + + // print note script + if script.is_some() { + let script = script.expect("Script should be Some"); + let mut table = create_dynamic_table(&["Note Script Code"]); + table + .load_preset(presets::UTF8_HORIZONTAL_ONLY) + .set_content_arrangement(ContentArrangement::DynamicFullWidth); + + table.add_row(vec![Cell::new(script.code())]); + println!("{table}"); + }; + + // print note vault + let mut table = create_dynamic_table(&["Note Assets"]); + table + .load_preset(presets::UTF8_HORIZONTAL_ONLY) + .set_content_arrangement(ContentArrangement::DynamicFullWidth); + + table.add_row(vec![ + Cell::new("Type").add_attribute(Attribute::Bold), + Cell::new("Faucet ID").add_attribute(Attribute::Bold), + Cell::new("Amount").add_attribute(Attribute::Bold), + ]); + let assets = assets.iter(); + + for asset in assets { + let (asset_type, faucet_id, amount) = match asset { + Asset::Fungible(fungible_asset) => { + ("Fungible Asset", fungible_asset.faucet_id(), fungible_asset.amount()) + }, + Asset::NonFungible(non_fungible_asset) => { + ("Non Fungible Asset", non_fungible_asset.faucet_id(), 1) + }, + }; + table.add_row(vec![asset_type, &faucet_id.to_hex(), &amount.to_string()]); + } + println!("{table}"); + + if inputs.is_some() { + let inputs = inputs.expect("Inputs should be Some"); + let inputs = NoteInputs::new(inputs.clone()).map_err(ClientError::NoteError)?; + let mut table = create_dynamic_table(&["Note Inputs"]); + table + .load_preset(presets::UTF8_HORIZONTAL_ONLY) + .set_content_arrangement(ContentArrangement::DynamicFullWidth); + table.add_row(vec![ + Cell::new("Index").add_attribute(Attribute::Bold), + Cell::new("Value").add_attribute(Attribute::Bold), + ]); + + inputs.values().iter().enumerate().for_each(|(idx, input)| { + table.add_row(vec![Cell::new(idx).add_attribute(Attribute::Bold), Cell::new(input)]); + }); + println!("{table}"); + }; + + Ok(()) +} + +// LIST CONSUMABLE INPUT NOTES +// ================================================================================================ +fn list_consumable_notes( + client: Client, + account_id: &Option, +) -> Result<(), String> { + let account_id = match account_id { + Some(id) => Some(AccountId::from_hex(id.as_str()).map_err(|err| err.to_string())?), + None => None, + }; + let notes = client.get_consumable_notes(account_id)?; + print_consumable_notes_summary(¬es)?; + Ok(()) +} + +// HELPERS +// ================================================================================================ +fn print_notes_summary<'a, I>(notes: I) -> Result<(), String> +where + I: IntoIterator, Option<&'a OutputNoteRecord>)>, +{ + let mut table = create_dynamic_table(&[ + "Note ID", + "Script Hash", + "Assets Hash", + "Inputs Hash", + "Serial Num", + "Type", + "Status", + "Exportable?", + ]); + + for (input_note_record, output_note_record) in notes { + let CliNoteSummary { + id, + script_hash, + assets_hash, + inputs_commitment, + serial_num, + note_type, + status, + tag: _tag, + sender: _sender, + exportable, + } = note_summary(input_note_record, output_note_record)?; + + let exportable = if exportable { "✔" } else { "✘" }; + + table.add_row(vec![ + id, + script_hash, + assets_hash, + inputs_commitment, + serial_num, + note_type, + status, + exportable.to_string(), + ]); + } + + println!("{table}"); + + Ok(()) +} + +fn print_consumable_notes_summary<'a, I>(notes: I) -> Result<(), String> +where + I: IntoIterator, +{ + let mut table = create_dynamic_table(&["Note ID", "Account ID", "Relevance"]); + + for consumable_note in notes { + for relevance in &consumable_note.relevances { + table.add_row(vec![ + consumable_note.note.id().to_hex(), + relevance.0.to_string(), + relevance.1.to_string(), + ]); + } + } + + println!("{table}"); + + Ok(()) +} + +fn note_record_type(note_record_metadata: Option<&NoteMetadata>) -> String { + match note_record_metadata { + Some(metadata) => match metadata.note_type() { + miden_objects::notes::NoteType::OffChain => "OffChain", + miden_objects::notes::NoteType::Encrypted => "Encrypted", + miden_objects::notes::NoteType::Public => "Public", + }, + None => "-", + } + .to_string() +} + +/// Given that one of the two records is Some, this function will return a summary of the note. +fn note_summary( + input_note_record: Option<&InputNoteRecord>, + output_note_record: Option<&OutputNoteRecord>, +) -> Result { + let note_id = input_note_record + .map(|record| record.id()) + .or(output_note_record.map(|record| record.id())) + .expect("One of the two records should be Some"); + + let commit_height = input_note_record + .map(|record| { + record + .inclusion_proof() + .map(|proof| proof.origin().block_num.to_string()) + .unwrap_or("-".to_string()) + }) + .or(output_note_record.map(|record| { + record + .inclusion_proof() + .map(|proof| proof.origin().block_num.to_string()) + .unwrap_or("-".to_string()) + })) + .expect("One of the two records should be Some"); + + let assets_hash_str = input_note_record + .map(|record| record.assets().commitment().to_string()) + .or(output_note_record.map(|record| record.assets().commitment().to_string())) + .expect("One of the two records should be Some"); + + let (inputs_commitment_str, serial_num, script_hash_str) = + match (input_note_record, output_note_record) { + (Some(record), _) => { + let details = record.details(); + ( + NoteInputs::new(details.inputs().clone()) + .map_err(ClientError::NoteError)? + .commitment() + .to_string(), + Digest::new(details.serial_num()).to_string(), + details.script().hash().to_string(), + ) + }, + (None, Some(record)) if record.details().is_some() => { + let details = record.details().expect("output record should have details"); + ( + NoteInputs::new(details.inputs().clone()) + .map_err(ClientError::NoteError)? + .commitment() + .to_string(), + Digest::new(details.serial_num()).to_string(), + details.script().hash().to_string(), + ) + }, + (None, Some(_record)) => ("-".to_string(), "-".to_string(), "-".to_string()), + (None, None) => panic!("One of the two records should be Some"), + }; + + let note_type = note_record_type( + input_note_record + .and_then(|record| record.metadata()) + .or(output_note_record.map(|record| record.metadata())), + ); + + let status = input_note_record + .map(|record| record.status()) + .or(output_note_record.map(|record| record.status())) + .expect("One of the two records should be Some"); + + let note_consumer = input_note_record + .map(|record| record.consumer_account_id()) + .or(output_note_record.map(|record| record.consumer_account_id())) + .expect("One of the two records should be Some"); + + let status = match status { + NoteStatus::Committed => { + status.to_string() + format!(" (height {})", commit_height).as_str() + }, + NoteStatus::Consumed => { + status.to_string() + + format!( + " (by {})", + note_consumer.map(|id| id.to_string()).unwrap_or("?".to_string()) + ) + .as_str() + }, + _ => status.to_string(), + }; + + let note_metadata = input_note_record + .map(|record| record.metadata()) + .or(output_note_record.map(|record| Some(record.metadata()))) + .expect("One of the two records should be Some"); + + let note_tag_str = note_metadata + .map(|metadata| metadata.tag().to_string()) + .unwrap_or("-".to_string()); + + let note_sender_str = note_metadata + .map(|metadata| metadata.sender().to_string()) + .unwrap_or("-".to_string()); + + Ok(CliNoteSummary { + id: note_id.inner().to_string(), + script_hash: script_hash_str, + assets_hash: assets_hash_str, + inputs_commitment: inputs_commitment_str, + serial_num, + note_type, + status, + tag: note_tag_str, + sender: note_sender_str, + exportable: output_note_record.is_some(), + }) +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use std::env::temp_dir; + + use miden_client::{ + client::transactions::transaction_request::TransactionTemplate, + errors::IdPrefixFetchError, + mock::{ + create_test_client, mock_full_chain_mmr_and_notes, mock_fungible_faucet_account, + mock_notes, + }, + store::{InputNoteRecord, NoteFilter}, + }; + use miden_lib::transaction::TransactionKernel; + use miden_objects::{ + accounts::{ + account_id::testing::ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN, AccountId, AuthSecretKey, + }, + assets::FungibleAsset, + crypto::dsa::rpo_falcon512::SecretKey, + notes::Note, + }; + + use crate::cli::{export::export_note, get_input_note_with_id_prefix, import::import_note}; + + #[tokio::test] + async fn test_import_note_validation() { + // generate test client + let mut client = create_test_client(); + + // generate test data + let assembler = TransactionKernel::assembler(); + let (consumed_notes, created_notes) = mock_notes(&assembler); + let (_, committed_notes, ..) = mock_full_chain_mmr_and_notes(consumed_notes); + + let committed_note: InputNoteRecord = committed_notes.first().unwrap().clone().into(); + let pending_note = InputNoteRecord::from(created_notes.first().unwrap().clone()); + + client.import_input_note(committed_note.clone(), false).await.unwrap(); + assert!(client.import_input_note(pending_note.clone(), true).await.is_err()); + client.import_input_note(pending_note.clone(), false).await.unwrap(); + assert!(pending_note.inclusion_proof().is_none()); + assert!(committed_note.inclusion_proof().is_some()); + } + + #[tokio::test] + async fn import_export_recorded_note() { + // This test will run a mint transaction that creates an output note and we'll try + // exporting that note and then importing it. So the client's state should be: + // + // 1. No notes at all + // 2. One output note + // 3. One output note, one input note. Both representing the same note. + + // generate test client + let mut client = create_test_client(); + + // Add a faucet account to run a mint tx against it + const FAUCET_ID: u64 = ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN; + const INITIAL_BALANCE: u64 = 1000; + let key_pair = SecretKey::new(); + + let faucet = mock_fungible_faucet_account( + AccountId::try_from(FAUCET_ID).unwrap(), + INITIAL_BALANCE, + key_pair.clone(), + ); + + client.sync_state().await.unwrap(); + client + .insert_account(&faucet, None, &AuthSecretKey::RpoFalcon512(key_pair)) + .unwrap(); + + // Ensure client has no notes + assert!(client.get_input_notes(NoteFilter::All).unwrap().is_empty()); + assert!(client.get_output_notes(NoteFilter::All).unwrap().is_empty()); + + // mint asset to create an output note + // using a random account id will mean that the note won't be included in the input notes + // table. + let transaction_template = TransactionTemplate::MintFungibleAsset( + FungibleAsset::new(faucet.id(), 5u64).unwrap(), + AccountId::from_hex("0x168187d729b31a84").unwrap(), + miden_objects::notes::NoteType::OffChain, + ); + + let transaction_request = client.build_transaction_request(transaction_template).unwrap(); + let transaction = client.new_transaction(transaction_request).unwrap(); + let created_note = transaction.created_notes().get_note(0).clone(); + client.submit_transaction(transaction).await.unwrap(); + + // Ensure client has no input notes and one output note + assert!(client.get_input_notes(NoteFilter::All).unwrap().is_empty()); + assert!(!client.get_output_notes(NoteFilter::All).unwrap().is_empty()); + let exported_note = client + .get_output_notes(NoteFilter::Unique(created_note.id())) + .unwrap() + .pop() + .unwrap(); + + // export the note with the CLI function + let mut filename_path = temp_dir(); + filename_path.push("test_import"); + println!("exporting note to {}", filename_path.to_string_lossy()); + export_note(&client, &exported_note.id().to_hex(), Some(filename_path.clone())).unwrap(); + println!("exported!"); + + // Try importing the same note with the CLI function + let imported_note_id = import_note(&mut client, filename_path, false).await.unwrap(); + + // Ensure client has one input note and one output note + assert_eq!(client.get_input_notes(NoteFilter::All).unwrap().len(), 1); + assert_eq!(client.get_output_notes(NoteFilter::All).unwrap().len(), 1); + + let imported_note = client + .get_input_notes(NoteFilter::Unique(imported_note_id)) + .unwrap() + .pop() + .unwrap(); + + let exported_note: InputNoteRecord = exported_note.try_into().unwrap(); + let exported_note: Note = exported_note.try_into().unwrap(); + let imported_note: Note = imported_note.try_into().unwrap(); + + assert_eq!(exported_note, imported_note); + } + + #[tokio::test] + async fn get_input_note_with_prefix() { + // generate test client + let mut client = create_test_client(); + + // Ensure we get an error if no note is found + let non_existent_note_id = "0x123456"; + assert_eq!( + get_input_note_with_id_prefix(&client, non_existent_note_id), + Err(IdPrefixFetchError::NoMatch( + format!("note ID prefix {non_existent_note_id}").to_string() + )) + ); + + // generate test data + let assembler = TransactionKernel::assembler(); + let (consumed_notes, created_notes) = mock_notes(&assembler); + let (_, notes, ..) = mock_full_chain_mmr_and_notes(consumed_notes); + + let committed_note: InputNoteRecord = notes.first().unwrap().clone().into(); + let pending_note = InputNoteRecord::from(created_notes.first().unwrap().clone()); + + client.import_input_note(committed_note.clone(), false).await.unwrap(); + client.import_input_note(pending_note.clone(), false).await.unwrap(); + assert!(pending_note.inclusion_proof().is_none()); + assert!(committed_note.inclusion_proof().is_some()); + + // Check that we can fetch Both notes + let note = get_input_note_with_id_prefix(&client, &committed_note.id().to_hex()).unwrap(); + assert_eq!(note.id(), committed_note.id()); + + let note = get_input_note_with_id_prefix(&client, &pending_note.id().to_hex()).unwrap(); + assert_eq!(note.id(), pending_note.id()); + + // Check that we get an error if many match + let note_id_with_many_matches = "0x"; + assert_eq!( + get_input_note_with_id_prefix(&client, note_id_with_many_matches), + Err(IdPrefixFetchError::MultipleMatches( + format!("note ID prefix {note_id_with_many_matches}").to_string() + )) + ); + } +} diff --git a/src/cli/sync.rs b/src/cli/sync.rs index 96f9d8af4..78e1851eb 100644 --- a/src/cli/sync.rs +++ b/src/cli/sync.rs @@ -3,11 +3,17 @@ use miden_client::{ store::Store, }; use miden_objects::crypto::rand::FeltRng; +use miden_tx::TransactionAuthenticator; -pub async fn sync_state( - mut client: Client, +pub async fn sync_state( + mut client: Client, ) -> Result<(), String> { - let block_num = client.sync_state().await?; - println!("State synced to block {}", block_num); + let new_details = client.sync_state().await?; + println!("State synced to block {}", new_details.block_num); + println!("New public notes: {}", new_details.new_notes); + println!("Tracked notes updated: {}", new_details.new_inclusion_proofs); + println!("Tracked notes consumed: {}", new_details.new_nullifiers); + println!("Tracked accounts updated: {}", new_details.updated_onchain_accounts); + println!("Commited transactions: {}", new_details.commited_transactions); Ok(()) } diff --git a/src/cli/tags.rs b/src/cli/tags.rs index 6d0d0ca79..8af4fcc96 100644 --- a/src/cli/tags.rs +++ b/src/cli/tags.rs @@ -1,35 +1,44 @@ use miden_client::{client::rpc::NodeRpcClient, store::Store}; -use miden_objects::crypto::rand::FeltRng; +use miden_objects::{ + crypto::rand::FeltRng, + notes::{NoteExecutionHint, NoteTag}, +}; +use miden_tx::TransactionAuthenticator; +use tracing::info; use super::{Client, Parser}; -#[derive(Debug, Parser, Clone)] -#[clap(about = "View and add tags")] -pub enum TagsCmd { +#[derive(Default, Debug, Parser, Clone)] +#[clap(about = "View and manage tags. Defaults to `list` command.")] +pub struct TagsCmd { /// List all tags monitored by this client - #[clap(short_flag = 'l')] - List, + #[clap(short, long, group = "action")] + list: bool, /// Add a new tag to the list of tags monitored by this client - #[clap(short_flag = 'a')] - Add { - #[clap()] - tag: u64, - }, + #[clap(short, long, group = "action", value_name = "tag")] + add: Option, + + /// Removes a tag from the list of tags monitored by this client + #[clap(short, long, group = "action", value_name = "tag")] + remove: Option, } impl TagsCmd { - pub async fn execute( + pub async fn execute( &self, - client: Client, + client: Client, ) -> Result<(), String> { match self { - TagsCmd::List => { - list_tags(client)?; - }, - TagsCmd::Add { tag } => { + TagsCmd { add: Some(tag), .. } => { add_tag(client, *tag)?; }, + TagsCmd { remove: Some(tag), .. } => { + remove_tag(client, *tag)?; + }, + _ => { + list_tags(client)?; + }, } Ok(()) } @@ -37,19 +46,38 @@ impl TagsCmd { // HELPERS // ================================================================================================ -fn list_tags( - client: Client, +fn list_tags( + client: Client, ) -> Result<(), String> { let tags = client.get_note_tags()?; - println!("tags: {:?}", tags); + println!("Tags: {:?}", tags); Ok(()) } -fn add_tag( - mut client: Client, - tag: u64, +fn add_tag( + mut client: Client, + tag: u32, ) -> Result<(), String> { + let tag: NoteTag = tag.into(); + let execution_mode = match tag.execution_hint() { + NoteExecutionHint::Local => "Local", + NoteExecutionHint::Network => "Network", + }; + info!( + "adding tag - Single Target? {} - Execution mode: {}", + tag.is_single_target(), + execution_mode + ); client.add_note_tag(tag)?; - println!("tag {} added", tag); + println!("Tag {} added", tag); + Ok(()) +} + +fn remove_tag( + mut client: Client, + tag: u32, +) -> Result<(), String> { + client.remove_note_tag(tag.into())?; + println!("Tag {} removed", tag); Ok(()) } diff --git a/src/cli/transactions.rs b/src/cli/transactions.rs index 05e0dd3da..413d1b1e8 100644 --- a/src/cli/transactions.rs +++ b/src/cli/transactions.rs @@ -1,222 +1,35 @@ -use clap::ValueEnum; use miden_client::{ - client::{ - rpc::NodeRpcClient, - transactions::{ - transaction_request::{PaymentTransactionData, TransactionTemplate}, - TransactionRecord, - }, - }, + client::{rpc::NodeRpcClient, transactions::TransactionRecord}, store::{Store, TransactionFilter}, }; -use miden_objects::{ - accounts::AccountId, - assets::FungibleAsset, - crypto::rand::FeltRng, - notes::{NoteId, NoteType as MidenNoteType}, -}; -use tracing::info; +use miden_objects::crypto::rand::FeltRng; +use miden_tx::TransactionAuthenticator; -use super::{get_note_with_id_prefix, Client, Parser}; +use super::{Client, Parser}; use crate::cli::create_dynamic_table; -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum NoteType { - Public, - Private, -} - -impl From<&NoteType> for MidenNoteType { - fn from(note_type: &NoteType) -> Self { - match note_type { - NoteType::Public => MidenNoteType::Public, - NoteType::Private => MidenNoteType::OffChain, - } - } -} - -#[derive(Clone, Debug, Parser)] -#[clap()] -pub enum TransactionType { - /// Create a pay-to-id transaction. - P2ID { - sender_account_id: String, - target_account_id: String, - faucet_id: String, - amount: u64, - #[clap(short, long, value_enum)] - note_type: NoteType, - }, - /// Mint `amount` tokens from the specified fungible faucet (corresponding to `faucet_id`). The created note can then be then consumed by - /// `target_account_id`. - Mint { - target_account_id: String, - faucet_id: String, - amount: u64, - #[clap(short, long, value_enum)] - note_type: NoteType, - }, - /// Create a pay-to-id with recall transaction. - P2IDR { - sender_account_id: String, - target_account_id: String, - faucet_id: String, - amount: u64, - recall_height: u32, - #[clap(short, long, value_enum)] - note_type: NoteType, - }, - /// Consume with the account corresponding to `account_id` all of the notes from `list_of_notes`. - ConsumeNotes { - account_id: String, - /// A list of note IDs or the hex prefixes of their corresponding IDs - list_of_notes: Vec, - }, -} - -#[derive(Debug, Parser, Clone)] -#[clap(about = "Execute and view transactions")] -pub enum Transaction { +#[derive(Default, Debug, Parser, Clone)] +#[clap(about = "Manage and view transactions. Defaults to `list` command.")] +pub struct TransactionCmd { /// List currently tracked transactions - #[clap(short_flag = 'l')] - List, - /// Execute a transaction, prove and submit it to the node. Once submitted, it - /// gets tracked by the client - #[clap(short_flag = 'n')] - New { - #[clap(subcommand)] - transaction_type: TransactionType, - }, + #[clap(short, long, group = "action")] + list: bool, } -impl Transaction { - pub async fn execute( +impl TransactionCmd { + pub async fn execute( &self, - mut client: Client, + client: Client, ) -> Result<(), String> { - match self { - Transaction::List => { - list_transactions(client)?; - }, - Transaction::New { transaction_type } => { - new_transaction(&mut client, transaction_type).await?; - }, - } + list_transactions(client)?; Ok(()) } } -// NEW TRANSACTION -// ================================================================================================ -async fn new_transaction( - client: &mut Client, - transaction_type: &TransactionType, -) -> Result<(), String> { - let transaction_template: TransactionTemplate = - build_transaction_template(client, transaction_type)?; - - let transaction_request = client.build_transaction_request(transaction_template)?; - let transaction_execution_result = client.new_transaction(transaction_request)?; - - info!("Executed transaction, proving and then submitting..."); - - client.submit_transaction(transaction_execution_result).await?; - - Ok(()) -} - -/// Builds a [TransactionTemplate] based on the transaction type provided via cli args -/// -/// For [TransactionTemplate::ConsumeNotes], it'll try to find the corresponding notes by using the -/// provided IDs as prefixes -fn build_transaction_template( - client: &Client, - transaction_type: &TransactionType, -) -> Result { - match transaction_type { - TransactionType::P2ID { - sender_account_id, - target_account_id, - faucet_id, - amount, - note_type, - } => { - let faucet_id = AccountId::from_hex(faucet_id).map_err(|err| err.to_string())?; - let fungible_asset = - FungibleAsset::new(faucet_id, *amount).map_err(|err| err.to_string())?.into(); - let sender_account_id = - AccountId::from_hex(sender_account_id).map_err(|err| err.to_string())?; - let target_account_id = - AccountId::from_hex(target_account_id).map_err(|err| err.to_string())?; - - let payment_transaction = - PaymentTransactionData::new(fungible_asset, sender_account_id, target_account_id); - - Ok(TransactionTemplate::PayToId(payment_transaction, note_type.into())) - }, - TransactionType::P2IDR { - sender_account_id, - target_account_id, - faucet_id, - amount, - recall_height, - note_type, - } => { - let faucet_id = AccountId::from_hex(faucet_id).map_err(|err| err.to_string())?; - let fungible_asset = - FungibleAsset::new(faucet_id, *amount).map_err(|err| err.to_string())?.into(); - let sender_account_id = - AccountId::from_hex(sender_account_id).map_err(|err| err.to_string())?; - let target_account_id = - AccountId::from_hex(target_account_id).map_err(|err| err.to_string())?; - - let payment_transaction = - PaymentTransactionData::new(fungible_asset, sender_account_id, target_account_id); - Ok(TransactionTemplate::PayToIdWithRecall( - payment_transaction, - *recall_height, - note_type.into(), - )) - }, - TransactionType::Mint { - faucet_id, - target_account_id, - amount, - note_type, - } => { - let faucet_id = AccountId::from_hex(faucet_id).map_err(|err| err.to_string())?; - let fungible_asset = - FungibleAsset::new(faucet_id, *amount).map_err(|err| err.to_string())?; - let target_account_id = - AccountId::from_hex(target_account_id).map_err(|err| err.to_string())?; - - Ok(TransactionTemplate::MintFungibleAsset( - fungible_asset, - target_account_id, - note_type.into(), - )) - }, - TransactionType::ConsumeNotes { account_id, list_of_notes } => { - let list_of_notes = list_of_notes - .iter() - .map(|note_id| { - get_note_with_id_prefix(client, note_id) - .map(|note_record| note_record.id()) - .map_err(|err| err.to_string()) - }) - .collect::, _>>()?; - - let account_id = AccountId::from_hex(account_id).map_err(|err| err.to_string())?; - - Ok(TransactionTemplate::ConsumeNotes(account_id, list_of_notes)) - }, - } -} - // LIST TRANSACTIONS // ================================================================================================ -fn list_transactions( - client: Client, +fn list_transactions( + client: Client, ) -> Result<(), String> { let transactions = client.get_transactions(TransactionFilter::All)?; print_transactions_summary(&transactions); diff --git a/src/client/accounts.rs b/src/client/accounts.rs index 0780a6751..b2a4d5aee 100644 --- a/src/client/accounts.rs +++ b/src/client/accounts.rs @@ -1,21 +1,17 @@ use miden_lib::AuthScheme; use miden_objects::{ accounts::{ - Account, AccountData, AccountId, AccountStorageType, AccountStub, AccountType, AuthData, + Account, AccountData, AccountId, AccountStorageType, AccountStub, AccountType, + AuthSecretKey, }, assets::TokenSymbol, - crypto::{ - dsa::rpo_falcon512::SecretKey, - rand::{FeltRng, RpoRandomCoin}, - }, - Digest, Felt, Word, + crypto::{dsa::rpo_falcon512::SecretKey, rand::FeltRng}, + Felt, Word, }; +use miden_tx::TransactionAuthenticator; use super::{rpc::NodeRpcClient, Client}; -use crate::{ - errors::ClientError, - store::{AuthInfo, Store}, -}; +use crate::{errors::ClientError, store::Store}; pub enum AccountTemplate { BasicWallet { @@ -46,7 +42,7 @@ impl From for AccountStorageType { } } -impl Client { +impl Client { // ACCOUNT CREATION // -------------------------------------------------------------------------------------------- @@ -81,35 +77,20 @@ impl Client { /// Will panic when trying to import a non-new account without a seed since this functionality /// is not currently implemented pub fn import_account(&mut self, account_data: AccountData) -> Result<(), ClientError> { - match account_data.auth { - AuthData::RpoFalcon512Seed(key_pair_seed) => { - // NOTE: The seed should probably come from a different format from miden-base's AccountData - let seed = Digest::try_from(&key_pair_seed)?.into(); - let mut rng = RpoRandomCoin::new(seed); - - let key_pair = SecretKey::with_rng(&mut rng); - - let account_seed = if !account_data.account.is_new() - && account_data.account_seed.is_some() - { - tracing::warn!("Imported an existing account and still provided a seed when it is not needed. It's possible that the account's file was incorrectly generated. The seed will be ignored."); - // Ignore the seed since it's not a new account - - // TODO: The alternative approach to this is to store the seed anyway, but - // ignore it at the point of executing against this transaction, but that - // approach seems a little bit more incorrect - None - } else { - account_data.account_seed - }; - - self.insert_account( - &account_data.account, - account_seed, - &AuthInfo::RpoFalcon512(key_pair), - ) - }, - } + let account_seed = if !account_data.account.is_new() && account_data.account_seed.is_some() + { + tracing::warn!("Imported an existing account and still provided a seed when it is not needed. It's possible that the account's file was incorrectly generated. The seed will be ignored."); + // Ignore the seed since it's not a new account + + // TODO: The alternative approach to this is to store the seed anyway, but + // ignore it at the point of executing against this transaction, but that + // approach seems a little bit more incorrect + None + } else { + account_data.account_seed + }; + + self.insert_account(&account_data.account, account_seed, &account_data.auth_secret_key) } /// Creates a new regular account and saves it in the store along with its seed and auth data @@ -142,7 +123,7 @@ impl Client { ) }?; - self.insert_account(&account, Some(seed), &AuthInfo::RpoFalcon512(key_pair))?; + self.insert_account(&account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair))?; Ok((account, seed)) } @@ -171,7 +152,7 @@ impl Client { auth_scheme, )?; - self.insert_account(&account, Some(seed), &AuthInfo::RpoFalcon512(key_pair))?; + self.insert_account(&account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair))?; Ok((account, seed)) } @@ -185,7 +166,7 @@ impl Client { &mut self, account: &Account, account_seed: Option, - auth_info: &AuthInfo, + auth_info: &AuthSecretKey, ) -> Result<(), ClientError> { if account.is_new() && account_seed.is_none() { return Err(ClientError::ImportNewAccountWithoutSeed); @@ -200,7 +181,7 @@ impl Client { // -------------------------------------------------------------------------------------------- /// Returns summary info about the accounts managed by this client. - pub fn get_accounts(&self) -> Result)>, ClientError> { + pub fn get_account_stubs(&self) -> Result)>, ClientError> { self.store.get_account_stubs().map_err(|err| err.into()) } @@ -220,13 +201,13 @@ impl Client { self.store.get_account_stub(account_id).map_err(|err| err.into()) } - /// Returns an [AuthInfo] object utilized to authenticate an account. + /// Returns an [AuthSecretKey] object utilized to authenticate an account. /// /// # Errors /// /// Returns a [ClientError::StoreError] with a [StoreError::AccountDataNotFound](crate::errors::StoreError::AccountDataNotFound) if the provided ID does /// not correspond to an existing account. - pub fn get_account_auth(&self, account_id: AccountId) -> Result { + pub fn get_account_auth(&self, account_id: AccountId) -> Result { self.store.get_account_auth(account_id).map_err(|err| err.into()) } } @@ -237,17 +218,15 @@ impl Client { #[cfg(test)] pub mod tests { use miden_objects::{ - accounts::{Account, AccountData, AccountId, AuthData}, + accounts::{Account, AccountData, AccountId, AuthSecretKey}, crypto::dsa::rpo_falcon512::SecretKey, Word, }; - use crate::{ - mock::{ - get_account_with_default_account_code, get_new_account_with_default_account_code, - ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN, ACCOUNT_ID_REGULAR, - }, - store::{sqlite_store::tests::create_test_client, AuthInfo}, + use crate::mock::{ + create_test_client, get_account_with_default_account_code, + get_new_account_with_default_account_code, ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN, + ACCOUNT_ID_REGULAR, }; fn create_account_data(account_id: u64) -> AccountData { @@ -257,7 +236,7 @@ pub mod tests { AccountData::new( account.clone(), Some(Word::default()), - AuthData::RpoFalcon512Seed([0; 32]), + AuthSecretKey::RpoFalcon512(SecretKey::new()), ) } @@ -286,10 +265,10 @@ pub mod tests { let key_pair = SecretKey::new(); assert!(client - .insert_account(&account, None, &AuthInfo::RpoFalcon512(key_pair.clone())) + .insert_account(&account, None, &AuthSecretKey::RpoFalcon512(key_pair.clone())) .is_err()); assert!(client - .insert_account(&account, Some(Word::default()), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&account, Some(Word::default()), &AuthSecretKey::RpoFalcon512(key_pair)) .is_ok()); } @@ -308,7 +287,7 @@ pub mod tests { .into_iter() .map(|account_data| account_data.account) .collect(); - let accounts = client.get_accounts().unwrap(); + let accounts = client.get_account_stubs().unwrap(); assert_eq!(accounts.len(), 2); for (client_acc, expected_acc) in accounts.iter().zip(expected_accounts.iter()) { diff --git a/src/client/chain_data.rs b/src/client/chain_data.rs index 8962a9173..30695bf18 100644 --- a/src/client/chain_data.rs +++ b/src/client/chain_data.rs @@ -1,6 +1,7 @@ use miden_objects::crypto::rand::FeltRng; #[cfg(test)] use miden_objects::BlockHeader; +use miden_tx::TransactionAuthenticator; #[cfg(test)] use crate::{ @@ -10,7 +11,7 @@ use crate::{ }; #[cfg(test)] -impl Client { +impl Client { pub fn get_block_headers_in_range( &self, start: u32, diff --git a/src/client/mod.rs b/src/client/mod.rs index a8238169d..7254eaf00 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,12 +1,14 @@ +use alloc::rc::Rc; + use miden_objects::{ crypto::rand::{FeltRng, RpoRandomCoin}, Felt, }; -use miden_tx::TransactionExecutor; +use miden_tx::{TransactionAuthenticator, TransactionExecutor}; use rand::Rng; use tracing::info; -use crate::store::Store; +use crate::store::{data_store::ClientDataStore, Store}; pub mod rpc; use rpc::NodeRpcClient; @@ -16,11 +18,12 @@ pub mod accounts; mod chain_data; mod note_screener; mod notes; -pub(crate) mod sync; +pub mod store_authenticator; +pub mod sync; pub mod transactions; +pub use note_screener::NoteRelevance; pub(crate) use note_screener::NoteScreener; - -use crate::store::data_store::ClientDataStore; +pub use notes::ConsumableNote; // MIDEN CLIENT // ================================================================================================ @@ -33,19 +36,19 @@ use crate::store::data_store::ClientDataStore; /// - Connects to one or more Miden nodes to periodically sync with the current state of the /// network. /// - Executes, proves, and submits transactions to the network as directed by the user. -pub struct Client { +pub struct Client { /// The client's store, which provides a way to write and read entities to provide persistence. - store: S, + store: Rc, /// An instance of [FeltRng] which provides randomness tools for generating new keys, /// serial numbers, etc. rng: R, /// An instance of [NodeRpcClient] which provides a way for the client to connect to the /// Miden node. rpc_api: N, - tx_executor: TransactionExecutor>, + tx_executor: TransactionExecutor, A>, } -impl Client { +impl Client { // CONSTRUCTOR // -------------------------------------------------------------------------------------------- @@ -60,6 +63,8 @@ impl Client { /// - `executor_store`: An instance of [Store] that provides a way for [TransactionExecutor] to /// retrieve relevant inputs at the moment of transaction execution. It should be the same /// store as the one for `store`, but it doesn't have to be the **same instance**. + /// - `authenticator`: Defines the transaction authenticator that will be used by the + /// transaction executor whenever a signature is requested from within the VM. /// - `in_debug_mode`: Instantiates the transaction executor (and in turn, its compiler) /// in debug mode, which will enable debug logs for scripts compiled with this mode for /// easier MASM debugging. @@ -67,12 +72,14 @@ impl Client { /// # Errors /// /// Returns an error if the client could not be instantiated. - pub fn new(api: N, rng: R, store: S, executor_store: S, in_debug_mode: bool) -> Self { + pub fn new(api: N, rng: R, store: Rc, authenticator: A, in_debug_mode: bool) -> Self { if in_debug_mode { info!("Creating the Client in debug mode."); } - let tx_executor = TransactionExecutor::new(ClientDataStore::new(executor_store)) - .with_debug_mode(in_debug_mode); + + let data_store = ClientDataStore::new(store.clone()); + let authenticator = Some(Rc::new(authenticator)); + let tx_executor = TransactionExecutor::new(data_store, authenticator); Self { store, rng, rpc_api: api, tx_executor } } @@ -83,8 +90,8 @@ impl Client { } #[cfg(any(test, feature = "test_utils"))] - pub fn store(&mut self) -> &mut S { - &mut self.store + pub fn store(&mut self) -> &S { + &self.store } } diff --git a/src/client/note_screener.rs b/src/client/note_screener.rs index 676f93a4a..c5618cac5 100644 --- a/src/client/note_screener.rs +++ b/src/client/note_screener.rs @@ -1,21 +1,14 @@ -use std::collections::BTreeSet; +use alloc::{collections::BTreeSet, rc::Rc}; +use core::fmt; use miden_objects::{accounts::AccountId, assets::Asset, notes::Note, Word}; +use super::transactions::transaction_request::known_script_roots::{P2ID, P2IDR, SWAP}; use crate::{ errors::{InvalidNoteInputsError, ScreenerError}, store::Store, }; -// KNOWN SCRIPT ROOTS -// -------------------------------------------------------------------------------------------- -pub(crate) const P2ID_NOTE_SCRIPT_ROOT: &str = - "0xcdfd70344b952980272119bc02b837d14c07bbfc54f86a254422f39391b77b35"; -pub(crate) const P2IDR_NOTE_SCRIPT_ROOT: &str = - "0x41e5727b99a12b36066c09854d39d64dd09d9265c442a9be3626897572bf1745"; -pub(crate) const SWAP_NOTE_SCRIPT_ROOT: &str = - "0x5852920f88985b651cf7ef5e48623f898b6c292f4a2c25dd788ff8b46dd90417"; - #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum NoteRelevance { /// The note can be consumed at any time. @@ -24,12 +17,21 @@ pub enum NoteRelevance { After(u32), } -pub struct NoteScreener<'a, S: Store> { - store: &'a S, +impl fmt::Display for NoteRelevance { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NoteRelevance::Always => write!(f, "Always"), + NoteRelevance::After(height) => write!(f, "After block {}", height), + } + } +} + +pub struct NoteScreener { + store: Rc, } -impl<'a, S: Store> NoteScreener<'a, S> { - pub fn new(store: &'a S) -> Self { +impl NoteScreener { + pub fn new(store: Rc) -> Self { Self { store } } @@ -47,9 +49,9 @@ impl<'a, S: Store> NoteScreener<'a, S> { let script_hash = note.script().hash().to_string(); let note_relevance = match script_hash.as_str() { - P2ID_NOTE_SCRIPT_ROOT => Self::check_p2id_relevance(note, &account_ids)?, - P2IDR_NOTE_SCRIPT_ROOT => Self::check_p2idr_relevance(note, &account_ids)?, - SWAP_NOTE_SCRIPT_ROOT => self.check_swap_relevance(note, &account_ids)?, + P2ID => Self::check_p2id_relevance(note, &account_ids)?, + P2IDR => Self::check_p2idr_relevance(note, &account_ids)?, + SWAP => self.check_swap_relevance(note, &account_ids)?, _ => self.check_script_relevance(note, &account_ids)?, }; @@ -181,59 +183,3 @@ impl<'a, S: Store> NoteScreener<'a, S> { .collect()) } } - -#[cfg(test)] -mod tests { - use miden_lib::notes::{create_p2id_note, create_p2idr_note, create_swap_note}; - use miden_objects::{ - accounts::{AccountId, ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN}, - assets::FungibleAsset, - crypto::rand::RpoRandomCoin, - notes::NoteType, - }; - - use crate::client::note_screener::{ - P2IDR_NOTE_SCRIPT_ROOT, P2ID_NOTE_SCRIPT_ROOT, SWAP_NOTE_SCRIPT_ROOT, - }; - - // We need to make sure the script roots we use for filters are in line with the note scripts - // coming from Miden objects - #[test] - fn ensure_correct_script_roots() { - // create dummy data for the notes - let faucet_id: AccountId = ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN.try_into().unwrap(); - let account_id: AccountId = ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN.try_into().unwrap(); - let rng = RpoRandomCoin::new(Default::default()); - - // create dummy notes to compare note script roots - let p2id_note = create_p2id_note( - account_id, - account_id, - vec![FungibleAsset::new(faucet_id, 100u64).unwrap().into()], - NoteType::OffChain, - rng, - ) - .unwrap(); - let p2idr_note = create_p2idr_note( - account_id, - account_id, - vec![FungibleAsset::new(faucet_id, 100u64).unwrap().into()], - NoteType::OffChain, - 10, - rng, - ) - .unwrap(); - let (swap_note, _serial_num) = create_swap_note( - account_id, - FungibleAsset::new(faucet_id, 100u64).unwrap().into(), - FungibleAsset::new(faucet_id, 100u64).unwrap().into(), - NoteType::OffChain, - rng, - ) - .unwrap(); - - assert_eq!(p2id_note.script().hash().to_string(), P2ID_NOTE_SCRIPT_ROOT); - assert_eq!(p2idr_note.script().hash().to_string(), P2IDR_NOTE_SCRIPT_ROOT); - assert_eq!(swap_note.script().hash().to_string(), SWAP_NOTE_SCRIPT_ROOT); - } -} diff --git a/src/client/notes.rs b/src/client/notes.rs index fef07427a..4a78c68c3 100644 --- a/src/client/notes.rs +++ b/src/client/notes.rs @@ -1,17 +1,30 @@ use miden_objects::{ + accounts::AccountId, assembly::ProgramAst, crypto::rand::FeltRng, - notes::{NoteId, NoteScript}, + notes::{NoteId, NoteInclusionProof, NoteScript}, }; -use miden_tx::ScriptTarget; +use miden_tx::{ScriptTarget, TransactionAuthenticator}; +use tracing::info; -use super::{rpc::NodeRpcClient, Client}; +use super::{note_screener::NoteRelevance, rpc::NodeRpcClient, Client}; use crate::{ + client::NoteScreener, errors::ClientError, - store::{InputNoteRecord, NoteFilter, Store}, + store::{InputNoteRecord, NoteFilter, OutputNoteRecord, Store}, }; -impl Client { +// TYPES +// -------------------------------------------------------------------------------------------- +/// Contains information about a note that can be consumed +pub struct ConsumableNote { + /// The consumable note + pub note: InputNoteRecord, + /// Stores which accounts can consume the note and it's relevance + pub relevances: Vec<(AccountId, NoteRelevance)>, +} + +impl Client { // INPUT NOTE DATA RETRIEVAL // -------------------------------------------------------------------------------------------- @@ -20,16 +33,138 @@ impl Client { self.store.get_input_notes(filter).map_err(|err| err.into()) } + /// Returns input notes that are able to be consumed by the account_id. + /// + /// If account_id is None then all consumable input notes are returned. + pub fn get_consumable_notes( + &self, + account_id: Option, + ) -> Result, ClientError> { + let commited_notes = self.store.get_input_notes(NoteFilter::Committed)?; + + let note_screener = NoteScreener::new(self.store.clone()); + + let mut relevant_notes = Vec::new(); + for input_note in commited_notes { + let account_relevance = + note_screener.check_relevance(&input_note.clone().try_into()?)?; + + if account_relevance.is_empty() { + continue; + } + + relevant_notes.push(ConsumableNote { + note: input_note, + relevances: account_relevance, + }); + } + + if let Some(account_id) = account_id { + relevant_notes.retain(|note| note.relevances.iter().any(|(id, _)| *id == account_id)); + } + + Ok(relevant_notes) + } + /// Returns the input note with the specified hash. pub fn get_input_note(&self, note_id: NoteId) -> Result { - self.store.get_input_note(note_id).map_err(|err| err.into()) + Ok(self + .store + .get_input_notes(NoteFilter::Unique(note_id))? + .pop() + .expect("The vector always has one element for NoteFilter::Unique")) + } + + // OUTPUT NOTE DATA RETRIEVAL + // -------------------------------------------------------------------------------------------- + + /// Returns output notes managed by this client. + pub fn get_output_notes( + &self, + filter: NoteFilter, + ) -> Result, ClientError> { + self.store.get_output_notes(filter).map_err(|err| err.into()) + } + + /// Returns the output note with the specified hash. + pub fn get_output_note(&self, note_id: NoteId) -> Result { + Ok(self + .store + .get_output_notes(NoteFilter::Unique(note_id))? + .pop() + .expect("The vector always has one element for NoteFilter::Unique")) } // INPUT NOTE CREATION // -------------------------------------------------------------------------------------------- - /// Imports a new input note into the client's store. - pub fn import_input_note(&mut self, note: InputNoteRecord) -> Result<(), ClientError> { + /// Imports a new input note into the client's store. The `verify` parameter dictates whether or + /// not the method verifies the existence of the note in the chain. + /// + /// If the imported note is verified to be on chain and it doesn't contain an inclusion proof + /// the method tries to build one. + /// If the verification fails then a [ClientError::ExistenceVerificationError] is raised. + pub async fn import_input_note( + &mut self, + note: InputNoteRecord, + verify: bool, + ) -> Result<(), ClientError> { + if !verify { + return self.store.insert_input_note(¬e).map_err(|err| err.into()); + } + + // Verify that note exists in chain + let mut chain_notes = self.rpc_api.get_notes_by_id(&[note.id()]).await?; + + if chain_notes.is_empty() { + return Err(ClientError::ExistenceVerificationError(note.id())); + } + + let note_details = chain_notes.pop().expect("chain_notes should have at least one element"); + let inclusion_details = note_details.inclusion_details(); + + // If the note exists in the chain and the client is synced to a height equal or + // greater than the note's creation block, get MMR and block header data for the + // note's block. Additionally create the inclusion proof if none is provided. + let inclusion_proof = if self.get_sync_height()? >= inclusion_details.block_num { + // Add the inclusion proof to the imported note + info!("Requesting MMR data for past block num {}", inclusion_details.block_num); + let block_header = + self.get_and_store_authenticated_block(inclusion_details.block_num).await?; + + let built_inclusion_proof = NoteInclusionProof::new( + inclusion_details.block_num, + block_header.sub_hash(), + block_header.note_root(), + inclusion_details.note_index.into(), + inclusion_details.merkle_path.clone(), + )?; + + // If the imported note already provides an inclusion proof, check that + // it equals the one we constructed from node data. + if let Some(proof) = note.inclusion_proof() { + if proof != &built_inclusion_proof { + return Err(ClientError::NoteImportError( + "Constructed inclusion proof does not equal the provided one".to_string(), + )); + } + } + + Some(built_inclusion_proof) + } else { + None + }; + + let note = InputNoteRecord::new( + note.id(), + note.recipient(), + note.assets().clone(), + note.status(), + note.metadata().copied(), + inclusion_proof, + note.details().clone(), + None, + ); self.store.insert_input_note(¬e).map_err(|err| err.into()) } diff --git a/src/client/rpc/mod.rs b/src/client/rpc/mod.rs index 98e058c41..b7e64c1bd 100644 --- a/src/client/rpc/mod.rs +++ b/src/client/rpc/mod.rs @@ -3,7 +3,7 @@ use core::fmt; use async_trait::async_trait; use miden_objects::{ accounts::{Account, AccountId}, - crypto::merkle::{MerklePath, MmrDelta}, + crypto::merkle::{MerklePath, MmrDelta, MmrProof}, notes::{Note, NoteId, NoteMetadata, NoteTag}, transaction::ProvenTransaction, BlockHeader, Digest, @@ -23,6 +23,35 @@ pub enum NoteDetails { Public(Note, NoteInclusionDetails), } +impl NoteDetails { + pub fn inclusion_details(&self) -> &NoteInclusionDetails { + match self { + NoteDetails::OffChain(_, _, inclusion_details) => inclusion_details, + NoteDetails::Public(_, inclusion_details) => inclusion_details, + } + } +} + +/// Describes the possible responses from the `GetAccountDetails` endpoint for an account +pub enum AccountDetails { + OffChain(AccountId, AccountUpdateSummary), + Public(Account, AccountUpdateSummary), +} + +/// Contains public updated information about the account requested +pub struct AccountUpdateSummary { + /// Account hash + pub hash: Digest, + /// Block number of last account update + pub last_block_num: u32, +} + +impl AccountUpdateSummary { + pub fn new(hash: Digest, last_block_num: u32) -> Self { + Self { hash, last_block_num } + } +} + /// Contains information related to the note inclusion, but not related to the block header /// that contains the note pub struct NoteInclusionDetails { @@ -55,13 +84,16 @@ pub trait NodeRpcClient { ) -> Result<(), NodeRpcClientError>; /// Given a block number, fetches the block header corresponding to that height from the node - /// using the `/GetBlockHeaderByNumber` endpoint + /// using the `/GetBlockHeaderByNumber` endpoint. + /// If `include_mmr_proof` is set to true and the function returns an `Ok`, the second value + /// of the return tuple should always be Some(MmrProof) /// /// When `None` is provided, returns info regarding the latest block async fn get_block_header_by_number( &mut self, - block_number: Option, - ) -> Result; + block_num: Option, + include_mmr_proof: bool, + ) -> Result<(BlockHeader, Option), NodeRpcClientError>; /// Fetches note-related data for a list of [NoteId] using the `/GetNotesById` rpc endpoint /// @@ -98,7 +130,7 @@ pub trait NodeRpcClient { async fn get_account_update( &mut self, account_id: AccountId, - ) -> Result; + ) -> Result; } // STATE SYNC INFO @@ -162,7 +194,6 @@ impl CommittedNote { &self.merkle_path } - #[allow(dead_code)] pub fn metadata(&self) -> NoteMetadata { self.metadata } diff --git a/src/client/rpc/tonic_client.rs b/src/client/rpc/tonic_client.rs index 56a720d94..41bfa5024 100644 --- a/src/client/rpc/tonic_client.rs +++ b/src/client/rpc/tonic_client.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use async_trait::async_trait; use miden_node_proto::{ errors::ConversionError, @@ -12,6 +14,7 @@ use miden_node_proto::{ }; use miden_objects::{ accounts::{Account, AccountId}, + crypto::merkle::{MerklePath, MmrProof}, notes::{Note, NoteId, NoteMetadata, NoteTag, NoteType}, transaction::ProvenTransaction, utils::Deserializable, @@ -21,10 +24,10 @@ use miden_tx::utils::Serializable; use tonic::transport::Channel; use super::{ - CommittedNote, NodeRpcClient, NodeRpcClientEndpoint, NoteDetails, NoteInclusionDetails, - StateSyncInfo, + AccountDetails, AccountUpdateSummary, CommittedNote, NodeRpcClient, NodeRpcClientEndpoint, + NoteDetails, NoteInclusionDetails, StateSyncInfo, }; -use crate::errors::NodeRpcClientError; +use crate::{config::RpcConfig, errors::NodeRpcClientError}; // TONIC RPC CLIENT // ================================================================================================ @@ -35,14 +38,16 @@ use crate::errors::NodeRpcClientError; pub struct TonicRpcClient { rpc_api: Option>, endpoint: String, + timeout_ms: u64, } impl TonicRpcClient { /// Returns a new instance of [TonicRpcClient] that'll do calls the `config_endpoint` provided - pub fn new(config_endpoint: &str) -> TonicRpcClient { + pub fn new(config: &RpcConfig) -> TonicRpcClient { TonicRpcClient { rpc_api: None, - endpoint: config_endpoint.to_string(), + endpoint: config.endpoint.to_string(), + timeout_ms: config.timeout_ms, } } @@ -52,7 +57,10 @@ impl TonicRpcClient { if self.rpc_api.is_some() { Ok(self.rpc_api.as_mut().unwrap()) } else { - let rpc_api = ApiClient::connect(self.endpoint.clone()) + let endpoint = tonic::transport::Endpoint::try_from(self.endpoint.clone()) + .map_err(|err| NodeRpcClientError::ConnectionError(err.to_string()))? + .timeout(Duration::from_millis(self.timeout_ms)); + let rpc_api = ApiClient::connect(endpoint) .await .map_err(|err| NodeRpcClientError::ConnectionError(err.to_string()))?; Ok(self.rpc_api.insert(rpc_api)) @@ -83,8 +91,13 @@ impl NodeRpcClient for TonicRpcClient { async fn get_block_header_by_number( &mut self, block_num: Option, - ) -> Result { - let request = GetBlockHeaderByNumberRequest { block_num }; + include_mmr_proof: bool, + ) -> Result<(BlockHeader, Option), NodeRpcClientError> { + let request = GetBlockHeaderByNumberRequest { + block_num, + include_mmr_proof: Some(include_mmr_proof), + }; + let rpc_api = self.rpc_api().await?; let api_response = rpc_api.get_block_header_by_number(request).await.map_err(|err| { NodeRpcClientError::RequestError( @@ -93,12 +106,38 @@ impl NodeRpcClient for TonicRpcClient { ) })?; - api_response - .into_inner() + let response = api_response.into_inner(); + + let block_header: BlockHeader = response .block_header .ok_or(NodeRpcClientError::ExpectedFieldMissing("BlockHeader".into()))? .try_into() - .map_err(|err: ConversionError| NodeRpcClientError::ConversionFailure(err.to_string())) + .map_err(|err: ConversionError| { + NodeRpcClientError::ConversionFailure(err.to_string()) + })?; + + let mmr_proof = if include_mmr_proof { + let forest = response + .chain_length + .ok_or(NodeRpcClientError::ExpectedFieldMissing("ChainLength".into()))?; + let merkle_path: MerklePath = response + .mmr_path + .ok_or(NodeRpcClientError::ExpectedFieldMissing("MmrPath".into()))? + .try_into() + .map_err(|err: ConversionError| { + NodeRpcClientError::ConversionFailure(err.to_string()) + })?; + + Some(MmrProof { + forest: forest as usize, + position: block_header.block_num() as usize, + merkle_path, + }) + } else { + None + }; + + Ok((block_header, mmr_proof)) } async fn get_notes_by_id( @@ -119,8 +158,11 @@ impl NodeRpcClient for TonicRpcClient { let rpc_notes = api_response.into_inner().notes; let mut response_notes = Vec::with_capacity(rpc_notes.len()); for note in rpc_notes { - let sender_id = - note.sender.ok_or(NodeRpcClientError::ExpectedFieldMissing("Sender".into()))?; + let sender_id = note + .metadata + .clone() + .and_then(|metadata| metadata.sender) + .ok_or(NodeRpcClientError::ExpectedFieldMissing("Metadata.Sender".into()))?; let inclusion_details = { let merkle_path = note @@ -140,7 +182,11 @@ impl NodeRpcClient for TonicRpcClient { }, // Off-chain notes do not have details None => { - let note_tag = NoteTag::from(note.tag).validate(NoteType::OffChain)?; + let tag = note + .metadata + .ok_or(NodeRpcClientError::ExpectedFieldMissing("Metadata".into()))? + .tag; + let note_tag = NoteTag::from(tag).validate(NoteType::OffChain)?; let note_metadata = NoteMetadata::new( sender_id.try_into()?, NoteType::OffChain, @@ -192,30 +238,21 @@ impl NodeRpcClient for TonicRpcClient { response.into_inner().try_into() } - /// Sends a [GetAccountDetailsRequest] to the Miden node, and extracts an [Account] from the + /// Sends a [GetAccountDetailsRequest] to the Miden node, and extracts an [AccountDetails] from the /// `GetAccountDetailsResponse` response. /// /// # Errors /// /// This function will return an error if: /// - /// - The provided account is not on-chain: this is due to the fact that for offchain accounts - /// the client is responsible /// - There was an error sending the request to the node - /// - The answer had a `None` for its account, or the account had a `None` at the `details` field. + /// - The answer had a `None` for one of the expected fields (account, summary, account_hash, details). /// - There is an error during [Account] deserialization async fn get_account_update( &mut self, account_id: AccountId, - ) -> Result { - if !account_id.is_on_chain() { - return Err(NodeRpcClientError::InvalidAccountReceived( - "should only get updates for offchain accounts".to_string(), - )); - } - - let account_id = account_id.into(); - let request = GetAccountDetailsRequest { account_id: Some(account_id) }; + ) -> Result { + let request = GetAccountDetailsRequest { account_id: Some(account_id.into()) }; let rpc_api = self.rpc_api().await?; @@ -230,14 +267,30 @@ impl NodeRpcClient for TonicRpcClient { "GetAccountDetails response should have an `account`".to_string(), ))?; - let details_bytes = - account_info.details.ok_or(NodeRpcClientError::ExpectedFieldMissing( - "GetAccountDetails response's account should have `details`".to_string(), + let account_summary = + account_info.summary.ok_or(NodeRpcClientError::ExpectedFieldMissing( + "GetAccountDetails response's account should have a `summary`".to_string(), ))?; - let details = Account::read_from_bytes(&details_bytes)?; + let hash = account_summary.account_hash.ok_or(NodeRpcClientError::ExpectedFieldMissing( + "GetAccountDetails response's account should have an `account_hash`".to_string(), + ))?; + + let hash = hash.try_into()?; + + let update_summary = AccountUpdateSummary::new(hash, account_summary.block_num); + if account_id.is_on_chain() { + let details_bytes = + account_info.details.ok_or(NodeRpcClientError::ExpectedFieldMissing( + "GetAccountDetails response's account should have `details`".to_string(), + ))?; + + let account = Account::read_from_bytes(&details_bytes)?; - Ok(details) + Ok(AccountDetails::Public(account, update_summary)) + } else { + Ok(AccountDetails::OffChain(account_id, update_summary)) + } } } @@ -296,17 +349,26 @@ impl TryFrom for StateSyncInfo { .try_into()?; let sender_account_id = note - .sender - .ok_or(NodeRpcClientError::ExpectedFieldMissing("Notes.Sender".into()))? + .metadata + .clone() + .and_then(|m| m.sender) + .ok_or(NodeRpcClientError::ExpectedFieldMissing("Notes.Metadata.Sender".into()))? .try_into()?; - let note_type = NoteType::try_from(Felt::new(note.note_type.into()))?; - let metadata = NoteMetadata::new( - sender_account_id, - note_type, - note.tag.into(), - Default::default(), - )?; + let tag = note + .metadata + .clone() + .ok_or(NodeRpcClientError::ExpectedFieldMissing("Notes.Metadata".into()))? + .tag; + + let note_type = note + .metadata + .ok_or(NodeRpcClientError::ExpectedFieldMissing("Notes.Metadata".into()))? + .note_type; + + let note_type = NoteType::try_from(note_type)?; + let metadata = + NoteMetadata::new(sender_account_id, note_type, tag.into(), Default::default())?; let committed_note = CommittedNote::new(note_id, note.note_index, merkle_path, metadata); diff --git a/src/client/store_authenticator.rs b/src/client/store_authenticator.rs new file mode 100644 index 000000000..e6976af82 --- /dev/null +++ b/src/client/store_authenticator.rs @@ -0,0 +1,102 @@ +use alloc::rc::Rc; +use core::cell::RefCell; + +use miden_objects::{ + accounts::{AccountDelta, AuthSecretKey}, + crypto::dsa::rpo_falcon512::{self, Polynomial}, + Digest, Felt, Word, +}; +use miden_tx::{AuthenticationError, TransactionAuthenticator}; +use rand::Rng; + +use crate::store::Store; + +/// Represents an authenticator based on a [Store] +pub struct StoreAuthenticator { + store: Rc, + rng: RefCell, +} + +impl StoreAuthenticator { + pub fn new_with_rng(store: Rc, rng: R) -> Self { + StoreAuthenticator { store, rng: RefCell::new(rng) } + } +} + +impl TransactionAuthenticator for StoreAuthenticator { + /// Gets a signature over a message, given a public key. + /// + /// The pub key should correspond to one of the keys tracked by the authenticator's store. + /// + /// # Errors + /// If the public key is not found in the store, [AuthenticationError::UnknownKey] is + /// returned. + fn get_signature( + &self, + pub_key: Word, + message: Word, + _account_delta: &AccountDelta, + ) -> Result, AuthenticationError> { + let mut rng = self.rng.borrow_mut(); + + let secret_key = self + .store + .get_account_auth_by_pub_key(pub_key) + .map_err(|_| AuthenticationError::UnknownKey(format!("{}", Digest::from(pub_key))))?; + + let AuthSecretKey::RpoFalcon512(k) = secret_key; + get_falcon_signature(&k, message, &mut *rng) + } +} +// HELPER FUNCTIONS +// ================================================================================================ + +// TODO: Remove the falcon signature function once it's available on base and made public + +/// Retrieves a falcon signature over a message. +/// Gets as input a [Word] containing a secret key, and a [Word] representing a message and +/// outputs a vector of values to be pushed onto the advice stack. +/// The values are the ones required for a Falcon signature verification inside the VM and they are: +/// +/// 1. The nonce represented as 8 field elements. +/// 2. The expanded public key represented as the coefficients of a polynomial of degree < 512. +/// 3. The signature represented as the coefficients of a polynomial of degree < 512. +/// 4. The product of the above two polynomials in the ring of polynomials with coefficients +/// in the Miden field. +/// +/// # Errors +/// Will return an error if either: +/// - The secret key is malformed due to either incorrect length or failed decoding. +/// - The signature generation failed. +/// +/// TODO: once this gets made public in miden base, remve this implementation and use the one from +/// base +fn get_falcon_signature( + key: &rpo_falcon512::SecretKey, + message: Word, + rng: &mut R, +) -> Result, AuthenticationError> { + // Generate the signature + let sig = key.sign_with_rng(message, rng); + // The signature is composed of a nonce and a polynomial s2 + // The nonce is represented as 8 field elements. + let nonce = sig.nonce(); + // We convert the signature to a polynomial + let s2 = sig.sig_poly(); + // We also need in the VM the expanded key corresponding to the public key the was provided + // via the operand stack + let h = key.compute_pub_key_poly().0; + // Lastly, for the probabilistic product routine that is part of the verification procedure, + // we need to compute the product of the expanded key and the signature polynomial in + // the ring of polynomials with coefficients in the Miden field. + let pi = Polynomial::mul_modulo_p(&h, s2); + // We now push the nonce, the expanded key, the signature polynomial, and the product of the + // expanded key and the signature polynomial to the advice stack. + let mut result: Vec = nonce.to_elements().to_vec(); + + result.extend(h.coefficients.iter().map(|a| Felt::from(a.value() as u32))); + result.extend(s2.coefficients.iter().map(|a| Felt::from(a.value() as u32))); + result.extend(pi.iter().map(|a| Felt::new(*a))); + result.reverse(); + Ok(result) +} diff --git a/src/client/sync.rs b/src/client/sync.rs index c910508eb..5a2ae1c39 100644 --- a/src/client/sync.rs +++ b/src/client/sync.rs @@ -1,68 +1,159 @@ -use std::collections::BTreeSet; +use alloc::collections::{BTreeMap, BTreeSet}; +use core::cmp::max; use crypto::merkle::{InOrderIndex, MmrDelta, MmrPeaks, PartialMmr}; use miden_objects::{ accounts::{Account, AccountId, AccountStub}, - crypto::{self, rand::FeltRng}, - notes::{NoteExecutionMode, NoteId, NoteInclusionProof, NoteTag}, + crypto::{self, merkle::MerklePath, rand::FeltRng}, + notes::{Note, NoteId, NoteInclusionProof, NoteInputs, NoteRecipient, NoteTag}, transaction::{InputNote, TransactionId}, BlockHeader, Digest, }; +use miden_tx::TransactionAuthenticator; use tracing::{info, warn}; use super::{ rpc::{CommittedNote, NodeRpcClient, NoteDetails}, transactions::TransactionRecord, - Client, + Client, NoteScreener, }; use crate::{ - errors::{ClientError, StoreError}, - store::{ChainMmrNodeFilter, NoteFilter, Store, TransactionFilter}, + client::rpc::AccountDetails, + errors::{ClientError, NodeRpcClientError, StoreError}, + store::{ChainMmrNodeFilter, InputNoteRecord, NoteFilter, Store, TransactionFilter}, }; +/// Contains stats about the sync operation +pub struct SyncSummary { + /// Block number up to which the client has been synced + pub block_num: u32, + /// Number of new notes received + pub new_notes: usize, + /// Number of tracked notes that received inclusion proofs + pub new_inclusion_proofs: usize, + /// Number of new nullifiers received + pub new_nullifiers: usize, + /// Number of on-chain accounts that have been updated + pub updated_onchain_accounts: usize, + /// Number of commited transactions + pub commited_transactions: usize, +} + +impl SyncSummary { + pub fn new( + block_num: u32, + new_notes: usize, + new_inclusion_proofs: usize, + new_nullifiers: usize, + updated_onchain_accounts: usize, + commited_transactions: usize, + ) -> Self { + Self { + block_num, + new_notes, + new_inclusion_proofs, + new_nullifiers, + updated_onchain_accounts, + commited_transactions, + } + } + + pub fn new_empty(block_num: u32) -> Self { + Self { + block_num, + new_notes: 0, + new_inclusion_proofs: 0, + new_nullifiers: 0, + updated_onchain_accounts: 0, + commited_transactions: 0, + } + } + + pub fn is_empty(&self) -> bool { + self.new_notes == 0 + && self.new_inclusion_proofs == 0 + && self.new_nullifiers == 0 + && self.updated_onchain_accounts == 0 + } + + pub fn combine_with(&mut self, other: &Self) { + self.block_num = max(self.block_num, other.block_num); + self.new_notes += other.new_notes; + self.new_inclusion_proofs += other.new_inclusion_proofs; + self.new_nullifiers += other.new_nullifiers; + self.updated_onchain_accounts += other.updated_onchain_accounts; + self.commited_transactions += other.commited_transactions; + } +} + pub enum SyncStatus { - SyncedToLastBlock(u32), - SyncedToBlock(u32), + SyncedToLastBlock(SyncSummary), + SyncedToBlock(SyncSummary), } /// Contains information about new notes as consequence of a sync pub struct SyncedNewNotes { /// A list of public notes that have been received on sync new_public_notes: Vec, + /// A list of input notes corresponding to updated locally-tracked input notes + updated_input_notes: Vec, /// A list of note IDs alongside their inclusion proofs for locally-tracked - /// notes - new_inclusion_proofs: Vec<(NoteId, NoteInclusionProof)>, + /// output notes + updated_output_notes: Vec<(NoteId, NoteInclusionProof)>, } impl SyncedNewNotes { pub fn new( new_public_notes: Vec, - new_inclusion_proofs: Vec<(NoteId, NoteInclusionProof)>, + updated_input_notes: Vec, + updated_output_notes: Vec<(NoteId, NoteInclusionProof)>, ) -> Self { - Self { new_public_notes, new_inclusion_proofs } + Self { + new_public_notes, + updated_input_notes, + updated_output_notes, + } } pub fn new_public_notes(&self) -> &[InputNote] { &self.new_public_notes } - pub fn new_inclusion_proofs(&self) -> &[(NoteId, NoteInclusionProof)] { - &self.new_inclusion_proofs + pub fn updated_input_notes(&self) -> &[InputNote] { + &self.updated_input_notes + } + + pub fn updated_output_notes(&self) -> &[(NoteId, NoteInclusionProof)] { + &self.updated_output_notes } /// Returns whether no new note-related information has been retrieved pub fn is_empty(&self) -> bool { - self.new_inclusion_proofs.is_empty() && self.new_public_notes.is_empty() + self.updated_input_notes.is_empty() + && self.updated_output_notes.is_empty() + && self.new_public_notes.is_empty() } } +/// Contains all information needed to perform the update after syncing with the node +pub struct StateSyncUpdate { + pub block_header: BlockHeader, + pub nullifiers: Vec, + pub synced_new_notes: SyncedNewNotes, + pub transactions_to_commit: Vec, + pub new_mmr_peaks: MmrPeaks, + pub new_authentication_nodes: Vec<(InOrderIndex, Digest)>, + pub updated_onchain_accounts: Vec, + pub block_has_relevant_notes: bool, +} + // CONSTANTS // ================================================================================================ /// The number of bits to shift identifiers for in use of filters. pub const FILTER_ID_SHIFT: u8 = 48; -impl Client { +impl Client { // SYNC STATE // -------------------------------------------------------------------------------------------- @@ -72,12 +163,16 @@ impl Client { } /// Returns the list of note tags tracked by the client. - pub fn get_note_tags(&self) -> Result, ClientError> { + /// + /// When syncing the state with the node, these tags will be added to the sync request and note-related information will be retrieved for notes that have matching tags. + /// + /// Note: Tags for accounts that are being tracked by the client are managed automatically by the client and do not need to be added here. That is, notes for managed accounts will be retrieved automatically by the client when syncing. + pub fn get_note_tags(&self) -> Result, ClientError> { self.store.get_note_tags().map_err(|err| err.into()) } /// Adds a note tag for the client to track. - pub fn add_note_tag(&mut self, tag: u64) -> Result<(), ClientError> { + pub fn add_note_tag(&mut self, tag: NoteTag) -> Result<(), ClientError> { match self.store.add_note_tag(tag).map_err(|err| err.into()) { Ok(true) => Ok(()), Ok(false) => { @@ -88,16 +183,34 @@ impl Client { } } + /// Removes a note tag for the client to track. + pub fn remove_note_tag(&mut self, tag: NoteTag) -> Result<(), ClientError> { + match self.store.remove_note_tag(tag)? { + true => Ok(()), + false => { + warn!("Tag {} wasn't being tracked", tag); + Ok(()) + }, + } + } + /// Syncs the client's state with the current state of the Miden network. /// Before doing so, it ensures the genesis block exists in the local store. /// /// Returns the block number the client has been synced to. - pub async fn sync_state(&mut self) -> Result { + pub async fn sync_state(&mut self) -> Result { self.ensure_genesis_in_place().await?; + let mut total_sync_details = SyncSummary::new_empty(0); loop { let response = self.sync_state_once().await?; - if let SyncStatus::SyncedToLastBlock(v) = response { - return Ok(v); + let details = match &response { + SyncStatus::SyncedToLastBlock(v) => v, + SyncStatus::SyncedToBlock(v) => v, + }; + total_sync_details.combine_with(details); + + if let SyncStatus::SyncedToLastBlock(_) = response { + return Ok(total_sync_details); } } } @@ -117,7 +230,7 @@ impl Client { /// Calls `get_block_header_by_number` requesting the genesis block and storing it /// in the local database async fn retrieve_and_store_genesis(&mut self) -> Result<(), ClientError> { - let genesis_block = self.rpc_api.get_block_header_by_number(Some(0)).await?; + let (genesis_block, _) = self.rpc_api.get_block_header_by_number(Some(0), false).await?; let blank_mmr_peaks = MmrPeaks::new(0, vec![]).expect("Blank MmrPeaks should not fail to instantiate"); @@ -137,11 +250,29 @@ impl Client { .map(|(acc_stub, _)| acc_stub) .collect(); - let note_tags: Vec = accounts + let account_note_tags: Vec = accounts .iter() - .map(|acc| NoteTag::from_account_id(acc.id(), NoteExecutionMode::Local)) + .map(|acc| { + NoteTag::from_account_id(acc.id(), miden_objects::notes::NoteExecutionHint::Local) + }) .collect::, _>>()?; + let stored_note_tags: Vec = self.store.get_note_tags()?; + + let uncommited_note_tags: Vec = self + .store + .get_input_notes(NoteFilter::Pending)? + .iter() + .filter_map(|note| note.metadata().map(|metadata| metadata.tag())) + .collect(); + + let note_tags: Vec = [account_note_tags, stored_note_tags, uncommited_note_tags] + .concat() + .into_iter() + .collect::>() + .into_iter() + .collect(); + // To receive information about added nullifiers, we reduce them to the higher 16 bits // Note that besides filtering by nullifier prefixes, the node also filters by block number // (it only returns nullifiers from current_block_num until response.block_header.block_num()) @@ -159,14 +290,23 @@ impl Client { .sync_state(current_block_num, &account_ids, ¬e_tags, &nullifiers_tags) .await?; - // We don't need to continue if the chain has not advanced + // We don't need to continue if the chain has not advanced, there are no new changes if response.block_header.block_num() == current_block_num { - return Ok(SyncStatus::SyncedToLastBlock(current_block_num)); + return Ok(SyncStatus::SyncedToLastBlock(SyncSummary::new_empty(current_block_num))); } + let committed_note_ids: Vec = response + .note_inclusions + .iter() + .map(|committed_note| *(committed_note.note_id())) + .collect(); + let new_note_details = self.get_note_details(response.note_inclusions, &response.block_header).await?; + let incoming_block_has_relevant_notes = + self.check_block_relevance(&new_note_details).await?; + let (onchain_accounts, offchain_accounts): (Vec<_>, Vec<_>) = accounts.into_iter().partition(|account_stub| account_stub.id().is_on_chain()); @@ -193,36 +333,59 @@ impl Client { )? }; - let note_ids: Vec = - new_note_details.new_inclusion_proofs.iter().map(|(id, _)| (*id)).collect(); - let uncommitted_transactions = self.store.get_transactions(TransactionFilter::Uncomitted)?; let transactions_to_commit = get_transactions_to_commit( &uncommitted_transactions, - ¬e_ids, + &committed_note_ids, &new_nullifiers, &response.account_hash_updates, ); + let num_new_notes = new_note_details.new_public_notes.len(); + let updated_ids: BTreeSet = new_note_details + .updated_input_notes + .iter() + .map(|n| n.note().id()) + .chain(new_note_details.updated_output_notes.iter().map(|(id, _)| *id)) + .collect(); + let num_new_inclusion_proofs = updated_ids.len(); + let num_new_nullifiers = new_nullifiers.len(); + let state_sync_update = StateSyncUpdate { + block_header: response.block_header, + nullifiers: new_nullifiers, + synced_new_notes: new_note_details, + transactions_to_commit: transactions_to_commit.clone(), + new_mmr_peaks: new_peaks, + new_authentication_nodes, + updated_onchain_accounts: updated_onchain_accounts.clone(), + block_has_relevant_notes: incoming_block_has_relevant_notes, + }; + // Apply received and computed updates to the store self.store - .apply_state_sync( - response.block_header, - new_nullifiers, - new_note_details, - &transactions_to_commit, - new_peaks, - &new_authentication_nodes, - &updated_onchain_accounts, - ) + .apply_state_sync(state_sync_update) .map_err(ClientError::StoreError)?; if response.chain_tip == response.block_header.block_num() { - Ok(SyncStatus::SyncedToLastBlock(response.chain_tip)) + Ok(SyncStatus::SyncedToLastBlock(SyncSummary::new( + response.chain_tip, + num_new_notes, + num_new_inclusion_proofs, + num_new_nullifiers, + updated_onchain_accounts.len(), + transactions_to_commit.len(), + ))) } else { - Ok(SyncStatus::SyncedToBlock(response.block_header.block_num())) + Ok(SyncStatus::SyncedToBlock(SyncSummary::new( + response.block_header.block_num(), + num_new_notes, + num_new_inclusion_proofs, + num_new_nullifiers, + updated_onchain_accounts.len(), + transactions_to_commit.len(), + ))) } } @@ -241,35 +404,67 @@ impl Client { // we might get many notes when we only care about a few of those. let mut new_public_notes = vec![]; - let mut local_notes_proofs = vec![]; - - let pending_input_notes = - self.store.get_input_notes(NoteFilter::Pending)?.into_iter().map(|n| n.id()); - - let pending_output_notes = - self.store.get_output_notes(NoteFilter::Pending)?.into_iter().map(|n| n.id()); + let mut tracked_input_notes = vec![]; + let mut tracked_output_notes_proofs = vec![]; - let mut all_pending_notes: BTreeSet = BTreeSet::new(); + let pending_input_notes: BTreeMap = self + .store + .get_input_notes(NoteFilter::Pending)? + .into_iter() + .map(|n| (n.id(), n)) + .collect(); - pending_input_notes.chain(pending_output_notes).for_each(|id| { - all_pending_notes.insert(id); - }); + let pending_output_notes: BTreeSet = self + .store + .get_output_notes(NoteFilter::Pending)? + .into_iter() + .map(|n| n.id()) + .collect(); for committed_note in committed_notes { - if all_pending_notes.contains(committed_note.note_id()) { + if let Some(note_record) = pending_input_notes.get(committed_note.note_id()) { // The note belongs to our locally tracked set of pending notes, build the inclusion proof - let note_with_inclusion_proof = NoteInclusionProof::new( + let note_inclusion_proof = NoteInclusionProof::new( + block_header.block_num(), + block_header.sub_hash(), + block_header.note_root(), + committed_note.note_index().into(), + committed_note.merkle_path().clone(), + )?; + + let note_inputs = NoteInputs::new(note_record.details().inputs().clone())?; + let note_recipient = NoteRecipient::new( + note_record.details().serial_num(), + note_record.details().script().clone(), + note_inputs, + ); + let note = Note::new( + note_record.assets().clone(), + committed_note.metadata(), + note_recipient, + ); + + let input_note = InputNote::new(note, note_inclusion_proof); + + tracked_input_notes.push(input_note); + } + + if pending_output_notes.contains(committed_note.note_id()) { + let note_id_with_inclusion_proof = NoteInclusionProof::new( block_header.block_num(), block_header.sub_hash(), block_header.note_root(), committed_note.note_index().into(), committed_note.merkle_path().clone(), ) - .map_err(ClientError::NoteError) - .map(|proof| (*committed_note.note_id(), proof))?; + .map(|note_inclusion_proof| (*committed_note.note_id(), note_inclusion_proof))?; - local_notes_proofs.push(note_with_inclusion_proof); - } else { + tracked_output_notes_proofs.push(note_id_with_inclusion_proof); + } + + if !pending_input_notes.contains_key(committed_note.note_id()) + && !pending_output_notes.contains(committed_note.note_id()) + { // The note is public and we are not tracking it, push to the list of IDs to query new_public_notes.push(*committed_note.note_id()); } @@ -279,7 +474,11 @@ impl Client { let new_public_notes = self.fetch_public_note_details(&new_public_notes, block_header).await?; - Ok(SyncedNewNotes::new(new_public_notes, local_notes_proofs)) + Ok(SyncedNewNotes::new( + new_public_notes, + tracked_input_notes, + tracked_output_notes_proofs, + )) } /// Queries the node for all received notes that are not being locally tracked in the client @@ -323,6 +522,33 @@ impl Client { Ok(return_notes) } + /// Extracts information about notes that the client is interested in, creating the note inclusion + /// proof in order to correctly update store data + async fn check_block_relevance( + &mut self, + committed_notes: &SyncedNewNotes, + ) -> Result { + // We'll only do the check for either incoming public notes or pending input notes as + // output notes are not really candidates to be consumed here. + + let note_screener = NoteScreener::new(self.store.clone()); + + // Find all relevant Input Notes using the note checker + for input_note in committed_notes.updated_input_notes() { + if !note_screener.check_relevance(input_note.note())?.is_empty() { + return Ok(true); + } + } + + for public_input_note in committed_notes.new_public_notes() { + if !note_screener.check_relevance(public_input_note.note())?.is_empty() { + return Ok(true); + } + } + + Ok(false) + } + /// Builds the current view of the chain's [PartialMmr]. Because we want to add all new /// authentication nodes that could come from applying the MMR updates, we need to track all /// known leaves thus far. @@ -381,8 +607,15 @@ impl Client { if let Some(tracked_account) = current_account { info!("On-chain account hash difference detected for account with ID: {}. Fetching node for updates...", tracked_account.id()); - let account = self.rpc_api.get_account_update(tracked_account.id()).await?; - accounts_to_update.push(account); + let account_details = self.rpc_api.get_account_update(tracked_account.id()).await?; + if let AccountDetails::Public(account, _) = account_details { + accounts_to_update.push(account); + } else { + return Err(NodeRpcClientError::InvalidAccountReceived( + "should only get updates for onchain accounts".to_string(), + ) + .into()); + } } } Ok(accounts_to_update) @@ -407,6 +640,54 @@ impl Client { } Ok(()) } + + /// Retrieves and stores a [BlockHeader] by number, and stores its authentication data as well. + pub(crate) async fn get_and_store_authenticated_block( + &mut self, + block_num: u32, + ) -> Result { + let mut current_partial_mmr = self.build_current_partial_mmr()?; + + if current_partial_mmr.is_tracked(block_num as usize) { + warn!("Current partial MMR already contains the requested data"); + let (block_header, _) = self.store.get_block_header_by_num(block_num)?; + return Ok(block_header); + } + + let (block_header, mmr_proof) = + self.rpc_api.get_block_header_by_number(Some(block_num), true).await?; + + let mut path_nodes: Vec<(InOrderIndex, Digest)> = vec![]; + + let mmr_proof = mmr_proof + .expect("NodeRpcApi::get_block_header_by_number() should have returned an MMR proof"); + // Trim merkle path to keep nodes relevant to our current PartialMmr + let rightmost_index = InOrderIndex::from_leaf_pos(current_partial_mmr.forest() - 1); + let mut idx = InOrderIndex::from_leaf_pos(block_num as usize); + for node in mmr_proof.merkle_path { + idx = idx.sibling(); + // Rightmost index is always the biggest value, so if the path contains any node + // past it, we can discard it for our version of the forest + if idx > rightmost_index { + continue; + } + path_nodes.push((idx, node)); + idx = idx.parent(); + } + + let merkle_path = MerklePath::new(path_nodes.iter().map(|(_, n)| *n).collect()); + + current_partial_mmr + .track(block_num as usize, block_header.hash(), &merkle_path) + .map_err(StoreError::MmrError)?; + + // Insert header and MMR nodes + self.store + .insert_block_header(block_header, current_partial_mmr.peaks(), true)?; + self.store.insert_chain_mmr_nodes(&path_nodes)?; + + Ok(block_header) + } } // UTILS @@ -449,7 +730,7 @@ fn apply_mmr_changes( // final_account_state fn get_transactions_to_commit( uncommitted_transactions: &[TransactionRecord], - _note_ids: &[NoteId], + note_ids: &[NoteId], nullifiers: &[Digest], account_hash_updates: &[(AccountId, Digest)], ) -> Vec { @@ -460,12 +741,10 @@ fn get_transactions_to_commit( // https://github.com/0xPolygonMiden/miden-client/issues/144, we should be aware // that in the future it'll be possible to have many transactions modifying an // account be included in a single block. If that happens, we'll need to rewrite - // this check + // this check. - // TODO: Review this. Because we receive note IDs based on account ID tags, - // we cannot base the status change on output notes alone; t.input_note_nullifiers.iter().all(|n| nullifiers.contains(n)) - //&& t.output_notes.iter().all(|n| note_ids.contains(&n.id())) + && t.output_notes.iter().all(|n| note_ids.contains(&n.id())) && account_hash_updates.iter().any(|(account_id, account_hash)| { *account_id == t.account_id && *account_hash == t.final_account_state }) diff --git a/src/client/transactions/mod.rs b/src/client/transactions/mod.rs index f8dbaa0fd..0a9e3d556 100644 --- a/src/client/transactions/mod.rs +++ b/src/client/transactions/mod.rs @@ -1,28 +1,30 @@ use alloc::collections::{BTreeMap, BTreeSet}; -use miden_lib::notes::{create_p2id_note, create_p2idr_note}; +use miden_lib::notes::{create_p2id_note, create_p2idr_note, create_swap_note}; use miden_objects::{ - accounts::{AccountDelta, AccountId}, + accounts::{AccountDelta, AccountId, AuthSecretKey}, assembly::ProgramAst, assets::FungibleAsset, crypto::rand::RpoRandomCoin, - notes::{Note, NoteId, NoteType}, + notes::{Note, NoteDetails, NoteId, NoteType}, transaction::{ - ExecutedTransaction, OutputNotes, ProvenTransaction, TransactionArgs, TransactionId, - TransactionScript, + ExecutedTransaction, InputNotes, OutputNote, OutputNotes, ProvenTransaction, + TransactionArgs, TransactionId, TransactionScript, }, Digest, Felt, Word, }; -use miden_tx::{ProvingOptions, ScriptTarget, TransactionProver}; +use miden_tx::{ProvingOptions, ScriptTarget, TransactionAuthenticator, TransactionProver}; use rand::Rng; use tracing::info; -use self::transaction_request::{PaymentTransactionData, TransactionRequest, TransactionTemplate}; -use super::{note_screener::NoteRelevance, rpc::NodeRpcClient, Client, FeltRng}; +use self::transaction_request::{ + PaymentTransactionData, SwapTransactionData, TransactionRequest, TransactionTemplate, +}; +use super::{rpc::NodeRpcClient, Client, FeltRng}; use crate::{ client::NoteScreener, errors::ClientError, - store::{AuthInfo, Store, TransactionFilter}, + store::{InputNoteRecord, Store, TransactionFilter}, }; pub mod transaction_request; @@ -30,62 +32,68 @@ pub mod transaction_request; // TRANSACTION RESULT // -------------------------------------------------------------------------------------------- -/// Represents the result of executing a transaction by the client +/// Represents the result of executing a transaction by the client. /// -/// It contains an [ExecutedTransaction], a list of [Note] that describe the details of the notes -/// created by the transaction execution, and a list of `usize` `relevant_notes` that contain the -/// indices of `output_notes` that are relevant to the client +/// It contains an [ExecutedTransaction], and a list of `relevant_notes` that contains the +/// `output_notes` that the client has to store as input notes, based on the NoteScreener +/// output from filtering the transaction's output notes or some partial note we expect to receive +/// in the future (you can check at swap notes for an example of this). pub struct TransactionResult { - executed_transaction: ExecutedTransaction, - output_notes: Vec, - relevant_notes: Option>>, + transaction: ExecutedTransaction, + relevant_notes: Vec, } impl TransactionResult { - pub fn new(executed_transaction: ExecutedTransaction, created_notes: Vec) -> Self { - Self { - executed_transaction, - output_notes: created_notes, - relevant_notes: None, + /// Screens the output notes to store and track the relevant ones, and instantiates a [TransactionResult] + pub fn new( + transaction: ExecutedTransaction, + note_screener: NoteScreener, + partial_notes: Vec, + ) -> Result { + let mut relevant_notes = vec![]; + + for note in notes_from_output(transaction.output_notes()) { + let account_relevance = note_screener.check_relevance(note)?; + + if !account_relevance.is_empty() { + relevant_notes.push(note.clone().into()); + } } - } - pub fn executed_transaction(&self) -> &ExecutedTransaction { - &self.executed_transaction + // Include partial output notes into the relevant notes + relevant_notes.extend(partial_notes.iter().map(InputNoteRecord::from)); + + let tx_result = Self { transaction, relevant_notes }; + + Ok(tx_result) } - pub fn created_notes(&self) -> &Vec { - &self.output_notes + pub fn executed_transaction(&self) -> &ExecutedTransaction { + &self.transaction } - pub fn relevant_notes(&self) -> Vec<&Note> { - if let Some(relevant_notes) = &self.relevant_notes { - relevant_notes - .keys() - .map(|note_index| &self.output_notes[*note_index]) - .collect() - } else { - self.created_notes().iter().collect() - } + pub fn created_notes(&self) -> &OutputNotes { + self.transaction.output_notes() } - pub fn set_relevant_notes( - &mut self, - relevant_notes: BTreeMap>, - ) { - self.relevant_notes = Some(relevant_notes); + pub fn relevant_notes(&self) -> &[InputNoteRecord] { + &self.relevant_notes } pub fn block_num(&self) -> u32 { - self.executed_transaction.block_header().block_num() + self.transaction.block_header().block_num() } pub fn transaction_arguments(&self) -> &TransactionArgs { - self.executed_transaction.tx_args() + self.transaction.tx_args() } pub fn account_delta(&self) -> &AccountDelta { - self.executed_transaction.account_delta() + self.transaction.account_delta() + } + + pub fn consumed_notes(&self) -> &InputNotes { + self.transaction.tx_inputs().input_notes() } } @@ -154,7 +162,7 @@ impl std::fmt::Display for TransactionStatus { } } -impl Client { +impl Client { // TRANSACTION DATA RETRIEVAL // -------------------------------------------------------------------------------------------- @@ -178,26 +186,33 @@ impl Client { let account_id = transaction_template.account_id(); let account_auth = self.store.get_account_auth(account_id)?; + let (_pk, _sk) = match account_auth { + AuthSecretKey::RpoFalcon512(key) => { + (key.public_key(), AuthSecretKey::RpoFalcon512(key)) + }, + }; + match transaction_template { TransactionTemplate::ConsumeNotes(_, notes) => { let program_ast = ProgramAst::parse(transaction_request::AUTH_CONSUME_NOTES_SCRIPT) .expect("shipped MASM is well-formed"); let notes = notes.iter().map(|id| (*id, None)).collect(); - let tx_script = { - let script_inputs = vec![account_auth.into_advice_inputs()]; - self.tx_executor.compile_tx_script(program_ast, script_inputs, vec![])? - }; - Ok(TransactionRequest::new(account_id, notes, vec![], Some(tx_script))) + let tx_script = self.tx_executor.compile_tx_script(program_ast, vec![], vec![])?; + Ok(TransactionRequest::new(account_id, notes, vec![], vec![], Some(tx_script))) }, TransactionTemplate::MintFungibleAsset(asset, target_account_id, note_type) => { - self.build_mint_tx_request(asset, account_auth, target_account_id, note_type) + self.build_mint_tx_request(asset, target_account_id, note_type) }, TransactionTemplate::PayToId(payment_data, note_type) => { - self.build_p2id_tx_request(account_auth, payment_data, None, note_type) + self.build_p2id_tx_request(payment_data, None, note_type) + }, + TransactionTemplate::PayToIdWithRecall(payment_data, recall_height, note_type) => { + self.build_p2id_tx_request(payment_data, Some(recall_height), note_type) + }, + TransactionTemplate::Swap(swap_data, note_type) => { + self.build_swap_tx_request(swap_data, note_type) }, - TransactionTemplate::PayToIdWithRecall(payment_data, recall_height, note_type) => self - .build_p2id_tx_request(account_auth, payment_data, Some(recall_height), note_type), } } @@ -208,7 +223,7 @@ impl Client { /// /// - Returns [ClientError::MissingOutputNotes] if the [TransactionRequest] ouput notes are /// not a subset of executor's output notes - /// - Returns a [ClientError::TransactionExecutionError] + /// - Returns a [ClientError::TransactionExecutorError] if the execution fails pub fn new_transaction( &mut self, transaction_request: TransactionRequest, @@ -221,8 +236,8 @@ impl Client { let block_num = self.store.get_sync_height()?; let note_ids = transaction_request.get_input_note_ids(); - let output_notes = transaction_request.expected_output_notes().to_vec(); + let partial_notes = transaction_request.expected_partial_notes().to_vec(); // Execute the transaction and get the witness let executed_transaction = self.tx_executor.execute_transaction( @@ -232,20 +247,29 @@ impl Client { transaction_request.into(), )?; - // Check that the expected output notes is a subset of the transaction's output notes - let tx_note_ids: BTreeSet = - executed_transaction.output_notes().iter().map(|n| n.id()).collect(); + // Check that the expected output notes matches the transaction outcome. + // We comprare authentication hashes where possible since that involves note IDs + metadata + // (as opposed to just note ID which remains the same regardless of metadata) + // We also do the check for partial output notes + let tx_note_auth_hashes: BTreeSet = + notes_from_output(executed_transaction.output_notes()) + .map(Note::authentication_hash) + .collect(); let missing_note_ids: Vec = output_notes .iter() - .filter_map(|n| (!tx_note_ids.contains(&n.id())).then_some(n.id())) + .filter_map(|n| { + (!tx_note_auth_hashes.contains(&n.authentication_hash())).then_some(n.id()) + }) .collect(); if !missing_note_ids.is_empty() { return Err(ClientError::MissingOutputNotes(missing_note_ids)); } - Ok(TransactionResult::new(executed_transaction, output_notes)) + let screener = NoteScreener::new(self.store.clone()); + + TransactionResult::new(executed_transaction, screener, partial_notes) } /// Proves the specified transaction witness, submits it to the node, and stores the transaction in @@ -263,19 +287,6 @@ impl Client { self.submit_proven_transaction_request(proven_transaction.clone()).await?; - let note_screener = NoteScreener::new(&self.store); - let mut relevant_notes = BTreeMap::new(); - - for (idx, note) in tx_result.created_notes().iter().enumerate() { - let account_relevance = note_screener.check_relevance(note)?; - if !account_relevance.is_empty() { - relevant_notes.insert(idx, account_relevance); - } - } - - let mut tx_result = tx_result; - tx_result.set_relevant_notes(relevant_notes); - // Transaction was proven and submitted to the node correctly, persist note details and update account self.store.apply_transaction(tx_result)?; @@ -324,7 +335,6 @@ impl Client { /// - If recall_height is Some(), a P2IDR note will be created. Otherwise, a P2ID is created. fn build_p2id_tx_request( &self, - auth_info: AuthInfo, payment_data: PaymentTransactionData, recall_height: Option, note_type: NoteType, @@ -351,7 +361,8 @@ impl Client { }; let recipient = created_note - .recipient_digest() + .recipient() + .digest() .iter() .map(|x| x.as_int().to_string()) .collect::>() @@ -368,15 +379,63 @@ impl Client { ) .expect("shipped MASM is well-formed"); - let tx_script = { - let script_inputs = vec![auth_info.into_advice_inputs()]; - self.tx_executor.compile_tx_script(tx_script, script_inputs, vec![])? - }; + let tx_script = self.tx_executor.compile_tx_script(tx_script, vec![], vec![])?; Ok(TransactionRequest::new( payment_data.account_id(), BTreeMap::new(), vec![created_note], + vec![], + Some(tx_script), + )) + } + + /// Helper to build a [TransactionRequest] for Swap-type transactions easily. + /// + /// - auth_info has to be from the executor account + fn build_swap_tx_request( + &self, + swap_data: SwapTransactionData, + note_type: NoteType, + ) -> Result { + let random_coin = self.get_random_coin(); + + // The created note is the one that we need as the output of the tx, the other one is the + // one that we expect to receive and consume eventually + let (created_note, payback_note_details) = create_swap_note( + swap_data.account_id(), + swap_data.offered_asset(), + swap_data.requested_asset(), + note_type, + random_coin, + )?; + + let recipient = created_note + .recipient() + .digest() + .iter() + .map(|x| x.as_int().to_string()) + .collect::>() + .join("."); + + let note_tag = created_note.metadata().tag().inner(); + + let tx_script = ProgramAst::parse( + &transaction_request::AUTH_SEND_ASSET_SCRIPT + .replace("{recipient}", &recipient) + .replace("{note_type}", &Felt::new(note_type as u64).to_string()) + .replace("{tag}", &Felt::new(note_tag.into()).to_string()) + .replace("{asset}", &prepare_word(&swap_data.offered_asset().into()).to_string()), + ) + .expect("shipped MASM is well-formed"); + + let tx_script = self.tx_executor.compile_tx_script(tx_script, vec![], vec![])?; + + Ok(TransactionRequest::new( + swap_data.account_id(), + BTreeMap::new(), + vec![created_note], + vec![payback_note_details], Some(tx_script), )) } @@ -387,7 +446,6 @@ impl Client { fn build_mint_tx_request( &self, asset: FungibleAsset, - faucet_auth_info: AuthInfo, target_account_id: AccountId, note_type: NoteType, ) -> Result { @@ -401,7 +459,8 @@ impl Client { )?; let recipient = created_note - .recipient_digest() + .recipient() + .digest() .iter() .map(|x| x.as_int().to_string()) .collect::>() @@ -418,15 +477,13 @@ impl Client { ) .expect("shipped MASM is well-formed"); - let tx_script = { - let script_inputs = vec![faucet_auth_info.into_advice_inputs()]; - self.tx_executor.compile_tx_script(tx_script, script_inputs, vec![])? - }; + let tx_script = self.tx_executor.compile_tx_script(tx_script, vec![], vec![])?; Ok(TransactionRequest::new( asset.faucet_id(), BTreeMap::new(), vec![created_note], + vec![], Some(tx_script), )) } @@ -438,3 +495,21 @@ impl Client { pub(crate) fn prepare_word(word: &Word) -> String { word.iter().map(|x| x.as_int().to_string()).collect::>().join(".") } + +/// Extracts notes from [OutputNotes] +/// Used for: +/// - checking the relevance of notes to save them as input notes +/// - validate hashes versus expected output notes after a transaction is executed +pub(crate) fn notes_from_output(output_notes: &OutputNotes) -> impl Iterator { + output_notes + .iter() + .filter(|n| matches!(n, OutputNote::Full(_))) + .map(|n| match n { + OutputNote::Full(n) => n, + // The following todo!() applies until we have a way to support flows where we have + // partial details of the note + OutputNote::Header(_) => { + todo!("For now, all details should be held in OutputNote::Fulls") + }, + }) +} diff --git a/src/client/transactions/transaction_request.rs b/src/client/transactions/transaction_request.rs index 37b58f879..e12dedf6f 100644 --- a/src/client/transactions/transaction_request.rs +++ b/src/client/transactions/transaction_request.rs @@ -3,7 +3,7 @@ use alloc::collections::BTreeMap; use miden_objects::{ accounts::AccountId, assets::{Asset, FungibleAsset}, - notes::{Note, NoteId, NoteType}, + notes::{Note, NoteDetails, NoteId, NoteType}, transaction::{TransactionArgs, TransactionScript}, vm::AdviceMap, Word, @@ -33,6 +33,8 @@ pub struct TransactionRequest { input_notes: BTreeMap>, /// A list of notes expected to be generated by the transactions. expected_output_notes: Vec, + /// A list of note details of notes we expect to be created as part of future transactions. + expected_partial_notes: Vec, /// Optional transaction script (together with its arguments). tx_script: Option, } @@ -45,12 +47,14 @@ impl TransactionRequest { account_id: AccountId, input_notes: BTreeMap>, expected_output_notes: Vec, + expected_partial_notes: Vec, tx_script: Option, ) -> Self { Self { account_id, input_notes, expected_output_notes, + expected_partial_notes, tx_script, } } @@ -81,6 +85,10 @@ impl TransactionRequest { &self.expected_output_notes } + pub fn expected_partial_notes(&self) -> &[NoteDetails] { + &self.expected_partial_notes + } + pub fn tx_script(&self) -> Option<&TransactionScript> { self.tx_script.as_ref() } @@ -113,6 +121,8 @@ pub enum TransactionTemplate { /// Creates a pay-to-id note directed to a specific account, specifying a block height after /// which the note can be recalled PayToIdWithRecall(PaymentTransactionData, u32, NoteType), + /// Creates a swap note offering a specific asset in exchange for another specific asset + Swap(SwapTransactionData, NoteType), } impl TransactionTemplate { @@ -123,6 +133,7 @@ impl TransactionTemplate { TransactionTemplate::MintFungibleAsset(asset, ..) => asset.faucet_id(), TransactionTemplate::PayToId(payment_data, _) => payment_data.account_id(), TransactionTemplate::PayToIdWithRecall(payment_data, ..) => payment_data.account_id(), + TransactionTemplate::Swap(swap_data, ..) => swap_data.account_id(), } } } @@ -168,3 +179,113 @@ impl PaymentTransactionData { self.asset } } + +// SWAP TRANSACTION DATA +// -------------------------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub struct SwapTransactionData { + sender_account_id: AccountId, + offered_asset: Asset, + requested_asset: Asset, +} + +impl SwapTransactionData { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + pub fn new( + sender_account_id: AccountId, + offered_asset: Asset, + requested_asset: Asset, + ) -> SwapTransactionData { + SwapTransactionData { + sender_account_id, + offered_asset, + requested_asset, + } + } + + /// Returns the executor [AccountId] + pub fn account_id(&self) -> AccountId { + self.sender_account_id + } + + /// Returns the transaction offered [Asset] + pub fn offered_asset(&self) -> Asset { + self.offered_asset + } + + /// Returns the transaction requested [Asset] + pub fn requested_asset(&self) -> Asset { + self.requested_asset + } +} + +// KNOWN SCRIPT ROOTS +// -------------------------------------------------------------------------------------------- + +pub mod known_script_roots { + pub const P2ID: &str = "0x0007b2229f7c8e3205a485a9879f1906798a2e27abd1706eaf58536e7cc3868b"; + pub const P2IDR: &str = "0x418ae31e80b53ddc99179d3cacbc4140c7b36ab04ddb26908b3a6ed2e40061d5"; + pub const SWAP: &str = "0xebbc82ad1688925175599bee2fb56bde649ebb9986fbce957ebee3eb4be5f140"; +} + +#[cfg(test)] +mod tests { + use miden_lib::notes::{create_p2id_note, create_p2idr_note, create_swap_note}; + use miden_objects::{ + accounts::{ + account_id::testing::{ + ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN, ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN, + }, + AccountId, + }, + assets::FungibleAsset, + crypto::rand::RpoRandomCoin, + notes::NoteType, + }; + + use crate::client::transactions::transaction_request::known_script_roots::{P2ID, P2IDR, SWAP}; + + // We need to make sure the script roots we use for filters are in line with the note scripts + // coming from Miden objects + #[test] + fn ensure_correct_script_roots() { + // create dummy data for the notes + let faucet_id: AccountId = ACCOUNT_ID_FUNGIBLE_FAUCET_ON_CHAIN.try_into().unwrap(); + let account_id: AccountId = ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN.try_into().unwrap(); + let rng = RpoRandomCoin::new(Default::default()); + + // create dummy notes to compare note script roots + let p2id_note = create_p2id_note( + account_id, + account_id, + vec![FungibleAsset::new(faucet_id, 100u64).unwrap().into()], + NoteType::OffChain, + rng, + ) + .unwrap(); + let p2idr_note = create_p2idr_note( + account_id, + account_id, + vec![FungibleAsset::new(faucet_id, 100u64).unwrap().into()], + NoteType::OffChain, + 10, + rng, + ) + .unwrap(); + let (swap_note, _serial_num) = create_swap_note( + account_id, + FungibleAsset::new(faucet_id, 100u64).unwrap().into(), + FungibleAsset::new(faucet_id, 100u64).unwrap().into(), + NoteType::OffChain, + rng, + ) + .unwrap(); + + assert_eq!(p2id_note.script().hash().to_string(), P2ID); + assert_eq!(p2idr_note.script().hash().to_string(), P2IDR); + assert_eq!(swap_note.script().hash().to_string(), SWAP); + } +} diff --git a/src/config.rs b/src/config.rs index be5fc26c5..2fbc46eb7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,12 +17,14 @@ pub struct ClientConfig { pub rpc: RpcConfig, /// Describes settings related to the store. pub store: StoreConfig, + /// Describes settings related to the CLI + pub cli: Option, } impl ClientConfig { /// Returns a new instance of [ClientConfig] with the specified store path and node endpoint. pub const fn new(store: StoreConfig, rpc: RpcConfig) -> Self { - Self { store, rpc } + Self { store, rpc, cli: None } } } @@ -79,10 +81,9 @@ impl fmt::Display for Endpoint { } } +const MIDEN_NODE_PORT: u16 = 57291; impl Default for Endpoint { fn default() -> Self { - const MIDEN_NODE_PORT: u16 = 57291; - Self { protocol: "http".to_string(), host: "localhost".to_string(), @@ -91,6 +92,56 @@ impl Default for Endpoint { } } +impl TryFrom<&str> for Endpoint { + type Error = String; + + fn try_from(endpoint: &str) -> Result { + let protocol_separator_index = endpoint.find("://"); + let port_separator_index = endpoint.rfind(':'); + + // port separator index might match with the protocol separator, if so that means there was + // no port defined + let port_separator_index = if port_separator_index == protocol_separator_index { + None + } else { + port_separator_index + }; + + let (protocol, hostname, port) = match (protocol_separator_index, port_separator_index) { + (Some(protocol_idx), Some(port_idx)) => { + let (protocol_and_hostname, port) = endpoint.split_at(port_idx); + let port = port[1..].parse::().map_err(|err| err.to_string())?; + + let (protocol, hostname) = protocol_and_hostname.split_at(protocol_idx); + // skip the separator + let hostname = &hostname[3..]; + + (protocol, hostname, port) + }, + (Some(protocol_idx), None) => { + let (protocol, hostname) = endpoint.split_at(protocol_idx); + // skip the separator + let hostname = &hostname[3..]; + + (protocol, hostname, MIDEN_NODE_PORT) + }, + (None, Some(port_idx)) => { + let (hostname, port) = endpoint.split_at(port_idx); + let port = port[1..].parse::().map_err(|err| err.to_string())?; + + ("https", hostname, port) + }, + (None, None) => ("https", endpoint, MIDEN_NODE_PORT), + }; + + Ok(Endpoint { + protocol: protocol.to_string(), + host: hostname.to_string(), + port, + }) + } +} + // STORE CONFIG // ================================================================================================ @@ -143,14 +194,141 @@ impl Default for StoreConfig { // RPC CONFIG // ================================================================================================ -#[derive(Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +/// Settings for the RPC client +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct RpcConfig { /// Address of the Miden node to connect to. pub endpoint: Endpoint, + /// Timeout for the rpc api requests + #[serde(default = "default_timeout")] + pub timeout_ms: u64, } -impl From for RpcConfig { - fn from(value: Endpoint) -> Self { - Self { endpoint: value } +const fn default_timeout() -> u64 { + 10000 +} + +impl Default for RpcConfig { + fn default() -> Self { + Self { + endpoint: Endpoint::default(), + timeout_ms: 10000, + } + } +} + +// CLI CONFIG +// ================================================================================================ + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CliConfig { + /// Address of the Miden node to connect to. + pub default_account_id: Option, +} + +#[cfg(test)] +mod test { + use crate::config::{Endpoint, MIDEN_NODE_PORT}; + + #[test] + fn test_endpoint_parsing_with_hostname_only() { + let endpoint = Endpoint::try_from("some.test.domain").unwrap(); + let expected_endpoint = Endpoint { + protocol: "https".to_string(), + host: "some.test.domain".to_string(), + port: MIDEN_NODE_PORT, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_with_ip() { + let endpoint = Endpoint::try_from("192.168.0.1").unwrap(); + let expected_endpoint = Endpoint { + protocol: "https".to_string(), + host: "192.168.0.1".to_string(), + port: MIDEN_NODE_PORT, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_with_port() { + let endpoint = Endpoint::try_from("some.test.domain:8000").unwrap(); + let expected_endpoint = Endpoint { + protocol: "https".to_string(), + host: "some.test.domain".to_string(), + port: 8000, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_with_ip_and_port() { + let endpoint = Endpoint::try_from("192.168.0.1:8000").unwrap(); + let expected_endpoint = Endpoint { + protocol: "https".to_string(), + host: "192.168.0.1".to_string(), + port: 8000, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_with_protocol() { + let endpoint = Endpoint::try_from("http://some.test.domain").unwrap(); + let expected_endpoint = Endpoint { + protocol: "http".to_string(), + host: "some.test.domain".to_string(), + port: MIDEN_NODE_PORT, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_with_protocol_and_ip() { + let endpoint = Endpoint::try_from("http://192.168.0.1").unwrap(); + let expected_endpoint = Endpoint { + protocol: "http".to_string(), + host: "192.168.0.1".to_string(), + port: MIDEN_NODE_PORT, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_with_both_protocol_and_port() { + let endpoint = Endpoint::try_from("http://some.test.domain:8080").unwrap(); + let expected_endpoint = Endpoint { + protocol: "http".to_string(), + host: "some.test.domain".to_string(), + port: 8080, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_with_ip_and_protocol_and_port() { + let endpoint = Endpoint::try_from("http://192.168.0.1:8080").unwrap(); + let expected_endpoint = Endpoint { + protocol: "http".to_string(), + host: "192.168.0.1".to_string(), + port: 8080, + }; + + assert_eq!(endpoint, expected_endpoint); + } + + #[test] + fn test_endpoint_parsing_should_fail_for_invalid_port() { + let endpoint = Endpoint::try_from("some.test.domain:8000/hello"); + assert!(endpoint.is_err()); } } diff --git a/src/errors.rs b/src/errors.rs index 7d8ffc050..371a6a69f 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -3,7 +3,7 @@ use core::fmt; use miden_node_proto::errors::ConversionError; use miden_objects::{ accounts::AccountId, crypto::merkle::MmrError, notes::NoteId, AccountError, AssetError, - AssetVaultError, Digest, NoteError, TransactionScriptError, + AssetVaultError, Digest, NoteError, TransactionScriptError, Word, }; use miden_tx::{ utils::{DeserializationError, HexParseError}, @@ -22,12 +22,15 @@ pub enum ClientError { ImportNewAccountWithoutSeed, MissingOutputNotes(Vec), NoteError(NoteError), + NoteImportError(String), + NoteRecordError(String), NoConsumableNoteForAccount(AccountId), NodeRpcClientError(NodeRpcClientError), ScreenerError(ScreenerError), StoreError(StoreError), TransactionExecutorError(TransactionExecutorError), TransactionProvingError(TransactionProverError), + ExistenceVerificationError(NoteId), } impl fmt::Display for ClientError { @@ -46,7 +49,7 @@ impl fmt::Display for ClientError { ClientError::MissingOutputNotes(note_ids) => { write!( f, - "transaction error: The transaction did not produce expected Note IDs: {}", + "transaction error: The transaction did not produce the expected notes corresponding to Note IDs: {}", note_ids.iter().map(|&id| id.to_hex()).collect::>().join(", ") ) }, @@ -54,6 +57,8 @@ impl fmt::Display for ClientError { write!(f, "No consumable note for account ID {}", account_id) }, ClientError::NoteError(err) => write!(f, "note error: {err}"), + ClientError::NoteImportError(err) => write!(f, "error importing note: {err}"), + ClientError::NoteRecordError(err) => write!(f, "note record error: {err}"), ClientError::NodeRpcClientError(err) => write!(f, "rpc api error: {err}"), ClientError::ScreenerError(err) => write!(f, "note screener error: {err}"), ClientError::StoreError(err) => write!(f, "store error: {err}"), @@ -63,6 +68,9 @@ impl fmt::Display for ClientError { ClientError::TransactionProvingError(err) => { write!(f, "transaction prover error: {err}") }, + ClientError::ExistenceVerificationError(note_id) => { + write!(f, "The note with ID {note_id} doesn't exist in the chain") + }, } } } @@ -149,13 +157,14 @@ pub enum StoreError { AccountDataNotFound(AccountId), AccountError(AccountError), AccountHashMismatch(AccountId), + AccountKeyNotFound(Word), AccountStorageNotFound(Digest), BlockHeaderNotFound(u32), ChainMmrNodeNotFound(u64), DatabaseError(String), DataDeserializationError(DeserializationError), HexParseError(HexParseError), - InputNoteNotFound(NoteId), + NoteNotFound(NoteId), InputSerializationError(serde_json::Error), JsonDataDeserializationError(serde_json::Error), MmrError(MmrError), @@ -252,6 +261,9 @@ impl fmt::Display for StoreError { AccountHashMismatch(account_id) => { write!(f, "account hash mismatch for account {account_id}") }, + AccountKeyNotFound(pub_key) => { + write!(f, "error: Public Key {} not found", Digest::from(pub_key)) + }, AccountStorageNotFound(root) => { write!(f, "account storage data with root {} not found", root) }, @@ -268,8 +280,8 @@ impl fmt::Display for StoreError { HexParseError(err) => { write!(f, "error parsing hex: {err}") }, - InputNoteNotFound(note_id) => { - write!(f, "input note with note id {} not found", note_id.inner()) + NoteNotFound(note_id) => { + write!(f, "note with note id {} not found", note_id.inner()) }, InputSerializationError(err) => { write!(f, "error trying to serialize inputs for the store: {err}") @@ -302,7 +314,7 @@ impl From for DataStoreError { DataStoreError::AccountNotFound(account_id) }, StoreError::BlockHeaderNotFound(block_num) => DataStoreError::BlockNotFound(block_num), - StoreError::InputNoteNotFound(note_id) => DataStoreError::NoteNotFound(note_id), + StoreError::NoteNotFound(note_id) => DataStoreError::NoteNotFound(note_id), err => DataStoreError::InternalError(err.to_string()), } } @@ -377,26 +389,26 @@ impl From for NodeRpcClientError { } } -// NOTE ID PREFIX FETCH ERROR +// ID PREFIX FETCH ERROR // ================================================================================================ -/// Error when Looking for a specific note ID from a partial ID +/// Error when Looking for a specific ID from a partial ID #[derive(Debug, Eq, PartialEq)] -pub enum NoteIdPrefixFetchError { +pub enum IdPrefixFetchError { NoMatch(String), MultipleMatches(String), } -impl fmt::Display for NoteIdPrefixFetchError { +impl fmt::Display for IdPrefixFetchError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - NoteIdPrefixFetchError::NoMatch(note_id) => { - write!(f, "No matches were found with the input prefix {note_id}.") + IdPrefixFetchError::NoMatch(id) => { + write!(f, "No matches were found with the {id}.") }, - NoteIdPrefixFetchError::MultipleMatches(note_id) => { + IdPrefixFetchError::MultipleMatches(id) => { write!( f, - "found more than one note for the provided ID {note_id} and only one match is expected." + "Found more than one element for the provided {id} and only one match is expected." ) }, } diff --git a/src/mock.rs b/src/mock.rs index d4af4fa3a..24a96573e 100644 --- a/src/mock.rs +++ b/src/mock.rs @@ -1,4 +1,5 @@ use alloc::collections::BTreeMap; +use std::{env::temp_dir, rc::Rc}; use async_trait::async_trait; use miden_lib::{transaction::TransactionKernel, AuthScheme}; @@ -6,19 +7,20 @@ use miden_node_proto::generated::{ account::AccountId as ProtoAccountId, block_header::BlockHeader as NodeBlockHeader, note::NoteSyncRecord, - requests::{GetBlockHeaderByNumberRequest, SyncStateRequest}, + requests::SyncStateRequest, responses::{NullifierUpdate, SyncStateResponse}, }; use miden_objects::{ accounts::{ - get_account_seed_single, Account, AccountCode, AccountId, AccountStorage, - AccountStorageType, AccountType, SlotItem, StorageSlot, ACCOUNT_ID_OFF_CHAIN_SENDER, + account_id::testing::ACCOUNT_ID_OFF_CHAIN_SENDER, get_account_seed_single, Account, + AccountCode, AccountId, AccountStorage, AccountStorageType, AccountType, AuthSecretKey, + SlotItem, StorageSlot, }, assembly::{Assembler, ModuleAst, ProgramAst}, assets::{Asset, AssetVault, FungibleAsset, TokenSymbol}, crypto::{ dsa::rpo_falcon512::SecretKey, - merkle::{Mmr, MmrDelta, NodeIndex, SimpleSmt}, + merkle::{Mmr, MmrDelta, MmrProof, NodeIndex, SimpleSmt}, rand::RpoRandomCoin, }, notes::{ @@ -30,12 +32,16 @@ use miden_objects::{ }; use rand::Rng; use tonic::{Response, Status}; +use uuid::Uuid; use crate::{ client::{ + get_random_coin, rpc::{ - NodeRpcClient, NodeRpcClientEndpoint, NoteDetails, NoteInclusionDetails, StateSyncInfo, + AccountDetails, NodeRpcClient, NodeRpcClientEndpoint, NoteDetails, + NoteInclusionDetails, StateSyncInfo, }, + store_authenticator::StoreAuthenticator, sync::FILTER_ID_SHIFT, transactions::{ prepare_word, @@ -43,11 +49,13 @@ use crate::{ }, Client, }, + config::{ClientConfig, RpcConfig}, errors::NodeRpcClientError, - store::{sqlite_store::SqliteStore, AuthInfo}, + store::sqlite_store::SqliteStore, }; -pub type MockClient = Client; +pub type MockClient = + Client>; // MOCK CONSTS // ================================================================================================ @@ -69,7 +77,7 @@ pub const DEFAULT_ACCOUNT_CODE: &str = " /// This struct implements the RPC API used by the client to communicate with the node. It is /// intended to be used for testing purposes only. pub struct MockRpcApi { - pub state_sync_requests: BTreeMap, + pub state_sync_requests: BTreeMap, pub genesis_block: BlockHeader, pub notes: BTreeMap, } @@ -102,31 +110,29 @@ impl NodeRpcClient for MockRpcApi { _nullifiers_tags: &[u16], ) -> Result { // Match request -> response through block_num - let response = - match self.state_sync_requests.iter().find(|(req, _)| req.block_num == block_num) { - Some((_req, response)) => { - let response = response.clone(); - Ok(Response::new(response)) - }, - None => Err(NodeRpcClientError::RequestError( - NodeRpcClientEndpoint::SyncState.to_string(), - Status::not_found("no response for sync state request").to_string(), - )), - }?; + let response = match self.state_sync_requests.get(&block_num) { + Some(response) => { + let response = response.clone(); + Ok(Response::new(response)) + }, + None => Err(NodeRpcClientError::RequestError( + NodeRpcClientEndpoint::SyncState.to_string(), + Status::not_found("no response for sync state request").to_string(), + )), + }?; response.into_inner().try_into() } - /// Creates and executes a [GetBlockHeaderByNumberRequest]. + /// Creates and executes a [GetBlockHeaderByNumberRequest](miden_node_proto::generated::requests::GetBlockHeaderByNumberRequest). /// Only used for retrieving genesis block right now so that's the only case we need to cover. async fn get_block_header_by_number( &mut self, block_num: Option, - ) -> Result { - let request = GetBlockHeaderByNumberRequest { block_num }; - - if request.block_num == Some(0) { - return Ok(self.genesis_block); + _include_mmr_proof: bool, + ) -> Result<(BlockHeader, Option), NodeRpcClientError> { + if block_num == Some(0) { + return Ok((self.genesis_block, None)); } panic!("get_block_header_by_number is supposed to be only used for genesis block") } @@ -167,7 +173,7 @@ impl NodeRpcClient for MockRpcApi { async fn get_account_update( &mut self, _account_id: AccountId, - ) -> Result { + ) -> Result { panic!("shouldn't be used for now") } } @@ -183,8 +189,8 @@ fn create_mock_sync_state_request_for_account_and_notes( genesis_block: &BlockHeader, mmr_delta: Option>, tracked_block_headers: Option>, -) -> BTreeMap { - let mut requests: BTreeMap = BTreeMap::new(); +) -> BTreeMap { + let mut requests: BTreeMap = BTreeMap::new(); let accounts = vec![ProtoAccountId { id: u64::from(account_id) }]; @@ -257,6 +263,13 @@ fn create_mock_sync_state_request_for_account_and_notes( nullifiers: nullifiers.clone(), }; + let metadata = miden_node_proto::generated::note::NoteMetadata { + sender: Some(account.id().into()), + note_type: NoteType::OffChain as u32, + tag: NoteTag::for_local_use_case(1u16, 0u16).unwrap().into(), + aux: Default::default(), + }; + // create a state sync response let response = SyncStateResponse { chain_tip, @@ -266,9 +279,7 @@ fn create_mock_sync_state_request_for_account_and_notes( notes: vec![NoteSyncRecord { note_index: 0, note_id: Some(created_notes_iter.next().unwrap().id().into()), - sender: Some(account.id().into()), - tag: 0u32, - note_type: NoteType::OffChain as u32, + metadata: Some(metadata), merkle_path: Some(miden_node_proto::generated::merkle::MerklePath::default()), }], nullifiers: vec![NullifierUpdate { @@ -276,18 +287,15 @@ fn create_mock_sync_state_request_for_account_and_notes( block_num: 7, }], }; - requests.insert(request, response); + requests.insert(request.block_num, response); } requests } /// Generates mock sync state requests and responses -fn generate_state_sync_mock_requests() -> ( - BlockHeader, - BTreeMap, - BTreeMap, -) { +fn generate_state_sync_mock_requests( +) -> (BlockHeader, BTreeMap, BTreeMap) { let account_id = AccountId::try_from(ACCOUNT_ID_REGULAR).unwrap(); // create sync state requests @@ -411,18 +419,18 @@ pub async fn insert_mock_data(client: &mut MockClient) -> Vec { // insert notes into database for note in consumed_notes.clone() { - client.import_input_note(note.into()).unwrap(); + client.import_input_note(note.into(), false).await.unwrap(); } // insert notes into database for note in created_notes.clone() { - client.import_input_note(note.into()).unwrap(); + client.import_input_note(note.into(), false).await.unwrap(); } // insert account - let key_pair = SecretKey::new(); + let secret_key = SecretKey::new(); client - .insert_account(&account, Some(account_seed), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&account, Some(account_seed), &AuthSecretKey::RpoFalcon512(secret_key)) .unwrap(); let genesis_block = BlockHeader::mock(0, None, None, &[]); @@ -457,7 +465,7 @@ pub async fn create_mock_transaction(client: &mut MockClient) { .unwrap(); client - .insert_account(&sender_account, Some(seed), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&sender_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair)) .unwrap(); let key_pair = SecretKey::new(); @@ -477,7 +485,7 @@ pub async fn create_mock_transaction(client: &mut MockClient) { .unwrap(); client - .insert_account(&target_account, Some(seed), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&target_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair)) .unwrap(); let key_pair = SecretKey::new(); @@ -501,7 +509,7 @@ pub async fn create_mock_transaction(client: &mut MockClient) { .unwrap(); client - .insert_account(&faucet, Some(seed), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&faucet, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair)) .unwrap(); let asset: miden_objects::assets::Asset = FungibleAsset::new(faucet.id(), 5u64).unwrap().into(); @@ -541,16 +549,19 @@ pub fn mock_fungible_faucet_account( let faucet_storage_slot_1 = [Felt::new(initial_balance), Felt::new(0), Felt::new(0), Felt::new(0)]; - let faucet_account_storage = AccountStorage::new(vec![ - SlotItem { - index: 0, - slot: StorageSlot::new_value(key_pair.public_key().into()), - }, - SlotItem { - index: 1, - slot: StorageSlot::new_value(faucet_storage_slot_1), - }, - ]) + let faucet_account_storage = AccountStorage::new( + vec![ + SlotItem { + index: 0, + slot: StorageSlot::new_value(key_pair.public_key().into()), + }, + SlotItem { + index: 1, + slot: StorageSlot::new_value(faucet_storage_slot_1), + }, + ], + vec![], + ) .unwrap(); Account::new( @@ -586,10 +597,13 @@ pub fn mock_notes(assembler: &Assembler) -> (Vec, Vec) { let note_program_ast = ProgramAst::parse("begin push.1 drop end").unwrap(); let (note_script, _) = NoteScript::new(note_program_ast, assembler).unwrap(); + let note_tag: NoteTag = + NoteTag::from_account_id(sender, miden_objects::notes::NoteExecutionHint::Local).unwrap(); + // Created Notes const SERIAL_NUM_4: Word = [Felt::new(13), Felt::new(14), Felt::new(15), Felt::new(16)]; let note_metadata = - NoteMetadata::new(sender, NoteType::OffChain, 1u32.into(), Default::default()).unwrap(); + NoteMetadata::new(sender, NoteType::OffChain, note_tag, Default::default()).unwrap(); let note_assets = NoteAssets::new(vec![fungible_asset_1]).unwrap(); let note_recipient = NoteRecipient::new(SERIAL_NUM_4, note_script.clone(), NoteInputs::new(vec![]).unwrap()); @@ -598,7 +612,7 @@ pub fn mock_notes(assembler: &Assembler) -> (Vec, Vec) { const SERIAL_NUM_5: Word = [Felt::new(17), Felt::new(18), Felt::new(19), Felt::new(20)]; let note_metadata = - NoteMetadata::new(sender, NoteType::OffChain, 2u32.into(), Default::default()).unwrap(); + NoteMetadata::new(sender, NoteType::OffChain, note_tag, Default::default()).unwrap(); let note_recipient = NoteRecipient::new(SERIAL_NUM_5, note_script.clone(), NoteInputs::new(vec![]).unwrap()); let note_assets = NoteAssets::new(vec![fungible_asset_2]).unwrap(); @@ -606,7 +620,7 @@ pub fn mock_notes(assembler: &Assembler) -> (Vec, Vec) { const SERIAL_NUM_6: Word = [Felt::new(21), Felt::new(22), Felt::new(23), Felt::new(24)]; let note_metadata = - NoteMetadata::new(sender, NoteType::OffChain, 2u32.into(), Default::default()).unwrap(); + NoteMetadata::new(sender, NoteType::OffChain, note_tag, Default::default()).unwrap(); let note_assets = NoteAssets::new(vec![fungible_asset_3]).unwrap(); let note_recipient = NoteRecipient::new(SERIAL_NUM_6, note_script, NoteInputs::new(vec![Felt::new(2)]).unwrap()); @@ -638,10 +652,10 @@ pub fn mock_notes(assembler: &Assembler) -> (Vec, Vec) { drop dropw dropw end ", - created_note_0_recipient = prepare_word(&created_notes[0].recipient_digest()), + created_note_0_recipient = prepare_word(&created_notes[0].recipient().digest()), created_note_0_tag = created_notes[0].metadata().tag(), created_note_0_asset = prepare_assets(created_notes[0].assets())[0], - created_note_1_recipient = prepare_word(&created_notes[1].recipient_digest()), + created_note_1_recipient = prepare_word(&created_notes[1].recipient().digest()), created_note_1_tag = created_notes[1].metadata().tag(), created_note_1_asset = prepare_assets(created_notes[1].assets())[0], ); @@ -661,7 +675,7 @@ pub fn mock_notes(assembler: &Assembler) -> (Vec, Vec) { drop dropw dropw end ", - created_note_2_recipient = prepare_word(&created_notes[2].recipient_digest()), + created_note_2_recipient = prepare_word(&created_notes[2].recipient().digest()), created_note_2_tag = created_notes[2].metadata().tag(), created_note_2_asset = prepare_assets(created_notes[2].assets())[0], ); @@ -671,7 +685,7 @@ pub fn mock_notes(assembler: &Assembler) -> (Vec, Vec) { // Consumed Notes const SERIAL_NUM_1: Word = [Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]; let note_metadata = - NoteMetadata::new(sender, NoteType::OffChain, 1u32.into(), Default::default()).unwrap(); + NoteMetadata::new(sender, NoteType::OffChain, note_tag, Default::default()).unwrap(); let note_recipient = NoteRecipient::new( SERIAL_NUM_1, note_2_script.clone(), @@ -682,7 +696,7 @@ pub fn mock_notes(assembler: &Assembler) -> (Vec, Vec) { const SERIAL_NUM_2: Word = [Felt::new(5), Felt::new(6), Felt::new(7), Felt::new(8)]; let note_metadata = - NoteMetadata::new(sender, NoteType::OffChain, 2u32.into(), Default::default()).unwrap(); + NoteMetadata::new(sender, NoteType::OffChain, note_tag, Default::default()).unwrap(); let note_assets = NoteAssets::new(vec![fungible_asset_2, fungible_asset_3]).unwrap(); let note_recipient = NoteRecipient::new( SERIAL_NUM_2, @@ -712,7 +726,7 @@ fn get_account_with_nonce( index: 0, slot: StorageSlot::new_value(public_key), }; - let account_storage = AccountStorage::new(vec![slot_item]).unwrap(); + let account_storage = AccountStorage::new(vec![slot_item], vec![]).unwrap(); let asset_vault = match assets { Some(asset) => AssetVault::new(&[asset]).unwrap(), @@ -747,3 +761,29 @@ fn prepare_assets(note_assets: &NoteAssets) -> Vec { } assets } + +pub fn create_test_client() -> MockClient { + let store = create_test_store_path() + .into_os_string() + .into_string() + .unwrap() + .try_into() + .unwrap(); + + let client_config = ClientConfig::new(store, RpcConfig::default()); + + let rpc_endpoint = client_config.rpc.endpoint.to_string(); + let store = SqliteStore::new((&client_config).into()).unwrap(); + let store = Rc::new(store); + + let rng = get_random_coin(); + let authenticator = StoreAuthenticator::new_with_rng(store.clone(), rng); + + MockClient::new(MockRpcApi::new(&rpc_endpoint), rng, store, authenticator, true) +} + +pub(crate) fn create_test_store_path() -> std::path::PathBuf { + let mut temp_file = temp_dir(); + temp_file.push(format!("{}.sqlite3", Uuid::new_v4())); + temp_file +} diff --git a/src/store/data_store.rs b/src/store/data_store.rs index 76cb3480d..667eeed4f 100644 --- a/src/store/data_store.rs +++ b/src/store/data_store.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeSet; +use alloc::{collections::BTreeSet, rc::Rc}; use miden_objects::{ accounts::AccountId, @@ -16,13 +16,14 @@ use crate::errors::{ClientError, StoreError}; // DATA STORE // ================================================================================================ +/// Wrapper structure that helps automatically implement [DataStore] over any [Store] pub struct ClientDataStore { /// Local database containing information about the accounts managed by this client. - pub(crate) store: S, + pub(crate) store: Rc, } impl ClientDataStore { - pub fn new(store: S) -> Self { + pub fn new(store: Rc) -> Self { Self { store } } } @@ -57,10 +58,10 @@ impl DataStore for ClientDataStore { let mut list_of_notes = vec![]; let mut notes_blocks: Vec = vec![]; - for note_id in notes { - let input_note_record = self.store.get_input_note(*note_id)?; + let input_note_records = self.store.get_input_notes(NoteFilter::List(notes))?; - let input_note: InputNote = input_note_record + for note_record in input_note_records { + let input_note: InputNote = note_record .try_into() .map_err(|err: ClientError| DataStoreError::InternalError(err.to_string()))?; @@ -80,7 +81,8 @@ impl DataStore for ClientDataStore { .map(|(header, _has_notes)| *header) .collect(); - let partial_mmr = build_partial_mmr_with_paths(&self.store, block_num, ¬es_blocks)?; + let partial_mmr = + build_partial_mmr_with_paths(self.store.as_ref(), block_num, ¬es_blocks)?; let chain_mmr = ChainMmr::new(partial_mmr, notes_blocks) .map_err(|err| DataStoreError::InternalError(err.to_string()))?; diff --git a/src/store/mod.rs b/src/store/mod.rs index 8b49b6cc0..14e8764b4 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -2,20 +2,15 @@ use alloc::collections::BTreeMap; use clap::error::Result; use miden_objects::{ - accounts::{Account, AccountId, AccountStub}, - crypto::{ - dsa::rpo_falcon512::SecretKey, - merkle::{InOrderIndex, MmrPeaks}, - }, - notes::{NoteId, Nullifier}, - transaction::TransactionId, - BlockHeader, Digest, Felt, Word, + accounts::{Account, AccountId, AccountStub, AuthSecretKey}, + crypto::merkle::{InOrderIndex, MmrPeaks}, + notes::{NoteId, NoteTag, Nullifier}, + BlockHeader, Digest, Word, }; -use miden_tx::utils::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable}; use crate::{ client::{ - sync::SyncedNewNotes, + sync::StateSyncUpdate, transactions::{TransactionRecord, TransactionResult}, }, errors::StoreError, @@ -36,6 +31,10 @@ pub use note_record::{InputNoteRecord, NoteRecordDetails, NoteStatus, OutputNote /// All update functions are implied to be atomic. That is, if multiple entities are meant to be /// updated as part of any single function and an error is returned during its execution, any /// changes that might have happened up to that point need to be rolled back and discarded. +/// +/// Because the [Store]'s ownership is shared between the executor and the client, interior +/// mutability is expected to be implemented, which is why all methods receive `&self` and +/// not `&mut self`. pub trait Store { // TRANSACTIONS // -------------------------------------------------------------------------------------------- @@ -51,26 +50,26 @@ pub trait Store { /// /// An update involves: /// - Applying the resulting [AccountDelta](miden_objects::accounts::AccountDelta) and storing the new [Account] state - /// - Storing new notes as a result of the transaction execution + /// - Storing new notes and payback note details as a result of the transaction execution /// - Inserting the transaction into the store to track - fn apply_transaction(&mut self, tx_result: TransactionResult) -> Result<(), StoreError>; + fn apply_transaction(&self, tx_result: TransactionResult) -> Result<(), StoreError>; // NOTES // -------------------------------------------------------------------------------------------- /// Retrieves the input notes from the store + /// + /// # Errors + /// + /// Returns a [StoreError::NoteNotFound] if the filter is [NoteFilter::Unique] and there is no Note with the provided ID fn get_input_notes(&self, filter: NoteFilter) -> Result, StoreError>; /// Retrieves the output notes from the store - fn get_output_notes(&self, filter: NoteFilter) -> Result, StoreError>; - - /// Retrieves an [InputNoteRecord] for the input note corresponding to the specified ID from - /// the store. /// /// # Errors /// - /// Returns a [StoreError::InputNoteNotFound] if there is no Note with the provided ID - fn get_input_note(&self, note_id: NoteId) -> Result; + /// Returns a [StoreError::NoteNotFound] if the filter is [NoteFilter::Unique] and there is no Note with the provided ID + fn get_output_notes(&self, filter: NoteFilter) -> Result, StoreError>; /// Returns the nullifiers of all unspent input notes /// @@ -86,7 +85,7 @@ pub trait Store { } /// Inserts the provided input note into the database - fn insert_input_note(&mut self, note: &InputNoteRecord) -> Result<(), StoreError>; + fn insert_input_note(&self, note: &InputNoteRecord) -> Result<(), StoreError>; // CHAIN DATA // -------------------------------------------------------------------------------------------- @@ -130,6 +129,11 @@ pub trait Store { filter: ChainMmrNodeFilter, ) -> Result, StoreError>; + /// Inserts MMR authentication nodes. + /// + /// In the case where the [InOrderIndex] already exists on the table, the insertion is ignored + fn insert_chain_mmr_nodes(&self, nodes: &[(InOrderIndex, Digest)]) -> Result<(), StoreError>; + /// Returns peaks information from the blockchain by a specific block number. /// /// If there is no chain MMR info stored for the provided block returns an empty [MmrPeaks] @@ -183,29 +187,45 @@ pub trait Store { /// Returns a `StoreError::AccountDataNotFound` if there is no account for the provided ID fn get_account(&self, account_id: AccountId) -> Result<(Account, Option), StoreError>; - /// Retrieves an account's [AuthInfo], utilized to authenticate the account. + /// Retrieves an account's [AuthSecretKey], utilized to authenticate the account. /// /// # Errors /// /// Returns a `StoreError::AccountDataNotFound` if there is no account for the provided ID - fn get_account_auth(&self, account_id: AccountId) -> Result; + fn get_account_auth(&self, account_id: AccountId) -> Result; + + /// Retrieves an account's [AuthSecretKey] by pub key, utilized to authenticate the account. + /// This is mainly used for authentication in transactions. + /// + /// # Errors + /// + /// Returns a `StoreError::AccountKeyNotFound` if there is no account for the provided key + fn get_account_auth_by_pub_key(&self, pub_key: Word) -> Result; - /// Inserts an [Account] along with the seed used to create it and its [AuthInfo] + /// Inserts an [Account] along with the seed used to create it and its [AuthSecretKey] fn insert_account( - &mut self, + &self, account: &Account, account_seed: Option, - auth_info: &AuthInfo, + auth_info: &AuthSecretKey, ) -> Result<(), StoreError>; // SYNC // -------------------------------------------------------------------------------------------- /// Returns the note tags that the client is interested in. - fn get_note_tags(&self) -> Result, StoreError>; + fn get_note_tags(&self) -> Result, StoreError>; /// Adds a note tag to the list of tags that the client is interested in. - fn add_note_tag(&mut self, tag: u64) -> Result; + /// + /// If the tag was already being tracked, returns false since no new tags were actually added. Otherwise true. + fn add_note_tag(&self, tag: NoteTag) -> Result; + + /// Removes a note tag from the list of tags that the client is interested in. + /// + /// If the tag was not present in the store returns false since no tag was actually removed. + /// Otherwise returns true. + fn remove_note_tag(&self, tag: NoteTag) -> Result; /// Returns the block number of the last state sync block. fn get_sync_height(&self) -> Result; @@ -218,75 +238,7 @@ pub trait Store { /// - Updating transactions in the store, marking as `committed` the ones provided with /// `committed_transactions` /// - Storing new MMR authentication nodes - fn apply_state_sync( - &mut self, - block_header: BlockHeader, - nullifiers: Vec, - new_note_details: SyncedNewNotes, - committed_transactions: &[TransactionId], - new_mmr_peaks: MmrPeaks, - new_authentication_nodes: &[(InOrderIndex, Digest)], - updated_onchain_accounts: &[Account], - ) -> Result<(), StoreError>; -} - -// DATABASE AUTH INFO -// ================================================================================================ - -/// Represents the types of authentication information of accounts -#[derive(Debug)] -pub enum AuthInfo { - RpoFalcon512(SecretKey), -} - -const RPO_FALCON512_AUTH: u8 = 0; - -impl AuthInfo { - /// Returns byte identifier of specific AuthInfo - const fn type_byte(&self) -> u8 { - match self { - AuthInfo::RpoFalcon512(_) => RPO_FALCON512_AUTH, - } - } - - /// Returns the authentication information as a tuple of (key, value) - /// that can be input to the advice map at the moment of transaction execution. - pub fn into_advice_inputs(self) -> (Word, Vec) { - match self { - AuthInfo::RpoFalcon512(key) => { - let pub_key: Word = key.public_key().into(); - let mut pk_sk_bytes = key.to_bytes(); - pk_sk_bytes.append(&mut pub_key.to_bytes()); - - (pub_key, pk_sk_bytes.iter().map(|a| Felt::new(*a as u64)).collect::>()) - }, - } - } -} - -impl Serializable for AuthInfo { - fn write_into(&self, target: &mut W) { - let mut bytes = vec![self.type_byte()]; - match self { - AuthInfo::RpoFalcon512(key_pair) => { - bytes.append(&mut key_pair.to_bytes()); - target.write_bytes(&bytes); - }, - } - } -} - -impl Deserializable for AuthInfo { - fn read_from(source: &mut R) -> Result { - let auth_type: u8 = source.read_u8()?; - match auth_type { - RPO_FALCON512_AUTH => { - let key_pair = SecretKey::read_from(source)?; - Ok(AuthInfo::RpoFalcon512(key_pair)) - }, - val => Err(DeserializationError::InvalidValue(val.to_string())), - } - } + fn apply_state_sync(&self, state_sync_update: StateSyncUpdate) -> Result<(), StoreError>; } // CHAIN MMR NODE FILTER @@ -313,7 +265,8 @@ pub enum TransactionFilter { // NOTE FILTER // ================================================================================================ -pub enum NoteFilter { +#[derive(Debug, Clone)] +pub enum NoteFilter<'a> { /// Return a list of all notes ([InputNoteRecord] or [OutputNoteRecord]). All, /// Filter by consumed notes ([InputNoteRecord] or [OutputNoteRecord]). notes that have been used as inputs in transactions. @@ -324,4 +277,8 @@ pub enum NoteFilter { /// Return a list of pending notes ([InputNoteRecord] or [OutputNoteRecord]). These represent notes for which the store /// does not have anchor data. Pending, + /// Return a list containing the note that matches with the provided [NoteId]. + List(&'a [NoteId]), + /// Return a list containing the note that matches with the provided [NoteId]. + Unique(NoteId), } diff --git a/src/store/note_record/input_note_record.rs b/src/store/note_record/input_note_record.rs index 8724012be..dee5509ee 100644 --- a/src/store/note_record/input_note_record.rs +++ b/src/store/note_record/input_note_record.rs @@ -1,13 +1,15 @@ use miden_objects::{ + accounts::AccountId, notes::{ - Note, NoteAssets, NoteId, NoteInclusionProof, NoteInputs, NoteMetadata, NoteRecipient, + Note, NoteAssets, NoteDetails, NoteId, NoteInclusionProof, NoteInputs, NoteMetadata, + NoteRecipient, }, transaction::InputNote, utils::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable}, Digest, }; -use super::{NoteRecordDetails, NoteStatus}; +use super::{NoteRecordDetails, NoteStatus, OutputNoteRecord}; use crate::errors::ClientError; // INPUT NOTE RECORD @@ -21,17 +23,24 @@ use crate::errors::ClientError; /// Once the proof is set, the [InputNoteRecord] can be transformed into an [InputNote] and used as /// input for transactions. /// +/// The `consumer_account_id` field is used to keep track of the account that consumed the note. It +/// is only valid if the `status` is [NoteStatus::Consumed]. If the note is consumed but the field +/// is [None] it means that the note was consumed by an untracked account. +/// /// It is also possible to convert [Note] and [InputNote] into [InputNoteRecord] (we fill the /// `metadata` and `inclusion_proof` fields if possible) #[derive(Clone, Debug, PartialEq)] pub struct InputNoteRecord { assets: NoteAssets, + // TODO: see if we can replace `NoteRecordDetails` with `NoteDetails` after miden-base v0.3 + // gets released details: NoteRecordDetails, id: NoteId, inclusion_proof: Option, metadata: Option, recipient: Digest, status: NoteStatus, + consumer_account_id: Option, } impl InputNoteRecord { @@ -43,6 +52,7 @@ impl InputNoteRecord { metadata: Option, inclusion_proof: Option, details: NoteRecordDetails, + consumer_account_id: Option, ) -> InputNoteRecord { InputNoteRecord { id, @@ -52,6 +62,7 @@ impl InputNoteRecord { metadata, inclusion_proof, details, + consumer_account_id, } } @@ -86,6 +97,31 @@ impl InputNoteRecord { pub fn details(&self) -> &NoteRecordDetails { &self.details } + + pub fn consumer_account_id(&self) -> Option { + self.consumer_account_id + } +} + +impl From<&NoteDetails> for InputNoteRecord { + fn from(note_details: &NoteDetails) -> Self { + InputNoteRecord { + id: note_details.id(), + assets: note_details.assets().clone(), + recipient: note_details.recipient().digest(), + metadata: None, + inclusion_proof: None, + status: NoteStatus::Pending, + details: NoteRecordDetails { + nullifier: note_details.nullifier().to_string(), + script_hash: note_details.script().hash(), + script: note_details.script().clone(), + inputs: note_details.inputs().to_vec(), + serial_num: note_details.serial_num(), + }, + consumer_account_id: None, + } + } } impl Serializable for InputNoteRecord { @@ -118,6 +154,7 @@ impl Deserializable for InputNoteRecord { metadata, inclusion_proof, details, + consumer_account_id: None, }) } } @@ -126,7 +163,7 @@ impl From for InputNoteRecord { fn from(note: Note) -> Self { InputNoteRecord { id: note.id(), - recipient: note.recipient_digest(), + recipient: note.recipient().digest(), assets: note.assets().clone(), status: NoteStatus::Pending, metadata: Some(*note.metadata()), @@ -137,6 +174,7 @@ impl From for InputNoteRecord { note.inputs().to_vec(), note.serial_num(), ), + consumer_account_id: None, } } } @@ -145,7 +183,7 @@ impl From for InputNoteRecord { fn from(recorded_note: InputNote) -> Self { InputNoteRecord { id: recorded_note.note().id(), - recipient: recorded_note.note().recipient_digest(), + recipient: recorded_note.note().recipient().digest(), assets: recorded_note.note().assets().clone(), status: NoteStatus::Pending, metadata: Some(*recorded_note.note().metadata()), @@ -156,6 +194,7 @@ impl From for InputNoteRecord { recorded_note.note().serial_num(), ), inclusion_proof: Some(recorded_note.proof().clone()), + consumer_account_id: None, } } } @@ -174,16 +213,53 @@ impl TryInto for InputNoteRecord { Ok(InputNote::new(note, proof.clone())) }, - (None, _) => { - Err(ClientError::NoteError(miden_objects::NoteError::invalid_origin_index( - "Input Note Record contains no inclusion proof".to_string(), - ))) - }, - (_, None) => { - Err(ClientError::NoteError(miden_objects::NoteError::invalid_origin_index( - "Input Note Record contains no metadata".to_string(), - ))) + (None, _) => Err(ClientError::NoteRecordError( + "Input Note Record contains no inclusion proof".to_string(), + )), + (_, None) => Err(ClientError::NoteRecordError( + "Input Note Record contains no metadata".to_string(), + )), + } + } +} + +impl TryInto for InputNoteRecord { + type Error = ClientError; + + fn try_into(self) -> Result { + match self.metadata { + Some(metadata) => { + let note_inputs = NoteInputs::new(self.details.inputs)?; + let note_recipient = + NoteRecipient::new(self.details.serial_num, self.details.script, note_inputs); + let note = Note::new(self.assets, metadata, note_recipient); + Ok(note) }, + None => Err(ClientError::NoteRecordError( + "Input Note Record contains no metadata".to_string(), + )), + } + } +} + +impl TryFrom for InputNoteRecord { + type Error = ClientError; + + fn try_from(output_note: OutputNoteRecord) -> Result { + match output_note.details() { + Some(details) => Ok(InputNoteRecord { + assets: output_note.assets().clone(), + details: details.clone(), + id: output_note.id(), + inclusion_proof: output_note.inclusion_proof().cloned(), + metadata: Some(*output_note.metadata()), + recipient: output_note.recipient(), + status: output_note.status(), + consumer_account_id: output_note.consumer_account_id(), + }), + None => Err(ClientError::NoteError(miden_objects::NoteError::invalid_origin_index( + "Output Note Record contains no details".to_string(), + ))), } } } diff --git a/src/store/note_record/mod.rs b/src/store/note_record/mod.rs index 603d8d479..b39ae4d85 100644 --- a/src/store/note_record/mod.rs +++ b/src/store/note_record/mod.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use miden_objects::{ assembly::{Assembler, ProgramAst}, notes::NoteScript, @@ -86,6 +88,16 @@ impl Deserializable for NoteStatus { } } +impl Display for NoteStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NoteStatus::Pending => write!(f, "Pending"), + NoteStatus::Committed => write!(f, "Committed"), + NoteStatus::Consumed => write!(f, "Consumed"), + } + } +} + fn default_script() -> NoteScript { let assembler = Assembler::default(); let note_program_ast = diff --git a/src/store/note_record/output_note_record.rs b/src/store/note_record/output_note_record.rs index a5c2441dc..5b562d0de 100644 --- a/src/store/note_record/output_note_record.rs +++ b/src/store/note_record/output_note_record.rs @@ -1,9 +1,11 @@ use miden_objects::{ + accounts::AccountId, notes::{Note, NoteAssets, NoteId, NoteInclusionProof, NoteMetadata}, Digest, }; -use super::{NoteRecordDetails, NoteStatus}; +use super::{InputNoteRecord, NoteRecordDetails, NoteStatus}; +use crate::errors::ClientError; // OUTPUT NOTE RECORD // ================================================================================================ @@ -17,6 +19,10 @@ use super::{NoteRecordDetails, NoteStatus}; /// /// It is also possible to convert [Note] into [OutputNoteRecord] (we fill the `details` and /// `inclusion_proof` fields if possible) +/// +/// The `consumer_account_id` field is used to keep track of the account that consumed the note. It +/// is only valid if the `status` is [NoteStatus::Consumed]. If the note is consumed but the field +/// is [None] it means that the note was consumed by an untracked account. #[derive(Clone, Debug, PartialEq)] pub struct OutputNoteRecord { assets: NoteAssets, @@ -26,6 +32,7 @@ pub struct OutputNoteRecord { metadata: NoteMetadata, recipient: Digest, status: NoteStatus, + consumer_account_id: Option, } impl OutputNoteRecord { @@ -37,6 +44,7 @@ impl OutputNoteRecord { metadata: NoteMetadata, inclusion_proof: Option, details: Option, + consumer_account_id: Option, ) -> OutputNoteRecord { OutputNoteRecord { id, @@ -46,6 +54,7 @@ impl OutputNoteRecord { metadata, inclusion_proof, details, + consumer_account_id, } } @@ -76,13 +85,21 @@ impl OutputNoteRecord { pub fn details(&self) -> Option<&NoteRecordDetails> { self.details.as_ref() } + + pub fn consumer_account_id(&self) -> Option { + self.consumer_account_id + } } +// CONVERSIONS +// ================================================================================================ + +// TODO: Improve conversions by implementing into_parts() impl From for OutputNoteRecord { fn from(note: Note) -> Self { OutputNoteRecord { id: note.id(), - recipient: note.recipient_digest(), + recipient: note.recipient().digest(), assets: note.assets().clone(), status: NoteStatus::Pending, metadata: *note.metadata(), @@ -93,6 +110,29 @@ impl From for OutputNoteRecord { note.inputs().to_vec(), note.serial_num(), )), + consumer_account_id: None, + } + } +} + +impl TryFrom for OutputNoteRecord { + type Error = ClientError; + + fn try_from(input_note: InputNoteRecord) -> Result { + match input_note.metadata() { + Some(metadata) => Ok(OutputNoteRecord { + assets: input_note.assets().clone(), + details: Some(input_note.details().clone()), + id: input_note.id(), + inclusion_proof: input_note.inclusion_proof().cloned(), + metadata: *metadata, + recipient: input_note.recipient(), + status: input_note.status(), + consumer_account_id: input_note.consumer_account_id(), + }), + None => Err(ClientError::NoteError(miden_objects::NoteError::invalid_origin_index( + "Input Note Record contains no metadata".to_string(), + ))), } } } diff --git a/src/store/sqlite_store/accounts.rs b/src/store/sqlite_store/accounts.rs index 109e4dbe9..992fdfcca 100644 --- a/src/store/sqlite_store/accounts.rs +++ b/src/store/sqlite_store/accounts.rs @@ -1,7 +1,7 @@ use clap::error::Result; use miden_lib::transaction::TransactionKernel; use miden_objects::{ - accounts::{Account, AccountCode, AccountId, AccountStorage, AccountStub}, + accounts::{Account, AccountCode, AccountId, AccountStorage, AccountStub, AuthSecretKey}, assembly::{AstSerdeOptions, ModuleAst}, assets::{Asset, AssetVault}, Digest, Felt, Word, @@ -10,24 +10,23 @@ use miden_tx::utils::{Deserializable, Serializable}; use rusqlite::{params, Transaction}; use super::SqliteStore; -use crate::{errors::StoreError, store::AuthInfo}; +use crate::errors::StoreError; // TYPES // ================================================================================================ type SerializedAccountData = (i64, String, String, String, i64, bool); type SerializedAccountsParts = (i64, i64, String, String, String, Option>); -type SerializedAccountAuthData = (i64, Vec); +type SerializedAccountAuthData = (i64, Vec, Vec); type SerializedAccountAuthParts = (i64, Vec); type SerializedAccountVaultData = (String, String); -type SerializedAccountVaultParts = (String, String); type SerializedAccountCodeData = (String, String, Vec); -type SerializedAccountCodeParts = (String, String, Vec); type SerializedAccountStorageData = (String, Vec); -type SerializedAccountStorageParts = (String, Vec); + +type SerializedFullAccountParts = (i64, i64, Option>, Vec, Vec, String); impl SqliteStore { // ACCOUNTS @@ -36,7 +35,7 @@ impl SqliteStore { pub(super) fn get_account_ids(&self) -> Result, StoreError> { const QUERY: &str = "SELECT DISTINCT id FROM accounts"; - self.db + self.db() .prepare(QUERY)? .query_map([], |row| row.get(0)) .expect("no binding parameters used in query") @@ -53,7 +52,7 @@ impl SqliteStore { FROM accounts a \ WHERE a.nonce = (SELECT MAX(b.nonce) FROM accounts b WHERE b.id = a.id)"; - self.db + self.db() .prepare(QUERY)? .query_map([], parse_accounts_columns) .expect("no binding parameters used in query") @@ -70,7 +69,7 @@ impl SqliteStore { FROM accounts WHERE id = ? \ ORDER BY nonce DESC \ LIMIT 1"; - self.db + self.db() .prepare(QUERY)? .query_map(params![account_id_int as i64], parse_accounts_columns)? .map(|result| Ok(result?).and_then(parse_accounts)) @@ -78,37 +77,40 @@ impl SqliteStore { .ok_or(StoreError::AccountDataNotFound(account_id))? } - // TODO: Get all parts from a single query pub(crate) fn get_account( &self, account_id: AccountId, ) -> Result<(Account, Option), StoreError> { - let (account_stub, seed) = self.get_account_stub(account_id)?; - let (_procedures, module_ast) = self.get_account_code(account_stub.code_root())?; - - let account_code = AccountCode::new(module_ast, &TransactionKernel::assembler()).unwrap(); - - let account_storage = self.get_account_storage(account_stub.storage_root())?; - - let account_vault = self.get_vault_assets(account_stub.vault_root())?; - let account_vault = AssetVault::new(&account_vault)?; - - let account = Account::new( - account_stub.id(), - account_vault, - account_storage, - account_code, - account_stub.nonce(), - ); + let account_id_int: u64 = account_id.into(); + const QUERY: &str = "SELECT accounts.id, accounts.nonce, accounts.account_seed, account_code.module, account_storage.slots, account_vaults.assets \ + FROM accounts \ + JOIN account_code ON accounts.code_root = account_code.root \ + JOIN account_storage ON accounts.storage_root = account_storage.root \ + JOIN account_vaults ON accounts.vault_root = account_vaults.root \ + WHERE accounts.id = ? \ + ORDER BY accounts.nonce DESC \ + LIMIT 1"; + + let result = self + .db() + .prepare(QUERY)? + .query_map(params![account_id_int as i64], parse_account_columns)? + .map(|result| Ok(result?).and_then(parse_account)) + .next() + .ok_or(StoreError::AccountDataNotFound(account_id))?; + let (account, account_seed) = result?; - Ok((account, seed)) + Ok((account, account_seed)) } /// Retrieve account keys data by Account Id - pub(crate) fn get_account_auth(&self, account_id: AccountId) -> Result { + pub(crate) fn get_account_auth( + &self, + account_id: AccountId, + ) -> Result { let account_id_int: u64 = account_id.into(); const QUERY: &str = "SELECT account_id, auth_info FROM account_auth WHERE account_id = ?"; - self.db + self.db() .prepare(QUERY)? .query_map(params![account_id_int as i64], parse_account_auth_columns)? .map(|result| Ok(result?).and_then(parse_account_auth)) @@ -116,56 +118,14 @@ impl SqliteStore { .ok_or(StoreError::AccountDataNotFound(account_id))? } - /// Retrieve account code-related data by code root - pub(super) fn get_account_code( - &self, - root: Digest, - ) -> Result<(Vec, ModuleAst), StoreError> { - let root_serialized = root.to_string(); - const QUERY: &str = "SELECT root, procedures, module FROM account_code WHERE root = ?"; - - self.db - .prepare(QUERY)? - .query_map(params![root_serialized], parse_account_code_columns)? - .map(|result| Ok(result?).and_then(parse_account_code)) - .next() - .ok_or(StoreError::AccountCodeDataNotFound(root))? - } - - /// Retrieve account storage data by vault root - pub(super) fn get_account_storage(&self, root: Digest) -> Result { - let root_serialized = &root.to_string(); - - const QUERY: &str = "SELECT root, slots FROM account_storage WHERE root = ?"; - self.db - .prepare(QUERY)? - .query_map(params![root_serialized], parse_account_storage_columns)? - .map(|result| Ok(result?).and_then(parse_account_storage)) - .next() - .ok_or(StoreError::AccountStorageNotFound(root))? - } - - /// Retrieve assets by vault root - pub(super) fn get_vault_assets(&self, root: Digest) -> Result, StoreError> { - let vault_root = - serde_json::to_string(&root).map_err(StoreError::InputSerializationError)?; - - const QUERY: &str = "SELECT root, assets FROM account_vaults WHERE root = ?"; - self.db - .prepare(QUERY)? - .query_map(params![vault_root], parse_account_asset_vault_columns)? - .map(|result| Ok(result?).and_then(parse_account_asset_vault)) - .next() - .ok_or(StoreError::VaultDataNotFound(root))? - } - pub(crate) fn insert_account( - &mut self, + &self, account: &Account, account_seed: Option, - auth_info: &AuthInfo, + auth_info: &AuthSecretKey, ) -> Result<(), StoreError> { - let tx = self.db.transaction()?; + let mut db = self.db(); + let tx = db.transaction()?; insert_account_code(&tx, account.code())?; insert_account_storage(&tx, account.storage())?; @@ -175,6 +135,18 @@ impl SqliteStore { Ok(tx.commit()?) } + + /// Returns an [AuthSecretKey] by a public key represented by a [Word] + pub fn get_account_auth_by_pub_key(&self, pub_key: Word) -> Result { + let pub_key_bytes = pub_key.to_bytes(); + const QUERY: &str = "SELECT account_id, auth_info FROM account_auth WHERE pub_key = ?"; + self.db() + .prepare(QUERY)? + .query_map(params![pub_key_bytes], parse_account_auth_columns)? + .map(|result| Ok(result?).and_then(parse_account_auth)) + .next() + .ok_or(StoreError::AccountKeyNotFound(pub_key))? + } } // HELPERS @@ -242,15 +214,17 @@ pub(super) fn insert_account_asset_vault( Ok(()) } -/// Inserts an [AuthInfo] for the account with id `account_id` +/// Inserts an [AuthSecretKey] for the account with id `account_id` pub(super) fn insert_account_auth( tx: &Transaction<'_>, account_id: AccountId, - auth_info: &AuthInfo, + auth_info: &AuthSecretKey, ) -> Result<(), StoreError> { - let (account_id, auth_info) = serialize_account_auth(account_id, auth_info)?; - const QUERY: &str = "INSERT INTO account_auth (account_id, auth_info) VALUES (?, ?)"; - tx.execute(QUERY, params![account_id, auth_info])?; + let (account_id, auth_info, pub_key) = serialize_account_auth(account_id, auth_info)?; + const QUERY: &str = + "INSERT INTO account_auth (account_id, auth_info, pub_key) VALUES (?, ?, ?)"; + + tx.execute(QUERY, params![account_id, auth_info, pub_key])?; Ok(()) } @@ -288,6 +262,32 @@ pub(super) fn parse_accounts( )) } +/// Parse an account from the provided parts. +pub(super) fn parse_account( + serialized_account_parts: SerializedFullAccountParts, +) -> Result<(Account, Option), StoreError> { + let (id, nonce, account_seed, module, storage, assets) = serialized_account_parts; + let account_seed = account_seed.map(|seed| Word::read_from_bytes(&seed)).transpose()?; + let account_id: AccountId = (id as u64) + .try_into() + .expect("Conversion from stored AccountID should not panic"); + let account_module = ModuleAst::from_bytes(&module)?; + let account_storage = AccountStorage::read_from_bytes(&storage)?; + let account_assets: Vec = + serde_json::from_str(&assets).map_err(StoreError::JsonDataDeserializationError)?; + + Ok(( + Account::new( + account_id, + AssetVault::new(&account_assets)?, + account_storage, + AccountCode::new(account_module, &TransactionKernel::assembler())?, + Felt::new(nonce as u64), + ), + account_seed, + )) +} + /// Serialized the provided account into database compatible types. fn serialize_account(account: &Account) -> Result { let id: u64 = account.id().into(); @@ -310,45 +310,29 @@ fn parse_account_auth_columns( Ok((account_id, auth_info_bytes)) } -/// Parse an `AuthInfo` from the provided parts. +/// Parse an `AuthSecretKey` from the provided parts. fn parse_account_auth( serialized_account_auth_parts: SerializedAccountAuthParts, -) -> Result { +) -> Result { let (_, auth_info_bytes) = serialized_account_auth_parts; - let auth_info = AuthInfo::read_from_bytes(&auth_info_bytes)?; + let auth_info = AuthSecretKey::read_from_bytes(&auth_info_bytes)?; Ok(auth_info) } /// Serialized the provided account_auth into database compatible types. fn serialize_account_auth( account_id: AccountId, - auth_info: &AuthInfo, + auth_info: &AuthSecretKey, ) -> Result { + let pub_key = match auth_info { + AuthSecretKey::RpoFalcon512(secret) => Word::from(secret.public_key()), + } + .to_bytes(); + let account_id: u64 = account_id.into(); let auth_info = auth_info.to_bytes(); - Ok((account_id as i64, auth_info)) -} - -/// Parse account_code columns from the provided row into native types. -fn parse_account_code_columns( - row: &rusqlite::Row<'_>, -) -> Result { - let root: String = row.get(0)?; - let procedures: String = row.get(1)?; - let module: Vec = row.get(2)?; - Ok((root, procedures, module)) -} - -/// Parse an account_code from the provided parts. -fn parse_account_code( - serialized_account_code_parts: SerializedAccountCodeParts, -) -> Result<(Vec, ModuleAst), StoreError> { - let (_, procedures, module) = serialized_account_code_parts; - let procedures = - serde_json::from_str(&procedures).map_err(StoreError::JsonDataDeserializationError)?; - let module = ModuleAst::from_bytes(&module)?; - Ok((procedures, module)) + Ok((account_id as i64, auth_info, pub_key)) } /// Serialize the provided account_code into database compatible types. @@ -363,25 +347,6 @@ fn serialize_account_code( Ok((root, procedures, module)) } -/// Parse account_storage columns from the provided row into native types. -fn parse_account_storage_columns( - row: &rusqlite::Row<'_>, -) -> Result { - let root: String = row.get(0)?; - let storage: Vec = row.get(1)?; - Ok((root, storage)) -} - -/// Parse an account_storage from the provided parts. -fn parse_account_storage( - serialized_account_storage_parts: SerializedAccountStorageParts, -) -> Result { - let (_, storage) = serialized_account_storage_parts; - - let storage = AccountStorage::read_from_bytes(&storage)?; - Ok(storage) -} - /// Serialize the provided account_storage into database compatible types. fn serialize_account_storage( account_storage: &AccountStorage, @@ -392,25 +357,6 @@ fn serialize_account_storage( Ok((root, storage)) } -/// Parse account_vault columns from the provided row into native types. -fn parse_account_asset_vault_columns( - row: &rusqlite::Row<'_>, -) -> Result { - let root: String = row.get(0)?; - let assets: String = row.get(1)?; - Ok((root, assets)) -} - -/// Parse a vector of assets from the provided parts. -fn parse_account_asset_vault( - serialized_account_asset_vault_parts: SerializedAccountVaultParts, -) -> Result, StoreError> { - let (_, assets) = serialized_account_asset_vault_parts; - - let assets = serde_json::from_str(&assets).map_err(StoreError::JsonDataDeserializationError)?; - Ok(assets) -} - /// Serialize the provided asset_vault into database compatible types. fn serialize_account_asset_vault( asset_vault: &AssetVault, @@ -422,6 +368,18 @@ fn serialize_account_asset_vault( Ok((root, assets)) } +/// Parse accounts parts from the provided row into native types +pub(super) fn parse_account_columns( + row: &rusqlite::Row<'_>, +) -> Result { + let id: i64 = row.get(0)?; + let nonce: i64 = row.get(1)?; + let account_seed: Option> = row.get(2)?; + let module: Vec = row.get(3)?; + let storage: Vec = row.get(4)?; + let assets: String = row.get(5)?; + Ok((id, nonce, account_seed, module, storage, assets)) +} #[cfg(test)] mod tests { use miden_objects::{ @@ -431,7 +389,7 @@ mod tests { }; use miden_tx::utils::{Deserializable, Serializable}; - use super::{insert_account_auth, AuthInfo}; + use super::{insert_account_auth, AuthSecretKey}; use crate::{ mock::DEFAULT_ACCOUNT_CODE, store::sqlite_store::{accounts::insert_account_code, tests::create_test_store}, @@ -439,11 +397,12 @@ mod tests { #[test] fn test_account_code_insertion_no_duplicates() { - let mut store = create_test_store(); + let store = create_test_store(); let assembler = miden_lib::transaction::TransactionKernel::assembler(); let module_ast = ModuleAst::parse(DEFAULT_ACCOUNT_CODE).unwrap(); let account_code = AccountCode::new(module_ast, &assembler).unwrap(); - let tx = store.db.transaction().unwrap(); + let mut db = store.db(); + let tx = db.transaction().unwrap(); // Table is empty at the beginning let mut actual: usize = @@ -464,11 +423,11 @@ mod tests { #[test] fn test_auth_info_serialization() { let exp_key_pair = SecretKey::new(); - let auth_info = AuthInfo::RpoFalcon512(exp_key_pair.clone()); + let auth_info = AuthSecretKey::RpoFalcon512(exp_key_pair.clone()); let bytes = auth_info.to_bytes(); - let actual = AuthInfo::read_from_bytes(&bytes).unwrap(); + let actual = AuthSecretKey::read_from_bytes(&bytes).unwrap(); match actual { - AuthInfo::RpoFalcon512(act_key_pair) => { + AuthSecretKey::RpoFalcon512(act_key_pair) => { assert_eq!(exp_key_pair.to_bytes(), act_key_pair.to_bytes()); assert_eq!(exp_key_pair.public_key(), act_key_pair.public_key()); }, @@ -479,19 +438,24 @@ mod tests { fn test_auth_info_store() { let exp_key_pair = SecretKey::new(); - let mut store = create_test_store(); + let store = create_test_store(); let account_id = AccountId::try_from(3238098370154045919u64).unwrap(); { - let tx = store.db.transaction().unwrap(); - insert_account_auth(&tx, account_id, &AuthInfo::RpoFalcon512(exp_key_pair.clone())) - .unwrap(); + let mut db = store.db(); + let tx = db.transaction().unwrap(); + insert_account_auth( + &tx, + account_id, + &AuthSecretKey::RpoFalcon512(exp_key_pair.clone()), + ) + .unwrap(); tx.commit().unwrap(); } let account_auth = store.get_account_auth(account_id).unwrap(); match account_auth { - AuthInfo::RpoFalcon512(act_key_pair) => { + AuthSecretKey::RpoFalcon512(act_key_pair) => { assert_eq!(exp_key_pair.to_bytes(), act_key_pair.to_bytes()); assert_eq!(exp_key_pair.public_key(), act_key_pair.public_key()); }, diff --git a/src/store/sqlite_store/chain_data.rs b/src/store/sqlite_store/chain_data.rs index 4223fb6df..7430d320c 100644 --- a/src/store/sqlite_store/chain_data.rs +++ b/src/store/sqlite_store/chain_data.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeMap; +use alloc::{collections::BTreeMap, rc::Rc}; use std::num::NonZeroUsize; use clap::error::Result; @@ -6,7 +6,7 @@ use miden_objects::{ crypto::merkle::{InOrderIndex, MmrPeaks}, BlockHeader, Digest, }; -use rusqlite::{params, OptionalExtension, Transaction}; +use rusqlite::{params, params_from_iter, types::Value, OptionalExtension, Transaction}; use super::SqliteStore; use crate::{errors::StoreError, store::ChainMmrNodeFilter}; @@ -25,13 +25,8 @@ impl ChainMmrNodeFilter<'_> { let base = String::from("SELECT id, node FROM chain_mmr_nodes"); match self { ChainMmrNodeFilter::All => base, - ChainMmrNodeFilter::List(ids) => { - let formatted_list = ids - .iter() - .map(|id| (Into::::into(*id)).to_string()) - .collect::>() - .join(","); - format!("{base} WHERE id IN ({})", formatted_list) + ChainMmrNodeFilter::List(_) => { + format!("{base} WHERE id IN rarray(?)") }, } } @@ -44,17 +39,12 @@ impl SqliteStore { chain_mmr_peaks: MmrPeaks, has_client_notes: bool, ) -> Result<(), StoreError> { - let chain_mmr_peaks = chain_mmr_peaks.peaks().to_vec(); - let (block_num, header, chain_mmr, has_client_notes) = - serialize_block_header(block_header, chain_mmr_peaks, has_client_notes)?; - const QUERY: &str = "\ - INSERT INTO block_headers - (block_num, header, chain_mmr_peaks, has_client_notes) - VALUES (?, ?, ?, ?)"; + let mut db = self.db(); + let tx = db.transaction()?; - self.db - .execute(QUERY, params![block_num, header, chain_mmr, has_client_notes])?; + Self::insert_block_header_tx(&tx, block_header, chain_mmr_peaks, has_client_notes)?; + tx.commit()?; Ok(()) } @@ -62,25 +52,23 @@ impl SqliteStore { &self, block_numbers: &[u32], ) -> Result, StoreError> { - let formatted_block_numbers_list = block_numbers + let block_number_list = block_numbers .iter() - .map(|block_number| (*block_number as i64).to_string()) - .collect::>() - .join(","); - let query = format!( - "SELECT block_num, header, chain_mmr_peaks, has_client_notes FROM block_headers WHERE block_num IN ({})", - formatted_block_numbers_list - ); - self.db - .prepare(&query)? - .query_map(params![], parse_block_headers_columns)? + .map(|block_number| Value::Integer(*block_number as i64)) + .collect::>(); + + const QUERY : &str = "SELECT block_num, header, chain_mmr_peaks, has_client_notes FROM block_headers WHERE block_num IN rarray(?)"; + + self.db() + .prepare(QUERY)? + .query_map(params![Rc::new(block_number_list)], parse_block_headers_columns)? .map(|result| Ok(result?).and_then(parse_block_header)) .collect() } pub(crate) fn get_tracked_block_headers(&self) -> Result, StoreError> { const QUERY: &str = "SELECT block_num, header, chain_mmr_peaks, has_client_notes FROM block_headers WHERE has_client_notes=true"; - self.db + self.db() .prepare(QUERY)? .query_map(params![], parse_block_headers_columns)? .map(|result| Ok(result?).and_then(parse_block_header).map(|(block, _)| block)) @@ -91,9 +79,19 @@ impl SqliteStore { &self, filter: ChainMmrNodeFilter, ) -> Result, StoreError> { - self.db + let mut params = Vec::new(); + if let ChainMmrNodeFilter::List(ids) = filter { + let id_values = ids + .iter() + .map(|id| Value::Integer(Into::::into(*id) as i64)) + .collect::>(); + + params.push(Rc::new(id_values)); + } + + self.db() .prepare(&filter.to_query())? - .query_map(params![], parse_chain_mmr_nodes_columns)? + .query_map(params_from_iter(params), parse_chain_mmr_nodes_columns)? .map(|result| Ok(result?).and_then(parse_chain_mmr_nodes)) .collect() } @@ -105,7 +103,7 @@ impl SqliteStore { const QUERY: &str = "SELECT chain_mmr_peaks FROM block_headers WHERE block_num = ?"; let mmr_peaks = self - .db + .db() .prepare(QUERY)? .query_row(params![block_num], |row| { let peaks: String = row.get(0)?; @@ -120,8 +118,20 @@ impl SqliteStore { Ok(MmrPeaks::new(0, vec![])?) } + pub fn insert_chain_mmr_nodes( + &self, + nodes: &[(InOrderIndex, Digest)], + ) -> Result<(), StoreError> { + let mut db = self.db(); + let tx = db.transaction()?; + + Self::insert_chain_mmr_nodes_tx(&tx, nodes)?; + + Ok(tx.commit().map(|_| ())?) + } + /// Inserts a list of MMR authentication nodes to the Chain MMR nodes table. - pub(crate) fn insert_chain_mmr_nodes( + pub(crate) fn insert_chain_mmr_nodes_tx( tx: &Transaction<'_>, nodes: &[(InOrderIndex, Digest)], ) -> Result<(), StoreError> { @@ -142,7 +152,7 @@ impl SqliteStore { let (block_num, header, chain_mmr, has_client_notes) = serialize_block_header(block_header, chain_mmr_peaks, has_client_notes)?; const QUERY: &str = "\ - INSERT INTO block_headers + INSERT OR IGNORE INTO block_headers (block_num, header, chain_mmr_peaks, has_client_notes) VALUES (?, ?, ?, ?)"; tx.execute(QUERY, params![block_num, header, chain_mmr, has_client_notes])?; @@ -160,7 +170,7 @@ fn insert_chain_mmr_node( node: Digest, ) -> Result<(), StoreError> { let (id, node) = serialize_chain_mmr_node(id, node)?; - const QUERY: &str = "INSERT INTO chain_mmr_nodes (id, node) VALUES (?, ?)"; + const QUERY: &str = "INSERT OR IGNORE INTO chain_mmr_nodes (id, node) VALUES (?, ?)"; tx.execute(QUERY, params![id, node])?; Ok(()) } @@ -248,7 +258,8 @@ mod test { fn insert_dummy_block_headers(store: &mut SqliteStore) -> Vec { let block_headers: Vec = (0..5).map(|block_num| BlockHeader::mock(block_num, None, None, &[])).collect(); - let tx = store.db.transaction().unwrap(); + let mut db = store.db(); + let tx = db.transaction().unwrap(); let dummy_peaks = MmrPeaks::new(0, Vec::new()).unwrap(); (0..5).for_each(|block_num| { SqliteStore::insert_block_header_tx( diff --git a/src/store/sqlite_store/mod.rs b/src/store/sqlite_store/mod.rs index 51c981be3..9794ce28f 100644 --- a/src/store/sqlite_store/mod.rs +++ b/src/store/sqlite_store/mod.rs @@ -1,21 +1,20 @@ use alloc::collections::BTreeMap; +use core::cell::{RefCell, RefMut}; use miden_objects::{ - accounts::{Account, AccountId, AccountStub}, + accounts::{Account, AccountId, AccountStub, AuthSecretKey}, crypto::merkle::{InOrderIndex, MmrPeaks}, - notes::NoteId, - transaction::TransactionId, + notes::NoteTag, BlockHeader, Digest, Word, }; -use rusqlite::Connection; +use rusqlite::{vtab::array, Connection}; use super::{ - AuthInfo, ChainMmrNodeFilter, InputNoteRecord, NoteFilter, OutputNoteRecord, Store, - TransactionFilter, + ChainMmrNodeFilter, InputNoteRecord, NoteFilter, OutputNoteRecord, Store, TransactionFilter, }; use crate::{ client::{ - sync::SyncedNewNotes, + sync::StateSyncUpdate, transactions::{TransactionRecord, TransactionResult}, }, config::StoreConfig, @@ -24,7 +23,7 @@ use crate::{ mod accounts; mod chain_data; -mod migrations; +pub(crate) mod migrations; mod notes; mod sync; mod transactions; @@ -91,7 +90,7 @@ mod transactions; /// - Thus, if needed you can create a struct representing the json values and use serde_json to /// simplify all of the serialization/deserialization logic pub struct SqliteStore { - pub(crate) db: Connection, + pub(crate) db: RefCell, } impl SqliteStore { @@ -101,9 +100,15 @@ impl SqliteStore { /// Returns a new instance of [Store] instantiated with the specified configuration options. pub fn new(config: StoreConfig) -> Result { let mut db = Connection::open(config.database_filepath)?; + array::load_module(&db)?; migrations::update_to_latest(&mut db)?; - Ok(Self { db }) + Ok(Self { db: RefCell::new(db) }) + } + + /// Returns a mutable reference to the internal [Connection] to the SQL DB + pub fn db(&self) -> RefMut<'_, Connection> { + self.db.borrow_mut() } } @@ -112,37 +117,24 @@ impl SqliteStore { // To simplify, all implementations rely on inner SqliteStore functions that map 1:1 by name // This way, the actual implementations are grouped by entity types in their own sub-modules impl Store for SqliteStore { - fn get_note_tags(&self) -> Result, StoreError> { + fn get_note_tags(&self) -> Result, StoreError> { self.get_note_tags() } - fn add_note_tag(&mut self, tag: u64) -> Result { + fn add_note_tag(&self, tag: NoteTag) -> Result { self.add_note_tag(tag) } + fn remove_note_tag(&self, tag: NoteTag) -> Result { + self.remove_note_tag(tag) + } + fn get_sync_height(&self) -> Result { self.get_sync_height() } - fn apply_state_sync( - &mut self, - block_header: BlockHeader, - nullifiers: Vec, - committed_notes: SyncedNewNotes, - committed_transactions: &[TransactionId], - new_mmr_peaks: MmrPeaks, - new_authentication_nodes: &[(InOrderIndex, Digest)], - updated_onchain_accounts: &[Account], - ) -> Result<(), StoreError> { - self.apply_state_sync( - block_header, - nullifiers, - committed_notes, - committed_transactions, - new_mmr_peaks, - new_authentication_nodes, - updated_onchain_accounts, - ) + fn apply_state_sync(&self, state_sync_update: StateSyncUpdate) -> Result<(), StoreError> { + self.apply_state_sync(state_sync_update) } fn get_transactions( @@ -152,7 +144,7 @@ impl Store for SqliteStore { self.get_transactions(transaction_filter) } - fn apply_transaction(&mut self, tx_result: TransactionResult) -> Result<(), StoreError> { + fn apply_transaction(&self, tx_result: TransactionResult) -> Result<(), StoreError> { self.apply_transaction(tx_result) } @@ -167,11 +159,7 @@ impl Store for SqliteStore { self.get_output_notes(note_filter) } - fn get_input_note(&self, note_id: NoteId) -> Result { - self.get_input_note(note_id) - } - - fn insert_input_note(&mut self, note: &InputNoteRecord) -> Result<(), StoreError> { + fn insert_input_note(&self, note: &InputNoteRecord) -> Result<(), StoreError> { self.insert_input_note(note) } @@ -202,15 +190,19 @@ impl Store for SqliteStore { self.get_chain_mmr_nodes(filter) } + fn insert_chain_mmr_nodes(&self, nodes: &[(InOrderIndex, Digest)]) -> Result<(), StoreError> { + self.insert_chain_mmr_nodes(nodes) + } + fn get_chain_mmr_peaks_by_block_num(&self, block_num: u32) -> Result { self.get_chain_mmr_peaks_by_block_num(block_num) } fn insert_account( - &mut self, + &self, account: &Account, account_seed: Option, - auth_info: &AuthInfo, + auth_info: &AuthSecretKey, ) -> Result<(), StoreError> { self.insert_account(account, account_seed, auth_info) } @@ -234,9 +226,13 @@ impl Store for SqliteStore { self.get_account(account_id) } - fn get_account_auth(&self, account_id: AccountId) -> Result { + fn get_account_auth(&self, account_id: AccountId) -> Result { self.get_account_auth(account_id) } + + fn get_account_auth_by_pub_key(&self, pub_key: Word) -> Result { + self.get_account_auth_by_pub_key(pub_key) + } } // TESTS @@ -244,48 +240,19 @@ impl Store for SqliteStore { #[cfg(test)] pub mod tests { - use std::env::temp_dir; + use std::cell::RefCell; - use rusqlite::Connection; - use uuid::Uuid; + use rusqlite::{vtab::array, Connection}; use super::{migrations, SqliteStore}; - use crate::{ - client::get_random_coin, - config::{ClientConfig, RpcConfig}, - mock::{MockClient, MockRpcApi}, - }; - - pub fn create_test_client() -> MockClient { - let client_config = ClientConfig { - store: create_test_store_path() - .into_os_string() - .into_string() - .unwrap() - .try_into() - .unwrap(), - rpc: RpcConfig::default(), - }; - - let rpc_endpoint = client_config.rpc.endpoint.to_string(); - let store = SqliteStore::new((&client_config).into()).unwrap(); - let rng = get_random_coin(); - let executor_store = SqliteStore::new((&client_config).into()).unwrap(); - - MockClient::new(MockRpcApi::new(&rpc_endpoint), rng, store, executor_store, true) - } - - pub(crate) fn create_test_store_path() -> std::path::PathBuf { - let mut temp_file = temp_dir(); - temp_file.push(format!("{}.sqlite3", Uuid::new_v4())); - temp_file - } + use crate::mock::create_test_store_path; pub(crate) fn create_test_store() -> SqliteStore { let temp_file = create_test_store_path(); let mut db = Connection::open(temp_file).unwrap(); + array::load_module(&db).unwrap(); migrations::update_to_latest(&mut db).unwrap(); - SqliteStore { db } + SqliteStore { db: RefCell::new(db) } } } diff --git a/src/store/sqlite_store/notes.rs b/src/store/sqlite_store/notes.rs index ecee9f456..1d58dbfe8 100644 --- a/src/store/sqlite_store/notes.rs +++ b/src/store/sqlite_store/notes.rs @@ -1,12 +1,15 @@ +use alloc::rc::Rc; use std::fmt; use clap::error::Result; use miden_objects::{ + accounts::AccountId, crypto::utils::{Deserializable, Serializable}, notes::{NoteAssets, NoteId, NoteInclusionProof, NoteMetadata, NoteScript, Nullifier}, + transaction::TransactionId, Digest, }; -use rusqlite::{named_params, params, Transaction}; +use rusqlite::{named_params, params, params_from_iter, types::Value, Transaction}; use super::SqliteStore; use crate::{ @@ -17,8 +20,9 @@ use crate::{ fn insert_note_query(table_name: NoteTable) -> String { format!("\ INSERT INTO {table_name} - (note_id, assets, recipient, status, metadata, details, inclusion_proof) - VALUES (:note_id, :assets, :recipient, :status, json(:metadata), json(:details), json(:inclusion_proof))") + (note_id, assets, recipient, status, metadata, details, inclusion_proof, consumer_transaction_id) + VALUES (:note_id, :assets, :recipient, :status, json(:metadata), json(:details), json(:inclusion_proof), :consumer_transaction_id)", + table_name = table_name) } // TYPES @@ -47,10 +51,26 @@ type SerializedOutputNoteData = ( Option, ); -type SerializedInputNoteParts = - (Vec, String, String, String, Option, Option, Vec); -type SerializedOutputNoteParts = - (Vec, Option, String, String, String, Option, Option>); +type SerializedInputNoteParts = ( + Vec, + String, + String, + String, + Option, + Option, + Vec, + Option, +); +type SerializedOutputNoteParts = ( + Vec, + Option, + String, + String, + String, + Option, + Option>, + Option, +); // NOTE TABLE // ================================================================================================ @@ -73,7 +93,7 @@ impl fmt::Display for NoteTable { // NOTE FILTER // ================================================================================================ -impl NoteFilter { +impl<'a> NoteFilter<'a> { /// Returns a [String] containing the query for this Filter fn to_query(&self, notes_table: NoteTable) -> String { let base = format!( @@ -84,11 +104,15 @@ impl NoteFilter { note.status, note.metadata, note.inclusion_proof, - script.serialized_note_script + script.serialized_note_script, + tx.account_id from {notes_table} AS note LEFT OUTER JOIN notes_scripts AS script ON note.details IS NOT NULL AND - json_extract(note.details, '$.script_hash') = script.script_hash" + json_extract(note.details, '$.script_hash') = script.script_hash + LEFT OUTER JOIN transactions AS tx + ON note.consumer_transaction_id IS NOT NULL AND + note.consumer_transaction_id = tx.id" ); match self { @@ -96,6 +120,9 @@ impl NoteFilter { NoteFilter::Committed => format!("{base} WHERE status = 'Committed'"), NoteFilter::Consumed => format!("{base} WHERE status = 'Consumed'"), NoteFilter::Pending => format!("{base} WHERE status = 'Pending'"), + NoteFilter::Unique(_) | NoteFilter::List(_) => { + format!("{base} WHERE note.note_id IN rarray(?)") + }, } } } @@ -108,12 +135,44 @@ impl SqliteStore { &self, filter: NoteFilter, ) -> Result, StoreError> { - self.db + let mut params = Vec::new(); + match filter { + NoteFilter::Unique(note_id) => { + let note_ids_list = vec![Value::Text(note_id.inner().to_string())]; + params.push(Rc::new(note_ids_list)); + }, + NoteFilter::List(note_ids) => { + let note_ids_list = note_ids + .iter() + .map(|note_id| Value::Text(note_id.inner().to_string())) + .collect::>(); + + params.push(Rc::new(note_ids_list)) + }, + _ => {}, + } + let notes = self + .db() .prepare(&filter.to_query(NoteTable::InputNotes))? - .query_map([], parse_input_note_columns) + .query_map(params_from_iter(params), parse_input_note_columns) .expect("no binding parameters used in query") .map(|result| Ok(result?).and_then(parse_input_note)) - .collect::, _>>() + .collect::, _>>()?; + + match filter { + NoteFilter::Unique(note_id) if notes.is_empty() => { + return Err(StoreError::NoteNotFound(note_id)); + }, + NoteFilter::List(note_ids) if note_ids.len() != notes.len() => { + let missing_note_id = note_ids + .iter() + .find(|¬e_id| !notes.iter().any(|note_record| note_record.id() == *note_id)) + .expect("should find one note id that wasn't retrieved by the db"); + return Err(StoreError::NoteNotFound(*missing_note_id)); + }, + _ => {}, + } + Ok(notes) } /// Retrieves the output notes from the database @@ -121,41 +180,49 @@ impl SqliteStore { &self, filter: NoteFilter, ) -> Result, StoreError> { - self.db + let mut params = Vec::new(); + match filter { + NoteFilter::Unique(note_id) => { + let note_ids_list = vec![Value::Text(note_id.inner().to_string())]; + params.push(Rc::new(note_ids_list)); + }, + NoteFilter::List(note_ids) => { + let note_ids_list = note_ids + .iter() + .map(|note_id| Value::Text(note_id.inner().to_string())) + .collect::>(); + + params.push(Rc::new(note_ids_list)) + }, + _ => {}, + } + let notes = self + .db() .prepare(&filter.to_query(NoteTable::OutputNotes))? - .query_map([], parse_output_note_columns) + .query_map(params_from_iter(params), parse_output_note_columns) .expect("no binding parameters used in query") .map(|result| Ok(result?).and_then(parse_output_note)) - .collect::, _>>() - } - - pub(crate) fn get_input_note(&self, note_id: NoteId) -> Result { - let query_id = ¬e_id.inner().to_string(); - - const QUERY: &str = "SELECT - note.assets, - note.details, - note.recipient, - note.status, - note.metadata, - note.inclusion_proof, - script.serialized_note_script - from input_notes AS note - LEFT OUTER JOIN notes_scripts AS script - ON note.details IS NOT NULL AND - json_extract(note.details, '$.script_hash') = script.script_hash - WHERE note.note_id = ?"; - - self.db - .prepare(QUERY)? - .query_map(params![query_id.to_string()], parse_input_note_columns)? - .map(|result| Ok(result?).and_then(parse_input_note)) - .next() - .ok_or(StoreError::InputNoteNotFound(note_id))? + .collect::, _>>()?; + + match filter { + NoteFilter::Unique(note_id) if notes.is_empty() => { + return Err(StoreError::NoteNotFound(note_id)); + }, + NoteFilter::List(note_ids) if note_ids.len() != notes.len() => { + let missing_note_id = note_ids + .iter() + .find(|¬e_id| !notes.iter().any(|note_record| note_record.id() == *note_id)) + .expect("should find one note id that wasn't retrieved by the db"); + return Err(StoreError::NoteNotFound(*missing_note_id)); + }, + _ => {}, + } + Ok(notes) } - pub(crate) fn insert_input_note(&mut self, note: &InputNoteRecord) -> Result<(), StoreError> { - let tx = self.db.transaction()?; + pub(crate) fn insert_input_note(&self, note: &InputNoteRecord) -> Result<(), StoreError> { + let mut db = self.db(); + let tx = db.transaction()?; insert_input_note_tx(&tx, note)?; @@ -166,7 +233,7 @@ impl SqliteStore { pub fn get_unspent_input_note_nullifiers(&self) -> Result, StoreError> { const QUERY: &str = "SELECT json_extract(details, '$.nullifier') FROM input_notes WHERE status = 'Committed'"; - self.db + self.db() .prepare(QUERY)? .query_map([], |row| row.get(0)) .expect("no binding parameters used in query") @@ -211,13 +278,14 @@ pub(super) fn insert_input_note_tx( ":metadata": metadata, ":details": details, ":inclusion_proof": inclusion_proof, + ":consumer_transaction_id": None::, }, ) .map_err(|err| StoreError::QueryError(err.to_string())) .map(|_| ())?; const QUERY: &str = - "INSERT OR IGNORE INTO notes_scripts (script_hash, serialized_note_script) VALUES (?, ?)"; + "INSERT OR REPLACE INTO notes_scripts (script_hash, serialized_note_script) VALUES (?, ?)"; tx.execute(QUERY, params![note_script_hash, serialized_note_script,]) .map_err(|err| StoreError::QueryError(err.to_string())) .map(|_| ()) @@ -256,12 +324,31 @@ pub fn insert_output_note_tx( .map(|_| ())?; const QUERY: &str = - "INSERT OR IGNORE INTO notes_scripts (script_hash, serialized_note_script) VALUES (?, ?)"; + "INSERT OR REPLACE INTO notes_scripts (script_hash, serialized_note_script) VALUES (?, ?)"; tx.execute(QUERY, params![note_script_hash, serialized_note_script,]) .map_err(|err| StoreError::QueryError(err.to_string())) .map(|_| ()) } +pub fn update_note_consumer_tx_id( + tx: &Transaction<'_>, + note_id: NoteId, + consumer_tx_id: TransactionId, +) -> Result<(), StoreError> { + const QUERY: &str = "UPDATE input_notes SET consumer_transaction_id = :consumer_transaction_id WHERE note_id = :note_id; + UPDATE output_notes SET consumer_transaction_id = :consumer_transaction_id WHERE note_id = :note_id;"; + + tx.execute( + QUERY, + named_params! { + ":note_id": note_id.inner().to_string(), + ":consumer_transaction_id": consumer_tx_id.to_string(), + }, + ) + .map_err(|err| StoreError::QueryError(err.to_string())) + .map(|_| ()) +} + /// Parse input note columns from the provided row into native types. fn parse_input_note_columns( row: &rusqlite::Row<'_>, @@ -273,6 +360,7 @@ fn parse_input_note_columns( let metadata: Option = row.get(4)?; let inclusion_proof: Option = row.get(5)?; let serialized_note_script: Vec = row.get(6)?; + let consumer_account_id: Option = row.get(7)?; Ok(( assets, @@ -282,6 +370,7 @@ fn parse_input_note_columns( metadata, inclusion_proof, serialized_note_script, + consumer_account_id, )) } @@ -297,6 +386,7 @@ fn parse_input_note( note_metadata, note_inclusion_proof, serialized_note_script, + consumer_account_id, ) = serialized_input_note_parts; // Merge the info that comes from the input notes table and the notes script table @@ -336,7 +426,10 @@ fn parse_input_note( let id = NoteId::new(recipient, note_assets.commitment()); let status: NoteStatus = serde_json::from_str(&format!("\"{status}\"")) .map_err(StoreError::JsonDataDeserializationError)?; - + let consumer_account_id: Option = match consumer_account_id { + Some(account_id) => Some(AccountId::try_from(account_id as u64)?), + None => None, + }; Ok(InputNoteRecord::new( id, recipient, @@ -345,6 +438,7 @@ fn parse_input_note( note_metadata, inclusion_proof, note_details, + consumer_account_id, )) } @@ -421,6 +515,7 @@ fn parse_output_note_columns( let metadata: String = row.get(4)?; let inclusion_proof: Option = row.get(5)?; let serialized_note_script: Option> = row.get(6)?; + let consumer_account_id: Option = row.get(7)?; Ok(( assets, @@ -430,6 +525,7 @@ fn parse_output_note_columns( metadata, inclusion_proof, serialized_note_script, + consumer_account_id, )) } @@ -445,6 +541,7 @@ fn parse_output_note( note_metadata, note_inclusion_proof, serialized_note_script, + consumer_account_id, ) = serialized_output_note_parts; let note_details: Option = if let Some(details_as_json_str) = note_details { @@ -487,6 +584,11 @@ fn parse_output_note( let status: NoteStatus = serde_json::from_str(&format!("\"{status}\"")) .map_err(StoreError::JsonDataDeserializationError)?; + let consumer_account_id: Option = match consumer_account_id { + Some(account_id) => Some(AccountId::try_from(account_id as u64)?), + None => None, + }; + Ok(OutputNoteRecord::new( id, recipient, @@ -495,6 +597,7 @@ fn parse_output_note( note_metadata, inclusion_proof, note_details, + consumer_account_id, )) } diff --git a/src/store/sqlite_store/store.sql b/src/store/sqlite_store/store.sql index 334485b13..b2fb3fd48 100644 --- a/src/store/sqlite_store/store.sql +++ b/src/store/sqlite_store/store.sql @@ -24,6 +24,7 @@ CREATE TABLE account_vaults ( CREATE TABLE account_auth ( account_id UNSIGNED BIG INT NOT NULL, -- ID of the account auth_info BLOB NOT NULL, -- Serialized representation of information needed for authentication + pub_key BLOB NOT NULL, -- Public key for easier authenticator use PRIMARY KEY (account_id) ); @@ -40,7 +41,7 @@ CREATE TABLE accounts ( FOREIGN KEY (code_root) REFERENCES account_code(root), FOREIGN KEY (storage_root) REFERENCES account_storage(root), FOREIGN KEY (vault_root) REFERENCES account_vaults(root) - + CONSTRAINT check_seed_nonzero CHECK (NOT (nonce = 0 AND account_seed IS NULL)) ); @@ -51,11 +52,11 @@ CREATE TABLE transactions ( init_account_state BLOB NOT NULL, -- Hash of the account state before the transaction was executed. final_account_state BLOB NOT NULL, -- Hash of the account state after the transaction was executed. input_notes BLOB, -- Serialized list of input note hashes - output_notes BLOB, -- Serialized list of output note hashes + output_notes BLOB, -- Serialized list of output note hashes script_hash BLOB, -- Transaction script hash script_inputs BLOB, -- Transaction script inputs block_num UNSIGNED BIG INT, -- Block number for the block against which the transaction was executed. - commit_height UNSIGNED BIG INT NULL, -- Block number of the block at which the transaction was included in the chain. + commit_height UNSIGNED BIG INT NULL, -- Block number of the block at which the transaction was included in the chain. FOREIGN KEY (script_hash) REFERENCES transaction_scripts(script_hash), PRIMARY KEY (id) ); @@ -82,7 +83,7 @@ CREATE TABLE input_notes ( -- sub_hash -- sub hash of the block the note was included in stored as a hex string -- note_root -- the note root of the block the note was created in -- note_path -- the Merkle path to the note in the note Merkle tree of the block the note was created in, stored as an array of digests - + metadata JSON NULL, -- JSON consisting of the following fields: -- sender_id -- the account ID of the sender -- tag -- the note tag @@ -92,10 +93,12 @@ CREATE TABLE input_notes ( -- script_hash -- the note's script hash -- inputs -- the serialized NoteInputs, including inputs hash and list of inputs -- serial_num -- the note serial number + consumer_transaction_id BLOB NULL, -- the transaction ID of the transaction that consumed the note + FOREIGN KEY (consumer_transaction_id) REFERENCES transactions(id) PRIMARY KEY (note_id) CONSTRAINT check_valid_inclusion_proof_json CHECK ( - inclusion_proof IS NULL OR + inclusion_proof IS NULL OR ( json_extract(inclusion_proof, '$.origin.block_num') IS NOT NULL AND json_extract(inclusion_proof, '$.origin.node_index') IS NOT NULL AND @@ -104,6 +107,7 @@ CREATE TABLE input_notes ( json_extract(inclusion_proof, '$.note_path') IS NOT NULL )) CONSTRAINT check_valid_metadata_json CHECK (metadata IS NULL OR (json_extract(metadata, '$.sender') IS NOT NULL AND json_extract(metadata, '$.tag') IS NOT NULL)) + CONSTRAINT check_valid_consumer_transaction_id CHECK (consumer_transaction_id IS NULL OR status != 'Pending') ); -- Create output notes table @@ -121,7 +125,7 @@ CREATE TABLE output_notes ( -- sub_hash -- sub hash of the block the note was included in stored as a hex string -- note_root -- the note root of the block the note was created in -- note_path -- the Merkle path to the note in the note Merkle tree of the block the note was created in, stored as an array of digests - + metadata JSON NOT NULL, -- JSON consisting of the following fields: -- sender_id -- the account ID of the sender -- tag -- the note tag @@ -131,10 +135,12 @@ CREATE TABLE output_notes ( -- script -- the note's script hash -- inputs -- the serialized NoteInputs, including inputs hash and list of inputs -- serial_num -- the note serial number + consumer_transaction_id BLOB NULL, -- the transaction ID of the transaction that consumed the note + FOREIGN KEY (consumer_transaction_id) REFERENCES transactions(id) PRIMARY KEY (note_id) CONSTRAINT check_valid_inclusion_proof_json CHECK ( - inclusion_proof IS NULL OR + inclusion_proof IS NULL OR ( json_extract(inclusion_proof, '$.origin.block_num') IS NOT NULL AND json_extract(inclusion_proof, '$.origin.node_index') IS NOT NULL AND @@ -143,14 +149,14 @@ CREATE TABLE output_notes ( json_extract(inclusion_proof, '$.note_path') IS NOT NULL )) CONSTRAINT check_valid_details_json CHECK ( - details IS NULL OR + details IS NULL OR ( json_extract(details, '$.nullifier') IS NOT NULL AND json_extract(details, '$.script_hash') IS NOT NULL AND json_extract(details, '$.inputs') IS NOT NULL AND json_extract(details, '$.serial_num') IS NOT NULL )) - + CONSTRAINT check_valid_consumer_transaction_id CHECK (consumer_transaction_id IS NULL OR status != 'Pending') ); -- Create note's scripts table, used for both input and output notes diff --git a/src/store/sqlite_store/sync.rs b/src/store/sqlite_store/sync.rs index f642ae25e..174da825c 100644 --- a/src/store/sqlite_store/sync.rs +++ b/src/store/sqlite_store/sync.rs @@ -1,24 +1,18 @@ -use miden_objects::{ - accounts::Account, - crypto::merkle::{InOrderIndex, MmrPeaks}, - notes::NoteInclusionProof, - transaction::TransactionId, - BlockHeader, Digest, -}; +use miden_objects::notes::{NoteInclusionProof, NoteTag}; use rusqlite::{named_params, params}; use super::SqliteStore; use crate::{ - client::sync::SyncedNewNotes, + client::sync::StateSyncUpdate, errors::StoreError, store::sqlite_store::{accounts::update_account, notes::insert_input_note_tx}, }; impl SqliteStore { - pub(crate) fn get_note_tags(&self) -> Result, StoreError> { + pub(crate) fn get_note_tags(&self) -> Result, StoreError> { const QUERY: &str = "SELECT tags FROM state_sync"; - self.db + self.db() .prepare(QUERY)? .query_map([], |row| row.get(0)) .expect("no binding parameters used in query") @@ -33,7 +27,7 @@ impl SqliteStore { .expect("state sync tags exist") } - pub(super) fn add_note_tag(&mut self, tag: u64) -> Result { + pub(super) fn add_note_tag(&self, tag: NoteTag) -> Result { let mut tags = self.get_note_tags()?; if tags.contains(&tag) { return Ok(false); @@ -42,15 +36,30 @@ impl SqliteStore { let tags = serde_json::to_string(&tags).map_err(StoreError::InputSerializationError)?; const QUERY: &str = "UPDATE state_sync SET tags = ?"; - self.db.execute(QUERY, params![tags])?; + self.db().execute(QUERY, params![tags])?; Ok(true) } + pub(super) fn remove_note_tag(&self, tag: NoteTag) -> Result { + let mut tags = self.get_note_tags()?; + if let Some(index_of_tag) = tags.iter().position(|&tag_candidate| tag_candidate == tag) { + tags.remove(index_of_tag); + + let tags = serde_json::to_string(&tags).map_err(StoreError::InputSerializationError)?; + + const QUERY: &str = "UPDATE state_sync SET tags = ?"; + self.db().execute(QUERY, params![tags])?; + return Ok(true); + } + + Ok(false) + } + pub(super) fn get_sync_height(&self) -> Result { const QUERY: &str = "SELECT block_num FROM state_sync"; - self.db + self.db() .prepare(QUERY)? .query_map([], |row| row.get(0)) .expect("no binding parameters used in query") @@ -60,16 +69,22 @@ impl SqliteStore { } pub(super) fn apply_state_sync( - &mut self, - block_header: BlockHeader, - nullifiers: Vec, - committed_notes: SyncedNewNotes, - committed_transactions: &[TransactionId], - new_mmr_peaks: MmrPeaks, - new_authentication_nodes: &[(InOrderIndex, Digest)], - updated_onchain_accounts: &[Account], + &self, + state_sync_update: StateSyncUpdate, ) -> Result<(), StoreError> { - let tx = self.db.transaction()?; + let StateSyncUpdate { + block_header, + nullifiers, + synced_new_notes: committed_notes, + transactions_to_commit: committed_transactions, + new_mmr_peaks, + new_authentication_nodes, + updated_onchain_accounts, + block_has_relevant_notes, + } = state_sync_update; + + let mut db = self.db(); + let tx = db.transaction()?; // Update state sync block number const BLOCK_NUMBER_QUERY: &str = "UPDATE state_sync SET block_num = ?"; @@ -87,16 +102,13 @@ impl SqliteStore { tx.execute(SPENT_OUTPUT_NOTE_QUERY, params![nullifier])?; } - // TODO: Due to the fact that notes are returned based on fuzzy matching of tags, - // this process of marking if the header has notes needs to be revisited - let block_has_relevant_notes = !committed_notes.is_empty(); Self::insert_block_header_tx(&tx, block_header, new_mmr_peaks, block_has_relevant_notes)?; // Insert new authentication nodes (inner nodes of the PartialMmr) - Self::insert_chain_mmr_nodes(&tx, new_authentication_nodes)?; + Self::insert_chain_mmr_nodes_tx(&tx, &new_authentication_nodes)?; - // Update tracked notes - for (note_id, inclusion_proof) in committed_notes.new_inclusion_proofs().iter() { + // Update tracked output notes + for (note_id, inclusion_proof) in committed_notes.updated_output_notes().iter() { let block_num = inclusion_proof.origin().block_num; let sub_hash = inclusion_proof.sub_hash(); let note_root = inclusion_proof.note_root(); @@ -111,26 +123,38 @@ impl SqliteStore { )?) .map_err(StoreError::InputSerializationError)?; - const COMMITTED_INPUT_NOTES_QUERY: &str = - "UPDATE input_notes SET status = 'Committed', inclusion_proof = json(:inclusion_proof) WHERE note_id = :note_id"; + // Update output notes + const COMMITTED_OUTPUT_NOTES_QUERY: &str = + "UPDATE output_notes SET status = 'Committed', inclusion_proof = json(:inclusion_proof) WHERE note_id = :note_id"; tx.execute( - COMMITTED_INPUT_NOTES_QUERY, + COMMITTED_OUTPUT_NOTES_QUERY, named_params! { ":inclusion_proof": inclusion_proof, ":note_id": note_id.inner().to_hex(), }, )?; + } - // Update output notes - const COMMITTED_OUTPUT_NOTES_QUERY: &str = - "UPDATE output_notes SET status = 'Committed', inclusion_proof = json(:inclusion_proof) WHERE note_id = :note_id"; + // Update tracked input notes + for input_note in committed_notes.updated_input_notes().iter() { + let inclusion_proof = input_note.proof(); + let metadata = input_note.note().metadata(); + + let inclusion_proof = serde_json::to_string(inclusion_proof) + .map_err(StoreError::InputSerializationError)?; + let metadata = + serde_json::to_string(metadata).map_err(StoreError::InputSerializationError)?; + + const COMMITTED_INPUT_NOTES_QUERY: &str = + "UPDATE input_notes SET status = 'Committed', inclusion_proof = json(:inclusion_proof), metadata = json(:metadata) WHERE note_id = :note_id"; tx.execute( - COMMITTED_OUTPUT_NOTES_QUERY, + COMMITTED_INPUT_NOTES_QUERY, named_params! { ":inclusion_proof": inclusion_proof, - ":note_id": note_id.inner().to_hex(), + ":metadata": metadata, + ":note_id": input_note.id().inner().to_hex(), }, )?; } @@ -144,12 +168,12 @@ impl SqliteStore { Self::mark_transactions_as_committed( &tx, block_header.block_num(), - committed_transactions, + &committed_transactions, )?; // Update onchain accounts on the db that have been updated onchain for account in updated_onchain_accounts { - update_account(&tx, account)?; + update_account(&tx, &account)?; } // Commit the updates diff --git a/src/store/sqlite_store/transactions.rs b/src/store/sqlite_store/transactions.rs index 1cb5ae898..fe7c74cb0 100644 --- a/src/store/sqlite_store/transactions.rs +++ b/src/store/sqlite_store/transactions.rs @@ -12,13 +12,15 @@ use tracing::info; use super::{ accounts::update_account, - notes::{insert_input_note_tx, insert_output_note_tx}, + notes::{insert_input_note_tx, insert_output_note_tx, update_note_consumer_tx_id}, SqliteStore, }; use crate::{ - client::transactions::{TransactionRecord, TransactionResult, TransactionStatus}, + client::transactions::{ + notes_from_output, TransactionRecord, TransactionResult, TransactionStatus, + }, errors::StoreError, - store::{InputNoteRecord, OutputNoteRecord, TransactionFilter}, + store::{OutputNoteRecord, TransactionFilter}, }; pub(crate) const INSERT_TRANSACTION_QUERY: &str = @@ -69,7 +71,7 @@ impl SqliteStore { &self, filter: TransactionFilter, ) -> Result, StoreError> { - self.db + self.db() .prepare(&filter.to_query())? .query_map([], parse_transaction_columns) .expect("no binding parameters used in query") @@ -78,7 +80,8 @@ impl SqliteStore { } /// Inserts a transaction and updates the current state based on the `tx_result` changes - pub fn apply_transaction(&mut self, tx_result: TransactionResult) -> Result<(), StoreError> { + pub fn apply_transaction(&self, tx_result: TransactionResult) -> Result<(), StoreError> { + let transaction_id = tx_result.executed_transaction().id(); let account_id = tx_result.executed_transaction().account_id(); let account_delta = tx_result.account_delta(); @@ -86,19 +89,20 @@ impl SqliteStore { account.apply_delta(account_delta).map_err(StoreError::AccountError)?; - let created_input_notes = tx_result - .relevant_notes() - .into_iter() - .map(|note| InputNoteRecord::from(note.clone())) - .collect::>(); + // Save only input notes that we care for (based on the note screener assessment) + let created_input_notes = tx_result.relevant_notes().to_vec(); - let created_output_notes = tx_result - .created_notes() - .iter() - .map(|note| OutputNoteRecord::from(note.clone())) + // Save all output notes + let created_output_notes = notes_from_output(tx_result.created_notes()) + .cloned() + .map(OutputNoteRecord::from) .collect::>(); - let tx = self.db.transaction()?; + let consumed_note_ids = + tx_result.consumed_notes().iter().map(|note| note.id()).collect::>(); + + let mut db = self.db(); + let tx = db.transaction()?; // Transaction Data insert_proven_transaction_data(&tx, tx_result)?; @@ -107,17 +111,18 @@ impl SqliteStore { update_account(&tx, &account)?; // Updates for notes - - // TODO: see if we should filter the input notes we store to keep notes we can consume with - // existing accounts - for note in &created_input_notes { - insert_input_note_tx(&tx, note)?; + for note in created_input_notes { + insert_input_note_tx(&tx, ¬e)?; } for note in &created_output_notes { insert_output_note_tx(&tx, note)?; } + for note_id in consumed_note_ids { + update_note_consumer_tx_id(&tx, note_id, transaction_id)?; + } + tx.commit()?; Ok(()) diff --git a/src/tests.rs b/src/tests.rs index c9e27841b..17cc8e421 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2,10 +2,14 @@ // ================================================================================================ use miden_lib::transaction::TransactionKernel; use miden_objects::{ - accounts::{AccountId, AccountStub, ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN}, + accounts::{ + account_id::testing::ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN, AccountId, AccountStub, + AuthSecretKey, + }, assembly::{AstSerdeOptions, ModuleAst}, assets::{FungibleAsset, TokenSymbol}, crypto::dsa::rpo_falcon512::SecretKey, + notes::NoteTag, Word, }; @@ -15,10 +19,10 @@ use crate::{ transactions::transaction_request::TransactionTemplate, }, mock::{ - get_account_with_default_account_code, mock_full_chain_mmr_and_notes, + create_test_client, get_account_with_default_account_code, mock_full_chain_mmr_and_notes, mock_fungible_faucet_account, mock_notes, ACCOUNT_ID_REGULAR, }, - store::{sqlite_store::tests::create_test_client, AuthInfo, InputNoteRecord, NoteFilter}, + store::{InputNoteRecord, NoteFilter}, }; #[tokio::test] @@ -34,7 +38,7 @@ async fn test_input_notes_round_trip() { // insert notes into database for note in consumed_notes.iter().cloned() { - client.import_input_note(note.into()).unwrap(); + client.import_input_note(note.into(), false).await.unwrap(); } // retrieve notes from database @@ -58,7 +62,10 @@ async fn test_get_input_note() { let (_consumed_notes, created_notes) = mock_notes(&assembler); // insert Note into database - client.import_input_note(created_notes.first().unwrap().clone().into()).unwrap(); + client + .import_input_note(created_notes.first().unwrap().clone().into(), false) + .await + .unwrap(); // retrieve note from database let retrieved_note = @@ -148,10 +155,14 @@ async fn insert_same_account_twice_fails() { let key_pair = SecretKey::new(); assert!(client - .insert_account(&account, Some(Word::default()), &AuthInfo::RpoFalcon512(key_pair.clone())) + .insert_account( + &account, + Some(Word::default()), + &AuthSecretKey::RpoFalcon512(key_pair.clone()) + ) .is_ok()); assert!(client - .insert_account(&account, Some(Word::default()), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&account, Some(Word::default()), &AuthSecretKey::RpoFalcon512(key_pair)) .is_err()); } @@ -179,7 +190,7 @@ async fn test_account_code() { assert_eq!(account_module, reconstructed_ast); client - .insert_account(&account, Some(Word::default()), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&account, Some(Word::default()), &AuthSecretKey::RpoFalcon512(key_pair)) .unwrap(); let (retrieved_acc, _) = client.get_account(account.id()).unwrap(); @@ -203,7 +214,7 @@ async fn test_get_account_by_id() { let key_pair = SecretKey::new(); client - .insert_account(&account, Some(Word::default()), &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&account, Some(Word::default()), &AuthSecretKey::RpoFalcon512(key_pair)) .unwrap(); // Retrieving an existing account should succeed @@ -233,16 +244,17 @@ async fn test_sync_state() { let pending_notes = client.get_input_notes(NoteFilter::Pending).unwrap(); // sync state - let block_num: u32 = client.sync_state().await.unwrap(); + let sync_details = client.sync_state().await.unwrap(); // verify that the client is synced to the latest block assert_eq!( - block_num, + sync_details.block_num, client.rpc_api().state_sync_requests.first_key_value().unwrap().1.chain_tip ); // verify that we now have one consumed note after syncing state assert_eq!(client.get_input_notes(NoteFilter::Consumed).unwrap().len(), 1); + assert_eq!(sync_details.new_nullifiers, 1); // verify that the pending note we had is now committed assert_ne!(client.get_input_notes(NoteFilter::Committed).unwrap(), pending_notes); @@ -263,11 +275,11 @@ async fn test_sync_state_mmr_state() { let tracked_block_headers = crate::mock::insert_mock_data(&mut client).await; // sync state - let block_num: u32 = client.sync_state().await.unwrap(); + let sync_details = client.sync_state().await.unwrap(); // verify that the client is synced to the latest block assert_eq!( - block_num, + sync_details.block_num, client.rpc_api().state_sync_requests.first_key_value().unwrap().1.chain_tip ); @@ -279,7 +291,7 @@ async fn test_sync_state_mmr_state() { // verify that we inserted the latest block into the db via the client let latest_block = client.get_sync_height().unwrap(); - assert_eq!(block_num, latest_block); + assert_eq!(sync_details.block_num, latest_block); assert_eq!( tracked_block_headers[tracked_block_headers.len() - 1], client.get_block_headers(&[latest_block]).unwrap()[0].0 @@ -309,27 +321,40 @@ async fn test_sync_state_mmr_state() { } #[tokio::test] -async fn test_add_tag() { +async fn test_tags() { // generate test client with a random store name let mut client = create_test_client(); - // assert that no tags are being tracked - assert_eq!(client.get_note_tags().unwrap().len(), 0); + // Assert that the store gets created with the tag 0 (used for notes consumable by any account) + assert_eq!(client.get_note_tags().unwrap(), vec![]); // add a tag - const TAG_VALUE_1: u64 = 1; - const TAG_VALUE_2: u64 = 2; - client.add_note_tag(TAG_VALUE_1).unwrap(); - client.add_note_tag(TAG_VALUE_2).unwrap(); + let tag_1: NoteTag = 1.into(); + let tag_2: NoteTag = 2.into(); + client.add_note_tag(tag_1).unwrap(); + client.add_note_tag(tag_2).unwrap(); // verify that the tag is being tracked - assert_eq!(client.get_note_tags().unwrap(), vec![TAG_VALUE_1, TAG_VALUE_2]); + assert_eq!(client.get_note_tags().unwrap(), vec![tag_1, tag_2]); // attempt to add the same tag again - client.add_note_tag(TAG_VALUE_1).unwrap(); + client.add_note_tag(tag_1).unwrap(); // verify that the tag is still being tracked only once - assert_eq!(client.get_note_tags().unwrap(), vec![TAG_VALUE_1, TAG_VALUE_2]); + assert_eq!(client.get_note_tags().unwrap(), vec![tag_1, tag_2]); + + // Try removing non-existent tag + let tag_4: NoteTag = 4.into(); + client.remove_note_tag(tag_4).unwrap(); + + // verify that the tracked tags are unchanged + assert_eq!(client.get_note_tags().unwrap(), vec![tag_1, tag_2]); + + // remove second tag + client.remove_note_tag(tag_1).unwrap(); + + // verify that tag_1 is not tracked anymore + assert_eq!(client.get_note_tags().unwrap(), vec![tag_2]); } #[tokio::test] @@ -351,7 +376,7 @@ async fn test_mint_transaction() { client .store() - .insert_account(&faucet, None, &AuthInfo::RpoFalcon512(key_pair)) + .insert_account(&faucet, None, &AuthSecretKey::RpoFalcon512(key_pair)) .unwrap(); client.sync_state().await.unwrap(); @@ -368,3 +393,47 @@ async fn test_mint_transaction() { let transaction = client.new_transaction(transaction_request).unwrap(); assert!(transaction.executed_transaction().account_delta().nonce().is_some()); } + +#[tokio::test] +async fn test_get_output_notes() { + const FAUCET_ID: u64 = ACCOUNT_ID_FUNGIBLE_FAUCET_OFF_CHAIN; + const INITIAL_BALANCE: u64 = 1000; + + // generate test client with a random store name + let mut client = create_test_client(); + + // Faucet account generation + let key_pair = SecretKey::new(); + + let faucet = mock_fungible_faucet_account( + AccountId::try_from(FAUCET_ID).unwrap(), + INITIAL_BALANCE, + key_pair.clone(), + ); + + client + .store() + .insert_account(&faucet, None, &AuthSecretKey::RpoFalcon512(key_pair)) + .unwrap(); + + client.sync_state().await.unwrap(); + + // Test submitting a mint transaction + let transaction_template = TransactionTemplate::MintFungibleAsset( + FungibleAsset::new(faucet.id(), 5u64).unwrap(), + AccountId::from_hex("0x168187d729b31a84").unwrap(), + miden_objects::notes::NoteType::OffChain, + ); + + let transaction_request = client.build_transaction_request(transaction_template).unwrap(); + + //Before executing transaction, there are no output notes + assert!(client.get_output_notes(NoteFilter::All).unwrap().is_empty()); + + let transaction = client.new_transaction(transaction_request).unwrap(); + client.submit_transaction(transaction).await.unwrap(); + + // Check that there was an output note but it wasn't consumed + assert!(client.get_output_notes(NoteFilter::Consumed).unwrap().is_empty()); + assert!(!client.get_output_notes(NoteFilter::All).unwrap().is_empty()); +} diff --git a/tests/README.md b/tests/README.md index ee66a087c..874a4c191 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,17 +4,17 @@ This document describes the current state of the organization of integration tes ## Running integration tests -There are commands provided in the `Makefile` to make running them easier. To run the current integration test, you should run: +There are commands provided in the `Makefile.toml` to make running them easier. To run the current integration test, you should run: ```bash # This will ensure we start from a clean node and client -make reset -# This command will clone the node's repo and generate the accounts and genesis files and lastly start the node and run it on background -make start-node & +cargo make reset +# This command will clone the node's repo and generate the accounts and genesis files and lastly start the node +cargo make node +# This command will run the node on background +cargo make start-node & # This will run the integration test -make integration-test -# After tests are done, we can kill the node process -make kill-node +cargo make integration-test ``` ## Integration Test Flow @@ -26,50 +26,28 @@ and transferring assets which runs against a running node. Before running the tests though, there is a setup we need to perform to have a node up and running. This is accomplished with the `node` command from the -`Makefile` and what it does is: +`Makefile.toml` and what it does is: - Clone the node repo if it doesn't exist. - Delete previously existing data. - Generate genesis and account data with `cargo run --release --bin miden-node --features testing -- make-genesis --inputs-path node/genesis.toml`. -After that we can start the node, again done in the `start-node` command from the `Makefile` +After that we can start the node, again done in the `start-node` command from +the `Makefile.toml`. Killing the node process after running the test is also +the user's responsibilty. ### Test Run -To run the integration test you just need to run `make integration-test`. It'll -run the rust binary for the integration test and report whether there was an -error or not and the exit code if so. Lastly it kills the node's process. - -### The test itself - -The current integration test at `./integration/main.rs` goes through the following steps: - -0. Wait for the node to be reachable (this is mainly so you can run `make start-node` - and `make integration-test` in parallel without major issues). - This is done with a sync request, although in the future we might use a - health check endpoint or similar. -1. Load accounts (1 regular *A*, 1 faucet *C*) created with the `make-genesis` - command of the node -2. Create an extra regular account *C* -3. Sync up the client -4. Mint an asset (this creates a note for the regular account *A*) and sync - again -5. Consume the note and sync again. (After this point the account *A* should - have an asset from faucet *C*) -6. Run a P2ID transaction to transfer some of the minted asset from account *A* - to *B*. Sync again -7. Consume the P2ID note for account *B*. Now both accounts should have some of - asset from faucet *C* -8. A double-spend is attempted to check that the client does not allow this - -In short, we're testing: - -- Account importing. -- Account creation. -- Sync and entity tracking. -- Mint tx. -- Consume note tx (both for an imported and a created account). -- P2ID tx. +To run the integration test you just need to run `cargo make integration-test`. +It'll run the integration tests as a cargo test using the `integration` feature +which is used to separate regular tests from integration tests. + +### Running tests against a remote node + +You can run the integration tests against a remote node by overwriting the rpc +section of the configuration file at `./config/miden-client.toml`. Note that +the store configuration part of the file is ignored as each test creates its +own database. ## CI integration diff --git a/tests/config/genesis.toml b/tests/config/genesis.toml new file mode 100644 index 000000000..b9f3c425c --- /dev/null +++ b/tests/config/genesis.toml @@ -0,0 +1,17 @@ +version = 1 +timestamp = 1672531200 + +[[accounts]] +type = "BasicWallet" +init_seed = "0xa123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +auth_scheme = "RpoFalcon512" +auth_seed = "0xb123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +[[accounts]] +type = "BasicFungibleFaucet" +init_seed = "0xc123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +auth_scheme = "RpoFalcon512" +auth_seed = "0xd123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +token_symbol = "POL" +decimals = 12 +max_supply = 1000000 diff --git a/tests/config/miden-client.toml b/tests/config/miden-client.toml new file mode 100644 index 000000000..2405337b4 --- /dev/null +++ b/tests/config/miden-client.toml @@ -0,0 +1,13 @@ +## USAGE: +## ================================================================================================ +# [rpc]: Settings for the RPC client used to communicate with the node +# - endpoint: tuple indicating the protocol (http, https), the host, and the port where the node is listening. +# [store]: Settings for the client's Store +# - database_filepath: path for the sqlite's database +[rpc] +endpoint = { protocol = "http", host = "localhost", port = 57291 } +timeout = 10000 + +[store] +# IGNORED ON INTEGRATION TESTS +database_filepath = "" diff --git a/tests/config/miden-node.toml b/tests/config/miden-node.toml new file mode 100644 index 000000000..85ae4c97a --- /dev/null +++ b/tests/config/miden-node.toml @@ -0,0 +1,20 @@ +[block_producer] +# port defined as: sum(ord(c)**p for (p, c) in enumerate('miden-block-producer', 1)) % 2**16 +endpoint = { host = "localhost", port = 48046 } +store_url = "http://localhost:28943" +# enables or disables the verification of transaction proofs before they are accepted into the +# transaction queue. +verify_tx_proofs = true + +[rpc] +# port defined as: sum(ord(c)**p for (p, c) in enumerate('miden-rpc', 1)) % 2**16 +endpoint = { host = "0.0.0.0", port = 57291 } +block_producer_url = "http://localhost:48046" +store_url = "http://localhost:28943" + +[store] +# port defined as: sum(ord(c)**p for (p, c) in enumerate('miden-store', 1)) % 2**16 +endpoint = { host = "localhost", port = 28943 } +database_filepath = "./miden-store.sqlite3" +genesis_filepath = "./genesis.dat" +blockstore_dir = "./blocks" diff --git a/tests/integration/asm/custom_p2id.masm b/tests/integration/asm/custom_p2id.masm index 3d9b07be5..827b6aa68 100644 --- a/tests/integration/asm/custom_p2id.masm +++ b/tests/integration/asm/custom_p2id.masm @@ -49,8 +49,11 @@ end begin # drop the note script root dropw + # => [NOTE_ARG] push.{expected_note_arg} assert_eqw + # drop the note script root + dropw # store the note inputs to memory starting at address 0 push.0 exec.note::get_inputs diff --git a/tests/integration/common.rs b/tests/integration/common.rs new file mode 100644 index 000000000..7e001598a --- /dev/null +++ b/tests/integration/common.rs @@ -0,0 +1,281 @@ +use std::{env::temp_dir, rc::Rc, time::Duration}; + +use figment::{ + providers::{Format, Toml}, + Figment, +}; +use miden_client::{ + client::{ + accounts::{AccountStorageMode, AccountTemplate}, + get_random_coin, + rpc::TonicRpcClient, + store_authenticator::StoreAuthenticator, + sync::SyncSummary, + transactions::transaction_request::{TransactionRequest, TransactionTemplate}, + Client, + }, + config::ClientConfig, + errors::{ClientError, NodeRpcClientError}, + store::{sqlite_store::SqliteStore, NoteFilter, TransactionFilter}, +}; +use miden_objects::{ + accounts::{ + account_id::testing::ACCOUNT_ID_REGULAR_ACCOUNT_UPDATABLE_CODE_OFF_CHAIN, Account, + AccountId, + }, + assets::{Asset, FungibleAsset, TokenSymbol}, + crypto::rand::RpoRandomCoin, + notes::{NoteId, NoteType}, + transaction::InputNote, +}; +use miden_tx::{DataStoreError, TransactionExecutorError}; +use uuid::Uuid; + +pub const ACCOUNT_ID_REGULAR: u64 = ACCOUNT_ID_REGULAR_ACCOUNT_UPDATABLE_CODE_OFF_CHAIN; + +pub type TestClient = Client< + TonicRpcClient, + RpoRandomCoin, + SqliteStore, + StoreAuthenticator, +>; + +pub const TEST_CLIENT_CONFIG_FILE_PATH: &str = "./tests/config/miden-client.toml"; +/// Creates a `TestClient` +/// +/// Creates the client using the config at `TEST_CLIENT_CONFIG_FILE_PATH`. The store's path is at a random temporary location, so the store section of the config file is ignored. +/// +/// # Panics +/// +/// Panics if there is no config file at `TEST_CLIENT_CONFIG_FILE_PATH`, or it cannot be +/// deserialized into a [ClientConfig] +pub fn create_test_client() -> TestClient { + let mut client_config: ClientConfig = Figment::from(Toml::file(TEST_CLIENT_CONFIG_FILE_PATH)) + .extract() + .expect("should be able to read test config at {TEST_CLIENT_CONFIG_FILE_PATH}"); + + client_config.store = create_test_store_path() + .into_os_string() + .into_string() + .unwrap() + .try_into() + .unwrap(); + + let store = { + let sqlite_store = SqliteStore::new((&client_config).into()).unwrap(); + Rc::new(sqlite_store) + }; + + let rng = get_random_coin(); + + let authenticator = StoreAuthenticator::new_with_rng(store.clone(), rng); + TestClient::new(TonicRpcClient::new(&client_config.rpc), rng, store, authenticator, true) +} + +pub fn create_test_store_path() -> std::path::PathBuf { + let mut temp_file = temp_dir(); + temp_file.push(format!("{}.sqlite3", Uuid::new_v4())); + temp_file +} + +pub async fn execute_tx_and_sync(client: &mut TestClient, tx_request: TransactionRequest) { + println!("Executing transaction..."); + let transaction_execution_result = client.new_transaction(tx_request).unwrap(); + let transaction_id = transaction_execution_result.executed_transaction().id(); + + println!("Sending transaction to node"); + client.submit_transaction(transaction_execution_result).await.unwrap(); + + // wait until tx is committed + loop { + println!("Syncing State..."); + client.sync_state().await.unwrap(); + + // Check if executed transaction got committed by the node + let uncommited_transactions = + client.get_transactions(TransactionFilter::Uncomitted).unwrap(); + let is_tx_committed = uncommited_transactions + .iter() + .all(|uncommited_tx| uncommited_tx.id != transaction_id); + + if is_tx_committed { + break; + } + + std::thread::sleep(std::time::Duration::new(3, 0)); + } +} + +// Syncs until `amount_of_blocks` have been created onchain compared to client's sync height +pub async fn wait_for_blocks(client: &mut TestClient, amount_of_blocks: u32) -> SyncSummary { + let current_block = client.get_sync_height().unwrap(); + let final_block = current_block + amount_of_blocks; + println!("Syncing until block {}...", final_block); + // wait until tx is committed + loop { + let summary = client.sync_state().await.unwrap(); + println!("Synced to block {} (syncing until {})...", summary.block_num, final_block); + + if summary.block_num >= final_block { + return summary; + } + + std::thread::sleep(std::time::Duration::new(3, 0)); + } +} + +/// Waits for node to be running. +/// +/// # Panics +/// +/// This function will panic if it does `NUMBER_OF_NODE_ATTEMPTS` unsuccessful checks or if we +/// receive an error other than a connection related error +pub async fn wait_for_node(client: &mut TestClient) { + const NODE_TIME_BETWEEN_ATTEMPTS: u64 = 5; + const NUMBER_OF_NODE_ATTEMPTS: u64 = 60; + + println!("Waiting for Node to be up. Checking every {NODE_TIME_BETWEEN_ATTEMPTS}s for {NUMBER_OF_NODE_ATTEMPTS} tries..."); + + for _try_number in 0..NUMBER_OF_NODE_ATTEMPTS { + match client.sync_state().await { + Err(ClientError::NodeRpcClientError(NodeRpcClientError::ConnectionError(_))) => { + std::thread::sleep(Duration::from_secs(NODE_TIME_BETWEEN_ATTEMPTS)); + }, + Err(other_error) => { + panic!("Unexpected error: {other_error}"); + }, + _ => return, + } + } + + panic!("Unable to connect to node"); +} + +pub const MINT_AMOUNT: u64 = 1000; +pub const TRANSFER_AMOUNT: u64 = 59; + +/// Sets up a basic client and returns (basic_account, basic_account, faucet_account) +pub async fn setup( + client: &mut TestClient, + accounts_storage_mode: AccountStorageMode, +) -> (Account, Account, Account) { + // Enusre clean state + assert!(client.get_account_stubs().unwrap().is_empty()); + assert!(client.get_transactions(TransactionFilter::All).unwrap().is_empty()); + assert!(client.get_input_notes(NoteFilter::All).unwrap().is_empty()); + + // Create faucet account + let (faucet_account, _) = client + .new_account(AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("MATIC").unwrap(), + decimals: 8, + max_supply: 1_000_000_000, + storage_mode: accounts_storage_mode, + }) + .unwrap(); + + // Create regular accounts + let (first_basic_account, _) = client + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + let (second_basic_account, _) = client + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + println!("Syncing State..."); + client.sync_state().await.unwrap(); + + // Get Faucet and regular accounts + println!("Fetching Accounts..."); + (first_basic_account, second_basic_account, faucet_account) +} + +/// Mints a note from faucet_account_id for basic_account_id, waits for inclusion and returns it +/// with 1000 units of the corresponding fungible asset +pub async fn mint_note( + client: &mut TestClient, + basic_account_id: AccountId, + faucet_account_id: AccountId, + note_type: NoteType, +) -> InputNote { + let (regular_account, _seed) = client.get_account(basic_account_id).unwrap(); + assert_eq!(regular_account.vault().assets().count(), 0); + + // Create a Mint Tx for 1000 units of our fungible asset + let fungible_asset = FungibleAsset::new(faucet_account_id, MINT_AMOUNT).unwrap(); + let tx_template = + TransactionTemplate::MintFungibleAsset(fungible_asset, basic_account_id, note_type); + + println!("Minting Asset"); + let tx_request = client.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(client, tx_request.clone()).await; + + // Check that note is committed and return it + println!("Fetching Committed Notes..."); + let note_id = tx_request.expected_output_notes()[0].id(); + let note = client.get_input_note(note_id).unwrap(); + note.try_into().unwrap() +} + +/// Consumes and wait until the transaction gets committed +/// This assumes the notes contain assets +pub async fn consume_notes( + client: &mut TestClient, + account_id: AccountId, + input_notes: &[InputNote], +) { + let tx_template = + TransactionTemplate::ConsumeNotes(account_id, input_notes.iter().map(|n| n.id()).collect()); + println!("Consuming Note..."); + let tx_request = client.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(client, tx_request).await; +} + +pub async fn assert_account_has_single_asset( + client: &TestClient, + account_id: AccountId, + asset_account_id: AccountId, + expected_amount: u64, +) { + let (regular_account, _seed) = client.get_account(account_id).unwrap(); + + assert_eq!(regular_account.vault().assets().count(), 1); + let asset = regular_account.vault().assets().next().unwrap(); + + if let Asset::Fungible(fungible_asset) = asset { + assert_eq!(fungible_asset.faucet_id(), asset_account_id); + assert_eq!(fungible_asset.amount(), expected_amount); + } else { + panic!("Account has consumed a note and should have a fungible asset"); + } +} + +pub async fn assert_note_cannot_be_consumed_twice( + client: &mut TestClient, + consuming_account_id: AccountId, + note_to_consume_id: NoteId, +) { + // Check that we can't consume the P2ID note again + let tx_template = + TransactionTemplate::ConsumeNotes(consuming_account_id, vec![note_to_consume_id]); + println!("Consuming Note..."); + + // Double-spend error expected to be received since we are consuming the same note + let tx_request = client.build_transaction_request(tx_template).unwrap(); + match client.new_transaction(tx_request) { + Err(ClientError::TransactionExecutorError( + TransactionExecutorError::FetchTransactionInputsFailed( + DataStoreError::NoteAlreadyConsumed(_), + ), + )) => {}, + Ok(_) => panic!("Double-spend error: Note should not be consumable!"), + _ => panic!("Unexpected error: {}", note_to_consume_id.to_hex()), + } +} diff --git a/tests/integration/custom_transactions_tests.rs b/tests/integration/custom_transactions_tests.rs new file mode 100644 index 000000000..7fa6d2896 --- /dev/null +++ b/tests/integration/custom_transactions_tests.rs @@ -0,0 +1,230 @@ +use std::collections::BTreeMap; + +use miden_client::client::{ + accounts::{AccountStorageMode, AccountTemplate}, + transactions::transaction_request::TransactionRequest, +}; +use miden_objects::{ + accounts::{AccountId, AuthSecretKey}, + assembly::ProgramAst, + assets::{FungibleAsset, TokenSymbol}, + crypto::rand::{FeltRng, RpoRandomCoin}, + notes::{ + Note, NoteAssets, NoteExecutionHint, NoteInputs, NoteMetadata, NoteRecipient, NoteTag, + NoteType, + }, + Felt, Word, +}; +use miden_tx::utils::Serializable; + +use super::common::*; + +// CUSTOM TRANSACTION REQUEST +// ================================================================================================ +// +// The following functions are for testing custom transaction code. What the test does is: +// +// - Create a custom tx that mints a custom note which checks that the note args are as expected +// (ie, a word of 4 felts that represent [9, 12, 18, 3]) +// +// - Create another transaction that consumes this note with custom code. This custom code only +// asserts that the {asserted_value} parameter is 0. To test this we first execute with +// an incorrect value passed in, and after that we try again with the correct value. +// +// Because it's currently not possible to create/consume notes without assets, the P2ID code +// is used as the base for the note code. +#[tokio::test] +async fn test_transaction_request() { + let mut client = create_test_client(); + wait_for_node(&mut client).await; + + let account_template = AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }; + + client.sync_state().await.unwrap(); + // Insert Account + let (regular_account, _seed) = client.new_account(account_template).unwrap(); + + let account_template = AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("TEST").unwrap(), + decimals: 5u8, + max_supply: 10_000u64, + storage_mode: AccountStorageMode::Local, + }; + let (fungible_faucet, _seed) = client.new_account(account_template).unwrap(); + println!("sda1"); + + // Execute mint transaction in order to create custom note + let note = mint_custom_note(&mut client, fungible_faucet.id(), regular_account.id()).await; + client.sync_state().await.unwrap(); + + // Prepare transaction + + // If these args were to be modified, the transaction would fail because the note code expects + // these exact arguments + let note_args = [[Felt::new(9), Felt::new(12), Felt::new(18), Felt::new(3)]]; + + let note_args_map = BTreeMap::from([(note.id(), Some(note_args[0]))]); + + let code = " + use.miden::contracts::auth::basic->auth_tx + use.miden::kernels::tx::prologue + use.miden::kernels::tx::memory + + begin + push.0 push.{asserted_value} + # => [0, {asserted_value}] + assert_eq + + call.auth_tx::auth_tx_rpo_falcon512 + end + "; + + // FAILURE ATTEMPT + + let failure_code = code.replace("{asserted_value}", "1"); + let program = ProgramAst::parse(&failure_code).unwrap(); + + let tx_script = { + let account_auth = client.get_account_auth(regular_account.id()).unwrap(); + let (pubkey_input, advice_map): (Word, Vec) = match account_auth { + AuthSecretKey::RpoFalcon512(key) => ( + key.public_key().into(), + key.to_bytes().iter().map(|a| Felt::new(*a as u64)).collect::>(), + ), + }; + + let script_inputs = vec![(pubkey_input, advice_map)]; + client.compile_tx_script(program, script_inputs, vec![]).unwrap() + }; + + let transaction_request = TransactionRequest::new( + regular_account.id(), + note_args_map.clone(), + vec![], + vec![], + Some(tx_script), + ); + + // This fails becuase of {asserted_value} having the incorrect number passed in + assert!(client.new_transaction(transaction_request).is_err()); + + // SUCCESS EXECUTION + + let success_code = code.replace("{asserted_value}", "0"); + let program = ProgramAst::parse(&success_code).unwrap(); + + let tx_script = { + let account_auth = client.get_account_auth(regular_account.id()).unwrap(); + let (pubkey_input, advice_map): (Word, Vec) = match account_auth { + AuthSecretKey::RpoFalcon512(key) => ( + key.public_key().into(), + key.to_bytes().iter().map(|a| Felt::new(*a as u64)).collect::>(), + ), + }; + + let script_inputs = vec![(pubkey_input, advice_map)]; + client.compile_tx_script(program, script_inputs, vec![]).unwrap() + }; + + let transaction_request = TransactionRequest::new( + regular_account.id(), + note_args_map, + vec![], + vec![], + Some(tx_script), + ); + + execute_tx_and_sync(&mut client, transaction_request).await; + + client.sync_state().await.unwrap(); +} + +async fn mint_custom_note( + client: &mut TestClient, + faucet_account_id: AccountId, + target_account_id: AccountId, +) -> Note { + // Prepare transaction + let mut random_coin = RpoRandomCoin::new(Default::default()); + let note = create_custom_note(client, faucet_account_id, target_account_id, &mut random_coin); + + let recipient = note + .recipient() + .digest() + .iter() + .map(|x| x.as_int().to_string()) + .collect::>() + .join("."); + + let note_tag = note.metadata().tag().inner(); + + let code = " + use.miden::contracts::faucets::basic_fungible->faucet + use.miden::contracts::auth::basic->auth_tx + + begin + push.{recipient} + push.{note_type} + push.{tag} + push.{amount} + call.faucet::distribute + + call.auth_tx::auth_tx_rpo_falcon512 + dropw dropw + end + " + .replace("{recipient}", &recipient) + .replace("{note_type}", &Felt::new(NoteType::OffChain as u64).to_string()) + .replace("{tag}", &Felt::new(note_tag.into()).to_string()) + .replace("{amount}", &Felt::new(10).to_string()); + + let program = ProgramAst::parse(&code).unwrap(); + + let tx_script = client.compile_tx_script(program, vec![], vec![]).unwrap(); + + let transaction_request = TransactionRequest::new( + faucet_account_id, + BTreeMap::new(), + vec![note.clone()], + vec![], + Some(tx_script), + ); + + let _ = execute_tx_and_sync(client, transaction_request).await; + note +} + +fn create_custom_note( + client: &TestClient, + faucet_account_id: AccountId, + target_account_id: AccountId, + rng: &mut RpoRandomCoin, +) -> Note { + let expected_note_arg = [Felt::new(9), Felt::new(12), Felt::new(18), Felt::new(3)] + .iter() + .map(|x| x.as_int().to_string()) + .collect::>() + .join("."); + + let note_script = + include_str!("asm/custom_p2id.masm").replace("{expected_note_arg}", &expected_note_arg); + let note_script = ProgramAst::parse(¬e_script).unwrap(); + let note_script = client.compile_note_script(note_script, vec![]).unwrap(); + + let inputs = NoteInputs::new(vec![target_account_id.into()]).unwrap(); + let serial_num = rng.draw_word(); + let note_metadata = NoteMetadata::new( + faucet_account_id, + NoteType::OffChain, + NoteTag::from_account_id(target_account_id, NoteExecutionHint::Local).unwrap(), + Default::default(), + ) + .unwrap(); + let note_assets = + NoteAssets::new(vec![FungibleAsset::new(faucet_account_id, 10).unwrap().into()]).unwrap(); + let note_recipient = NoteRecipient::new(serial_num, note_script, inputs); + Note::new(note_assets, note_metadata, note_recipient) +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 923f785b2..b78f48369 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -1,320 +1,31 @@ -use std::{collections::BTreeMap, env::temp_dir, time::Duration}; - use miden_client::{ client::{ accounts::{AccountStorageMode, AccountTemplate}, - get_random_coin, - rpc::TonicRpcClient, - transactions::transaction_request::{ - PaymentTransactionData, TransactionRequest, TransactionTemplate, - }, - Client, + rpc::{AccountDetails, NodeRpcClient}, + transactions::transaction_request::{PaymentTransactionData, TransactionTemplate}, + NoteRelevance, }, - config::{ClientConfig, RpcConfig}, - errors::{ClientError, NodeRpcClientError}, - store::{sqlite_store::SqliteStore, AuthInfo, NoteFilter, TransactionFilter}, + errors::ClientError, + store::{NoteFilter, NoteStatus}, }; -use miden_lib::transaction::TransactionKernel; use miden_objects::{ - accounts::{Account, AccountId, ACCOUNT_ID_REGULAR_ACCOUNT_UPDATABLE_CODE_OFF_CHAIN}, - assembly::ProgramAst, - assets::{Asset, FungibleAsset, TokenSymbol}, - crypto::rand::{FeltRng, RpoRandomCoin}, - notes::{ - Note, NoteAssets, NoteExecutionMode, NoteId, NoteInputs, NoteMetadata, NoteRecipient, - NoteScript, NoteTag, NoteType, - }, - transaction::InputNote, - Felt, Word, + accounts::AccountId, + assets::{Asset, FungibleAsset}, + notes::NoteType, }; -use miden_tx::{utils::Serializable, DataStoreError, TransactionExecutorError}; -use uuid::Uuid; - -pub const ACCOUNT_ID_REGULAR: u64 = ACCOUNT_ID_REGULAR_ACCOUNT_UPDATABLE_CODE_OFF_CHAIN; - -type TestClient = Client; - -fn create_test_client() -> TestClient { - let client_config = ClientConfig { - store: create_test_store_path() - .into_os_string() - .into_string() - .unwrap() - .try_into() - .unwrap(), - rpc: RpcConfig::default(), - }; - - let rpc_endpoint = client_config.rpc.endpoint.to_string(); - let store = SqliteStore::new((&client_config).into()).unwrap(); - let executor_store = SqliteStore::new((&client_config).into()).unwrap(); - let rng = get_random_coin(); - TestClient::new(TonicRpcClient::new(&rpc_endpoint), rng, store, executor_store, true) -} - -fn create_test_store_path() -> std::path::PathBuf { - let mut temp_file = temp_dir(); - temp_file.push(format!("{}.sqlite3", Uuid::new_v4())); - temp_file -} - -async fn execute_tx_and_sync(client: &mut TestClient, tx_request: TransactionRequest) { - println!("Executing transaction..."); - let transaction_execution_result = client.new_transaction(tx_request).unwrap(); - let transaction_id = transaction_execution_result.executed_transaction().id(); - - println!("Sending transaction to node"); - client.submit_transaction(transaction_execution_result).await.unwrap(); - - // wait until tx is committed - loop { - println!("Syncing State..."); - client.sync_state().await.unwrap(); - - // Check if executed transaction got committed by the node - let uncommited_transactions = - client.get_transactions(TransactionFilter::Uncomitted).unwrap(); - let is_tx_committed = uncommited_transactions - .iter() - .find(|uncommited_tx| uncommited_tx.id == transaction_id) - .is_none(); - - if is_tx_committed { - break; - } - - std::thread::sleep(std::time::Duration::new(3, 0)); - } -} - -/// Waits for node to be running. -/// -/// # Panics -/// -/// This function will panic if it does `NUMBER_OF_NODE_ATTEMPTS` unsuccessful checks or if we -/// receive an error other than a connection related error -async fn wait_for_node(client: &mut TestClient) { - const NODE_TIME_BETWEEN_ATTEMPTS: u64 = 5; - const NUMBER_OF_NODE_ATTEMPTS: u64 = 60; - - println!("Waiting for Node to be up. Checking every {NODE_TIME_BETWEEN_ATTEMPTS}s for {NUMBER_OF_NODE_ATTEMPTS} tries..."); - - for _try_number in 0..NUMBER_OF_NODE_ATTEMPTS { - match client.sync_state().await { - Err(ClientError::NodeRpcClientError(NodeRpcClientError::ConnectionError(_))) => { - std::thread::sleep(Duration::from_secs(NODE_TIME_BETWEEN_ATTEMPTS)); - }, - Err(other_error) => { - panic!("Unexpected error: {other_error}"); - }, - _ => return, - } - } - - panic!("Unable to connect to node"); -} - -const MINT_AMOUNT: u64 = 1000; -const TRANSFER_AMOUNT: u64 = 59; - -/// Sets up a basic client and returns (basic_account, basic_account, faucet_account) -async fn setup( - client: &mut TestClient, - accounts_storage_mode: AccountStorageMode, -) -> (Account, Account, Account) { - // Enusre clean state - assert!(client.get_accounts().unwrap().is_empty()); - assert!(client.get_transactions(TransactionFilter::All).unwrap().is_empty()); - assert!(client.get_input_notes(NoteFilter::All).unwrap().is_empty()); - - // Create faucet account - let (faucet_account, _) = client - .new_account(AccountTemplate::FungibleFaucet { - token_symbol: TokenSymbol::new("MATIC").unwrap(), - decimals: 8, - max_supply: 1_000_000_000, - storage_mode: accounts_storage_mode, - }) - .unwrap(); - - // Create regular accounts - let (first_basic_account, _) = client - .new_account(AccountTemplate::BasicWallet { - mutable_code: false, - storage_mode: AccountStorageMode::Local, - }) - .unwrap(); - - let (second_basic_account, _) = client - .new_account(AccountTemplate::BasicWallet { - mutable_code: false, - storage_mode: AccountStorageMode::Local, - }) - .unwrap(); - - wait_for_node(client).await; - - println!("Syncing State..."); - client.sync_state().await.unwrap(); - - // Get Faucet and regular accounts - println!("Fetching Accounts..."); - (first_basic_account, second_basic_account, faucet_account) -} - -/// Mints a note from faucet_account_id for basic_account_id, waits for inclusion and returns it -async fn mint_note( - client: &mut TestClient, - basic_account_id: AccountId, - faucet_account_id: AccountId, - note_type: NoteType, -) -> InputNote { - let (regular_account, _seed) = client.get_account(basic_account_id).unwrap(); - assert_eq!(regular_account.vault().assets().count(), 0); - - // Create a Mint Tx for 1000 units of our fungible asset - let fungible_asset = FungibleAsset::new(faucet_account_id, MINT_AMOUNT).unwrap(); - let tx_template = - TransactionTemplate::MintFungibleAsset(fungible_asset, basic_account_id, note_type); - - println!("Minting Asset"); - let tx_request = client.build_transaction_request(tx_template).unwrap(); - let _ = execute_tx_and_sync(client, tx_request.clone()).await; - - // Check that note is committed and return it - println!("Fetching Committed Notes..."); - let note_id = tx_request.expected_output_notes()[0].id(); - let note = client.get_input_note(note_id).unwrap(); - note.try_into().unwrap() -} - -/// Consumes and wait until the transaction gets committed -/// This assumes the notes contain assets -async fn consume_notes(client: &mut TestClient, account_id: AccountId, input_notes: &[InputNote]) { - let tx_template = - TransactionTemplate::ConsumeNotes(account_id, input_notes.iter().map(|n| n.id()).collect()); - println!("Consuming Note..."); - let tx_request = client.build_transaction_request(tx_template).unwrap(); - execute_tx_and_sync(client, tx_request).await; -} - -async fn assert_account_has_single_asset( - client: &TestClient, - account_id: AccountId, - asset_account_id: AccountId, - expected_amount: u64, -) { - let (regular_account, _seed) = client.get_account(account_id).unwrap(); - - assert_eq!(regular_account.vault().assets().count(), 1); - let asset = regular_account.vault().assets().next().unwrap(); - - if let Asset::Fungible(fungible_asset) = asset { - assert_eq!(fungible_asset.faucet_id(), asset_account_id); - assert_eq!(fungible_asset.amount(), expected_amount); - } else { - panic!("Account has consumed a note and should have a fungible asset"); - } -} - -#[tokio::test] -async fn test_onchain_notes_flow() { - // Client 1 is an offchain faucet which will mint an onchain note for client 2 - let mut client_1 = create_test_client(); - // Client 2 is an offchain account which will consume the note that it will sync from the node - let mut client_2 = create_test_client(); - // Client 3 will be transferred part of the assets by client 2's account - let mut client_3 = create_test_client(); - - // Create faucet account - let (faucet_account, _) = client_1 - .new_account(AccountTemplate::FungibleFaucet { - token_symbol: TokenSymbol::new("MATIC").unwrap(), - decimals: 8, - max_supply: 1_000_000_000, - storage_mode: AccountStorageMode::Local, - }) - .unwrap(); - - // Create regular accounts - let (basic_wallet_1, _) = client_2 - .new_account(AccountTemplate::BasicWallet { - mutable_code: false, - storage_mode: AccountStorageMode::Local, - }) - .unwrap(); - - // Create regular accounts - let (basic_wallet_2, _) = client_3 - .new_account(AccountTemplate::BasicWallet { - mutable_code: false, - storage_mode: AccountStorageMode::Local, - }) - .unwrap(); - client_1.sync_state().await.unwrap(); - client_2.sync_state().await.unwrap(); - - let tx_template = TransactionTemplate::MintFungibleAsset( - FungibleAsset::new(faucet_account.id(), MINT_AMOUNT).unwrap().into(), - basic_wallet_1.id(), - NoteType::Public, - ); +use miden_tx::TransactionExecutorError; - let tx_request = client_1.build_transaction_request(tx_template).unwrap(); - let note = tx_request.expected_output_notes()[0].clone(); - execute_tx_and_sync(&mut client_1, tx_request).await; - - // Client 2's account should receive the note here: - client_2.sync_state().await.unwrap(); - - // Assert that the note is the same - let received_note: InputNote = client_2.get_input_note(note.id()).unwrap().try_into().unwrap(); - assert_eq!(received_note.note().authentication_hash(), note.authentication_hash()); - assert_eq!(received_note.note(), ¬e); - - // consume the note - consume_notes(&mut client_2, basic_wallet_1.id(), &[received_note]).await; - assert_account_has_single_asset( - &client_2, - basic_wallet_1.id(), - faucet_account.id(), - MINT_AMOUNT, - ) - .await; - - let p2id_asset = FungibleAsset::new(faucet_account.id(), TRANSFER_AMOUNT).unwrap(); - let tx_template = TransactionTemplate::PayToId( - PaymentTransactionData::new(p2id_asset.into(), basic_wallet_1.id(), basic_wallet_2.id()), - NoteType::Public, - ); - let tx_request = client_2.build_transaction_request(tx_template).unwrap(); - execute_tx_and_sync(&mut client_2, tx_request).await; - - // sync client 3 (basic account 2) - client_3.sync_state().await.unwrap(); - // client 3 should only have one note - let note = client_3 - .get_input_notes(NoteFilter::Committed) - .unwrap() - .get(0) - .unwrap() - .clone() - .try_into() - .unwrap(); +mod common; +use common::*; - consume_notes(&mut client_3, basic_wallet_2.id(), &[note]).await; - assert_account_has_single_asset( - &client_3, - basic_wallet_2.id(), - faucet_account.id(), - TRANSFER_AMOUNT, - ) - .await; -} +mod custom_transactions_tests; +mod onchain_tests; +mod swap_transactions_tests; #[tokio::test] async fn test_added_notes() { let mut client = create_test_client(); + wait_for_node(&mut client).await; let (_, _, faucet_account_stub) = setup(&mut client, AccountStorageMode::Local).await; // Mint some asset for an account not tracked by the client. It should not be stored as an @@ -338,6 +49,7 @@ async fn test_added_notes() { #[tokio::test] async fn test_p2id_transfer() { let mut client = create_test_client(); + wait_for_node(&mut client).await; let (first_regular_account, second_regular_account, faucet_account_stub) = setup(&mut client, AccountStorageMode::Local).await; @@ -404,8 +116,9 @@ async fn test_p2id_transfer() { } #[tokio::test] -async fn test_p2idr_transfer() { +async fn test_p2idr_transfer_consumed_by_target() { let mut client = create_test_client(); + wait_for_node(&mut client).await; let (first_regular_account, second_regular_account, faucet_account_stub) = setup(&mut client, AccountStorageMode::Local).await; @@ -418,9 +131,20 @@ async fn test_p2idr_transfer() { let note = mint_note(&mut client, from_account_id, faucet_account_id, NoteType::OffChain).await; println!("about to consume"); - consume_notes(&mut client, from_account_id, &[note]).await; + //Check that the note is not consumed by the target account + assert!(matches!( + client.get_input_note(note.id()).unwrap().status(), + NoteStatus::Committed + )); + + consume_notes(&mut client, from_account_id, &[note.clone()]).await; assert_account_has_single_asset(&client, from_account_id, faucet_account_id, MINT_AMOUNT).await; + // Check that the note is consumed by the target account + let input_note = client.get_input_note(note.id()).unwrap(); + assert!(matches!(input_note.status(), NoteStatus::Consumed)); + assert_eq!(input_note.consumer_account_id().unwrap(), from_account_id); + // Do a transfer from first account to second account with Recall. In this situation we'll do // the happy path where the `to_account_id` consumes the note println!("getting balance"); @@ -486,375 +210,372 @@ async fn test_p2idr_transfer() { assert_note_cannot_be_consumed_twice(&mut client, to_account_id, notes[0].id()).await; } -async fn assert_note_cannot_be_consumed_twice( - client: &mut TestClient, - consuming_account_id: AccountId, - note_to_consume_id: NoteId, -) { - // Check that we can't consume the P2ID note again - let tx_template = - TransactionTemplate::ConsumeNotes(consuming_account_id, vec![note_to_consume_id]); - println!("Consuming Note..."); +#[tokio::test] +async fn test_p2idr_transfer_consumed_by_sender() { + let mut client = create_test_client(); + wait_for_node(&mut client).await; + + let (first_regular_account, second_regular_account, faucet_account_stub) = + setup(&mut client, AccountStorageMode::Local).await; + + let from_account_id = first_regular_account.id(); + let to_account_id = second_regular_account.id(); + let faucet_account_id = faucet_account_stub.id(); + + // First Mint necesary token + let note = mint_note(&mut client, from_account_id, faucet_account_id, NoteType::OffChain).await; + + consume_notes(&mut client, from_account_id, &[note]).await; + assert_account_has_single_asset(&client, from_account_id, faucet_account_id, MINT_AMOUNT).await; + // Do a transfer from first account to second account with Recall. In this situation we'll do + // the happy path where the `to_account_id` consumes the note + let from_account_balance = client + .get_account(from_account_id) + .unwrap() + .0 + .vault() + .get_balance(faucet_account_id) + .unwrap_or(0); + let current_block_num = client.get_sync_height().unwrap(); + let asset = FungibleAsset::new(faucet_account_id, TRANSFER_AMOUNT).unwrap(); + let tx_template = TransactionTemplate::PayToIdWithRecall( + PaymentTransactionData::new(Asset::Fungible(asset), from_account_id, to_account_id), + current_block_num + 5, + NoteType::OffChain, + ); + println!("Running P2IDR tx..."); + let tx_request = client.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client, tx_request).await; + + // Check that note is committed + println!("Fetching Committed Notes..."); + let notes = client.get_input_notes(NoteFilter::Committed).unwrap(); + assert!(!notes.is_empty()); - // Double-spend error expected to be received since we are consuming the same note + // Check that it's still too early to consume + let tx_template = TransactionTemplate::ConsumeNotes(from_account_id, vec![notes[0].id()]); + println!("Consuming Note (too early)..."); let tx_request = client.build_transaction_request(tx_template).unwrap(); - match client.new_transaction(tx_request) { - Err(ClientError::TransactionExecutorError( - TransactionExecutorError::FetchTransactionInputsFailed( - DataStoreError::NoteAlreadyConsumed(_), - ), - )) => {}, - Ok(_) => panic!("Double-spend error: Note should not be consumable!"), - _ => panic!("Unexpected error: {}", note_to_consume_id.to_hex()), + let transaction_execution_result = client.new_transaction(tx_request); + assert!(transaction_execution_result.is_err_and(|err| { + matches!( + err, + ClientError::TransactionExecutorError( + TransactionExecutorError::ExecuteTransactionProgramFailed(_) + ) + ) + })); + + // Wait to consume with the sender account + println!("Waiting for note to be consumable by sender"); + let current_block_num = client.get_sync_height().unwrap(); + + while client.get_sync_height().unwrap() < current_block_num + 5 { + client.sync_state().await.unwrap(); + } + + // Consume the note with the sender account + let tx_template = TransactionTemplate::ConsumeNotes(from_account_id, vec![notes[0].id()]); + println!("Consuming Note..."); + let tx_request = client.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client, tx_request).await; + + let (regular_account, seed) = client.get_account(from_account_id).unwrap(); + // The seed should not be retrieved due to the account not being new + assert!(!regular_account.is_new() && seed.is_none()); + assert_eq!(regular_account.vault().assets().count(), 1); + let asset = regular_account.vault().assets().next().unwrap(); + + // Validate the the sender hasn't lost funds + if let Asset::Fungible(fungible_asset) = asset { + assert_eq!(fungible_asset.amount(), from_account_balance); + } else { + panic!("Error: Account should have a fungible asset"); } + + let (regular_account, _seed) = client.get_account(to_account_id).unwrap(); + assert_eq!(regular_account.vault().assets().count(), 0); + + // Check that the target can't consume the note anymore + assert_note_cannot_be_consumed_twice(&mut client, to_account_id, notes[0].id()).await; } -// TODO: We might want to move these functions related to custom transactions to their own module -// file - -// CUSTOM TRANSACTION REQUEST -// ================================================================================================ -// -// The following functions are for testing custom transaction code. What the test does is: -// -// - Create a custom tx that mints a custom note which checks that the note args are as expected -// (ie, a word of 4 felts that represent [9, 12, 18, 3]) -// -// - Create another transaction that consumes this note with custom code. This custom code only -// asserts that the {asserted_value} parameter is 0. To test this we first execute with -// an incorrect value passed in, and after that we try again with the correct value. -// -// Because it's currently not possible to create/consume notes without assets, the P2ID code -// is used as the base for the note code. #[tokio::test] -async fn test_transaction_request() { +async fn test_get_consumable_notes() { let mut client = create_test_client(); - let account_template = AccountTemplate::BasicWallet { - mutable_code: false, - storage_mode: AccountStorageMode::Local, - }; + let (first_regular_account, second_regular_account, faucet_account_stub) = + setup(&mut client, AccountStorageMode::Local).await; - client.sync_state().await.unwrap(); - // Insert Account - let (regular_account, _seed) = client.new_account(account_template).unwrap(); + let from_account_id = first_regular_account.id(); + let to_account_id = second_regular_account.id(); + let faucet_account_id = faucet_account_stub.id(); - let account_template = AccountTemplate::FungibleFaucet { - token_symbol: TokenSymbol::new("TEST").unwrap(), - decimals: 5u8, - max_supply: 10_000u64, - storage_mode: AccountStorageMode::Local, - }; - let (fungible_faucet, _seed) = client.new_account(account_template).unwrap(); + //No consumable notes initially + assert!(client.get_consumable_notes(None).unwrap().is_empty()); - // Execute mint transaction in order to create custom note - let note = mint_custom_note(&mut client, fungible_faucet.id(), regular_account.id()).await; + // First Mint necesary token + let note = mint_note(&mut client, from_account_id, faucet_account_id, NoteType::OffChain).await; - client.sync_state().await.unwrap(); + // Check that note is consumable by the account that minted + assert!(!client.get_consumable_notes(None).unwrap().is_empty()); + assert!(!client.get_consumable_notes(Some(from_account_id)).unwrap().is_empty()); + assert!(client.get_consumable_notes(Some(to_account_id)).unwrap().is_empty()); - // Prepare transaction + consume_notes(&mut client, from_account_id, &[note]).await; - // If these args were to be modified, the transaction would fail because the note code expects - // these exact arguments - let note_args = [[Felt::new(9), Felt::new(12), Felt::new(18), Felt::new(3)]]; + //After consuming there are no more consumable notes + assert!(client.get_consumable_notes(None).unwrap().is_empty()); - let note_args_map = BTreeMap::from([(note.id(), Some(note_args[0]))]); + // Do a transfer from first account to second account + let asset = FungibleAsset::new(faucet_account_id, TRANSFER_AMOUNT).unwrap(); + let tx_template = TransactionTemplate::PayToIdWithRecall( + PaymentTransactionData::new(Asset::Fungible(asset), from_account_id, to_account_id), + 100, + NoteType::OffChain, + ); + println!("Running P2IDR tx..."); + let tx_request = client.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client, tx_request).await; - let code = " - use.miden::contracts::auth::basic->auth_tx - use.miden::kernels::tx::prologue - use.miden::kernels::tx::memory + // Check that note is consumable by both accounts + let consumable_notes = client.get_consumable_notes(None).unwrap(); + let relevant_accounts = &consumable_notes.first().unwrap().relevances; + assert_eq!(relevant_accounts.len(), 2); + assert!(!client.get_consumable_notes(Some(from_account_id)).unwrap().is_empty()); + assert!(!client.get_consumable_notes(Some(to_account_id)).unwrap().is_empty()); - begin - push.0 push.{asserted_value} - # => [0, {asserted_value}] - assert_eq + // Check that the note is only consumable after block 100 for the account that sent the transaction + let from_account_relevance = relevant_accounts + .iter() + .find(|relevance| relevance.0 == from_account_id) + .unwrap() + .1; + assert_eq!(from_account_relevance, NoteRelevance::After(100)); - call.auth_tx::auth_tx_rpo_falcon512 - end - "; + // Check that the note is always consumable for the account that received the transaction + let to_account_relevance = relevant_accounts + .iter() + .find(|relevance| relevance.0 == to_account_id) + .unwrap() + .1; + assert_eq!(to_account_relevance, NoteRelevance::Always); +} - // FAILURE ATTEMPT +#[tokio::test] +async fn test_get_output_notes() { + let mut client = create_test_client(); - let failure_code = code.replace("{asserted_value}", "1"); - let program = ProgramAst::parse(&failure_code).unwrap(); + let (first_regular_account, _, faucet_account_stub) = + setup(&mut client, AccountStorageMode::Local).await; - let tx_script = { - let account_auth = client.get_account_auth(regular_account.id()).unwrap(); - let (pubkey_input, advice_map): (Word, Vec) = match account_auth { - AuthInfo::RpoFalcon512(key) => ( - key.public_key().into(), - key.to_bytes().iter().map(|a| Felt::new(*a as u64)).collect::>(), - ), - }; + let from_account_id = first_regular_account.id(); + let faucet_account_id = faucet_account_stub.id(); + let random_account_id = AccountId::from_hex("0x0123456789abcdef").unwrap(); - let script_inputs = vec![(pubkey_input, advice_map)]; - client.compile_tx_script(program, script_inputs, vec![]).unwrap() - }; + // No output notes initially + assert!(client.get_output_notes(NoteFilter::All).unwrap().is_empty()); - let transaction_request = TransactionRequest::new( - regular_account.id(), - note_args_map.clone(), - vec![], - Some(tx_script), - ); + // First Mint necesary token + let note = mint_note(&mut client, from_account_id, faucet_account_id, NoteType::OffChain).await; - // This fails becuase of {asserted_value} having the incorrect number passed in - assert!(client.new_transaction(transaction_request).is_err()); + // Check that there was an output note but it wasn't consumed + assert!(client.get_output_notes(NoteFilter::Consumed).unwrap().is_empty()); + assert!(!client.get_output_notes(NoteFilter::All).unwrap().is_empty()); - // SUCCESS EXECUTION + consume_notes(&mut client, from_account_id, &[note]).await; - let success_code = code.replace("{asserted_value}", "0"); - let program = ProgramAst::parse(&success_code).unwrap(); + //After consuming, the note is returned when using the [NoteFilter::Consumed] filter + assert!(!client.get_output_notes(NoteFilter::Consumed).unwrap().is_empty()); - let tx_script = { - let account_auth = client.get_account_auth(regular_account.id()).unwrap(); - let (pubkey_input, advice_map): (Word, Vec) = match account_auth { - AuthInfo::RpoFalcon512(key) => ( - key.public_key().into(), - key.to_bytes().iter().map(|a| Felt::new(*a as u64)).collect::>(), - ), - }; + // Do a transfer from first account to second account + let asset = FungibleAsset::new(faucet_account_id, TRANSFER_AMOUNT).unwrap(); + let tx_template = TransactionTemplate::PayToId( + PaymentTransactionData::new(Asset::Fungible(asset), from_account_id, random_account_id), + NoteType::OffChain, + ); + println!("Running P2ID tx..."); + let tx_request = client.build_transaction_request(tx_template).unwrap(); - let script_inputs = vec![(pubkey_input, advice_map)]; - client.compile_tx_script(program, script_inputs, vec![]).unwrap() - }; + let output_note_id = tx_request.expected_output_notes()[0].id(); - let transaction_request = - TransactionRequest::new(regular_account.id(), note_args_map, vec![], Some(tx_script)); + // Before executing, the output note is not found + assert!(client.get_output_note(output_note_id).is_err()); - execute_tx_and_sync(&mut client, transaction_request).await; + execute_tx_and_sync(&mut client, tx_request).await; - client.sync_state().await.unwrap(); + // After executing, the note is only found in output notes + assert!(client.get_output_note(output_note_id).is_ok()); + assert!(client.get_input_note(output_note_id).is_err()); } -async fn mint_custom_note( - client: &mut TestClient, - faucet_account_id: AccountId, - target_account_id: AccountId, -) -> Note { - // Prepare transaction - let mut random_coin = RpoRandomCoin::new(Default::default()); - let note = create_custom_note(faucet_account_id, target_account_id, &mut random_coin); - - let recipient = note - .recipient_digest() - .iter() - .map(|x| x.as_int().to_string()) - .collect::>() - .join("."); - - let note_tag = note.metadata().tag().inner(); - - let code = " - use.miden::contracts::faucets::basic_fungible->faucet - use.miden::contracts::auth::basic->auth_tx - - begin - push.{recipient} - push.{note_type} - push.{tag} - push.{amount} - call.faucet::distribute - - call.auth_tx::auth_tx_rpo_falcon512 - dropw dropw - end - " - .replace("{recipient}", &recipient) - .replace("{note_type}", &Felt::new(NoteType::OffChain as u64).to_string()) - .replace("{tag}", &Felt::new(note_tag.into()).to_string()) - .replace("{amount}", &Felt::new(10).to_string()); - - let program = ProgramAst::parse(&code).unwrap(); - - let tx_script = { - let account_auth = client.get_account_auth(faucet_account_id).unwrap(); - let (pubkey_input, advice_map): (Word, Vec) = match account_auth { - AuthInfo::RpoFalcon512(key) => ( - key.public_key().into(), - key.to_bytes().iter().map(|a| Felt::new(*a as u64)).collect::>(), - ), - }; - - let script_inputs = vec![(pubkey_input, advice_map)]; - client.compile_tx_script(program, script_inputs, vec![]).unwrap() - }; - - let transaction_request = TransactionRequest::new( - faucet_account_id, - BTreeMap::new(), - vec![note.clone()], - Some(tx_script), +#[tokio::test] +async fn test_import_pending_notes() { + let mut client_1 = create_test_client(); + let (first_basic_account, _second_basic_account, faucet_account) = + setup(&mut client_1, AccountStorageMode::Local).await; + + let mut client_2 = create_test_client(); + let (client_2_account, _seed) = client_2 + .new_account(AccountTemplate::BasicWallet { + mutable_code: true, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + wait_for_node(&mut client_2).await; + + let tx_template = TransactionTemplate::MintFungibleAsset( + FungibleAsset::new(faucet_account.id(), MINT_AMOUNT).unwrap(), + client_2_account.id(), + NoteType::OffChain, ); - let _ = execute_tx_and_sync(client, transaction_request).await; - note -} + let tx_request = client_1.build_transaction_request(tx_template).unwrap(); + let note = tx_request.expected_output_notes()[0].clone(); + client_2.sync_state().await.unwrap(); -fn create_custom_note( - faucet_account_id: AccountId, - target_account_id: AccountId, - rng: &mut RpoRandomCoin, -) -> Note { - let assembler = TransactionKernel::assembler(); + // If the verification is requested before execution then the import should fail + assert!(client_2.import_input_note(note.clone().into(), true).await.is_err()); + execute_tx_and_sync(&mut client_1, tx_request).await; - let expected_note_arg = [Felt::new(9), Felt::new(12), Felt::new(18), Felt::new(3)] - .iter() - .map(|x| x.to_string()) - .collect::>() - .join("."); - - let note_script = - include_str!("asm/custom_p2id.masm").replace("{expected_note_arg}", &expected_note_arg); - let note_script = ProgramAst::parse(¬e_script).unwrap(); - let (note_script, _) = NoteScript::new(note_script, &assembler).unwrap(); - - let inputs = NoteInputs::new(vec![target_account_id.into()]).unwrap(); - let serial_num = rng.draw_word(); - let note_metadata = NoteMetadata::new( - faucet_account_id, + // Use client 1 to wait until a couple of blocks have passed + wait_for_blocks(&mut client_1, 3).await; + + let new_sync_data = client_2.sync_state().await.unwrap(); + client_2.import_input_note(note.clone().into(), true).await.unwrap(); + let input_note = client_2.get_input_note(note.id()).unwrap(); + assert!(new_sync_data.block_num > input_note.inclusion_proof().unwrap().origin().block_num + 1); + + // If imported after execution and syncing then the inclusion proof should be Some + assert!(input_note.inclusion_proof().is_some()); + + // If client 2 succesfully consumes the note, we confirm we have MMR and block header data + consume_notes(&mut client_2, client_2_account.id(), &[input_note.try_into().unwrap()]).await; + + let tx_template = TransactionTemplate::MintFungibleAsset( + FungibleAsset::new(faucet_account.id(), MINT_AMOUNT).unwrap(), + first_basic_account.id(), NoteType::OffChain, - NoteTag::from_account_id(target_account_id, NoteExecutionMode::Local) - .unwrap() - .into(), - Default::default(), - ) - .unwrap(); - let note_assets = - NoteAssets::new(vec![FungibleAsset::new(faucet_account_id, 10).unwrap().into()]).unwrap(); - let note_recipient = NoteRecipient::new(serial_num, note_script, inputs); - Note::new(note_assets, note_metadata, note_recipient) + ); + + let tx_request = client_1.build_transaction_request(tx_template).unwrap(); + let note = tx_request.expected_output_notes()[0].clone(); + + // Import an uncommited note without verification + client_2.import_input_note(note.clone().into(), false).await.unwrap(); + let input_note = client_2.get_input_note(note.id()).unwrap(); + + // If imported before execution then the inclusion proof should be None + assert!(input_note.inclusion_proof().is_none()); + + execute_tx_and_sync(&mut client_1, tx_request).await; + client_2.sync_state().await.unwrap(); + + // After sync, the imported note should have inclusion proof even if it's not relevant for its accounts. + let input_note = client_2.get_input_note(note.id()).unwrap(); + assert!(input_note.inclusion_proof().is_some()); + + // If inclusion proof is invalid this should panic + consume_notes(&mut client_1, first_basic_account.id(), &[input_note.try_into().unwrap()]).await; } #[tokio::test] -async fn test_onchain_accounts() { - let mut client_1 = create_test_client(); - let mut client_2 = create_test_client(); +async fn test_get_account_update() { + // Create a client with both public and private accounts. + let mut client = create_test_client(); - let (first_regular_account, _second_regular_account, faucet_account_stub) = - setup(&mut client_1, AccountStorageMode::OnChain).await; + let (basic_wallet_1, _, faucet_account) = setup(&mut client, AccountStorageMode::Local).await; - let ( - second_client_first_regular_account, - _other_second_regular_account, - _other_faucet_account_stub, - ) = setup(&mut client_2, AccountStorageMode::Local).await; + let (basic_wallet_2, _) = client + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::OnChain, + }) + .unwrap(); - let target_account_id = first_regular_account.id(); - let second_client_target_account_id = second_client_first_regular_account.id(); - let faucet_account_id = faucet_account_stub.id(); + // Mint and consume notes with both accounts so they are included in the node. + let note1 = + mint_note(&mut client, basic_wallet_1.id(), faucet_account.id(), NoteType::OffChain).await; + let note2 = + mint_note(&mut client, basic_wallet_2.id(), faucet_account.id(), NoteType::OffChain).await; + + client.sync_state().await.unwrap(); - let (_, faucet_seed) = client_1.get_account_stub_by_id(faucet_account_id).unwrap(); - let auth_info = client_1.get_account_auth(faucet_account_id).unwrap(); - client_2.insert_account(&faucet_account_stub, faucet_seed, &auth_info).unwrap(); + consume_notes(&mut client, basic_wallet_1.id(), &[note1]).await; + consume_notes(&mut client, basic_wallet_2.id(), &[note2]).await; - // First Mint necesary token - println!("First client consuming note"); - let note = - mint_note(&mut client_1, target_account_id, faucet_account_id, NoteType::OffChain).await; + wait_for_node(&mut client).await; + client.sync_state().await.unwrap(); - // Update the state in the other client and ensure the onchain faucet hash is consistent - // between clients - client_2.sync_state().await.unwrap(); + // Request updates from node for both accounts. The request should not fail and both types of + // [AccountDetails] should be received. + let details1 = client.rpc_api().get_account_update(basic_wallet_1.id()).await.unwrap(); + let details2 = client.rpc_api().get_account_update(basic_wallet_2.id()).await.unwrap(); - let (client_1_faucet, _) = client_1.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); - let (client_2_faucet, _) = client_2.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); + assert!(matches!(details1, AccountDetails::OffChain(_, _))); + assert!(matches!(details2, AccountDetails::Public(_, _))); +} - assert_eq!(client_1_faucet.hash(), client_2_faucet.hash()); +#[tokio::test] +async fn test_sync_detail_values() { + let mut client1 = create_test_client(); + let mut client2 = create_test_client(); + wait_for_node(&mut client1).await; + wait_for_node(&mut client2).await; - // Now use the faucet in the second client to mint to its own account - println!("Second client consuming note"); - let second_client_note = mint_note( - &mut client_2, - second_client_target_account_id, - faucet_account_id, - NoteType::OffChain, - ) - .await; + let (first_regular_account, _, faucet_account_stub) = + setup(&mut client1, AccountStorageMode::Local).await; - // Update the state in the other client and ensure the onchain faucet hash is consistent - // between clients - client_1.sync_state().await.unwrap(); + let (second_regular_account, _) = client2 + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); - println!("About to consume"); - consume_notes(&mut client_1, target_account_id, &[note]).await; - assert_account_has_single_asset(&client_1, target_account_id, faucet_account_id, MINT_AMOUNT) + let from_account_id = first_regular_account.id(); + let to_account_id = second_regular_account.id(); + let faucet_account_id = faucet_account_stub.id(); + + // First Mint necesary token + let note = + mint_note(&mut client1, from_account_id, faucet_account_id, NoteType::OffChain).await; + consume_notes(&mut client1, from_account_id, &[note]).await; + assert_account_has_single_asset(&client1, from_account_id, faucet_account_id, MINT_AMOUNT) .await; - consume_notes(&mut client_2, second_client_target_account_id, &[second_client_note]).await; - assert_account_has_single_asset( - &client_2, - second_client_target_account_id, - faucet_account_id, - MINT_AMOUNT, - ) - .await; - - let (client_1_faucet, _) = client_1.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); - let (client_2_faucet, _) = client_2.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); - - assert_eq!(client_1_faucet.hash(), client_2_faucet.hash()); - - // Now we'll try to do a p2id transfer from an account of one client to the other one - let from_account_id = target_account_id; - let to_account_id = second_client_target_account_id; - - // get initial balances - let from_account_balance = client_1 - .get_account(from_account_id) - .unwrap() - .0 - .vault() - .get_balance(faucet_account_id) - .unwrap_or(0); - let to_account_balance = client_2 - .get_account(to_account_id) - .unwrap() - .0 - .vault() - .get_balance(faucet_account_id) - .unwrap_or(0); + // Second client sync shouldn't have any new changes + let new_details = client2.sync_state().await.unwrap(); + assert!(new_details.is_empty()); + + // Do a transfer with recall from first account to second account let asset = FungibleAsset::new(faucet_account_id, TRANSFER_AMOUNT).unwrap(); - let tx_template = TransactionTemplate::PayToId( + let tx_template = TransactionTemplate::PayToIdWithRecall( PaymentTransactionData::new(Asset::Fungible(asset), from_account_id, to_account_id), + new_details.block_num + 5, NoteType::Public, ); - println!("Running P2ID tx..."); - let tx_request = client_1.build_transaction_request(tx_template).unwrap(); - execute_tx_and_sync(&mut client_1, tx_request).await; - - // sync on second client until we receive the note - println!("Syncing on second client..."); - client_2.sync_state().await.unwrap(); - let notes = client_2.get_input_notes(NoteFilter::Committed).unwrap(); - - // Consume the note - println!("Consuming note con second client..."); - let tx_template = TransactionTemplate::ConsumeNotes(to_account_id, vec![notes[0].id()]); - let tx_request = client_2.build_transaction_request(tx_template).unwrap(); - execute_tx_and_sync(&mut client_2, tx_request).await; - - // sync on first client - println!("Syncing on first client..."); - client_1.sync_state().await.unwrap(); - - let new_from_account_balance = client_1 - .get_account(from_account_id) - .unwrap() - .0 - .vault() - .get_balance(faucet_account_id) - .unwrap_or(0); - let new_to_account_balance = client_2 - .get_account(to_account_id) - .unwrap() - .0 - .vault() - .get_balance(faucet_account_id) - .unwrap_or(0); - - assert_eq!(new_from_account_balance, from_account_balance - TRANSFER_AMOUNT); - assert_eq!(new_to_account_balance, to_account_balance + TRANSFER_AMOUNT); + let tx_request = client1.build_transaction_request(tx_template).unwrap(); + let note_id = tx_request.expected_output_notes()[0].id(); + execute_tx_and_sync(&mut client1, tx_request).await; + + // Second client sync should have new note + let new_details = client2.sync_state().await.unwrap(); + assert_eq!(new_details.new_notes, 1); + assert_eq!(new_details.new_inclusion_proofs, 0); + assert_eq!(new_details.new_nullifiers, 0); + assert_eq!(new_details.updated_onchain_accounts, 0); + + // Consume the note with the second account + let tx_template = TransactionTemplate::ConsumeNotes(to_account_id, vec![note_id]); + let tx_request = client2.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client2, tx_request).await; + + // First client sync should have a new nullifier as the note was consumed + let new_details = client1.sync_state().await.unwrap(); + assert_eq!(new_details.new_notes, 0); + assert_eq!(new_details.new_inclusion_proofs, 0); + assert_eq!(new_details.new_nullifiers, 1); } diff --git a/tests/integration/onchain_tests.rs b/tests/integration/onchain_tests.rs new file mode 100644 index 000000000..8f31ee246 --- /dev/null +++ b/tests/integration/onchain_tests.rs @@ -0,0 +1,311 @@ +use miden_client::{ + client::{ + accounts::{AccountStorageMode, AccountTemplate}, + transactions::transaction_request::{PaymentTransactionData, TransactionTemplate}, + }, + store::{NoteFilter, NoteStatus}, +}; +use miden_objects::{ + accounts::AccountId, + assets::{Asset, FungibleAsset, TokenSymbol}, + notes::{NoteTag, NoteType}, + transaction::InputNote, +}; + +use super::common::*; + +#[tokio::test] +async fn test_onchain_notes_flow() { + // Client 1 is an offchain faucet which will mint an onchain note for client 2 + let mut client_1 = create_test_client(); + // Client 2 is an offchain account which will consume the note that it will sync from the node + let mut client_2 = create_test_client(); + // Client 3 will be transferred part of the assets by client 2's account + let mut client_3 = create_test_client(); + wait_for_node(&mut client_3).await; + + // Create faucet account + let (faucet_account, _) = client_1 + .new_account(AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("MATIC").unwrap(), + decimals: 8, + max_supply: 1_000_000_000, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // Create regular accounts + let (basic_wallet_1, _) = client_2 + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // Create regular accounts + let (basic_wallet_2, _) = client_3 + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + client_1.sync_state().await.unwrap(); + client_2.sync_state().await.unwrap(); + + let tx_template = TransactionTemplate::MintFungibleAsset( + FungibleAsset::new(faucet_account.id(), MINT_AMOUNT).unwrap(), + basic_wallet_1.id(), + NoteType::Public, + ); + + let tx_request = client_1.build_transaction_request(tx_template).unwrap(); + let note = tx_request.expected_output_notes()[0].clone(); + execute_tx_and_sync(&mut client_1, tx_request).await; + + // Client 2's account should receive the note here: + client_2.sync_state().await.unwrap(); + + // Assert that the note is the same + let received_note: InputNote = client_2.get_input_note(note.id()).unwrap().try_into().unwrap(); + assert_eq!(received_note.note().authentication_hash(), note.authentication_hash()); + assert_eq!(received_note.note(), ¬e); + + // consume the note + consume_notes(&mut client_2, basic_wallet_1.id(), &[received_note]).await; + assert_account_has_single_asset( + &client_2, + basic_wallet_1.id(), + faucet_account.id(), + MINT_AMOUNT, + ) + .await; + + let p2id_asset = FungibleAsset::new(faucet_account.id(), TRANSFER_AMOUNT).unwrap(); + let tx_template = TransactionTemplate::PayToId( + PaymentTransactionData::new(p2id_asset.into(), basic_wallet_1.id(), basic_wallet_2.id()), + NoteType::Public, + ); + let tx_request = client_2.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client_2, tx_request).await; + + // sync client 3 (basic account 2) + client_3.sync_state().await.unwrap(); + // client 3 should only have one note + let note = client_3 + .get_input_notes(NoteFilter::Committed) + .unwrap() + .first() + .unwrap() + .clone() + .try_into() + .unwrap(); + + consume_notes(&mut client_3, basic_wallet_2.id(), &[note]).await; + assert_account_has_single_asset( + &client_3, + basic_wallet_2.id(), + faucet_account.id(), + TRANSFER_AMOUNT, + ) + .await; +} + +#[tokio::test] +async fn test_onchain_accounts() { + let mut client_1 = create_test_client(); + let mut client_2 = create_test_client(); + wait_for_node(&mut client_2).await; + + let (first_regular_account, _second_regular_account, faucet_account_stub) = + setup(&mut client_1, AccountStorageMode::OnChain).await; + + let ( + second_client_first_regular_account, + _other_second_regular_account, + _other_faucet_account_stub, + ) = setup(&mut client_2, AccountStorageMode::Local).await; + + let target_account_id = first_regular_account.id(); + let second_client_target_account_id = second_client_first_regular_account.id(); + let faucet_account_id = faucet_account_stub.id(); + + let (_, faucet_seed) = client_1.get_account_stub_by_id(faucet_account_id).unwrap(); + let auth_info = client_1.get_account_auth(faucet_account_id).unwrap(); + client_2.insert_account(&faucet_account_stub, faucet_seed, &auth_info).unwrap(); + + // First Mint necesary token + println!("First client consuming note"); + let note = + mint_note(&mut client_1, target_account_id, faucet_account_id, NoteType::OffChain).await; + + // Update the state in the other client and ensure the onchain faucet hash is consistent + // between clients + client_2.sync_state().await.unwrap(); + + let (client_1_faucet, _) = client_1.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); + let (client_2_faucet, _) = client_2.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); + + assert_eq!(client_1_faucet.hash(), client_2_faucet.hash()); + + // Now use the faucet in the second client to mint to its own account + println!("Second client consuming note"); + let second_client_note = mint_note( + &mut client_2, + second_client_target_account_id, + faucet_account_id, + NoteType::OffChain, + ) + .await; + + // Update the state in the other client and ensure the onchain faucet hash is consistent + // between clients + client_1.sync_state().await.unwrap(); + + println!("About to consume"); + consume_notes(&mut client_1, target_account_id, &[note]).await; + assert_account_has_single_asset(&client_1, target_account_id, faucet_account_id, MINT_AMOUNT) + .await; + consume_notes(&mut client_2, second_client_target_account_id, &[second_client_note]).await; + assert_account_has_single_asset( + &client_2, + second_client_target_account_id, + faucet_account_id, + MINT_AMOUNT, + ) + .await; + + let (client_1_faucet, _) = client_1.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); + let (client_2_faucet, _) = client_2.get_account_stub_by_id(faucet_account_stub.id()).unwrap(); + + assert_eq!(client_1_faucet.hash(), client_2_faucet.hash()); + + // Now we'll try to do a p2id transfer from an account of one client to the other one + let from_account_id = target_account_id; + let to_account_id = second_client_target_account_id; + + // get initial balances + let from_account_balance = client_1 + .get_account(from_account_id) + .unwrap() + .0 + .vault() + .get_balance(faucet_account_id) + .unwrap_or(0); + let to_account_balance = client_2 + .get_account(to_account_id) + .unwrap() + .0 + .vault() + .get_balance(faucet_account_id) + .unwrap_or(0); + + let asset = FungibleAsset::new(faucet_account_id, TRANSFER_AMOUNT).unwrap(); + let tx_template = TransactionTemplate::PayToId( + PaymentTransactionData::new(Asset::Fungible(asset), from_account_id, to_account_id), + NoteType::Public, + ); + + println!("Running P2ID tx..."); + let tx_request = client_1.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client_1, tx_request).await; + + // sync on second client until we receive the note + println!("Syncing on second client..."); + client_2.sync_state().await.unwrap(); + let notes = client_2.get_input_notes(NoteFilter::Committed).unwrap(); + + //Import the note on the first client so that we can later check its consumer account + client_1.import_input_note(notes[0].clone(), false).await.unwrap(); + + // Consume the note + println!("Consuming note con second client..."); + let tx_template = TransactionTemplate::ConsumeNotes(to_account_id, vec![notes[0].id()]); + let tx_request = client_2.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client_2, tx_request).await; + + // sync on first client + println!("Syncing on first client..."); + client_1.sync_state().await.unwrap(); + + // Check that the client doesn't know who consumed the note + let input_note = client_1.get_input_note(notes[0].id()).unwrap(); + assert!(matches!(input_note.status(), NoteStatus::Consumed)); + assert!(input_note.consumer_account_id().is_none()); + + let new_from_account_balance = client_1 + .get_account(from_account_id) + .unwrap() + .0 + .vault() + .get_balance(faucet_account_id) + .unwrap_or(0); + let new_to_account_balance = client_2 + .get_account(to_account_id) + .unwrap() + .0 + .vault() + .get_balance(faucet_account_id) + .unwrap_or(0); + + assert_eq!(new_from_account_balance, from_account_balance - TRANSFER_AMOUNT); + assert_eq!(new_to_account_balance, to_account_balance + TRANSFER_AMOUNT); +} + +#[tokio::test] +async fn test_onchain_notes_sync_with_tag() { + // Client 1 has an offchain faucet which will mint an onchain note for client 2 + let mut client_1 = create_test_client(); + // Client 2 will be used to sync and check that by adding the tag we can still fetch notes + // whose tag doesn't necessarily match any of its accounts + let mut client_2 = create_test_client(); + // Client 3 will be the control client. We won't add any tags and expect the note not to be + // fetched + let mut client_3 = create_test_client(); + wait_for_node(&mut client_3).await; + + // Create faucet account + let (faucet_account, _) = client_1 + .new_account(AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("MATIC").unwrap(), + decimals: 8, + max_supply: 1_000_000_000, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + client_1.sync_state().await.unwrap(); + client_2.sync_state().await.unwrap(); + client_3.sync_state().await.unwrap(); + + let target_account_id = AccountId::try_from(ACCOUNT_ID_REGULAR).unwrap(); + let tx_template = TransactionTemplate::MintFungibleAsset( + FungibleAsset::new(faucet_account.id(), MINT_AMOUNT).unwrap(), + target_account_id, + NoteType::Public, + ); + + let tx_request = client_1.build_transaction_request(tx_template).unwrap(); + let note = tx_request.expected_output_notes()[0].clone(); + execute_tx_and_sync(&mut client_1, tx_request).await; + + // Load tag into client 2 + client_2 + .add_note_tag( + NoteTag::from_account_id( + target_account_id, + miden_objects::notes::NoteExecutionHint::Local, + ) + .unwrap(), + ) + .unwrap(); + + // Client 2's account should receive the note here: + client_2.sync_state().await.unwrap(); + client_3.sync_state().await.unwrap(); + + // Assert that the note is the same + let received_note: InputNote = client_2.get_input_note(note.id()).unwrap().try_into().unwrap(); + assert_eq!(received_note.note().authentication_hash(), note.authentication_hash()); + assert_eq!(received_note.note(), ¬e); + assert!(client_3.get_input_notes(NoteFilter::All).unwrap().is_empty()); +} diff --git a/tests/integration/swap_transactions_tests.rs b/tests/integration/swap_transactions_tests.rs new file mode 100644 index 000000000..585ee1679 --- /dev/null +++ b/tests/integration/swap_transactions_tests.rs @@ -0,0 +1,483 @@ +use miden_client::client::{ + accounts::{AccountStorageMode, AccountTemplate}, + transactions::transaction_request::{SwapTransactionData, TransactionTemplate}, +}; +use miden_objects::{ + accounts::AccountId, + assets::{Asset, FungibleAsset, TokenSymbol}, + notes::{NoteExecutionHint, NoteTag, NoteType}, +}; + +use super::common::*; + +// SWAP FULLY ONCHAIN +// ================================================================================================ + +#[tokio::test] +async fn test_swap_fully_onchain() { + const OFFERED_ASSET_AMOUNT: u64 = 1; + const REQUESTED_ASSET_AMOUNT: u64 = 25; + const BTC_MINT_AMOUNT: u64 = 1000; + const ETH_MINT_AMOUNT: u64 = 1000; + let mut client1 = create_test_client(); + wait_for_node(&mut client1).await; + let mut client2 = create_test_client(); + let mut client_with_faucets = create_test_client(); + + client1.sync_state().await.unwrap(); + client2.sync_state().await.unwrap(); + client_with_faucets.sync_state().await.unwrap(); + + // Create Client 1's basic wallet (We'll call it accountA) + let (account_a, _) = client1 + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // Create Client 2's basic wallet (We'll call it accountB) + let (account_b, _) = client2 + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // Create client with faucets BTC faucet (note: it's not real BTC) + let (btc_faucet_account, _) = client_with_faucets + .new_account(AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("BTC").unwrap(), + decimals: 8, + max_supply: 1_000_000, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + // Create client with faucets ETH faucet (note: it's not real ETH) + let (eth_faucet_account, _) = client_with_faucets + .new_account(AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("ETH").unwrap(), + decimals: 8, + max_supply: 1_000_000, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // mint 1000 BTC for accountA + println!("minting 1000 btc for account A"); + mint( + &mut client_with_faucets, + account_a.id(), + btc_faucet_account.id(), + NoteType::Public, + BTC_MINT_AMOUNT, + ) + .await; + println!("minting 1000 eth for account B"); + // mint 1000 ETH for accountB + mint( + &mut client_with_faucets, + account_b.id(), + eth_faucet_account.id(), + NoteType::Public, + ETH_MINT_AMOUNT, + ) + .await; + + // Sync and consume note for accountA + client1.sync_state().await.unwrap(); + let client_1_notes = client1.get_input_notes(miden_client::store::NoteFilter::All).unwrap(); + assert_eq!(client_1_notes.len(), 1); + + println!("Consuming mint note on first client..."); + let tx_template = + TransactionTemplate::ConsumeNotes(account_a.id(), vec![client_1_notes[0].id()]); + let tx_request = client1.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client1, tx_request).await; + + // Sync and consume note for accountB + client2.sync_state().await.unwrap(); + let client_2_notes = client2.get_input_notes(miden_client::store::NoteFilter::All).unwrap(); + assert_eq!(client_2_notes.len(), 1); + + println!("Consuming mint note on second client..."); + let tx_template = + TransactionTemplate::ConsumeNotes(account_b.id(), vec![client_2_notes[0].id()]); + let tx_request = client2.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client2, tx_request).await; + + // Create ONCHAIN swap note (clientA offers 1 BTC in exchange of 25 ETH) + // check that account now has 1 less BTC + println!("creating swap note with accountA"); + let offered_asset = FungibleAsset::new(btc_faucet_account.id(), OFFERED_ASSET_AMOUNT).unwrap(); + let requested_asset = + FungibleAsset::new(eth_faucet_account.id(), REQUESTED_ASSET_AMOUNT).unwrap(); + let tx_template = TransactionTemplate::Swap( + SwapTransactionData::new( + account_a.id(), + Asset::Fungible(offered_asset), + Asset::Fungible(requested_asset), + ), + NoteType::Public, + ); + println!("Running SWAP tx..."); + let tx_request = client1.build_transaction_request(tx_template).unwrap(); + + let expected_output_notes = tx_request.expected_output_notes().to_vec(); + let expected_payback_note_details = tx_request.expected_partial_notes().to_vec(); + assert_eq!(expected_output_notes.len(), 1); + assert_eq!(expected_payback_note_details.len(), 1); + + execute_tx_and_sync(&mut client1, tx_request).await; + + let payback_note_tag = + build_swap_tag(NoteType::Public, btc_faucet_account.id(), eth_faucet_account.id()); + + // add swap note's tag to both client 1 and client 2 (TODO: check if it's needed for both) + // we could technically avoid this step, but for the first iteration of swap notes we'll + // require to manually add tags + println!("Adding swap tags"); + client1.add_note_tag(payback_note_tag).unwrap(); + client2.add_note_tag(payback_note_tag).unwrap(); + + // sync on client 2, we should get the swap note + // consume swap note with accountB, and check that the vault changed appropiately + client2.sync_state().await.unwrap(); + println!("Consuming swap note on second client..."); + let tx_template = + TransactionTemplate::ConsumeNotes(account_b.id(), vec![expected_output_notes[0].id()]); + let tx_request = client2.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client2, tx_request).await; + + // sync on client 1, we should get the missing payback note details. + // try consuming the received note with accountA, it should now have 25 ETH + client1.sync_state().await.unwrap(); + println!("Consuming swap payback note on first client..."); + let tx_template = TransactionTemplate::ConsumeNotes( + account_a.id(), + vec![expected_payback_note_details[0].id()], + ); + let tx_request = client1.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client1, tx_request).await; + + // At the end we should end up with + // + // - accountA: 999 BTC, 25 ETH + // - accountB: 1 BTC, 975 ETH + + // first reload the account + let (account_a, _) = client1.get_account(account_a.id()).unwrap(); + let account_a_assets = account_a.vault().assets(); + assert_eq!(account_a_assets.count(), 2); + let mut account_a_assets = account_a.vault().assets(); + + let asset_1 = account_a_assets.next().unwrap(); + let asset_2 = account_a_assets.next().unwrap(); + + match (asset_1, asset_2) { + (Asset::Fungible(btc_asset), Asset::Fungible(eth_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 999); + assert_eq!(eth_asset.amount(), 25); + }, + (Asset::Fungible(eth_asset), Asset::Fungible(btc_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 999); + assert_eq!(eth_asset.amount(), 25); + }, + _ => panic!("should only have fungible assets!"), + } + + let (account_b, _) = client2.get_account(account_b.id()).unwrap(); + let account_b_assets = account_b.vault().assets(); + assert_eq!(account_b_assets.count(), 2); + let mut account_b_assets = account_b.vault().assets(); + + let asset_1 = account_b_assets.next().unwrap(); + let asset_2 = account_b_assets.next().unwrap(); + + match (asset_1, asset_2) { + (Asset::Fungible(btc_asset), Asset::Fungible(eth_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 1); + assert_eq!(eth_asset.amount(), 975); + }, + (Asset::Fungible(eth_asset), Asset::Fungible(btc_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 1); + assert_eq!(eth_asset.amount(), 975); + }, + _ => panic!("should only have fungible assets!"), + } +} + +#[tokio::test] +async fn test_swap_offchain() { + const OFFERED_ASSET_AMOUNT: u64 = 1; + const REQUESTED_ASSET_AMOUNT: u64 = 25; + const BTC_MINT_AMOUNT: u64 = 1000; + const ETH_MINT_AMOUNT: u64 = 1000; + let mut client1 = create_test_client(); + wait_for_node(&mut client1).await; + let mut client2 = create_test_client(); + let mut client_with_faucets = create_test_client(); + + client1.sync_state().await.unwrap(); + client2.sync_state().await.unwrap(); + client_with_faucets.sync_state().await.unwrap(); + + // Create Client 1's basic wallet (We'll call it accountA) + let (account_a, _) = client1 + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // Create Client 2's basic wallet (We'll call it accountB) + let (account_b, _) = client2 + .new_account(AccountTemplate::BasicWallet { + mutable_code: false, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // Create client with faucets BTC faucet (note: it's not real BTC) + let (btc_faucet_account, _) = client_with_faucets + .new_account(AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("BTC").unwrap(), + decimals: 8, + max_supply: 1_000_000, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + // Create client with faucets ETH faucet (note: it's not real ETH) + let (eth_faucet_account, _) = client_with_faucets + .new_account(AccountTemplate::FungibleFaucet { + token_symbol: TokenSymbol::new("ETH").unwrap(), + decimals: 8, + max_supply: 1_000_000, + storage_mode: AccountStorageMode::Local, + }) + .unwrap(); + + // mint 1000 BTC for accountA + println!("minting 1000 btc for account A"); + mint( + &mut client_with_faucets, + account_a.id(), + btc_faucet_account.id(), + NoteType::Public, + BTC_MINT_AMOUNT, + ) + .await; + // mint 1000 ETH for accountB + println!("minting 1000 eth for account B"); + mint( + &mut client_with_faucets, + account_b.id(), + eth_faucet_account.id(), + NoteType::Public, + ETH_MINT_AMOUNT, + ) + .await; + + // Sync and consume note for accountA + client1.sync_state().await.unwrap(); + let client_1_notes = client1.get_input_notes(miden_client::store::NoteFilter::All).unwrap(); + assert_eq!(client_1_notes.len(), 1); + + println!("Consuming mint note on first client..."); + let tx_template = + TransactionTemplate::ConsumeNotes(account_a.id(), vec![client_1_notes[0].id()]); + let tx_request = client1.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client1, tx_request).await; + + // Sync and consume note for accountB + client2.sync_state().await.unwrap(); + let client_2_notes = client2.get_input_notes(miden_client::store::NoteFilter::All).unwrap(); + assert_eq!(client_2_notes.len(), 1); + + println!("Consuming mint note on second client..."); + let tx_template = + TransactionTemplate::ConsumeNotes(account_b.id(), vec![client_2_notes[0].id()]); + let tx_request = client2.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client2, tx_request).await; + + // Create ONCHAIN swap note (clientA offers 1 BTC in exchange of 25 ETH) + // check that account now has 1 less BTC + println!("creating swap note with accountA"); + let offered_asset = FungibleAsset::new(btc_faucet_account.id(), OFFERED_ASSET_AMOUNT).unwrap(); + let requested_asset = + FungibleAsset::new(eth_faucet_account.id(), REQUESTED_ASSET_AMOUNT).unwrap(); + let tx_template = TransactionTemplate::Swap( + SwapTransactionData::new( + account_a.id(), + Asset::Fungible(offered_asset), + Asset::Fungible(requested_asset), + ), + NoteType::OffChain, + ); + println!("Running SWAP tx..."); + let tx_request = client1.build_transaction_request(tx_template).unwrap(); + + let expected_output_notes = tx_request.expected_output_notes().to_vec(); + let expected_payback_note_details = tx_request.expected_partial_notes().to_vec(); + assert_eq!(expected_output_notes.len(), 1); + assert_eq!(expected_payback_note_details.len(), 1); + + execute_tx_and_sync(&mut client1, tx_request).await; + + // Export note from client 1 to client 2 + let exported_note = client1.get_output_note(expected_output_notes[0].id()).unwrap(); + + client2 + .import_input_note(exported_note.try_into().unwrap(), true) + .await + .unwrap(); + + // Sync so we get the inclusion proof info + client2.sync_state().await.unwrap(); + + // consume swap note with accountB, and check that the vault changed appropiately + println!("Consuming swap note on second client..."); + let tx_template = + TransactionTemplate::ConsumeNotes(account_b.id(), vec![expected_output_notes[0].id()]); + let tx_request = client2.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client2, tx_request).await; + + // sync on client 1, we should get the missing payback note details. + // try consuming the received note with accountA, it should now have 25 ETH + client1.sync_state().await.unwrap(); + println!("Consuming swap payback note on first client..."); + let tx_template = TransactionTemplate::ConsumeNotes( + account_a.id(), + vec![expected_payback_note_details[0].id()], + ); + let tx_request = client1.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(&mut client1, tx_request).await; + + // At the end we should end up with + // + // - accountA: 999 BTC, 25 ETH + // - accountB: 1 BTC, 975 ETH + + // first reload the account + let (account_a, _) = client1.get_account(account_a.id()).unwrap(); + let account_a_assets = account_a.vault().assets(); + assert_eq!(account_a_assets.count(), 2); + let mut account_a_assets = account_a.vault().assets(); + + let asset_1 = account_a_assets.next().unwrap(); + let asset_2 = account_a_assets.next().unwrap(); + + match (asset_1, asset_2) { + (Asset::Fungible(btc_asset), Asset::Fungible(eth_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 999); + assert_eq!(eth_asset.amount(), 25); + }, + (Asset::Fungible(eth_asset), Asset::Fungible(btc_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 999); + assert_eq!(eth_asset.amount(), 25); + }, + _ => panic!("should only have fungible assets!"), + } + + let (account_b, _) = client2.get_account(account_b.id()).unwrap(); + let account_b_assets = account_b.vault().assets(); + assert_eq!(account_b_assets.count(), 2); + let mut account_b_assets = account_b.vault().assets(); + + let asset_1 = account_b_assets.next().unwrap(); + let asset_2 = account_b_assets.next().unwrap(); + + match (asset_1, asset_2) { + (Asset::Fungible(btc_asset), Asset::Fungible(eth_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 1); + assert_eq!(eth_asset.amount(), 975); + }, + (Asset::Fungible(eth_asset), Asset::Fungible(btc_asset)) + if btc_asset.faucet_id() == btc_faucet_account.id() + && eth_asset.faucet_id() == eth_faucet_account.id() => + { + assert_eq!(btc_asset.amount(), 1); + assert_eq!(eth_asset.amount(), 975); + }, + _ => panic!("should only have fungible assets!"), + } +} + +/// Returns a note tag for a swap note with the specified parameters. +/// +/// Use case ID for the returned tag is set to 0. +/// +/// Tag payload is constructed by taking asset tags (8 bits of faucet ID) and concatenating them +/// together as offered_asset_tag + requested_asset tag. +/// +/// Network execution hint for the returned tag is set to `Local`. +/// +/// Based on miden-base's implementation () +fn build_swap_tag( + note_type: NoteType, + offered_asset_faucet_id: AccountId, + requested_asset_faucet_id: AccountId, +) -> NoteTag { + const SWAP_USE_CASE_ID: u16 = 0; + + // get bits 4..12 from faucet IDs of both assets, these bits will form the tag payload; the + // reason we skip the 4 most significant bits is that these encode metadata of underlying + // faucets and are likely to be the same for many different faucets. + + let offered_asset_id: u64 = offered_asset_faucet_id.into(); + let offered_asset_tag = (offered_asset_id >> 52) as u8; + + let requested_asset_id: u64 = requested_asset_faucet_id.into(); + let requested_asset_tag = (requested_asset_id >> 52) as u8; + + let payload = ((offered_asset_tag as u16) << 8) | (requested_asset_tag as u16); + + let execution = NoteExecutionHint::Local; + match note_type { + NoteType::Public => NoteTag::for_public_use_case(SWAP_USE_CASE_ID, payload, execution), + _ => NoteTag::for_local_use_case(SWAP_USE_CASE_ID, payload), + } + .unwrap() +} + +/// Mints a note from faucet_account_id for basic_account_id, waits for inclusion and returns it +/// with 1000 units of the corresponding fungible asset +/// +/// `basic_account_id` does not need to be tracked by the client, but `faucet_account_id` does +async fn mint( + client: &mut TestClient, + basic_account_id: AccountId, + faucet_account_id: AccountId, + note_type: NoteType, + mint_amount: u64, +) { + // Create a Mint Tx for 1000 units of our fungible asset + let fungible_asset = FungibleAsset::new(faucet_account_id, mint_amount).unwrap(); + let tx_template = + TransactionTemplate::MintFungibleAsset(fungible_asset, basic_account_id, note_type); + + println!("Minting Asset"); + let tx_request = client.build_transaction_request(tx_template).unwrap(); + execute_tx_and_sync(client, tx_request.clone()).await; +}