From 86b39cb5539db925d8190bf2f1fad246040e22ac Mon Sep 17 00:00:00 2001 From: Bobbin Threadbare <43513081+bobbinth@users.noreply.github.com> Date: Tue, 27 Jun 2023 09:17:21 -0700 Subject: [PATCH] Initial commit feat: set up client structure add command skeleton and table migrations lints and remove testing prints fmt] add log to crate testing feature so that PoW in seed grinding does not take long addressing reviews map_err fix deserialization remove further unwraps add test address further reviews use new functions address reviews prettier table replace tabled with no std alt add SQL relations align comments feat: add github ci workflow feat: introduce input notes db and list command prettier wrapping table --- .github/workflows/ci.yml | 69 ++++++++ .gitignore | 14 ++ .pre-commit-config.yaml | 43 +++++ Cargo.toml | 33 ++++ LICENSE | 21 +++ README.md | 1 + src/cli/account.rs | 183 +++++++++++++++++++ src/cli/input_notes.rs | 142 +++++++++++++++ src/cli/mod.rs | 51 ++++++ src/cli/test.rs | 18 ++ src/errors.rs | 67 +++++++ src/lib.rs | 242 +++++++++++++++++++++++++ src/main.rs | 15 ++ src/store/migrations.rs | 21 +++ src/store/mod.rs | 374 +++++++++++++++++++++++++++++++++++++++ src/store/store.sql | 61 +++++++ 16 files changed, 1355 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/cli/account.rs create mode 100644 src/cli/input_notes.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/test.rs create mode 100644 src/errors.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/store/migrations.rs create mode 100644 src/store/mod.rs create mode 100644 src/store/store.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..f05172b89 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI +on: + push: + branches: + - main + pull_request: + types: [opened, repoened, synchronize] + +jobs: + test: + name: Test Rust ${{matrix.toolchain}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + strategy: + fail-fast: false + matrix: + toolchain: [stable, nightly] + os: [ubuntu] + steps: + - uses: actions/checkout@main + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{matrix.toolchain}} + override: true + - name: Test + uses: actions-rs/cargo@v1 + env: + RUSTFLAGS: -C debug-assertions + with: + command: test + args: --release + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@main + - name: Install minimal stable with clippy + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly + components: clippy + override: true + + - name: Clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: --all --all-targets -- -D clippy::all -D warnings + + rustfmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@main + - name: Install minimal stable with rustfmt + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + components: rustfmt + override: true + + - name: rustfmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..6985cf1bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..d00cf263f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: pretty-format-json + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: detect-private-key +- repo: https://github.com/hackaugusto/pre-commit-cargo + rev: v1.0.0 + hooks: + # Allows cargo fmt to modify the source code prior to the commit + - id: cargo + name: Cargo fmt + args: ["+stable", "fmt", "--all"] + stages: [commit] + # Requires code to be properly formatted prior to pushing upstream + - id: cargo + name: Cargo fmt --check + args: ["+stable", "fmt", "--all", "--check"] + stages: [push, manual] + - id: cargo + name: Cargo check --all-targets + args: ["+stable", "check", "--all-targets"] + - id: cargo + name: Cargo check --all-targets --no-default-features + args: ["+stable", "check", "--all-targets", "--no-default-features"] + - id: cargo + name: Cargo check --all-targets --all-features + args: ["+stable", "check", "--all-targets", "--all-features"] + # Unlike fmt, clippy will not be automatically applied + - id: cargo + name: Cargo clippy + args: ["+nightly", "clippy", "--workspace", "--", "--deny", "clippy::all", "--deny", "warnings"] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..42229c366 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "miden-client" +version = "0.1.0" +authors = ["miden contributors"] +readme = "README.md" +license = "MIT" +repository = "https://github.com/0xPolygonMiden/miden-client" +keywords = ["miden", "client"] +edition = "2021" +rust-version = "1.67" + +[features] +default = ["std"] +std = ["crypto/std", "objects/std"] +testing = ["objects/testing", "mock"] + +[dependencies] +clap = { version = "4.3" , features = ["derive"] } +crypto = { package = "miden-crypto", git = "https://github.com/0xPolygonMiden/crypto", branch = "next", default-features = false } +lazy_static = "1.4.0" +objects = { package = "miden-objects", git = "https://github.com/0xPolygonMiden/miden-base", branch = "main", features = ["serde"] } +miden_lib = { package = "miden-lib", git = "https://github.com/0xPolygonMiden/miden-base", branch = "main", default-features = false } +mock = { package = "miden-mock", git = "https://github.com/0xPolygonMiden/miden-base", branch = "main", default-features = false, optional = true } +rusqlite = { version = "0.29.0", features = ["bundled"] } +rusqlite_migration = { version = "1.0" } +rand = { version="0.8.5" } +serde = {version="1.0", features = ["derive"]} +serde_json = { version = "1.0", features = ["raw_value"] } +comfy-table = "7.1.0" + +[dev-dependencies] +uuid = { version = "1.6.1", features = ["serde", "v4"] } +mock = { package = "miden-mock", git = "https://github.com/0xPolygonMiden/miden-base", branch = "main", default-features = false } diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..c6fe12c86 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Polygon Miden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..f229c0ada --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# miden-client diff --git a/src/cli/account.rs b/src/cli/account.rs new file mode 100644 index 000000000..9507187c9 --- /dev/null +++ b/src/cli/account.rs @@ -0,0 +1,183 @@ +use clap::Parser; +use comfy_table::{presets, Attribute, Cell, ContentArrangement, Table}; +use crypto::{dsa::rpo_falcon512::KeyPair, Felt}; +use miden_client::Client; +use miden_lib::{faucets, AuthScheme}; +use objects::{accounts::AccountType, assets::TokenSymbol}; +use rand::Rng; + +// ACCOUNT COMMAND +// ================================================================================================ + +#[derive(Debug, Clone, Parser)] +#[clap(about = "View accounts and account details")] +pub enum AccountCmd { + /// List all accounts monitored by this client + #[clap(short_flag = 'l')] + List, + + /// View details of the account for the specified ID + #[clap(short_flag = 'v')] + View { + #[clap()] + id: Option, + }, + + /// Create new account and store it locally + #[clap(short_flag = 'n')] + New { + #[clap(subcommand)] + template: Option, + + /// Executes a transaction that records the account on-chain + #[clap(short, long, default_value_t = false)] + deploy: bool, + }, +} + +#[derive(Debug, Parser, Clone)] +#[clap()] +pub enum AccountTemplate { + /// Creates a basic account (Regular account with immutable code) + BasicImmutable, + /// Creates a basic account (Regular account with mutable code) + BasicMutable, + /// Creates a faucet for fungible tokens + FungibleFaucet { + #[clap(short, long)] + token_symbol: String, + #[clap(short, long)] + decimals: u8, + #[clap(short, long)] + max_supply: u64, + }, + /// Creates a faucet for non-fungible tokens + NonFungibleFaucet, +} + +impl AccountCmd { + pub fn execute(&self, client: Client) -> Result<(), String> { + match self { + AccountCmd::List => { + list_accounts(client)?; + } + AccountCmd::New { template, deploy } => { + new_account(client, template, *deploy)?; + } + AccountCmd::View { id: _ } => todo!(), + } + Ok(()) + } +} + +// LIST ACCOUNTS +// ================================================================================================ + +fn list_accounts(client: Client) -> Result<(), String> { + println!("{}", "-".repeat(240)); + println!( + "{0: <18} | {1: <66} | {2: <66} | {3: <66} | {4: <15}", + "account id", "code root", "vault root", "storage root", "nonce", + ); + println!("{}", "-".repeat(240)); + + let accounts = client.get_accounts().map_err(|err| err.to_string())?; + + let mut table = Table::new(); + table + .load_preset(presets::UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("account id").add_attribute(Attribute::Bold), + Cell::new("code root").add_attribute(Attribute::Bold), + Cell::new("vault root").add_attribute(Attribute::Bold), + Cell::new("storage root").add_attribute(Attribute::Bold), + Cell::new("nonce").add_attribute(Attribute::Bold), + ]); + + accounts.iter().for_each(|acc| { + table.add_row(vec![ + acc.id().to_string(), + acc.code_root().to_string(), + acc.vault_root().to_string(), + acc.storage_root().to_string(), + acc.nonce().to_string(), + ]); + }); + + println!("{table}"); + Ok(()) +} + +// ACCOUNT NEW +// ================================================================================================ + +fn new_account( + client: Client, + template: &Option, + deploy: bool, +) -> Result<(), String> { + if deploy { + todo!("Recording the account on chain is not supported yet"); + } + + let key_pair: KeyPair = + KeyPair::new().map_err(|err| format!("Error generating KeyPair: {}", err))?; + let auth_scheme: AuthScheme = AuthScheme::RpoFalcon512 { + pub_key: key_pair.public_key(), + }; + + let mut rng = rand::thread_rng(); + // we need to use an initial seed to create the wallet account + let init_seed: [u8; 32] = rng.gen(); + + // TODO: as the client takes form, make errors more structured + let (account, _) = match template { + None => todo!("Generic account creation is not supported yet"), + Some(AccountTemplate::BasicImmutable) => miden_lib::wallets::create_basic_wallet( + init_seed, + auth_scheme, + AccountType::RegularAccountImmutableCode, + ), + Some(AccountTemplate::FungibleFaucet { + token_symbol, + decimals, + max_supply, + }) => { + let max_supply = max_supply.to_le_bytes(); + faucets::create_basic_fungible_faucet( + init_seed, + TokenSymbol::new(token_symbol) + .expect("Hardcoded test token symbol creation should not panic"), + *decimals, + Felt::try_from(max_supply.as_slice()) + .map_err(|_| "Maximum supply must fit into a field element")?, + auth_scheme, + ) + } + Some(AccountTemplate::BasicMutable) => miden_lib::wallets::create_basic_wallet( + init_seed, + auth_scheme, + AccountType::RegularAccountUpdatableCode, + ), + _ => todo!("Template not supported yet"), + } + .map_err(|err| err.to_string())?; + + // TODO: Make these inserts atomic through a single transaction + client + .store() + .insert_account_code(account.code()) + .and_then(|_| client.store().insert_account_storage(account.storage())) + .and_then(|_| client.store().insert_account_vault(account.vault())) + .and_then(|_| client.store().insert_account(&account)) + .map(|_| { + println!( + "Succesfully created and stored Account ID: {}", + account.id() + ) + }) + .map_err(|x| x.to_string())?; + + Ok(()) +} diff --git a/src/cli/input_notes.rs b/src/cli/input_notes.rs new file mode 100644 index 000000000..2c3566c30 --- /dev/null +++ b/src/cli/input_notes.rs @@ -0,0 +1,142 @@ +use super::Client; +use super::Parser; + +use objects::notes::RecordedNote; +use objects::Digest; + +#[derive(Debug, Parser, Clone)] +#[clap(about = "View input notes")] +pub enum InputNotes { + /// List input notes + #[clap(short_flag = 'l')] + List, + + /// Show details of the input note for the specified note hash + #[clap(short_flag = 's')] + Show { + /// Hash of the input note to show + #[clap()] + hash: 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, + }, +} + +impl InputNotes { + pub fn execute(&self, client: Client) -> Result<(), String> { + match self { + InputNotes::List => { + list_input_notes(client)?; + } + InputNotes::Show { + hash, + script, + vault, + inputs, + } => { + show_input_note(client, hash.clone(), *script, *vault, *inputs)?; + } + } + Ok(()) + } +} + +// LIST INPUT NOTES +// ================================================================================================ +fn list_input_notes(client: Client) -> Result<(), String> { + let notes = client.get_input_notes().map_err(|err| err.to_string())?; + print_notes_summary(¬es); + Ok(()) +} + +fn show_input_note( + client: Client, + hash: String, + show_script: bool, + show_vault: bool, + show_inputs: bool, +) -> Result<(), String> { + let hash = Digest::try_from(hash) + .map_err(|err| format!("Failed to parse input note hash: {}", err))?; + + let note = client + .store() + .get_input_note_by_hash(hash) + .map_err(|err| err.to_string())?; + + // print note summary + print_notes_summary(core::iter::once(¬e)); + + // print note script + if show_script { + println!("{}", "-".repeat(240)); + println!("Note script hash: {}", note.note().script().hash()); + println!("{}", "-".repeat(240)); + println!("Note Script:"); + println!("{}", "-".repeat(240)); + println!("{}", note.note().script().code()); + }; + + // print note vault + if show_vault { + println!("{}", "-".repeat(240)); + println!("Note vault hash: {}", note.note().vault().hash()); + println!("{}", "-".repeat(240)); + println!("Note Vault:"); + println!("{}", "-".repeat(240)); + for asset in note.note().vault().iter() { + // To do print this nicely + println!("{:?}", asset); + } + }; + + if show_inputs { + println!("{}", "-".repeat(240)); + println!("Note inputs hash: {}", note.note().inputs().hash()); + println!("{}", "-".repeat(240)); + println!("Note Inputs:"); + println!("{}", "-".repeat(240)); + for (idx, input) in note.note().inputs().inputs().iter().enumerate() { + // To do print this nicely + println!("{idx}: {input}"); + } + }; + + Ok(()) +} + +// HELPERS +// ================================================================================================ +fn print_notes_summary<'a, I>(notes: I) +where + I: IntoIterator, +{ + println!("{}", "-".repeat(240)); + println!( + "{0: <66} | {1: <66} | {2: <66} | {3: <66} | {4: <15}", + "hash", "script hash", "vault hash", "inputs hash", "serial num", + ); + println!("{}", "-".repeat(240)); + + for note in notes { + println!( + "{0: <66} | {1: <66} | {2: <66} | {3: <66} | {4: <15}", + note.note().hash(), + note.note().script().hash(), + note.note().vault().hash(), + note.note().inputs().hash(), + Digest::new(note.note().serial_num()), + ); + } + println!("{}", "-".repeat(240)); +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 000000000..1b6c15b6e --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,51 @@ +use crate::{Client, ClientConfig}; +use clap::Parser; + +mod account; +mod input_notes; +#[cfg(feature = "testing")] +mod test; + +/// Root CLI struct +#[derive(Parser, Debug)] +#[clap( + name = "Miden", + about = "Miden Client", + version, + rename_all = "kebab-case" +)] +pub struct Cli { + #[clap(subcommand)] + action: Command, +} + +/// CLI actions +#[derive(Debug, Parser)] +pub enum Command { + #[clap(subcommand)] + Account(account::AccountCmd), + #[clap(subcommand)] + InputNotes(input_notes::InputNotes), + #[cfg(feature = "testing")] + /// Insert test data into the database + TestData, +} + +/// CLI entry point +impl Cli { + pub fn execute(&self) -> Result<(), String> { + // create a client + let client = Client::new(ClientConfig::default()).map_err(|err| err.to_string())?; + + // execute cli command + match &self.action { + Command::Account(account) => account.execute(client), + Command::InputNotes(notes) => notes.execute(client), + #[cfg(feature = "testing")] + Command::TestData => { + test::insert_test_data(client); + Ok(()) + } + } + } +} diff --git a/src/cli/test.rs b/src/cli/test.rs new file mode 100644 index 000000000..57b2202bf --- /dev/null +++ b/src/cli/test.rs @@ -0,0 +1,18 @@ +use super::Client; + +pub fn insert_test_data(mut client: Client) { + use mock::mock::{ + account::MockAccountType, notes::AssetPreservationStatus, transaction::mock_inputs, + }; + + // generate test data + let (_, _, _, recorded_notes) = mock_inputs( + MockAccountType::StandardExisting, + AssetPreservationStatus::Preserved, + ); + + // insert notes into database + for note in recorded_notes.into_iter() { + client.insert_input_note(note).unwrap(); + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 000000000..53fcd0c0c --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,67 @@ +use core::fmt; +use objects::{AccountError, Digest}; + +// CLIENT ERROR +// ================================================================================================ + +#[derive(Debug)] +pub enum ClientError { + StoreError(StoreError), + AccountError(AccountError), +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ClientError::StoreError(err) => write!(f, "store error: {err}"), + ClientError::AccountError(err) => write!(f, "account error: {err}"), + } + } +} + +impl From for ClientError { + fn from(err: StoreError) -> Self { + Self::StoreError(err) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ClientError {} + +// STORE ERROR +// ================================================================================================ + +#[derive(Debug)] +pub enum StoreError { + ConnectionError(rusqlite::Error), + MigrationError(rusqlite_migration::Error), + ColumnParsingError(rusqlite::Error), + QueryError(rusqlite::Error), + InputSerializationError(serde_json::Error), + DataDeserializationError(serde_json::Error), + InputNoteNotFound(Digest), +} + +impl fmt::Display for StoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use StoreError::*; + match self { + ConnectionError(err) => write!(f, "failed to connect to the database: {err}"), + MigrationError(err) => write!(f, "failed to update the database: {err}"), + QueryError(err) => write!(f, "failed to retrieve data from the database: {err}"), + ColumnParsingError(err) => { + write!(f, "failed to parse data retrieved from the database: {err}") + } + InputSerializationError(err) => { + write!(f, "error trying to serialize inputs for the store: {err}") + } + DataDeserializationError(err) => { + write!(f, "error deserializing data from the store: {err}") + } + InputNoteNotFound(hash) => write!(f, "input note with hash {} not found", hash), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for StoreError {} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..741599586 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,242 @@ +use objects::{ + accounts::{Account, AccountId, AccountStub}, + notes::RecordedNote, + Digest, +}; +use std::path::PathBuf; + +mod store; +use store::Store; + +pub mod errors; +use errors::ClientError; + +// MIDEN CLIENT +// ================================================================================================ + +/// A light client for connecting to the Miden rollup network. +/// +/// Miden client is responsible for managing a set of accounts. Specifically, the client: +/// - Keeps track of the current and historical states of a set of accounts and related objects +/// such as notes and transactions. +/// - 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 { + /// Local database containing information about the accounts managed by this client. + store: Store, + // TODO + // node: connection to Miden node +} + +impl Client { + // CONSTRUCTOR + // -------------------------------------------------------------------------------------------- + + /// Returns a new instance of [Client] instantiated with the specified configuration options. + /// + /// # Errors + /// Returns an error if the client could not be instantiated. + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + store: Store::new((&config).into())?, + }) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns a reference to the store + pub fn store(&self) -> &Store { + &self.store + } + + // ACCOUNT DATA RETRIEVAL + // -------------------------------------------------------------------------------------------- + + /// Returns summary info about the accounts managed by this client. + /// + /// TODO: replace `AccountStub` with a more relevant structure. + pub fn get_accounts(&self) -> Result, ClientError> { + self.store.get_accounts().map_err(|err| err.into()) + } + + /// Returns historical states for the account with the specified ID. + /// + /// TODO: wrap `Account` in a type with additional info. + /// TODO: consider changing the interface to support pagination. + pub fn get_account_history(&self, _account_id: AccountId) -> Result, ClientError> { + todo!() + } + + /// Returns detailed information about the current state of the account with the specified ID. + /// + /// TODO: wrap `Account` in a type with additional info (e.g., status). + /// TODO: consider adding `nonce` as another parameter to identify a specific account state. + pub fn get_account_details(&self, _account_id: AccountId) -> Result { + todo!() + } + + // INPUT NOTE DATA RETRIEVAL + // -------------------------------------------------------------------------------------------- + + /// Returns input notes managed by this client. + pub fn get_input_notes(&self) -> Result, ClientError> { + self.store.get_input_notes().map_err(|err| err.into()) + } + + /// Returns the input note with the specified hash. + pub fn get_input_note(&self, hash: Digest) -> Result { + self.store + .get_input_note_by_hash(hash) + .map_err(|err| err.into()) + } + + // INPUT NOTE CREATION + // -------------------------------------------------------------------------------------------- + + /// Inserts a new input note into the client's store. + pub fn insert_input_note(&mut self, note: RecordedNote) -> Result<(), ClientError> { + self.store + .insert_input_note(¬e) + .map_err(|err| err.into()) + } + + // TODO: add methods for retrieving note and transaction info, and for creating/executing + // transaction +} + +// CLIENT CONFIG +// ================================================================================================ + +/// Configuration options of Miden client. +#[derive(Debug, PartialEq, Eq)] +pub struct ClientConfig { + /// Location of the client's data file. + store_path: String, + /// Address of the Miden node to connect to. + node_endpoint: Endpoint, +} + +impl ClientConfig { + /// Returns a new instance of [ClientConfig] with the specified store path and node endpoint. + pub fn new(store_path: String, node_endpoint: Endpoint) -> Self { + Self { + store_path, + node_endpoint, + } + } +} + +impl Default for ClientConfig { + fn default() -> Self { + const STORE_FILENAME: &str = "store.sqlite3"; + + // get directory of the currently executing binary, or fallback to the current directory + let exec_dir = match std::env::current_exe() { + Ok(mut path) => { + path.pop(); + path + } + Err(_) => PathBuf::new(), + }; + + let store_path = exec_dir.join(STORE_FILENAME); + + Self { + store_path: store_path + .into_os_string() + .into_string() + .expect("Creating the hardcoded path to the store file should not panic"), + node_endpoint: Endpoint::default(), + } + } +} + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Debug)] +pub struct Endpoint { + pub host: String, + pub port: u16, +} + +impl Default for Endpoint { + fn default() -> Self { + const MIDEN_NODE_PORT: u16 = 57291; + + Self { + host: "localhost".to_string(), + port: MIDEN_NODE_PORT, + } + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::store::tests::create_test_store_path; + use mock::mock::{ + account::MockAccountType, notes::AssetPreservationStatus, transaction::mock_inputs, + }; + + #[test] + fn test_input_notes_round_trip() { + // generate test store path + let store_path = create_test_store_path(); + + // generate test client + let mut client = super::Client::new(super::ClientConfig::new( + store_path.into_os_string().into_string().unwrap(), + super::Endpoint::default(), + )) + .unwrap(); + + // generate test data + let (_, _, _, recorded_notes) = mock_inputs( + MockAccountType::StandardExisting, + AssetPreservationStatus::Preserved, + ); + + // insert notes into database + for note in recorded_notes.iter().cloned() { + client.insert_input_note(note).unwrap(); + } + + // retrieve notes from database + let retrieved_notes = client.get_input_notes().unwrap(); + + // compare notes + assert_eq!(recorded_notes, retrieved_notes); + } + + #[test] + fn test_get_input_note() { + // generate test store path + let store_path = create_test_store_path(); + + // generate test client + let mut client = super::Client::new(super::ClientConfig::new( + store_path.into_os_string().into_string().unwrap(), + super::Endpoint::default(), + )) + .unwrap(); + + // generate test data + let (_, _, _, recorded_notes) = mock_inputs( + MockAccountType::StandardExisting, + AssetPreservationStatus::Preserved, + ); + + // insert note into database + client.insert_input_note(recorded_notes[0].clone()).unwrap(); + + // retrieve note from database + let retrieved_note = client + .get_input_note(recorded_notes[0].note().hash()) + .unwrap(); + + // compare notes + assert_eq!(recorded_notes[0], retrieved_note); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 000000000..d7ff9f2d9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +use clap::Parser; +use miden_client::{Client, ClientConfig}; + +mod cli; +use cli::Cli; + +fn main() { + // read command-line args + let cli = Cli::parse(); + + // execute cli action + if let Err(error) = cli.execute() { + println!("{}", error); + } +} diff --git a/src/store/migrations.rs b/src/store/migrations.rs new file mode 100644 index 000000000..52ce8da99 --- /dev/null +++ b/src/store/migrations.rs @@ -0,0 +1,21 @@ +use super::StoreError; +use lazy_static::lazy_static; +use rusqlite::Connection; +use rusqlite_migration::{Migrations, M}; + +// MIGRATIONS +// ================================================================================================ + +lazy_static! { + static ref MIGRATIONS: Migrations<'static> = + Migrations::new(vec![M::up(include_str!("store.sql")),]); +} + +// PUBLIC FUNCTIONS +// ================================================================================================ + +pub fn update_to_latest(conn: &mut Connection) -> Result<(), StoreError> { + MIGRATIONS + .to_latest(conn) + .map_err(StoreError::MigrationError) +} diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 000000000..4b6aa47d0 --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,374 @@ +use super::{errors::StoreError, AccountStub, ClientConfig}; +use crypto::{utils::collections::BTreeMap, Word}; +use objects::{ + accounts::{Account, AccountCode, AccountId, AccountStorage, AccountVault}, + assembly::AstSerdeOptions, + assets::Asset, + notes::{Note, NoteMetadata, RecordedNote}, + Digest, Felt, +}; +use rusqlite::{params, Connection}; + +mod migrations; + +// TYPES +// ================================================================================================ + +type SerializedInputNoteData = ( + String, + String, + String, + String, + String, + String, + i64, + i64, + i64, + String, + String, + String, + i64, +); + +type SerializedInputNoteParts = (String, String, String, String, u64, u64, u64, String); + +// CLIENT STORE +// ================================================================================================ + +pub struct Store { + db: Connection, +} + +impl Store { + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Returns a new instance of [Store] instantiated with the specified configuration options. + pub fn new(config: StoreConfig) -> Result { + let mut db = Connection::open(config.path).map_err(StoreError::ConnectionError)?; + migrations::update_to_latest(&mut db)?; + + Ok(Self { db }) + } + + // ACCOUNTS + // -------------------------------------------------------------------------------------------- + + pub fn get_accounts(&self) -> Result, StoreError> { + let mut stmt = self + .db + .prepare("SELECT id, nonce, vault_root, storage_root, code_root FROM accounts") + .map_err(StoreError::QueryError)?; + + let mut rows = stmt.query([]).map_err(StoreError::QueryError)?; + let mut result = Vec::new(); + while let Some(row) = rows.next().map_err(StoreError::QueryError)? { + // TODO: implement proper error handling and conversions + + let id: i64 = row.get(0).map_err(StoreError::QueryError)?; + let nonce: i64 = row.get(1).map_err(StoreError::QueryError)?; + + let vault_root: String = row.get(2).map_err(StoreError::QueryError)?; + let storage_root: String = row.get(3).map_err(StoreError::QueryError)?; + let code_root: String = row.get(4).map_err(StoreError::QueryError)?; + + result.push(AccountStub::new( + (id as u64) + .try_into() + .expect("Conversion from stored AccountID should not panic"), + (nonce as u64).into(), + serde_json::from_str(&vault_root).map_err(StoreError::DataDeserializationError)?, + serde_json::from_str(&storage_root) + .map_err(StoreError::DataDeserializationError)?, + serde_json::from_str(&code_root).map_err(StoreError::DataDeserializationError)?, + )); + } + + Ok(result) + } + + pub fn insert_account(&self, account: &Account) -> Result<(), StoreError> { + let id: u64 = account.id().into(); + let code_root = serde_json::to_string(&account.code().root()) + .map_err(StoreError::InputSerializationError)?; + let storage_root = serde_json::to_string(&account.storage().root()) + .map_err(StoreError::InputSerializationError)?; + let vault_root = serde_json::to_string(&account.vault().commitment()) + .map_err(StoreError::InputSerializationError)?; + + self.db.execute( + "INSERT INTO accounts (id, code_root, storage_root, vault_root, nonce, committed) VALUES (?, ?, ?, ?, ?, ?)", + params![ + id as i64, + code_root, + storage_root, + vault_root, + account.nonce().inner() as i64, + account.is_on_chain(), + ], + ) + .map(|_| ()) + .map_err(StoreError::QueryError) + } + + pub fn insert_account_code(&self, account_code: &AccountCode) -> Result<(), StoreError> { + let code_root = serde_json::to_string(&account_code.root()) + .map_err(StoreError::InputSerializationError)?; + let code = serde_json::to_string(account_code.procedures()) + .map_err(StoreError::InputSerializationError)?; + let module = account_code.module().to_bytes(AstSerdeOptions { + serialize_imports: true, + }); + + self.db + .execute( + "INSERT INTO account_code (root, procedures, module) VALUES (?, ?, ?)", + params![code_root, code, module,], + ) + .map(|_| ()) + .map_err(StoreError::QueryError) + } + + pub fn insert_account_storage( + &self, + account_storage: &AccountStorage, + ) -> Result<(), StoreError> { + let storage_root = serde_json::to_string(&account_storage.root()) + .map_err(StoreError::InputSerializationError)?; + + let storage_slots: BTreeMap = account_storage.slots().leaves().collect(); + let storage_slots = + serde_json::to_string(&storage_slots).map_err(StoreError::InputSerializationError)?; + + self.db + .execute( + "INSERT INTO account_storage (root, slots) VALUES (?, ?)", + params![storage_root, storage_slots], + ) + .map(|_| ()) + .map_err(StoreError::QueryError) + } + + pub fn insert_account_vault(&self, account_vault: &AccountVault) -> Result<(), StoreError> { + let vault_root = serde_json::to_string(&account_vault.commitment()) + .map_err(StoreError::InputSerializationError)?; + + let assets: Vec = account_vault.assets().collect(); + let assets = serde_json::to_string(&assets).map_err(StoreError::InputSerializationError)?; + + self.db + .execute( + "INSERT INTO account_vaults (root, assets) VALUES (?, ?)", + params![vault_root, assets], + ) + .map(|_| ()) + .map_err(StoreError::QueryError) + } + + // NOTES + // -------------------------------------------------------------------------------------------- + + /// Retrieves the input notes from the database + pub fn get_input_notes(&self) -> Result, StoreError> { + const QUERY: &str = "SELECT script, inputs, vault, serial_num, sender_id, tag, num_assets, inclusion_proof FROM input_notes"; + + self.db + .prepare(QUERY) + .map_err(StoreError::QueryError)? + .query_map([], parse_input_note_columns) + .expect("no binding parameters used in query") + .map(|result| { + result + .map_err(StoreError::ColumnParsingError) + .and_then(parse_input_note) + }) + .collect::, _>>() + } + + /// Retrieves the input note with the specified hash from the database + pub fn get_input_note_by_hash(&self, hash: Digest) -> Result { + let query_hash = + serde_json::to_string(&hash).map_err(StoreError::InputSerializationError)?; + const QUERY: &str = "SELECT script, inputs, vault, serial_num, sender_id, tag, num_assets, inclusion_proof FROM input_notes WHERE hash = ?"; + + self.db + .prepare(QUERY) + .map_err(StoreError::QueryError)? + .query_map(params![query_hash.to_string()], parse_input_note_columns) + .map_err(StoreError::QueryError)? + .map(|result| { + result + .map_err(StoreError::ColumnParsingError) + .and_then(parse_input_note) + }) + .next() + .ok_or(StoreError::InputNoteNotFound(hash))? + } + + /// Inserts the provided input note into the database + pub fn insert_input_note(&self, recorded_note: &RecordedNote) -> Result<(), StoreError> { + let ( + hash, + nullifier, + script, + vault, + inputs, + serial_num, + sender_id, + tag, + num_assets, + inclusion_proof, + recipients, + status, + commit_height, + ) = serialize_input_note(recorded_note)?; + + const QUERY: &str = "\ + INSERT INTO input_notes + (hash, nullifier, script, vault, inputs, serial_num, sender_id, tag, num_assets, inclusion_proof, recipients, status, commit_height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + self.db + .execute( + QUERY, + params![ + hash, + nullifier, + script, + vault, + inputs, + serial_num, + sender_id, + tag, + num_assets, + inclusion_proof, + recipients, + status, + commit_height + ], + ) + .map_err(StoreError::QueryError) + .map(|_| ()) + } +} + +// STORE CONFIG +// ================================================================================================ + +pub struct StoreConfig { + path: String, +} + +impl From<&ClientConfig> for StoreConfig { + fn from(config: &ClientConfig) -> Self { + Self { + path: config.store_path.clone(), + } + } +} + +// HELPERS +// ================================================================================================ +/// Parse input note columns from the provided row into native types. +fn parse_input_note_columns( + row: &rusqlite::Row<'_>, +) -> Result { + let script: String = row.get(0)?; + let inputs: String = row.get(1)?; + let vault: String = row.get(2)?; + let serial_num: String = row.get(3)?; + let sender_id = row.get::(4)? as u64; + let tag = row.get::(5)? as u64; + let num_assets = row.get::(6)? as u64; + let inclusion_proof: String = row.get(7)?; + Ok(( + script, + inputs, + vault, + serial_num, + sender_id, + tag, + num_assets, + inclusion_proof, + )) +} + +/// Parse a note from the provided parts. +fn parse_input_note( + serialized_input_note_parts: SerializedInputNoteParts, +) -> Result { + let (script, inputs, vault, serial_num, sender_id, tag, num_assets, inclusion_proof) = + serialized_input_note_parts; + let script = serde_json::from_str(&script).map_err(StoreError::DataDeserializationError)?; + let inputs = serde_json::from_str(&inputs).map_err(StoreError::DataDeserializationError)?; + let vault = serde_json::from_str(&vault).map_err(StoreError::DataDeserializationError)?; + let serial_num = + serde_json::from_str(&serial_num).map_err(StoreError::DataDeserializationError)?; + let note_metadata = NoteMetadata::new( + AccountId::new_unchecked(Felt::new(sender_id)), + Felt::new(tag), + Felt::new(num_assets), + ); + let note = Note::from_parts(script, inputs, vault, serial_num, note_metadata); + + let inclusion_proof = + serde_json::from_str(&inclusion_proof).map_err(StoreError::DataDeserializationError)?; + Ok(RecordedNote::new(note, inclusion_proof)) +} + +/// Serialize the provided input note into database compatible types. +fn serialize_input_note( + recorded_note: &RecordedNote, +) -> Result { + let hash = serde_json::to_string(&recorded_note.note().hash()) + .map_err(StoreError::InputSerializationError)?; + let nullifier = serde_json::to_string(&recorded_note.note().nullifier()) + .map_err(StoreError::InputSerializationError)?; + let script = serde_json::to_string(&recorded_note.note().script()) + .map_err(StoreError::InputSerializationError)?; + let vault = serde_json::to_string(&recorded_note.note().vault()) + .map_err(StoreError::InputSerializationError)?; + let inputs = serde_json::to_string(&recorded_note.note().inputs()) + .map_err(StoreError::InputSerializationError)?; + let serial_num = serde_json::to_string(&recorded_note.note().serial_num()) + .map_err(StoreError::InputSerializationError)?; + let sender_id = u64::from(recorded_note.note().metadata().sender()) as i64; + let tag = u64::from(recorded_note.note().metadata().tag()) as i64; + let num_assets = u64::from(recorded_note.note().metadata().num_assets()) as i64; + let inclusion_proof = serde_json::to_string(&recorded_note.proof()) + .map_err(StoreError::InputSerializationError)?; + let recipients = serde_json::to_string(&recorded_note.note().metadata().tag()) + .map_err(StoreError::InputSerializationError)?; + let status = String::from("committed"); + let commit_height = recorded_note.origin().block_num.inner() as i64; + Ok(( + hash, + nullifier, + script, + vault, + inputs, + serial_num, + sender_id, + tag, + num_assets, + inclusion_proof, + recipients, + status, + commit_height, + )) +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +pub mod tests { + use std::env::temp_dir; + use uuid::Uuid; + + 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 + } +} diff --git a/src/store/store.sql b/src/store/store.sql new file mode 100644 index 000000000..8e92129e0 --- /dev/null +++ b/src/store/store.sql @@ -0,0 +1,61 @@ +-- Create account_code table +CREATE TABLE account_code ( + root BLOB NOT NULL, -- root of the Merkle tree for all exported procedures in account module. + procedures BLOB NOT NULL, -- serialized procedure digests for the account code. + module BLOB NOT NULL, -- serialized ModuleAst for the account code. + PRIMARY KEY (root) +); + +-- Create account_storage table +CREATE TABLE account_storage ( + root BLOB NOT NULL, -- root of the account storage Merkle tree. + slots BLOB NOT NULL, -- serialized key-value pair of non-empty account slots. + PRIMARY KEY (root) +); + +-- Create account_vaults table +CREATE TABLE account_vaults ( + root BLOB NOT NULL, -- root of the Merkle tree for the account vault. + assets BLOB NOT NULL, -- serialized account vault assets. + PRIMARY KEY (root) +); + +-- Create account_keys table +CREATE TABLE account_keys ( + account_id UNSIGNED BIG INT NOT NULL, -- ID of the account + key_pair BLOB NOT NULL, -- key pair + PRIMARY KEY (account_id), + FOREIGN KEY (account_id) REFERENCES accounts(id) +); + +-- Create accounts table +CREATE TABLE accounts ( + id UNSIGNED BIG INT NOT NULL, -- account ID. + code_root BLOB NOT NULL, -- root of the account_code Merkle tree. + storage_root BLOB NOT NULL, -- root of the account_storage Merkle tree. + vault_root BLOB NOT NULL, -- root of the account_vault Merkle tree. + nonce BIGINT NOT NULL, -- account nonce. + committed BOOLEAN NOT NULL, -- true if recorded, false if not. + PRIMARY KEY (id), + FOREIGN KEY (code_root) REFERENCES account_code(root), + FOREIGN KEY (storage_root) REFERENCES account_storage(root), + FOREIGN KEY (vault_root) REFERENCES account_vaults(root) +); + +-- Create input notes table +CREATE TABLE input_notes ( + hash BLOB NOT NULL, -- the note hash + nullifier BLOB NOT NULL, -- the nullifier of the note + script BLOB NOT NULL, -- the serialized NoteScript, including script hash and ProgramAst + vault BLOB NOT NULL, -- the serialized NoteVault, including vault hash and list of assets + inputs BLOB NOT NULL, -- the serialized NoteInputs, including inputs hash and list of inputs + serial_num BLOB NOT NULL, -- the note serial number + sender_id UNSIGNED BIG INT NOT NULL, -- the account ID of the sender + tag UNSIGNED BIG INT NOT NULL, -- the note tag + num_assets UNSIGNED BIG INT NOT NULL, -- the number of assets in the note + inclusion_proof BLOB NOT NULL, -- the inclusion proof of the note against a block number + recipients BLOB NOT NULL, -- a list of account IDs of accounts which can consume this note + status TEXT CHECK( status IN ('pending', 'committed')), -- the status of the note - either pending or committed + commit_height UNSIGNED BIG INT NOT NULL, -- the block number at which the note was included into the chain + PRIMARY KEY (hash) +);