diff --git a/.github/workflows/coins-tests.yml b/.github/workflows/coins-tests.yml index f94e9fd54..65d2b6dce 100644 --- a/.github/workflows/coins-tests.yml +++ b/.github/workflows/coins-tests.yml @@ -32,5 +32,20 @@ jobs: -p bitcoin-serai \ -p alloy-simple-request-transport \ -p ethereum-serai \ + -p serai-ethereum-relayer \ + -p monero-io \ -p monero-generators \ - -p monero-serai + -p monero-primitives \ + -p monero-mlsag \ + -p monero-clsag \ + -p monero-borromean \ + -p monero-bulletproofs \ + -p monero-serai \ + -p monero-rpc \ + -p monero-simple-request-rpc \ + -p monero-address \ + -p monero-wallet \ + -p monero-seed \ + -p polyseed \ + -p monero-wallet-util \ + -p monero-serai-verify-chain diff --git a/.github/workflows/coordinator-tests.yml b/.github/workflows/coordinator-tests.yml index 138fd1064..1ef277045 100644 --- a/.github/workflows/coordinator-tests.yml +++ b/.github/workflows/coordinator-tests.yml @@ -37,4 +37,4 @@ jobs: uses: ./.github/actions/build-dependencies - name: Run coordinator Docker tests - run: cd tests/coordinator && GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features + run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features -p serai-coordinator-tests diff --git a/.github/workflows/full-stack-tests.yml b/.github/workflows/full-stack-tests.yml index baacf7746..7bcce8666 100644 --- a/.github/workflows/full-stack-tests.yml +++ b/.github/workflows/full-stack-tests.yml @@ -19,4 +19,4 @@ jobs: uses: ./.github/actions/build-dependencies - name: Run Full Stack Docker tests - run: cd tests/full-stack && GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features + run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features -p serai-full-stack-tests diff --git a/.github/workflows/message-queue-tests.yml b/.github/workflows/message-queue-tests.yml index 7894549c2..aa6f93288 100644 --- a/.github/workflows/message-queue-tests.yml +++ b/.github/workflows/message-queue-tests.yml @@ -33,4 +33,4 @@ jobs: uses: ./.github/actions/build-dependencies - name: Run message-queue Docker tests - run: cd tests/message-queue && GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features + run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features -p serai-message-queue-tests diff --git a/.github/workflows/monero-tests.yaml b/.github/workflows/monero-tests.yaml index 3f2127cef..1901b256f 100644 --- a/.github/workflows/monero-tests.yaml +++ b/.github/workflows/monero-tests.yaml @@ -26,7 +26,22 @@ jobs: uses: ./.github/actions/test-dependencies - name: Run Unit Tests Without Features - run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-serai --lib + run: | + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-io --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-generators --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-primitives --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-mlsag --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-clsag --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-borromean --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-bulletproofs --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-serai --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-rpc --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-simple-request-rpc --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-address --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-wallet --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-seed --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package polyseed --lib + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-wallet-util --lib # Doesn't run unit tests with features as the tests workflow will @@ -46,11 +61,17 @@ jobs: monero-version: ${{ matrix.version }} - name: Run Integration Tests Without Features - # Runs with the binaries feature so the binaries build - # https://github.com/rust-lang/cargo/issues/8396 - run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-serai --features binaries --test '*' + run: | + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-serai --test '*' + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-simple-request-rpc --test '*' + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-wallet --test '*' + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-wallet-util --test '*' - name: Run Integration Tests # Don't run if the the tests workflow also will if: ${{ matrix.version != 'v0.18.2.0' }} - run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-serai --all-features --test '*' + run: | + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-serai --all-features --test '*' + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-simple-request-rpc --test '*' + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-wallet --all-features --test '*' + GITHUB_CI=true RUST_BACKTRACE=1 cargo test --package monero-wallet-util --all-features --test '*' diff --git a/.github/workflows/no-std.yml b/.github/workflows/no-std.yml index 79ea57670..4e446daf5 100644 --- a/.github/workflows/no-std.yml +++ b/.github/workflows/no-std.yml @@ -32,4 +32,4 @@ jobs: run: sudo apt update && sudo apt install -y gcc-riscv64-unknown-elf gcc-multilib && rustup target add riscv32imac-unknown-none-elf - name: Verify no-std builds - run: cd tests/no-std && CFLAGS=-I/usr/include cargo build --target riscv32imac-unknown-none-elf + run: CFLAGS=-I/usr/include cargo build --target riscv32imac-unknown-none-elf -p serai-no-std-tests diff --git a/.github/workflows/processor-tests.yml b/.github/workflows/processor-tests.yml index 0b5ecbbe0..c5b023216 100644 --- a/.github/workflows/processor-tests.yml +++ b/.github/workflows/processor-tests.yml @@ -37,4 +37,4 @@ jobs: uses: ./.github/actions/build-dependencies - name: Run processor Docker tests - run: cd tests/processor && GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features + run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features -p serai-processor-tests diff --git a/.github/workflows/reproducible-runtime.yml b/.github/workflows/reproducible-runtime.yml index d34e5ca5d..2c418bd5c 100644 --- a/.github/workflows/reproducible-runtime.yml +++ b/.github/workflows/reproducible-runtime.yml @@ -33,4 +33,4 @@ jobs: uses: ./.github/actions/build-dependencies - name: Run Reproducible Runtime tests - run: cd tests/reproducible-runtime && GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features + run: GITHUB_CI=true RUST_BACKTRACE=1 cargo test --all-features -p serai-reproducible-runtime-tests diff --git a/Cargo.lock b/Cargo.lock index e1d14265e..1d379bd54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,16 +880,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base58-monero" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978e81a45367d2409ecd33369a45dda2e9a3ca516153ec194de1fbda4b9fb79d" -dependencies = [ - "thiserror", - "tiny-keccak", -] - [[package]] name = "base58ck" version = "0.1.0" @@ -4751,6 +4741,71 @@ dependencies = [ "zeroize", ] +[[package]] +name = "monero-address" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "hex", + "hex-literal", + "monero-io", + "monero-primitives", + "rand_core", + "serde", + "serde_json", + "std-shims", + "thiserror", + "zeroize", +] + +[[package]] +name = "monero-borromean" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "monero-generators", + "monero-io", + "monero-primitives", + "std-shims", + "zeroize", +] + +[[package]] +name = "monero-bulletproofs" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "hex-literal", + "monero-generators", + "monero-io", + "monero-primitives", + "rand_core", + "std-shims", + "subtle", + "thiserror", + "zeroize", +] + +[[package]] +name = "monero-clsag" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "dalek-ff-group", + "flexible-transcript", + "group", + "modular-frost", + "monero-generators", + "monero-io", + "monero-primitives", + "rand_chacha", + "rand_core", + "std-shims", + "subtle", + "thiserror", + "zeroize", +] + [[package]] name = "monero-generators" version = "0.4.0" @@ -4759,44 +4814,162 @@ dependencies = [ "dalek-ff-group", "group", "hex", + "monero-io", "sha3", "std-shims", "subtle", ] +[[package]] +name = "monero-io" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "std-shims", +] + +[[package]] +name = "monero-mlsag" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "monero-generators", + "monero-io", + "monero-primitives", + "std-shims", + "thiserror", + "zeroize", +] + +[[package]] +name = "monero-primitives" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "hex", + "monero-generators", + "monero-io", + "sha3", + "std-shims", + "zeroize", +] + +[[package]] +name = "monero-rpc" +version = "0.1.0" +dependencies = [ + "async-trait", + "curve25519-dalek", + "hex", + "monero-address", + "monero-serai", + "serde", + "serde_json", + "std-shims", + "thiserror", + "zeroize", +] + +[[package]] +name = "monero-seed" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "hex", + "monero-primitives", + "rand_core", + "std-shims", + "thiserror", + "zeroize", +] + [[package]] name = "monero-serai" version = "0.1.4-alpha" dependencies = [ - "async-lock", + "curve25519-dalek", + "hex-literal", + "monero-borromean", + "monero-bulletproofs", + "monero-clsag", + "monero-generators", + "monero-io", + "monero-mlsag", + "monero-primitives", + "std-shims", + "zeroize", +] + +[[package]] +name = "monero-serai-verify-chain" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "hex", + "monero-rpc", + "monero-serai", + "monero-simple-request-rpc", + "rand_core", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "monero-simple-request-rpc" +version = "0.1.0" +dependencies = [ + "async-trait", + "digest_auth", + "hex", + "monero-address", + "monero-rpc", + "simple-request", + "tokio", +] + +[[package]] +name = "monero-wallet" +version = "0.1.0" +dependencies = [ "async-trait", - "base58-monero", "curve25519-dalek", "dalek-ff-group", - "digest_auth", "flexible-transcript", "group", "hex", - "hex-literal", "modular-frost", - "monero-generators", - "multiexp", - "pbkdf2 0.12.2", + "monero-address", + "monero-rpc", + "monero-serai", + "monero-simple-request-rpc", "rand", "rand_chacha", "rand_core", "rand_distr", "serde", "serde_json", - "sha3", - "simple-request", "std-shims", - "subtle", "thiserror", "tokio", "zeroize", ] +[[package]] +name = "monero-wallet-util" +version = "0.1.0" +dependencies = [ + "curve25519-dalek", + "hex", + "monero-seed", + "monero-wallet", + "polyseed", + "rand_core", + "std-shims", + "thiserror", + "zeroize", +] + [[package]] name = "multiaddr" version = "0.18.1" @@ -5680,6 +5853,20 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyseed" +version = "0.1.0" +dependencies = [ + "hex", + "pbkdf2 0.12.2", + "rand_core", + "sha3", + "std-shims", + "subtle", + "thiserror", + "zeroize", +] + [[package]] name = "polyval" version = "0.6.2" @@ -7780,7 +7967,7 @@ dependencies = [ "frost-schnorrkel", "hex", "modular-frost", - "monero-serai", + "monero-wallet", "multiaddr", "parity-scale-codec", "rand_core", @@ -7938,7 +8125,8 @@ dependencies = [ "curve25519-dalek", "dockertest", "hex", - "monero-serai", + "monero-simple-request-rpc", + "monero-wallet", "parity-scale-codec", "rand_core", "serai-client", @@ -8036,8 +8224,7 @@ dependencies = [ "dleq", "flexible-transcript", "minimal-ed448", - "monero-generators", - "monero-serai", + "monero-wallet-util", "multiexp", "schnorr-signatures", ] @@ -8138,7 +8325,8 @@ dependencies = [ "k256", "log", "modular-frost", - "monero-serai", + "monero-simple-request-rpc", + "monero-wallet", "parity-scale-codec", "rand_chacha", "rand_core", @@ -8149,7 +8337,6 @@ dependencies = [ "serai-env", "serai-message-queue", "serai-processor-messages", - "serde", "serde_json", "sp-application-crypto", "thiserror", @@ -8184,7 +8371,8 @@ dependencies = [ "ethereum-serai", "hex", "k256", - "monero-serai", + "monero-simple-request-rpc", + "monero-wallet", "parity-scale-codec", "rand_core", "serai-client", diff --git a/Cargo.toml b/Cargo.toml index ce0062f01..7f63f6268 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,8 +43,22 @@ members = [ "coins/ethereum", "coins/ethereum/relayer", + "coins/monero/io", "coins/monero/generators", + "coins/monero/primitives", + "coins/monero/ringct/mlsag", + "coins/monero/ringct/clsag", + "coins/monero/ringct/borromean", + "coins/monero/ringct/bulletproofs", "coins/monero", + "coins/monero/rpc", + "coins/monero/rpc/simple-request", + "coins/monero/wallet/address", + "coins/monero/wallet", + "coins/monero/wallet/seed", + "coins/monero/wallet/polyseed", + "coins/monero/wallet/util", + "coins/monero/verify-chain", "message-queue", @@ -60,12 +74,14 @@ members = [ "substrate/coins/primitives", "substrate/coins/pallet", - "substrate/in-instructions/primitives", - "substrate/in-instructions/pallet", + "substrate/dex/pallet", "substrate/validator-sets/primitives", "substrate/validator-sets/pallet", + "substrate/in-instructions/primitives", + "substrate/in-instructions/pallet", + "substrate/signals/primitives", "substrate/signals/pallet", diff --git a/coins/monero/Cargo.toml b/coins/monero/Cargo.toml index fb655b844..ae38dabf8 100644 --- a/coins/monero/Cargo.toml +++ b/coins/monero/Cargo.toml @@ -18,94 +18,35 @@ workspace = true [dependencies] std-shims = { path = "../../common/std-shims", version = "^0.1.1", default-features = false } -async-trait = { version = "0.1", default-features = false } -thiserror = { version = "1", default-features = false, optional = true } - zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } -subtle = { version = "^2.4", default-features = false } - -rand_core = { version = "0.6", default-features = false } -# Used to send transactions -rand = { version = "0.8", default-features = false } -rand_chacha = { version = "0.3", default-features = false } -# Used to select decoys -rand_distr = { version = "0.4", default-features = false } - -sha3 = { version = "0.10", default-features = false } -pbkdf2 = { version = "0.12", features = ["simple"], default-features = false } -curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize", "precomputed-tables"] } - -# Used for the hash to curve, along with the more complicated proofs -group = { version = "0.13", default-features = false } -dalek-ff-group = { path = "../../crypto/dalek-ff-group", version = "0.4", default-features = false } -multiexp = { path = "../../crypto/multiexp", version = "0.4", default-features = false, features = ["batch"] } - -# Needed for multisig -transcript = { package = "flexible-transcript", path = "../../crypto/transcript", version = "0.3", default-features = false, features = ["recommended"], optional = true } -frost = { package = "modular-frost", path = "../../crypto/frost", version = "0.8", default-features = false, features = ["ed25519"], optional = true } +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } +monero-io = { path = "io", version = "0.1", default-features = false } monero-generators = { path = "generators", version = "0.4", default-features = false } - -async-lock = { version = "3", default-features = false, optional = true } +monero-primitives = { path = "primitives", version = "0.1", default-features = false } +monero-mlsag = { path = "ringct/mlsag", version = "0.1", default-features = false } +monero-clsag = { path = "ringct/clsag", version = "0.1", default-features = false } +monero-borromean = { path = "ringct/borromean", version = "0.1", default-features = false } +monero-bulletproofs = { path = "ringct/bulletproofs", version = "0.1", default-features = false } hex-literal = "0.4" -hex = { version = "0.4", default-features = false, features = ["alloc"] } -serde = { version = "1", default-features = false, features = ["derive", "alloc"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } - -base58-monero = { version = "2", default-features = false, features = ["check"] } - -# Used for the provided HTTP RPC -digest_auth = { version = "0.3", default-features = false, optional = true } -simple-request = { path = "../../common/request", version = "0.1", default-features = false, features = ["tls"], optional = true } -tokio = { version = "1", default-features = false, optional = true } - -[build-dependencies] -dalek-ff-group = { path = "../../crypto/dalek-ff-group", version = "0.4", default-features = false } -monero-generators = { path = "generators", version = "0.4", default-features = false } - -[dev-dependencies] -tokio = { version = "1", features = ["sync", "macros"] } - -frost = { package = "modular-frost", path = "../../crypto/frost", features = ["tests"] } [features] std = [ "std-shims/std", - "thiserror", - "zeroize/std", - "subtle/std", - - "rand_core/std", - "rand/std", - "rand_chacha/std", - "rand_distr/std", - - "sha3/std", - "pbkdf2/std", - - "multiexp/std", - - "transcript/std", + "monero-io/std", "monero-generators/std", - - "async-lock?/std", - - "hex/std", - "serde/std", - "serde_json/std", - - "base58-monero/std", + "monero-primitives/std", + "monero-mlsag/std", + "monero-clsag/std", + "monero-borromean/std", + "monero-bulletproofs/std", ] -cache-distribution = ["async-lock"] -http-rpc = ["digest_auth", "simple-request", "tokio"] -multisig = ["transcript", "frost", "std"] -binaries = ["tokio/rt-multi-thread", "tokio/macros", "http-rpc"] -experimental = [] - -default = ["std", "http-rpc"] +compile-time-generators = ["curve25519-dalek/precomputed-tables", "monero-bulletproofs/compile-time-generators"] +multisig = ["monero-clsag/multisig", "std"] +default = ["std", "compile-time-generators"] diff --git a/coins/monero/LICENSE b/coins/monero/LICENSE index 6779f0ec8..91d893c11 100644 --- a/coins/monero/LICENSE +++ b/coins/monero/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022-2023 Luke Parker +Copyright (c) 2022-2024 Luke Parker Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/coins/monero/README.md b/coins/monero/README.md index 517fb4bbb..50146e2eb 100644 --- a/coins/monero/README.md +++ b/coins/monero/README.md @@ -1,49 +1,28 @@ # monero-serai -A modern Monero transaction library intended for usage in wallets. It prides -itself on accuracy, correctness, and removing common pit falls developers may -face. +A modern Monero transaction library. It provides a modern, Rust-friendly view of +the Monero protocol. -monero-serai also offers the following features: +This library is usable under no-std when the `std` feature (on by default) is +disabled. -- Featured Addresses -- A FROST-based multisig orders of magnitude more performant than Monero's +### Wallet Functionality -### Purpose and support +monero-serai originally included wallet functionality. That has been moved to +monero-wallet. + +### Purpose and Support monero-serai was written for Serai, a decentralized exchange aiming to support Monero. Despite this, monero-serai is intended to be a widely usable library, accurate to Monero. monero-serai guarantees the functionality needed for Serai, -yet will not deprive functionality from other users. - -Various legacy transaction formats are not currently implemented, yet we are -willing to add support for them. There aren't active development efforts around -them however. - -### Caveats - -This library DOES attempt to do the following: - -- Create on-chain transactions identical to how wallet2 would (unless told not - to) -- Not be detectable as monero-serai when scanning outputs -- Not reveal spent outputs to the connected RPC node - -This library DOES NOT attempt to do the following: - -- Have identical RPC behavior when creating transactions -- Be a wallet - -This means that monero-serai shouldn't be fingerprintable on-chain. It also -shouldn't be fingerprintable if a targeted attack occurs to detect if the -receiving wallet is monero-serai or wallet2. It also should be generally safe -for usage with remote nodes. +yet does not include any functionality specific to Serai. -It won't hide from remote nodes it's monero-serai however, potentially -allowing a remote node to profile you. The implications of this are left to the -user to consider. +### Cargo Features -It also won't act as a wallet, just as a transaction library. wallet2 has -several *non-transaction-level* policies, such as always attempting to use two -inputs to create transactions. These are considered out of scope to -monero-serai. +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). +- `compile-time-generators` (on by default): Derives the generators at + compile-time so they don't need to be derived at runtime. This is recommended + if program size doesn't need to be kept minimal. +- `multisig`: Enables the `multisig` feature for all dependencies. diff --git a/coins/monero/build.rs b/coins/monero/build.rs deleted file mode 100644 index b10e956a5..000000000 --- a/coins/monero/build.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::{ - io::Write, - env, - path::Path, - fs::{File, remove_file}, -}; - -use dalek_ff_group::EdwardsPoint; - -use monero_generators::bulletproofs_generators; - -fn serialize(generators_string: &mut String, points: &[EdwardsPoint]) { - for generator in points { - generators_string.extend( - format!( - " - dalek_ff_group::EdwardsPoint( - curve25519_dalek::edwards::CompressedEdwardsY({:?}).decompress().unwrap() - ), - ", - generator.compress().to_bytes() - ) - .chars(), - ); - } -} - -fn generators(prefix: &'static str, path: &str) { - let generators = bulletproofs_generators(prefix.as_bytes()); - #[allow(non_snake_case)] - let mut G_str = String::new(); - serialize(&mut G_str, &generators.G); - #[allow(non_snake_case)] - let mut H_str = String::new(); - serialize(&mut H_str, &generators.H); - - let path = Path::new(&env::var("OUT_DIR").unwrap()).join(path); - let _ = remove_file(&path); - File::create(&path) - .unwrap() - .write_all( - format!( - " - pub(crate) static GENERATORS_CELL: OnceLock = OnceLock::new(); - pub fn GENERATORS() -> &'static Generators {{ - GENERATORS_CELL.get_or_init(|| Generators {{ - G: vec![ - {G_str} - ], - H: vec![ - {H_str} - ], - }}) - }} - ", - ) - .as_bytes(), - ) - .unwrap(); -} - -fn main() { - println!("cargo:rerun-if-changed=build.rs"); - - generators("bulletproof", "generators.rs"); - generators("bulletproof_plus", "generators_plus.rs"); -} diff --git a/coins/monero/generators/Cargo.toml b/coins/monero/generators/Cargo.toml index 22df2ae33..0796eb137 100644 --- a/coins/monero/generators/Cargo.toml +++ b/coins/monero/generators/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "monero-generators" version = "0.4.0" -description = "Monero's hash_to_point and generators" +description = "Monero's hash to point function and generators" license = "MIT" repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/generators" authors = ["Luke Parker "] @@ -20,15 +20,27 @@ std-shims = { path = "../../../common/std-shims", version = "^0.1.1", default-fe subtle = { version = "^2.4", default-features = false } sha3 = { version = "0.10", default-features = false } - -curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize", "precomputed-tables"] } +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } group = { version = "0.13", default-features = false } dalek-ff-group = { path = "../../../crypto/dalek-ff-group", version = "0.4", default-features = false } +monero-io = { path = "../io", version = "0.1", default-features = false } + [dev-dependencies] hex = "0.4" [features] -std = ["std-shims/std", "subtle/std", "sha3/std", "dalek-ff-group/std"] +std = [ + "std-shims/std", + + "subtle/std", + + "sha3/std", + + "group/alloc", + "dalek-ff-group/std", + + "monero-io/std" +] default = ["std"] diff --git a/coins/monero/generators/LICENSE b/coins/monero/generators/LICENSE index 6779f0ec8..91d893c11 100644 --- a/coins/monero/generators/LICENSE +++ b/coins/monero/generators/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022-2023 Luke Parker +Copyright (c) 2022-2024 Luke Parker Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/coins/monero/generators/README.md b/coins/monero/generators/README.md index bab293c98..e9ac925b8 100644 --- a/coins/monero/generators/README.md +++ b/coins/monero/generators/README.md @@ -1,7 +1,13 @@ # Monero Generators Generators used by Monero in both its Pedersen commitments and Bulletproofs(+). -An implementation of Monero's `ge_fromfe_frombytes_vartime`, simply called -`hash_to_point` here, is included, as needed to generate generators. +An implementation of Monero's `hash_to_ec` is included, as needed to generate +the generators. -This library is usable under no-std when the `std` feature is disabled. +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/generators/src/hash_to_point.rs b/coins/monero/generators/src/hash_to_point.rs index 6a76207d0..23b3a0866 100644 --- a/coins/monero/generators/src/hash_to_point.rs +++ b/coins/monero/generators/src/hash_to_point.rs @@ -1,27 +1,20 @@ use subtle::ConditionallySelectable; -use curve25519_dalek::edwards::{EdwardsPoint, CompressedEdwardsY}; +use curve25519_dalek::edwards::EdwardsPoint; use group::ff::{Field, PrimeField}; use dalek_ff_group::FieldElement; -use crate::hash; +use monero_io::decompress_point; -/// Decompress canonically encoded ed25519 point -/// It does not check if the point is in the prime order subgroup -pub fn decompress_point(bytes: [u8; 32]) -> Option { - CompressedEdwardsY(bytes) - .decompress() - // Ban points which are either unreduced or -0 - .filter(|point| point.compress().to_bytes() == bytes) -} +use crate::keccak256; -/// Monero's hash to point function, as named `hash_to_ec`. +/// Monero's `hash_to_ec` function. pub fn hash_to_point(bytes: [u8; 32]) -> EdwardsPoint { #[allow(non_snake_case)] let A = FieldElement::from(486662u64); - let v = FieldElement::from_square(hash(&bytes)).double(); + let v = FieldElement::from_square(keccak256(&bytes)).double(); let w = v + FieldElement::ONE; let x = w.square() + (-A.square() * v); diff --git a/coins/monero/generators/src/lib.rs b/coins/monero/generators/src/lib.rs index c52350c20..1fc7c0993 100644 --- a/coins/monero/generators/src/lib.rs +++ b/coins/monero/generators/src/lib.rs @@ -1,45 +1,46 @@ -//! Generators used by Monero in both its Pedersen commitments and Bulletproofs(+). -//! -//! An implementation of Monero's `ge_fromfe_frombytes_vartime`, simply called -//! `hash_to_point` here, is included, as needed to generate generators. - +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] #![cfg_attr(not(feature = "std"), no_std)] use std_shims::{sync::OnceLock, vec::Vec}; use sha3::{Digest, Keccak256}; -use curve25519_dalek::edwards::{EdwardsPoint as DalekPoint}; - -use group::{Group, GroupEncoding}; -use dalek_ff_group::EdwardsPoint; +use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, edwards::EdwardsPoint}; -mod varint; -use varint::write_varint; +use monero_io::{write_varint, decompress_point}; mod hash_to_point; -pub use hash_to_point::{hash_to_point, decompress_point}; +pub use hash_to_point::hash_to_point; #[cfg(test)] mod tests; -fn hash(data: &[u8]) -> [u8; 32] { +fn keccak256(data: &[u8]) -> [u8; 32] { Keccak256::digest(data).into() } -static H_CELL: OnceLock = OnceLock::new(); -/// Monero's alternate generator `H`, used for amounts in Pedersen commitments. +static H_CELL: OnceLock = OnceLock::new(); +/// Monero's `H` generator. +/// +/// Contrary to convention (`G` for values, `H` for randomness), `H` is used by Monero for amounts +/// within Pedersen commitments. #[allow(non_snake_case)] -pub fn H() -> DalekPoint { +pub fn H() -> EdwardsPoint { *H_CELL.get_or_init(|| { - decompress_point(hash(&EdwardsPoint::generator().to_bytes())).unwrap().mul_by_cofactor() + decompress_point(keccak256(&ED25519_BASEPOINT_POINT.compress().to_bytes())) + .unwrap() + .mul_by_cofactor() }) } -static H_POW_2_CELL: OnceLock<[DalekPoint; 64]> = OnceLock::new(); -/// Monero's alternate generator `H`, multiplied by 2**i for i in 1 ..= 64. +static H_POW_2_CELL: OnceLock<[EdwardsPoint; 64]> = OnceLock::new(); +/// Monero's `H` generator, multiplied by 2**i for i in 1 ..= 64. +/// +/// This table is useful when working with amounts, which are u64s. #[allow(non_snake_case)] -pub fn H_pow_2() -> &'static [DalekPoint; 64] { +pub fn H_pow_2() -> &'static [EdwardsPoint; 64] { H_POW_2_CELL.get_or_init(|| { let mut res = [H(); 64]; for i in 1 .. 64 { @@ -49,31 +50,45 @@ pub fn H_pow_2() -> &'static [DalekPoint; 64] { }) } -const MAX_M: usize = 16; -const N: usize = 64; -const MAX_MN: usize = MAX_M * N; +/// The maximum amount of commitments provable for within a single range proof. +pub const MAX_COMMITMENTS: usize = 16; +/// The amount of bits a value within a commitment may use. +pub const COMMITMENT_BITS: usize = 64; +/// The logarithm (over 2) of the amount of bits a value within a commitment may use. +pub const LOG_COMMITMENT_BITS: usize = 6; // 2 ** 6 == N /// Container struct for Bulletproofs(+) generators. #[allow(non_snake_case)] pub struct Generators { + /// The G (bold) vector of generators. pub G: Vec, + /// The H (bold) vector of generators. pub H: Vec, } /// Generate generators as needed for Bulletproofs(+), as Monero does. +/// +/// Consumers should not call this function ad-hoc, yet call it within a build script or use a +/// once-initialized static. pub fn bulletproofs_generators(dst: &'static [u8]) -> Generators { + // The maximum amount of bits used within a single range proof. + const MAX_MN: usize = MAX_COMMITMENTS * COMMITMENT_BITS; + + let mut preimage = H().compress().to_bytes().to_vec(); + preimage.extend(dst); + let mut res = Generators { G: Vec::with_capacity(MAX_MN), H: Vec::with_capacity(MAX_MN) }; for i in 0 .. MAX_MN { + // We generate a pair of generators per iteration let i = 2 * i; - let mut even = H().compress().to_bytes().to_vec(); - even.extend(dst); - let mut odd = even.clone(); + let mut even = preimage.clone(); + write_varint(&i, &mut even).unwrap(); + res.H.push(hash_to_point(keccak256(&even))); - write_varint(&i.try_into().unwrap(), &mut even).unwrap(); - write_varint(&(i + 1).try_into().unwrap(), &mut odd).unwrap(); - res.H.push(EdwardsPoint(hash_to_point(hash(&even)))); - res.G.push(EdwardsPoint(hash_to_point(hash(&odd)))); + let mut odd = preimage.clone(); + write_varint(&(i + 1), &mut odd).unwrap(); + res.G.push(hash_to_point(keccak256(&odd))); } res } diff --git a/coins/monero/generators/src/tests/hash_to_point.rs b/coins/monero/generators/src/tests/hash_to_point.rs deleted file mode 100644 index c4535e085..000000000 --- a/coins/monero/generators/src/tests/hash_to_point.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::{decompress_point, hash_to_point}; - -#[test] -fn crypto_tests() { - // tests.txt file copied from monero repo - // https://github.com/monero-project/monero/ - // blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/tests/crypto/tests.txt - let reader = include_str!("./tests.txt"); - - for line in reader.lines() { - let mut words = line.split_whitespace(); - let command = words.next().unwrap(); - - match command { - "check_key" => { - let key = words.next().unwrap(); - let expected = match words.next().unwrap() { - "true" => true, - "false" => false, - _ => unreachable!("invalid result"), - }; - - let actual = decompress_point(hex::decode(key).unwrap().try_into().unwrap()); - - assert_eq!(actual.is_some(), expected); - } - "hash_to_ec" => { - let bytes = words.next().unwrap(); - let expected = words.next().unwrap(); - - let actual = hash_to_point(hex::decode(bytes).unwrap().try_into().unwrap()); - - assert_eq!(hex::encode(actual.compress().to_bytes()), expected); - } - _ => unreachable!("unknown command"), - } - } -} diff --git a/coins/monero/generators/src/tests/mod.rs b/coins/monero/generators/src/tests/mod.rs index ec208e9c2..3ab9449f9 100644 --- a/coins/monero/generators/src/tests/mod.rs +++ b/coins/monero/generators/src/tests/mod.rs @@ -1 +1,36 @@ -mod hash_to_point; +use crate::{decompress_point, hash_to_point}; + +#[test] +fn test_vectors() { + // tests.txt file copied from monero repo + // https://github.com/monero-project/monero/ + // blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/tests/crypto/tests.txt + let reader = include_str!("./tests.txt"); + + for line in reader.lines() { + let mut words = line.split_whitespace(); + let command = words.next().unwrap(); + + match command { + "check_key" => { + let key = words.next().unwrap(); + let expected = match words.next().unwrap() { + "true" => true, + "false" => false, + _ => unreachable!("invalid result"), + }; + + let actual = decompress_point(hex::decode(key).unwrap().try_into().unwrap()); + assert_eq!(actual.is_some(), expected); + } + "hash_to_ec" => { + let bytes = words.next().unwrap(); + let expected = words.next().unwrap(); + + let actual = hash_to_point(hex::decode(bytes).unwrap().try_into().unwrap()); + assert_eq!(hex::encode(actual.compress().to_bytes()), expected); + } + _ => unreachable!("unknown command"), + } + } +} diff --git a/coins/monero/generators/src/varint.rs b/coins/monero/generators/src/varint.rs deleted file mode 100644 index 2e82816ef..000000000 --- a/coins/monero/generators/src/varint.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std_shims::io::{self, Write}; - -const VARINT_CONTINUATION_MASK: u8 = 0b1000_0000; -pub(crate) fn write_varint(varint: &u64, w: &mut W) -> io::Result<()> { - let mut varint = *varint; - while { - let mut b = u8::try_from(varint & u64::from(!VARINT_CONTINUATION_MASK)).unwrap(); - varint >>= 7; - if varint != 0 { - b |= VARINT_CONTINUATION_MASK; - } - w.write_all(&[b])?; - varint != 0 - } {} - Ok(()) -} diff --git a/coins/monero/io/Cargo.toml b/coins/monero/io/Cargo.toml new file mode 100644 index 000000000..f43f6448d --- /dev/null +++ b/coins/monero/io/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "monero-io" +version = "0.1.0" +description = "Serialization functions, as within the Monero protocol" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/io" +authors = ["Luke Parker "] +edition = "2021" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../common/std-shims", version = "^0.1.1", default-features = false } + +curve25519-dalek = { version = "4", default-features = false, features = ["alloc"] } + +[features] +std = ["std-shims/std"] +default = ["std"] diff --git a/coins/monero/io/LICENSE b/coins/monero/io/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/io/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/io/README.md b/coins/monero/io/README.md new file mode 100644 index 000000000..536b72dd2 --- /dev/null +++ b/coins/monero/io/README.md @@ -0,0 +1,11 @@ +# Monero IO + +Serialization functions, as within the Monero protocol. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/io/src/lib.rs b/coins/monero/io/src/lib.rs new file mode 100644 index 000000000..68acbe805 --- /dev/null +++ b/coins/monero/io/src/lib.rs @@ -0,0 +1,219 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +use core::fmt::Debug; +use std_shims::{ + vec, + vec::Vec, + io::{self, Read, Write}, +}; + +use curve25519_dalek::{ + scalar::Scalar, + edwards::{EdwardsPoint, CompressedEdwardsY}, +}; + +const VARINT_CONTINUATION_MASK: u8 = 0b1000_0000; + +mod sealed { + /// A trait for a number readable/writable as a VarInt. + /// + /// This is sealed to prevent unintended implementations. + pub trait VarInt: TryInto + TryFrom + Copy { + const BITS: usize; + } + + impl VarInt for u8 { + const BITS: usize = 8; + } + impl VarInt for u32 { + const BITS: usize = 32; + } + impl VarInt for u64 { + const BITS: usize = 64; + } + impl VarInt for usize { + const BITS: usize = core::mem::size_of::() * 8; + } +} + +/// The amount of bytes this number will take when serialized as a VarInt. +/// +/// This function will panic if the VarInt exceeds u64::MAX. +pub fn varint_len(varint: V) -> usize { + let varint_u64: u64 = varint.try_into().map_err(|_| "varint exceeded u64").unwrap(); + ((usize::try_from(u64::BITS - varint_u64.leading_zeros()).unwrap().saturating_sub(1)) / 7) + 1 +} + +/// Write a byte. +/// +/// This is used as a building block within generic functions. +pub fn write_byte(byte: &u8, w: &mut W) -> io::Result<()> { + w.write_all(&[*byte]) +} + +/// Write a number, VarInt-encoded. +/// +/// This will panic if the VarInt exceeds u64::MAX. +pub fn write_varint(varint: &U, w: &mut W) -> io::Result<()> { + let mut varint: u64 = (*varint).try_into().map_err(|_| "varint exceeded u64").unwrap(); + while { + let mut b = u8::try_from(varint & u64::from(!VARINT_CONTINUATION_MASK)).unwrap(); + varint >>= 7; + if varint != 0 { + b |= VARINT_CONTINUATION_MASK; + } + write_byte(&b, w)?; + varint != 0 + } {} + Ok(()) +} + +/// Write a scalar. +pub fn write_scalar(scalar: &Scalar, w: &mut W) -> io::Result<()> { + w.write_all(&scalar.to_bytes()) +} + +/// Write a point. +pub fn write_point(point: &EdwardsPoint, w: &mut W) -> io::Result<()> { + w.write_all(&point.compress().to_bytes()) +} + +/// Write a list of elements, without length-prefixing. +pub fn write_raw_vec io::Result<()>>( + f: F, + values: &[T], + w: &mut W, +) -> io::Result<()> { + for value in values { + f(value, w)?; + } + Ok(()) +} + +/// Write a list of elements, with length-prefixing. +pub fn write_vec io::Result<()>>( + f: F, + values: &[T], + w: &mut W, +) -> io::Result<()> { + write_varint(&values.len(), w)?; + write_raw_vec(f, values, w) +} + +/// Read a constant amount of bytes. +pub fn read_bytes(r: &mut R) -> io::Result<[u8; N]> { + let mut res = [0; N]; + r.read_exact(&mut res)?; + Ok(res) +} + +/// Read a single byte. +pub fn read_byte(r: &mut R) -> io::Result { + Ok(read_bytes::<_, 1>(r)?[0]) +} + +/// Read a u16, little-endian encoded. +pub fn read_u16(r: &mut R) -> io::Result { + read_bytes(r).map(u16::from_le_bytes) +} + +/// Read a u32, little-endian encoded. +pub fn read_u32(r: &mut R) -> io::Result { + read_bytes(r).map(u32::from_le_bytes) +} + +/// Read a u64, little-endian encoded. +pub fn read_u64(r: &mut R) -> io::Result { + read_bytes(r).map(u64::from_le_bytes) +} + +/// Read a canonically-encoded VarInt. +pub fn read_varint(r: &mut R) -> io::Result { + let mut bits = 0; + let mut res = 0; + while { + let b = read_byte(r)?; + if (bits != 0) && (b == 0) { + Err(io::Error::other("non-canonical varint"))?; + } + if ((bits + 7) >= U::BITS) && (b >= (1 << (U::BITS - bits))) { + Err(io::Error::other("varint overflow"))?; + } + + res += u64::from(b & (!VARINT_CONTINUATION_MASK)) << bits; + bits += 7; + b & VARINT_CONTINUATION_MASK == VARINT_CONTINUATION_MASK + } {} + res.try_into().map_err(|_| io::Error::other("VarInt does not fit into integer type")) +} + +/// Read a canonically-encoded scalar. +/// +/// Some scalars within the Monero protocol are not enforced to be canonically encoded. For such +/// scalars, they should be represented as `[u8; 32]` and later converted to scalars as relevant. +pub fn read_scalar(r: &mut R) -> io::Result { + Option::from(Scalar::from_canonical_bytes(read_bytes(r)?)) + .ok_or_else(|| io::Error::other("unreduced scalar")) +} + +/// Decompress a canonically-encoded Ed25519 point. +/// +/// Ed25519 is of order `8 * l`. This function ensures each of those `8 * l` points have a singular +/// encoding by checking points aren't encoded with an unreduced field element, and aren't negative +/// when the negative is equivalent (0 == -0). +/// +/// Since this decodes an Ed25519 point, it does not check the point is in the prime-order +/// subgroup. Torsioned points do have a canonical encoding, and only aren't canonical when +/// considered in relation to the prime-order subgroup. +pub fn decompress_point(bytes: [u8; 32]) -> Option { + CompressedEdwardsY(bytes) + .decompress() + // Ban points which are either unreduced or -0 + .filter(|point| point.compress().to_bytes() == bytes) +} + +/// Read a canonically-encoded Ed25519 point. +/// +/// This internally calls `decompress_point` and has the same definition of canonicity. This +/// function does not check the resulting point is within the prime-order subgroup. +pub fn read_point(r: &mut R) -> io::Result { + let bytes = read_bytes(r)?; + decompress_point(bytes).ok_or_else(|| io::Error::other("invalid point")) +} + +/// Read a canonically-encoded Ed25519 point, within the prime-order subgroup. +pub fn read_torsion_free_point(r: &mut R) -> io::Result { + read_point(r) + .ok() + .filter(EdwardsPoint::is_torsion_free) + .ok_or_else(|| io::Error::other("invalid point")) +} + +/// Read a variable-length list of elements, without length-prefixing. +pub fn read_raw_vec io::Result>( + f: F, + len: usize, + r: &mut R, +) -> io::Result> { + let mut res = vec![]; + for _ in 0 .. len { + res.push(f(r)?); + } + Ok(res) +} + +/// Read a constant-length list of elements. +pub fn read_array io::Result, const N: usize>( + f: F, + r: &mut R, +) -> io::Result<[T; N]> { + read_raw_vec(f, N, r).map(|vec| vec.try_into().unwrap()) +} + +/// Read a length-prefixed variable-length list of elements. +pub fn read_vec io::Result>(f: F, r: &mut R) -> io::Result> { + read_raw_vec(f, read_varint(r)?, r) +} diff --git a/coins/monero/primitives/Cargo.toml b/coins/monero/primitives/Cargo.toml new file mode 100644 index 000000000..9477d6419 --- /dev/null +++ b/coins/monero/primitives/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "monero-primitives" +version = "0.1.0" +description = "Primitives for the Monero protocol" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/primitives" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../common/std-shims", version = "^0.1.1", default-features = false } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } + +# Cryptographic dependencies +sha3 = { version = "0.10", default-features = false } +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +# Other Monero dependencies +monero-io = { path = "../io", version = "0.1", default-features = false } +monero-generators = { path = "../generators", version = "0.4", default-features = false } + +[dev-dependencies] +hex = { version = "0.4", default-features = false, features = ["alloc"] } + +[features] +std = [ + "std-shims/std", + + "zeroize/std", + + "sha3/std", + + "monero-generators/std", +] +default = ["std"] diff --git a/coins/monero/primitives/LICENSE b/coins/monero/primitives/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/primitives/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/primitives/README.md b/coins/monero/primitives/README.md new file mode 100644 index 000000000..c866193bb --- /dev/null +++ b/coins/monero/primitives/README.md @@ -0,0 +1,11 @@ +# Monero Primitives + +Primitive structures and functions for the Monero protocol. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/primitives/src/lib.rs b/coins/monero/primitives/src/lib.rs new file mode 100644 index 000000000..e84ab46f9 --- /dev/null +++ b/coins/monero/primitives/src/lib.rs @@ -0,0 +1,238 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +use std_shims::{io, vec::Vec}; +#[cfg(feature = "std")] +use std_shims::sync::OnceLock; + +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use sha3::{Digest, Keccak256}; +use curve25519_dalek::{ + constants::ED25519_BASEPOINT_POINT, + traits::VartimePrecomputedMultiscalarMul, + scalar::Scalar, + edwards::{EdwardsPoint, VartimeEdwardsPrecomputation}, +}; + +use monero_io::*; +use monero_generators::H; + +mod unreduced_scalar; +pub use unreduced_scalar::UnreducedScalar; + +#[cfg(test)] +mod tests; + +// On std, we cache some variables in statics. +#[cfg(feature = "std")] +static INV_EIGHT_CELL: OnceLock = OnceLock::new(); +/// The inverse of 8 over l. +#[cfg(feature = "std")] +#[allow(non_snake_case)] +pub fn INV_EIGHT() -> Scalar { + *INV_EIGHT_CELL.get_or_init(|| Scalar::from(8u8).invert()) +} +// In no-std environments, we prefer the reduced memory use and calculate it ad-hoc. +/// The inverse of 8 over l. +#[cfg(not(feature = "std"))] +#[allow(non_snake_case)] +pub fn INV_EIGHT() -> Scalar { + Scalar::from(8u8).invert() +} + +#[cfg(feature = "std")] +static G_PRECOMP_CELL: OnceLock = OnceLock::new(); +/// A cached (if std) pre-computation of the Ed25519 generator, G. +#[cfg(feature = "std")] +#[allow(non_snake_case)] +pub fn G_PRECOMP() -> &'static VartimeEdwardsPrecomputation { + G_PRECOMP_CELL.get_or_init(|| VartimeEdwardsPrecomputation::new([ED25519_BASEPOINT_POINT])) +} +/// A cached (if std) pre-computation of the Ed25519 generator, G. +#[cfg(not(feature = "std"))] +#[allow(non_snake_case)] +pub fn G_PRECOMP() -> VartimeEdwardsPrecomputation { + VartimeEdwardsPrecomputation::new([ED25519_BASEPOINT_POINT]) +} + +/// The Keccak-256 hash function. +pub fn keccak256(data: impl AsRef<[u8]>) -> [u8; 32] { + Keccak256::digest(data.as_ref()).into() +} + +/// Hash the provided data to a scalar via keccak256(data) % l. +/// +/// This function panics if it finds the Keccak-256 preimage for [0; 32]. +pub fn keccak256_to_scalar(data: impl AsRef<[u8]>) -> Scalar { + let scalar = Scalar::from_bytes_mod_order(keccak256(data.as_ref())); + // Monero will explicitly error in this case + // This library acknowledges its practical impossibility of it occurring, and doesn't bother to + // code in logic to handle it. That said, if it ever occurs, something must happen in order to + // not generate/verify a proof we believe to be valid when it isn't + assert!(scalar != Scalar::ZERO, "ZERO HASH: {:?}", data.as_ref()); + scalar +} + +/// Transparent structure representing a Pedersen commitment's contents. +#[allow(non_snake_case)] +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct Commitment { + /// The mask for this commitment. + pub mask: Scalar, + /// The amount committed to by this commitment. + pub amount: u64, +} + +impl core::fmt::Debug for Commitment { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { + fmt.debug_struct("Commitment").field("amount", &self.amount).finish_non_exhaustive() + } +} + +impl Commitment { + /// A commitment to zero, defined with a mask of 1 (as to not be the identity). + pub fn zero() -> Commitment { + Commitment { mask: Scalar::ONE, amount: 0 } + } + + /// Create a new Commitment. + pub fn new(mask: Scalar, amount: u64) -> Commitment { + Commitment { mask, amount } + } + + /// Calculate the Pedersen commitment, as a point, from this transparent structure. + pub fn calculate(&self) -> EdwardsPoint { + EdwardsPoint::vartime_double_scalar_mul_basepoint(&Scalar::from(self.amount), &H(), &self.mask) + } + + /// Write the Commitment. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn write(&self, w: &mut W) -> io::Result<()> { + w.write_all(&self.mask.to_bytes())?; + w.write_all(&self.amount.to_le_bytes()) + } + + /// Serialize the Commitment to a `Vec`. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn serialize(&self) -> Vec { + let mut res = Vec::with_capacity(32 + 8); + self.write(&mut res).unwrap(); + res + } + + /// Read a Commitment. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn read(r: &mut R) -> io::Result { + Ok(Commitment::new(read_scalar(r)?, read_u64(r)?)) + } +} + +/// Decoy data, as used for producing Monero's ring signatures. +#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] +pub struct Decoys { + offsets: Vec, + signer_index: u8, + ring: Vec<[EdwardsPoint; 2]>, +} + +#[allow(clippy::len_without_is_empty)] +impl Decoys { + /// Create a new instance of decoy data. + /// + /// `offsets` are the positions of each ring member within the Monero blockchain, offset from the + /// prior member's position (with the initial ring member offset from 0). + pub fn new(offsets: Vec, signer_index: u8, ring: Vec<[EdwardsPoint; 2]>) -> Option { + if (offsets.len() != ring.len()) || (usize::from(signer_index) >= ring.len()) { + None?; + } + Some(Decoys { offsets, signer_index, ring }) + } + + /// The length of the ring. + pub fn len(&self) -> usize { + self.offsets.len() + } + + /// The positions of the ring members within the Monero blockchain, as their offsets. + /// + /// The list is formatted as the position of the first ring member, then the offset from each + /// ring member to its prior. + pub fn offsets(&self) -> &[u64] { + &self.offsets + } + + /// The positions of the ring members within the Monero blockchain. + pub fn positions(&self) -> Vec { + let mut res = Vec::with_capacity(self.len()); + res.push(self.offsets[0]); + for m in 1 .. self.len() { + res.push(res[m - 1] + self.offsets[m]); + } + res + } + + /// The index of the signer within the ring. + pub fn signer_index(&self) -> u8 { + self.signer_index + } + + /// The ring. + pub fn ring(&self) -> &[[EdwardsPoint; 2]] { + &self.ring + } + + /// The [key, commitment] pair of the signer. + pub fn signer_ring_members(&self) -> [EdwardsPoint; 2] { + self.ring[usize::from(self.signer_index)] + } + + /// Write the Decoys. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn write(&self, w: &mut impl io::Write) -> io::Result<()> { + write_vec(write_varint, &self.offsets, w)?; + w.write_all(&[self.signer_index])?; + write_vec( + |pair, w| { + write_point(&pair[0], w)?; + write_point(&pair[1], w) + }, + &self.ring, + w, + ) + } + + /// Serialize the Decoys to a `Vec`. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn serialize(&self) -> Vec { + let mut res = + Vec::with_capacity((1 + (2 * self.offsets.len())) + 1 + 1 + (self.ring.len() * 64)); + self.write(&mut res).unwrap(); + res + } + + /// Read a set of Decoys. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn read(r: &mut impl io::Read) -> io::Result { + Decoys::new( + read_vec(read_varint, r)?, + read_byte(r)?, + read_vec(|r| Ok([read_point(r)?, read_point(r)?]), r)?, + ) + .ok_or_else(|| io::Error::other("invalid Decoys")) + } +} diff --git a/coins/monero/src/tests/unreduced_scalar.rs b/coins/monero/primitives/src/tests.rs similarity index 97% rename from coins/monero/src/tests/unreduced_scalar.rs rename to coins/monero/primitives/src/tests.rs index 1816991d9..a14d1cd51 100644 --- a/coins/monero/src/tests/unreduced_scalar.rs +++ b/coins/monero/primitives/src/tests.rs @@ -1,6 +1,6 @@ use curve25519_dalek::scalar::Scalar; -use crate::unreduced_scalar::*; +use crate::UnreducedScalar; #[test] fn recover_scalars() { diff --git a/coins/monero/src/unreduced_scalar.rs b/coins/monero/primitives/src/unreduced_scalar.rs similarity index 76% rename from coins/monero/src/unreduced_scalar.rs rename to coins/monero/primitives/src/unreduced_scalar.rs index d0baa681e..8b75a4f7f 100644 --- a/coins/monero/src/unreduced_scalar.rs +++ b/coins/monero/primitives/src/unreduced_scalar.rs @@ -1,18 +1,19 @@ use core::cmp::Ordering; - use std_shims::{ sync::OnceLock, io::{self, *}, }; +use zeroize::Zeroize; + use curve25519_dalek::scalar::Scalar; -use crate::serialize::*; +use monero_io::*; static PRECOMPUTED_SCALARS_CELL: OnceLock<[Scalar; 8]> = OnceLock::new(); -/// Precomputed scalars used to recover an incorrectly reduced scalar. +// Precomputed scalars used to recover an incorrectly reduced scalar. #[allow(non_snake_case)] -pub(crate) fn PRECOMPUTED_SCALARS() -> [Scalar; 8] { +fn PRECOMPUTED_SCALARS() -> [Scalar; 8] { *PRECOMPUTED_SCALARS_CELL.get_or_init(|| { let mut precomputed_scalars = [Scalar::ONE; 8]; for (i, scalar) in precomputed_scalars.iter_mut().enumerate().skip(1) { @@ -22,22 +23,27 @@ pub(crate) fn PRECOMPUTED_SCALARS() -> [Scalar; 8] { }) } -#[derive(Clone, PartialEq, Eq, Debug)] +/// An unreduced scalar. +/// +/// While most of modern Monero enforces scalars be reduced, certain legacy parts of the code did +/// not. These section can generally simply be read as a scalar/reduced into a scalar when the time +/// comes, yet a couple have non-standard reductions performed. +/// +/// This struct delays scalar conversions and offers the non-standard reduction. +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct UnreducedScalar(pub [u8; 32]); impl UnreducedScalar { + /// Write an UnreducedScalar. pub fn write(&self, w: &mut W) -> io::Result<()> { w.write_all(&self.0) } + /// Read an UnreducedScalar. pub fn read(r: &mut R) -> io::Result { Ok(UnreducedScalar(read_bytes(r)?)) } - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - fn as_bits(&self) -> [u8; 256] { let mut bits = [0; 256]; for (i, bit) in bits.iter_mut().enumerate() { @@ -47,12 +53,12 @@ impl UnreducedScalar { bits } - /// Computes the non-adjacent form of this scalar with width 5. - /// - /// This matches Monero's `slide` function and intentionally gives incorrect outputs under - /// certain conditions in order to match Monero. - /// - /// This function does not execute in constant time. + // Computes the non-adjacent form of this scalar with width 5. + // + // This matches Monero's `slide` function and intentionally gives incorrect outputs under + // certain conditions in order to match Monero. + // + // This function does not execute in constant time. fn non_adjacent_form(&self) -> [i8; 256] { let bits = self.as_bits(); let mut naf = [0i8; 256]; @@ -108,11 +114,11 @@ impl UnreducedScalar { /// Recover the scalar that an array of bytes was incorrectly interpreted as by Monero's `slide` /// function. /// - /// In Borromean range proofs Monero was not checking that the scalars used were - /// reduced. This lead to the scalar stored being interpreted as a different scalar, - /// this function recovers that scalar. + /// In Borromean range proofs, Monero was not checking that the scalars used were + /// reduced. This lead to the scalar stored being interpreted as a different scalar. + /// This function recovers that scalar. /// - /// See: https://github.com/monero-project/monero/issues/8438 + /// See for more info. pub fn recover_monero_slide_scalar(&self) -> Scalar { if self.0[31] & 128 == 0 { // Computing the w-NAF of a number can only give an output with 1 more bit than diff --git a/coins/monero/ringct/borromean/Cargo.toml b/coins/monero/ringct/borromean/Cargo.toml new file mode 100644 index 000000000..b239a8c3b --- /dev/null +++ b/coins/monero/ringct/borromean/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "monero-borromean" +version = "0.1.0" +description = "Borromean ring signatures arranged into a range proof, as done by the Monero protocol" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/ringct/borromean" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } + +# Cryptographic dependencies +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +# Other Monero dependencies +monero-io = { path = "../../io", version = "0.1", default-features = false } +monero-generators = { path = "../../generators", version = "0.4", default-features = false } +monero-primitives = { path = "../../primitives", version = "0.1", default-features = false } + +[features] +std = [ + "std-shims/std", + + "zeroize/std", + + "monero-io/std", + "monero-generators/std", + "monero-primitives/std", +] +default = ["std"] diff --git a/coins/monero/ringct/borromean/LICENSE b/coins/monero/ringct/borromean/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/ringct/borromean/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/ringct/borromean/README.md b/coins/monero/ringct/borromean/README.md new file mode 100644 index 000000000..3b8368048 --- /dev/null +++ b/coins/monero/ringct/borromean/README.md @@ -0,0 +1,12 @@ +# Monero Borromean + +Borromean ring signatures arranged into a range proof, as done by the Monero +protocol. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/src/ringct/borromean.rs b/coins/monero/ringct/borromean/src/lib.rs similarity index 61% rename from coins/monero/src/ringct/borromean.rs rename to coins/monero/ringct/borromean/src/lib.rs index 215b3394c..5e1051422 100644 --- a/coins/monero/src/ringct/borromean.rs +++ b/coins/monero/ringct/borromean/src/lib.rs @@ -1,26 +1,35 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(non_snake_case)] + use core::fmt::Debug; use std_shims::io::{self, Read, Write}; +use zeroize::Zeroize; + use curve25519_dalek::{traits::Identity, Scalar, EdwardsPoint}; +use monero_io::*; use monero_generators::H_pow_2; +use monero_primitives::{keccak256_to_scalar, UnreducedScalar}; -use crate::{hash_to_scalar, unreduced_scalar::UnreducedScalar, serialize::*}; - -/// 64 Borromean ring signatures. -/// -/// s0 and s1 are stored as `UnreducedScalar`s due to Monero not requiring they were reduced. -/// `UnreducedScalar` preserves their original byte encoding and implements a custom reduction -/// algorithm which was in use. -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct BorromeanSignatures { - pub s0: [UnreducedScalar; 64], - pub s1: [UnreducedScalar; 64], - pub ee: Scalar, +// 64 Borromean ring signatures, as needed for a 64-bit range proof. +// +// s0 and s1 are stored as `UnreducedScalar`s due to Monero not requiring they were reduced. +// `UnreducedScalar` preserves their original byte encoding and implements a custom reduction +// algorithm which was in use. +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] +struct BorromeanSignatures { + s0: [UnreducedScalar; 64], + s1: [UnreducedScalar; 64], + ee: Scalar, } impl BorromeanSignatures { - pub fn read(r: &mut R) -> io::Result { + // Read a set of BorromeanSignatures. + fn read(r: &mut R) -> io::Result { Ok(BorromeanSignatures { s0: read_array(UnreducedScalar::read, r)?, s1: read_array(UnreducedScalar::read, r)?, @@ -28,7 +37,8 @@ impl BorromeanSignatures { }) } - pub fn write(&self, w: &mut W) -> io::Result<()> { + // Write the set of BorromeanSignatures. + fn write(&self, w: &mut W) -> io::Result<()> { for s0 in &self.s0 { s0.write(w)?; } @@ -50,36 +60,41 @@ impl BorromeanSignatures { ); #[allow(non_snake_case)] let LV = EdwardsPoint::vartime_double_scalar_mul_basepoint( - &hash_to_scalar(LL.compress().as_bytes()), + &keccak256_to_scalar(LL.compress().as_bytes()), &keys_b[i], &self.s1[i].recover_monero_slide_scalar(), ); transcript[(i * 32) .. ((i + 1) * 32)].copy_from_slice(LV.compress().as_bytes()); } - hash_to_scalar(&transcript) == self.ee + keccak256_to_scalar(transcript) == self.ee } } /// A range proof premised on Borromean ring signatures. -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct BorromeanRange { - pub sigs: BorromeanSignatures, - pub bit_commitments: [EdwardsPoint; 64], + sigs: BorromeanSignatures, + bit_commitments: [EdwardsPoint; 64], } impl BorromeanRange { + /// Read a BorromeanRange proof. pub fn read(r: &mut R) -> io::Result { Ok(BorromeanRange { sigs: BorromeanSignatures::read(r)?, bit_commitments: read_array(read_point, r)?, }) } + + /// Write the BorromeanRange proof. pub fn write(&self, w: &mut W) -> io::Result<()> { self.sigs.write(w)?; write_raw_vec(write_point, &self.bit_commitments, w) } + /// Verify the commitment contains a 64-bit value. + #[must_use] pub fn verify(&self, commitment: &EdwardsPoint) -> bool { if &self.bit_commitments.iter().sum::() != commitment { return false; diff --git a/coins/monero/ringct/bulletproofs/Cargo.toml b/coins/monero/ringct/bulletproofs/Cargo.toml new file mode 100644 index 000000000..121fd8834 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "monero-bulletproofs" +version = "0.1.0" +description = "Bulletproofs(+) range proofs, as defined by the Monero protocol" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/ringct/bulletproofs" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +thiserror = { version = "1", default-features = false, optional = true } + +rand_core = { version = "0.6", default-features = false } +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } +subtle = { version = "^2.4", default-features = false } + +# Cryptographic dependencies +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +# Other Monero dependencies +monero-io = { path = "../../io", version = "0.1", default-features = false } +monero-generators = { path = "../../generators", version = "0.4", default-features = false } +monero-primitives = { path = "../../primitives", version = "0.1", default-features = false } + +[build-dependencies] +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } +monero-generators = { path = "../../generators", version = "0.4", default-features = false } + +[dev-dependencies] +hex-literal = "0.4" + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "rand_core/std", + "zeroize/std", + "subtle/std", + + "monero-io/std", + "monero-generators/std", + "monero-primitives/std", +] +compile-time-generators = ["curve25519-dalek/precomputed-tables"] +default = ["std", "compile-time-generators"] diff --git a/coins/monero/ringct/bulletproofs/LICENSE b/coins/monero/ringct/bulletproofs/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/ringct/bulletproofs/README.md b/coins/monero/ringct/bulletproofs/README.md new file mode 100644 index 000000000..8f407fefd --- /dev/null +++ b/coins/monero/ringct/bulletproofs/README.md @@ -0,0 +1,14 @@ +# Monero Bulletproofs(+) + +Bulletproofs(+) range proofs, as defined by the Monero protocol. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). +- `compile-time-generators` (on by default): Derives the generators at + compile-time so they don't need to be derived at runtime. This is recommended + if program size doesn't need to be kept minimal. diff --git a/coins/monero/ringct/bulletproofs/build.rs b/coins/monero/ringct/bulletproofs/build.rs new file mode 100644 index 000000000..6ef1bb549 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/build.rs @@ -0,0 +1,88 @@ +use std::{ + io::Write, + env, + path::Path, + fs::{File, remove_file}, +}; + +#[cfg(feature = "compile-time-generators")] +fn generators(prefix: &'static str, path: &str) { + use curve25519_dalek::EdwardsPoint; + + use monero_generators::bulletproofs_generators; + + fn serialize(generators_string: &mut String, points: &[EdwardsPoint]) { + for generator in points { + generators_string.extend( + format!( + " + curve25519_dalek::edwards::CompressedEdwardsY({:?}).decompress().unwrap(), + ", + generator.compress().to_bytes() + ) + .chars(), + ); + } + } + + let generators = bulletproofs_generators(prefix.as_bytes()); + #[allow(non_snake_case)] + let mut G_str = String::new(); + serialize(&mut G_str, &generators.G); + #[allow(non_snake_case)] + let mut H_str = String::new(); + serialize(&mut H_str, &generators.H); + + let path = Path::new(&env::var("OUT_DIR").unwrap()).join(path); + let _ = remove_file(&path); + File::create(&path) + .unwrap() + .write_all( + format!( + " + static GENERATORS_CELL: OnceLock = OnceLock::new(); + pub(crate) fn GENERATORS() -> &'static Generators {{ + GENERATORS_CELL.get_or_init(|| Generators {{ + G: std_shims::vec![ + {G_str} + ], + H: std_shims::vec![ + {H_str} + ], + }}) + }} + ", + ) + .as_bytes(), + ) + .unwrap(); +} + +#[cfg(not(feature = "compile-time-generators"))] +fn generators(prefix: &'static str, path: &str) { + let path = Path::new(&env::var("OUT_DIR").unwrap()).join(path); + let _ = remove_file(&path); + File::create(&path) + .unwrap() + .write_all( + format!( + r#" + static GENERATORS_CELL: OnceLock = OnceLock::new(); + pub(crate) fn GENERATORS() -> &'static Generators {{ + GENERATORS_CELL.get_or_init(|| {{ + monero_generators::bulletproofs_generators(b"{prefix}") + }}) + }} + "#, + ) + .as_bytes(), + ) + .unwrap(); +} + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + generators("bulletproof", "generators.rs"); + generators("bulletproof_plus", "generators_plus.rs"); +} diff --git a/coins/monero/ringct/bulletproofs/src/batch_verifier.rs b/coins/monero/ringct/bulletproofs/src/batch_verifier.rs new file mode 100644 index 000000000..1bf9fb8d1 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/src/batch_verifier.rs @@ -0,0 +1,101 @@ +use std_shims::vec::Vec; + +use curve25519_dalek::{ + constants::ED25519_BASEPOINT_POINT, + traits::{IsIdentity, VartimeMultiscalarMul}, + scalar::Scalar, + edwards::EdwardsPoint, +}; + +use monero_generators::{H, Generators}; + +use crate::{original, plus}; + +#[derive(Default)] +pub(crate) struct InternalBatchVerifier { + pub(crate) g: Scalar, + pub(crate) h: Scalar, + pub(crate) g_bold: Vec, + pub(crate) h_bold: Vec, + pub(crate) other: Vec<(Scalar, EdwardsPoint)>, +} + +impl InternalBatchVerifier { + #[must_use] + fn verify(self, G: EdwardsPoint, H: EdwardsPoint, generators: &Generators) -> bool { + let capacity = 2 + self.g_bold.len() + self.h_bold.len() + self.other.len(); + let mut scalars = Vec::with_capacity(capacity); + let mut points = Vec::with_capacity(capacity); + + scalars.push(self.g); + points.push(G); + + scalars.push(self.h); + points.push(H); + + for (i, g_bold) in self.g_bold.into_iter().enumerate() { + scalars.push(g_bold); + points.push(generators.G[i]); + } + + for (i, h_bold) in self.h_bold.into_iter().enumerate() { + scalars.push(h_bold); + points.push(generators.H[i]); + } + + for (scalar, point) in self.other { + scalars.push(scalar); + points.push(point); + } + + EdwardsPoint::vartime_multiscalar_mul(scalars, points).is_identity() + } +} + +#[derive(Default)] +pub(crate) struct BulletproofsBatchVerifier(pub(crate) InternalBatchVerifier); +impl BulletproofsBatchVerifier { + #[must_use] + pub(crate) fn verify(self) -> bool { + self.0.verify(ED25519_BASEPOINT_POINT, H(), original::GENERATORS()) + } +} + +#[derive(Default)] +pub(crate) struct BulletproofsPlusBatchVerifier(pub(crate) InternalBatchVerifier); +impl BulletproofsPlusBatchVerifier { + #[must_use] + pub(crate) fn verify(self) -> bool { + // Bulletproofs+ is written as per the paper, with G for the value and H for the mask + // Monero uses H for the value and G for the mask + self.0.verify(H(), ED25519_BASEPOINT_POINT, plus::GENERATORS()) + } +} + +/// A batch verifier for Bulletproofs(+). +/// +/// This uses a fixed layout such that all fixed points only incur a single point scaling, +/// regardless of the amounts of proofs verified. For all variable points (commitments), they're +/// accumulated with the fixed points into a single multiscalar multiplication. +#[derive(Default)] +pub struct BatchVerifier { + pub(crate) original: BulletproofsBatchVerifier, + pub(crate) plus: BulletproofsPlusBatchVerifier, +} +impl BatchVerifier { + /// Create a new batch verifier. + pub fn new() -> Self { + Self { + original: BulletproofsBatchVerifier(InternalBatchVerifier::default()), + plus: BulletproofsPlusBatchVerifier(InternalBatchVerifier::default()), + } + } + + /// Verify all of the proofs queued within this batch verifier. + /// + /// This uses a variable-time multiscalar multiplication internally. + #[must_use] + pub fn verify(self) -> bool { + self.original.verify() && self.plus.verify() + } +} diff --git a/coins/monero/ringct/bulletproofs/src/core.rs b/coins/monero/ringct/bulletproofs/src/core.rs new file mode 100644 index 000000000..091126702 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/src/core.rs @@ -0,0 +1,74 @@ +use std_shims::{vec, vec::Vec}; + +use curve25519_dalek::{ + traits::{MultiscalarMul, VartimeMultiscalarMul}, + scalar::Scalar, + edwards::EdwardsPoint, +}; + +pub(crate) use monero_generators::{MAX_COMMITMENTS, COMMITMENT_BITS, LOG_COMMITMENT_BITS}; + +pub(crate) fn multiexp(pairs: &[(Scalar, EdwardsPoint)]) -> EdwardsPoint { + let mut buf_scalars = Vec::with_capacity(pairs.len()); + let mut buf_points = Vec::with_capacity(pairs.len()); + for (scalar, point) in pairs { + buf_scalars.push(scalar); + buf_points.push(point); + } + EdwardsPoint::multiscalar_mul(buf_scalars, buf_points) +} + +pub(crate) fn multiexp_vartime(pairs: &[(Scalar, EdwardsPoint)]) -> EdwardsPoint { + let mut buf_scalars = Vec::with_capacity(pairs.len()); + let mut buf_points = Vec::with_capacity(pairs.len()); + for (scalar, point) in pairs { + buf_scalars.push(scalar); + buf_points.push(point); + } + EdwardsPoint::vartime_multiscalar_mul(buf_scalars, buf_points) +} + +/* +This has room for optimization worth investigating further. It currently takes +an iterative approach. It can be optimized further via divide and conquer. + +Assume there are 4 challenges. + +Iterative approach (current): + 1. Do the optimal multiplications across challenge column 0 and 1. + 2. Do the optimal multiplications across that result and column 2. + 3. Do the optimal multiplications across that result and column 3. + +Divide and conquer (worth investigating further): + 1. Do the optimal multiplications across challenge column 0 and 1. + 2. Do the optimal multiplications across challenge column 2 and 3. + 3. Multiply both results together. + +When there are 4 challenges (n=16), the iterative approach does 28 multiplications +versus divide and conquer's 24. +*/ +pub(crate) fn challenge_products(challenges: &[(Scalar, Scalar)]) -> Vec { + let mut products = vec![Scalar::ONE; 1 << challenges.len()]; + + if !challenges.is_empty() { + products[0] = challenges[0].1; + products[1] = challenges[0].0; + + for (j, challenge) in challenges.iter().enumerate().skip(1) { + let mut slots = (1 << (j + 1)) - 1; + while slots > 0 { + products[slots] = products[slots / 2] * challenge.0; + products[slots - 1] = products[slots / 2] * challenge.1; + + slots = slots.saturating_sub(2); + } + } + + // Sanity check since if the above failed to populate, it'd be critical + for product in &products { + debug_assert!(*product != Scalar::ZERO); + } + } + + products +} diff --git a/coins/monero/ringct/bulletproofs/src/lib.rs b/coins/monero/ringct/bulletproofs/src/lib.rs new file mode 100644 index 000000000..d6e47d75a --- /dev/null +++ b/coins/monero/ringct/bulletproofs/src/lib.rs @@ -0,0 +1,267 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(non_snake_case)] + +use std_shims::{ + vec, + vec::Vec, + io::{self, Read, Write}, +}; + +use rand_core::{RngCore, CryptoRng}; +use zeroize::Zeroizing; + +use curve25519_dalek::edwards::EdwardsPoint; + +use monero_io::*; +pub use monero_generators::MAX_COMMITMENTS; +use monero_primitives::Commitment; + +pub(crate) mod scalar_vector; +pub(crate) mod core; +use crate::core::LOG_COMMITMENT_BITS; + +pub(crate) mod batch_verifier; +use batch_verifier::{BulletproofsBatchVerifier, BulletproofsPlusBatchVerifier}; +pub use batch_verifier::BatchVerifier; + +pub(crate) mod original; +use crate::original::OriginalStruct; + +pub(crate) mod plus; +use crate::plus::*; + +#[cfg(test)] +mod tests; + +/// An error from proving/verifying Bulletproofs(+). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum BulletproofError { + /// Proving/verifying a Bulletproof(+) range proof with no commitments. + #[cfg_attr(feature = "std", error("no commitments to prove the range for"))] + NoCommitments, + /// Proving/verifying a Bulletproof(+) range proof with more commitments than supported. + #[cfg_attr(feature = "std", error("too many commitments to prove the range for"))] + TooManyCommitments, +} + +/// A Bulletproof(+). +/// +/// This encapsulates either a Bulletproof or a Bulletproof+. +#[allow(clippy::large_enum_variant)] +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum Bulletproof { + /// A Bulletproof. + Original(OriginalStruct), + /// A Bulletproof+. + Plus(AggregateRangeProof), +} + +impl Bulletproof { + fn bp_fields(plus: bool) -> usize { + if plus { + 6 + } else { + 9 + } + } + + /// Calculate the weight penalty for the Bulletproof(+). + /// + /// Bulletproofs(+) are logarithmically sized yet linearly timed. Evaluating by their size alone + /// accordingly doesn't properly represent the burden of the proof. Monero 'claws back' some of + /// the weight lost by using a proof smaller than it is fast to compensate for this. + // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ + // src/cryptonote_basic/cryptonote_format_utils.cpp#L106-L124 + pub fn calculate_bp_clawback(plus: bool, n_outputs: usize) -> (usize, usize) { + #[allow(non_snake_case)] + let mut LR_len = 0; + let mut n_padded_outputs = 1; + while n_padded_outputs < n_outputs { + LR_len += 1; + n_padded_outputs = 1 << LR_len; + } + LR_len += LOG_COMMITMENT_BITS; + + let mut bp_clawback = 0; + if n_padded_outputs > 2 { + let fields = Bulletproof::bp_fields(plus); + let base = ((fields + (2 * (LOG_COMMITMENT_BITS + 1))) * 32) / 2; + let size = (fields + (2 * LR_len)) * 32; + bp_clawback = ((base * n_padded_outputs) - size) * 4 / 5; + } + + (bp_clawback, LR_len) + } + + /// Prove the list of commitments are within [0 .. 2^64) with an aggregate Bulletproof. + pub fn prove( + rng: &mut R, + outputs: &[Commitment], + ) -> Result { + if outputs.is_empty() { + Err(BulletproofError::NoCommitments)?; + } + if outputs.len() > MAX_COMMITMENTS { + Err(BulletproofError::TooManyCommitments)?; + } + Ok(Bulletproof::Original(OriginalStruct::prove(rng, outputs))) + } + + /// Prove the list of commitments are within [0 .. 2^64) with an aggregate Bulletproof+. + pub fn prove_plus( + rng: &mut R, + outputs: Vec, + ) -> Result { + if outputs.is_empty() { + Err(BulletproofError::NoCommitments)?; + } + if outputs.len() > MAX_COMMITMENTS { + Err(BulletproofError::TooManyCommitments)?; + } + Ok(Bulletproof::Plus( + AggregateRangeStatement::new(outputs.iter().map(Commitment::calculate).collect()) + .unwrap() + .prove(rng, &Zeroizing::new(AggregateRangeWitness::new(outputs).unwrap())) + .unwrap(), + )) + } + + /// Verify the given Bulletproof(+). + #[must_use] + pub fn verify(&self, rng: &mut R, commitments: &[EdwardsPoint]) -> bool { + match self { + Bulletproof::Original(bp) => { + let mut verifier = BulletproofsBatchVerifier::default(); + if !bp.verify(rng, &mut verifier, commitments) { + return false; + } + verifier.verify() + } + Bulletproof::Plus(bp) => { + let mut verifier = BulletproofsPlusBatchVerifier::default(); + let Some(statement) = AggregateRangeStatement::new(commitments.to_vec()) else { + return false; + }; + if !statement.verify(rng, &mut verifier, bp.clone()) { + return false; + } + verifier.verify() + } + } + } + + /// Accumulate the verification for the given Bulletproof(+) into the specified BatchVerifier. + /// + /// Returns false if the Bulletproof(+) isn't sane, leaving the BatchVerifier in an undefined + /// state. + /// + /// Returns true if the Bulletproof(+) is sane, regardless of its validity. + /// + /// The BatchVerifier must have its verification function executed to actually verify this proof. + #[must_use] + pub fn batch_verify( + &self, + rng: &mut R, + verifier: &mut BatchVerifier, + commitments: &[EdwardsPoint], + ) -> bool { + match self { + Bulletproof::Original(bp) => bp.verify(rng, &mut verifier.original, commitments), + Bulletproof::Plus(bp) => { + let Some(statement) = AggregateRangeStatement::new(commitments.to_vec()) else { + return false; + }; + statement.verify(rng, &mut verifier.plus, bp.clone()) + } + } + } + + fn write_core io::Result<()>>( + &self, + w: &mut W, + specific_write_vec: F, + ) -> io::Result<()> { + match self { + Bulletproof::Original(bp) => { + write_point(&bp.A, w)?; + write_point(&bp.S, w)?; + write_point(&bp.T1, w)?; + write_point(&bp.T2, w)?; + write_scalar(&bp.tau_x, w)?; + write_scalar(&bp.mu, w)?; + specific_write_vec(&bp.L, w)?; + specific_write_vec(&bp.R, w)?; + write_scalar(&bp.a, w)?; + write_scalar(&bp.b, w)?; + write_scalar(&bp.t, w) + } + + Bulletproof::Plus(bp) => { + write_point(&bp.A, w)?; + write_point(&bp.wip.A, w)?; + write_point(&bp.wip.B, w)?; + write_scalar(&bp.wip.r_answer, w)?; + write_scalar(&bp.wip.s_answer, w)?; + write_scalar(&bp.wip.delta_answer, w)?; + specific_write_vec(&bp.wip.L, w)?; + specific_write_vec(&bp.wip.R, w) + } + } + } + + /// Write a Bulletproof(+) for the message signed by a transaction's signature. + /// + /// This has a distinct encoding from the standard encoding. + pub fn signature_write(&self, w: &mut W) -> io::Result<()> { + self.write_core(w, |points, w| write_raw_vec(write_point, points, w)) + } + + /// Write a Bulletproof(+). + pub fn write(&self, w: &mut W) -> io::Result<()> { + self.write_core(w, |points, w| write_vec(write_point, points, w)) + } + + /// Serialize a Bulletproof(+) to a `Vec`. + pub fn serialize(&self) -> Vec { + let mut serialized = vec![]; + self.write(&mut serialized).unwrap(); + serialized + } + + /// Read a Bulletproof. + pub fn read(r: &mut R) -> io::Result { + Ok(Bulletproof::Original(OriginalStruct { + A: read_point(r)?, + S: read_point(r)?, + T1: read_point(r)?, + T2: read_point(r)?, + tau_x: read_scalar(r)?, + mu: read_scalar(r)?, + L: read_vec(read_point, r)?, + R: read_vec(read_point, r)?, + a: read_scalar(r)?, + b: read_scalar(r)?, + t: read_scalar(r)?, + })) + } + + /// Read a Bulletproof+. + pub fn read_plus(r: &mut R) -> io::Result { + Ok(Bulletproof::Plus(AggregateRangeProof { + A: read_point(r)?, + wip: WipProof { + A: read_point(r)?, + B: read_point(r)?, + r_answer: read_scalar(r)?, + s_answer: read_scalar(r)?, + delta_answer: read_scalar(r)?, + L: read_vec(read_point, r)?.into_iter().collect(), + R: read_vec(read_point, r)?.into_iter().collect(), + }, + })) + } +} diff --git a/coins/monero/ringct/bulletproofs/src/original/mod.rs b/coins/monero/ringct/bulletproofs/src/original/mod.rs new file mode 100644 index 000000000..43fa7cfe8 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/src/original/mod.rs @@ -0,0 +1,395 @@ +use std_shims::{vec, vec::Vec, sync::OnceLock}; + +use rand_core::{RngCore, CryptoRng}; +use zeroize::Zeroize; +use subtle::{Choice, ConditionallySelectable}; + +use curve25519_dalek::{ + constants::{ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_TABLE}, + scalar::Scalar, + edwards::EdwardsPoint, +}; + +use monero_generators::{H, Generators}; +use monero_primitives::{INV_EIGHT, Commitment, keccak256_to_scalar}; + +use crate::{core::*, ScalarVector, batch_verifier::BulletproofsBatchVerifier}; + +include!(concat!(env!("OUT_DIR"), "/generators.rs")); + +static TWO_N_CELL: OnceLock = OnceLock::new(); +fn TWO_N() -> &'static ScalarVector { + TWO_N_CELL.get_or_init(|| ScalarVector::powers(Scalar::from(2u8), COMMITMENT_BITS)) +} + +static IP12_CELL: OnceLock = OnceLock::new(); +fn IP12() -> Scalar { + *IP12_CELL.get_or_init(|| ScalarVector(vec![Scalar::ONE; COMMITMENT_BITS]).inner_product(TWO_N())) +} + +fn MN(outputs: usize) -> (usize, usize, usize) { + let mut logM = 0; + let mut M; + while { + M = 1 << logM; + (M <= MAX_COMMITMENTS) && (M < outputs) + } { + logM += 1; + } + + (logM + LOG_COMMITMENT_BITS, M, M * COMMITMENT_BITS) +} + +fn bit_decompose(commitments: &[Commitment]) -> (ScalarVector, ScalarVector) { + let (_, M, MN) = MN(commitments.len()); + + let sv = commitments.iter().map(|c| Scalar::from(c.amount)).collect::>(); + let mut aL = ScalarVector::new(MN); + let mut aR = ScalarVector::new(MN); + + for j in 0 .. M { + for i in (0 .. COMMITMENT_BITS).rev() { + let bit = + if j < sv.len() { Choice::from((sv[j][i / 8] >> (i % 8)) & 1) } else { Choice::from(0) }; + aL.0[(j * COMMITMENT_BITS) + i] = + Scalar::conditional_select(&Scalar::ZERO, &Scalar::ONE, bit); + aR.0[(j * COMMITMENT_BITS) + i] = + Scalar::conditional_select(&-Scalar::ONE, &Scalar::ZERO, bit); + } + } + + (aL, aR) +} + +fn hash_commitments>( + commitments: C, +) -> (Scalar, Vec) { + let V = commitments.into_iter().map(|c| c * INV_EIGHT()).collect::>(); + (keccak256_to_scalar(V.iter().flat_map(|V| V.compress().to_bytes()).collect::>()), V) +} + +fn alpha_rho( + rng: &mut R, + generators: &Generators, + aL: &ScalarVector, + aR: &ScalarVector, +) -> (Scalar, EdwardsPoint) { + fn vector_exponent(generators: &Generators, a: &ScalarVector, b: &ScalarVector) -> EdwardsPoint { + debug_assert_eq!(a.len(), b.len()); + (a * &generators.G[.. a.len()]) + (b * &generators.H[.. b.len()]) + } + + let ar = Scalar::random(rng); + (ar, (vector_exponent(generators, aL, aR) + (ED25519_BASEPOINT_TABLE * &ar)) * INV_EIGHT()) +} + +fn LR_statements( + a: &ScalarVector, + G_i: &[EdwardsPoint], + b: &ScalarVector, + H_i: &[EdwardsPoint], + cL: Scalar, + U: EdwardsPoint, +) -> Vec<(Scalar, EdwardsPoint)> { + let mut res = a + .0 + .iter() + .copied() + .zip(G_i.iter().copied()) + .chain(b.0.iter().copied().zip(H_i.iter().copied())) + .collect::>(); + res.push((cL, U)); + res +} + +fn hash_cache(cache: &mut Scalar, mash: &[[u8; 32]]) -> Scalar { + let slice = + &[cache.to_bytes().as_ref(), mash.iter().copied().flatten().collect::>().as_ref()] + .concat(); + *cache = keccak256_to_scalar(slice); + *cache +} + +fn hadamard_fold( + l: &[EdwardsPoint], + r: &[EdwardsPoint], + a: Scalar, + b: Scalar, +) -> Vec { + let mut res = Vec::with_capacity(l.len() / 2); + for i in 0 .. l.len() { + res.push(multiexp(&[(a, l[i]), (b, r[i])])); + } + res +} + +/// Internal structure representing a Bulletproof, as defined by Monero.. +#[doc(hidden)] +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct OriginalStruct { + pub(crate) A: EdwardsPoint, + pub(crate) S: EdwardsPoint, + pub(crate) T1: EdwardsPoint, + pub(crate) T2: EdwardsPoint, + pub(crate) tau_x: Scalar, + pub(crate) mu: Scalar, + pub(crate) L: Vec, + pub(crate) R: Vec, + pub(crate) a: Scalar, + pub(crate) b: Scalar, + pub(crate) t: Scalar, +} + +impl OriginalStruct { + pub(crate) fn prove( + rng: &mut R, + commitments: &[Commitment], + ) -> OriginalStruct { + let (logMN, M, MN) = MN(commitments.len()); + + let (aL, aR) = bit_decompose(commitments); + let commitments_points = commitments.iter().map(Commitment::calculate).collect::>(); + let (mut cache, _) = hash_commitments(commitments_points.clone()); + + let (sL, sR) = + ScalarVector((0 .. (MN * 2)).map(|_| Scalar::random(&mut *rng)).collect::>()).split(); + + let generators = GENERATORS(); + let (mut alpha, A) = alpha_rho(&mut *rng, generators, &aL, &aR); + let (mut rho, S) = alpha_rho(&mut *rng, generators, &sL, &sR); + + let y = hash_cache(&mut cache, &[A.compress().to_bytes(), S.compress().to_bytes()]); + let mut cache = keccak256_to_scalar(y.to_bytes()); + let z = cache; + + let l0 = aL - z; + let l1 = sL; + + let mut zero_twos = Vec::with_capacity(MN); + let zpow = ScalarVector::powers(z, M + 2); + for j in 0 .. M { + for i in 0 .. COMMITMENT_BITS { + zero_twos.push(zpow[j + 2] * TWO_N()[i]); + } + } + + let yMN = ScalarVector::powers(y, MN); + let r0 = ((aR + z) * &yMN) + &ScalarVector(zero_twos); + let r1 = yMN * &sR; + + let (T1, T2, x, mut tau_x) = { + let t1 = l0.clone().inner_product(&r1) + r0.clone().inner_product(&l1); + let t2 = l1.clone().inner_product(&r1); + + let mut tau1 = Scalar::random(&mut *rng); + let mut tau2 = Scalar::random(&mut *rng); + + let T1 = multiexp(&[(t1, H()), (tau1, ED25519_BASEPOINT_POINT)]) * INV_EIGHT(); + let T2 = multiexp(&[(t2, H()), (tau2, ED25519_BASEPOINT_POINT)]) * INV_EIGHT(); + + let x = + hash_cache(&mut cache, &[z.to_bytes(), T1.compress().to_bytes(), T2.compress().to_bytes()]); + + let tau_x = (tau2 * (x * x)) + (tau1 * x); + + tau1.zeroize(); + tau2.zeroize(); + (T1, T2, x, tau_x) + }; + + let mu = (x * rho) + alpha; + alpha.zeroize(); + rho.zeroize(); + + for (i, gamma) in commitments.iter().map(|c| c.mask).enumerate() { + tau_x += zpow[i + 2] * gamma; + } + + let l = l0 + &(l1 * x); + let r = r0 + &(r1 * x); + + let t = l.clone().inner_product(&r); + + let x_ip = + hash_cache(&mut cache, &[x.to_bytes(), tau_x.to_bytes(), mu.to_bytes(), t.to_bytes()]); + + let mut a = l; + let mut b = r; + + let yinv = y.invert(); + let yinvpow = ScalarVector::powers(yinv, MN); + + let mut G_proof = generators.G[.. a.len()].to_vec(); + let mut H_proof = generators.H[.. a.len()].to_vec(); + H_proof.iter_mut().zip(yinvpow.0.iter()).for_each(|(this_H, yinvpow)| *this_H *= yinvpow); + let U = H() * x_ip; + + let mut L = Vec::with_capacity(logMN); + let mut R = Vec::with_capacity(logMN); + + while a.len() != 1 { + let (aL, aR) = a.split(); + let (bL, bR) = b.split(); + + let cL = aL.clone().inner_product(&bR); + let cR = aR.clone().inner_product(&bL); + + let (G_L, G_R) = G_proof.split_at(aL.len()); + let (H_L, H_R) = H_proof.split_at(aL.len()); + + let L_i = multiexp(&LR_statements(&aL, G_R, &bR, H_L, cL, U)) * INV_EIGHT(); + let R_i = multiexp(&LR_statements(&aR, G_L, &bL, H_R, cR, U)) * INV_EIGHT(); + L.push(L_i); + R.push(R_i); + + let w = hash_cache(&mut cache, &[L_i.compress().to_bytes(), R_i.compress().to_bytes()]); + let w_inv = w.invert(); + + a = (aL * w) + &(aR * w_inv); + b = (bL * w_inv) + &(bR * w); + + if a.len() != 1 { + G_proof = hadamard_fold(G_L, G_R, w_inv, w); + H_proof = hadamard_fold(H_L, H_R, w, w_inv); + } + } + + let res = OriginalStruct { A, S, T1, T2, tau_x, mu, L, R, a: a[0], b: b[0], t }; + + #[cfg(debug_assertions)] + { + let mut verifier = BulletproofsBatchVerifier::default(); + debug_assert!(res.verify(rng, &mut verifier, &commitments_points)); + debug_assert!(verifier.verify()); + } + + res + } + + #[must_use] + pub(crate) fn verify( + &self, + rng: &mut R, + verifier: &mut BulletproofsBatchVerifier, + commitments: &[EdwardsPoint], + ) -> bool { + // Verify commitments are valid + if commitments.is_empty() || (commitments.len() > MAX_COMMITMENTS) { + return false; + } + + // Verify L and R are properly sized + if self.L.len() != self.R.len() { + return false; + } + + let (logMN, M, MN) = MN(commitments.len()); + if self.L.len() != logMN { + return false; + } + + // Rebuild all challenges + let (mut cache, commitments) = hash_commitments(commitments.iter().copied()); + let y = hash_cache(&mut cache, &[self.A.compress().to_bytes(), self.S.compress().to_bytes()]); + + let z = keccak256_to_scalar(y.to_bytes()); + cache = z; + + let x = hash_cache( + &mut cache, + &[z.to_bytes(), self.T1.compress().to_bytes(), self.T2.compress().to_bytes()], + ); + + let x_ip = hash_cache( + &mut cache, + &[x.to_bytes(), self.tau_x.to_bytes(), self.mu.to_bytes(), self.t.to_bytes()], + ); + + let mut w_and_w_inv = Vec::with_capacity(logMN); + for (L, R) in self.L.iter().zip(&self.R) { + let w = hash_cache(&mut cache, &[L.compress().to_bytes(), R.compress().to_bytes()]); + let w_inv = w.invert(); + w_and_w_inv.push((w, w_inv)); + } + + // Convert the proof from * INV_EIGHT to its actual form + let normalize = |point: &EdwardsPoint| point.mul_by_cofactor(); + + let L = self.L.iter().map(normalize).collect::>(); + let R = self.R.iter().map(normalize).collect::>(); + let T1 = normalize(&self.T1); + let T2 = normalize(&self.T2); + let A = normalize(&self.A); + let S = normalize(&self.S); + + let commitments = commitments.iter().map(EdwardsPoint::mul_by_cofactor).collect::>(); + + // Verify it + let zpow = ScalarVector::powers(z, M + 3); + + // First multiexp + { + let verifier_weight = Scalar::random(rng); + + let ip1y = ScalarVector::powers(y, M * COMMITMENT_BITS).sum(); + let mut k = -(zpow[2] * ip1y); + for j in 1 ..= M { + k -= zpow[j + 2] * IP12(); + } + let y1 = self.t - ((z * ip1y) + k); + verifier.0.h -= verifier_weight * y1; + + verifier.0.g -= verifier_weight * self.tau_x; + + for (j, commitment) in commitments.iter().enumerate() { + verifier.0.other.push((verifier_weight * zpow[j + 2], *commitment)); + } + + verifier.0.other.push((verifier_weight * x, T1)); + verifier.0.other.push((verifier_weight * (x * x), T2)); + } + + // Second multiexp + { + let verifier_weight = Scalar::random(rng); + let z3 = (self.t - (self.a * self.b)) * x_ip; + verifier.0.h += verifier_weight * z3; + verifier.0.g -= verifier_weight * self.mu; + + verifier.0.other.push((verifier_weight, A)); + verifier.0.other.push((verifier_weight * x, S)); + + { + let ypow = ScalarVector::powers(y, MN); + let yinv = y.invert(); + let yinvpow = ScalarVector::powers(yinv, MN); + + let w_cache = challenge_products(&w_and_w_inv); + + while verifier.0.g_bold.len() < MN { + verifier.0.g_bold.push(Scalar::ZERO); + } + while verifier.0.h_bold.len() < MN { + verifier.0.h_bold.push(Scalar::ZERO); + } + + for i in 0 .. MN { + let g = (self.a * w_cache[i]) + z; + verifier.0.g_bold[i] -= verifier_weight * g; + + let mut h = self.b * yinvpow[i] * w_cache[(!i) & (MN - 1)]; + h -= ((zpow[(i / COMMITMENT_BITS) + 2] * TWO_N()[i % COMMITMENT_BITS]) + (z * ypow[i])) * + yinvpow[i]; + verifier.0.h_bold[i] -= verifier_weight * h; + } + } + + for i in 0 .. logMN { + verifier.0.other.push((verifier_weight * (w_and_w_inv[i].0 * w_and_w_inv[i].0), L[i])); + verifier.0.other.push((verifier_weight * (w_and_w_inv[i].1 * w_and_w_inv[i].1), R[i])); + } + } + + true + } +} diff --git a/coins/monero/src/ringct/bulletproofs/plus/aggregate_range_proof.rs b/coins/monero/ringct/bulletproofs/src/plus/aggregate_range_proof.rs similarity index 65% rename from coins/monero/src/ringct/bulletproofs/plus/aggregate_range_proof.rs rename to coins/monero/ringct/bulletproofs/src/plus/aggregate_range_proof.rs index cba950142..2f39c7d3f 100644 --- a/coins/monero/src/ringct/bulletproofs/plus/aggregate_range_proof.rs +++ b/coins/monero/ringct/bulletproofs/src/plus/aggregate_range_proof.rs @@ -1,33 +1,27 @@ -use std_shims::vec::Vec; +use std_shims::{vec, vec::Vec}; use rand_core::{RngCore, CryptoRng}; - use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; -use multiexp::{multiexp, multiexp_vartime, BatchVerifier}; -use group::{ - ff::{Field, PrimeField}, - Group, GroupEncoding, -}; -use dalek_ff_group::{Scalar, EdwardsPoint}; +use curve25519_dalek::{traits::Identity, scalar::Scalar, edwards::EdwardsPoint}; + +use monero_primitives::{INV_EIGHT, Commitment, keccak256_to_scalar}; use crate::{ - Commitment, - ringct::{ - bulletproofs::core::{MAX_M, N}, - bulletproofs::plus::{ - ScalarVector, PointVector, GeneratorsList, Generators, - transcript::*, - weighted_inner_product::{WipStatement, WipWitness, WipProof}, - padded_pow_of_2, u64_decompose, - }, + batch_verifier::BulletproofsPlusBatchVerifier, + core::{MAX_COMMITMENTS, COMMITMENT_BITS, multiexp, multiexp_vartime}, + plus::{ + ScalarVector, PointVector, GeneratorsList, BpPlusGenerators, + transcript::*, + weighted_inner_product::{WipStatement, WipWitness, WipProof}, + padded_pow_of_2, u64_decompose, }, }; // Figure 3 of the Bulletproofs+ Paper #[derive(Clone, Debug)] pub(crate) struct AggregateRangeStatement { - generators: Generators, + generators: BpPlusGenerators, V: Vec, } @@ -42,7 +36,7 @@ pub(crate) struct AggregateRangeWitness(Vec); impl AggregateRangeWitness { pub(crate) fn new(commitments: Vec) -> Option { - if commitments.is_empty() || (commitments.len() > MAX_M) { + if commitments.is_empty() || (commitments.len() > MAX_COMMITMENTS) { return None; } @@ -50,35 +44,48 @@ impl AggregateRangeWitness { } } +/// Internal structure representing a Bulletproof+, as defined by Monero.. +#[doc(hidden)] #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct AggregateRangeProof { pub(crate) A: EdwardsPoint, pub(crate) wip: WipProof, } +struct AHatComputation { + y: Scalar, + d_descending_y_plus_z: ScalarVector, + y_mn_plus_one: Scalar, + z: Scalar, + z_pow: ScalarVector, + A_hat: EdwardsPoint, +} + impl AggregateRangeStatement { pub(crate) fn new(V: Vec) -> Option { - if V.is_empty() || (V.len() > MAX_M) { + if V.is_empty() || (V.len() > MAX_COMMITMENTS) { return None; } - Some(Self { generators: Generators::new(), V }) + Some(Self { generators: BpPlusGenerators::new(), V }) } fn transcript_A(transcript: &mut Scalar, A: EdwardsPoint) -> (Scalar, Scalar) { - let y = hash_to_scalar(&[transcript.to_repr().as_ref(), A.to_bytes().as_ref()].concat()); - let z = hash_to_scalar(y.to_bytes().as_ref()); + let y = keccak256_to_scalar( + [transcript.to_bytes().as_ref(), A.compress().to_bytes().as_ref()].concat(), + ); + let z = keccak256_to_scalar(y.to_bytes().as_ref()); *transcript = z; (y, z) } fn d_j(j: usize, m: usize) -> ScalarVector { - let mut d_j = Vec::with_capacity(m * N); - for _ in 0 .. (j - 1) * N { + let mut d_j = Vec::with_capacity(m * COMMITMENT_BITS); + for _ in 0 .. (j - 1) * COMMITMENT_BITS { d_j.push(Scalar::ZERO); } - d_j.append(&mut ScalarVector::powers(Scalar::from(2u8), N).0); - for _ in 0 .. (m - j) * N { + d_j.append(&mut ScalarVector::powers(Scalar::from(2u8), COMMITMENT_BITS).0); + for _ in 0 .. (m - j) * COMMITMENT_BITS { d_j.push(Scalar::ZERO); } ScalarVector(d_j) @@ -86,23 +93,26 @@ impl AggregateRangeStatement { fn compute_A_hat( mut V: PointVector, - generators: &Generators, + generators: &BpPlusGenerators, transcript: &mut Scalar, mut A: EdwardsPoint, - ) -> (Scalar, ScalarVector, Scalar, Scalar, ScalarVector, EdwardsPoint) { + ) -> AHatComputation { let (y, z) = Self::transcript_A(transcript, A); A = A.mul_by_cofactor(); while V.len() < padded_pow_of_2(V.len()) { V.0.push(EdwardsPoint::identity()); } - let mn = V.len() * N; + let mn = V.len() * COMMITMENT_BITS; + // 2, 4, 6, 8... powers of z, of length equivalent to the amount of commitments let mut z_pow = Vec::with_capacity(V.len()); + // z**2 + z_pow.push(z * z); let mut d = ScalarVector::new(mn); for j in 1 ..= V.len() { - z_pow.push(z.pow(Scalar::from(2 * u64::try_from(j).unwrap()))); // TODO: Optimize this + z_pow.push(*z_pow.last().unwrap() * z_pow[0]); d = d + &(Self::d_j(j, V.len()) * (z_pow[j - 1])); } @@ -128,23 +138,23 @@ impl AggregateRangeStatement { let neg_z = -z; let mut A_terms = Vec::with_capacity((generators.len() * 2) + 2); for (i, d_y_z) in d_descending_y_plus_z.0.iter().enumerate() { - A_terms.push((neg_z, generators.generator(GeneratorsList::GBold1, i))); - A_terms.push((*d_y_z, generators.generator(GeneratorsList::HBold1, i))); + A_terms.push((neg_z, generators.generator(GeneratorsList::GBold, i))); + A_terms.push((*d_y_z, generators.generator(GeneratorsList::HBold, i))); } A_terms.push((y_mn_plus_one, commitment_accum)); A_terms.push(( - ((y_pows * z) - (d.sum() * y_mn_plus_one * z) - (y_pows * z.square())), - Generators::g(), + ((y_pows * z) - (d.sum() * y_mn_plus_one * z) - (y_pows * (z * z))), + BpPlusGenerators::g(), )); - ( + AHatComputation { y, d_descending_y_plus_z, y_mn_plus_one, z, - ScalarVector(z_pow), - A + multiexp_vartime(&A_terms), - ) + z_pow: ScalarVector(z_pow), + A_hat: A + multiexp_vartime(&A_terms), + } } pub(crate) fn prove( @@ -157,7 +167,7 @@ impl AggregateRangeStatement { return None; } for (commitment, witness) in self.V.iter().zip(witness.0.iter()) { - if witness.calculate() != **commitment { + if witness.calculate() != *commitment { return None; } } @@ -170,19 +180,19 @@ impl AggregateRangeStatement { // Commitments aren't transmitted INV_EIGHT though, so this multiplies by INV_EIGHT to enable // clearing its cofactor without mutating the value // For some reason, these values are transcripted * INV_EIGHT, not as transmitted - let mut V = V.into_iter().map(|V| EdwardsPoint(V.0 * crate::INV_EIGHT())).collect::>(); + let V = V.into_iter().map(|V| V * INV_EIGHT()).collect::>(); let mut transcript = initial_transcript(V.iter()); - V.iter_mut().for_each(|V| *V = V.mul_by_cofactor()); + let mut V = V.iter().map(EdwardsPoint::mul_by_cofactor).collect::>(); // Pad V while V.len() < padded_pow_of_2(V.len()) { V.push(EdwardsPoint::identity()); } - let generators = generators.reduce(V.len() * N); + let generators = generators.reduce(V.len() * COMMITMENT_BITS); let mut d_js = Vec::with_capacity(V.len()); - let mut a_l = ScalarVector(Vec::with_capacity(V.len() * N)); + let mut a_l = ScalarVector(Vec::with_capacity(V.len() * COMMITMENT_BITS)); for j in 1 ..= V.len() { d_js.push(Self::d_j(j, V.len())); #[allow(clippy::map_unwrap_or)] @@ -200,26 +210,26 @@ impl AggregateRangeStatement { let mut A_terms = Vec::with_capacity((generators.len() * 2) + 1); for (i, a_l) in a_l.0.iter().enumerate() { - A_terms.push((*a_l, generators.generator(GeneratorsList::GBold1, i))); + A_terms.push((*a_l, generators.generator(GeneratorsList::GBold, i))); } for (i, a_r) in a_r.0.iter().enumerate() { - A_terms.push((*a_r, generators.generator(GeneratorsList::HBold1, i))); + A_terms.push((*a_r, generators.generator(GeneratorsList::HBold, i))); } - A_terms.push((alpha, Generators::h())); + A_terms.push((alpha, BpPlusGenerators::h())); let mut A = multiexp(&A_terms); A_terms.zeroize(); // Multiply by INV_EIGHT per earlier commentary - A.0 *= crate::INV_EIGHT(); + A *= INV_EIGHT(); - let (y, d_descending_y_plus_z, y_mn_plus_one, z, z_pow, A_hat) = + let AHatComputation { y, d_descending_y_plus_z, y_mn_plus_one, z, z_pow, A_hat } = Self::compute_A_hat(PointVector(V), &generators, &mut transcript, A); let a_l = a_l - z; let a_r = a_r + &d_descending_y_plus_z; let mut alpha = alpha; for j in 1 ..= witness.0.len() { - alpha += z_pow[j - 1] * Scalar(witness.0[j - 1].mask) * y_mn_plus_one; + alpha += z_pow[j - 1] * witness.0[j - 1].mask * y_mn_plus_one; } Some(AggregateRangeProof { @@ -230,23 +240,22 @@ impl AggregateRangeStatement { }) } - pub(crate) fn verify( + pub(crate) fn verify( self, rng: &mut R, - verifier: &mut BatchVerifier, - id: Id, + verifier: &mut BulletproofsPlusBatchVerifier, proof: AggregateRangeProof, ) -> bool { let Self { generators, V } = self; - let mut V = V.into_iter().map(|V| EdwardsPoint(V.0 * crate::INV_EIGHT())).collect::>(); + let V = V.into_iter().map(|V| V * INV_EIGHT()).collect::>(); let mut transcript = initial_transcript(V.iter()); - V.iter_mut().for_each(|V| *V = V.mul_by_cofactor()); + let V = V.iter().map(EdwardsPoint::mul_by_cofactor).collect::>(); - let generators = generators.reduce(V.len() * N); + let generators = generators.reduce(V.len() * COMMITMENT_BITS); - let (y, _, _, _, _, A_hat) = + let AHatComputation { y, A_hat, .. } = Self::compute_A_hat(PointVector(V), &generators, &mut transcript, proof.A); - WipStatement::new(generators, A_hat, y).verify(rng, verifier, id, transcript, proof.wip) + WipStatement::new(generators, A_hat, y).verify(rng, verifier, transcript, proof.wip) } } diff --git a/coins/monero/src/ringct/bulletproofs/plus/mod.rs b/coins/monero/ringct/bulletproofs/src/plus/mod.rs similarity index 59% rename from coins/monero/src/ringct/bulletproofs/plus/mod.rs rename to coins/monero/ringct/bulletproofs/src/plus/mod.rs index 304178214..ec7ca6a7c 100644 --- a/coins/monero/src/ringct/bulletproofs/plus/mod.rs +++ b/coins/monero/ringct/bulletproofs/src/plus/mod.rs @@ -1,9 +1,13 @@ #![allow(non_snake_case)] -use group::Group; -use dalek_ff_group::{Scalar, EdwardsPoint}; +use std_shims::sync::OnceLock; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar, edwards::EdwardsPoint}; + +use monero_generators::{H, Generators}; + +pub(crate) use crate::scalar_vector::ScalarVector; -pub(crate) use crate::ringct::bulletproofs::scalar_vector::ScalarVector; mod point_vector; pub(crate) use point_vector::PointVector; @@ -23,55 +27,51 @@ pub(crate) fn padded_pow_of_2(i: usize) -> usize { #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub(crate) enum GeneratorsList { - GBold1, - HBold1, + GBold, + HBold, } // TODO: Table these #[derive(Clone, Debug)] -pub(crate) struct Generators { - g_bold1: &'static [EdwardsPoint], - h_bold1: &'static [EdwardsPoint], +pub(crate) struct BpPlusGenerators { + g_bold: &'static [EdwardsPoint], + h_bold: &'static [EdwardsPoint], } -mod generators { - use std_shims::sync::OnceLock; - use monero_generators::Generators; - include!(concat!(env!("OUT_DIR"), "/generators_plus.rs")); -} +include!(concat!(env!("OUT_DIR"), "/generators_plus.rs")); -impl Generators { +impl BpPlusGenerators { #[allow(clippy::new_without_default)] pub(crate) fn new() -> Self { - let gens = generators::GENERATORS(); - Generators { g_bold1: &gens.G, h_bold1: &gens.H } + let gens = GENERATORS(); + BpPlusGenerators { g_bold: &gens.G, h_bold: &gens.H } } pub(crate) fn len(&self) -> usize { - self.g_bold1.len() + self.g_bold.len() } pub(crate) fn g() -> EdwardsPoint { - dalek_ff_group::EdwardsPoint(crate::H()) + H() } pub(crate) fn h() -> EdwardsPoint { - EdwardsPoint::generator() + ED25519_BASEPOINT_POINT } pub(crate) fn generator(&self, list: GeneratorsList, i: usize) -> EdwardsPoint { match list { - GeneratorsList::GBold1 => self.g_bold1[i], - GeneratorsList::HBold1 => self.h_bold1[i], + GeneratorsList::GBold => self.g_bold[i], + GeneratorsList::HBold => self.h_bold[i], } } pub(crate) fn reduce(&self, generators: usize) -> Self { // Round to the nearest power of 2 let generators = padded_pow_of_2(generators); - assert!(generators <= self.g_bold1.len()); + assert!(generators <= self.g_bold.len()); - Generators { g_bold1: &self.g_bold1[.. generators], h_bold1: &self.h_bold1[.. generators] } + BpPlusGenerators { g_bold: &self.g_bold[.. generators], h_bold: &self.h_bold[.. generators] } } } diff --git a/coins/monero/src/ringct/bulletproofs/plus/point_vector.rs b/coins/monero/ringct/bulletproofs/src/plus/point_vector.rs similarity index 90% rename from coins/monero/src/ringct/bulletproofs/plus/point_vector.rs rename to coins/monero/ringct/bulletproofs/src/plus/point_vector.rs index ac753a013..f9b52a61c 100644 --- a/coins/monero/src/ringct/bulletproofs/plus/point_vector.rs +++ b/coins/monero/ringct/bulletproofs/src/plus/point_vector.rs @@ -3,12 +3,10 @@ use std_shims::vec::Vec; use zeroize::{Zeroize, ZeroizeOnDrop}; -use dalek_ff_group::EdwardsPoint; +use curve25519_dalek::edwards::EdwardsPoint; #[cfg(test)] -use multiexp::multiexp; -#[cfg(test)] -use crate::ringct::bulletproofs::plus::ScalarVector; +use crate::{core::multiexp, plus::ScalarVector}; #[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] pub(crate) struct PointVector(pub(crate) Vec); diff --git a/coins/monero/ringct/bulletproofs/src/plus/transcript.rs b/coins/monero/ringct/bulletproofs/src/plus/transcript.rs new file mode 100644 index 000000000..3e43a2390 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/src/plus/transcript.rs @@ -0,0 +1,20 @@ +use std_shims::{sync::OnceLock, vec::Vec}; + +use curve25519_dalek::{scalar::Scalar, edwards::EdwardsPoint}; + +use monero_generators::hash_to_point; +use monero_primitives::{keccak256, keccak256_to_scalar}; + +// Monero starts BP+ transcripts with the following constant. +static TRANSCRIPT_CELL: OnceLock<[u8; 32]> = OnceLock::new(); +pub(crate) fn TRANSCRIPT() -> [u8; 32] { + // Why this uses a hash_to_point is completely unknown. + *TRANSCRIPT_CELL + .get_or_init(|| hash_to_point(keccak256(b"bulletproof_plus_transcript")).compress().to_bytes()) +} + +pub(crate) fn initial_transcript(commitments: core::slice::Iter<'_, EdwardsPoint>) -> Scalar { + let commitments_hash = + keccak256_to_scalar(commitments.flat_map(|V| V.compress().to_bytes()).collect::>()); + keccak256_to_scalar([TRANSCRIPT().as_ref(), &commitments_hash.to_bytes()].concat()) +} diff --git a/coins/monero/src/ringct/bulletproofs/plus/weighted_inner_product.rs b/coins/monero/ringct/bulletproofs/src/plus/weighted_inner_product.rs similarity index 64% rename from coins/monero/src/ringct/bulletproofs/plus/weighted_inner_product.rs rename to coins/monero/ringct/bulletproofs/src/plus/weighted_inner_product.rs index 7cb9a4df2..abd7ea29e 100644 --- a/coins/monero/src/ringct/bulletproofs/plus/weighted_inner_product.rs +++ b/coins/monero/ringct/bulletproofs/src/plus/weighted_inner_product.rs @@ -1,24 +1,21 @@ -use std_shims::vec::Vec; +use std_shims::{vec, vec::Vec}; use rand_core::{RngCore, CryptoRng}; - use zeroize::{Zeroize, ZeroizeOnDrop}; -use multiexp::{BatchVerifier, multiexp, multiexp_vartime}; -use group::{ - ff::{Field, PrimeField}, - GroupEncoding, -}; -use dalek_ff_group::{Scalar, EdwardsPoint}; +use curve25519_dalek::{scalar::Scalar, edwards::EdwardsPoint}; -use crate::ringct::bulletproofs::plus::{ - ScalarVector, PointVector, GeneratorsList, Generators, padded_pow_of_2, transcript::*, +use monero_primitives::{INV_EIGHT, keccak256_to_scalar}; +use crate::{ + core::{multiexp, multiexp_vartime, challenge_products}, + batch_verifier::BulletproofsPlusBatchVerifier, + plus::{ScalarVector, PointVector, GeneratorsList, BpPlusGenerators, padded_pow_of_2}, }; // Figure 1 of the Bulletproofs+ paper #[derive(Clone, Debug)] pub(crate) struct WipStatement { - generators: Generators, + generators: BpPlusGenerators, P: EdwardsPoint, y: ScalarVector, } @@ -68,7 +65,7 @@ pub(crate) struct WipProof { } impl WipStatement { - pub(crate) fn new(generators: Generators, P: EdwardsPoint, y: Scalar) -> Self { + pub(crate) fn new(generators: BpPlusGenerators, P: EdwardsPoint, y: Scalar) -> Self { debug_assert_eq!(generators.len(), padded_pow_of_2(generators.len())); // y ** n @@ -82,16 +79,26 @@ impl WipStatement { } fn transcript_L_R(transcript: &mut Scalar, L: EdwardsPoint, R: EdwardsPoint) -> Scalar { - let e = hash_to_scalar( - &[transcript.to_repr().as_ref(), L.to_bytes().as_ref(), R.to_bytes().as_ref()].concat(), + let e = keccak256_to_scalar( + [ + transcript.to_bytes().as_ref(), + L.compress().to_bytes().as_ref(), + R.compress().to_bytes().as_ref(), + ] + .concat(), ); *transcript = e; e } fn transcript_A_B(transcript: &mut Scalar, A: EdwardsPoint, B: EdwardsPoint) -> Scalar { - let e = hash_to_scalar( - &[transcript.to_repr().as_ref(), A.to_bytes().as_ref(), B.to_bytes().as_ref()].concat(), + let e = keccak256_to_scalar( + [ + transcript.to_bytes().as_ref(), + A.compress().to_bytes().as_ref(), + B.compress().to_bytes().as_ref(), + ] + .concat(), ); *transcript = e; e @@ -119,7 +126,7 @@ impl WipStatement { debug_assert_eq!(g_bold1.len(), h_bold1.len()); let e = Self::transcript_L_R(transcript, L, R); - let inv_e = e.invert().unwrap(); + let inv_e = e.invert(); // This vartime is safe as all of these arguments are public let mut new_g_bold = Vec::with_capacity(g_bold1.len()); @@ -133,57 +140,12 @@ impl WipStatement { new_h_bold.push(multiexp_vartime(&[(e, h_bold.0), (inv_e, h_bold.1)])); } - let e_square = e.square(); - let inv_e_square = inv_e.square(); + let e_square = e * e; + let inv_e_square = inv_e * inv_e; (e, inv_e, e_square, inv_e_square, PointVector(new_g_bold), PointVector(new_h_bold)) } - /* - This has room for optimization worth investigating further. It currently takes - an iterative approach. It can be optimized further via divide and conquer. - - Assume there are 4 challenges. - - Iterative approach (current): - 1. Do the optimal multiplications across challenge column 0 and 1. - 2. Do the optimal multiplications across that result and column 2. - 3. Do the optimal multiplications across that result and column 3. - - Divide and conquer (worth investigating further): - 1. Do the optimal multiplications across challenge column 0 and 1. - 2. Do the optimal multiplications across challenge column 2 and 3. - 3. Multiply both results together. - - When there are 4 challenges (n=16), the iterative approach does 28 multiplications - versus divide and conquer's 24. - */ - fn challenge_products(challenges: &[(Scalar, Scalar)]) -> Vec { - let mut products = vec![Scalar::ONE; 1 << challenges.len()]; - - if !challenges.is_empty() { - products[0] = challenges[0].1; - products[1] = challenges[0].0; - - for (j, challenge) in challenges.iter().enumerate().skip(1) { - let mut slots = (1 << (j + 1)) - 1; - while slots > 0 { - products[slots] = products[slots / 2] * challenge.0; - products[slots - 1] = products[slots / 2] * challenge.1; - - slots = slots.saturating_sub(2); - } - } - - // Sanity check since if the above failed to populate, it'd be critical - for product in &products { - debug_assert!(!bool::from(product.is_zero())); - } - } - - products - } - pub(crate) fn prove( self, rng: &mut R, @@ -197,12 +159,12 @@ impl WipStatement { if generators.len() != witness.a.len() { return None; } - let (g, h) = (Generators::g(), Generators::h()); + let (g, h) = (BpPlusGenerators::g(), BpPlusGenerators::h()); let mut g_bold = vec![]; let mut h_bold = vec![]; for i in 0 .. generators.len() { - g_bold.push(generators.generator(GeneratorsList::GBold1, i)); - h_bold.push(generators.generator(GeneratorsList::HBold1, i)); + g_bold.push(generators.generator(GeneratorsList::GBold, i)); + h_bold.push(generators.generator(GeneratorsList::HBold, i)); } let mut g_bold = PointVector(g_bold); let mut h_bold = PointVector(h_bold); @@ -261,7 +223,7 @@ impl WipStatement { let c_r = (a2.clone() * y_n_hat).weighted_inner_product(&b1, &y); // TODO: Calculate these with a batch inversion - let y_inv_n_hat = y_n_hat.invert().unwrap(); + let y_inv_n_hat = y_n_hat.invert(); let mut L_terms = (a1.clone() * y_inv_n_hat) .0 @@ -271,7 +233,7 @@ impl WipStatement { .collect::>(); L_terms.push((c_l, g)); L_terms.push((d_l, h)); - let L = multiexp(&L_terms) * Scalar(crate::INV_EIGHT()); + let L = multiexp(&L_terms) * INV_EIGHT(); L_vec.push(L); L_terms.zeroize(); @@ -283,7 +245,7 @@ impl WipStatement { .collect::>(); R_terms.push((c_r, g)); R_terms.push((d_r, h)); - let R = multiexp(&R_terms) * Scalar(crate::INV_EIGHT()); + let R = multiexp(&R_terms) * INV_EIGHT(); R_vec.push(R); R_terms.zeroize(); @@ -316,33 +278,32 @@ impl WipStatement { let mut A_terms = vec![(r, g_bold[0]), (s, h_bold[0]), ((ry * b[0]) + (s * y[0] * a[0]), g), (delta, h)]; - let A = multiexp(&A_terms) * Scalar(crate::INV_EIGHT()); + let A = multiexp(&A_terms) * INV_EIGHT(); A_terms.zeroize(); let mut B_terms = vec![(ry * s, g), (eta, h)]; - let B = multiexp(&B_terms) * Scalar(crate::INV_EIGHT()); + let B = multiexp(&B_terms) * INV_EIGHT(); B_terms.zeroize(); let e = Self::transcript_A_B(&mut transcript, A, B); let r_answer = r + (a[0] * e); let s_answer = s + (b[0] * e); - let delta_answer = eta + (delta * e) + (alpha * e.square()); + let delta_answer = eta + (delta * e) + (alpha * (e * e)); Some(WipProof { L: L_vec, R: R_vec, A, B, r_answer, s_answer, delta_answer }) } - pub(crate) fn verify( + pub(crate) fn verify( self, rng: &mut R, - verifier: &mut BatchVerifier, - id: Id, + verifier: &mut BulletproofsPlusBatchVerifier, mut transcript: Scalar, mut proof: WipProof, ) -> bool { - let WipStatement { generators, P, y } = self; + let verifier_weight = Scalar::random(rng); - let (g, h) = (Generators::g(), Generators::h()); + let WipStatement { generators, P, y } = self; // Verify the L/R lengths { @@ -359,7 +320,7 @@ impl WipStatement { } let inv_y = { - let inv_y = y[0].invert().unwrap(); + let inv_y = y[0].invert(); let mut res = Vec::with_capacity(y.len()); res.push(inv_y); while res.len() < y.len() { @@ -368,51 +329,49 @@ impl WipStatement { res }; - let mut P_terms = vec![(Scalar::ONE, P)]; - P_terms.reserve(6 + (2 * generators.len()) + proof.L.len()); + let mut e_is = Vec::with_capacity(proof.L.len()); + for (L, R) in proof.L.iter_mut().zip(proof.R.iter_mut()) { + e_is.push(Self::transcript_L_R(&mut transcript, *L, *R)); + *L = L.mul_by_cofactor(); + *R = R.mul_by_cofactor(); + } - let mut challenges = Vec::with_capacity(proof.L.len()); - let product_cache = { - let mut es = Vec::with_capacity(proof.L.len()); - for (L, R) in proof.L.iter_mut().zip(proof.R.iter_mut()) { - es.push(Self::transcript_L_R(&mut transcript, *L, *R)); - *L = L.mul_by_cofactor(); - *R = R.mul_by_cofactor(); - } + let e = Self::transcript_A_B(&mut transcript, proof.A, proof.B); + proof.A = proof.A.mul_by_cofactor(); + proof.B = proof.B.mul_by_cofactor(); + let neg_e_square = verifier_weight * -(e * e); - let mut inv_es = es.clone(); - let mut scratch = vec![Scalar::ZERO; es.len()]; - group::ff::BatchInverter::invert_with_external_scratch(&mut inv_es, &mut scratch); - drop(scratch); + verifier.0.other.push((neg_e_square, P)); - debug_assert_eq!(es.len(), inv_es.len()); - debug_assert_eq!(es.len(), proof.L.len()); - debug_assert_eq!(es.len(), proof.R.len()); - for ((e, inv_e), (L, R)) in - es.drain(..).zip(inv_es.drain(..)).zip(proof.L.iter().zip(proof.R.iter())) + let mut challenges = Vec::with_capacity(proof.L.len()); + let product_cache = { + let mut inv_e_is = e_is.clone(); + Scalar::batch_invert(&mut inv_e_is); + + debug_assert_eq!(e_is.len(), inv_e_is.len()); + debug_assert_eq!(e_is.len(), proof.L.len()); + debug_assert_eq!(e_is.len(), proof.R.len()); + for ((e_i, inv_e_i), (L, R)) in + e_is.drain(..).zip(inv_e_is.drain(..)).zip(proof.L.iter().zip(proof.R.iter())) { - debug_assert_eq!(e.invert().unwrap(), inv_e); + debug_assert_eq!(e_i.invert(), inv_e_i); - challenges.push((e, inv_e)); + challenges.push((e_i, inv_e_i)); - let e_square = e.square(); - let inv_e_square = inv_e.square(); - P_terms.push((e_square, *L)); - P_terms.push((inv_e_square, *R)); + let e_i_square = e_i * e_i; + let inv_e_i_square = inv_e_i * inv_e_i; + verifier.0.other.push((neg_e_square * e_i_square, *L)); + verifier.0.other.push((neg_e_square * inv_e_i_square, *R)); } - Self::challenge_products(&challenges) + challenge_products(&challenges) }; - let e = Self::transcript_A_B(&mut transcript, proof.A, proof.B); - proof.A = proof.A.mul_by_cofactor(); - proof.B = proof.B.mul_by_cofactor(); - let neg_e_square = -e.square(); - - let mut multiexp = P_terms; - multiexp.reserve(4 + (2 * generators.len())); - for (scalar, _) in &mut multiexp { - *scalar *= neg_e_square; + while verifier.0.g_bold.len() < generators.len() { + verifier.0.g_bold.push(Scalar::ZERO); + } + while verifier.0.h_bold.len() < generators.len() { + verifier.0.h_bold.push(Scalar::ZERO); } let re = proof.r_answer * e; @@ -421,23 +380,18 @@ impl WipStatement { if i > 0 { scalar *= inv_y[i - 1]; } - multiexp.push((scalar, generators.generator(GeneratorsList::GBold1, i))); + verifier.0.g_bold[i] += verifier_weight * scalar; } let se = proof.s_answer * e; for i in 0 .. generators.len() { - multiexp.push(( - se * product_cache[product_cache.len() - 1 - i], - generators.generator(GeneratorsList::HBold1, i), - )); + verifier.0.h_bold[i] += verifier_weight * (se * product_cache[product_cache.len() - 1 - i]); } - multiexp.push((-e, proof.A)); - multiexp.push((proof.r_answer * y[0] * proof.s_answer, g)); - multiexp.push((proof.delta_answer, h)); - multiexp.push((-Scalar::ONE, proof.B)); - - verifier.queue(rng, id, multiexp); + verifier.0.other.push((verifier_weight * -e, proof.A)); + verifier.0.g += verifier_weight * (proof.r_answer * y[0] * proof.s_answer); + verifier.0.h += verifier_weight * proof.delta_answer; + verifier.0.other.push((-verifier_weight, proof.B)); true } diff --git a/coins/monero/src/ringct/bulletproofs/scalar_vector.rs b/coins/monero/ringct/bulletproofs/src/scalar_vector.rs similarity index 96% rename from coins/monero/src/ringct/bulletproofs/scalar_vector.rs rename to coins/monero/ringct/bulletproofs/src/scalar_vector.rs index e62883672..ae723a427 100644 --- a/coins/monero/src/ringct/bulletproofs/scalar_vector.rs +++ b/coins/monero/ringct/bulletproofs/src/scalar_vector.rs @@ -2,13 +2,13 @@ use core::{ borrow::Borrow, ops::{Index, IndexMut, Add, Sub, Mul}, }; -use std_shims::vec::Vec; +use std_shims::{vec, vec::Vec}; use zeroize::{Zeroize, ZeroizeOnDrop}; -use group::ff::Field; -use dalek_ff_group::{Scalar, EdwardsPoint}; -use multiexp::multiexp; +use curve25519_dalek::{scalar::Scalar, edwards::EdwardsPoint}; + +use crate::core::multiexp; #[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] pub(crate) struct ScalarVector(pub(crate) Vec); diff --git a/coins/monero/src/tests/bulletproofs/mod.rs b/coins/monero/ringct/bulletproofs/src/tests/mod.rs similarity index 77% rename from coins/monero/src/tests/bulletproofs/mod.rs rename to coins/monero/ringct/bulletproofs/src/tests/mod.rs index 6c2762063..45a04362e 100644 --- a/coins/monero/src/tests/bulletproofs/mod.rs +++ b/coins/monero/ringct/bulletproofs/src/tests/mod.rs @@ -2,13 +2,11 @@ use hex_literal::hex; use rand_core::OsRng; use curve25519_dalek::scalar::Scalar; -use monero_generators::decompress_point; -use multiexp::BatchVerifier; -use crate::{ - Commitment, random_scalar, - ringct::bulletproofs::{Bulletproofs, original::OriginalStruct}, -}; +use monero_io::decompress_point; +use monero_primitives::Commitment; + +use crate::{batch_verifier::BatchVerifier, original::OriginalStruct, Bulletproof, BulletproofError}; mod plus; @@ -18,12 +16,12 @@ fn bulletproofs_vector() { let point = |point| decompress_point(point).unwrap(); // Generated from Monero - assert!(Bulletproofs::Original(OriginalStruct { + assert!(Bulletproof::Original(OriginalStruct { A: point(hex!("ef32c0b9551b804decdcb107eb22aa715b7ce259bf3c5cac20e24dfa6b28ac71")), S: point(hex!("e1285960861783574ee2b689ae53622834eb0b035d6943103f960cd23e063fa0")), T1: point(hex!("4ea07735f184ba159d0e0eb662bac8cde3eb7d39f31e567b0fbda3aa23fe5620")), T2: point(hex!("b8390aa4b60b255630d40e592f55ec6b7ab5e3a96bfcdcd6f1cd1d2fc95f441e")), - taux: scalar(hex!("5957dba8ea9afb23d6e81cc048a92f2d502c10c749dc1b2bd148ae8d41ec7107")), + tau_x: scalar(hex!("5957dba8ea9afb23d6e81cc048a92f2d502c10c749dc1b2bd148ae8d41ec7107")), mu: scalar(hex!("923023b234c2e64774b820b4961f7181f6c1dc152c438643e5a25b0bf271bc02")), L: vec![ point(hex!("c45f656316b9ebf9d357fb6a9f85b5f09e0b991dd50a6e0ae9b02de3946c9d99")), @@ -64,19 +62,23 @@ macro_rules! bulletproofs_tests { #[test] fn $name() { // Create Bulletproofs for all possible output quantities - let mut verifier = BatchVerifier::new(16); + let mut verifier = BatchVerifier::new(); for i in 1 ..= 16 { let commitments = (1 ..= i) - .map(|i| Commitment::new(random_scalar(&mut OsRng), u64::try_from(i).unwrap())) + .map(|i| Commitment::new(Scalar::random(&mut OsRng), u64::try_from(i).unwrap())) .collect::>(); - let bp = Bulletproofs::prove(&mut OsRng, &commitments, $plus).unwrap(); + let bp = if $plus { + Bulletproof::prove_plus(&mut OsRng, commitments.clone()).unwrap() + } else { + Bulletproof::prove(&mut OsRng, &commitments).unwrap() + }; let commitments = commitments.iter().map(Commitment::calculate).collect::>(); assert!(bp.verify(&mut OsRng, &commitments)); - assert!(bp.batch_verify(&mut OsRng, &mut verifier, i, &commitments)); + assert!(bp.batch_verify(&mut OsRng, &mut verifier, &commitments)); } - assert!(verifier.verify_vartime()); + assert!(verifier.verify()); } #[test] @@ -86,7 +88,15 @@ macro_rules! bulletproofs_tests { for _ in 0 .. 17 { commitments.push(Commitment::new(Scalar::ZERO, 0)); } - assert!(Bulletproofs::prove(&mut OsRng, &commitments, $plus).is_err()); + assert_eq!( + (if $plus { + Bulletproof::prove_plus(&mut OsRng, commitments) + } else { + Bulletproof::prove(&mut OsRng, &commitments) + }) + .unwrap_err(), + BulletproofError::TooManyCommitments, + ); } }; } diff --git a/coins/monero/ringct/bulletproofs/src/tests/plus/aggregate_range_proof.rs b/coins/monero/ringct/bulletproofs/src/tests/plus/aggregate_range_proof.rs new file mode 100644 index 000000000..fc5d429e8 --- /dev/null +++ b/coins/monero/ringct/bulletproofs/src/tests/plus/aggregate_range_proof.rs @@ -0,0 +1,28 @@ +use rand_core::{RngCore, OsRng}; + +use curve25519_dalek::Scalar; + +use monero_primitives::Commitment; + +use crate::{ + batch_verifier::BulletproofsPlusBatchVerifier, + plus::aggregate_range_proof::{AggregateRangeStatement, AggregateRangeWitness}, +}; + +#[test] +fn test_aggregate_range_proof() { + let mut verifier = BulletproofsPlusBatchVerifier::default(); + for m in 1 ..= 16 { + let mut commitments = vec![]; + for _ in 0 .. m { + commitments.push(Commitment::new(Scalar::random(&mut OsRng), OsRng.next_u64())); + } + let commitment_points = commitments.iter().map(Commitment::calculate).collect(); + let statement = AggregateRangeStatement::new(commitment_points).unwrap(); + let witness = AggregateRangeWitness::new(commitments).unwrap(); + + let proof = statement.clone().prove(&mut OsRng, &witness).unwrap(); + statement.verify(&mut OsRng, &mut verifier, proof); + } + assert!(verifier.verify()); +} diff --git a/coins/monero/src/tests/bulletproofs/plus/mod.rs b/coins/monero/ringct/bulletproofs/src/tests/plus/mod.rs similarity index 100% rename from coins/monero/src/tests/bulletproofs/plus/mod.rs rename to coins/monero/ringct/bulletproofs/src/tests/plus/mod.rs diff --git a/coins/monero/src/tests/bulletproofs/plus/weighted_inner_product.rs b/coins/monero/ringct/bulletproofs/src/tests/plus/weighted_inner_product.rs similarity index 66% rename from coins/monero/src/tests/bulletproofs/plus/weighted_inner_product.rs rename to coins/monero/ringct/bulletproofs/src/tests/plus/weighted_inner_product.rs index b0890cf87..eaa00cd35 100644 --- a/coins/monero/src/tests/bulletproofs/plus/weighted_inner_product.rs +++ b/coins/monero/ringct/bulletproofs/src/tests/plus/weighted_inner_product.rs @@ -2,13 +2,14 @@ use rand_core::OsRng; -use multiexp::BatchVerifier; -use group::{ff::Field, Group}; -use dalek_ff_group::{Scalar, EdwardsPoint}; +use curve25519_dalek::{traits::Identity, scalar::Scalar, edwards::EdwardsPoint}; -use crate::ringct::bulletproofs::plus::{ - ScalarVector, PointVector, GeneratorsList, Generators, - weighted_inner_product::{WipStatement, WipWitness}, +use crate::{ + batch_verifier::BulletproofsPlusBatchVerifier, + plus::{ + ScalarVector, PointVector, GeneratorsList, BpPlusGenerators, + weighted_inner_product::{WipStatement, WipWitness}, + }, }; #[test] @@ -17,33 +18,33 @@ fn test_zero_weighted_inner_product() { let P = EdwardsPoint::identity(); let y = Scalar::random(&mut OsRng); - let generators = Generators::new().reduce(1); + let generators = BpPlusGenerators::new().reduce(1); let statement = WipStatement::new(generators, P, y); let witness = WipWitness::new(ScalarVector::new(1), ScalarVector::new(1), Scalar::ZERO).unwrap(); let transcript = Scalar::random(&mut OsRng); let proof = statement.clone().prove(&mut OsRng, transcript, &witness).unwrap(); - let mut verifier = BatchVerifier::new(1); - statement.verify(&mut OsRng, &mut verifier, (), transcript, proof); - assert!(verifier.verify_vartime()); + let mut verifier = BulletproofsPlusBatchVerifier::default(); + statement.verify(&mut OsRng, &mut verifier, transcript, proof); + assert!(verifier.verify()); } #[test] fn test_weighted_inner_product() { // P = sum(g_bold * a, h_bold * b, g * (a * y * b), h * alpha) - let mut verifier = BatchVerifier::new(6); - let generators = Generators::new(); + let mut verifier = BulletproofsPlusBatchVerifier::default(); + let generators = BpPlusGenerators::new(); for i in [1, 2, 4, 8, 16, 32] { let generators = generators.reduce(i); - let g = Generators::g(); - let h = Generators::h(); + let g = BpPlusGenerators::g(); + let h = BpPlusGenerators::h(); assert_eq!(generators.len(), i); let mut g_bold = vec![]; let mut h_bold = vec![]; for i in 0 .. i { - g_bold.push(generators.generator(GeneratorsList::GBold1, i)); - h_bold.push(generators.generator(GeneratorsList::HBold1, i)); + g_bold.push(generators.generator(GeneratorsList::GBold, i)); + h_bold.push(generators.generator(GeneratorsList::HBold, i)); } let g_bold = PointVector(g_bold); let h_bold = PointVector(h_bold); @@ -75,7 +76,7 @@ fn test_weighted_inner_product() { let transcript = Scalar::random(&mut OsRng); let proof = statement.clone().prove(&mut OsRng, transcript, &witness).unwrap(); - statement.verify(&mut OsRng, &mut verifier, (), transcript, proof); + statement.verify(&mut OsRng, &mut verifier, transcript, proof); } - assert!(verifier.verify_vartime()); + assert!(verifier.verify()); } diff --git a/coins/monero/ringct/clsag/Cargo.toml b/coins/monero/ringct/clsag/Cargo.toml new file mode 100644 index 000000000..59aaff573 --- /dev/null +++ b/coins/monero/ringct/clsag/Cargo.toml @@ -0,0 +1,65 @@ +[package] +name = "monero-clsag" +version = "0.1.0" +description = "The CLSAG linkable ring signature, as defined by the Monero protocol" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/ringct/clsag" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +thiserror = { version = "1", default-features = false, optional = true } + +rand_core = { version = "0.6", default-features = false } +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } +subtle = { version = "^2.4", default-features = false } + +# Cryptographic dependencies +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +# Multisig dependencies +rand_chacha = { version = "0.3", default-features = false, optional = true } +transcript = { package = "flexible-transcript", path = "../../../../crypto/transcript", version = "0.3", default-features = false, features = ["recommended"], optional = true } +group = { version = "0.13", default-features = false, optional = true } +dalek-ff-group = { path = "../../../../crypto/dalek-ff-group", version = "0.4", default-features = false, optional = true } +frost = { package = "modular-frost", path = "../../../../crypto/frost", default-features = false, features = ["ed25519"], optional = true } + +# Other Monero dependencies +monero-io = { path = "../../io", version = "0.1", default-features = false } +monero-generators = { path = "../../generators", version = "0.4", default-features = false } +monero-primitives = { path = "../../primitives", version = "0.1", default-features = false } + +[dev-dependencies] +frost = { package = "modular-frost", path = "../../../../crypto/frost", default-features = false, features = ["ed25519", "tests"] } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "rand_core/std", + "zeroize/std", + "subtle/std", + + "rand_chacha?/std", + "transcript?/std", + "group?/alloc", + "dalek-ff-group?/std", + + "monero-io/std", + "monero-generators/std", + "monero-primitives/std", +] +multisig = ["rand_chacha", "transcript", "group", "dalek-ff-group", "frost", "std"] +default = ["std"] diff --git a/coins/monero/ringct/clsag/LICENSE b/coins/monero/ringct/clsag/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/ringct/clsag/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/ringct/clsag/README.md b/coins/monero/ringct/clsag/README.md new file mode 100644 index 000000000..4b90c86c3 --- /dev/null +++ b/coins/monero/ringct/clsag/README.md @@ -0,0 +1,15 @@ +# Monero CLSAG + +The CLSAG linkable ring signature, as defined by the Monero protocol. + +Additionally included is a FROST-inspired threshold multisignature algorithm. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). +- `multisig`: Provides a FROST-inspired threshold multisignature algorithm for + use. diff --git a/coins/monero/src/ringct/clsag/mod.rs b/coins/monero/ringct/clsag/src/lib.rs similarity index 54% rename from coins/monero/src/ringct/clsag/mod.rs rename to coins/monero/ringct/clsag/src/lib.rs index 042d964ac..0aab537b3 100644 --- a/coins/monero/src/ringct/clsag/mod.rs +++ b/coins/monero/ringct/clsag/src/lib.rs @@ -1,7 +1,12 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] #![allow(non_snake_case)] use core::ops::Deref; use std_shims::{ + vec, vec::Vec, io::{self, Read, Write}, }; @@ -18,64 +23,67 @@ use curve25519_dalek::{ edwards::{EdwardsPoint, VartimeEdwardsPrecomputation}, }; -use crate::{ - INV_EIGHT, BASEPOINT_PRECOMP, Commitment, random_scalar, hash_to_scalar, wallet::decoys::Decoys, - ringct::hash_to_point, serialize::*, -}; +use monero_io::*; +use monero_generators::hash_to_point; +use monero_primitives::{INV_EIGHT, G_PRECOMP, Commitment, Decoys, keccak256_to_scalar}; #[cfg(feature = "multisig")] mod multisig; #[cfg(feature = "multisig")] -pub use multisig::{ClsagDetails, ClsagAddendum, ClsagMultisig}; +pub use multisig::{ClsagMultisigMaskSender, ClsagAddendum, ClsagMultisig}; + +#[cfg(all(feature = "std", test))] +mod tests; -/// Errors returned when CLSAG signing fails. +/// Errors when working with CLSAGs. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(feature = "std", derive(thiserror::Error))] pub enum ClsagError { - #[cfg_attr(feature = "std", error("internal error ({0})"))] - InternalError(&'static str), + /// The ring was invalid (such as being too small or too large). #[cfg_attr(feature = "std", error("invalid ring"))] InvalidRing, - #[cfg_attr(feature = "std", error("invalid ring member (member {0}, ring size {1})"))] - InvalidRingMember(u8, u8), + /// The discrete logarithm of the key, scaling G, wasn't equivalent to the signing ring member. + #[cfg_attr(feature = "std", error("invalid commitment"))] + InvalidKey, + /// The commitment opening provided did not match the ring member's. #[cfg_attr(feature = "std", error("invalid commitment"))] InvalidCommitment, + /// The key image was invalid (such as being identity or torsioned) #[cfg_attr(feature = "std", error("invalid key image"))] InvalidImage, + /// The `D` component was invalid. #[cfg_attr(feature = "std", error("invalid D"))] InvalidD, + /// The `s` vector was invalid. #[cfg_attr(feature = "std", error("invalid s"))] InvalidS, + /// The `c1` variable was invalid. #[cfg_attr(feature = "std", error("invalid c1"))] InvalidC1, } -/// Input being signed for. +/// Context on the input being signed for. #[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct ClsagInput { - // The actual commitment for the true spend - pub(crate) commitment: Commitment, - // True spend index, offsets, and ring - pub(crate) decoys: Decoys, +pub struct ClsagContext { + // The opening for the commitment of the signing ring member + commitment: Commitment, + // Selected ring members' positions, signer index, and ring + decoys: Decoys, } -impl ClsagInput { - pub fn new(commitment: Commitment, decoys: Decoys) -> Result { - let n = decoys.len(); - if n > u8::MAX.into() { - Err(ClsagError::InternalError("max ring size in this library is u8 max"))?; - } - let n = u8::try_from(n).unwrap(); - if decoys.i >= n { - Err(ClsagError::InvalidRingMember(decoys.i, n))?; +impl ClsagContext { + /// Create a new context, as necessary for signing. + pub fn new(decoys: Decoys, commitment: Commitment) -> Result { + if decoys.len() > u8::MAX.into() { + Err(ClsagError::InvalidRing)?; } // Validate the commitment matches - if decoys.ring[usize::from(decoys.i)][1] != commitment.calculate() { + if decoys.signer_ring_members()[1] != commitment.calculate() { Err(ClsagError::InvalidCommitment)?; } - Ok(ClsagInput { commitment, decoys }) + Ok(ClsagContext { commitment, decoys }) } } @@ -86,6 +94,7 @@ enum Mode { } // Core of the CLSAG algorithm, applicable to both sign and verify with minimal differences +// // Said differences are covered via the above Mode fn core( ring: &[[EdwardsPoint; 2]], @@ -134,10 +143,10 @@ fn core( to_hash.extend(D_INV_EIGHT.compress().to_bytes()); to_hash.extend(pseudo_out.compress().to_bytes()); // mu_P with agg_0 - let mu_P = hash_to_scalar(&to_hash); + let mu_P = keccak256_to_scalar(&to_hash); // mu_C with agg_1 to_hash[PREFIX_AGG_0_LEN - 1] = b'1'; - let mu_C = hash_to_scalar(&to_hash); + let mu_C = keccak256_to_scalar(&to_hash); // Truncate it for the round transcript, altering the DST as needed to_hash.truncate(((2 * n) + 1) * 32); @@ -159,7 +168,7 @@ fn core( end = r + n; to_hash.extend(A.compress().to_bytes()); to_hash.extend(AH.compress().to_bytes()); - c = hash_to_scalar(&to_hash); + c = keccak256_to_scalar(&to_hash); } Mode::Verify(c1) => { @@ -181,11 +190,11 @@ fn core( EdwardsPoint::multiscalar_mul([s[i], c_p, c_c], [ED25519_BASEPOINT_POINT, P[i], C[i]]) } Mode::Verify(..) => { - BASEPOINT_PRECOMP().vartime_mixed_multiscalar_mul([s[i]], [c_p, c_c], [P[i], C[i]]) + G_PRECOMP().vartime_mixed_multiscalar_mul([s[i]], [c_p, c_c], [P[i], C[i]]) } }; - let PH = hash_to_point(&P[i]); + let PH = hash_to_point(P[i].compress().0); // (c_p * I) + (c_c * D) + (s_i * PH) let R = match A_c1 { @@ -198,7 +207,7 @@ fn core( to_hash.truncate(((2 * n) + 3) * 32); to_hash.extend(L.compress().to_bytes()); to_hash.extend(R.compress().to_bytes()); - c = hash_to_scalar(&to_hash); + c = keccak256_to_scalar(&to_hash); // This will only execute once and shouldn't need to be constant time. Making it constant time // removes the risk of branch prediction creating timing differences depending on ring index @@ -210,91 +219,142 @@ fn core( ((D_INV_EIGHT, c * mu_P, c * mu_C), c1) } -/// CLSAG signature, as used in Monero. +/// The CLSAG signature, as used in Monero. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Clsag { + /// The difference of the commitment randomnesses, scaling the key image generator. pub D: EdwardsPoint, + /// The responses for each ring member. pub s: Vec, + /// The first challenge in the ring. pub c1: Scalar, } +struct ClsagSignCore { + incomplete_clsag: Clsag, + pseudo_out: EdwardsPoint, + key_challenge: Scalar, + challenged_mask: Scalar, +} + impl Clsag { // Sign core is the extension of core as needed for signing, yet is shared between single signer // and multisig, hence why it's still core - pub(crate) fn sign_core( + fn sign_core( rng: &mut R, I: &EdwardsPoint, - input: &ClsagInput, + input: &ClsagContext, mask: Scalar, msg: &[u8; 32], A: EdwardsPoint, AH: EdwardsPoint, - ) -> (Clsag, EdwardsPoint, Scalar, Scalar) { - let r: usize = input.decoys.i.into(); + ) -> ClsagSignCore { + let r: usize = input.decoys.signer_index().into(); let pseudo_out = Commitment::new(mask, input.commitment.amount).calculate(); - let z = input.commitment.mask - mask; + let mask_delta = input.commitment.mask - mask; - let H = hash_to_point(&input.decoys.ring[r][0]); - let D = H * z; - let mut s = Vec::with_capacity(input.decoys.ring.len()); - for _ in 0 .. input.decoys.ring.len() { - s.push(random_scalar(rng)); + let H = hash_to_point(input.decoys.ring()[r][0].compress().0); + let D = H * mask_delta; + let mut s = Vec::with_capacity(input.decoys.ring().len()); + for _ in 0 .. input.decoys.ring().len() { + s.push(Scalar::random(rng)); + } + let ((D, c_p, c_c), c1) = + core(input.decoys.ring(), I, &pseudo_out, msg, &D, &s, &Mode::Sign(r, A, AH)); + + ClsagSignCore { + incomplete_clsag: Clsag { D, s, c1 }, + pseudo_out, + key_challenge: c_p, + challenged_mask: c_c * mask_delta, } - let ((D, p, c), c1) = - core(&input.decoys.ring, I, &pseudo_out, msg, &D, &s, &Mode::Sign(r, A, AH)); - - (Clsag { D, s, c1 }, pseudo_out, p, c * z) } - /// Generate CLSAG signatures for the given inputs. - /// inputs is of the form (private key, key image, input). - /// sum_outputs is for the sum of the outputs' commitment masks. + /// Sign CLSAG signatures for the provided inputs. + /// + /// Monero ensures the rerandomized input commitments have the same value as the outputs by + /// checking `sum(rerandomized_input_commitments) - sum(output_commitments) == 0`. This requires + /// not only the amounts balance, yet also + /// `sum(input_commitment_masks) - sum(output_commitment_masks)`. + /// + /// Monero solves this by following the wallet protocol to determine each output commitment's + /// randomness, then using random masks for all but the last input. The last input is + /// rerandomized to the necessary mask for the equation to balance. + /// + /// Due to Monero having this behavior, it only makes sense to sign CLSAGs as a list, hence this + /// API being the way it is. + /// + /// `inputs` is of the form (discrete logarithm of the key, context). + /// + /// `sum_outputs` is for the sum of the output commitments' masks. pub fn sign( rng: &mut R, - mut inputs: Vec<(Zeroizing, EdwardsPoint, ClsagInput)>, + mut inputs: Vec<(Zeroizing, ClsagContext)>, sum_outputs: Scalar, msg: [u8; 32], - ) -> Vec<(Clsag, EdwardsPoint)> { + ) -> Result, ClsagError> { + // Create the key images + let mut key_image_generators = vec![]; + let mut key_images = vec![]; + for input in &inputs { + let key = input.1.decoys.signer_ring_members()[0]; + + // Check the key is consistent + if (ED25519_BASEPOINT_TABLE * input.0.deref()) != key { + Err(ClsagError::InvalidKey)?; + } + + let key_image_generator = hash_to_point(key.compress().0); + key_image_generators.push(key_image_generator); + key_images.push(key_image_generator * input.0.deref()); + } + let mut res = Vec::with_capacity(inputs.len()); let mut sum_pseudo_outs = Scalar::ZERO; for i in 0 .. inputs.len() { - let mut mask = random_scalar(rng); + let mask; + // If this is the last input, set the mask as described above if i == (inputs.len() - 1) { mask = sum_outputs - sum_pseudo_outs; } else { + mask = Scalar::random(rng); sum_pseudo_outs += mask; } - let mut nonce = Zeroizing::new(random_scalar(rng)); - let (mut clsag, pseudo_out, p, c) = Clsag::sign_core( - rng, - &inputs[i].1, - &inputs[i].2, - mask, - &msg, - nonce.deref() * ED25519_BASEPOINT_TABLE, - nonce.deref() * - hash_to_point(&inputs[i].2.decoys.ring[usize::from(inputs[i].2.decoys.i)][0]), - ); - // Effectively r - cx, except cx is (c_p x) + (c_c z), where z is the delta between a ring - // member's commitment and our input commitment (which will only have a known discrete log - // over G if the amounts cancel out) - clsag.s[usize::from(inputs[i].2.decoys.i)] = nonce.deref() - ((p * inputs[i].0.deref()) + c); + let mut nonce = Zeroizing::new(Scalar::random(rng)); + let ClsagSignCore { mut incomplete_clsag, pseudo_out, key_challenge, challenged_mask } = + Clsag::sign_core( + rng, + &key_images[i], + &inputs[i].1, + mask, + &msg, + nonce.deref() * ED25519_BASEPOINT_TABLE, + nonce.deref() * key_image_generators[i], + ); + // Effectively r - c x, except c x is (c_p x) + (c_c z), where z is the delta between the + // ring member's commitment and our pseudo-out commitment (which will only have a known + // discrete log over G if the amounts cancel out) + incomplete_clsag.s[usize::from(inputs[i].1.decoys.signer_index())] = + nonce.deref() - ((key_challenge * inputs[i].0.deref()) + challenged_mask); + let clsag = incomplete_clsag; + + // Zeroize private keys and nonces. inputs[i].0.zeroize(); nonce.zeroize(); debug_assert!(clsag - .verify(&inputs[i].2.decoys.ring, &inputs[i].1, &pseudo_out, &msg) + .verify(inputs[i].1.decoys.ring(), &key_images[i], &pseudo_out, &msg) .is_ok()); res.push((clsag, pseudo_out)); } - res + Ok(res) } - /// Verify the CLSAG signature against the given Transaction data. + /// Verify a CLSAG signature for the provided context. pub fn verify( &self, ring: &[[EdwardsPoint; 2]], @@ -302,15 +362,15 @@ impl Clsag { pseudo_out: &EdwardsPoint, msg: &[u8; 32], ) -> Result<(), ClsagError> { - // Preliminary checks. s, c1, and points must also be encoded canonically, which isn't checked - // here + // Preliminary checks + // s, c1, and points must also be encoded canonically, which is checked at time of decode if ring.is_empty() { Err(ClsagError::InvalidRing)?; } if ring.len() != self.s.len() { Err(ClsagError::InvalidS)?; } - if I.is_identity() { + if I.is_identity() || (!I.is_torsion_free()) { Err(ClsagError::InvalidImage)?; } @@ -326,16 +386,14 @@ impl Clsag { Ok(()) } - pub(crate) fn fee_weight(ring_len: usize) -> usize { - (ring_len * 32) + 32 + 32 - } - + /// Write a CLSAG. pub fn write(&self, w: &mut W) -> io::Result<()> { write_raw_vec(write_scalar, &self.s, w)?; w.write_all(&self.c1.to_bytes())?; write_point(&self.D, w) } + /// Read a CLSAG. pub fn read(decoys: usize, r: &mut R) -> io::Result { Ok(Clsag { s: read_raw_vec(read_scalar, decoys, r)?, c1: read_scalar(r)?, D: read_point(r)? }) } diff --git a/coins/monero/src/ringct/clsag/multisig.rs b/coins/monero/ringct/clsag/src/multisig.rs similarity index 61% rename from coins/monero/src/ringct/clsag/multisig.rs rename to coins/monero/ringct/clsag/src/multisig.rs index e9234979d..bfbb8fc55 100644 --- a/coins/monero/src/ringct/clsag/multisig.rs +++ b/coins/monero/ringct/clsag/src/multisig.rs @@ -1,14 +1,14 @@ use core::{ops::Deref, fmt::Debug}; use std_shims::{ + sync::{Arc, Mutex}, io::{self, Read, Write}, collections::HashMap, }; -use std::sync::{Arc, RwLock}; use rand_core::{RngCore, CryptoRng, SeedableRng}; use rand_chacha::ChaCha20Rng; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use zeroize::{Zeroize, Zeroizing}; use curve25519_dalek::{scalar::Scalar, edwards::EdwardsPoint}; @@ -26,23 +26,22 @@ use frost::{ algorithm::{WriteAddendum, Algorithm}, }; -use crate::ringct::{ - hash_to_point, - clsag::{ClsagInput, Clsag}, -}; +use monero_generators::hash_to_point; + +use crate::{ClsagContext, Clsag}; -impl ClsagInput { +impl ClsagContext { fn transcript(&self, transcript: &mut T) { // Doesn't domain separate as this is considered part of the larger CLSAG proof // Ring index - transcript.append_message(b"real_spend", [self.decoys.i]); + transcript.append_message(b"signer_index", [self.decoys.signer_index()]); // Ring - for (i, pair) in self.decoys.ring.iter().enumerate() { - // Doesn't include global output indexes as CLSAG doesn't care and won't be affected by it + for (i, pair) in self.decoys.ring().iter().enumerate() { + // Doesn't include global output indexes as CLSAG doesn't care/won't be affected by it // They're just a unreliable reference to this data which will be included in the message - // if in use + // if somehow relevant transcript.append_message(b"member", [u8::try_from(i).expect("ring size exceeded 255")]); // This also transcripts the key image generator since it's derived from this key transcript.append_message(b"key", pair[0].compress().to_bytes()); @@ -50,33 +49,56 @@ impl ClsagInput { } // Doesn't include the commitment's parts as the above ring + index includes the commitment - // The only potential malleability would be if the G/H relationship is known breaking the + // The only potential malleability would be if the G/H relationship is known, breaking the // discrete log problem, which breaks everything already } } -/// CLSAG input and the mask to use for it. -#[derive(Clone, Debug, Zeroize, ZeroizeOnDrop)] -pub struct ClsagDetails { - input: ClsagInput, - mask: Scalar, +/// A channel to send the mask to use for the pseudo-out (rerandomized commitment) with. +/// +/// A mask must be sent along this channel before any preprocess addendums are handled. Breaking +/// this rule will cause a panic. +#[derive(Clone, Debug)] +pub struct ClsagMultisigMaskSender { + buf: Arc>>, } +#[derive(Clone, Debug)] +struct ClsagMultisigMaskReceiver { + buf: Arc>>, +} +impl ClsagMultisigMaskSender { + fn new() -> (ClsagMultisigMaskSender, ClsagMultisigMaskReceiver) { + let buf = Arc::new(Mutex::new(None)); + (ClsagMultisigMaskSender { buf: buf.clone() }, ClsagMultisigMaskReceiver { buf }) + } -impl ClsagDetails { - pub fn new(input: ClsagInput, mask: Scalar) -> ClsagDetails { - ClsagDetails { input, mask } + /// Send a mask to a CLSAG multisig instance. + pub fn send(self, mask: Scalar) { + *self.buf.lock() = Some(mask); + } +} +impl ClsagMultisigMaskReceiver { + fn recv(self) -> Scalar { + self.buf.lock().unwrap() } } -/// Addendum produced during the FROST signing process with relevant data. +/// Addendum produced during the signing process. #[derive(Clone, PartialEq, Eq, Zeroize, Debug)] pub struct ClsagAddendum { - pub(crate) key_image: dfg::EdwardsPoint, + key_image_share: dfg::EdwardsPoint, +} + +impl ClsagAddendum { + /// The key image share within this addendum. + pub fn key_image_share(&self) -> dfg::EdwardsPoint { + self.key_image_share + } } impl WriteAddendum for ClsagAddendum { fn write(&self, writer: &mut W) -> io::Result<()> { - writer.write_all(self.key_image.compress().to_bytes().as_ref()) + writer.write_all(self.key_image_share.compress().to_bytes().as_ref()) } } @@ -90,66 +112,83 @@ struct Interim { pseudo_out: EdwardsPoint, } -/// FROST algorithm for producing a CLSAG signature. +/// FROST-inspired algorithm for producing a CLSAG signature. +/// +/// Before this has its `process_addendum` called, a mask must be set. Else this will panic. +/// +/// The message signed is expected to be a 32-byte value. Per Monero, it's the keccak256 hash of +/// the transaction data which is signed. This will panic if the message is not a 32-byte value. #[allow(non_snake_case)] #[derive(Clone, Debug)] pub struct ClsagMultisig { transcript: RecommendedTranscript, - pub(crate) H: EdwardsPoint, + key_image_generator: EdwardsPoint, key_image_shares: HashMap<[u8; 32], dfg::EdwardsPoint>, image: Option, - details: Arc>>, + context: ClsagContext, + + mask_recv: Option, + mask: Option, msg: Option<[u8; 32]>, interim: Option, } impl ClsagMultisig { + /// Construct a new instance of multisignature CLSAG signing. pub fn new( transcript: RecommendedTranscript, - output_key: EdwardsPoint, - details: Arc>>, - ) -> ClsagMultisig { - ClsagMultisig { - transcript, - - H: hash_to_point(&output_key), - key_image_shares: HashMap::new(), - image: None, - - details, - - msg: None, - interim: None, - } + context: ClsagContext, + ) -> (ClsagMultisig, ClsagMultisigMaskSender) { + let (mask_send, mask_recv) = ClsagMultisigMaskSender::new(); + ( + ClsagMultisig { + transcript, + + key_image_generator: hash_to_point(context.decoys.signer_ring_members()[0].compress().0), + key_image_shares: HashMap::new(), + image: None, + + context, + + mask_recv: Some(mask_recv), + mask: None, + + msg: None, + interim: None, + }, + mask_send, + ) } - fn input(&self) -> ClsagInput { - (*self.details.read().unwrap()).as_ref().unwrap().input.clone() - } - - fn mask(&self) -> Scalar { - (*self.details.read().unwrap()).as_ref().unwrap().mask + /// The key image generator used by the signer. + pub fn key_image_generator(&self) -> EdwardsPoint { + self.key_image_generator } } impl Algorithm for ClsagMultisig { type Transcript = RecommendedTranscript; type Addendum = ClsagAddendum; + // We output the CLSAG and the key image, which requires an interactive protocol to obtain type Signature = (Clsag, EdwardsPoint); + // We need the nonce represented against both G and the key image generator fn nonces(&self) -> Vec> { - vec![vec![dfg::EdwardsPoint::generator(), dfg::EdwardsPoint(self.H)]] + vec![vec![dfg::EdwardsPoint::generator(), dfg::EdwardsPoint(self.key_image_generator)]] } + // We also publish our share of the key image fn preprocess_addendum( &mut self, _rng: &mut R, keys: &ThresholdKeys, ) -> ClsagAddendum { - ClsagAddendum { key_image: dfg::EdwardsPoint(self.H) * keys.secret_share().deref() } + ClsagAddendum { + key_image_share: dfg::EdwardsPoint(self.key_image_generator) * keys.secret_share().deref(), + } } fn read_addendum(&self, reader: &mut R) -> io::Result { @@ -163,7 +202,7 @@ impl Algorithm for ClsagMultisig { Err(io::Error::other("non-canonical key image"))?; } - Ok(ClsagAddendum { key_image: xH }) + Ok(ClsagAddendum { key_image_share: xH }) } fn process_addendum( @@ -175,21 +214,27 @@ impl Algorithm for ClsagMultisig { if self.image.is_none() { self.transcript.domain_separate(b"CLSAG"); // Transcript the ring - self.input().transcript(&mut self.transcript); + self.context.transcript(&mut self.transcript); + // Fetch the mask from the Mutex + // We set it to a variable to ensure our view of it is consistent + // It was this or a mpsc channel... std doesn't have oneshot :/ + self.mask = Some(self.mask_recv.take().unwrap().recv()); // Transcript the mask - self.transcript.append_message(b"mask", self.mask().to_bytes()); + self.transcript.append_message(b"mask", self.mask.expect("mask wasn't set").to_bytes()); // Init the image to the offset - self.image = Some(dfg::EdwardsPoint(self.H) * view.offset()); + self.image = Some(dfg::EdwardsPoint(self.key_image_generator) * view.offset()); } // Transcript this participant's contribution self.transcript.append_message(b"participant", l.to_bytes()); - self.transcript.append_message(b"key_image_share", addendum.key_image.compress().to_bytes()); + self + .transcript + .append_message(b"key_image_share", addendum.key_image_share.compress().to_bytes()); // Accumulate the interpolated share let interpolated_key_image_share = - addendum.key_image * lagrange::(l, view.included()); + addendum.key_image_share * lagrange::(l, view.included()); *self.image.as_mut().unwrap() += interpolated_key_image_share; self @@ -211,28 +256,34 @@ impl Algorithm for ClsagMultisig { msg: &[u8], ) -> dfg::Scalar { // Use the transcript to get a seeded random number generator + // // The transcript contains private data, preventing passive adversaries from recreating this - // process even if they have access to commitments (specifically, the ring index being signed - // for, along with the mask which should not only require knowing the shared keys yet also the - // input commitment masks) + // process even if they have access to the commitments/key image share broadcast so far + // + // Specifically, the transcript contains the signer's index within the ring, along with the + // opening of the commitment being re-randomized (and what it's re-randomized to) let mut rng = ChaCha20Rng::from_seed(self.transcript.rng_seed(b"decoy_responses")); self.msg = Some(msg.try_into().expect("CLSAG message should be 32-bytes")); - #[allow(non_snake_case)] - let (clsag, pseudo_out, p, c) = Clsag::sign_core( + let sign_core = Clsag::sign_core( &mut rng, &self.image.expect("verifying a share despite never processing any addendums").0, - &self.input(), - self.mask(), + &self.context, + self.mask.expect("mask wasn't set"), self.msg.as_ref().unwrap(), nonce_sums[0][0].0, nonce_sums[0][1].0, ); - self.interim = Some(Interim { p, c, clsag, pseudo_out }); + self.interim = Some(Interim { + p: sign_core.key_challenge, + c: sign_core.challenged_mask, + clsag: sign_core.incomplete_clsag, + pseudo_out: sign_core.pseudo_out, + }); // r - p x, where p is the challenge for the keys - *nonces[0] - dfg::Scalar(p) * view.secret_share().deref() + *nonces[0] - dfg::Scalar(sign_core.key_challenge) * view.secret_share().deref() } #[must_use] @@ -244,12 +295,12 @@ impl Algorithm for ClsagMultisig { ) -> Option { let interim = self.interim.as_ref().unwrap(); let mut clsag = interim.clsag.clone(); - // We produced shares as `r - p x`, yet the signature is `r - p x - c x` + // We produced shares as `r - p x`, yet the signature is actually `r - p x - c x` // Substract `c x` (saved as `c`) now - clsag.s[usize::from(self.input().decoys.i)] = sum.0 - interim.c; + clsag.s[usize::from(self.context.decoys.signer_index())] = sum.0 - interim.c; if clsag .verify( - &self.input().decoys.ring, + self.context.decoys.ring(), &self.image.expect("verifying a signature despite never processing any addendums").0, &interim.pseudo_out, self.msg.as_ref().unwrap(), @@ -293,11 +344,11 @@ impl Algorithm for ClsagMultisig { let key_image_share = self.key_image_shares[&verification_share.to_bytes()]; - // Hash every variable relevant here, using the hahs output as the random weight + // Hash every variable relevant here, using the hash output as the random weight let mut weight_transcript = RecommendedTranscript::new(b"monero-serai v0.1 ClsagMultisig::verify_share"); weight_transcript.append_message(b"G", dfg::EdwardsPoint::generator().to_bytes()); - weight_transcript.append_message(b"H", self.H.to_bytes()); + weight_transcript.append_message(b"H", self.key_image_generator.to_bytes()); weight_transcript.append_message(b"xG", verification_share.to_bytes()); weight_transcript.append_message(b"xH", key_image_share.to_bytes()); weight_transcript.append_message(b"rG", nonces[0][0].to_bytes()); @@ -315,7 +366,7 @@ impl Algorithm for ClsagMultisig { ]; let mut part_two = vec![ - (weight * share, dfg::EdwardsPoint(self.H)), + (weight * share, dfg::EdwardsPoint(self.key_image_generator)), // -(R.1 - pK) == -R.1 + pK (-weight, nonces[0][1]), (weight * dfg::Scalar(interim.p), key_image_share), diff --git a/coins/monero/src/tests/clsag.rs b/coins/monero/ringct/clsag/src/tests.rs similarity index 62% rename from coins/monero/src/tests/clsag.rs rename to coins/monero/ringct/clsag/src/tests.rs index a17d7ba27..ba71d69cd 100644 --- a/coins/monero/src/tests/clsag.rs +++ b/coins/monero/ringct/clsag/src/tests.rs @@ -1,6 +1,4 @@ use core::ops::Deref; -#[cfg(feature = "multisig")] -use std::sync::{Arc, RwLock}; use zeroize::Zeroizing; use rand_core::{RngCore, OsRng}; @@ -12,16 +10,11 @@ use transcript::{Transcript, RecommendedTranscript}; #[cfg(feature = "multisig")] use frost::curve::Ed25519; -use crate::{ - Commitment, random_scalar, - wallet::Decoys, - ringct::{ - generate_key_image, - clsag::{ClsagInput, Clsag}, - }, -}; +use monero_generators::hash_to_point; +use monero_primitives::{Commitment, Decoys}; +use crate::{ClsagContext, Clsag}; #[cfg(feature = "multisig")] -use crate::ringct::clsag::{ClsagDetails, ClsagMultisig}; +use crate::ClsagMultisig; #[cfg(feature = "multisig")] use frost::{ @@ -43,8 +36,8 @@ fn clsag() { let mut secrets = (Zeroizing::new(Scalar::ZERO), Scalar::ZERO); let mut ring = vec![]; for i in 0 .. RING_LEN { - let dest = Zeroizing::new(random_scalar(&mut OsRng)); - let mask = random_scalar(&mut OsRng); + let dest = Zeroizing::new(Scalar::random(&mut OsRng)); + let mask = Scalar::random(&mut OsRng); let amount; if i == real { secrets = (dest.clone(), mask); @@ -56,31 +49,29 @@ fn clsag() { .push([dest.deref() * ED25519_BASEPOINT_TABLE, Commitment::new(mask, amount).calculate()]); } - let image = generate_key_image(&secrets.0); let (mut clsag, pseudo_out) = Clsag::sign( &mut OsRng, vec![( - secrets.0, - image, - ClsagInput::new( + secrets.0.clone(), + ClsagContext::new( + Decoys::new((1 ..= RING_LEN).collect(), u8::try_from(real).unwrap(), ring.clone()) + .unwrap(), Commitment::new(secrets.1, AMOUNT), - Decoys { - i: u8::try_from(real).unwrap(), - offsets: (1 ..= RING_LEN).collect(), - ring: ring.clone(), - }, ) .unwrap(), )], - random_scalar(&mut OsRng), + Scalar::random(&mut OsRng), msg, ) + .unwrap() .swap_remove(0); + let image = + hash_to_point((ED25519_BASEPOINT_TABLE * secrets.0.deref()).compress().0) * secrets.0.deref(); clsag.verify(&ring, &image, &pseudo_out, &msg).unwrap(); // make sure verification fails if we throw a random `c1` at it. - clsag.c1 = random_scalar(&mut OsRng); + clsag.c1 = Scalar::random(&mut OsRng); assert!(clsag.verify(&ring, &image, &pseudo_out, &msg).is_err()); } } @@ -90,15 +81,15 @@ fn clsag() { fn clsag_multisig() { let keys = key_gen::<_, Ed25519>(&mut OsRng); - let randomness = random_scalar(&mut OsRng); + let randomness = Scalar::random(&mut OsRng); let mut ring = vec![]; for i in 0 .. RING_LEN { let dest; let mask; let amount; if i != u64::from(RING_INDEX) { - dest = &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE; - mask = random_scalar(&mut OsRng); + dest = &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE; + mask = Scalar::random(&mut OsRng); amount = OsRng.next_u64(); } else { dest = keys[&Participant::new(1).unwrap()].group_key().0; @@ -108,19 +99,15 @@ fn clsag_multisig() { ring.push([dest, Commitment::new(mask, amount).calculate()]); } - let mask_sum = random_scalar(&mut OsRng); - let algorithm = ClsagMultisig::new( + let (algorithm, mask_send) = ClsagMultisig::new( RecommendedTranscript::new(b"Monero Serai CLSAG Test"), - keys[&Participant::new(1).unwrap()].group_key().0, - Arc::new(RwLock::new(Some(ClsagDetails::new( - ClsagInput::new( - Commitment::new(randomness, AMOUNT), - Decoys { i: RING_INDEX, offsets: (1 ..= RING_LEN).collect(), ring: ring.clone() }, - ) - .unwrap(), - mask_sum, - )))), + ClsagContext::new( + Decoys::new((1 ..= RING_LEN).collect(), RING_INDEX, ring.clone()).unwrap(), + Commitment::new(randomness, AMOUNT), + ) + .unwrap(), ); + mask_send.send(Scalar::random(&mut OsRng)); sign( &mut OsRng, diff --git a/coins/monero/ringct/mlsag/Cargo.toml b/coins/monero/ringct/mlsag/Cargo.toml new file mode 100644 index 000000000..e8a9747ff --- /dev/null +++ b/coins/monero/ringct/mlsag/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "monero-mlsag" +version = "0.1.0" +description = "The MLSAG linkable ring signature, as defined by the Monero protocol" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/ringct/mlsag" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +thiserror = { version = "1", default-features = false, optional = true } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } + +# Cryptographic dependencies +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +# Other Monero dependencies +monero-io = { path = "../../io", version = "0.1", default-features = false } +monero-generators = { path = "../../generators", version = "0.4", default-features = false } +monero-primitives = { path = "../../primitives", version = "0.1", default-features = false } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "zeroize/std", + + "monero-io/std", + "monero-generators/std", + "monero-primitives/std", +] +default = ["std"] diff --git a/coins/monero/ringct/mlsag/LICENSE b/coins/monero/ringct/mlsag/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/ringct/mlsag/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/ringct/mlsag/README.md b/coins/monero/ringct/mlsag/README.md new file mode 100644 index 000000000..40e979b62 --- /dev/null +++ b/coins/monero/ringct/mlsag/README.md @@ -0,0 +1,11 @@ +# Monero MLSAG + +The MLSAG linkable ring signature, as defined by the Monero protocol. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/src/ringct/mlsag.rs b/coins/monero/ringct/mlsag/src/lib.rs similarity index 76% rename from coins/monero/src/ringct/mlsag.rs rename to coins/monero/ringct/mlsag/src/lib.rs index e5f00bf7a..d9f15eadc 100644 --- a/coins/monero/src/ringct/mlsag.rs +++ b/coins/monero/ringct/mlsag/src/lib.rs @@ -1,4 +1,11 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(non_snake_case)] + use std_shims::{ + vec, vec::Vec, io::{self, Read, Write}, }; @@ -7,32 +14,40 @@ use zeroize::Zeroize; use curve25519_dalek::{traits::IsIdentity, Scalar, EdwardsPoint}; -use monero_generators::H; - -use crate::{hash_to_scalar, ringct::hash_to_point, serialize::*}; +use monero_io::*; +use monero_generators::{H, hash_to_point}; +use monero_primitives::keccak256_to_scalar; +/// Errors when working with MLSAGs. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(feature = "std", derive(thiserror::Error))] pub enum MlsagError { + /// Invalid ring (such as too small or too large). #[cfg_attr(feature = "std", error("invalid ring"))] InvalidRing, + /// Invalid amount of key images. #[cfg_attr(feature = "std", error("invalid amount of key images"))] InvalidAmountOfKeyImages, + /// Invalid ss matrix. #[cfg_attr(feature = "std", error("invalid ss"))] InvalidSs, - #[cfg_attr(feature = "std", error("key image was identity"))] - IdentityKeyImage, + /// Invalid key image. + #[cfg_attr(feature = "std", error("invalid key image"))] + InvalidKeyImage, + /// Invalid ci vector. #[cfg_attr(feature = "std", error("invalid ci"))] InvalidCi, } +/// A vector of rings, forming a matrix, to verify the MLSAG with. #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct RingMatrix { matrix: Vec>, } impl RingMatrix { - pub fn new(matrix: Vec>) -> Result { + /// Construct a ring matrix from an already formatted series of points. + fn new(matrix: Vec>) -> Result { // Monero requires that there is more than one ring member for MLSAG signatures: // https://github.com/monero-project/monero/blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/ // src/ringct/rctSigs.cpp#L462 @@ -60,16 +75,17 @@ impl RingMatrix { RingMatrix::new(matrix) } - pub fn iter(&self) -> impl Iterator { + /// Iterate over the members of the matrix. + fn iter(&self) -> impl Iterator { self.matrix.iter().map(AsRef::as_ref) } - /// Return the amount of members in the ring. + /// Get the amount of members in the ring. pub fn members(&self) -> usize { self.matrix.len() } - /// Returns the length of a ring member. + /// Get the length of a ring member. /// /// A ring member is a vector of points for which the signer knows all of the discrete logarithms /// of. @@ -79,13 +95,15 @@ impl RingMatrix { } } +/// The MLSAG linkable ring signature, as used in Monero. #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct Mlsag { - pub ss: Vec>, - pub cc: Scalar, + ss: Vec>, + cc: Scalar, } impl Mlsag { + /// Write a MLSAG. pub fn write(&self, w: &mut W) -> io::Result<()> { for ss in &self.ss { write_raw_vec(write_scalar, ss, w)?; @@ -93,6 +111,7 @@ impl Mlsag { write_scalar(&self.cc, w) } + /// Read a MLSAG. pub fn read(mixins: usize, ss_2_elements: usize, r: &mut R) -> io::Result { Ok(Mlsag { ss: (0 .. mixins) @@ -102,6 +121,7 @@ impl Mlsag { }) } + /// Verify a MLSAG. pub fn verify( &self, msg: &[u8; 32], @@ -136,23 +156,24 @@ impl Mlsag { #[allow(non_snake_case)] let L = EdwardsPoint::vartime_double_scalar_mul_basepoint(&ci, ring_member_entry, s); - buf.extend_from_slice(ring_member_entry.compress().as_bytes()); + let compressed_ring_member_entry = ring_member_entry.compress(); + buf.extend_from_slice(compressed_ring_member_entry.as_bytes()); buf.extend_from_slice(L.compress().as_bytes()); // Not all dimensions need to be linkable, e.g. commitments, and only linkable layers need // to have key images. if let Some(ki) = ki { - if ki.is_identity() { - Err(MlsagError::IdentityKeyImage)?; + if ki.is_identity() || (!ki.is_torsion_free()) { + Err(MlsagError::InvalidKeyImage)?; } #[allow(non_snake_case)] - let R = (s * hash_to_point(ring_member_entry)) + (ci * ki); + let R = (s * hash_to_point(compressed_ring_member_entry.to_bytes())) + (ci * ki); buf.extend_from_slice(R.compress().as_bytes()); } } - ci = hash_to_scalar(&buf); + ci = keccak256_to_scalar(&buf); // keep the msg in the buffer. buf.drain(msg.len() ..); } @@ -164,8 +185,9 @@ impl Mlsag { } } -/// An aggregate ring matrix builder, usable to set up the ring matrix to prove/verify an aggregate -/// MLSAG signature. +/// Builder for a RingMatrix when using an aggregate signature. +/// +/// This handles the formatting as necessary. #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct AggregateRingMatrixBuilder { key_ring: Vec>, @@ -176,7 +198,7 @@ pub struct AggregateRingMatrixBuilder { impl AggregateRingMatrixBuilder { /// Create a new AggregateRingMatrixBuilder. /// - /// Takes in the transaction's outputs; commitments and fee. + /// This takes in the transaction's outputs' commitments and fee used. pub fn new(commitments: &[EdwardsPoint], fee: u64) -> Self { AggregateRingMatrixBuilder { key_ring: vec![], @@ -206,7 +228,7 @@ impl AggregateRingMatrixBuilder { Ok(()) } - /// Build and return the [`RingMatrix`] + /// Build and return the [`RingMatrix`]. pub fn build(mut self) -> Result { for (i, amount_commitment) in self.amounts_ring.drain(..).enumerate() { self.key_ring[i].push(amount_commitment); diff --git a/coins/monero/rpc/Cargo.toml b/coins/monero/rpc/Cargo.toml new file mode 100644 index 000000000..ffe503795 --- /dev/null +++ b/coins/monero/rpc/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "monero-rpc" +version = "0.1.0" +description = "Trait for an RPC connection to a Monero daemon, built around monero-serai" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/rpc" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../common/std-shims", version = "^0.1.1", default-features = false } + +async-trait = { version = "0.1", default-features = false } +thiserror = { version = "1", default-features = false, optional = true } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } + +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +monero-serai = { path = "..", default-features = false } +monero-address = { path = "../wallet/address", default-features = false } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "zeroize/std", + "hex/std", + "serde/std", + "serde_json/std", + + "monero-serai/std", + "monero-address/std", +] +default = ["std"] diff --git a/coins/monero/rpc/LICENSE b/coins/monero/rpc/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/rpc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/rpc/README.md b/coins/monero/rpc/README.md new file mode 100644 index 000000000..4badf1d8d --- /dev/null +++ b/coins/monero/rpc/README.md @@ -0,0 +1,11 @@ +# Monero RPC + +Trait for an RPC connection to a Monero daemon, built around monero-serai. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/rpc/simple-request/Cargo.toml b/coins/monero/rpc/simple-request/Cargo.toml new file mode 100644 index 000000000..b0c53fcb0 --- /dev/null +++ b/coins/monero/rpc/simple-request/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "monero-simple-request-rpc" +version = "0.1.0" +description = "RPC connection to a Monero daemon via simple-request, built around monero-serai" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/rpc/simple-request" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +async-trait = { version = "0.1", default-features = false } + +hex = { version = "0.4", default-features = false, features = ["alloc"] } +digest_auth = { version = "0.3", default-features = false } +simple-request = { path = "../../../../common/request", version = "0.1", default-features = false, features = ["tls"] } +tokio = { version = "1", default-features = false } + +monero-rpc = { path = "..", default-features = false, features = ["std"] } + +[dev-dependencies] +monero-address = { path = "../../wallet/address", default-features = false, features = ["std"] } + +tokio = { version = "1", default-features = false, features = ["macros"] } diff --git a/coins/monero/rpc/simple-request/LICENSE b/coins/monero/rpc/simple-request/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/rpc/simple-request/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/rpc/simple-request/README.md b/coins/monero/rpc/simple-request/README.md new file mode 100644 index 000000000..947e777ec --- /dev/null +++ b/coins/monero/rpc/simple-request/README.md @@ -0,0 +1,3 @@ +# Monero simple-request RPC + +RPC connection to a Monero daemon via simple-request, built around monero-serai. diff --git a/coins/monero/src/rpc/http.rs b/coins/monero/rpc/simple-request/src/lib.rs similarity index 95% rename from coins/monero/src/rpc/http.rs rename to coins/monero/rpc/simple-request/src/lib.rs index 4ed349a5c..336513092 100644 --- a/coins/monero/src/rpc/http.rs +++ b/coins/monero/rpc/simple-request/src/lib.rs @@ -1,3 +1,7 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] + use std::{sync::Arc, io::Read, time::Duration}; use async_trait::async_trait; @@ -10,7 +14,7 @@ use simple_request::{ Response, Client, }; -use crate::rpc::{RpcError, RpcConnection, Rpc}; +use monero_rpc::{RpcError, Rpc}; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); @@ -33,13 +37,13 @@ enum Authentication { /// /// Requires tokio. #[derive(Clone, Debug)] -pub struct HttpRpc { +pub struct SimpleRequestRpc { authentication: Authentication, url: String, request_timeout: Duration, } -impl HttpRpc { +impl SimpleRequestRpc { fn digest_auth_challenge( response: &Response, ) -> Result, RpcError> { @@ -60,7 +64,7 @@ impl HttpRpc { /// /// A daemon requiring authentication can be used via including the username and password in the /// URL. - pub async fn new(url: String) -> Result, RpcError> { + pub async fn new(url: String) -> Result { Self::with_custom_timeout(url, DEFAULT_TIMEOUT).await } @@ -71,7 +75,7 @@ impl HttpRpc { pub async fn with_custom_timeout( mut url: String, request_timeout: Duration, - ) -> Result, RpcError> { + ) -> Result { let authentication = if url.contains('@') { // Parse out the username and password let url_clone = url; @@ -119,11 +123,11 @@ impl HttpRpc { Authentication::Unauthenticated(Client::with_connection_pool()) }; - Ok(Rpc(HttpRpc { authentication, url, request_timeout })) + Ok(SimpleRequestRpc { authentication, url, request_timeout }) } } -impl HttpRpc { +impl SimpleRequestRpc { async fn inner_post(&self, route: &str, body: Vec) -> Result, RpcError> { let request_fn = |uri| { Request::post(uri) @@ -277,7 +281,7 @@ impl HttpRpc { } #[async_trait] -impl RpcConnection for HttpRpc { +impl Rpc for SimpleRequestRpc { async fn post(&self, route: &str, body: Vec) -> Result, RpcError> { tokio::time::timeout(self.request_timeout, self.inner_post(route, body)) .await diff --git a/coins/monero/rpc/simple-request/tests/tests.rs b/coins/monero/rpc/simple-request/tests/tests.rs new file mode 100644 index 000000000..c787387d2 --- /dev/null +++ b/coins/monero/rpc/simple-request/tests/tests.rs @@ -0,0 +1,75 @@ +use monero_address::{Network, MoneroAddress}; + +// monero-rpc doesn't include a transport +// We can't include the simple-request crate there as then we'd have a cyclical dependency +// Accordingly, we test monero-rpc here (implicitly testing the simple-request transport) +use monero_rpc::*; +use monero_simple_request_rpc::*; + +const ADDRESS: &str = + "4B33mFPMq6mKi7Eiyd5XuyKRVMGVZz1Rqb9ZTyGApXW5d1aT7UBDZ89ewmnWFkzJ5wPd2SFbn313vCT8a4E2Qf4KQH4pNey"; + +#[tokio::test] +async fn test_rpc() { + let rpc = + SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap(); + + { + // Test get_height + let height = rpc.get_height().await.unwrap(); + // The height should be the amount of blocks on chain + // The number of a block should be its zero-indexed position + // Accordingly, there should be no block whose number is the height + assert!(rpc.get_block_by_number(height).await.is_err()); + let block_number = height - 1; + // There should be a block just prior + let block = rpc.get_block_by_number(block_number).await.unwrap(); + + // Also test the block RPC routes are consistent + assert_eq!(block.number().unwrap(), block_number); + assert_eq!(rpc.get_block(block.hash()).await.unwrap(), block); + assert_eq!(rpc.get_block_hash(block_number).await.unwrap(), block.hash()); + + // And finally the hardfork version route + assert_eq!(rpc.get_hardfork_version().await.unwrap(), block.header.hardfork_version); + } + + // Test generate_blocks + for amount_of_blocks in [1, 5] { + let (blocks, number) = rpc + .generate_blocks( + &MoneroAddress::from_str(Network::Mainnet, ADDRESS).unwrap(), + amount_of_blocks, + ) + .await + .unwrap(); + let height = rpc.get_height().await.unwrap(); + assert_eq!(number, height - 1); + + let mut actual_blocks = Vec::with_capacity(amount_of_blocks); + for i in (height - amount_of_blocks) .. height { + actual_blocks.push(rpc.get_block_by_number(i).await.unwrap().hash()); + } + assert_eq!(blocks, actual_blocks); + } + + // Test get_output_distribution + // It's documented to take two inclusive block numbers + { + let height = rpc.get_height().await.unwrap(); + + rpc.get_output_distribution(0 ..= height).await.unwrap_err(); + assert_eq!(rpc.get_output_distribution(0 .. height).await.unwrap().len(), height); + + assert_eq!(rpc.get_output_distribution(0 .. (height - 1)).await.unwrap().len(), height - 1); + assert_eq!(rpc.get_output_distribution(1 .. height).await.unwrap().len(), height - 1); + + assert_eq!(rpc.get_output_distribution(0 ..= 0).await.unwrap().len(), 1); + assert_eq!(rpc.get_output_distribution(0 ..= 1).await.unwrap().len(), 2); + assert_eq!(rpc.get_output_distribution(1 ..= 1).await.unwrap().len(), 1); + + rpc.get_output_distribution(0 .. 0).await.unwrap_err(); + #[allow(clippy::reversed_empty_ranges)] + rpc.get_output_distribution(1 .. 0).await.unwrap_err(); + } +} diff --git a/coins/monero/src/rpc/mod.rs b/coins/monero/rpc/src/lib.rs similarity index 51% rename from coins/monero/src/rpc/mod.rs rename to coins/monero/rpc/src/lib.rs index c0a8eae2d..2bdcc0bb1 100644 --- a/coins/monero/src/rpc/mod.rs +++ b/coins/monero/rpc/src/lib.rs @@ -1,43 +1,177 @@ -use core::fmt::Debug; -#[cfg(not(feature = "std"))] -use alloc::boxed::Box; +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +use core::{ + fmt::Debug, + ops::{Bound, RangeBounds}, +}; use std_shims::{ + alloc::{boxed::Box, format}, + vec, vec::Vec, io, string::{String, ToString}, }; +use zeroize::Zeroize; + use async_trait::async_trait; use curve25519_dalek::edwards::EdwardsPoint; -use monero_generators::decompress_point; - use serde::{Serialize, Deserialize, de::DeserializeOwned}; use serde_json::{Value, json}; -use crate::{ - Protocol, - serialize::*, +use monero_serai::{ + io::*, transaction::{Input, Timelock, Transaction}, block::Block, - wallet::{FeePriority, Fee}, }; - -#[cfg(feature = "http-rpc")] -mod http; -#[cfg(feature = "http-rpc")] -pub use http::*; +use monero_address::Address; // Number of blocks the fee estimate will be valid for // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ // src/wallet/wallet2.cpp#L121 const GRACE_BLOCKS_FOR_FEE_ESTIMATE: u64 = 10; +/// An error from the RPC. +#[derive(Clone, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum RpcError { + /// An internal error. + #[cfg_attr(feature = "std", error("internal error ({0})"))] + InternalError(String), + /// A connection error with the node. + #[cfg_attr(feature = "std", error("connection error ({0})"))] + ConnectionError(String), + /// The node is invalid per the expected protocol. + #[cfg_attr(feature = "std", error("invalid node ({0})"))] + InvalidNode(String), + /// Requested transactions weren't found. + #[cfg_attr(feature = "std", error("transactions not found"))] + TransactionsNotFound(Vec<[u8; 32]>), + /// The transaction was pruned. + /// + /// Pruned transactions are not supported at this time. + #[cfg_attr(feature = "std", error("pruned transaction"))] + PrunedTransaction, + /// A transaction (sent or received) was invalid. + #[cfg_attr(feature = "std", error("invalid transaction ({0:?})"))] + InvalidTransaction([u8; 32]), + /// The returned fee was unusable. + #[cfg_attr(feature = "std", error("unexpected fee response"))] + InvalidFee, + /// The priority intended for use wasn't usable. + #[cfg_attr(feature = "std", error("invalid priority"))] + InvalidPriority, +} + +/// A struct containing a fee rate. +/// +/// The fee rate is defined as a per-weight cost, along with a mask for rounding purposes. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] +pub struct FeeRate { + /// The fee per-weight of the transaction. + per_weight: u64, + /// The mask to round with. + mask: u64, +} + +impl FeeRate { + /// Construct a new fee rate. + pub fn new(per_weight: u64, mask: u64) -> Result { + if (per_weight == 0) || (mask == 0) { + Err(RpcError::InvalidFee)?; + } + Ok(FeeRate { per_weight, mask }) + } + + /// Write the FeeRate. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn write(&self, w: &mut impl io::Write) -> io::Result<()> { + w.write_all(&self.per_weight.to_le_bytes())?; + w.write_all(&self.mask.to_le_bytes()) + } + + /// Serialize the FeeRate to a `Vec`. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn serialize(&self) -> Vec { + let mut res = Vec::with_capacity(16); + self.write(&mut res).unwrap(); + res + } + + /// Read a FeeRate. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn read(r: &mut impl io::Read) -> io::Result { + let per_weight = read_u64(r)?; + let mask = read_u64(r)?; + FeeRate::new(per_weight, mask).map_err(io::Error::other) + } + + /// Calculate the fee to use from the weight. + /// + /// This function may panic upon overflow. + pub fn calculate_fee_from_weight(&self, weight: usize) -> u64 { + let fee = self.per_weight * u64::try_from(weight).unwrap(); + let fee = fee.div_ceil(self.mask) * self.mask; + debug_assert_eq!(weight, self.calculate_weight_from_fee(fee), "Miscalculated weight from fee"); + fee + } + + /// Calculate the weight from the fee. + pub fn calculate_weight_from_fee(&self, fee: u64) -> usize { + usize::try_from(fee / self.per_weight).unwrap() + } +} + +/// The priority for the fee. +/// +/// Higher-priority transactions will be included in blocks earlier. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[allow(non_camel_case_types)] +pub enum FeePriority { + /// The `Unimportant` priority, as defined by Monero. + Unimportant, + /// The `Normal` priority, as defined by Monero. + Normal, + /// The `Elevated` priority, as defined by Monero. + Elevated, + /// The `Priority` priority, as defined by Monero. + Priority, + /// A custom priority. + Custom { + /// The numeric representation of the priority, as used within the RPC. + priority: u32, + }, +} + +/// https://github.com/monero-project/monero/blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/ +/// src/simplewallet/simplewallet.cpp#L161 +impl FeePriority { + pub(crate) fn fee_priority(&self) -> u32 { + match self { + FeePriority::Unimportant => 1, + FeePriority::Normal => 2, + FeePriority::Elevated => 3, + FeePriority::Priority => 4, + FeePriority::Custom { priority, .. } => *priority, + } + } +} + #[derive(Deserialize, Debug)] -pub struct EmptyResponse {} +struct EmptyResponse {} #[derive(Deserialize, Debug)] -pub struct JsonRpcResponse { +struct JsonRpcResponse { result: T, } @@ -54,38 +188,19 @@ struct TransactionsResponse { txs: Vec, } +/// The response to an output query. #[derive(Deserialize, Debug)] pub struct OutputResponse { + /// The height of the block this output was added to the chain in. pub height: usize, + /// If the output is unlocked, per the node's local view. pub unlocked: bool, - key: String, - mask: String, - txid: String, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "std", derive(thiserror::Error))] -pub enum RpcError { - #[cfg_attr(feature = "std", error("internal error ({0})"))] - InternalError(&'static str), - #[cfg_attr(feature = "std", error("connection error ({0})"))] - ConnectionError(String), - #[cfg_attr(feature = "std", error("invalid node ({0})"))] - InvalidNode(String), - #[cfg_attr(feature = "std", error("unsupported protocol version ({0})"))] - UnsupportedProtocol(usize), - #[cfg_attr(feature = "std", error("transactions not found"))] - TransactionsNotFound(Vec<[u8; 32]>), - #[cfg_attr(feature = "std", error("invalid point ({0})"))] - InvalidPoint(String), - #[cfg_attr(feature = "std", error("pruned transaction"))] - PrunedTransaction, - #[cfg_attr(feature = "std", error("invalid transaction ({0:?})"))] - InvalidTransaction([u8; 32]), - #[cfg_attr(feature = "std", error("unexpected fee response"))] - InvalidFee, - #[cfg_attr(feature = "std", error("invalid priority"))] - InvalidPriority, + /// The output's key. + pub key: String, + /// The output's commitment. + pub mask: String, + /// The transaction which created this output. + pub txid: String, } fn rpc_hex(value: &str) -> Result, RpcError> { @@ -98,51 +213,38 @@ fn hash_hex(hash: &str) -> Result<[u8; 32], RpcError> { fn rpc_point(point: &str) -> Result { decompress_point( - rpc_hex(point)?.try_into().map_err(|_| RpcError::InvalidPoint(point.to_string()))?, + rpc_hex(point)? + .try_into() + .map_err(|_| RpcError::InvalidNode(format!("invalid point: {point}")))?, ) - .ok_or_else(|| RpcError::InvalidPoint(point.to_string())) -} - -// Read an EPEE VarInt, distinct from the VarInts used throughout the rest of the protocol -fn read_epee_vi(reader: &mut R) -> io::Result { - let vi_start = read_byte(reader)?; - let len = match vi_start & 0b11 { - 0 => 1, - 1 => 2, - 2 => 4, - 3 => 8, - _ => unreachable!(), - }; - let mut vi = u64::from(vi_start >> 2); - for i in 1 .. len { - vi |= u64::from(read_byte(reader)?) << (((i - 1) * 8) + 6); - } - Ok(vi) + .ok_or_else(|| RpcError::InvalidNode(format!("invalid point: {point}"))) } +/// An RPC connection to a Monero daemon. +/// +/// This is abstract such that users can use an HTTP library (which being their choice), a +/// Tor/i2p-based transport, or even a memory buffer an external service somehow routes. +/// +/// While no implementors are directly provided, [monero-simple-request-rpc]( +/// https://github.com/serai-dex/serai/tree/develop/coins/monero/rpc/simple-request +/// ) is recommended. #[async_trait] -pub trait RpcConnection: Clone + Debug { +pub trait Rpc: Sync + Clone + Debug { /// Perform a POST request to the specified route with the specified body. /// /// The implementor is left to handle anything such as authentication. async fn post(&self, route: &str, body: Vec) -> Result, RpcError>; -} -// TODO: Make this provided methods for RpcConnection? -#[derive(Clone, Debug)] -pub struct Rpc(R); -impl Rpc { /// Perform a RPC call to the specified route with the provided parameters. /// /// This is NOT a JSON-RPC call. They use a route of "json_rpc" and are available via /// `json_rpc_call`. - pub async fn rpc_call( + async fn rpc_call( &self, route: &str, params: Option, ) -> Result { let res = self - .0 .post( route, if let Some(params) = params { @@ -155,11 +257,11 @@ impl Rpc { let res_str = std_shims::str::from_utf8(&res) .map_err(|_| RpcError::InvalidNode("response wasn't utf-8".to_string()))?; serde_json::from_str(res_str) - .map_err(|_| RpcError::InvalidNode(format!("response wasn't json: {res_str}"))) + .map_err(|_| RpcError::InvalidNode(format!("response wasn't the expected json: {res_str}"))) } - /// Perform a JSON-RPC call with the specified method with the provided parameters - pub async fn json_rpc_call( + /// Perform a JSON-RPC call with the specified method with the provided parameters. + async fn json_rpc_call( &self, method: &str, params: Option, @@ -172,45 +274,54 @@ impl Rpc { } /// Perform a binary call to the specified route with the provided parameters. - pub async fn bin_call(&self, route: &str, params: Vec) -> Result, RpcError> { - self.0.post(route, params).await + async fn bin_call(&self, route: &str, params: Vec) -> Result, RpcError> { + self.post(route, params).await } /// Get the active blockchain protocol version. - pub async fn get_protocol(&self) -> Result { + /// + /// This is specifically the major version within the most recent block header. + async fn get_hardfork_version(&self) -> Result { #[derive(Deserialize, Debug)] - struct ProtocolResponse { - major_version: usize, + struct HeaderResponse { + major_version: u8, } #[derive(Deserialize, Debug)] struct LastHeaderResponse { - block_header: ProtocolResponse, + block_header: HeaderResponse, } Ok( - match self + self .json_rpc_call::("get_last_block_header", None) .await? .block_header - .major_version - { - 13 | 14 => Protocol::v14, - 15 | 16 => Protocol::v16, - protocol => Err(RpcError::UnsupportedProtocol(protocol))?, - }, + .major_version, ) } - pub async fn get_height(&self) -> Result { + /// Get the height of the Monero blockchain. + /// + /// The height is defined as the amount of blocks on the blockchain. For a blockchain with only + /// its genesis block, the height will be 1. + async fn get_height(&self) -> Result { #[derive(Deserialize, Debug)] struct HeightResponse { height: usize, } - Ok(self.rpc_call::, HeightResponse>("get_height", None).await?.height) + let res = self.rpc_call::, HeightResponse>("get_height", None).await?.height; + if res == 0 { + Err(RpcError::InvalidNode("node responded with 0 for the height".to_string()))?; + } + Ok(res) } - pub async fn get_transactions(&self, hashes: &[[u8; 32]]) -> Result, RpcError> { + /// Get the specified transactions. + /// + /// The received transactions will be hashed in order to verify the correct transactions were + /// returned. + async fn get_transactions(&self, hashes: &[[u8; 32]]) -> Result, RpcError> { if hashes.is_empty() { return Ok(vec![]); } @@ -255,7 +366,7 @@ impl Rpc { // https://github.com/monero-project/monero/issues/8311 if res.as_hex.is_empty() { - match tx.prefix.inputs.first() { + match tx.prefix().inputs.first() { Some(Input::Gen { .. }) => (), _ => Err(RpcError::PrunedTransaction)?, } @@ -274,13 +385,19 @@ impl Rpc { .collect() } - pub async fn get_transaction(&self, tx: [u8; 32]) -> Result { + /// Get the specified transaction. + /// + /// The received transaction will be hashed in order to verify the correct transaction was + /// returned. + async fn get_transaction(&self, tx: [u8; 32]) -> Result { self.get_transactions(&[tx]).await.map(|mut txs| txs.swap_remove(0)) } - /// Get the hash of a block from the node by the block's numbers. - /// This function does not verify the returned block hash is actually for the number in question. - pub async fn get_block_hash(&self, number: usize) -> Result<[u8; 32], RpcError> { + /// Get the hash of a block from the node. + /// + /// `number` is the block's zero-indexed position on the blockchain (`0` for the genesis block, + /// `height - 1` for the latest block). + async fn get_block_hash(&self, number: usize) -> Result<[u8; 32], RpcError> { #[derive(Deserialize, Debug)] struct BlockHeaderResponse { hash: String, @@ -296,8 +413,9 @@ impl Rpc { } /// Get a block from the node by its hash. - /// This function does not verify the returned block actually has the hash in question. - pub async fn get_block(&self, hash: [u8; 32]) -> Result { + /// + /// The received block will be hashed in order to verify the correct block was returned. + async fn get_block(&self, hash: [u8; 32]) -> Result { #[derive(Deserialize, Debug)] struct BlockResponse { blob: String, @@ -314,7 +432,11 @@ impl Rpc { Ok(block) } - pub async fn get_block_by_number(&self, number: usize) -> Result { + /// Get a block from the node by its number. + /// + /// `number` is the block's zero-indexed position on the blockchain (`0` for the genesis block, + /// `height - 1` for the latest block). + async fn get_block_by_number(&self, number: usize) -> Result { #[derive(Deserialize, Debug)] struct BlockResponse { blob: String, @@ -327,56 +449,74 @@ impl Rpc { .map_err(|_| RpcError::InvalidNode("invalid block".to_string()))?; // Make sure this is actually the block for this number - match block.miner_tx.prefix.inputs.first() { + match block.miner_transaction.prefix().inputs.first() { Some(Input::Gen(actual)) => { - if usize::try_from(*actual).unwrap() == number { + if *actual == number { Ok(block) } else { Err(RpcError::InvalidNode("different block than requested (number)".to_string())) } } _ => Err(RpcError::InvalidNode( - "block's miner_tx didn't have an input of kind Input::Gen".to_string(), + "block's miner_transaction didn't have an input of kind Input::Gen".to_string(), )), } } - pub async fn get_block_transactions(&self, hash: [u8; 32]) -> Result, RpcError> { + /// Get the transactions within a block. + /// + /// This function returns all transactions in the block, including the miner's transaction. + /// + /// This function does not verify the returned transactions are the ones committed to by the + /// block's header. + async fn get_block_transactions(&self, hash: [u8; 32]) -> Result, RpcError> { let block = self.get_block(hash).await?; - let mut res = vec![block.miner_tx]; - res.extend(self.get_transactions(&block.txs).await?); + let mut res = vec![block.miner_transaction]; + res.extend(self.get_transactions(&block.transactions).await?); Ok(res) } - pub async fn get_block_transactions_by_number( + /// Get the transactions within a block. + /// + /// This function returns all transactions in the block, including the miner's transaction. + /// + /// This function does not verify the returned transactions are the ones committed to by the + /// block's header. + async fn get_block_transactions_by_number( &self, number: usize, ) -> Result, RpcError> { - self.get_block_transactions(self.get_block_hash(number).await?).await + let block = self.get_block_by_number(number).await?; + let mut res = vec![block.miner_transaction]; + res.extend(self.get_transactions(&block.transactions).await?); + Ok(res) } /// Get the output indexes of the specified transaction. - pub async fn get_o_indexes(&self, hash: [u8; 32]) -> Result, RpcError> { - /* - TODO: Use these when a suitable epee serde lib exists - - #[derive(Serialize, Debug)] - struct Request { - txid: [u8; 32], - } - - #[derive(Deserialize, Debug)] - struct OIndexes { - o_indexes: Vec, - } - */ - + async fn get_o_indexes(&self, hash: [u8; 32]) -> Result, RpcError> { // Given the immaturity of Rust epee libraries, this is a homegrown one which is only validated // to work against this specific function // Header for EPEE, an 8-byte magic and a version const EPEE_HEADER: &[u8] = b"\x01\x11\x01\x01\x01\x01\x02\x01\x01"; + // Read an EPEE VarInt, distinct from the VarInts used throughout the rest of the protocol + fn read_epee_vi(reader: &mut R) -> io::Result { + let vi_start = read_byte(reader)?; + let len = match vi_start & 0b11 { + 0 => 1, + 1 => 2, + 2 => 4, + 3 => 8, + _ => unreachable!(), + }; + let mut vi = u64::from(vi_start >> 2); + for i in 1 .. len { + vi |= u64::from(read_byte(reader)?) << (((i - 1) * 8) + 6); + } + Ok(vi) + } + let mut request = EPEE_HEADER.to_vec(); // Number of fields (shifted over 2 bits as the 2 LSBs are reserved for metadata) request.push(1 << 2); @@ -396,29 +536,58 @@ impl Rpc { (|| { let mut res = None; - let mut is_okay = false; + let mut has_status = false; if read_bytes::<_, { EPEE_HEADER.len() }>(&mut indexes)? != EPEE_HEADER { Err(io::Error::other("invalid header"))?; } let read_object = |reader: &mut &[u8]| -> io::Result> { + // Read the amount of fields let fields = read_byte(reader)? >> 2; for _ in 0 .. fields { + // Read the length of the field's name let name_len = read_byte(reader)?; + // Read the name of the field let name = read_raw_vec(read_byte, name_len.into(), reader)?; let type_with_array_flag = read_byte(reader)?; + // The type of this field, without the potentially set array flag let kind = type_with_array_flag & (!0x80); - - let iters = if type_with_array_flag != kind { read_epee_vi(reader)? } else { 1 }; - - if (&name == b"o_indexes") && (kind != 5) { - Err(io::Error::other("o_indexes weren't u64s"))?; + let has_array_flag = type_with_array_flag != kind; + + // Read this many instances of the field + let iters = if has_array_flag { read_epee_vi(reader)? } else { 1 }; + + // Check the field type + { + #[allow(clippy::match_same_arms)] + let (expected_type, expected_array_flag) = match name.as_slice() { + b"o_indexes" => (5, true), + b"status" => (10, false), + b"untrusted" => (11, false), + b"credits" => (5, false), + b"top_hash" => (10, false), + // On-purposely prints name as a byte vector to prevent printing arbitrary strings + // This is a self-describing format so we don't have to error here, yet we don't + // claim this to be a complete deserialization function + // To ensure it works for this specific use case, it's best to ensure it's limited + // to this specific use case (ensuring we have less variables to deal with) + _ => Err(io::Error::other(format!("unrecognized field in get_o_indexes: {name:?}")))?, + }; + if (expected_type != kind) || (expected_array_flag != has_array_flag) { + let fmt_array_bool = |array_bool| if array_bool { "array" } else { "not array" }; + Err(io::Error::other(format!( + "field {name:?} was {kind} ({}), expected {expected_type} ({})", + fmt_array_bool(has_array_flag), + fmt_array_bool(expected_array_flag) + )))?; + } } - let f = match kind { + let read_field_as_bytes = match kind { + /* // i64 1 => |reader: &mut &[u8]| read_raw_vec(read_byte, 8, reader), // i32 @@ -427,8 +596,10 @@ impl Rpc { 3 => |reader: &mut &[u8]| read_raw_vec(read_byte, 2, reader), // i8 4 => |reader: &mut &[u8]| read_raw_vec(read_byte, 1, reader), + */ // u64 5 => |reader: &mut &[u8]| read_raw_vec(read_byte, 8, reader), + /* // u32 6 => |reader: &mut &[u8]| read_raw_vec(read_byte, 4, reader), // u16 @@ -437,6 +608,7 @@ impl Rpc { 8 => |reader: &mut &[u8]| read_raw_vec(read_byte, 1, reader), // double 9 => |reader: &mut &[u8]| read_raw_vec(read_byte, 8, reader), + */ // string, or any collection of bytes 10 => |reader: &mut &[u8]| { let len = read_epee_vi(reader)?; @@ -448,55 +620,47 @@ impl Rpc { }, // bool 11 => |reader: &mut &[u8]| read_raw_vec(read_byte, 1, reader), + /* // object, errors here as it shouldn't be used on this call 12 => { |_: &mut &[u8]| Err(io::Error::other("node used object in reply to get_o_indexes")) } // array, so far unused 13 => |_: &mut &[u8]| Err(io::Error::other("node used the unused array type")), + */ _ => |_: &mut &[u8]| Err(io::Error::other("node used an invalid type")), }; let mut bytes_res = vec![]; for _ in 0 .. iters { - bytes_res.push(f(reader)?); + bytes_res.push(read_field_as_bytes(reader)?); } let mut actual_res = Vec::with_capacity(bytes_res.len()); match name.as_slice() { b"o_indexes" => { for o_index in bytes_res { - actual_res.push(u64::from_le_bytes( - o_index - .try_into() - .map_err(|_| io::Error::other("node didn't provide 8 bytes for a u64"))?, - )); + actual_res.push(read_u64(&mut o_index.as_slice())?); } res = Some(actual_res); } b"status" => { if bytes_res .first() - .ok_or_else(|| io::Error::other("status wasn't a string"))? + .ok_or_else(|| io::Error::other("status was a 0-length array"))? .as_slice() != b"OK" { - // TODO: Better handle non-OK responses Err(io::Error::other("response wasn't OK"))?; } - is_okay = true; + has_status = true; } - _ => continue, - } - - if is_okay && res.is_some() { - break; + b"untrusted" | b"credits" | b"top_hash" => continue, + _ => Err(io::Error::other("unrecognized field in get_o_indexes"))?, } } - // Didn't return a response with a status - // (if the status wasn't okay, we would've already errored) - if !is_okay { + if !has_status { Err(io::Error::other("response didn't contain a status"))?; } @@ -507,15 +671,15 @@ impl Rpc { read_object(&mut indexes) })() - .map_err(|_| RpcError::InvalidNode("invalid binary response".to_string())) + .map_err(|e| RpcError::InvalidNode(format!("invalid binary response: {e:?}"))) } - /// Get the output distribution, from the specified height to the specified height (both - /// inclusive). - pub async fn get_output_distribution( + /// Get the output distribution. + /// + /// `range` is in terms of block numbers. + async fn get_output_distribution( &self, - from: usize, - to: usize, + range: impl Send + RangeBounds, ) -> Result, RpcError> { #[derive(Deserialize, Debug)] struct Distribution { @@ -524,10 +688,31 @@ impl Rpc { #[derive(Deserialize, Debug)] struct Distributions { - distributions: Vec, + distributions: [Distribution; 1], + } + + let from = match range.start_bound() { + Bound::Included(from) => *from, + Bound::Excluded(from) => from + .checked_add(1) + .ok_or_else(|| RpcError::InternalError("range's from wasn't representable".to_string()))?, + Bound::Unbounded => 0, + }; + let to = match range.end_bound() { + Bound::Included(to) => *to, + Bound::Excluded(to) => to + .checked_sub(1) + .ok_or_else(|| RpcError::InternalError("range's to wasn't representable".to_string()))?, + Bound::Unbounded => self.get_height().await? - 1, + }; + if from > to { + Err(RpcError::InternalError(format!( + "malformed range: inclusive start {from}, inclusive end {to}" + )))?; } - let mut distributions: Distributions = self + let zero_zero_case = (from == 0) && (to == 0); + let distributions: Distributions = self .json_rpc_call( "get_output_distribution", Some(json!({ @@ -535,16 +720,31 @@ impl Rpc { "amounts": [0], "cumulative": true, "from_height": from, - "to_height": to, + "to_height": if zero_zero_case { 1 } else { to }, })), ) .await?; - - Ok(distributions.distributions.swap_remove(0).distribution) + let mut distributions = distributions.distributions; + let mut distribution = core::mem::take(&mut distributions[0].distribution); + + let expected_len = if zero_zero_case { 2 } else { (to - from) + 1 }; + if expected_len != distribution.len() { + Err(RpcError::InvalidNode(format!( + "distribution length ({}) wasn't of the requested length ({})", + distribution.len(), + expected_len + )))?; + } + // Requesting 0, 0 returns the distribution for the entire chain + // We work-around this by requesting 0, 1 (yielding two blocks), then popping the second block + if zero_zero_case { + distribution.pop(); + } + Ok(distribution) } - /// Get the specified outputs from the RingCT (zero-amount) pool - pub async fn get_outs(&self, indexes: &[u64]) -> Result, RpcError> { + /// Get the specified outputs from the RingCT (zero-amount) pool. + async fn get_outs(&self, indexes: &[u64]) -> Result, RpcError> { #[derive(Deserialize, Debug)] struct OutsResponse { status: String, @@ -576,7 +776,13 @@ impl Rpc { /// /// The timelock being satisfied is distinct from being free of the 10-block lock applied to all /// Monero transactions. - pub async fn get_unlocked_outputs( + /// + /// The node is trusted for if the output is unlocked unless `fingerprintable_canonical` is set + /// to true. If `fingerprintable_canonical` is set to true, the node's local view isn't used, yet + /// the transaction's timelock is checked to be unlocked at the specified `height`. This offers a + /// canonical decoy selection, yet is fingerprintable as time-based timelocks aren't evaluated + /// (and considered locked, preventing their selection). + async fn get_unlocked_outputs( &self, indexes: &[u64], height: usize, @@ -592,7 +798,7 @@ impl Rpc { ) .await? } else { - Vec::new() + vec![] }; // TODO: https://github.com/serai-dex/serai/issues/104 @@ -614,7 +820,9 @@ impl Rpc { }; Ok(Some([key, rpc_point(&out.mask)?]).filter(|_| { if fingerprintable_canonical { - Timelock::Block(height) >= txs[i].prefix.timelock + // TODO: Are timelock blocks by height or number? + // TODO: This doesn't check the default timelock has been passed + Timelock::Block(height) >= txs[i].prefix().additional_timelock } else { out.unlocked } @@ -623,29 +831,22 @@ impl Rpc { .collect() } - async fn get_fee_v14(&self, priority: FeePriority) -> Result { + /// Get the currently estimated fee rate from the node. + /// + /// This may be manipulated to unsafe levels and MUST be sanity checked. + /// + /// This MUST NOT be expected to be deterministic in any way. + // TODO: Take a sanity check argument + async fn get_fee_rate(&self, priority: FeePriority) -> Result { #[derive(Deserialize, Debug)] - struct FeeResponseV14 { + struct FeeResponse { status: String, + fees: Option>, fee: u64, quantization_mask: u64, } - // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ - // src/wallet/wallet2.cpp#L7569-L7584 - // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ - // src/wallet/wallet2.cpp#L7660-L7661 - let priority_idx = - usize::try_from(if priority.fee_priority() == 0 { 1 } else { priority.fee_priority() - 1 }) - .map_err(|_| RpcError::InvalidPriority)?; - let multipliers = [1, 5, 25, 1000]; - if priority_idx >= multipliers.len() { - // though not an RPC error, it seems sensible to treat as such - Err(RpcError::InvalidPriority)?; - } - let fee_multiplier = multipliers[priority_idx]; - - let res: FeeResponseV14 = self + let res: FeeResponse = self .json_rpc_call( "get_fee_estimate", Some(json!({ "grace_blocks": GRACE_BLOCKS_FOR_FEE_ESTIMATE })), @@ -656,31 +857,7 @@ impl Rpc { Err(RpcError::InvalidFee)?; } - Ok(Fee { per_weight: res.fee * fee_multiplier, mask: res.quantization_mask }) - } - - /// Get the currently estimated fee from the node. - /// - /// This may be manipulated to unsafe levels and MUST be sanity checked. - // TODO: Take a sanity check argument - pub async fn get_fee(&self, protocol: Protocol, priority: FeePriority) -> Result { - // TODO: Implement wallet2's adjust_priority which by default automatically uses a lower - // priority than provided depending on the backlog in the pool - if protocol.v16_fee() { - #[derive(Deserialize, Debug)] - struct FeeResponse { - status: String, - fees: Vec, - quantization_mask: u64, - } - - let res: FeeResponse = self - .json_rpc_call( - "get_fee_estimate", - Some(json!({ "grace_blocks": GRACE_BLOCKS_FOR_FEE_ESTIMATE })), - ) - .await?; - + if let Some(fees) = res.fees { // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ // src/wallet/wallet2.cpp#L7615-L7620 let priority_idx = usize::try_from(if priority.fee_priority() >= 4 { @@ -690,19 +867,32 @@ impl Rpc { }) .map_err(|_| RpcError::InvalidPriority)?; - if res.status != "OK" { - Err(RpcError::InvalidFee) - } else if priority_idx >= res.fees.len() { + if priority_idx >= fees.len() { Err(RpcError::InvalidPriority) } else { - Ok(Fee { per_weight: res.fees[priority_idx], mask: res.quantization_mask }) + FeeRate::new(fees[priority_idx], res.quantization_mask) } } else { - self.get_fee_v14(priority).await + // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ + // src/wallet/wallet2.cpp#L7569-L7584 + // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ + // src/wallet/wallet2.cpp#L7660-L7661 + let priority_idx = + usize::try_from(if priority.fee_priority() == 0 { 1 } else { priority.fee_priority() - 1 }) + .map_err(|_| RpcError::InvalidPriority)?; + let multipliers = [1, 5, 25, 1000]; + if priority_idx >= multipliers.len() { + // though not an RPC error, it seems sensible to treat as such + Err(RpcError::InvalidPriority)?; + } + let fee_multiplier = multipliers[priority_idx]; + + FeeRate::new(res.fee * fee_multiplier, res.quantization_mask) } } - pub async fn publish_transaction(&self, tx: &Transaction) -> Result<(), RpcError> { + /// Publish a transaction. + async fn publish_transaction(&self, tx: &Transaction) -> Result<(), RpcError> { #[allow(dead_code)] #[derive(Deserialize, Debug)] struct SendRawResponse { @@ -730,10 +920,12 @@ impl Rpc { Ok(()) } - // TODO: Take &Address, not &str? - pub async fn generate_blocks( + /// Generate blocks, with the specified address receiving the block reward. + /// + /// Returns the hashes of the generated blocks and the last block's number. + async fn generate_blocks( &self, - address: &str, + address: &Address, block_count: usize, ) -> Result<(Vec<[u8; 32]>, usize), RpcError> { #[derive(Debug, Deserialize)] @@ -746,7 +938,7 @@ impl Rpc { .json_rpc_call::( "generateblocks", Some(json!({ - "wallet_address": address, + "wallet_address": address.to_string(), "amount_of_blocks": block_count })), ) diff --git a/coins/monero/src/bin/reserialize_chain.rs b/coins/monero/src/bin/reserialize_chain.rs deleted file mode 100644 index f2ebfcc1a..000000000 --- a/coins/monero/src/bin/reserialize_chain.rs +++ /dev/null @@ -1,321 +0,0 @@ -#[cfg(feature = "binaries")] -mod binaries { - pub(crate) use std::sync::Arc; - - pub(crate) use curve25519_dalek::{scalar::Scalar, edwards::EdwardsPoint}; - - pub(crate) use multiexp::BatchVerifier; - - pub(crate) use serde::Deserialize; - pub(crate) use serde_json::json; - - pub(crate) use monero_serai::{ - Commitment, - ringct::RctPrunable, - transaction::{Input, Transaction}, - block::Block, - rpc::{RpcError, Rpc, HttpRpc}, - }; - - pub(crate) use monero_generators::decompress_point; - - pub(crate) use tokio::task::JoinHandle; - - pub(crate) async fn check_block(rpc: Arc>, block_i: usize) { - let hash = loop { - match rpc.get_block_hash(block_i).await { - Ok(hash) => break hash, - Err(RpcError::ConnectionError(e)) => { - println!("get_block_hash ConnectionError: {e}"); - continue; - } - Err(e) => panic!("couldn't get block {block_i}'s hash: {e:?}"), - } - }; - - // TODO: Grab the JSON to also check it was deserialized correctly - #[derive(Deserialize, Debug)] - struct BlockResponse { - blob: String, - } - let res: BlockResponse = loop { - match rpc.json_rpc_call("get_block", Some(json!({ "hash": hex::encode(hash) }))).await { - Ok(res) => break res, - Err(RpcError::ConnectionError(e)) => { - println!("get_block ConnectionError: {e}"); - continue; - } - Err(e) => panic!("couldn't get block {block_i} via block.hash(): {e:?}"), - } - }; - - let blob = hex::decode(res.blob).expect("node returned non-hex block"); - let block = Block::read(&mut blob.as_slice()) - .unwrap_or_else(|e| panic!("couldn't deserialize block {block_i}: {e}")); - assert_eq!(block.hash(), hash, "hash differs"); - assert_eq!(block.serialize(), blob, "serialization differs"); - - let txs_len = 1 + block.txs.len(); - - if !block.txs.is_empty() { - #[derive(Deserialize, Debug)] - struct TransactionResponse { - tx_hash: String, - as_hex: String, - } - #[derive(Deserialize, Debug)] - struct TransactionsResponse { - #[serde(default)] - missed_tx: Vec, - txs: Vec, - } - - let mut hashes_hex = block.txs.iter().map(hex::encode).collect::>(); - let mut all_txs = vec![]; - while !hashes_hex.is_empty() { - let txs: TransactionsResponse = loop { - match rpc - .rpc_call( - "get_transactions", - Some(json!({ - "txs_hashes": hashes_hex.drain(.. hashes_hex.len().min(100)).collect::>(), - })), - ) - .await - { - Ok(txs) => break txs, - Err(RpcError::ConnectionError(e)) => { - println!("get_transactions ConnectionError: {e}"); - continue; - } - Err(e) => panic!("couldn't call get_transactions: {e:?}"), - } - }; - assert!(txs.missed_tx.is_empty()); - all_txs.extend(txs.txs); - } - - let mut batch = BatchVerifier::new(block.txs.len()); - for (tx_hash, tx_res) in block.txs.into_iter().zip(all_txs) { - assert_eq!( - tx_res.tx_hash, - hex::encode(tx_hash), - "node returned a transaction with different hash" - ); - - let tx = Transaction::read( - &mut hex::decode(&tx_res.as_hex).expect("node returned non-hex transaction").as_slice(), - ) - .expect("couldn't deserialize transaction"); - - assert_eq!( - hex::encode(tx.serialize()), - tx_res.as_hex, - "Transaction serialization was different" - ); - assert_eq!(tx.hash(), tx_hash, "Transaction hash was different"); - - if matches!(tx.rct_signatures.prunable, RctPrunable::Null) { - assert_eq!(tx.prefix.version, 1); - assert!(!tx.signatures.is_empty()); - continue; - } - - let sig_hash = tx.signature_hash(); - // Verify all proofs we support proving for - // This is due to having debug_asserts calling verify within their proving, and CLSAG - // multisig explicitly calling verify as part of its signing process - // Accordingly, making sure our signature_hash algorithm is correct is great, and further - // making sure the verification functions are valid is appreciated - match tx.rct_signatures.prunable { - RctPrunable::Null | - RctPrunable::AggregateMlsagBorromean { .. } | - RctPrunable::MlsagBorromean { .. } => {} - RctPrunable::MlsagBulletproofs { bulletproofs, .. } => { - assert!(bulletproofs.batch_verify( - &mut rand_core::OsRng, - &mut batch, - (), - &tx.rct_signatures.base.commitments - )); - } - RctPrunable::Clsag { bulletproofs, clsags, pseudo_outs } => { - assert!(bulletproofs.batch_verify( - &mut rand_core::OsRng, - &mut batch, - (), - &tx.rct_signatures.base.commitments - )); - - for (i, clsag) in clsags.into_iter().enumerate() { - let (amount, key_offsets, image) = match &tx.prefix.inputs[i] { - Input::Gen(_) => panic!("Input::Gen"), - Input::ToKey { amount, key_offsets, key_image } => (amount, key_offsets, key_image), - }; - - let mut running_sum = 0; - let mut actual_indexes = vec![]; - for offset in key_offsets { - running_sum += offset; - actual_indexes.push(running_sum); - } - - async fn get_outs( - rpc: &Rpc, - amount: u64, - indexes: &[u64], - ) -> Vec<[EdwardsPoint; 2]> { - #[derive(Deserialize, Debug)] - struct Out { - key: String, - mask: String, - } - - #[derive(Deserialize, Debug)] - struct Outs { - outs: Vec, - } - - let outs: Outs = loop { - match rpc - .rpc_call( - "get_outs", - Some(json!({ - "get_txid": true, - "outputs": indexes.iter().map(|o| json!({ - "amount": amount, - "index": o - })).collect::>() - })), - ) - .await - { - Ok(outs) => break outs, - Err(RpcError::ConnectionError(e)) => { - println!("get_outs ConnectionError: {e}"); - continue; - } - Err(e) => panic!("couldn't connect to RPC to get outs: {e:?}"), - } - }; - - let rpc_point = |point: &str| { - decompress_point( - hex::decode(point) - .expect("invalid hex for ring member") - .try_into() - .expect("invalid point len for ring member"), - ) - .expect("invalid point for ring member") - }; - - outs - .outs - .iter() - .map(|out| { - let mask = rpc_point(&out.mask); - if amount != 0 { - assert_eq!(mask, Commitment::new(Scalar::from(1u8), amount).calculate()); - } - [rpc_point(&out.key), mask] - }) - .collect() - } - - clsag - .verify( - &get_outs(&rpc, amount.unwrap_or(0), &actual_indexes).await, - image, - &pseudo_outs[i], - &sig_hash, - ) - .unwrap(); - } - } - } - } - assert!(batch.verify_vartime()); - } - - println!("Deserialized, hashed, and reserialized {block_i} with {txs_len} TXs"); - } -} - -#[cfg(feature = "binaries")] -#[tokio::main] -async fn main() { - use binaries::*; - - let args = std::env::args().collect::>(); - - // Read start block as the first arg - let mut block_i = args[1].parse::().expect("invalid start block"); - - // How many blocks to work on at once - let async_parallelism: usize = - args.get(2).unwrap_or(&"8".to_string()).parse::().expect("invalid parallelism argument"); - - // Read further args as RPC URLs - let default_nodes = vec![ - "http://xmr-node.cakewallet.com:18081".to_string(), - "https://node.sethforprivacy.com".to_string(), - ]; - let mut specified_nodes = vec![]; - { - let mut i = 0; - loop { - let Some(node) = args.get(3 + i) else { break }; - specified_nodes.push(node.clone()); - i += 1; - } - } - let nodes = if specified_nodes.is_empty() { default_nodes } else { specified_nodes }; - - let rpc = |url: String| async move { - HttpRpc::new(url.clone()) - .await - .unwrap_or_else(|_| panic!("couldn't create HttpRpc connected to {url}")) - }; - let main_rpc = rpc(nodes[0].clone()).await; - let mut rpcs = vec![]; - for i in 0 .. async_parallelism { - rpcs.push(Arc::new(rpc(nodes[i % nodes.len()].clone()).await)); - } - - let mut rpc_i = 0; - let mut handles: Vec> = vec![]; - let mut height = 0; - loop { - let new_height = main_rpc.get_height().await.expect("couldn't call get_height"); - if new_height == height { - break; - } - height = new_height; - - while block_i < height { - if handles.len() >= async_parallelism { - // Guarantee one handle is complete - handles.swap_remove(0).await.unwrap(); - - // Remove all of the finished handles - let mut i = 0; - while i < handles.len() { - if handles[i].is_finished() { - handles.swap_remove(i).await.unwrap(); - continue; - } - i += 1; - } - } - - handles.push(tokio::spawn(check_block(rpcs[rpc_i].clone(), block_i))); - rpc_i = (rpc_i + 1) % rpcs.len(); - block_i += 1; - } - } -} - -#[cfg(not(feature = "binaries"))] -fn main() { - panic!("To run binaries, please build with `--feature binaries`."); -} diff --git a/coins/monero/src/block.rs b/coins/monero/src/block.rs index b4e97169d..62a77f8b7 100644 --- a/coins/monero/src/block.rs +++ b/coins/monero/src/block.rs @@ -1,12 +1,13 @@ use std_shims::{ + vec, vec::Vec, io::{self, Read, Write}, }; use crate::{ - hash, + io::*, + primitives::keccak256, merkle::merkle_root, - serialize::*, transaction::{Input, Transaction}, }; @@ -15,34 +16,50 @@ const CORRECT_BLOCK_HASH_202612: [u8; 32] = const EXISTING_BLOCK_HASH_202612: [u8; 32] = hex_literal::hex!("bbd604d2ba11ba27935e006ed39c9bfdd99b76bf4a50654bc1e1e61217962698"); +/// A Monero block's header. #[derive(Clone, PartialEq, Eq, Debug)] pub struct BlockHeader { - pub major_version: u8, - pub minor_version: u8, + /// The hard fork of the protocol this block follows. + /// + /// Per the C++ codebase, this is the `major_version`. + pub hardfork_version: u8, + /// A signal for a proposed hard fork. + /// + /// Per the C++ codebase, this is the `minor_version`. + pub hardfork_signal: u8, + /// Seconds since the epoch. pub timestamp: u64, + /// The previous block's hash. pub previous: [u8; 32], + /// The nonce used to mine the block. + /// + /// Miners should increment this while attempting to find a block with a hash satisfying the PoW + /// rules. pub nonce: u32, } impl BlockHeader { + /// Write the BlockHeader. pub fn write(&self, w: &mut W) -> io::Result<()> { - write_varint(&self.major_version, w)?; - write_varint(&self.minor_version, w)?; + write_varint(&self.hardfork_version, w)?; + write_varint(&self.hardfork_signal, w)?; write_varint(&self.timestamp, w)?; w.write_all(&self.previous)?; w.write_all(&self.nonce.to_le_bytes()) } + /// Serialize the BlockHeader to a `Vec`. pub fn serialize(&self) -> Vec { let mut serialized = vec![]; self.write(&mut serialized).unwrap(); serialized } + /// Read a BlockHeader. pub fn read(r: &mut R) -> io::Result { Ok(BlockHeader { - major_version: read_varint(r)?, - minor_version: read_varint(r)?, + hardfork_version: read_varint(r)?, + hardfork_signal: read_varint(r)?, timestamp: read_varint(r)?, previous: read_bytes(r)?, nonce: read_bytes(r).map(u32::from_le_bytes)?, @@ -50,81 +67,86 @@ impl BlockHeader { } } +/// A Monero block. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Block { + /// The block's header. pub header: BlockHeader, - pub miner_tx: Transaction, - pub txs: Vec<[u8; 32]>, + /// The miner's transaction. + pub miner_transaction: Transaction, + /// The transactions within this block. + pub transactions: Vec<[u8; 32]>, } impl Block { - pub fn number(&self) -> Option { - match self.miner_tx.prefix.inputs.first() { - Some(Input::Gen(number)) => Some(*number), - _ => None, + /// The zero-index position of this block within the blockchain. + /// + /// This information comes from the Block's miner transaction. If the miner transaction isn't + /// structed as expected, this will return None. + pub fn number(&self) -> Option { + match &self.miner_transaction { + Transaction::V1 { prefix, .. } | Transaction::V2 { prefix, .. } => { + match prefix.inputs.first() { + Some(Input::Gen(number)) => Some(*number), + _ => None, + } + } } } + /// Write the Block. pub fn write(&self, w: &mut W) -> io::Result<()> { self.header.write(w)?; - self.miner_tx.write(w)?; - write_varint(&self.txs.len(), w)?; - for tx in &self.txs { + self.miner_transaction.write(w)?; + write_varint(&self.transactions.len(), w)?; + for tx in &self.transactions { w.write_all(tx)?; } Ok(()) } - fn tx_merkle_root(&self) -> [u8; 32] { - merkle_root(self.miner_tx.hash(), &self.txs) + /// Serialize the Block to a `Vec`. + pub fn serialize(&self) -> Vec { + let mut serialized = vec![]; + self.write(&mut serialized).unwrap(); + serialized } /// Serialize the block as required for the proof of work hash. /// /// This is distinct from the serialization required for the block hash. To get the block hash, /// use the [`Block::hash`] function. - pub fn serialize_hashable(&self) -> Vec { + pub fn serialize_pow_hash(&self) -> Vec { let mut blob = self.header.serialize(); - blob.extend_from_slice(&self.tx_merkle_root()); - write_varint(&(1 + u64::try_from(self.txs.len()).unwrap()), &mut blob).unwrap(); - + blob.extend_from_slice(&merkle_root(self.miner_transaction.hash(), &self.transactions)); + write_varint(&(1 + u64::try_from(self.transactions.len()).unwrap()), &mut blob).unwrap(); blob } + /// Get the hash of this block. pub fn hash(&self) -> [u8; 32] { - let mut hashable = self.serialize_hashable(); - // Monero pre-appends a VarInt of the block hashing blobs length before getting the block hash + let mut hashable = self.serialize_pow_hash(); + // Monero pre-appends a VarInt of the block-to-hash'ss length before getting the block hash, // but doesn't do this when getting the proof of work hash :) - let mut hashing_blob = Vec::with_capacity(8 + hashable.len()); + let mut hashing_blob = Vec::with_capacity(9 + hashable.len()); write_varint(&u64::try_from(hashable.len()).unwrap(), &mut hashing_blob).unwrap(); hashing_blob.append(&mut hashable); - let hash = hash(&hashing_blob); + let hash = keccak256(hashing_blob); if hash == CORRECT_BLOCK_HASH_202612 { return EXISTING_BLOCK_HASH_202612; }; - hash } - pub fn serialize(&self) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized).unwrap(); - serialized - } - + /// Read a Block. pub fn read(r: &mut R) -> io::Result { - let header = BlockHeader::read(r)?; - - let miner_tx = Transaction::read(r)?; - if !matches!(miner_tx.prefix.inputs.as_slice(), &[Input::Gen(_)]) { - Err(io::Error::other("Miner transaction has incorrect input type."))?; - } - Ok(Block { - header, - miner_tx, - txs: (0_usize .. read_varint(r)?).map(|_| read_bytes(r)).collect::>()?, + header: BlockHeader::read(r)?, + miner_transaction: Transaction::read(r)?, + transactions: (0_usize .. read_varint(r)?) + .map(|_| read_bytes(r)) + .collect::>()?, }) } } diff --git a/coins/monero/src/lib.rs b/coins/monero/src/lib.rs index 4e6b26d1c..1bfcbba74 100644 --- a/coins/monero/src/lib.rs +++ b/coins/monero/src/lib.rs @@ -1,241 +1,36 @@ #![cfg_attr(docsrs, feature(doc_auto_cfg))] #![doc = include_str!("../README.md")] +#![deny(missing_docs)] #![cfg_attr(not(feature = "std"), no_std)] -#[cfg(not(feature = "std"))] -#[macro_use] -extern crate alloc; - -use std_shims::{sync::OnceLock, io}; - -use rand_core::{RngCore, CryptoRng}; - -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use sha3::{Digest, Keccak256}; - -use curve25519_dalek::{ - constants::{ED25519_BASEPOINT_TABLE, ED25519_BASEPOINT_POINT}, - scalar::Scalar, - edwards::{EdwardsPoint, VartimeEdwardsPrecomputation}, - traits::VartimePrecomputedMultiscalarMul, -}; - -pub use monero_generators::{H, decompress_point}; +pub use monero_io as io; +pub use monero_generators as generators; +pub use monero_primitives as primitives; mod merkle; -mod serialize; -use serialize::{read_byte, read_u16}; - -/// UnreducedScalar struct with functionality for recovering incorrectly reduced scalars. -mod unreduced_scalar; - /// Ring Signature structs and functionality. pub mod ring_signatures; /// RingCT structs and functionality. pub mod ringct; -use ringct::RctType; -/// Transaction structs. +/// Transaction structs and functionality. pub mod transaction; -/// Block structs. +/// Block structs and functionality. pub mod block; -/// Monero daemon RPC interface. -pub mod rpc; -/// Wallet functionality, enabling scanning and sending transactions. -pub mod wallet; - -#[cfg(test)] -mod tests; - -pub const DEFAULT_LOCK_WINDOW: usize = 10; -pub const COINBASE_LOCK_WINDOW: usize = 60; -pub const BLOCK_TIME: usize = 120; - -static INV_EIGHT_CELL: OnceLock = OnceLock::new(); -#[allow(non_snake_case)] -pub(crate) fn INV_EIGHT() -> Scalar { - *INV_EIGHT_CELL.get_or_init(|| Scalar::from(8u8).invert()) -} - -static BASEPOINT_PRECOMP_CELL: OnceLock = OnceLock::new(); -#[allow(non_snake_case)] -pub(crate) fn BASEPOINT_PRECOMP() -> &'static VartimeEdwardsPrecomputation { - BASEPOINT_PRECOMP_CELL - .get_or_init(|| VartimeEdwardsPrecomputation::new([ED25519_BASEPOINT_POINT])) -} - -/// Monero protocol version. +/// The minimum amount of blocks an output is locked for. /// -/// v15 is omitted as v15 was simply v14 and v16 being active at the same time, with regards to the -/// transactions supported. Accordingly, v16 should be used during v15. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -#[allow(non_camel_case_types)] -pub enum Protocol { - v14, - v16, - Custom { - ring_len: usize, - bp_plus: bool, - optimal_rct_type: RctType, - view_tags: bool, - v16_fee: bool, - }, -} - -impl Protocol { - /// Amount of ring members under this protocol version. - pub fn ring_len(&self) -> usize { - match self { - Protocol::v14 => 11, - Protocol::v16 => 16, - Protocol::Custom { ring_len, .. } => *ring_len, - } - } - - /// Whether or not the specified version uses Bulletproofs or Bulletproofs+. - /// - /// This method will likely be reworked when versions not using Bulletproofs at all are added. - pub fn bp_plus(&self) -> bool { - match self { - Protocol::v14 => false, - Protocol::v16 => true, - Protocol::Custom { bp_plus, .. } => *bp_plus, - } - } - - // TODO: Make this an Option when we support pre-RCT protocols - pub fn optimal_rct_type(&self) -> RctType { - match self { - Protocol::v14 => RctType::Clsag, - Protocol::v16 => RctType::BulletproofsPlus, - Protocol::Custom { optimal_rct_type, .. } => *optimal_rct_type, - } - } - - /// Whether or not the specified version uses view tags. - pub fn view_tags(&self) -> bool { - match self { - Protocol::v14 => false, - Protocol::v16 => true, - Protocol::Custom { view_tags, .. } => *view_tags, - } - } - - /// Whether or not the specified version uses the fee algorithm from Monero - /// hard fork version 16 (released in v18 binaries). - pub fn v16_fee(&self) -> bool { - match self { - Protocol::v14 => false, - Protocol::v16 => true, - Protocol::Custom { v16_fee, .. } => *v16_fee, - } - } - - pub(crate) fn write(&self, w: &mut W) -> io::Result<()> { - match self { - Protocol::v14 => w.write_all(&[0, 14]), - Protocol::v16 => w.write_all(&[0, 16]), - Protocol::Custom { ring_len, bp_plus, optimal_rct_type, view_tags, v16_fee } => { - // Custom, version 0 - w.write_all(&[1, 0])?; - w.write_all(&u16::try_from(*ring_len).unwrap().to_le_bytes())?; - w.write_all(&[u8::from(*bp_plus)])?; - w.write_all(&[optimal_rct_type.to_byte()])?; - w.write_all(&[u8::from(*view_tags)])?; - w.write_all(&[u8::from(*v16_fee)]) - } - } - } - - pub(crate) fn read(r: &mut R) -> io::Result { - Ok(match read_byte(r)? { - // Monero protocol - 0 => match read_byte(r)? { - 14 => Protocol::v14, - 16 => Protocol::v16, - _ => Err(io::Error::other("unrecognized monero protocol"))?, - }, - // Custom - 1 => match read_byte(r)? { - 0 => Protocol::Custom { - ring_len: read_u16(r)?.into(), - bp_plus: match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(io::Error::other("invalid bool serialization"))?, - }, - optimal_rct_type: RctType::from_byte(read_byte(r)?) - .ok_or_else(|| io::Error::other("invalid RctType serialization"))?, - view_tags: match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(io::Error::other("invalid bool serialization"))?, - }, - v16_fee: match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(io::Error::other("invalid bool serialization"))?, - }, - }, - _ => Err(io::Error::other("unrecognized custom protocol serialization"))?, - }, - _ => Err(io::Error::other("unrecognized protocol serialization"))?, - }) - } -} - -/// Transparent structure representing a Pedersen commitment's contents. -#[allow(non_snake_case)] -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub struct Commitment { - pub mask: Scalar, - pub amount: u64, -} - -impl core::fmt::Debug for Commitment { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { - fmt.debug_struct("Commitment").field("amount", &self.amount).finish_non_exhaustive() - } -} - -impl Commitment { - /// A commitment to zero, defined with a mask of 1 (as to not be the identity). - pub fn zero() -> Commitment { - Commitment { mask: Scalar::ONE, amount: 0 } - } - - pub fn new(mask: Scalar, amount: u64) -> Commitment { - Commitment { mask, amount } - } - - /// Calculate a Pedersen commitment, as a point, from the transparent structure. - pub fn calculate(&self) -> EdwardsPoint { - (&self.mask * ED25519_BASEPOINT_TABLE) + (Scalar::from(self.amount) * H()) - } -} - -/// Support generating a random scalar using a modern rand, as dalek's is notoriously dated. -pub fn random_scalar(rng: &mut R) -> Scalar { - let mut r = [0; 64]; - rng.fill_bytes(&mut r); - Scalar::from_bytes_mod_order_wide(&r) -} +/// If Monero suffered a re-organization, any transactions which selected decoys belonging to +/// recent blocks would become invalidated. Accordingly, transactions must use decoys which are +/// presumed to not be invalidated in the future. If wallets only selected n-block-old outputs as +/// decoys, then any ring member within the past n blocks would have to be the real spend. +/// Preventing this at the consensus layer ensures privacy and integrity. +pub const DEFAULT_LOCK_WINDOW: usize = 10; -pub(crate) fn hash(data: &[u8]) -> [u8; 32] { - Keccak256::digest(data).into() -} +/// The minimum amount of blocks a coinbase output is locked for. +pub const COINBASE_LOCK_WINDOW: usize = 60; -/// Hash the provided data to a scalar via keccak256(data) % l. -pub fn hash_to_scalar(data: &[u8]) -> Scalar { - let scalar = Scalar::from_bytes_mod_order(hash(data)); - // Monero will explicitly error in this case - // This library acknowledges its practical impossibility of it occurring, and doesn't bother to - // code in logic to handle it. That said, if it ever occurs, something must happen in order to - // not generate/verify a proof we believe to be valid when it isn't - assert!(scalar != Scalar::ZERO, "ZERO HASH: {data:?}"); - scalar -} +/// Monero's block time target, in seconds. +pub const BLOCK_TIME: usize = 120; diff --git a/coins/monero/src/merkle.rs b/coins/monero/src/merkle.rs index 8123b9028..6c689618d 100644 --- a/coins/monero/src/merkle.rs +++ b/coins/monero/src/merkle.rs @@ -1,11 +1,11 @@ use std_shims::vec::Vec; -use crate::hash; +use crate::primitives::keccak256; pub(crate) fn merkle_root(root: [u8; 32], leafs: &[[u8; 32]]) -> [u8; 32] { match leafs.len() { 0 => root, - 1 => hash(&[root, leafs[0]].concat()), + 1 => keccak256([root, leafs[0]].concat()), _ => { let mut hashes = Vec::with_capacity(1 + leafs.len()); hashes.push(root); @@ -29,7 +29,7 @@ pub(crate) fn merkle_root(root: [u8; 32], leafs: &[[u8; 32]]) -> [u8; 32] { let mut paired_hashes = Vec::with_capacity(overage); while let Some(left) = rightmost.next() { let right = rightmost.next().unwrap(); - paired_hashes.push(hash(&[left.as_ref(), &right].concat())); + paired_hashes.push(keccak256([left.as_ref(), &right].concat())); } drop(rightmost); @@ -42,7 +42,7 @@ pub(crate) fn merkle_root(root: [u8; 32], leafs: &[[u8; 32]]) -> [u8; 32] { while hashes.len() > 1 { let mut i = 0; while i < hashes.len() { - new_hashes.push(hash(&[hashes[i], hashes[i + 1]].concat())); + new_hashes.push(keccak256([hashes[i], hashes[i + 1]].concat())); i += 2; } diff --git a/coins/monero/src/ring_signatures.rs b/coins/monero/src/ring_signatures.rs index 72d30b0e9..26e721a04 100644 --- a/coins/monero/src/ring_signatures.rs +++ b/coins/monero/src/ring_signatures.rs @@ -7,34 +7,36 @@ use zeroize::Zeroize; use curve25519_dalek::{EdwardsPoint, Scalar}; -use monero_generators::hash_to_point; - -use crate::{serialize::*, hash_to_scalar}; +use crate::{io::*, generators::hash_to_point, primitives::keccak256_to_scalar}; #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] -pub struct Signature { +struct Signature { c: Scalar, - r: Scalar, + s: Scalar, } impl Signature { - pub fn write(&self, w: &mut W) -> io::Result<()> { + fn write(&self, w: &mut W) -> io::Result<()> { write_scalar(&self.c, w)?; - write_scalar(&self.r, w)?; + write_scalar(&self.s, w)?; Ok(()) } - pub fn read(r: &mut R) -> io::Result { - Ok(Signature { c: read_scalar(r)?, r: read_scalar(r)? }) + fn read(r: &mut R) -> io::Result { + Ok(Signature { c: read_scalar(r)?, s: read_scalar(r)? }) } } +/// A ring signature. +/// +/// This was used by the original Cryptonote transaction protocol and was deprecated with RingCT. #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct RingSignature { sigs: Vec, } impl RingSignature { + /// Write the RingSignature. pub fn write(&self, w: &mut W) -> io::Result<()> { for sig in &self.sigs { sig.write(w)?; @@ -42,31 +44,49 @@ impl RingSignature { Ok(()) } + /// Read a RingSignature. pub fn read(members: usize, r: &mut R) -> io::Result { Ok(RingSignature { sigs: read_raw_vec(Signature::read, members, r)? }) } + /// Verify the ring signature. pub fn verify(&self, msg: &[u8; 32], ring: &[EdwardsPoint], key_image: &EdwardsPoint) -> bool { if ring.len() != self.sigs.len() { return false; } - let mut buf = Vec::with_capacity(32 + (32 * 2 * ring.len())); + let mut buf = Vec::with_capacity(32 + (2 * 32 * ring.len())); buf.extend_from_slice(msg); let mut sum = Scalar::ZERO; - for (ring_member, sig) in ring.iter().zip(&self.sigs) { + /* + The traditional Schnorr signature is: + r = sample() + c = H(r G || m) + s = r - c x + Verified as: + s G + c A == R + + Each ring member here performs a dual-Schnorr signature for: + s G + c A + s HtP(A) + c K + Where the transcript is pushed both these values, r G, r HtP(A) for the real spend. + This also serves as a DLEq proof between the key and the key image. + + Checking sum(c) == H(transcript) acts a disjunction, where any one of the `c`s can be + modified to cause the intended sum, if and only if a corresponding `s` value is known. + */ + #[allow(non_snake_case)] - let Li = EdwardsPoint::vartime_double_scalar_mul_basepoint(&sig.c, ring_member, &sig.r); + let Li = EdwardsPoint::vartime_double_scalar_mul_basepoint(&sig.c, ring_member, &sig.s); buf.extend_from_slice(Li.compress().as_bytes()); #[allow(non_snake_case)] - let Ri = (sig.r * hash_to_point(ring_member.compress().to_bytes())) + (sig.c * key_image); + let Ri = (sig.s * hash_to_point(ring_member.compress().to_bytes())) + (sig.c * key_image); buf.extend_from_slice(Ri.compress().as_bytes()); sum += sig.c; } - - sum == hash_to_scalar(&buf) + sum == keccak256_to_scalar(buf) } } diff --git a/coins/monero/src/ringct.rs b/coins/monero/src/ringct.rs new file mode 100644 index 000000000..a544e15f8 --- /dev/null +++ b/coins/monero/src/ringct.rs @@ -0,0 +1,461 @@ +use std_shims::{ + vec, + vec::Vec, + io::{self, Read, Write}, +}; + +use zeroize::Zeroize; + +use curve25519_dalek::edwards::EdwardsPoint; + +pub use monero_mlsag as mlsag; +pub use monero_clsag as clsag; +pub use monero_borromean as borromean; +pub use monero_bulletproofs as bulletproofs; + +use crate::{ + io::*, + ringct::{mlsag::Mlsag, clsag::Clsag, borromean::BorromeanRange, bulletproofs::Bulletproof}, +}; + +/// An encrypted amount. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum EncryptedAmount { + /// The original format for encrypted amounts. + Original { + /// A mask used with a mask derived from the shared secret to encrypt the amount. + mask: [u8; 32], + /// The amount, as a scalar, encrypted. + amount: [u8; 32], + }, + /// The "compact" format for encrypted amounts. + Compact { + /// The amount, as a u64, encrypted. + amount: [u8; 8], + }, +} + +impl EncryptedAmount { + /// Read an EncryptedAmount from a reader. + pub fn read(compact: bool, r: &mut R) -> io::Result { + Ok(if !compact { + EncryptedAmount::Original { mask: read_bytes(r)?, amount: read_bytes(r)? } + } else { + EncryptedAmount::Compact { amount: read_bytes(r)? } + }) + } + + /// Write the EncryptedAmount to a writer. + pub fn write(&self, w: &mut W) -> io::Result<()> { + match self { + EncryptedAmount::Original { mask, amount } => { + w.write_all(mask)?; + w.write_all(amount) + } + EncryptedAmount::Compact { amount } => w.write_all(amount), + } + } +} + +/// The type of the RingCT data. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] +pub enum RctType { + /// One MLSAG for multiple inputs and Borromean range proofs. + /// + /// This aligns with RCTTypeFull. + AggregateMlsagBorromean, + // One MLSAG for each input and a Borromean range proof. + /// + /// This aligns with RCTTypeSimple. + MlsagBorromean, + // One MLSAG for each input and a Bulletproof. + /// + /// This aligns with RCTTypeBulletproof. + MlsagBulletproofs, + /// One MLSAG for each input and a Bulletproof, yet using EncryptedAmount::Compact. + /// + /// This aligns with RCTTypeBulletproof2. + MlsagBulletproofsCompactAmount, + /// One CLSAG for each input and a Bulletproof. + /// + /// This aligns with RCTTypeCLSAG. + ClsagBulletproof, + /// One CLSAG for each input and a Bulletproof+. + /// + /// This aligns with RCTTypeBulletproofPlus. + ClsagBulletproofPlus, +} + +impl From for u8 { + fn from(kind: RctType) -> u8 { + match kind { + RctType::AggregateMlsagBorromean => 1, + RctType::MlsagBorromean => 2, + RctType::MlsagBulletproofs => 3, + RctType::MlsagBulletproofsCompactAmount => 4, + RctType::ClsagBulletproof => 5, + RctType::ClsagBulletproofPlus => 6, + } + } +} + +impl TryFrom for RctType { + type Error = (); + fn try_from(byte: u8) -> Result { + Ok(match byte { + 1 => RctType::AggregateMlsagBorromean, + 2 => RctType::MlsagBorromean, + 3 => RctType::MlsagBulletproofs, + 4 => RctType::MlsagBulletproofsCompactAmount, + 5 => RctType::ClsagBulletproof, + 6 => RctType::ClsagBulletproofPlus, + _ => Err(())?, + }) + } +} + +impl RctType { + /// True if this RctType uses compact encrypted amounts, false otherwise. + pub fn compact_encrypted_amounts(&self) -> bool { + match self { + RctType::AggregateMlsagBorromean | RctType::MlsagBorromean | RctType::MlsagBulletproofs => { + false + } + RctType::MlsagBulletproofsCompactAmount | + RctType::ClsagBulletproof | + RctType::ClsagBulletproofPlus => true, + } + } + + /// True if this RctType uses a Bulletproof, false otherwise. + pub(crate) fn bulletproof(&self) -> bool { + match self { + RctType::MlsagBulletproofs | + RctType::MlsagBulletproofsCompactAmount | + RctType::ClsagBulletproof => true, + RctType::AggregateMlsagBorromean | + RctType::MlsagBorromean | + RctType::ClsagBulletproofPlus => false, + } + } + + /// True if this RctType uses a Bulletproof+, false otherwise. + pub(crate) fn bulletproof_plus(&self) -> bool { + match self { + RctType::ClsagBulletproofPlus => true, + RctType::AggregateMlsagBorromean | + RctType::MlsagBorromean | + RctType::MlsagBulletproofs | + RctType::MlsagBulletproofsCompactAmount | + RctType::ClsagBulletproof => false, + } + } +} + +/// The base of the RingCT data. +/// +/// This excludes all proofs (which once initially verified do not need to be kept around) and +/// solely keeps data which either impacts the effects of the transactions or is needed to scan it. +/// +/// The one exception for this is `pseudo_outs`, which was originally present here yet moved to +/// RctPrunable in a later hard fork (causing it to be present in both). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RctBase { + /// The fee used by this transaction. + pub fee: u64, + /// The re-randomized amount commitments used within inputs. + /// + /// This field was deprecated and is empty for modern RctTypes. + pub pseudo_outs: Vec, + /// The encrypted amounts for the recipients to decrypt. + pub encrypted_amounts: Vec, + /// The output commitments. + pub commitments: Vec, +} + +impl RctBase { + /// Write the RctBase. + pub fn write(&self, w: &mut W, rct_type: RctType) -> io::Result<()> { + w.write_all(&[u8::from(rct_type)])?; + + write_varint(&self.fee, w)?; + if rct_type == RctType::MlsagBorromean { + write_raw_vec(write_point, &self.pseudo_outs, w)?; + } + for encrypted_amount in &self.encrypted_amounts { + encrypted_amount.write(w)?; + } + write_raw_vec(write_point, &self.commitments, w) + } + + /// Read a RctBase. + pub fn read( + inputs: usize, + outputs: usize, + r: &mut R, + ) -> io::Result> { + let rct_type = read_byte(r)?; + if rct_type == 0 { + return Ok(None); + } + let rct_type = + RctType::try_from(rct_type).map_err(|()| io::Error::other("invalid RCT type"))?; + + match rct_type { + RctType::AggregateMlsagBorromean | RctType::MlsagBorromean => {} + RctType::MlsagBulletproofs | + RctType::MlsagBulletproofsCompactAmount | + RctType::ClsagBulletproof | + RctType::ClsagBulletproofPlus => { + if outputs == 0 { + // Because the Bulletproofs(+) layout must be canonical, there must be 1 Bulletproof if + // Bulletproofs are in use + // If there are Bulletproofs, there must be a matching amount of outputs, implicitly + // banning 0 outputs + // Since HF 12 (CLSAG being 13), a 2-output minimum has also been enforced + Err(io::Error::other("RCT with Bulletproofs(+) had 0 outputs"))?; + } + } + } + + Ok(Some(( + rct_type, + RctBase { + fee: read_varint(r)?, + // Only read pseudo_outs if they have yet to be moved to RctPrunable + // This would apply to AggregateMlsagBorromean and MlsagBorromean, except + // AggregateMlsagBorromean doesn't use pseudo_outs due to using the sum of the output + // commitments directly as the effective singular pseudo-out + pseudo_outs: if rct_type == RctType::MlsagBorromean { + read_raw_vec(read_point, inputs, r)? + } else { + vec![] + }, + encrypted_amounts: (0 .. outputs) + .map(|_| EncryptedAmount::read(rct_type.compact_encrypted_amounts(), r)) + .collect::>()?, + commitments: read_raw_vec(read_point, outputs, r)?, + }, + ))) + } +} + +/// The prunable part of the RingCT data. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum RctPrunable { + /// An aggregate MLSAG with Borromean range proofs. + AggregateMlsagBorromean { + /// The aggregate MLSAG ring signature. + mlsag: Mlsag, + /// The Borromean range proofs for each output. + borromean: Vec, + }, + /// MLSAGs with Borromean range proofs. + MlsagBorromean { + /// The MLSAG ring signatures for each input. + mlsags: Vec, + /// The Borromean range proofs for each output. + borromean: Vec, + }, + /// MLSAGs with Bulletproofs. + MlsagBulletproofs { + /// The MLSAG ring signatures for each input. + mlsags: Vec, + /// The re-blinded commitments for the outputs being spent. + pseudo_outs: Vec, + /// The aggregate Bulletproof, proving the outputs are within range. + bulletproof: Bulletproof, + }, + /// MLSAGs with Bulletproofs and compact encrypted amounts. + /// + /// This has an identical layout to MlsagBulletproofs and is interpreted the exact same way. It's + /// only differentiated to ensure discovery of the correct RctType. + MlsagBulletproofsCompactAmount { + /// The MLSAG ring signatures for each input. + mlsags: Vec, + /// The re-blinded commitments for the outputs being spent. + pseudo_outs: Vec, + /// The aggregate Bulletproof, proving the outputs are within range. + bulletproof: Bulletproof, + }, + /// CLSAGs with Bulletproofs(+). + Clsag { + /// The CLSAGs for each input. + clsags: Vec, + /// The re-blinded commitments for the outputs being spent. + pseudo_outs: Vec, + /// The aggregate Bulletproof(+), proving the outputs are within range. + bulletproof: Bulletproof, + }, +} + +impl RctPrunable { + /// Write the RctPrunable. + pub fn write(&self, w: &mut W, rct_type: RctType) -> io::Result<()> { + match self { + RctPrunable::AggregateMlsagBorromean { borromean, mlsag } => { + write_raw_vec(BorromeanRange::write, borromean, w)?; + mlsag.write(w) + } + RctPrunable::MlsagBorromean { borromean, mlsags } => { + write_raw_vec(BorromeanRange::write, borromean, w)?; + write_raw_vec(Mlsag::write, mlsags, w) + } + RctPrunable::MlsagBulletproofs { bulletproof, mlsags, pseudo_outs } | + RctPrunable::MlsagBulletproofsCompactAmount { bulletproof, mlsags, pseudo_outs } => { + if rct_type == RctType::MlsagBulletproofs { + w.write_all(&1u32.to_le_bytes())?; + } else { + w.write_all(&[1])?; + } + bulletproof.write(w)?; + + write_raw_vec(Mlsag::write, mlsags, w)?; + write_raw_vec(write_point, pseudo_outs, w) + } + RctPrunable::Clsag { bulletproof, clsags, pseudo_outs } => { + w.write_all(&[1])?; + bulletproof.write(w)?; + + write_raw_vec(Clsag::write, clsags, w)?; + write_raw_vec(write_point, pseudo_outs, w) + } + } + } + + /// Serialize the RctPrunable to a `Vec`. + pub fn serialize(&self, rct_type: RctType) -> Vec { + let mut serialized = vec![]; + self.write(&mut serialized, rct_type).unwrap(); + serialized + } + + /// Read a RctPrunable. + pub fn read( + rct_type: RctType, + ring_length: usize, + inputs: usize, + outputs: usize, + r: &mut R, + ) -> io::Result { + Ok(match rct_type { + RctType::AggregateMlsagBorromean => RctPrunable::AggregateMlsagBorromean { + borromean: read_raw_vec(BorromeanRange::read, outputs, r)?, + mlsag: Mlsag::read(ring_length, inputs + 1, r)?, + }, + RctType::MlsagBorromean => RctPrunable::MlsagBorromean { + borromean: read_raw_vec(BorromeanRange::read, outputs, r)?, + mlsags: (0 .. inputs).map(|_| Mlsag::read(ring_length, 2, r)).collect::>()?, + }, + RctType::MlsagBulletproofs | RctType::MlsagBulletproofsCompactAmount => { + let bulletproof = { + if (if rct_type == RctType::MlsagBulletproofs { + u64::from(read_u32(r)?) + } else { + read_varint(r)? + }) != 1 + { + Err(io::Error::other("n bulletproofs instead of one"))?; + } + Bulletproof::read(r)? + }; + let mlsags = + (0 .. inputs).map(|_| Mlsag::read(ring_length, 2, r)).collect::>()?; + let pseudo_outs = read_raw_vec(read_point, inputs, r)?; + if rct_type == RctType::MlsagBulletproofs { + RctPrunable::MlsagBulletproofs { bulletproof, mlsags, pseudo_outs } + } else { + debug_assert_eq!(rct_type, RctType::MlsagBulletproofsCompactAmount); + RctPrunable::MlsagBulletproofsCompactAmount { bulletproof, mlsags, pseudo_outs } + } + } + RctType::ClsagBulletproof | RctType::ClsagBulletproofPlus => RctPrunable::Clsag { + bulletproof: { + if read_varint::<_, u64>(r)? != 1 { + Err(io::Error::other("n bulletproofs instead of one"))?; + } + (if rct_type == RctType::ClsagBulletproof { + Bulletproof::read + } else { + Bulletproof::read_plus + })(r)? + }, + clsags: (0 .. inputs).map(|_| Clsag::read(ring_length, r)).collect::>()?, + pseudo_outs: read_raw_vec(read_point, inputs, r)?, + }, + }) + } + + /// Write the RctPrunable as necessary for signing the signature. + pub(crate) fn signature_write(&self, w: &mut W) -> io::Result<()> { + match self { + RctPrunable::AggregateMlsagBorromean { borromean, .. } | + RctPrunable::MlsagBorromean { borromean, .. } => { + borromean.iter().try_for_each(|rs| rs.write(w)) + } + RctPrunable::MlsagBulletproofs { bulletproof, .. } | + RctPrunable::MlsagBulletproofsCompactAmount { bulletproof, .. } | + RctPrunable::Clsag { bulletproof, .. } => bulletproof.signature_write(w), + } + } +} + +/// The RingCT proofs. +/// +/// This contains both the RctBase and RctPrunable structs. +/// +/// The C++ codebase refers to this as rct_signatures. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RctProofs { + /// The data necessary for handling this transaction. + pub base: RctBase, + /// The data necessary for verifying this transaction. + pub prunable: RctPrunable, +} + +impl RctProofs { + /// RctType for a given RctProofs struct. + pub fn rct_type(&self) -> RctType { + match &self.prunable { + RctPrunable::AggregateMlsagBorromean { .. } => RctType::AggregateMlsagBorromean, + RctPrunable::MlsagBorromean { .. } => RctType::MlsagBorromean, + RctPrunable::MlsagBulletproofs { .. } => RctType::MlsagBulletproofs, + RctPrunable::MlsagBulletproofsCompactAmount { .. } => RctType::MlsagBulletproofsCompactAmount, + RctPrunable::Clsag { bulletproof, .. } => { + if matches!(bulletproof, Bulletproof::Original { .. }) { + RctType::ClsagBulletproof + } else { + RctType::ClsagBulletproofPlus + } + } + } + } + + /// Write the RctProofs. + pub fn write(&self, w: &mut W) -> io::Result<()> { + let rct_type = self.rct_type(); + self.base.write(w, rct_type)?; + self.prunable.write(w, rct_type) + } + + /// Serialize the RctProofs to a `Vec`. + pub fn serialize(&self) -> Vec { + let mut serialized = vec![]; + self.write(&mut serialized).unwrap(); + serialized + } + + /// Read a RctProofs. + pub fn read( + ring_length: usize, + inputs: usize, + outputs: usize, + r: &mut R, + ) -> io::Result> { + let Some((rct_type, base)) = RctBase::read(inputs, outputs, r)? else { return Ok(None) }; + Ok(Some(RctProofs { + base, + prunable: RctPrunable::read(rct_type, ring_length, inputs, outputs, r)?, + })) + } +} diff --git a/coins/monero/src/ringct/bulletproofs/core.rs b/coins/monero/src/ringct/bulletproofs/core.rs deleted file mode 100644 index 6c264e00e..000000000 --- a/coins/monero/src/ringct/bulletproofs/core.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std_shims::{vec::Vec, sync::OnceLock}; - -use rand_core::{RngCore, CryptoRng}; - -use subtle::{Choice, ConditionallySelectable}; - -use curve25519_dalek::edwards::EdwardsPoint as DalekPoint; - -use group::{ff::Field, Group}; -use dalek_ff_group::{Scalar, EdwardsPoint}; - -use multiexp::multiexp as multiexp_const; - -pub(crate) use monero_generators::Generators; - -use crate::{INV_EIGHT as DALEK_INV_EIGHT, H as DALEK_H, Commitment, hash_to_scalar as dalek_hash}; -pub(crate) use crate::ringct::bulletproofs::scalar_vector::*; - -#[inline] -pub(crate) fn INV_EIGHT() -> Scalar { - Scalar(DALEK_INV_EIGHT()) -} - -#[inline] -pub(crate) fn H() -> EdwardsPoint { - EdwardsPoint(DALEK_H()) -} - -pub(crate) fn hash_to_scalar(data: &[u8]) -> Scalar { - Scalar(dalek_hash(data)) -} - -// Components common between variants -pub(crate) const MAX_M: usize = 16; -pub(crate) const LOG_N: usize = 6; // 2 << 6 == N -pub(crate) const N: usize = 64; - -pub(crate) fn prove_multiexp(pairs: &[(Scalar, EdwardsPoint)]) -> EdwardsPoint { - multiexp_const(pairs) * INV_EIGHT() -} - -pub(crate) fn vector_exponent( - generators: &Generators, - a: &ScalarVector, - b: &ScalarVector, -) -> EdwardsPoint { - debug_assert_eq!(a.len(), b.len()); - (a * &generators.G[.. a.len()]) + (b * &generators.H[.. b.len()]) -} - -pub(crate) fn hash_cache(cache: &mut Scalar, mash: &[[u8; 32]]) -> Scalar { - let slice = - &[cache.to_bytes().as_ref(), mash.iter().copied().flatten().collect::>().as_ref()] - .concat(); - *cache = hash_to_scalar(slice); - *cache -} - -pub(crate) fn MN(outputs: usize) -> (usize, usize, usize) { - let mut logM = 0; - let mut M; - while { - M = 1 << logM; - (M <= MAX_M) && (M < outputs) - } { - logM += 1; - } - - (logM + LOG_N, M, M * N) -} - -pub(crate) fn bit_decompose(commitments: &[Commitment]) -> (ScalarVector, ScalarVector) { - let (_, M, MN) = MN(commitments.len()); - - let sv = commitments.iter().map(|c| Scalar::from(c.amount)).collect::>(); - let mut aL = ScalarVector::new(MN); - let mut aR = ScalarVector::new(MN); - - for j in 0 .. M { - for i in (0 .. N).rev() { - let bit = - if j < sv.len() { Choice::from((sv[j][i / 8] >> (i % 8)) & 1) } else { Choice::from(0) }; - aL.0[(j * N) + i] = Scalar::conditional_select(&Scalar::ZERO, &Scalar::ONE, bit); - aR.0[(j * N) + i] = Scalar::conditional_select(&-Scalar::ONE, &Scalar::ZERO, bit); - } - } - - (aL, aR) -} - -pub(crate) fn hash_commitments>( - commitments: C, -) -> (Scalar, Vec) { - let V = commitments.into_iter().map(|c| EdwardsPoint(c) * INV_EIGHT()).collect::>(); - (hash_to_scalar(&V.iter().flat_map(|V| V.compress().to_bytes()).collect::>()), V) -} - -pub(crate) fn alpha_rho( - rng: &mut R, - generators: &Generators, - aL: &ScalarVector, - aR: &ScalarVector, -) -> (Scalar, EdwardsPoint) { - let ar = Scalar::random(rng); - (ar, (vector_exponent(generators, aL, aR) + (EdwardsPoint::generator() * ar)) * INV_EIGHT()) -} - -pub(crate) fn LR_statements( - a: &ScalarVector, - G_i: &[EdwardsPoint], - b: &ScalarVector, - H_i: &[EdwardsPoint], - cL: Scalar, - U: EdwardsPoint, -) -> Vec<(Scalar, EdwardsPoint)> { - let mut res = a - .0 - .iter() - .copied() - .zip(G_i.iter().copied()) - .chain(b.0.iter().copied().zip(H_i.iter().copied())) - .collect::>(); - res.push((cL, U)); - res -} - -static TWO_N_CELL: OnceLock = OnceLock::new(); -pub(crate) fn TWO_N() -> &'static ScalarVector { - TWO_N_CELL.get_or_init(|| ScalarVector::powers(Scalar::from(2u8), N)) -} - -pub(crate) fn challenge_products(w: &[Scalar], winv: &[Scalar]) -> Vec { - let mut products = vec![Scalar::ZERO; 1 << w.len()]; - products[0] = winv[0]; - products[1] = w[0]; - for j in 1 .. w.len() { - let mut slots = (1 << (j + 1)) - 1; - while slots > 0 { - products[slots] = products[slots / 2] * w[j]; - products[slots - 1] = products[slots / 2] * winv[j]; - slots = slots.saturating_sub(2); - } - } - - // Sanity check as if the above failed to populate, it'd be critical - for w in &products { - debug_assert!(!bool::from(w.is_zero())); - } - - products -} diff --git a/coins/monero/src/ringct/bulletproofs/mod.rs b/coins/monero/src/ringct/bulletproofs/mod.rs deleted file mode 100644 index ce9f74926..000000000 --- a/coins/monero/src/ringct/bulletproofs/mod.rs +++ /dev/null @@ -1,229 +0,0 @@ -#![allow(non_snake_case)] - -use std_shims::{ - vec::Vec, - io::{self, Read, Write}, -}; - -use rand_core::{RngCore, CryptoRng}; - -use zeroize::{Zeroize, Zeroizing}; - -use curve25519_dalek::edwards::EdwardsPoint; -use multiexp::BatchVerifier; - -use crate::{Commitment, wallet::TransactionError, serialize::*}; - -pub(crate) mod scalar_vector; -pub(crate) mod core; -use self::core::LOG_N; - -pub(crate) mod original; -use self::original::OriginalStruct; - -pub(crate) mod plus; -use self::plus::*; - -pub(crate) const MAX_OUTPUTS: usize = self::core::MAX_M; - -/// Bulletproofs enum, supporting the original and plus formulations. -#[allow(clippy::large_enum_variant)] -#[derive(Clone, PartialEq, Eq, Debug)] -pub enum Bulletproofs { - Original(OriginalStruct), - Plus(AggregateRangeProof), -} - -impl Bulletproofs { - fn bp_fields(plus: bool) -> usize { - if plus { - 6 - } else { - 9 - } - } - - // https://github.com/monero-project/monero/blob/94e67bf96bbc010241f29ada6abc89f49a81759c/ - // src/cryptonote_basic/cryptonote_format_utils.cpp#L106-L124 - pub(crate) fn calculate_bp_clawback(plus: bool, n_outputs: usize) -> (usize, usize) { - #[allow(non_snake_case)] - let mut LR_len = 0; - let mut n_padded_outputs = 1; - while n_padded_outputs < n_outputs { - LR_len += 1; - n_padded_outputs = 1 << LR_len; - } - LR_len += LOG_N; - - let mut bp_clawback = 0; - if n_padded_outputs > 2 { - let fields = Bulletproofs::bp_fields(plus); - let base = ((fields + (2 * (LOG_N + 1))) * 32) / 2; - let size = (fields + (2 * LR_len)) * 32; - bp_clawback = ((base * n_padded_outputs) - size) * 4 / 5; - } - - (bp_clawback, LR_len) - } - - pub(crate) fn fee_weight(plus: bool, outputs: usize) -> usize { - #[allow(non_snake_case)] - let (bp_clawback, LR_len) = Bulletproofs::calculate_bp_clawback(plus, outputs); - 32 * (Bulletproofs::bp_fields(plus) + (2 * LR_len)) + 2 + bp_clawback - } - - /// Prove the list of commitments are within [0 .. 2^64). - pub fn prove( - rng: &mut R, - outputs: &[Commitment], - plus: bool, - ) -> Result { - if outputs.is_empty() { - Err(TransactionError::NoOutputs)?; - } - if outputs.len() > MAX_OUTPUTS { - Err(TransactionError::TooManyOutputs)?; - } - Ok(if !plus { - Bulletproofs::Original(OriginalStruct::prove(rng, outputs)) - } else { - use dalek_ff_group::EdwardsPoint as DfgPoint; - Bulletproofs::Plus( - AggregateRangeStatement::new(outputs.iter().map(|com| DfgPoint(com.calculate())).collect()) - .unwrap() - .prove(rng, &Zeroizing::new(AggregateRangeWitness::new(outputs.to_vec()).unwrap())) - .unwrap(), - ) - }) - } - - /// Verify the given Bulletproofs. - #[must_use] - pub fn verify(&self, rng: &mut R, commitments: &[EdwardsPoint]) -> bool { - match self { - Bulletproofs::Original(bp) => bp.verify(rng, commitments), - Bulletproofs::Plus(bp) => { - let mut verifier = BatchVerifier::new(1); - // If this commitment is torsioned (which is allowed), this won't be a well-formed - // dfg::EdwardsPoint (expected to be of prime-order) - // The actual BP+ impl will perform a torsion clear though, making this safe - // TODO: Have AggregateRangeStatement take in dalek EdwardsPoint for clarity on this - let Some(statement) = AggregateRangeStatement::new( - commitments.iter().map(|c| dalek_ff_group::EdwardsPoint(*c)).collect(), - ) else { - return false; - }; - if !statement.verify(rng, &mut verifier, (), bp.clone()) { - return false; - } - verifier.verify_vartime() - } - } - } - - /// Accumulate the verification for the given Bulletproofs into the specified BatchVerifier. - /// Returns false if the Bulletproofs aren't sane, without mutating the BatchVerifier. - /// Returns true if the Bulletproofs are sane, regardless of their validity. - #[must_use] - pub fn batch_verify( - &self, - rng: &mut R, - verifier: &mut BatchVerifier, - id: ID, - commitments: &[EdwardsPoint], - ) -> bool { - match self { - Bulletproofs::Original(bp) => bp.batch_verify(rng, verifier, id, commitments), - Bulletproofs::Plus(bp) => { - let Some(statement) = AggregateRangeStatement::new( - commitments.iter().map(|c| dalek_ff_group::EdwardsPoint(*c)).collect(), - ) else { - return false; - }; - statement.verify(rng, verifier, id, bp.clone()) - } - } - } - - fn write_core io::Result<()>>( - &self, - w: &mut W, - specific_write_vec: F, - ) -> io::Result<()> { - match self { - Bulletproofs::Original(bp) => { - write_point(&bp.A, w)?; - write_point(&bp.S, w)?; - write_point(&bp.T1, w)?; - write_point(&bp.T2, w)?; - write_scalar(&bp.taux, w)?; - write_scalar(&bp.mu, w)?; - specific_write_vec(&bp.L, w)?; - specific_write_vec(&bp.R, w)?; - write_scalar(&bp.a, w)?; - write_scalar(&bp.b, w)?; - write_scalar(&bp.t, w) - } - - Bulletproofs::Plus(bp) => { - write_point(&bp.A.0, w)?; - write_point(&bp.wip.A.0, w)?; - write_point(&bp.wip.B.0, w)?; - write_scalar(&bp.wip.r_answer.0, w)?; - write_scalar(&bp.wip.s_answer.0, w)?; - write_scalar(&bp.wip.delta_answer.0, w)?; - specific_write_vec(&bp.wip.L.iter().copied().map(|L| L.0).collect::>(), w)?; - specific_write_vec(&bp.wip.R.iter().copied().map(|R| R.0).collect::>(), w) - } - } - } - - pub(crate) fn signature_write(&self, w: &mut W) -> io::Result<()> { - self.write_core(w, |points, w| write_raw_vec(write_point, points, w)) - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - self.write_core(w, |points, w| write_vec(write_point, points, w)) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized).unwrap(); - serialized - } - - /// Read Bulletproofs. - pub fn read(r: &mut R) -> io::Result { - Ok(Bulletproofs::Original(OriginalStruct { - A: read_point(r)?, - S: read_point(r)?, - T1: read_point(r)?, - T2: read_point(r)?, - taux: read_scalar(r)?, - mu: read_scalar(r)?, - L: read_vec(read_point, r)?, - R: read_vec(read_point, r)?, - a: read_scalar(r)?, - b: read_scalar(r)?, - t: read_scalar(r)?, - })) - } - - /// Read Bulletproofs+. - pub fn read_plus(r: &mut R) -> io::Result { - use dalek_ff_group::{Scalar as DfgScalar, EdwardsPoint as DfgPoint}; - - Ok(Bulletproofs::Plus(AggregateRangeProof { - A: DfgPoint(read_point(r)?), - wip: WipProof { - A: DfgPoint(read_point(r)?), - B: DfgPoint(read_point(r)?), - r_answer: DfgScalar(read_scalar(r)?), - s_answer: DfgScalar(read_scalar(r)?), - delta_answer: DfgScalar(read_scalar(r)?), - L: read_vec(read_point, r)?.into_iter().map(DfgPoint).collect(), - R: read_vec(read_point, r)?.into_iter().map(DfgPoint).collect(), - }, - })) - } -} diff --git a/coins/monero/src/ringct/bulletproofs/original.rs b/coins/monero/src/ringct/bulletproofs/original.rs deleted file mode 100644 index 0e841080e..000000000 --- a/coins/monero/src/ringct/bulletproofs/original.rs +++ /dev/null @@ -1,322 +0,0 @@ -use std_shims::{vec::Vec, sync::OnceLock}; - -use rand_core::{RngCore, CryptoRng}; - -use zeroize::Zeroize; - -use curve25519_dalek::{scalar::Scalar as DalekScalar, edwards::EdwardsPoint as DalekPoint}; - -use group::{ff::Field, Group}; -use dalek_ff_group::{ED25519_BASEPOINT_POINT as G, Scalar, EdwardsPoint}; - -use multiexp::{BatchVerifier, multiexp}; - -use crate::{Commitment, ringct::bulletproofs::core::*}; - -include!(concat!(env!("OUT_DIR"), "/generators.rs")); - -static IP12_CELL: OnceLock = OnceLock::new(); -pub(crate) fn IP12() -> Scalar { - *IP12_CELL.get_or_init(|| ScalarVector(vec![Scalar::ONE; N]).inner_product(TWO_N())) -} - -pub(crate) fn hadamard_fold( - l: &[EdwardsPoint], - r: &[EdwardsPoint], - a: Scalar, - b: Scalar, -) -> Vec { - let mut res = Vec::with_capacity(l.len() / 2); - for i in 0 .. l.len() { - res.push(multiexp(&[(a, l[i]), (b, r[i])])); - } - res -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct OriginalStruct { - pub(crate) A: DalekPoint, - pub(crate) S: DalekPoint, - pub(crate) T1: DalekPoint, - pub(crate) T2: DalekPoint, - pub(crate) taux: DalekScalar, - pub(crate) mu: DalekScalar, - pub(crate) L: Vec, - pub(crate) R: Vec, - pub(crate) a: DalekScalar, - pub(crate) b: DalekScalar, - pub(crate) t: DalekScalar, -} - -impl OriginalStruct { - pub(crate) fn prove( - rng: &mut R, - commitments: &[Commitment], - ) -> OriginalStruct { - let (logMN, M, MN) = MN(commitments.len()); - - let (aL, aR) = bit_decompose(commitments); - let commitments_points = commitments.iter().map(Commitment::calculate).collect::>(); - let (mut cache, _) = hash_commitments(commitments_points.clone()); - - let (sL, sR) = - ScalarVector((0 .. (MN * 2)).map(|_| Scalar::random(&mut *rng)).collect::>()).split(); - - let generators = GENERATORS(); - let (mut alpha, A) = alpha_rho(&mut *rng, generators, &aL, &aR); - let (mut rho, S) = alpha_rho(&mut *rng, generators, &sL, &sR); - - let y = hash_cache(&mut cache, &[A.compress().to_bytes(), S.compress().to_bytes()]); - let mut cache = hash_to_scalar(&y.to_bytes()); - let z = cache; - - let l0 = aL - z; - let l1 = sL; - - let mut zero_twos = Vec::with_capacity(MN); - let zpow = ScalarVector::powers(z, M + 2); - for j in 0 .. M { - for i in 0 .. N { - zero_twos.push(zpow[j + 2] * TWO_N()[i]); - } - } - - let yMN = ScalarVector::powers(y, MN); - let r0 = ((aR + z) * &yMN) + &ScalarVector(zero_twos); - let r1 = yMN * &sR; - - let (T1, T2, x, mut taux) = { - let t1 = l0.clone().inner_product(&r1) + r0.clone().inner_product(&l1); - let t2 = l1.clone().inner_product(&r1); - - let mut tau1 = Scalar::random(&mut *rng); - let mut tau2 = Scalar::random(&mut *rng); - - let T1 = prove_multiexp(&[(t1, H()), (tau1, EdwardsPoint::generator())]); - let T2 = prove_multiexp(&[(t2, H()), (tau2, EdwardsPoint::generator())]); - - let x = - hash_cache(&mut cache, &[z.to_bytes(), T1.compress().to_bytes(), T2.compress().to_bytes()]); - - let taux = (tau2 * (x * x)) + (tau1 * x); - - tau1.zeroize(); - tau2.zeroize(); - (T1, T2, x, taux) - }; - - let mu = (x * rho) + alpha; - alpha.zeroize(); - rho.zeroize(); - - for (i, gamma) in commitments.iter().map(|c| Scalar(c.mask)).enumerate() { - taux += zpow[i + 2] * gamma; - } - - let l = l0 + &(l1 * x); - let r = r0 + &(r1 * x); - - let t = l.clone().inner_product(&r); - - let x_ip = - hash_cache(&mut cache, &[x.to_bytes(), taux.to_bytes(), mu.to_bytes(), t.to_bytes()]); - - let mut a = l; - let mut b = r; - - let yinv = y.invert().unwrap(); - let yinvpow = ScalarVector::powers(yinv, MN); - - let mut G_proof = generators.G[.. a.len()].to_vec(); - let mut H_proof = generators.H[.. a.len()].to_vec(); - H_proof.iter_mut().zip(yinvpow.0.iter()).for_each(|(this_H, yinvpow)| *this_H *= yinvpow); - let U = H() * x_ip; - - let mut L = Vec::with_capacity(logMN); - let mut R = Vec::with_capacity(logMN); - - while a.len() != 1 { - let (aL, aR) = a.split(); - let (bL, bR) = b.split(); - - let cL = aL.clone().inner_product(&bR); - let cR = aR.clone().inner_product(&bL); - - let (G_L, G_R) = G_proof.split_at(aL.len()); - let (H_L, H_R) = H_proof.split_at(aL.len()); - - let L_i = prove_multiexp(&LR_statements(&aL, G_R, &bR, H_L, cL, U)); - let R_i = prove_multiexp(&LR_statements(&aR, G_L, &bL, H_R, cR, U)); - L.push(L_i); - R.push(R_i); - - let w = hash_cache(&mut cache, &[L_i.compress().to_bytes(), R_i.compress().to_bytes()]); - let winv = w.invert().unwrap(); - - a = (aL * w) + &(aR * winv); - b = (bL * winv) + &(bR * w); - - if a.len() != 1 { - G_proof = hadamard_fold(G_L, G_R, winv, w); - H_proof = hadamard_fold(H_L, H_R, w, winv); - } - } - - let res = OriginalStruct { - A: *A, - S: *S, - T1: *T1, - T2: *T2, - taux: *taux, - mu: *mu, - L: L.drain(..).map(|L| *L).collect(), - R: R.drain(..).map(|R| *R).collect(), - a: *a[0], - b: *b[0], - t: *t, - }; - debug_assert!(res.verify(rng, &commitments_points)); - res - } - - #[must_use] - fn verify_core( - &self, - rng: &mut R, - verifier: &mut BatchVerifier, - id: ID, - commitments: &[DalekPoint], - ) -> bool { - // Verify commitments are valid - if commitments.is_empty() || (commitments.len() > MAX_M) { - return false; - } - - // Verify L and R are properly sized - if self.L.len() != self.R.len() { - return false; - } - - let (logMN, M, MN) = MN(commitments.len()); - if self.L.len() != logMN { - return false; - } - - // Rebuild all challenges - let (mut cache, commitments) = hash_commitments(commitments.iter().copied()); - let y = hash_cache(&mut cache, &[self.A.compress().to_bytes(), self.S.compress().to_bytes()]); - - let z = hash_to_scalar(&y.to_bytes()); - cache = z; - - let x = hash_cache( - &mut cache, - &[z.to_bytes(), self.T1.compress().to_bytes(), self.T2.compress().to_bytes()], - ); - - let x_ip = hash_cache( - &mut cache, - &[x.to_bytes(), self.taux.to_bytes(), self.mu.to_bytes(), self.t.to_bytes()], - ); - - let mut w = Vec::with_capacity(logMN); - let mut winv = Vec::with_capacity(logMN); - for (L, R) in self.L.iter().zip(&self.R) { - w.push(hash_cache(&mut cache, &[L.compress().to_bytes(), R.compress().to_bytes()])); - winv.push(cache.invert().unwrap()); - } - - // Convert the proof from * INV_EIGHT to its actual form - let normalize = |point: &DalekPoint| EdwardsPoint(point.mul_by_cofactor()); - - let L = self.L.iter().map(normalize).collect::>(); - let R = self.R.iter().map(normalize).collect::>(); - let T1 = normalize(&self.T1); - let T2 = normalize(&self.T2); - let A = normalize(&self.A); - let S = normalize(&self.S); - - let commitments = commitments.iter().map(EdwardsPoint::mul_by_cofactor).collect::>(); - - // Verify it - let mut proof = Vec::with_capacity(4 + commitments.len()); - - let zpow = ScalarVector::powers(z, M + 3); - let ip1y = ScalarVector::powers(y, M * N).sum(); - let mut k = -(zpow[2] * ip1y); - for j in 1 ..= M { - k -= zpow[j + 2] * IP12(); - } - let y1 = Scalar(self.t) - ((z * ip1y) + k); - proof.push((-y1, H())); - - proof.push((-Scalar(self.taux), G)); - - for (j, commitment) in commitments.iter().enumerate() { - proof.push((zpow[j + 2], *commitment)); - } - - proof.push((x, T1)); - proof.push((x * x, T2)); - verifier.queue(&mut *rng, id, proof); - - proof = Vec::with_capacity(4 + (2 * (MN + logMN))); - let z3 = (Scalar(self.t) - (Scalar(self.a) * Scalar(self.b))) * x_ip; - proof.push((z3, H())); - proof.push((-Scalar(self.mu), G)); - - proof.push((Scalar::ONE, A)); - proof.push((x, S)); - - { - let ypow = ScalarVector::powers(y, MN); - let yinv = y.invert().unwrap(); - let yinvpow = ScalarVector::powers(yinv, MN); - - let w_cache = challenge_products(&w, &winv); - - let generators = GENERATORS(); - for i in 0 .. MN { - let g = (Scalar(self.a) * w_cache[i]) + z; - proof.push((-g, generators.G[i])); - - let mut h = Scalar(self.b) * yinvpow[i] * w_cache[(!i) & (MN - 1)]; - h -= ((zpow[(i / N) + 2] * TWO_N()[i % N]) + (z * ypow[i])) * yinvpow[i]; - proof.push((-h, generators.H[i])); - } - } - - for i in 0 .. logMN { - proof.push((w[i] * w[i], L[i])); - proof.push((winv[i] * winv[i], R[i])); - } - verifier.queue(rng, id, proof); - - true - } - - #[must_use] - pub(crate) fn verify( - &self, - rng: &mut R, - commitments: &[DalekPoint], - ) -> bool { - let mut verifier = BatchVerifier::new(1); - if self.verify_core(rng, &mut verifier, (), commitments) { - verifier.verify_vartime() - } else { - false - } - } - - #[must_use] - pub(crate) fn batch_verify( - &self, - rng: &mut R, - verifier: &mut BatchVerifier, - id: ID, - commitments: &[DalekPoint], - ) -> bool { - self.verify_core(rng, verifier, id, commitments) - } -} diff --git a/coins/monero/src/ringct/bulletproofs/plus/transcript.rs b/coins/monero/src/ringct/bulletproofs/plus/transcript.rs deleted file mode 100644 index 2108013b3..000000000 --- a/coins/monero/src/ringct/bulletproofs/plus/transcript.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std_shims::{sync::OnceLock, vec::Vec}; - -use dalek_ff_group::{Scalar, EdwardsPoint}; - -use monero_generators::{hash_to_point as raw_hash_to_point}; -use crate::{hash, hash_to_scalar as dalek_hash}; - -// Monero starts BP+ transcripts with the following constant. -static TRANSCRIPT_CELL: OnceLock<[u8; 32]> = OnceLock::new(); -pub(crate) fn TRANSCRIPT() -> [u8; 32] { - // Why this uses a hash_to_point is completely unknown. - *TRANSCRIPT_CELL - .get_or_init(|| raw_hash_to_point(hash(b"bulletproof_plus_transcript")).compress().to_bytes()) -} - -pub(crate) fn hash_to_scalar(data: &[u8]) -> Scalar { - Scalar(dalek_hash(data)) -} - -pub(crate) fn initial_transcript(commitments: core::slice::Iter<'_, EdwardsPoint>) -> Scalar { - let commitments_hash = - hash_to_scalar(&commitments.flat_map(|V| V.compress().to_bytes()).collect::>()); - hash_to_scalar(&[TRANSCRIPT().as_ref(), &commitments_hash.to_bytes()].concat()) -} diff --git a/coins/monero/src/ringct/hash_to_point.rs b/coins/monero/src/ringct/hash_to_point.rs deleted file mode 100644 index a36b6ee70..000000000 --- a/coins/monero/src/ringct/hash_to_point.rs +++ /dev/null @@ -1,8 +0,0 @@ -use curve25519_dalek::edwards::EdwardsPoint; - -pub use monero_generators::{hash_to_point as raw_hash_to_point}; - -/// Monero's hash to point function, as named `ge_fromfe_frombytes_vartime`. -pub fn hash_to_point(key: &EdwardsPoint) -> EdwardsPoint { - raw_hash_to_point(key.compress().to_bytes()) -} diff --git a/coins/monero/src/ringct/mod.rs b/coins/monero/src/ringct/mod.rs deleted file mode 100644 index bcd7f0c86..000000000 --- a/coins/monero/src/ringct/mod.rs +++ /dev/null @@ -1,400 +0,0 @@ -use core::ops::Deref; -use std_shims::{ - vec::Vec, - io::{self, Read, Write}, -}; - -use zeroize::{Zeroize, Zeroizing}; - -use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar, edwards::EdwardsPoint}; - -pub(crate) mod hash_to_point; -pub use hash_to_point::{raw_hash_to_point, hash_to_point}; - -/// MLSAG struct, along with verifying functionality. -pub mod mlsag; -/// CLSAG struct, along with signing and verifying functionality. -pub mod clsag; -/// BorromeanRange struct, along with verifying functionality. -pub mod borromean; -/// Bulletproofs(+) structs, along with proving and verifying functionality. -pub mod bulletproofs; - -use crate::{ - Protocol, - serialize::*, - ringct::{mlsag::Mlsag, clsag::Clsag, borromean::BorromeanRange, bulletproofs::Bulletproofs}, -}; - -/// Generate a key image for a given key. Defined as `x * hash_to_point(xG)`. -pub fn generate_key_image(secret: &Zeroizing) -> EdwardsPoint { - hash_to_point(&(ED25519_BASEPOINT_TABLE * secret.deref())) * secret.deref() -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub enum EncryptedAmount { - Original { mask: [u8; 32], amount: [u8; 32] }, - Compact { amount: [u8; 8] }, -} - -impl EncryptedAmount { - pub fn read(compact: bool, r: &mut R) -> io::Result { - Ok(if !compact { - EncryptedAmount::Original { mask: read_bytes(r)?, amount: read_bytes(r)? } - } else { - EncryptedAmount::Compact { amount: read_bytes(r)? } - }) - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - match self { - EncryptedAmount::Original { mask, amount } => { - w.write_all(mask)?; - w.write_all(amount) - } - EncryptedAmount::Compact { amount } => w.write_all(amount), - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -pub enum RctType { - /// No RCT proofs. - Null, - /// One MLSAG for multiple inputs and Borromean range proofs (RCTTypeFull). - MlsagAggregate, - // One MLSAG for each input and a Borromean range proof (RCTTypeSimple). - MlsagIndividual, - // One MLSAG for each input and a Bulletproof (RCTTypeBulletproof). - Bulletproofs, - /// One MLSAG for each input and a Bulletproof, yet starting to use EncryptedAmount::Compact - /// (RCTTypeBulletproof2). - BulletproofsCompactAmount, - /// One CLSAG for each input and a Bulletproof (RCTTypeCLSAG). - Clsag, - /// One CLSAG for each input and a Bulletproof+ (RCTTypeBulletproofPlus). - BulletproofsPlus, -} - -impl RctType { - pub fn to_byte(self) -> u8 { - match self { - RctType::Null => 0, - RctType::MlsagAggregate => 1, - RctType::MlsagIndividual => 2, - RctType::Bulletproofs => 3, - RctType::BulletproofsCompactAmount => 4, - RctType::Clsag => 5, - RctType::BulletproofsPlus => 6, - } - } - - pub fn from_byte(byte: u8) -> Option { - Some(match byte { - 0 => RctType::Null, - 1 => RctType::MlsagAggregate, - 2 => RctType::MlsagIndividual, - 3 => RctType::Bulletproofs, - 4 => RctType::BulletproofsCompactAmount, - 5 => RctType::Clsag, - 6 => RctType::BulletproofsPlus, - _ => None?, - }) - } - - pub fn compact_encrypted_amounts(&self) -> bool { - match self { - RctType::Null | - RctType::MlsagAggregate | - RctType::MlsagIndividual | - RctType::Bulletproofs => false, - RctType::BulletproofsCompactAmount | RctType::Clsag | RctType::BulletproofsPlus => true, - } - } -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct RctBase { - pub fee: u64, - pub pseudo_outs: Vec, - pub encrypted_amounts: Vec, - pub commitments: Vec, -} - -impl RctBase { - pub(crate) fn fee_weight(outputs: usize, fee: u64) -> usize { - // 1 byte for the RCT signature type - 1 + (outputs * (8 + 32)) + varint_len(fee) - } - - pub fn write(&self, w: &mut W, rct_type: RctType) -> io::Result<()> { - w.write_all(&[rct_type.to_byte()])?; - match rct_type { - RctType::Null => Ok(()), - _ => { - write_varint(&self.fee, w)?; - if rct_type == RctType::MlsagIndividual { - write_raw_vec(write_point, &self.pseudo_outs, w)?; - } - for encrypted_amount in &self.encrypted_amounts { - encrypted_amount.write(w)?; - } - write_raw_vec(write_point, &self.commitments, w) - } - } - } - - pub fn read(inputs: usize, outputs: usize, r: &mut R) -> io::Result<(RctBase, RctType)> { - let rct_type = - RctType::from_byte(read_byte(r)?).ok_or_else(|| io::Error::other("invalid RCT type"))?; - - match rct_type { - RctType::Null | RctType::MlsagAggregate | RctType::MlsagIndividual => {} - RctType::Bulletproofs | - RctType::BulletproofsCompactAmount | - RctType::Clsag | - RctType::BulletproofsPlus => { - if outputs == 0 { - // Because the Bulletproofs(+) layout must be canonical, there must be 1 Bulletproof if - // Bulletproofs are in use - // If there are Bulletproofs, there must be a matching amount of outputs, implicitly - // banning 0 outputs - // Since HF 12 (CLSAG being 13), a 2-output minimum has also been enforced - Err(io::Error::other("RCT with Bulletproofs(+) had 0 outputs"))?; - } - } - } - - Ok(( - if rct_type == RctType::Null { - RctBase { fee: 0, pseudo_outs: vec![], encrypted_amounts: vec![], commitments: vec![] } - } else { - RctBase { - fee: read_varint(r)?, - pseudo_outs: if rct_type == RctType::MlsagIndividual { - read_raw_vec(read_point, inputs, r)? - } else { - vec![] - }, - encrypted_amounts: (0 .. outputs) - .map(|_| EncryptedAmount::read(rct_type.compact_encrypted_amounts(), r)) - .collect::>()?, - commitments: read_raw_vec(read_point, outputs, r)?, - } - }, - rct_type, - )) - } -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub enum RctPrunable { - Null, - AggregateMlsagBorromean { - borromean: Vec, - mlsag: Mlsag, - }, - MlsagBorromean { - borromean: Vec, - mlsags: Vec, - }, - MlsagBulletproofs { - bulletproofs: Bulletproofs, - mlsags: Vec, - pseudo_outs: Vec, - }, - Clsag { - bulletproofs: Bulletproofs, - clsags: Vec, - pseudo_outs: Vec, - }, -} - -impl RctPrunable { - pub(crate) fn fee_weight(protocol: Protocol, inputs: usize, outputs: usize) -> usize { - // 1 byte for number of BPs (technically a VarInt, yet there's always just zero or one) - 1 + Bulletproofs::fee_weight(protocol.bp_plus(), outputs) + - (inputs * (Clsag::fee_weight(protocol.ring_len()) + 32)) - } - - pub fn write(&self, w: &mut W, rct_type: RctType) -> io::Result<()> { - match self { - RctPrunable::Null => Ok(()), - RctPrunable::AggregateMlsagBorromean { borromean, mlsag } => { - write_raw_vec(BorromeanRange::write, borromean, w)?; - mlsag.write(w) - } - RctPrunable::MlsagBorromean { borromean, mlsags } => { - write_raw_vec(BorromeanRange::write, borromean, w)?; - write_raw_vec(Mlsag::write, mlsags, w) - } - RctPrunable::MlsagBulletproofs { bulletproofs, mlsags, pseudo_outs } => { - if rct_type == RctType::Bulletproofs { - w.write_all(&1u32.to_le_bytes())?; - } else { - w.write_all(&[1])?; - } - bulletproofs.write(w)?; - - write_raw_vec(Mlsag::write, mlsags, w)?; - write_raw_vec(write_point, pseudo_outs, w) - } - RctPrunable::Clsag { bulletproofs, clsags, pseudo_outs } => { - w.write_all(&[1])?; - bulletproofs.write(w)?; - - write_raw_vec(Clsag::write, clsags, w)?; - write_raw_vec(write_point, pseudo_outs, w) - } - } - } - - pub fn serialize(&self, rct_type: RctType) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized, rct_type).unwrap(); - serialized - } - - pub fn read( - rct_type: RctType, - ring_length: usize, - inputs: usize, - outputs: usize, - r: &mut R, - ) -> io::Result { - // While we generally don't bother with misc consensus checks, this affects the safety of - // the below defined rct_type function - // The exact line preventing zero-input transactions is: - // https://github.com/monero-project/monero/blob/00fd416a99686f0956361d1cd0337fe56e58d4a7/ - // src/ringct/rctSigs.cpp#L609 - // And then for RctNull, that's only allowed for miner TXs which require one input of - // Input::Gen - if inputs == 0 { - Err(io::Error::other("transaction had no inputs"))?; - } - - Ok(match rct_type { - RctType::Null => RctPrunable::Null, - RctType::MlsagAggregate => RctPrunable::AggregateMlsagBorromean { - borromean: read_raw_vec(BorromeanRange::read, outputs, r)?, - mlsag: Mlsag::read(ring_length, inputs + 1, r)?, - }, - RctType::MlsagIndividual => RctPrunable::MlsagBorromean { - borromean: read_raw_vec(BorromeanRange::read, outputs, r)?, - mlsags: (0 .. inputs).map(|_| Mlsag::read(ring_length, 2, r)).collect::>()?, - }, - RctType::Bulletproofs | RctType::BulletproofsCompactAmount => { - RctPrunable::MlsagBulletproofs { - bulletproofs: { - if (if rct_type == RctType::Bulletproofs { - u64::from(read_u32(r)?) - } else { - read_varint(r)? - }) != 1 - { - Err(io::Error::other("n bulletproofs instead of one"))?; - } - Bulletproofs::read(r)? - }, - mlsags: (0 .. inputs) - .map(|_| Mlsag::read(ring_length, 2, r)) - .collect::>()?, - pseudo_outs: read_raw_vec(read_point, inputs, r)?, - } - } - RctType::Clsag | RctType::BulletproofsPlus => RctPrunable::Clsag { - bulletproofs: { - if read_varint::<_, u64>(r)? != 1 { - Err(io::Error::other("n bulletproofs instead of one"))?; - } - (if rct_type == RctType::Clsag { Bulletproofs::read } else { Bulletproofs::read_plus })( - r, - )? - }, - clsags: (0 .. inputs).map(|_| Clsag::read(ring_length, r)).collect::>()?, - pseudo_outs: read_raw_vec(read_point, inputs, r)?, - }, - }) - } - - pub(crate) fn signature_write(&self, w: &mut W) -> io::Result<()> { - match self { - RctPrunable::Null => panic!("Serializing RctPrunable::Null for a signature"), - RctPrunable::AggregateMlsagBorromean { borromean, .. } | - RctPrunable::MlsagBorromean { borromean, .. } => { - borromean.iter().try_for_each(|rs| rs.write(w)) - } - RctPrunable::MlsagBulletproofs { bulletproofs, .. } | - RctPrunable::Clsag { bulletproofs, .. } => bulletproofs.signature_write(w), - } - } -} - -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct RctSignatures { - pub base: RctBase, - pub prunable: RctPrunable, -} - -impl RctSignatures { - /// RctType for a given RctSignatures struct. - pub fn rct_type(&self) -> RctType { - match &self.prunable { - RctPrunable::Null => RctType::Null, - RctPrunable::AggregateMlsagBorromean { .. } => RctType::MlsagAggregate, - RctPrunable::MlsagBorromean { .. } => RctType::MlsagIndividual, - // RctBase ensures there's at least one output, making the following - // inferences guaranteed/expects impossible on any valid RctSignatures - RctPrunable::MlsagBulletproofs { .. } => { - if matches!( - self - .base - .encrypted_amounts - .first() - .expect("MLSAG with Bulletproofs didn't have any outputs"), - EncryptedAmount::Original { .. } - ) { - RctType::Bulletproofs - } else { - RctType::BulletproofsCompactAmount - } - } - RctPrunable::Clsag { bulletproofs, .. } => { - if matches!(bulletproofs, Bulletproofs::Original { .. }) { - RctType::Clsag - } else { - RctType::BulletproofsPlus - } - } - } - } - - pub(crate) fn fee_weight(protocol: Protocol, inputs: usize, outputs: usize, fee: u64) -> usize { - RctBase::fee_weight(outputs, fee) + RctPrunable::fee_weight(protocol, inputs, outputs) - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - let rct_type = self.rct_type(); - self.base.write(w, rct_type)?; - self.prunable.write(w, rct_type) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read( - ring_length: usize, - inputs: usize, - outputs: usize, - r: &mut R, - ) -> io::Result { - let base = RctBase::read(inputs, outputs, r)?; - Ok(RctSignatures { - base: base.0, - prunable: RctPrunable::read(base.1, ring_length, inputs, outputs, r)?, - }) - } -} diff --git a/coins/monero/src/serialize.rs b/coins/monero/src/serialize.rs deleted file mode 100644 index d2ae5980a..000000000 --- a/coins/monero/src/serialize.rs +++ /dev/null @@ -1,172 +0,0 @@ -use core::fmt::Debug; -use std_shims::{ - vec::Vec, - io::{self, Read, Write}, -}; - -use curve25519_dalek::{scalar::Scalar, edwards::EdwardsPoint}; - -use monero_generators::decompress_point; - -const VARINT_CONTINUATION_MASK: u8 = 0b1000_0000; - -mod sealed { - pub trait VarInt: TryInto + TryFrom + Copy { - const BITS: usize; - } - impl VarInt for u8 { - const BITS: usize = 8; - } - impl VarInt for u32 { - const BITS: usize = 32; - } - impl VarInt for u64 { - const BITS: usize = 64; - } - impl VarInt for usize { - const BITS: usize = core::mem::size_of::() * 8; - } -} - -// This will panic if the VarInt exceeds u64::MAX -pub(crate) fn varint_len(varint: U) -> usize { - let varint_u64: u64 = varint.try_into().map_err(|_| "varint exceeded u64").unwrap(); - ((usize::try_from(u64::BITS - varint_u64.leading_zeros()).unwrap().saturating_sub(1)) / 7) + 1 -} - -pub(crate) fn write_byte(byte: &u8, w: &mut W) -> io::Result<()> { - w.write_all(&[*byte]) -} - -// This will panic if the VarInt exceeds u64::MAX -pub(crate) fn write_varint(varint: &U, w: &mut W) -> io::Result<()> { - let mut varint: u64 = (*varint).try_into().map_err(|_| "varint exceeded u64").unwrap(); - while { - let mut b = u8::try_from(varint & u64::from(!VARINT_CONTINUATION_MASK)).unwrap(); - varint >>= 7; - if varint != 0 { - b |= VARINT_CONTINUATION_MASK; - } - write_byte(&b, w)?; - varint != 0 - } {} - Ok(()) -} - -pub(crate) fn write_scalar(scalar: &Scalar, w: &mut W) -> io::Result<()> { - w.write_all(&scalar.to_bytes()) -} - -pub(crate) fn write_point(point: &EdwardsPoint, w: &mut W) -> io::Result<()> { - w.write_all(&point.compress().to_bytes()) -} - -pub(crate) fn write_raw_vec io::Result<()>>( - f: F, - values: &[T], - w: &mut W, -) -> io::Result<()> { - for value in values { - f(value, w)?; - } - Ok(()) -} - -pub(crate) fn write_vec io::Result<()>>( - f: F, - values: &[T], - w: &mut W, -) -> io::Result<()> { - write_varint(&values.len(), w)?; - write_raw_vec(f, values, w) -} - -pub(crate) fn read_bytes(r: &mut R) -> io::Result<[u8; N]> { - let mut res = [0; N]; - r.read_exact(&mut res)?; - Ok(res) -} - -pub(crate) fn read_byte(r: &mut R) -> io::Result { - Ok(read_bytes::<_, 1>(r)?[0]) -} - -pub(crate) fn read_u16(r: &mut R) -> io::Result { - read_bytes(r).map(u16::from_le_bytes) -} - -pub(crate) fn read_u32(r: &mut R) -> io::Result { - read_bytes(r).map(u32::from_le_bytes) -} - -pub(crate) fn read_u64(r: &mut R) -> io::Result { - read_bytes(r).map(u64::from_le_bytes) -} - -pub(crate) fn read_varint(r: &mut R) -> io::Result { - let mut bits = 0; - let mut res = 0; - while { - let b = read_byte(r)?; - if (bits != 0) && (b == 0) { - Err(io::Error::other("non-canonical varint"))?; - } - if ((bits + 7) >= U::BITS) && (b >= (1 << (U::BITS - bits))) { - Err(io::Error::other("varint overflow"))?; - } - - res += u64::from(b & (!VARINT_CONTINUATION_MASK)) << bits; - bits += 7; - b & VARINT_CONTINUATION_MASK == VARINT_CONTINUATION_MASK - } {} - res.try_into().map_err(|_| io::Error::other("VarInt does not fit into integer type")) -} - -// All scalar fields supported by monero-serai are checked to be canonical for valid transactions -// While from_bytes_mod_order would be more flexible, it's not currently needed and would be -// inaccurate to include now. While casting a wide net may be preferable, it'd also be inaccurate -// for now. There's also further edge cases as noted by -// https://github.com/monero-project/monero/issues/8438, where some scalars had an archaic -// reduction applied -pub(crate) fn read_scalar(r: &mut R) -> io::Result { - Option::from(Scalar::from_canonical_bytes(read_bytes(r)?)) - .ok_or_else(|| io::Error::other("unreduced scalar")) -} - -pub(crate) fn read_point(r: &mut R) -> io::Result { - let bytes = read_bytes(r)?; - decompress_point(bytes).ok_or_else(|| io::Error::other("invalid point")) -} - -pub(crate) fn read_torsion_free_point(r: &mut R) -> io::Result { - read_point(r) - .ok() - .filter(EdwardsPoint::is_torsion_free) - .ok_or_else(|| io::Error::other("invalid point")) -} - -pub(crate) fn read_raw_vec io::Result>( - f: F, - len: usize, - r: &mut R, -) -> io::Result> { - let mut res = vec![]; - for _ in 0 .. len { - res.push(f(r)?); - } - Ok(res) -} - -pub(crate) fn read_array io::Result, const N: usize>( - f: F, - r: &mut R, -) -> io::Result<[T; N]> { - read_raw_vec(f, N, r).map(|vec| vec.try_into().unwrap()) -} - -pub(crate) fn read_vec io::Result>( - f: F, - r: &mut R, -) -> io::Result> { - read_raw_vec(f, read_varint(r)?, r) -} diff --git a/coins/monero/src/tests/bulletproofs/plus/aggregate_range_proof.rs b/coins/monero/src/tests/bulletproofs/plus/aggregate_range_proof.rs deleted file mode 100644 index 658da250e..000000000 --- a/coins/monero/src/tests/bulletproofs/plus/aggregate_range_proof.rs +++ /dev/null @@ -1,30 +0,0 @@ -use rand_core::{RngCore, OsRng}; - -use multiexp::BatchVerifier; -use group::ff::Field; -use dalek_ff_group::{Scalar, EdwardsPoint}; - -use crate::{ - Commitment, - ringct::bulletproofs::plus::aggregate_range_proof::{ - AggregateRangeStatement, AggregateRangeWitness, - }, -}; - -#[test] -fn test_aggregate_range_proof() { - let mut verifier = BatchVerifier::new(16); - for m in 1 ..= 16 { - let mut commitments = vec![]; - for _ in 0 .. m { - commitments.push(Commitment::new(*Scalar::random(&mut OsRng), OsRng.next_u64())); - } - let commitment_points = commitments.iter().map(|com| EdwardsPoint(com.calculate())).collect(); - let statement = AggregateRangeStatement::new(commitment_points).unwrap(); - let witness = AggregateRangeWitness::new(commitments).unwrap(); - - let proof = statement.clone().prove(&mut OsRng, &witness).unwrap(); - statement.verify(&mut OsRng, &mut verifier, (), proof); - } - assert!(verifier.verify_vartime()); -} diff --git a/coins/monero/src/tests/mod.rs b/coins/monero/src/tests/mod.rs deleted file mode 100644 index 33d56f22b..000000000 --- a/coins/monero/src/tests/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod unreduced_scalar; -mod clsag; -mod bulletproofs; -mod address; -mod seed; -mod extra; diff --git a/coins/monero/src/tests/seed.rs b/coins/monero/src/tests/seed.rs deleted file mode 100644 index 2c421abee..000000000 --- a/coins/monero/src/tests/seed.rs +++ /dev/null @@ -1,482 +0,0 @@ -use zeroize::Zeroizing; - -use rand_core::OsRng; - -use curve25519_dalek::scalar::Scalar; - -use crate::{ - hash, - wallet::seed::{ - Seed, SeedType, SeedError, - classic::{self, trim_by_lang}, - polyseed, - }, -}; - -#[test] -fn test_classic_seed() { - struct Vector { - language: classic::Language, - seed: String, - spend: String, - view: String, - } - - let vectors = [ - Vector { - language: classic::Language::Chinese, - seed: "摇 曲 艺 武 滴 然 效 似 赏 式 祥 歌 买 疑 小 碧 堆 博 键 房 鲜 悲 付 喷 武".into(), - spend: "a5e4fff1706ef9212993a69f246f5c95ad6d84371692d63e9bb0ea112a58340d".into(), - view: "1176c43ce541477ea2f3ef0b49b25112b084e26b8a843e1304ac4677b74cdf02".into(), - }, - Vector { - language: classic::Language::English, - seed: "washing thirsty occur lectures tuesday fainted toxic adapt \ - abnormal memoir nylon mostly building shrugged online ember northern \ - ruby woes dauntless boil family illness inroads northern" - .into(), - spend: "c0af65c0dd837e666b9d0dfed62745f4df35aed7ea619b2798a709f0fe545403".into(), - view: "513ba91c538a5a9069e0094de90e927c0cd147fa10428ce3ac1afd49f63e3b01".into(), - }, - Vector { - language: classic::Language::Dutch, - seed: "setwinst riphagen vimmetje extase blief tuitelig fuiven meifeest \ - ponywagen zesmaal ripdeal matverf codetaal leut ivoor rotten \ - wisgerhof winzucht typograaf atrium rein zilt traktaat verzaagd setwinst" - .into(), - spend: "e2d2873085c447c2bc7664222ac8f7d240df3aeac137f5ff2022eaa629e5b10a".into(), - view: "eac30b69477e3f68093d131c7fd961564458401b07f8c87ff8f6030c1a0c7301".into(), - }, - Vector { - language: classic::Language::French, - seed: "poids vaseux tarte bazar poivre effet entier nuance \ - sensuel ennui pacte osselet poudre battre alibi mouton \ - stade paquet pliage gibier type question position projet pliage" - .into(), - spend: "2dd39ff1a4628a94b5c2ec3e42fb3dfe15c2b2f010154dc3b3de6791e805b904".into(), - view: "6725b32230400a1032f31d622b44c3a227f88258939b14a7c72e00939e7bdf0e".into(), - }, - Vector { - language: classic::Language::Spanish, - seed: "minero ocupar mirar evadir octubre cal logro miope \ - opaco disco ancla litio clase cuello nasal clase \ - fiar avance deseo mente grumo negro cordón croqueta clase" - .into(), - spend: "ae2c9bebdddac067d73ec0180147fc92bdf9ac7337f1bcafbbe57dd13558eb02".into(), - view: "18deafb34d55b7a43cae2c1c1c206a3c80c12cc9d1f84640b484b95b7fec3e05".into(), - }, - Vector { - language: classic::Language::German, - seed: "Kaliber Gabelung Tapir Liveband Favorit Specht Enklave Nabel \ - Jupiter Foliant Chronik nisten löten Vase Aussage Rekord \ - Yeti Gesetz Eleganz Alraune Künstler Almweide Jahr Kastanie Almweide" - .into(), - spend: "79801b7a1b9796856e2397d862a113862e1fdc289a205e79d8d70995b276db06".into(), - view: "99f0ec556643bd9c038a4ed86edcb9c6c16032c4622ed2e000299d527a792701".into(), - }, - Vector { - language: classic::Language::Italian, - seed: "cavo pancetta auto fulmine alleanza filmato diavolo prato \ - forzare meritare litigare lezione segreto evasione votare buio \ - licenza cliente dorso natale crescere vento tutelare vetta evasione" - .into(), - spend: "5e7fd774eb00fa5877e2a8b4dc9c7ffe111008a3891220b56a6e49ac816d650a".into(), - view: "698a1dce6018aef5516e82ca0cb3e3ec7778d17dfb41a137567bfa2e55e63a03".into(), - }, - Vector { - language: classic::Language::Portuguese, - seed: "agito eventualidade onus itrio holograma sodomizar objetos dobro \ - iugoslavo bcrepuscular odalisca abjeto iuane darwinista eczema acetona \ - cibernetico hoquei gleba driver buffer azoto megera nogueira agito" - .into(), - spend: "13b3115f37e35c6aa1db97428b897e584698670c1b27854568d678e729200c0f".into(), - view: "ad1b4fd35270f5f36c4da7166672b347e75c3f4d41346ec2a06d1d0193632801".into(), - }, - Vector { - language: classic::Language::Japanese, - seed: "ぜんぶ どうぐ おたがい せんきょ おうじ そんちょう じゅしん いろえんぴつ \ - かほう つかれる えらぶ にちじょう くのう にちようび ぬまえび さんきゃく \ - おおや ちぬき うすめる いがく せつでん さうな すいえい せつだん おおや" - .into(), - spend: "c56e895cdb13007eda8399222974cdbab493640663804b93cbef3d8c3df80b0b".into(), - view: "6c3634a313ec2ee979d565c33888fd7c3502d696ce0134a8bc1a2698c7f2c508".into(), - }, - Vector { - language: classic::Language::Russian, - seed: "шатер икра нация ехать получать инерция доза реальный \ - рыжий таможня лопата душа веселый клетка атлас лекция \ - обгонять паек наивный лыжный дурак стать ежик задача паек" - .into(), - spend: "7cb5492df5eb2db4c84af20766391cd3e3662ab1a241c70fc881f3d02c381f05".into(), - view: "fcd53e41ec0df995ab43927f7c44bc3359c93523d5009fb3f5ba87431d545a03".into(), - }, - Vector { - language: classic::Language::Esperanto, - seed: "ukazo klini peco etikedo fabriko imitado onklino urino \ - pudro incidento kumuluso ikono smirgi hirundo uretro krii \ - sparkado super speciala pupo alpinisto cvana vokegi zombio fabriko" - .into(), - spend: "82ebf0336d3b152701964ed41df6b6e9a035e57fc98b84039ed0bd4611c58904".into(), - view: "cd4d120e1ea34360af528f6a3e6156063312d9cefc9aa6b5218d366c0ed6a201".into(), - }, - Vector { - language: classic::Language::Lojban, - seed: "jetnu vensa julne xrotu xamsi julne cutci dakli \ - mlatu xedja muvgau palpi xindo sfubu ciste cinri \ - blabi darno dembi janli blabi fenki bukpu burcu blabi" - .into(), - spend: "e4f8c6819ab6cf792cebb858caabac9307fd646901d72123e0367ebc0a79c200".into(), - view: "c806ce62bafaa7b2d597f1a1e2dbe4a2f96bfd804bf6f8420fc7f4a6bd700c00".into(), - }, - Vector { - language: classic::Language::EnglishOld, - seed: "glorious especially puff son moment add youth nowhere \ - throw glide grip wrong rhythm consume very swear \ - bitter heavy eventually begin reason flirt type unable" - .into(), - spend: "647f4765b66b636ff07170ab6280a9a6804dfbaf19db2ad37d23be024a18730b".into(), - view: "045da65316a906a8c30046053119c18020b07a7a3a6ef5c01ab2a8755416bd02".into(), - }, - // The following seeds require the language specification in order to calculate - // a single valid checksum - Vector { - language: classic::Language::Spanish, - seed: "pluma laico atraer pintor peor cerca balde buscar \ - lancha batir nulo reloj resto gemelo nevera poder columna gol \ - oveja latir amplio bolero feliz fuerza nevera" - .into(), - spend: "30303983fc8d215dd020cc6b8223793318d55c466a86e4390954f373fdc7200a".into(), - view: "97c649143f3c147ba59aa5506cc09c7992c5c219bb26964442142bf97980800e".into(), - }, - Vector { - language: classic::Language::Spanish, - seed: "pluma pluma pluma pluma pluma pluma pluma pluma \ - pluma pluma pluma pluma pluma pluma pluma pluma \ - pluma pluma pluma pluma pluma pluma pluma pluma pluma" - .into(), - spend: "b4050000b4050000b4050000b4050000b4050000b4050000b4050000b4050000".into(), - view: "d73534f7912b395eb70ef911791a2814eb6df7ce56528eaaa83ff2b72d9f5e0f".into(), - }, - Vector { - language: classic::Language::English, - seed: "plus plus plus plus plus plus plus plus \ - plus plus plus plus plus plus plus plus \ - plus plus plus plus plus plus plus plus plus" - .into(), - spend: "3b0400003b0400003b0400003b0400003b0400003b0400003b0400003b040000".into(), - view: "43a8a7715eed11eff145a2024ddcc39740255156da7bbd736ee66a0838053a02".into(), - }, - Vector { - language: classic::Language::Spanish, - seed: "audio audio audio audio audio audio audio audio \ - audio audio audio audio audio audio audio audio \ - audio audio audio audio audio audio audio audio audio" - .into(), - spend: "ba000000ba000000ba000000ba000000ba000000ba000000ba000000ba000000".into(), - view: "1437256da2c85d029b293d8c6b1d625d9374969301869b12f37186e3f906c708".into(), - }, - Vector { - language: classic::Language::English, - seed: "audio audio audio audio audio audio audio audio \ - audio audio audio audio audio audio audio audio \ - audio audio audio audio audio audio audio audio audio" - .into(), - spend: "7900000079000000790000007900000079000000790000007900000079000000".into(), - view: "20bec797ab96780ae6a045dd816676ca7ed1d7c6773f7022d03ad234b581d600".into(), - }, - ]; - - for vector in vectors { - let trim_seed = |seed: &str| { - seed - .split_whitespace() - .map(|word| trim_by_lang(word, vector.language)) - .collect::>() - .join(" ") - }; - - // Test against Monero - { - println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); - let seed = - Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(vector.seed.clone())) - .unwrap(); - let trim = trim_seed(&vector.seed); - assert_eq!( - seed, - Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(trim)).unwrap() - ); - - let spend: [u8; 32] = hex::decode(vector.spend).unwrap().try_into().unwrap(); - // For classical seeds, Monero directly uses the entropy as a spend key - assert_eq!( - Option::::from(Scalar::from_canonical_bytes(*seed.entropy())), - Option::::from(Scalar::from_canonical_bytes(spend)), - ); - - let view: [u8; 32] = hex::decode(vector.view).unwrap().try_into().unwrap(); - // Monero then derives the view key as H(spend) - assert_eq!( - Scalar::from_bytes_mod_order(hash(&spend)), - Scalar::from_canonical_bytes(view).unwrap() - ); - - assert_eq!( - Seed::from_entropy(SeedType::Classic(vector.language), Zeroizing::new(spend), None) - .unwrap(), - seed - ); - } - - // Test against ourselves - { - let seed = Seed::new(&mut OsRng, SeedType::Classic(vector.language)); - println!("{}. seed: {}", line!(), *seed.to_string()); - let trim = trim_seed(&seed.to_string()); - assert_eq!( - seed, - Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(trim)).unwrap() - ); - assert_eq!( - seed, - Seed::from_entropy(SeedType::Classic(vector.language), seed.entropy(), None).unwrap() - ); - assert_eq!( - seed, - Seed::from_string(SeedType::Classic(vector.language), seed.to_string()).unwrap() - ); - } - } -} - -#[test] -fn test_polyseed() { - struct Vector { - language: polyseed::Language, - seed: String, - entropy: String, - birthday: u64, - has_prefix: bool, - has_accent: bool, - } - - let vectors = [ - Vector { - language: polyseed::Language::English, - seed: "raven tail swear infant grief assist regular lamp \ - duck valid someone little harsh puppy airport language" - .into(), - entropy: "dd76e7359a0ded37cd0ff0f3c829a5ae01673300000000000000000000000000".into(), - birthday: 1638446400, - has_prefix: true, - has_accent: false, - }, - Vector { - language: polyseed::Language::Spanish, - seed: "eje fin parte célebre tabú pestaña lienzo puma \ - prisión hora regalo lengua existir lápiz lote sonoro" - .into(), - entropy: "5a2b02df7db21fcbe6ec6df137d54c7b20fd2b00000000000000000000000000".into(), - birthday: 3118651200, - has_prefix: true, - has_accent: true, - }, - Vector { - language: polyseed::Language::French, - seed: "valable arracher décaler jeudi amusant dresser mener épaissir risible \ - prouesse réserve ampleur ajuster muter caméra enchère" - .into(), - entropy: "11cfd870324b26657342c37360c424a14a050b00000000000000000000000000".into(), - birthday: 1679314966, - has_prefix: true, - has_accent: true, - }, - Vector { - language: polyseed::Language::Italian, - seed: "caduco midollo copione meninge isotopo illogico riflesso tartaruga fermento \ - olandese normale tristezza episodio voragine forbito achille" - .into(), - entropy: "7ecc57c9b4652d4e31428f62bec91cfd55500600000000000000000000000000".into(), - birthday: 1679316358, - has_prefix: true, - has_accent: false, - }, - Vector { - language: polyseed::Language::Portuguese, - seed: "caverna custear azedo adeus senador apertada sedoso omitir \ - sujeito aurora videira molho cartaz gesso dentista tapar" - .into(), - entropy: "45473063711376cae38f1b3eba18c874124e1d00000000000000000000000000".into(), - birthday: 1679316657, - has_prefix: true, - has_accent: false, - }, - Vector { - language: polyseed::Language::Czech, - seed: "usmrtit nora dotaz komunita zavalit funkce mzda sotva akce \ - vesta kabel herna stodola uvolnit ustrnout email" - .into(), - entropy: "7ac8a4efd62d9c3c4c02e350d32326df37821c00000000000000000000000000".into(), - birthday: 1679316898, - has_prefix: true, - has_accent: false, - }, - Vector { - language: polyseed::Language::Korean, - seed: "전망 선풍기 국제 무궁화 설사 기름 이론적 해안 절망 예선 \ - 지우개 보관 절망 말기 시각 귀신" - .into(), - entropy: "684663fda420298f42ed94b2c512ed38ddf12b00000000000000000000000000".into(), - birthday: 1679317073, - has_prefix: false, - has_accent: false, - }, - Vector { - language: polyseed::Language::Japanese, - seed: "うちあわせ ちつじょ つごう しはい けんこう とおる てみやげ はんとし たんとう \ - といれ おさない おさえる むかう ぬぐう なふだ せまる" - .into(), - entropy: "94e6665518a6286c6e3ba508a2279eb62b771f00000000000000000000000000".into(), - birthday: 1679318722, - has_prefix: false, - has_accent: false, - }, - Vector { - language: polyseed::Language::ChineseTraditional, - seed: "亂 挖 斤 柄 代 圈 枝 轄 魯 論 函 開 勘 番 榮 壁".into(), - entropy: "b1594f585987ab0fd5a31da1f0d377dae5283f00000000000000000000000000".into(), - birthday: 1679426433, - has_prefix: false, - has_accent: false, - }, - Vector { - language: polyseed::Language::ChineseSimplified, - seed: "啊 百 族 府 票 划 伪 仓 叶 虾 借 溜 晨 左 等 鬼".into(), - entropy: "21cdd366f337b89b8d1bc1df9fe73047c22b0300000000000000000000000000".into(), - birthday: 1679426817, - has_prefix: false, - has_accent: false, - }, - // The following seed requires the language specification in order to calculate - // a single valid checksum - Vector { - language: polyseed::Language::Spanish, - seed: "impo sort usua cabi venu nobl oliv clim \ - cont barr marc auto prod vaca torn fati" - .into(), - entropy: "dbfce25fe09b68a340e01c62417eeef43ad51800000000000000000000000000".into(), - birthday: 1701511650, - has_prefix: true, - has_accent: true, - }, - ]; - - for vector in vectors { - let add_whitespace = |mut seed: String| { - seed.push(' '); - seed - }; - - let seed_without_accents = |seed: &str| { - seed - .split_whitespace() - .map(|w| w.chars().filter(char::is_ascii).collect::()) - .collect::>() - .join(" ") - }; - - let trim_seed = |seed: &str| { - let seed_to_trim = - if vector.has_accent { seed_without_accents(seed) } else { seed.to_string() }; - seed_to_trim - .split_whitespace() - .map(|w| { - let mut ascii = 0; - let mut to_take = w.len(); - for (i, char) in w.chars().enumerate() { - if char.is_ascii() { - ascii += 1; - } - if ascii == polyseed::PREFIX_LEN { - // +1 to include this character, which put us at the prefix length - to_take = i + 1; - break; - } - } - w.chars().take(to_take).collect::() - }) - .collect::>() - .join(" ") - }; - - // String -> Seed - println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); - let seed = - Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(vector.seed.clone())) - .unwrap(); - let trim = trim_seed(&vector.seed); - let add_whitespace = add_whitespace(vector.seed.clone()); - let seed_without_accents = seed_without_accents(&vector.seed); - - // Make sure a version with added whitespace still works - let whitespaced_seed = - Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(add_whitespace)) - .unwrap(); - assert_eq!(seed, whitespaced_seed); - // Check trimmed versions works - if vector.has_prefix { - let trimmed_seed = - Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(trim)).unwrap(); - assert_eq!(seed, trimmed_seed); - } - // Check versions without accents work - if vector.has_accent { - let seed_without_accents = Seed::from_string( - SeedType::Polyseed(vector.language), - Zeroizing::new(seed_without_accents), - ) - .unwrap(); - assert_eq!(seed, seed_without_accents); - } - - let entropy = Zeroizing::new(hex::decode(vector.entropy).unwrap().try_into().unwrap()); - assert_eq!(seed.entropy(), entropy); - assert!(seed.birthday().abs_diff(vector.birthday) < polyseed::TIME_STEP); - - // Entropy -> Seed - let from_entropy = - Seed::from_entropy(SeedType::Polyseed(vector.language), entropy, Some(seed.birthday())) - .unwrap(); - assert_eq!(seed.to_string(), from_entropy.to_string()); - - // Check against ourselves - { - let seed = Seed::new(&mut OsRng, SeedType::Polyseed(vector.language)); - println!("{}. seed: {}", line!(), *seed.to_string()); - assert_eq!( - seed, - Seed::from_string(SeedType::Polyseed(vector.language), seed.to_string()).unwrap() - ); - assert_eq!( - seed, - Seed::from_entropy( - SeedType::Polyseed(vector.language), - seed.entropy(), - Some(seed.birthday()) - ) - .unwrap() - ); - } - } -} - -#[test] -fn test_invalid_polyseed() { - // This seed includes unsupported features bits and should error on decode - let seed = "include domain claim resemble urban hire lunch bird \ - crucial fire best wife ring warm ignore model" - .into(); - let res = - Seed::from_string(SeedType::Polyseed(polyseed::Language::English), Zeroizing::new(seed)); - assert_eq!(res, Err(SeedError::UnsupportedFeatures)); -} diff --git a/coins/monero/src/transaction.rs b/coins/monero/src/transaction.rs index 89d489fe2..6fe2a2516 100644 --- a/coins/monero/src/transaction.rs +++ b/coins/monero/src/transaction.rs @@ -1,5 +1,6 @@ use core::cmp::Ordering; use std_shims::{ + vec, vec::Vec, io::{self, Read, Write}, }; @@ -9,25 +10,30 @@ use zeroize::Zeroize; use curve25519_dalek::edwards::{EdwardsPoint, CompressedEdwardsY}; use crate::{ - Protocol, hash, - serialize::*, + io::*, + primitives::keccak256, ring_signatures::RingSignature, - ringct::{bulletproofs::Bulletproofs, RctType, RctBase, RctPrunable, RctSignatures}, + ringct::{bulletproofs::Bulletproof, RctProofs}, }; +/// An input in the Monero protocol. #[derive(Clone, PartialEq, Eq, Debug)] pub enum Input { - Gen(u64), - ToKey { amount: Option, key_offsets: Vec, key_image: EdwardsPoint }, + /// An input for a miner transaction, which is generating new coins. + Gen(usize), + /// An input spending an output on-chain. + ToKey { + /// The pool this input spends an output of. + amount: Option, + /// The decoys used by this input's ring, specified as their offset distance from each other. + key_offsets: Vec, + /// The key image (linking tag, nullifer) for the spent output. + key_image: EdwardsPoint, + }, } impl Input { - pub(crate) fn fee_weight(offsets_weight: usize) -> usize { - // Uses 1 byte for the input type - // Uses 1 byte for the VarInt amount due to amount being 0 - 1 + 1 + offsets_weight + 32 - } - + /// Write the Input. pub fn write(&self, w: &mut W) -> io::Result<()> { match self { Input::Gen(height) => { @@ -44,12 +50,14 @@ impl Input { } } + /// Serialize the Input to a `Vec`. pub fn serialize(&self) -> Vec { let mut res = vec![]; self.write(&mut res).unwrap(); res } + /// Read an Input. pub fn read(r: &mut R) -> io::Result { Ok(match read_byte(r)? { 255 => Input::Gen(read_varint(r)?), @@ -72,21 +80,19 @@ impl Input { } } -// Doesn't bother moving to an enum for the unused Script classes +/// An output in the Monero protocol. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Output { + /// The pool this output should be sorted into. pub amount: Option, + /// The key which can spend this output. pub key: CompressedEdwardsY, + /// The view tag for this output, as used to accelerate scanning. pub view_tag: Option, } impl Output { - pub(crate) fn fee_weight(view_tags: bool) -> usize { - // Uses 1 byte for the output type - // Uses 1 byte for the VarInt amount due to amount being 0 - 1 + 1 + 32 + if view_tags { 1 } else { 0 } - } - + /// Write the Output. pub fn write(&self, w: &mut W) -> io::Result<()> { write_varint(&self.amount.unwrap_or(0), w)?; w.write_all(&[2 + u8::from(self.view_tag.is_some())])?; @@ -97,12 +103,14 @@ impl Output { Ok(()) } + /// Write the Output to a `Vec`. pub fn serialize(&self) -> Vec { let mut res = Vec::with_capacity(8 + 1 + 32); self.write(&mut res).unwrap(); res } + /// Read an Output. pub fn read(rct: bool, r: &mut R) -> io::Result { let amount = read_varint(r)?; let amount = if rct { @@ -128,33 +136,54 @@ impl Output { } } +/// An additional timelock for a Monero transaction. +/// +/// Monero outputs are locked by a default timelock. If a timelock is explicitly specified, the +/// longer of the two will be the timelock used. #[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] pub enum Timelock { + /// No additional timelock. None, + /// Additionally locked until this block. Block(usize), + /// Additionally locked until this many seconds since the epoch. Time(u64), } impl Timelock { - fn from_raw(raw: u64) -> Timelock { - if raw == 0 { - Timelock::None - } else if raw < 500_000_000 { - Timelock::Block(usize::try_from(raw).unwrap()) - } else { - Timelock::Time(raw) + /// Write the Timelock. + pub fn write(&self, w: &mut W) -> io::Result<()> { + match self { + Timelock::None => write_varint(&0u8, w), + Timelock::Block(block) => write_varint(block, w), + Timelock::Time(time) => write_varint(time, w), } } - fn write(&self, w: &mut W) -> io::Result<()> { - write_varint( - &match self { - Timelock::None => 0, - Timelock::Block(block) => (*block).try_into().unwrap(), - Timelock::Time(time) => *time, - }, - w, - ) + /// Serialize the Timelock to a `Vec`. + pub fn serialize(&self) -> Vec { + let mut res = Vec::with_capacity(1); + self.write(&mut res).unwrap(); + res + } + + /// Read a Timelock. + pub fn read(r: &mut R) -> io::Result { + const TIMELOCK_BLOCK_THRESHOLD: usize = 500_000_000; + + let raw = read_varint::<_, u64>(r)?; + Ok(if raw == 0 { + Timelock::None + } else if raw < + u64::try_from(TIMELOCK_BLOCK_THRESHOLD) + .expect("TIMELOCK_BLOCK_THRESHOLD didn't fit in a u64") + { + Timelock::Block(usize::try_from(raw).expect( + "timelock overflowed usize despite being less than a const representable with a usize", + )) + } else { + Timelock::Time(raw) + }) } } @@ -171,56 +200,46 @@ impl PartialOrd for Timelock { } } +/// The transaction prefix. +/// +/// This is common to all transaction versions and contains most parts of the transaction needed to +/// handle it. It excludes any proofs. #[derive(Clone, PartialEq, Eq, Debug)] pub struct TransactionPrefix { - pub version: u64, - pub timelock: Timelock, + /// The timelock this transaction is additionally constrained by. + /// + /// All transactions on the blockchain are subject to a 10-block lock. This adds a further + /// constraint. + pub additional_timelock: Timelock, + /// The inputs for this transaction. pub inputs: Vec, + /// The outputs for this transaction. pub outputs: Vec, + /// The additional data included within the transaction. + /// + /// This is an arbitrary data field, yet is used by wallets for containing the data necessary to + /// scan the transaction. pub extra: Vec, } impl TransactionPrefix { - pub(crate) fn fee_weight( - decoy_weights: &[usize], - outputs: usize, - view_tags: bool, - extra: usize, - ) -> usize { - // Assumes Timelock::None since this library won't let you create a TX with a timelock - // 1 input for every decoy weight - 1 + 1 + - varint_len(decoy_weights.len()) + - decoy_weights.iter().map(|&offsets_weight| Input::fee_weight(offsets_weight)).sum::() + - varint_len(outputs) + - (outputs * Output::fee_weight(view_tags)) + - varint_len(extra) + - extra - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - write_varint(&self.version, w)?; - self.timelock.write(w)?; + /// Write a TransactionPrefix. + /// + /// This is distinct from Monero in that it won't write any version. + fn write(&self, w: &mut W) -> io::Result<()> { + self.additional_timelock.write(w)?; write_vec(Input::write, &self.inputs, w)?; write_vec(Output::write, &self.outputs, w)?; write_varint(&self.extra.len(), w)?; w.write_all(&self.extra) } - pub fn serialize(&self) -> Vec { - let mut res = vec![]; - self.write(&mut res).unwrap(); - res - } - - pub fn read(r: &mut R) -> io::Result { - let version = read_varint(r)?; - // TODO: Create an enum out of version - if (version == 0) || (version > 2) { - Err(io::Error::other("unrecognized transaction version"))?; - } - - let timelock = Timelock::from_raw(read_varint(r)?); + /// Read a TransactionPrefix. + /// + /// This is distinct from Monero in that it won't read the version. The version must be passed + /// in. + pub fn read(r: &mut R, version: u64) -> io::Result { + let additional_timelock = Timelock::read(r)?; let inputs = read_vec(|r| Input::read(r), r)?; if inputs.is_empty() { @@ -229,8 +248,7 @@ impl TransactionPrefix { let is_miner_tx = matches!(inputs[0], Input::Gen { .. }); let mut prefix = TransactionPrefix { - version, - timelock, + additional_timelock, inputs, outputs: read_vec(|r| Output::read((!is_miner_tx) && (version == 2), r), r)?, extra: vec![], @@ -239,100 +257,114 @@ impl TransactionPrefix { Ok(prefix) } - pub fn hash(&self) -> [u8; 32] { - hash(&self.serialize()) + fn hash(&self, version: u64) -> [u8; 32] { + let mut buf = vec![]; + write_varint(&version, &mut buf).unwrap(); + self.write(&mut buf).unwrap(); + keccak256(buf) } } -/// Monero transaction. For version 1, rct_signatures still contains an accurate fee value. +/// A Monero transaction. +#[allow(clippy::large_enum_variant)] #[derive(Clone, PartialEq, Eq, Debug)] -pub struct Transaction { - pub prefix: TransactionPrefix, - pub signatures: Vec, - pub rct_signatures: RctSignatures, +pub enum Transaction { + /// A version 1 transaction, used by the original Cryptonote codebase. + V1 { + /// The transaction's prefix. + prefix: TransactionPrefix, + /// The transaction's ring signatures. + signatures: Vec, + }, + /// A version 2 transaction, used by the RingCT protocol. + V2 { + /// The transaction's prefix. + prefix: TransactionPrefix, + /// The transaction's proofs. + proofs: Option, + }, } impl Transaction { - pub(crate) fn fee_weight( - protocol: Protocol, - decoy_weights: &[usize], - outputs: usize, - extra: usize, - fee: u64, - ) -> usize { - TransactionPrefix::fee_weight(decoy_weights, outputs, protocol.view_tags(), extra) + - RctSignatures::fee_weight(protocol, decoy_weights.len(), outputs, fee) + /// Get the version of this transaction. + pub fn version(&self) -> u8 { + match self { + Transaction::V1 { .. } => 1, + Transaction::V2 { .. } => 2, + } + } + + /// Get the TransactionPrefix of this transaction. + pub fn prefix(&self) -> &TransactionPrefix { + match self { + Transaction::V1 { prefix, .. } | Transaction::V2 { prefix, .. } => prefix, + } + } + + /// Get a mutable reference to the TransactionPrefix of this transaction. + pub fn prefix_mut(&mut self) -> &mut TransactionPrefix { + match self { + Transaction::V1 { prefix, .. } | Transaction::V2 { prefix, .. } => prefix, + } } + /// Write the Transaction. + /// + /// Some writable transactions may not be readable if they're malformed, per Monero's consensus + /// rules. pub fn write(&self, w: &mut W) -> io::Result<()> { - self.prefix.write(w)?; - if self.prefix.version == 1 { - for ring_sig in &self.signatures { - ring_sig.write(w)?; + write_varint(&self.version(), w)?; + match self { + Transaction::V1 { prefix, signatures } => { + prefix.write(w)?; + for ring_sig in signatures { + ring_sig.write(w)?; + } + } + Transaction::V2 { prefix, proofs } => { + prefix.write(w)?; + match proofs { + None => w.write_all(&[0])?, + Some(proofs) => proofs.write(w)?, + } } - Ok(()) - } else if self.prefix.version == 2 { - self.rct_signatures.write(w) - } else { - panic!("Serializing a transaction with an unknown version"); } + Ok(()) } + /// Write the Transaction to a `Vec`. pub fn serialize(&self) -> Vec { let mut res = Vec::with_capacity(2048); self.write(&mut res).unwrap(); res } + /// Read a Transaction. pub fn read(r: &mut R) -> io::Result { - let prefix = TransactionPrefix::read(r)?; - let mut signatures = vec![]; - let mut rct_signatures = RctSignatures { - base: RctBase { fee: 0, encrypted_amounts: vec![], pseudo_outs: vec![], commitments: vec![] }, - prunable: RctPrunable::Null, - }; - - if prefix.version == 1 { - signatures = prefix - .inputs - .iter() - .filter_map(|input| match input { - Input::ToKey { key_offsets, .. } => Some(RingSignature::read(key_offsets.len(), r)), - _ => None, - }) - .collect::>()?; - - if !matches!(prefix.inputs[0], Input::Gen(..)) { - let in_amount = prefix - .inputs - .iter() - .map(|input| match input { - Input::Gen(..) => Err(io::Error::other("Input::Gen present in non-coinbase v1 TX"))?, - // v1 TXs can burn v2 outputs - // dcff3fe4f914d6b6bd4a5b800cc4cca8f2fdd1bd73352f0700d463d36812f328 is one such TX - // It includes a pre-RCT signature for a RCT output, yet if you interpret the RCT - // output as being worth 0, it passes a sum check (guaranteed since no outputs are RCT) - Input::ToKey { amount, .. } => Ok(amount.unwrap_or(0)), - }) - .collect::>>()? - .into_iter() - .sum::(); - - let mut out = 0; - for output in &prefix.outputs { - if output.amount.is_none() { - Err(io::Error::other("v1 transaction had a 0-amount output"))?; + let version = read_varint(r)?; + let prefix = TransactionPrefix::read(r, version)?; + + if version == 1 { + let signatures = if (prefix.inputs.len() == 1) && matches!(prefix.inputs[0], Input::Gen(_)) { + vec![] + } else { + let mut signatures = Vec::with_capacity(prefix.inputs.len()); + for input in &prefix.inputs { + match input { + Input::ToKey { key_offsets, .. } => { + signatures.push(RingSignature::read(key_offsets.len(), r)?) + } + _ => { + Err(io::Error::other("reading signatures for a transaction with non-ToKey inputs"))? + } } - out += output.amount.unwrap(); } + signatures + }; - if in_amount < out { - Err(io::Error::other("transaction spent more than it had as inputs"))?; - } - rct_signatures.base.fee = in_amount - out; - } - } else if prefix.version == 2 { - rct_signatures = RctSignatures::read( + Ok(Transaction::V1 { prefix, signatures }) + } else if version == 2 { + let proofs = RctProofs::read( prefix.inputs.first().map_or(0, |input| match input { Input::Gen(_) => 0, Input::ToKey { key_offsets, .. } => key_offsets.len(), @@ -341,79 +373,87 @@ impl Transaction { prefix.outputs.len(), r, )?; + + Ok(Transaction::V2 { prefix, proofs }) } else { - Err(io::Error::other("Tried to deserialize unknown version"))?; + Err(io::Error::other("tried to deserialize unknown version")) } - - Ok(Transaction { prefix, signatures, rct_signatures }) } + /// The hash of the transaction. pub fn hash(&self) -> [u8; 32] { let mut buf = Vec::with_capacity(2048); - if self.prefix.version == 1 { - self.write(&mut buf).unwrap(); - hash(&buf) - } else { - let mut hashes = Vec::with_capacity(96); - - hashes.extend(self.prefix.hash()); - - self.rct_signatures.base.write(&mut buf, self.rct_signatures.rct_type()).unwrap(); - hashes.extend(hash(&buf)); - buf.clear(); - - hashes.extend(&match self.rct_signatures.prunable { - RctPrunable::Null => [0; 32], - _ => { - self.rct_signatures.prunable.write(&mut buf, self.rct_signatures.rct_type()).unwrap(); - hash(&buf) + match self { + Transaction::V1 { .. } => { + self.write(&mut buf).unwrap(); + keccak256(buf) + } + Transaction::V2 { prefix, proofs } => { + let mut hashes = Vec::with_capacity(96); + + hashes.extend(prefix.hash(2)); + + if let Some(proofs) = proofs { + let rct_type = proofs.rct_type(); + proofs.base.write(&mut buf, rct_type).unwrap(); + hashes.extend(keccak256(&buf)); + buf.clear(); + + proofs.prunable.write(&mut buf, rct_type).unwrap(); + hashes.extend(keccak256(buf)); + } else { + // Serialization of RctBase::Null + hashes.extend(keccak256([0])); + hashes.extend([0; 32]); } - }); - hash(&hashes) + keccak256(hashes) + } } } /// Calculate the hash of this transaction as needed for signing it. - pub fn signature_hash(&self) -> [u8; 32] { - if self.prefix.version == 1 { - return self.prefix.hash(); - } - - let mut buf = Vec::with_capacity(2048); - let mut sig_hash = Vec::with_capacity(96); + /// + /// This returns None if the transaction is without signatures. + pub fn signature_hash(&self) -> Option<[u8; 32]> { + match self { + Transaction::V1 { prefix, .. } => Some(prefix.hash(1)), + Transaction::V2 { prefix, proofs } => { + let mut buf = Vec::with_capacity(2048); + let mut sig_hash = Vec::with_capacity(96); - sig_hash.extend(self.prefix.hash()); + sig_hash.extend(prefix.hash(2)); - self.rct_signatures.base.write(&mut buf, self.rct_signatures.rct_type()).unwrap(); - sig_hash.extend(hash(&buf)); - buf.clear(); + let proofs = proofs.as_ref()?; + proofs.base.write(&mut buf, proofs.rct_type()).unwrap(); + sig_hash.extend(keccak256(&buf)); + buf.clear(); - self.rct_signatures.prunable.signature_write(&mut buf).unwrap(); - sig_hash.extend(hash(&buf)); + proofs.prunable.signature_write(&mut buf).unwrap(); + sig_hash.extend(keccak256(buf)); - hash(&sig_hash) + Some(keccak256(sig_hash)) + } + } } fn is_rct_bulletproof(&self) -> bool { - match &self.rct_signatures.rct_type() { - RctType::Bulletproofs | RctType::BulletproofsCompactAmount | RctType::Clsag => true, - RctType::Null | - RctType::MlsagAggregate | - RctType::MlsagIndividual | - RctType::BulletproofsPlus => false, + match self { + Transaction::V1 { .. } => false, + Transaction::V2 { proofs, .. } => { + let Some(proofs) = proofs else { return false }; + proofs.rct_type().bulletproof() + } } } fn is_rct_bulletproof_plus(&self) -> bool { - match &self.rct_signatures.rct_type() { - RctType::BulletproofsPlus => true, - RctType::Null | - RctType::MlsagAggregate | - RctType::MlsagIndividual | - RctType::Bulletproofs | - RctType::BulletproofsCompactAmount | - RctType::Clsag => false, + match self { + Transaction::V1 { .. } => false, + Transaction::V2 { proofs, .. } => { + let Some(proofs) = proofs else { return false }; + proofs.rct_type().bulletproof_plus() + } } } @@ -426,7 +466,15 @@ impl Transaction { if !(bp || bp_plus) { blob_size } else { - blob_size + Bulletproofs::calculate_bp_clawback(bp_plus, self.prefix.outputs.len()).0 + blob_size + + Bulletproof::calculate_bp_clawback( + bp_plus, + match self { + Transaction::V1 { .. } => panic!("v1 transaction was BP(+)"), + Transaction::V2 { prefix, .. } => prefix.outputs.len(), + }, + ) + .0 } } } diff --git a/coins/monero/src/wallet/address.rs b/coins/monero/src/wallet/address.rs deleted file mode 100644 index d080488da..000000000 --- a/coins/monero/src/wallet/address.rs +++ /dev/null @@ -1,325 +0,0 @@ -use core::{marker::PhantomData, fmt}; -use std_shims::string::ToString; - -use zeroize::Zeroize; - -use curve25519_dalek::edwards::EdwardsPoint; - -use monero_generators::decompress_point; - -use base58_monero::base58::{encode_check, decode_check}; - -/// The network this address is for. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -pub enum Network { - Mainnet, - Testnet, - Stagenet, -} - -/// The address type, supporting the officially documented addresses, along with -/// [Featured Addresses](https://gist.github.com/kayabaNerve/01c50bbc35441e0bbdcee63a9d823789). -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -pub enum AddressType { - Standard, - Integrated([u8; 8]), - Subaddress, - Featured { subaddress: bool, payment_id: Option<[u8; 8]>, guaranteed: bool }, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -pub struct SubaddressIndex { - pub(crate) account: u32, - pub(crate) address: u32, -} - -impl SubaddressIndex { - pub const fn new(account: u32, address: u32) -> Option { - if (account == 0) && (address == 0) { - return None; - } - Some(SubaddressIndex { account, address }) - } - - pub fn account(&self) -> u32 { - self.account - } - - pub fn address(&self) -> u32 { - self.address - } -} - -/// Address specification. Used internally to create addresses. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -pub enum AddressSpec { - Standard, - Integrated([u8; 8]), - Subaddress(SubaddressIndex), - Featured { subaddress: Option, payment_id: Option<[u8; 8]>, guaranteed: bool }, -} - -impl AddressType { - pub fn is_subaddress(&self) -> bool { - matches!(self, AddressType::Subaddress) || - matches!(self, AddressType::Featured { subaddress: true, .. }) - } - - pub fn payment_id(&self) -> Option<[u8; 8]> { - if let AddressType::Integrated(id) = self { - Some(*id) - } else if let AddressType::Featured { payment_id, .. } = self { - *payment_id - } else { - None - } - } - - pub fn is_guaranteed(&self) -> bool { - matches!(self, AddressType::Featured { guaranteed: true, .. }) - } -} - -/// A type which returns the byte for a given address. -pub trait AddressBytes: Clone + Copy + PartialEq + Eq + fmt::Debug { - fn network_bytes(network: Network) -> (u8, u8, u8, u8); -} - -/// Address bytes for Monero. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct MoneroAddressBytes; -impl AddressBytes for MoneroAddressBytes { - fn network_bytes(network: Network) -> (u8, u8, u8, u8) { - match network { - Network::Mainnet => (18, 19, 42, 70), - Network::Testnet => (53, 54, 63, 111), - Network::Stagenet => (24, 25, 36, 86), - } - } -} - -/// Address metadata. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct AddressMeta { - _bytes: PhantomData, - pub network: Network, - pub kind: AddressType, -} - -impl Zeroize for AddressMeta { - fn zeroize(&mut self) { - self.network.zeroize(); - self.kind.zeroize(); - } -} - -/// Error when decoding an address. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "std", derive(thiserror::Error))] -pub enum AddressError { - #[cfg_attr(feature = "std", error("invalid address byte"))] - InvalidByte, - #[cfg_attr(feature = "std", error("invalid address encoding"))] - InvalidEncoding, - #[cfg_attr(feature = "std", error("invalid length"))] - InvalidLength, - #[cfg_attr(feature = "std", error("invalid key"))] - InvalidKey, - #[cfg_attr(feature = "std", error("unknown features"))] - UnknownFeatures, - #[cfg_attr(feature = "std", error("different network than expected"))] - DifferentNetwork, -} - -impl AddressMeta { - #[allow(clippy::wrong_self_convention)] - fn to_byte(&self) -> u8 { - let bytes = B::network_bytes(self.network); - match self.kind { - AddressType::Standard => bytes.0, - AddressType::Integrated(_) => bytes.1, - AddressType::Subaddress => bytes.2, - AddressType::Featured { .. } => bytes.3, - } - } - - /// Create an address's metadata. - pub fn new(network: Network, kind: AddressType) -> Self { - AddressMeta { _bytes: PhantomData, network, kind } - } - - // Returns an incomplete instantiation in the case of Integrated/Featured addresses - fn from_byte(byte: u8) -> Result { - let mut meta = None; - for network in [Network::Mainnet, Network::Testnet, Network::Stagenet] { - let (standard, integrated, subaddress, featured) = B::network_bytes(network); - if let Some(kind) = match byte { - _ if byte == standard => Some(AddressType::Standard), - _ if byte == integrated => Some(AddressType::Integrated([0; 8])), - _ if byte == subaddress => Some(AddressType::Subaddress), - _ if byte == featured => { - Some(AddressType::Featured { subaddress: false, payment_id: None, guaranteed: false }) - } - _ => None, - } { - meta = Some(AddressMeta::new(network, kind)); - break; - } - } - - meta.ok_or(AddressError::InvalidByte) - } - - pub fn is_subaddress(&self) -> bool { - self.kind.is_subaddress() - } - - pub fn payment_id(&self) -> Option<[u8; 8]> { - self.kind.payment_id() - } - - pub fn is_guaranteed(&self) -> bool { - self.kind.is_guaranteed() - } -} - -/// A Monero address, composed of metadata and a spend/view key. -#[derive(Clone, Copy, PartialEq, Eq)] -pub struct Address { - pub meta: AddressMeta, - pub spend: EdwardsPoint, - pub view: EdwardsPoint, -} - -impl fmt::Debug for Address { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { - fmt - .debug_struct("Address") - .field("meta", &self.meta) - .field("spend", &hex::encode(self.spend.compress().0)) - .field("view", &hex::encode(self.view.compress().0)) - // This is not a real field yet is the most valuable thing to know when debugging - .field("(address)", &self.to_string()) - .finish() - } -} - -impl Zeroize for Address { - fn zeroize(&mut self) { - self.meta.zeroize(); - self.spend.zeroize(); - self.view.zeroize(); - } -} - -impl fmt::Display for Address { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut data = vec![self.meta.to_byte()]; - data.extend(self.spend.compress().to_bytes()); - data.extend(self.view.compress().to_bytes()); - if let AddressType::Featured { subaddress, payment_id, guaranteed } = self.meta.kind { - // Technically should be a VarInt, yet we don't have enough features it's needed - data.push( - u8::from(subaddress) + (u8::from(payment_id.is_some()) << 1) + (u8::from(guaranteed) << 2), - ); - } - if let Some(id) = self.meta.kind.payment_id() { - data.extend(id); - } - write!(f, "{}", encode_check(&data).unwrap()) - } -} - -impl Address { - pub fn new(meta: AddressMeta, spend: EdwardsPoint, view: EdwardsPoint) -> Self { - Address { meta, spend, view } - } - - pub fn from_str_raw(s: &str) -> Result { - let raw = decode_check(s).map_err(|_| AddressError::InvalidEncoding)?; - if raw.len() < (1 + 32 + 32) { - Err(AddressError::InvalidLength)?; - } - - let mut meta = AddressMeta::from_byte(raw[0])?; - let spend = - decompress_point(raw[1 .. 33].try_into().unwrap()).ok_or(AddressError::InvalidKey)?; - let view = - decompress_point(raw[33 .. 65].try_into().unwrap()).ok_or(AddressError::InvalidKey)?; - let mut read = 65; - - if matches!(meta.kind, AddressType::Featured { .. }) { - if raw[read] >= (2 << 3) { - Err(AddressError::UnknownFeatures)?; - } - - let subaddress = (raw[read] & 1) == 1; - let integrated = ((raw[read] >> 1) & 1) == 1; - let guaranteed = ((raw[read] >> 2) & 1) == 1; - - meta.kind = AddressType::Featured { - subaddress, - payment_id: Some([0; 8]).filter(|_| integrated), - guaranteed, - }; - read += 1; - } - - // Update read early so we can verify the length - if meta.kind.payment_id().is_some() { - read += 8; - } - if raw.len() != read { - Err(AddressError::InvalidLength)?; - } - - if let AddressType::Integrated(ref mut id) = meta.kind { - id.copy_from_slice(&raw[(read - 8) .. read]); - } - if let AddressType::Featured { payment_id: Some(ref mut id), .. } = meta.kind { - id.copy_from_slice(&raw[(read - 8) .. read]); - } - - Ok(Address { meta, spend, view }) - } - - pub fn from_str(network: Network, s: &str) -> Result { - Self::from_str_raw(s).and_then(|addr| { - if addr.meta.network == network { - Ok(addr) - } else { - Err(AddressError::DifferentNetwork)? - } - }) - } - - pub fn network(&self) -> Network { - self.meta.network - } - - pub fn is_subaddress(&self) -> bool { - self.meta.is_subaddress() - } - - pub fn payment_id(&self) -> Option<[u8; 8]> { - self.meta.payment_id() - } - - pub fn is_guaranteed(&self) -> bool { - self.meta.is_guaranteed() - } -} - -/// Instantiation of the Address type with Monero's network bytes. -pub type MoneroAddress = Address; -// Allow re-interpreting of an arbitrary address as a monero address so it can be used with the -// rest of this library. Doesn't use From as it was conflicting with From for T. -impl MoneroAddress { - pub fn from(address: Address) -> MoneroAddress { - MoneroAddress::new( - AddressMeta::new(address.meta.network, address.meta.kind), - address.spend, - address.view, - ) - } -} diff --git a/coins/monero/src/wallet/mod.rs b/coins/monero/src/wallet/mod.rs deleted file mode 100644 index 3b08fd975..000000000 --- a/coins/monero/src/wallet/mod.rs +++ /dev/null @@ -1,268 +0,0 @@ -use core::ops::Deref; -use std_shims::collections::{HashSet, HashMap}; - -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; - -use curve25519_dalek::{ - constants::ED25519_BASEPOINT_TABLE, - scalar::Scalar, - edwards::{EdwardsPoint, CompressedEdwardsY}, -}; - -use crate::{ - hash, hash_to_scalar, serialize::write_varint, ringct::EncryptedAmount, transaction::Input, -}; - -pub mod extra; -pub(crate) use extra::{PaymentId, ExtraField, Extra}; - -/// Seed creation and parsing functionality. -pub mod seed; - -/// Address encoding and decoding functionality. -pub mod address; -use address::{Network, AddressType, SubaddressIndex, AddressSpec, AddressMeta, MoneroAddress}; - -mod scan; -pub use scan::{ReceivedOutput, SpendableOutput, Timelocked}; - -pub mod decoys; -pub use decoys::Decoys; - -mod send; -pub use send::{FeePriority, Fee, TransactionError, Change, SignableTransaction, Eventuality}; -#[cfg(feature = "std")] -pub use send::SignableTransactionBuilder; -#[cfg(feature = "multisig")] -pub(crate) use send::InternalPayment; -#[cfg(feature = "multisig")] -pub use send::TransactionMachine; - -fn key_image_sort(x: &EdwardsPoint, y: &EdwardsPoint) -> core::cmp::Ordering { - x.compress().to_bytes().cmp(&y.compress().to_bytes()).reverse() -} - -// https://gist.github.com/kayabaNerve/8066c13f1fe1573286ba7a2fd79f6100 -pub(crate) fn uniqueness(inputs: &[Input]) -> [u8; 32] { - let mut u = b"uniqueness".to_vec(); - for input in inputs { - match input { - // If Gen, this should be the only input, making this loop somewhat pointless - // This works and even if there were somehow multiple inputs, it'd be a false negative - Input::Gen(height) => { - write_varint(height, &mut u).unwrap(); - } - Input::ToKey { key_image, .. } => u.extend(key_image.compress().to_bytes()), - } - } - hash(&u) -} - -// Hs("view_tag" || 8Ra || o), Hs(8Ra || o), and H(8Ra || 0x8d) with uniqueness inclusion in the -// Scalar as an option -#[allow(non_snake_case)] -pub(crate) fn shared_key( - uniqueness: Option<[u8; 32]>, - ecdh: EdwardsPoint, - o: usize, -) -> (u8, Scalar, [u8; 8]) { - // 8Ra - let mut output_derivation = ecdh.mul_by_cofactor().compress().to_bytes().to_vec(); - - let mut payment_id_xor = [0; 8]; - payment_id_xor - .copy_from_slice(&hash(&[output_derivation.as_ref(), [0x8d].as_ref()].concat())[.. 8]); - - // || o - write_varint(&o, &mut output_derivation).unwrap(); - - let view_tag = hash(&[b"view_tag".as_ref(), &output_derivation].concat())[0]; - - // uniqueness || - let shared_key = if let Some(uniqueness) = uniqueness { - [uniqueness.as_ref(), &output_derivation].concat() - } else { - output_derivation - }; - - (view_tag, hash_to_scalar(&shared_key), payment_id_xor) -} - -pub(crate) fn commitment_mask(shared_key: Scalar) -> Scalar { - let mut mask = b"commitment_mask".to_vec(); - mask.extend(shared_key.to_bytes()); - hash_to_scalar(&mask) -} - -pub(crate) fn amount_encryption(amount: u64, key: Scalar) -> [u8; 8] { - let mut amount_mask = b"amount".to_vec(); - amount_mask.extend(key.to_bytes()); - (amount ^ u64::from_le_bytes(hash(&amount_mask)[.. 8].try_into().unwrap())).to_le_bytes() -} - -// TODO: Move this under EncryptedAmount? -fn amount_decryption(amount: &EncryptedAmount, key: Scalar) -> (Scalar, u64) { - match amount { - EncryptedAmount::Original { mask, amount } => { - #[cfg(feature = "experimental")] - { - let mask_shared_sec = hash(key.as_bytes()); - let mask = - Scalar::from_bytes_mod_order(*mask) - Scalar::from_bytes_mod_order(mask_shared_sec); - - let amount_shared_sec = hash(&mask_shared_sec); - let amount_scalar = - Scalar::from_bytes_mod_order(*amount) - Scalar::from_bytes_mod_order(amount_shared_sec); - // d2b from rctTypes.cpp - let amount = u64::from_le_bytes(amount_scalar.to_bytes()[0 .. 8].try_into().unwrap()); - - (mask, amount) - } - - #[cfg(not(feature = "experimental"))] - { - let _ = mask; - let _ = amount; - todo!("decrypting a legacy monero transaction's amount") - } - } - EncryptedAmount::Compact { amount } => ( - commitment_mask(key), - u64::from_le_bytes(amount_encryption(u64::from_le_bytes(*amount), key)), - ), - } -} - -/// The private view key and public spend key, enabling scanning transactions. -#[derive(Clone, Zeroize, ZeroizeOnDrop)] -pub struct ViewPair { - spend: EdwardsPoint, - view: Zeroizing, -} - -impl ViewPair { - pub fn new(spend: EdwardsPoint, view: Zeroizing) -> ViewPair { - ViewPair { spend, view } - } - - pub fn spend(&self) -> EdwardsPoint { - self.spend - } - - pub fn view(&self) -> EdwardsPoint { - self.view.deref() * ED25519_BASEPOINT_TABLE - } - - fn subaddress_derivation(&self, index: SubaddressIndex) -> Scalar { - hash_to_scalar(&Zeroizing::new( - [ - b"SubAddr\0".as_ref(), - Zeroizing::new(self.view.to_bytes()).as_ref(), - &index.account().to_le_bytes(), - &index.address().to_le_bytes(), - ] - .concat(), - )) - } - - fn subaddress_keys(&self, index: SubaddressIndex) -> (EdwardsPoint, EdwardsPoint) { - let scalar = self.subaddress_derivation(index); - let spend = self.spend + (&scalar * ED25519_BASEPOINT_TABLE); - let view = self.view.deref() * spend; - (spend, view) - } - - /// Returns an address with the provided specification. - pub fn address(&self, network: Network, spec: AddressSpec) -> MoneroAddress { - let mut spend = self.spend; - let mut view: EdwardsPoint = self.view.deref() * ED25519_BASEPOINT_TABLE; - - // construct the address meta - let meta = match spec { - AddressSpec::Standard => AddressMeta::new(network, AddressType::Standard), - AddressSpec::Integrated(payment_id) => { - AddressMeta::new(network, AddressType::Integrated(payment_id)) - } - AddressSpec::Subaddress(index) => { - (spend, view) = self.subaddress_keys(index); - AddressMeta::new(network, AddressType::Subaddress) - } - AddressSpec::Featured { subaddress, payment_id, guaranteed } => { - if let Some(index) = subaddress { - (spend, view) = self.subaddress_keys(index); - } - AddressMeta::new( - network, - AddressType::Featured { subaddress: subaddress.is_some(), payment_id, guaranteed }, - ) - } - }; - - MoneroAddress::new(meta, spend, view) - } -} - -/// Transaction scanner. -/// This scanner is capable of generating subaddresses, additionally scanning for them once they've -/// been explicitly generated. If the burning bug is attempted, any secondary outputs will be -/// ignored. -#[derive(Clone)] -pub struct Scanner { - pair: ViewPair, - // Also contains the spend key as None - pub(crate) subaddresses: HashMap>, - pub(crate) burning_bug: Option>, -} - -impl Zeroize for Scanner { - fn zeroize(&mut self) { - self.pair.zeroize(); - - // These may not be effective, unfortunately - for (mut key, mut value) in self.subaddresses.drain() { - key.zeroize(); - value.zeroize(); - } - if let Some(ref mut burning_bug) = self.burning_bug.take() { - for mut output in burning_bug.drain() { - output.zeroize(); - } - } - } -} - -impl Drop for Scanner { - fn drop(&mut self) { - self.zeroize(); - } -} - -impl ZeroizeOnDrop for Scanner {} - -impl Scanner { - /// Create a Scanner from a ViewPair. - /// - /// burning_bug is a HashSet of used keys, intended to prevent key reuse which would burn funds. - /// - /// When an output is successfully scanned, the output key MUST be saved to disk. - /// - /// When a new scanner is created, ALL saved output keys must be passed in to be secure. - /// - /// If None is passed, a modified shared key derivation is used which is immune to the burning - /// bug (specifically the Guaranteed feature from Featured Addresses). - pub fn from_view(pair: ViewPair, burning_bug: Option>) -> Scanner { - let mut subaddresses = HashMap::new(); - subaddresses.insert(pair.spend.compress(), None); - Scanner { pair, subaddresses, burning_bug } - } - - /// Register a subaddress. - // There used to be an address function here, yet it wasn't safe. It could generate addresses - // incompatible with the Scanner. While we could return None for that, then we have the issue - // of runtime failures to generate an address. - // Removing that API was the simplest option. - pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) { - let (spend, _) = self.pair.subaddress_keys(subaddress); - self.subaddresses.insert(spend.compress(), Some(subaddress)); - } -} diff --git a/coins/monero/src/wallet/scan.rs b/coins/monero/src/wallet/scan.rs deleted file mode 100644 index 45bae04df..000000000 --- a/coins/monero/src/wallet/scan.rs +++ /dev/null @@ -1,521 +0,0 @@ -use core::ops::Deref; -use std_shims::{ - vec::Vec, - string::ToString, - io::{self, Read, Write}, -}; - -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar, edwards::EdwardsPoint}; - -use monero_generators::decompress_point; - -use crate::{ - Commitment, - serialize::{read_byte, read_u32, read_u64, read_bytes, read_scalar, read_point, read_raw_vec}, - transaction::{Input, Timelock, Transaction}, - block::Block, - rpc::{RpcError, RpcConnection, Rpc}, - wallet::{ - PaymentId, Extra, address::SubaddressIndex, Scanner, uniqueness, shared_key, amount_decryption, - }, -}; - -/// An absolute output ID, defined as its transaction hash and output index. -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub struct AbsoluteId { - pub tx: [u8; 32], - pub o: u8, -} - -impl core::fmt::Debug for AbsoluteId { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { - fmt.debug_struct("AbsoluteId").field("tx", &hex::encode(self.tx)).field("o", &self.o).finish() - } -} - -impl AbsoluteId { - pub fn write(&self, w: &mut W) -> io::Result<()> { - w.write_all(&self.tx)?; - w.write_all(&[self.o]) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = Vec::with_capacity(32 + 1); - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(AbsoluteId { tx: read_bytes(r)?, o: read_byte(r)? }) - } -} - -/// The data contained with an output. -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub struct OutputData { - pub key: EdwardsPoint, - /// Absolute difference between the spend key and the key in this output - pub key_offset: Scalar, - pub commitment: Commitment, -} - -impl core::fmt::Debug for OutputData { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { - fmt - .debug_struct("OutputData") - .field("key", &hex::encode(self.key.compress().0)) - .field("key_offset", &hex::encode(self.key_offset.to_bytes())) - .field("commitment", &self.commitment) - .finish() - } -} - -impl OutputData { - pub fn write(&self, w: &mut W) -> io::Result<()> { - w.write_all(&self.key.compress().to_bytes())?; - w.write_all(&self.key_offset.to_bytes())?; - w.write_all(&self.commitment.mask.to_bytes())?; - w.write_all(&self.commitment.amount.to_le_bytes()) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = Vec::with_capacity(32 + 32 + 32 + 8); - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(OutputData { - key: read_point(r)?, - key_offset: read_scalar(r)?, - commitment: Commitment::new(read_scalar(r)?, read_u64(r)?), - }) - } -} - -/// The metadata for an output. -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub struct Metadata { - /// The subaddress this output was sent to. - pub subaddress: Option, - /// The payment ID included with this output. - /// There are 2 circumstances in which the reference wallet2 ignores the payment ID - /// but the payment ID will be returned here anyway: - /// - /// 1) If the payment ID is tied to an output received by a subaddress account - /// that spent Monero in the transaction (the received output is considered - /// "change" and is not considered a "payment" in this case). If there are multiple - /// spending subaddress accounts in a transaction, the highest index spent key image - /// is used to determine the spending subaddress account. - /// - /// 2) If the payment ID is the unencrypted variant and the block's hf version is - /// v12 or higher (https://github.com/serai-dex/serai/issues/512) - pub payment_id: Option, - /// Arbitrary data encoded in TX extra. - pub arbitrary_data: Vec>, -} - -impl core::fmt::Debug for Metadata { - fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { - fmt - .debug_struct("Metadata") - .field("subaddress", &self.subaddress) - .field("payment_id", &self.payment_id) - .field("arbitrary_data", &self.arbitrary_data.iter().map(hex::encode).collect::>()) - .finish() - } -} - -impl Metadata { - pub fn write(&self, w: &mut W) -> io::Result<()> { - if let Some(subaddress) = self.subaddress { - w.write_all(&[1])?; - w.write_all(&subaddress.account().to_le_bytes())?; - w.write_all(&subaddress.address().to_le_bytes())?; - } else { - w.write_all(&[0])?; - } - - if let Some(payment_id) = self.payment_id { - w.write_all(&[1])?; - payment_id.write(w)?; - } else { - w.write_all(&[0])?; - } - - w.write_all(&u32::try_from(self.arbitrary_data.len()).unwrap().to_le_bytes())?; - for part in &self.arbitrary_data { - w.write_all(&[u8::try_from(part.len()).unwrap()])?; - w.write_all(part)?; - } - Ok(()) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = Vec::with_capacity(1 + 8 + 1); - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - let subaddress = if read_byte(r)? == 1 { - Some( - SubaddressIndex::new(read_u32(r)?, read_u32(r)?) - .ok_or_else(|| io::Error::other("invalid subaddress in metadata"))?, - ) - } else { - None - }; - - Ok(Metadata { - subaddress, - payment_id: if read_byte(r)? == 1 { PaymentId::read(r).ok() } else { None }, - arbitrary_data: { - let mut data = vec![]; - for _ in 0 .. read_u32(r)? { - let len = read_byte(r)?; - data.push(read_raw_vec(read_byte, usize::from(len), r)?); - } - data - }, - }) - } -} - -/// A received output, defined as its absolute ID, data, and metadara. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct ReceivedOutput { - pub absolute: AbsoluteId, - pub data: OutputData, - pub metadata: Metadata, -} - -impl ReceivedOutput { - pub fn key(&self) -> EdwardsPoint { - self.data.key - } - - pub fn key_offset(&self) -> Scalar { - self.data.key_offset - } - - pub fn commitment(&self) -> Commitment { - self.data.commitment.clone() - } - - pub fn arbitrary_data(&self) -> &[Vec] { - &self.metadata.arbitrary_data - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - self.absolute.write(w)?; - self.data.write(w)?; - self.metadata.write(w) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(ReceivedOutput { - absolute: AbsoluteId::read(r)?, - data: OutputData::read(r)?, - metadata: Metadata::read(r)?, - }) - } -} - -/// A spendable output, defined as a received output and its index on the Monero blockchain. -/// This index is dependent on the Monero blockchain and will only be known once the output is -/// included within a block. This may change if there's a reorganization. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct SpendableOutput { - pub output: ReceivedOutput, - pub global_index: u64, -} - -impl SpendableOutput { - /// Update the spendable output's global index. This is intended to be called if a - /// re-organization occurred. - pub async fn refresh_global_index( - &mut self, - rpc: &Rpc, - ) -> Result<(), RpcError> { - self.global_index = *rpc - .get_o_indexes(self.output.absolute.tx) - .await? - .get(usize::from(self.output.absolute.o)) - .ok_or(RpcError::InvalidNode( - "node returned output indexes didn't include an index for this output".to_string(), - ))?; - Ok(()) - } - - pub async fn from( - rpc: &Rpc, - output: ReceivedOutput, - ) -> Result { - let mut output = SpendableOutput { output, global_index: 0 }; - output.refresh_global_index(rpc).await?; - Ok(output) - } - - pub fn key(&self) -> EdwardsPoint { - self.output.key() - } - - pub fn key_offset(&self) -> Scalar { - self.output.key_offset() - } - - pub fn commitment(&self) -> Commitment { - self.output.commitment() - } - - pub fn arbitrary_data(&self) -> &[Vec] { - self.output.arbitrary_data() - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - self.output.write(w)?; - w.write_all(&self.global_index.to_le_bytes()) - } - - pub fn serialize(&self) -> Vec { - let mut serialized = vec![]; - self.write(&mut serialized).unwrap(); - serialized - } - - pub fn read(r: &mut R) -> io::Result { - Ok(SpendableOutput { output: ReceivedOutput::read(r)?, global_index: read_u64(r)? }) - } -} - -/// A collection of timelocked outputs, either received or spendable. -#[derive(Zeroize)] -pub struct Timelocked(Timelock, Vec); -impl Drop for Timelocked { - fn drop(&mut self) { - self.zeroize(); - } -} -impl ZeroizeOnDrop for Timelocked {} - -impl Timelocked { - pub fn timelock(&self) -> Timelock { - self.0 - } - - /// Return the outputs if they're not timelocked, or an empty vector if they are. - #[must_use] - pub fn not_locked(&self) -> Vec { - if self.0 == Timelock::None { - return self.1.clone(); - } - vec![] - } - - /// Returns None if the Timelocks aren't comparable. Returns Some(vec![]) if none are unlocked. - #[must_use] - pub fn unlocked(&self, timelock: Timelock) -> Option> { - // If the Timelocks are comparable, return the outputs if they're now unlocked - if self.0 <= timelock { - Some(self.1.clone()) - } else { - None - } - } - - #[must_use] - pub fn ignore_timelock(&self) -> Vec { - self.1.clone() - } -} - -impl Scanner { - /// Scan a transaction to discover the received outputs. - pub fn scan_transaction(&mut self, tx: &Transaction) -> Timelocked { - // Only scan RCT TXs since we can only spend RCT outputs - if tx.prefix.version != 2 { - return Timelocked(tx.prefix.timelock, vec![]); - } - - let Ok(extra) = Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref()) else { - return Timelocked(tx.prefix.timelock, vec![]); - }; - - let Some((tx_keys, additional)) = extra.keys() else { - return Timelocked(tx.prefix.timelock, vec![]); - }; - - let payment_id = extra.payment_id(); - - let mut res = vec![]; - for (o, output) in tx.prefix.outputs.iter().enumerate() { - // https://github.com/serai-dex/serai/issues/106 - if let Some(burning_bug) = self.burning_bug.as_ref() { - if burning_bug.contains(&output.key) { - continue; - } - } - - let output_key = decompress_point(output.key.to_bytes()); - if output_key.is_none() { - continue; - } - let output_key = output_key.unwrap(); - - let additional = additional.as_ref().map(|additional| additional.get(o)); - - for key in tx_keys.iter().map(|key| Some(Some(key))).chain(core::iter::once(additional)) { - let key = match key { - Some(Some(key)) => key, - Some(None) => { - // This is non-standard. There were additional keys, yet not one for this output - // https://github.com/monero-project/monero/ - // blob/04a1e2875d6e35e27bb21497988a6c822d319c28/ - // src/cryptonote_basic/cryptonote_format_utils.cpp#L1062 - continue; - } - None => { - break; - } - }; - let (view_tag, shared_key, payment_id_xor) = shared_key( - if self.burning_bug.is_none() { Some(uniqueness(&tx.prefix.inputs)) } else { None }, - self.pair.view.deref() * key, - o, - ); - - let payment_id = payment_id.map(|id| id ^ payment_id_xor); - - if let Some(actual_view_tag) = output.view_tag { - if actual_view_tag != view_tag { - continue; - } - } - - // P - shared == spend - let subaddress = - self.subaddresses.get(&(output_key - (&shared_key * ED25519_BASEPOINT_TABLE)).compress()); - if subaddress.is_none() { - continue; - } - let subaddress = *subaddress.unwrap(); - - // If it has torsion, it'll subtract the non-torsioned shared key to a torsioned key - // We will not have a torsioned key in our HashMap of keys, so we wouldn't identify it as - // ours - // If we did though, it'd enable bypassing the included burning bug protection - assert!(output_key.is_torsion_free()); - - let mut key_offset = shared_key; - if let Some(subaddress) = subaddress { - key_offset += self.pair.subaddress_derivation(subaddress); - } - // Since we've found an output to us, get its amount - let mut commitment = Commitment::zero(); - - // Miner transaction - if let Some(amount) = output.amount { - commitment.amount = amount; - // Regular transaction - } else { - let (mask, amount) = match tx.rct_signatures.base.encrypted_amounts.get(o) { - Some(amount) => amount_decryption(amount, shared_key), - // This should never happen, yet it may be possible with miner transactions? - // Using get just decreases the possibility of a panic and lets us move on in that case - None => break, - }; - - // Rebuild the commitment to verify it - commitment = Commitment::new(mask, amount); - // If this is a malicious commitment, move to the next output - // Any other R value will calculate to a different spend key and are therefore ignorable - if Some(&commitment.calculate()) != tx.rct_signatures.base.commitments.get(o) { - break; - } - } - - if commitment.amount != 0 { - res.push(ReceivedOutput { - absolute: AbsoluteId { tx: tx.hash(), o: o.try_into().unwrap() }, - - data: OutputData { key: output_key, key_offset, commitment }, - - metadata: Metadata { subaddress, payment_id, arbitrary_data: extra.data() }, - }); - - if let Some(burning_bug) = self.burning_bug.as_mut() { - burning_bug.insert(output.key); - } - } - // Break to prevent public keys from being included multiple times, triggering multiple - // inclusions of the same output - break; - } - } - - Timelocked(tx.prefix.timelock, res) - } - - /// Scan a block to obtain its spendable outputs. Its the presence in a block giving these - /// transactions their global index, and this must be batched as asking for the index of specific - /// transactions is a dead giveaway for which transactions you successfully scanned. This - /// function obtains the output indexes for the miner transaction, incrementing from there - /// instead. - pub async fn scan( - &mut self, - rpc: &Rpc, - block: &Block, - ) -> Result>, RpcError> { - let mut index = rpc.get_o_indexes(block.miner_tx.hash()).await?[0]; - let mut txs = vec![block.miner_tx.clone()]; - txs.extend(rpc.get_transactions(&block.txs).await?); - - let map = |mut timelock: Timelocked, index| { - if timelock.1.is_empty() { - None - } else { - Some(Timelocked( - timelock.0, - timelock - .1 - .drain(..) - .map(|output| SpendableOutput { - global_index: index + u64::from(output.absolute.o), - output, - }) - .collect(), - )) - } - }; - - let mut res = vec![]; - for tx in txs { - if let Some(timelock) = map(self.scan_transaction(&tx), index) { - res.push(timelock); - } - index += u64::try_from( - tx.prefix - .outputs - .iter() - // Filter to v2 miner TX outputs/RCT outputs since we're tracking the RCT output index - .filter(|output| { - let is_v2_miner_tx = - (tx.prefix.version == 2) && matches!(tx.prefix.inputs.first(), Some(Input::Gen(..))); - is_v2_miner_tx || output.amount.is_none() - }) - .count(), - ) - .unwrap() - } - Ok(res) - } -} diff --git a/coins/monero/src/wallet/seed/mod.rs b/coins/monero/src/wallet/seed/mod.rs deleted file mode 100644 index 3cb2911e2..000000000 --- a/coins/monero/src/wallet/seed/mod.rs +++ /dev/null @@ -1,136 +0,0 @@ -use core::fmt; -use std_shims::string::String; - -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; -use rand_core::{RngCore, CryptoRng}; - -pub(crate) mod classic; -pub(crate) mod polyseed; -use classic::{CLASSIC_SEED_LENGTH, CLASSIC_SEED_LENGTH_WITH_CHECKSUM, ClassicSeed}; -use polyseed::{POLYSEED_LENGTH, Polyseed}; - -/// Error when decoding a seed. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "std", derive(thiserror::Error))] -pub enum SeedError { - #[cfg_attr(feature = "std", error("invalid number of words in seed"))] - InvalidSeedLength, - #[cfg_attr(feature = "std", error("unknown language"))] - UnknownLanguage, - #[cfg_attr(feature = "std", error("invalid checksum"))] - InvalidChecksum, - #[cfg_attr(feature = "std", error("english old seeds don't support checksums"))] - EnglishOldWithChecksum, - #[cfg_attr(feature = "std", error("provided entropy is not valid"))] - InvalidEntropy, - #[cfg_attr(feature = "std", error("invalid seed"))] - InvalidSeed, - #[cfg_attr(feature = "std", error("provided features are not supported"))] - UnsupportedFeatures, -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum SeedType { - Classic(classic::Language), - Polyseed(polyseed::Language), -} - -/// A Monero seed. -#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] -pub enum Seed { - Classic(ClassicSeed), - Polyseed(Polyseed), -} - -impl fmt::Debug for Seed { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Seed::Classic(_) => f.debug_struct("Seed::Classic").finish_non_exhaustive(), - Seed::Polyseed(_) => f.debug_struct("Seed::Polyseed").finish_non_exhaustive(), - } - } -} - -impl Seed { - /// Creates a new `Seed`. - pub fn new(rng: &mut R, seed_type: SeedType) -> Seed { - match seed_type { - SeedType::Classic(lang) => Seed::Classic(ClassicSeed::new(rng, lang)), - SeedType::Polyseed(lang) => Seed::Polyseed(Polyseed::new(rng, lang)), - } - } - - /// Parse a seed from a `String`. - pub fn from_string(seed_type: SeedType, words: Zeroizing) -> Result { - let word_count = words.split_whitespace().count(); - match seed_type { - SeedType::Classic(lang) => { - if word_count != CLASSIC_SEED_LENGTH && word_count != CLASSIC_SEED_LENGTH_WITH_CHECKSUM { - Err(SeedError::InvalidSeedLength)? - } else { - ClassicSeed::from_string(lang, words).map(Seed::Classic) - } - } - SeedType::Polyseed(lang) => { - if word_count != POLYSEED_LENGTH { - Err(SeedError::InvalidSeedLength)? - } else { - Polyseed::from_string(lang, words).map(Seed::Polyseed) - } - } - } - } - - /// Creates a `Seed` from an entropy and an optional birthday (denoted in seconds since the - /// epoch). - /// - /// For `SeedType::Classic`, the birthday is ignored. - /// - /// For `SeedType::Polyseed`, the last 13 bytes of `entropy` must be `0`. - // TODO: Return Result, not Option - pub fn from_entropy( - seed_type: SeedType, - entropy: Zeroizing<[u8; 32]>, - birthday: Option, - ) -> Option { - match seed_type { - SeedType::Classic(lang) => ClassicSeed::from_entropy(lang, entropy).map(Seed::Classic), - SeedType::Polyseed(lang) => { - Polyseed::from(lang, 0, birthday.unwrap_or(0), entropy).map(Seed::Polyseed).ok() - } - } - } - - /// Returns seed as `String`. - pub fn to_string(&self) -> Zeroizing { - match self { - Seed::Classic(seed) => seed.to_string(), - Seed::Polyseed(seed) => seed.to_string(), - } - } - - /// Returns the entropy for this seed. - pub fn entropy(&self) -> Zeroizing<[u8; 32]> { - match self { - Seed::Classic(seed) => seed.entropy(), - Seed::Polyseed(seed) => seed.entropy().clone(), - } - } - - /// Returns the key derived from this seed. - pub fn key(&self) -> Zeroizing<[u8; 32]> { - match self { - // Classic does not differentiate between its entropy and its key - Seed::Classic(seed) => seed.entropy(), - Seed::Polyseed(seed) => seed.key(), - } - } - - /// Returns the birthday of this seed. - pub fn birthday(&self) -> u64 { - match self { - Seed::Classic(_) => 0, - Seed::Polyseed(seed) => seed.birthday(), - } - } -} diff --git a/coins/monero/src/wallet/send/builder.rs b/coins/monero/src/wallet/send/builder.rs deleted file mode 100644 index 55d0fc29c..000000000 --- a/coins/monero/src/wallet/send/builder.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::sync::{Arc, RwLock}; - -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; - -use crate::{ - Protocol, - wallet::{ - address::MoneroAddress, Fee, SpendableOutput, Change, Decoys, SignableTransaction, - TransactionError, extra::MAX_ARBITRARY_DATA_SIZE, - }, -}; - -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -struct SignableTransactionBuilderInternal { - protocol: Protocol, - fee_rate: Fee, - - r_seed: Option>, - inputs: Vec<(SpendableOutput, Decoys)>, - payments: Vec<(MoneroAddress, u64)>, - change_address: Change, - data: Vec>, -} - -impl SignableTransactionBuilderInternal { - // Takes in the change address so users don't miss that they have to manually set one - // If they don't, all leftover funds will become part of the fee - fn new(protocol: Protocol, fee_rate: Fee, change_address: Change) -> Self { - Self { - protocol, - fee_rate, - r_seed: None, - inputs: vec![], - payments: vec![], - change_address, - data: vec![], - } - } - - fn set_r_seed(&mut self, r_seed: Zeroizing<[u8; 32]>) { - self.r_seed = Some(r_seed); - } - - fn add_input(&mut self, input: (SpendableOutput, Decoys)) { - self.inputs.push(input); - } - fn add_inputs(&mut self, inputs: &[(SpendableOutput, Decoys)]) { - self.inputs.extend(inputs.iter().cloned()); - } - - fn add_payment(&mut self, dest: MoneroAddress, amount: u64) { - self.payments.push((dest, amount)); - } - fn add_payments(&mut self, payments: &[(MoneroAddress, u64)]) { - self.payments.extend(payments); - } - - fn add_data(&mut self, data: Vec) { - self.data.push(data); - } -} - -/// A Transaction Builder for Monero transactions. -/// All methods provided will modify self while also returning a shallow copy, enabling efficient -/// chaining with a clean API. -/// In order to fork the builder at some point, clone will still return a deep copy. -#[derive(Debug)] -pub struct SignableTransactionBuilder(Arc>); -impl Clone for SignableTransactionBuilder { - fn clone(&self) -> Self { - Self(Arc::new(RwLock::new((*self.0.read().unwrap()).clone()))) - } -} - -impl PartialEq for SignableTransactionBuilder { - fn eq(&self, other: &Self) -> bool { - *self.0.read().unwrap() == *other.0.read().unwrap() - } -} -impl Eq for SignableTransactionBuilder {} - -impl Zeroize for SignableTransactionBuilder { - fn zeroize(&mut self) { - self.0.write().unwrap().zeroize() - } -} - -impl SignableTransactionBuilder { - fn shallow_copy(&self) -> Self { - Self(self.0.clone()) - } - - pub fn new(protocol: Protocol, fee_rate: Fee, change_address: Change) -> Self { - Self(Arc::new(RwLock::new(SignableTransactionBuilderInternal::new( - protocol, - fee_rate, - change_address, - )))) - } - - pub fn set_r_seed(&mut self, r_seed: Zeroizing<[u8; 32]>) -> Self { - self.0.write().unwrap().set_r_seed(r_seed); - self.shallow_copy() - } - - pub fn add_input(&mut self, input: (SpendableOutput, Decoys)) -> Self { - self.0.write().unwrap().add_input(input); - self.shallow_copy() - } - pub fn add_inputs(&mut self, inputs: &[(SpendableOutput, Decoys)]) -> Self { - self.0.write().unwrap().add_inputs(inputs); - self.shallow_copy() - } - - pub fn add_payment(&mut self, dest: MoneroAddress, amount: u64) -> Self { - self.0.write().unwrap().add_payment(dest, amount); - self.shallow_copy() - } - pub fn add_payments(&mut self, payments: &[(MoneroAddress, u64)]) -> Self { - self.0.write().unwrap().add_payments(payments); - self.shallow_copy() - } - - pub fn add_data(&mut self, data: Vec) -> Result { - if data.len() > MAX_ARBITRARY_DATA_SIZE { - Err(TransactionError::TooMuchData)?; - } - self.0.write().unwrap().add_data(data); - Ok(self.shallow_copy()) - } - - pub fn build(self) -> Result { - let read = self.0.read().unwrap(); - SignableTransaction::new( - read.protocol, - read.r_seed.clone(), - read.inputs.clone(), - read.payments.clone(), - &read.change_address, - read.data.clone(), - read.fee_rate, - ) - } -} diff --git a/coins/monero/src/wallet/send/mod.rs b/coins/monero/src/wallet/send/mod.rs deleted file mode 100644 index 153e6b6cf..000000000 --- a/coins/monero/src/wallet/send/mod.rs +++ /dev/null @@ -1,1038 +0,0 @@ -use core::{ops::Deref, fmt}; -use std_shims::{ - vec::Vec, - io, - string::{String, ToString}, -}; - -use rand_core::{RngCore, CryptoRng, SeedableRng}; -use rand_chacha::ChaCha20Rng; -use rand::seq::SliceRandom; - -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; - -use group::Group; -use curve25519_dalek::{ - constants::{ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_TABLE}, - scalar::Scalar, - edwards::EdwardsPoint, -}; -use dalek_ff_group as dfg; - -#[cfg(feature = "multisig")] -use frost::FrostError; - -use crate::{ - Protocol, Commitment, hash, random_scalar, - serialize::{ - read_byte, read_bytes, read_u64, read_scalar, read_point, read_vec, write_byte, write_scalar, - write_point, write_raw_vec, write_vec, - }, - ringct::{ - generate_key_image, - clsag::{ClsagError, ClsagInput, Clsag}, - bulletproofs::{MAX_OUTPUTS, Bulletproofs}, - RctBase, RctPrunable, RctSignatures, - }, - transaction::{Input, Output, Timelock, TransactionPrefix, Transaction}, - rpc::RpcError, - wallet::{ - address::{Network, AddressSpec, MoneroAddress}, - ViewPair, SpendableOutput, Decoys, PaymentId, ExtraField, Extra, key_image_sort, uniqueness, - shared_key, commitment_mask, amount_encryption, - extra::{ARBITRARY_DATA_MARKER, MAX_ARBITRARY_DATA_SIZE}, - }, -}; - -#[cfg(feature = "std")] -mod builder; -#[cfg(feature = "std")] -pub use builder::SignableTransactionBuilder; - -#[cfg(feature = "multisig")] -mod multisig; -#[cfg(feature = "multisig")] -pub use multisig::TransactionMachine; -use crate::ringct::EncryptedAmount; - -#[allow(non_snake_case)] -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -struct SendOutput { - R: EdwardsPoint, - view_tag: u8, - dest: EdwardsPoint, - commitment: Commitment, - amount: [u8; 8], -} - -impl SendOutput { - #[allow(non_snake_case)] - fn internal( - unique: [u8; 32], - output: (usize, (MoneroAddress, u64), bool), - ecdh: EdwardsPoint, - R: EdwardsPoint, - ) -> (SendOutput, Option<[u8; 8]>) { - let o = output.0; - let need_dummy_payment_id = output.2; - let output = output.1; - - let (view_tag, shared_key, payment_id_xor) = - shared_key(Some(unique).filter(|_| output.0.is_guaranteed()), ecdh, o); - - let payment_id = output - .0 - .payment_id() - .or(if need_dummy_payment_id { Some([0u8; 8]) } else { None }) - .map(|id| (u64::from_le_bytes(id) ^ u64::from_le_bytes(payment_id_xor)).to_le_bytes()); - - ( - SendOutput { - R, - view_tag, - dest: ((&shared_key * ED25519_BASEPOINT_TABLE) + output.0.spend), - commitment: Commitment::new(commitment_mask(shared_key), output.1), - amount: amount_encryption(output.1, shared_key), - }, - payment_id, - ) - } - - fn new( - r: &Zeroizing, - unique: [u8; 32], - output: (usize, (MoneroAddress, u64), bool), - ) -> (SendOutput, Option<[u8; 8]>) { - let address = output.1 .0; - SendOutput::internal( - unique, - output, - r.deref() * address.view, - if !address.is_subaddress() { - r.deref() * ED25519_BASEPOINT_TABLE - } else { - r.deref() * address.spend - }, - ) - } - - fn change( - ecdh: EdwardsPoint, - unique: [u8; 32], - output: (usize, (MoneroAddress, u64), bool), - ) -> (SendOutput, Option<[u8; 8]>) { - SendOutput::internal(unique, output, ecdh, ED25519_BASEPOINT_POINT) - } -} - -#[derive(Clone, PartialEq, Eq, Debug)] -#[cfg_attr(feature = "std", derive(thiserror::Error))] -pub enum TransactionError { - #[cfg_attr(feature = "std", error("multiple addresses with payment IDs"))] - MultiplePaymentIds, - #[cfg_attr(feature = "std", error("no inputs"))] - NoInputs, - #[cfg_attr(feature = "std", error("no outputs"))] - NoOutputs, - #[cfg_attr(feature = "std", error("invalid number of decoys"))] - InvalidDecoyQuantity, - #[cfg_attr(feature = "std", error("only one output and no change address"))] - NoChange, - #[cfg_attr(feature = "std", error("too many outputs"))] - TooManyOutputs, - #[cfg_attr(feature = "std", error("too much data"))] - TooMuchData, - #[cfg_attr(feature = "std", error("too many inputs/too much arbitrary data"))] - TooLargeTransaction, - #[cfg_attr( - feature = "std", - error("not enough funds (inputs {inputs}, outputs {outputs}, fee {fee})") - )] - NotEnoughFunds { inputs: u64, outputs: u64, fee: u64 }, - #[cfg_attr(feature = "std", error("wrong spend private key"))] - WrongPrivateKey, - #[cfg_attr(feature = "std", error("rpc error ({0})"))] - RpcError(RpcError), - #[cfg_attr(feature = "std", error("clsag error ({0})"))] - ClsagError(ClsagError), - #[cfg_attr(feature = "std", error("invalid transaction ({0})"))] - InvalidTransaction(RpcError), - #[cfg(feature = "multisig")] - #[cfg_attr(feature = "std", error("frost error {0}"))] - FrostError(FrostError), -} - -fn prepare_inputs( - inputs: &[(SpendableOutput, Decoys)], - spend: &Zeroizing, - tx: &mut Transaction, -) -> Result, EdwardsPoint, ClsagInput)>, TransactionError> { - let mut signable = Vec::with_capacity(inputs.len()); - - for (i, (input, decoys)) in inputs.iter().enumerate() { - let input_spend = Zeroizing::new(input.key_offset() + spend.deref()); - let image = generate_key_image(&input_spend); - signable.push(( - input_spend, - image, - ClsagInput::new(input.commitment().clone(), decoys.clone()) - .map_err(TransactionError::ClsagError)?, - )); - - tx.prefix.inputs.push(Input::ToKey { - amount: None, - key_offsets: decoys.offsets.clone(), - key_image: signable[i].1, - }); - } - - signable.sort_by(|x, y| x.1.compress().to_bytes().cmp(&y.1.compress().to_bytes()).reverse()); - tx.prefix.inputs.sort_by(|x, y| { - if let (Input::ToKey { key_image: x, .. }, Input::ToKey { key_image: y, .. }) = (x, y) { - x.compress().to_bytes().cmp(&y.compress().to_bytes()).reverse() - } else { - panic!("Input wasn't ToKey") - } - }); - - Ok(signable) -} - -// Deterministically calculate what the TX weight and fee will be. -fn calculate_weight_and_fee( - protocol: Protocol, - decoy_weights: &[usize], - n_outputs: usize, - extra: usize, - fee_rate: Fee, -) -> (usize, u64) { - // Starting the fee at 0 here is different than core Monero's wallet2.cpp, which starts its fee - // calculation with an estimate. - // - // This difference is okay in practice because wallet2 still ends up using a fee calculated from - // a TX's weight, as calculated later in this function. - // - // See this PR highlighting wallet2's behavior: - // https://github.com/monero-project/monero/pull/8882 - // - // Even with that PR, if the estimated fee's VarInt byte length is larger than the calculated - // fee's, the wallet can theoretically use a fee not based on the actual TX weight. This does not - // occur in practice as it's nearly impossible for wallet2 to estimate a fee that is larger - // than the calculated fee today, and on top of that, even more unlikely for that estimate's - // VarInt to be larger in byte length than the calculated fee's. - let mut weight = 0usize; - let mut fee = 0u64; - - let mut done = false; - let mut iters = 0; - let max_iters = 5; - while !done { - weight = Transaction::fee_weight(protocol, decoy_weights, n_outputs, extra, fee); - - let fee_calculated_from_weight = fee_rate.calculate_fee_from_weight(weight); - - // Continue trying to use the fee calculated from the tx's weight - done = fee_calculated_from_weight == fee; - - fee = fee_calculated_from_weight; - - #[cfg(test)] - debug_assert!(iters < max_iters, "Reached max fee calculation attempts"); - // Should never happen because the fee VarInt byte length shouldn't change *every* single iter. - // `iters` reaching `max_iters` is unexpected. - if iters >= max_iters { - // Fail-safe break to ensure funds are still spendable - break; - } - iters += 1; - } - - (weight, fee) -} - -/// Fee struct, defined as a per-unit cost and a mask for rounding purposes. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] -pub struct Fee { - pub per_weight: u64, - pub mask: u64, -} - -impl Fee { - pub fn calculate_fee_from_weight(&self, weight: usize) -> u64 { - let fee = (((self.per_weight * u64::try_from(weight).unwrap()) + self.mask - 1) / self.mask) * - self.mask; - debug_assert_eq!(weight, self.calculate_weight_from_fee(fee), "Miscalculated weight from fee"); - fee - } - - pub fn calculate_weight_from_fee(&self, fee: u64) -> usize { - usize::try_from(fee / self.per_weight).unwrap() - } -} - -/// Fee priority, determining how quickly a transaction is included in a block. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -#[allow(non_camel_case_types)] -pub enum FeePriority { - Unimportant, - Normal, - Elevated, - Priority, - Custom { priority: u32 }, -} - -/// https://github.com/monero-project/monero/blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/ -/// src/simplewallet/simplewallet.cpp#L161 -impl FeePriority { - pub(crate) fn fee_priority(&self) -> u32 { - match self { - FeePriority::Unimportant => 1, - FeePriority::Normal => 2, - FeePriority::Elevated => 3, - FeePriority::Priority => 4, - FeePriority::Custom { priority, .. } => *priority, - } - } -} - -#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] -pub(crate) enum InternalPayment { - Payment((MoneroAddress, u64), bool), - Change((MoneroAddress, u64), Option>), -} - -/// The eventual output of a SignableTransaction. -/// -/// If the SignableTransaction has a Change with a view key, this will also have the view key. -/// Accordingly, it must be treated securely. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] -pub struct Eventuality { - protocol: Protocol, - r_seed: Zeroizing<[u8; 32]>, - inputs: Vec, - payments: Vec, - extra: Vec, -} - -/// A signable transaction, either in a single-signer or multisig context. -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct SignableTransaction { - protocol: Protocol, - r_seed: Option>, - inputs: Vec<(SpendableOutput, Decoys)>, - has_change: bool, - payments: Vec, - data: Vec>, - fee: u64, - fee_rate: Fee, -} - -/// Specification for a change output. -#[derive(Clone, PartialEq, Eq, Zeroize)] -pub struct Change { - address: Option, - view: Option>, -} - -impl fmt::Debug for Change { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_struct("Change").field("address", &self.address).finish_non_exhaustive() - } -} - -impl Change { - /// Create a change output specification from a ViewPair, as needed to maintain privacy. - pub fn new(view: &ViewPair, guaranteed: bool) -> Change { - Change { - address: Some(view.address( - Network::Mainnet, - if !guaranteed { - AddressSpec::Standard - } else { - AddressSpec::Featured { subaddress: None, payment_id: None, guaranteed: true } - }, - )), - view: Some(view.view.clone()), - } - } - - /// Create a fingerprintable change output specification which will harm privacy. Only use this - /// if you know what you're doing. - /// - /// If the change address is None, there are 2 potential fingerprints: - /// - /// 1) The change in the tx is shunted to the fee (fingerprintable fee). - /// - /// 2) If there are 2 outputs in the tx, there would be no payment ID as is the case when the - /// reference wallet creates 2 output txs, since monero-serai doesn't know which output - /// to tie the dummy payment ID to. - pub fn fingerprintable(address: Option) -> Change { - Change { address, view: None } - } -} - -fn need_additional(payments: &[InternalPayment]) -> (bool, bool) { - let mut has_change_view = false; - let subaddresses = payments - .iter() - .filter(|payment| match *payment { - InternalPayment::Payment(payment, _) => payment.0.is_subaddress(), - InternalPayment::Change(change, change_view) => { - if change_view.is_some() { - has_change_view = true; - // It should not be possible to construct a change specification to a subaddress with a - // view key - debug_assert!(!change.0.is_subaddress()); - } - change.0.is_subaddress() - } - }) - .count() != - 0; - - // We need additional keys if we have any subaddresses - let mut additional = subaddresses; - // Unless the above change view key path is taken - if (payments.len() == 2) && has_change_view { - additional = false; - } - - (subaddresses, additional) -} - -fn sanity_check_change_payment_quantity(payments: &[InternalPayment], has_change_address: bool) { - debug_assert_eq!( - payments - .iter() - .filter(|payment| match *payment { - InternalPayment::Payment(_, _) => false, - InternalPayment::Change(_, _) => true, - }) - .count(), - if has_change_address { 1 } else { 0 }, - "Unexpected number of change outputs" - ); -} - -impl SignableTransaction { - /// Create a signable transaction. - /// - /// `r_seed` refers to a seed used to derive the transaction's ephemeral keys (colloquially - /// called Rs). If None is provided, one will be automatically generated. - /// - /// Up to 16 outputs may be present, including the change output. If the change address is - /// specified, leftover funds will be sent to it. - /// - /// Each chunk of data must not exceed MAX_ARBITRARY_DATA_SIZE and will be embedded in TX extra. - pub fn new( - protocol: Protocol, - r_seed: Option>, - inputs: Vec<(SpendableOutput, Decoys)>, - payments: Vec<(MoneroAddress, u64)>, - change: &Change, - data: Vec>, - fee_rate: Fee, - ) -> Result { - // Make sure there's only one payment ID - let mut has_payment_id = { - let mut payment_ids = 0; - let mut count = |addr: MoneroAddress| { - if addr.payment_id().is_some() { - payment_ids += 1 - } - }; - for payment in &payments { - count(payment.0); - } - if let Some(change_address) = change.address.as_ref() { - count(*change_address); - } - if payment_ids > 1 { - Err(TransactionError::MultiplePaymentIds)?; - } - payment_ids == 1 - }; - - if inputs.is_empty() { - Err(TransactionError::NoInputs)?; - } - if payments.is_empty() { - Err(TransactionError::NoOutputs)?; - } - - for (_, decoys) in &inputs { - if decoys.len() != protocol.ring_len() { - Err(TransactionError::InvalidDecoyQuantity)?; - } - } - - for part in &data { - if part.len() > MAX_ARBITRARY_DATA_SIZE { - Err(TransactionError::TooMuchData)?; - } - } - - // If we don't have two outputs, as required by Monero, error - if (payments.len() == 1) && change.address.is_none() { - Err(TransactionError::NoChange)?; - } - - // All 2 output txs created by the reference wallet have payment IDs to avoid - // fingerprinting integrated addresses. Note: we won't create a dummy payment - // ID if we create a 0-change 2-output tx since we don't know which output should - // receive the payment ID and such a tx is fingerprintable to monero-serai anyway - let need_dummy_payment_id = !has_payment_id && payments.len() == 1; - has_payment_id |= need_dummy_payment_id; - - // Get the outgoing amount ignoring fees - let out_amount = payments.iter().map(|payment| payment.1).sum::(); - - let outputs = payments.len() + usize::from(change.address.is_some()); - if outputs > MAX_OUTPUTS { - Err(TransactionError::TooManyOutputs)?; - } - - // Collect payments in a container that includes a change output if a change address is provided - let mut payments = payments - .into_iter() - .map(|payment| InternalPayment::Payment(payment, need_dummy_payment_id)) - .collect::>(); - debug_assert!(!need_dummy_payment_id || (payments.len() == 1 && change.address.is_some())); - - if let Some(change_address) = change.address.as_ref() { - // Push a 0 amount change output that we'll use to do fee calculations. - // We'll modify the change amount after calculating the fee - payments.push(InternalPayment::Change((*change_address, 0), change.view.clone())); - } - - // Determine if we'll need additional pub keys in tx extra - let (_, additional) = need_additional(&payments); - - // Calculate the extra length - let extra = Extra::fee_weight(outputs, additional, has_payment_id, data.as_ref()); - - // https://github.com/monero-project/monero/pull/8733 - const MAX_EXTRA_SIZE: usize = 1060; - if extra > MAX_EXTRA_SIZE { - Err(TransactionError::TooMuchData)?; - } - - // Caclculate weight of decoys - let decoy_weights = - inputs.iter().map(|(_, decoy)| Decoys::fee_weight(&decoy.offsets)).collect::>(); - - // Deterministically calculate tx weight and fee - let (weight, fee) = - calculate_weight_and_fee(protocol, &decoy_weights, outputs, extra, fee_rate); - - // The actual limit is half the block size, and for the minimum block size of 300k, that'd be - // 150k - // wallet2 will only create transactions up to 100k bytes however - const MAX_TX_SIZE: usize = 100_000; - if weight >= MAX_TX_SIZE { - Err(TransactionError::TooLargeTransaction)?; - } - - // Make sure we have enough funds - let in_amount = inputs.iter().map(|(input, _)| input.commitment().amount).sum::(); - if in_amount < (out_amount + fee) { - Err(TransactionError::NotEnoughFunds { inputs: in_amount, outputs: out_amount, fee })?; - } - - // Sanity check we have the expected number of change outputs - sanity_check_change_payment_quantity(&payments, change.address.is_some()); - - // Modify the amount of the change output - if let Some(change_address) = change.address.as_ref() { - let change_payment = payments.last_mut().unwrap(); - debug_assert!(matches!(change_payment, InternalPayment::Change(_, _))); - *change_payment = InternalPayment::Change( - (*change_address, in_amount - out_amount - fee), - change.view.clone(), - ); - } - - // Sanity check the change again after modifying - sanity_check_change_payment_quantity(&payments, change.address.is_some()); - - // Sanity check outgoing amount + fee == incoming amount - if change.address.is_some() { - debug_assert_eq!( - payments - .iter() - .map(|payment| match *payment { - InternalPayment::Payment(payment, _) => payment.1, - InternalPayment::Change(change, _) => change.1, - }) - .sum::() + - fee, - in_amount, - "Outgoing amount + fee != incoming amount" - ); - } - - Ok(SignableTransaction { - protocol, - r_seed, - inputs, - payments, - has_change: change.address.is_some(), - data, - fee, - fee_rate, - }) - } - - pub fn fee(&self) -> u64 { - self.fee - } - - pub fn fee_rate(&self) -> Fee { - self.fee_rate - } - - #[allow(clippy::type_complexity)] - fn prepare_payments( - seed: &Zeroizing<[u8; 32]>, - inputs: &[EdwardsPoint], - payments: &mut Vec, - uniqueness: [u8; 32], - ) -> (EdwardsPoint, Vec>, Vec, Option<[u8; 8]>) { - let mut rng = { - // Hash the inputs into the seed so we don't re-use Rs - // Doesn't re-use uniqueness as that's based on key images, which requires interactivity - // to generate. The output keys do not - // This remains private so long as the seed is private - let mut r_uniqueness = vec![]; - for input in inputs { - r_uniqueness.extend(input.compress().to_bytes()); - } - ChaCha20Rng::from_seed(hash( - &[b"monero-serai_outputs".as_ref(), seed.as_ref(), &r_uniqueness].concat(), - )) - }; - - // Shuffle the payments - payments.shuffle(&mut rng); - - // Used for all non-subaddress outputs, or if there's only one subaddress output and a change - let tx_key = Zeroizing::new(random_scalar(&mut rng)); - let mut tx_public_key = tx_key.deref() * ED25519_BASEPOINT_TABLE; - - // If any of these outputs are to a subaddress, we need keys distinct to them - // The only time this *does not* force having additional keys is when the only other output - // is a change output we have the view key for, enabling rewriting rA to aR - let (subaddresses, additional) = need_additional(payments); - let modified_change_ecdh = subaddresses && (!additional); - - // If we're using the aR rewrite, update tx_public_key from rG to rB - if modified_change_ecdh { - for payment in &*payments { - match payment { - InternalPayment::Payment(payment, _) => { - // This should be the only payment and it should be a subaddress - debug_assert!(payment.0.is_subaddress()); - tx_public_key = tx_key.deref() * payment.0.spend; - } - InternalPayment::Change(_, _) => {} - } - } - debug_assert!(tx_public_key != (tx_key.deref() * ED25519_BASEPOINT_TABLE)); - } - - // Actually create the outputs - let mut additional_keys = vec![]; - let mut outputs = Vec::with_capacity(payments.len()); - let mut id = None; - for (o, mut payment) in payments.drain(..).enumerate() { - // Downcast the change output to a payment output if it doesn't require special handling - // regarding it's view key - payment = if !modified_change_ecdh { - if let InternalPayment::Change(change, _) = &payment { - InternalPayment::Payment(*change, false) - } else { - payment - } - } else { - payment - }; - - let (output, payment_id) = match payment { - InternalPayment::Payment(payment, need_dummy_payment_id) => { - // If this is a subaddress, generate a dedicated r. Else, reuse the TX key - let dedicated = Zeroizing::new(random_scalar(&mut rng)); - let use_dedicated = additional && payment.0.is_subaddress(); - let r = if use_dedicated { &dedicated } else { &tx_key }; - - let (mut output, payment_id) = - SendOutput::new(r, uniqueness, (o, payment, need_dummy_payment_id)); - if modified_change_ecdh { - debug_assert_eq!(tx_public_key, output.R); - } - - if use_dedicated { - additional_keys.push(dedicated); - } else { - // If this used tx_key, randomize its R - // This is so when extra is created, there's a distinct R for it to use - output.R = dfg::EdwardsPoint::random(&mut rng).0; - } - (output, payment_id) - } - InternalPayment::Change(change, change_view) => { - // Instead of rA, use Ra, where R is r * subaddress_spend_key - // change.view must be Some as if it's None, this payment would've been downcast - let ecdh = tx_public_key * change_view.unwrap().deref(); - SendOutput::change(ecdh, uniqueness, (o, change, false)) - } - }; - - outputs.push(output); - id = id.or(payment_id); - } - - (tx_public_key, additional_keys, outputs, id) - } - - #[allow(non_snake_case)] - fn extra( - tx_key: EdwardsPoint, - additional: bool, - Rs: Vec, - id: Option<[u8; 8]>, - data: &mut Vec>, - ) -> Vec { - #[allow(non_snake_case)] - let Rs_len = Rs.len(); - let mut extra = Extra::new(tx_key, if additional { Rs } else { vec![] }); - - if let Some(id) = id { - let mut id_vec = Vec::with_capacity(1 + 8); - PaymentId::Encrypted(id).write(&mut id_vec).unwrap(); - extra.push(ExtraField::Nonce(id_vec)); - } - - // Include data if present - let extra_len = Extra::fee_weight(Rs_len, additional, id.is_some(), data.as_ref()); - for part in data.drain(..) { - let mut arb = vec![ARBITRARY_DATA_MARKER]; - arb.extend(part); - extra.push(ExtraField::Nonce(arb)); - } - - let mut serialized = Vec::with_capacity(extra_len); - extra.write(&mut serialized).unwrap(); - debug_assert_eq!(extra_len, serialized.len()); - serialized - } - - /// Returns the eventuality of this transaction. - /// - /// The eventuality is defined as the TX extra/outputs this transaction will create, if signed - /// with the specified seed. This eventuality can be compared to on-chain transactions to see - /// if the transaction has already been signed and published. - pub fn eventuality(&self) -> Option { - let inputs = self.inputs.iter().map(|(input, _)| input.key()).collect::>(); - let (tx_key, additional, outputs, id) = Self::prepare_payments( - self.r_seed.as_ref()?, - &inputs, - &mut self.payments.clone(), - // Lie about the uniqueness, used when determining output keys/commitments yet not the - // ephemeral keys, which is want we want here - // While we do still grab the outputs variable, it's so we can get its Rs - [0; 32], - ); - #[allow(non_snake_case)] - let Rs = outputs.iter().map(|output| output.R).collect(); - drop(outputs); - - let additional = !additional.is_empty(); - let extra = Self::extra(tx_key, additional, Rs, id, &mut self.data.clone()); - - Some(Eventuality { - protocol: self.protocol, - r_seed: self.r_seed.clone()?, - inputs, - payments: self.payments.clone(), - extra, - }) - } - - fn prepare_transaction( - &mut self, - rng: &mut R, - uniqueness: [u8; 32], - ) -> (Transaction, Scalar) { - // If no seed for the ephemeral keys was provided, make one - let r_seed = self.r_seed.clone().unwrap_or_else(|| { - let mut res = Zeroizing::new([0; 32]); - rng.fill_bytes(res.as_mut()); - res - }); - - let (tx_key, additional, outputs, id) = Self::prepare_payments( - &r_seed, - &self.inputs.iter().map(|(input, _)| input.key()).collect::>(), - &mut self.payments, - uniqueness, - ); - // This function only cares if additional keys were necessary, not what they were - let additional = !additional.is_empty(); - - let commitments = outputs.iter().map(|output| output.commitment.clone()).collect::>(); - let sum = commitments.iter().map(|commitment| commitment.mask).sum(); - - // Safe due to the constructor checking MAX_OUTPUTS - let bp = Bulletproofs::prove(rng, &commitments, self.protocol.bp_plus()).unwrap(); - - // Create the TX extra - let extra = Self::extra( - tx_key, - additional, - outputs.iter().map(|output| output.R).collect(), - id, - &mut self.data, - ); - - let mut fee = self.inputs.iter().map(|(input, _)| input.commitment().amount).sum::(); - let mut tx_outputs = Vec::with_capacity(outputs.len()); - let mut encrypted_amounts = Vec::with_capacity(outputs.len()); - for output in &outputs { - fee -= output.commitment.amount; - tx_outputs.push(Output { - amount: None, - key: output.dest.compress(), - view_tag: Some(output.view_tag).filter(|_| self.protocol.view_tags()), - }); - encrypted_amounts.push(EncryptedAmount::Compact { amount: output.amount }); - } - if self.has_change { - debug_assert_eq!(self.fee, fee, "transaction will use an unexpected fee"); - } - - ( - Transaction { - prefix: TransactionPrefix { - version: 2, - timelock: Timelock::None, - inputs: vec![], - outputs: tx_outputs, - extra, - }, - signatures: vec![], - rct_signatures: RctSignatures { - base: RctBase { - fee, - encrypted_amounts, - pseudo_outs: vec![], - commitments: commitments.iter().map(Commitment::calculate).collect(), - }, - prunable: RctPrunable::Clsag { bulletproofs: bp, clsags: vec![], pseudo_outs: vec![] }, - }, - }, - sum, - ) - } - - /// Sign this transaction. - pub fn sign( - mut self, - rng: &mut R, - spend: &Zeroizing, - ) -> Result { - let mut images = Vec::with_capacity(self.inputs.len()); - for (input, _) in &self.inputs { - let mut offset = Zeroizing::new(spend.deref() + input.key_offset()); - if (offset.deref() * ED25519_BASEPOINT_TABLE) != input.key() { - Err(TransactionError::WrongPrivateKey)?; - } - - images.push(generate_key_image(&offset)); - offset.zeroize(); - } - images.sort_by(key_image_sort); - - let (mut tx, mask_sum) = self.prepare_transaction( - rng, - uniqueness( - &images - .iter() - .map(|image| Input::ToKey { amount: None, key_offsets: vec![], key_image: *image }) - .collect::>(), - ), - ); - - let signable = prepare_inputs(&self.inputs, spend, &mut tx)?; - - let clsag_pairs = Clsag::sign(rng, signable, mask_sum, tx.signature_hash()); - match tx.rct_signatures.prunable { - RctPrunable::Null => panic!("Signing for RctPrunable::Null"), - RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. } => { - clsags.append(&mut clsag_pairs.iter().map(|clsag| clsag.0.clone()).collect::>()); - pseudo_outs.append(&mut clsag_pairs.iter().map(|clsag| clsag.1).collect::>()); - } - _ => unreachable!("attempted to sign a TX which wasn't CLSAG"), - } - - if self.has_change { - debug_assert_eq!( - self.fee_rate.calculate_fee_from_weight(tx.weight()), - tx.rct_signatures.base.fee, - "transaction used unexpected fee", - ); - } - - Ok(tx) - } -} - -impl Eventuality { - /// Enables building a HashMap of Extra -> Eventuality for efficiently checking if an on-chain - /// transaction may match this eventuality. - /// - /// This extra is cryptographically bound to: - /// 1) A specific set of inputs (via their output key) - /// 2) A specific seed for the ephemeral keys - /// - /// This extra may be used in a transaction with a distinct set of inputs, yet no honest - /// transaction which doesn't satisfy this Eventuality will contain it. - pub fn extra(&self) -> &[u8] { - &self.extra - } - - #[must_use] - pub fn matches(&self, tx: &Transaction) -> bool { - if self.payments.len() != tx.prefix.outputs.len() { - return false; - } - - // Verify extra. - // Even if all the outputs were correct, a malicious extra could still cause a recipient to - // fail to receive their funds. - // This is the cheapest check available to perform as it does not require TX-specific ECC ops. - if self.extra != tx.prefix.extra { - return false; - } - - // Also ensure no timelock was set. - if tx.prefix.timelock != Timelock::None { - return false; - } - - // Generate the outputs. This is TX-specific due to uniqueness. - let (_, _, outputs, _) = SignableTransaction::prepare_payments( - &self.r_seed, - &self.inputs, - &mut self.payments.clone(), - uniqueness(&tx.prefix.inputs), - ); - - let rct_type = tx.rct_signatures.rct_type(); - if rct_type != self.protocol.optimal_rct_type() { - return false; - } - - // TODO: Remove this when the following for loop is updated - assert!( - rct_type.compact_encrypted_amounts(), - "created an Eventuality for a very old RctType we don't support proving for" - ); - - for (o, (expected, actual)) in outputs.iter().zip(tx.prefix.outputs.iter()).enumerate() { - // Verify the output, commitment, and encrypted amount. - if (&Output { - amount: None, - key: expected.dest.compress(), - view_tag: Some(expected.view_tag).filter(|_| self.protocol.view_tags()), - } != actual) || - (Some(&expected.commitment.calculate()) != tx.rct_signatures.base.commitments.get(o)) || - (Some(&EncryptedAmount::Compact { amount: expected.amount }) != - tx.rct_signatures.base.encrypted_amounts.get(o)) - { - return false; - } - } - - true - } - - pub fn write(&self, w: &mut W) -> io::Result<()> { - self.protocol.write(w)?; - write_raw_vec(write_byte, self.r_seed.as_ref(), w)?; - write_vec(write_point, &self.inputs, w)?; - - fn write_payment(payment: &InternalPayment, w: &mut W) -> io::Result<()> { - match payment { - InternalPayment::Payment(payment, need_dummy_payment_id) => { - w.write_all(&[0])?; - write_vec(write_byte, payment.0.to_string().as_bytes(), w)?; - w.write_all(&payment.1.to_le_bytes())?; - if *need_dummy_payment_id { - w.write_all(&[1]) - } else { - w.write_all(&[0]) - } - } - InternalPayment::Change(change, change_view) => { - w.write_all(&[1])?; - write_vec(write_byte, change.0.to_string().as_bytes(), w)?; - w.write_all(&change.1.to_le_bytes())?; - if let Some(view) = change_view.as_ref() { - w.write_all(&[1])?; - write_scalar(view, w) - } else { - w.write_all(&[0]) - } - } - } - } - write_vec(write_payment, &self.payments, w)?; - - write_vec(write_byte, &self.extra, w) - } - - pub fn serialize(&self) -> Vec { - let mut buf = Vec::with_capacity(128); - self.write(&mut buf).unwrap(); - buf - } - - pub fn read(r: &mut R) -> io::Result { - fn read_address(r: &mut R) -> io::Result { - String::from_utf8(read_vec(read_byte, r)?) - .ok() - .and_then(|str| MoneroAddress::from_str_raw(&str).ok()) - .ok_or_else(|| io::Error::other("invalid address")) - } - - fn read_payment(r: &mut R) -> io::Result { - Ok(match read_byte(r)? { - 0 => InternalPayment::Payment( - (read_address(r)?, read_u64(r)?), - match read_byte(r)? { - 0 => false, - 1 => true, - _ => Err(io::Error::other("invalid need additional"))?, - }, - ), - 1 => InternalPayment::Change( - (read_address(r)?, read_u64(r)?), - match read_byte(r)? { - 0 => None, - 1 => Some(Zeroizing::new(read_scalar(r)?)), - _ => Err(io::Error::other("invalid change view"))?, - }, - ), - _ => Err(io::Error::other("invalid payment"))?, - }) - } - - Ok(Eventuality { - protocol: Protocol::read(r)?, - r_seed: Zeroizing::new(read_bytes::<_, 32>(r)?), - inputs: read_vec(read_point, r)?, - payments: read_vec(read_payment, r)?, - extra: read_vec(read_byte, r)?, - }) - } -} diff --git a/coins/monero/src/wallet/send/multisig.rs b/coins/monero/src/wallet/send/multisig.rs deleted file mode 100644 index a5be404a2..000000000 --- a/coins/monero/src/wallet/send/multisig.rs +++ /dev/null @@ -1,424 +0,0 @@ -use std_shims::{ - vec::Vec, - io::{self, Read}, - collections::HashMap, -}; -use std::sync::{Arc, RwLock}; - -use zeroize::Zeroizing; - -use rand_core::{RngCore, CryptoRng, SeedableRng}; -use rand_chacha::ChaCha20Rng; - -use group::ff::Field; -use curve25519_dalek::{traits::Identity, scalar::Scalar, edwards::EdwardsPoint}; -use dalek_ff_group as dfg; - -use transcript::{Transcript, RecommendedTranscript}; -use frost::{ - curve::Ed25519, - Participant, FrostError, ThresholdKeys, - dkg::lagrange, - sign::{ - Writable, Preprocess, CachedPreprocess, SignatureShare, PreprocessMachine, SignMachine, - SignatureMachine, AlgorithmMachine, AlgorithmSignMachine, AlgorithmSignatureMachine, - }, -}; - -use crate::{ - random_scalar, - ringct::{ - clsag::{ClsagInput, ClsagDetails, ClsagAddendum, ClsagMultisig}, - RctPrunable, - }, - transaction::{Input, Transaction}, - wallet::{TransactionError, InternalPayment, SignableTransaction, key_image_sort, uniqueness}, -}; - -/// FROST signing machine to produce a signed transaction. -pub struct TransactionMachine { - signable: SignableTransaction, - - i: Participant, - transcript: RecommendedTranscript, - - // Hashed key and scalar offset - key_images: Vec<(EdwardsPoint, Scalar)>, - inputs: Vec>>>, - clsags: Vec>, -} - -pub struct TransactionSignMachine { - signable: SignableTransaction, - - i: Participant, - transcript: RecommendedTranscript, - - key_images: Vec<(EdwardsPoint, Scalar)>, - inputs: Vec>>>, - clsags: Vec>, - - our_preprocess: Vec>, -} - -pub struct TransactionSignatureMachine { - tx: Transaction, - clsags: Vec>, -} - -impl SignableTransaction { - /// Create a FROST signing machine out of this signable transaction. - /// The height is the Monero blockchain height to synchronize around. - pub fn multisig( - self, - keys: &ThresholdKeys, - mut transcript: RecommendedTranscript, - ) -> Result { - let mut inputs = vec![]; - for _ in 0 .. self.inputs.len() { - // Doesn't resize as that will use a single Rc for the entire Vec - inputs.push(Arc::new(RwLock::new(None))); - } - let mut clsags = vec![]; - - // Create a RNG out of the input shared keys, which either requires the view key or being every - // sender, and the payments (address and amount), which a passive adversary may be able to know - // depending on how these transactions are coordinated - // Being every sender would already let you note rings which happen to use your transactions - // multiple times, already breaking privacy there - - transcript.domain_separate(b"monero_transaction"); - - // Also include the spend_key as below only the key offset is included, so this transcripts the - // sum product - // Useful as transcripting the sum product effectively transcripts the key image, further - // guaranteeing the one time properties noted below - transcript.append_message(b"spend_key", keys.group_key().0.compress().to_bytes()); - - if let Some(r_seed) = &self.r_seed { - transcript.append_message(b"r_seed", r_seed); - } - - for (input, decoys) in &self.inputs { - // These outputs can only be spent once. Therefore, it forces all RNGs derived from this - // transcript (such as the one used to create one time keys) to be unique - transcript.append_message(b"input_hash", input.output.absolute.tx); - transcript.append_message(b"input_output_index", [input.output.absolute.o]); - // Not including this, with a doxxed list of payments, would allow brute forcing the inputs - // to determine RNG seeds and therefore the true spends - transcript.append_message(b"input_shared_key", input.key_offset().to_bytes()); - - // Ensure all signers are signing the same rings - transcript.append_message(b"real_spend", [decoys.i]); - for (i, ring_member) in decoys.ring.iter().enumerate() { - transcript - .append_message(b"ring_member", [u8::try_from(i).expect("ring size exceeded 255")]); - transcript.append_message(b"ring_member_offset", decoys.offsets[i].to_le_bytes()); - transcript.append_message(b"ring_member_key", ring_member[0].compress().to_bytes()); - transcript.append_message(b"ring_member_commitment", ring_member[1].compress().to_bytes()); - } - } - - for payment in &self.payments { - match payment { - InternalPayment::Payment(payment, need_dummy_payment_id) => { - transcript.append_message(b"payment_address", payment.0.to_string().as_bytes()); - transcript.append_message(b"payment_amount", payment.1.to_le_bytes()); - transcript.append_message( - b"need_dummy_payment_id", - [if *need_dummy_payment_id { 1u8 } else { 0u8 }], - ); - } - InternalPayment::Change(change, change_view) => { - transcript.append_message(b"change_address", change.0.to_string().as_bytes()); - transcript.append_message(b"change_amount", change.1.to_le_bytes()); - if let Some(view) = change_view.as_ref() { - transcript.append_message(b"change_view_key", Zeroizing::new(view.to_bytes())); - } - } - } - } - - let mut key_images = vec![]; - for (i, (input, _)) in self.inputs.iter().enumerate() { - // Check this the right set of keys - let offset = keys.offset(dfg::Scalar(input.key_offset())); - if offset.group_key().0 != input.key() { - Err(TransactionError::WrongPrivateKey)?; - } - - let clsag = ClsagMultisig::new(transcript.clone(), input.key(), inputs[i].clone()); - key_images.push(( - clsag.H, - keys.current_offset().unwrap_or(dfg::Scalar::ZERO).0 + self.inputs[i].0.key_offset(), - )); - clsags.push(AlgorithmMachine::new(clsag, offset)); - } - - Ok(TransactionMachine { - signable: self, - - i: keys.params().i(), - transcript, - - key_images, - inputs, - clsags, - }) - } -} - -impl PreprocessMachine for TransactionMachine { - type Preprocess = Vec>; - type Signature = Transaction; - type SignMachine = TransactionSignMachine; - - fn preprocess( - mut self, - rng: &mut R, - ) -> (TransactionSignMachine, Self::Preprocess) { - // Iterate over each CLSAG calling preprocess - let mut preprocesses = Vec::with_capacity(self.clsags.len()); - let clsags = self - .clsags - .drain(..) - .map(|clsag| { - let (clsag, preprocess) = clsag.preprocess(rng); - preprocesses.push(preprocess); - clsag - }) - .collect(); - let our_preprocess = preprocesses.clone(); - - // We could add further entropy here, and previous versions of this library did so - // As of right now, the multisig's key, the inputs being spent, and the FROST data itself - // will be used for RNG seeds. In order to recreate these RNG seeds, breaking privacy, - // counterparties must have knowledge of the multisig, either the view key or access to the - // coordination layer, and then access to the actual FROST signing process - // If the commitments are sent in plain text, then entropy here also would be, making it not - // increase privacy. If they're not sent in plain text, or are otherwise inaccessible, they - // already offer sufficient entropy. That's why further entropy is not included - - ( - TransactionSignMachine { - signable: self.signable, - - i: self.i, - transcript: self.transcript, - - key_images: self.key_images, - inputs: self.inputs, - clsags, - - our_preprocess, - }, - preprocesses, - ) - } -} - -impl SignMachine for TransactionSignMachine { - type Params = (); - type Keys = ThresholdKeys; - type Preprocess = Vec>; - type SignatureShare = Vec>; - type SignatureMachine = TransactionSignatureMachine; - - fn cache(self) -> CachedPreprocess { - unimplemented!( - "Monero transactions don't support caching their preprocesses due to {}", - "being already bound to a specific transaction" - ); - } - - fn from_cache( - (): (), - _: ThresholdKeys, - _: CachedPreprocess, - ) -> (Self, Self::Preprocess) { - unimplemented!( - "Monero transactions don't support caching their preprocesses due to {}", - "being already bound to a specific transaction" - ); - } - - fn read_preprocess(&self, reader: &mut R) -> io::Result { - self.clsags.iter().map(|clsag| clsag.read_preprocess(reader)).collect() - } - - fn sign( - mut self, - mut commitments: HashMap, - msg: &[u8], - ) -> Result<(TransactionSignatureMachine, Self::SignatureShare), FrostError> { - if !msg.is_empty() { - panic!("message was passed to the TransactionMachine when it generates its own"); - } - - // Find out who's included - // This may not be a valid set of signers yet the algorithm machine will error if it's not - commitments.remove(&self.i); // Remove, if it was included for some reason - let mut included = commitments.keys().copied().collect::>(); - included.push(self.i); - included.sort_unstable(); - - // Start calculating the key images, as needed on the TX level - let mut images = vec![EdwardsPoint::identity(); self.clsags.len()]; - for (image, (generator, offset)) in images.iter_mut().zip(&self.key_images) { - *image = generator * offset; - } - - // Convert the serialized nonces commitments to a parallelized Vec - let mut commitments = (0 .. self.clsags.len()) - .map(|c| { - included - .iter() - .map(|l| { - // Add all commitments to the transcript for their entropy - // While each CLSAG will do this as they need to for security, they have their own - // transcripts cloned from this TX's initial premise's transcript. For our TX - // transcript to have the CLSAG data for entropy, it'll have to be added ourselves here - self.transcript.append_message(b"participant", (*l).to_bytes()); - - let preprocess = if *l == self.i { - self.our_preprocess[c].clone() - } else { - commitments.get_mut(l).ok_or(FrostError::MissingParticipant(*l))?[c].clone() - }; - - { - let mut buf = vec![]; - preprocess.write(&mut buf).unwrap(); - self.transcript.append_message(b"preprocess", buf); - } - - // While here, calculate the key image - // Clsag will parse/calculate/validate this as needed, yet doing so here as well - // provides the easiest API overall, as this is where the TX is (which needs the key - // images in its message), along with where the outputs are determined (where our - // outputs may need these in order to guarantee uniqueness) - images[c] += preprocess.addendum.key_image.0 * lagrange::(*l, &included).0; - - Ok((*l, preprocess)) - }) - .collect::, _>>() - }) - .collect::, _>>()?; - - // Remove our preprocess which shouldn't be here. It was just the easiest way to implement the - // above - for map in &mut commitments { - map.remove(&self.i); - } - - // Create the actual transaction - let (mut tx, output_masks) = { - let mut sorted_images = images.clone(); - sorted_images.sort_by(key_image_sort); - - self.signable.prepare_transaction( - // Technically, r_seed is used for the transaction keys if it's provided - &mut ChaCha20Rng::from_seed(self.transcript.rng_seed(b"transaction_keys_bulletproofs")), - uniqueness( - &sorted_images - .iter() - .map(|image| Input::ToKey { amount: None, key_offsets: vec![], key_image: *image }) - .collect::>(), - ), - ) - }; - - // Sort the inputs, as expected - let mut sorted = Vec::with_capacity(self.clsags.len()); - while !self.clsags.is_empty() { - let (inputs, decoys) = self.signable.inputs.swap_remove(0); - sorted.push(( - images.swap_remove(0), - inputs, - decoys, - self.inputs.swap_remove(0), - self.clsags.swap_remove(0), - commitments.swap_remove(0), - )); - } - sorted.sort_by(|x, y| key_image_sort(&x.0, &y.0)); - - let mut rng = ChaCha20Rng::from_seed(self.transcript.rng_seed(b"pseudo_out_masks")); - let mut sum_pseudo_outs = Scalar::ZERO; - while !sorted.is_empty() { - let value = sorted.remove(0); - - let mut mask = random_scalar(&mut rng); - if sorted.is_empty() { - mask = output_masks - sum_pseudo_outs; - } else { - sum_pseudo_outs += mask; - } - - tx.prefix.inputs.push(Input::ToKey { - amount: None, - key_offsets: value.2.offsets.clone(), - key_image: value.0, - }); - - *value.3.write().unwrap() = Some(ClsagDetails::new( - ClsagInput::new(value.1.commitment().clone(), value.2).map_err(|_| { - panic!("Signing an input which isn't present in the ring we created for it") - })?, - mask, - )); - - self.clsags.push(value.4); - commitments.push(value.5); - } - - let msg = tx.signature_hash(); - - // Iterate over each CLSAG calling sign - let mut shares = Vec::with_capacity(self.clsags.len()); - let clsags = self - .clsags - .drain(..) - .map(|clsag| { - let (clsag, share) = clsag.sign(commitments.remove(0), &msg)?; - shares.push(share); - Ok(clsag) - }) - .collect::>()?; - - Ok((TransactionSignatureMachine { tx, clsags }, shares)) - } -} - -impl SignatureMachine for TransactionSignatureMachine { - type SignatureShare = Vec>; - - fn read_share(&self, reader: &mut R) -> io::Result { - self.clsags.iter().map(|clsag| clsag.read_share(reader)).collect() - } - - fn complete( - mut self, - shares: HashMap, - ) -> Result { - let mut tx = self.tx; - match tx.rct_signatures.prunable { - RctPrunable::Null => panic!("Signing for RctPrunable::Null"), - RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. } => { - for (c, clsag) in self.clsags.drain(..).enumerate() { - let (clsag, pseudo_out) = clsag.complete( - shares.iter().map(|(l, shares)| (*l, shares[c].clone())).collect::>(), - )?; - clsags.push(clsag); - pseudo_outs.push(pseudo_out); - } - } - RctPrunable::AggregateMlsagBorromean { .. } | - RctPrunable::MlsagBorromean { .. } | - RctPrunable::MlsagBulletproofs { .. } => { - unreachable!("attempted to sign a multisig TX which wasn't CLSAG") - } - } - Ok(tx) - } -} diff --git a/coins/monero/tests/runner.rs b/coins/monero/tests/runner.rs deleted file mode 100644 index 9cef6c21a..000000000 --- a/coins/monero/tests/runner.rs +++ /dev/null @@ -1,326 +0,0 @@ -use core::ops::Deref; -use std_shims::{sync::OnceLock, collections::HashSet}; - -use zeroize::Zeroizing; -use rand_core::OsRng; - -use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar}; - -use tokio::sync::Mutex; - -use monero_serai::{ - random_scalar, - rpc::{HttpRpc, Rpc}, - wallet::{ - ViewPair, Scanner, - address::{Network, AddressType, AddressSpec, AddressMeta, MoneroAddress}, - SpendableOutput, Fee, - }, - transaction::Transaction, - DEFAULT_LOCK_WINDOW, -}; - -pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) { - let spend = random_scalar(&mut OsRng); - let spend_pub = &spend * ED25519_BASEPOINT_TABLE; - let view = Zeroizing::new(random_scalar(&mut OsRng)); - ( - spend, - ViewPair::new(spend_pub, view.clone()), - MoneroAddress { - meta: AddressMeta::new(Network::Mainnet, AddressType::Standard), - spend: spend_pub, - view: view.deref() * ED25519_BASEPOINT_TABLE, - }, - ) -} - -// TODO: Support transactions already on-chain -// TODO: Don't have a side effect of mining blocks more blocks than needed under race conditions -pub async fn mine_until_unlocked(rpc: &Rpc, addr: &str, tx_hash: [u8; 32]) { - // mine until tx is in a block - let mut height = rpc.get_height().await.unwrap(); - let mut found = false; - while !found { - let block = rpc.get_block_by_number(height - 1).await.unwrap(); - found = match block.txs.iter().find(|&&x| x == tx_hash) { - Some(_) => true, - None => { - height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1; - false - } - } - } - - // Mine until tx's outputs are unlocked - let o_indexes: Vec = rpc.get_o_indexes(tx_hash).await.unwrap(); - while rpc - .get_outs(&o_indexes) - .await - .unwrap() - .into_iter() - .all(|o| (!(o.unlocked && height >= (o.height + DEFAULT_LOCK_WINDOW)))) - { - height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1; - } -} - -// Mines 60 blocks and returns an unlocked miner TX output. -#[allow(dead_code)] -pub async fn get_miner_tx_output(rpc: &Rpc, view: &ViewPair) -> SpendableOutput { - let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - - // Mine 60 blocks to unlock a miner TX - let start = rpc.get_height().await.unwrap(); - rpc - .generate_blocks(&view.address(Network::Mainnet, AddressSpec::Standard).to_string(), 60) - .await - .unwrap(); - - let block = rpc.get_block_by_number(start).await.unwrap(); - scanner.scan(rpc, &block).await.unwrap().swap_remove(0).ignore_timelock().swap_remove(0) -} - -/// Make sure the weight and fee match the expected calculation. -pub fn check_weight_and_fee(tx: &Transaction, fee_rate: Fee) { - let fee = tx.rct_signatures.base.fee; - - let weight = tx.weight(); - let expected_weight = fee_rate.calculate_weight_from_fee(fee); - assert_eq!(weight, expected_weight); - - let expected_fee = fee_rate.calculate_fee_from_weight(weight); - assert_eq!(fee, expected_fee); -} - -pub async fn rpc() -> Rpc { - let rpc = HttpRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap(); - - // Only run once - if rpc.get_height().await.unwrap() != 1 { - return rpc; - } - - let addr = MoneroAddress { - meta: AddressMeta::new(Network::Mainnet, AddressType::Standard), - spend: &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE, - view: &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE, - } - .to_string(); - - // Mine 40 blocks to ensure decoy availability - rpc.generate_blocks(&addr, 40).await.unwrap(); - - // Make sure we recognize the protocol - rpc.get_protocol().await.unwrap(); - - rpc -} - -pub static SEQUENTIAL: OnceLock> = OnceLock::new(); - -#[macro_export] -macro_rules! async_sequential { - ($(async fn $name: ident() $body: block)*) => { - $( - #[tokio::test] - async fn $name() { - let guard = runner::SEQUENTIAL.get_or_init(|| tokio::sync::Mutex::new(())).lock().await; - let local = tokio::task::LocalSet::new(); - local.run_until(async move { - if let Err(err) = tokio::task::spawn_local(async move { $body }).await { - drop(guard); - Err(err).unwrap() - } - }).await; - } - )* - } -} - -#[macro_export] -macro_rules! test { - ( - $name: ident, - ( - $first_tx: expr, - $first_checks: expr, - ), - $(( - $tx: expr, - $checks: expr, - )$(,)?),* - ) => { - async_sequential! { - async fn $name() { - use core::{ops::Deref, any::Any}; - use std::collections::HashSet; - #[cfg(feature = "multisig")] - use std::collections::HashMap; - - use zeroize::Zeroizing; - use rand_core::OsRng; - - use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE; - - #[cfg(feature = "multisig")] - use transcript::{Transcript, RecommendedTranscript}; - #[cfg(feature = "multisig")] - use frost::{ - curve::Ed25519, - Participant, - tests::{THRESHOLD, key_gen}, - }; - - use monero_serai::{ - random_scalar, - wallet::{ - address::{Network, AddressSpec}, ViewPair, Scanner, Change, Decoys, FeePriority, - SignableTransaction, SignableTransactionBuilder, - }, - }; - - use runner::{ - random_address, rpc, mine_until_unlocked, get_miner_tx_output, - check_weight_and_fee, - }; - - type Builder = SignableTransactionBuilder; - - // Run each function as both a single signer and as a multisig - #[allow(clippy::redundant_closure_call)] - for multisig in [false, true] { - // Only run the multisig variant if multisig is enabled - if multisig { - #[cfg(not(feature = "multisig"))] - continue; - } - - let spend = Zeroizing::new(random_scalar(&mut OsRng)); - #[cfg(feature = "multisig")] - let keys = key_gen::<_, Ed25519>(&mut OsRng); - - let spend_pub = if !multisig { - spend.deref() * ED25519_BASEPOINT_TABLE - } else { - #[cfg(not(feature = "multisig"))] - panic!("Multisig branch called without the multisig feature"); - #[cfg(feature = "multisig")] - keys[&Participant::new(1).unwrap()].group_key().0 - }; - - let rpc = rpc().await; - - let view = ViewPair::new(spend_pub, Zeroizing::new(random_scalar(&mut OsRng))); - let addr = view.address(Network::Mainnet, AddressSpec::Standard); - - let miner_tx = get_miner_tx_output(&rpc, &view).await; - - let protocol = rpc.get_protocol().await.unwrap(); - - let builder = SignableTransactionBuilder::new( - protocol, - rpc.get_fee(protocol, FeePriority::Unimportant).await.unwrap(), - Change::new( - &ViewPair::new( - &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE, - Zeroizing::new(random_scalar(&mut OsRng)) - ), - false - ), - ); - - let sign = |tx: SignableTransaction| { - let spend = spend.clone(); - #[cfg(feature = "multisig")] - let keys = keys.clone(); - async move { - if !multisig { - tx.sign(&mut OsRng, &spend).unwrap() - } else { - #[cfg(not(feature = "multisig"))] - panic!("Multisig branch called without the multisig feature"); - #[cfg(feature = "multisig")] - { - let mut machines = HashMap::new(); - for i in (1 ..= THRESHOLD).map(|i| Participant::new(i).unwrap()) { - machines.insert( - i, - tx - .clone() - .multisig( - &keys[&i], - RecommendedTranscript::new(b"Monero Serai Test Transaction"), - ) - .unwrap(), - ); - } - - frost::tests::sign_without_caching(&mut OsRng, machines, &[]) - } - } - } - }; - - // TODO: Generate a distinct wallet for each transaction to prevent overlap - let next_addr = addr; - - let temp = Box::new({ - let mut builder = builder.clone(); - - let decoys = Decoys::fingerprintable_canonical_select( - &mut OsRng, - &rpc, - protocol.ring_len(), - rpc.get_height().await.unwrap(), - &[miner_tx.clone()], - ) - .await - .unwrap(); - builder.add_input((miner_tx, decoys.first().unwrap().clone())); - - let (tx, state) = ($first_tx)(rpc.clone(), builder, next_addr).await; - let fee_rate = tx.fee_rate().clone(); - let signed = sign(tx).await; - rpc.publish_transaction(&signed).await.unwrap(); - mine_until_unlocked(&rpc, &random_address().2.to_string(), signed.hash()).await; - let tx = rpc.get_transaction(signed.hash()).await.unwrap(); - check_weight_and_fee(&tx, fee_rate); - let scanner = - Scanner::from_view(view.clone(), Some(HashSet::new())); - ($first_checks)(rpc.clone(), tx, scanner, state).await - }); - #[allow(unused_variables, unused_mut, unused_assignments)] - let mut carried_state: Box = temp; - - $( - let (tx, state) = ($tx)( - protocol, - rpc.clone(), - builder.clone(), - next_addr, - *carried_state.downcast().unwrap() - ).await; - let fee_rate = tx.fee_rate().clone(); - let signed = sign(tx).await; - rpc.publish_transaction(&signed).await.unwrap(); - mine_until_unlocked(&rpc, &random_address().2.to_string(), signed.hash()).await; - let tx = rpc.get_transaction(signed.hash()).await.unwrap(); - if stringify!($name) != "spend_one_input_to_two_outputs_no_change" { - // Skip weight and fee check for the above test because when there is no change, - // the change is added to the fee - check_weight_and_fee(&tx, fee_rate); - } - #[allow(unused_assignments)] - { - let scanner = - Scanner::from_view(view.clone(), Some(HashSet::new())); - carried_state = - Box::new(($checks)(rpc.clone(), tx, scanner, state).await); - } - )* - } - } - } - } -} diff --git a/coins/monero/tests/scan.rs b/coins/monero/tests/scan.rs deleted file mode 100644 index 3e9c9069e..000000000 --- a/coins/monero/tests/scan.rs +++ /dev/null @@ -1,305 +0,0 @@ -use rand::RngCore; - -use monero_serai::{ - transaction::Transaction, - wallet::{address::SubaddressIndex, extra::PaymentId}, -}; - -mod runner; - -test!( - scan_standard_address, - ( - |_, mut builder: Builder, _| async move { - let view = runner::random_address().1; - let scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - builder.add_payment(view.address(Network::Mainnet, AddressSpec::Standard), 5); - (builder.build().unwrap(), scanner) - }, - |_, tx: Transaction, _, mut state: Scanner| async move { - let output = state.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - let dummy_payment_id = PaymentId::Encrypted([0u8; 8]); - assert_eq!(output.metadata.payment_id, Some(dummy_payment_id)); - }, - ), -); - -test!( - scan_subaddress, - ( - |_, mut builder: Builder, _| async move { - let subaddress = SubaddressIndex::new(0, 1).unwrap(); - - let view = runner::random_address().1; - let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - scanner.register_subaddress(subaddress); - - builder.add_payment(view.address(Network::Mainnet, AddressSpec::Subaddress(subaddress)), 5); - (builder.build().unwrap(), (scanner, subaddress)) - }, - |_, tx: Transaction, _, mut state: (Scanner, SubaddressIndex)| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.subaddress, Some(state.1)); - }, - ), -); - -test!( - scan_integrated_address, - ( - |_, mut builder: Builder, _| async move { - let view = runner::random_address().1; - let scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - - let mut payment_id = [0u8; 8]; - OsRng.fill_bytes(&mut payment_id); - - builder.add_payment(view.address(Network::Mainnet, AddressSpec::Integrated(payment_id)), 5); - (builder.build().unwrap(), (scanner, payment_id)) - }, - |_, tx: Transaction, _, mut state: (Scanner, [u8; 8])| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1))); - }, - ), -); - -test!( - scan_featured_standard, - ( - |_, mut builder: Builder, _| async move { - let view = runner::random_address().1; - let scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { subaddress: None, payment_id: None, guaranteed: false }, - ), - 5, - ); - (builder.build().unwrap(), scanner) - }, - |_, tx: Transaction, _, mut state: Scanner| async move { - let output = state.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - }, - ), -); - -test!( - scan_featured_subaddress, - ( - |_, mut builder: Builder, _| async move { - let subaddress = SubaddressIndex::new(0, 2).unwrap(); - - let view = runner::random_address().1; - let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - scanner.register_subaddress(subaddress); - - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { - subaddress: Some(subaddress), - payment_id: None, - guaranteed: false, - }, - ), - 5, - ); - (builder.build().unwrap(), (scanner, subaddress)) - }, - |_, tx: Transaction, _, mut state: (Scanner, SubaddressIndex)| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.subaddress, Some(state.1)); - }, - ), -); - -test!( - scan_featured_integrated, - ( - |_, mut builder: Builder, _| async move { - let view = runner::random_address().1; - let scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - let mut payment_id = [0u8; 8]; - OsRng.fill_bytes(&mut payment_id); - - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { - subaddress: None, - payment_id: Some(payment_id), - guaranteed: false, - }, - ), - 5, - ); - (builder.build().unwrap(), (scanner, payment_id)) - }, - |_, tx: Transaction, _, mut state: (Scanner, [u8; 8])| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1))); - }, - ), -); - -test!( - scan_featured_integrated_subaddress, - ( - |_, mut builder: Builder, _| async move { - let subaddress = SubaddressIndex::new(0, 3).unwrap(); - - let view = runner::random_address().1; - let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - scanner.register_subaddress(subaddress); - - let mut payment_id = [0u8; 8]; - OsRng.fill_bytes(&mut payment_id); - - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { - subaddress: Some(subaddress), - payment_id: Some(payment_id), - guaranteed: false, - }, - ), - 5, - ); - (builder.build().unwrap(), (scanner, payment_id, subaddress)) - }, - |_, tx: Transaction, _, mut state: (Scanner, [u8; 8], SubaddressIndex)| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1))); - assert_eq!(output.metadata.subaddress, Some(state.2)); - }, - ), -); - -test!( - scan_guaranteed_standard, - ( - |_, mut builder: Builder, _| async move { - let view = runner::random_address().1; - let scanner = Scanner::from_view(view.clone(), None); - - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { subaddress: None, payment_id: None, guaranteed: true }, - ), - 5, - ); - (builder.build().unwrap(), scanner) - }, - |_, tx: Transaction, _, mut state: Scanner| async move { - let output = state.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - }, - ), -); - -test!( - scan_guaranteed_subaddress, - ( - |_, mut builder: Builder, _| async move { - let subaddress = SubaddressIndex::new(1, 0).unwrap(); - - let view = runner::random_address().1; - let mut scanner = Scanner::from_view(view.clone(), None); - scanner.register_subaddress(subaddress); - - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { - subaddress: Some(subaddress), - payment_id: None, - guaranteed: true, - }, - ), - 5, - ); - (builder.build().unwrap(), (scanner, subaddress)) - }, - |_, tx: Transaction, _, mut state: (Scanner, SubaddressIndex)| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.subaddress, Some(state.1)); - }, - ), -); - -test!( - scan_guaranteed_integrated, - ( - |_, mut builder: Builder, _| async move { - let view = runner::random_address().1; - let scanner = Scanner::from_view(view.clone(), None); - let mut payment_id = [0u8; 8]; - OsRng.fill_bytes(&mut payment_id); - - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { - subaddress: None, - payment_id: Some(payment_id), - guaranteed: true, - }, - ), - 5, - ); - (builder.build().unwrap(), (scanner, payment_id)) - }, - |_, tx: Transaction, _, mut state: (Scanner, [u8; 8])| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1))); - }, - ), -); - -test!( - scan_guaranteed_integrated_subaddress, - ( - |_, mut builder: Builder, _| async move { - let subaddress = SubaddressIndex::new(1, 1).unwrap(); - - let view = runner::random_address().1; - let mut scanner = Scanner::from_view(view.clone(), None); - scanner.register_subaddress(subaddress); - - let mut payment_id = [0u8; 8]; - OsRng.fill_bytes(&mut payment_id); - - builder.add_payment( - view.address( - Network::Mainnet, - AddressSpec::Featured { - subaddress: Some(subaddress), - payment_id: Some(payment_id), - guaranteed: true, - }, - ), - 5, - ); - (builder.build().unwrap(), (scanner, payment_id, subaddress)) - }, - |_, tx: Transaction, _, mut state: (Scanner, [u8; 8], SubaddressIndex)| async move { - let output = state.0.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(state.1))); - assert_eq!(output.metadata.subaddress, Some(state.2)); - }, - ), -); diff --git a/coins/monero/tests/send.rs b/coins/monero/tests/send.rs deleted file mode 100644 index 4c338eb65..000000000 --- a/coins/monero/tests/send.rs +++ /dev/null @@ -1,316 +0,0 @@ -use rand_core::OsRng; - -use monero_serai::{ - transaction::Transaction, - wallet::{ - extra::Extra, address::SubaddressIndex, ReceivedOutput, SpendableOutput, Decoys, - SignableTransactionBuilder, - }, - rpc::{Rpc, HttpRpc}, - Protocol, -}; - -mod runner; - -// Set up inputs, select decoys, then add them to the TX builder -async fn add_inputs( - protocol: Protocol, - rpc: &Rpc, - outputs: Vec, - builder: &mut SignableTransactionBuilder, -) { - let mut spendable_outputs = Vec::with_capacity(outputs.len()); - for output in outputs { - spendable_outputs.push(SpendableOutput::from(rpc, output).await.unwrap()); - } - - let decoys = Decoys::fingerprintable_canonical_select( - &mut OsRng, - rpc, - protocol.ring_len(), - rpc.get_height().await.unwrap(), - &spendable_outputs, - ) - .await - .unwrap(); - - let inputs = spendable_outputs.into_iter().zip(decoys).collect::>(); - - builder.add_inputs(&inputs); -} - -test!( - spend_miner_output, - ( - |_, mut builder: Builder, addr| async move { - builder.add_payment(addr, 5); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 5); - }, - ), -); - -test!( - spend_multiple_outputs, - ( - |_, mut builder: Builder, addr| async move { - builder.add_payment(addr, 1000000000000); - builder.add_payment(addr, 2000000000000); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut outputs = scanner.scan_transaction(&tx).not_locked(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); - assert_eq!(outputs[0].commitment().amount, 1000000000000); - assert_eq!(outputs[1].commitment().amount, 2000000000000); - outputs - }, - ), - ( - |protocol: Protocol, rpc, mut builder: Builder, addr, outputs: Vec| async move { - add_inputs(protocol, &rpc, outputs, &mut builder).await; - builder.add_payment(addr, 6); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 6); - }, - ), -); - -test!( - // Ideally, this would be single_R, yet it isn't feasible to apply allow(non_snake_case) here - single_r_subaddress_send, - ( - // Consume this builder for an output we can use in the future - // This is needed because we can't get the input from the passed in builder - |_, mut builder: Builder, addr| async move { - builder.add_payment(addr, 1000000000000); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut outputs = scanner.scan_transaction(&tx).not_locked(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); - assert_eq!(outputs[0].commitment().amount, 1000000000000); - outputs - }, - ), - ( - |protocol, rpc: Rpc<_>, _, _, outputs: Vec| async move { - use monero_serai::wallet::FeePriority; - - let change_view = ViewPair::new( - &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE, - Zeroizing::new(random_scalar(&mut OsRng)), - ); - - let mut builder = SignableTransactionBuilder::new( - protocol, - rpc.get_fee(protocol, FeePriority::Unimportant).await.unwrap(), - Change::new(&change_view, false), - ); - add_inputs(protocol, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await; - - // Send to a subaddress - let sub_view = ViewPair::new( - &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE, - Zeroizing::new(random_scalar(&mut OsRng)), - ); - builder.add_payment( - sub_view - .address(Network::Mainnet, AddressSpec::Subaddress(SubaddressIndex::new(0, 1).unwrap())), - 1, - ); - (builder.build().unwrap(), (change_view, sub_view)) - }, - |_, tx: Transaction, _, views: (ViewPair, ViewPair)| async move { - // Make sure the change can pick up its output - let mut change_scanner = Scanner::from_view(views.0, Some(HashSet::new())); - assert!(change_scanner.scan_transaction(&tx).not_locked().len() == 1); - - // Make sure the subaddress can pick up its output - let mut sub_scanner = Scanner::from_view(views.1, Some(HashSet::new())); - sub_scanner.register_subaddress(SubaddressIndex::new(0, 1).unwrap()); - let sub_outputs = sub_scanner.scan_transaction(&tx).not_locked(); - assert!(sub_outputs.len() == 1); - assert_eq!(sub_outputs[0].commitment().amount, 1); - - // Make sure only one R was included in TX extra - assert!(Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref()) - .unwrap() - .keys() - .unwrap() - .1 - .is_none()); - }, - ), -); - -test!( - spend_one_input_to_one_output_plus_change, - ( - |_, mut builder: Builder, addr| async move { - builder.add_payment(addr, 2000000000000); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut outputs = scanner.scan_transaction(&tx).not_locked(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); - assert_eq!(outputs[0].commitment().amount, 2000000000000); - outputs - }, - ), - ( - |protocol: Protocol, rpc, mut builder: Builder, addr, outputs: Vec| async move { - add_inputs(protocol, &rpc, outputs, &mut builder).await; - builder.add_payment(addr, 2); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); - assert_eq!(output.commitment().amount, 2); - }, - ), -); - -test!( - spend_max_outputs, - ( - |_, mut builder: Builder, addr| async move { - builder.add_payment(addr, 1000000000000); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut outputs = scanner.scan_transaction(&tx).not_locked(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); - assert_eq!(outputs[0].commitment().amount, 1000000000000); - outputs - }, - ), - ( - |protocol: Protocol, rpc, mut builder: Builder, addr, outputs: Vec| async move { - add_inputs(protocol, &rpc, outputs, &mut builder).await; - - for i in 0 .. 15 { - builder.add_payment(addr, i + 1); - } - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut scanned_tx = scanner.scan_transaction(&tx).not_locked(); - - let mut output_amounts = HashSet::new(); - for i in 0 .. 15 { - output_amounts.insert(i + 1); - } - for _ in 0 .. 15 { - let output = scanned_tx.swap_remove(0); - let amount = output.commitment().amount; - assert!(output_amounts.contains(&amount)); - output_amounts.remove(&amount); - } - }, - ), -); - -test!( - spend_max_outputs_to_subaddresses, - ( - |_, mut builder: Builder, addr| async move { - builder.add_payment(addr, 1000000000000); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut outputs = scanner.scan_transaction(&tx).not_locked(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); - assert_eq!(outputs[0].commitment().amount, 1000000000000); - outputs - }, - ), - ( - |protocol: Protocol, rpc, mut builder: Builder, _, outputs: Vec| async move { - add_inputs(protocol, &rpc, outputs, &mut builder).await; - - let view = runner::random_address().1; - let mut scanner = Scanner::from_view(view.clone(), Some(HashSet::new())); - - let mut subaddresses = vec![]; - for i in 0 .. 15 { - let subaddress = SubaddressIndex::new(0, i + 1).unwrap(); - scanner.register_subaddress(subaddress); - - builder.add_payment( - view.address(Network::Mainnet, AddressSpec::Subaddress(subaddress)), - u64::from(i + 1), - ); - subaddresses.push(subaddress); - } - - (builder.build().unwrap(), (scanner, subaddresses)) - }, - |_, tx: Transaction, _, mut state: (Scanner, Vec)| async move { - use std::collections::HashMap; - - let mut scanned_tx = state.0.scan_transaction(&tx).not_locked(); - - let mut output_amounts_by_subaddress = HashMap::new(); - for i in 0 .. 15 { - output_amounts_by_subaddress.insert(u64::try_from(i + 1).unwrap(), state.1[i]); - } - for _ in 0 .. 15 { - let output = scanned_tx.swap_remove(0); - let amount = output.commitment().amount; - - assert!(output_amounts_by_subaddress.contains_key(&amount)); - assert_eq!(output.metadata.subaddress, Some(output_amounts_by_subaddress[&amount])); - - output_amounts_by_subaddress.remove(&amount); - } - }, - ), -); - -test!( - spend_one_input_to_two_outputs_no_change, - ( - |_, mut builder: Builder, addr| async move { - builder.add_payment(addr, 1000000000000); - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut outputs = scanner.scan_transaction(&tx).not_locked(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); - assert_eq!(outputs[0].commitment().amount, 1000000000000); - outputs - }, - ), - ( - |protocol, rpc: Rpc<_>, _, addr, outputs: Vec| async move { - use monero_serai::wallet::FeePriority; - - let mut builder = SignableTransactionBuilder::new( - protocol, - rpc.get_fee(protocol, FeePriority::Unimportant).await.unwrap(), - Change::fingerprintable(None), - ); - add_inputs(protocol, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await; - builder.add_payment(addr, 10000); - builder.add_payment(addr, 50000); - - (builder.build().unwrap(), ()) - }, - |_, tx: Transaction, mut scanner: Scanner, ()| async move { - let mut outputs = scanner.scan_transaction(&tx).not_locked(); - outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); - assert_eq!(outputs[0].commitment().amount, 10000); - assert_eq!(outputs[1].commitment().amount, 50000); - - // The remainder should get shunted to fee, which is fingerprintable - assert_eq!(tx.rct_signatures.base.fee, 1000000000000 - 10000 - 50000); - }, - ), -); diff --git a/coins/monero/tests/tests.rs b/coins/monero/tests/tests.rs new file mode 100644 index 000000000..7b6656f26 --- /dev/null +++ b/coins/monero/tests/tests.rs @@ -0,0 +1,3 @@ +// TODO +#[test] +fn test() {} diff --git a/coins/monero/verify-chain/Cargo.toml b/coins/monero/verify-chain/Cargo.toml new file mode 100644 index 000000000..ce2f28612 --- /dev/null +++ b/coins/monero/verify-chain/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "monero-serai-verify-chain" +version = "0.1.0" +description = "A binary to deserialize and verify the Monero blockchain" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/verify-chain" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" +publish = false + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +rand_core = { version = "0.6", default-features = false, features = ["std"] } + +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +hex = { version = "0.4", default-features = false, features = ["std"] } +serde = { version = "1", default-features = false, features = ["derive", "alloc", "std"] } +serde_json = { version = "1", default-features = false, features = ["alloc", "std"] } + +monero-serai = { path = "..", default-features = false, features = ["std", "compile-time-generators"] } +monero-rpc = { path = "../rpc", default-features = false, features = ["std"] } +monero-simple-request-rpc = { path = "../rpc/simple-request", default-features = false } + +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] } diff --git a/coins/monero/verify-chain/LICENSE b/coins/monero/verify-chain/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/verify-chain/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/verify-chain/README.md b/coins/monero/verify-chain/README.md new file mode 100644 index 000000000..4348b2c16 --- /dev/null +++ b/coins/monero/verify-chain/README.md @@ -0,0 +1,7 @@ +# monero-serai Verify Chain + +A binary to deserialize and verify the Monero blockchain. + +This is not complete. This is not intended to be complete. This is intended to +test monero-serai against actual blockchain data. Do not use this as an +inflation checker. diff --git a/coins/monero/verify-chain/src/main.rs b/coins/monero/verify-chain/src/main.rs new file mode 100644 index 000000000..845396067 --- /dev/null +++ b/coins/monero/verify-chain/src/main.rs @@ -0,0 +1,317 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] + +use curve25519_dalek::{scalar::Scalar, edwards::EdwardsPoint}; + +use serde::Deserialize; +use serde_json::json; + +use monero_serai::{ + io::decompress_point, + primitives::Commitment, + ringct::{RctPrunable, bulletproofs::BatchVerifier}, + transaction::{Input, Transaction}, + block::Block, +}; + +use monero_rpc::{RpcError, Rpc}; +use monero_simple_request_rpc::SimpleRequestRpc; + +use tokio::task::JoinHandle; + +async fn check_block(rpc: impl Rpc, block_i: usize) { + let hash = loop { + match rpc.get_block_hash(block_i).await { + Ok(hash) => break hash, + Err(RpcError::ConnectionError(e)) => { + println!("get_block_hash ConnectionError: {e}"); + continue; + } + Err(e) => panic!("couldn't get block {block_i}'s hash: {e:?}"), + } + }; + + // TODO: Grab the JSON to also check it was deserialized correctly + #[derive(Deserialize, Debug)] + struct BlockResponse { + blob: String, + } + let res: BlockResponse = loop { + match rpc.json_rpc_call("get_block", Some(json!({ "hash": hex::encode(hash) }))).await { + Ok(res) => break res, + Err(RpcError::ConnectionError(e)) => { + println!("get_block ConnectionError: {e}"); + continue; + } + Err(e) => panic!("couldn't get block {block_i} via block.hash(): {e:?}"), + } + }; + + let blob = hex::decode(res.blob).expect("node returned non-hex block"); + let block = Block::read(&mut blob.as_slice()) + .unwrap_or_else(|e| panic!("couldn't deserialize block {block_i}: {e}")); + assert_eq!(block.hash(), hash, "hash differs"); + assert_eq!(block.serialize(), blob, "serialization differs"); + + let txs_len = 1 + block.transactions.len(); + + if !block.transactions.is_empty() { + #[derive(Deserialize, Debug)] + struct TransactionResponse { + tx_hash: String, + as_hex: String, + } + #[derive(Deserialize, Debug)] + struct TransactionsResponse { + #[serde(default)] + missed_tx: Vec, + txs: Vec, + } + + let mut hashes_hex = block.transactions.iter().map(hex::encode).collect::>(); + let mut all_txs = vec![]; + while !hashes_hex.is_empty() { + let txs: TransactionsResponse = loop { + match rpc + .rpc_call( + "get_transactions", + Some(json!({ + "txs_hashes": hashes_hex.drain(.. hashes_hex.len().min(100)).collect::>(), + })), + ) + .await + { + Ok(txs) => break txs, + Err(RpcError::ConnectionError(e)) => { + println!("get_transactions ConnectionError: {e}"); + continue; + } + Err(e) => panic!("couldn't call get_transactions: {e:?}"), + } + }; + assert!(txs.missed_tx.is_empty()); + all_txs.extend(txs.txs); + } + + let mut batch = BatchVerifier::new(); + for (tx_hash, tx_res) in block.transactions.into_iter().zip(all_txs) { + assert_eq!( + tx_res.tx_hash, + hex::encode(tx_hash), + "node returned a transaction with different hash" + ); + + let tx = Transaction::read( + &mut hex::decode(&tx_res.as_hex).expect("node returned non-hex transaction").as_slice(), + ) + .expect("couldn't deserialize transaction"); + + assert_eq!( + hex::encode(tx.serialize()), + tx_res.as_hex, + "Transaction serialization was different" + ); + assert_eq!(tx.hash(), tx_hash, "Transaction hash was different"); + + match tx { + Transaction::V1 { prefix: _, signatures } => { + assert!(!signatures.is_empty()); + continue; + } + Transaction::V2 { prefix: _, proofs: None } => { + panic!("proofs were empty in non-miner v2 transaction"); + } + Transaction::V2 { ref prefix, proofs: Some(ref proofs) } => { + let sig_hash = tx.signature_hash().expect("no signature hash for TX with proofs"); + // Verify all proofs we support proving for + // This is due to having debug_asserts calling verify within their proving, and CLSAG + // multisig explicitly calling verify as part of its signing process + // Accordingly, making sure our signature_hash algorithm is correct is great, and further + // making sure the verification functions are valid is appreciated + match &proofs.prunable { + RctPrunable::AggregateMlsagBorromean { .. } | RctPrunable::MlsagBorromean { .. } => {} + RctPrunable::MlsagBulletproofs { bulletproof, .. } | + RctPrunable::MlsagBulletproofsCompactAmount { bulletproof, .. } => { + assert!(bulletproof.batch_verify( + &mut rand_core::OsRng, + &mut batch, + &proofs.base.commitments + )); + } + RctPrunable::Clsag { bulletproof, clsags, pseudo_outs } => { + assert!(bulletproof.batch_verify( + &mut rand_core::OsRng, + &mut batch, + &proofs.base.commitments + )); + + for (i, clsag) in clsags.iter().enumerate() { + let (amount, key_offsets, image) = match &prefix.inputs[i] { + Input::Gen(_) => panic!("Input::Gen"), + Input::ToKey { amount, key_offsets, key_image } => { + (amount, key_offsets, key_image) + } + }; + + let mut running_sum = 0; + let mut actual_indexes = vec![]; + for offset in key_offsets { + running_sum += offset; + actual_indexes.push(running_sum); + } + + async fn get_outs( + rpc: &impl Rpc, + amount: u64, + indexes: &[u64], + ) -> Vec<[EdwardsPoint; 2]> { + #[derive(Deserialize, Debug)] + struct Out { + key: String, + mask: String, + } + + #[derive(Deserialize, Debug)] + struct Outs { + outs: Vec, + } + + let outs: Outs = loop { + match rpc + .rpc_call( + "get_outs", + Some(json!({ + "get_txid": true, + "outputs": indexes.iter().map(|o| json!({ + "amount": amount, + "index": o + })).collect::>() + })), + ) + .await + { + Ok(outs) => break outs, + Err(RpcError::ConnectionError(e)) => { + println!("get_outs ConnectionError: {e}"); + continue; + } + Err(e) => panic!("couldn't connect to RPC to get outs: {e:?}"), + } + }; + + let rpc_point = |point: &str| { + decompress_point( + hex::decode(point) + .expect("invalid hex for ring member") + .try_into() + .expect("invalid point len for ring member"), + ) + .expect("invalid point for ring member") + }; + + outs + .outs + .iter() + .map(|out| { + let mask = rpc_point(&out.mask); + if amount != 0 { + assert_eq!(mask, Commitment::new(Scalar::from(1u8), amount).calculate()); + } + [rpc_point(&out.key), mask] + }) + .collect() + } + + clsag + .verify( + &get_outs(&rpc, amount.unwrap_or(0), &actual_indexes).await, + image, + &pseudo_outs[i], + &sig_hash, + ) + .unwrap(); + } + } + } + } + } + } + assert!(batch.verify()); + } + + println!("Deserialized, hashed, and reserialized {block_i} with {txs_len} TXs"); +} + +#[tokio::main] +async fn main() { + let args = std::env::args().collect::>(); + + // Read start block as the first arg + let mut block_i = + args.get(1).expect("no start block specified").parse::().expect("invalid start block"); + + // How many blocks to work on at once + let async_parallelism: usize = + args.get(2).unwrap_or(&"8".to_string()).parse::().expect("invalid parallelism argument"); + + // Read further args as RPC URLs + let default_nodes = vec![ + // "http://xmr-node.cakewallet.com:18081".to_string(), + "http://node.tools.rino.io:18081".to_string(), + // "https://node.sethforprivacy.com".to_string(), + ]; + let mut specified_nodes = vec![]; + { + let mut i = 0; + loop { + let Some(node) = args.get(3 + i) else { break }; + specified_nodes.push(node.clone()); + i += 1; + } + } + let nodes = if specified_nodes.is_empty() { default_nodes } else { specified_nodes }; + + let rpc = |url: String| async move { + SimpleRequestRpc::new(url.clone()) + .await + .unwrap_or_else(|_| panic!("couldn't create SimpleRequestRpc connected to {url}")) + }; + let main_rpc = rpc(nodes[0].clone()).await; + let mut rpcs = vec![]; + for i in 0 .. async_parallelism { + rpcs.push(rpc(nodes[i % nodes.len()].clone()).await); + } + + let mut rpc_i = 0; + let mut handles: Vec> = vec![]; + let mut height = 0; + loop { + let new_height = main_rpc.get_height().await.expect("couldn't call get_height"); + if new_height == height { + break; + } + height = new_height; + + while block_i < height { + if handles.len() >= async_parallelism { + // Guarantee one handle is complete + handles.swap_remove(0).await.unwrap(); + + // Remove all of the finished handles + let mut i = 0; + while i < handles.len() { + if handles[i].is_finished() { + handles.swap_remove(i).await.unwrap(); + continue; + } + i += 1; + } + } + + handles.push(tokio::spawn(check_block(rpcs[rpc_i].clone(), block_i))); + rpc_i = (rpc_i + 1) % rpcs.len(); + block_i += 1; + } + } +} diff --git a/coins/monero/wallet/Cargo.toml b/coins/monero/wallet/Cargo.toml new file mode 100644 index 000000000..4e3088862 --- /dev/null +++ b/coins/monero/wallet/Cargo.toml @@ -0,0 +1,76 @@ +[package] +name = "monero-wallet" +version = "0.1.0" +description = "Wallet functionality for the Monero protocol, built around monero-serai" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../common/std-shims", version = "^0.1.1", default-features = false } + +async-trait = { version = "0.1", default-features = false } +thiserror = { version = "1", default-features = false, optional = true } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } + +rand_core = { version = "0.6", default-features = false } +# Used to send transactions +rand = { version = "0.8", default-features = false } +rand_chacha = { version = "0.3", default-features = false } +# Used to select decoys +rand_distr = { version = "0.4", default-features = false } + +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize", "group"] } + +# Multisig dependencies +transcript = { package = "flexible-transcript", path = "../../../crypto/transcript", version = "0.3", default-features = false, features = ["recommended"], optional = true } +group = { version = "0.13", default-features = false, optional = true } +dalek-ff-group = { path = "../../../crypto/dalek-ff-group", version = "0.4", default-features = false, optional = true } +frost = { package = "modular-frost", path = "../../../crypto/frost", default-features = false, features = ["ed25519"], optional = true } + +hex = { version = "0.4", default-features = false, features = ["alloc"] } + +monero-serai = { path = "..", default-features = false } +monero-rpc = { path = "../rpc", default-features = false } +monero-address = { path = "./address", default-features = false } + +[dev-dependencies] +serde = { version = "1", default-features = false, features = ["derive", "alloc", "std"] } +serde_json = { version = "1", default-features = false, features = ["alloc", "std"] } + +frost = { package = "modular-frost", path = "../../../crypto/frost", default-features = false, features = ["ed25519", "tests"] } + +tokio = { version = "1", features = ["sync", "macros"] } + +monero-simple-request-rpc = { path = "../rpc/simple-request", default-features = false } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "zeroize/std", + + "rand_core/std", + "rand/std", + "rand_chacha/std", + "rand_distr/std", + + "monero-serai/std", + "monero-rpc/std", + "monero-address/std", +] +compile-time-generators = ["curve25519-dalek/precomputed-tables", "monero-serai/compile-time-generators"] +multisig = ["transcript", "group", "dalek-ff-group", "frost", "monero-serai/multisig", "std"] +default = ["std", "compile-time-generators"] diff --git a/coins/monero/wallet/LICENSE b/coins/monero/wallet/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/wallet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/wallet/README.md b/coins/monero/wallet/README.md new file mode 100644 index 000000000..d88a56d94 --- /dev/null +++ b/coins/monero/wallet/README.md @@ -0,0 +1,58 @@ +# Monero Wallet + +Wallet functionality for the Monero protocol, built around monero-serai. This +library prides itself on resolving common pit falls developers may face. + +monero-wallet also offers a FROST-inspired multisignature protocol orders of +magnitude more performant than Monero's own. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Features + +- Scanning Monero transactions +- Sending Monero transactions +- Sending Monero transactions with a FROST-inspired threshold multisignature + protocol, orders of magnitude more performant than Monero's own + +### Caveats + +This library DOES attempt to do the following: + +- Create on-chain transactions identical to how wallet2 would (unless told not + to) +- Not be detectable as monero-serai when scanning outputs +- Not reveal spent outputs to the connected RPC node + +This library DOES NOT attempt to do the following: + +- Have identical RPC behavior when creating transactions +- Be a wallet + +This means that monero-serai shouldn't be fingerprintable on-chain. It also +shouldn't be fingerprintable if a targeted attack occurs to detect if the +receiving wallet is monero-serai or wallet2. It also should be generally safe +for usage with remote nodes. + +It won't hide from remote nodes it's monero-serai however, potentially +allowing a remote node to profile you. The implications of this are left to the +user to consider. + +It also won't act as a wallet, just as a wallet functionality library. wallet2 +has several *non-transaction-level* policies, such as always attempting to use +two inputs to create transactions. These are considered out of scope to +monero-serai. + +Finally, this library only supports producing transactions with CLSAG +signatures. That means this library cannot spend non-RingCT outputs. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). +- `compile-time-generators` (on by default): Derives the generators at + compile-time so they don't need to be derived at runtime. This is recommended + if program size doesn't need to be kept minimal. +- `multisig`: Adds support for creation of transactions using a threshold + multisignature wallet. diff --git a/coins/monero/wallet/address/Cargo.toml b/coins/monero/wallet/address/Cargo.toml new file mode 100644 index 000000000..bb5baee0b --- /dev/null +++ b/coins/monero/wallet/address/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "monero-address" +version = "0.1.0" +description = "Rust implementation of Monero addresses" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet/address" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +thiserror = { version = "1", default-features = false, optional = true } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } + +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +monero-io = { path = "../../io", default-features = false } +monero-primitives = { path = "../../primitives", default-features = false } + +[dev-dependencies] +rand_core = { version = "0.6", default-features = false, features = ["std"] } + +hex-literal = { version = "0.4", default-features = false } +hex = { version = "0.4", default-features = false, features = ["alloc"] } + +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "zeroize/std", + + "monero-io/std", +] +default = ["std"] diff --git a/coins/monero/wallet/address/LICENSE b/coins/monero/wallet/address/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/wallet/address/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/wallet/address/README.md b/coins/monero/wallet/address/README.md new file mode 100644 index 000000000..8fe3b77d4 --- /dev/null +++ b/coins/monero/wallet/address/README.md @@ -0,0 +1,11 @@ +# Monero Address + +Rust implementation of Monero addresses. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/wallet/address/src/base58check.rs b/coins/monero/wallet/address/src/base58check.rs new file mode 100644 index 000000000..003f21f1b --- /dev/null +++ b/coins/monero/wallet/address/src/base58check.rs @@ -0,0 +1,106 @@ +use std_shims::{vec::Vec, string::String}; + +use monero_primitives::keccak256; + +const ALPHABET_LEN: u64 = 58; +const ALPHABET: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +pub(crate) const BLOCK_LEN: usize = 8; +const ENCODED_BLOCK_LEN: usize = 11; + +const CHECKSUM_LEN: usize = 4; + +// The maximum possible length of an encoding of this many bytes +// +// This is used for determining padding/how many bytes an encoding actually uses +pub(crate) fn encoded_len_for_bytes(bytes: usize) -> usize { + let bits = u64::try_from(bytes).expect("length exceeded 2**64") * 8; + let mut max = if bits == 64 { u64::MAX } else { (1 << bits) - 1 }; + + let mut i = 0; + while max != 0 { + max /= ALPHABET_LEN; + i += 1; + } + i +} + +// Encode an arbitrary-length stream of data +pub(crate) fn encode(bytes: &[u8]) -> String { + let mut res = String::with_capacity(bytes.len().div_ceil(BLOCK_LEN) * ENCODED_BLOCK_LEN); + + for chunk in bytes.chunks(BLOCK_LEN) { + // Convert to a u64 + let mut fixed_len_chunk = [0; BLOCK_LEN]; + fixed_len_chunk[(BLOCK_LEN - chunk.len()) ..].copy_from_slice(chunk); + let mut val = u64::from_be_bytes(fixed_len_chunk); + + // Convert to the base58 encoding + let mut chunk_str = [char::from(ALPHABET[0]); ENCODED_BLOCK_LEN]; + let mut i = 0; + while val > 0 { + chunk_str[i] = ALPHABET[usize::try_from(val % ALPHABET_LEN) + .expect("ALPHABET_LEN exceeds usize despite being a usize")] + .into(); + i += 1; + val /= ALPHABET_LEN; + } + + // Only take used bytes, and since we put the LSBs in the first byte, reverse the byte order + for c in chunk_str.into_iter().take(encoded_len_for_bytes(chunk.len())).rev() { + res.push(c); + } + } + + res +} + +// Decode an arbitrary-length stream of data +pub(crate) fn decode(data: &str) -> Option> { + let mut res = Vec::with_capacity((data.len() / ENCODED_BLOCK_LEN) * BLOCK_LEN); + + for chunk in data.as_bytes().chunks(ENCODED_BLOCK_LEN) { + // Convert the chunk back to a u64 + let mut sum = 0u64; + for this_char in chunk { + sum = sum.checked_mul(ALPHABET_LEN)?; + sum += u64::try_from(ALPHABET.iter().position(|a| a == this_char)?) + .expect("alphabet len exceeded 2**64"); + } + + // From the size of the encoding, determine the size of the bytes + let mut used_bytes = None; + for i in 1 ..= BLOCK_LEN { + if encoded_len_for_bytes(i) == chunk.len() { + used_bytes = Some(i); + break; + } + } + // Only push on the used bytes + res.extend(&sum.to_be_bytes()[(BLOCK_LEN - used_bytes.unwrap()) ..]); + } + + Some(res) +} + +// Encode an arbitrary-length stream of data, with a checksum +pub(crate) fn encode_check(mut data: Vec) -> String { + let checksum = keccak256(&data); + data.extend(&checksum[.. CHECKSUM_LEN]); + encode(&data) +} + +// Decode an arbitrary-length stream of data, with a checksum +pub(crate) fn decode_check(data: &str) -> Option> { + if data.len() < CHECKSUM_LEN { + None?; + } + + let mut res = decode(data)?; + let checksum_pos = res.len() - CHECKSUM_LEN; + if keccak256(&res[.. checksum_pos])[.. CHECKSUM_LEN] != res[checksum_pos ..] { + None?; + } + res.truncate(checksum_pos); + Some(res) +} diff --git a/coins/monero/wallet/address/src/lib.rs b/coins/monero/wallet/address/src/lib.rs new file mode 100644 index 000000000..96cbee199 --- /dev/null +++ b/coins/monero/wallet/address/src/lib.rs @@ -0,0 +1,502 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +use core::fmt::{self, Write}; +use std_shims::{ + vec, + string::{String, ToString}, +}; + +use zeroize::Zeroize; + +use curve25519_dalek::EdwardsPoint; + +use monero_io::*; + +mod base58check; +use base58check::{encode_check, decode_check}; + +#[cfg(test)] +mod tests; + +/// The address type. +/// +/// The officially specified addresses are supported, along with +/// [Featured Addresses](https://gist.github.com/kayabaNerve/01c50bbc35441e0bbdcee63a9d823789). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] +pub enum AddressType { + /// A legacy address type. + Legacy, + /// A legacy address with a payment ID embedded. + LegacyIntegrated([u8; 8]), + /// A subaddress. + /// + /// This is what SHOULD be used if specific functionality isn't needed. + Subaddress, + /// A featured address. + /// + /// Featured Addresses are an unofficial address specification which is meant to be extensible + /// and support a variety of functionality. This functionality includes being a subaddresses AND + /// having a payment ID, along with being immune to the burning bug. + /// + /// At this time, support for featured addresses is limited to this crate. There should be no + /// expectation of interoperability. + Featured { + /// If this address is a subaddress. + subaddress: bool, + /// The payment ID associated with this address. + payment_id: Option<[u8; 8]>, + /// If this address is guaranteed. + /// + /// A guaranteed address is one where any outputs scanned to it are guaranteed to be spendable + /// under the hardness of various cryptographic problems (which are assumed hard). This is via + /// a modified shared-key derivation which eliminates the burning bug. + guaranteed: bool, + }, +} + +impl AddressType { + /// If this address is a subaddress. + pub fn is_subaddress(&self) -> bool { + matches!(self, AddressType::Subaddress) || + matches!(self, AddressType::Featured { subaddress: true, .. }) + } + + /// The payment ID within this address. + // TODO: wallet-core PaymentId? TX extra crate imported here? + pub fn payment_id(&self) -> Option<[u8; 8]> { + if let AddressType::LegacyIntegrated(id) = self { + Some(*id) + } else if let AddressType::Featured { payment_id, .. } = self { + *payment_id + } else { + None + } + } + + /// If this address is guaranteed. + /// + /// A guaranteed address is one where any outputs scanned to it are guaranteed to be spendable + /// under the hardness of various cryptographic problems (which are assumed hard). This is via + /// a modified shared-key derivation which eliminates the burning bug. + pub fn is_guaranteed(&self) -> bool { + matches!(self, AddressType::Featured { guaranteed: true, .. }) + } +} + +/// A subaddress index. +/// +/// Subaddresses are derived from a root using a `(account, address)` tuple as an index. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] +pub struct SubaddressIndex { + account: u32, + address: u32, +} + +impl SubaddressIndex { + /// Create a new SubaddressIndex. + pub const fn new(account: u32, address: u32) -> Option { + if (account == 0) && (address == 0) { + return None; + } + Some(SubaddressIndex { account, address }) + } + + /// Get the account this subaddress index is under. + pub const fn account(&self) -> u32 { + self.account + } + + /// Get the address this subaddress index is for, within its account. + pub const fn address(&self) -> u32 { + self.address + } +} + +/// Bytes used as prefixes when encoding addresses. +/// +/// These distinguish the address's type. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] +pub struct AddressBytes { + legacy: u8, + legacy_integrated: u8, + subaddress: u8, + featured: u8, +} + +impl AddressBytes { + /// Create a new set of address bytes, one for each address type. + pub const fn new( + legacy: u8, + legacy_integrated: u8, + subaddress: u8, + featured: u8, + ) -> Option { + if (legacy == legacy_integrated) || (legacy == subaddress) || (legacy == featured) { + return None; + } + if (legacy_integrated == subaddress) || (legacy_integrated == featured) { + return None; + } + if subaddress == featured { + return None; + } + Some(AddressBytes { legacy, legacy_integrated, subaddress, featured }) + } + + const fn to_const_generic(self) -> u32 { + ((self.legacy as u32) << 24) + + ((self.legacy_integrated as u32) << 16) + + ((self.subaddress as u32) << 8) + + (self.featured as u32) + } + + #[allow(clippy::cast_possible_truncation)] + const fn from_const_generic(const_generic: u32) -> Self { + let legacy = (const_generic >> 24) as u8; + let legacy_integrated = ((const_generic >> 16) & (u8::MAX as u32)) as u8; + let subaddress = ((const_generic >> 8) & (u8::MAX as u32)) as u8; + let featured = (const_generic & (u8::MAX as u32)) as u8; + + AddressBytes { legacy, legacy_integrated, subaddress, featured } + } +} + +// TODO: Cite origin +const MONERO_MAINNET_BYTES: AddressBytes = match AddressBytes::new(18, 19, 42, 70) { + Some(bytes) => bytes, + None => panic!("mainnet byte constants conflicted"), +}; +const MONERO_STAGENET_BYTES: AddressBytes = match AddressBytes::new(24, 25, 36, 86) { + Some(bytes) => bytes, + None => panic!("stagenet byte constants conflicted"), +}; +const MONERO_TESTNET_BYTES: AddressBytes = match AddressBytes::new(53, 54, 63, 111) { + Some(bytes) => bytes, + None => panic!("testnet byte constants conflicted"), +}; + +/// The network this address is for. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] +pub enum Network { + /// A mainnet address. + Mainnet, + /// A stagenet address. + /// + /// Stagenet maintains parity with mainnet and is useful for testing integrations accordingly. + Stagenet, + /// A testnet address. + /// + /// Testnet is used to test new consensus rules and functionality. + Testnet, +} + +/// Errors when decoding an address. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum AddressError { + /// The address had an invalid (network, type) byte. + #[cfg_attr(feature = "std", error("invalid byte for the address's network/type ({0})"))] + InvalidTypeByte(u8), + /// The address wasn't a valid Base58Check (as defined by Monero) string. + #[cfg_attr(feature = "std", error("invalid address encoding"))] + InvalidEncoding, + /// The data encoded wasn't the proper length. + #[cfg_attr(feature = "std", error("invalid length"))] + InvalidLength, + /// The address had an invalid key. + #[cfg_attr(feature = "std", error("invalid key"))] + InvalidKey, + /// The address was featured with unrecognized features. + #[cfg_attr(feature = "std", error("unknown features"))] + UnknownFeatures(u64), + /// The network was for a different network than expected. + #[cfg_attr( + feature = "std", + error("different network ({actual:?}) than expected ({expected:?})") + )] + DifferentNetwork { + /// The Network expected. + expected: Network, + /// The Network embedded within the Address. + actual: Network, + }, + /// The view key was of small order despite being in a guaranteed address. + #[cfg_attr(feature = "std", error("small-order view key in guaranteed address"))] + SmallOrderView, +} + +/// Bytes used as prefixes when encoding addresses, variable to the network instance. +/// +/// These distinguish the address's network and type. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] +pub struct NetworkedAddressBytes { + mainnet: AddressBytes, + stagenet: AddressBytes, + testnet: AddressBytes, +} + +impl NetworkedAddressBytes { + /// Create a new set of address bytes, one for each network. + pub const fn new( + mainnet: AddressBytes, + stagenet: AddressBytes, + testnet: AddressBytes, + ) -> Option { + let res = NetworkedAddressBytes { mainnet, stagenet, testnet }; + let all_bytes = res.to_const_generic(); + + let mut i = 0; + while i < 12 { + let this_byte = (all_bytes >> (32 + (i * 8))) & (u8::MAX as u128); + + let mut j = 0; + while j < 12 { + if i == j { + j += 1; + continue; + } + let other_byte = (all_bytes >> (32 + (j * 8))) & (u8::MAX as u128); + if this_byte == other_byte { + return None; + } + + j += 1; + } + + i += 1; + } + + Some(res) + } + + /// Convert this set of address bytes to its representation as a u128. + /// + /// We cannot use this struct directly as a const generic unfortunately. + pub const fn to_const_generic(self) -> u128 { + ((self.mainnet.to_const_generic() as u128) << 96) + + ((self.stagenet.to_const_generic() as u128) << 64) + + ((self.testnet.to_const_generic() as u128) << 32) + } + + #[allow(clippy::cast_possible_truncation)] + const fn from_const_generic(const_generic: u128) -> Self { + let mainnet = AddressBytes::from_const_generic((const_generic >> 96) as u32); + let stagenet = + AddressBytes::from_const_generic(((const_generic >> 64) & (u32::MAX as u128)) as u32); + let testnet = + AddressBytes::from_const_generic(((const_generic >> 32) & (u32::MAX as u128)) as u32); + + NetworkedAddressBytes { mainnet, stagenet, testnet } + } + + fn network(&self, network: Network) -> &AddressBytes { + match network { + Network::Mainnet => &self.mainnet, + Network::Stagenet => &self.stagenet, + Network::Testnet => &self.testnet, + } + } + + fn byte(&self, network: Network, kind: AddressType) -> u8 { + let address_bytes = self.network(network); + + match kind { + AddressType::Legacy => address_bytes.legacy, + AddressType::LegacyIntegrated(_) => address_bytes.legacy_integrated, + AddressType::Subaddress => address_bytes.subaddress, + AddressType::Featured { .. } => address_bytes.featured, + } + } + + // This will return an incomplete AddressType for LegacyIntegrated/Featured. + fn metadata_from_byte(&self, byte: u8) -> Result<(Network, AddressType), AddressError> { + let mut meta = None; + for network in [Network::Mainnet, Network::Testnet, Network::Stagenet] { + let address_bytes = self.network(network); + if let Some(kind) = match byte { + _ if byte == address_bytes.legacy => Some(AddressType::Legacy), + _ if byte == address_bytes.legacy_integrated => Some(AddressType::LegacyIntegrated([0; 8])), + _ if byte == address_bytes.subaddress => Some(AddressType::Subaddress), + _ if byte == address_bytes.featured => { + Some(AddressType::Featured { subaddress: false, payment_id: None, guaranteed: false }) + } + _ => None, + } { + meta = Some((network, kind)); + break; + } + } + + meta.ok_or(AddressError::InvalidTypeByte(byte)) + } +} + +/// The bytes used for distinguishing Monero addresses. +pub const MONERO_BYTES: NetworkedAddressBytes = match NetworkedAddressBytes::new( + MONERO_MAINNET_BYTES, + MONERO_STAGENET_BYTES, + MONERO_TESTNET_BYTES, +) { + Some(bytes) => bytes, + None => panic!("Monero network byte constants conflicted"), +}; + +/// A Monero address. +#[derive(Clone, Copy, PartialEq, Eq, Zeroize)] +pub struct Address { + network: Network, + kind: AddressType, + spend: EdwardsPoint, + view: EdwardsPoint, +} + +impl fmt::Debug for Address { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + let hex = |bytes: &[u8]| -> String { + let mut res = String::with_capacity(2 + (2 * bytes.len())); + res.push_str("0x"); + for b in bytes { + write!(&mut res, "{b:02x}").unwrap(); + } + res + }; + + fmt + .debug_struct("Address") + .field("network", &self.network) + .field("kind", &self.kind) + .field("spend", &hex(&self.spend.compress().to_bytes())) + .field("view", &hex(&self.view.compress().to_bytes())) + // This is not a real field yet is the most valuable thing to know when debugging + .field("(address)", &self.to_string()) + .finish() + } +} + +impl fmt::Display for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let address_bytes: NetworkedAddressBytes = + NetworkedAddressBytes::from_const_generic(ADDRESS_BYTES); + + let mut data = vec![address_bytes.byte(self.network, self.kind)]; + data.extend(self.spend.compress().to_bytes()); + data.extend(self.view.compress().to_bytes()); + if let AddressType::Featured { subaddress, payment_id, guaranteed } = self.kind { + let features_uint = + (u8::from(guaranteed) << 2) + (u8::from(payment_id.is_some()) << 1) + u8::from(subaddress); + write_varint(&features_uint, &mut data).unwrap(); + } + if let Some(id) = self.kind.payment_id() { + data.extend(id); + } + write!(f, "{}", encode_check(data)) + } +} + +impl Address { + /// Create a new address. + pub fn new(network: Network, kind: AddressType, spend: EdwardsPoint, view: EdwardsPoint) -> Self { + Address { network, kind, spend, view } + } + + /// Parse an address from a String, accepting any network it is. + pub fn from_str_with_unchecked_network(s: &str) -> Result { + let raw = decode_check(s).ok_or(AddressError::InvalidEncoding)?; + let mut raw = raw.as_slice(); + + let address_bytes: NetworkedAddressBytes = + NetworkedAddressBytes::from_const_generic(ADDRESS_BYTES); + let (network, mut kind) = address_bytes + .metadata_from_byte(read_byte(&mut raw).map_err(|_| AddressError::InvalidLength)?)?; + let spend = read_point(&mut raw).map_err(|_| AddressError::InvalidKey)?; + let view = read_point(&mut raw).map_err(|_| AddressError::InvalidKey)?; + + if matches!(kind, AddressType::Featured { .. }) { + let features = read_varint::<_, u64>(&mut raw).map_err(|_| AddressError::InvalidLength)?; + if (features >> 3) != 0 { + Err(AddressError::UnknownFeatures(features))?; + } + + let subaddress = (features & 1) == 1; + let integrated = ((features >> 1) & 1) == 1; + let guaranteed = ((features >> 2) & 1) == 1; + + kind = + AddressType::Featured { subaddress, payment_id: integrated.then_some([0; 8]), guaranteed }; + } + + // Read the payment ID, if there should be one + match kind { + AddressType::LegacyIntegrated(ref mut id) | + AddressType::Featured { payment_id: Some(ref mut id), .. } => { + *id = read_bytes(&mut raw).map_err(|_| AddressError::InvalidLength)?; + } + _ => {} + }; + + if !raw.is_empty() { + Err(AddressError::InvalidLength)?; + } + + Ok(Address { network, kind, spend, view }) + } + + /// Create a new address from a `&str`. + /// + /// This takes in an argument for the expected network, erroring if a distinct network was used. + /// It also errors if the address is invalid (as expected). + pub fn from_str(network: Network, s: &str) -> Result { + Self::from_str_with_unchecked_network(s).and_then(|addr| { + if addr.network == network { + Ok(addr) + } else { + Err(AddressError::DifferentNetwork { actual: addr.network, expected: network })? + } + }) + } + + /// The network this address is intended for use on. + pub fn network(&self) -> Network { + self.network + } + + /// The type of address this is. + pub fn kind(&self) -> &AddressType { + &self.kind + } + + /// If this is a subaddress. + pub fn is_subaddress(&self) -> bool { + self.kind.is_subaddress() + } + + /// The payment ID for this address. + pub fn payment_id(&self) -> Option<[u8; 8]> { + self.kind.payment_id() + } + + /// If this address is guaranteed. + /// + /// A guaranteed address is one where any outputs scanned to it are guaranteed to be spendable + /// under the hardness of various cryptographic problems (which are assumed hard). This is via + /// a modified shared-key derivation which eliminates the burning bug. + pub fn is_guaranteed(&self) -> bool { + self.kind.is_guaranteed() + } + + /// The public spend key for this address. + pub fn spend(&self) -> EdwardsPoint { + self.spend + } + + /// The public view key for this address. + pub fn view(&self) -> EdwardsPoint { + self.view + } +} + +/// Instantiation of the Address type with Monero's network bytes. +pub type MoneroAddress = Address<{ MONERO_BYTES.to_const_generic() }>; diff --git a/coins/monero/src/tests/address.rs b/coins/monero/wallet/address/src/tests.rs similarity index 65% rename from coins/monero/src/tests/address.rs rename to coins/monero/wallet/address/src/tests.rs index e26901e48..2804832ab 100644 --- a/coins/monero/src/tests/address.rs +++ b/coins/monero/wallet/address/src/tests.rs @@ -2,14 +2,11 @@ use hex_literal::hex; use rand_core::{RngCore, OsRng}; -use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE; +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar}; -use monero_generators::decompress_point; +use monero_io::decompress_point; -use crate::{ - random_scalar, - wallet::address::{Network, AddressType, AddressMeta, MoneroAddress}, -}; +use crate::{Network, AddressType, MoneroAddress}; const SPEND: [u8; 32] = hex!("f8631661f6ab4e6fda310c797330d86e23a682f20d5bc8cc27b18051191f16d7"); const VIEW: [u8; 32] = hex!("4a1535063ad1fee2dabbf909d4fd9a873e29541b401f0944754e17c9a41820ce"); @@ -30,14 +27,49 @@ const SUBADDRESS: &str = const FEATURED_JSON: &str = include_str!("vectors/featured_addresses.json"); +#[test] +fn test_encoded_len_for_bytes() { + // For an encoding of length `l`, we prune to the amount of bytes which encodes with length `l` + // This assumes length `l` -> amount of bytes has a singular answer, which is tested here + use crate::base58check::*; + let mut set = std::collections::HashSet::new(); + for i in 0 .. BLOCK_LEN { + set.insert(encoded_len_for_bytes(i)); + } + assert_eq!(set.len(), BLOCK_LEN); +} + +#[test] +fn base58check() { + use crate::base58check::*; + + assert_eq!(encode(&[]), String::new()); + assert!(decode("").unwrap().is_empty()); + + let full_block = &[1, 2, 3, 4, 5, 6, 7, 8]; + assert_eq!(&decode(&encode(full_block)).unwrap(), full_block); + + let partial_block = &[1, 2, 3]; + assert_eq!(&decode(&encode(partial_block)).unwrap(), partial_block); + + let max_encoded_block = &[u8::MAX; 8]; + assert_eq!(&decode(&encode(max_encoded_block)).unwrap(), max_encoded_block); + + let max_decoded_block = "zzzzzzzzzzz"; + assert!(decode(max_decoded_block).is_none()); + + let full_and_partial_block = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + assert_eq!(&decode(&encode(full_and_partial_block)).unwrap(), full_and_partial_block); +} + #[test] fn standard_address() { let addr = MoneroAddress::from_str(Network::Mainnet, STANDARD).unwrap(); - assert_eq!(addr.meta.network, Network::Mainnet); - assert_eq!(addr.meta.kind, AddressType::Standard); - assert!(!addr.meta.kind.is_subaddress()); - assert_eq!(addr.meta.kind.payment_id(), None); - assert!(!addr.meta.kind.is_guaranteed()); + assert_eq!(addr.network(), Network::Mainnet); + assert_eq!(addr.kind(), &AddressType::Legacy); + assert!(!addr.is_subaddress()); + assert_eq!(addr.payment_id(), None); + assert!(!addr.is_guaranteed()); assert_eq!(addr.spend.compress().to_bytes(), SPEND); assert_eq!(addr.view.compress().to_bytes(), VIEW); assert_eq!(addr.to_string(), STANDARD); @@ -46,11 +78,11 @@ fn standard_address() { #[test] fn integrated_address() { let addr = MoneroAddress::from_str(Network::Mainnet, INTEGRATED).unwrap(); - assert_eq!(addr.meta.network, Network::Mainnet); - assert_eq!(addr.meta.kind, AddressType::Integrated(PAYMENT_ID)); - assert!(!addr.meta.kind.is_subaddress()); - assert_eq!(addr.meta.kind.payment_id(), Some(PAYMENT_ID)); - assert!(!addr.meta.kind.is_guaranteed()); + assert_eq!(addr.network(), Network::Mainnet); + assert_eq!(addr.kind(), &AddressType::LegacyIntegrated(PAYMENT_ID)); + assert!(!addr.is_subaddress()); + assert_eq!(addr.payment_id(), Some(PAYMENT_ID)); + assert!(!addr.is_guaranteed()); assert_eq!(addr.spend.compress().to_bytes(), SPEND); assert_eq!(addr.view.compress().to_bytes(), VIEW); assert_eq!(addr.to_string(), INTEGRATED); @@ -59,11 +91,11 @@ fn integrated_address() { #[test] fn subaddress() { let addr = MoneroAddress::from_str(Network::Mainnet, SUBADDRESS).unwrap(); - assert_eq!(addr.meta.network, Network::Mainnet); - assert_eq!(addr.meta.kind, AddressType::Subaddress); - assert!(addr.meta.kind.is_subaddress()); - assert_eq!(addr.meta.kind.payment_id(), None); - assert!(!addr.meta.kind.is_guaranteed()); + assert_eq!(addr.network(), Network::Mainnet); + assert_eq!(addr.kind(), &AddressType::Subaddress); + assert!(addr.is_subaddress()); + assert_eq!(addr.payment_id(), None); + assert!(!addr.is_guaranteed()); assert_eq!(addr.spend.compress().to_bytes(), SUB_SPEND); assert_eq!(addr.view.compress().to_bytes(), SUB_VIEW); assert_eq!(addr.to_string(), SUBADDRESS); @@ -75,8 +107,8 @@ fn featured() { [(Network::Mainnet, 'C'), (Network::Testnet, 'K'), (Network::Stagenet, 'F')] { for _ in 0 .. 100 { - let spend = &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE; - let view = &random_scalar(&mut OsRng) * ED25519_BASEPOINT_TABLE; + let spend = &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE; + let view = &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE; for features in 0 .. (1 << 3) { const SUBADDRESS_FEATURE_BIT: u8 = 1; @@ -93,8 +125,7 @@ fn featured() { let guaranteed = (features & GUARANTEED_FEATURE_BIT) == GUARANTEED_FEATURE_BIT; let kind = AddressType::Featured { subaddress, payment_id, guaranteed }; - let meta = AddressMeta::new(network, kind); - let addr = MoneroAddress::new(meta, spend, view); + let addr = MoneroAddress::new(network, kind, spend, view); assert_eq!(addr.to_string().chars().next().unwrap(), first); assert_eq!(MoneroAddress::from_str(network, &addr.to_string()).unwrap(), addr); @@ -158,14 +189,12 @@ fn featured_vectors() { assert_eq!( MoneroAddress::new( - AddressMeta::new( - network, - AddressType::Featured { - subaddress: vector.subaddress, - payment_id: vector.payment_id, - guaranteed: vector.guaranteed - } - ), + network, + AddressType::Featured { + subaddress: vector.subaddress, + payment_id: vector.payment_id, + guaranteed: vector.guaranteed + }, spend, view ) diff --git a/coins/monero/src/tests/vectors/featured_addresses.json b/coins/monero/wallet/address/src/vectors/featured_addresses.json similarity index 100% rename from coins/monero/src/tests/vectors/featured_addresses.json rename to coins/monero/wallet/address/src/vectors/featured_addresses.json diff --git a/coins/monero/wallet/polyseed/Cargo.toml b/coins/monero/wallet/polyseed/Cargo.toml new file mode 100644 index 000000000..a4434353b --- /dev/null +++ b/coins/monero/wallet/polyseed/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "polyseed" +version = "0.1.0" +description = "Rust implementation of Polyseed" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet/polyseed" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +thiserror = { version = "1", default-features = false, optional = true } + +subtle = { version = "^2.4", default-features = false } +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } +rand_core = { version = "0.6", default-features = false } + +sha3 = { version = "0.10", default-features = false } +pbkdf2 = { version = "0.12", features = ["simple"], default-features = false } + +[dev-dependencies] +hex = { version = "0.4", default-features = false, features = ["std"] } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "subtle/std", + "zeroize/std", + "rand_core/std", + + "sha3/std", + "pbkdf2/std", +] +default = ["std"] diff --git a/coins/monero/wallet/polyseed/LICENSE b/coins/monero/wallet/polyseed/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/wallet/polyseed/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/wallet/polyseed/README.md b/coins/monero/wallet/polyseed/README.md new file mode 100644 index 000000000..4869bba05 --- /dev/null +++ b/coins/monero/wallet/polyseed/README.md @@ -0,0 +1,11 @@ +# Polyseed + +Rust implementation of [Polyseed](https://github.com/tevador/polyseed). + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/src/wallet/seed/polyseed.rs b/coins/monero/wallet/polyseed/src/lib.rs similarity index 79% rename from coins/monero/src/wallet/seed/polyseed.rs rename to coins/monero/wallet/polyseed/src/lib.rs index cecdef9e7..8c4b502f3 100644 --- a/coins/monero/src/wallet/seed/polyseed.rs +++ b/coins/monero/wallet/polyseed/src/lib.rs @@ -1,5 +1,10 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + use core::fmt; -use std_shims::{sync::OnceLock, vec::Vec, string::String, collections::HashMap}; +use std_shims::{sync::OnceLock, string::String, collections::HashMap}; #[cfg(feature = "std")] use std::time::{SystemTime, UNIX_EPOCH}; @@ -10,7 +15,8 @@ use rand_core::{RngCore, CryptoRng}; use sha3::Sha3_256; use pbkdf2::pbkdf2_hmac; -use super::SeedError; +#[cfg(test)] +mod tests; // Features const FEATURE_BITS: u8 = 5; @@ -34,7 +40,7 @@ fn polyseed_features_supported(features: u8) -> bool { const DATE_BITS: u8 = 10; const DATE_MASK: u16 = (1u16 << DATE_BITS) - 1; const POLYSEED_EPOCH: u64 = 1635768000; // 1st November 2021 12:00 UTC -pub(crate) const TIME_STEP: u64 = 2629746; // 30.436875 days = 1/12 of the Gregorian year +const TIME_STEP: u64 = 2629746; // 30.436875 days = 1/12 of the Gregorian year // After ~85 years, this will roll over. fn birthday_encode(time: u64) -> u16 { @@ -60,8 +66,8 @@ const LAST_BYTE_SECRET_BITS_MASK: u8 = ((1 << (BITS_PER_BYTE - CLEAR_BITS)) - 1) const SECRET_BITS_PER_WORD: usize = 10; -// Amount of words in a seed -pub(crate) const POLYSEED_LENGTH: usize = 16; +// The amount of words in a seed. +const POLYSEED_LENGTH: usize = 16; // Amount of characters each word must have if trimmed pub(crate) const PREFIX_LEN: usize = 4; @@ -98,30 +104,58 @@ const POLYSEED_KEYGEN_ITERATIONS: u32 = 10000; // See: https://github.com/tevador/polyseed/blob/master/include/polyseed.h#L58 const COIN: u16 = 0; +/// An error when working with a Polyseed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum PolyseedError { + /// The seed was invalid. + #[cfg_attr(feature = "std", error("invalid seed"))] + InvalidSeed, + /// The entropy was invalid. + #[cfg_attr(feature = "std", error("invalid entropy"))] + InvalidEntropy, + /// The checksum did not match the data. + #[cfg_attr(feature = "std", error("invalid checksum"))] + InvalidChecksum, + /// Unsupported feature bits were set. + #[cfg_attr(feature = "std", error("unsupported features"))] + UnsupportedFeatures, +} + /// Language options for Polyseed. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Zeroize)] pub enum Language { + /// English language option. English, + /// Spanish language option. Spanish, + /// French language option. French, + /// Italian language option. Italian, + /// Japanese language option. Japanese, + /// Korean language option. Korean, + /// Czech language option. Czech, + /// Portuguese language option. Portuguese, + /// Simplified Chinese language option. ChineseSimplified, + /// Traditional Chinese language option. ChineseTraditional, } struct WordList { - words: Vec, + words: &'static [&'static str], has_prefix: bool, has_accent: bool, } impl WordList { - fn new(words: &str, has_prefix: bool, has_accent: bool) -> WordList { - let res = WordList { words: serde_json::from_str(words).unwrap(), has_prefix, has_accent }; + fn new(words: &'static [&'static str], has_prefix: bool, has_accent: bool) -> WordList { + let res = WordList { words, has_prefix, has_accent }; // This is needed for a later unwrap to not fails assert!(words.len() < usize::from(u16::MAX)); res @@ -133,26 +167,27 @@ static LANGUAGES_CELL: OnceLock> = OnceLock::new(); fn LANGUAGES() -> &'static HashMap { LANGUAGES_CELL.get_or_init(|| { HashMap::from([ - (Language::Czech, WordList::new(include_str!("./polyseed/cs.json"), true, false)), - (Language::French, WordList::new(include_str!("./polyseed/fr.json"), true, true)), - (Language::Korean, WordList::new(include_str!("./polyseed/ko.json"), false, false)), - (Language::English, WordList::new(include_str!("./polyseed/en.json"), true, false)), - (Language::Italian, WordList::new(include_str!("./polyseed/it.json"), true, false)), - (Language::Spanish, WordList::new(include_str!("./polyseed/es.json"), true, true)), - (Language::Japanese, WordList::new(include_str!("./polyseed/ja.json"), false, false)), - (Language::Portuguese, WordList::new(include_str!("./polyseed/pt.json"), true, false)), + (Language::Czech, WordList::new(include!("./words/cs.rs"), true, false)), + (Language::French, WordList::new(include!("./words/fr.rs"), true, true)), + (Language::Korean, WordList::new(include!("./words/ko.rs"), false, false)), + (Language::English, WordList::new(include!("./words/en.rs"), true, false)), + (Language::Italian, WordList::new(include!("./words/it.rs"), true, false)), + (Language::Spanish, WordList::new(include!("./words/es.rs"), true, true)), + (Language::Japanese, WordList::new(include!("./words/ja.rs"), false, false)), + (Language::Portuguese, WordList::new(include!("./words/pt.rs"), true, false)), ( Language::ChineseSimplified, - WordList::new(include_str!("./polyseed/zh_simplified.json"), false, false), + WordList::new(include!("./words/zh_simplified.rs"), false, false), ), ( Language::ChineseTraditional, - WordList::new(include_str!("./polyseed/zh_traditional.json"), false, false), + WordList::new(include!("./words/zh_traditional.rs"), false, false), ), ]) }) } +/// A Polyseed. #[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] pub struct Polyseed { language: Language, @@ -222,13 +257,13 @@ impl Polyseed { masked_features: u8, encoded_birthday: u16, entropy: Zeroizing<[u8; 32]>, - ) -> Result { + ) -> Result { if !polyseed_features_supported(masked_features) { - Err(SeedError::UnsupportedFeatures)?; + Err(PolyseedError::UnsupportedFeatures)?; } if !valid_entropy(&entropy) { - Err(SeedError::InvalidEntropy)?; + Err(PolyseedError::InvalidEntropy)?; } let mut res = Polyseed { @@ -244,23 +279,24 @@ impl Polyseed { /// Create a new `Polyseed` with specific internals. /// - /// `birthday` is defined in seconds since the Unix epoch. + /// `birthday` is defined in seconds since the epoch. pub fn from( language: Language, features: u8, birthday: u64, entropy: Zeroizing<[u8; 32]>, - ) -> Result { + ) -> Result { Self::from_internal(language, user_features(features), birthday_encode(birthday), entropy) } /// Create a new `Polyseed`. /// - /// This uses the system's time for the birthday, if available. + /// This uses the system's time for the birthday, if available, else 0. pub fn new(rng: &mut R, language: Language) -> Polyseed { // Get the birthday #[cfg(feature = "std")] - let birthday = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let birthday = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(core::time::Duration::ZERO).as_secs(); #[cfg(not(feature = "std"))] let birthday = 0; @@ -275,7 +311,7 @@ impl Polyseed { /// Create a new `Polyseed` from a String. #[allow(clippy::needless_pass_by_value)] - pub fn from_string(lang: Language, seed: Zeroizing) -> Result { + pub fn from_string(lang: Language, seed: Zeroizing) -> Result { // Decode the seed into its polynomial coefficients let mut poly = [0; POLYSEED_LENGTH]; @@ -325,7 +361,7 @@ impl Polyseed { } else { check_if_matches(lang_word_list.has_prefix, lang_word_list.words.iter(), word) }) else { - Err(SeedError::InvalidSeed)? + Err(PolyseedError::InvalidSeed)? }; // WordList asserts the word list length is less than u16::MAX @@ -337,7 +373,7 @@ impl Polyseed { // Validate the checksum if poly_eval(&poly) != 0 { - Err(SeedError::InvalidChecksum)?; + Err(PolyseedError::InvalidChecksum)?; } // Convert the polynomial into entropy @@ -416,6 +452,7 @@ impl Polyseed { key } + /// The String representation of this seed. pub fn to_string(&self) -> Zeroizing { // Encode the polynomial with the existing checksum let mut poly = self.to_poly(); @@ -428,7 +465,7 @@ impl Polyseed { let mut seed = Zeroizing::new(String::new()); let words = &LANGUAGES()[&self.language].words; for i in 0 .. poly.len() { - seed.push_str(&words[usize::from(poly[i])]); + seed.push_str(words[usize::from(poly[i])]); if i < poly.len() - 1 { seed.push(' '); } diff --git a/coins/monero/wallet/polyseed/src/tests.rs b/coins/monero/wallet/polyseed/src/tests.rs new file mode 100644 index 000000000..4913c2173 --- /dev/null +++ b/coins/monero/wallet/polyseed/src/tests.rs @@ -0,0 +1,218 @@ +use zeroize::Zeroizing; +use rand_core::OsRng; + +use crate::*; + +#[test] +fn test_polyseed() { + struct Vector { + language: Language, + seed: String, + entropy: String, + birthday: u64, + has_prefix: bool, + has_accent: bool, + } + + let vectors = [ + Vector { + language: Language::English, + seed: "raven tail swear infant grief assist regular lamp \ + duck valid someone little harsh puppy airport language" + .into(), + entropy: "dd76e7359a0ded37cd0ff0f3c829a5ae01673300000000000000000000000000".into(), + birthday: 1638446400, + has_prefix: true, + has_accent: false, + }, + Vector { + language: Language::Spanish, + seed: "eje fin parte célebre tabú pestaña lienzo puma \ + prisión hora regalo lengua existir lápiz lote sonoro" + .into(), + entropy: "5a2b02df7db21fcbe6ec6df137d54c7b20fd2b00000000000000000000000000".into(), + birthday: 3118651200, + has_prefix: true, + has_accent: true, + }, + Vector { + language: Language::French, + seed: "valable arracher décaler jeudi amusant dresser mener épaissir risible \ + prouesse réserve ampleur ajuster muter caméra enchère" + .into(), + entropy: "11cfd870324b26657342c37360c424a14a050b00000000000000000000000000".into(), + birthday: 1679314966, + has_prefix: true, + has_accent: true, + }, + Vector { + language: Language::Italian, + seed: "caduco midollo copione meninge isotopo illogico riflesso tartaruga fermento \ + olandese normale tristezza episodio voragine forbito achille" + .into(), + entropy: "7ecc57c9b4652d4e31428f62bec91cfd55500600000000000000000000000000".into(), + birthday: 1679316358, + has_prefix: true, + has_accent: false, + }, + Vector { + language: Language::Portuguese, + seed: "caverna custear azedo adeus senador apertada sedoso omitir \ + sujeito aurora videira molho cartaz gesso dentista tapar" + .into(), + entropy: "45473063711376cae38f1b3eba18c874124e1d00000000000000000000000000".into(), + birthday: 1679316657, + has_prefix: true, + has_accent: false, + }, + Vector { + language: Language::Czech, + seed: "usmrtit nora dotaz komunita zavalit funkce mzda sotva akce \ + vesta kabel herna stodola uvolnit ustrnout email" + .into(), + entropy: "7ac8a4efd62d9c3c4c02e350d32326df37821c00000000000000000000000000".into(), + birthday: 1679316898, + has_prefix: true, + has_accent: false, + }, + Vector { + language: Language::Korean, + seed: "전망 선풍기 국제 무궁화 설사 기름 이론적 해안 절망 예선 \ + 지우개 보관 절망 말기 시각 귀신" + .into(), + entropy: "684663fda420298f42ed94b2c512ed38ddf12b00000000000000000000000000".into(), + birthday: 1679317073, + has_prefix: false, + has_accent: false, + }, + Vector { + language: Language::Japanese, + seed: "うちあわせ ちつじょ つごう しはい けんこう とおる てみやげ はんとし たんとう \ + といれ おさない おさえる むかう ぬぐう なふだ せまる" + .into(), + entropy: "94e6665518a6286c6e3ba508a2279eb62b771f00000000000000000000000000".into(), + birthday: 1679318722, + has_prefix: false, + has_accent: false, + }, + Vector { + language: Language::ChineseTraditional, + seed: "亂 挖 斤 柄 代 圈 枝 轄 魯 論 函 開 勘 番 榮 壁".into(), + entropy: "b1594f585987ab0fd5a31da1f0d377dae5283f00000000000000000000000000".into(), + birthday: 1679426433, + has_prefix: false, + has_accent: false, + }, + Vector { + language: Language::ChineseSimplified, + seed: "啊 百 族 府 票 划 伪 仓 叶 虾 借 溜 晨 左 等 鬼".into(), + entropy: "21cdd366f337b89b8d1bc1df9fe73047c22b0300000000000000000000000000".into(), + birthday: 1679426817, + has_prefix: false, + has_accent: false, + }, + // The following seed requires the language specification in order to calculate + // a single valid checksum + Vector { + language: Language::Spanish, + seed: "impo sort usua cabi venu nobl oliv clim \ + cont barr marc auto prod vaca torn fati" + .into(), + entropy: "dbfce25fe09b68a340e01c62417eeef43ad51800000000000000000000000000".into(), + birthday: 1701511650, + has_prefix: true, + has_accent: true, + }, + ]; + + for vector in vectors { + let add_whitespace = |mut seed: String| { + seed.push(' '); + seed + }; + + let seed_without_accents = |seed: &str| { + seed + .split_whitespace() + .map(|w| w.chars().filter(char::is_ascii).collect::()) + .collect::>() + .join(" ") + }; + + let trim_seed = |seed: &str| { + let seed_to_trim = + if vector.has_accent { seed_without_accents(seed) } else { seed.to_string() }; + seed_to_trim + .split_whitespace() + .map(|w| { + let mut ascii = 0; + let mut to_take = w.len(); + for (i, char) in w.chars().enumerate() { + if char.is_ascii() { + ascii += 1; + } + if ascii == PREFIX_LEN { + // +1 to include this character, which put us at the prefix length + to_take = i + 1; + break; + } + } + w.chars().take(to_take).collect::() + }) + .collect::>() + .join(" ") + }; + + // String -> Seed + println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); + let seed = Polyseed::from_string(vector.language, Zeroizing::new(vector.seed.clone())).unwrap(); + let trim = trim_seed(&vector.seed); + let add_whitespace = add_whitespace(vector.seed.clone()); + let seed_without_accents = seed_without_accents(&vector.seed); + + // Make sure a version with added whitespace still works + let whitespaced_seed = + Polyseed::from_string(vector.language, Zeroizing::new(add_whitespace)).unwrap(); + assert_eq!(seed, whitespaced_seed); + // Check trimmed versions works + if vector.has_prefix { + let trimmed_seed = Polyseed::from_string(vector.language, Zeroizing::new(trim)).unwrap(); + assert_eq!(seed, trimmed_seed); + } + // Check versions without accents work + if vector.has_accent { + let seed_without_accents = + Polyseed::from_string(vector.language, Zeroizing::new(seed_without_accents)).unwrap(); + assert_eq!(seed, seed_without_accents); + } + + let entropy = Zeroizing::new(hex::decode(vector.entropy).unwrap().try_into().unwrap()); + assert_eq!(*seed.entropy(), entropy); + assert!(seed.birthday().abs_diff(vector.birthday) < TIME_STEP); + + // Entropy -> Seed + let from_entropy = Polyseed::from(vector.language, 0, seed.birthday(), entropy).unwrap(); + assert_eq!(seed.to_string(), from_entropy.to_string()); + + // Check against ourselves + { + let seed = Polyseed::new(&mut OsRng, vector.language); + println!("{}. seed: {}", line!(), *seed.to_string()); + assert_eq!(seed, Polyseed::from_string(vector.language, seed.to_string()).unwrap()); + assert_eq!( + seed, + Polyseed::from(vector.language, 0, seed.birthday(), seed.entropy().clone(),).unwrap() + ); + } + } +} + +#[test] +fn test_invalid_polyseed() { + // This seed includes unsupported features bits and should error on decode + let seed = "include domain claim resemble urban hire lunch bird \ + crucial fire best wife ring warm ignore model" + .into(); + let res = Polyseed::from_string(Language::English, Zeroizing::new(seed)); + assert_eq!(res, Err(PolyseedError::UnsupportedFeatures)); +} diff --git a/coins/monero/src/wallet/seed/polyseed/cs.json b/coins/monero/wallet/polyseed/src/words/cs.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/cs.json rename to coins/monero/wallet/polyseed/src/words/cs.rs index a1c466cd7..aab4b8d46 100644 --- a/coins/monero/src/wallet/seed/polyseed/cs.json +++ b/coins/monero/wallet/polyseed/src/words/cs.rs @@ -1,4 +1,4 @@ -[ +&[ "abdikace", "abeceda", "adresa", @@ -2047,4 +2047,4 @@ "zvrat", "zvukovod", "zvyk" -] \ No newline at end of file +] diff --git a/coins/monero/src/wallet/seed/polyseed/en.json b/coins/monero/wallet/polyseed/src/words/en.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/en.json rename to coins/monero/wallet/polyseed/src/words/en.rs index 7c2d07df8..aa90e28d3 100644 --- a/coins/monero/src/wallet/seed/polyseed/en.json +++ b/coins/monero/wallet/polyseed/src/words/en.rs @@ -1,4 +1,4 @@ -[ +&[ "abandon", "ability", "able", diff --git a/coins/monero/src/wallet/seed/polyseed/es.json b/coins/monero/wallet/polyseed/src/words/es.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/es.json rename to coins/monero/wallet/polyseed/src/words/es.rs index 32287459f..21ab12995 100644 --- a/coins/monero/src/wallet/seed/polyseed/es.json +++ b/coins/monero/wallet/polyseed/src/words/es.rs @@ -1,4 +1,4 @@ -[ +&[ "ábaco", "abdomen", "abeja", diff --git a/coins/monero/src/wallet/seed/polyseed/fr.json b/coins/monero/wallet/polyseed/src/words/fr.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/fr.json rename to coins/monero/wallet/polyseed/src/words/fr.rs index 9b11a2451..5b3303e9f 100644 --- a/coins/monero/src/wallet/seed/polyseed/fr.json +++ b/coins/monero/wallet/polyseed/src/words/fr.rs @@ -1,4 +1,4 @@ -[ +&[ "abaisser", "abandon", "abdiquer", diff --git a/coins/monero/src/wallet/seed/polyseed/it.json b/coins/monero/wallet/polyseed/src/words/it.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/it.json rename to coins/monero/wallet/polyseed/src/words/it.rs index d452d1e2f..6d0e43821 100644 --- a/coins/monero/src/wallet/seed/polyseed/it.json +++ b/coins/monero/wallet/polyseed/src/words/it.rs @@ -1,4 +1,4 @@ -[ +&[ "abaco", "abbaglio", "abbinato", diff --git a/coins/monero/src/wallet/seed/polyseed/ja.json b/coins/monero/wallet/polyseed/src/words/ja.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/ja.json rename to coins/monero/wallet/polyseed/src/words/ja.rs index e70550815..3c94572ac 100644 --- a/coins/monero/src/wallet/seed/polyseed/ja.json +++ b/coins/monero/wallet/polyseed/src/words/ja.rs @@ -1,4 +1,4 @@ -[ +&[ "あいこくしん", "あいさつ", "あいだ", diff --git a/coins/monero/src/wallet/seed/polyseed/ko.json b/coins/monero/wallet/polyseed/src/words/ko.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/ko.json rename to coins/monero/wallet/polyseed/src/words/ko.rs index 4d8dd9f3e..a41902f5b 100644 --- a/coins/monero/src/wallet/seed/polyseed/ko.json +++ b/coins/monero/wallet/polyseed/src/words/ko.rs @@ -1,4 +1,4 @@ -[ +&[ "가격", "가끔", "가난", diff --git a/coins/monero/src/wallet/seed/polyseed/pt.json b/coins/monero/wallet/polyseed/src/words/pt.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/pt.json rename to coins/monero/wallet/polyseed/src/words/pt.rs index 4f35462c6..38b9ff4c5 100644 --- a/coins/monero/src/wallet/seed/polyseed/pt.json +++ b/coins/monero/wallet/polyseed/src/words/pt.rs @@ -1,4 +1,4 @@ -[ +&[ "abacate", "abaixo", "abalar", diff --git a/coins/monero/src/wallet/seed/polyseed/zh_simplified.json b/coins/monero/wallet/polyseed/src/words/zh_simplified.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/zh_simplified.json rename to coins/monero/wallet/polyseed/src/words/zh_simplified.rs index 0c0da9e2f..50d2b586c 100644 --- a/coins/monero/src/wallet/seed/polyseed/zh_simplified.json +++ b/coins/monero/wallet/polyseed/src/words/zh_simplified.rs @@ -1,4 +1,4 @@ -[ +&[ "的", "一", "是", diff --git a/coins/monero/src/wallet/seed/polyseed/zh_traditional.json b/coins/monero/wallet/polyseed/src/words/zh_traditional.rs similarity index 99% rename from coins/monero/src/wallet/seed/polyseed/zh_traditional.json rename to coins/monero/wallet/polyseed/src/words/zh_traditional.rs index cc73470ca..3847f6e6c 100644 --- a/coins/monero/src/wallet/seed/polyseed/zh_traditional.json +++ b/coins/monero/wallet/polyseed/src/words/zh_traditional.rs @@ -1,4 +1,4 @@ -[ +&[ "的", "一", "是", diff --git a/coins/monero/wallet/seed/Cargo.toml b/coins/monero/wallet/seed/Cargo.toml new file mode 100644 index 000000000..32ba8cfd9 --- /dev/null +++ b/coins/monero/wallet/seed/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "monero-seed" +version = "0.1.0" +description = "Rust implementation of Monero's seed algorithm" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet/seed" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +thiserror = { version = "1", default-features = false, optional = true } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } +rand_core = { version = "0.6", default-features = false } + +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +[dev-dependencies] +hex = { version = "0.4", default-features = false, features = ["std"] } +monero-primitives = { path = "../../primitives", default-features = false, features = ["std"] } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "zeroize/std", + "rand_core/std", +] +default = ["std"] diff --git a/coins/monero/wallet/seed/LICENSE b/coins/monero/wallet/seed/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/wallet/seed/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/wallet/seed/README.md b/coins/monero/wallet/seed/README.md new file mode 100644 index 000000000..dded41331 --- /dev/null +++ b/coins/monero/wallet/seed/README.md @@ -0,0 +1,11 @@ +# Monero Seeds + +Rust implementation of Monero's seed algorithm. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). diff --git a/coins/monero/src/wallet/seed/classic.rs b/coins/monero/wallet/seed/src/lib.rs similarity index 64% rename from coins/monero/src/wallet/seed/classic.rs rename to coins/monero/wallet/seed/src/lib.rs index 0605e4bce..5c8cbe348 100644 --- a/coins/monero/src/wallet/seed/classic.rs +++ b/coins/monero/wallet/seed/src/lib.rs @@ -1,6 +1,12 @@ -use core::ops::Deref; +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +use core::{ops::Deref, fmt}; use std_shims::{ sync::OnceLock, + vec, vec::Vec, string::{String, ToString}, collections::HashMap, @@ -11,26 +17,60 @@ use rand_core::{RngCore, CryptoRng}; use curve25519_dalek::scalar::Scalar; -use crate::{random_scalar, wallet::seed::SeedError}; - -pub(crate) const CLASSIC_SEED_LENGTH: usize = 24; -pub(crate) const CLASSIC_SEED_LENGTH_WITH_CHECKSUM: usize = 25; +#[cfg(test)] +mod tests; + +// The amount of words in a seed without a checksum. +const SEED_LENGTH: usize = 24; +// The amount of words in a seed with a checksum. +const SEED_LENGTH_WITH_CHECKSUM: usize = 25; + +/// An error when working with a seed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum SeedError { + #[cfg_attr(feature = "std", error("invalid seed"))] + /// The seed was invalid. + InvalidSeed, + /// The checksum did not match the data. + #[cfg_attr(feature = "std", error("invalid checksum"))] + InvalidChecksum, + /// The deprecated English language option was used with a checksum. + /// + /// The deprecated English language option did not include a checksum. + #[cfg_attr(feature = "std", error("deprecated English language option included a checksum"))] + DeprecatedEnglishWithChecksum, +} +/// Language options. #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Zeroize)] pub enum Language { + /// Chinese language option. Chinese, + /// English language option. English, + /// Dutch language option. Dutch, + /// French language option. French, + /// Spanish language option. Spanish, + /// German language option. German, + /// Italian language option. Italian, + /// Portuguese language option. Portuguese, + /// Japanese language option. Japanese, + /// Russian language option. Russian, + /// Esperanto language option. Esperanto, + /// Lojban language option. Lojban, - EnglishOld, + /// The original, and deprecated, English language. + DeprecatedEnglish, } fn trim(word: &str, len: usize) -> Zeroizing { @@ -38,14 +78,14 @@ fn trim(word: &str, len: usize) -> Zeroizing { } struct WordList { - word_list: Vec<&'static str>, + word_list: &'static [&'static str], word_map: HashMap<&'static str, usize>, trimmed_word_map: HashMap, unique_prefix_length: usize, } impl WordList { - fn new(word_list: Vec<&'static str>, prefix_length: usize) -> WordList { + fn new(word_list: &'static [&'static str], prefix_length: usize) -> WordList { let mut lang = WordList { word_list, word_map: HashMap::new(), @@ -67,32 +107,23 @@ static LANGUAGES_CELL: OnceLock> = OnceLock::new(); fn LANGUAGES() -> &'static HashMap { LANGUAGES_CELL.get_or_init(|| { HashMap::from([ - (Language::Chinese, WordList::new(include!("./classic/zh.rs"), 1)), - (Language::English, WordList::new(include!("./classic/en.rs"), 3)), - (Language::Dutch, WordList::new(include!("./classic/nl.rs"), 4)), - (Language::French, WordList::new(include!("./classic/fr.rs"), 4)), - (Language::Spanish, WordList::new(include!("./classic/es.rs"), 4)), - (Language::German, WordList::new(include!("./classic/de.rs"), 4)), - (Language::Italian, WordList::new(include!("./classic/it.rs"), 4)), - (Language::Portuguese, WordList::new(include!("./classic/pt.rs"), 4)), - (Language::Japanese, WordList::new(include!("./classic/ja.rs"), 3)), - (Language::Russian, WordList::new(include!("./classic/ru.rs"), 4)), - (Language::Esperanto, WordList::new(include!("./classic/eo.rs"), 4)), - (Language::Lojban, WordList::new(include!("./classic/jbo.rs"), 4)), - (Language::EnglishOld, WordList::new(include!("./classic/ang.rs"), 4)), + (Language::Chinese, WordList::new(include!("./words/zh.rs"), 1)), + (Language::English, WordList::new(include!("./words/en.rs"), 3)), + (Language::Dutch, WordList::new(include!("./words/nl.rs"), 4)), + (Language::French, WordList::new(include!("./words/fr.rs"), 4)), + (Language::Spanish, WordList::new(include!("./words/es.rs"), 4)), + (Language::German, WordList::new(include!("./words/de.rs"), 4)), + (Language::Italian, WordList::new(include!("./words/it.rs"), 4)), + (Language::Portuguese, WordList::new(include!("./words/pt.rs"), 4)), + (Language::Japanese, WordList::new(include!("./words/ja.rs"), 3)), + (Language::Russian, WordList::new(include!("./words/ru.rs"), 4)), + (Language::Esperanto, WordList::new(include!("./words/eo.rs"), 4)), + (Language::Lojban, WordList::new(include!("./words/jbo.rs"), 4)), + (Language::DeprecatedEnglish, WordList::new(include!("./words/ang.rs"), 4)), ]) }) } -#[cfg(test)] -pub(crate) fn trim_by_lang(word: &str, lang: Language) -> String { - if lang != Language::EnglishOld { - word.chars().take(LANGUAGES()[&lang].unique_prefix_length).collect() - } else { - word.to_string() - } -} - fn checksum_index(words: &[Zeroizing], lang: &WordList) -> usize { let mut trimmed_words = Zeroizing::new(String::new()); for w in words { @@ -135,7 +166,7 @@ fn checksum_index(words: &[Zeroizing], lang: &WordList) -> usize { // Convert a private key to a seed #[allow(clippy::needless_pass_by_value)] -fn key_to_seed(lang: Language, key: Zeroizing) -> ClassicSeed { +fn key_to_seed(lang: Language, key: Zeroizing) -> Seed { let bytes = Zeroizing::new(key.to_bytes()); // get the language words @@ -172,7 +203,7 @@ fn key_to_seed(lang: Language, key: Zeroizing) -> ClassicSeed { indices.zeroize(); // create a checksum word for all languages except old english - if lang != Language::EnglishOld { + if lang != Language::DeprecatedEnglish { let checksum = seed[checksum_index(&seed, &LANGUAGES()[&lang])].clone(); seed.push(checksum); } @@ -184,26 +215,26 @@ fn key_to_seed(lang: Language, key: Zeroizing) -> ClassicSeed { } *res += word; } - ClassicSeed(lang, res) + Seed(lang, res) } // Convert a seed to bytes -pub(crate) fn seed_to_bytes(lang: Language, words: &str) -> Result, SeedError> { +fn seed_to_bytes(lang: Language, words: &str) -> Result, SeedError> { // get seed words let words = words.split_whitespace().map(|w| Zeroizing::new(w.to_string())).collect::>(); - if (words.len() != CLASSIC_SEED_LENGTH) && (words.len() != CLASSIC_SEED_LENGTH_WITH_CHECKSUM) { + if (words.len() != SEED_LENGTH) && (words.len() != SEED_LENGTH_WITH_CHECKSUM) { panic!("invalid seed passed to seed_to_bytes"); } - let has_checksum = words.len() == CLASSIC_SEED_LENGTH_WITH_CHECKSUM; - if has_checksum && lang == Language::EnglishOld { - Err(SeedError::EnglishOldWithChecksum)?; + let has_checksum = words.len() == SEED_LENGTH_WITH_CHECKSUM; + if has_checksum && lang == Language::DeprecatedEnglish { + Err(SeedError::DeprecatedEnglishWithChecksum)?; } // Validate words are in the language word list let lang_word_list: &WordList = &LANGUAGES()[&lang]; let matched_indices = (|| { - let has_checksum = words.len() == CLASSIC_SEED_LENGTH_WITH_CHECKSUM; + let has_checksum = words.len() == SEED_LENGTH_WITH_CHECKSUM; let mut matched_indices = Zeroizing::new(vec![]); // Iterate through all the words and see if they're all present @@ -272,15 +303,27 @@ pub(crate) fn seed_to_bytes(lang: Language, words: &str) -> Result); -impl ClassicSeed { - pub(crate) fn new(rng: &mut R, lang: Language) -> ClassicSeed { - key_to_seed(lang, Zeroizing::new(random_scalar(rng))) +pub struct Seed(Language, Zeroizing); + +impl fmt::Debug for Seed { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Seed").finish_non_exhaustive() + } +} + +impl Seed { + /// Create a new seed. + pub fn new(rng: &mut R, lang: Language) -> Seed { + let mut scalar_bytes = Zeroizing::new([0; 64]); + rng.fill_bytes(scalar_bytes.as_mut()); + key_to_seed(lang, Zeroizing::new(Scalar::from_bytes_mod_order_wide(scalar_bytes.deref()))) } + /// Parse a seed from a string. #[allow(clippy::needless_pass_by_value)] - pub fn from_string(lang: Language, words: Zeroizing) -> Result { + pub fn from_string(lang: Language, words: Zeroizing) -> Result { let entropy = seed_to_bytes(lang, &words)?; // Make sure this is a valid scalar @@ -295,17 +338,20 @@ impl ClassicSeed { Ok(Self::from_entropy(lang, entropy).unwrap()) } + /// Create a seed from entropy. #[allow(clippy::needless_pass_by_value)] - pub fn from_entropy(lang: Language, entropy: Zeroizing<[u8; 32]>) -> Option { + pub fn from_entropy(lang: Language, entropy: Zeroizing<[u8; 32]>) -> Option { Option::from(Scalar::from_canonical_bytes(*entropy)) .map(|scalar| key_to_seed(lang, Zeroizing::new(scalar))) } - pub(crate) fn to_string(&self) -> Zeroizing { + /// Convert a seed to a string. + pub fn to_string(&self) -> Zeroizing { self.1.clone() } - pub(crate) fn entropy(&self) -> Zeroizing<[u8; 32]> { + /// Return the entropy underlying this seed. + pub fn entropy(&self) -> Zeroizing<[u8; 32]> { seed_to_bytes(self.0, &self.1).unwrap() } } diff --git a/coins/monero/wallet/seed/src/tests.rs b/coins/monero/wallet/seed/src/tests.rs new file mode 100644 index 000000000..c477a00d1 --- /dev/null +++ b/coins/monero/wallet/seed/src/tests.rs @@ -0,0 +1,234 @@ +use zeroize::Zeroizing; +use rand_core::OsRng; + +use curve25519_dalek::scalar::Scalar; + +use monero_primitives::keccak256; + +use crate::*; + +#[test] +fn test_original_seed() { + struct Vector { + language: Language, + seed: String, + spend: String, + view: String, + } + + let vectors = [ + Vector { + language: Language::Chinese, + seed: "摇 曲 艺 武 滴 然 效 似 赏 式 祥 歌 买 疑 小 碧 堆 博 键 房 鲜 悲 付 喷 武".into(), + spend: "a5e4fff1706ef9212993a69f246f5c95ad6d84371692d63e9bb0ea112a58340d".into(), + view: "1176c43ce541477ea2f3ef0b49b25112b084e26b8a843e1304ac4677b74cdf02".into(), + }, + Vector { + language: Language::English, + seed: "washing thirsty occur lectures tuesday fainted toxic adapt \ + abnormal memoir nylon mostly building shrugged online ember northern \ + ruby woes dauntless boil family illness inroads northern" + .into(), + spend: "c0af65c0dd837e666b9d0dfed62745f4df35aed7ea619b2798a709f0fe545403".into(), + view: "513ba91c538a5a9069e0094de90e927c0cd147fa10428ce3ac1afd49f63e3b01".into(), + }, + Vector { + language: Language::Dutch, + seed: "setwinst riphagen vimmetje extase blief tuitelig fuiven meifeest \ + ponywagen zesmaal ripdeal matverf codetaal leut ivoor rotten \ + wisgerhof winzucht typograaf atrium rein zilt traktaat verzaagd setwinst" + .into(), + spend: "e2d2873085c447c2bc7664222ac8f7d240df3aeac137f5ff2022eaa629e5b10a".into(), + view: "eac30b69477e3f68093d131c7fd961564458401b07f8c87ff8f6030c1a0c7301".into(), + }, + Vector { + language: Language::French, + seed: "poids vaseux tarte bazar poivre effet entier nuance \ + sensuel ennui pacte osselet poudre battre alibi mouton \ + stade paquet pliage gibier type question position projet pliage" + .into(), + spend: "2dd39ff1a4628a94b5c2ec3e42fb3dfe15c2b2f010154dc3b3de6791e805b904".into(), + view: "6725b32230400a1032f31d622b44c3a227f88258939b14a7c72e00939e7bdf0e".into(), + }, + Vector { + language: Language::Spanish, + seed: "minero ocupar mirar evadir octubre cal logro miope \ + opaco disco ancla litio clase cuello nasal clase \ + fiar avance deseo mente grumo negro cordón croqueta clase" + .into(), + spend: "ae2c9bebdddac067d73ec0180147fc92bdf9ac7337f1bcafbbe57dd13558eb02".into(), + view: "18deafb34d55b7a43cae2c1c1c206a3c80c12cc9d1f84640b484b95b7fec3e05".into(), + }, + Vector { + language: Language::German, + seed: "Kaliber Gabelung Tapir Liveband Favorit Specht Enklave Nabel \ + Jupiter Foliant Chronik nisten löten Vase Aussage Rekord \ + Yeti Gesetz Eleganz Alraune Künstler Almweide Jahr Kastanie Almweide" + .into(), + spend: "79801b7a1b9796856e2397d862a113862e1fdc289a205e79d8d70995b276db06".into(), + view: "99f0ec556643bd9c038a4ed86edcb9c6c16032c4622ed2e000299d527a792701".into(), + }, + Vector { + language: Language::Italian, + seed: "cavo pancetta auto fulmine alleanza filmato diavolo prato \ + forzare meritare litigare lezione segreto evasione votare buio \ + licenza cliente dorso natale crescere vento tutelare vetta evasione" + .into(), + spend: "5e7fd774eb00fa5877e2a8b4dc9c7ffe111008a3891220b56a6e49ac816d650a".into(), + view: "698a1dce6018aef5516e82ca0cb3e3ec7778d17dfb41a137567bfa2e55e63a03".into(), + }, + Vector { + language: Language::Portuguese, + seed: "agito eventualidade onus itrio holograma sodomizar objetos dobro \ + iugoslavo bcrepuscular odalisca abjeto iuane darwinista eczema acetona \ + cibernetico hoquei gleba driver buffer azoto megera nogueira agito" + .into(), + spend: "13b3115f37e35c6aa1db97428b897e584698670c1b27854568d678e729200c0f".into(), + view: "ad1b4fd35270f5f36c4da7166672b347e75c3f4d41346ec2a06d1d0193632801".into(), + }, + Vector { + language: Language::Japanese, + seed: "ぜんぶ どうぐ おたがい せんきょ おうじ そんちょう じゅしん いろえんぴつ \ + かほう つかれる えらぶ にちじょう くのう にちようび ぬまえび さんきゃく \ + おおや ちぬき うすめる いがく せつでん さうな すいえい せつだん おおや" + .into(), + spend: "c56e895cdb13007eda8399222974cdbab493640663804b93cbef3d8c3df80b0b".into(), + view: "6c3634a313ec2ee979d565c33888fd7c3502d696ce0134a8bc1a2698c7f2c508".into(), + }, + Vector { + language: Language::Russian, + seed: "шатер икра нация ехать получать инерция доза реальный \ + рыжий таможня лопата душа веселый клетка атлас лекция \ + обгонять паек наивный лыжный дурак стать ежик задача паек" + .into(), + spend: "7cb5492df5eb2db4c84af20766391cd3e3662ab1a241c70fc881f3d02c381f05".into(), + view: "fcd53e41ec0df995ab43927f7c44bc3359c93523d5009fb3f5ba87431d545a03".into(), + }, + Vector { + language: Language::Esperanto, + seed: "ukazo klini peco etikedo fabriko imitado onklino urino \ + pudro incidento kumuluso ikono smirgi hirundo uretro krii \ + sparkado super speciala pupo alpinisto cvana vokegi zombio fabriko" + .into(), + spend: "82ebf0336d3b152701964ed41df6b6e9a035e57fc98b84039ed0bd4611c58904".into(), + view: "cd4d120e1ea34360af528f6a3e6156063312d9cefc9aa6b5218d366c0ed6a201".into(), + }, + Vector { + language: Language::Lojban, + seed: "jetnu vensa julne xrotu xamsi julne cutci dakli \ + mlatu xedja muvgau palpi xindo sfubu ciste cinri \ + blabi darno dembi janli blabi fenki bukpu burcu blabi" + .into(), + spend: "e4f8c6819ab6cf792cebb858caabac9307fd646901d72123e0367ebc0a79c200".into(), + view: "c806ce62bafaa7b2d597f1a1e2dbe4a2f96bfd804bf6f8420fc7f4a6bd700c00".into(), + }, + Vector { + language: Language::DeprecatedEnglish, + seed: "glorious especially puff son moment add youth nowhere \ + throw glide grip wrong rhythm consume very swear \ + bitter heavy eventually begin reason flirt type unable" + .into(), + spend: "647f4765b66b636ff07170ab6280a9a6804dfbaf19db2ad37d23be024a18730b".into(), + view: "045da65316a906a8c30046053119c18020b07a7a3a6ef5c01ab2a8755416bd02".into(), + }, + // The following seeds require the language specification in order to calculate + // a single valid checksum + Vector { + language: Language::Spanish, + seed: "pluma laico atraer pintor peor cerca balde buscar \ + lancha batir nulo reloj resto gemelo nevera poder columna gol \ + oveja latir amplio bolero feliz fuerza nevera" + .into(), + spend: "30303983fc8d215dd020cc6b8223793318d55c466a86e4390954f373fdc7200a".into(), + view: "97c649143f3c147ba59aa5506cc09c7992c5c219bb26964442142bf97980800e".into(), + }, + Vector { + language: Language::Spanish, + seed: "pluma pluma pluma pluma pluma pluma pluma pluma \ + pluma pluma pluma pluma pluma pluma pluma pluma \ + pluma pluma pluma pluma pluma pluma pluma pluma pluma" + .into(), + spend: "b4050000b4050000b4050000b4050000b4050000b4050000b4050000b4050000".into(), + view: "d73534f7912b395eb70ef911791a2814eb6df7ce56528eaaa83ff2b72d9f5e0f".into(), + }, + Vector { + language: Language::English, + seed: "plus plus plus plus plus plus plus plus \ + plus plus plus plus plus plus plus plus \ + plus plus plus plus plus plus plus plus plus" + .into(), + spend: "3b0400003b0400003b0400003b0400003b0400003b0400003b0400003b040000".into(), + view: "43a8a7715eed11eff145a2024ddcc39740255156da7bbd736ee66a0838053a02".into(), + }, + Vector { + language: Language::Spanish, + seed: "audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio audio" + .into(), + spend: "ba000000ba000000ba000000ba000000ba000000ba000000ba000000ba000000".into(), + view: "1437256da2c85d029b293d8c6b1d625d9374969301869b12f37186e3f906c708".into(), + }, + Vector { + language: Language::English, + seed: "audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio audio" + .into(), + spend: "7900000079000000790000007900000079000000790000007900000079000000".into(), + view: "20bec797ab96780ae6a045dd816676ca7ed1d7c6773f7022d03ad234b581d600".into(), + }, + ]; + + for vector in vectors { + fn trim_by_lang(word: &str, lang: Language) -> String { + if lang != Language::DeprecatedEnglish { + word.chars().take(LANGUAGES()[&lang].unique_prefix_length).collect() + } else { + word.to_string() + } + } + + let trim_seed = |seed: &str| { + seed + .split_whitespace() + .map(|word| trim_by_lang(word, vector.language)) + .collect::>() + .join(" ") + }; + + // Test against Monero + { + println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); + let seed = Seed::from_string(vector.language, Zeroizing::new(vector.seed.clone())).unwrap(); + let trim = trim_seed(&vector.seed); + assert_eq!(seed, Seed::from_string(vector.language, Zeroizing::new(trim)).unwrap()); + + let spend: [u8; 32] = hex::decode(vector.spend).unwrap().try_into().unwrap(); + // For originalal seeds, Monero directly uses the entropy as a spend key + assert_eq!( + Option::::from(Scalar::from_canonical_bytes(*seed.entropy())), + Option::::from(Scalar::from_canonical_bytes(spend)), + ); + + let view: [u8; 32] = hex::decode(vector.view).unwrap().try_into().unwrap(); + // Monero then derives the view key as H(spend) + assert_eq!( + Scalar::from_bytes_mod_order(keccak256(spend)), + Scalar::from_canonical_bytes(view).unwrap() + ); + + assert_eq!(Seed::from_entropy(vector.language, Zeroizing::new(spend)).unwrap(), seed); + } + + // Test against ourselves + { + let seed = Seed::new(&mut OsRng, vector.language); + println!("{}. seed: {}", line!(), *seed.to_string()); + let trim = trim_seed(&seed.to_string()); + assert_eq!(seed, Seed::from_string(vector.language, Zeroizing::new(trim)).unwrap()); + assert_eq!(seed, Seed::from_entropy(vector.language, seed.entropy()).unwrap()); + assert_eq!(seed, Seed::from_string(vector.language, seed.to_string()).unwrap()); + } + } +} diff --git a/coins/monero/src/wallet/seed/classic/ang.rs b/coins/monero/wallet/seed/src/words/ang.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/ang.rs rename to coins/monero/wallet/seed/src/words/ang.rs index d2e47840e..2800b1a99 100644 --- a/coins/monero/src/wallet/seed/classic/ang.rs +++ b/coins/monero/wallet/seed/src/words/ang.rs @@ -1,4 +1,4 @@ -vec![ +&[ "like", "just", "love", diff --git a/coins/monero/src/wallet/seed/classic/de.rs b/coins/monero/wallet/seed/src/words/de.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/de.rs rename to coins/monero/wallet/seed/src/words/de.rs index c66183560..85dee0819 100644 --- a/coins/monero/src/wallet/seed/classic/de.rs +++ b/coins/monero/wallet/seed/src/words/de.rs @@ -1,4 +1,4 @@ -vec![ +&[ "Abakus", "Abart", "abbilden", diff --git a/coins/monero/src/wallet/seed/classic/en.rs b/coins/monero/wallet/seed/src/words/en.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/en.rs rename to coins/monero/wallet/seed/src/words/en.rs index ae7886919..c6f9a454e 100644 --- a/coins/monero/src/wallet/seed/classic/en.rs +++ b/coins/monero/wallet/seed/src/words/en.rs @@ -1,4 +1,4 @@ -vec![ +&[ "abbey", "abducts", "ability", diff --git a/coins/monero/src/wallet/seed/classic/eo.rs b/coins/monero/wallet/seed/src/words/eo.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/eo.rs rename to coins/monero/wallet/seed/src/words/eo.rs index eb518af0e..d9d6ff40e 100644 --- a/coins/monero/src/wallet/seed/classic/eo.rs +++ b/coins/monero/wallet/seed/src/words/eo.rs @@ -1,4 +1,4 @@ -vec![ +&[ "abako", "abdiki", "abelo", diff --git a/coins/monero/src/wallet/seed/classic/es.rs b/coins/monero/wallet/seed/src/words/es.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/es.rs rename to coins/monero/wallet/seed/src/words/es.rs index d6f26855f..09fb346df 100644 --- a/coins/monero/src/wallet/seed/classic/es.rs +++ b/coins/monero/wallet/seed/src/words/es.rs @@ -1,4 +1,4 @@ -vec![ +&[ "ábaco", "abdomen", "abeja", diff --git a/coins/monero/src/wallet/seed/classic/fr.rs b/coins/monero/wallet/seed/src/words/fr.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/fr.rs rename to coins/monero/wallet/seed/src/words/fr.rs index e2ca8ad4f..338eeb387 100644 --- a/coins/monero/src/wallet/seed/classic/fr.rs +++ b/coins/monero/wallet/seed/src/words/fr.rs @@ -1,4 +1,4 @@ -vec![ +&[ "abandon", "abattre", "aboi", diff --git a/coins/monero/src/wallet/seed/classic/it.rs b/coins/monero/wallet/seed/src/words/it.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/it.rs rename to coins/monero/wallet/seed/src/words/it.rs index d303452c6..343984e6c 100644 --- a/coins/monero/src/wallet/seed/classic/it.rs +++ b/coins/monero/wallet/seed/src/words/it.rs @@ -1,4 +1,4 @@ -vec![ +&[ "abbinare", "abbonato", "abisso", diff --git a/coins/monero/src/wallet/seed/classic/ja.rs b/coins/monero/wallet/seed/src/words/ja.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/ja.rs rename to coins/monero/wallet/seed/src/words/ja.rs index bc2aafde8..da2d9fb60 100644 --- a/coins/monero/src/wallet/seed/classic/ja.rs +++ b/coins/monero/wallet/seed/src/words/ja.rs @@ -1,4 +1,4 @@ -vec![ +&[ "あいこくしん", "あいさつ", "あいだ", diff --git a/coins/monero/src/wallet/seed/classic/jbo.rs b/coins/monero/wallet/seed/src/words/jbo.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/jbo.rs rename to coins/monero/wallet/seed/src/words/jbo.rs index bcfcc6bc6..a58f8d11a 100644 --- a/coins/monero/src/wallet/seed/classic/jbo.rs +++ b/coins/monero/wallet/seed/src/words/jbo.rs @@ -1,4 +1,4 @@ -vec![ +&[ "backi", "bacru", "badna", diff --git a/coins/monero/src/wallet/seed/classic/nl.rs b/coins/monero/wallet/seed/src/words/nl.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/nl.rs rename to coins/monero/wallet/seed/src/words/nl.rs index e2f1912f8..0c191e7f0 100644 --- a/coins/monero/src/wallet/seed/classic/nl.rs +++ b/coins/monero/wallet/seed/src/words/nl.rs @@ -1,4 +1,4 @@ -vec![ +&[ "aalglad", "aalscholver", "aambeeld", diff --git a/coins/monero/src/wallet/seed/classic/pt.rs b/coins/monero/wallet/seed/src/words/pt.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/pt.rs rename to coins/monero/wallet/seed/src/words/pt.rs index 6f37336bd..cede0ac54 100644 --- a/coins/monero/src/wallet/seed/classic/pt.rs +++ b/coins/monero/wallet/seed/src/words/pt.rs @@ -1,4 +1,4 @@ -vec![ +&[ "abaular", "abdominal", "abeto", diff --git a/coins/monero/src/wallet/seed/classic/ru.rs b/coins/monero/wallet/seed/src/words/ru.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/ru.rs rename to coins/monero/wallet/seed/src/words/ru.rs index 3b36ef61d..609fa4cbe 100644 --- a/coins/monero/src/wallet/seed/classic/ru.rs +++ b/coins/monero/wallet/seed/src/words/ru.rs @@ -1,4 +1,4 @@ -vec![ +&[ "абажур", "абзац", "абонент", diff --git a/coins/monero/src/wallet/seed/classic/zh.rs b/coins/monero/wallet/seed/src/words/zh.rs similarity index 99% rename from coins/monero/src/wallet/seed/classic/zh.rs rename to coins/monero/wallet/seed/src/words/zh.rs index 2ea7916ed..42f05b4a2 100644 --- a/coins/monero/src/wallet/seed/classic/zh.rs +++ b/coins/monero/wallet/seed/src/words/zh.rs @@ -1,4 +1,4 @@ -vec![ +&[ "的", "一", "是", diff --git a/coins/monero/src/wallet/decoys.rs b/coins/monero/wallet/src/decoys.rs similarity index 76% rename from coins/monero/src/wallet/decoys.rs rename to coins/monero/wallet/src/decoys.rs index b0282f378..00b771bcd 100644 --- a/coins/monero/src/wallet/decoys.rs +++ b/coins/monero/wallet/src/decoys.rs @@ -1,14 +1,8 @@ -use std_shims::{vec::Vec, collections::HashSet}; - -#[cfg(feature = "cache-distribution")] -use std_shims::sync::OnceLock; +// TODO: Clean this -#[cfg(all(feature = "cache-distribution", not(feature = "std")))] -use std_shims::sync::Mutex; -#[cfg(all(feature = "cache-distribution", feature = "std"))] -use async_lock::Mutex; +use std_shims::{vec::Vec, collections::HashSet}; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::Zeroize; use rand_core::{RngCore, CryptoRng}; use rand_distr::{Distribution, Gamma}; @@ -18,10 +12,9 @@ use rand_distr::num_traits::Float; use curve25519_dalek::edwards::EdwardsPoint; use crate::{ - serialize::varint_len, - wallet::SpendableOutput, - rpc::{RpcError, RpcConnection, Rpc}, DEFAULT_LOCK_WINDOW, COINBASE_LOCK_WINDOW, BLOCK_TIME, + rpc::{RpcError, Rpc}, + WalletOutput, }; const RECENT_WINDOW: usize = 15; @@ -29,20 +22,10 @@ const BLOCKS_PER_YEAR: usize = 365 * 24 * 60 * 60 / BLOCK_TIME; #[allow(clippy::cast_precision_loss)] const TIP_APPLICATION: f64 = (DEFAULT_LOCK_WINDOW * BLOCK_TIME) as f64; -// TODO: Resolve safety of this in case a reorg occurs/the network changes -// TODO: Update this when scanning a block, as possible -#[cfg(feature = "cache-distribution")] -static DISTRIBUTION_CELL: OnceLock>> = OnceLock::new(); -#[cfg(feature = "cache-distribution")] -#[allow(non_snake_case)] -fn DISTRIBUTION() -> &'static Mutex> { - DISTRIBUTION_CELL.get_or_init(|| Mutex::new(Vec::with_capacity(3000000))) -} - #[allow(clippy::too_many_arguments)] -async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>( +async fn select_n<'a, R: RngCore + CryptoRng>( rng: &mut R, - rpc: &Rpc, + rpc: &impl Rpc, distribution: &[u64], height: usize, high: u64, @@ -53,9 +36,9 @@ async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>( fingerprintable_canonical: bool, ) -> Result, RpcError> { // TODO: consider removing this extra RPC and expect the caller to handle it - if fingerprintable_canonical && height > rpc.get_height().await? { + if fingerprintable_canonical && (height > rpc.get_height().await?) { // TODO: Don't use InternalError for the caller's failure - Err(RpcError::InternalError("decoys being requested from too young blocks"))?; + Err(RpcError::InternalError("decoys being requested from too young blocks".to_string()))?; } #[cfg(test)] @@ -73,7 +56,7 @@ async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>( iters += 1; // This is cheap and on fresh chains, a lot of rounds may be needed if iters == 100 { - Err(RpcError::InternalError("hit decoy selection round limit"))?; + Err(RpcError::InternalError("hit decoy selection round limit".to_string()))?; } } @@ -150,22 +133,14 @@ fn offset(ring: &[u64]) -> Vec { res } -async fn select_decoys( +async fn select_decoys( rng: &mut R, - rpc: &Rpc, + rpc: &impl Rpc, ring_len: usize, height: usize, - inputs: &[SpendableOutput], + inputs: &[WalletOutput], fingerprintable_canonical: bool, ) -> Result, RpcError> { - #[cfg(feature = "cache-distribution")] - #[cfg(not(feature = "std"))] - let mut distribution = DISTRIBUTION().lock(); - #[cfg(feature = "cache-distribution")] - #[cfg(feature = "std")] - let mut distribution = DISTRIBUTION().lock().await; - - #[cfg(not(feature = "cache-distribution"))] let mut distribution = vec![]; let decoy_count = ring_len - 1; @@ -174,14 +149,13 @@ async fn select_decoys( let mut real = Vec::with_capacity(inputs.len()); let mut outputs = Vec::with_capacity(inputs.len()); for input in inputs { - real.push(input.global_index); + real.push(input.relative_id.index_on_blockchain); outputs.push((real[real.len() - 1], [input.key(), input.commitment().calculate()])); } if distribution.len() < height { // TODO: verify distribution elems are strictly increasing - let extension = - rpc.get_output_distribution(distribution.len(), height.saturating_sub(1)).await?; + let extension = rpc.get_output_distribution(distribution.len() .. height).await?; distribution.extend(extension); } // If asked to use an older height than previously asked, truncate to ensure accuracy @@ -189,7 +163,7 @@ async fn select_decoys( distribution.truncate(height); if distribution.len() < DEFAULT_LOCK_WINDOW { - Err(RpcError::InternalError("not enough decoy candidates"))?; + Err(RpcError::InternalError("not enough blocks to select decoys".to_string()))?; } #[allow(clippy::cast_precision_loss)] @@ -207,10 +181,12 @@ async fn select_decoys( // TODO: Create a TX with less than the target amount, as allowed by the protocol let high = distribution[distribution.len() - DEFAULT_LOCK_WINDOW]; - if high.saturating_sub(COINBASE_LOCK_WINDOW as u64) < + // This assumes that each miner TX had one output (as sane) and checks we have sufficient + // outputs even when excluding them (due to their own timelock requirements) + if high.saturating_sub(u64::try_from(COINBASE_LOCK_WINDOW).unwrap()) < u64::try_from(inputs.len() * ring_len).unwrap() { - Err(RpcError::InternalError("not enough coinbase candidates"))?; + Err(RpcError::InternalError("not enough decoy candidates".to_string()))?; } // Select all decoys for this transaction, assuming we generate a sane transaction @@ -287,54 +263,36 @@ async fn select_decoys( // members } - res.push(Decoys { - // Binary searches for the real spend since we don't know where it sorted to - i: u8::try_from(ring.partition_point(|x| x.0 < o.0)).unwrap(), - offsets: offset(&ring.iter().map(|output| output.0).collect::>()), - ring: ring.iter().map(|output| output.1).collect(), - }); + res.push( + Decoys::new( + offset(&ring.iter().map(|output| output.0).collect::>()), + // Binary searches for the real spend since we don't know where it sorted to + u8::try_from(ring.partition_point(|x| x.0 < o.0)).unwrap(), + ring.iter().map(|output| output.1).collect(), + ) + .unwrap(), + ); } Ok(res) } -/// Decoy data, containing the actual member as well (at index `i`). -#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] -pub struct Decoys { - pub(crate) i: u8, - pub(crate) offsets: Vec, - pub(crate) ring: Vec<[EdwardsPoint; 2]>, -} - -#[allow(clippy::len_without_is_empty)] -impl Decoys { - pub fn fee_weight(offsets: &[u64]) -> usize { - varint_len(offsets.len()) + offsets.iter().map(|offset| varint_len(*offset)).sum::() - } - - pub fn len(&self) -> usize { - self.offsets.len() - } - - pub fn indexes(&self) -> Vec { - let mut res = vec![self.offsets[0]; self.len()]; - for m in 1 .. res.len() { - res[m] = res[m - 1] + self.offsets[m]; - } - res - } +pub use monero_serai::primitives::Decoys; +// TODO: Remove this trait +/// TODO: Document +#[cfg(feature = "std")] +#[async_trait::async_trait] +pub trait DecoySelection { /// Select decoys using the same distribution as Monero. Relies on the monerod RPC /// response for an output's unlocked status, minimizing trips to the daemon. - pub async fn select( + async fn select( rng: &mut R, - rpc: &Rpc, + rpc: &impl Rpc, ring_len: usize, height: usize, - inputs: &[SpendableOutput], - ) -> Result, RpcError> { - select_decoys(rng, rpc, ring_len, height, inputs, false).await - } + inputs: &[WalletOutput], + ) -> Result, RpcError>; /// If no reorg has occurred and an honest RPC, any caller who passes the same height to this /// function will use the same distribution to select decoys. It is fingerprintable @@ -344,12 +302,34 @@ impl Decoys { /// /// TODO: upstream change to monerod get_outs RPC to accept a height param for checking /// output's unlocked status and remove all usage of fingerprintable_canonical - pub async fn fingerprintable_canonical_select( + async fn fingerprintable_canonical_select( + rng: &mut R, + rpc: &impl Rpc, + ring_len: usize, + height: usize, + inputs: &[WalletOutput], + ) -> Result, RpcError>; +} + +#[cfg(feature = "std")] +#[async_trait::async_trait] +impl DecoySelection for Decoys { + async fn select( + rng: &mut R, + rpc: &impl Rpc, + ring_len: usize, + height: usize, + inputs: &[WalletOutput], + ) -> Result, RpcError> { + select_decoys(rng, rpc, ring_len, height, inputs, false).await + } + + async fn fingerprintable_canonical_select( rng: &mut R, - rpc: &Rpc, + rpc: &impl Rpc, ring_len: usize, height: usize, - inputs: &[SpendableOutput], + inputs: &[WalletOutput], ) -> Result, RpcError> { select_decoys(rng, rpc, ring_len, height, inputs, true).await } diff --git a/coins/monero/src/wallet/extra.rs b/coins/monero/wallet/src/extra.rs similarity index 71% rename from coins/monero/src/wallet/extra.rs rename to coins/monero/wallet/src/extra.rs index deed8036b..20cd3c8f0 100644 --- a/coins/monero/src/wallet/extra.rs +++ b/coins/monero/wallet/src/extra.rs @@ -1,5 +1,6 @@ use core::ops::BitXor; use std_shims::{ + vec, vec::Vec, io::{self, Read, BufRead, Write}, }; @@ -8,25 +9,28 @@ use zeroize::Zeroize; use curve25519_dalek::edwards::EdwardsPoint; -use crate::serialize::{ - varint_len, read_byte, read_bytes, read_varint, read_point, read_vec, write_byte, write_varint, - write_point, write_vec, -}; +use monero_serai::io::*; -pub const MAX_TX_EXTRA_PADDING_COUNT: usize = 255; -pub const MAX_TX_EXTRA_NONCE_SIZE: usize = 255; +pub(crate) const MAX_TX_EXTRA_PADDING_COUNT: usize = 255; +const MAX_TX_EXTRA_NONCE_SIZE: usize = 255; -pub const PAYMENT_ID_MARKER: u8 = 0; -pub const ENCRYPTED_PAYMENT_ID_MARKER: u8 = 1; +const PAYMENT_ID_MARKER: u8 = 0; +const ENCRYPTED_PAYMENT_ID_MARKER: u8 = 1; // Used as it's the highest value not interpretable as a continued VarInt -pub const ARBITRARY_DATA_MARKER: u8 = 127; +pub(crate) const ARBITRARY_DATA_MARKER: u8 = 127; +/// The max amount of data which will fit within a blob of arbitrary data. // 1 byte is used for the marker pub const MAX_ARBITRARY_DATA_SIZE: usize = MAX_TX_EXTRA_NONCE_SIZE - 1; +/// A Payment ID. +/// +/// This is a legacy method of identifying why Monero was sent to the receiver. #[derive(Clone, Copy, PartialEq, Eq, Debug, Zeroize)] pub enum PaymentId { + /// A deprecated form of payment ID which is no longer supported. Unencrypted([u8; 32]), + /// An encrypted payment ID. Encrypted([u8; 8]), } @@ -45,6 +49,7 @@ impl BitXor<[u8; 8]> for PaymentId { } impl PaymentId { + /// Write the PaymentId. pub fn write(&self, w: &mut W) -> io::Result<()> { match self { PaymentId::Unencrypted(id) => { @@ -59,6 +64,14 @@ impl PaymentId { Ok(()) } + /// Serialize the PaymentId to a `Vec`. + pub fn serialize(&self) -> Vec { + let mut res = Vec::with_capacity(1 + 8); + self.write(&mut res).unwrap(); + res + } + + /// Read a PaymentId. pub fn read(r: &mut R) -> io::Result { Ok(match read_byte(r)? { 0 => PaymentId::Unencrypted(read_bytes(r)?), @@ -68,18 +81,39 @@ impl PaymentId { } } -// Doesn't bother with padding nor MinerGate +/// A field within the TX extra. #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub enum ExtraField { + /// Padding. + /// + /// This is a block of zeroes within the TX extra. Padding(usize), + /// The transaction key. + /// + /// This is a commitment to the randomness used for deriving outputs. PublicKey(EdwardsPoint), + /// The nonce field. + /// + /// This is used for data, such as payment IDs. Nonce(Vec), + /// The field for merge-mining. + /// + /// This is used within miner transactions who are merge-mining Monero to specify the foreign + /// block they mined. MergeMining(usize, [u8; 32]), + /// The additional transaction keys. + /// + /// These are the per-output commitments to the randomness used for deriving outputs. PublicKeys(Vec), + /// The 'mysterious' Minergate tag. + /// + /// This was used by a closed source entity without documentation. Support for parsing it was + /// added to reduce extra which couldn't be decoded. MysteriousMinergate(Vec), } impl ExtraField { + /// Write the ExtraField. pub fn write(&self, w: &mut W) -> io::Result<()> { match self { ExtraField::Padding(size) => { @@ -113,6 +147,14 @@ impl ExtraField { Ok(()) } + /// Serialize the ExtraField to a `Vec`. + pub fn serialize(&self) -> Vec { + let mut res = Vec::with_capacity(1 + 8); + self.write(&mut res).unwrap(); + res + } + + /// Read an ExtraField. pub fn read(r: &mut R) -> io::Result { Ok(match read_byte(r)? { 0 => ExtraField::Padding({ @@ -154,9 +196,15 @@ impl ExtraField { } } +/// The result of decoding a transaction's extra field. #[derive(Clone, PartialEq, Eq, Debug, Zeroize)] pub struct Extra(pub(crate) Vec); impl Extra { + /// The keys within this extra. + /// + /// This returns all keys specified with `PublicKey` and the first set of keys specified with + /// `PublicKeys`, so long as they're well-formed. + // TODO: Cite this pub fn keys(&self) -> Option<(Vec, Option>)> { let mut keys = vec![]; let mut additional = None; @@ -177,6 +225,8 @@ impl Extra { } } + /// The payment ID embedded within this extra. + // TODO: Monero distinguishes encrypted/unencrypted payment ID retrieval pub fn payment_id(&self) -> Option { for field in &self.0 { if let ExtraField::Nonce(data) = field { @@ -186,6 +236,9 @@ impl Extra { None } + /// The arbitrary data within this extra. + /// + /// This uses a marker custom to monero-wallet. pub fn data(&self) -> Vec> { let mut res = vec![]; for field in &self.0 { @@ -211,23 +264,7 @@ impl Extra { self.0.push(field); } - #[rustfmt::skip] - pub(crate) fn fee_weight( - outputs: usize, - additional: bool, - payment_id: bool, - data: &[Vec] - ) -> usize { - // PublicKey, key - (1 + 32) + - // PublicKeys, length, additional keys - (if additional { 1 + 1 + (outputs * 32) } else { 0 }) + - // PaymentId (Nonce), length, encrypted, ID - (if payment_id { 1 + 1 + 1 + 8 } else { 0 }) + - // Nonce, length, ARBITRARY_DATA_MARKER, data - data.iter().map(|v| 1 + varint_len(1 + v.len()) + 1 + v.len()).sum::() - } - + /// Write the Extra. pub fn write(&self, w: &mut W) -> io::Result<()> { for field in &self.0 { field.write(w)?; @@ -235,12 +272,16 @@ impl Extra { Ok(()) } + /// Serialize the Extra to a `Vec`. pub fn serialize(&self) -> Vec { let mut buf = vec![]; self.write(&mut buf).unwrap(); buf } + // TODO: Is this supposed to silently drop trailing gibberish? + /// Read an `Extra`. + #[allow(clippy::unnecessary_wraps)] pub fn read(r: &mut R) -> io::Result { let mut res = Extra(vec![]); let mut field; diff --git a/coins/monero/wallet/src/lib.rs b/coins/monero/wallet/src/lib.rs new file mode 100644 index 000000000..133be188b --- /dev/null +++ b/coins/monero/wallet/src/lib.rs @@ -0,0 +1,168 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +use std_shims::vec::Vec; + +use zeroize::{Zeroize, Zeroizing}; + +use curve25519_dalek::{Scalar, EdwardsPoint}; + +use monero_serai::{ + io::write_varint, + primitives::{Commitment, keccak256, keccak256_to_scalar}, + ringct::EncryptedAmount, + transaction::Input, +}; + +pub use monero_serai::*; + +pub use monero_rpc as rpc; + +pub use monero_address as address; + +mod view_pair; +pub use view_pair::{ViewPair, GuaranteedViewPair}; + +/// Structures and functionality for working with transactions' extra fields. +pub mod extra; +pub(crate) use extra::{PaymentId, Extra}; + +pub(crate) mod output; +pub use output::WalletOutput; + +mod scan; +pub use scan::{Scanner, GuaranteedScanner}; + +#[cfg(feature = "std")] +mod decoys; +#[cfg(not(feature = "std"))] +mod decoys { + pub use monero_serai::primitives::Decoys; + /// TODO: Document/remove + pub trait DecoySelection {} +} +pub use decoys::{DecoySelection, Decoys}; + +/// Structs and functionality for sending transactions. +pub mod send; + +#[cfg(test)] +mod tests; + +#[derive(Clone, PartialEq, Eq, Zeroize)] +struct SharedKeyDerivations { + // Hs("view_tag" || 8Ra || o) + view_tag: u8, + // Hs(uniqueness || 8Ra || o) where uniqueness may be empty + shared_key: Scalar, +} + +impl SharedKeyDerivations { + // https://gist.github.com/kayabaNerve/8066c13f1fe1573286ba7a2fd79f6100 + fn uniqueness(inputs: &[Input]) -> [u8; 32] { + let mut u = b"uniqueness".to_vec(); + for input in inputs { + match input { + // If Gen, this should be the only input, making this loop somewhat pointless + // This works and even if there were somehow multiple inputs, it'd be a false negative + Input::Gen(height) => { + write_varint(height, &mut u).unwrap(); + } + Input::ToKey { key_image, .. } => u.extend(key_image.compress().to_bytes()), + } + } + keccak256(u) + } + + #[allow(clippy::needless_pass_by_value)] + fn output_derivations( + uniqueness: Option<[u8; 32]>, + ecdh: Zeroizing, + o: usize, + ) -> Zeroizing { + // 8Ra + let mut output_derivation = Zeroizing::new( + Zeroizing::new(Zeroizing::new(ecdh.mul_by_cofactor()).compress().to_bytes()).to_vec(), + ); + + // || o + { + let output_derivation: &mut Vec = output_derivation.as_mut(); + write_varint(&o, output_derivation).unwrap(); + } + + let view_tag = keccak256([b"view_tag".as_ref(), &output_derivation].concat())[0]; + + // uniqueness || + let output_derivation = if let Some(uniqueness) = uniqueness { + Zeroizing::new([uniqueness.as_ref(), &output_derivation].concat()) + } else { + output_derivation + }; + + Zeroizing::new(SharedKeyDerivations { + view_tag, + shared_key: keccak256_to_scalar(&output_derivation), + }) + } + + // H(8Ra || 0x8d) + // TODO: Make this itself a PaymentId + #[allow(clippy::needless_pass_by_value)] + fn payment_id_xor(ecdh: Zeroizing) -> [u8; 8] { + // 8Ra + let output_derivation = Zeroizing::new( + Zeroizing::new(Zeroizing::new(ecdh.mul_by_cofactor()).compress().to_bytes()).to_vec(), + ); + + let mut payment_id_xor = [0; 8]; + payment_id_xor + .copy_from_slice(&keccak256([output_derivation.as_ref(), [0x8d].as_ref()].concat())[.. 8]); + payment_id_xor + } + + fn commitment_mask(&self) -> Scalar { + let mut mask = b"commitment_mask".to_vec(); + mask.extend(self.shared_key.as_bytes()); + let res = keccak256_to_scalar(&mask); + mask.zeroize(); + res + } + + fn compact_amount_encryption(&self, amount: u64) -> [u8; 8] { + let mut amount_mask = Zeroizing::new(b"amount".to_vec()); + amount_mask.extend(self.shared_key.to_bytes()); + let mut amount_mask = keccak256(&amount_mask); + + let mut amount_mask_8 = [0; 8]; + amount_mask_8.copy_from_slice(&amount_mask[.. 8]); + amount_mask.zeroize(); + + (amount ^ u64::from_le_bytes(amount_mask_8)).to_le_bytes() + } + + fn decrypt(&self, enc_amount: &EncryptedAmount) -> Commitment { + match enc_amount { + // TODO: Add a test vector for this + EncryptedAmount::Original { mask, amount } => { + let mask_shared_sec = keccak256(self.shared_key.as_bytes()); + let mask = + Scalar::from_bytes_mod_order(*mask) - Scalar::from_bytes_mod_order(mask_shared_sec); + + let amount_shared_sec = keccak256(mask_shared_sec); + let amount_scalar = + Scalar::from_bytes_mod_order(*amount) - Scalar::from_bytes_mod_order(amount_shared_sec); + // d2b from rctTypes.cpp + let amount = u64::from_le_bytes(amount_scalar.to_bytes()[0 .. 8].try_into().unwrap()); + + Commitment::new(mask, amount) + } + EncryptedAmount::Compact { amount } => Commitment::new( + self.commitment_mask(), + u64::from_le_bytes(self.compact_amount_encryption(u64::from_le_bytes(*amount))), + ), + } + } +} diff --git a/coins/monero/wallet/src/output.rs b/coins/monero/wallet/src/output.rs new file mode 100644 index 000000000..41b853be6 --- /dev/null +++ b/coins/monero/wallet/src/output.rs @@ -0,0 +1,337 @@ +use std_shims::{ + vec, + vec::Vec, + io::{self, Read, Write}, +}; + +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use curve25519_dalek::{Scalar, edwards::EdwardsPoint}; + +use crate::{ + io::*, primitives::Commitment, transaction::Timelock, address::SubaddressIndex, extra::PaymentId, +}; + +/// An absolute output ID, defined as its transaction hash and output index. +/// +/// This is not the output's key as multiple outputs may share an output key. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub(crate) struct AbsoluteId { + pub(crate) transaction: [u8; 32], + pub(crate) index_in_transaction: u32, +} + +impl core::fmt::Debug for AbsoluteId { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { + fmt + .debug_struct("AbsoluteId") + .field("transaction", &hex::encode(self.transaction)) + .field("index_in_transaction", &self.index_in_transaction) + .finish() + } +} + +impl AbsoluteId { + /// Write the AbsoluteId. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn write(&self, w: &mut W) -> io::Result<()> { + w.write_all(&self.transaction)?; + w.write_all(&self.index_in_transaction.to_le_bytes()) + } + + /// Read an AbsoluteId. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn read(r: &mut R) -> io::Result { + Ok(AbsoluteId { transaction: read_bytes(r)?, index_in_transaction: read_u32(r)? }) + } +} + +/// An output's relative ID. +/// +/// This id defined as the block which contains the transaction creating the output and the +/// output's index on the blockchain. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub(crate) struct RelativeId { + pub(crate) block: [u8; 32], + pub(crate) index_on_blockchain: u64, +} + +impl core::fmt::Debug for RelativeId { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { + fmt + .debug_struct("RelativeId") + .field("block", &hex::encode(self.block)) + .field("index_on_blockchain", &self.index_on_blockchain) + .finish() + } +} + +impl RelativeId { + /// Write the RelativeId. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn write(&self, w: &mut W) -> io::Result<()> { + w.write_all(&self.block)?; + w.write_all(&self.index_on_blockchain.to_le_bytes()) + } + + /// Read an RelativeId. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn read(r: &mut R) -> io::Result { + Ok(RelativeId { block: read_bytes(r)?, index_on_blockchain: read_u64(r)? }) + } +} + +/// The data within an output as necessary to spend an output, and the output's additional +/// timelock. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub(crate) struct OutputData { + pub(crate) key: EdwardsPoint, + pub(crate) key_offset: Scalar, + pub(crate) commitment: Commitment, + pub(crate) additional_timelock: Timelock, +} + +impl core::fmt::Debug for OutputData { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { + fmt + .debug_struct("OutputData") + .field("key", &hex::encode(self.key.compress().0)) + .field("key_offset", &hex::encode(self.key_offset.to_bytes())) + .field("commitment", &self.commitment) + .field("additional_timelock", &self.additional_timelock) + .finish() + } +} + +impl OutputData { + // Write the OutputData. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn write(&self, w: &mut W) -> io::Result<()> { + w.write_all(&self.key.compress().to_bytes())?; + w.write_all(&self.key_offset.to_bytes())?; + self.commitment.write(w)?; + self.additional_timelock.write(w) + } + + /// Read an OutputData. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn read(r: &mut R) -> io::Result { + Ok(OutputData { + key: read_point(r)?, + key_offset: read_scalar(r)?, + commitment: Commitment::read(r)?, + additional_timelock: Timelock::read(r)?, + }) + } +} + +/// The metadata for an output. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub(crate) struct Metadata { + pub(crate) subaddress: Option, + pub(crate) payment_id: Option, + pub(crate) arbitrary_data: Vec>, +} + +impl core::fmt::Debug for Metadata { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> { + fmt + .debug_struct("Metadata") + .field("subaddress", &self.subaddress) + .field("payment_id", &self.payment_id) + .field("arbitrary_data", &self.arbitrary_data.iter().map(hex::encode).collect::>()) + .finish() + } +} + +impl Metadata { + /// Write the Metadata. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn write(&self, w: &mut W) -> io::Result<()> { + if let Some(subaddress) = self.subaddress { + w.write_all(&[1])?; + w.write_all(&subaddress.account().to_le_bytes())?; + w.write_all(&subaddress.address().to_le_bytes())?; + } else { + w.write_all(&[0])?; + } + + if let Some(payment_id) = self.payment_id { + w.write_all(&[1])?; + payment_id.write(w)?; + } else { + w.write_all(&[0])?; + } + + w.write_all(&u32::try_from(self.arbitrary_data.len()).unwrap().to_le_bytes())?; + for part in &self.arbitrary_data { + w.write_all(&[u8::try_from(part.len()).unwrap()])?; + w.write_all(part)?; + } + Ok(()) + } + + /// Read a Metadata. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + fn read(r: &mut R) -> io::Result { + let subaddress = match read_byte(r)? { + 0 => None, + 1 => Some( + SubaddressIndex::new(read_u32(r)?, read_u32(r)?) + .ok_or_else(|| io::Error::other("invalid subaddress in metadata"))?, + ), + _ => Err(io::Error::other("invalid subaddress is_some boolean in metadata"))?, + }; + + Ok(Metadata { + subaddress, + payment_id: if read_byte(r)? == 1 { PaymentId::read(r).ok() } else { None }, + arbitrary_data: { + let mut data = vec![]; + for _ in 0 .. read_u32(r)? { + let len = read_byte(r)?; + data.push(read_raw_vec(read_byte, usize::from(len), r)?); + } + data + }, + }) + } +} + +/// A received output. +/// +/// This struct contains all data necessary to spend this output, or handle it as a payment. +/// +/// This struct is bound to a specific instance of the blockchain. If the blockchain reorganizes +/// the block this struct is bound to, it MUST be discarded. If any outputs are mutual to both +/// blockchains, scanning the new blockchain will yield those outputs again. +#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)] +pub struct WalletOutput { + /// The absolute ID for this transaction. + pub(crate) absolute_id: AbsoluteId, + /// The ID for this transaction, relative to the blockchain. + pub(crate) relative_id: RelativeId, + /// The output's data. + pub(crate) data: OutputData, + /// Associated metadata relevant for handling it as a payment. + pub(crate) metadata: Metadata, +} + +impl WalletOutput { + /// The hash of the transaction which created this output. + pub fn transaction(&self) -> [u8; 32] { + self.absolute_id.transaction + } + + /// The index of the output within the transaction. + pub fn index_in_transaction(&self) -> u32 { + self.absolute_id.index_in_transaction + } + + /// The block containing the transaction which created this output. + pub fn block(&self) -> [u8; 32] { + self.relative_id.block + } + + /// The index of the output on the blockchain. + pub fn index_on_blockchain(&self) -> u64 { + self.relative_id.index_on_blockchain + } + + /// The key this output may be spent by. + pub fn key(&self) -> EdwardsPoint { + self.data.key + } + + /// The scalar to add to the private spend key for it to be the discrete logarithm of this + /// output's key. + pub fn key_offset(&self) -> Scalar { + self.data.key_offset + } + + /// The commitment this output created. + pub fn commitment(&self) -> &Commitment { + &self.data.commitment + } + + /// The additional timelock this output is subject to. + /// + /// All outputs are subject to the '10-block lock', a 10-block window after their inclusion + /// on-chain during which they cannot be spent. Outputs may be additionally timelocked. This + /// function only returns the additional timelock. + pub fn additional_timelock(&self) -> Timelock { + self.data.additional_timelock + } + + /// The index of the subaddress this output was identified as sent to. + pub fn subaddress(&self) -> Option { + self.metadata.subaddress + } + + /// The payment ID included with this output. + /// + /// This field may be `Some` even if wallet would not return a payment ID. This will happen if + /// the scanned output belongs to the subaddress which spent Monero within the transaction which + /// created the output. If multiple subaddresses spent Monero within this transactions, the key + /// image with the highest index is determined to be the subaddress considered as the one + /// spending. + // TODO: Clarify and cite for point A ("highest index spent key image"??) + pub fn payment_id(&self) -> Option { + self.metadata.payment_id + } + + /// The arbitrary data from the `extra` field of the transaction which created this output. + pub fn arbitrary_data(&self) -> &[Vec] { + &self.metadata.arbitrary_data + } + + /// Write the WalletOutput. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn write(&self, w: &mut W) -> io::Result<()> { + self.absolute_id.write(w)?; + self.relative_id.write(w)?; + self.data.write(w)?; + self.metadata.write(w) + } + + /// Serialize the WalletOutput to a `Vec`. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn serialize(&self) -> Vec { + let mut serialized = Vec::with_capacity(128); + self.write(&mut serialized).unwrap(); + serialized + } + + /// Read a WalletOutput. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn read(r: &mut R) -> io::Result { + Ok(WalletOutput { + absolute_id: AbsoluteId::read(r)?, + relative_id: RelativeId::read(r)?, + data: OutputData::read(r)?, + metadata: Metadata::read(r)?, + }) + } +} diff --git a/coins/monero/wallet/src/scan.rs b/coins/monero/wallet/src/scan.rs new file mode 100644 index 000000000..442c05640 --- /dev/null +++ b/coins/monero/wallet/src/scan.rs @@ -0,0 +1,457 @@ +use core::ops::Deref; +use std_shims::{alloc::format, vec, vec::Vec, string::ToString, collections::HashMap}; + +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, edwards::CompressedEdwardsY}; + +use monero_rpc::{RpcError, Rpc}; +use monero_serai::{ + io::*, + primitives::Commitment, + transaction::{Timelock, Transaction}, + block::Block, +}; +use crate::{ + address::SubaddressIndex, ViewPair, GuaranteedViewPair, output::*, PaymentId, Extra, + SharedKeyDerivations, +}; + +/// A collection of potentially additionally timelocked outputs. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct Timelocked(Vec); + +impl Timelocked { + /// Return the outputs which aren't subject to an additional timelock. + #[must_use] + pub fn not_additionally_locked(self) -> Vec { + let mut res = vec![]; + for output in &self.0 { + if output.additional_timelock() == Timelock::None { + res.push(output.clone()); + } + } + res + } + + /// Return the outputs whose additional timelock unlocks by the specified block/time. + /// + /// Additional timelocks are almost never used outside of miner transactions, and are + /// increasingly planned for removal. Ignoring non-miner additionally-timelocked outputs is + /// recommended. + /// + /// `block` is the block number of the block the additional timelock must be satsified by. + /// + /// `time` is represented in seconds since the epoch. Please note Monero uses an on-chain + /// deterministic clock for time which is subject to variance from the real world time. This time + /// argument will be evaluated against Monero's clock, not the local system's clock. + #[must_use] + pub fn additional_timelock_satisfied_by(self, block: usize, time: u64) -> Vec { + let mut res = vec![]; + for output in &self.0 { + if (output.additional_timelock() <= Timelock::Block(block)) || + (output.additional_timelock() <= Timelock::Time(time)) + { + res.push(output.clone()); + } + } + res + } + + /// Ignore the timelocks and return all outputs within this container. + #[must_use] + pub fn ignore_additional_timelock(mut self) -> Vec { + let mut res = vec![]; + core::mem::swap(&mut self.0, &mut res); + res + } +} + +#[derive(Clone)] +struct InternalScanner { + pair: ViewPair, + guaranteed: bool, + subaddresses: HashMap>, +} + +impl Zeroize for InternalScanner { + fn zeroize(&mut self) { + self.pair.zeroize(); + self.guaranteed.zeroize(); + + // This may not be effective, unfortunately + for (mut key, mut value) in self.subaddresses.drain() { + key.zeroize(); + value.zeroize(); + } + } +} +impl Drop for InternalScanner { + fn drop(&mut self) { + self.zeroize(); + } +} +impl ZeroizeOnDrop for InternalScanner {} + +impl InternalScanner { + fn new(pair: ViewPair, guaranteed: bool) -> Self { + let mut subaddresses = HashMap::new(); + subaddresses.insert(pair.spend().compress(), None); + Self { pair, guaranteed, subaddresses } + } + + fn register_subaddress(&mut self, subaddress: SubaddressIndex) { + let (spend, _) = self.pair.subaddress_keys(subaddress); + self.subaddresses.insert(spend.compress(), Some(subaddress)); + } + + fn scan_transaction( + &self, + block_hash: [u8; 32], + tx_start_index_on_blockchain: u64, + tx: &Transaction, + ) -> Result { + // Only scan TXs creating RingCT outputs + // For the full details on why this check is equivalent, please see the documentation in `scan` + if tx.version() != 2 { + return Ok(Timelocked(vec![])); + } + + // Read the extra field + let Ok(extra) = Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()) else { + return Ok(Timelocked(vec![])); + }; + + let Some((tx_keys, additional)) = extra.keys() else { + return Ok(Timelocked(vec![])); + }; + let payment_id = extra.payment_id(); + + let mut res = vec![]; + for (o, output) in tx.prefix().outputs.iter().enumerate() { + let Some(output_key) = decompress_point(output.key.to_bytes()) else { continue }; + + // Monero checks with each TX key and with the additional key for this output + + // This will be None if there's no additional keys, Some(None) if there's additional keys + // yet not one for this output (which is non-standard), and Some(Some(_)) if there's an + // additional key for this output + // https://github.com/monero-project/monero/ + // blob/04a1e2875d6e35e27bb21497988a6c822d319c28/ + // src/cryptonote_basic/cryptonote_format_utils.cpp#L1062 + let additional = additional.as_ref().map(|additional| additional.get(o)); + + #[allow(clippy::manual_let_else)] + for key in tx_keys.iter().map(|key| Some(Some(key))).chain(core::iter::once(additional)) { + // Get the key, or continue if there isn't one + let key = match key { + Some(Some(key)) => key, + Some(None) | None => continue, + }; + // Calculate the ECDH + let ecdh = Zeroizing::new(self.pair.view.deref() * key); + let output_derivations = SharedKeyDerivations::output_derivations( + if self.guaranteed { + Some(SharedKeyDerivations::uniqueness(&tx.prefix().inputs)) + } else { + None + }, + ecdh.clone(), + o, + ); + + // Check the view tag matches, if there is a view tag + if let Some(actual_view_tag) = output.view_tag { + if actual_view_tag != output_derivations.view_tag { + continue; + } + } + + // P - shared == spend + let Some(subaddress) = ({ + // The output key may be of torsion [0, 8) + // Our subtracting of a prime-order element means any torsion will be preserved + // If someone wanted to malleate output keys with distinct torsions, only one will be + // scanned accordingly (the one which has matching torsion of the spend key) + let subaddress_spend_key = + output_key - (&output_derivations.shared_key * ED25519_BASEPOINT_TABLE); + self.subaddresses.get(&subaddress_spend_key.compress()) + }) else { + continue; + }; + let subaddress = *subaddress; + + // The key offset is this shared key + let mut key_offset = output_derivations.shared_key; + if let Some(subaddress) = subaddress { + // And if this was to a subaddress, it's additionally the offset from subaddress spend + // key to the normal spend key + key_offset += self.pair.subaddress_derivation(subaddress); + } + // Since we've found an output to us, get its amount + let mut commitment = Commitment::zero(); + + // Miner transaction + if let Some(amount) = output.amount { + commitment.amount = amount; + // Regular transaction + } else { + let Transaction::V2 { proofs: Some(ref proofs), .. } = &tx else { + // Invalid transaction, as of consensus rules at the time of writing this code + Err(RpcError::InvalidNode("non-miner v2 transaction without RCT proofs".to_string()))? + }; + + commitment = match proofs.base.encrypted_amounts.get(o) { + Some(amount) => output_derivations.decrypt(amount), + // Invalid transaction, as of consensus rules at the time of writing this code + None => Err(RpcError::InvalidNode( + "RCT proofs without an encrypted amount per output".to_string(), + ))?, + }; + + // Rebuild the commitment to verify it + if Some(&commitment.calculate()) != proofs.base.commitments.get(o) { + continue; + } + } + + // Decrypt the payment ID + let payment_id = payment_id.map(|id| id ^ SharedKeyDerivations::payment_id_xor(ecdh)); + + res.push(WalletOutput { + absolute_id: AbsoluteId { + transaction: tx.hash(), + index_in_transaction: o.try_into().unwrap(), + }, + relative_id: RelativeId { + block: block_hash, + index_on_blockchain: tx_start_index_on_blockchain + u64::try_from(o).unwrap(), + }, + data: OutputData { + key: output_key, + key_offset, + commitment, + additional_timelock: tx.prefix().additional_timelock, + }, + metadata: Metadata { subaddress, payment_id, arbitrary_data: extra.data() }, + }); + + // Break to prevent public keys from being included multiple times, triggering multiple + // inclusions of the same output + break; + } + } + + Ok(Timelocked(res)) + } + + async fn scan(&mut self, rpc: &impl Rpc, block: &Block) -> Result { + if block.header.hardfork_version > 16 { + Err(RpcError::InternalError(format!( + "scanning a hardfork {} block, when we only support up to 16", + block.header.hardfork_version + )))?; + } + + let block_hash = block.hash(); + + // We obtain all TXs in full + let mut txs = vec![block.miner_transaction.clone()]; + txs.extend(rpc.get_transactions(&block.transactions).await?); + + /* + Requesting the output index for each output we sucessfully scan would cause a loss of privacy + We could instead request the output indexes for all outputs we scan, yet this would notably + increase the amount of RPC calls we make. + + We solve this by requesting the output index for the first RingCT output in the block, which + should be within the miner transaction. Then, as we scan transactions, we update the output + index ourselves. + + Please note we only will scan RingCT outputs so we only need to track the RingCT output + index. This decision was made due to spending CN outputs potentially having burdensome + requirements (the need to make a v1 TX due to insufficient decoys). + + We bound ourselves to only scanning RingCT outputs by only scanning v2 transactions. This is + safe and correct since: + + 1) v1 transactions cannot create RingCT outputs. + + https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454 + /src/cryptonote_basic/cryptonote_format_utils.cpp#L866-L869 + + 2) v2 miner transactions implicitly create RingCT outputs. + + https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454 + /src/blockchain_db/blockchain_db.cpp#L232-L241 + + 3) v2 transactions must create RingCT outputs. + + https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c45 + /src/cryptonote_core/blockchain.cpp#L3055-L3065 + + That does bound on the hard fork version being >= 3, yet all v2 TXs have a hard fork + version > 3. + + https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454 + /src/cryptonote_core/blockchain.cpp#L3417 + */ + + // Get the starting index + let mut tx_start_index_on_blockchain = { + let mut tx_start_index_on_blockchain = None; + for tx in &txs { + // If this isn't a RingCT output, or there are no outputs, move to the next TX + if (!matches!(tx, Transaction::V2 { .. })) || tx.prefix().outputs.is_empty() { + continue; + } + + let index = *rpc.get_o_indexes(tx.hash()).await?.first().ok_or_else(|| { + RpcError::InvalidNode( + "requested output indexes for a TX with outputs and got none".to_string(), + ) + })?; + tx_start_index_on_blockchain = Some(index); + break; + } + let Some(tx_start_index_on_blockchain) = tx_start_index_on_blockchain else { + // Block had no RingCT outputs + return Ok(Timelocked(vec![])); + }; + tx_start_index_on_blockchain + }; + + let mut res = Timelocked(vec![]); + for tx in txs { + // Push all outputs into our result + { + let mut this_txs_outputs = vec![]; + core::mem::swap( + &mut self.scan_transaction(block_hash, tx_start_index_on_blockchain, &tx)?.0, + &mut this_txs_outputs, + ); + res.0.extend(this_txs_outputs); + } + + // Update the RingCT starting index for the next TX + if matches!(tx, Transaction::V2 { .. }) { + tx_start_index_on_blockchain += u64::try_from(tx.prefix().outputs.len()).unwrap() + } + } + + // If the block's version is >= 12, drop all unencrypted payment IDs + // TODO: Cite rule + // TODO: What if TX extra had multiple payment IDs embedded? + if block.header.hardfork_version >= 12 { + for output in &mut res.0 { + if matches!(output.metadata.payment_id, Some(PaymentId::Unencrypted(_))) { + output.metadata.payment_id = None; + } + } + } + + Ok(res) + } +} + +/// A transaction scanner to find outputs received. +/// +/// When an output is successfully scanned, the output key MUST be checked against the local +/// database for lack of prior observation. If it was prior observed, that output is an instance +/// of the burning bug (TODO: cite) and MAY be unspendable. Only the prior received output(s) or +/// the newly received output will be spendable (as spending one will burn all of them). +/// +/// Once checked, the output key MUST be saved to the local database so future checks can be +/// performed. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Scanner(InternalScanner); + +impl Scanner { + /// Create a Scanner from a ViewPair. + pub fn new(pair: ViewPair) -> Self { + Self(InternalScanner::new(pair, false)) + } + + /// Register a subaddress to scan for. + /// + /// Subaddresses must be explicitly registered ahead of time in order to be successfully scanned. + pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) { + self.0.register_subaddress(subaddress) + } + + /* + /// Scan a transaction. + /// + /// This takes in the block hash the transaction is contained in. This method is NOT recommended + /// and MUST be used carefully. The node will receive a request for the output indexes of the + /// specified transactions, which may de-anonymize which transactions belong to a user. + pub async fn scan_transaction( + &self, + rpc: &impl Rpc, + block_hash: [u8; 32], + tx: &Transaction, + ) -> Result { + // This isn't technically illegal due to a lack of minimum output rules for a while + let Some(tx_start_index_on_blockchain) = + rpc.get_o_indexes(tx.hash()).await?.first().copied() else { + return Ok(Timelocked(vec![])) + }; + self.0.scan_transaction(block_hash, tx_start_index_on_blockchain, tx) + } + */ + + /// Scan a block. + pub async fn scan(&mut self, rpc: &impl Rpc, block: &Block) -> Result { + self.0.scan(rpc, block).await + } +} + +/// A transaction scanner to find outputs received which are guaranteed to be spendable. +/// +/// 'Guaranteed' outputs, or transactions outputs to the burning bug, are not officially specified +/// by the Monero project. They should only be used if necessary. No support outside of +/// monero-wallet is promised. +/// +/// "guaranteed to be spendable" assumes satisfaction of any timelocks in effect. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct GuaranteedScanner(InternalScanner); + +impl GuaranteedScanner { + /// Create a GuaranteedScanner from a GuaranteedViewPair. + pub fn new(pair: GuaranteedViewPair) -> Self { + Self(InternalScanner::new(pair.0, true)) + } + + /// Register a subaddress to scan for. + /// + /// Subaddresses must be explicitly registered ahead of time in order to be successfully scanned. + pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) { + self.0.register_subaddress(subaddress) + } + + /* + /// Scan a transaction. + /// + /// This takes in the block hash the transaction is contained in. This method is NOT recommended + /// and MUST be used carefully. The node will receive a request for the output indexes of the + /// specified transactions, which may de-anonymize which transactions belong to a user. + pub async fn scan_transaction( + &self, + rpc: &impl Rpc, + block_hash: [u8; 32], + tx: &Transaction, + ) -> Result { + // This isn't technically illegal due to a lack of minimum output rules for a while + let Some(tx_start_index_on_blockchain) = + rpc.get_o_indexes(tx.hash()).await?.first().copied() else { + return Ok(Timelocked(vec![])) + }; + self.0.scan_transaction(block_hash, tx_start_index_on_blockchain, tx) + } + */ + + /// Scan a block. + pub async fn scan(&mut self, rpc: &impl Rpc, block: &Block) -> Result { + self.0.scan(rpc, block).await + } +} diff --git a/coins/monero/wallet/src/send/eventuality.rs b/coins/monero/wallet/src/send/eventuality.rs new file mode 100644 index 000000000..ff93c1ac6 --- /dev/null +++ b/coins/monero/wallet/src/send/eventuality.rs @@ -0,0 +1,137 @@ +use std_shims::{vec::Vec, io}; + +use zeroize::Zeroize; + +use crate::{ + ringct::RctProofs, + transaction::{Input, Timelock, Transaction}, + send::SignableTransaction, +}; + +/// The eventual output of a SignableTransaction. +/// +/// If a SignableTransaction is signed and published on-chain, it will create a Transaction +/// identifiable to whoever else has the same SignableTransaction (with the same outgoing view +/// key). This structure enables checking if a Transaction is in fact such an output, as it can. +/// +/// Since Monero is a privacy coin without outgoing view keys, this only performs a fuzzy match. +/// The fuzzy match executes over the outputs and associated data necessary to work with the +/// outputs (the transaction randomness, ciphertexts). This transaction does not check if the +/// inputs intended to be spent where actually the inputs spent (as infeasible). +/// +/// The transaction randomness does bind to the inputs intended to be spent, so an on-chain +/// transaction will not match for multiple `Eventuality`s unless the `SignableTransaction`s they +/// were built from were in conflict (and their intended transactions cannot simultaneously exist +/// on-chain). +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] +pub struct Eventuality(SignableTransaction); + +impl From for Eventuality { + fn from(tx: SignableTransaction) -> Eventuality { + Eventuality(tx) + } +} + +impl Eventuality { + /// Return the `extra` field any transaction following this intent would use. + /// + /// This enables building a HashMap of Extra -> Eventuality for efficiently fetching the + /// `Eventuality` an on-chain transaction may complete. + /// + /// This extra is cryptographically bound to the inputs intended to be spent. If the + /// `SignableTransaction`s the `Eventuality`s are built from are not in conflict (their intended + /// transactions can simultaneously exist on-chain), then each extra will only have a single + /// Eventuality associated (barring a cryptographic problem considered hard failing). + pub fn extra(&self) -> Vec { + self.0.extra() + } + + /// Return if this TX matches the SignableTransaction this was created from. + /// + /// Matching the SignableTransaction means this transaction created the expected outputs, they're + /// scannable, they're not locked, and this transaction claims to use the intended inputs (though + /// this is not guaranteed). This 'claim' is evaluated by this transaction using the transaction + /// keys derived from the intended inputs. This ensures two SignableTransactions with the same + /// intended payments don't match for each other's `Eventuality`s (as they'll have distinct + /// inputs intended). + #[must_use] + pub fn matches(&self, tx: &Transaction) -> bool { + // Verify extra + if self.0.extra() != tx.prefix().extra { + return false; + } + + // Also ensure no timelock was set + if tx.prefix().additional_timelock != Timelock::None { + return false; + } + + // Check the amount of inputs aligns + if tx.prefix().inputs.len() != self.0.inputs.len() { + return false; + } + // Collect the key images used by this transaction + let Ok(key_images) = tx + .prefix() + .inputs + .iter() + .map(|input| match input { + Input::Gen(_) => Err(()), + Input::ToKey { key_image, .. } => Ok(*key_image), + }) + .collect::, _>>() + else { + return false; + }; + + // Check the outputs + if self.0.outputs(&key_images) != tx.prefix().outputs { + return false; + } + + // Check the encrypted amounts and commitments + let commitments_and_encrypted_amounts = self.0.commitments_and_encrypted_amounts(&key_images); + let Transaction::V2 { proofs: Some(RctProofs { ref base, .. }), .. } = tx else { + return false; + }; + if base.commitments != + commitments_and_encrypted_amounts + .iter() + .map(|(commitment, _)| commitment.calculate()) + .collect::>() + { + return false; + } + if base.encrypted_amounts != + commitments_and_encrypted_amounts.into_iter().map(|(_, amount)| amount).collect::>() + { + return false; + } + + true + } + + /// Write the Eventuality. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn write(&self, w: &mut W) -> io::Result<()> { + self.0.write(w) + } + + /// Serialize the Eventuality to a `Vec`. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn serialize(&self) -> Vec { + self.0.serialize() + } + + /// Read a Eventuality. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn read(r: &mut R) -> io::Result { + Ok(Eventuality(SignableTransaction::read(r)?)) + } +} diff --git a/coins/monero/wallet/src/send/mod.rs b/coins/monero/wallet/src/send/mod.rs new file mode 100644 index 000000000..625157c9d --- /dev/null +++ b/coins/monero/wallet/src/send/mod.rs @@ -0,0 +1,583 @@ +use core::{ops::Deref, fmt}; +use std_shims::{ + io, vec, + vec::Vec, + string::{String, ToString}, +}; + +use zeroize::{Zeroize, Zeroizing}; + +use rand_core::{RngCore, CryptoRng}; +use rand::seq::SliceRandom; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, Scalar, EdwardsPoint}; +#[cfg(feature = "multisig")] +use frost::FrostError; + +use crate::{ + io::*, + generators::{MAX_COMMITMENTS, hash_to_point}, + primitives::Decoys, + ringct::{ + clsag::{ClsagError, ClsagContext, Clsag}, + RctType, RctPrunable, RctProofs, + }, + transaction::Transaction, + extra::MAX_ARBITRARY_DATA_SIZE, + address::{Network, MoneroAddress}, + rpc::FeeRate, + ViewPair, GuaranteedViewPair, WalletOutput, +}; + +mod tx_keys; +mod tx; +mod eventuality; +pub use eventuality::Eventuality; + +#[cfg(feature = "multisig")] +mod multisig; +#[cfg(feature = "multisig")] +pub use multisig::{TransactionMachine, TransactionSignMachine, TransactionSignatureMachine}; + +pub(crate) fn key_image_sort(x: &EdwardsPoint, y: &EdwardsPoint) -> core::cmp::Ordering { + x.compress().to_bytes().cmp(&y.compress().to_bytes()).reverse() +} + +#[derive(Clone, PartialEq, Eq, Zeroize)] +enum ChangeEnum { + None, + AddressOnly(MoneroAddress), + AddressWithView(MoneroAddress, Zeroizing), +} + +impl fmt::Debug for ChangeEnum { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ChangeEnum::None => f.debug_struct("ChangeEnum::None").finish_non_exhaustive(), + ChangeEnum::AddressOnly(addr) => { + f.debug_struct("ChangeEnum::AddressOnly").field("addr", &addr).finish() + } + ChangeEnum::AddressWithView(addr, _) => { + f.debug_struct("ChangeEnum::AddressWithView").field("addr", &addr).finish_non_exhaustive() + } + } + } +} + +/// Specification for a change output. +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] +pub struct Change(ChangeEnum); + +impl Change { + /// Create a change output specification. + /// + /// This take the view key as Monero assumes it has the view key for change outputs. It optimizes + /// its wallet protocol accordingly. + pub fn new(view: &ViewPair) -> Change { + Change(ChangeEnum::AddressWithView( + // Which network doesn't matter as the derivations will all be the same + // TODO: Support subaddresses + view.legacy_address(Network::Mainnet), + view.view.clone(), + )) + } + + /// Create a change output specification for a guaranteed view pair. + /// + /// This take the view key as Monero assumes it has the view key for change outputs. It optimizes + /// its wallet protocol accordingly. + pub fn guaranteed(view: &GuaranteedViewPair) -> Change { + Change(ChangeEnum::AddressWithView( + view.address( + // Which network doesn't matter as the derivations will all be the same + Network::Mainnet, + // TODO: Support subaddresses + None, + None, + ), + view.0.view.clone(), + )) + } + + /// Create a fingerprintable change output specification. + /// + /// You MUST assume this will harm your privacy. Only use this if you know what you're doing. + /// + /// If the change address is Some, this will be unable to optimize the transaction as the + /// Monero wallet protocol expects it can (due to presumably having the view key for the change + /// output). If a transaction should be optimized, and isn'tm it will be fingerprintable. + /// + /// If the change address is None, there are two fingerprints: + /// + /// 1) The change in the TX is shunted to the fee (making it fingerprintable). + /// + /// 2) If there are two outputs in the TX, Monero would create a payment ID for the non-change + /// output so an observer can't tell apart TXs with a payment ID from TXs without a payment + /// ID. monero-wallet will simply not create a payment ID in this case, revealing it's a + /// monero-wallet TX without change. + pub fn fingerprintable(address: Option) -> Change { + if let Some(address) = address { + Change(ChangeEnum::AddressOnly(address)) + } else { + Change(ChangeEnum::None) + } + } +} + +#[derive(Clone, PartialEq, Eq, Zeroize)] +enum InternalPayment { + Payment(MoneroAddress, u64), + Change(MoneroAddress, Option>), +} + +impl InternalPayment { + fn address(&self) -> &MoneroAddress { + match self { + InternalPayment::Payment(addr, _) | InternalPayment::Change(addr, _) => addr, + } + } +} + +impl fmt::Debug for InternalPayment { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + InternalPayment::Payment(addr, amount) => f + .debug_struct("InternalPayment::Payment") + .field("addr", &addr) + .field("amount", &amount) + .finish(), + InternalPayment::Change(addr, _) => { + f.debug_struct("InternalPayment::Change").field("addr", &addr).finish_non_exhaustive() + } + } + } +} + +/// An error while sending Monero. +#[derive(Clone, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum SendError { + /// The RingCT type to produce proofs for this transaction with weren't supported. + #[cfg_attr(feature = "std", error("this library doesn't yet support that RctType"))] + UnsupportedRctType, + /// The transaction had no inputs specified. + #[cfg_attr(feature = "std", error("no inputs"))] + NoInputs, + /// The decoy quantity was invalid for the specified RingCT type. + #[cfg_attr(feature = "std", error("invalid number of decoys"))] + InvalidDecoyQuantity, + /// The transaction had no outputs specified. + #[cfg_attr(feature = "std", error("no outputs"))] + NoOutputs, + /// The transaction had too many outputs specified. + #[cfg_attr(feature = "std", error("too many outputs"))] + TooManyOutputs, + /// The transaction did not have a change output, and did not have two outputs. + /// + /// Monero requires all transactions have at least two outputs, assuming one payment and one + /// change (or at least one dummy and one change). Accordingly, specifying no change and only + /// one payment prevents creating a valid transaction + #[cfg_attr(feature = "std", error("only one output and no change address"))] + NoChange, + /// Multiple addresses had payment IDs specified. + /// + /// Only one payment ID is allowed per transaction. + #[cfg_attr(feature = "std", error("multiple addresses with payment IDs"))] + MultiplePaymentIds, + /// Too much arbitrary data was specified. + #[cfg_attr(feature = "std", error("too much data"))] + TooMuchArbitraryData, + /// The created transaction was too large. + #[cfg_attr(feature = "std", error("too large of a transaction"))] + TooLargeTransaction, + /// This transaction could not pay for itself. + #[cfg_attr( + feature = "std", + error( + "not enough funds (inputs {inputs}, outputs {outputs}, necessary_fee {necessary_fee:?})" + ) + )] + NotEnoughFunds { + /// The amount of funds the inputs contributed. + inputs: u64, + /// The amount of funds the outputs required. + outputs: u64, + /// The fee necessary to be paid on top. + /// + /// If this is None, it is because the fee was not calculated as the outputs alone caused this + /// error. + necessary_fee: Option, + }, + /// This transaction is being signed with the wrong private key. + #[cfg_attr(feature = "std", error("wrong spend private key"))] + WrongPrivateKey, + /// This transaction was read from a bytestream which was malicious. + #[cfg_attr( + feature = "std", + error("this SignableTransaction was created by deserializing a malicious serialization") + )] + MaliciousSerialization, + /// There was an error when working with the CLSAGs. + #[cfg_attr(feature = "std", error("clsag error ({0})"))] + ClsagError(ClsagError), + /// There was an error when working with FROST. + #[cfg(feature = "multisig")] + #[cfg_attr(feature = "std", error("frost error {0}"))] + FrostError(FrostError), +} + +/// A signable transaction. +#[derive(Clone, PartialEq, Eq, Debug, Zeroize)] +pub struct SignableTransaction { + rct_type: RctType, + outgoing_view_key: Zeroizing<[u8; 32]>, + inputs: Vec<(WalletOutput, Decoys)>, + payments: Vec, + data: Vec>, + fee_rate: FeeRate, +} + +struct SignableTransactionWithKeyImages { + intent: SignableTransaction, + key_images: Vec, +} + +impl SignableTransaction { + fn validate(&self) -> Result<(), SendError> { + match self.rct_type { + RctType::ClsagBulletproof | RctType::ClsagBulletproofPlus => {} + _ => Err(SendError::UnsupportedRctType)?, + } + + if self.inputs.is_empty() { + Err(SendError::NoInputs)?; + } + for (_, decoys) in &self.inputs { + // TODO: Add a function for the ring length + if decoys.len() != + match self.rct_type { + RctType::ClsagBulletproof => 11, + RctType::ClsagBulletproofPlus => 16, + _ => panic!("unsupported RctType"), + } + { + Err(SendError::InvalidDecoyQuantity)?; + } + } + + // Check we have at least one non-change output + if !self.payments.iter().any(|payment| matches!(payment, InternalPayment::Payment(_, _))) { + Err(SendError::NoOutputs)?; + } + // If we don't have at least two outputs, as required by Monero, error + if self.payments.len() < 2 { + Err(SendError::NoChange)?; + } + // Check we don't have multiple Change outputs due to decoding a malicious serialization + { + let mut change_count = 0; + for payment in &self.payments { + change_count += usize::from(u8::from(matches!(payment, InternalPayment::Change(_, _)))); + } + if change_count > 1 { + Err(SendError::MaliciousSerialization)?; + } + } + + // Make sure there's at most one payment ID + { + let mut payment_ids = 0; + for payment in &self.payments { + payment_ids += usize::from(u8::from(payment.address().payment_id().is_some())); + } + if payment_ids > 1 { + Err(SendError::MultiplePaymentIds)?; + } + } + + if self.payments.len() > MAX_COMMITMENTS { + Err(SendError::TooManyOutputs)?; + } + + // Check the length of each arbitrary data + for part in &self.data { + if part.len() > MAX_ARBITRARY_DATA_SIZE { + Err(SendError::TooMuchArbitraryData)?; + } + } + + // Check the length of TX extra + // https://github.com/monero-project/monero/pull/8733 + const MAX_EXTRA_SIZE: usize = 1060; + if self.extra().len() > MAX_EXTRA_SIZE { + Err(SendError::TooMuchArbitraryData)?; + } + + // Make sure we have enough funds + let in_amount = self.inputs.iter().map(|(input, _)| input.commitment().amount).sum::(); + let payments_amount = self + .payments + .iter() + .filter_map(|payment| match payment { + InternalPayment::Payment(_, amount) => Some(amount), + InternalPayment::Change(_, _) => None, + }) + .sum::(); + let (weight, necessary_fee) = self.weight_and_necessary_fee(); + if in_amount < (payments_amount + necessary_fee) { + Err(SendError::NotEnoughFunds { + inputs: in_amount, + outputs: payments_amount, + necessary_fee: Some(necessary_fee), + })?; + } + + // The actual limit is half the block size, and for the minimum block size of 300k, that'd be + // 150k + // wallet2 will only create transactions up to 100k bytes however + // TODO: Cite + const MAX_TX_SIZE: usize = 100_000; + if weight >= MAX_TX_SIZE { + Err(SendError::TooLargeTransaction)?; + } + + Ok(()) + } + + /// Create a new SignableTransaction. + /// + /// `outgoing_view_key` is used to seed the RNGs for this transaction. Anyone with knowledge of + /// the outgoing view key will be able to identify a transaction produced with this methodology, + /// and the data within it. Accordingly, it must be treated as a private key. + /// + /// `data` represents arbitrary data which will be embedded into the transaction's `extra` field. + /// The embedding occurs using an `ExtraField::Nonce` with a custom marker byte (as to not + /// conflict with a payment ID). + pub fn new( + rct_type: RctType, + outgoing_view_key: Zeroizing<[u8; 32]>, + inputs: Vec<(WalletOutput, Decoys)>, + payments: Vec<(MoneroAddress, u64)>, + change: Change, + data: Vec>, + fee_rate: FeeRate, + ) -> Result { + // Re-format the payments and change into a consolidated payments list + let mut payments = payments + .into_iter() + .map(|(addr, amount)| InternalPayment::Payment(addr, amount)) + .collect::>(); + match change.0 { + ChangeEnum::None => {} + ChangeEnum::AddressOnly(addr) => payments.push(InternalPayment::Change(addr, None)), + ChangeEnum::AddressWithView(addr, view) => { + payments.push(InternalPayment::Change(addr, Some(view))) + } + } + + let mut res = + SignableTransaction { rct_type, outgoing_view_key, inputs, payments, data, fee_rate }; + res.validate()?; + + // Shuffle the payments + { + let mut rng = res.seeded_rng(b"shuffle_payments"); + res.payments.shuffle(&mut rng); + } + + Ok(res) + } + + /// The fee rate this transaction uses. + pub fn fee_rate(&self) -> FeeRate { + self.fee_rate + } + + /// The fee this transaction requires. + /// + /// This is distinct from the fee this transaction will use. If no change output is specified, + /// all unspent coins will be shunted to the fee. + pub fn necessary_fee(&self) -> u64 { + self.weight_and_necessary_fee().1 + } + + /// Write a SignableTransaction. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn write(&self, w: &mut W) -> io::Result<()> { + fn write_input(input: &(WalletOutput, Decoys), w: &mut W) -> io::Result<()> { + input.0.write(w)?; + input.1.write(w) + } + + fn write_payment(payment: &InternalPayment, w: &mut W) -> io::Result<()> { + match payment { + InternalPayment::Payment(addr, amount) => { + w.write_all(&[0])?; + write_vec(write_byte, addr.to_string().as_bytes(), w)?; + w.write_all(&amount.to_le_bytes()) + } + InternalPayment::Change(addr, change_view) => { + w.write_all(&[1])?; + write_vec(write_byte, addr.to_string().as_bytes(), w)?; + if let Some(view) = change_view.as_ref() { + w.write_all(&[1])?; + write_scalar(view, w) + } else { + w.write_all(&[0]) + } + } + } + } + + write_byte(&u8::from(self.rct_type), w)?; + w.write_all(self.outgoing_view_key.as_slice())?; + write_vec(write_input, &self.inputs, w)?; + write_vec(write_payment, &self.payments, w)?; + write_vec(|data, w| write_vec(write_byte, data, w), &self.data, w)?; + self.fee_rate.write(w) + } + + /// Serialize the SignableTransaction to a `Vec`. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn serialize(&self) -> Vec { + let mut buf = Vec::with_capacity(256); + self.write(&mut buf).unwrap(); + buf + } + + /// Read a `SignableTransaction`. + /// + /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol + /// defined serialization. + pub fn read(r: &mut R) -> io::Result { + fn read_input(r: &mut impl io::Read) -> io::Result<(WalletOutput, Decoys)> { + Ok((WalletOutput::read(r)?, Decoys::read(r)?)) + } + + fn read_address(r: &mut R) -> io::Result { + String::from_utf8(read_vec(read_byte, r)?) + .ok() + .and_then(|str| MoneroAddress::from_str_with_unchecked_network(&str).ok()) + .ok_or_else(|| io::Error::other("invalid address")) + } + + fn read_payment(r: &mut R) -> io::Result { + Ok(match read_byte(r)? { + 0 => InternalPayment::Payment(read_address(r)?, read_u64(r)?), + 1 => InternalPayment::Change( + read_address(r)?, + match read_byte(r)? { + 0 => None, + 1 => Some(Zeroizing::new(read_scalar(r)?)), + _ => Err(io::Error::other("invalid change view"))?, + }, + ), + _ => Err(io::Error::other("invalid payment"))?, + }) + } + + let res = SignableTransaction { + rct_type: RctType::try_from(read_byte(r)?) + .map_err(|()| io::Error::other("unsupported/invalid RctType"))?, + outgoing_view_key: Zeroizing::new(read_bytes(r)?), + inputs: read_vec(read_input, r)?, + payments: read_vec(read_payment, r)?, + data: read_vec(|r| read_vec(read_byte, r), r)?, + fee_rate: FeeRate::read(r)?, + }; + match res.validate() { + Ok(()) => {} + Err(e) => Err(io::Error::other(e))?, + } + Ok(res) + } + + fn with_key_images(mut self, key_images: Vec) -> SignableTransactionWithKeyImages { + debug_assert_eq!(self.inputs.len(), key_images.len()); + + // Sort the inputs by their key images + let mut sorted_inputs = self.inputs.into_iter().zip(key_images).collect::>(); + sorted_inputs + .sort_by(|(_, key_image_a), (_, key_image_b)| key_image_sort(key_image_a, key_image_b)); + + self.inputs = Vec::with_capacity(sorted_inputs.len()); + let mut key_images = Vec::with_capacity(sorted_inputs.len()); + for (input, key_image) in sorted_inputs { + self.inputs.push(input); + key_images.push(key_image); + } + + SignableTransactionWithKeyImages { intent: self, key_images } + } + + /// Sign this transaction. + pub fn sign( + self, + rng: &mut (impl RngCore + CryptoRng), + sender_spend_key: &Zeroizing, + ) -> Result { + // Calculate the key images + let mut key_images = vec![]; + for (input, _) in &self.inputs { + let input_key = Zeroizing::new(sender_spend_key.deref() + input.key_offset()); + if (input_key.deref() * ED25519_BASEPOINT_TABLE) != input.key() { + Err(SendError::WrongPrivateKey)?; + } + let key_image = input_key.deref() * hash_to_point(input.key().compress().to_bytes()); + key_images.push(key_image); + } + + // Convert to a SignableTransactionWithKeyImages + let tx = self.with_key_images(key_images); + + // Prepare the CLSAG signatures + let mut clsag_signs = Vec::with_capacity(tx.intent.inputs.len()); + for (input, decoys) in &tx.intent.inputs { + // Re-derive the input key as this will be in a different order + let input_key = Zeroizing::new(sender_spend_key.deref() + input.key_offset()); + clsag_signs.push(( + input_key, + ClsagContext::new(decoys.clone(), input.commitment().clone()) + .map_err(SendError::ClsagError)?, + )); + } + + // Get the output commitments' mask sum + let mask_sum = tx.intent.sum_output_masks(&tx.key_images); + + // Get the actual TX, just needing the CLSAGs + let mut tx = tx.transaction_without_signatures(); + + // Sign the CLSAGs + let clsags_and_pseudo_outs = + Clsag::sign(rng, clsag_signs, mask_sum, tx.signature_hash().unwrap()) + .map_err(SendError::ClsagError)?; + + // Fill in the CLSAGs/pseudo-outs + let inputs_len = tx.prefix().inputs.len(); + let Transaction::V2 { + proofs: + Some(RctProofs { + prunable: RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. }, + .. + }), + .. + } = tx + else { + panic!("not signing clsag?") + }; + *clsags = Vec::with_capacity(inputs_len); + *pseudo_outs = Vec::with_capacity(inputs_len); + for (clsag, pseudo_out) in clsags_and_pseudo_outs { + clsags.push(clsag); + pseudo_outs.push(pseudo_out); + } + + // Return the signed TX + Ok(tx) + } +} diff --git a/coins/monero/wallet/src/send/multisig.rs b/coins/monero/wallet/src/send/multisig.rs new file mode 100644 index 000000000..79e8d5f37 --- /dev/null +++ b/coins/monero/wallet/src/send/multisig.rs @@ -0,0 +1,304 @@ +use std_shims::{ + vec::Vec, + io::{self, Read}, + collections::HashMap, +}; + +use rand_core::{RngCore, CryptoRng}; + +use group::ff::Field; +use curve25519_dalek::{traits::Identity, Scalar, EdwardsPoint}; +use dalek_ff_group as dfg; + +use transcript::{Transcript, RecommendedTranscript}; +use frost::{ + curve::Ed25519, + Participant, FrostError, ThresholdKeys, + dkg::lagrange, + sign::{ + Preprocess, CachedPreprocess, SignatureShare, PreprocessMachine, SignMachine, SignatureMachine, + AlgorithmMachine, AlgorithmSignMachine, AlgorithmSignatureMachine, + }, +}; + +use monero_serai::{ + ringct::{ + clsag::{ClsagContext, ClsagMultisigMaskSender, ClsagAddendum, ClsagMultisig}, + RctPrunable, RctProofs, + }, + transaction::Transaction, +}; +use crate::send::{SendError, SignableTransaction, key_image_sort}; + +/// Initial FROST machine to produce a signed transaction. +pub struct TransactionMachine { + signable: SignableTransaction, + + i: Participant, + + // The key image generator, and the scalar offset from the spend key + key_image_generators_and_offsets: Vec<(EdwardsPoint, Scalar)>, + clsags: Vec<(ClsagMultisigMaskSender, AlgorithmMachine)>, +} + +/// Second FROST machine to produce a signed transaction. +pub struct TransactionSignMachine { + signable: SignableTransaction, + + i: Participant, + + key_image_generators_and_offsets: Vec<(EdwardsPoint, Scalar)>, + clsags: Vec<(ClsagMultisigMaskSender, AlgorithmSignMachine)>, + + our_preprocess: Vec>, +} + +/// Final FROST machine to produce a signed transaction. +pub struct TransactionSignatureMachine { + tx: Transaction, + clsags: Vec>, +} + +impl SignableTransaction { + /// Create a FROST signing machine out of this signable transaction. + pub fn multisig(self, keys: &ThresholdKeys) -> Result { + let mut clsags = vec![]; + + let mut key_image_generators_and_offsets = vec![]; + for (i, (input, decoys)) in self.inputs.iter().enumerate() { + // Check this is the right set of keys + let offset = keys.offset(dfg::Scalar(input.key_offset())); + if offset.group_key().0 != input.key() { + Err(SendError::WrongPrivateKey)?; + } + + let context = ClsagContext::new(decoys.clone(), input.commitment().clone()) + .map_err(SendError::ClsagError)?; + let (clsag, clsag_mask_send) = ClsagMultisig::new( + RecommendedTranscript::new(b"Monero Multisignature Transaction"), + context, + ); + key_image_generators_and_offsets.push(( + clsag.key_image_generator(), + keys.current_offset().unwrap_or(dfg::Scalar::ZERO).0 + self.inputs[i].0.key_offset(), + )); + clsags.push((clsag_mask_send, AlgorithmMachine::new(clsag, offset))); + } + + Ok(TransactionMachine { + signable: self, + i: keys.params().i(), + key_image_generators_and_offsets, + clsags, + }) + } +} + +impl PreprocessMachine for TransactionMachine { + type Preprocess = Vec>; + type Signature = Transaction; + type SignMachine = TransactionSignMachine; + + fn preprocess( + mut self, + rng: &mut R, + ) -> (TransactionSignMachine, Self::Preprocess) { + // Iterate over each CLSAG calling preprocess + let mut preprocesses = Vec::with_capacity(self.clsags.len()); + let clsags = self + .clsags + .drain(..) + .map(|(clsag_mask_send, clsag)| { + let (clsag, preprocess) = clsag.preprocess(rng); + preprocesses.push(preprocess); + (clsag_mask_send, clsag) + }) + .collect(); + let our_preprocess = preprocesses.clone(); + + ( + TransactionSignMachine { + signable: self.signable, + + i: self.i, + + key_image_generators_and_offsets: self.key_image_generators_and_offsets, + clsags, + + our_preprocess, + }, + preprocesses, + ) + } +} + +impl SignMachine for TransactionSignMachine { + type Params = (); + type Keys = ThresholdKeys; + type Preprocess = Vec>; + type SignatureShare = Vec>; + type SignatureMachine = TransactionSignatureMachine; + + fn cache(self) -> CachedPreprocess { + unimplemented!( + "Monero transactions don't support caching their preprocesses due to {}", + "being already bound to a specific transaction" + ); + } + + fn from_cache( + (): (), + _: ThresholdKeys, + _: CachedPreprocess, + ) -> (Self, Self::Preprocess) { + unimplemented!( + "Monero transactions don't support caching their preprocesses due to {}", + "being already bound to a specific transaction" + ); + } + + fn read_preprocess(&self, reader: &mut R) -> io::Result { + self.clsags.iter().map(|clsag| clsag.1.read_preprocess(reader)).collect() + } + + fn sign( + self, + mut commitments: HashMap, + msg: &[u8], + ) -> Result<(TransactionSignatureMachine, Self::SignatureShare), FrostError> { + if !msg.is_empty() { + panic!("message was passed to the TransactionMachine when it generates its own"); + } + + // We do not need to be included here, yet this set of signers has yet to be validated + // We explicitly remove ourselves to ensure we aren't included twice, if we were redundantly + // included + commitments.remove(&self.i); + + // Find out who's included + let mut included = commitments.keys().copied().collect::>(); + // This push won't duplicate due to the above removal + included.push(self.i); + // unstable sort may reorder elements of equal order + // Given our lack of duplicates, we should have no elements of equal order + included.sort_unstable(); + + // Start calculating the key images, as needed on the TX level + let mut key_images = vec![EdwardsPoint::identity(); self.clsags.len()]; + for (image, (generator, offset)) in + key_images.iter_mut().zip(&self.key_image_generators_and_offsets) + { + *image = generator * offset; + } + + // Convert the serialized nonces commitments to a parallelized Vec + let mut commitments = (0 .. self.clsags.len()) + .map(|c| { + included + .iter() + .map(|l| { + let preprocess = if *l == self.i { + self.our_preprocess[c].clone() + } else { + commitments.get_mut(l).ok_or(FrostError::MissingParticipant(*l))?[c].clone() + }; + + // While here, calculate the key image as needed to call sign + // The CLSAG algorithm will independently calculate the key image/verify these shares + key_images[c] += + preprocess.addendum.key_image_share().0 * lagrange::(*l, &included).0; + + Ok((*l, preprocess)) + }) + .collect::, _>>() + }) + .collect::, _>>()?; + + // The above inserted our own preprocess into these maps (which is unnecessary) + // Remove it now + for map in &mut commitments { + map.remove(&self.i); + } + + // The actual TX will have sorted its inputs by key image + // We apply the same sort now to our CLSAG machines + let mut clsags = Vec::with_capacity(self.clsags.len()); + for ((key_image, clsag), commitments) in key_images.iter().zip(self.clsags).zip(commitments) { + clsags.push((key_image, clsag, commitments)); + } + clsags.sort_by(|x, y| key_image_sort(x.0, y.0)); + let clsags = + clsags.into_iter().map(|(_, clsag, commitments)| (clsag, commitments)).collect::>(); + + // Specify the TX's key images + let tx = self.signable.with_key_images(key_images); + + // We now need to decide the masks for each CLSAG + let clsag_len = clsags.len(); + let output_masks = tx.intent.sum_output_masks(&tx.key_images); + let mut rng = tx.intent.seeded_rng(b"multisig_pseudo_out_masks"); + let mut sum_pseudo_outs = Scalar::ZERO; + let mut to_sign = Vec::with_capacity(clsag_len); + for (i, ((clsag_mask_send, clsag), commitments)) in clsags.into_iter().enumerate() { + let mut mask = Scalar::random(&mut rng); + if i == (clsag_len - 1) { + mask = output_masks - sum_pseudo_outs; + } else { + sum_pseudo_outs += mask; + } + clsag_mask_send.send(mask); + to_sign.push((clsag, commitments)); + } + + let tx = tx.transaction_without_signatures(); + let msg = tx.signature_hash().unwrap(); + + // Iterate over each CLSAG calling sign + let mut shares = Vec::with_capacity(to_sign.len()); + let clsags = to_sign + .drain(..) + .map(|(clsag, commitments)| { + let (clsag, share) = clsag.sign(commitments, &msg)?; + shares.push(share); + Ok(clsag) + }) + .collect::>()?; + + Ok((TransactionSignatureMachine { tx, clsags }, shares)) + } +} + +impl SignatureMachine for TransactionSignatureMachine { + type SignatureShare = Vec>; + + fn read_share(&self, reader: &mut R) -> io::Result { + self.clsags.iter().map(|clsag| clsag.read_share(reader)).collect() + } + + fn complete( + mut self, + shares: HashMap, + ) -> Result { + let mut tx = self.tx; + match tx { + Transaction::V2 { + proofs: + Some(RctProofs { + prunable: RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. }, + .. + }), + .. + } => { + for (c, clsag) in self.clsags.drain(..).enumerate() { + let (clsag, pseudo_out) = clsag.complete( + shares.iter().map(|(l, shares)| (*l, shares[c].clone())).collect::>(), + )?; + clsags.push(clsag); + pseudo_outs.push(pseudo_out); + } + } + _ => unreachable!("attempted to sign a multisig TX which wasn't CLSAG"), + } + Ok(tx) + } +} diff --git a/coins/monero/wallet/src/send/tx.rs b/coins/monero/wallet/src/send/tx.rs new file mode 100644 index 000000000..237703164 --- /dev/null +++ b/coins/monero/wallet/src/send/tx.rs @@ -0,0 +1,323 @@ +use std_shims::{vec, vec::Vec}; + +use curve25519_dalek::{ + constants::{ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_TABLE}, + Scalar, EdwardsPoint, +}; + +use crate::{ + io::{varint_len, write_varint}, + primitives::Commitment, + ringct::{ + clsag::Clsag, bulletproofs::Bulletproof, EncryptedAmount, RctType, RctBase, RctPrunable, + RctProofs, + }, + transaction::{Input, Output, Timelock, TransactionPrefix, Transaction}, + extra::{ARBITRARY_DATA_MARKER, PaymentId, ExtraField, Extra}, + send::{InternalPayment, SignableTransaction, SignableTransactionWithKeyImages}, +}; + +impl SignableTransaction { + // Output the inputs for this transaction. + pub(crate) fn inputs(&self, key_images: &[EdwardsPoint]) -> Vec { + debug_assert_eq!(self.inputs.len(), key_images.len()); + + let mut res = Vec::with_capacity(self.inputs.len()); + for ((_, decoys), key_image) in self.inputs.iter().zip(key_images) { + res.push(Input::ToKey { + amount: None, + key_offsets: decoys.offsets().to_vec(), + key_image: *key_image, + }); + } + res + } + + // Output the outputs for this transaction. + pub(crate) fn outputs(&self, key_images: &[EdwardsPoint]) -> Vec { + let shared_key_derivations = self.shared_key_derivations(key_images); + debug_assert_eq!(self.payments.len(), shared_key_derivations.len()); + + let mut res = Vec::with_capacity(self.payments.len()); + for (payment, shared_key_derivations) in self.payments.iter().zip(&shared_key_derivations) { + let key = + (&shared_key_derivations.shared_key * ED25519_BASEPOINT_TABLE) + payment.address().spend(); + res.push(Output { + key: key.compress(), + amount: None, + view_tag: (match self.rct_type { + RctType::ClsagBulletproof => false, + RctType::ClsagBulletproofPlus => true, + _ => panic!("unsupported RctType"), + }) + .then_some(shared_key_derivations.view_tag), + }); + } + res + } + + // Calculate the TX extra for this transaction. + pub(crate) fn extra(&self) -> Vec { + let (tx_key, additional_keys) = self.transaction_keys_pub(); + debug_assert!(additional_keys.is_empty() || (additional_keys.len() == self.payments.len())); + let payment_id_xors = self.payment_id_xors(); + debug_assert_eq!(self.payments.len(), payment_id_xors.len()); + + let amount_of_keys = 1 + additional_keys.len(); + let mut extra = Extra::new(tx_key, additional_keys); + + if let Some((id, id_xor)) = + self.payments.iter().zip(&payment_id_xors).find_map(|(payment, payment_id_xor)| { + payment.address().payment_id().map(|id| (id, payment_id_xor)) + }) + { + let id = (u64::from_le_bytes(id) ^ u64::from_le_bytes(*id_xor)).to_le_bytes(); + let mut id_vec = Vec::with_capacity(1 + 8); + PaymentId::Encrypted(id).write(&mut id_vec).unwrap(); + extra.push(ExtraField::Nonce(id_vec)); + } else { + // If there's no payment ID, we push a dummy (as wallet2 does) if there's only one payment + if (self.payments.len() == 2) && + self.payments.iter().any(|payment| matches!(payment, InternalPayment::Change(_, _))) + { + let (_, payment_id_xor) = self + .payments + .iter() + .zip(&payment_id_xors) + .find(|(payment, _)| matches!(payment, InternalPayment::Payment(_, _))) + .expect("multiple change outputs?"); + let mut id_vec = Vec::with_capacity(1 + 8); + // The dummy payment ID is [0; 8], which when xor'd with the mask, is just the mask + PaymentId::Encrypted(*payment_id_xor).write(&mut id_vec).unwrap(); + extra.push(ExtraField::Nonce(id_vec)); + } + } + + // Include data if present + for part in &self.data { + let mut arb = vec![ARBITRARY_DATA_MARKER]; + arb.extend(part); + extra.push(ExtraField::Nonce(arb)); + } + + let mut serialized = Vec::with_capacity(32 * amount_of_keys); + extra.write(&mut serialized).unwrap(); + serialized + } + + pub(crate) fn weight_and_necessary_fee(&self) -> (usize, u64) { + /* + This transaction is variable length to: + - The decoy offsets (fixed) + - The TX extra (variable to key images, requiring an interactive protocol) + + Thankfully, the TX extra *length* is fixed. Accordingly, we can calculate the inevitable TX's + weight at this time with a shimmed transaction. + */ + let base_weight = { + let mut key_images = Vec::with_capacity(self.inputs.len()); + let mut clsags = Vec::with_capacity(self.inputs.len()); + let mut pseudo_outs = Vec::with_capacity(self.inputs.len()); + for _ in &self.inputs { + key_images.push(ED25519_BASEPOINT_POINT); + clsags.push(Clsag { + D: ED25519_BASEPOINT_POINT, + s: vec![ + Scalar::ZERO; + match self.rct_type { + RctType::ClsagBulletproof => 11, + RctType::ClsagBulletproofPlus => 16, + _ => unreachable!("unsupported RCT type"), + } + ], + c1: Scalar::ZERO, + }); + pseudo_outs.push(ED25519_BASEPOINT_POINT); + } + let mut encrypted_amounts = Vec::with_capacity(self.payments.len()); + let mut bp_commitments = Vec::with_capacity(self.payments.len()); + let mut commitments = Vec::with_capacity(self.payments.len()); + for _ in &self.payments { + encrypted_amounts.push(EncryptedAmount::Compact { amount: [0; 8] }); + bp_commitments.push(Commitment::zero()); + commitments.push(ED25519_BASEPOINT_POINT); + } + + let padded_log2 = { + let mut log2_find = 0; + while (1 << log2_find) < self.payments.len() { + log2_find += 1; + } + log2_find + }; + // This is log2 the padded amount of IPA rows + // We have 64 rows per commitment, so we need 64 * c IPA rows + // We rewrite this as 2**6 * c + // By finding the padded log2 of c, we get 2**6 * 2**p + // This declares the log2 to be 6 + p + let lr_len = 6 + padded_log2; + + let bulletproof = match self.rct_type { + RctType::ClsagBulletproof => { + let mut bp = Vec::with_capacity(((9 + (2 * lr_len)) * 32) + 2); + let push_point = |bp: &mut Vec| { + bp.push(1); + bp.extend([0; 31]); + }; + let push_scalar = |bp: &mut Vec| bp.extend([0; 32]); + for _ in 0 .. 4 { + push_point(&mut bp); + } + for _ in 0 .. 2 { + push_scalar(&mut bp); + } + for _ in 0 .. 2 { + write_varint(&lr_len, &mut bp).unwrap(); + for _ in 0 .. lr_len { + push_point(&mut bp); + } + } + for _ in 0 .. 3 { + push_scalar(&mut bp); + } + Bulletproof::read(&mut bp.as_slice()).expect("made an invalid dummy BP") + } + RctType::ClsagBulletproofPlus => { + let mut bp = Vec::with_capacity(((6 + (2 * lr_len)) * 32) + 2); + let push_point = |bp: &mut Vec| { + bp.push(1); + bp.extend([0; 31]); + }; + let push_scalar = |bp: &mut Vec| bp.extend([0; 32]); + for _ in 0 .. 3 { + push_point(&mut bp); + } + for _ in 0 .. 3 { + push_scalar(&mut bp); + } + for _ in 0 .. 2 { + write_varint(&lr_len, &mut bp).unwrap(); + for _ in 0 .. lr_len { + push_point(&mut bp); + } + } + Bulletproof::read_plus(&mut bp.as_slice()).expect("made an invalid dummy BP+") + } + _ => panic!("unsupported RctType"), + }; + + // `- 1` to remove the one byte for the 0 fee + Transaction::V2 { + prefix: TransactionPrefix { + additional_timelock: Timelock::None, + inputs: self.inputs(&key_images), + outputs: self.outputs(&key_images), + extra: self.extra(), + }, + proofs: Some(RctProofs { + base: RctBase { fee: 0, encrypted_amounts, pseudo_outs: vec![], commitments }, + prunable: RctPrunable::Clsag { bulletproof, clsags, pseudo_outs }, + }), + } + .weight() - + 1 + }; + + // We now have the base weight, without the fee encoded + // The fee itself will impact the weight as its encoding is [1, 9] bytes long + let mut possible_weights = Vec::with_capacity(9); + for i in 1 ..= 9 { + possible_weights.push(base_weight + i); + } + debug_assert_eq!(possible_weights.len(), 9); + + // We now calculate the fee which would be used for each weight + let mut possible_fees = Vec::with_capacity(9); + for weight in possible_weights { + possible_fees.push(self.fee_rate.calculate_fee_from_weight(weight)); + } + + // We now look for the fee whose length matches the length used to derive it + let mut weight_and_fee = None; + for (fee_len, possible_fee) in possible_fees.into_iter().enumerate() { + let fee_len = 1 + fee_len; + debug_assert!(1 <= fee_len); + debug_assert!(fee_len <= 9); + + // We use the first fee whose encoded length is not larger than the length used within this + // weight + // This should be because the lengths are equal, yet means if somehow none are equal, this + // will still terminate successfully + if varint_len(possible_fee) <= fee_len { + weight_and_fee = Some((base_weight + fee_len, possible_fee)); + break; + } + } + weight_and_fee.unwrap() + } +} + +impl SignableTransactionWithKeyImages { + pub(crate) fn transaction_without_signatures(&self) -> Transaction { + let commitments_and_encrypted_amounts = + self.intent.commitments_and_encrypted_amounts(&self.key_images); + let mut commitments = Vec::with_capacity(self.intent.payments.len()); + let mut bp_commitments = Vec::with_capacity(self.intent.payments.len()); + let mut encrypted_amounts = Vec::with_capacity(self.intent.payments.len()); + for (commitment, encrypted_amount) in commitments_and_encrypted_amounts { + commitments.push(commitment.calculate()); + bp_commitments.push(commitment); + encrypted_amounts.push(encrypted_amount); + } + let bulletproof = { + let mut bp_rng = self.intent.seeded_rng(b"bulletproof"); + (match self.intent.rct_type { + RctType::ClsagBulletproof => Bulletproof::prove(&mut bp_rng, &bp_commitments), + RctType::ClsagBulletproofPlus => Bulletproof::prove_plus(&mut bp_rng, bp_commitments), + _ => panic!("unsupported RctType"), + }) + .expect("couldn't prove BP(+)s for this many payments despite checking in constructor?") + }; + + Transaction::V2 { + prefix: TransactionPrefix { + additional_timelock: Timelock::None, + inputs: self.intent.inputs(&self.key_images), + outputs: self.intent.outputs(&self.key_images), + extra: self.intent.extra(), + }, + proofs: Some(RctProofs { + base: RctBase { + fee: if self + .intent + .payments + .iter() + .any(|payment| matches!(payment, InternalPayment::Change(_, _))) + { + // The necessary fee is the fee + self.intent.weight_and_necessary_fee().1 + } else { + // If we don't have a change output, the difference is the fee + let inputs = + self.intent.inputs.iter().map(|input| input.0.commitment().amount).sum::(); + let payments = self + .intent + .payments + .iter() + .filter_map(|payment| match payment { + InternalPayment::Payment(_, amount) => Some(amount), + InternalPayment::Change(_, _) => None, + }) + .sum::(); + // Safe since the constructor checks inputs >= (payments + fee) + inputs - payments + }, + encrypted_amounts, + pseudo_outs: vec![], + commitments, + }, + prunable: RctPrunable::Clsag { bulletproof, clsags: vec![], pseudo_outs: vec![] }, + }), + } + } +} diff --git a/coins/monero/wallet/src/send/tx_keys.rs b/coins/monero/wallet/src/send/tx_keys.rs new file mode 100644 index 000000000..8cf141f79 --- /dev/null +++ b/coins/monero/wallet/src/send/tx_keys.rs @@ -0,0 +1,241 @@ +use core::ops::Deref; +use std_shims::{vec, vec::Vec}; + +use zeroize::Zeroizing; + +use rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, Scalar, EdwardsPoint}; + +use crate::{ + primitives::{keccak256, Commitment}, + ringct::EncryptedAmount, + SharedKeyDerivations, + send::{InternalPayment, SignableTransaction, key_image_sort}, +}; + +impl SignableTransaction { + pub(crate) fn seeded_rng(&self, dst: &'static [u8]) -> ChaCha20Rng { + // Apply the DST + let mut transcript = Zeroizing::new(vec![u8::try_from(dst.len()).unwrap()]); + transcript.extend(dst); + + // Bind to the outgoing view key to prevent foreign entities from rebuilding the transcript + transcript.extend(self.outgoing_view_key.as_slice()); + + // Ensure uniqueness across transactions by binding to a use-once object + // The keys for the inputs is binding to their key images, making them use-once + let mut input_keys = self.inputs.iter().map(|(input, _)| input.key()).collect::>(); + // We sort the inputs mid-way through TX construction, so apply our own sort to ensure a + // consistent order + // We use the key image sort as it's applicable and well-defined, not because these are key + // images + input_keys.sort_by(key_image_sort); + for key in input_keys { + transcript.extend(key.compress().to_bytes()); + } + + ChaCha20Rng::from_seed(keccak256(&transcript)) + } + + fn has_payments_to_subaddresses(&self) -> bool { + self.payments.iter().any(|payment| match payment { + InternalPayment::Payment(addr, _) => addr.is_subaddress(), + InternalPayment::Change(addr, view) => { + if view.is_some() { + // It should not be possible to construct a change specification to a subaddress with a + // view key + // TODO + debug_assert!(!addr.is_subaddress()); + } + addr.is_subaddress() + } + }) + } + + fn should_use_additional_keys(&self) -> bool { + let has_payments_to_subaddresses = self.has_payments_to_subaddresses(); + if !has_payments_to_subaddresses { + return false; + } + + let has_change_view = self.payments.iter().any(|payment| match payment { + InternalPayment::Payment(_, _) => false, + InternalPayment::Change(_, view) => view.is_some(), + }); + + /* + If sending to a subaddress, the shared key is not `rG` yet `rB`. Because of this, a + per-subaddress shared key is necessary, causing the usage of additional keys. + + The one exception is if we're sending to a subaddress in a 2-output transaction. The second + output, the change output, will attempt scanning the singular key `rB` with `v rB`. While we + cannot calculate `r vB` with just `r` (as that'd require `vB` when we presumably only have + `vG` when sending), since we do in fact have `v` (due to it being our own view key for our + change output), we can still calculate the shared secret. + */ + has_payments_to_subaddresses && !((self.payments.len() == 2) && has_change_view) + } + + // Calculate the transaction keys used as randomness. + fn transaction_keys(&self) -> (Zeroizing, Vec>) { + let mut rng = self.seeded_rng(b"transaction_keys"); + + let tx_key = Zeroizing::new(Scalar::random(&mut rng)); + + let mut additional_keys = vec![]; + if self.should_use_additional_keys() { + for _ in 0 .. self.payments.len() { + additional_keys.push(Zeroizing::new(Scalar::random(&mut rng))); + } + } + (tx_key, additional_keys) + } + + fn ecdhs(&self) -> Vec> { + let (tx_key, additional_keys) = self.transaction_keys(); + debug_assert!(additional_keys.is_empty() || (additional_keys.len() == self.payments.len())); + let (tx_key_pub, additional_keys_pub) = self.transaction_keys_pub(); + debug_assert_eq!(additional_keys_pub.len(), additional_keys.len()); + + let mut res = Vec::with_capacity(self.payments.len()); + for (i, payment) in self.payments.iter().enumerate() { + let addr = payment.address(); + let key_to_use = + if addr.is_subaddress() { additional_keys.get(i).unwrap_or(&tx_key) } else { &tx_key }; + + let ecdh = match payment { + // If we don't have the view key, use the key dedicated for this address (r A) + InternalPayment::Payment(_, _) | InternalPayment::Change(_, None) => { + Zeroizing::new(key_to_use.deref() * addr.view()) + } + // If we do have the view key, use the commitment to the key (a R) + InternalPayment::Change(_, Some(view)) => Zeroizing::new(view.deref() * tx_key_pub), + }; + + res.push(ecdh); + } + res + } + + // Calculate the shared keys and the necessary derivations. + pub(crate) fn shared_key_derivations( + &self, + key_images: &[EdwardsPoint], + ) -> Vec> { + let ecdhs = self.ecdhs(); + + let uniqueness = SharedKeyDerivations::uniqueness(&self.inputs(key_images)); + + let mut res = Vec::with_capacity(self.payments.len()); + for (i, (payment, ecdh)) in self.payments.iter().zip(ecdhs).enumerate() { + let addr = payment.address(); + res.push(SharedKeyDerivations::output_derivations( + addr.is_guaranteed().then_some(uniqueness), + ecdh, + i, + )); + } + res + } + + // Calculate the payment ID XOR masks. + pub(crate) fn payment_id_xors(&self) -> Vec<[u8; 8]> { + let mut res = Vec::with_capacity(self.payments.len()); + for ecdh in self.ecdhs() { + res.push(SharedKeyDerivations::payment_id_xor(ecdh)); + } + res + } + + // Calculate the transaction_keys' commitments. + // + // These depend on the payments. Commitments for payments to subaddresses use the spend key for + // the generator. + pub(crate) fn transaction_keys_pub(&self) -> (EdwardsPoint, Vec) { + let (tx_key, additional_keys) = self.transaction_keys(); + debug_assert!(additional_keys.is_empty() || (additional_keys.len() == self.payments.len())); + + // The single transaction key uses the subaddress's spend key as its generator + let has_payments_to_subaddresses = self.has_payments_to_subaddresses(); + let should_use_additional_keys = self.should_use_additional_keys(); + if has_payments_to_subaddresses && (!should_use_additional_keys) { + debug_assert_eq!(additional_keys.len(), 0); + + let InternalPayment::Payment(addr, _) = self + .payments + .iter() + .find(|payment| matches!(payment, InternalPayment::Payment(_, _))) + .expect("payment to subaddress yet no payment") + else { + panic!("filtered payment wasn't a payment") + }; + + // TODO: Support subaddresses as change? + debug_assert!(addr.is_subaddress()); + + return (tx_key.deref() * addr.spend(), vec![]); + } + + if should_use_additional_keys { + let mut additional_keys_pub = vec![]; + for (additional_key, payment) in additional_keys.into_iter().zip(&self.payments) { + let addr = payment.address(); + // TODO: Double check this against wallet2 + if addr.is_subaddress() { + additional_keys_pub.push(additional_key.deref() * addr.spend()); + } else { + additional_keys_pub.push(additional_key.deref() * ED25519_BASEPOINT_TABLE) + } + } + return (tx_key.deref() * ED25519_BASEPOINT_TABLE, additional_keys_pub); + } + + debug_assert!(!has_payments_to_subaddresses); + debug_assert!(!should_use_additional_keys); + (tx_key.deref() * ED25519_BASEPOINT_TABLE, vec![]) + } + + pub(crate) fn commitments_and_encrypted_amounts( + &self, + key_images: &[EdwardsPoint], + ) -> Vec<(Commitment, EncryptedAmount)> { + let shared_key_derivations = self.shared_key_derivations(key_images); + + let mut res = Vec::with_capacity(self.payments.len()); + for (payment, shared_key_derivations) in self.payments.iter().zip(shared_key_derivations) { + let amount = match payment { + InternalPayment::Payment(_, amount) => *amount, + InternalPayment::Change(_, _) => { + let inputs = self.inputs.iter().map(|input| input.0.commitment().amount).sum::(); + let payments = self + .payments + .iter() + .filter_map(|payment| match payment { + InternalPayment::Payment(_, amount) => Some(amount), + InternalPayment::Change(_, _) => None, + }) + .sum::(); + let necessary_fee = self.weight_and_necessary_fee().1; + // Safe since the constructor checked this TX has enough funds for itself + inputs - (payments + necessary_fee) + } + }; + let commitment = Commitment::new(shared_key_derivations.commitment_mask(), amount); + let encrypted_amount = EncryptedAmount::Compact { + amount: shared_key_derivations.compact_amount_encryption(amount), + }; + res.push((commitment, encrypted_amount)); + } + res + } + + pub(crate) fn sum_output_masks(&self, key_images: &[EdwardsPoint]) -> Scalar { + self + .commitments_and_encrypted_amounts(key_images) + .into_iter() + .map(|(commitment, _)| commitment.mask) + .sum() + } +} diff --git a/coins/monero/src/tests/extra.rs b/coins/monero/wallet/src/tests/extra.rs similarity index 61% rename from coins/monero/src/tests/extra.rs rename to coins/monero/wallet/src/tests/extra.rs index b727fe9de..8dcd51f67 100644 --- a/coins/monero/src/tests/extra.rs +++ b/coins/monero/wallet/src/tests/extra.rs @@ -1,13 +1,57 @@ +use curve25519_dalek::edwards::{EdwardsPoint, CompressedEdwardsY}; + use crate::{ - wallet::{ExtraField, Extra, extra::MAX_TX_EXTRA_PADDING_COUNT}, - serialize::write_varint, + io::write_varint, + extra::{MAX_TX_EXTRA_PADDING_COUNT, ExtraField, Extra}, }; -use curve25519_dalek::edwards::{EdwardsPoint, CompressedEdwardsY}; - -// Borrowed tests from +// Tests derived from // https://github.com/monero-project/monero/blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/ // tests/unit_tests/test_tx_utils.cpp +// which is licensed +#[rustfmt::skip] +/* +Copyright (c) 2014-2022, The Monero Project + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Parts of the project are originally copyright (c) 2012-2013 The Cryptonote +developers + +Parts of the project are originally copyright (c) 2014 The Boolberry +developers, distributed under the MIT licence: + + 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. +*/ const PUB_KEY_BYTES: [u8; 33] = [ 1, 30, 208, 98, 162, 133, 64, 85, 83, 112, 91, 188, 89, 211, 24, 131, 39, 154, 22, 228, 80, 63, diff --git a/coins/monero/wallet/src/tests/mod.rs b/coins/monero/wallet/src/tests/mod.rs new file mode 100644 index 000000000..d8fa7cb07 --- /dev/null +++ b/coins/monero/wallet/src/tests/mod.rs @@ -0,0 +1 @@ +mod extra; diff --git a/coins/monero/wallet/src/view_pair.rs b/coins/monero/wallet/src/view_pair.rs new file mode 100644 index 000000000..eb89d96d4 --- /dev/null +++ b/coins/monero/wallet/src/view_pair.rs @@ -0,0 +1,144 @@ +use core::ops::Deref; + +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, Scalar, EdwardsPoint}; + +use crate::{ + primitives::keccak256_to_scalar, + address::{Network, AddressType, SubaddressIndex, MoneroAddress}, +}; + +/// An error while working with a ViewPair. +#[derive(Clone, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum ViewPairError { + /// The spend key was torsioned. + /// + /// Torsioned spend keys are of questionable spendability. This library avoids that question by + /// rejecting such ViewPairs. + // CLSAG seems to support it if the challenge does a torsion clear, FCMP++ should ship with a + // torsion clear, yet it's not worth it to modify CLSAG sign to generate challenges until the + // torsion clears and ensure spendability (nor can we reasonably guarantee that in the future) + #[cfg_attr(feature = "std", error("torsioned spend key"))] + TorsionedSpendKey, +} + +/// The pair of keys necessary to scan transactions. +/// +/// This is composed of the public spend key and the private view key. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct ViewPair { + spend: EdwardsPoint, + pub(crate) view: Zeroizing, +} + +impl ViewPair { + /// Create a new ViewPair. + pub fn new(spend: EdwardsPoint, view: Zeroizing) -> Result { + if !spend.is_torsion_free() { + Err(ViewPairError::TorsionedSpendKey)?; + } + Ok(ViewPair { spend, view }) + } + + /// The public spend key for this ViewPair. + pub fn spend(&self) -> EdwardsPoint { + self.spend + } + + /// The public view key for this ViewPair. + pub fn view(&self) -> EdwardsPoint { + self.view.deref() * ED25519_BASEPOINT_TABLE + } + + pub(crate) fn subaddress_derivation(&self, index: SubaddressIndex) -> Scalar { + keccak256_to_scalar(Zeroizing::new( + [ + b"SubAddr\0".as_ref(), + Zeroizing::new(self.view.to_bytes()).as_ref(), + &index.account().to_le_bytes(), + &index.address().to_le_bytes(), + ] + .concat(), + )) + } + + pub(crate) fn subaddress_keys(&self, index: SubaddressIndex) -> (EdwardsPoint, EdwardsPoint) { + let scalar = self.subaddress_derivation(index); + let spend = self.spend + (&scalar * ED25519_BASEPOINT_TABLE); + let view = self.view.deref() * spend; + (spend, view) + } + + /// Derive a legacy address from this ViewPair. + /// + /// Subaddresses SHOULD be used instead. + pub fn legacy_address(&self, network: Network) -> MoneroAddress { + MoneroAddress::new(network, AddressType::Legacy, self.spend, self.view()) + } + + /// Derive a legacy integrated address from this ViewPair. + /// + /// Subaddresses SHOULD be used instead. + pub fn legacy_integrated_address(&self, network: Network, payment_id: [u8; 8]) -> MoneroAddress { + MoneroAddress::new(network, AddressType::LegacyIntegrated(payment_id), self.spend, self.view()) + } + + /// Derive a subaddress from this ViewPair. + pub fn subaddress(&self, network: Network, subaddress: SubaddressIndex) -> MoneroAddress { + let (spend, view) = self.subaddress_keys(subaddress); + MoneroAddress::new(network, AddressType::Subaddress, spend, view) + } +} + +/// The pair of keys necessary to scan outputs immune to the burning bug. +/// +/// This is composed of the public spend key and a non-zero private view key. +/// +/// 'Guaranteed' outputs, or transactions outputs to the burning bug, are not officially specified +/// by the Monero project. They should only be used if necessary. No support outside of +/// monero-wallet is promised. +#[derive(Clone, Zeroize)] +pub struct GuaranteedViewPair(pub(crate) ViewPair); + +impl GuaranteedViewPair { + /// Create a new GuaranteedViewPair. + pub fn new(spend: EdwardsPoint, view: Zeroizing) -> Result { + ViewPair::new(spend, view).map(GuaranteedViewPair) + } + + /// The public spend key for this GuaranteedViewPair. + pub fn spend(&self) -> EdwardsPoint { + self.0.spend() + } + + /// The public view key for this GuaranteedViewPair. + pub fn view(&self) -> EdwardsPoint { + self.0.view() + } + + /// Returns an address with the provided specification. + /// + /// The returned address will be a featured address with the guaranteed flag set. These should + /// not be presumed to be interoperable with any other software. + pub fn address( + &self, + network: Network, + subaddress: Option, + payment_id: Option<[u8; 8]>, + ) -> MoneroAddress { + let (spend, view) = if let Some(index) = subaddress { + self.0.subaddress_keys(index) + } else { + (self.spend(), self.view()) + }; + + MoneroAddress::new( + network, + AddressType::Featured { subaddress: subaddress.is_some(), payment_id, guaranteed: true }, + spend, + view, + ) + } +} diff --git a/coins/monero/tests/add_data.rs b/coins/monero/wallet/tests/add_data.rs similarity index 63% rename from coins/monero/tests/add_data.rs rename to coins/monero/wallet/tests/add_data.rs index ab45177b9..6aa57dbca 100644 --- a/coins/monero/tests/add_data.rs +++ b/coins/monero/wallet/tests/add_data.rs @@ -1,7 +1,5 @@ -use monero_serai::{ - transaction::Transaction, - wallet::{TransactionError, extra::MAX_ARBITRARY_DATA_SIZE}, -}; +use monero_serai::transaction::Transaction; +use monero_wallet::{rpc::Rpc, extra::MAX_ARBITRARY_DATA_SIZE, send::SendError}; mod runner; @@ -17,8 +15,10 @@ test!( builder.add_payment(addr, 5); (builder.build().unwrap(), (arbitrary_data,)) }, - |_, tx: Transaction, mut scanner: Scanner, data: (Vec,)| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + |rpc, block, tx: Transaction, mut scanner: Scanner, data: (Vec,)| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); assert_eq!(output.commitment().amount, 5); assert_eq!(output.arbitrary_data()[0], data.0); }, @@ -42,8 +42,10 @@ test!( builder.add_payment(addr, 5); (builder.build().unwrap(), data) }, - |_, tx: Transaction, mut scanner: Scanner, data: Vec>| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + |rpc, block, tx: Transaction, mut scanner: Scanner, data: Vec>| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); assert_eq!(output.commitment().amount, 5); assert_eq!(output.arbitrary_data(), data); }, @@ -58,7 +60,7 @@ test!( let mut data = vec![b'a'; MAX_ARBITRARY_DATA_SIZE + 1]; // Make sure we get an error if we try to add it to the TX - assert_eq!(builder.add_data(data.clone()), Err(TransactionError::TooMuchData)); + assert_eq!(builder.add_data(data.clone()), Err(SendError::TooMuchArbitraryData)); // Reduce data size and retry. The data will now be 255 bytes long (including the added // marker), exactly @@ -68,8 +70,10 @@ test!( builder.add_payment(addr, 5); (builder.build().unwrap(), data) }, - |_, tx: Transaction, mut scanner: Scanner, data: Vec| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + |rpc, block, tx: Transaction, mut scanner: Scanner, data: Vec| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); assert_eq!(output.commitment().amount, 5); assert_eq!(output.arbitrary_data(), vec![data]); }, diff --git a/coins/monero/tests/decoys.rs b/coins/monero/wallet/tests/decoys.rs similarity index 63% rename from coins/monero/tests/decoys.rs rename to coins/monero/wallet/tests/decoys.rs index e85eab9d8..90574f49b 100644 --- a/coins/monero/tests/decoys.rs +++ b/coins/monero/wallet/tests/decoys.rs @@ -1,8 +1,9 @@ -use monero_serai::{ +use monero_simple_request_rpc::SimpleRequestRpc; +use monero_wallet::{ + DEFAULT_LOCK_WINDOW, transaction::Transaction, - wallet::SpendableOutput, - rpc::{Rpc, OutputResponse}, - Protocol, DEFAULT_LOCK_WINDOW, + rpc::{OutputResponse, Rpc}, + WalletOutput, }; mod runner; @@ -15,20 +16,22 @@ test!( builder.add_payment(addr, 2000000000000); (builder.build().unwrap(), ()) }, - |rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, ()| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); assert_eq!(output.commitment().amount, 2000000000000); - SpendableOutput::from(&rpc, output).await.unwrap() + output }, ), ( // Then make a second tx1 - |protocol: Protocol, rpc: Rpc<_>, mut builder: Builder, addr, state: _| async move { - let output_tx0: SpendableOutput = state; + |rct_type: RctType, rpc: SimpleRequestRpc, mut builder: Builder, addr, state: _| async move { + let output_tx0: WalletOutput = state; let decoys = Decoys::fingerprintable_canonical_select( &mut OsRng, &rpc, - protocol.ring_len(), + ring_len(rct_type), rpc.get_height().await.unwrap(), &[output_tx0.clone()], ) @@ -39,42 +42,41 @@ test!( builder.add_inputs(&inputs); builder.add_payment(addr, 1000000000000); - (builder.build().unwrap(), (protocol, output_tx0)) + (builder.build().unwrap(), (rct_type, output_tx0)) }, // Then make sure DSA selects freshly unlocked output from tx1 as a decoy - |rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, state: (_, _)| async move { + |rpc, _, tx: Transaction, _: Scanner, state: (_, _)| async move { use rand_core::OsRng; + let rpc: SimpleRequestRpc = rpc; + let height = rpc.get_height().await.unwrap(); - let output_tx1 = - SpendableOutput::from(&rpc, scanner.scan_transaction(&tx).not_locked().swap_remove(0)) - .await - .unwrap(); + let most_recent_o_index = rpc.get_o_indexes(tx.hash()).await.unwrap().pop().unwrap(); // Make sure output from tx1 is in the block in which it unlocks let out_tx1: OutputResponse = - rpc.get_outs(&[output_tx1.global_index]).await.unwrap().swap_remove(0); + rpc.get_outs(&[most_recent_o_index]).await.unwrap().swap_remove(0); assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW); assert!(out_tx1.unlocked); // Select decoys using spendable output from tx0 as the real, and make sure DSA selects // the freshly unlocked output from tx1 as a decoy - let (protocol, output_tx0): (Protocol, SpendableOutput) = state; + let (rct_type, output_tx0): (RctType, WalletOutput) = state; let mut selected_fresh_decoy = false; let mut attempts = 1000; while !selected_fresh_decoy && attempts > 0 { let decoys = Decoys::fingerprintable_canonical_select( &mut OsRng, // TODO: use a seeded RNG to consistently select the latest output &rpc, - protocol.ring_len(), + ring_len(rct_type), height, &[output_tx0.clone()], ) .await .unwrap(); - selected_fresh_decoy = decoys[0].indexes().contains(&output_tx1.global_index); + selected_fresh_decoy = decoys[0].positions().contains(&most_recent_o_index); attempts -= 1; } @@ -92,20 +94,23 @@ test!( builder.add_payment(addr, 2000000000000); (builder.build().unwrap(), ()) }, - |rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, ()| async move { - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + |rpc: SimpleRequestRpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); assert_eq!(output.commitment().amount, 2000000000000); - SpendableOutput::from(&rpc, output).await.unwrap() + output }, ), ( // Then make a second tx1 - |protocol: Protocol, rpc: Rpc<_>, mut builder: Builder, addr, state: _| async move { - let output_tx0: SpendableOutput = state; + |rct_type: RctType, rpc, mut builder: Builder, addr, output_tx0: WalletOutput| async move { + let rpc: SimpleRequestRpc = rpc; + let decoys = Decoys::select( &mut OsRng, &rpc, - protocol.ring_len(), + ring_len(rct_type), rpc.get_height().await.unwrap(), &[output_tx0.clone()], ) @@ -116,42 +121,41 @@ test!( builder.add_inputs(&inputs); builder.add_payment(addr, 1000000000000); - (builder.build().unwrap(), (protocol, output_tx0)) + (builder.build().unwrap(), (rct_type, output_tx0)) }, // Then make sure DSA selects freshly unlocked output from tx1 as a decoy - |rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, state: (_, _)| async move { + |rpc, _, tx: Transaction, _: Scanner, state: (_, _)| async move { use rand_core::OsRng; + let rpc: SimpleRequestRpc = rpc; + let height = rpc.get_height().await.unwrap(); - let output_tx1 = - SpendableOutput::from(&rpc, scanner.scan_transaction(&tx).not_locked().swap_remove(0)) - .await - .unwrap(); + let most_recent_o_index = rpc.get_o_indexes(tx.hash()).await.unwrap().pop().unwrap(); // Make sure output from tx1 is in the block in which it unlocks let out_tx1: OutputResponse = - rpc.get_outs(&[output_tx1.global_index]).await.unwrap().swap_remove(0); + rpc.get_outs(&[most_recent_o_index]).await.unwrap().swap_remove(0); assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW); assert!(out_tx1.unlocked); // Select decoys using spendable output from tx0 as the real, and make sure DSA selects // the freshly unlocked output from tx1 as a decoy - let (protocol, output_tx0): (Protocol, SpendableOutput) = state; + let (rct_type, output_tx0): (RctType, WalletOutput) = state; let mut selected_fresh_decoy = false; let mut attempts = 1000; while !selected_fresh_decoy && attempts > 0 { let decoys = Decoys::select( &mut OsRng, // TODO: use a seeded RNG to consistently select the latest output &rpc, - protocol.ring_len(), + ring_len(rct_type), height, &[output_tx0.clone()], ) .await .unwrap(); - selected_fresh_decoy = decoys[0].indexes().contains(&output_tx1.global_index); + selected_fresh_decoy = decoys[0].positions().contains(&most_recent_o_index); attempts -= 1; } diff --git a/coins/monero/tests/eventuality.rs b/coins/monero/wallet/tests/eventuality.rs similarity index 62% rename from coins/monero/tests/eventuality.rs rename to coins/monero/wallet/tests/eventuality.rs index dfbc6f0dd..0c25e86e0 100644 --- a/coins/monero/tests/eventuality.rs +++ b/coins/monero/wallet/tests/eventuality.rs @@ -1,11 +1,9 @@ use curve25519_dalek::constants::ED25519_BASEPOINT_POINT; -use monero_serai::{ - transaction::Transaction, - wallet::{ - Eventuality, - address::{AddressType, AddressMeta, MoneroAddress}, - }, +use monero_serai::transaction::Transaction; +use monero_wallet::{ + rpc::Rpc, + address::{AddressType, MoneroAddress}, }; mod runner; @@ -18,7 +16,8 @@ test!( // Each have their own slight implications to eventualities builder.add_payment( MoneroAddress::new( - AddressMeta::new(Network::Mainnet, AddressType::Standard), + Network::Mainnet, + AddressType::Legacy, ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_POINT, ), @@ -26,7 +25,8 @@ test!( ); builder.add_payment( MoneroAddress::new( - AddressMeta::new(Network::Mainnet, AddressType::Integrated([0xaa; 8])), + Network::Mainnet, + AddressType::LegacyIntegrated([0xaa; 8]), ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_POINT, ), @@ -34,7 +34,8 @@ test!( ); builder.add_payment( MoneroAddress::new( - AddressMeta::new(Network::Mainnet, AddressType::Subaddress), + Network::Mainnet, + AddressType::Subaddress, ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_POINT, ), @@ -42,36 +43,36 @@ test!( ); builder.add_payment( MoneroAddress::new( - AddressMeta::new( - Network::Mainnet, - AddressType::Featured { subaddress: false, payment_id: None, guaranteed: true }, - ), + Network::Mainnet, + AddressType::Featured { subaddress: false, payment_id: None, guaranteed: true }, ED25519_BASEPOINT_POINT, ED25519_BASEPOINT_POINT, ), 4, ); - builder.set_r_seed(Zeroizing::new([0xbb; 32])); let tx = builder.build().unwrap(); - let eventuality = tx.eventuality().unwrap(); + let eventuality = Eventuality::from(tx.clone()); assert_eq!( eventuality, Eventuality::read::<&[u8]>(&mut eventuality.serialize().as_ref()).unwrap() ); (tx, eventuality) }, - |_, mut tx: Transaction, _, eventuality: Eventuality| async move { + |_, _, mut tx: Transaction, _, eventuality: Eventuality| async move { // 4 explicitly outputs added and one change output - assert_eq!(tx.prefix.outputs.len(), 5); + assert_eq!(tx.prefix().outputs.len(), 5); // The eventuality's available extra should be the actual TX's - assert_eq!(tx.prefix.extra, eventuality.extra()); + assert_eq!(tx.prefix().extra, eventuality.extra()); // The TX should match assert!(eventuality.matches(&tx)); // Mutate the TX - tx.rct_signatures.base.commitments[0] += ED25519_BASEPOINT_POINT; + let Transaction::V2 { proofs: Some(ref mut proofs), .. } = tx else { + panic!("TX wasn't RingCT") + }; + proofs.base.commitments[0] += ED25519_BASEPOINT_POINT; // Verify it no longer matches assert!(!eventuality.matches(&tx)); }, diff --git a/coins/monero/wallet/tests/runner/builder.rs b/coins/monero/wallet/tests/runner/builder.rs new file mode 100644 index 000000000..df42a1da7 --- /dev/null +++ b/coins/monero/wallet/tests/runner/builder.rs @@ -0,0 +1,83 @@ +use zeroize::{Zeroize, Zeroizing}; + +use monero_wallet::{ + primitives::Decoys, + ringct::RctType, + rpc::FeeRate, + address::MoneroAddress, + WalletOutput, + send::{Change, SendError, SignableTransaction}, + extra::MAX_ARBITRARY_DATA_SIZE, +}; + +/// A builder for Monero transactions. +#[derive(Clone, PartialEq, Eq, Zeroize, Debug)] +pub struct SignableTransactionBuilder { + rct_type: RctType, + outgoing_view_key: Zeroizing<[u8; 32]>, + inputs: Vec<(WalletOutput, Decoys)>, + payments: Vec<(MoneroAddress, u64)>, + change: Change, + data: Vec>, + fee_rate: FeeRate, +} + +impl SignableTransactionBuilder { + pub fn new( + rct_type: RctType, + outgoing_view_key: Zeroizing<[u8; 32]>, + change: Change, + fee_rate: FeeRate, + ) -> Self { + Self { + rct_type, + outgoing_view_key, + inputs: vec![], + payments: vec![], + change, + data: vec![], + fee_rate, + } + } + + pub fn add_input(&mut self, input: (WalletOutput, Decoys)) -> &mut Self { + self.inputs.push(input); + self + } + #[allow(unused)] + pub fn add_inputs(&mut self, inputs: &[(WalletOutput, Decoys)]) -> &mut Self { + self.inputs.extend(inputs.iter().cloned()); + self + } + + pub fn add_payment(&mut self, dest: MoneroAddress, amount: u64) -> &mut Self { + self.payments.push((dest, amount)); + self + } + #[allow(unused)] + pub fn add_payments(&mut self, payments: &[(MoneroAddress, u64)]) -> &mut Self { + self.payments.extend(payments); + self + } + + #[allow(unused)] + pub fn add_data(&mut self, data: Vec) -> Result<&mut Self, SendError> { + if data.len() > MAX_ARBITRARY_DATA_SIZE { + Err(SendError::TooMuchArbitraryData)?; + } + self.data.push(data); + Ok(self) + } + + pub fn build(self) -> Result { + SignableTransaction::new( + self.rct_type, + self.outgoing_view_key, + self.inputs, + self.payments, + self.change, + self.data, + self.fee_rate, + ) + } +} diff --git a/coins/monero/wallet/tests/runner/mod.rs b/coins/monero/wallet/tests/runner/mod.rs new file mode 100644 index 000000000..35e317800 --- /dev/null +++ b/coins/monero/wallet/tests/runner/mod.rs @@ -0,0 +1,357 @@ +use core::ops::Deref; +use std_shims::sync::OnceLock; + +use zeroize::Zeroizing; +use rand_core::OsRng; + +use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar}; + +use tokio::sync::Mutex; + +use monero_simple_request_rpc::SimpleRequestRpc; +use monero_wallet::{ + ringct::RctType, + transaction::Transaction, + block::Block, + rpc::{Rpc, FeeRate}, + address::{Network, AddressType, MoneroAddress}, + DEFAULT_LOCK_WINDOW, ViewPair, GuaranteedViewPair, WalletOutput, Scanner, +}; + +mod builder; +pub use builder::SignableTransactionBuilder; + +pub fn ring_len(rct_type: RctType) -> usize { + match rct_type { + RctType::ClsagBulletproof => 11, + RctType::ClsagBulletproofPlus => 16, + _ => panic!("ring size unknown for RctType"), + } +} + +pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) { + let spend = Scalar::random(&mut OsRng); + let spend_pub = &spend * ED25519_BASEPOINT_TABLE; + let view = Zeroizing::new(Scalar::random(&mut OsRng)); + ( + spend, + ViewPair::new(spend_pub, view.clone()).unwrap(), + MoneroAddress::new( + Network::Mainnet, + AddressType::Legacy, + spend_pub, + view.deref() * ED25519_BASEPOINT_TABLE, + ), + ) +} + +#[allow(unused)] +pub fn random_guaranteed_address() -> (Scalar, GuaranteedViewPair, MoneroAddress) { + let spend = Scalar::random(&mut OsRng); + let spend_pub = &spend * ED25519_BASEPOINT_TABLE; + let view = Zeroizing::new(Scalar::random(&mut OsRng)); + ( + spend, + GuaranteedViewPair::new(spend_pub, view.clone()).unwrap(), + MoneroAddress::new( + Network::Mainnet, + AddressType::Legacy, + spend_pub, + view.deref() * ED25519_BASEPOINT_TABLE, + ), + ) +} + +// TODO: Support transactions already on-chain +// TODO: Don't have a side effect of mining blocks more blocks than needed under race conditions +pub async fn mine_until_unlocked( + rpc: &SimpleRequestRpc, + addr: &MoneroAddress, + tx_hash: [u8; 32], +) -> Block { + // mine until tx is in a block + let mut height = rpc.get_height().await.unwrap(); + let mut found = false; + let mut block = None; + while !found { + let inner_block = rpc.get_block_by_number(height - 1).await.unwrap(); + found = match inner_block.transactions.iter().find(|&&x| x == tx_hash) { + Some(_) => { + block = Some(inner_block); + true + } + None => { + height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1; + false + } + } + } + + // Mine until tx's outputs are unlocked + for _ in 0 .. (DEFAULT_LOCK_WINDOW - 1) { + rpc.generate_blocks(addr, 1).await.unwrap(); + } + + block.unwrap() +} + +// Mines 60 blocks and returns an unlocked miner TX output. +#[allow(dead_code)] +pub async fn get_miner_tx_output(rpc: &SimpleRequestRpc, view: &ViewPair) -> WalletOutput { + let mut scanner = Scanner::new(view.clone()); + + // Mine 60 blocks to unlock a miner TX + let start = rpc.get_height().await.unwrap(); + rpc.generate_blocks(&view.legacy_address(Network::Mainnet), 60).await.unwrap(); + + let block = rpc.get_block_by_number(start).await.unwrap(); + scanner.scan(rpc, &block).await.unwrap().ignore_additional_timelock().swap_remove(0) +} + +/// Make sure the weight and fee match the expected calculation. +pub fn check_weight_and_fee(tx: &Transaction, fee_rate: FeeRate) { + let Transaction::V2 { proofs: Some(ref proofs), .. } = tx else { panic!("TX wasn't RingCT") }; + let fee = proofs.base.fee; + + let weight = tx.weight(); + let expected_weight = fee_rate.calculate_weight_from_fee(fee); + assert_eq!(weight, expected_weight); + + let expected_fee = fee_rate.calculate_fee_from_weight(weight); + assert_eq!(fee, expected_fee); +} + +pub async fn rpc() -> SimpleRequestRpc { + let rpc = + SimpleRequestRpc::new("http://serai:seraidex@127.0.0.1:18081".to_string()).await.unwrap(); + + const BLOCKS_TO_MINE: usize = 110; + + // Only run once + if rpc.get_height().await.unwrap() > BLOCKS_TO_MINE { + return rpc; + } + + let addr = MoneroAddress::new( + Network::Mainnet, + AddressType::Legacy, + &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE, + &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE, + ); + + // Mine enough blocks to ensure decoy availability + rpc.generate_blocks(&addr, BLOCKS_TO_MINE).await.unwrap(); + + rpc +} + +pub static SEQUENTIAL: OnceLock> = OnceLock::new(); + +#[macro_export] +macro_rules! async_sequential { + ($(async fn $name: ident() $body: block)*) => { + $( + #[tokio::test] + async fn $name() { + let guard = runner::SEQUENTIAL.get_or_init(|| tokio::sync::Mutex::new(())).lock().await; + let local = tokio::task::LocalSet::new(); + local.run_until(async move { + if let Err(err) = tokio::task::spawn_local(async move { $body }).await { + drop(guard); + Err(err).unwrap() + } + }).await; + } + )* + } +} + +#[macro_export] +macro_rules! test { + ( + $name: ident, + ( + $first_tx: expr, + $first_checks: expr, + ), + $(( + $tx: expr, + $checks: expr, + )$(,)?),* + ) => { + async_sequential! { + async fn $name() { + use core::{ops::Deref, any::Any}; + #[cfg(feature = "multisig")] + use std::collections::HashMap; + + use zeroize::Zeroizing; + use rand_core::{RngCore, OsRng}; + + use curve25519_dalek::{constants::ED25519_BASEPOINT_TABLE, scalar::Scalar}; + + #[cfg(feature = "multisig")] + use frost::{ + curve::Ed25519, + Participant, + tests::{THRESHOLD, key_gen}, + }; + + use monero_wallet::{ + primitives::Decoys, + ringct::RctType, + rpc::FeePriority, + address::Network, + ViewPair, + DecoySelection, + Scanner, + send::{Change, SignableTransaction, Eventuality}, + }; + + use runner::{ + SignableTransactionBuilder, ring_len, random_address, rpc, mine_until_unlocked, + get_miner_tx_output, check_weight_and_fee, + }; + + type Builder = SignableTransactionBuilder; + + // Run each function as both a single signer and as a multisig + #[allow(clippy::redundant_closure_call)] + for multisig in [false, true] { + // Only run the multisig variant if multisig is enabled + if multisig { + #[cfg(not(feature = "multisig"))] + continue; + } + + let spend = Zeroizing::new(Scalar::random(&mut OsRng)); + #[cfg(feature = "multisig")] + let keys = key_gen::<_, Ed25519>(&mut OsRng); + + let spend_pub = if !multisig { + spend.deref() * ED25519_BASEPOINT_TABLE + } else { + #[cfg(not(feature = "multisig"))] + panic!("Multisig branch called without the multisig feature"); + #[cfg(feature = "multisig")] + keys[&Participant::new(1).unwrap()].group_key().0 + }; + + let rpc = rpc().await; + + let view_priv = Zeroizing::new(Scalar::random(&mut OsRng)); + let mut outgoing_view = Zeroizing::new([0; 32]); + OsRng.fill_bytes(outgoing_view.as_mut()); + let view = ViewPair::new(spend_pub, view_priv.clone()).unwrap(); + let addr = view.legacy_address(Network::Mainnet); + + let miner_tx = get_miner_tx_output(&rpc, &view).await; + + let rct_type = match rpc.get_hardfork_version().await.unwrap() { + 14 => RctType::ClsagBulletproof, + 15 | 16 => RctType::ClsagBulletproofPlus, + _ => panic!("unrecognized hardfork version"), + }; + + let builder = SignableTransactionBuilder::new( + rct_type, + outgoing_view, + Change::new( + &ViewPair::new( + &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE, + Zeroizing::new(Scalar::random(&mut OsRng)) + ).unwrap(), + ), + rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), + ); + + let sign = |tx: SignableTransaction| { + let spend = spend.clone(); + #[cfg(feature = "multisig")] + let keys = keys.clone(); + + let eventuality = Eventuality::from(tx.clone()); + + let tx = if !multisig { + tx.sign(&mut OsRng, &spend).unwrap() + } else { + #[cfg(not(feature = "multisig"))] + panic!("multisig branch called without the multisig feature"); + #[cfg(feature = "multisig")] + { + let mut machines = HashMap::new(); + for i in (1 ..= THRESHOLD).map(|i| Participant::new(i).unwrap()) { + machines.insert(i, tx.clone().multisig(&keys[&i]).unwrap()); + } + + frost::tests::sign_without_caching(&mut OsRng, machines, &[]) + } + }; + + assert_eq!(&eventuality.extra(), &tx.prefix().extra, "eventuality extra was distinct"); + assert!(eventuality.matches(&tx), "eventuality didn't match"); + + tx + }; + + // TODO: Generate a distinct wallet for each transaction to prevent overlap + let next_addr = addr; + + let temp = Box::new({ + let mut builder = builder.clone(); + + let decoys = Decoys::fingerprintable_canonical_select( + &mut OsRng, + &rpc, + ring_len(rct_type), + rpc.get_height().await.unwrap(), + &[miner_tx.clone()], + ) + .await + .unwrap(); + builder.add_input((miner_tx, decoys.first().unwrap().clone())); + + let (tx, state) = ($first_tx)(rpc.clone(), builder, next_addr).await; + let fee_rate = tx.fee_rate().clone(); + let signed = sign(tx); + rpc.publish_transaction(&signed).await.unwrap(); + let block = + mine_until_unlocked(&rpc, &random_address().2, signed.hash()).await; + let tx = rpc.get_transaction(signed.hash()).await.unwrap(); + check_weight_and_fee(&tx, fee_rate); + let scanner = Scanner::new(view.clone()); + ($first_checks)(rpc.clone(), block, tx, scanner, state).await + }); + #[allow(unused_variables, unused_mut, unused_assignments)] + let mut carried_state: Box = temp; + + $( + let (tx, state) = ($tx)( + rct_type, + rpc.clone(), + builder.clone(), + next_addr, + *carried_state.downcast().unwrap() + ).await; + let fee_rate = tx.fee_rate().clone(); + let signed = sign(tx); + rpc.publish_transaction(&signed).await.unwrap(); + let block = + mine_until_unlocked(&rpc, &random_address().2, signed.hash()).await; + let tx = rpc.get_transaction(signed.hash()).await.unwrap(); + if stringify!($name) != "spend_one_input_to_two_outputs_no_change" { + // Skip weight and fee check for the above test because when there is no change, + // the change is added to the fee + check_weight_and_fee(&tx, fee_rate); + } + #[allow(unused_assignments)] + { + let scanner = Scanner::new(view.clone()); + carried_state = Box::new(($checks)(rpc.clone(), block, tx, scanner, state).await); + } + )* + } + } + } + } +} diff --git a/coins/monero/wallet/tests/scan.rs b/coins/monero/wallet/tests/scan.rs new file mode 100644 index 000000000..b2a51c60c --- /dev/null +++ b/coins/monero/wallet/tests/scan.rs @@ -0,0 +1,166 @@ +use monero_serai::transaction::Transaction; +use monero_wallet::{rpc::Rpc, address::SubaddressIndex, extra::PaymentId, GuaranteedScanner}; + +mod runner; + +test!( + scan_standard_address, + ( + |_, mut builder: Builder, _| async move { + let view = runner::random_address().1; + let scanner = Scanner::new(view.clone()); + builder.add_payment(view.legacy_address(Network::Mainnet), 5); + (builder.build().unwrap(), scanner) + }, + |rpc, block, tx: Transaction, _, mut state: Scanner| async move { + let output = state.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + let dummy_payment_id = PaymentId::Encrypted([0u8; 8]); + assert_eq!(output.payment_id(), Some(dummy_payment_id)); + }, + ), +); + +test!( + scan_subaddress, + ( + |_, mut builder: Builder, _| async move { + let subaddress = SubaddressIndex::new(0, 1).unwrap(); + + let view = runner::random_address().1; + let mut scanner = Scanner::new(view.clone()); + scanner.register_subaddress(subaddress); + + builder.add_payment(view.subaddress(Network::Mainnet, subaddress), 5); + (builder.build().unwrap(), (scanner, subaddress)) + }, + |rpc, block, tx: Transaction, _, mut state: (Scanner, SubaddressIndex)| async move { + let output = + state.0.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + assert_eq!(output.subaddress(), Some(state.1)); + }, + ), +); + +test!( + scan_integrated_address, + ( + |_, mut builder: Builder, _| async move { + let view = runner::random_address().1; + let scanner = Scanner::new(view.clone()); + + let mut payment_id = [0u8; 8]; + OsRng.fill_bytes(&mut payment_id); + + builder.add_payment(view.legacy_integrated_address(Network::Mainnet, payment_id), 5); + (builder.build().unwrap(), (scanner, payment_id)) + }, + |rpc, block, tx: Transaction, _, mut state: (Scanner, [u8; 8])| async move { + let output = + state.0.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + assert_eq!(output.payment_id(), Some(PaymentId::Encrypted(state.1))); + }, + ), +); + +test!( + scan_guaranteed, + ( + |_, mut builder: Builder, _| async move { + let view = runner::random_guaranteed_address().1; + let scanner = GuaranteedScanner::new(view.clone()); + builder.add_payment(view.address(Network::Mainnet, None, None), 5); + (builder.build().unwrap(), scanner) + }, + |rpc, block, tx: Transaction, _, mut scanner: GuaranteedScanner| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + assert_eq!(output.subaddress(), None); + }, + ), +); + +test!( + scan_guaranteed_subaddress, + ( + |_, mut builder: Builder, _| async move { + let subaddress = SubaddressIndex::new(0, 2).unwrap(); + + let view = runner::random_guaranteed_address().1; + let mut scanner = GuaranteedScanner::new(view.clone()); + scanner.register_subaddress(subaddress); + + builder.add_payment(view.address(Network::Mainnet, Some(subaddress), None), 5); + (builder.build().unwrap(), (scanner, subaddress)) + }, + |rpc, block, tx: Transaction, _, mut state: (GuaranteedScanner, SubaddressIndex)| async move { + let output = + state.0.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + assert_eq!(output.subaddress(), Some(state.1)); + }, + ), +); + +test!( + scan_guaranteed_integrated, + ( + |_, mut builder: Builder, _| async move { + let view = runner::random_guaranteed_address().1; + let scanner = GuaranteedScanner::new(view.clone()); + let mut payment_id = [0u8; 8]; + OsRng.fill_bytes(&mut payment_id); + + builder.add_payment(view.address(Network::Mainnet, None, Some(payment_id)), 5); + (builder.build().unwrap(), (scanner, payment_id)) + }, + |rpc, block, tx: Transaction, _, mut state: (GuaranteedScanner, [u8; 8])| async move { + let output = + state.0.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + assert_eq!(output.payment_id(), Some(PaymentId::Encrypted(state.1))); + }, + ), +); + +#[rustfmt::skip] +test!( + scan_guaranteed_integrated_subaddress, + ( + |_, mut builder: Builder, _| async move { + let subaddress = SubaddressIndex::new(0, 3).unwrap(); + + let view = runner::random_guaranteed_address().1; + let mut scanner = GuaranteedScanner::new(view.clone()); + scanner.register_subaddress(subaddress); + + let mut payment_id = [0u8; 8]; + OsRng.fill_bytes(&mut payment_id); + + builder.add_payment(view.address(Network::Mainnet, Some(subaddress), Some(payment_id)), 5); + (builder.build().unwrap(), (scanner, payment_id, subaddress)) + }, + | + rpc, + block, + tx: Transaction, + _, + mut state: (GuaranteedScanner, [u8; 8], SubaddressIndex), + | async move { + let output = state.0.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + assert_eq!(output.payment_id(), Some(PaymentId::Encrypted(state.1))); + assert_eq!(output.subaddress(), Some(state.2)); + }, + ), +); diff --git a/coins/monero/wallet/tests/send.rs b/coins/monero/wallet/tests/send.rs new file mode 100644 index 000000000..7084003ff --- /dev/null +++ b/coins/monero/wallet/tests/send.rs @@ -0,0 +1,335 @@ +use std::collections::HashSet; + +use rand_core::OsRng; + +use monero_simple_request_rpc::SimpleRequestRpc; +use monero_wallet::{ + primitives::Decoys, ringct::RctType, transaction::Transaction, rpc::Rpc, + address::SubaddressIndex, extra::Extra, WalletOutput, DecoySelection, +}; + +mod runner; +use runner::{SignableTransactionBuilder, ring_len}; + +// Set up inputs, select decoys, then add them to the TX builder +async fn add_inputs( + rct_type: RctType, + rpc: &SimpleRequestRpc, + outputs: Vec, + builder: &mut SignableTransactionBuilder, +) { + let decoys = Decoys::fingerprintable_canonical_select( + &mut OsRng, + rpc, + ring_len(rct_type), + rpc.get_height().await.unwrap(), + &outputs, + ) + .await + .unwrap(); + + let inputs = outputs.into_iter().zip(decoys).collect::>(); + + builder.add_inputs(&inputs); +} + +test!( + spend_miner_output, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 5); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 5); + }, + ), +); + +test!( + spend_multiple_outputs, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 1000000000000); + builder.add_payment(addr, 2000000000000); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let mut outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs[0].transaction(), tx.hash()); + assert_eq!(outputs[0].transaction(), tx.hash()); + outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); + assert_eq!(outputs[0].commitment().amount, 1000000000000); + assert_eq!(outputs[1].commitment().amount, 2000000000000); + outputs + }, + ), + ( + |rct_type: RctType, rpc, mut builder: Builder, addr, outputs: Vec| async move { + add_inputs(rct_type, &rpc, outputs, &mut builder).await; + builder.add_payment(addr, 6); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 6); + }, + ), +); + +test!( + // Ideally, this would be single_R, yet it isn't feasible to apply allow(non_snake_case) here + single_r_subaddress_send, + ( + // Consume this builder for an output we can use in the future + // This is needed because we can't get the input from the passed in builder + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 1000000000000); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].transaction(), tx.hash()); + assert_eq!(outputs[0].commitment().amount, 1000000000000); + outputs + }, + ), + ( + |rct_type, rpc: SimpleRequestRpc, _, _, outputs: Vec| async move { + use monero_wallet::rpc::FeePriority; + + let view_priv = Zeroizing::new(Scalar::random(&mut OsRng)); + let mut outgoing_view = Zeroizing::new([0; 32]); + OsRng.fill_bytes(outgoing_view.as_mut()); + let change_view = + ViewPair::new(&Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE, view_priv.clone()) + .unwrap(); + + let mut builder = SignableTransactionBuilder::new( + rct_type, + outgoing_view, + Change::new(&change_view), + rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), + ); + add_inputs(rct_type, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await; + + // Send to a subaddress + let sub_view = ViewPair::new( + &Scalar::random(&mut OsRng) * ED25519_BASEPOINT_TABLE, + Zeroizing::new(Scalar::random(&mut OsRng)), + ) + .unwrap(); + builder + .add_payment(sub_view.subaddress(Network::Mainnet, SubaddressIndex::new(0, 1).unwrap()), 1); + (builder.build().unwrap(), (change_view, sub_view)) + }, + |rpc, block, tx: Transaction, _, views: (ViewPair, ViewPair)| async move { + // Make sure the change can pick up its output + let mut change_scanner = Scanner::new(views.0); + assert!( + change_scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().len() == 1 + ); + + // Make sure the subaddress can pick up its output + let mut sub_scanner = Scanner::new(views.1); + sub_scanner.register_subaddress(SubaddressIndex::new(0, 1).unwrap()); + let sub_outputs = sub_scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert!(sub_outputs.len() == 1); + assert_eq!(sub_outputs[0].transaction(), tx.hash()); + assert_eq!(sub_outputs[0].commitment().amount, 1); + + // Make sure only one R was included in TX extra + assert!(Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()) + .unwrap() + .keys() + .unwrap() + .1 + .is_none()); + }, + ), +); + +test!( + spend_one_input_to_one_output_plus_change, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 2000000000000); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].transaction(), tx.hash()); + assert_eq!(outputs[0].commitment().amount, 2000000000000); + outputs + }, + ), + ( + |rct_type: RctType, rpc, mut builder: Builder, addr, outputs: Vec| async move { + add_inputs(rct_type, &rpc, outputs, &mut builder).await; + builder.add_payment(addr, 2); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let output = + scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + assert_eq!(output.commitment().amount, 2); + }, + ), +); + +test!( + spend_max_outputs, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 1000000000000); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].transaction(), tx.hash()); + assert_eq!(outputs[0].commitment().amount, 1000000000000); + outputs + }, + ), + ( + |rct_type: RctType, rpc, mut builder: Builder, addr, outputs: Vec| async move { + add_inputs(rct_type, &rpc, outputs, &mut builder).await; + + for i in 0 .. 15 { + builder.add_payment(addr, i + 1); + } + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let mut scanned_tx = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + + let mut output_amounts = HashSet::new(); + for i in 0 .. 15 { + output_amounts.insert(i + 1); + } + for _ in 0 .. 15 { + let output = scanned_tx.swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + let amount = output.commitment().amount; + assert!(output_amounts.remove(&amount)); + } + assert_eq!(output_amounts.len(), 0); + }, + ), +); + +test!( + spend_max_outputs_to_subaddresses, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 1000000000000); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].transaction(), tx.hash()); + assert_eq!(outputs[0].commitment().amount, 1000000000000); + outputs + }, + ), + ( + |rct_type: RctType, rpc, mut builder: Builder, _, outputs: Vec| async move { + add_inputs(rct_type, &rpc, outputs, &mut builder).await; + + let view = runner::random_address().1; + let mut scanner = Scanner::new(view.clone()); + + let mut subaddresses = vec![]; + for i in 0 .. 15 { + let subaddress = SubaddressIndex::new(0, i + 1).unwrap(); + scanner.register_subaddress(subaddress); + + builder.add_payment(view.subaddress(Network::Mainnet, subaddress), u64::from(i + 1)); + subaddresses.push(subaddress); + } + + (builder.build().unwrap(), (scanner, subaddresses)) + }, + |rpc, block, tx: Transaction, _, mut state: (Scanner, Vec)| async move { + use std::collections::HashMap; + + let mut scanned_tx = state.0.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + + let mut output_amounts_by_subaddress = HashMap::new(); + for i in 0 .. 15 { + output_amounts_by_subaddress.insert(u64::try_from(i + 1).unwrap(), state.1[i]); + } + for _ in 0 .. 15 { + let output = scanned_tx.swap_remove(0); + assert_eq!(output.transaction(), tx.hash()); + let amount = output.commitment().amount; + + assert_eq!( + output.subaddress().unwrap(), + output_amounts_by_subaddress.remove(&amount).unwrap() + ); + } + assert_eq!(output_amounts_by_subaddress.len(), 0); + }, + ), +); + +test!( + spend_one_input_to_two_outputs_no_change, + ( + |_, mut builder: Builder, addr| async move { + builder.add_payment(addr, 1000000000000); + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].transaction(), tx.hash()); + assert_eq!(outputs[0].commitment().amount, 1000000000000); + outputs + }, + ), + ( + |rct_type, rpc: SimpleRequestRpc, _, addr, outputs: Vec| async move { + use monero_wallet::rpc::FeePriority; + + let mut outgoing_view = Zeroizing::new([0; 32]); + OsRng.fill_bytes(outgoing_view.as_mut()); + let mut builder = SignableTransactionBuilder::new( + rct_type, + outgoing_view, + Change::fingerprintable(None), + rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), + ); + add_inputs(rct_type, &rpc, vec![outputs.first().unwrap().clone()], &mut builder).await; + builder.add_payment(addr, 10000); + builder.add_payment(addr, 50000); + + (builder.build().unwrap(), ()) + }, + |rpc, block, tx: Transaction, mut scanner: Scanner, ()| async move { + let mut outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs[0].transaction(), tx.hash()); + assert_eq!(outputs[1].transaction(), tx.hash()); + outputs.sort_by(|x, y| x.commitment().amount.cmp(&y.commitment().amount)); + assert_eq!(outputs[0].commitment().amount, 10000); + assert_eq!(outputs[1].commitment().amount, 50000); + + // The remainder should get shunted to fee, which is fingerprintable + let Transaction::V2 { proofs: Some(ref proofs), .. } = tx else { panic!("TX wasn't RingCT") }; + assert_eq!(proofs.base.fee, 1000000000000 - 10000 - 50000); + }, + ), +); diff --git a/coins/monero/tests/wallet2_compatibility.rs b/coins/monero/wallet/tests/wallet2_compatibility.rs similarity index 75% rename from coins/monero/tests/wallet2_compatibility.rs rename to coins/monero/wallet/tests/wallet2_compatibility.rs index c6b589789..e7815d70e 100644 --- a/coins/monero/tests/wallet2_compatibility.rs +++ b/coins/monero/wallet/tests/wallet2_compatibility.rs @@ -1,23 +1,30 @@ -use std::collections::HashSet; - use rand_core::{OsRng, RngCore}; use serde::Deserialize; use serde_json::json; -use monero_serai::{ +use monero_simple_request_rpc::SimpleRequestRpc; +use monero_wallet::{ transaction::Transaction, - rpc::{EmptyResponse, HttpRpc, Rpc}, - wallet::{ - address::{Network, AddressSpec, SubaddressIndex, MoneroAddress}, - extra::{MAX_TX_EXTRA_NONCE_SIZE, Extra, PaymentId}, - Scanner, - }, + rpc::Rpc, + address::{Network, SubaddressIndex, MoneroAddress}, + extra::{MAX_ARBITRARY_DATA_SIZE, Extra, PaymentId}, + Scanner, }; mod runner; -async fn make_integrated_address(rpc: &Rpc, payment_id: [u8; 8]) -> String { +#[derive(Clone, Copy, PartialEq, Eq)] +enum AddressSpec { + Legacy, + LegacyIntegrated([u8; 8]), + Subaddress(SubaddressIndex), +} + +#[derive(Deserialize, Debug)] +struct EmptyResponse {} + +async fn make_integrated_address(rpc: &SimpleRequestRpc, payment_id: [u8; 8]) -> String { #[derive(Debug, Deserialize)] struct IntegratedAddressResponse { integrated_address: String, @@ -34,8 +41,8 @@ async fn make_integrated_address(rpc: &Rpc, payment_id: [u8; 8]) -> Str res.integrated_address } -async fn initialize_rpcs() -> (Rpc, Rpc, String) { - let wallet_rpc = HttpRpc::new("http://127.0.0.1:18082".to_string()).await.unwrap(); +async fn initialize_rpcs() -> (SimpleRequestRpc, SimpleRequestRpc, MoneroAddress) { + let wallet_rpc = SimpleRequestRpc::new("http://127.0.0.1:18082".to_string()).await.unwrap(); let daemon_rpc = runner::rpc().await; #[derive(Debug, Deserialize)] @@ -57,9 +64,10 @@ async fn initialize_rpcs() -> (Rpc, Rpc, String) { wallet_rpc.json_rpc_call("get_address", Some(json!({ "account_index": 0 }))).await.unwrap(); // Fund the new wallet - daemon_rpc.generate_blocks(&address.address, 70).await.unwrap(); + let address = MoneroAddress::from_str(Network::Mainnet, &address.address).unwrap(); + daemon_rpc.generate_blocks(&address, 70).await.unwrap(); - (wallet_rpc, daemon_rpc, address.address) + (wallet_rpc, daemon_rpc, address) } async fn from_wallet_rpc_to_self(spec: AddressSpec) { @@ -68,7 +76,13 @@ async fn from_wallet_rpc_to_self(spec: AddressSpec) { // make an addr let (_, view_pair, _) = runner::random_address(); - let addr = view_pair.address(Network::Mainnet, spec); + let addr = match spec { + AddressSpec::Legacy => view_pair.legacy_address(Network::Mainnet), + AddressSpec::LegacyIntegrated(payment_id) => { + view_pair.legacy_integrated_address(Network::Mainnet, payment_id) + } + AddressSpec::Subaddress(index) => view_pair.subaddress(Network::Mainnet, index), + }; // refresh & make a tx let _: EmptyResponse = wallet_rpc.json_rpc_call("refresh", None).await.unwrap(); @@ -90,38 +104,39 @@ async fn from_wallet_rpc_to_self(spec: AddressSpec) { // TODO: Needs https://github.com/monero-project/monero/pull/9260 // let fee_rate = daemon_rpc - // .get_fee(daemon_rpc.get_protocol().await.unwrap(), FeePriority::Unimportant) + // .get_fee_rate(FeePriority::Unimportant) // .await // .unwrap(); // unlock it - runner::mine_until_unlocked(&daemon_rpc, &wallet_rpc_addr, tx_hash).await; + let block = runner::mine_until_unlocked(&daemon_rpc, &wallet_rpc_addr, tx_hash).await; // Create the scanner - let mut scanner = Scanner::from_view(view_pair, Some(HashSet::new())); + let mut scanner = Scanner::new(view_pair); if let AddressSpec::Subaddress(index) = spec { scanner.register_subaddress(index); } // Retrieve it and scan it - let tx = daemon_rpc.get_transaction(tx_hash).await.unwrap(); - let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0); + let output = + scanner.scan(&daemon_rpc, &block).await.unwrap().not_additionally_locked().swap_remove(0); + assert_eq!(output.transaction(), tx_hash); // TODO: Needs https://github.com/monero-project/monero/pull/9260 // runner::check_weight_and_fee(&tx, fee_rate); match spec { AddressSpec::Subaddress(index) => { - assert_eq!(output.metadata.subaddress, Some(index)); - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted([0u8; 8]))); + assert_eq!(output.subaddress(), Some(index)); + assert_eq!(output.payment_id(), Some(PaymentId::Encrypted([0u8; 8]))); } - AddressSpec::Integrated(payment_id) => { - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted(payment_id))); - assert_eq!(output.metadata.subaddress, None); + AddressSpec::LegacyIntegrated(payment_id) => { + assert_eq!(output.payment_id(), Some(PaymentId::Encrypted(payment_id))); + assert_eq!(output.subaddress(), None); } - AddressSpec::Standard | AddressSpec::Featured { .. } => { - assert_eq!(output.metadata.subaddress, None); - assert_eq!(output.metadata.payment_id, Some(PaymentId::Encrypted([0u8; 8]))); + AddressSpec::Legacy => { + assert_eq!(output.subaddress(), None); + assert_eq!(output.payment_id(), Some(PaymentId::Encrypted([0u8; 8]))); } } assert_eq!(output.commitment().amount, 1000000000000); @@ -129,7 +144,7 @@ async fn from_wallet_rpc_to_self(spec: AddressSpec) { async_sequential!( async fn receipt_of_wallet_rpc_tx_standard() { - from_wallet_rpc_to_self(AddressSpec::Standard).await; + from_wallet_rpc_to_self(AddressSpec::Legacy).await; } async fn receipt_of_wallet_rpc_tx_subaddress() { @@ -139,7 +154,7 @@ async_sequential!( async fn receipt_of_wallet_rpc_tx_integrated() { let mut payment_id = [0u8; 8]; OsRng.fill_bytes(&mut payment_id); - from_wallet_rpc_to_self(AddressSpec::Integrated(payment_id)).await; + from_wallet_rpc_to_self(AddressSpec::LegacyIntegrated(payment_id)).await; } ); @@ -170,11 +185,10 @@ test!( let (wallet_rpc, _, wallet_rpc_addr) = initialize_rpcs().await; // add destination - builder - .add_payment(MoneroAddress::from_str(Network::Mainnet, &wallet_rpc_addr).unwrap(), 1000000); + builder.add_payment(wallet_rpc_addr, 1000000); (builder.build().unwrap(), wallet_rpc) }, - |_, tx: Transaction, _, data: Rpc| async move { + |_, _, tx: Transaction, _, data: SimpleRequestRpc| async move { // confirm receipt let _: EmptyResponse = data.json_rpc_call("refresh", None).await.unwrap(); let transfer: TransfersResponse = data @@ -208,7 +222,7 @@ test!( .add_payment(MoneroAddress::from_str(Network::Mainnet, &addr.address).unwrap(), 1000000); (builder.build().unwrap(), (wallet_rpc, addr.account_index)) }, - |_, tx: Transaction, _, data: (Rpc, u32)| async move { + |_, _, tx: Transaction, _, data: (SimpleRequestRpc, u32)| async move { // confirm receipt let _: EmptyResponse = data.0.json_rpc_call("refresh", None).await.unwrap(); let transfer: TransfersResponse = data @@ -224,7 +238,7 @@ test!( assert_eq!(transfer.transfer.payment_id, hex::encode([0u8; 8])); // Make sure only one R was included in TX extra - assert!(Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref()) + assert!(Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()) .unwrap() .keys() .unwrap() @@ -260,7 +274,7 @@ test!( ]); (builder.build().unwrap(), (wallet_rpc, daemon_rpc, addrs.address_index)) }, - |_, tx: Transaction, _, data: (Rpc, Rpc, u32)| async move { + |_, _, tx: Transaction, _, data: (SimpleRequestRpc, SimpleRequestRpc, u32)| async move { // confirm receipt let _: EmptyResponse = data.0.json_rpc_call("refresh", None).await.unwrap(); let transfer: TransfersResponse = data @@ -283,7 +297,7 @@ test!( // Make sure 3 additional pub keys are included in TX extra let keys = - Extra::read::<&[u8]>(&mut tx.prefix.extra.as_ref()).unwrap().keys().unwrap().1.unwrap(); + Extra::read::<&[u8]>(&mut tx.prefix().extra.as_ref()).unwrap().keys().unwrap().1.unwrap(); assert_eq!(keys.len(), 3); }, @@ -305,7 +319,7 @@ test!( builder.add_payment(MoneroAddress::from_str(Network::Mainnet, &addr).unwrap(), 1000000); (builder.build().unwrap(), (wallet_rpc, payment_id)) }, - |_, tx: Transaction, _, data: (Rpc, [u8; 8])| async move { + |_, _, tx: Transaction, _, data: (SimpleRequestRpc, [u8; 8])| async move { // confirm receipt let _: EmptyResponse = data.0.json_rpc_call("refresh", None).await.unwrap(); let transfer: TransfersResponse = data @@ -328,19 +342,17 @@ test!( let (wallet_rpc, _, wallet_rpc_addr) = initialize_rpcs().await; // add destination - builder - .add_payment(MoneroAddress::from_str(Network::Mainnet, &wallet_rpc_addr).unwrap(), 1000000); + builder.add_payment(wallet_rpc_addr, 1000000); // Make 2 data that is the full 255 bytes for _ in 0 .. 2 { - // Subtract 1 since we prefix data with 127 - let data = vec![b'a'; MAX_TX_EXTRA_NONCE_SIZE - 1]; + let data = vec![b'a'; MAX_ARBITRARY_DATA_SIZE]; builder.add_data(data).unwrap(); } (builder.build().unwrap(), wallet_rpc) }, - |_, tx: Transaction, _, data: Rpc| async move { + |_, _, tx: Transaction, _, data: SimpleRequestRpc| async move { // confirm receipt let _: EmptyResponse = data.json_rpc_call("refresh", None).await.unwrap(); let transfer: TransfersResponse = data diff --git a/coins/monero/wallet/util/Cargo.toml b/coins/monero/wallet/util/Cargo.toml new file mode 100644 index 000000000..bedc8aecf --- /dev/null +++ b/coins/monero/wallet/util/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "monero-wallet-util" +version = "0.1.0" +description = "Additional utility functions for monero-wallet" +license = "MIT" +repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet/util" +authors = ["Luke Parker "] +edition = "2021" +rust-version = "1.79" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false } + +thiserror = { version = "1", default-features = false, optional = true } + +zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] } +rand_core = { version = "0.6", default-features = false } + +monero-wallet = { path = "..", default-features = false } + +monero-seed = { path = "../seed", default-features = false } +polyseed = { path = "../polyseed", default-features = false } + +[dev-dependencies] +hex = { version = "0.4", default-features = false, features = ["std"] } +curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] } + +[features] +std = [ + "std-shims/std", + + "thiserror", + + "zeroize/std", + "rand_core/std", + + "monero-wallet/std", + + "monero-seed/std", + "polyseed/std", +] +compile-time-generators = ["monero-wallet/compile-time-generators"] +multisig = ["monero-wallet/multisig", "std"] +default = ["std", "compile-time-generators"] diff --git a/coins/monero/wallet/util/LICENSE b/coins/monero/wallet/util/LICENSE new file mode 100644 index 000000000..91d893c11 --- /dev/null +++ b/coins/monero/wallet/util/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 Luke Parker + +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/coins/monero/wallet/util/README.md b/coins/monero/wallet/util/README.md new file mode 100644 index 000000000..15d7c80ca --- /dev/null +++ b/coins/monero/wallet/util/README.md @@ -0,0 +1,25 @@ +# Monero Wallet Utilities + +Additional utility functions for monero-wallet. + +This library is isolated as it adds a notable amount of dependencies to the +tree, and to be a subject to a distinct versioning policy. This library may +more frequently undergo breaking API changes. + +This library is usable under no-std when the `std` feature (on by default) is +disabled. + +### Features + +- Support for Monero's seed algorithm +- Support for Polyseed + +### Cargo Features + +- `std` (on by default): Enables `std` (and with it, more efficient internal + implementations). +- `compile-time-generators` (on by default): Derives the generators at + compile-time so they don't need to be derived at runtime. This is recommended + if program size doesn't need to be kept minimal. +- `multisig`: Adds support for creation of transactions using a threshold + multisignature wallet. diff --git a/coins/monero/wallet/util/src/lib.rs b/coins/monero/wallet/util/src/lib.rs new file mode 100644 index 000000000..e2aaa6962 --- /dev/null +++ b/coins/monero/wallet/util/src/lib.rs @@ -0,0 +1,9 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +pub use monero_wallet::*; + +/// Seed creation and parsing functionality. +pub mod seed; diff --git a/coins/monero/wallet/util/src/seed.rs b/coins/monero/wallet/util/src/seed.rs new file mode 100644 index 000000000..77ca3358d --- /dev/null +++ b/coins/monero/wallet/util/src/seed.rs @@ -0,0 +1,150 @@ +use core::fmt; +use std_shims::string::String; + +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use rand_core::{RngCore, CryptoRng}; + +pub use monero_seed as original; +pub use polyseed; + +use original::{SeedError as OriginalSeedError, Seed as OriginalSeed}; +use polyseed::{PolyseedError, Polyseed}; + +/// An error from working with seeds. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +pub enum SeedError { + /// The seed was invalid. + #[cfg_attr(feature = "std", error("invalid seed"))] + InvalidSeed, + /// The entropy was invalid. + #[cfg_attr(feature = "std", error("invalid entropy"))] + InvalidEntropy, + /// The checksum did not match the data. + #[cfg_attr(feature = "std", error("invalid checksum"))] + InvalidChecksum, + /// Unsupported features were enabled. + #[cfg_attr(feature = "std", error("unsupported features"))] + UnsupportedFeatures, +} + +impl From for SeedError { + fn from(error: OriginalSeedError) -> SeedError { + match error { + OriginalSeedError::DeprecatedEnglishWithChecksum | OriginalSeedError::InvalidChecksum => { + SeedError::InvalidChecksum + } + OriginalSeedError::InvalidSeed => SeedError::InvalidSeed, + } + } +} + +impl From for SeedError { + fn from(error: PolyseedError) -> SeedError { + match error { + PolyseedError::UnsupportedFeatures => SeedError::UnsupportedFeatures, + PolyseedError::InvalidEntropy => SeedError::InvalidEntropy, + PolyseedError::InvalidSeed => SeedError::InvalidSeed, + PolyseedError::InvalidChecksum => SeedError::InvalidChecksum, + } + } +} + +/// The type of the seed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SeedType { + /// The seed format originally used by Monero, + Original(monero_seed::Language), + /// Polyseed. + Polyseed(polyseed::Language), +} + +/// A seed, internally either the original format or a Polyseed. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub enum Seed { + /// The originally formatted seed. + Original(OriginalSeed), + /// A Polyseed. + Polyseed(Polyseed), +} + +impl fmt::Debug for Seed { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Seed::Original(_) => f.debug_struct("Seed::Original").finish_non_exhaustive(), + Seed::Polyseed(_) => f.debug_struct("Seed::Polyseed").finish_non_exhaustive(), + } + } +} + +impl Seed { + /// Create a new seed. + pub fn new(rng: &mut R, seed_type: SeedType) -> Seed { + match seed_type { + SeedType::Original(lang) => Seed::Original(OriginalSeed::new(rng, lang)), + SeedType::Polyseed(lang) => Seed::Polyseed(Polyseed::new(rng, lang)), + } + } + + /// Parse a seed from a string. + pub fn from_string(seed_type: SeedType, words: Zeroizing) -> Result { + match seed_type { + SeedType::Original(lang) => Ok(OriginalSeed::from_string(lang, words).map(Seed::Original)?), + SeedType::Polyseed(lang) => Ok(Polyseed::from_string(lang, words).map(Seed::Polyseed)?), + } + } + + /// Create a seed from entropy. + /// + /// A birthday may be optionally provided, denoted in seconds since the epoch. For + /// SeedType::Original, it will be ignored. For SeedType::Polyseed, it'll be embedded into the + /// seed. + /// + /// For SeedType::Polyseed, the last 13 bytes of `entropy` must be 0. + // TODO: Return Result, not Option + pub fn from_entropy( + seed_type: SeedType, + entropy: Zeroizing<[u8; 32]>, + birthday: Option, + ) -> Option { + match seed_type { + SeedType::Original(lang) => OriginalSeed::from_entropy(lang, entropy).map(Seed::Original), + SeedType::Polyseed(lang) => { + Polyseed::from(lang, 0, birthday.unwrap_or(0), entropy).ok().map(Seed::Polyseed) + } + } + } + + /// Converts the seed to a string. + pub fn to_string(&self) -> Zeroizing { + match self { + Seed::Original(seed) => seed.to_string(), + Seed::Polyseed(seed) => seed.to_string(), + } + } + + /// Get the entropy for this seed. + pub fn entropy(&self) -> Zeroizing<[u8; 32]> { + match self { + Seed::Original(seed) => seed.entropy(), + Seed::Polyseed(seed) => seed.entropy().clone(), + } + } + + /// Get the key derived from this seed. + pub fn key(&self) -> Zeroizing<[u8; 32]> { + match self { + // Original does not differentiate between its entropy and its key + Seed::Original(seed) => seed.entropy(), + Seed::Polyseed(seed) => seed.key(), + } + } + + /// Get the birthday of this seed, denoted in seconds since the epoch. + pub fn birthday(&self) -> u64 { + match self { + Seed::Original(_) => 0, + Seed::Polyseed(seed) => seed.birthday(), + } + } +} diff --git a/coins/monero/wallet/util/tests/tests.rs b/coins/monero/wallet/util/tests/tests.rs new file mode 100644 index 000000000..7b6656f26 --- /dev/null +++ b/coins/monero/wallet/util/tests/tests.rs @@ -0,0 +1,3 @@ +// TODO +#[test] +fn test() {} diff --git a/common/request/src/lib.rs b/common/request/src/lib.rs index 60e510193..df9689e1e 100644 --- a/common/request/src/lib.rs +++ b/common/request/src/lib.rs @@ -58,6 +58,8 @@ impl Client { res.set_nodelay(true); res.set_reuse_address(true); #[cfg(feature = "tls")] + res.enforce_http(false); + #[cfg(feature = "tls")] let res = HttpsConnectorBuilder::new() .with_native_roots() .expect("couldn't fetch system's SSL roots") diff --git a/orchestration/dev/coins/monero/run.sh b/orchestration/dev/coins/monero/run.sh index 675d44382..75a93e464 100755 --- a/orchestration/dev/coins/monero/run.sh +++ b/orchestration/dev/coins/monero/run.sh @@ -7,5 +7,5 @@ RPC_PASS="${RPC_PASS:=seraidex}" monerod --non-interactive --regtest --offline --fixed-difficulty=1 \ --no-zmq --rpc-bind-ip=0.0.0.0 --rpc-bind-port=18081 --confirm-external-bind \ --rpc-access-control-origins "*" --disable-rpc-ban \ - --rpc-login=$RPC_USER:$RPC_PASS \ + --rpc-login=$RPC_USER:$RPC_PASS --log-level 2 \ $1 diff --git a/orchestration/src/coins/monero.rs b/orchestration/src/coins/monero.rs index c21bc6107..7318e3e33 100644 --- a/orchestration/src/coins/monero.rs +++ b/orchestration/src/coins/monero.rs @@ -69,14 +69,7 @@ CMD ["/run.sh"] } pub fn monero(orchestration_path: &Path, network: Network) { - monero_internal( - network, - if network == Network::Dev { Os::Alpine } else { Os::Debian }, - orchestration_path, - "monero", - "monerod", - "18080 18081", - ) + monero_internal(network, Os::Debian, orchestration_path, "monero", "monerod", "18080 18081") } pub fn monero_wallet_rpc(orchestration_path: &Path) { diff --git a/processor/Cargo.toml b/processor/Cargo.toml index cc0108488..5ce87bbc4 100644 --- a/processor/Cargo.toml +++ b/processor/Cargo.toml @@ -21,7 +21,6 @@ workspace = true async-trait = { version = "0.1", default-features = false } zeroize = { version = "1", default-features = false, features = ["std"] } thiserror = { version = "1", default-features = false } -serde = { version = "1", default-features = false, features = ["std", "derive"] } # Libs rand_core = { version = "0.6", default-features = false, features = ["std", "getrandom"] } @@ -53,7 +52,8 @@ ethereum-serai = { path = "../coins/ethereum", default-features = false, optiona # Monero dalek-ff-group = { path = "../crypto/dalek-ff-group", default-features = false, features = ["std"], optional = true } -monero-serai = { path = "../coins/monero", default-features = false, features = ["std", "http-rpc", "multisig"], optional = true } +monero-simple-request-rpc = { path = "../coins/monero/rpc/simple-request", default-features = false, optional = true } +monero-wallet = { path = "../coins/monero/wallet", default-features = false, features = ["std", "multisig", "compile-time-generators"], optional = true } # Application log = { version = "0.4", default-features = false, features = ["std"] } @@ -87,7 +87,7 @@ bitcoin = ["dep:secp256k1", "secp256k1", "bitcoin-serai", "serai-client/bitcoin" ethereum = ["secp256k1", "ethereum-serai/tests"] ed25519 = ["dalek-ff-group", "frost/ed25519"] -monero = ["ed25519", "monero-serai", "serai-client/monero"] +monero = ["ed25519", "monero-simple-request-rpc", "monero-wallet", "serai-client/monero"] binaries = ["env_logger", "serai-env", "message-queue"] parity-db = ["serai-db/parity-db"] diff --git a/processor/src/networks/bitcoin.rs b/processor/src/networks/bitcoin.rs index 183444b12..43c266c4c 100644 --- a/processor/src/networks/bitcoin.rs +++ b/processor/src/networks/bitcoin.rs @@ -465,7 +465,9 @@ impl Bitcoin { Err(TransactionError::NoOutputs | TransactionError::NotEnoughFunds) => Ok(None), // amortize_fee removes payments which fall below the dust threshold Err(TransactionError::DustPayment) => panic!("dust payment despite removing dust"), - Err(TransactionError::TooMuchData) => panic!("too much data despite not specifying data"), + Err(TransactionError::TooMuchData) => { + panic!("too much data despite not specifying data") + } Err(TransactionError::TooLowFee) => { panic!("created a transaction whose fee is below the minimum") } diff --git a/processor/src/networks/monero.rs b/processor/src/networks/monero.rs index 8d4d17606..b72cfad93 100644 --- a/processor/src/networks/monero.rs +++ b/processor/src/networks/monero.rs @@ -13,19 +13,20 @@ use ciphersuite::group::{ff::Field, Group}; use dalek_ff_group::{Scalar, EdwardsPoint}; use frost::{curve::Ed25519, ThresholdKeys}; -use monero_serai::{ - Protocol, +use monero_simple_request_rpc::SimpleRequestRpc; +use monero_wallet::{ ringct::RctType, transaction::Transaction, block::Block, - rpc::{RpcError, HttpRpc, Rpc}, - wallet::{ - ViewPair, Scanner, - address::{Network as MoneroNetwork, SubaddressIndex, AddressSpec}, - Fee, SpendableOutput, Change, Decoys, TransactionError, - SignableTransaction as MSignableTransaction, Eventuality, TransactionMachine, + rpc::{FeeRate, RpcError, Rpc}, + address::{Network as MoneroNetwork, SubaddressIndex}, + ViewPair, GuaranteedViewPair, WalletOutput, GuaranteedScanner, DecoySelection, Decoys, + send::{ + SendError, Change, SignableTransaction as MSignableTransaction, Eventuality, TransactionMachine, }, }; +#[cfg(test)] +use monero_wallet::Scanner; use tokio::time::sleep; @@ -45,7 +46,7 @@ use crate::{ }; #[derive(Clone, PartialEq, Eq, Debug)] -pub struct Output(SpendableOutput, Vec); +pub struct Output(WalletOutput); const EXTERNAL_SUBADDRESS: Option = SubaddressIndex::new(0, 0); const BRANCH_SUBADDRESS: Option = SubaddressIndex::new(1, 0); @@ -59,7 +60,7 @@ impl OutputTrait for Output { type Id = [u8; 32]; fn kind(&self) -> OutputType { - match self.0.output.metadata.subaddress { + match self.0.subaddress() { EXTERNAL_SUBADDRESS => OutputType::External, BRANCH_SUBADDRESS => OutputType::Branch, CHANGE_SUBADDRESS => OutputType::Change, @@ -69,15 +70,15 @@ impl OutputTrait for Output { } fn id(&self) -> Self::Id { - self.0.output.data.key.compress().to_bytes() + self.0.key().compress().to_bytes() } fn tx_id(&self) -> [u8; 32] { - self.0.output.absolute.tx + self.0.transaction() } fn key(&self) -> EdwardsPoint { - EdwardsPoint(self.0.output.data.key - (EdwardsPoint::generator().0 * self.0.key_offset())) + EdwardsPoint(self.0.key() - (EdwardsPoint::generator().0 * self.0.key_offset())) } fn presumed_origin(&self) -> Option
{ @@ -89,26 +90,22 @@ impl OutputTrait for Output { } fn data(&self) -> &[u8] { - &self.1 + let Some(data) = self.0.arbitrary_data().first() else { return &[] }; + // If the data is too large, prune it + // This should cause decoding the instruction to fail, and trigger a refund as appropriate + if data.len() > usize::try_from(MAX_DATA_LEN).unwrap() { + return &[]; + } + data } fn write(&self, writer: &mut W) -> io::Result<()> { self.0.write(writer)?; - writer.write_all(&u16::try_from(self.1.len()).unwrap().to_le_bytes())?; - writer.write_all(&self.1)?; Ok(()) } fn read(reader: &mut R) -> io::Result { - let output = SpendableOutput::read(reader)?; - - let mut data_len = [0; 2]; - reader.read_exact(&mut data_len)?; - - let mut data = vec![0; usize::from(u16::from_le_bytes(data_len))]; - reader.read_exact(&mut data)?; - - Ok(Output(output, data)) + Ok(Output(WalletOutput::read(reader)?)) } } @@ -121,7 +118,10 @@ impl TransactionTrait for Transaction { #[cfg(test)] async fn fee(&self, _: &Monero) -> u64 { - self.rct_signatures.base.fee + match self { + Transaction::V1 { .. } => panic!("v1 TX in test-only function"), + Transaction::V2 { ref proofs, .. } => proofs.as_ref().unwrap().base.fee, + } } } @@ -134,7 +134,7 @@ impl EventualityTrait for Eventuality { // Extra includess the one time keys which are derived from the plan ID, so a collision here is a // hash collision fn lookup(&self) -> Vec { - self.extra().to_vec() + self.extra() } fn read(reader: &mut R) -> io::Result { @@ -156,13 +156,10 @@ impl EventualityTrait for Eventuality { } #[derive(Clone, Debug)] -pub struct SignableTransaction { - transcript: RecommendedTranscript, - actual: MSignableTransaction, -} +pub struct SignableTransaction(MSignableTransaction); impl SignableTransactionTrait for SignableTransaction { fn fee(&self) -> u64 { - self.actual.fee() + self.0.necessary_fee() } } @@ -179,17 +176,17 @@ impl BlockTrait for Block { async fn time(&self, rpc: &Monero) -> u64 { // Constant from Monero - const BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW: u64 = 60; + const BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW: usize = 60; // If Monero doesn't have enough blocks to build a window, it doesn't define a network time if (self.number().unwrap() + 1) < BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW { // Use the block number as the time - return self.number().unwrap(); + return u64::try_from(self.number().unwrap()).unwrap(); } let mut timestamps = vec![self.header.timestamp]; let mut parent = self.parent(); - while u64::try_from(timestamps.len()).unwrap() < BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW { + while timestamps.len() < BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW { let mut parent_block; while { parent_block = rpc.rpc.get_block(parent).await; @@ -220,13 +217,13 @@ impl BlockTrait for Block { // Monero also solely requires the block's time not be less than the median, it doesn't ensure // it advances the median forward // Ensure monotonicity despite both these issues by adding the block number to the median time - res + self.number().unwrap() + res + u64::try_from(self.number().unwrap()).unwrap() } } #[derive(Clone, Debug)] pub struct Monero { - rpc: Rpc, + rpc: SimpleRequestRpc, } // Shim required for testing/debugging purposes due to generic arguments also necessitating trait // bounds @@ -247,31 +244,32 @@ fn map_rpc_err(err: RpcError) -> NetworkError { NetworkError::ConnectionError } +enum MakeSignableTransactionResult { + Fee(u64), + SignableTransaction(MSignableTransaction), +} + impl Monero { pub async fn new(url: String) -> Monero { - let mut res = HttpRpc::new(url.clone()).await; + let mut res = SimpleRequestRpc::new(url.clone()).await; while let Err(e) = res { log::error!("couldn't connect to Monero node: {e:?}"); tokio::time::sleep(Duration::from_secs(5)).await; - res = HttpRpc::new(url.clone()).await; + res = SimpleRequestRpc::new(url.clone()).await; } Monero { rpc: res.unwrap() } } - fn view_pair(spend: EdwardsPoint) -> ViewPair { - ViewPair::new(spend.0, Zeroizing::new(additional_key::(0).0)) + fn view_pair(spend: EdwardsPoint) -> GuaranteedViewPair { + GuaranteedViewPair::new(spend.0, Zeroizing::new(additional_key::(0).0)).unwrap() } fn address_internal(spend: EdwardsPoint, subaddress: Option) -> Address { - Address::new(Self::view_pair(spend).address( - MoneroNetwork::Mainnet, - AddressSpec::Featured { subaddress, payment_id: None, guaranteed: true }, - )) - .unwrap() + Address::new(Self::view_pair(spend).address(MoneroNetwork::Mainnet, subaddress, None)).unwrap() } - fn scanner(spend: EdwardsPoint) -> Scanner { - let mut scanner = Scanner::from_view(Self::view_pair(spend), None); + fn scanner(spend: EdwardsPoint) -> GuaranteedScanner { + let mut scanner = GuaranteedScanner::new(Self::view_pair(spend)); debug_assert!(EXTERNAL_SUBADDRESS.is_none()); scanner.register_subaddress(BRANCH_SUBADDRESS.unwrap()); scanner.register_subaddress(CHANGE_SUBADDRESS.unwrap()); @@ -279,26 +277,24 @@ impl Monero { scanner } - async fn median_fee(&self, block: &Block) -> Result { + async fn median_fee(&self, block: &Block) -> Result { let mut fees = vec![]; - for tx_hash in &block.txs { + for tx_hash in &block.transactions { let tx = self.rpc.get_transaction(*tx_hash).await.map_err(|_| NetworkError::ConnectionError)?; // Only consider fees from RCT transactions, else the fee property read wouldn't be accurate - if tx.rct_signatures.rct_type() != RctType::Null { - continue; - } - // This isn't entirely accurate as Bulletproof TXs will have a higher weight than their - // serialization length - // It's likely 'good enough' - // TODO2: Improve - fees.push(tx.rct_signatures.base.fee / u64::try_from(tx.serialize().len()).unwrap()); + let fee = match &tx { + Transaction::V2 { proofs: Some(proofs), .. } => proofs.base.fee, + _ => continue, + }; + fees.push(fee / u64::try_from(tx.weight()).unwrap()); } fees.sort(); let fee = fees.get(fees.len() / 2).copied().unwrap_or(0); // TODO: Set a sane minimum fee - Ok(Fee { per_weight: fee.max(1500000), mask: 10000 }) + const MINIMUM_FEE: u64 = 1_500_000; + Ok(FeeRate::new(fee.max(MINIMUM_FEE), 10000).unwrap()) } async fn make_signable_transaction( @@ -309,7 +305,7 @@ impl Monero { payments: &[Payment], change: &Option
, calculating_fee: bool, - ) -> Result, NetworkError> { + ) -> Result, NetworkError> { for payment in payments { assert_eq!(payment.balance.coin, Coin::Monero); } @@ -318,26 +314,13 @@ impl Monero { let block_for_fee = self.get_block(block_number).await?; let fee_rate = self.median_fee(&block_for_fee).await?; - // Get the protocol for the specified block number - // For now, this should just be v16, the latest deployed protocol, since there's no upcoming - // hard fork to be mindful of - let get_protocol = || Protocol::v16; - - #[cfg(not(test))] - let protocol = get_protocol(); - // If this is a test, we won't be using a mainnet node and need a distinct protocol - // determination - // Just use whatever the node expects - #[cfg(test)] - let protocol = self.rpc.get_protocol().await.unwrap(); - - // Hedge against the above codegen failing by having an always included runtime check - if !cfg!(test) { - assert_eq!(protocol, get_protocol()); - } - - // Check a fork hasn't occurred which this processor hasn't been updated for - assert_eq!(protocol, self.rpc.get_protocol().await.map_err(map_rpc_err)?); + // Determine the RCT proofs to make based off the hard fork + // TODO: Make a fn for this block which is duplicated with tests + let rct_type = match block_for_fee.header.hardfork_version { + 14 => RctType::ClsagBulletproof, + 15 | 16 => RctType::ClsagBulletproofPlus, + _ => panic!("Monero hard forked and the processor wasn't updated for it"), + }; let spendable_outputs = inputs.iter().map(|input| input.0.clone()).collect::>(); @@ -350,7 +333,12 @@ impl Monero { let decoys = Decoys::fingerprintable_canonical_select( &mut ChaCha20Rng::from_seed(transcript.rng_seed(b"decoys")), &self.rpc, - protocol.ring_len(), + // TODO: Have Decoys take RctType + match rct_type { + RctType::ClsagBulletproof => 11, + RctType::ClsagBulletproofPlus => 16, + _ => panic!("selecting decoys for an unsupported RctType"), + }, block_number + 1, &spendable_outputs, ) @@ -369,7 +357,8 @@ impl Monero { payments.push(Payment { address: Address::new( ViewPair::new(EdwardsPoint::generator().0, Zeroizing::new(Scalar::ONE.0)) - .address(MoneroNetwork::Mainnet, AddressSpec::Standard), + .unwrap() + .legacy_address(MoneroNetwork::Mainnet), ) .unwrap(), balance: Balance { coin: Coin::Monero, amount: Amount(0) }, @@ -379,56 +368,69 @@ impl Monero { let payments = payments .into_iter() - // If we're solely estimating the fee, don't actually specify an amount - // This won't affect the fee calculation yet will ensure we don't hit an out of funds error - .map(|payment| { - (payment.address.into(), if calculating_fee { 0 } else { payment.balance.amount.0 }) - }) + .map(|payment| (payment.address.into(), payment.balance.amount.0)) .collect::>(); match MSignableTransaction::new( - protocol, - // Use the plan ID as the r_seed - // This perfectly binds the plan while simultaneously allowing verifying the plan was - // executed with no additional communication - Some(Zeroizing::new(*plan_id)), + rct_type, + // Use the plan ID as the outgoing view key + Zeroizing::new(*plan_id), inputs.clone(), payments, - &Change::fingerprintable(change.as_ref().map(|change| change.clone().into())), + Change::fingerprintable(change.as_ref().map(|change| change.clone().into())), vec![], fee_rate, ) { - Ok(signable) => Ok(Some((transcript, signable))), + Ok(signable) => Ok(Some({ + if calculating_fee { + MakeSignableTransactionResult::Fee(signable.necessary_fee()) + } else { + MakeSignableTransactionResult::SignableTransaction(signable) + } + })), Err(e) => match e { - TransactionError::MultiplePaymentIds => { - panic!("multiple payment IDs despite not supporting integrated addresses"); + SendError::UnsupportedRctType => { + panic!("trying to use an RctType unsupported by monero-wallet") } - TransactionError::NoInputs | - TransactionError::NoOutputs | - TransactionError::InvalidDecoyQuantity | - TransactionError::NoChange | - TransactionError::TooManyOutputs | - TransactionError::TooMuchData | - TransactionError::TooLargeTransaction | - TransactionError::WrongPrivateKey => { + SendError::NoInputs | + SendError::InvalidDecoyQuantity | + SendError::NoOutputs | + SendError::TooManyOutputs | + SendError::NoChange | + SendError::TooMuchArbitraryData | + SendError::TooLargeTransaction | + SendError::WrongPrivateKey => { panic!("created an Monero invalid transaction: {e}"); } - TransactionError::ClsagError(_) | - TransactionError::InvalidTransaction(_) | - TransactionError::FrostError(_) => { - panic!("supposedly unreachable (at this time) Monero error: {e}"); + SendError::MultiplePaymentIds => { + panic!("multiple payment IDs despite not supporting integrated addresses"); } - TransactionError::NotEnoughFunds { inputs, outputs, fee } => { + SendError::NotEnoughFunds { inputs, outputs, necessary_fee } => { log::debug!( - "Monero NotEnoughFunds. inputs: {:?}, outputs: {:?}, fee: {fee}", + "Monero NotEnoughFunds. inputs: {:?}, outputs: {:?}, necessary_fee: {necessary_fee:?}", inputs, outputs ); - Ok(None) + match necessary_fee { + Some(necessary_fee) => { + // If we're solely calculating the fee, return the fee this TX will cost + if calculating_fee { + Ok(Some(MakeSignableTransactionResult::Fee(necessary_fee))) + } else { + // If we're actually trying to make the TX, return None + Ok(None) + } + } + // We didn't have enough funds to even cover the outputs + None => { + // Ensure we're not misinterpreting this + assert!(outputs > inputs); + Ok(None) + } + } } - TransactionError::RpcError(e) => { - log::error!("RpcError when preparing transaction: {e:?}"); - Err(map_rpc_err(e)) + SendError::MaliciousSerialization | SendError::ClsagError(_) | SendError::FrostError(_) => { + panic!("supposedly unreachable (at this time) Monero error: {e}"); } }, } @@ -436,18 +438,17 @@ impl Monero { #[cfg(test)] fn test_view_pair() -> ViewPair { - ViewPair::new(*EdwardsPoint::generator(), Zeroizing::new(Scalar::ONE.0)) + ViewPair::new(*EdwardsPoint::generator(), Zeroizing::new(Scalar::ONE.0)).unwrap() } #[cfg(test)] fn test_scanner() -> Scanner { - Scanner::from_view(Self::test_view_pair(), Some(std::collections::HashSet::new())) + Scanner::new(Self::test_view_pair()) } #[cfg(test)] fn test_address() -> Address { - Address::new(Self::test_view_pair().address(MoneroNetwork::Mainnet, AddressSpec::Standard)) - .unwrap() + Address::new(Self::test_view_pair().legacy_address(MoneroNetwork::Mainnet)).unwrap() } } @@ -475,7 +476,6 @@ impl Network for Monero { const MAX_OUTPUTS: usize = 16; // 0.01 XMR - // TODO: Set a sane dust const DUST: u64 = 10000000000; // TODO @@ -528,34 +528,17 @@ impl Network for Monero { } }; - let mut txs = outputs - .iter() - .filter_map(|outputs| Some(outputs.not_locked()).filter(|outputs| !outputs.is_empty())) - .collect::>(); - - // This should be pointless as we shouldn't be able to scan for any other subaddress - // This just ensures nothing invalid makes it through - for tx_outputs in &txs { - for output in tx_outputs { - assert!([EXTERNAL_SUBADDRESS, BRANCH_SUBADDRESS, CHANGE_SUBADDRESS, FORWARD_SUBADDRESS] - .contains(&output.output.metadata.subaddress)); - } - } - - let mut outputs = Vec::with_capacity(txs.len()); - for mut tx_outputs in txs.drain(..) { - for output in tx_outputs.drain(..) { - let mut data = output.arbitrary_data().first().cloned().unwrap_or(vec![]); - - // The Output serialization code above uses u16 to represent length - data.truncate(u16::MAX.into()); - // Monero data segments should be <= 255 already, and MAX_DATA_LEN is currently 512 - // This just allows either Monero to change, or MAX_DATA_LEN to change, without introducing - // complicationso - data.truncate(MAX_DATA_LEN.try_into().unwrap()); - - outputs.push(Output(output, data)); - } + // Miner transactions are required to explicitly state their timelock, so this does exclude + // those (which have an extended timelock we don't want to deal with) + let raw_outputs = outputs.not_additionally_locked(); + let mut outputs = Vec::with_capacity(raw_outputs.len()); + for output in raw_outputs { + // This should be pointless as we shouldn't be able to scan for any other subaddress + // This just helps ensures nothing invalid makes it through + assert!([EXTERNAL_SUBADDRESS, BRANCH_SUBADDRESS, CHANGE_SUBADDRESS, FORWARD_SUBADDRESS] + .contains(&output.subaddress())); + + outputs.push(Output(output)); } outputs @@ -577,7 +560,7 @@ impl Network for Monero { block: &Block, res: &mut HashMap<[u8; 32], (usize, [u8; 32], Transaction)>, ) { - for hash in &block.txs { + for hash in &block.transactions { let tx = { let mut tx; while { @@ -590,23 +573,21 @@ impl Network for Monero { tx.unwrap() }; - if let Some((_, eventuality)) = eventualities.map.get(&tx.prefix.extra) { + if let Some((_, eventuality)) = eventualities.map.get(&tx.prefix().extra) { if eventuality.matches(&tx) { res.insert( - eventualities.map.remove(&tx.prefix.extra).unwrap().0, - (usize::try_from(block.number().unwrap()).unwrap(), tx.id(), tx), + eventualities.map.remove(&tx.prefix().extra).unwrap().0, + (block.number().unwrap(), tx.id(), tx), ); } } } eventualities.block_number += 1; - assert_eq!(eventualities.block_number, usize::try_from(block.number().unwrap()).unwrap()); + assert_eq!(eventualities.block_number, block.number().unwrap()); } - for block_num in - (eventualities.block_number + 1) .. usize::try_from(block.number().unwrap()).unwrap() - { + for block_num in (eventualities.block_number + 1) .. block.number().unwrap() { let block = { let mut block; while { @@ -624,7 +605,7 @@ impl Network for Monero { // Also check the current block check_block(self, eventualities, block, &mut res).await; - assert_eq!(eventualities.block_number, usize::try_from(block.number().unwrap()).unwrap()); + assert_eq!(eventualities.block_number, block.number().unwrap()); res } @@ -636,12 +617,14 @@ impl Network for Monero { payments: &[Payment], change: &Option
, ) -> Result, NetworkError> { - Ok( - self - .make_signable_transaction(block_number, &[0; 32], inputs, payments, change, true) - .await? - .map(|(_, signable)| signable.fee()), - ) + let res = self + .make_signable_transaction(block_number, &[0; 32], inputs, payments, change, true) + .await?; + let Some(res) = res else { return Ok(None) }; + let MakeSignableTransactionResult::Fee(fee) = res else { + panic!("told make_signable_transaction calculating_fee and got transaction") + }; + Ok(Some(fee)) } async fn signable_transaction( @@ -654,16 +637,17 @@ impl Network for Monero { change: &Option
, (): &(), ) -> Result, NetworkError> { - Ok( - self - .make_signable_transaction(block_number, plan_id, inputs, payments, change, false) - .await? - .map(|(transcript, signable)| { - let signable = SignableTransaction { transcript, actual: signable }; - let eventuality = signable.actual.eventuality().unwrap(); - (signable, eventuality) - }), - ) + let res = self + .make_signable_transaction(block_number, plan_id, inputs, payments, change, false) + .await?; + let Some(res) = res else { return Ok(None) }; + let MakeSignableTransactionResult::SignableTransaction(signable) = res else { + panic!("told make_signable_transaction not calculating_fee and got fee") + }; + + let signable = SignableTransaction(signable); + let eventuality = signable.0.clone().into(); + Ok(Some((signable, eventuality))) } async fn attempt_sign( @@ -671,7 +655,7 @@ impl Network for Monero { keys: ThresholdKeys, transaction: SignableTransaction, ) -> Result { - match transaction.actual.clone().multisig(&keys, transaction.transcript) { + match transaction.0.clone().multisig(&keys) { Ok(machine) => Ok(machine), Err(e) => panic!("failed to create a multisig machine for TX: {e}"), } @@ -705,7 +689,7 @@ impl Network for Monero { #[cfg(test)] async fn get_block_number(&self, id: &[u8; 32]) -> usize { - self.rpc.get_block(*id).await.unwrap().number().unwrap().try_into().unwrap() + self.rpc.get_block(*id).await.unwrap().number().unwrap() } #[cfg(test)] @@ -724,7 +708,7 @@ impl Network for Monero { eventuality: &Eventuality, ) -> Transaction { let block = self.rpc.get_block_by_number(block).await.unwrap(); - for tx in &block.txs { + for tx in &block.transactions { let tx = self.rpc.get_transaction(*tx).await.unwrap(); if eventuality.matches(&tx) { return tx; @@ -737,53 +721,42 @@ impl Network for Monero { async fn mine_block(&self) { // https://github.com/serai-dex/serai/issues/198 sleep(std::time::Duration::from_millis(100)).await; - - #[derive(Debug, serde::Deserialize)] - struct EmptyResponse {} - let _: EmptyResponse = self - .rpc - .rpc_call( - "json_rpc", - Some(serde_json::json!({ - "method": "generateblocks", - "params": { - "wallet_address": Self::test_address().to_string(), - "amount_of_blocks": 1 - }, - })), - ) - .await - .unwrap(); + self.rpc.generate_blocks(&Self::test_address().into(), 1).await.unwrap(); } #[cfg(test)] async fn test_send(&self, address: Address) -> Block { use zeroize::Zeroizing; - use rand_core::OsRng; - use monero_serai::wallet::FeePriority; + use rand_core::{RngCore, OsRng}; + use monero_wallet::rpc::FeePriority; let new_block = self.get_latest_block_number().await.unwrap() + 1; for _ in 0 .. 80 { self.mine_block().await; } - let outputs = Self::test_scanner() - .scan(&self.rpc, &self.rpc.get_block_by_number(new_block).await.unwrap()) - .await - .unwrap() - .swap_remove(0) - .ignore_timelock(); + let new_block = self.rpc.get_block_by_number(new_block).await.unwrap(); + let outputs = + Self::test_scanner().scan(&self.rpc, &new_block).await.unwrap().ignore_additional_timelock(); let amount = outputs[0].commitment().amount; // The dust should always be sufficient for the fee let fee = Monero::DUST; - let protocol = self.rpc.get_protocol().await.unwrap(); + let rct_type = match new_block.header.hardfork_version { + 14 => RctType::ClsagBulletproof, + 15 | 16 => RctType::ClsagBulletproofPlus, + _ => panic!("Monero hard forked and the processor wasn't updated for it"), + }; let decoys = Decoys::fingerprintable_canonical_select( &mut OsRng, &self.rpc, - protocol.ring_len(), + match rct_type { + RctType::ClsagBulletproof => 11, + RctType::ClsagBulletproofPlus => 16, + _ => panic!("selecting decoys for an unsupported RctType"), + }, self.rpc.get_height().await.unwrap(), &outputs, ) @@ -792,14 +765,16 @@ impl Network for Monero { let inputs = outputs.into_iter().zip(decoys).collect::>(); + let mut outgoing_view_key = Zeroizing::new([0; 32]); + OsRng.fill_bytes(outgoing_view_key.as_mut()); let tx = MSignableTransaction::new( - protocol, - None, + rct_type, + outgoing_view_key, inputs, vec![(address.into(), amount - fee)], - &Change::fingerprintable(Some(Self::test_address().into())), + Change::fingerprintable(Some(Self::test_address().into())), vec![], - self.rpc.get_fee(protocol, FeePriority::Unimportant).await.unwrap(), + self.rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), ) .unwrap() .sign(&mut OsRng, &Zeroizing::new(Scalar::ONE.0)) diff --git a/substrate/client/Cargo.toml b/substrate/client/Cargo.toml index 0eeb3a2f5..c69bf5f56 100644 --- a/substrate/client/Cargo.toml +++ b/substrate/client/Cargo.toml @@ -39,7 +39,7 @@ simple-request = { path = "../../common/request", version = "0.1", optional = tr bitcoin = { version = "0.32", optional = true } ciphersuite = { path = "../../crypto/ciphersuite", version = "0.4", optional = true } -monero-serai = { path = "../../coins/monero", version = "0.1.4-alpha", optional = true } +monero-wallet = { path = "../../coins/monero/wallet", version = "0.1.0", default-features = false, features = ["std"], optional = true } [dev-dependencies] rand_core = "0.6" @@ -62,7 +62,7 @@ borsh = ["serai-abi/borsh"] networks = [] bitcoin = ["networks", "dep:bitcoin"] -monero = ["networks", "ciphersuite/ed25519", "monero-serai"] +monero = ["networks", "ciphersuite/ed25519", "monero-wallet"] # Assumes the default usage is to use Serai as a DEX, which doesn't actually # require connecting to a Serai node diff --git a/substrate/client/src/networks/monero.rs b/substrate/client/src/networks/monero.rs index 5b43860e9..bd5e0a15c 100644 --- a/substrate/client/src/networks/monero.rs +++ b/substrate/client/src/networks/monero.rs @@ -4,7 +4,7 @@ use scale::{Encode, Decode}; use ciphersuite::{Ciphersuite, Ed25519}; -use monero_serai::wallet::address::{AddressError, Network, AddressType, AddressMeta, MoneroAddress}; +use monero_wallet::address::{AddressError, Network, AddressType, MoneroAddress}; #[derive(Clone, PartialEq, Eq, Debug)] pub struct Address(MoneroAddress); @@ -33,7 +33,7 @@ impl fmt::Display for Address { // SCALE-encoded variant of Monero addresses. #[derive(Clone, PartialEq, Eq, Debug, Encode, Decode)] enum EncodedAddressType { - Standard, + Legacy, Subaddress, Featured(u8), } @@ -52,22 +52,20 @@ impl TryFrom> for Address { let addr = EncodedAddress::decode(&mut data.as_ref()).map_err(|_| ())?; // Convert over Ok(Address(MoneroAddress::new( - AddressMeta::new( - Network::Mainnet, - match addr.kind { - EncodedAddressType::Standard => AddressType::Standard, - EncodedAddressType::Subaddress => AddressType::Subaddress, - EncodedAddressType::Featured(flags) => { - let subaddress = (flags & 1) != 0; - let integrated = (flags & (1 << 1)) != 0; - let guaranteed = (flags & (1 << 2)) != 0; - if integrated { - Err(())?; - } - AddressType::Featured { subaddress, payment_id: None, guaranteed } + Network::Mainnet, + match addr.kind { + EncodedAddressType::Legacy => AddressType::Legacy, + EncodedAddressType::Subaddress => AddressType::Subaddress, + EncodedAddressType::Featured(flags) => { + let subaddress = (flags & 1) != 0; + let integrated = (flags & (1 << 1)) != 0; + let guaranteed = (flags & (1 << 2)) != 0; + if integrated { + Err(())?; } - }, - ), + AddressType::Featured { subaddress, payment_id: None, guaranteed } + } + }, Ed25519::read_G::<&[u8]>(&mut addr.spend.as_ref()).map_err(|_| ())?.0, Ed25519::read_G::<&[u8]>(&mut addr.view.as_ref()).map_err(|_| ())?.0, ))) @@ -85,16 +83,19 @@ impl Into for Address { impl Into> for Address { fn into(self) -> Vec { EncodedAddress { - kind: match self.0.meta.kind { - AddressType::Standard => EncodedAddressType::Standard, + kind: match self.0.kind() { + AddressType::Legacy => EncodedAddressType::Legacy, + AddressType::LegacyIntegrated(_) => { + panic!("integrated address became Serai Monero address") + } AddressType::Subaddress => EncodedAddressType::Subaddress, - AddressType::Integrated(_) => panic!("integrated address became Serai Monero address"), - AddressType::Featured { subaddress, payment_id: _, guaranteed } => { - EncodedAddressType::Featured(u8::from(subaddress) + (u8::from(guaranteed) << 2)) + AddressType::Featured { subaddress, payment_id, guaranteed } => { + debug_assert!(payment_id.is_none()); + EncodedAddressType::Featured(u8::from(*subaddress) + (u8::from(*guaranteed) << 2)) } }, - spend: self.0.spend.compress().0, - view: self.0.view.compress().0, + spend: self.0.spend().compress().0, + view: self.0.view().compress().0, } .encode() } diff --git a/tests/full-stack/Cargo.toml b/tests/full-stack/Cargo.toml index 58e6de28c..6a487a348 100644 --- a/tests/full-stack/Cargo.toml +++ b/tests/full-stack/Cargo.toml @@ -27,7 +27,8 @@ rand_core = { version = "0.6", default-features = false } curve25519-dalek = { version = "4", features = ["rand_core"] } bitcoin-serai = { path = "../../coins/bitcoin" } -monero-serai = { path = "../../coins/monero" } +monero-simple-request-rpc = { path = "../../coins/monero/rpc/simple-request" } +monero-wallet = { path = "../../coins/monero/wallet" } scale = { package = "parity-scale-codec", version = "3" } serde = "1" diff --git a/tests/full-stack/src/lib.rs b/tests/full-stack/src/lib.rs index 5e39c70d9..509a67fac 100644 --- a/tests/full-stack/src/lib.rs +++ b/tests/full-stack/src/lib.rs @@ -53,8 +53,9 @@ impl Handles { pub async fn monero( &self, ops: &DockerOperations, - ) -> monero_serai::rpc::Rpc { - use monero_serai::rpc::HttpRpc; + ) -> monero_simple_request_rpc::SimpleRequestRpc { + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::rpc::Rpc; let rpc = ops.handle(&self.monero.0).host_port(self.monero.1).unwrap(); let rpc = format!("http://{RPC_USER}:{RPC_PASS}@{}:{}", rpc.0, rpc.1); @@ -62,7 +63,7 @@ impl Handles { // If the RPC server has yet to start, sleep for up to 60s until it does for _ in 0 .. 60 { tokio::time::sleep(Duration::from_secs(1)).await; - let Ok(client) = HttpRpc::new(rpc.clone()).await else { continue }; + let Ok(client) = SimpleRequestRpc::new(rpc.clone()).await else { continue }; if client.get_height().await.is_err() { continue; } diff --git a/tests/full-stack/src/tests/mint_and_burn.rs b/tests/full-stack/src/tests/mint_and_burn.rs index 4093e47dd..a5a6577db 100644 --- a/tests/full-stack/src/tests/mint_and_burn.rs +++ b/tests/full-stack/src/tests/mint_and_burn.rs @@ -1,7 +1,6 @@ use std::{ sync::{OnceLock, Arc, Mutex}, time::{Duration, Instant}, - collections::HashSet, }; use zeroize::Zeroizing; @@ -88,14 +87,11 @@ async fn mint_and_burn_test() { // Mine a Monero block let monero_blocks = { use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar}; - use monero_serai::wallet::{ - ViewPair, - address::{Network, AddressSpec}, - }; + use monero_wallet::{rpc::Rpc, ViewPair, address::Network}; let addr = ViewPair::new(ED25519_BASEPOINT_POINT, Zeroizing::new(Scalar::ONE)) - .address(Network::Mainnet, AddressSpec::Standard) - .to_string(); + .unwrap() + .legacy_address(Network::Mainnet); let rpc = producer_handles.monero(ops).await; let mut res = Vec::with_capacity(count); @@ -103,8 +99,8 @@ async fn mint_and_burn_test() { let block = rpc.get_block(rpc.generate_blocks(&addr, 1).await.unwrap().0[0]).await.unwrap(); - let mut txs = Vec::with_capacity(block.txs.len()); - for tx in &block.txs { + let mut txs = Vec::with_capacity(block.transactions.len()); + for tx in &block.transactions { txs.push(rpc.get_transaction(*tx).await.unwrap()); } res.push((serde_json::json!([hex::encode(block.serialize())]), txs)); @@ -128,6 +124,8 @@ async fn mint_and_burn_test() { } { + use monero_wallet::rpc::Rpc; + let rpc = handles.monero(ops).await; for (block, txs) in &monero_blocks { @@ -345,33 +343,30 @@ async fn mint_and_burn_test() { // Send in XMR { use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar}; - use monero_serai::{ - Protocol, - transaction::Timelock, - wallet::{ - ViewPair, Scanner, Decoys, Change, FeePriority, SignableTransaction, - address::{Network, AddressType, AddressMeta, MoneroAddress}, - }, - decompress_point, + use monero_wallet::{ + io::decompress_point, + ringct::RctType, + rpc::{FeePriority, Rpc}, + address::{Network, AddressType, MoneroAddress}, + ViewPair, Scanner, DecoySelection, Decoys, + send::{Change, SignableTransaction}, }; // Grab the first output on the chain let rpc = handles[0].monero(&ops).await; - let view_pair = ViewPair::new(ED25519_BASEPOINT_POINT, Zeroizing::new(Scalar::ONE)); - let mut scanner = Scanner::from_view(view_pair.clone(), Some(HashSet::new())); + let view_pair = ViewPair::new(ED25519_BASEPOINT_POINT, Zeroizing::new(Scalar::ONE)).unwrap(); + let mut scanner = Scanner::new(view_pair.clone()); let output = scanner .scan(&rpc, &rpc.get_block_by_number(1).await.unwrap()) .await .unwrap() - .swap_remove(0) - .unlocked(Timelock::Block(rpc.get_height().await.unwrap())) - .unwrap() + .additional_timelock_satisfied_by(rpc.get_height().await.unwrap(), 0) .swap_remove(0); let decoys = Decoys::fingerprintable_canonical_select( &mut OsRng, &rpc, - Protocol::v16.ring_len(), + 16, rpc.get_height().await.unwrap(), &[output.clone()], ) @@ -379,25 +374,25 @@ async fn mint_and_burn_test() { .unwrap() .swap_remove(0); + let mut outgoing_view_key = Zeroizing::new([0; 32]); + OsRng.fill_bytes(outgoing_view_key.as_mut()); let tx = SignableTransaction::new( - Protocol::v16, - None, + RctType::ClsagBulletproofPlus, + outgoing_view_key, vec![(output, decoys)], vec![( MoneroAddress::new( - AddressMeta::new( - Network::Mainnet, - AddressType::Featured { guaranteed: true, subaddress: false, payment_id: None }, - ), + Network::Mainnet, + AddressType::Featured { guaranteed: true, subaddress: false, payment_id: None }, decompress_point(monero_key_pair.1.to_vec().try_into().unwrap()).unwrap(), ED25519_BASEPOINT_POINT * processor::additional_key::(0).0, ), 1_100_000_000_000, )], - &Change::new(&view_pair, false), + Change::new(&view_pair), vec![Shorthand::transfer(None, serai_addr).encode()], - rpc.get_fee(Protocol::v16, FeePriority::Unimportant).await.unwrap(), + rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), ) .unwrap() .sign(&mut OsRng, &Zeroizing::new(Scalar::ONE)) @@ -473,9 +468,10 @@ async fn mint_and_burn_test() { let spend = ED25519_BASEPOINT_TABLE * &Scalar::random(&mut OsRng); let view = Scalar::random(&mut OsRng); - use monero_serai::wallet::address::{Network, AddressType, AddressMeta, MoneroAddress}; + use monero_wallet::address::{Network, AddressType, MoneroAddress}; let addr = MoneroAddress::new( - AddressMeta::new(Network::Mainnet, AddressType::Standard), + Network::Mainnet, + AddressType::Legacy, spend, ED25519_BASEPOINT_TABLE * &view, ); @@ -486,7 +482,10 @@ async fn mint_and_burn_test() { // Get the current blocks let mut start_bitcoin_block = handles[0].bitcoin(&ops).await.get_latest_block_number().await.unwrap(); - let mut start_monero_block = handles[0].monero(&ops).await.get_height().await.unwrap(); + let mut start_monero_block = { + use monero_wallet::rpc::Rpc; + handles[0].monero(&ops).await.get_height().await.unwrap() + }; // Burn the sriBTC/sriXMR { @@ -578,12 +577,10 @@ async fn mint_and_burn_test() { // Verify the received Monero TX { - use monero_serai::wallet::{ViewPair, Scanner}; + use monero_wallet::{transaction::Transaction, rpc::Rpc, ViewPair, Scanner}; let rpc = handles[0].monero(&ops).await; - let mut scanner = Scanner::from_view( - ViewPair::new(monero_spend, Zeroizing::new(monero_view)), - Some(HashSet::new()), - ); + let mut scanner = + Scanner::new(ViewPair::new(monero_spend, Zeroizing::new(monero_view)).unwrap()); // Check for up to 5 minutes let mut found = false; @@ -591,15 +588,16 @@ async fn mint_and_burn_test() { while i < (5 * 6) { if let Ok(block) = rpc.get_block_by_number(start_monero_block).await { start_monero_block += 1; - let outputs = scanner.scan(&rpc, &block).await.unwrap(); + let outputs = scanner.scan(&rpc, &block).await.unwrap().not_additionally_locked(); if !outputs.is_empty() { assert_eq!(outputs.len(), 1); - let outputs = outputs[0].not_locked(); - assert_eq!(outputs.len(), 1); - assert_eq!(block.txs.len(), 1); - let tx = rpc.get_transaction(block.txs[0]).await.unwrap(); - let tx_fee = tx.rct_signatures.base.fee; + assert_eq!(block.transactions.len(), 1); + let tx = rpc.get_transaction(block.transactions[0]).await.unwrap(); + let tx_fee = match &tx { + Transaction::V2 { proofs: Some(proofs), .. } => proofs.base.fee, + _ => panic!("fetched TX wasn't a signed V2 TX"), + }; assert_eq!(outputs[0].commitment().amount, 1_000_000_000_000 - tx_fee); found = true; diff --git a/tests/no-std/Cargo.toml b/tests/no-std/Cargo.toml index dc128786b..59015d4df 100644 --- a/tests/no-std/Cargo.toml +++ b/tests/no-std/Cargo.toml @@ -35,5 +35,4 @@ dkg = { path = "../../crypto/dkg", default-features = false } bitcoin-serai = { path = "../../coins/bitcoin", default-features = false, features = ["hazmat"] } -monero-generators = { path = "../../coins/monero/generators", default-features = false } -monero-serai = { path = "../../coins/monero", default-features = false, features = ["cache-distribution"] } +monero-wallet-util = { path = "../../coins/monero/wallet/util", default-features = false, features = ["compile-time-generators"] } diff --git a/tests/no-std/src/lib.rs b/tests/no-std/src/lib.rs index 183fd40e8..fa9da268b 100644 --- a/tests/no-std/src/lib.rs +++ b/tests/no-std/src/lib.rs @@ -20,5 +20,4 @@ pub use frost_schnorrkel; pub use bitcoin_serai; -pub use monero_generators; -pub use monero_serai; +pub use monero_wallet_util; diff --git a/tests/processor/Cargo.toml b/tests/processor/Cargo.toml index e46312c59..a8245fffb 100644 --- a/tests/processor/Cargo.toml +++ b/tests/processor/Cargo.toml @@ -31,7 +31,8 @@ bitcoin-serai = { path = "../../coins/bitcoin" } k256 = "0.13" ethereum-serai = { path = "../../coins/ethereum" } -monero-serai = { path = "../../coins/monero" } +monero-simple-request-rpc = { path = "../../coins/monero/rpc/simple-request" } +monero-wallet = { path = "../../coins/monero/wallet" } messages = { package = "serai-processor-messages", path = "../../processor/messages" } diff --git a/tests/processor/src/lib.rs b/tests/processor/src/lib.rs index 8aa6d48d2..789d86797 100644 --- a/tests/processor/src/lib.rs +++ b/tests/processor/src/lib.rs @@ -274,11 +274,12 @@ impl Coordinator { } } NetworkId::Monero => { - use monero_serai::rpc::HttpRpc; + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::rpc::Rpc; // Monero's won't, so call get_height if handle - .block_on(HttpRpc::new(rpc_url.clone())) + .block_on(SimpleRequestRpc::new(rpc_url.clone())) .ok() .and_then(|rpc| handle.block_on(rpc.get_height()).ok()) .is_some() @@ -403,25 +404,16 @@ impl Coordinator { } NetworkId::Monero => { use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar}; - use monero_serai::{ - wallet::{ - ViewPair, - address::{Network, AddressSpec}, - }, - rpc::HttpRpc, - }; + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::{rpc::Rpc, address::Network, ViewPair}; - let rpc = HttpRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); - let _: EmptyResponse = rpc - .json_rpc_call( - "generateblocks", - Some(serde_json::json!({ - "wallet_address": ViewPair::new( - ED25519_BASEPOINT_POINT, - Zeroizing::new(Scalar::ONE), - ).address(Network::Mainnet, AddressSpec::Standard).to_string(), - "amount_of_blocks": 1, - })), + let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); + rpc + .generate_blocks( + &ViewPair::new(ED25519_BASEPOINT_POINT, Zeroizing::new(Scalar::ONE)) + .unwrap() + .legacy_address(Network::Mainnet), + 1, ) .await .unwrap(); @@ -517,15 +509,19 @@ impl Coordinator { } } NetworkId::Monero => { - use monero_serai::rpc::HttpRpc; + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::rpc::Rpc; - let rpc = HttpRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); + let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); let to = rpc.get_height().await.unwrap(); for coordinator in others { - let other_rpc = - HttpRpc::new(network_rpc(coordinator.network, ops, &coordinator.network_handle)) - .await - .expect("couldn't connect to the Monero RPC"); + let other_rpc = SimpleRequestRpc::new(network_rpc( + coordinator.network, + ops, + &coordinator.network_handle, + )) + .await + .expect("couldn't connect to the Monero RPC"); let from = other_rpc.get_height().await.unwrap(); for b in from .. to { @@ -574,10 +570,12 @@ impl Coordinator { let _ = provider.send_raw_transaction(tx).await.unwrap(); } NetworkId::Monero => { - use monero_serai::{transaction::Transaction, rpc::HttpRpc}; + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::{transaction::Transaction, rpc::Rpc}; - let rpc = - HttpRpc::new(rpc_url).await.expect("couldn't connect to the coordinator's Monero RPC"); + let rpc = SimpleRequestRpc::new(rpc_url) + .await + .expect("couldn't connect to the coordinator's Monero RPC"); rpc.publish_transaction(&Transaction::read(&mut &*tx).unwrap()).await.unwrap(); } NetworkId::Serai => panic!("processor tests broadcasting block to Serai"), @@ -672,10 +670,12 @@ impl Coordinator { None } NetworkId::Monero => { - use monero_serai::rpc::HttpRpc; + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::rpc::Rpc; - let rpc = - HttpRpc::new(rpc_url).await.expect("couldn't connect to the coordinator's Monero RPC"); + let rpc = SimpleRequestRpc::new(rpc_url) + .await + .expect("couldn't connect to the coordinator's Monero RPC"); let mut hash = [0; 32]; hash.copy_from_slice(tx); if let Ok(tx) = rpc.get_transaction(hash).await { diff --git a/tests/processor/src/networks.rs b/tests/processor/src/networks.rs index 9af339b74..074c0b2bb 100644 --- a/tests/processor/src/networks.rs +++ b/tests/processor/src/networks.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use zeroize::Zeroizing; use rand_core::{RngCore, OsRng}; @@ -103,8 +101,8 @@ pub enum Wallet { Monero { handle: String, spend_key: Zeroizing, - view_pair: monero_serai::wallet::ViewPair, - inputs: Vec, + view_pair: monero_wallet::ViewPair, + last_tx: (usize, [u8; 32]), }, } @@ -189,55 +187,27 @@ impl Wallet { NetworkId::Monero => { use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar}; - use monero_serai::{ - wallet::{ - ViewPair, Scanner, - address::{Network, AddressSpec}, - }, - rpc::HttpRpc, - }; + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::{rpc::Rpc, address::Network, ViewPair}; - let mut bytes = [0; 64]; - OsRng.fill_bytes(&mut bytes); - let spend_key = Scalar::from_bytes_mod_order_wide(&bytes); - OsRng.fill_bytes(&mut bytes); - let view_key = Scalar::from_bytes_mod_order_wide(&bytes); + let spend_key = Scalar::random(&mut OsRng); + let view_key = Scalar::random(&mut OsRng); let view_pair = - ViewPair::new(ED25519_BASEPOINT_POINT * spend_key, Zeroizing::new(view_key)); + ViewPair::new(ED25519_BASEPOINT_POINT * spend_key, Zeroizing::new(view_key)).unwrap(); - let rpc = HttpRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); + let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); let height = rpc.get_height().await.unwrap(); // Mines 200 blocks so sufficient decoys exist, as only 60 is needed for maturity - let _: EmptyResponse = rpc - .json_rpc_call( - "generateblocks", - Some(serde_json::json!({ - "wallet_address": view_pair.address( - Network::Mainnet, - AddressSpec::Standard - ).to_string(), - "amount_of_blocks": 200, - })), - ) - .await - .unwrap(); + rpc.generate_blocks(&view_pair.legacy_address(Network::Mainnet), 200).await.unwrap(); let block = rpc.get_block(rpc.get_block_hash(height).await.unwrap()).await.unwrap(); - let output = Scanner::from_view(view_pair.clone(), Some(HashSet::new())) - .scan(&rpc, &block) - .await - .unwrap() - .remove(0) - .ignore_timelock() - .remove(0); - Wallet::Monero { handle, spend_key: Zeroizing::new(spend_key), view_pair, - inputs: vec![output.output.clone()], + last_tx: (height, block.miner_transaction.hash()), } } NetworkId::Serai => panic!("creating a wallet for for Serai"), @@ -434,38 +404,45 @@ impl Wallet { ) } - Wallet::Monero { handle, ref spend_key, ref view_pair, ref mut inputs } => { + Wallet::Monero { handle, ref spend_key, ref view_pair, ref mut last_tx } => { use curve25519_dalek::constants::ED25519_BASEPOINT_POINT; - use monero_serai::{ - Protocol, - wallet::{ - address::{Network, AddressType, AddressMeta, Address}, - SpendableOutput, Decoys, Change, FeePriority, Scanner, SignableTransaction, - }, - rpc::HttpRpc, - decompress_point, + use monero_simple_request_rpc::SimpleRequestRpc; + use monero_wallet::{ + io::decompress_point, + ringct::RctType, + rpc::{FeePriority, Rpc}, + address::{Network, AddressType, Address}, + Scanner, DecoySelection, Decoys, + send::{Change, SignableTransaction}, }; use processor::{additional_key, networks::Monero}; let rpc_url = network_rpc(NetworkId::Monero, ops, handle); - let rpc = HttpRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); + let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); // Prepare inputs - let outputs = std::mem::take(inputs); - let mut these_inputs = vec![]; - for output in outputs { - these_inputs.push( - SpendableOutput::from(&rpc, output) + let current_height = rpc.get_height().await.unwrap(); + let mut inputs = vec![]; + for block in last_tx.0 .. current_height { + let block = rpc.get_block_by_number(block).await.unwrap(); + if (block.miner_transaction.hash() == last_tx.1) || + block.transactions.contains(&last_tx.1) + { + inputs = Scanner::new(view_pair.clone()) + .scan(&rpc, &block) .await - .expect("prior transaction was never published"), - ); + .unwrap() + .ignore_additional_timelock(); + } } + assert!(!inputs.is_empty()); + let mut decoys = Decoys::fingerprintable_canonical_select( &mut OsRng, &rpc, - Protocol::v16.ring_len(), + 16, rpc.get_height().await.unwrap(), - &these_inputs, + &inputs, ) .await .unwrap(); @@ -473,10 +450,8 @@ impl Wallet { let to_spend_key = decompress_point(<[u8; 32]>::try_from(to.as_ref()).unwrap()).unwrap(); let to_view_key = additional_key::(0); let to_addr = Address::new( - AddressMeta::new( - Network::Mainnet, - AddressType::Featured { subaddress: false, payment_id: None, guaranteed: true }, - ), + Network::Mainnet, + AddressType::Featured { subaddress: false, payment_id: None, guaranteed: true }, to_spend_key, ED25519_BASEPOINT_POINT * to_view_key.0, ); @@ -487,26 +462,24 @@ impl Wallet { if let Some(instruction) = instruction { data.push(Shorthand::Raw(RefundableInInstruction { origin: None, instruction }).encode()); } + let mut outgoing_view_key = Zeroizing::new([0; 32]); + OsRng.fill_bytes(outgoing_view_key.as_mut()); let tx = SignableTransaction::new( - Protocol::v16, - None, - these_inputs.drain(..).zip(decoys.drain(..)).collect(), + RctType::ClsagBulletproofPlus, + outgoing_view_key, + inputs.drain(..).zip(decoys.drain(..)).collect(), vec![(to_addr, AMOUNT)], - &Change::new(view_pair, false), + Change::new(view_pair), data, - rpc.get_fee(Protocol::v16, FeePriority::Unimportant).await.unwrap(), + rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), ) .unwrap() .sign(&mut OsRng, spend_key) .unwrap(); - // Push the change output - inputs.push( - Scanner::from_view(view_pair.clone(), Some(HashSet::new())) - .scan_transaction(&tx) - .ignore_timelock() - .remove(0), - ); + // Update the last TX to track the change output + last_tx.0 = current_height; + last_tx.1 = tx.hash(); (tx.serialize(), Balance { coin: Coin::Monero, amount: Amount(AMOUNT) }) } @@ -531,13 +504,11 @@ impl Wallet { ) .unwrap(), Wallet::Monero { view_pair, .. } => { - use monero_serai::wallet::address::{Network, AddressSpec}; + use monero_wallet::address::Network; ExternalAddress::new( - networks::monero::Address::new( - view_pair.address(Network::Mainnet, AddressSpec::Standard), - ) - .unwrap() - .into(), + networks::monero::Address::new(view_pair.legacy_address(Network::Mainnet)) + .unwrap() + .into(), ) .unwrap() }