From 2aee4a1b6a322647a5d46299cc1b6d8df603b67c Mon Sep 17 00:00:00 2001 From: Jack Pooley <169029079+jackpooleywc@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:25:32 +0200 Subject: [PATCH] Initial commit --- .env.template | 8 + .github/workflows/ci.yml | 25 ++ .gitignore | 29 ++ .gitmodules | 3 + Cargo.toml | 35 ++ Makefile | 45 +++ Package.swift | 33 ++ README.md | 90 +++++ crates/cli/Cargo.toml | 10 + crates/cli/src/main.rs | 3 + crates/ffi/.gitignore | 6 + crates/ffi/Cargo.toml | 39 ++ crates/ffi/YttriumCore/Package.swift | 36 ++ .../YttriumCore/SwiftFFI/Error/FFIError.swift | 4 + .../SwiftFFI/Models/FFITransaction.swift | 47 +++ .../YttriumCore/SwiftFFI/SignServiceFFI.swift | 112 ++++++ .../Tests/AccountClientTests.swift | 37 ++ crates/ffi/build-rust-ios.sh | 53 +++ crates/ffi/build-rust.sh | 73 ++++ crates/ffi/build.rs | 13 + crates/ffi/src/account_client.rs | 94 +++++ crates/ffi/src/account_client_eip7702.rs | 76 ++++ crates/ffi/src/config.rs | 25 ++ crates/ffi/src/error.rs | 24 ++ crates/ffi/src/lib.rs | 95 +++++ crates/ffi/src/log.rs | 19 + crates/yttrium/Cargo.toml | 39 ++ crates/yttrium/src/account_client.rs | 66 ++++ crates/yttrium/src/bundler.rs | 4 + crates/yttrium/src/bundler/client.rs | 358 ++++++++++++++++++ crates/yttrium/src/bundler/config.rs | 13 + crates/yttrium/src/bundler/models.rs | 2 + .../src/bundler/models/estimate_result.rs | 12 + .../bundler/models/user_operation_receipt.rs | 69 ++++ crates/yttrium/src/bundler/pimlico.rs | 3 + crates/yttrium/src/bundler/pimlico/client.rs | 126 ++++++ .../yttrium/src/bundler/pimlico/gas_price.rs | 17 + .../yttrium/src/bundler/pimlico/paymaster.rs | 2 + .../src/bundler/pimlico/paymaster/client.rs | 157 ++++++++ .../src/bundler/pimlico/paymaster/models.rs | 135 +++++++ crates/yttrium/src/chain.rs | 135 +++++++ crates/yttrium/src/config.rs | 100 +++++ crates/yttrium/src/contracts | 1 + crates/yttrium/src/eip7702.rs | 1 + crates/yttrium/src/eip7702/account_client.rs | 47 +++ crates/yttrium/src/entry_point.rs | 208 ++++++++++ .../src/entry_point/get_sender_address.rs | 122 ++++++ crates/yttrium/src/error.rs | 25 ++ crates/yttrium/src/jsonrpc.rs | 55 +++ crates/yttrium/src/lib.rs | 13 + crates/yttrium/src/sign_service.rs | 77 ++++ crates/yttrium/src/signer.rs | 196 ++++++++++ crates/yttrium/src/smart_accounts.rs | 2 + crates/yttrium/src/smart_accounts/nonce.rs | 27 ++ .../src/smart_accounts/simple_account.rs | 72 ++++ .../simple_account/create_account.rs | 28 ++ .../smart_accounts/simple_account/factory.rs | 86 +++++ crates/yttrium/src/transaction.rs | 28 ++ crates/yttrium/src/transaction/send.rs | 332 ++++++++++++++++ .../src/transaction/send/send_tests.rs | 1 + .../send/send_tests/test_send_pimlico_v07.rs | 261 +++++++++++++ crates/yttrium/src/user_operation.rs | 109 ++++++ crates/yttrium/src/user_operation/hash.rs | 64 ++++ .../src/user_operation/hash/pack_v07.rs | 100 +++++ .../user_operation/hash/pack_v07/combine.rs | 41 ++ .../hash/pack_v07/hashed_call_data.rs | 34 ++ .../hash/pack_v07/hashed_init_code.rs | 46 +++ .../pack_v07/hashed_paymaster_and_data.rs | 98 +++++ ...riority_fee_per_gas_and_max_fee_per_gas.rs | 38 ++ ...ificaction_gas_limit_and_call_gas_limit.rs | 40 ++ .../src/user_operation/user_operation_hash.rs | 76 ++++ crates/yttrium/tests/shared/mod.rs | 0 crates/yttrium/tests/test_send_transaction.rs | 9 + justfile | 17 + platforms/swift/.gitignore | 8 + .../swift/Sources/Yttrium/AccountClient.swift | 91 +++++ .../Sources/Yttrium/AccountClient7702.swift | 62 +++ .../Yttrium/AccountClientProtocol.swift | 33 ++ .../YttriumTests/AccountClientTests.swift | 24 ++ rustfmt.toml | 6 + test/scripts/7702/docker-compose.yaml | 20 + test/scripts/7702/local-infra.sh | 5 + test/scripts/forked_state/docker-compose.yaml | 20 + test/scripts/forked_state/local-infra.sh | 5 + test/scripts/local_infra/docker-compose.yaml | 19 + test/scripts/local_infra/local-infra.sh | 5 + 86 files changed, 4824 insertions(+) create mode 100644 .env.template create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 README.md create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/src/main.rs create mode 100644 crates/ffi/.gitignore create mode 100644 crates/ffi/Cargo.toml create mode 100644 crates/ffi/YttriumCore/Package.swift create mode 100644 crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Error/FFIError.swift create mode 100644 crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Models/FFITransaction.swift create mode 100644 crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/SignServiceFFI.swift create mode 100644 crates/ffi/YttriumCore/Tests/AccountClientTests.swift create mode 100644 crates/ffi/build-rust-ios.sh create mode 100644 crates/ffi/build-rust.sh create mode 100644 crates/ffi/build.rs create mode 100644 crates/ffi/src/account_client.rs create mode 100644 crates/ffi/src/account_client_eip7702.rs create mode 100644 crates/ffi/src/config.rs create mode 100644 crates/ffi/src/error.rs create mode 100644 crates/ffi/src/lib.rs create mode 100644 crates/ffi/src/log.rs create mode 100644 crates/yttrium/Cargo.toml create mode 100644 crates/yttrium/src/account_client.rs create mode 100644 crates/yttrium/src/bundler.rs create mode 100644 crates/yttrium/src/bundler/client.rs create mode 100644 crates/yttrium/src/bundler/config.rs create mode 100644 crates/yttrium/src/bundler/models.rs create mode 100644 crates/yttrium/src/bundler/models/estimate_result.rs create mode 100644 crates/yttrium/src/bundler/models/user_operation_receipt.rs create mode 100644 crates/yttrium/src/bundler/pimlico.rs create mode 100644 crates/yttrium/src/bundler/pimlico/client.rs create mode 100644 crates/yttrium/src/bundler/pimlico/gas_price.rs create mode 100644 crates/yttrium/src/bundler/pimlico/paymaster.rs create mode 100644 crates/yttrium/src/bundler/pimlico/paymaster/client.rs create mode 100644 crates/yttrium/src/bundler/pimlico/paymaster/models.rs create mode 100644 crates/yttrium/src/chain.rs create mode 100644 crates/yttrium/src/config.rs create mode 160000 crates/yttrium/src/contracts create mode 100644 crates/yttrium/src/eip7702.rs create mode 100644 crates/yttrium/src/eip7702/account_client.rs create mode 100644 crates/yttrium/src/entry_point.rs create mode 100644 crates/yttrium/src/entry_point/get_sender_address.rs create mode 100644 crates/yttrium/src/error.rs create mode 100644 crates/yttrium/src/jsonrpc.rs create mode 100644 crates/yttrium/src/lib.rs create mode 100644 crates/yttrium/src/sign_service.rs create mode 100644 crates/yttrium/src/signer.rs create mode 100644 crates/yttrium/src/smart_accounts.rs create mode 100644 crates/yttrium/src/smart_accounts/nonce.rs create mode 100644 crates/yttrium/src/smart_accounts/simple_account.rs create mode 100644 crates/yttrium/src/smart_accounts/simple_account/create_account.rs create mode 100644 crates/yttrium/src/smart_accounts/simple_account/factory.rs create mode 100644 crates/yttrium/src/transaction.rs create mode 100644 crates/yttrium/src/transaction/send.rs create mode 100644 crates/yttrium/src/transaction/send/send_tests.rs create mode 100644 crates/yttrium/src/transaction/send/send_tests/test_send_pimlico_v07.rs create mode 100644 crates/yttrium/src/user_operation.rs create mode 100644 crates/yttrium/src/user_operation/hash.rs create mode 100644 crates/yttrium/src/user_operation/hash/pack_v07.rs create mode 100644 crates/yttrium/src/user_operation/hash/pack_v07/combine.rs create mode 100644 crates/yttrium/src/user_operation/hash/pack_v07/hashed_call_data.rs create mode 100644 crates/yttrium/src/user_operation/hash/pack_v07/hashed_init_code.rs create mode 100644 crates/yttrium/src/user_operation/hash/pack_v07/hashed_paymaster_and_data.rs create mode 100644 crates/yttrium/src/user_operation/hash/pack_v07/max_priority_fee_per_gas_and_max_fee_per_gas.rs create mode 100644 crates/yttrium/src/user_operation/hash/pack_v07/verificaction_gas_limit_and_call_gas_limit.rs create mode 100644 crates/yttrium/src/user_operation/user_operation_hash.rs create mode 100644 crates/yttrium/tests/shared/mod.rs create mode 100644 crates/yttrium/tests/test_send_transaction.rs create mode 100644 justfile create mode 100644 platforms/swift/.gitignore create mode 100644 platforms/swift/Sources/Yttrium/AccountClient.swift create mode 100644 platforms/swift/Sources/Yttrium/AccountClient7702.swift create mode 100644 platforms/swift/Sources/Yttrium/AccountClientProtocol.swift create mode 100644 platforms/swift/Tests/YttriumTests/AccountClientTests.swift create mode 100644 rustfmt.toml create mode 100644 test/scripts/7702/docker-compose.yaml create mode 100644 test/scripts/7702/local-infra.sh create mode 100644 test/scripts/forked_state/docker-compose.yaml create mode 100644 test/scripts/forked_state/local-infra.sh create mode 100644 test/scripts/local_infra/docker-compose.yaml create mode 100644 test/scripts/local_infra/local-infra.sh diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..1802628 --- /dev/null +++ b/.env.template @@ -0,0 +1,8 @@ +TENDERLY_API_KEY="" +BUNDLER_API_KEY="" +BUNDLER_BASE_URL="" +RPC_API_KEY="" +RPC_BASE_URL="" +PIMLICO_API_KEY="" +PIMLICO_BUNDLER_URL="" +PIMLICO_RPC_URL="" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e835e76 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: Rust CI + +on: + pull_request: + push: + branches: + - main + +env: + CARGO_TERM_COLOR: always + +jobs: + build_rust_and_test: + name: Rust project - latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup update stable && rustup default stable + - run: rustup toolchain install nightly -c rustfmt + - run: git submodule update --init --recursive + - run: make setup-thirdparty + - run: cargo test --all-features --lib --bins + # - run: cargo clippy --workspace --all-features --all-targets -- -D warnings + # - run: cargo +nightly fmt --all -- --check + # - run: cargo +nightly udeps --workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9c0fbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +/target +/Cargo.lock +.vscode +.env +.DS_Store +.direnv/* + +.build +DerivedData +/.previous-build +xcuserdata +.DS_Store +*~ +\#* +.\#* +.*.sw[nop] +*.xcscmblueprint +/default.profraw +*.xcodeproj +Utilities/Docker/*.tar.gz +.swiftpm +Package.resolved +/build +*.pyc +.docc-build +.vscode +Utilities/InstalledSwiftPMConfiguration/config.json +.devcontainer +.cursorignore diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..109c452 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "crates/yttrium/src/contracts"] + path = crates/yttrium/src/contracts + url = git@github.com:eth-infinitism/account-abstraction.git diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cc4ccb2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[workspace] +members = ["crates/*"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.79" + +[workspace.dependencies] +# Errors/Result +eyre = "0.6.12" +thiserror = "1.0" + +# Async +tokio = { version = "1.17", features = ["full"] } + +# Networking +reqwest = { version = "0.12.5", features = ["json"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Logging +oslog = "0.2.0" +log = "0.4.20" + +# [profile.release] +# lto = true +# codegen-units = 1 +# panic = "abort" + +# [profile.dev] +# debug = 0 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..34904bc --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +.PHONY: build setup build-ios-bindings fetch-thirdparty setup-thirdparty test format clean local-infra local-infra-forked local-infra-7702 + +build: + cargo build --release + +setup: fetch-thirdparty setup-thirdparty build-debug-mode build-ios-bindings + +build-debug-mode: + cargo build + +fetch-thirdparty: + git submodule update --init + +setup-thirdparty: + cd crates/yttrium/src/contracts/ && yarn install --frozen-lockfile --immutable && yarn compile + +build-ios-bindings: + sh crates/ffi/build-rust-ios.sh + open Package.swift + +test: + cargo test --workspace + +format: + cargo +nightly fmt --all + cargo sort --workspace --grouped + +lint: + cargo +nightly fmt --all -- --check + cargo clippy --all -- -D warnings -A clippy::derive_partial_eq_without_eq -D clippy::unwrap_used -D clippy::uninlined_format_args + cargo sort --check --workspace --grouped + cargo +nightly udeps --workspace + +clean: + cd crates/account/src/contracts && yarn clean && cd ../../../../ + cargo clean + +local-infra: + cd test/scripts/local_infra && sh local-infra.sh + +local-infra-forked: + cd test/scripts/forked_state && sh local-infra.sh + +local-infra-7702: + cd test/scripts/7702 && sh local-infra.sh \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..4f43bcc --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "yttrium", + platforms: [ + .macOS(.v14), + .iOS(.v13), + .watchOS(.v10), + .tvOS(.v17) + ], + products: [ + .library( + name: "Yttrium", + targets: ["Yttrium"]), + ], + dependencies: [ + .package(path: "crates/ffi/YttriumCore") + ], + targets: [ + .target( + name: "Yttrium", + dependencies: [ + "YttriumCore" + ], + path: "platforms/swift/Sources/Yttrium") + , + .testTarget( + name: "YttriumTests", + dependencies: ["Yttrium"], + path: "platforms/swift/Tests/YttriumTests"), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cffa4e --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Yttrium + +Yttrium is a cross-platform library designed for working with smart accounts, currently focused on the Ethereum ecosystem. + +> [!CAUTION] +> This project is under heavy development and is currently in a pre-alpha state. + +## Overview + +Yttrium simplifies the process of building applications that utilize account abstraction. It provides essential abstractions and primitives for Wallets and DApps to interact with and implement smart account functionality. + +A primary goal of this project is to enable externally owned account (EOA) wallets to offer advanced features such as batch transactions and transaction sponsorship to their users. + +While initially focused on Ethereum, Yttrium aims to be a cross-chain account abstraction library. + +## Architecture + +The following diagram provides an overview of the Yttrium architecture: + +```mermaid +graph TD; + CoreRustLibrary[Core Rust Library] -->|Compiled to| NativeLibrary[Native Library] + NativeLibrary --> |Is consumed by| SwiftWrapper[Swift Wrapper] + NativeLibrary -.-> OtherNativePlatform["Other Native Platform"] + CoreRustLibrary -->|Compiled to| WebAssemblyModule[WebAssembly Module] + WebAssemblyModule --> |Is consumed by| TypeScriptWrapper[TypeScript Wrapper] + WebAssemblyModule -.-> OtherWebAssemblyPlatform["Other WebAssembly Platform"] + + style CoreRustLibrary color:#fff,fill:#CE422B,stroke:#fff,stroke-width:2px + style NativeLibrary fill:#0000FF,stroke:#fff,stroke-width:2px + style SwiftWrapper color:#fff,fill:#F05138,stroke:#fff,stroke-width:2px + style WebAssemblyModule fill:#654ff0,stroke:#fff,stroke-width:2px,color:#fff + style TypeScriptWrapper color:#fff,fill:#3178c6,stroke:#fff,stroke-width:2px + style OtherNativePlatform color:#333,fill:#ccc,stroke:#ccc,stroke-dasharray: 5,5 + style OtherWebAssemblyPlatform color:#333,fill:#ccc,stroke:#ccc,stroke-dasharray: 5,5 +``` + +## Standards + +In the near future, Yttrium will implement the following standards: +* ERC-4337 (in development) +* ERC-7702 (in development) + +Additional standards and features will be added as the project evolves. + +## Available APIs + +Yttrium currently provides APIs for: +* Swift +* Rust + +Planned future APIs include: +* JavaScript/TypeScript (via WebAssembly) +* Kotlin +* Flutter +* C#/Unity + +## Target Platforms + +Currently supported platforms: +* Apple platforms (iOS, macOS) +* Linux + +Planned future support includes: +* WebAssembly +* Android +* Web +* Windows + +## Installation and Setup + +### Development Dependencies + +To contribute to this project, ensure you have the following dependencies installed: + +- `rustup` +- `cargo` +- `rustc` +- `swiftc` and Xcode +- `foundry` + +### Setup + +After installing the dependencies, clone the repository and run the following command to set up the project: + +```bash +make setup +``` + +This will fetch the third party dependencies and build the project, including the Swift bindings. \ No newline at end of file diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..645352b --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +yttrium = { path = "../yttrium" } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..5bf256e --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello world"); +} diff --git a/crates/ffi/.gitignore b/crates/ffi/.gitignore new file mode 100644 index 0000000..e249eb3 --- /dev/null +++ b/crates/ffi/.gitignore @@ -0,0 +1,6 @@ +# swift-bridge +generated/ +YttriumCore/RustXcframework.xcframework/ +YttriumCore/.swiftpm/ +YttriumCore/Sources/YttriumCore/ffi.swift +YttriumCore/Sources/YttriumCore/SwiftBridgeCore.swift diff --git a/crates/ffi/Cargo.toml b/crates/ffi/Cargo.toml new file mode 100644 index 0000000..3a04706 --- /dev/null +++ b/crates/ffi/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "ffi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["staticlib"] + +[build-dependencies] +swift-bridge-build = { git = "https://github.com/wooden-worm/swift-bridge.git", branch = "derive_debug_serde" } + +[dependencies] +# See: https://github.com/kornelski/rust-security-framework/pull/204 +security-framework = "2.10.0" + +swift-bridge = { git = "https://github.com/wooden-worm/swift-bridge.git", branch = "derive_debug_serde", features = [ + "async", +] } +yttrium = { path = "../yttrium" } + +# Errors +eyre.workspace = true + +# Async +tokio.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Networking +reqwest.workspace = true + +# Logging +log.workspace = true + +[target.'cfg(target_os = "ios")'.dependencies] +oslog.workspace = true diff --git a/crates/ffi/YttriumCore/Package.swift b/crates/ffi/YttriumCore/Package.swift new file mode 100644 index 0000000..9b0166f --- /dev/null +++ b/crates/ffi/YttriumCore/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version:5.9.0 +import PackageDescription +let package = Package( + name: "YttriumCore", + platforms: [ + .macOS(.v14), + .iOS(.v13), + .watchOS(.v10), + .tvOS(.v17) + ], + products: [ + .library( + name: "YttriumCore", + targets: ["YttriumCore"] + ), + ], + dependencies: [], + targets: [ + .binaryTarget( + name: "RustXcframework", + path: "RustXcframework.xcframework" + ), + .target( + name: "YttriumCore", + dependencies: [ + "RustXcframework" + ] + ), + .testTarget( + name: "YttriumCoreTests", + dependencies: [ + "YttriumCore" + ] + ), + ] +) diff --git a/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Error/FFIError.swift b/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Error/FFIError.swift new file mode 100644 index 0000000..127f027 --- /dev/null +++ b/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Error/FFIError.swift @@ -0,0 +1,4 @@ +import Foundation + +extension FFIError: @unchecked Sendable {} +extension FFIError: Error {} diff --git a/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Models/FFITransaction.swift b/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Models/FFITransaction.swift new file mode 100644 index 0000000..e2ebb23 --- /dev/null +++ b/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/Models/FFITransaction.swift @@ -0,0 +1,47 @@ +import Foundation + +extension FFITransaction: Codable { + + private enum CodingKeys: String, CodingKey { + case _to + case _value + case _data + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let to = try container.decode(String.self, forKey: ._to) + let value = try container.decode(String.self, forKey: ._value) + let data = try container.decode(String.self, forKey: ._data) + + _to = to.intoRustString() + _value = value.intoRustString() + _data = data.intoRustString() + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(to, forKey: ._to) + try container.encode(value, forKey: ._value) + try container.encode(data, forKey: ._data) + } + + public var to: String { + _to.toString() + } + + public var value: String { + _value.toString() + } + + public var data: String { + _value.toString() + } + + public init(to: String, value: String, data: String) { + self._to = to.intoRustString() + self._value = value.intoRustString() + self._data = data.intoRustString() + } +} diff --git a/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/SignServiceFFI.swift b/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/SignServiceFFI.swift new file mode 100644 index 0000000..c43485d --- /dev/null +++ b/crates/ffi/YttriumCore/Sources/YttriumCore/SwiftFFI/SignServiceFFI.swift @@ -0,0 +1,112 @@ +import Foundation + +public final class Signers { + + public static var shared = Signers() + + private var signers: [SignerId: Signer] + + public init(signers: [Signer] = []) { + let signerKeyValues = signers.map { ($0.signerId, $0) } + self.signers = Dictionary(uniqueKeysWithValues: signerKeyValues) + } + + public func signer(id: SignerId) -> Signer? { + self.signers[id] + } + + public func register(signer: Signer) { + signers[signer.signerId] = signer + } + + public func register(signers: [Signer]) { + for signer in signers { + register(signer: signer) + } + } +} + +public enum SignerError: Error { + case unknown +} + +public typealias OnSign = (String) -> Result + +public final class Signer { + + public let signerId: SignerId + + private let onSign: OnSign + + public init(signerId: SignerId, onSign: @escaping OnSign) { + self.signerId = signerId + self.onSign = onSign + } + + public func sign(message: String) -> Result { + onSign(message) + } +} + +public struct SignerId: Hashable, CustomStringConvertible, RawRepresentable { + + public var rawValue: String { + "\(account)-\(chainId)" + } + + public var description: String { + rawValue + } + + public let account: String + public let chainId: Int + + public init(account: String, chainId: Int) { + self.account = account + self.chainId = chainId + } + + public init?(rawValue: String) { + let idComponents = rawValue.split(separator: "-") + guard idComponents.count == 2 else { + return nil + } + let account = String(idComponents[0]) + guard let chainId = Int(idComponents[1]) else { + return nil + } + self.account = account + self.chainId = chainId + } +} + +public final class SignerServiceFFI { + + public let signer: Signer + + public init(signer_id: RustString) { + let idString = signer_id.toString() + let signerId = SignerId(rawValue: idString)! + self.signer = Signers.shared.signer(id: signerId)! + } + + public func sign(message: RustString) -> FFIStringResult { + signer.sign(message: message.toString()) + .mapError(\.localizedDescription) + .ffi + } +} + +extension String: Error {} + +extension Result where Success == String, Failure == String { + + public var ffi: FFIStringResult { + switch self { + case .success(let value): + return .Ok(value.intoRustString()) + case .failure(let error): + return .Err(error.localizedDescription.intoRustString()) + } + } +} diff --git a/crates/ffi/YttriumCore/Tests/AccountClientTests.swift b/crates/ffi/YttriumCore/Tests/AccountClientTests.swift new file mode 100644 index 0000000..25d8e34 --- /dev/null +++ b/crates/ffi/YttriumCore/Tests/AccountClientTests.swift @@ -0,0 +1,37 @@ +@testable import YttriumCore +import XCTest + +final class AccountClientTests: XCTestCase { + + func testGetAddress() async throws { + + let accountAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".intoRustString() + + let chainId = Int64(0) + + let accountClient = FFIAccountClient( + .init( + account_address: accountAddress, + chain_id: chainId, + config: .init( + endpoints: .init( + rpc: .init( + api_key: "".intoRustString(), + base_url: "https://eth.merkle.io".intoRustString() + ), + bundler: .init( + api_key: "".intoRustString(), + base_url: "https://localhost:4337".intoRustString() + ) + ) + ) + ) + ) + + let expectedAddress = "0xa3aBDC7f6334CD3EE466A115f30522377787c024" + + let address = try await accountClient.get_address().toString() + + XCTAssertEqual(address, expectedAddress) + } +} diff --git a/crates/ffi/build-rust-ios.sh b/crates/ffi/build-rust-ios.sh new file mode 100644 index 0000000..6561c9f --- /dev/null +++ b/crates/ffi/build-rust-ios.sh @@ -0,0 +1,53 @@ +# build-rust-ios.sh + +#!/bin/bash + +PACKAGE_NAME=ffi +SWIFT_PACKAGE_NAME=YttriumCore + +set -e + +THISDIR=$(dirname $0) +cd $THISDIR + +echo "Building for iOS..." + +rustup target add aarch64-apple-ios +rustup target add x86_64-apple-ios +rustup target add aarch64-apple-ios-sim + +cargo build --target aarch64-apple-ios +cargo build --target x86_64-apple-ios +cargo build --target aarch64-apple-ios-sim + +mkdir -p ./../../target/universal-ios/debug + +echo "Lipoing for iOS..." + +lipo \ + ./../../target/aarch64-apple-ios-sim/debug/lib$PACKAGE_NAME.a \ + ./../../target/x86_64-apple-ios/debug/lib$PACKAGE_NAME.a -create -output \ + ./../../target/universal-ios/debug/lib$PACKAGE_NAME.a + +function create_package { + swift-bridge-cli create-package \ + --bridges-dir ./generated \ + --out-dir $SWIFT_PACKAGE_NAME \ + --ios ./../../target/aarch64-apple-ios/debug/lib$PACKAGE_NAME.a \ + --simulator ./../../target/universal-ios/debug/lib$PACKAGE_NAME.a \ + --name $SWIFT_PACKAGE_NAME +} + +# Check if Package.swift file exists +if [ -f $SWIFT_PACKAGE_NAME/Package.swift ]; then + echo "Package.swift already exists. Copying existing file to backup..." + rm -f $SWIFT_PACKAGE_NAME/Package.swift.bak + cp $SWIFT_PACKAGE_NAME/Package.swift $SWIFT_PACKAGE_NAME/Package.swift.bak + echo "Creating Swift package..." + create_package + cp $SWIFT_PACKAGE_NAME/Package.swift.bak $SWIFT_PACKAGE_NAME/Package.swift + rm -f $SWIFT_PACKAGE_NAME/Package.swift.bak +else + echo "Creating Swift package..." + create_package +fi diff --git a/crates/ffi/build-rust.sh b/crates/ffi/build-rust.sh new file mode 100644 index 0000000..f351d01 --- /dev/null +++ b/crates/ffi/build-rust.sh @@ -0,0 +1,73 @@ +# build-rust.sh + +#!/bin/bash + +PACKAGE_NAME=ffi +SWIFT_PACKAGE_NAME=YttriumCore + +set -e + +THISDIR=$(dirname $0) +cd $THISDIR + +echo "Building for macOS..." + +rustup target add x86_64-apple-darwin +rustup target add aarch64-apple-darwin + +cargo build --target x86_64-apple-darwin +cargo build --target aarch64-apple-darwin + +mkdir -p ./../../target/universal-macos/debug + +echo "Lipoing for macOS..." + +lipo \ + ./../../target/aarch64-apple-darwin/debug/lib$PACKAGE_NAME.a \ + ./../../target/x86_64-apple-darwin/debug/lib$PACKAGE_NAME.a -create -output \ + ./../../target/universal-macos/debug/lib$PACKAGE_NAME.a + +echo "Building for iOS..." + +rustup target add aarch64-apple-ios +rustup target add x86_64-apple-ios +rustup target add aarch64-apple-ios-sim + +cargo build --target aarch64-apple-ios +cargo build --target x86_64-apple-ios +cargo build --target aarch64-apple-ios-sim + +mkdir -p ./../../target/universal-ios/debug + +echo "Lipoing for iOS..." + +lipo \ + ./../../target/aarch64-apple-ios-sim/debug/lib$PACKAGE_NAME.a \ + ./../../target/x86_64-apple-ios/debug/lib$PACKAGE_NAME.a -create -output \ + ./../../target/universal-ios/debug/lib$PACKAGE_NAME.a + +# function create_package (); + +function create_package { + swift-bridge-cli create-package \ + --bridges-dir ./generated \ + --out-dir $SWIFT_PACKAGE_NAME \ + --ios ./../../target/aarch64-apple-ios/debug/lib$PACKAGE_NAME.a \ + --simulator ./../../target/universal-ios/debug/lib$PACKAGE_NAME.a \ + --macos ./../../target/universal-macos/debug/lib$PACKAGE_NAME.a \ + --name $SWIFT_PACKAGE_NAME +} + +# Check if Package.swift file exists +if [ -f $SWIFT_PACKAGE_NAME/Package.swift ]; then + echo "Package.swift already exists. Copying existing file to backup..." + rm -f $SWIFT_PACKAGE_NAME/Package.swift.bak + cp $SWIFT_PACKAGE_NAME/Package.swift $SWIFT_PACKAGE_NAME/Package.swift.bak + echo "Creating Swift package..." + create_package + cp $SWIFT_PACKAGE_NAME/Package.swift.bak $SWIFT_PACKAGE_NAME/Package.swift + rm -f $SWIFT_PACKAGE_NAME/Package.swift.bak +else + echo "Creating Swift package..." + create_package +fi diff --git a/crates/ffi/build.rs b/crates/ffi/build.rs new file mode 100644 index 0000000..284d926 --- /dev/null +++ b/crates/ffi/build.rs @@ -0,0 +1,13 @@ +use std::path::PathBuf; + +fn main() { + let out_dir = PathBuf::from("./generated"); + + let bridges = vec!["src/lib.rs"]; + for path in &bridges { + println!("cargo:rerun-if-changed={}", path); + } + + swift_bridge_build::parse_bridges(bridges) + .write_all_concatenated(out_dir, env!("CARGO_PKG_NAME")); +} diff --git a/crates/ffi/src/account_client.rs b/crates/ffi/src/account_client.rs new file mode 100644 index 0000000..2f99903 --- /dev/null +++ b/crates/ffi/src/account_client.rs @@ -0,0 +1,94 @@ +use super::ffi; +use ffi::FFIAccountClientConfig; +use ffi::FFIError; +use sign_service::SignService; +use yttrium::account_client::AccountClient; +use yttrium::error::YttriumError; +use yttrium::sign_service; +use yttrium::sign_service::address_from_string; +use yttrium::transaction::Transaction; + +pub struct FFIAccountClient { + pub owner_address: String, + pub chain_id: i64, + account_client: yttrium::account_client::AccountClient, +} + +impl FFIAccountClient { + pub fn new(config: FFIAccountClientConfig) -> Self { + #[cfg(target_os = "ios")] + match crate::log::init_os_logger() { + Ok(_) => { + log::debug!("log::debug! Logging setup successfully"); + } + Err(err) => { + println!("Logging setup failure e: {:?}", err.to_string()); + } + } + + let owner_address = config.owner_address.clone(); + let chain_id = config.chain_id.clone(); + let signer_id = format!("{}-{}", owner_address, chain_id); + + let sign_fn = Box::new(move |message: String| { + let signer_service = ffi::SignerServiceFFI::new(signer_id.clone()); + let sign = signer_service.sign(message); + let result = match sign { + ffi::FFIStringResult::Ok(signed_message) => Ok(signed_message), + ffi::FFIStringResult::Err(error) => { + Err(YttriumError { message: error }) + } + }; + result + }); + + let owner = address_from_string(&owner_address).unwrap(); + + let signer = SignService::new(sign_fn, owner); + + let account_client = AccountClient::new( + config.owner_address.clone(), + config.chain_id.clone(), + config.config.into(), + signer, + ); + + Self { + owner_address: config.owner_address.clone(), + chain_id: config.chain_id, + account_client, + } + } + + pub fn chain_id(&self) -> i64 { + self.chain_id + } + + pub async fn get_address(&self) -> Result { + self.account_client + .get_address() + .await + .map_err(|e| FFIError::Unknown(e.to_string())) + } + + pub async fn send_transaction( + &self, + transaction: ffi::FFITransaction, + ) -> Result { + let transaction = Transaction::from(transaction); + self.account_client + .send_transaction(transaction) + .await + .map_err(|e| FFIError::Unknown(e.to_string())) + } +} + +impl From for Transaction { + fn from(transaction: ffi::FFITransaction) -> Self { + Self { + to: transaction._to, + value: transaction._value, + data: transaction._data, + } + } +} diff --git a/crates/ffi/src/account_client_eip7702.rs b/crates/ffi/src/account_client_eip7702.rs new file mode 100644 index 0000000..4b71214 --- /dev/null +++ b/crates/ffi/src/account_client_eip7702.rs @@ -0,0 +1,76 @@ +use super::ffi; +use ffi::FFIAccountClientConfig; +use ffi::FFIError; +use sign_service::SignService; +use yttrium::eip7702::account_client::AccountClient; +use yttrium::error::YttriumError; +use yttrium::sign_service; +use yttrium::sign_service::address_from_string; +use yttrium::transaction::Transaction; + +pub struct FFI7702AccountClient { + pub owner_address: String, + pub chain_id: i64, + account_client: yttrium::eip7702::account_client::AccountClient, +} + +impl FFI7702AccountClient { + pub fn new(config: FFIAccountClientConfig) -> Self { + #[cfg(target_os = "ios")] + match crate::log::init_os_logger() { + Ok(_) => { + log::debug!("log::debug! Logging setup successfully"); + } + Err(err) => { + println!("Logging setup failure e: {:?}", err.to_string()); + } + } + + let owner_address = config.owner_address.clone(); + let chain_id = config.chain_id.clone(); + let signer_id = format!("{}-{}", owner_address, chain_id); + + let sign_fn = Box::new(move |message: String| { + let signer_service = ffi::SignerServiceFFI::new(signer_id.clone()); + let sign = signer_service.sign(message); + let result = match sign { + ffi::FFIStringResult::Ok(signed_message) => Ok(signed_message), + ffi::FFIStringResult::Err(error) => { + Err(YttriumError { message: error }) + } + }; + result + }); + + let owner = address_from_string(&owner_address).unwrap(); + + let signer = SignService::new(sign_fn, owner); + + let account_client = AccountClient::new( + config.owner_address.clone(), + config.chain_id.clone(), + config.config.into(), + signer, + ); + + Self { + owner_address: config.owner_address.clone(), + chain_id: config.chain_id, + account_client, + } + } + + pub async fn send_batch_transaction( + &self, + batch: String, + ) -> Result { + let batch: Vec = + serde_json::from_str(&batch).unwrap(); + let batch_transaction: Vec = + batch.into_iter().map(Into::into).collect(); + self.account_client + .send_batch_transaction(batch_transaction) + .await + .map_err(|e| FFIError::Unknown(e.to_string())) + } +} diff --git a/crates/ffi/src/config.rs b/crates/ffi/src/config.rs new file mode 100644 index 0000000..03125fb --- /dev/null +++ b/crates/ffi/src/config.rs @@ -0,0 +1,25 @@ +use super::ffi; + +impl Into for ffi::FFIEndpoint { + fn into(self) -> yttrium::config::Endpoint { + yttrium::config::Endpoint { + api_key: self.api_key, + base_url: self.base_url, + } + } +} + +impl Into for ffi::FFIEndpoints { + fn into(self) -> yttrium::config::Endpoints { + yttrium::config::Endpoints { + rpc: self.rpc.into(), + bundler: self.bundler.into(), + } + } +} + +impl Into for ffi::FFIConfig { + fn into(self) -> yttrium::config::Config { + yttrium::config::Config { endpoints: self.endpoints.into() } + } +} diff --git a/crates/ffi/src/error.rs b/crates/ffi/src/error.rs new file mode 100644 index 0000000..57ac0fe --- /dev/null +++ b/crates/ffi/src/error.rs @@ -0,0 +1,24 @@ +use super::ffi; +use ffi::FFIError; + +impl std::fmt::Display for FFIError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + FFIError::Unknown(message) => { + write!(f, "Unknown error: {}", message) + } + } + } +} + +impl std::fmt::Debug for FFIError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + FFIError::Unknown(message) => { + write!(f, "Unknown error: {}", message) + } + } + } +} + +impl std::error::Error for FFIError {} diff --git a/crates/ffi/src/lib.rs b/crates/ffi/src/lib.rs new file mode 100644 index 0000000..d9aeaba --- /dev/null +++ b/crates/ffi/src/lib.rs @@ -0,0 +1,95 @@ +use self::account_client::FFIAccountClient; +use self::account_client_eip7702::FFI7702AccountClient; +use swift_bridge; + +pub mod account_client; +pub mod account_client_eip7702; +pub mod config; +pub mod error; +pub mod log; + +#[swift_bridge::bridge] +mod ffi { + + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + #[swift_bridge(swift_repr = "struct")] + pub struct FFITransaction { + pub _to: String, + pub _value: String, + pub _data: String, + } + + #[derive(Debug, Clone)] + #[swift_bridge(swift_repr = "struct")] + pub struct FFIEndpoint { + pub api_key: String, + pub base_url: String, + } + + #[derive(Debug, Clone)] + #[swift_bridge(swift_repr = "struct")] + pub struct FFIEndpoints { + pub rpc: FFIEndpoint, + pub bundler: FFIEndpoint, + } + + #[derive(Debug, Clone)] + #[swift_bridge(swift_repr = "struct")] + pub struct FFIConfig { + pub endpoints: FFIEndpoints, + } + + #[derive(Debug, Clone)] + #[swift_bridge(swift_repr = "struct")] + pub struct FFIAccountClientConfig { + pub owner_address: String, + pub chain_id: i64, + pub config: FFIConfig, + } + + enum FFIStringResult { + Ok(String), + Err(String), + } + + enum FFIError { + Unknown(String), + } + + extern "Rust" { + type FFIAccountClient; + + #[swift_bridge(init)] + fn new(config: FFIAccountClientConfig) -> FFIAccountClient; + + pub fn chain_id(&self) -> i64; + + pub async fn get_address(&self) -> Result; + + pub async fn send_transaction( + &self, + _transaction: FFITransaction, + ) -> Result; + } + + extern "Rust" { + type FFI7702AccountClient; + + #[swift_bridge(init)] + fn new(config: FFIAccountClientConfig) -> FFI7702AccountClient; + + pub async fn send_batch_transaction( + &self, + batch: String, + ) -> Result; + } + + extern "Swift" { + type SignerServiceFFI; + + #[swift_bridge(init)] + fn new(signer_id: String) -> SignerServiceFFI; + + fn sign(&self, message: String) -> FFIStringResult; + } +} diff --git a/crates/ffi/src/log.rs b/crates/ffi/src/log.rs new file mode 100644 index 0000000..3ae7b61 --- /dev/null +++ b/crates/ffi/src/log.rs @@ -0,0 +1,19 @@ +#[cfg(target_os = "ios")] +use crate::ffi::FFIError; +#[cfg(target_os = "ios")] +use oslog; +#[cfg(target_os = "ios")] +use oslog::OsLogger; + +#[cfg(target_os = "ios")] +pub fn init_os_logger() -> Result<(), FFIError> { + #[cfg(debug_assertions)] + let level = log::LevelFilter::Debug; + #[cfg(not(debug_assertions))] + let level = log::LevelFilter::Info; + + OsLogger::new("com.walletconnect.YttriumCore") + .level_filter(level) + .init() + .map_err(|e| FFIError::Unknown(e.to_string())) +} diff --git a/crates/yttrium/Cargo.toml b/crates/yttrium/Cargo.toml new file mode 100644 index 0000000..192c91f --- /dev/null +++ b/crates/yttrium/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "yttrium" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +# Ethereum +alloy = { git = "https://github.com/alloy-rs/alloy", rev = "b000e16", features = [ + "full", + "node-bindings", + "rpc-types-trace", + "signer-mnemonic", +] } +# foundry-block-explorers = "0.2.3" + +# Error/Result +eyre.workspace = true +thiserror.workspace = true + +# Async +tokio.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Networking +reqwest.workspace = true + +# Env vars +dotenvy = "0.15.7" + +# Other +hex = "0.4.3" + +[dev-dependencies] +# mocking +wiremock = "0.6.0" diff --git a/crates/yttrium/src/account_client.rs b/crates/yttrium/src/account_client.rs new file mode 100644 index 0000000..929f233 --- /dev/null +++ b/crates/yttrium/src/account_client.rs @@ -0,0 +1,66 @@ +use crate::config::Config; +use crate::sign_service::SignService; +use crate::transaction::{send::send_transaction, Transaction}; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[allow(dead_code)] +pub struct AccountClient { + owner: String, + chain_id: i64, + config: Config, + sign_service: Arc>, +} + +impl AccountClient { + pub fn new( + owner: String, + chain_id: i64, + config: Config, + sign_service: SignService, + ) -> Self { + Self { + owner, + chain_id, + config: config.clone(), + sign_service: Arc::new(Mutex::new(sign_service)), + } + } + + pub fn chain_id(&self) -> i64 { + self.chain_id + } + + pub async fn get_address(&self) -> eyre::Result { + todo!("Implement get_address") + } + + pub async fn sign_message(&self, message: String) -> eyre::Result { + todo!("Implement sign_message: {}", message) + } + + pub async fn send_batch_transaction( + &self, + batch: Vec, + ) -> eyre::Result { + todo!("Implement send_batch_transaction: {:?}", batch) + } + + pub async fn send_transaction( + &self, + transaction: Transaction, + ) -> eyre::Result { + send_transaction(self.sign_service.clone(), transaction).await + } +} + +impl AccountClient { + pub fn mock() -> Self { + AccountClient { + owner: "".to_string(), + chain_id: 0, + config: Config::local(), + sign_service: Arc::new(Mutex::new(SignService::mock())), + } + } +} diff --git a/crates/yttrium/src/bundler.rs b/crates/yttrium/src/bundler.rs new file mode 100644 index 0000000..abd177a --- /dev/null +++ b/crates/yttrium/src/bundler.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod config; +pub mod models; +pub mod pimlico; diff --git a/crates/yttrium/src/bundler/client.rs b/crates/yttrium/src/bundler/client.rs new file mode 100644 index 0000000..7f63f8f --- /dev/null +++ b/crates/yttrium/src/bundler/client.rs @@ -0,0 +1,358 @@ +use super::config::BundlerConfig; +use super::models::{ + estimate_result::EstimateResult, + user_operation_receipt::UserOperationReceipt, +}; +use crate::jsonrpc::{JSONRPCResponse, Request, Response}; +use crate::user_operation::UserOperationV07; +use eyre::Ok; + +pub struct BundlerClient { + client: reqwest::Client, + config: BundlerConfig, +} + +impl BundlerClient { + pub fn new(config: BundlerConfig) -> Self { + Self { client: reqwest::Client::new(), config } + } + + pub async fn send_user_operation( + &self, + entry_point_address: alloy::primitives::Address, + user_op: UserOperationV07, + ) -> eyre::Result { + let bundler_url = self.config.url().clone(); + + let user_op_value = serde_json::to_value(&user_op)?; + + println!("\nuser_op_value: {}", user_op_value); + + let entry_point_address_str = entry_point_address.to_string(); + + let entry_point_addr_param = entry_point_address_str.into(); + + println!("\nentry_point_addr_param: {}", entry_point_addr_param); + + let params = vec![user_op_value, entry_point_addr_param]; + + println!("\nparams: {:#?}", params); + + let send_body = crate::jsonrpc::Request { + jsonrpc: "2.0".into(), + id: 1, + method: "eth_sendUserOperation".into(), + params, + }; + + let response = self + .client + .post(bundler_url.as_str()) + .json(&send_body) + .send() + .await?; + + let response_text = response.text().await?; + println!("response_text: {:?}", response_text); + + let raw_payload = + serde_json::from_str::>(&response_text)?; + println!("raw_payload: {:?}", raw_payload); + + let response: Response = raw_payload.into(); + + let user_operation_hash = response?; + + Ok(user_operation_hash) + } + + pub async fn estimate_user_operation_gas( + &self, + entry_point_address: crate::entry_point::EntryPointAddress, + user_op: UserOperationV07, + ) -> eyre::Result { + println!("user_op: {:?}", user_op); + + let bundler_url = self.config.url().clone(); + + use crate::jsonrpc::{JSONRPCResponse, Request, Response}; + use serde_json; + + let value = serde_json::to_value(&user_op).unwrap(); + + let params: Vec = + vec![value, entry_point_address.to_string().into()]; + + let req_body = Request { + jsonrpc: "2.0".into(), + id: 1, + method: "eth_estimateUserOperationGas".into(), + params: params, + }; + println!("req_body: {:?}", serde_json::to_string(&req_body)?); + + let post = self + .client + .post(bundler_url.as_str()) + .json(&req_body) + .send() + .await?; + println!("post: {:?}", post); + let res = post.text().await?; + println!("res: {:?}", res); + let v = serde_json::from_str::>(&res)?; + + println!("json: {:?}", v); + + let response: Response = v.into(); + + let response_estimate = response?; + + Ok(response_estimate) + } + + pub async fn supported_entry_points( + &self, + op: String, + ) -> eyre::Result { + Ok(op) + } + + pub async fn chain_id(&self, op: String) -> eyre::Result { + Ok(op) + } + + pub async fn get_user_operation_by_hash( + &self, + op: String, + ) -> eyre::Result { + Ok(op) + } + + pub async fn get_user_operation_receipt( + &self, + hash: String, + ) -> eyre::Result { + let bundler_url = self.config.url().clone(); + + let hash_value = serde_json::to_value(&hash)?; + + let send_body = Request { + jsonrpc: "2.0".into(), + id: 1, + method: "eth_getUserOperationReceipt".into(), + params: vec![hash_value], + }; + + let response = self + .client + .post(bundler_url.as_str()) + .json(&send_body) + .send() + .await?; + + let response_text = response.text().await?; + println!("response_text: {:?}", response_text); + let raw_payload = serde_json::from_str::< + JSONRPCResponse, + >(&response_text)?; + + println!("raw_payload: {:?}", raw_payload); + + let response: Response = raw_payload.into(); + + let response_estimate = response?; + + Ok(response_estimate) + } + + pub async fn wait_for_user_operation_receipt( + &self, + hash: String, + ) -> eyre::Result { + use std::time::{Duration, Instant}; + use tokio::time::sleep; + + let polling_interval: Duration = Duration::from_millis(2000); + let timeout: Option = Some(Duration::from_secs(60)); + + let start_time = Instant::now(); + + loop { + match self.get_user_operation_receipt(hash.clone()).await { + eyre::Result::Ok(receipt) => return Ok(receipt), + _ => { + if let Some(timeout_duration) = timeout { + if start_time.elapsed() > timeout_duration { + return Err(eyre::eyre!( + "Timeout waiting for user operation receipt" + )); + } + } + println!( + "No Receipt yet. Trying again in {:?}", + polling_interval.as_millis() + ); + sleep(polling_interval).await; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + bundler::models::{ + estimate_result::EstimateResult, + user_operation_receipt::UserOperationReceipt, + }, + entry_point, + }; + use alloy::primitives::{Address, Bytes, U256}; + use eyre::ensure; + + pub async fn setup_gas_estimation_bundler_mock( + ) -> eyre::Result { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let expected_request_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "method": "eth_estimateUserOperationGas", + }); + + let response_estimate = EstimateResult { + pre_verification_gas: U256::from(100000), + verification_gas_limit: U256::from(100000), + call_gas_limit: U256::from(100000), + paymaster_verification_gas_limit: None, + paymaster_post_op_gas_limit: None, + }; + + let response_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "result": response_estimate, + }); + + let response = ResponseTemplate::new(200).set_body_json(response_body); + + use wiremock::matchers::body_partial_json; + + Mock::given(method("POST")) + .and(path("/")) + .and(body_partial_json(&expected_request_body)) + .respond_with(response) + .mount(&mock_server) + .await; + + let bundler_client = BundlerClient::new(BundlerConfig::new( + mock_server.uri().to_string(), + )); + + Ok(bundler_client) + } + + #[tokio::test] + async fn test_estimate_gas() -> eyre::Result<()> { + let sender: Address = + "0x5FbDB2315678afecb367f032d93F642f64180aa3".parse()?; + + let entry_point_address = + entry_point::EntryPointConfig::V07_SEPOLIA.address(); + + let bundler_client = setup_gas_estimation_bundler_mock().await?; + + let user_op = { + let sender: Address = sender; + let nonce: U256 = U256::from(0); + let factory: Address = Address::ZERO; + let factory_data: Bytes = Bytes::new(); + let call_data: Bytes = Bytes::new(); + let call_gas_limit: U256 = U256::from(100000); + let verification_gas_limit: U256 = U256::from(100000); + let pre_verification_gas: U256 = U256::from(100000); + let max_fee_per_gas: U256 = U256::from(100000); + let max_priority_fee_per_gas: U256 = U256::from(100000); + let paymaster: Option
= None; + let paymaster_data: Option = None; + let signature: Bytes = Bytes::new(); + + UserOperationV07 { + sender: sender.into(), + nonce: nonce.into(), + factory: factory.into(), + factory_data: factory_data.into(), + call_data: call_data.into(), + call_gas_limit: call_gas_limit.into(), + verification_gas_limit: verification_gas_limit.into(), + paymaster_post_op_gas_limit: Some(U256::from(100000)), + paymaster_verification_gas_limit: Some(U256::from(100000)), + pre_verification_gas: pre_verification_gas.into(), + max_fee_per_gas: max_fee_per_gas.into(), + max_priority_fee_per_gas: max_priority_fee_per_gas.into(), + paymaster: paymaster, + paymaster_data: paymaster_data, + signature: signature, + } + }; + + let estimate_result = bundler_client + .estimate_user_operation_gas(entry_point_address, user_op) + .await?; + + ensure!(estimate_result.call_gas_limit == U256::from(100000)); + + Ok(()) + } + + #[tokio::test] + async fn test_get_user_operation_receipt() -> eyre::Result<()> { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let expected_request_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "method": "eth_getUserOperationReceipt", + }); + + let user_operation_hash = "0x93c06f3f5909cc2b192713ed9bf93e3e1fde4b22fcd2466304fa404f9b80ff90".to_string(); + + let response_payload = UserOperationReceipt::mock(); + + let response_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "result": response_payload, + }); + + let response = ResponseTemplate::new(200).set_body_json(response_body); + use wiremock::matchers::body_partial_json; + Mock::given(method("POST")) + .and(path("/")) + .and(body_partial_json(&expected_request_body)) + .respond_with(response) + .mount(&mock_server) + .await; + + let bundler_client = BundlerClient::new(BundlerConfig::new( + mock_server.uri().to_string(), + )); + + let receipt = bundler_client + .get_user_operation_receipt(user_operation_hash.clone()) + .await?; + + eyre::ensure!(receipt == response_payload); + + Ok(()) + } +} diff --git a/crates/yttrium/src/bundler/config.rs b/crates/yttrium/src/bundler/config.rs new file mode 100644 index 0000000..604de24 --- /dev/null +++ b/crates/yttrium/src/bundler/config.rs @@ -0,0 +1,13 @@ +pub struct BundlerConfig { + url: String, +} + +impl BundlerConfig { + pub fn new(url: String) -> Self { + BundlerConfig { url } + } + + pub fn url(&self) -> String { + self.url.clone() + } +} diff --git a/crates/yttrium/src/bundler/models.rs b/crates/yttrium/src/bundler/models.rs new file mode 100644 index 0000000..9b234c6 --- /dev/null +++ b/crates/yttrium/src/bundler/models.rs @@ -0,0 +1,2 @@ +pub mod estimate_result; +pub mod user_operation_receipt; diff --git a/crates/yttrium/src/bundler/models/estimate_result.rs b/crates/yttrium/src/bundler/models/estimate_result.rs new file mode 100644 index 0000000..684a484 --- /dev/null +++ b/crates/yttrium/src/bundler/models/estimate_result.rs @@ -0,0 +1,12 @@ +use alloy::primitives::U256; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EstimateResult { + pub call_gas_limit: U256, + pub pre_verification_gas: U256, + pub verification_gas_limit: U256, + pub paymaster_verification_gas_limit: Option, + pub paymaster_post_op_gas_limit: Option, +} diff --git a/crates/yttrium/src/bundler/models/user_operation_receipt.rs b/crates/yttrium/src/bundler/models/user_operation_receipt.rs new file mode 100644 index 0000000..01a3a56 --- /dev/null +++ b/crates/yttrium/src/bundler/models/user_operation_receipt.rs @@ -0,0 +1,69 @@ +use alloy::primitives::Address; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserOperationReceiptReceipt { + pub transaction_hash: String, + pub transaction_index: String, + pub block_hash: String, + pub block_number: String, + pub from: Address, + pub to: Address, + pub cumulative_gas_used: String, + pub gas_used: String, + pub contract_address: Option, + pub status: String, + pub logs_bloom: String, + pub r#type: String, + pub effective_gas_price: String, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserOperationReceipt { + pub user_op_hash: String, + pub entry_point: Address, + pub sender: Address, + pub nonce: String, + pub paymaster: String, + pub actual_gas_cost: String, + pub actual_gas_used: String, + pub success: bool, + pub reason: String, + pub receipt: UserOperationReceiptReceipt, + // TODO: add `logs` property +} + +impl UserOperationReceipt { + pub fn mock() -> Self { + UserOperationReceipt { + user_op_hash: "0x93c06f3f5909cc2b192713ed9bf93e3e1fde4b22fcd2466304fa404f9b80ff90".to_string(), + entry_point: "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789" + .parse() + .unwrap(), + sender: "0x9E1276a4A64D064256E7347cdA4d8C8039b1bc48".parse().unwrap(), + nonce: "0x3".to_string(), + paymaster: "0xb80bCD1Bcf735238EAB64ffc3431076605A21D61".to_string(), + actual_gas_cost: "0x11bed797b2d5c8".to_string(), + actual_gas_used: "0x20725".to_string(), + success: true, + reason: "".to_string(), + receipt: UserOperationReceiptReceipt { + transaction_hash: "0x68b5465c1efe05e5a29f8551c3808e5fd3b0a46e7abb007e11c586632cf46c23".to_string(), + transaction_index: "0x85".to_string(), + block_hash: "0x0b95eb450c36397458e77e38420b89f0b6336b7c61b7bbb9898e0318da0f4cd0".to_string(), + block_number: "0x113fc81".to_string(), + from: "0x374a2c4dcb38ecbb606117ae1bfe402a52176ec1".parse().unwrap(), + to: "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789".parse().unwrap(), + cumulative_gas_used: "0x12bafe6".to_string(), + gas_used: "0x20d07".to_string(), + contract_address: None, + status: "0x1".to_string(), + logs_bloom: "0x04400000000040002000000000000000000000000000000000000000000000000008000000000000000200010000000000100000000000000000020000000000000000000000000000000008000000000100000000000000000000000000000000000000080000000008000000000000000000000000000000000010000000000000000000040040100088000000000000000000000000000000000000000000000000000000000100400000000008000000000000000000000002000000000000000002000000100001000000000000000000002000000000000040000000000000000000000000200000000000000000000000000000000000000000000010".to_string(), + r#type: "0x2".to_string(), + effective_gas_price: "0x86cb70a28".to_string(), + }, + } + } +} diff --git a/crates/yttrium/src/bundler/pimlico.rs b/crates/yttrium/src/bundler/pimlico.rs new file mode 100644 index 0000000..2ebb137 --- /dev/null +++ b/crates/yttrium/src/bundler/pimlico.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod gas_price; +pub mod paymaster; diff --git a/crates/yttrium/src/bundler/pimlico/client.rs b/crates/yttrium/src/bundler/pimlico/client.rs new file mode 100644 index 0000000..2d61f76 --- /dev/null +++ b/crates/yttrium/src/bundler/pimlico/client.rs @@ -0,0 +1,126 @@ +use super::gas_price::GasPrice; +use crate::bundler::config::BundlerConfig; +use eyre::Ok; + +pub struct BundlerClient { + client: reqwest::Client, + config: BundlerConfig, +} + +impl BundlerClient { + pub fn new(config: BundlerConfig) -> Self { + Self { client: reqwest::Client::new(), config } + } + + pub async fn estimate_user_operation_gas_price( + &self, + ) -> eyre::Result { + println!("estimate_user_operation_gas_price"); + + let bundler_url = self.config.url().clone(); + + use serde_json; + + use crate::jsonrpc::{ + JSONRPCResponse, RequestWithEmptyParams, Response, + }; + + let req_body = RequestWithEmptyParams { + jsonrpc: "2.0".into(), + id: 1, + method: "pimlico_getUserOperationGasPrice".into(), + }; + println!("req_body: {:?}", serde_json::to_string(&req_body)?); + + let post = self + .client + .post(bundler_url.as_str()) + .json(&req_body) + .send() + .await?; + println!("post: {:?}", post); + let res = post.text().await?; + println!("res: {:?}", res); + let v = serde_json::from_str::>(&res)?; + + println!("json: {:?}", v); + + let response: Response = v.into(); + + let response_estimate = response?; + + Ok(response_estimate) + } +} + +#[cfg(test)] +mod tests { + use super::super::gas_price::{GasPrice, GasPriceItem}; + use super::*; + use alloy::primitives::U256; + use eyre::ensure; + + pub async fn setup_gas_estimation_bundler_mock( + ) -> eyre::Result { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let url = mock_server.uri().to_string(); + + let expected_request_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "method": "pimlico_getUserOperationGasPrice", + }); + + let response_gas_price = GasPrice { + slow: GasPriceItem { + max_fee_per_gas: U256::from(100000), + max_priority_fee_per_gas: U256::from(100000), + }, + standard: GasPriceItem { + max_fee_per_gas: U256::from(100000), + max_priority_fee_per_gas: U256::from(100000), + }, + fast: GasPriceItem { + max_fee_per_gas: U256::from(100000), + max_priority_fee_per_gas: U256::from(100000), + }, + }; + + let response_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "result": response_gas_price, + }); + + let response = ResponseTemplate::new(200).set_body_json(response_body); + + use wiremock::matchers::body_partial_json; + + Mock::given(method("POST")) + .and(path("/")) + .and(body_partial_json(&expected_request_body)) + .respond_with(response) + .mount(&mock_server) + .await; + + let bundler_client = BundlerClient::new(BundlerConfig::new(url)); + + Ok(bundler_client) + } + + #[tokio::test] + async fn test_estimate_user_operation_gas_price() -> eyre::Result<()> { + let bundler_client = setup_gas_estimation_bundler_mock().await?; + + let gas_price = + bundler_client.estimate_user_operation_gas_price().await?; + + ensure!(gas_price.fast.max_fee_per_gas.to_string() == "100000"); + + Ok(()) + } +} diff --git a/crates/yttrium/src/bundler/pimlico/gas_price.rs b/crates/yttrium/src/bundler/pimlico/gas_price.rs new file mode 100644 index 0000000..a9c76b4 --- /dev/null +++ b/crates/yttrium/src/bundler/pimlico/gas_price.rs @@ -0,0 +1,17 @@ +use alloy::primitives::U256; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GasPriceItem { + pub max_fee_per_gas: U256, + pub max_priority_fee_per_gas: U256, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GasPrice { + pub slow: GasPriceItem, + pub standard: GasPriceItem, + pub fast: GasPriceItem, +} diff --git a/crates/yttrium/src/bundler/pimlico/paymaster.rs b/crates/yttrium/src/bundler/pimlico/paymaster.rs new file mode 100644 index 0000000..04f3e94 --- /dev/null +++ b/crates/yttrium/src/bundler/pimlico/paymaster.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod models; diff --git a/crates/yttrium/src/bundler/pimlico/paymaster/client.rs b/crates/yttrium/src/bundler/pimlico/paymaster/client.rs new file mode 100644 index 0000000..36014bc --- /dev/null +++ b/crates/yttrium/src/bundler/pimlico/paymaster/client.rs @@ -0,0 +1,157 @@ +use super::models::{ + SponsorshipResponseV07, SponsorshipResultV07, + UserOperationPreSponsorshipV07, +}; +use crate::bundler::config::BundlerConfig; +use crate::entry_point::EntryPointAddress; +use crate::jsonrpc::{JSONRPCResponse, Request, Response}; + +use serde_json; + +pub struct PaymasterClient { + client: reqwest::Client, + config: BundlerConfig, +} + +impl PaymasterClient { + pub fn new(config: BundlerConfig) -> Self { + Self { client: reqwest::Client::new(), config } + } + + pub async fn sponsor_user_operation_v07( + &self, + user_operation: &UserOperationPreSponsorshipV07, + entry_point: &EntryPointAddress, + sponsorship_policy_id: Option, + ) -> eyre::Result { + println!("sponsor_user_operation_v07 "); + + let bundler_url = self.config.url().clone(); + + let params: Vec = { + let user_operation_value = serde_json::to_value(&user_operation)?; + let mut vec: Vec = vec![ + user_operation_value, + entry_point.to_address().to_string().into(), + ]; + if let Some(sponsorship_policy_id) = sponsorship_policy_id { + vec.push(sponsorship_policy_id.into()); + } + vec + }; + + let req_body: Request> = Request { + jsonrpc: "2.0".into(), + id: 1, + method: "pm_sponsorUserOperation".into(), + params: params, + }; + println!("req_body: {:?}", serde_json::to_string(&req_body)?); + + let post = self + .client + .post(bundler_url.as_str()) + .json(&req_body) + .send() + .await?; + println!("post: {:?}", post); + let res = post.text().await?; + println!("res: {:?}", res); + let v = serde_json::from_str::>( + &res, + )?; + + println!("json: {:?}", v); + + let response: Response = v.into(); + + let response_estimate = response?; + + let result = SponsorshipResultV07 { + call_gas_limit: response_estimate.call_gas_limit, + verification_gas_limit: response_estimate.verification_gas_limit, + pre_verification_gas: response_estimate.pre_verification_gas, + paymaster: response_estimate.paymaster, + paymaster_verification_gas_limit: response_estimate + .paymaster_verification_gas_limit, + paymaster_post_op_gas_limit: response_estimate + .paymaster_post_op_gas_limit, + paymaster_data: response_estimate.paymaster_data, + }; + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::Address; + use eyre::ensure; + + pub async fn setup_sponsor_user_operation_v07_paymaster_mock( + ) -> eyre::Result { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + let url = mock_server.uri().to_string(); + + let expected_request_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "method": "pm_sponsorUserOperation", + }); + + let sponsorship_payload = SponsorshipResponseV07::mock(); + + let response_body = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "result": sponsorship_payload, + }); + + let response = ResponseTemplate::new(200).set_body_json(response_body); + + use wiremock::matchers::body_partial_json; + + Mock::given(method("POST")) + .and(path("/")) + .and(body_partial_json(&expected_request_body)) + .respond_with(response) + .mount(&mock_server) + .await; + + let bundler_client = PaymasterClient::new(BundlerConfig::new(url)); + + Ok(bundler_client) + } + + #[tokio::test] + async fn test_sponsor_user_operation_v07() -> eyre::Result<()> { + let paymaster_client = + setup_sponsor_user_operation_v07_paymaster_mock().await?; + + let entry_point = + "0x0000000071727De22E5E9d8BAf0edAc6f37da032".parse::
()?; + let entry_point_address = + crate::entry_point::EntryPointAddress::new(entry_point); + + let user_operation = crate::user_operation::UserOperationV07::mock(); + let user_operation_pre = + UserOperationPreSponsorshipV07::from(user_operation); + + let sponsorship_result = paymaster_client + .sponsor_user_operation_v07( + &user_operation_pre, + &entry_point_address, + None, + ) + .await?; + + ensure!(sponsorship_result.call_gas_limit.to_string() == "100000"); + + Ok(()) + } +} diff --git a/crates/yttrium/src/bundler/pimlico/paymaster/models.rs b/crates/yttrium/src/bundler/pimlico/paymaster/models.rs new file mode 100644 index 0000000..bb1f8b9 --- /dev/null +++ b/crates/yttrium/src/bundler/pimlico/paymaster/models.rs @@ -0,0 +1,135 @@ +use crate::user_operation::UserOperationV07; +use alloy::primitives::{Address, Bytes, U256}; +use serde::{Deserialize, Serialize}; + +#[derive( + Default, + Clone, + Debug, + Ord, + PartialOrd, + PartialEq, + Eq, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct UserOperationPreSponsorshipV07 { + pub sender: Address, + pub nonce: U256, + pub factory: Option
, + pub factory_data: Option, + pub call_data: Bytes, + pub call_gas_limit: U256, + pub verification_gas_limit: U256, + pub pre_verification_gas: U256, + pub max_fee_per_gas: U256, + pub max_priority_fee_per_gas: U256, + pub paymaster: Option
, + pub paymaster_verification_gas_limit: Option, + pub paymaster_post_op_gas_limit: Option, + pub paymaster_data: Option, + pub signature: Bytes, +} + +impl From for UserOperationPreSponsorshipV07 { + fn from(user_op: UserOperationV07) -> Self { + Self { + sender: user_op.sender, + nonce: user_op.nonce, + factory: user_op.factory, + factory_data: user_op.factory_data, + call_data: user_op.call_data, + call_gas_limit: user_op.call_gas_limit, + verification_gas_limit: user_op.verification_gas_limit, + pre_verification_gas: user_op.pre_verification_gas, + max_fee_per_gas: user_op.max_fee_per_gas, + max_priority_fee_per_gas: user_op.max_priority_fee_per_gas, + paymaster: user_op.paymaster, + paymaster_verification_gas_limit: user_op + .paymaster_verification_gas_limit, + paymaster_post_op_gas_limit: user_op.paymaster_post_op_gas_limit, + paymaster_data: user_op.paymaster_data, + signature: user_op.signature, + } + } +} + +#[derive( + Default, + Clone, + Debug, + Ord, + PartialOrd, + PartialEq, + Eq, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct SponsorshipResultV06 { + pub call_gas_limit: U256, + pub verification_gas_limit: U256, + pub pre_verification_gas: U256, + pub paymaster_and_data: Bytes, +} + +#[derive( + Default, + Clone, + Debug, + Ord, + PartialOrd, + PartialEq, + Eq, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct SponsorshipResultV07 { + pub call_gas_limit: U256, + pub verification_gas_limit: U256, + pub pre_verification_gas: U256, + pub paymaster: Address, + pub paymaster_verification_gas_limit: U256, + pub paymaster_post_op_gas_limit: U256, + pub paymaster_data: Bytes, +} + +#[derive( + Default, + Clone, + Debug, + Ord, + PartialOrd, + PartialEq, + Eq, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct SponsorshipResponseV07 { + pub pre_verification_gas: U256, + pub verification_gas_limit: U256, + pub call_gas_limit: U256, + pub paymaster: Address, + pub paymaster_verification_gas_limit: U256, + pub paymaster_post_op_gas_limit: U256, + pub paymaster_data: Bytes, +} + +impl SponsorshipResponseV07 { + pub fn mock() -> Self { + Self { + call_gas_limit: U256::from(100000), + verification_gas_limit: U256::from(100000), + pre_verification_gas: U256::from(100000), + paymaster: "0xb80bCD1Bcf735238EAB64ffc3431076605A21D61" + .parse() + .unwrap(), + paymaster_verification_gas_limit: U256::from(100000), + paymaster_post_op_gas_limit: U256::from(100000), + paymaster_data: Bytes::from(vec![]), + } + } +} diff --git a/crates/yttrium/src/chain.rs b/crates/yttrium/src/chain.rs new file mode 100644 index 0000000..59f5cda --- /dev/null +++ b/crates/yttrium/src/chain.rs @@ -0,0 +1,135 @@ +use crate::entry_point::{EntryPointConfig, EntryPointVersion}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ChainId(&'static str); + +impl ChainId { + pub const ETHEREUM_MAINNET: Self = Self::new_const("eip155:1"); + + pub const ETHEREUM_SEPOLIA: Self = Self::new_const("eip155:11155111"); + + pub const LOCAL_FOUNDRY_ETHEREUM_SEPOLIA: Self = + Self::new_const("eip155:31337"); + + const fn new_const(caip2_identifier: &'static str) -> Self { + Self(caip2_identifier) + } + + pub fn new(caip2_identifier: &'static str) -> eyre::Result { + let components = caip2_identifier.split(':').collect::>(); + let prefix = components + .get(0) + .map(ToOwned::to_owned) + .ok_or_else(|| eyre::eyre!("Invalid CAIP2 chain identifier"))?; + let chain_id = components + .get(1) + .map(ToOwned::to_owned) + .ok_or_else(|| eyre::eyre!("Invalid CAIP2 chain identifier"))?; + match prefix { + "eip155" => { + let _: u64 = chain_id.parse()?; + Ok(Self(&caip2_identifier)) + } + _ => Err(eyre::eyre!("Invalid EIP155 chain ID")), + } + } + + pub fn caip2_identifier(&self) -> String { + self.0.to_string() + } + + pub fn eip155_chain_id(&self) -> eyre::Result { + let components = self.0.split(':').collect::>(); + let prefix = components + .get(0) + .map(ToOwned::to_owned) + .ok_or_else(|| eyre::eyre!("Invalid CAIP2 chain identifier"))?; + if prefix != "eip155" { + return Err(eyre::eyre!("Invalid EIP155 chain ID")); + } + let chain_id_string = components + .get(1) + .map(ToOwned::to_owned) + .ok_or_else(|| eyre::eyre!("Invalid CAIP2 chain identifier")) + .unwrap(); + let chain_id = chain_id_string.parse()?; + Ok(chain_id) + } +} + +impl Into for ChainId { + fn into(self) -> String { + self.0.to_string() + } +} + +impl fmt::Display for ChainId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Chain { + pub id: ChainId, + pub entry_point_version: EntryPointVersion, + pub name: &'static str, +} + +impl Chain { + pub const ETHEREUM_MAINNET_V07: Self = Self { + id: ChainId::ETHEREUM_MAINNET, + entry_point_version: EntryPointVersion::V07, + name: "Ethereum Mainnet", + }; + + pub const ETHEREUM_MAINNET_V06: Self = Self { + id: ChainId::ETHEREUM_MAINNET, + entry_point_version: EntryPointVersion::V06, + name: "Ethereum Mainnet", + }; + + pub const ETHEREUM_SEPOLIA_V07: Self = Self { + id: ChainId::ETHEREUM_SEPOLIA, + entry_point_version: EntryPointVersion::V07, + name: "Ethereum Sepolia", + }; + + pub const ETHEREUM_SEPOLIA_V06: Self = Self { + id: ChainId::ETHEREUM_SEPOLIA, + entry_point_version: EntryPointVersion::V06, + name: "Ethereum Sepolia", + }; + + pub const LOCAL_ETHEREUM_SEPOLIA_V07: Self = Self { + id: ChainId::LOCAL_FOUNDRY_ETHEREUM_SEPOLIA, + entry_point_version: EntryPointVersion::V07, + name: "Local Ethereum Sepolia", + }; + + pub const LOCAL_ETHEREUM_SEPOLIA_V06: Self = Self { + id: ChainId::LOCAL_FOUNDRY_ETHEREUM_SEPOLIA, + entry_point_version: EntryPointVersion::V06, + name: "Local Ethereum Sepolia", + }; +} + +impl Chain { + pub fn entry_point_config(&self) -> EntryPointConfig { + EntryPointConfig { + chain_id: self.id, + version: self.entry_point_version, + } + } + + pub fn caip2_identifier(&self) -> String { + self.id.caip2_identifier() + } +} + +impl fmt::Display for Chain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} diff --git a/crates/yttrium/src/config.rs b/crates/yttrium/src/config.rs new file mode 100644 index 0000000..e8461fc --- /dev/null +++ b/crates/yttrium/src/config.rs @@ -0,0 +1,100 @@ +use dotenvy::dotenv; +use std::env; + +const LOCAL_RPC_URL: &str = "http://localhost:8545"; +const LOCAL_BUNDLER_URL: &str = "http://localhost:4337"; + +#[derive(Clone, Debug, PartialEq)] +pub struct Config { + pub endpoints: Endpoints, +} + +impl Config { + pub fn local() -> Self { + Config { endpoints: Endpoints::local() } + } + + pub fn pimlico() -> Self { + Config { endpoints: Endpoints::pimlico() } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Endpoints { + pub rpc: Endpoint, + pub bundler: Endpoint, +} + +impl Endpoints { + pub fn live() -> Self { + dotenv().unwrap(); + + let rpc = { + let api_key = env::var("RPC_API_KEY") + .expect("You've not set the RPC_API_KEY"); + let base_url = env::var("RPC_BASE_URL") + .expect("You've not set the RPC_BASE_URL"); + Endpoint { api_key, base_url } + }; + + let bundler = { + let api_key = env::var("BUNDLER_API_KEY") + .expect("You've not set the BUNDLER_API_KEY"); + let base_url = env::var("BUNDLER_BASE_URL") + .expect("You've not set the BUNDLER_BASE_URL"); + Endpoint { api_key, base_url } + }; + + Endpoints { rpc, bundler } + } + + pub fn local() -> Self { + Endpoints { + rpc: Endpoint::local_rpc(), + bundler: Endpoint::local_bundler(), + } + } + + pub fn pimlico() -> Self { + dotenv().unwrap(); + + let api_key = env::var("PIMLICO_API_KEY") + .expect("You've not set the PIMLICO_API_KEY"); + + let rpc = { + let base_url = env::var("PIMLICO_RPC_URL") + .expect("You've not set the PIMLICO_RPC_URL"); + Endpoint { api_key: api_key.clone(), base_url } + }; + + let bundler = { + let base_url = env::var("PIMLICO_BUNDLER_URL") + .expect("You've not set the PIMLICO_BUNDLER_URL"); + Endpoint { api_key: api_key.clone(), base_url } + }; + + Endpoints { rpc, bundler } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Endpoint { + pub base_url: String, + pub api_key: String, +} + +impl Endpoint { + pub fn local_rpc() -> Self { + Endpoint { + base_url: LOCAL_RPC_URL.to_string(), + api_key: "".to_string(), + } + } + + pub fn local_bundler() -> Self { + Endpoint { + base_url: LOCAL_BUNDLER_URL.to_string(), + api_key: "".to_string(), + } + } +} diff --git a/crates/yttrium/src/contracts b/crates/yttrium/src/contracts new file mode 160000 index 0000000..6f02f5a --- /dev/null +++ b/crates/yttrium/src/contracts @@ -0,0 +1 @@ +Subproject commit 6f02f5a28a20e804d0410b4b5b570dd4b076dcf9 diff --git a/crates/yttrium/src/eip7702.rs b/crates/yttrium/src/eip7702.rs new file mode 100644 index 0000000..1a44db9 --- /dev/null +++ b/crates/yttrium/src/eip7702.rs @@ -0,0 +1 @@ +pub mod account_client; diff --git a/crates/yttrium/src/eip7702/account_client.rs b/crates/yttrium/src/eip7702/account_client.rs new file mode 100644 index 0000000..497002a --- /dev/null +++ b/crates/yttrium/src/eip7702/account_client.rs @@ -0,0 +1,47 @@ +use crate::config::Config; +use crate::sign_service::SignService; +use crate::transaction::Transaction; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[allow(dead_code)] +pub struct AccountClient { + owner: String, + chain_id: i64, + config: Config, + sign_service: Arc>, +} + +impl AccountClient { + pub fn new( + owner: String, + chain_id: i64, + config: Config, + sign_service: SignService, + ) -> Self { + Self { + owner, + chain_id, + config: config.clone(), + sign_service: Arc::new(Mutex::new(sign_service)), + } + } + + pub async fn send_batch_transaction( + &self, + _batch: Vec, + ) -> eyre::Result { + todo!() + } +} + +impl AccountClient { + pub fn mock() -> Self { + AccountClient { + owner: "".to_string(), + chain_id: 0, + config: Config::local(), + sign_service: Arc::new(Mutex::new(SignService::mock())), + } + } +} diff --git a/crates/yttrium/src/entry_point.rs b/crates/yttrium/src/entry_point.rs new file mode 100644 index 0000000..fd7f56c --- /dev/null +++ b/crates/yttrium/src/entry_point.rs @@ -0,0 +1,208 @@ +use crate::chain::ChainId; +use alloy::sol; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EntryPointAddress(alloy::primitives::Address); + +impl EntryPointAddress { + pub fn new(address: alloy::primitives::Address) -> Self { + Self(address) + } + + pub fn to_address(&self) -> alloy::primitives::Address { + self.into() + } +} + +impl ToString for EntryPointAddress { + fn to_string(&self) -> String { + self.0.to_string() + } +} + +impl Into for EntryPointAddress { + fn into(self) -> alloy::primitives::Address { + self.0 + } +} + +impl Into for &EntryPointAddress { + fn into(self) -> alloy::primitives::Address { + self.0 + } +} + +pub const ENTRYPOINT_ADDRESS_V06: &str = + "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"; +pub const ENTRYPOINT_ADDRESS_V07: &str = + "0x0000000071727De22E5E9d8BAf0edAc6f37da032"; + +pub const ENTRYPOINT_V06_TYPE: &str = "v0.6"; +pub const ENTRYPOINT_V07_TYPE: &str = "v0.7"; + +sol!( + #[allow(missing_docs)] + #[sol(rpc)] + EntryPoint, + "src/contracts/artifacts/contracts/core/EntryPoint.sol/EntryPoint.json" +); + +pub mod get_sender_address; + +pub struct EntryPointConfig { + pub chain_id: ChainId, + pub version: EntryPointVersion, +} + +impl EntryPointConfig { + pub const V07_MAINNET: EntryPointConfig = EntryPointConfig { + chain_id: ChainId::ETHEREUM_MAINNET, + version: EntryPointVersion::V07, + }; + + pub const V07_SEPOLIA: EntryPointConfig = EntryPointConfig { + chain_id: ChainId::ETHEREUM_SEPOLIA, + version: EntryPointVersion::V07, + }; + + pub const V07_LOCAL_FOUNDRY_SEPOLIA: EntryPointConfig = EntryPointConfig { + chain_id: ChainId::LOCAL_FOUNDRY_ETHEREUM_SEPOLIA, + version: EntryPointVersion::V07, + }; + + pub fn address(&self) -> EntryPointAddress { + match self.chain_id { + ChainId::ETHEREUM_MAINNET + | ChainId::ETHEREUM_SEPOLIA + | ChainId::LOCAL_FOUNDRY_ETHEREUM_SEPOLIA => match self.version { + EntryPointVersion::V06 => EntryPointAddress::new( + ENTRYPOINT_ADDRESS_V06.parse().unwrap(), + ), + EntryPointVersion::V07 => EntryPointAddress::new( + ENTRYPOINT_ADDRESS_V07.parse().unwrap(), + ), + }, + _ => panic!("Unsupported chain ID"), + } + } + + pub fn type_string(&self) -> String { + self.version.type_string() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum EntryPointVersion { + V06, + V07, +} + +impl EntryPointVersion { + pub fn type_string(&self) -> String { + match self { + EntryPointVersion::V06 => ENTRYPOINT_V06_TYPE.to_string(), + EntryPointVersion::V07 => ENTRYPOINT_V07_TYPE.to_string(), + } + } + + pub fn is_v06(&self) -> bool { + self == &EntryPointVersion::V06 + } + + pub fn is_v07(&self) -> bool { + self == &EntryPointVersion::V07 + } +} + +impl From for String { + fn from(value: EntryPointVersion) -> Self { + value.type_string() + } +} + +impl From for EntryPointVersion { + fn from(value: String) -> Self { + match value.as_str() { + ENTRYPOINT_V06_TYPE => EntryPointVersion::V06, + ENTRYPOINT_V07_TYPE => EntryPointVersion::V07, + _ => panic!("invalid version string"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::Address; + use eyre; + + #[test] + fn test_address_type() -> eyre::Result<()> { + { + let expected_v06_address: Address = + ENTRYPOINT_ADDRESS_V06.parse().unwrap(); + let v06 = EntryPointVersion::V06; + let mainnet_config = EntryPointConfig { + chain_id: ChainId::ETHEREUM_MAINNET, + version: v06, + }; + let mainnet_v06_address = mainnet_config.address(); + eyre::ensure!( + mainnet_v06_address.to_address() == expected_v06_address, + format!("unexpected address: {:?}", mainnet_v06_address) + ); + }; + + { + let expected_v07_address: Address = + ENTRYPOINT_ADDRESS_V07.parse().unwrap(); + let v07 = EntryPointVersion::V07; + let mainnet_config = EntryPointConfig { + chain_id: ChainId::ETHEREUM_MAINNET, + version: v07, + }; + let mainnet_v07_address = mainnet_config.address(); + eyre::ensure!( + mainnet_v07_address.to_address() == expected_v07_address, + format!("unexpected address: {:?}", mainnet_v07_address) + ); + }; + + { + let expected_v07_address: Address = + ENTRYPOINT_ADDRESS_V07.parse().unwrap(); + let v07 = EntryPointVersion::V07; + let local_sepolia_config = EntryPointConfig { + chain_id: ChainId::LOCAL_FOUNDRY_ETHEREUM_SEPOLIA, + version: v07, + }; + let local_sepolia_v07_address = local_sepolia_config.address(); + eyre::ensure!( + local_sepolia_v07_address.to_address() == expected_v07_address, + format!("unexpected address: {:?}", local_sepolia_v07_address) + ); + }; + + { + let v07_type = ENTRYPOINT_V07_TYPE.to_string(); + let v07 = EntryPointVersion::from(v07_type); + eyre::ensure!(v07.is_v07()); + eyre::ensure!( + v07 == EntryPointVersion::V07, + format!("unexpected type: {:?}", v07) + ); + }; + + { + let v06_type = ENTRYPOINT_V06_TYPE.to_string(); + let v06 = EntryPointVersion::from(v06_type); + eyre::ensure!(v06.is_v06()); + eyre::ensure!( + v06 == EntryPointVersion::V06, + format!("unexpected type: {:?}", v06) + ); + }; + + Ok(()) + } +} diff --git a/crates/yttrium/src/entry_point/get_sender_address.rs b/crates/yttrium/src/entry_point/get_sender_address.rs new file mode 100644 index 0000000..a33e3c4 --- /dev/null +++ b/crates/yttrium/src/entry_point/get_sender_address.rs @@ -0,0 +1,122 @@ +use alloy::{ + contract::Error as ContractError, + primitives::{Address, Bytes}, + sol, + sol_types::{SolCall, SolError}, +}; +use std::str::FromStr; + +sol!( + #[allow(missing_docs)] + #[derive(Debug, PartialEq, Eq)] + error SenderAddressResult(address sender); + function getSenderAddress(bytes calldata initCode) public; +); + +pub struct GetSenderAddress(getSenderAddressCall); + +impl GetSenderAddress { + pub fn new_with_init_code(init_code: Bytes) -> Self { + Self(getSenderAddressCall { initCode: init_code }) + } + + pub fn new_with_factory_and_factory_data( + factory: Address, + factory_data: Bytes, + ) -> Self { + let mut init_code = vec![]; + init_code.extend(factory.as_slice()); + init_code.extend(factory_data); + + let init_code: Bytes = init_code.into(); + + Self(getSenderAddressCall { initCode: init_code }) + } + + pub fn encode(&self) -> Vec { + getSenderAddressCall::abi_encode(&self.0) + } +} + +pub async fn get_sender_address_v07( + provider: &P, + factory: Address, + factory_data: Bytes, + entrypoint: super::EntryPointAddress, +) -> eyre::Result
+where + T: alloy::contract::private::Transport + ::core::clone::Clone, + P: alloy::contract::private::Provider, + N: alloy::contract::private::Network, +{ + let init_code: Bytes = { + let mut init_code = vec![]; + init_code.extend(factory.as_slice()); + init_code.extend(factory_data); + init_code.into() + }; + + let instance = super::EntryPoint::new(entrypoint.to_address(), provider); + + let call_builder = instance.getSenderAddress(init_code); + + let call: Result< + crate::entry_point::EntryPoint::getSenderAddressReturn, + ContractError, + > = call_builder.call().await; + + if let Err(error) = call { + println!("Error: {:?}", error); + match error { + ContractError::TransportError(transport_error) => { + println!("transport_error: {:?}", transport_error); + + let error_resp = + transport_error.as_error_resp().unwrap().clone(); + + println!("error_resp: {:?}", error_resp.clone()); + + let code = error_resp.code.clone(); + println!("error_resp_code: {:?}", code); + + let message = error_resp.message.clone(); + println!("error_resp_message: {:?}", message); + + let error_resp_data = error_resp.data.clone().unwrap(); + + println!("error_resp_data: {:?}", error_resp_data.clone()); + + let hex_value = + error_resp_data.get().split("\"").nth(1).unwrap(); + + let hex = hex_value.to_string(); + + let hex = hex.strip_prefix("0x").unwrap(); + + let error_resp_data_bytes_bytes = Bytes::from_str(hex).unwrap(); + + println!( + "error_resp_data_bytes_bytes: {:?}", + error_resp_data_bytes_bytes.clone() + ); + + let decoded_data = SenderAddressResult::abi_decode( + &error_resp_data_bytes_bytes, + true, + )?; + + let addr = decoded_data.sender; + + println!("addr: {:?}", addr.clone()); + + return Ok(addr); + } + _ => { + println!("error: {:?}", error); + todo!() + } + } + }; + + Err(eyre::eyre!("Invalid entrypoint")) +} diff --git a/crates/yttrium/src/error.rs b/crates/yttrium/src/error.rs new file mode 100644 index 0000000..eab52af --- /dev/null +++ b/crates/yttrium/src/error.rs @@ -0,0 +1,25 @@ +use std::error::Error; +use std::fmt; + +#[derive(Eq, Hash, PartialEq, Debug, Clone, Default, PartialOrd, Ord)] +pub struct YttriumError { + pub message: String, +} + +impl fmt::Display for YttriumError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for YttriumError { + fn description(&self) -> &str { + &self.message + } +} + +impl From for YttriumError { + fn from(e: alloy::signers::Error) -> Self { + YttriumError { message: e.to_string() } + } +} diff --git a/crates/yttrium/src/jsonrpc.rs b/crates/yttrium/src/jsonrpc.rs new file mode 100644 index 0000000..03b42a5 --- /dev/null +++ b/crates/yttrium/src/jsonrpc.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use thiserror::Error; + +pub type Response = Result>; + +#[derive(Debug, Deserialize, Error)] +pub struct ErrorPayload { + pub message: String, + pub data: Option, + pub code: Option, +} + +impl fmt::Display for ErrorPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ErrorPayload {{ message: {} }}", self.message) + } +} + +#[derive(Debug, Deserialize)] +pub struct JSONRPCResponse { + pub jsonrpc: String, + pub id: u64, + pub result: Option, + pub error: Option>, +} + +impl Into> for JSONRPCResponse { + fn into(self) -> Response { + if let Some(result) = self.result { + return Ok(result); + } + + if let Some(error) = self.error { + return Err(error); + } + + panic!("Malformed response"); + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Request { + pub jsonrpc: String, + pub id: u64, + pub method: String, + pub params: T, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RequestWithEmptyParams { + pub jsonrpc: String, + pub id: u64, + pub method: String, +} diff --git a/crates/yttrium/src/lib.rs b/crates/yttrium/src/lib.rs new file mode 100644 index 0000000..50e0b9f --- /dev/null +++ b/crates/yttrium/src/lib.rs @@ -0,0 +1,13 @@ +pub mod account_client; +pub mod bundler; +pub mod chain; +pub mod config; +pub mod eip7702; +pub mod entry_point; +pub mod error; +pub mod jsonrpc; +pub mod sign_service; +pub mod signer; +pub mod smart_accounts; +pub mod transaction; +pub mod user_operation; diff --git a/crates/yttrium/src/sign_service.rs b/crates/yttrium/src/sign_service.rs new file mode 100644 index 0000000..937a07b --- /dev/null +++ b/crates/yttrium/src/sign_service.rs @@ -0,0 +1,77 @@ +use crate::error::YttriumError; +use alloy::{ + primitives::Address, + signers::local::{coins_bip39::English, MnemonicBuilder}, +}; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub type SignFN = + dyn Fn(String) -> Result + Send + 'static; + +pub type BoxSignFN = Box; + +pub struct SignService { + sign_fn: Arc>, + owner: Address, +} + +impl SignService { + pub fn new(sign_fn: BoxSignFN, owner: Address) -> Self { + SignService { sign_fn: Arc::new(Mutex::new(sign_fn)), owner } + } + + pub fn owner(&self) -> Address { + self.owner + } + + pub fn sign(&self, message: String) -> Result { + let sign_fn = self.sign_fn.clone(); + let sign_fn = sign_fn + .try_lock() + .map_err(|e| YttriumError { message: e.to_string() })?; + (sign_fn)(message) + } +} + +impl SignService { + pub fn mock() -> Self { + SignService { + sign_fn: Arc::new(Mutex::new(Box::new(|_| Ok("".to_string())))), + owner: Address::ZERO, + } + } + + pub async fn mock_with_mnemonic(mnemonic: String) -> Self { + let phrase = mnemonic.clone(); + let index: u32 = 0; + + let wallet = MnemonicBuilder::::default() + .phrase(phrase.to_string()) + .index(index) + .unwrap() + .build() + .unwrap(); + + let alloy_signer = + alloy::signers::local::PrivateKeySigner::from(wallet.clone()); + + let signer = crate::signer::Signer::from(alloy_signer.clone()); + + let owner = alloy_signer.address(); + + SignService { + sign_fn: Arc::new(Mutex::new(Box::new(move |msg: String| { + let signature = signer.sign_message_string_sync(msg).unwrap(); + + Ok(signature) + }))), + owner, + } + } +} + +pub fn address_from_string(address: &str) -> eyre::Result
{ + let address = address.parse::
()?; + Ok(address) +} diff --git a/crates/yttrium/src/signer.rs b/crates/yttrium/src/signer.rs new file mode 100644 index 0000000..0c7d83c --- /dev/null +++ b/crates/yttrium/src/signer.rs @@ -0,0 +1,196 @@ +use crate::user_operation::UserOperationV07; +use crate::{error::YttriumError, sign_service::SignService}; +use alloy::{ + primitives::Address, + signers::{ + local::{coins_bip39::English, MnemonicBuilder, PrivateKeySigner}, + SignerSync, + }, +}; +use eyre; +use std::sync::Arc; +use tokio::sync::{Mutex, MutexGuard}; + +pub struct Signer { + sign_service: Arc>, +} + +impl Signer { + pub fn new(sign_service: Arc>) -> Self { + Self { sign_service } + } + + pub fn signer_from_phrase( + phrase: &str, + _chain_id: u64, + ) -> eyre::Result { + let index = 0; + let local_signer = MnemonicBuilder::::default() + .phrase(phrase) + .index(index)? + .build()?; + let signer = Self::from(local_signer); + Ok(signer) + } + + pub async fn owner(&self) -> Address { + let sign_service_clone = Arc::clone(&self.sign_service); + let sign_service = sign_service_clone.lock().await; + let owner = sign_service.owner(); + owner + } + + pub async fn sign_message(&self, message: String) -> eyre::Result> { + let sign_service_clone = Arc::clone(&self.sign_service); + let sign_service = sign_service_clone.lock().await; + let result = Self::sign_message_impl(message, &sign_service)?; + Ok(result) + } + + pub fn sign_user_operation_sync_v07( + &self, + uo: &crate::user_operation::UserOperationV07, + ep: &Address, + chain_id: u64, + ) -> eyre::Result { + let sign_service_clone = Arc::clone(&self.sign_service); + let sign_service = sign_service_clone.try_lock()?; + let result = Self::sign_user_operation_impl_v07( + uo, + ep, + chain_id, + &sign_service, + )?; + Ok(result) + } + + pub fn sign_message_sync(&self, message: String) -> eyre::Result> { + let sign_service_clone = Arc::clone(&self.sign_service); + let sign_service = sign_service_clone.try_lock()?; + let result = Self::sign_message_impl(message, &sign_service)?; + Ok(result) + } + + pub fn sign_message_string_sync( + &self, + message: String, + ) -> eyre::Result { + let sign_service_clone = Arc::clone(&self.sign_service); + let sign_service = sign_service_clone.try_lock()?; + let result = Self::sign_message_as_string_impl(message, &sign_service)?; + Ok(result) + } + + fn sign_user_operation_impl_v07( + uo: &UserOperationV07, + ep: &Address, + chain_id: u64, + sign_service: &MutexGuard, + ) -> eyre::Result { + let hash = uo.hash(&ep, chain_id)?; + let message_bytes = hash.0.to_vec(); + println!("message_bytes: {:?}", message_bytes.clone()); + + let message = hex::encode(message_bytes); + println!("message: {:?}", message.clone()); + + let signature = sign_service.sign(message)?; + let sig_vec: Vec<_> = signature.into(); + let mut user_operation = uo.clone(); + user_operation.signature = sig_vec.into(); + Ok(user_operation) + } + + fn sign_message_impl( + message: String, + sign_service: &MutexGuard, + ) -> eyre::Result> { + let signature = + Self::sign_message_as_string_impl(message, sign_service)?; + let signature_bytes: Vec = signature.into(); + Ok(signature_bytes) + } + + fn sign_message_as_string_impl( + message: String, + sign_service: &MutexGuard, + ) -> eyre::Result { + let signature = sign_service.sign(message)?; + Ok(signature) + } +} + +impl From for Signer +where + S: alloy::signers::SignerSync + + Send + + Sync + + 'static, + S: alloy::signers::Signer + + Send + + Sync + + 'static, +{ + fn from(signer: S) -> Self { + let owner = signer.address(); + let sign_fn = Box::new(move |msg: String| { + signer + .sign_message_sync(msg.as_bytes()) + .map(|sig| sig.as_bytes().to_vec()) + .map(hex::encode) + .map_err(YttriumError::from) + }); + let sign_service_s = SignService::new(sign_fn, owner); + let sign_service = Arc::new(Mutex::new(sign_service_s)); + let signer = Signer::new(sign_service); + signer + } +} + +pub fn sign_user_operation_v07_with_ecdsa( + uo: &UserOperationV07, + ep: &Address, + chain_id: u64, + signer: PrivateKeySigner, +) -> eyre::Result { + let hash = uo.hash(&ep, chain_id)?; + + println!("hash: {:?}", hash.clone()); + + let message = hash.0; + + println!("message: {:?}", message.clone()); + + let message_bytes = message.to_vec(); + + println!("message_bytes: {:?}", message_bytes.clone()); + + let signature = signer.sign_message_sync(&message_bytes)?; + println!("signature: {:?}", signature); + let sig_vec: Vec = signature.into(); + println!("hex::encode(sig_vec): {:?}", hex::encode(sig_vec.clone())); + + let mut user_operation = uo.clone(); + user_operation.signature = sig_vec.into(); + Ok(user_operation) +} + +#[cfg(test)] +mod tests { + use super::*; + use eyre::ensure; + + pub const ETHERIEUM_MAINNET_CHAIN_ID: u64 = 1; + pub const MNEMONIC_PHRASE: &str = + "test test test test test test test test test test test junk"; + pub const CHAIN_ID: u64 = ETHERIEUM_MAINNET_CHAIN_ID; + + #[tokio::test] + async fn test_sign_message_sync() -> eyre::Result<()> { + let signer = Signer::signer_from_phrase(MNEMONIC_PHRASE, CHAIN_ID)?; + let message = "Hello, world!".to_string(); + let signature = signer.sign_message_sync(message)?; + ensure!(signature.len() > 0, "Signature is empty"); + Ok(()) + } +} diff --git a/crates/yttrium/src/smart_accounts.rs b/crates/yttrium/src/smart_accounts.rs new file mode 100644 index 0000000..ffcd84a --- /dev/null +++ b/crates/yttrium/src/smart_accounts.rs @@ -0,0 +1,2 @@ +pub mod nonce; +pub mod simple_account; diff --git a/crates/yttrium/src/smart_accounts/nonce.rs b/crates/yttrium/src/smart_accounts/nonce.rs new file mode 100644 index 0000000..f32ee1d --- /dev/null +++ b/crates/yttrium/src/smart_accounts/nonce.rs @@ -0,0 +1,27 @@ +use alloy::primitives::U256; + +pub async fn get_nonce( + provider: &P, + address: &crate::smart_accounts::simple_account::SimpleAccountAddress, + entry_point_address: &crate::entry_point::EntryPointAddress, +) -> eyre::Result +where + T: alloy::contract::private::Transport + ::core::clone::Clone, + P: alloy::contract::private::Provider, + N: alloy::contract::private::Network, +{ + let entry_point_instance = crate::entry_point::EntryPoint::new( + entry_point_address.to_address(), + provider, + ); + let key = U256::ZERO; + + let get_nonce_call = + entry_point_instance.getNonce(address.to_address(), key).call().await?; + + let nonce_uint = get_nonce_call.nonce; + + let nonce: u64 = nonce_uint.to::(); + + Ok(nonce) +} diff --git a/crates/yttrium/src/smart_accounts/simple_account.rs b/crates/yttrium/src/smart_accounts/simple_account.rs new file mode 100644 index 0000000..903c7c0 --- /dev/null +++ b/crates/yttrium/src/smart_accounts/simple_account.rs @@ -0,0 +1,72 @@ +use alloy::{sol, sol_types::SolCall}; + +sol!( + #[allow(missing_docs)] + function execute(address dest, uint256 value, bytes calldata func); +); + +pub mod create_account; +pub mod factory; + +pub struct SimpleAccountExecute(executeCall); + +impl SimpleAccountExecute { + pub fn new( + address: alloy::primitives::Address, + value: alloy::primitives::U256, + func: alloy::primitives::Bytes, + ) -> Self { + Self(executeCall { dest: address, value: value, func: func }) + } + + pub fn encode(&self) -> Vec { + executeCall::abi_encode(&self.0) + } +} + +sol!( + #[allow(missing_docs)] + #[sol(rpc)] + SimpleAccount, + "src/contracts/artifacts/contracts/samples/SimpleAccount.sol/SimpleAccount.json" +); + +pub const DUMMY_SIGNATURE_HEX: &str = "0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SimpleAccountAddress(alloy::primitives::Address); + +impl SimpleAccountAddress { + pub fn new(address: alloy::primitives::Address) -> Self { + Self(address) + } + + pub fn to_address(&self) -> alloy::primitives::Address { + self.0 + } +} + +impl Into for SimpleAccountAddress { + fn into(self) -> alloy::primitives::Address { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OwnerAddress(alloy::primitives::Address); + +impl OwnerAddress { + pub fn new(address: alloy::primitives::Address) -> Self { + Self(address) + } + + pub fn to_address(&self) -> alloy::primitives::Address { + self.0 + } +} + +impl Into for OwnerAddress { + fn into(self) -> alloy::primitives::Address { + self.0 + } +} diff --git a/crates/yttrium/src/smart_accounts/simple_account/create_account.rs b/crates/yttrium/src/smart_accounts/simple_account/create_account.rs new file mode 100644 index 0000000..9525c7e --- /dev/null +++ b/crates/yttrium/src/smart_accounts/simple_account/create_account.rs @@ -0,0 +1,28 @@ +use alloy::{primitives::U256, sol, sol_types::SolCall}; + +sol!( + #[allow(missing_docs)] + #[derive(Debug, PartialEq, Eq)] + type SimpleAccount is address; + function createAccount(address owner,uint256 salt) public returns (SimpleAccount ret); +); + +pub struct SimpleAccountCreate(createAccountCall); + +impl SimpleAccountCreate { + pub fn new( + owner: alloy::primitives::Address, + salt: alloy::primitives::U256, + ) -> Self { + Self(createAccountCall { owner: owner, salt: salt }) + } + + pub fn new_u64(owner: alloy::primitives::Address, salt: u64) -> Self { + let salt = U256::from(salt); + Self(createAccountCall { owner: owner, salt: salt }) + } + + pub fn encode(&self) -> Vec { + createAccountCall::abi_encode(&self.0) + } +} diff --git a/crates/yttrium/src/smart_accounts/simple_account/factory.rs b/crates/yttrium/src/smart_accounts/simple_account/factory.rs new file mode 100644 index 0000000..66db655 --- /dev/null +++ b/crates/yttrium/src/smart_accounts/simple_account/factory.rs @@ -0,0 +1,86 @@ +use crate::chain::ChainId; +use crate::entry_point::EntryPointVersion; +use alloy::{primitives::Address, sol}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FactoryAddress(alloy::primitives::Address); + +impl FactoryAddress { + pub const V06: &'static str = ""; // TODO + + pub const V07: &'static str = "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985"; + + pub const SEPOLIA_V06: &'static str = + "0x9406Cc6185a346906296840746125a0E44976454"; + + pub const SEPOLIA_V07: &'static str = + "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985"; + + pub const LOCAL_V06: &'static str = + "0x9406Cc6185a346906296840746125a0E44976454"; + + pub const LOCAL_V07: &'static str = + "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985"; + + pub fn new(address: alloy::primitives::Address) -> Self { + Self(address) + } + + pub fn v06() -> Self { + Self(Self::V06.parse().unwrap()) + } + + pub fn v07() -> Self { + Self(Self::V07.parse().unwrap()) + } + + pub fn local_v06() -> Self { + Self(Self::LOCAL_V06.parse().unwrap()) + } + + pub fn local_v07() -> Self { + Self(Self::LOCAL_V07.parse().unwrap()) + } + + pub fn to_address(self) -> alloy::primitives::Address { + self.into() + } +} + +impl From for Address { + fn from(factory_address: FactoryAddress) -> Self { + factory_address.0 + } +} + +pub fn factory_address_from_chain_id(chain_id: ChainId) -> FactoryAddress { + factory_address_from_chain_id_and_version(chain_id, EntryPointVersion::V07) +} + +pub fn factory_address_from_chain_id_and_version( + chain_id: ChainId, + entry_point_version: EntryPointVersion, +) -> FactoryAddress { + match chain_id { + ChainId::ETHEREUM_MAINNET => match entry_point_version { + EntryPointVersion::V06 => FactoryAddress::v06(), + EntryPointVersion::V07 => FactoryAddress::v07(), + }, + ChainId::ETHEREUM_SEPOLIA => match entry_point_version { + EntryPointVersion::V06 => FactoryAddress::v06(), + EntryPointVersion::V07 => FactoryAddress::v07(), + }, + ChainId::LOCAL_FOUNDRY_ETHEREUM_SEPOLIA => match entry_point_version { + EntryPointVersion::V06 => FactoryAddress::local_v06(), + EntryPointVersion::V07 => FactoryAddress::local_v07(), + }, + _ => panic!("Unsupported chain ID"), + } +} + +sol!( + #[allow(missing_docs)] + #[sol(rpc)] + SimpleAccountFactory, + "src/contracts/artifacts/contracts/samples/SimpleAccountFactory.sol/SimpleAccountFactory.json" +); diff --git a/crates/yttrium/src/transaction.rs b/crates/yttrium/src/transaction.rs new file mode 100644 index 0000000..24fc246 --- /dev/null +++ b/crates/yttrium/src/transaction.rs @@ -0,0 +1,28 @@ +pub mod send; + +#[derive(Debug, Clone, PartialEq)] +pub struct Transaction { + pub to: String, + pub value: String, + pub data: String, +} + +impl std::fmt::Display for Transaction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Transaction(to: {}, value: {}, data: {})", + self.to, self.value, self.data + ) + } +} + +impl Transaction { + pub fn mock() -> Self { + Self { + to: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string(), + value: "0".to_string(), + data: "0x68656c6c6f".to_string(), + } + } +} diff --git a/crates/yttrium/src/transaction/send.rs b/crates/yttrium/src/transaction/send.rs new file mode 100644 index 0000000..fcdd8df --- /dev/null +++ b/crates/yttrium/src/transaction/send.rs @@ -0,0 +1,332 @@ +use crate::{ + sign_service::SignService, transaction::Transaction, + user_operation::UserOperationV07, +}; +use core::fmt; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct UserOperationEstimated(UserOperationV07); + +impl Into for UserOperationEstimated { + fn into(self) -> UserOperationV07 { + self.0 + } +} + +#[derive(Debug, Clone)] +pub struct SignedUserOperation(UserOperationV07); + +impl Into for SignedUserOperation { + fn into(self) -> UserOperationV07 { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct SentUserOperationHash(String); + +impl From for String { + fn from(user_operation_hash: SentUserOperationHash) -> Self { + user_operation_hash.0 + } +} + +impl fmt::Display for SentUserOperationHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +pub mod send_tests; + +pub async fn send_transaction( + sign_service: Arc>, + transaction: Transaction, +) -> eyre::Result { + let _ = sign_service.try_lock()?; + todo!("Calling send_transaction with transaction: {transaction:?}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + bundler::{ + client::BundlerClient, + config::BundlerConfig, + pimlico::{ + client::BundlerClient as PimlicoBundlerClient, + paymaster::client::PaymasterClient, + }, + }, + entry_point::get_sender_address::get_sender_address_v07, + signer::sign_user_operation_v07_with_ecdsa, + smart_accounts::{ + nonce::get_nonce, + simple_account::{ + create_account::SimpleAccountCreate, factory::FactoryAddress, + SimpleAccountAddress, SimpleAccountExecute, + }, + }, + user_operation::UserOperationV07, + }; + use alloy::{ + network::EthereumWallet, + primitives::{Address, Bytes, U256}, + providers::ProviderBuilder, + signers::local::{ + coins_bip39::English, MnemonicBuilder, PrivateKeySigner, + }, + }; + use std::str::FromStr; + + const MNEMONIC_PHRASE: &str = + "test test test test test test test test test test test junk"; + + async fn send_transaction( + sign_service: Arc>, + transaction: Transaction, + ) -> eyre::Result { + let sign_service = sign_service.clone(); + let sign_service = sign_service.lock().await; + + let config = crate::config::Config::pimlico(); + + let bundler_base_url = config.endpoints.bundler.base_url; + + let bundler_client = + BundlerClient::new(BundlerConfig::new(bundler_base_url.clone())); + + let pimlico_client: PimlicoBundlerClient = PimlicoBundlerClient::new( + BundlerConfig::new(bundler_base_url.clone()), + ); + + let chain = crate::chain::Chain::ETHEREUM_SEPOLIA_V07; + let entry_point_config = chain.entry_point_config(); + + let chain_id = chain.id.eip155_chain_id()?; + + let entry_point_address = entry_point_config.address(); + + let rpc_url = config.endpoints.rpc.base_url; + + // Create a provider + + let (ethereum_wallet, alloy_signer) = { + let phrase = MNEMONIC_PHRASE.to_string(); + let index: u32 = 0; + + let local_signer = { + let local_signer_result = MnemonicBuilder::::default() + .phrase(phrase.clone()) + .index(index)? + .build(); + let local_signer = match local_signer_result { + Ok(signer) => signer, + Err(e) => { + println!("Error creating signer: {}", e); + let local_signer: PrivateKeySigner = phrase.parse()?; + local_signer + } + }; + local_signer + }; + let ethereum_wallet = EthereumWallet::from(local_signer.clone()); + (ethereum_wallet, local_signer) + }; + + let rpc_url: reqwest::Url = rpc_url.parse()?; + let provider = ProviderBuilder::new() + .with_recommended_fillers() + .wallet(ethereum_wallet.clone()) + .on_http(rpc_url); + + let simple_account_factory_address_primitives: Address = + "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985".parse()?; + let simple_account_factory_address = + FactoryAddress::new(simple_account_factory_address_primitives); + + let owner = ethereum_wallet.clone().default_signer(); + let owner_address = owner.address(); + let sign_service_owner = sign_service.owner(); + assert_eq!( + owner_address, sign_service_owner, + "Owner addresses don't match, should be {:?}, is {:?}", + owner_address, sign_service_owner + ); + + let factory_data_call = SimpleAccountCreate::new_u64(owner_address, 0); + + let factory_data_value = factory_data_call.encode(); + + let factory_data_value_hex = hex::encode(factory_data_value.clone()); + + let factory_data_value_hex_prefixed = + format!("0x{}", factory_data_value_hex); + + println!( + "Generated factory_data: {:?}", + factory_data_value_hex_prefixed.clone() + ); + + // 5. Calculate the sender address + + let sender_address = get_sender_address_v07( + &provider, + simple_account_factory_address.clone().into(), + factory_data_value.clone().into(), + entry_point_address.clone(), + ) + .await?; + + println!("Calculated sender address: {:?}", sender_address); + + let to: Address = transaction.to.parse()?; + let value: alloy::primitives::Uint<256, 4> = + transaction.value.parse()?; + let data_hex = transaction.data.strip_prefix("0x").unwrap(); + let data: Bytes = Bytes::from_str(data_hex)?; + + let call_data = SimpleAccountExecute::new(to, value, data); + let call_data_encoded = call_data.encode(); + let call_data_value_hex = hex::encode(call_data_encoded.clone()); + let call_data_value_hex_prefixed = format!("0x{}", call_data_value_hex); + + println!("Generated callData: {:?}", call_data_value_hex_prefixed); + + // Fill out remaining UserOperation values + + let gas_price = + pimlico_client.estimate_user_operation_gas_price().await?; + + assert!(gas_price.fast.max_fee_per_gas > U256::from(1)); + + println!("Gas price: {:?}", gas_price); + + let nonce = get_nonce( + &provider, + &SimpleAccountAddress::new(sender_address), + &entry_point_address, + ) + .await?; + + let user_op = UserOperationV07 { + sender: sender_address, + nonce: U256::from(nonce), + factory: None, + factory_data: None, + call_data: Bytes::from_str(&call_data_value_hex)?, + call_gas_limit: U256::from(0), + verification_gas_limit: U256::from(0), + pre_verification_gas: U256::from(0), + max_fee_per_gas: gas_price.fast.max_fee_per_gas, + max_priority_fee_per_gas: gas_price.fast.max_priority_fee_per_gas, + paymaster: None, + paymaster_verification_gas_limit: None, + paymaster_post_op_gas_limit: None, + paymaster_data: None, + signature: Bytes::from_str( + crate::smart_accounts::simple_account::DUMMY_SIGNATURE_HEX + .strip_prefix("0x") + .unwrap(), + )?, + }; + + let paymaster_client = + PaymasterClient::new(BundlerConfig::new(bundler_base_url.clone())); + + let sponsor_user_op_result = paymaster_client + .sponsor_user_operation_v07( + &user_op.clone().into(), + &entry_point_address, + None, + ) + .await?; + + println!("sponsor_user_op_result: {:?}", sponsor_user_op_result); + + let sponsored_user_op = { + let s = sponsor_user_op_result.clone(); + let mut op = user_op.clone(); + + op.call_gas_limit = s.call_gas_limit; + op.verification_gas_limit = s.verification_gas_limit; + op.pre_verification_gas = s.pre_verification_gas; + op.paymaster = Some(s.paymaster); + op.paymaster_verification_gas_limit = + Some(s.paymaster_verification_gas_limit); + op.paymaster_post_op_gas_limit = + Some(s.paymaster_post_op_gas_limit); + op.paymaster_data = Some(s.paymaster_data); + + op + }; + + println!("Received paymaster sponsor result: {:?}", sponsored_user_op); + + // Sign the UserOperation + + let signed_user_op = sign_user_operation_v07_with_ecdsa( + &sponsored_user_op.clone(), + &entry_point_address.to_address(), + chain_id, + alloy_signer, + )?; + + println!("Generated signature: {:?}", signed_user_op.signature); + + let user_operation_hash = bundler_client + .send_user_operation( + entry_point_address.to_address(), + signed_user_op.clone(), + ) + .await?; + + println!("Received User Operation hash: {:?}", user_operation_hash); + + // let receipt = bundler_client + // .get_user_operation_receipt(user_operation_hash.clone()) + // .await?; + + // println!("Received User Operation receipt: {:?}", receipt); + + // println!("Querying for receipts..."); + + // let receipt = bundler_client + // .wait_for_user_operation_receipt(user_operation_hash.clone()) + // .await?; + + // let tx_hash = receipt.receipt.transaction_hash; + // println!( + // "UserOperation included: https://sepolia.etherscan.io/tx/{}", + // tx_hash + // ); + + Ok(user_operation_hash) + } + + #[tokio::test] + #[ignore = "TODO: rewrite against local infrastructure"] + async fn test_send_transaction() -> eyre::Result<()> { + let transaction = Transaction::mock(); + + let mnemonic = MNEMONIC_PHRASE.to_string(); + + let sign_service = + crate::sign_service::SignService::mock_with_mnemonic( + mnemonic.clone(), + ) + .await; + + let sign_service_arc = Arc::new(Mutex::new(sign_service)); + + let transaction_hash = + send_transaction(sign_service_arc, transaction).await?; + + println!("Transaction sent: {}", transaction_hash); + + Ok(()) + } +} diff --git a/crates/yttrium/src/transaction/send/send_tests.rs b/crates/yttrium/src/transaction/send/send_tests.rs new file mode 100644 index 0000000..23edfa9 --- /dev/null +++ b/crates/yttrium/src/transaction/send/send_tests.rs @@ -0,0 +1 @@ +pub mod test_send_pimlico_v07; diff --git a/crates/yttrium/src/transaction/send/send_tests/test_send_pimlico_v07.rs b/crates/yttrium/src/transaction/send/send_tests/test_send_pimlico_v07.rs new file mode 100644 index 0000000..68c798c --- /dev/null +++ b/crates/yttrium/src/transaction/send/send_tests/test_send_pimlico_v07.rs @@ -0,0 +1,261 @@ +#[cfg(test)] +mod tests { + use crate::{ + bundler::{ + client::BundlerClient, config::BundlerConfig, + pimlico::client::BundlerClient as PimlicoBundlerClient, + pimlico::paymaster::client::PaymasterClient, + }, + entry_point::get_sender_address::get_sender_address_v07, + smart_accounts::simple_account::{ + create_account::SimpleAccountCreate, SimpleAccountExecute, + }, + user_operation::UserOperationV07, + }; + use alloy::{ + network::EthereumWallet, + primitives::{Address, Bytes, U256}, + providers::ProviderBuilder, + signers::local::{coins_bip39::English, MnemonicBuilder}, + }; + use std::str::FromStr; + + const SIMPLE_ACCOUNT_FACTORY_ADDRESS: &str = + "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985"; + + const MNEMONIC_PHRASE: &str = + "test test test test test test test test test test test junk"; + + #[tokio::test] + #[ignore = "TODO: rewrite against local infrastructure"] + async fn test_send_transaction_pimlico_v07() -> eyre::Result<()> { + let expected_factory_data_hex = "0x5fbfb9cf000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000000000"; + + let expected_call_data_hex = "0xb61d27f6000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa9604500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000"; + + let config = crate::config::Config::pimlico(); + + // 3. Create the clients + + let bundler_base_url = config.endpoints.bundler.base_url; + + let bundler_client = + BundlerClient::new(BundlerConfig::new(bundler_base_url.clone())); + + let pimlico_client: PimlicoBundlerClient = PimlicoBundlerClient::new( + BundlerConfig::new(bundler_base_url.clone()), + ); + + let phrase = MNEMONIC_PHRASE; + let index: u32 = 0; + + let chain = crate::chain::Chain::ETHEREUM_SEPOLIA_V07; + let entry_point_config = chain.entry_point_config(); + + let chain_id = chain.id.eip155_chain_id()?; + + let wallet = MnemonicBuilder::::default() + .phrase(phrase) + .index(index)? + .build()?; + + let alloy_signer = + alloy::signers::local::PrivateKeySigner::from(wallet.clone()); + + let ethereum_wallet = EthereumWallet::from(wallet.clone()); + + let rpc_url = config.endpoints.rpc.base_url; + + // Create a provider with the wallet. + let rpc_url: reqwest::Url = rpc_url.parse()?; + let provider = ProviderBuilder::new() + .with_recommended_fillers() + .wallet(ethereum_wallet.clone()) + .on_http(rpc_url); + + // 4.Generate the factory and factoryData + + let _simple_account_factory_address: Address = + SIMPLE_ACCOUNT_FACTORY_ADDRESS.parse()?; + let simple_account_factory_address = + crate::smart_accounts::simple_account::factory::FactoryAddress::new( + _simple_account_factory_address, + ); + + let entry_point_address = entry_point_config.address(); + + let owner = ethereum_wallet.clone().default_signer(); + let owner_address = owner.address(); + + let factory_data_call = SimpleAccountCreate::new_u64(owner_address, 0); + + let factory_data_value = factory_data_call.encode(); + + let factory_data_value_hex = hex::encode(factory_data_value.clone()); + + let factory_data_value_hex_prefixed = + format!("0x{}", factory_data_value_hex); + + assert_eq!( + factory_data_value_hex_prefixed.clone(), + expected_factory_data_hex, + "Factory data value hex does not match expected factory data hex" + ); + + println!( + "Generated factory_data: {:?}", + factory_data_value_hex_prefixed.clone() + ); + + // 5. Calculate the sender address + + let sender_address = get_sender_address_v07( + &provider, + simple_account_factory_address.clone().into(), + factory_data_value.clone().into(), + entry_point_address.clone(), + ) + .await?; + + println!("Calculated sender address: {:?}", sender_address); + + // 6. Generate the callData + + let to: Address = + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".parse()?; // vitalik + let value = alloy::primitives::Uint::<256, 4>::from(0); + + let data: Bytes = + Bytes::from_str("0x68656c6c6f".strip_prefix("0x").unwrap())?; // "hello" encoded to utf-8 bytes + + let call_data = SimpleAccountExecute::new(to, value, data); + let call_data_encoded = call_data.encode(); + let call_data_value_hex = hex::encode(call_data_encoded.clone()); + let call_data_value_hex_prefixed = format!("0x{}", call_data_value_hex); + + println!("Generated callData: {:?}", call_data_value_hex_prefixed); + + assert_eq!( + call_data_value_hex_prefixed, expected_call_data_hex, + "Call data value hex does not match expected call data hex" + ); + + // 7. Fill out remaining UserOperation values + + let gas_price = + pimlico_client.estimate_user_operation_gas_price().await?; + + assert!(gas_price.fast.max_fee_per_gas > U256::from(1)); + + println!("Gas price: {:?}", gas_price); + + let nonce = crate::smart_accounts::nonce::get_nonce( + &provider, + &crate::smart_accounts::simple_account::SimpleAccountAddress::new( + sender_address, + ), + &entry_point_address, + ) + .await?; + + let user_op = { + let user_op = UserOperationV07 { + sender: sender_address, + nonce: U256::from(nonce), + factory: None, + factory_data: None, + call_data: Bytes::from_str(&call_data_value_hex).unwrap(), + call_gas_limit: U256::from(0), + verification_gas_limit: U256::from(0), + pre_verification_gas: U256::from(0), + max_fee_per_gas: gas_price.fast.max_fee_per_gas, + max_priority_fee_per_gas: gas_price + .fast + .max_priority_fee_per_gas, + paymaster: None, + paymaster_verification_gas_limit: None, + paymaster_post_op_gas_limit: None, + paymaster_data: None, + signature: Bytes::from_str( + crate::smart_accounts::simple_account::DUMMY_SIGNATURE_HEX + .strip_prefix("0x") + .unwrap(), + )?, + }; + + user_op + }; + + // 8. Request Pimlico verifying paymaster sponsorship + + let paymaster_client = + PaymasterClient::new(BundlerConfig::new(bundler_base_url.clone())); + + let sponsor_user_op_result = paymaster_client + .sponsor_user_operation_v07( + &user_op.clone().into(), + &entry_point_address, + None, + ) + .await?; + + println!("sponsor_user_op_result: {:?}", sponsor_user_op_result); + + let sponsored_user_op = { + let s = sponsor_user_op_result.clone(); + let mut op = user_op.clone(); + + op.call_gas_limit = s.call_gas_limit; + op.verification_gas_limit = s.verification_gas_limit; + op.pre_verification_gas = s.pre_verification_gas; + op.paymaster = Some(s.paymaster); + op.paymaster_verification_gas_limit = + Some(s.paymaster_verification_gas_limit); + op.paymaster_post_op_gas_limit = + Some(s.paymaster_post_op_gas_limit); + op.paymaster_data = Some(s.paymaster_data); + + op + }; + + println!("Received paymaster sponsor result: {:?}", sponsored_user_op); + + // 9. Sign the UserOperation + + let signed_user_op = crate::signer::sign_user_operation_v07_with_ecdsa( + &sponsored_user_op.clone(), + &entry_point_address.to_address(), + chain_id, + alloy_signer, + )?; + + println!("Generated signature: {:?}", signed_user_op.signature); + + // 10. Submit the UserOperation to be bundled + + let user_operation_hash = bundler_client + .send_user_operation( + entry_point_address.to_address(), + signed_user_op.clone(), + ) + .await?; + + println!("Received User Operation hash: {:?}", user_operation_hash); + + // let's also wait for the userOperation to be included, by continually querying for the receipts + + // println!("Querying for receipts..."); + + // let receipt = bundler_client + // .wait_for_user_operation_receipt(user_operation_hash) + // .await?; + + // let tx_hash = receipt.receipt.transaction_hash; + // println!( + // "UserOperation included: https://sepolia.etherscan.io/tx/{}", + // tx_hash + // ); + + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation.rs b/crates/yttrium/src/user_operation.rs new file mode 100644 index 0000000..9d28229 --- /dev/null +++ b/crates/yttrium/src/user_operation.rs @@ -0,0 +1,109 @@ +use alloy::primitives::{Address, Bytes, U256}; +use serde::{Deserialize, Serialize}; + +pub mod hash; +pub mod user_operation_hash; + +use crate::user_operation::{ + hash::get_user_operation_hash_v07, user_operation_hash::UserOperationHash, +}; + +pub fn as_checksum_addr(val: &Address, s: S) -> Result +where + S: serde::Serializer, +{ + let address_checksum: String = val.to_checksum(None); + serde::Serialize::serialize(&address_checksum, s) +} + +#[derive( + Default, + Clone, + Debug, + Ord, + PartialOrd, + PartialEq, + Eq, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct UserOperationV07 { + #[serde(serialize_with = "as_checksum_addr")] + pub sender: Address, + pub nonce: U256, + pub factory: Option
, + pub factory_data: Option, + pub call_data: Bytes, + pub call_gas_limit: U256, + pub verification_gas_limit: U256, + pub pre_verification_gas: U256, + pub max_fee_per_gas: U256, + pub max_priority_fee_per_gas: U256, + pub paymaster: Option
, + pub paymaster_verification_gas_limit: Option, + pub paymaster_post_op_gas_limit: Option, + pub paymaster_data: Option, + pub signature: Bytes, +} + +impl UserOperationV07 { + /// Calculates the hash of the user operation + pub fn hash( + &self, + entry_point: &Address, + chain_id: u64, + ) -> eyre::Result { + get_user_operation_hash_v07(self, entry_point, chain_id) + } +} + +impl UserOperationV07 { + pub fn mock() -> Self { + use std::str::FromStr; + + let sender = "0xa3aBDC7f6334CD3EE466A115f30522377787c024" + .parse::
() + .unwrap(); + let nonce = U256::from(16); + let factory: Option
= None; + let factory_data: Option = None; + let call_date = Bytes::from_str("b61d27f6000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa9604500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000").unwrap(); + + let max_fee_per_gas = U256::from(17578054897u64); + let max_priority_fee_per_gas = U256::from(1138018869u64); + + let signature = Bytes::from_str("a15569dd8f8324dbeabf8073fdec36d4b754f53ce5901e283c6de79af177dc94557fa3c9922cd7af2a96ca94402d35c39f266925ee6407aeb32b31d76978d4ba1c").unwrap(); + let call_gas_limit = U256::from(80000); + let verification_gas_limit = U256::from(68389); + let pre_verification_gas = U256::from(55721); + let paymaster = Some( + "0x0000000000000039cd5e8aE05257CE51C473ddd1" + .parse::
() + .unwrap(), + ); + let paymaster_verification_gas_limit = Some(U256::from(27776)); + let paymaster_post_op_gas_limit = Some(U256::from(1)); + let paymaster_data = Some(Bytes::from_str("00000066cc6b8b000000000000bce787423a07dde9c43cdf50ff33bf35b18babd336cc9739fd9f6dca86e200934505c311454b60c3aa1d206e6bb893f3489e77ace4c58f30d47cebd368a1422a1c").unwrap()); + + let user_operation = UserOperationV07 { + sender: sender, + nonce: nonce, + factory: factory, + factory_data: factory_data, + call_data: call_date, + call_gas_limit: call_gas_limit, + verification_gas_limit: verification_gas_limit, + pre_verification_gas: pre_verification_gas, + max_fee_per_gas: max_fee_per_gas, + max_priority_fee_per_gas: max_priority_fee_per_gas, + paymaster: paymaster, + paymaster_verification_gas_limit: paymaster_verification_gas_limit, + paymaster_post_op_gas_limit: paymaster_post_op_gas_limit, + paymaster_data: paymaster_data, + signature: signature, + }; + + user_operation + } +} diff --git a/crates/yttrium/src/user_operation/hash.rs b/crates/yttrium/src/user_operation/hash.rs new file mode 100644 index 0000000..bc2e153 --- /dev/null +++ b/crates/yttrium/src/user_operation/hash.rs @@ -0,0 +1,64 @@ +use crate::user_operation::{ + user_operation_hash::UserOperationHash, UserOperationV07, +}; +use alloy::{ + primitives::{keccak256, Address, Bytes, B256, U256}, + sol_types::SolValue, +}; + +pub mod pack_v07; + +pub fn get_user_operation_hash_v07( + user_operation: &UserOperationV07, + entry_point: &Address, + chain_id: u64, +) -> eyre::Result { + let packed_user_operation = { + let packed = pack_v07::pack_user_operation_v07(user_operation)?; + println!("packed: {:?}", packed); + keccak256(packed) + }; + println!("packed_user_operation: {:?}", packed_user_operation); + + let chain_id = U256::from(chain_id); + + let values = (packed_user_operation, entry_point, chain_id); + let abi_encoded = values.abi_encode(); + let abi_encoded_packed = values.abi_encode_packed(); + println!("abi_encoded: {:?}", abi_encoded.clone()); + println!("abi_encoded_packed: {:?}", abi_encoded_packed.clone()); + assert_eq!(values.sol_name(), "(bytes32,address,uint256)"); + + let encoded: Bytes = abi_encoded.into(); + let hash_bytes = keccak256(encoded); + let hash = B256::from_slice(hash_bytes.as_slice()); + let user_op_hash = UserOperationHash(hash); + Ok(user_op_hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_user_operation_hash_v07() -> eyre::Result<()> { + let expected_hash = + "0xa1ea19d934f05fc2d725f2be8452ad7e2f29d9747674045ea366a320b782411d"; + let user_operation = UserOperationV07::mock(); + let entry_point = + "0x0000000071727De22E5E9d8BAf0edAc6f37da032".parse::
()?; + let chain_id = 11155111; + let hash = get_user_operation_hash_v07( + &user_operation, + &entry_point, + chain_id, + )?; + println!("hash: {:?}", hash); + eyre::ensure!( + format!("{}", hash.0) == expected_hash, + "hash should be {}", + expected_hash + ); + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/hash/pack_v07.rs b/crates/yttrium/src/user_operation/hash/pack_v07.rs new file mode 100644 index 0000000..7d9d79b --- /dev/null +++ b/crates/yttrium/src/user_operation/hash/pack_v07.rs @@ -0,0 +1,100 @@ +use crate::user_operation::UserOperationV07; +use alloy::primitives::Address; + +pub mod combine; +pub mod hashed_call_data; +pub mod hashed_init_code; +pub mod hashed_paymaster_and_data; +pub mod max_priority_fee_per_gas_and_max_fee_per_gas; +pub mod verificaction_gas_limit_and_call_gas_limit; + +pub fn pack_user_operation_v07( + user_operation: &UserOperationV07, +) -> eyre::Result> { + println!( + "pack_user_operation_v07 user_operation: {:?}", + user_operation.clone() + ); + + let hashed_init_code = + hashed_init_code::get_hashed_init_code(&user_operation)?; + println!("hashed_init_code: {:?}", hashed_init_code); + + let hashed_call_data = + hashed_call_data::get_hashed_call_data(&user_operation)?; + println!("hashed_call_data: {:?}", hashed_call_data); + + let hashed_paymaster_and_data = + hashed_paymaster_and_data::get_hashed_paymaster_and_data( + &user_operation, + )?; + println!("hashed_paymaster_and_data: {:?}", hashed_paymaster_and_data); + + use alloy::sol_types::SolValue; + + let verificaction_gas_limit_and_call_gas_limit_item = + verificaction_gas_limit_and_call_gas_limit::get_verificaction_gas_limit_and_call_gas_limit(&user_operation)?; + println!( + "verificaction_gas_limit_and_call_gas_limit_item: {:?}", + verificaction_gas_limit_and_call_gas_limit_item + ); + + let max_priority_fee_per_gas_and_max_fee_per_gas_item = + max_priority_fee_per_gas_and_max_fee_per_gas::get_max_priority_fee_per_gas_and_max_fee_per_gas(&user_operation)?; + println!( + "max_priority_fee_per_gas_and_max_fee_per_gas_item: {:?}", + max_priority_fee_per_gas_and_max_fee_per_gas_item + ); + + let items: ( + Address, + alloy::primitives::Uint<256, 4>, + alloy::primitives::FixedBytes<32>, + alloy::primitives::FixedBytes<32>, + alloy::primitives::FixedBytes<32>, + alloy::primitives::Uint<256, 4>, + alloy::primitives::FixedBytes<32>, + alloy::primitives::FixedBytes<32>, + ) = ( + user_operation.sender as Address, + user_operation.nonce, + hashed_init_code, + hashed_call_data, + verificaction_gas_limit_and_call_gas_limit_item, + user_operation.pre_verification_gas, + max_priority_fee_per_gas_and_max_fee_per_gas_item, + hashed_paymaster_and_data, + ); + + let encoded = items.abi_encode(); + println!("encoded: {:?}", encoded.clone()); + + Ok(encoded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pack_user_operation_v07() -> eyre::Result<()> { + let expected_packed_user_operation_hex = "0x000000000000000000000000a3abdc7f6334cd3ee466a115f30522377787c0240000000000000000000000000000000000000000000000000000000000000010c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4700a8139e8d993db78f1d6b8682c7dcf9d4ef0b49b8bf883dc0a22a45b7aa7da2c00000000000000000000000000010b2500000000000000000000000000013880000000000000000000000000000000000000000000000000000000000000d9a900000000000000000000000043d4ca3500000000000000000000000417bbd4f1fc0dffa735c71f138a00eaaafa56834aebf784e3e446612810f3f325cfb8eda9"; + + let user_operation = UserOperationV07::mock(); + let packed_user_operation = pack_user_operation_v07(&user_operation)?; + println!("packed_user_operation: {:?}", packed_user_operation); + + let packed_user_operation_hex = + hex::encode(packed_user_operation.clone()); + println!("packed_user_operation_hex: {:?}", packed_user_operation_hex); + + eyre::ensure!( + format!("0x{}", packed_user_operation_hex) + == expected_packed_user_operation_hex, + "packed_user_operation_hex should be {:?}", + expected_packed_user_operation_hex + ); + + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/hash/pack_v07/combine.rs b/crates/yttrium/src/user_operation/hash/pack_v07/combine.rs new file mode 100644 index 0000000..bb4cd1f --- /dev/null +++ b/crates/yttrium/src/user_operation/hash/pack_v07/combine.rs @@ -0,0 +1,41 @@ +use alloy::primitives::{Uint, B256}; +use std::str::FromStr; + +pub fn combine_and_trim_first_16_bytes( + items: Vec>, +) -> eyre::Result { + let items_bytes_hex = items + .iter() + .map(|item| item.to_be_bytes_vec()[16..].to_vec()) + .map(hex::encode) + .collect::>(); + println!("items_bytes_hex: {:?}", items_bytes_hex); + + let combined = items_bytes_hex.join(""); + println!("combined: {:?}", combined); + + let result = B256::from_str(&combined)?; + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_combine_and_trim_first_16_bytes() -> eyre::Result<()> { + let expected_result = B256::from_str( + "0000000000000000000000000000000100000000000000000000000000000002", + )?; + let items = vec![Uint::<256, 4>::from(1), Uint::<256, 4>::from(2)]; + let result = combine_and_trim_first_16_bytes(items)?; + println!("result: {:?}", result); + eyre::ensure!( + result == expected_result, + "result should be {}", + expected_result + ); + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/hash/pack_v07/hashed_call_data.rs b/crates/yttrium/src/user_operation/hash/pack_v07/hashed_call_data.rs new file mode 100644 index 0000000..26ca82b --- /dev/null +++ b/crates/yttrium/src/user_operation/hash/pack_v07/hashed_call_data.rs @@ -0,0 +1,34 @@ +use crate::user_operation::UserOperationV07; +use alloy::primitives::{keccak256, B256}; + +pub fn get_hashed_call_data( + user_operation: &UserOperationV07, +) -> eyre::Result { + let hashed_call_data = { + let call_data = user_operation.clone().call_data; + keccak256(call_data) + }; + let hashed_call_data_hex = hex::encode(hashed_call_data.clone()); + println!("hashed_call_data_hex: {:?}", hashed_call_data_hex); + Ok(hashed_call_data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_hashed_call_data() -> eyre::Result<()> { + let expected_hashed_call_data_hex = + "0x0a8139e8d993db78f1d6b8682c7dcf9d4ef0b49b8bf883dc0a22a45b7aa7da2c"; + let user_operation = UserOperationV07::mock(); + let hashed_call_data = get_hashed_call_data(&user_operation)?; + println!("hashed_call_data: {:?}", hashed_call_data); + eyre::ensure!( + format!("{}", hashed_call_data) == expected_hashed_call_data_hex, + "hashed_call_data should be {}", + expected_hashed_call_data_hex + ); + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/hash/pack_v07/hashed_init_code.rs b/crates/yttrium/src/user_operation/hash/pack_v07/hashed_init_code.rs new file mode 100644 index 0000000..1a38cbd --- /dev/null +++ b/crates/yttrium/src/user_operation/hash/pack_v07/hashed_init_code.rs @@ -0,0 +1,46 @@ +use crate::user_operation::UserOperationV07; +use alloy::primitives::{keccak256, B256}; + +pub fn get_hashed_init_code( + user_operation: &UserOperationV07, +) -> eyre::Result { + let uo = user_operation.clone(); + let value_vec = if let (Some(factory), Some(factory_data)) = + (uo.factory.clone(), uo.factory_data.clone()) + { + let factory_vec: Vec = factory.to_vec(); + let factory_data_vec: Vec = factory_data.into(); + let mut bytes_vec: Vec = vec![]; + bytes_vec.extend(factory_vec); + bytes_vec.extend(factory_data_vec); + bytes_vec + } else { + let bytes_vec: Vec = vec![]; + bytes_vec + }; + + let hashed_init_code = keccak256(value_vec); + println!("hashed_init_code: {:?}", hashed_init_code); + + Ok(hashed_init_code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_hashed_init_code() -> eyre::Result<()> { + let expected_hashed_init_code_hex = + "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"; + let user_operation = UserOperationV07::mock(); + let hashed_init_code = get_hashed_init_code(&user_operation)?; + println!("hashed_init_code: {:?}", hashed_init_code); + eyre::ensure!( + format!("{}", hashed_init_code) == expected_hashed_init_code_hex, + "hashed_init_code should be {}", + expected_hashed_init_code_hex + ); + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/hash/pack_v07/hashed_paymaster_and_data.rs b/crates/yttrium/src/user_operation/hash/pack_v07/hashed_paymaster_and_data.rs new file mode 100644 index 0000000..1b0123e --- /dev/null +++ b/crates/yttrium/src/user_operation/hash/pack_v07/hashed_paymaster_and_data.rs @@ -0,0 +1,98 @@ +use crate::user_operation::UserOperationV07; +use alloy::primitives::{keccak256, Bytes, Uint, B256, U256}; +use std::str::FromStr; + +fn trim_first_16_bytes_or_default(item: Option>) -> String { + let item = item.unwrap_or(U256::from(0)); + + let item_bytes_vec: Vec = item.to_be_bytes_vec()[16..].to_vec(); + + let item_hex = hex::encode(item_bytes_vec); + + item_hex +} + +fn get_data(user_operation: &UserOperationV07) -> eyre::Result { + let uo: UserOperationV07 = user_operation.clone(); + + let data = if let Some(paymaster) = uo.paymaster.clone() { + println!("paymaster: {:?}", paymaster); + + let paymaster_hex = format!("{}", paymaster); + + let paymaster_verification_gas_limit_hex = { + let verification_limit_hex = trim_first_16_bytes_or_default( + uo.paymaster_verification_gas_limit, + ); + verification_limit_hex + }; + + let paymaster_post_op_gas_limit_hex = { + let post_limit_hex = + trim_first_16_bytes_or_default(uo.paymaster_post_op_gas_limit); + post_limit_hex + }; + + let paymaster_data = { + let paymaster_data = uo.paymaster_data.clone().unwrap_or_default(); + + let paymaster_data_hex = + format!("{:?}", paymaster_data)[2..].to_string(); + println!("paymaster_data_hex: {:?}", paymaster_data_hex); + + paymaster_data_hex + }; + + let combined = format!( + "{}{}{}{}", + paymaster_hex, + paymaster_verification_gas_limit_hex, + paymaster_post_op_gas_limit_hex, + paymaster_data + ); + println!("combined: {:?}", combined); + + combined + } else { + "".to_string() + }; + + let data = data.strip_prefix("0x").unwrap(); + + let bytes = Bytes::from_str(data)?; + + Ok(bytes) +} + +pub fn get_hashed_paymaster_and_data( + user_operation: &UserOperationV07, +) -> eyre::Result { + let data = get_data(user_operation)?; + println!("data: {:?}", data); + + let hashed = keccak256(data); + println!("hashed: {:?}", hashed); + + Ok(hashed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_hashed_paymaster_and_data() -> eyre::Result<()> { + let expected_hashed_paymaster_and_data_hex = "0xfc0dffa735c71f138a00eaaafa56834aebf784e3e446612810f3f325cfb8eda9"; + let user_operation = UserOperationV07::mock(); + let hashed_paymaster_and_data = + get_hashed_paymaster_and_data(&user_operation)?; + println!("hashed_paymaster_and_data: {:?}", hashed_paymaster_and_data); + eyre::ensure!( + format!("{}", hashed_paymaster_and_data) + == expected_hashed_paymaster_and_data_hex, + "hashed_paymaster_and_data should be {}", + expected_hashed_paymaster_and_data_hex + ); + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/hash/pack_v07/max_priority_fee_per_gas_and_max_fee_per_gas.rs b/crates/yttrium/src/user_operation/hash/pack_v07/max_priority_fee_per_gas_and_max_fee_per_gas.rs new file mode 100644 index 0000000..af4aab9 --- /dev/null +++ b/crates/yttrium/src/user_operation/hash/pack_v07/max_priority_fee_per_gas_and_max_fee_per_gas.rs @@ -0,0 +1,38 @@ +use crate::user_operation::UserOperationV07; +use alloy::primitives::B256; + +pub fn get_max_priority_fee_per_gas_and_max_fee_per_gas( + user_operation: &UserOperationV07, +) -> eyre::Result { + let values = vec![ + user_operation.max_priority_fee_per_gas, + user_operation.max_fee_per_gas, + ]; + let combined = super::combine::combine_and_trim_first_16_bytes(values)?; + Ok(combined) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_max_priority_fee_per_gas_and_max_fee_per_gas( + ) -> eyre::Result<()> { + let expected_max_priority_fee_per_gas_and_max_fee_per_gas_hex = "0x00000000000000000000000043d4ca3500000000000000000000000417bbd4f1"; + let user_operation = UserOperationV07::mock(); + let max_priority_fee_per_gas_and_max_fee_per_gas = + get_max_priority_fee_per_gas_and_max_fee_per_gas(&user_operation)?; + println!( + "max_priority_fee_per_gas_and_max_fee_per_gas: {:?}", + max_priority_fee_per_gas_and_max_fee_per_gas + ); + eyre::ensure!( + format!("{}", max_priority_fee_per_gas_and_max_fee_per_gas) + == expected_max_priority_fee_per_gas_and_max_fee_per_gas_hex, + "max_priority_fee_per_gas_and_max_fee_per_gas should be {}", + expected_max_priority_fee_per_gas_and_max_fee_per_gas_hex + ); + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/hash/pack_v07/verificaction_gas_limit_and_call_gas_limit.rs b/crates/yttrium/src/user_operation/hash/pack_v07/verificaction_gas_limit_and_call_gas_limit.rs new file mode 100644 index 0000000..219f706 --- /dev/null +++ b/crates/yttrium/src/user_operation/hash/pack_v07/verificaction_gas_limit_and_call_gas_limit.rs @@ -0,0 +1,40 @@ +use super::combine::combine_and_trim_first_16_bytes; +use crate::user_operation::UserOperationV07; +use alloy::primitives::B256; + +pub fn get_verificaction_gas_limit_and_call_gas_limit( + user_operation: &UserOperationV07, +) -> eyre::Result { + let values = vec![ + user_operation.verification_gas_limit, + user_operation.call_gas_limit, + ]; + let combined = combine_and_trim_first_16_bytes(values)?; + Ok(combined) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_verificaction_gas_limit_and_call_gas_limit() -> eyre::Result<()> + { + let expected_verification_gas_limit_and_call_gas_limit_hex = + "0x00000000000000000000000000010b2500000000000000000000000000013880"; + let user_operation = UserOperationV07::mock(); + let verification_gas_limit_and_call_gas_limit = + get_verificaction_gas_limit_and_call_gas_limit(&user_operation)?; + println!( + "verification_gas_limit_and_call_gas_limit: {:?}", + verification_gas_limit_and_call_gas_limit + ); + eyre::ensure!( + format!("{}", verification_gas_limit_and_call_gas_limit) + == expected_verification_gas_limit_and_call_gas_limit_hex, + "verification_gas_limit_and_call_gas_limit should be {}", + expected_verification_gas_limit_and_call_gas_limit_hex + ); + Ok(()) + } +} diff --git a/crates/yttrium/src/user_operation/user_operation_hash.rs b/crates/yttrium/src/user_operation/user_operation_hash.rs new file mode 100644 index 0000000..c8fbdc2 --- /dev/null +++ b/crates/yttrium/src/user_operation/user_operation_hash.rs @@ -0,0 +1,76 @@ +use alloy::primitives::B256; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// User operation hash +#[derive( + Eq, + Hash, + PartialEq, + Debug, + Serialize, + Deserialize, + Clone, + Copy, + Default, + PartialOrd, + Ord, +)] +pub struct UserOperationHash(pub B256); + +impl From for UserOperationHash { + fn from(value: B256) -> Self { + Self(B256::from_slice(&value.0)) + } +} + +impl From for B256 { + fn from(value: UserOperationHash) -> Self { + B256::from_slice(&value.0 .0) + } +} + +impl From<[u8; 32]> for UserOperationHash { + fn from(value: [u8; 32]) -> Self { + Self(B256::from_slice(&value)) + } +} + +impl FromStr for UserOperationHash { + type Err = alloy::hex::FromHexError; + fn from_str(s: &str) -> Result { + B256::from_str(s).map(|h| h.into()) + } +} + +impl UserOperationHash { + #[inline] + pub const fn as_fixed_bytes(&self) -> &[u8; 32] { + &self.0 .0 + } + + #[inline] + pub fn as_bytes_mut(&mut self) -> &mut [u8] { + &mut self.0 .0 + } + + #[inline] + pub fn repeat_byte(byte: u8) -> UserOperationHash { + UserOperationHash(B256::from_slice(&[byte; 32])) + } + + #[inline] + pub fn zero() -> UserOperationHash { + UserOperationHash::repeat_byte(0u8) + } + + pub fn assign_from_slice(&mut self, src: &[u8]) { + self.as_bytes_mut().copy_from_slice(src); + } + + pub fn from_slice(src: &[u8]) -> Self { + let mut ret = Self::zero(); + ret.assign_from_slice(src); + ret + } +} diff --git a/crates/yttrium/tests/shared/mod.rs b/crates/yttrium/tests/shared/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/yttrium/tests/test_send_transaction.rs b/crates/yttrium/tests/test_send_transaction.rs new file mode 100644 index 0000000..2ce871f --- /dev/null +++ b/crates/yttrium/tests/test_send_transaction.rs @@ -0,0 +1,9 @@ +pub const ETHERIEUM_MAINNET_CHAIN_ID: u64 = 1; +pub const MNEMONIC_PHRASE: &str = + "test test test test test test test test test test test junk"; +pub const CHAIN_ID: u64 = ETHERIEUM_MAINNET_CHAIN_ID; + +#[tokio::test] +async fn test_send_transaction_on_sepolia() -> eyre::Result<()> { + Ok(()) +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..2a141ca --- /dev/null +++ b/justfile @@ -0,0 +1,17 @@ +setup: + git submodule update --init --recursive + make setup-thirdparty + +devloop: setup clippy test fmt udeps + +test: + cargo test --all-features --lib --bins + +clippy: + cargo clippy --workspace --all-features --all-targets -- -D warnings + +fmt: + cargo +nightly fmt --all + +udeps: + cargo +nightly udeps --workspace diff --git a/platforms/swift/.gitignore b/platforms/swift/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/platforms/swift/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/platforms/swift/Sources/Yttrium/AccountClient.swift b/platforms/swift/Sources/Yttrium/AccountClient.swift new file mode 100644 index 0000000..371eb6f --- /dev/null +++ b/platforms/swift/Sources/Yttrium/AccountClient.swift @@ -0,0 +1,91 @@ +import Foundation +import YttriumCore + +public final class AccountClient: AccountClientProtocol { + + public var onSign: OnSign? { + didSet { + if let onSign = onSign { + register(onSign: onSign) + } + } + } + + public let chainId: Int + + private let entryPoint: String + + private let coreAccountClient: YttriumCore.FFIAccountClient + + public init(ownerAddress: String, entryPoint: String, chainId: Int, onSign: OnSign?) { + let config: FFIAccountClientConfig = FFIAccountClientConfig( + owner_address: ownerAddress.intoRustString(), + chain_id: Int64(chainId), + config: .init( + endpoints: .init( + rpc: .init( + api_key: "".intoRustString(), + base_url: "https://localhost:8545".intoRustString() // TODO + ), + bundler: .init( + api_key: "".intoRustString(), + base_url: "https://localhost:4337".intoRustString() // TODO + ) + ) + ) + ) + self.chainId = chainId + self.entryPoint = entryPoint + self.coreAccountClient = FFIAccountClient(config) + } + + private func register(onSign: @escaping OnSign) { + let signer: Signer = .init( + signerId: .init( + account: entryPoint, + chainId: chainId + ), + onSign: { message in + onSign(message) + .mapError(YttriumCore.SignerError.from(error:)) + } + ) + Signers.shared.register(signer: signer) + } + + public func sendTransaction(_ transaction: Transaction) async throws -> String { + try await coreAccountClient.send_transaction(transaction.ffi).toString() + } + + public func sendBatchTransaction(_ batch: [Transaction]) async throws -> String { + fatalError("Not yet implemented") + } + + public func getAddress() async throws -> String { + try await coreAccountClient.get_address().toString() + } + + public func signMessage(_ message: String) throws -> String { + fatalError("Not yet implemented") + } +} + +extension Transaction { + + var ffi: FFITransaction { + FFITransaction( + to: to, + value: value, + data: data + ) + } +} + +extension YttriumCore.SignerError { + static func from(error: SignerError) -> Self { + switch error { + case .unknown: + return .unknown + } + } +} diff --git a/platforms/swift/Sources/Yttrium/AccountClient7702.swift b/platforms/swift/Sources/Yttrium/AccountClient7702.swift new file mode 100644 index 0000000..d90286a --- /dev/null +++ b/platforms/swift/Sources/Yttrium/AccountClient7702.swift @@ -0,0 +1,62 @@ +import Foundation +import YttriumCore + +public final class AccountClient7702 { + + public var onSign: OnSign? { + didSet { + if let onSign = onSign { + register(onSign: onSign) + } + } + } + + public let chainId: Int + + private let entryPoint: String + + private let core7702AccountClient: YttriumCore.FFI7702AccountClient + + public init(ownerAddress: String, entryPoint: String, chainId: Int, onSign: OnSign?) { + let config: FFIAccountClientConfig = FFIAccountClientConfig( + owner_address: ownerAddress.intoRustString(), + chain_id: Int64(chainId), + config: .init( + endpoints: .init( + rpc: .init( + api_key: "".intoRustString(), + base_url: "https://localhost:8545".intoRustString() // TODO + ), + bundler: .init( + api_key: "".intoRustString(), + base_url: "https://localhost:4337".intoRustString() // TODO + ) + ) + ) + ) + self.chainId = chainId + self.entryPoint = entryPoint + self.core7702AccountClient = FFI7702AccountClient(config) + } + + private func register(onSign: @escaping OnSign) { + let signer: Signer = .init( + signerId: .init( + account: entryPoint, + chainId: chainId + ), + onSign: { message in + onSign(message) + .mapError(YttriumCore.SignerError.from(error:)) + } + ) + Signers.shared.register(signer: signer) + } + + public func sendBatchTransaction(_ batch: [Transaction]) async throws -> String { + let ffiBatch = batch.map(\.ffi) + let batchJSONData = try JSONEncoder().encode(ffiBatch) + let batchJSONString = String(decoding: batchJSONData, as: UTF8.self) + return try await core7702AccountClient.send_batch_transaction(batchJSONString).toString() + } +} diff --git a/platforms/swift/Sources/Yttrium/AccountClientProtocol.swift b/platforms/swift/Sources/Yttrium/AccountClientProtocol.swift new file mode 100644 index 0000000..4ed838e --- /dev/null +++ b/platforms/swift/Sources/Yttrium/AccountClientProtocol.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct Transaction: Codable, Equatable { + let to: String + let value: String + let data: String + + public init(to: String, value: String, data: String) { + self.to = to + self.value = value + self.data = data + } +} + +public enum SignerError: Error { + case unknown +} + +public typealias OnSign = (String) -> Result + +public protocol AccountClientProtocol { + + var onSign: OnSign? { get set } + + var chainId: Int { get } + + init(ownerAddress: String, entryPoint: String, chainId: Int, onSign: OnSign?) + + func sendTransaction(_ transaction: Transaction) async throws -> String + func sendBatchTransaction(_ batch: [Transaction]) async throws -> String + func getAddress() async throws -> String + func signMessage(_ message: String) throws -> String +} diff --git a/platforms/swift/Tests/YttriumTests/AccountClientTests.swift b/platforms/swift/Tests/YttriumTests/AccountClientTests.swift new file mode 100644 index 0000000..0340f02 --- /dev/null +++ b/platforms/swift/Tests/YttriumTests/AccountClientTests.swift @@ -0,0 +1,24 @@ +import Foundation +import XCTest +@testable import YttriumCore +@testable import Yttrium + +final class AccountClientTests: XCTestCase { + func testGetAddress() async throws { + let accountAddress = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + let chainId = 0 + let accountClient = AccountClient( + entryPoint: accountAddress, // TODO + chainId: chainId, + onSign: { _ in + fatalError() + } + ) + + let expectedAddress = "0xa3aBDC7f6334CD3EE466A115f30522377787c024" + + let address = try await accountClient.getAddress() + + XCTAssertEqual(address, expectedAddress) + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7f456a3 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +max_width = 80 +use_small_heuristics = "Max" +comment_width = 100 +wrap_comments = true +format_code_in_doc_comments = true +doc_comment_code_block_width = 100 diff --git a/test/scripts/7702/docker-compose.yaml b/test/scripts/7702/docker-compose.yaml new file mode 100644 index 0000000..b892556 --- /dev/null +++ b/test/scripts/7702/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + anvil: + image: ghcr.io/foundry-rs/foundry:nightly-0b73b426d3aeb1563eeab4d5f2f8134d1c3902e3 + ports: ["8545:8545"] + entrypoint: [ "anvil","--chain-id", "31337", "--hardfork", "Prague", "--host", "0.0.0.0", "--block-time", "0.1", "--gas-price", "1", "--accounts", "100", "--silent",] + platform: linux/amd64/v8 + + mock-paymaster: + image: ghcr.io/pimlicolabs/mock-verifying-paymaster:main + ports: ["3000:3000"] + environment: + - ALTO_RPC=http://alto:4337 + - ANVIL_RPC=http://anvil:8545 + + alto: + image: ghcr.io/pimlicolabs/mock-alto-bundler:main + ports: ["4337:4337"] + environment: + - ANVIL_RPC=http://anvil:8545 + - SKIP_DEPLOYMENTS=true \ No newline at end of file diff --git a/test/scripts/7702/local-infra.sh b/test/scripts/7702/local-infra.sh new file mode 100644 index 0000000..3e77a99 --- /dev/null +++ b/test/scripts/7702/local-infra.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +# Start up local testing infrastructure + +docker compose up \ No newline at end of file diff --git a/test/scripts/forked_state/docker-compose.yaml b/test/scripts/forked_state/docker-compose.yaml new file mode 100644 index 0000000..1551a97 --- /dev/null +++ b/test/scripts/forked_state/docker-compose.yaml @@ -0,0 +1,20 @@ +services: + anvil: + image: ghcr.io/foundry-rs/foundry:nightly-f6208d8db68f9acbe4ff8cd76958309efb61ea0b + ports: ["8545:8545"] + entrypoint: [ "anvil", "--chain-id", "31337", "--fork-url", "https://gateway.tenderly.co/public/sepolia", "--host", "0.0.0.0", "--block-time", "0.1", "--gas-price", "1", "--accounts", "100", "--silent" ] + platform: linux/amd64/v8 + + mock-paymaster: + image: ghcr.io/pimlicolabs/mock-verifying-paymaster:main + ports: ["3000:3000"] + environment: + - ALTO_RPC=http://alto:4337 + - ANVIL_RPC=http://anvil:8545 + + alto: + image: ghcr.io/pimlicolabs/mock-alto-bundler:main + ports: ["4337:4337"] + environment: + - ANVIL_RPC=http://anvil:8545 + - SKIP_DEPLOYMENTS=true \ No newline at end of file diff --git a/test/scripts/forked_state/local-infra.sh b/test/scripts/forked_state/local-infra.sh new file mode 100644 index 0000000..3e77a99 --- /dev/null +++ b/test/scripts/forked_state/local-infra.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +# Start up local testing infrastructure + +docker compose up \ No newline at end of file diff --git a/test/scripts/local_infra/docker-compose.yaml b/test/scripts/local_infra/docker-compose.yaml new file mode 100644 index 0000000..69704cd --- /dev/null +++ b/test/scripts/local_infra/docker-compose.yaml @@ -0,0 +1,19 @@ +services: + anvil: + image: ghcr.io/foundry-rs/foundry:nightly-f6208d8db68f9acbe4ff8cd76958309efb61ea0b + ports: ["8545:8545"] + entrypoint: ["anvil", "--host", "0.0.0.0", "--block-time", "0.1", "--silent"] + platform: linux/amd64/v8 + + mock-paymaster: + image: ghcr.io/pimlicolabs/mock-verifying-paymaster:main + ports: ["3000:3000"] + environment: + - ALTO_RPC=http://alto:4337 + - ANVIL_RPC=http://anvil:8545 + + alto: + image: ghcr.io/pimlicolabs/mock-alto-bundler:main + ports: ["4337:4337"] + environment: + - ANVIL_RPC=http://anvil:8545 \ No newline at end of file diff --git a/test/scripts/local_infra/local-infra.sh b/test/scripts/local_infra/local-infra.sh new file mode 100644 index 0000000..3e77a99 --- /dev/null +++ b/test/scripts/local_infra/local-infra.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +# Start up local testing infrastructure + +docker compose up \ No newline at end of file