diff --git a/CLAUDE.md b/CLAUDE.md index 3f1c724..2708648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,8 @@ docker-compose up -d 7. **paymaster-sponsoring** - Sponsoring logic and webhook handling 8. **paymaster-common** - Shared utilities, monitoring, and service management 9. **paymaster-cli** - Command-line interface for setup and management -10. **website** - Landing page with documentation links and useful resources +10. **paymaster-client** - Rust client library for the paymaster JSON-RPC API (builder pattern, build+sign+execute flow) +11. **website** - Landing page with documentation links and useful resources ### Key Services @@ -123,14 +124,6 @@ The service uses environment variables and configuration files. Key configuratio 4. Service locks a relayer, executes transaction, then releases relayer 5. Relayer balances are monitored and rebalanced as needed -### Testing - -Most crates include comprehensive test suites. Key testing utilities: -- Mock implementations for external dependencies -- Test transactions and accounts in `paymaster-starknet/testing` -- Integration tests for RPC endpoints -- Relayer lock testing with mock coordination layers - ### Error Handling The codebase uses `thiserror` for error handling with comprehensive error types: @@ -159,6 +152,11 @@ The codebase uses `thiserror` for error handling with comprehensive error types: - Comprehensive error handling for network issues - Gas price monitoring and fee estimation +### Builder Pattern +- Builders must use the typestate pattern to enforce required fields at compile time +- Required parameters go in the constructor or in methods that transition state +- Optional parameters work on any state via a generic impl block + ### Relayer Management - Segregated locking to prevent race conditions - Automatic rebalancing via AVNU swaps @@ -167,4 +165,110 @@ The codebase uses `thiserror` for error handling with comprehensive error types: ### Language - The whole codebase is strictly using English -- New/edited comments must be in English as well \ No newline at end of file +- New/edited comments must be in English as well +- +- ## Code Style + +Follows rustfmt config: max_width=170, chain_width=80, Unix newlines. + +**Don't use section comments**: Avoid comments like `// ============` or `// --- Section ---` to delimit code sections. Use module structure and whitespace instead. + +## Tools & Plugins + +- **rust-analyzer-lsp**: Use this MCP plugin for Rust code analysis +- **plugin:context7:context7**: Use for up-to-date documentation on libraries + +## Testing Guidelines + +Most crates include comprehensive test suites. Key testing utilities: +- Mock implementations for external dependencies +- Test transactions and accounts in `paymaster-starknet/testing` +- Integration tests for RPC endpoints +- Relayer lock testing with mock coordination layers + +### Test Patterns + +- Unit tests: `#[cfg(test)] mod tests` at bottom of file +- Integration tests: Use testcontainers with `setup_mongo()` +- Naming: `should__when_` +- Structure: Given/When/Then comments +- Nested modules per function: `mod function_name { #[test] fn should_... }` + +### Test Pragmatism + +Write tests that provide real value. Avoid over-testing and redundant coverage: + +- **Test each behavior once**: If a helper function has 2 behaviors (success/error), test those 2 cases in unit tests for that function +- **Don't re-test dependencies**: If function B calls function A, only test B's happy path with A succeeding. A's error cases are already covered by A's own tests +- **Avoid trivial tests**: Don't test obvious things like "constructor sets fields" or "getter returns value" +- **Focus on boundaries**: Test edge cases at the lowest level where they occur, not at every layer + +Example - What NOT to do: +```rust +// parse_signature already has tests for empty input, invalid hex, etc. +// Don't re-test those cases in verify_signature_endpoint! + +// ❌ BAD: Re-testing parse_signature errors through the endpoint +#[case::empty_signature(json!({ ..., "signature": [] }))] // Already tested in parse_signature +#[case::invalid_hex(json!({ ..., "signature": ["invalid"] }))] // Already tested + +// ✅ GOOD: Test only verify_signature_endpoint's own logic +#[case::address_mismatch(...)] // Endpoint's own validation +#[case::nonce_not_found(...)] // Endpoint's own behavior +``` + +**Rule of thumb**: If a test failure would point you to a dependency's code rather than the function being tested, the test is redundant. + +### rstest Parameterized Tests + +Use `rstest` for parameterized tests, grouped by outcome: +- **Split by success/error**: Don't mix success and error cases in the same function +- **Split by HTTP status**: Group endpoint tests by expected status code (400, 401, etc.) +- **Payload in `#[case]`**: Put the full input data directly in the case attribute for visibility + +Example - Unit tests split by success/error: +```rust +#[rstest] +#[case::valid_hex(vec!["0x123", "0x456"], 2)] +#[case::single_element(vec!["0x123"], 1)] +fn should_parse_valid_signature_when(#[case] input: Vec<&str>, #[case] expected_len: usize) { + let result = parse_signature(&input.into_iter().map(String::from).collect::>()); + assert_eq!(result.unwrap().len(), expected_len); +} + +#[rstest] +#[case::empty(vec![])] +#[case::decimal_rejected(vec!["123", "456"])] +fn should_reject_invalid_signature_when(#[case] input: Vec<&str>) { + let result = parse_signature(&input.into_iter().map(String::from).collect::>()); + assert!(result.is_err()); +} +``` + +Example - HTTP endpoint tests grouped by status code: +```rust +#[rstest] +#[case::invalid_wallet_address(json!({ "wallet_address": "invalid", ... }))] +#[case::invalid_message_address(json!({ "wallet_address": "0x...", "message": { "address": "invalid" }, ... }))] +#[actix_rt::test] +async fn should_return_400_when(#[case] payload: serde_json::Value) { + // ... test returning 400 +} + +#[rstest] +#[case::address_mismatch(json!({ ... }))] +#[actix_rt::test] +async fn should_return_401_when(#[case] payload: serde_json::Value) { + // ... test returning 401 +} +``` + +**Note**: Tests requiring specific setup (database operations before request) should remain as individual functions. + +## CLAUDE.md Maintenance + +**REQUIRED**: Update this file when making significant architecture changes: +- Document major new features +- Update environment variables when adding new ones +- Keep the "Progress Tracker" section up to date +- Update API structure when adding new endpoints diff --git a/Cargo.lock b/Cargo.lock index eefefd3..c12f4fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2522,6 +2522,21 @@ dependencies = [ "uuid 1.17.0", ] +[[package]] +name = "paymaster-client" +version = "1.0.0" +dependencies = [ + "paymaster-rpc", + "paymaster-starknet", + "serde", + "serde_json", + "starknet", + "thiserror 2.0.12", + "tokio", + "tracing", + "wiremock", +] + [[package]] name = "paymaster-common" version = "1.0.0" @@ -3690,7 +3705,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "starknet" version = "0.17.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "starknet-accounts", "starknet-contract", @@ -3705,7 +3720,7 @@ dependencies = [ [[package]] name = "starknet-accounts" version = "0.16.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "async-trait", "auto_impl", @@ -3719,7 +3734,7 @@ dependencies = [ [[package]] name = "starknet-contract" version = "0.16.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "serde", "serde_json", @@ -3733,7 +3748,7 @@ dependencies = [ [[package]] name = "starknet-core" version = "0.16.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "base64 0.21.7", "crypto-bigint", @@ -3755,7 +3770,7 @@ dependencies = [ [[package]] name = "starknet-core-derive" version = "0.1.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "proc-macro2", "quote", @@ -3765,7 +3780,7 @@ dependencies = [ [[package]] name = "starknet-crypto" version = "0.8.1" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "crypto-bigint", "hex", @@ -3783,7 +3798,7 @@ dependencies = [ [[package]] name = "starknet-curve" version = "0.6.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "starknet-types-core", ] @@ -3791,7 +3806,7 @@ dependencies = [ [[package]] name = "starknet-macros" version = "0.2.5" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "starknet-core", "syn", @@ -3800,7 +3815,7 @@ dependencies = [ [[package]] name = "starknet-providers" version = "0.16.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "async-trait", "auto_impl", @@ -3820,7 +3835,7 @@ dependencies = [ [[package]] name = "starknet-signers" version = "0.14.0" -source = "git+https://github.com/xJonathanLEI/starknet-rs?tag=starknet%2Fv0.17.0#85906137d634c86b07de20fa33071e5cf186ce21" +source = "git+https://github.com/florian-bellotti/starknet-rs?branch=bugfix%2Ftyped-data#e3e9fcac80347bd1d2194cf57b48ca164dc6c20d" dependencies = [ "async-trait", "auto_impl", diff --git a/Cargo.toml b/Cargo.toml index 4fbab4f..c75e6ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/paymaster-sponsoring", "crates/paymaster-cli", "crates/paymaster", + "crates/paymaster-client", ] [workspace.package] @@ -38,7 +39,7 @@ serde = "1.0.219" serde_json = "1.0.139" serde_with = "3.14.0" simple_logger = "5.0.0" -starknet = { git = "https://github.com/xJonathanLEI/starknet-rs", tag = "starknet/v0.17.0" } +starknet = { git = "https://github.com/florian-bellotti/starknet-rs", branch = "bugfix/typed-data" } testcontainers = "0.23.3" thiserror = "2.0.11" tokio = "1.43.0" diff --git a/README.md b/README.md index 6b688ba..5c12eaa 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,57 @@ const { transaction_hash } = result; 🔗 [Full Integration Guide available here](https://docs.out-of-gas.xyz/docs/dapp-integration) +### Rust Client + +Add the dependency to your `Cargo.toml`: + +```toml +[dependencies] +paymaster-client = { git = "https://github.com/avnu-labs/paymaster" } +``` + +```rust +use paymaster_client::{PaymasterClient, STRK_TOKEN}; + +#[tokio::main] +async fn main() -> Result<(), paymaster_client::Error> { + let client = PaymasterClient::builder("https://sepolia.paymaster.avnu.fi") + .api_key("YOUR_API_KEY") + .build()?; + + // Sponsored transaction (gas paid by the paymaster) + let resp = client.transaction(your_account_address) + .call(your_call()) + .sponsored() + .send(&your_wallet) + .await?; + + println!("tx hash: {:#x}", resp.transaction_hash); + + // Non-sponsored transaction (gas defaults to STRK) + let resp = client.transaction(your_account_address) + .call(your_call()) + .send(&your_wallet) + .await?; + + println!("tx hash: {:#x}", resp.transaction_hash); + + // Two-step flow: inspect fees before signing + let prepared = client.transaction(your_account_address) + .call(your_call()) + .gas_token(STRK_TOKEN) + .build() + .await?; + + println!("Estimated fee: {:#x}", prepared.fee.estimated_fee_in_strk); + let resp = prepared.send(&your_wallet).await?; + + println!("tx hash: {:#x}", resp.transaction_hash); + + Ok(()) +} +``` + ## 📖 Documentation 📚 [Full documentation available here](https://docs.out-of-gas.xyz) diff --git a/crates/paymaster-client/Cargo.toml b/crates/paymaster-client/Cargo.toml new file mode 100644 index 0000000..335b7eb --- /dev/null +++ b/crates/paymaster-client/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "paymaster-client" +version = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } + +[dependencies] +paymaster-rpc = { path = "../paymaster-rpc" } +paymaster-starknet = { path = "../paymaster-starknet" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["arbitrary_precision", "raw_value"] } +starknet = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true, features = ["attributes"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +wiremock = { workspace = true } diff --git a/crates/paymaster-client/src/lib.rs b/crates/paymaster-client/src/lib.rs new file mode 100644 index 0000000..03d6745 --- /dev/null +++ b/crates/paymaster-client/src/lib.rs @@ -0,0 +1,105 @@ +mod transaction; +mod signature; + +use std::ops::Deref; +use std::time::Duration; + +use starknet::core::types::Felt; +use thiserror::Error; + +pub use paymaster_rpc::{ + BuildTransactionRequest, BuildTransactionResponse, DeployAndInvokeTransaction, DeployTransaction, DeploymentParameters, DirectInvokeParameters, + ExecutableInvokeParameters, ExecutableTransactionParameters, ExecuteDirectRequest, ExecuteDirectResponse, ExecuteDirectTransactionParameters, ExecuteRequest, + ExecuteResponse, ExecutionParameters, FeeEstimate, FeeMode, InvokeParameters, InvokeTransaction, TimeBounds, TipPriority, TokenPrice, TransactionParameters, +}; +pub use transaction::TransactionBuilder; +use paymaster_starknet::constants::Token; +use crate::transaction::Unset; + +/// STRK token address on Starknet. +pub const STRK_TOKEN: Felt = Token::STRK_ADDRESS; + +#[derive(Error, Debug)] +pub enum Error { + #[error("RPC error: {0}")] + Rpc(#[from] paymaster_rpc::client::Error), + + #[error("signing error: {0}")] + Signing(String), + + #[error("configuration error: {0}")] + Configuration(String), +} + +/// Paymaster client wrapping the low-level RPC client. +/// +/// Use [`Deref`] to access all native RPC methods (e.g. `client.health()`). +/// Use [`transaction()`](PaymasterClient::transaction) to start building a transaction. +/// +/// # Example +/// +/// ```ignore +/// let client = PaymasterClient::builder("https://sepolia.paymaster.avnu.fi/") +/// .api_key("my-key") +/// .build()?; +/// +/// // High-level builder +/// client.transaction(account_address) +/// .call(transfer_call) +/// .sponsored() +/// .send(&wallet).await?; +/// +/// // Low-level RPC via Deref +/// client.health().await?; +/// ``` +#[derive(Clone)] +pub struct PaymasterClient { + inner: paymaster_rpc::client::Client, +} + +impl PaymasterClient { + pub fn new(endpoint: &str) -> Self { + Self { + inner: paymaster_rpc::client::Client::new(endpoint), + } + } + + pub fn builder(endpoint: impl Into) -> PaymasterClientBuilder { + PaymasterClientBuilder { + inner: paymaster_rpc::client::Client::builder(endpoint), + } + } + + /// Starts building a transaction for the given account address. + pub fn transaction(&self, address: Felt) -> TransactionBuilder<'_, Unset, Unset> { + TransactionBuilder::new(&self.inner, address) + } +} + +impl Deref for PaymasterClient { + type Target = paymaster_rpc::client::Client; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +pub struct PaymasterClientBuilder { + inner: paymaster_rpc::client::ClientBuilder, +} + +impl PaymasterClientBuilder { + pub fn api_key(mut self, api_key: impl Into) -> Self { + self.inner = self.inner.api_key(api_key); + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.inner = self.inner.timeout(timeout); + self + } + + pub fn build(self) -> Result { + Ok(PaymasterClient { inner: self.inner.build()? }) + } +} diff --git a/crates/paymaster-client/src/signature.rs b/crates/paymaster-client/src/signature.rs new file mode 100644 index 0000000..175a5d9 --- /dev/null +++ b/crates/paymaster-client/src/signature.rs @@ -0,0 +1,19 @@ +use starknet::core::types::{Felt, TypedData}; +use starknet::signers::Signer; +use crate::Error; + +pub async fn sign_typed_data(typed_data: &TypedData, address: Felt, signer: &S) -> Result, Error> +where + S: Signer + Send + Sync, +{ + let message_hash = typed_data + .message_hash(address) + .map_err(|e| Error::Signing(format!("failed to compute message hash: {e}")))?; + + let sig = signer + .sign_hash(&message_hash) + .await + .map_err(|e| Error::Signing(e.to_string()))?; + + Ok(vec![sig.r, sig.s]) +} \ No newline at end of file diff --git a/crates/paymaster-client/src/transaction.rs b/crates/paymaster-client/src/transaction.rs new file mode 100644 index 0000000..ff2a94a --- /dev/null +++ b/crates/paymaster-client/src/transaction.rs @@ -0,0 +1,668 @@ +use paymaster_rpc::client::Client; +use starknet::core::types::{Call, Felt}; +use starknet::signers::Signer; +use crate::*; +use crate::signature::sign_typed_data; + +pub struct Unset; +pub struct HasDeploy(DeploymentParameters); +pub struct HasInvoke(Vec); + +/// High-level builder that orchestrates the build, sign, and execute flow. +/// +/// Uses the typestate pattern to enforce that a transaction type is set before building. +/// Created via [`PaymasterClient::transaction()`](crate::PaymasterClient::transaction). +/// +/// # Example +/// +/// ```ignore +/// use starknet::signers::{LocalWallet, SigningKey}; +/// +/// let client = PaymasterClient::builder("https://sepolia.paymaster.avnu.fi/") +/// .api_key("my-key") +/// .build()?; +/// let wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar(private_key)); +/// +/// // One-step flow +/// client.transaction(account_address) +/// .call(transfer_call) +/// .sponsored() +/// .send(&wallet) +/// .await?; +/// +/// // Two-step flow with fee inspection +/// let prepared = client.transaction(account_address) +/// .call(transfer_call) +/// .build() +/// .await?; +/// +/// println!("Fee: {:?}", prepared.fee); +/// let result = prepared.send(&wallet).await?; +/// ``` +pub struct TransactionBuilder<'a, Deploy, Invoke> { + client: &'a Client, + address: Felt, + + deploy: Deploy, + invoke: Invoke, + fee: FeeMode, + + time_bounds: Option, +} + +impl<'a> TransactionBuilder<'a, Unset, Unset> { + pub fn new(client: &'a Client, address: Felt) -> Self { + Self { + client, + address, + + deploy: Unset, + invoke: Unset, + fee: FeeMode::Default { gas_token: STRK_TOKEN, tip: TipPriority::Normal }, + + time_bounds: None, + } + } +} + +impl<'a, Deploy, Invoke> TransactionBuilder<'a, Deploy, Invoke> { + /// Sets time bounds for transaction execution. + pub fn time_bounds(mut self, bounds: TimeBounds) -> Self { + self.time_bounds = Some(bounds); + self + } + + /// Sets the calls to include in the invoke transaction. + pub fn fee_mode(self, fee_mode: FeeMode) -> TransactionBuilder<'a, Deploy, Invoke> { + TransactionBuilder { + fee: fee_mode, + ..self + } + } + + pub fn sponsored(self) -> TransactionBuilder<'a, Deploy, Invoke> { + self.fee_mode(FeeMode::Sponsored { tip: TipPriority::Normal }) + } +} + +impl<'a, Invoke> TransactionBuilder<'a, Unset, Invoke> { + /// Sets deployment parameters for a deploy transaction. + pub fn deployment(self, deployment: DeploymentParameters) -> TransactionBuilder<'a, HasDeploy, Invoke> { + TransactionBuilder { + client: self.client, + address: self.address, + + deploy: HasDeploy(deployment), + invoke: self.invoke, + fee: self.fee, + + time_bounds: self.time_bounds + } + } +} + +impl<'a, Deploy> TransactionBuilder<'a, Deploy, Unset> { + pub fn call(self, call: Call) -> TransactionBuilder<'a, Deploy, HasInvoke> { + self.calls(vec![call]) + } + + /// Sets the calls to include in the invoke transaction. + pub fn calls(self, calls: Vec) -> TransactionBuilder<'a, Deploy, HasInvoke> { + TransactionBuilder { + client: self.client, + address: self.address, + + deploy: self.deploy, + invoke: HasInvoke(calls), + fee: self.fee, + + time_bounds: self.time_bounds + } + } +} + +impl<'a> TransactionBuilder<'a, HasDeploy, HasInvoke> { + /// Builds the transaction and returns a [`PreparedTransaction`] with the fee estimate. + /// + /// Use this for a two-step flow where you want to inspect fees before signing. + pub async fn build(self) -> Result, Error> { + let request = BuildTransactionRequest { + transaction: TransactionParameters::DeployAndInvoke { + deployment: self.deploy.0, + invoke: InvokeParameters { + user_address: self.address, + calls: self.invoke.0, + }, + }, + parameters: ExecutionParameters::V1 { + fee_mode: self.fee, + time_bounds: self.time_bounds, + }, + }; + + let response = self + .client + .build_transaction(request) + .await?; + + Ok(BuiltTransaction { + client: self.client, + address: self.address, + + transaction: response, + }) + } + + pub async fn send(self, signer: &S) -> Result + where + S: Signer + Send + Sync + { + self + .build() + .await? + .sign(signer) + .await? + .execute() + .await + } +} + +impl<'a> TransactionBuilder<'a, HasDeploy, Unset> { + /// Builds the transaction and returns a [`PreparedTransaction`] with the fee estimate. + /// + /// Use this for a two-step flow where you want to inspect fees before signing. + pub async fn build(self) -> Result, Error> { + if !matches!(self.fee, FeeMode::Sponsored { .. }) { + return Err(Error::Configuration("deploy-only only supported in sponsored mode".to_string())) + } + + let request = BuildTransactionRequest { + transaction: TransactionParameters::Deploy { + deployment: self.deploy.0, + }, + parameters: ExecutionParameters::V1 { + fee_mode: self.fee, + time_bounds: self.time_bounds, + }, + }; + + let response = self + .client + .build_transaction(request) + .await?; + + Ok(BuiltTransaction { + client: self.client, + address: self.address, + + transaction: response, + }) + } + + pub async fn send(self, signer: &S) -> Result + where + S: Signer + Send + Sync + { + self + .build() + .await? + .sign(signer) + .await? + .execute() + .await + } +} + +impl<'a> TransactionBuilder<'a, Unset, HasInvoke> { + /// Builds the transaction and returns a [`PreparedTransaction`] with the fee estimate. + /// + /// Use this for a two-step flow where you want to inspect fees before signing. + pub async fn build(self) -> Result, Error> { + let request = BuildTransactionRequest { + transaction: TransactionParameters::Invoke { + invoke: InvokeParameters { + user_address: self.address, + calls: self.invoke.0, + }, + }, + parameters: ExecutionParameters::V1 { + fee_mode: self.fee, + time_bounds: self.time_bounds, + }, + }; + + let response = self + .client + .build_transaction(request) + .await?; + + Ok(BuiltTransaction { + client: self.client, + address: self.address, + + transaction: response, + }) + } + + pub async fn send(self, signer: &S) -> Result + where + S: Signer + Send + Sync + { + self + .build() + .await? + .sign(signer) + .await? + .execute() + .await + } +} + +/// A transaction that has been built and is ready to be signed and sent. +/// +/// Contains the fee estimate from the build step, allowing inspection before signing. +pub struct BuiltTransaction<'a> { + client: &'a Client, + address: Felt, + + transaction: BuildTransactionResponse, +} + +impl<'a> BuiltTransaction<'a> { + pub fn fee_estimate(&self) -> FeeEstimate { + match &self.transaction { + BuildTransactionResponse::Invoke(tx) => tx.fee.clone(), + BuildTransactionResponse::DeployAndInvoke(tx) => tx.fee.clone(), + BuildTransactionResponse::Deploy(tx) => tx.fee.clone(), + } + } + + pub fn execution_parameters(&self) -> ExecutionParameters { + match &self.transaction { + BuildTransactionResponse::Invoke(tx) => tx.parameters.clone(), + BuildTransactionResponse::DeployAndInvoke(tx) => tx.parameters.clone(), + BuildTransactionResponse::Deploy(tx) => tx.parameters.clone(), + } + } + + pub async fn sign(self, signer: &'a S) -> Result, Error> + where + S: Signer + Send + Sync + { + Ok(ExecutableTransaction { + client: self.client, + + transaction: match &self.transaction { + BuildTransactionResponse::Invoke(tx) => self.build_invoke(signer, tx).await?, + BuildTransactionResponse::DeployAndInvoke(tx) => self.build_deploy_and_invoke(signer, tx).await?, + BuildTransactionResponse::Deploy(tx) => self.build_deploy(tx)?, + }, + parameters: self.execution_parameters() + }) + } + + fn build_deploy(&self, tx: &DeployTransaction) -> Result { + Ok(ExecutableTransactionParameters::Deploy { + deployment: tx.deployment.clone(), + }) + } + + async fn build_invoke(&self, signer: &S, tx: &InvokeTransaction) -> Result + where + S: Signer + Send + Sync + { + Ok(ExecutableTransactionParameters::Invoke { + invoke: ExecutableInvokeParameters { + user_address: self.address, + typed_data: tx.typed_data.clone(), + signature: sign_typed_data(&tx.typed_data, self.address, signer).await?, + }, + }) + } + + async fn build_deploy_and_invoke(&self, signer: &S, tx: &DeployAndInvokeTransaction) -> Result + where + S: Signer + Send + Sync + { + Ok(ExecutableTransactionParameters::DeployAndInvoke { + deployment: tx.deployment.clone(), + invoke: ExecutableInvokeParameters { + user_address: self.address, + typed_data: tx.typed_data.clone(), + signature: sign_typed_data(&tx.typed_data, self.address, signer).await?, + }, + }) + } + + pub async fn send(self, signer: &S) -> Result + where + S: Signer + Send + Sync + { + self + .sign(signer) + .await? + .execute() + .await + } +} + +pub struct ExecutableTransaction<'a> { + client: &'a Client, + + transaction: ExecutableTransactionParameters, + parameters: ExecutionParameters +} + +impl<'a> ExecutableTransaction<'a> { + pub async fn execute(self) -> Result { + let request = ExecuteRequest { + transaction: self.transaction, + parameters: self.parameters, + }; + + let response = self + .client + .execute_transaction(request) + .await?; + + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use starknet::signers::{LocalWallet, SigningKey}; + use wiremock::matchers::{body_partial_json, method}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + use super::*; + use crate::PaymasterClient; + + struct JsonRpcOk(serde_json::Value); + + impl Respond for JsonRpcOk { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + let id = body.get("id").cloned().unwrap_or(serde_json::json!(0)); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", + "result": self.0, + "id": id + })) + } + } + + fn test_wallet() -> LocalWallet { + LocalWallet::from_signing_key(SigningKey::from_secret_scalar(Felt::from_hex_unchecked("0x5678"))) + } + + fn typed_data_json() -> serde_json::Value { + serde_json::json!({ + "types": { + "StarknetDomain": [ + {"name": "name", "type": "shortstring"}, + {"name": "version", "type": "shortstring"}, + {"name": "chainId", "type": "shortstring"}, + {"name": "revision", "type": "shortstring"} + ], + "OutsideExecution": [ + {"name": "Caller", "type": "ContractAddress"}, + {"name": "Nonce", "type": "felt"}, + {"name": "Execute After", "type": "u128"}, + {"name": "Execute Before", "type": "u128"}, + {"name": "Calls", "type": "Call*"} + ], + "Call": [ + {"name": "To", "type": "ContractAddress"}, + {"name": "Selector", "type": "selector"}, + {"name": "Calldata", "type": "felt*"} + ] + }, + "primaryType": "OutsideExecution", + "domain": { + "name": "Account.execute_from_outside", + "version": "2", + "chainId": "0x534e5f5345504f4c4941", + "revision": "1" + }, + "message": { + "Caller": "0x414e595f43414c4c4552", + "Nonce": "0x1", + "Execute After": "0x0", + "Execute Before": "0xffffffffffffffff", + "Calls": [] + } + }) + } + + fn invoke_build_result() -> serde_json::Value { + serde_json::json!({ + "type": "invoke", + "typed_data": typed_data_json(), + "parameters": { + "version": "0x1", + "fee_mode": {"mode": "sponsored", "tip": "normal"}, + "time_bounds": null + }, + "fee": { + "gas_token_price_in_strk": "0x1", + "estimated_fee_in_strk": "0x100", + "estimated_fee_in_gas_token": "0x100", + "suggested_max_fee_in_strk": "0x200", + "suggested_max_fee_in_gas_token": "0x200" + } + }) + } + + fn deploy_build_result() -> serde_json::Value { + serde_json::json!({ + "type": "deploy", + "deployment": { + "address": "0x1", + "class_hash": "0x2", + "salt": "0x3", + "calldata": [], + "sigdata": null, + "version": 1 + }, + "parameters": { + "version": "0x1", + "fee_mode": {"mode": "sponsored", "tip": "normal"}, + "time_bounds": null + }, + "fee": { + "gas_token_price_in_strk": "0x1", + "estimated_fee_in_strk": "0x50", + "estimated_fee_in_gas_token": "0x50", + "suggested_max_fee_in_strk": "0xa0", + "suggested_max_fee_in_gas_token": "0xa0" + } + }) + } + + fn execute_result() -> serde_json::Value { + serde_json::json!({ + "transaction_hash": "0xdeadbeef", + "tracking_id": "0x0" + }) + } + + #[tokio::test] + async fn should_default_gas_token_to_strk() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({ + "method": "paymaster_buildTransaction", + "params": [{ + "parameters": { + "fee_mode": { + "mode": "default", + "gas_token": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + } + } + }] + }))) + .respond_with(JsonRpcOk(invoke_build_result())) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_executeTransaction"}))) + .respond_with(JsonRpcOk(execute_result())) + .expect(1) + .mount(&server) + .await; + + let client = PaymasterClient::new(&server.uri()); + client + .transaction(Felt::from_hex_unchecked("0x1234")) + .calls(vec![]) + .send(&test_wallet()) + .await + .unwrap(); + } + + #[tokio::test] + async fn should_reject_deploy_only_when_not_sponsored() { + let client = PaymasterClient::new("http://localhost:1234"); + let result = client + .transaction(Felt::ONE) + .deployment(DeploymentParameters { + address: Felt::ONE, + class_hash: Felt::TWO, + salt: Felt::THREE, + calldata: vec![], + sigdata: None, + version: 1, + }) + .fee_mode(FeeMode::Sponsored { tip: TipPriority::Normal }) + .send(&test_wallet()) + .await; + + assert!(matches!(result, Err(Error::Configuration(ref msg)) if msg.contains("sponsored"))); + } + + #[tokio::test] + async fn should_execute_deploy_only_when_sponsored() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_buildTransaction"}))) + .respond_with(JsonRpcOk(deploy_build_result())) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_executeTransaction"}))) + .respond_with(JsonRpcOk(execute_result())) + .expect(1) + .mount(&server) + .await; + + let client = PaymasterClient::new(&server.uri()); + let result = client + .transaction(Felt::ONE) + .deployment(DeploymentParameters { + address: Felt::ONE, + class_hash: Felt::TWO, + salt: Felt::THREE, + calldata: vec![], + sigdata: None, + version: 1, + }) + .sponsored() + .send(&test_wallet()) + .await + .unwrap(); + + assert_eq!(result.transaction_hash, Felt::from_hex_unchecked("0xdeadbeef")); + } + + #[tokio::test] + async fn should_return_fee_estimate_when_build() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_buildTransaction"}))) + .respond_with(JsonRpcOk(invoke_build_result())) + .expect(1) + .mount(&server) + .await; + + let client = PaymasterClient::new(&server.uri()); + let prepared = client + .transaction(Felt::from_hex_unchecked("0x1234")) + .calls(vec![]) + .sponsored() + .build() + .await + .unwrap(); + + assert_eq!(prepared.fee_estimate().estimated_fee_in_strk, Felt::from_hex_unchecked("0x100")); + assert_eq!(prepared.fee_estimate().suggested_max_fee_in_strk, Felt::from_hex_unchecked("0x200")); + } + + #[tokio::test] + async fn should_execute_after_build_when_two_step_flow() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_buildTransaction"}))) + .respond_with(JsonRpcOk(invoke_build_result())) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_executeTransaction"}))) + .respond_with(JsonRpcOk(execute_result())) + .expect(1) + .mount(&server) + .await; + + let client = PaymasterClient::new(&server.uri()); + let prepared = client + .transaction(Felt::from_hex_unchecked("0x1234")) + .calls(vec![]) + .sponsored() + .build() + .await + .unwrap(); + + let result = prepared + .send(&test_wallet()) + .await + .unwrap(); + + assert_eq!(result.transaction_hash, Felt::from_hex_unchecked("0xdeadbeef")); + } + + #[tokio::test] + async fn should_complete_full_invoke_flow() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_buildTransaction"}))) + .respond_with(JsonRpcOk(invoke_build_result())) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(body_partial_json(serde_json::json!({"method": "paymaster_executeTransaction"}))) + .respond_with(JsonRpcOk(execute_result())) + .expect(1) + .mount(&server) + .await; + + let wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar(Felt::from_hex_unchecked("0x5678"))); + let client = PaymasterClient::new(&server.uri()); + let result = client + .transaction(Felt::from_hex_unchecked("0x1234")) + .calls(vec![]) + .sponsored() + .send(&wallet) + .await + .unwrap(); + + assert_eq!(result.transaction_hash, Felt::from_hex_unchecked("0xdeadbeef")); + } +} diff --git a/crates/paymaster-rpc/src/client.rs b/crates/paymaster-rpc/src/client.rs index 9491670..ebcee3d 100644 --- a/crates/paymaster-rpc/src/client.rs +++ b/crates/paymaster-rpc/src/client.rs @@ -1,10 +1,15 @@ -use jsonrpsee::http_client::HttpClient; +use std::time::Duration; + +use jsonrpsee::http_client::{HeaderMap, HeaderValue, HttpClient, HttpClientBuilder}; use crate::endpoint::execute_raw::{ExecuteDirectRequest, ExecuteDirectResponse}; use crate::{BuildTransactionRequest, BuildTransactionResponse, ExecuteRequest, ExecuteResponse, PaymasterAPIClient, TokenPrice}; pub type Error = jsonrpsee::core::ClientError; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone)] pub struct Client { inner: HttpClient, } @@ -16,6 +21,18 @@ impl Client { } } + pub fn builder(endpoint: impl Into) -> ClientBuilder { + ClientBuilder { + endpoint: endpoint.into(), + api_key: None, + timeout: DEFAULT_TIMEOUT, + } + } + + pub async fn health(&self) -> Result { + self.inner.health().await + } + pub async fn is_available(&self) -> Result { self.inner.is_available().await } @@ -36,3 +53,34 @@ impl Client { self.inner.get_supported_tokens().await } } + +pub struct ClientBuilder { + endpoint: String, + api_key: Option, + timeout: Duration, +} + +impl ClientBuilder { + pub fn api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn build(self) -> Result { + let mut headers = HeaderMap::new(); + if let Some(key) = self.api_key { + headers.insert( + "x-paymaster-api-key", + HeaderValue::from_str(&key).map_err(|e| Error::Custom(format!("invalid API key header value: {e}")))?, + ); + } + + let inner = HttpClientBuilder::default().request_timeout(self.timeout).set_headers(headers).build(&self.endpoint)?; + Ok(Client { inner }) + } +} diff --git a/crates/paymaster-rpc/src/endpoint/build.rs b/crates/paymaster-rpc/src/endpoint/build.rs index 52e5434..270f7c1 100644 --- a/crates/paymaster-rpc/src/endpoint/build.rs +++ b/crates/paymaster-rpc/src/endpoint/build.rs @@ -12,7 +12,7 @@ use crate::endpoint::validation::{check_is_allowed_fee_mode, check_is_supported_ use crate::endpoint::RequestContext; use crate::Error; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct BuildTransactionRequest { pub transaction: TransactionParameters, pub parameters: ExecutionParameters, @@ -64,7 +64,7 @@ impl From for paymaster_execution::InvokeParameters { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BuildTransactionResponse { Deploy(DeployTransaction), @@ -72,7 +72,7 @@ pub enum BuildTransactionResponse { DeployAndInvoke(DeployAndInvokeTransaction), } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct DeployTransaction { pub deployment: DeploymentParameters, pub parameters: ExecutionParameters, @@ -85,7 +85,7 @@ impl From for BuildTransactionResponse { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct InvokeTransaction { pub typed_data: TypedData, pub parameters: ExecutionParameters, @@ -98,7 +98,7 @@ impl From for BuildTransactionResponse { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct DeployAndInvokeTransaction { pub deployment: DeploymentParameters, pub typed_data: TypedData, diff --git a/crates/paymaster-rpc/src/endpoint/execute.rs b/crates/paymaster-rpc/src/endpoint/execute.rs index d3072ee..61fd860 100644 --- a/crates/paymaster-rpc/src/endpoint/execute.rs +++ b/crates/paymaster-rpc/src/endpoint/execute.rs @@ -10,13 +10,13 @@ use crate::endpoint::validation::check_service_is_available; use crate::endpoint::RequestContext; use crate::Error; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteRequest { pub transaction: ExecutableTransactionParameters, pub parameters: ExecutionParameters, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ExecutableTransactionParameters { Deploy { @@ -47,7 +47,7 @@ impl TryFrom for paymaster_execution::Executabl } #[serde_as] -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecutableInvokeParameters { #[serde_as(as = "UfeHex")] pub user_address: Felt, diff --git a/crates/paymaster-rpc/src/endpoint/execute_raw.rs b/crates/paymaster-rpc/src/endpoint/execute_raw.rs index c9db011..8c25873 100644 --- a/crates/paymaster-rpc/src/endpoint/execute_raw.rs +++ b/crates/paymaster-rpc/src/endpoint/execute_raw.rs @@ -9,13 +9,13 @@ use crate::endpoint::validation::check_service_is_available; use crate::endpoint::RequestContext; use crate::Error; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteDirectRequest { pub transaction: ExecuteDirectTransactionParameters, pub parameters: ExecutionParameters, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ExecuteDirectTransactionParameters { Invoke { invoke: DirectInvokeParameters }, @@ -30,7 +30,7 @@ impl From for paymaster_execution::Executabl } #[serde_as] -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct DirectInvokeParameters { #[serde_as(as = "UfeHex")] pub user_address: Felt, diff --git a/crates/paymaster-rpc/src/lib.rs b/crates/paymaster-rpc/src/lib.rs index 2151e3f..1532927 100644 --- a/crates/paymaster-rpc/src/lib.rs +++ b/crates/paymaster-rpc/src/lib.rs @@ -13,12 +13,12 @@ mod context; pub use context::{Configuration, RPCConfiguration}; mod endpoint; -use crate::endpoint::execute_raw::{ExecuteDirectRequest, ExecuteDirectResponse}; +pub use crate::endpoint::execute_raw::{DirectInvokeParameters, ExecuteDirectRequest, ExecuteDirectResponse, ExecuteDirectTransactionParameters}; pub use endpoint::build::{ BuildTransactionRequest, BuildTransactionResponse, DeployAndInvokeTransaction, DeployTransaction, FeeEstimate, InvokeParameters, InvokeTransaction, TransactionParameters, }; -pub use endpoint::common::{DeploymentParameters, ExecutionParameters, FeeMode, TimeBounds}; +pub use endpoint::common::{DeploymentParameters, ExecutionParameters, FeeMode, TimeBounds, TipPriority}; pub use endpoint::execute::{ExecutableInvokeParameters, ExecutableTransactionParameters, ExecuteRequest, ExecuteResponse}; pub use endpoint::token::TokenPrice;